Skip to main content

triblespace_core/repo/
capability.rs

1//! Capability-based authorization for triblespace networks.
2//!
3//! Implements a chain-of-trust capability system where:
4//!
5//! - A team has a single immutable root keypair (the "team root"), generated
6//!   once at team creation and used to sign exactly one capability — the
7//!   founder's. The team root never operates online; it's the constitutional
8//!   document for the team's identity.
9//! - All other capabilities chain off the founder's via delegation. Any holder
10//!   of a capability can sign a sub-capability for someone else, as long as
11//!   the sub-cap's scope is a subset of their own. Verification walks the
12//!   chain back to the team root.
13//! - Each capability link is two blobs: a `cap` blob (the claim) and a `sig`
14//!   blob (the issuer's signature over the cap blob's bytes). For chains of
15//!   length > 1, each non-root cap embeds its parent's signature inline as a
16//!   sub-entity, which halves the cold-cache verification fetch count by
17//!   eliminating a separate round-trip per intermediate signature.
18//! - Signatures attest to the cap blob's canonical bytes (SimpleArchive's
19//!   serialization is already canonical), not to a hash of those bytes —
20//!   matching the existing commit-signing convention. This keeps signatures
21//!   hash-agnostic across any future Blake3 migration.
22//!
23//! Scope is encoded as tribles inside the cap blob, anchored at
24//! `cap_scope_root`. Permissions are tagged via `metadata::tag` linking
25//! to constants like `PERM_READ`, `PERM_WRITE`, `PERM_ADMIN`. Optional
26//! per-resource restrictions like `scope_branch` narrow a permission to a
27//! specific branch.
28//!
29//! (Names like `cap_scope_root`, `metadata::tag`, `scope_branch`, and
30//! `PERM_*` are spelled in plain code formatting rather than as
31//! intra-doc links because the macro-generated attribute items and
32//! the `id_hex!`-defined constants don't reliably resolve as
33//! rustdoc link targets from a `//!` block.)
34//!
35//! See `docs/sync_relay_auth_design.md` (or the `shared.pile` wiki fragment
36//! titled "Sync Relay Auth Design") for the full design rationale.
37
38use crate::id::Id;
39use crate::id_hex;
40
41/// Tag indicating a scope grants read access on the resources in scope.
42pub const PERM_READ: Id = id_hex!("A75EED8224A553DD8002576E2E8A6823");
43/// Tag indicating a scope grants write access on the resources in scope.
44pub const PERM_WRITE: Id = id_hex!("C56AAF4191DD4FBB9F197B79435B881D");
45/// Tag indicating a scope grants admin (delegation) authority.
46pub const PERM_ADMIN: Id = id_hex!("EC68A0CBF9EF421F59A0A69ED80FD79F");
47
48use crate::inline::encodings::ed25519 as ed;
49use crate::blob::encodings::simplearchive::SimpleArchive;
50use crate::inline::encodings::genid::GenId;
51use crate::inline::encodings::hash::Handle;
52
53triblespace_core_macros::attributes! {
54    // ── Cap blob ──────────────────────────────────────────────────────
55    /// The pubkey this capability authorizes. Must match the verified
56    /// peer identity at connection time (i.e. the connecting peer's
57    /// iroh `EndpointId`).
58    "1A8A6A9D8CA1DA67FACAB373DE21233B" as pub cap_subject: ed::ED25519PublicKey;
59    /// The pubkey of the entity that signed this capability. Must match
60    /// the `signed_by` field of the accompanying signature blob.
61    /// Recorded in the cap so verification can detect a sig-blob/cap
62    /// issuer mismatch without an extra fetch.
63    "2E9CD97ED0698FAF18EAEB74B5893685" as pub cap_issuer: ed::ED25519PublicKey;
64    /// Entity id within the cap blob anchoring the scope tribles. The
65    /// scope sub-graph hanging off this id encodes which permissions
66    /// (and optionally which resources) the capability grants.
67    "1A7DD2026BEFBE55A354CE10839CFDD6" as pub cap_scope_root: GenId;
68    // Note: chain references (cap_parent, embedded parent sig) live in
69    // the sig blob, not the cap blob. A cap blob is a pure declaration
70    // of (subject, issuer, scope, expiry) — independent of which
71    // authority chain endorses it. See sig_parent_cap below.
72
73    // ── Scope ─────────────────────────────────────────────────────────
74    /// Optional restriction of a permission to a specific branch.
75    /// Repeated when a permission applies to multiple branches; absent
76    /// when the permission is unrestricted (applies to every branch
77    /// the holder is otherwise authorised on).
78    "46246789D627C1B0F81B21418E179DFD" as pub scope_branch: GenId;
79
80    // ── Sig blob ──────────────────────────────────────────────────────
81    /// Handle of the cap blob this signature attests to. The signature
82    /// itself is over the cap blob's canonical bytes (i.e.
83    /// `cap_blob.bytes`), not over the handle. SimpleArchive is already
84    /// canonical, so the bytes the signer signs are exactly what the
85    /// hasher hashes.
86    "230E175A083E29155C860B38BD44F2F3" as pub sig_signs: Handle<SimpleArchive>;
87    /// Handle of the parent cap blob in the chain. Absent when this
88    /// entry's issuer is the team root (chain terminator). Present on
89    /// every other sig-blob outer entity and recursive sub-entity.
90    "ACF20EE95C6A4AE16B445590E88AB9BE" as pub sig_parent_cap: Handle<SimpleArchive>;
91    /// Entity id within the same sig blob holding the parent's proof
92    /// inline. The sub-entity carries `signed_by`, `signature_r`,
93    /// `signature_s`, and (if the chain continues) its own
94    /// `sig_parent_cap` + `sig_embedded_parent_proof`. Absent when
95    /// the issuer is the team root.
96    "8ED30E412129FB0A791BD335EACF2E82" as pub sig_embedded_parent_proof: GenId;
97    // Note: sig_signer + sig_value (r/s) reuse the existing
98    // `repo::signed_by`, `repo::signature_r`, `repo::signature_s`
99    // attributes — same convention as commit signatures, plus
100    // structural reuse (a sig blob has the same shape inside as the
101    // signature portion of a commit's metadata blob).
102}
103
104/// Tag identifying a blob as a capability claim.
105#[allow(dead_code)]
106pub const KIND_CAPABILITY: Id = id_hex!("B8D76786ACD20F344A4E5CBFC0F75772");
107/// Tag identifying a blob as a capability signature.
108#[allow(dead_code)]
109pub const KIND_CAPABILITY_SIG: Id = id_hex!("E6BB52CE6E02D51C3676ECE1EEA9094F");
110
111// ── Builder ──────────────────────────────────────────────────────────
112
113use ed25519::Signature;
114use ed25519_dalek::SigningKey;
115use ed25519_dalek::VerifyingKey;
116use ed25519::signature::Signer;
117
118use crate::blob::Blob;
119use crate::blob::IntoBlob;
120use crate::blob::TryFromBlob;
121use crate::blob::encodings::simplearchive::UnarchiveError;
122use crate::id::ExclusiveId;
123use crate::macros::entity;
124use crate::macros::pattern;
125use crate::query::find;
126use crate::trible::TribleSet;
127use crate::inline::Inline;
128use crate::inline::IntoInline;
129use crate::inline::encodings::time::NsTAIInterval;
130
131/// Errors returned by [`build_capability`].
132#[derive(Debug)]
133pub enum BuildError {
134    /// The provided parent signature blob could not be parsed as a valid
135    /// SimpleArchive.
136    ParseParentSig(UnarchiveError),
137    /// The provided parent signature blob did not contain exactly one
138    /// signature entity (i.e. exactly one entity carrying [`sig_signs`]).
139    ParentSigShape,
140}
141
142/// Build a capability link.
143///
144/// Returns the pair `(cap_blob, sig_blob)`:
145/// - `cap_blob` carries the claim (subject pubkey, scope, expiry, parent
146///   pointer, embedded parent signature). Its content-addressed handle is
147///   what the sig blob attests to.
148/// - `sig_blob` carries the issuer's signature over `cap_blob.bytes` plus
149///   the issuer's pubkey, alongside a `sig_signs` handle pointing at the
150///   cap blob.
151///
152/// `parent = None` constructs a root-issued capability: the issuer is
153/// expected to be the team root keypair, and the resulting cap has no
154/// `cap_parent` and no embedded parent signature. Verification terminates
155/// at this link when the issuer pubkey matches the team root.
156///
157/// `parent = Some((parent_cap, parent_sig))` constructs a delegated
158/// capability: the parent's signature is embedded inline in the new cap
159/// blob (via [`cap_embedded_parent_sig`] pointing at a sub-entity carrying
160/// `signed_by` + `signature_r` + `signature_s` reusing the existing
161/// commit-signature attribute conventions) so verifiers can walk one level
162/// up the chain without a separate fetch for the parent's signature.
163///
164/// `scope_facts` should be a TribleSet anchored at `scope_root` describing
165/// the capability's scope (permission tags via [`crate::metadata::tag`],
166/// optional resource restrictions via [`scope_branch`], etc.). The caller
167/// is responsible for producing a scope that's a subset of any parent
168/// scope; this builder does not enforce subsumption.
169///
170/// # Example
171///
172/// Mint a length-1 capability — team root signs the founder's cap
173/// directly. The returned `(cap_blob, sig_blob)` pair is what callers
174/// persist into the pile; the founder presents the sig blob's handle
175/// at connection time.
176///
177/// ```rust
178/// use ed25519_dalek::SigningKey;
179/// use triblespace_core::id::{ufoid, ExclusiveId};
180/// use triblespace_core::macros::entity;
181/// use triblespace_core::trible::TribleSet;
182/// use triblespace_core::inline::TryToInline;
183/// use triblespace_core::repo::capability::{build_capability, PERM_READ};
184/// use rand::rngs::OsRng;
185///
186/// let team_root = SigningKey::generate(&mut OsRng);
187/// let founder = SigningKey::generate(&mut OsRng);
188///
189/// // PERM_READ scope, no branch restriction (read-everything cap).
190/// let scope_root = ufoid();
191/// let scope_facts: TribleSet = entity! {
192///     ExclusiveId::force_ref(&scope_root) @
193///     triblespace_core::metadata::tag: PERM_READ,
194/// }
195/// .into();
196///
197/// let now = hifitime::Epoch::now().unwrap();
198/// let expiry = (now, now + hifitime::Duration::from_seconds(24.0 * 3600.0))
199///     .try_to_inline()
200///     .unwrap();
201///
202/// let (cap_blob, sig_blob) = build_capability(
203///     &team_root,
204///     founder.verifying_key(),
205///     None, // no parent — direct child of the team root
206///     *scope_root,
207///     scope_facts,
208///     expiry,
209/// )
210/// .expect("cap builds");
211///
212/// // Both blobs go into the pile. The founder's "credential" is the
213/// // sig blob's content-addressed handle.
214/// assert!(!cap_blob.bytes.is_empty());
215/// assert!(!sig_blob.bytes.is_empty());
216/// ```
217pub fn build_capability(
218    issuer: &SigningKey,
219    subject: VerifyingKey,
220    parent: Option<(Blob<SimpleArchive>, Blob<SimpleArchive>)>,
221    scope_root: crate::id::Id,
222    scope_facts: TribleSet,
223    expiry: Inline<NsTAIInterval>,
224) -> Result<(Blob<SimpleArchive>, Blob<SimpleArchive>), BuildError> {
225    let issuer_pubkey: VerifyingKey = issuer.verifying_key();
226
227    // Build the cap blob — pure declaration of (subject, issuer, scope,
228    // expiry) and any caller-supplied scope facts. NO chain references;
229    // those live in the sig blob.
230    let cap_fragment = entity! {
231        cap_subject: issuer_subject_value(subject),
232        cap_issuer: issuer_subject_value(issuer_pubkey),
233        cap_scope_root: scope_root,
234        crate::metadata::expires_at: expiry,
235    };
236
237    let mut cap_set = TribleSet::from(cap_fragment);
238    cap_set += scope_facts;
239
240    let cap_blob: Blob<SimpleArchive> = cap_set.to_blob();
241    let cap_handle: Inline<Handle<SimpleArchive>> = (&cap_blob).get_handle();
242
243    // Sign the cap blob's canonical bytes.
244    let signature: Signature = issuer.sign(&cap_blob.bytes);
245
246    // Build the sig blob. Outer entity carries the leaf sig over the
247    // cap, plus (if there's a parent) `sig_parent_cap` + the parent's
248    // entire proof. The parent's tribles are folded in under their
249    // existing entity ids; the parent's outer entity becomes our
250    // embedded proof sub-entity. We strip the parent's `sig_signs`
251    // attribute on its outer entity — that attribute marks the leaf
252    // entity of a sig blob, and once embedded as a sub-entity it's no
253    // longer a leaf.
254    let mut sig_set: TribleSet = TribleSet::from(entity! {
255        sig_signs: cap_handle,
256        crate::repo::signed_by: issuer_pubkey,
257        crate::repo::signature_r: signature,
258        crate::repo::signature_s: signature,
259    });
260    let leaf_outer_id: crate::id::Id = find!(
261        (s: crate::id::Id, _h: Inline<Handle<SimpleArchive>>),
262        pattern!(&sig_set, [{ ?s @ sig_signs: ?_h }])
263    )
264    .map(|(s, _)| s)
265    .next()
266    .expect("just inserted our own outer sig entity");
267
268    if let Some((parent_cap_blob, parent_sig_blob)) = parent {
269        let parent_cap_handle: Inline<Handle<SimpleArchive>> =
270            parent_cap_blob.get_handle();
271
272        let parent_sig_set: TribleSet =
273            TryFromBlob::<SimpleArchive>::try_from_blob(parent_sig_blob)
274                .map_err(BuildError::ParseParentSig)?;
275
276        // Locate the parent's outer leaf entity (the one with sig_signs).
277        let mut parent_outer_iter = find!(
278            (sig: crate::id::Id, _signed: Inline<Handle<SimpleArchive>>),
279            pattern!(&parent_sig_set, [{ ?sig @ sig_signs: ?_signed }])
280        )
281        .map(|(sig, _)| sig);
282        let parent_outer_id = match (
283            parent_outer_iter.next(),
284            parent_outer_iter.next(),
285        ) {
286            (Some(id), None) => id,
287            _ => return Err(BuildError::ParentSigShape),
288        };
289
290        // Pull every trible from the parent sig blob into our sig blob,
291        // dropping the parent's outer `sig_signs` trible (since that
292        // entity is no longer a leaf in the merged sig blob).
293        let sig_signs_attr_id = sig_signs.id();
294        for trible in parent_sig_set.iter() {
295            if *trible.e() == parent_outer_id && *trible.a() == sig_signs_attr_id {
296                continue;
297            }
298            sig_set.insert(trible);
299        }
300
301        // Attach the parent linkage to our own outer entity.
302        sig_set += TribleSet::from(entity! {
303            ExclusiveId::force_ref(&leaf_outer_id) @
304            sig_parent_cap: parent_cap_handle,
305            sig_embedded_parent_proof: parent_outer_id,
306        });
307    }
308
309    let sig_blob: Blob<SimpleArchive> = sig_set.to_blob();
310
311    Ok((cap_blob, sig_blob))
312}
313
314/// Convenience: convert a `VerifyingKey` to a `Inline<ED25519PublicKey>`.
315/// Inlined to avoid an explicit `IntoInline` import at the call sites in
316/// the builder above.
317fn issuer_subject_value(key: VerifyingKey) -> Inline<ed::ED25519PublicKey> {
318    key.to_inline()
319}
320
321// ── Scope subsumption ────────────────────────────────────────────────
322
323/// Collect the permission tag ids and branch restrictions from a scope
324/// sub-graph anchored at `scope_root`.
325fn collect_scope_facts(
326    set: &TribleSet,
327    scope_root: crate::id::Id,
328) -> (HashSet<crate::id::Id>, HashSet<crate::id::Id>) {
329    let perms: HashSet<crate::id::Id> = find!(
330        (perm: crate::id::Id),
331        pattern!(set, [{ scope_root @ crate::metadata::tag: ?perm }])
332    )
333    .map(|(p,)| p)
334    .collect();
335
336    let branches: HashSet<crate::id::Id> = find!(
337        (branch: crate::id::Id),
338        pattern!(set, [{ scope_root @ scope_branch: ?branch }])
339    )
340    .map(|(b,)| b)
341    .collect();
342
343    (perms, branches)
344}
345
346/// Check whether a parent scope authorises a child scope.
347///
348/// Rules:
349/// - If parent grants `PERM_ADMIN`, parent subsumes every child scope.
350/// - Otherwise: every permission tag in the child must be in the
351///   parent's set (with `PERM_WRITE` implying `PERM_READ` for upgrade
352///   compatibility, but an explicit `PERM_READ`-only parent does *not*
353///   imply `PERM_WRITE` for the child).
354/// - Branch restriction: an empty `scope_branch` set means "all
355///   branches"; a non-empty set restricts the scope to those branches.
356///   The child's restriction set must be a subset of the parent's
357///   (where empty parent = all branches allowed).
358///
359/// Unknown permission tags in the child cause subsumption to fail
360/// closed.
361pub fn scope_subsumes(
362    parent_set: &TribleSet,
363    parent_scope_root: crate::id::Id,
364    child_set: &TribleSet,
365    child_scope_root: crate::id::Id,
366) -> bool {
367    let (parent_perms, parent_branches) =
368        collect_scope_facts(parent_set, parent_scope_root);
369    let (child_perms, child_branches) =
370        collect_scope_facts(child_set, child_scope_root);
371
372    if parent_perms.contains(&PERM_ADMIN) {
373        return true;
374    }
375
376    for perm in &child_perms {
377        if *perm == PERM_READ {
378            if !parent_perms.contains(&PERM_READ)
379                && !parent_perms.contains(&PERM_WRITE)
380            {
381                return false;
382            }
383        } else if *perm == PERM_WRITE {
384            if !parent_perms.contains(&PERM_WRITE) {
385                return false;
386            }
387        } else if *perm == PERM_ADMIN {
388            // Parent isn't admin (already checked), so the child can't
389            // claim admin either.
390            return false;
391        } else {
392            // Unknown permission — fail closed.
393            return false;
394        }
395    }
396
397    // Branch restriction subsumption.
398    if !parent_branches.is_empty() {
399        if child_branches.is_empty() {
400            return false;
401        }
402        for b in &child_branches {
403            if !parent_branches.contains(b) {
404                return false;
405            }
406        }
407    }
408
409    true
410}
411
412
413// ── Verifier ──────────────────────────────────────────────────────────
414
415use ed25519_dalek::Verifier;
416use std::collections::HashSet;
417use crate::inline::TryFromInline;
418use hifitime::Epoch;
419
420/// Errors returned by [`verify_chain`].
421#[derive(Debug)]
422pub enum VerifyError {
423    /// The leaf or some intermediate sig/cap blob could not be parsed
424    /// as a valid SimpleArchive.
425    ParseBlob(UnarchiveError),
426    /// Fetching a referenced blob (cap or sig) from the caller-supplied
427    /// fetch function failed.
428    Fetch,
429    /// A signature failed to verify against the expected pubkey + cap
430    /// blob bytes.
431    BadSignature,
432    /// The leaf cap's subject did not match the expected (connecting)
433    /// peer pubkey.
434    SubjectMismatch,
435    /// A cap's `cap_issuer` did not match the accompanying sig's
436    /// `signed_by`.
437    IssuerMismatch,
438    /// A cap or one of its parent caps has expired.
439    Expired,
440    /// A child cap's scope was not a subset of its parent's scope.
441    /// (Enforcement deferred to the scope-subsumption module — for now
442    /// this variant is reserved for future use.)
443    ScopeNotSubset,
444    /// A cap blob is missing required attributes (e.g. cap_subject,
445    /// cap_issuer, cap_scope_root, expires_at) or has multiple
446    /// conflicting values.
447    MalformedCap,
448    /// A sig blob is missing required attributes or has multiple
449    /// conflicting values.
450    MalformedSig,
451    /// The leaf sig blob refers to a cap blob whose handle the verifier
452    /// could not retrieve.
453    LeafCapMissing,
454    /// A non-root sig-blob entity (one whose signer differs from the
455    /// team root) is missing either `sig_parent_cap` or
456    /// `sig_embedded_parent_proof`.
457    NonRootMissingParent,
458    /// The chain exceeded a sanity-bound depth without terminating at
459    /// the team root.
460    ChainTooDeep,
461}
462
463impl From<UnarchiveError> for VerifyError {
464    fn from(e: UnarchiveError) -> Self {
465        VerifyError::ParseBlob(e)
466    }
467}
468
469/// A successfully verified leaf capability.
470///
471/// Returned by [`verify_chain`] on a successful walk back to the
472/// configured `team_root`. Carries the leaf cap's full `TribleSet` so
473/// callers can ask:
474///
475/// - [`permissions`](Self::permissions) — which `PERM_*` tags are
476///   hung on the scope root
477/// - [`granted_branches`](Self::granted_branches) — `Some(set)` if the
478///   cap restricts itself to specific branches, or `None` if it's
479///   unrestricted within its permission set
480/// - [`grants_read`](Self::grants_read) — convenience for "any read-
481///   equivalent permission" (write/admin imply read)
482/// - [`grants_read_on`](Self::grants_read_on) — combines the two:
483///   read-permission AND (unrestricted OR branch-in-scope)
484///
485/// # Example
486///
487/// Build a `VerifiedCapability` directly (skipping `verify_chain` —
488/// the helpers operate on `cap_set` shape, not on the chain proof,
489/// so a hand-crafted instance suffices for testing scope predicates):
490///
491/// ```rust
492/// use std::collections::HashSet;
493/// use triblespace_core::id::{ufoid, ExclusiveId, Id};
494/// use triblespace_core::macros::entity;
495/// use triblespace_core::trible::TribleSet;
496/// use triblespace_core::repo::capability::{
497///     scope_branch, VerifiedCapability, PERM_READ,
498/// };
499/// use ed25519_dalek::SigningKey;
500/// use rand::rngs::OsRng;
501///
502/// let scope_root = ufoid();
503/// let allowed_branch = ufoid();
504/// // PERM_READ scope, restricted to one branch.
505/// let mut cap_set = TribleSet::new();
506/// cap_set += TribleSet::from(entity! {
507///     ExclusiveId::force_ref(&scope_root) @
508///     triblespace_core::metadata::tag: PERM_READ,
509/// });
510/// cap_set += TribleSet::from(entity! {
511///     ExclusiveId::force_ref(&scope_root) @
512///     scope_branch: *allowed_branch,
513/// });
514///
515/// let verified = VerifiedCapability {
516///     subject: SigningKey::generate(&mut OsRng).verifying_key(),
517///     scope_root: *scope_root,
518///     cap_set,
519/// };
520///
521/// // permissions() exposes the raw tag set.
522/// let perms = verified.permissions();
523/// assert_eq!(perms.len(), 1);
524/// assert!(perms.contains(&PERM_READ));
525///
526/// // granted_branches() returns Some(set) for restricted caps.
527/// let branches = verified.granted_branches().expect("restricted");
528/// assert!(branches.contains(&*allowed_branch));
529///
530/// // grants_read() short-circuits to "any read-equivalent perm".
531/// assert!(verified.grants_read());
532///
533/// // grants_read_on() composes both checks.
534/// assert!(verified.grants_read_on(&*allowed_branch));
535/// let other_branch: Id = *ufoid();
536/// assert!(!verified.grants_read_on(&other_branch));
537/// ```
538#[derive(Debug, Clone)]
539pub struct VerifiedCapability {
540    /// The subject pubkey the leaf cap authorizes.
541    pub subject: VerifyingKey,
542    /// The scope root entity id within the leaf cap blob.
543    pub scope_root: crate::id::Id,
544    /// The leaf cap's full TribleSet (caller can extract its scope by
545    /// querying tribles anchored at `scope_root`).
546    pub cap_set: TribleSet,
547}
548
549impl VerifiedCapability {
550    /// Returns the set of permissions tagged on this cap's scope root
551    /// (a subset of `{`[`PERM_READ`]`,`[`PERM_WRITE`]`,`[`PERM_ADMIN`]`}`).
552    pub fn permissions(&self) -> HashSet<crate::id::Id> {
553        let (perms, _) = collect_scope_facts(&self.cap_set, self.scope_root);
554        perms
555    }
556
557    /// Returns `Some(set)` if the cap restricts itself to a specific
558    /// non-empty set of branches, or `None` if the cap is unrestricted
559    /// (i.e. applies to every branch within the granted permission set).
560    pub fn granted_branches(&self) -> Option<HashSet<crate::id::Id>> {
561        let (_, branches) = collect_scope_facts(&self.cap_set, self.scope_root);
562        if branches.is_empty() { None } else { Some(branches) }
563    }
564
565    /// Returns `true` if the cap grants any read-equivalent permission
566    /// (read, write, or admin — write/admin imply read, matching the
567    /// subsumption rules in [`scope_subsumes`]).
568    pub fn grants_read(&self) -> bool {
569        let perms = self.permissions();
570        perms.contains(&PERM_READ)
571            || perms.contains(&PERM_WRITE)
572            || perms.contains(&PERM_ADMIN)
573    }
574
575    /// Returns `true` if the cap grants read-equivalent permission on
576    /// the given branch — i.e. the cap [`grants_read`](Self::grants_read)
577    /// AND either is unrestricted or its restriction set contains
578    /// `branch`.
579    pub fn grants_read_on(&self, branch: &crate::id::Id) -> bool {
580        if !self.grants_read() {
581            return false;
582        }
583        match self.granted_branches() {
584            None => true,
585            Some(set) => set.contains(branch),
586        }
587    }
588}
589
590/// Maximum chain depth the verifier will walk before giving up. Real
591/// chains are 1-3 deep typically; this is a sanity bound to refuse
592/// adversarial deep chains.
593pub const MAX_CHAIN_DEPTH: usize = 32;
594
595/// Verify a single signature blob's claim against a cap blob's bytes.
596///
597// The old `verify_sig_blob` helper was replaced by the
598// `extract_and_verify_sig_at` helper used by `verify_chain` — that one
599// works against an arbitrary entity inside a sig blob (outer leaf or
600// embedded sub-entity), which is what the new chain walk needs.
601
602/// Extract a cap blob's declared attributes: subject, issuer, scope
603/// root, expiry. Cap blobs are pure declarations now — chain
604/// references live in the sig blob, so this is just a four-field
605/// projection.
606fn extract_cap_fields(
607    cap_set: &TribleSet,
608) -> Result<CapFields, VerifyError> {
609    let mut iter = find!(
610        (cap: crate::id::Id,
611         subject: VerifyingKey,
612         issuer: VerifyingKey,
613         scope_root: crate::id::Id,
614         expiry: Inline<NsTAIInterval>),
615        pattern!(cap_set, [{
616            ?cap @
617            cap_subject: ?subject,
618            cap_issuer: ?issuer,
619            cap_scope_root: ?scope_root,
620            crate::metadata::expires_at: ?expiry,
621        }])
622    );
623    let (cap_id, subject, issuer, scope_root, expiry) = match (iter.next(), iter.next()) {
624        (Some(row), None) => row,
625        _ => return Err(VerifyError::MalformedCap),
626    };
627
628    Ok(CapFields {
629        cap_id,
630        subject,
631        issuer,
632        scope_root,
633        expiry,
634    })
635}
636
637#[derive(Debug, Clone)]
638struct CapFields {
639    #[allow(dead_code)]
640    cap_id: crate::id::Id,
641    subject: VerifyingKey,
642    issuer: VerifyingKey,
643    scope_root: crate::id::Id,
644    expiry: Inline<NsTAIInterval>,
645}
646
647/// Verify that a leaf signature blob plus its referenced cap blob form
648/// a valid capability chain rooted at `team_root`, authorising the
649/// `expected_subject` to act with the leaf cap's scope.
650///
651/// `fetch_blob` is called to retrieve any cap blob referenced by a
652/// `cap_parent` handle during chain walk. The leaf sig and leaf cap
653/// blobs are also looked up via `fetch_blob`, given the
654/// `leaf_sig_handle`.
655///
656/// Eviction in the descriptive-caps model is per-issuer non-renewal
657/// (the issuer's local retraction-policy pin), not a broadcast
658/// revocation blob. Verification therefore checks signatures and
659/// expiry only; a "revoked" peer's chain dies at its next natural
660/// expiry once the issuer stops renewing.
661///
662/// Returns the verified leaf capability on success.
663///
664/// # Example
665///
666/// End-to-end auth flow: team root mints a length-1 cap for a
667/// member, then verifies it.
668///
669/// ```rust
670/// use ed25519_dalek::SigningKey;
671/// use std::collections::HashMap;
672/// use triblespace_core::blob::Blob;
673/// use triblespace_core::blob::encodings::simplearchive::SimpleArchive;
674/// use triblespace_core::id::{ufoid, ExclusiveId};
675/// use triblespace_core::macros::entity;
676/// use triblespace_core::trible::TribleSet;
677/// use triblespace_core::inline::TryToInline;
678/// use triblespace_core::inline::Inline;
679/// use triblespace_core::inline::encodings::hash::Handle;
680/// use triblespace_core::repo::capability::{
681///     build_capability, verify_chain, PERM_READ,
682/// };
683/// use rand::rngs::OsRng;
684///
685/// // Team root mints itself; in a real deployment this happens
686/// // once at team creation and the secret is archived offline.
687/// let team_root = SigningKey::generate(&mut OsRng);
688/// let member = SigningKey::generate(&mut OsRng);
689///
690/// // Scope: a single anchor entity tagged with PERM_READ.
691/// let scope_root = ufoid();
692/// let scope_facts: TribleSet = entity! {
693///     ExclusiveId::force_ref(&scope_root) @
694///     triblespace_core::metadata::tag: PERM_READ,
695/// }
696/// .into();
697///
698/// // 24-hour expiry interval, anchored at "now".
699/// let now = hifitime::Epoch::now().unwrap();
700/// let expiry = (now, now + hifitime::Duration::from_seconds(24.0 * 3600.0))
701///     .try_to_inline()
702///     .unwrap();
703///
704/// // Length-1 chain: team root signs the member's cap directly.
705/// let (cap_blob, sig_blob) = build_capability(
706///     &team_root,
707///     member.verifying_key(),
708///     None, // No parent — directly off the root.
709///     *scope_root,
710///     scope_facts,
711///     expiry,
712/// )
713/// .unwrap();
714///
715/// // The peer presents the *sig* blob's handle on connection.
716/// let leaf_sig_handle: Inline<Handle<SimpleArchive>> =
717///     (&sig_blob).get_handle();
718///
719/// // The verifier needs both blobs available via the fetch closure.
720/// let cap_handle: Inline<Handle<SimpleArchive>> =
721///     (&cap_blob).get_handle();
722/// let mut blobs: HashMap<[u8; 32], Blob<SimpleArchive>> = HashMap::new();
723/// blobs.insert(cap_handle.raw, cap_blob);
724/// blobs.insert(leaf_sig_handle.raw, sig_blob);
725///
726/// let verified = verify_chain(
727///     team_root.verifying_key(),
728///     leaf_sig_handle,
729///     member.verifying_key(),
730///     |h| blobs.get(&h.raw).cloned(),
731/// )
732/// .expect("chain valid");
733///
734/// assert_eq!(verified.subject, member.verifying_key());
735/// assert!(verified.grants_read());
736/// ```
737pub fn verify_chain<F>(
738    team_root: VerifyingKey,
739    leaf_sig_handle: Inline<Handle<SimpleArchive>>,
740    expected_subject: VerifyingKey,
741    mut fetch_blob: F,
742) -> Result<VerifiedCapability, VerifyError>
743where
744    F: FnMut(Inline<Handle<SimpleArchive>>) -> Option<Blob<SimpleArchive>>,
745{
746    // Through the clock seam: simulated runs check expiry against
747    // virtual time, so cap-lifetime scenarios (renewal windows,
748    // expiry-during-partition) are deterministically scriptable.
749    let now: Epoch = crate::clock::epoch_now();
750
751    // Helper: a cap is valid until the *upper bound* of its expiry
752    // interval. We compare that upper bound against `now`.
753    let is_expired = |expiry: &Inline<NsTAIInterval>| -> bool {
754        match <(Epoch, Epoch)>::try_from_inline(expiry) {
755            Ok((_lower, upper)) => upper < now,
756            // A malformed/inverted interval is treated as expired so
757            // adversarial caps can't fall through.
758            Err(_) => true,
759        }
760    };
761
762    // ── Leaf step ────────────────────────────────────────────────────
763    //
764    // The leaf sig blob carries: the leaf signature (over the leaf
765    // cap), the leaf cap handle (via sig_signs), and — if the chain
766    // extends beyond a single hop — the recursive chain proof
767    // (sig_parent_cap + sig_embedded_parent_proof, each linking to the
768    // next level's signer/signature/parent).
769    let leaf_sig_blob = fetch_blob(leaf_sig_handle).ok_or(VerifyError::Fetch)?;
770    let sig_set: TribleSet = TryFromBlob::try_from_blob(leaf_sig_blob)?;
771
772    // Find the leaf outer entity — the one carrying sig_signs.
773    let mut leaf_outer_iter = find!(
774        (sig: crate::id::Id, h: Inline<Handle<SimpleArchive>>),
775        pattern!(&sig_set, [{ ?sig @ sig_signs: ?h }])
776    );
777    let (mut current_outer_id, leaf_cap_handle) = match (
778        leaf_outer_iter.next(),
779        leaf_outer_iter.next(),
780    ) {
781        (Some(row), None) => row,
782        _ => return Err(VerifyError::MalformedSig),
783    };
784
785    // Fetch + decode the leaf cap.
786    let leaf_cap_blob = fetch_blob(leaf_cap_handle).ok_or(VerifyError::LeafCapMissing)?;
787    let leaf_cap_set: TribleSet = TryFromBlob::try_from_blob(leaf_cap_blob.clone())?;
788    let leaf_fields = extract_cap_fields(&leaf_cap_set)?;
789
790    // Subject must match the connecting peer.
791    if leaf_fields.subject != expected_subject {
792        return Err(VerifyError::SubjectMismatch);
793    }
794    if is_expired(&leaf_fields.expiry) {
795        return Err(VerifyError::Expired);
796    }
797
798    // Verify the outer signature attests to the leaf cap's bytes,
799    // signed by the leaf's claimed issuer.
800    let outer_signer = extract_and_verify_sig_at(
801        &sig_set,
802        current_outer_id,
803        &leaf_cap_blob,
804    )?;
805    if outer_signer != leaf_fields.issuer {
806        return Err(VerifyError::IssuerMismatch);
807    }
808
809    // ── Walk back to root ────────────────────────────────────────────
810    //
811    // Loop invariant:
812    //   - `current_outer_id`: the entity in `sig_set` whose signature
813    //     we have just verified (over `current_cap_set`'s blob bytes).
814    //   - `current_signer`: the pubkey that signed `current_cap_set`'s
815    //     blob (== current cap's issuer).
816    //   - `current_cap_set`: the decoded cap whose signature we've
817    //     verified.
818    let mut current_signer = outer_signer;
819    let mut current_cap_set = leaf_cap_set.clone();
820    let mut current_fields = leaf_fields.clone();
821    let mut depth = 0usize;
822
823    loop {
824        // Termination: the issuer of the current cap is the team root.
825        if current_signer == team_root {
826            return Ok(VerifiedCapability {
827                subject: leaf_fields.subject,
828                scope_root: leaf_fields.scope_root,
829                cap_set: leaf_cap_set,
830            });
831        }
832
833        depth += 1;
834        if depth > MAX_CHAIN_DEPTH {
835            return Err(VerifyError::ChainTooDeep);
836        }
837
838        // Non-root: the current outer entity must carry sig_parent_cap
839        // + sig_embedded_parent_proof pointing at the next sub-entity.
840        let mut parent_iter = find!(
841            (ph: Inline<Handle<SimpleArchive>>, pid: crate::id::Id),
842            pattern!(&sig_set, [{
843                current_outer_id @
844                sig_parent_cap: ?ph,
845                sig_embedded_parent_proof: ?pid,
846            }])
847        );
848        let (parent_cap_handle, parent_proof_id) = match (
849            parent_iter.next(),
850            parent_iter.next(),
851        ) {
852            (Some(row), None) => row,
853            _ => return Err(VerifyError::NonRootMissingParent),
854        };
855
856        // Fetch + decode the parent cap.
857        let parent_cap_blob = fetch_blob(parent_cap_handle).ok_or(VerifyError::Fetch)?;
858        let parent_cap_set: TribleSet =
859            TryFromBlob::try_from_blob(parent_cap_blob.clone())?;
860        let parent_fields = extract_cap_fields(&parent_cap_set)?;
861
862        // Verify the parent proof's sig attests to the parent cap's
863        // bytes, signed by some authority.
864        let parent_signer = extract_and_verify_sig_at(
865            &sig_set,
866            parent_proof_id,
867            &parent_cap_blob,
868        )?;
869        if parent_signer != parent_fields.issuer {
870            return Err(VerifyError::IssuerMismatch);
871        }
872        if is_expired(&parent_fields.expiry) {
873            return Err(VerifyError::Expired);
874        }
875        // Each child link's scope must be a subset of its parent's.
876        if !scope_subsumes(
877            &parent_cap_set,
878            parent_fields.scope_root,
879            &current_cap_set,
880            current_fields.scope_root,
881        ) {
882            return Err(VerifyError::ScopeNotSubset);
883        }
884
885        // Step.
886        current_outer_id = parent_proof_id;
887        current_signer = parent_signer;
888        current_cap_set = parent_cap_set;
889        current_fields = parent_fields;
890    }
891}
892
893/// Extract a `(signed_by, signature_r, signature_s)` from a specific
894/// entity inside a sig blob's TribleSet, verify it's a valid signature
895/// over `signed_blob.bytes`, and return the signer.
896fn extract_and_verify_sig_at(
897    sig_set: &TribleSet,
898    entity: crate::id::Id,
899    signed_blob: &Blob<SimpleArchive>,
900) -> Result<VerifyingKey, VerifyError> {
901    let mut iter = find!(
902        (signer: VerifyingKey, r, s),
903        pattern!(sig_set, [{
904            entity @
905            crate::repo::signed_by: ?signer,
906            crate::repo::signature_r: ?r,
907            crate::repo::signature_s: ?s,
908        }])
909    );
910    let (signer, r, s) = match (iter.next(), iter.next()) {
911        (Some(row), None) => row,
912        _ => return Err(VerifyError::MalformedSig),
913    };
914    let signature = Signature::from_components(r, s);
915    signer
916        .verify(&signed_blob.bytes, &signature)
917        .map_err(|_| VerifyError::BadSignature)?;
918    Ok(signer)
919}
920
921#[cfg(test)]
922mod tests {
923    //! Tests for the descriptive-caps shape: cap blobs are pure
924    //! declarations; sig blobs carry the chain proof as recursive
925    //! embedded sub-entities. See decide#5ed64e57.
926    use super::*;
927    use crate::inline::TryToInline;
928    use ed25519_dalek::SigningKey;
929    use hifitime::Epoch;
930    use rand::rngs::OsRng;
931    use std::collections::HashMap;
932
933    fn key() -> SigningKey {
934        SigningKey::generate(&mut OsRng)
935    }
936
937    fn interval(seconds_from_now: f64) -> Inline<NsTAIInterval> {
938        let now = Epoch::now().expect("system time");
939        let later = now + hifitime::Duration::from_seconds(seconds_from_now);
940        (now, later).try_to_inline().expect("valid interval")
941    }
942
943    fn expired_interval() -> Inline<NsTAIInterval> {
944        let now = Epoch::now().expect("system time");
945        let past_start = now - hifitime::Duration::from_seconds(7200.0);
946        let past_end = now - hifitime::Duration::from_seconds(3600.0);
947        (past_start, past_end).try_to_inline().expect("valid interval")
948    }
949
950    fn empty_scope() -> (Id, TribleSet) {
951        let scope_root = crate::id::ufoid();
952        let facts = TribleSet::from(entity! { ExclusiveId::force_ref(&scope_root) @
953            crate::metadata::tag: PERM_READ,
954        });
955        (*scope_root, facts)
956    }
957
958    /// Build a fetch_blob closure backed by an in-memory map.
959    fn fetch_from(
960        blobs: &[Blob<SimpleArchive>],
961    ) -> impl FnMut(Inline<Handle<SimpleArchive>>) -> Option<Blob<SimpleArchive>> + '_ {
962        let map: HashMap<_, _> = blobs
963            .iter()
964            .map(|b| {
965                let h: Inline<Handle<SimpleArchive>> = b.get_handle();
966                (h.raw, b.clone())
967            })
968            .collect();
969        move |h| map.get(&h.raw).cloned()
970    }
971
972    // ── Length-1 chain ────────────────────────────────────────────────
973
974    #[test]
975    fn length_one_chain_round_trips() {
976        let team_root = key();
977        let (scope_root, scope_facts) = empty_scope();
978
979        let (cap_blob, sig_blob) = build_capability(
980            &team_root,
981            team_root.verifying_key(),
982            None,
983            scope_root,
984            scope_facts,
985            interval(3600.0),
986        )
987        .expect("build");
988
989        let sig_handle: Inline<Handle<SimpleArchive>> = (&sig_blob).get_handle();
990        let blobs = [cap_blob.clone(), sig_blob.clone()];
991
992        let verified = verify_chain(
993            team_root.verifying_key(),
994            sig_handle,
995            team_root.verifying_key(),
996            fetch_from(&blobs),
997        )
998        .expect("verify");
999
1000        assert_eq!(verified.subject, team_root.verifying_key());
1001        assert_eq!(verified.scope_root, scope_root);
1002    }
1003
1004    // ── Length-N chain ────────────────────────────────────────────────
1005
1006    fn three_level_chain()
1007    -> (SigningKey, SigningKey, SigningKey, Vec<Blob<SimpleArchive>>, Inline<Handle<SimpleArchive>>) {
1008        let team_root = key();
1009        let a = key();
1010        let b = key();
1011
1012        // Level 1: team_root → A (subject = A)
1013        let (scope1_root, scope1_facts) = empty_scope();
1014        let (cap_a, sig_a) = build_capability(
1015            &team_root,
1016            a.verifying_key(),
1017            None,
1018            scope1_root,
1019            scope1_facts,
1020            interval(3600.0),
1021        )
1022        .expect("build level-1");
1023
1024        // Level 2: A → B (subject = B)
1025        let (scope2_root, scope2_facts) = empty_scope();
1026        let (cap_b, sig_b) = build_capability(
1027            &a,
1028            b.verifying_key(),
1029            Some((cap_a.clone(), sig_a.clone())),
1030            scope2_root,
1031            scope2_facts,
1032            interval(3600.0),
1033        )
1034        .expect("build level-2");
1035
1036        let leaf_sig_handle: Inline<Handle<SimpleArchive>> = (&sig_b).get_handle();
1037        let blobs = vec![cap_a, sig_a, cap_b, sig_b];
1038        (team_root, a, b, blobs, leaf_sig_handle)
1039    }
1040
1041    #[test]
1042    fn length_three_chain_round_trips() {
1043        let (team_root, _a, b, blobs, leaf_sig_handle) = three_level_chain();
1044
1045        let verified = verify_chain(
1046            team_root.verifying_key(),
1047            leaf_sig_handle,
1048            b.verifying_key(),
1049            fetch_from(&blobs),
1050        )
1051        .expect("verify");
1052
1053        assert_eq!(verified.subject, b.verifying_key());
1054    }
1055
1056    #[test]
1057    fn rejects_subject_mismatch() {
1058        let (team_root, _a, _b, blobs, leaf_sig_handle) = three_level_chain();
1059        let imposter = key();
1060
1061        let err = verify_chain(
1062            team_root.verifying_key(),
1063            leaf_sig_handle,
1064            imposter.verifying_key(),
1065            fetch_from(&blobs),
1066        )
1067        .expect_err("must reject subject mismatch");
1068
1069        assert!(matches!(err, VerifyError::SubjectMismatch));
1070    }
1071
1072    #[test]
1073    fn rejects_wrong_team_root() {
1074        let (_real_team_root, _a, b, blobs, leaf_sig_handle) = three_level_chain();
1075        let wrong_root = key();
1076
1077        // With a wrong team root, the chain walk never finds a sig
1078        // signed by it — climbs to the actual root, finds no
1079        // sig_parent_cap there, errors with NonRootMissingParent
1080        // (current_signer != wrong_team_root && no parent linkage).
1081        let err = verify_chain(
1082            wrong_root.verifying_key(),
1083            leaf_sig_handle,
1084            b.verifying_key(),
1085            fetch_from(&blobs),
1086        )
1087        .expect_err("must reject wrong team root");
1088
1089        assert!(matches!(err, VerifyError::NonRootMissingParent));
1090    }
1091
1092    #[test]
1093    fn rejects_expired_leaf() {
1094        let team_root = key();
1095        let (scope_root, scope_facts) = empty_scope();
1096
1097        let (cap_blob, sig_blob) = build_capability(
1098            &team_root,
1099            team_root.verifying_key(),
1100            None,
1101            scope_root,
1102            scope_facts,
1103            expired_interval(),
1104        )
1105        .expect("build");
1106
1107        let sig_handle: Inline<Handle<SimpleArchive>> = (&sig_blob).get_handle();
1108        let blobs = [cap_blob, sig_blob];
1109
1110        let err = verify_chain(
1111            team_root.verifying_key(),
1112            sig_handle,
1113            team_root.verifying_key(),
1114            fetch_from(&blobs),
1115        )
1116        .expect_err("must reject expired");
1117
1118        assert!(matches!(err, VerifyError::Expired));
1119    }
1120
1121    #[test]
1122    fn rejects_expired_intermediate() {
1123        // Length-2 chain where team_root's cap to A has expired,
1124        // but A's cap to B has not. verify must reject.
1125        let team_root = key();
1126        let a = key();
1127        let b = key();
1128
1129        let (scope1_root, scope1_facts) = empty_scope();
1130        let (cap_a, sig_a) = build_capability(
1131            &team_root,
1132            a.verifying_key(),
1133            None,
1134            scope1_root,
1135            scope1_facts,
1136            expired_interval(),
1137        )
1138        .expect("build level-1");
1139
1140        let (scope2_root, scope2_facts) = empty_scope();
1141        let (cap_b, sig_b) = build_capability(
1142            &a,
1143            b.verifying_key(),
1144            Some((cap_a.clone(), sig_a.clone())),
1145            scope2_root,
1146            scope2_facts,
1147            interval(3600.0),
1148        )
1149        .expect("build level-2");
1150
1151        let leaf_sig_handle: Inline<Handle<SimpleArchive>> = (&sig_b).get_handle();
1152        let blobs = [cap_a, sig_a, cap_b, sig_b];
1153
1154        let err = verify_chain(
1155            team_root.verifying_key(),
1156            leaf_sig_handle,
1157            b.verifying_key(),
1158            fetch_from(&blobs),
1159        )
1160        .expect_err("must reject expired intermediate");
1161
1162        assert!(matches!(err, VerifyError::Expired));
1163    }
1164
1165    // ── Structural checks ─────────────────────────────────────────────
1166
1167    #[test]
1168    fn cap_blob_carries_no_chain_attributes() {
1169        // The whole point of the refactor: cap blobs are pure
1170        // declarations. Verify that even at depth > 1, the inner cap
1171        // blobs don't contain sig_parent_cap / sig_embedded_parent_proof
1172        // or any other chain reference.
1173        let (_team_root, _a, _b, blobs, _leaf_sig_handle) = three_level_chain();
1174
1175        for blob in &blobs {
1176            let set: TribleSet = match TryFromBlob::try_from_blob(blob.clone()) {
1177                Ok(s) => s,
1178                Err(_) => continue, // not a SimpleArchive blob; skip
1179            };
1180            // If this set contains cap_subject, it's a cap blob —
1181            // those must NOT carry sig-blob-only attributes.
1182            let is_cap = find!(
1183                (e: Id, s: VerifyingKey),
1184                pattern!(&set, [{ ?e @ cap_subject: ?s }])
1185            )
1186            .next()
1187            .is_some();
1188            if !is_cap {
1189                continue;
1190            }
1191
1192            let has_parent_link = find!(
1193                (e: Id, h: Inline<Handle<SimpleArchive>>),
1194                pattern!(&set, [{ ?e @ sig_parent_cap: ?h }])
1195            )
1196            .next()
1197            .is_some();
1198            assert!(
1199                !has_parent_link,
1200                "cap blob unexpectedly carries sig_parent_cap"
1201            );
1202        }
1203    }
1204
1205    #[test]
1206    fn leaf_sig_blob_carries_full_chain() {
1207        // The leaf sig blob should carry every cap's handle in its
1208        // recursive embedded proof structure. Walk the structure and
1209        // confirm we see N entries for an N-deep chain.
1210        let (_team_root, _a, _b, blobs, leaf_sig_handle) = three_level_chain();
1211
1212        let leaf_sig_blob = fetch_from(&blobs)(leaf_sig_handle).expect("fetch leaf sig");
1213        let sig_set: TribleSet = TryFromBlob::try_from_blob(leaf_sig_blob).expect("parse sig");
1214
1215        // Count entities with signed_by — should be 2 (A signed cap_b,
1216        // team_root signed cap_a). Each level of the chain contributes
1217        // exactly one signed_by trible.
1218        let signed_by_entities: HashSet<Id> = find!(
1219            (e: Id, s: VerifyingKey),
1220            pattern!(&sig_set, [{ ?e @ crate::repo::signed_by: ?s }])
1221        )
1222        .map(|(e, _)| e)
1223        .collect();
1224        assert_eq!(
1225            signed_by_entities.len(),
1226            2,
1227            "expected 2 signed_by entities (one per chain level); got {}",
1228            signed_by_entities.len()
1229        );
1230
1231        // Count entities with sig_parent_cap — should be 1 (the leaf's
1232        // outer entity points at A's cap; the embedded proof for A's
1233        // signature is itself the root level and has no further
1234        // sig_parent_cap).
1235        let parent_links: HashSet<Id> = find!(
1236            (e: Id, h: Inline<Handle<SimpleArchive>>),
1237            pattern!(&sig_set, [{ ?e @ sig_parent_cap: ?h }])
1238        )
1239        .map(|(e, _)| e)
1240        .collect();
1241        assert_eq!(
1242            parent_links.len(),
1243            1,
1244            "expected 1 sig_parent_cap entry for length-2 chain"
1245        );
1246    }
1247}