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 ¤t_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}