Skip to main content

contextgraph_host/
trust.rs

1//! Trust roots for provenance attestation, and the host-side verifier that
2//! consumes them (`SPEC.md` §6.5, F8–F9;
3//! [ADR 0016](https://github.com/macanderson/context-graph-protocol/blob/main/docs/adr/0016-attestation-trust-roots.md)).
4//!
5//! [ADR 0010](https://github.com/macanderson/context-graph-protocol/blob/main/docs/adr/0010-provenance-attestation.md)
6//! specifies the bytes a provider signs and deliberately stops there, so a
7//! provider holding keys in an HSM signs a public 32-byte commitment with its
8//! own backend. That leaves the question on the host's side of the wire: given
9//! a [`ProvenanceAttestation`] and a frame, **where does the public key come
10//! from?**
11//!
12//! The answer here is the only one that needs no organization behind it: **the
13//! operator is the trust root.** A [`TrustStore`] maps a `provider_id` to the
14//! keys that provider may sign under, and a key is in it because a person put
15//! it there — from the same material, in the same act, as the provider's own
16//! configuration and its consent grant. This is how `ssh` learns a host key and
17//! how `minisign` learns a signer. A registry, a well-known endpoint or a
18//! transparency log would all work better and all require a party both sides
19//! already trust; `GOVERNANCE.md`'s consent boundary rules that out for a host,
20//! and the attestation stays portable enough for one to be built *over* this.
21//!
22//! # F9 is the load-bearing rule
23//!
24//! An attestation this host cannot verify degrades its frame to *unattested*.
25//! It never disqualifies it. A host that dropped such frames would hand any
26//! peer a denial-of-service primitive — attach a malformed attestation, watch
27//! the evidence vanish — so every path in this module ends in an
28//! [`AttestationState`] and none of them ends in a dropped frame. Verification
29//! adds a fact to the audit; it never subtracts evidence and never reranks.
30//!
31//! # Attacker-controlled work is bounded before any cryptography runs
32//!
33//! Every field of an attestation arrives from the provider, so this module
34//! checks the cheap structural facts first and only then hashes or verifies:
35//! an oversized signature is rejected on its length rather than hex-decoded, an
36//! attestation naming an unknown `key_id` never reaches the signature check at
37//! all, and at most one attestation is verified per frame. The frame count is
38//! already bounded by the `max_frames` audit that runs before this
39//! ([`Host::query_all`](crate::Host::query_all)), so the total work is linear in
40//! a quantity the host already agreed to accept.
41
42use std::collections::{BTreeMap, HashMap};
43
44use contextgraph_types::{
45    ALGORITHM_ED25519, AttestationVerdict, ContextFrame, ContextQueryResult, FrameAttestation,
46    FrameId, InclusionProof, ProvenanceAttestation, verify_frame_attestation,
47    verify_frame_inclusion,
48};
49use serde::{Deserialize, Serialize};
50
51/// The exact length of a `sha256:<64 lowercase hex>` commitment string.
52const COMMITMENT_LEN: usize = "sha256:".len() + 64;
53
54/// The exact length of a hex-encoded Ed25519 signature (64 bytes).
55const ED25519_SIGNATURE_HEX_LEN: usize = 128;
56
57/// The exact length of a hex-encoded Ed25519 public key (32 bytes).
58const ED25519_PUBLIC_KEY_HEX_LEN: usize = 64;
59
60/// How much of a provider-supplied identifier is copied into an audit record.
61/// A `key_id` or an `algorithm` is echoed back so an operator can act on it, and
62/// an attacker must not be able to make the audit grow without bound by sending
63/// a megabyte one.
64const MAX_ECHOED_IDENTIFIER: usize = 128;
65
66/// An Ed25519 public key a host trusts for one provider.
67///
68/// `public_key` is lowercase hex rather than raw bytes for the reason
69/// [`ProvenanceAttestation::signature`] is: one encoding across the whole
70/// surface, and a key an operator can paste out of a provider's README into a
71/// config file without a base64 detour.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct TrustedKey {
74    /// The `key_id` an attestation must name to be checked against this key.
75    /// Rotation is a new `key_id`, never a reused one (`SPEC.md` §6.5), so a
76    /// store may hold several keys for one provider at once.
77    pub key_id: String,
78    /// The raw public key, lowercase hex.
79    pub public_key: String,
80}
81
82impl TrustedKey {
83    /// A trusted Ed25519 key from a `key_id` and a 64-character lowercase-hex
84    /// public key.
85    ///
86    /// Returns `None` for anything that is not a well-formed Ed25519 public
87    /// key encoding, so a typo in a config file fails where a person is reading
88    /// the error rather than months later as an unexplained `MalformedKey` in
89    /// an audit.
90    pub fn ed25519_hex(key_id: impl Into<String>, public_key: impl Into<String>) -> Option<Self> {
91        let public_key = public_key.into();
92        if public_key.len() != ED25519_PUBLIC_KEY_HEX_LEN || decode_hex(&public_key).is_none() {
93            return None;
94        }
95        Some(Self {
96            key_id: key_id.into(),
97            public_key,
98        })
99    }
100
101    /// A trusted Ed25519 key from raw public-key bytes — the form
102    /// [`contextgraph_types::public_key_for`] returns.
103    pub fn ed25519_bytes(key_id: impl Into<String>, public_key: &[u8; 32]) -> Self {
104        Self {
105            key_id: key_id.into(),
106            public_key: encode_hex(public_key),
107        }
108    }
109
110    /// A `sha256:<hex>` fingerprint over the key bytes — the short string a host
111    /// shows a person next to the consent prompt, so "I consent to this
112    /// provider" and "I trust this key" are one decision (ADR 0016 §2).
113    ///
114    /// Over the *decoded* bytes, so two spellings of the same key cannot
115    /// fingerprint differently. A key whose hex does not decode has no
116    /// fingerprint to show.
117    pub fn fingerprint(&self) -> Option<String> {
118        let bytes = decode_hex(&self.public_key)?;
119        Some(contextgraph_types::digest_string(&sha256(&bytes)))
120    }
121}
122
123/// The keys a host trusts, per provider — the local answer to "who may sign
124/// evidence I will treat as attested?" (ADR 0016).
125///
126/// Serde-able and persistable, mirroring
127/// [`ConsentStore`](crate::consent::ConsentStore), because it is the same kind
128/// of object: a record of a decision one person made about one provider on one
129/// machine. Nothing populates it implicitly — there is no discovery, no
130/// fetching, and no trust-on-first-use. An empty store is a host that verifies
131/// nothing and loses nothing, which is the default posture.
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct TrustStore {
134    /// `provider_id -> key_id -> key`. `BTreeMap` inside so iteration over one
135    /// provider's keys is deterministic in an audit or a rendered report.
136    #[serde(default)]
137    keys: HashMap<String, BTreeMap<String, TrustedKey>>,
138}
139
140impl TrustStore {
141    /// An empty store: no provider has a trusted key, so every attestation
142    /// resolves to [`AttestationState::NoTrustedKey`] and every frame is still
143    /// served (F9).
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Trust `key` for `provider_id`, replacing any key already held under the
149    /// same `key_id`.
150    pub fn trust(&mut self, provider_id: impl Into<String>, key: TrustedKey) {
151        self.keys
152            .entry(provider_id.into())
153            .or_default()
154            .insert(key.key_id.clone(), key);
155    }
156
157    /// Stop trusting one key. Returns whether a key was actually removed.
158    ///
159    /// This is the whole of revocation, and it is local: nothing here learns
160    /// that a key was compromised, so a host that is told so out of band calls
161    /// this, and a host that is never told keeps trusting it (ADR 0016).
162    pub fn revoke(&mut self, provider_id: &str, key_id: &str) -> bool {
163        let Some(keys) = self.keys.get_mut(provider_id) else {
164            return false;
165        };
166        let removed = keys.remove(key_id).is_some();
167        if keys.is_empty() {
168            self.keys.remove(provider_id);
169        }
170        removed
171    }
172
173    /// The key held for `(provider_id, key_id)`, if any.
174    pub fn key(&self, provider_id: &str, key_id: &str) -> Option<&TrustedKey> {
175        self.keys.get(provider_id)?.get(key_id)
176    }
177
178    /// Every key trusted for one provider, in `key_id` order.
179    pub fn keys_for(&self, provider_id: &str) -> impl Iterator<Item = &TrustedKey> {
180        self.keys
181            .get(provider_id)
182            .into_iter()
183            .flat_map(|k| k.values())
184    }
185
186    /// Whether this store trusts no key at all.
187    pub fn is_empty(&self) -> bool {
188        self.keys.values().all(|keys| keys.is_empty())
189    }
190
191    /// Check one attestation against this store and report what was found
192    /// (`SPEC.md` §6.5.4).
193    ///
194    /// Total: every input produces a state, and none of them is an error a
195    /// caller could mistake for a reason to drop the frame (F9). The cheap
196    /// structural checks run first so a hostile attestation cannot buy more
197    /// than a constant amount of work before it is dismissed.
198    pub fn check(
199        &self,
200        provider_id: &str,
201        frame: &ContextFrame,
202        attestation: &ProvenanceAttestation,
203    ) -> AttestationState {
204        self.check_signed_as(provider_id, provider_id, frame, attestation)
205    }
206
207    /// [`check`](Self::check) with the trust-lookup id and the signing id told
208    /// apart. See [`check_result_signed_as`](Self::check_result_signed_as) for
209    /// why a host needs both: `local_id` decides *whose key may sign this*, and
210    /// `signing_id` — the handshake-declared `provider.name` — decides *what
211    /// bytes were signed* (`SPEC.md` §6.5.2).
212    pub fn check_signed_as(
213        &self,
214        local_id: &str,
215        signing_id: &str,
216        frame: &ContextFrame,
217        attestation: &ProvenanceAttestation,
218    ) -> AttestationState {
219        let provider_id = local_id;
220        // F8, first: a scheme this build cannot check is *uncheckable*, which is
221        // a different finding from invalid and is not improved by holding a key.
222        if attestation.algorithm != ALGORITHM_ED25519 {
223            return AttestationState::UnknownAlgorithm {
224                algorithm: echoed(&attestation.algorithm),
225            };
226        }
227
228        // No key ⇒ no signature check. This is also the bound that keeps an
229        // unknown peer from spending the host's CPU: reaching the verifier at
230        // all requires an operator to have trusted a key under this exact id.
231        let Some(key) = self.key(provider_id, &attestation.key_id) else {
232            return AttestationState::NoTrustedKey {
233                key_id: echoed(&attestation.key_id),
234            };
235        };
236
237        // Structural length checks before any decoding. `verify_commitment`
238        // would reach the same verdicts, but only after hex-decoding a string
239        // whose length the provider chose.
240        if attestation.signed_commitment.len() != COMMITMENT_LEN {
241            return AttestationState::Invalid {
242                verdict: AttestationVerdict::MalformedCommitment,
243            };
244        }
245        if attestation.signature.len() != ED25519_SIGNATURE_HEX_LEN {
246            return AttestationState::Invalid {
247                verdict: AttestationVerdict::MalformedSignature,
248            };
249        }
250        let Some(public_key) = decode_hex(&key.public_key) else {
251            // A key this host stored that does not decode: an operator
252            // configuration bug, reported as the verdict it is rather than as a
253            // finding about the provider.
254            return AttestationState::Invalid {
255                verdict: AttestationVerdict::MalformedKey,
256            };
257        };
258
259        // Both verifying verdicts are *attested* — an identity-only attestation
260        // is a narrower guarantee, not a failed one, and demoting it to
261        // `Invalid` would discard a signature that genuinely checks out.
262        //
263        // `covers_content` is read off the verdict rather than re-derived from
264        // `frame.content_digest.is_some()`. The two agreed when this was
265        // written, and a single source of truth is what keeps them agreeing:
266        // the rule for what a commitment binds lives in `frame_commitment`, and
267        // this is downstream of it (#128).
268        // The commitment is recomputed with `signing_id`, never `local_id`: the
269        // preimage §6.5.2 defines contains the name the provider declared, which
270        // is the only provider identifier both ends of the wire observe.
271        match verify_frame_attestation(signing_id, frame, attestation, &public_key) {
272            verdict @ (AttestationVerdict::Valid | AttestationVerdict::ValidIdentityOnly) => {
273                AttestationState::Attested {
274                    key_id: attestation.key_id.clone(),
275                    attester_id: echoed(&attestation.attester_id),
276                    covers_content: verdict.binds_content(),
277                }
278            }
279            verdict => AttestationState::Invalid { verdict },
280        }
281    }
282
283    /// Check the evidence a provider attached to one query result, and return
284    /// one outcome per frame in it — including the frames no entry covered,
285    /// which are [`AttestationState::Unattested`].
286    ///
287    /// The evidence is read off `result` itself
288    /// ([`frame_attestations`](ContextQueryResult::frame_attestations) and
289    /// [`result_attestation`](ContextQueryResult::result_attestation)), not
290    /// passed alongside it. That is deliberate and it is the whole of #161: an
291    /// attestation has exactly one home (`SPEC.md` §6.5.5, ADR 0014), so a
292    /// caller cannot hand this method a set of signatures that disagrees with
293    /// the answer they cover, and no tie-breaking rule is needed because there
294    /// is never a tie.
295    ///
296    /// The result is a **total** account of the frames: a caller can read a
297    /// state for every frame it is about to compose, and never has to guess
298    /// whether an absent entry means unsigned or unchecked.
299    ///
300    /// At most one entry is checked per frame, and at most
301    /// `result.frames.len()` entries are examined at all. A conforming provider
302    /// sends no more than one entry per frame, so the cap binds only a provider
303    /// that already over-sent — and the consequence falls on that provider
304    /// alone: its own later entries read as absent, and its frames are still
305    /// served.
306    pub fn check_result(
307        &self,
308        provider_id: &str,
309        result: &ContextQueryResult,
310    ) -> Vec<FrameAttestationOutcome> {
311        self.check_result_signed_as(provider_id, provider_id, result)
312    }
313
314    /// [`check_result`](Self::check_result) with the two provider identities
315    /// told apart: `local_id` is the host's own key for this provider, and
316    /// `signing_id` is the name the provider signs under.
317    ///
318    /// # Why there are two
319    ///
320    /// `SPEC.md` §6.5.2 puts the provider id inside the signed preimage, and is
321    /// explicit about *which* id: the handshake-declared `provider.name`, because
322    /// a host's local id "is not a string the provider ever sees — so it is not
323    /// one a provider could sign against". A host that recomputes the commitment
324    /// with its own local id gets a different preimage and therefore a different
325    /// digest, and reports [`AttestationVerdict::CommitmentMismatch`] — the
326    /// verdict §6.5.4 reserves for *a frame that changed after signing*. An
327    /// operator who merely named the provider something else in their config
328    /// would be handed a tampering incident over honest evidence.
329    ///
330    /// The two ids cannot be collapsed in the other direction either. Trust is
331    /// keyed on `local_id` because that is the id the *operator* chose, in the
332    /// same act as the consent grant; keying it on the declared name would let a
333    /// provider claim another's trusted key by declaring its name, which is the
334    /// substitution the identity binding exists to prevent. So the local id
335    /// answers "whose key may sign this?" and the declared name answers "what
336    /// bytes were signed?" — different questions with different right answers.
337    ///
338    /// # The same split decides matching, not only verifying
339    ///
340    /// A provider has no notion of this host's local routing id, so every
341    /// [`FrameAttestation::frame`] it puts on the wire is built from the same
342    /// name it signs under (the reference provider builds both from its one
343    /// `attestation_provider_id()`). Matching `offered` against
344    /// `frame.identity(local_id)` would therefore miss every entry whenever an
345    /// operator's config id differs from the provider's declared name — the
346    /// same false negative this split exists to close, one layer above the
347    /// signature check itself. The lookup key is `frame.identity(signing_id)`
348    /// for that reason.
349    ///
350    /// The **returned** [`FrameId`]s are keyed by `local_id`, not
351    /// `signing_id`: [`FrameId::provider_id`] is documented as "the same
352    /// routing/consent key the host registered it under", and it is the id
353    /// the rest of the composition path (`compose.rs`, `usage_report`) already
354    /// builds its own `FrameId`s from. Echoing `signing_id` here instead would
355    /// desynchronize this ledger from every other identity the host emits for
356    /// the same frame.
357    pub fn check_result_signed_as(
358        &self,
359        local_id: &str,
360        signing_id: &str,
361        result: &ContextQueryResult,
362    ) -> Vec<FrameAttestationOutcome> {
363        let mut offered: HashMap<&FrameId, &FrameAttestation> = HashMap::new();
364        for entry in result.frame_attestations.iter().take(result.frames.len()) {
365            // First entry wins: a flood of duplicates for one frame cannot
366            // multiply the verification work.
367            offered.entry(&entry.frame).or_insert(entry);
368        }
369
370        result
371            .frames
372            .iter()
373            .map(|frame| {
374                // Match on the whole identity triple, never on the frame id
375                // alone: two frames sharing an id but not a digest are
376                // different bytes, and handing the first one's evidence to the
377                // second is the substitution the binding exists to prevent
378                // (`SPEC.md` §6.5.2). Built from `signing_id` — see this
379                // method's doc comment for why.
380                let signing_identity = frame.identity(signing_id);
381                let state = match offered.get(&signing_identity) {
382                    Some(entry) => self.check_entry(
383                        local_id,
384                        signing_id,
385                        frame,
386                        entry,
387                        result.result_attestation.as_ref(),
388                    ),
389                    None => AttestationState::Unattested,
390                };
391                FrameAttestationOutcome {
392                    // Built from `local_id`, deliberately different from the
393                    // lookup key above — see this method's doc comment.
394                    frame: frame.identity(local_id),
395                    state,
396                }
397            })
398            .collect()
399    }
400
401    /// One entry's outcome: the per-frame signature if it carries one, its
402    /// membership of the signed result-set root if it does not.
403    ///
404    /// A [`FrameAttestation`] carries **either** shape, and the reason both
405    /// exist is cost: signing one Merkle root with a per-frame inclusion proof
406    /// says with one signature what *n* per-frame signatures say. A host that
407    /// checked only the first shape would report every provider that chose the
408    /// cheap one as unattested.
409    ///
410    /// The per-frame signature wins when an entry carries both. It is the
411    /// narrower claim — it binds this frame directly, with no tree in between —
412    /// and checking it costs one verification rather than a walk plus one.
413    fn check_entry(
414        &self,
415        local_id: &str,
416        signing_id: &str,
417        frame: &ContextFrame,
418        entry: &FrameAttestation,
419        result_attestation: Option<&ProvenanceAttestation>,
420    ) -> AttestationState {
421        if let Some(attestation) = &entry.attestation {
422            return self.check_signed_as(local_id, signing_id, frame, attestation);
423        }
424        match (&entry.inclusion_proof, result_attestation) {
425            (Some(proof), Some(root)) => {
426                self.check_inclusion(local_id, signing_id, frame, proof, root)
427            }
428            // A proof of membership of a root the answer never carried, or an
429            // entry naming a frame and asserting nothing about it. Neither can
430            // be turned into a check, and F9 makes that a degradation to
431            // unattested rather than a reason to withhold the frame.
432            _ => AttestationState::UnusableEvidence,
433        }
434    }
435
436    /// Check one frame's membership of the signed result-set root
437    /// (`SPEC.md` §6.5.3, F13).
438    ///
439    /// Ordered exactly as [`check`](Self::check) is, and for the same reason:
440    /// the scheme, then the key, then the structural lengths, and only then any
441    /// hashing. The key lookup in particular is the bound that keeps an
442    /// untrusted peer from spending this host's CPU walking a Merkle path —
443    /// reaching the walk at all requires an operator to have trusted a key
444    /// under the root attestation's exact `key_id`.
445    /// [`check_inclusion`](Self::check_inclusion) — same two-id split as
446    /// [`check_signed_as`](Self::check_signed_as) and for the same reason:
447    /// `local_id` keys the trust lookup, `signing_id` recomputes the root
448    /// (`frame_commitment` inside [`verify_frame_inclusion`]), because the
449    /// root was built over commitments the provider signed under its own
450    /// declared name.
451    fn check_inclusion(
452        &self,
453        local_id: &str,
454        signing_id: &str,
455        frame: &ContextFrame,
456        proof: &InclusionProof,
457        root: &ProvenanceAttestation,
458    ) -> AttestationState {
459        if root.algorithm != ALGORITHM_ED25519 {
460            return AttestationState::UnknownAlgorithm {
461                algorithm: echoed(&root.algorithm),
462            };
463        }
464        let Some(key) = self.key(local_id, &root.key_id) else {
465            return AttestationState::NoTrustedKey {
466                key_id: echoed(&root.key_id),
467            };
468        };
469        if root.signed_commitment.len() != COMMITMENT_LEN {
470            return AttestationState::Invalid {
471                verdict: AttestationVerdict::MalformedCommitment,
472            };
473        }
474        if root.signature.len() != ED25519_SIGNATURE_HEX_LEN {
475            return AttestationState::Invalid {
476                verdict: AttestationVerdict::MalformedSignature,
477            };
478        }
479        let Some(public_key) = decode_hex(&key.public_key) else {
480            return AttestationState::Invalid {
481                verdict: AttestationVerdict::MalformedKey,
482            };
483        };
484
485        // `covers_content` is read off the verdict rather than re-derived, for
486        // the reason `check` gives: the rule for what a commitment binds lives
487        // in `frame_commitment`, and both call sites stay downstream of it
488        // (#128).
489        match verify_frame_inclusion(signing_id, frame, proof, root, &public_key) {
490            verdict @ (AttestationVerdict::Valid | AttestationVerdict::ValidIdentityOnly) => {
491                AttestationState::Attested {
492                    key_id: root.key_id.clone(),
493                    attester_id: echoed(&root.attester_id),
494                    covers_content: verdict.binds_content(),
495                }
496            }
497            verdict => AttestationState::Invalid { verdict },
498        }
499    }
500}
501
502/// What a host found when it checked one frame's attestation (ADR 0016 §4).
503///
504/// Named outcomes rather than a boolean, for the reason
505/// [`AttestationVerdict`] is named: [`NoTrustedKey`](Self::NoTrustedKey) is a
506/// configuration gap an operator closes in a minute, and
507/// [`Invalid`](Self::Invalid) carrying
508/// [`CommitmentMismatch`](AttestationVerdict::CommitmentMismatch) is an
509/// incident. Collapsing them sends someone hunting the wrong one.
510///
511/// **None of these states removes a frame from a composition.** F9 makes an
512/// unverifiable attestation a degradation to *unattested*, never a
513/// disqualification.
514#[derive(Debug, Clone, PartialEq, Eq, Default)]
515pub enum AttestationState {
516    /// No attestation check was performed on this frame — the host composed it
517    /// without consulting a trust store. Distinct from
518    /// [`Unattested`](Self::Unattested): "I did not look" is not "there was
519    /// nothing to find".
520    #[default]
521    NotChecked,
522    /// The provider offered no attestation for this frame.
523    Unattested,
524    /// The signature verified against a key this host trusts for this provider.
525    ///
526    /// This means exactly "signed by a key this operator chose to trust". It
527    /// does not mean the content is true, and it carries no weight for a
528    /// second host that holds no key (ADR 0016 Consequences).
529    Attested {
530        /// The key that verified it.
531        key_id: String,
532        /// The attesting authority the attestation names — who is accountable
533        /// for the claim, as distinct from the key that produced it.
534        attester_id: String,
535        /// Whether the signature covers the frame's **content bytes**.
536        ///
537        /// A frame commitment is over `(provider_id, frame_id, content_digest)`
538        /// plus the provenance chain head (`SPEC.md` §6.5.2), and
539        /// `content_digest` is optional. So a frame that declares none has a
540        /// perfectly valid signature over its identity and its provenance and
541        /// **nothing at all over its text**: the same provider can re-serve
542        /// different content under the same frame id and this signature still
543        /// verifies.
544        ///
545        /// `false` says so out loud, so a host does not render such a frame as
546        /// though its words were signed. It is not a failure — the frame is
547        /// attested — it is a narrower claim than a reader would otherwise
548        /// assume, and assuming it is the mistake this field exists to prevent.
549        covers_content: bool,
550    },
551    /// An attestation was offered, but this host holds no trusted key under
552    /// that `key_id` for that provider. A configuration gap, **not** a forgery
553    /// finding: the signature was never checked, so nothing is known about it.
554    NoTrustedKey {
555        /// The `key_id` the attestation named, so an operator knows what to add.
556        key_id: String,
557    },
558    /// The attestation names a signature scheme this build cannot check
559    /// (`SPEC.md` F8). A refusal to guess, not a failure to validate.
560    UnknownAlgorithm {
561        /// The scheme the attestation named.
562        algorithm: String,
563    },
564    /// An entry named this frame and this host could not turn it into a check:
565    /// an inclusion proof with no signed `result_attestation` root to prove
566    /// membership *of*, or an entry carrying neither a signature nor a proof.
567    ///
568    /// F9: the frame is served, and every decision treats this as unattested.
569    /// The state is named rather than folded into
570    /// [`Unattested`](Self::Unattested) because the two say different things
571    /// about the provider — one chose not to sign, the other sent evidence that
572    /// does not resolve — and only the second is worth an operator's attention.
573    /// [`was_offered`](Self::was_offered) is true here.
574    UnusableEvidence,
575    /// A trusted key was found and the check did not succeed — a forgery, a
576    /// frame altered after signing, or an attestation too malformed to check.
577    /// The verdict says which.
578    Invalid {
579        /// The named finding from `contextgraph_types::attest`.
580        verdict: AttestationVerdict,
581    },
582}
583
584impl AttestationState {
585    /// Whether this frame is attested by a key this host trusts. Every other
586    /// state — including [`NotChecked`](Self::NotChecked) — is `false`, because
587    /// "I could not check it" is never "it is good" (`SPEC.md` F8).
588    pub fn is_attested(&self) -> bool {
589        matches!(self, Self::Attested { .. })
590    }
591
592    /// Whether the signature covers the frame's content bytes as well as its
593    /// identity and provenance — see
594    /// [`Attested::covers_content`](Self::Attested). `false` for every state
595    /// that is not [`Attested`](Self::Attested).
596    pub fn covers_content(&self) -> bool {
597        matches!(
598            self,
599            Self::Attested {
600                covers_content: true,
601                ..
602            }
603        )
604    }
605
606    /// Whether an attestation was offered at all. A host reports on
607    /// [`NoTrustedKey`](Self::NoTrustedKey) differently from
608    /// [`Unattested`](Self::Unattested): the first is the host's gap, the
609    /// second is the provider's choice.
610    pub fn was_offered(&self) -> bool {
611        !matches!(self, Self::NotChecked | Self::Unattested)
612    }
613}
614
615/// One frame's attestation outcome, keyed by the frame's stable identity so it
616/// joins the composition audit without a positional assumption.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct FrameAttestationOutcome {
619    /// The frame's stable identity.
620    pub frame: FrameId,
621    /// What the host found.
622    pub state: AttestationState,
623}
624
625/// Every frame's attestation state from one fan-out, keyed by identity — the
626/// join the composer reads so an [`AuditEntry`](crate::compose::AuditEntry) can
627/// say whether the evidence it quotes was signed.
628///
629/// An **empty** ledger is not "nothing was attested": it is "nothing was
630/// checked", and a lookup returns [`AttestationState::NotChecked`] to say so.
631#[derive(Debug, Clone, Default, PartialEq, Eq)]
632pub struct AttestationLedger {
633    states: BTreeMap<FrameId, AttestationState>,
634}
635
636impl AttestationLedger {
637    /// An empty ledger: every lookup is [`AttestationState::NotChecked`].
638    pub fn new() -> Self {
639        Self::default()
640    }
641
642    /// Record one frame's outcome, replacing any previous entry for the same
643    /// identity.
644    pub fn record(&mut self, outcome: FrameAttestationOutcome) {
645        self.states.insert(outcome.frame, outcome.state);
646    }
647
648    /// The state recorded for a frame, or [`AttestationState::NotChecked`] when
649    /// this ledger has nothing to say about it.
650    pub fn state_for(&self, frame: &FrameId) -> AttestationState {
651        self.states
652            .get(frame)
653            .cloned()
654            .unwrap_or(AttestationState::NotChecked)
655    }
656
657    /// Whether this ledger recorded nothing.
658    pub fn is_empty(&self) -> bool {
659        self.states.is_empty()
660    }
661
662    /// How many frames this ledger has a state for.
663    pub fn len(&self) -> usize {
664        self.states.len()
665    }
666}
667
668impl FromIterator<FrameAttestationOutcome> for AttestationLedger {
669    fn from_iter<I: IntoIterator<Item = FrameAttestationOutcome>>(outcomes: I) -> Self {
670        let mut ledger = Self::new();
671        for outcome in outcomes {
672            ledger.record(outcome);
673        }
674        ledger
675    }
676}
677
678/// The first `MAX_ECHOED_IDENTIFIER` bytes of a provider-supplied identifier,
679/// cut at a UTF-8 boundary. An audit record echoes these so an operator can act
680/// on them; the cap is what stops a hostile provider growing the record without
681/// bound.
682fn echoed(value: &str) -> String {
683    if value.len() <= MAX_ECHOED_IDENTIFIER {
684        return value.to_string();
685    }
686    let mut end = MAX_ECHOED_IDENTIFIER;
687    while end > 0 && !value.is_char_boundary(end) {
688        end -= 1;
689    }
690    value[..end].to_string()
691}
692
693/// Decode a lowercase-or-uppercase hex string into bytes. `None` for an odd
694/// length or a non-hex byte.
695fn decode_hex(hex: &str) -> Option<Vec<u8>> {
696    let bytes = hex.as_bytes();
697    if !bytes.len().is_multiple_of(2) {
698        return None;
699    }
700    let mut out = Vec::with_capacity(bytes.len() / 2);
701    let mut index = 0;
702    // Indexed rather than `chunks_exact(2)`: clippy 1.98 wants `as_chunks::<2>`
703    // for a constant chunk size, and that API is newer than this workspace's
704    // MSRV. `get` keeps the walk panic-free without either.
705    while index < bytes.len() {
706        let hi = (*bytes.get(index)? as char).to_digit(16)?;
707        let lo = (*bytes.get(index + 1)? as char).to_digit(16)?;
708        out.push((hi * 16 + lo) as u8);
709        index += 2;
710    }
711    Some(out)
712}
713
714/// Lowercase hex for raw bytes.
715fn encode_hex(bytes: &[u8]) -> String {
716    let mut out = String::with_capacity(bytes.len() * 2);
717    for byte in bytes {
718        out.push(char::from_digit((byte >> 4) as u32, 16).expect("nibble is < 16"));
719        out.push(char::from_digit((byte & 0x0f) as u32, 16).expect("nibble is < 16"));
720    }
721    out
722}
723
724/// SHA-256 over `bytes`, for [`TrustedKey::fingerprint`].
725fn sha256(bytes: &[u8]) -> [u8; 32] {
726    use sha2::{Digest, Sha256};
727    Sha256::digest(bytes).into()
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use contextgraph_types::{
734        FrameKind, Provenance, public_key_for, sign_commitment, sign_frame_attestation,
735    };
736
737    /// A deterministic seed. Tests need reproducible signatures, and this key
738    /// signs nothing outside this file.
739    const SEED: [u8; 32] = [3u8; 32];
740    /// A second seed, for "signed by someone else" cases.
741    const OTHER_SEED: [u8; 32] = [9u8; 32];
742
743    const PROVIDER: &str = "docs";
744    const KEY_ID: &str = "docs-2026-08";
745
746    /// A frame that declares a `content_digest`, so its commitment binds its
747    /// bytes as well as its identity (`SPEC.md` §6.5.2).
748    fn frame(id: &str) -> ContextFrame {
749        let mut frame = digestless_frame(id);
750        frame.content_digest = Some(format!("sha256:{}", "cd".repeat(32)));
751        frame
752    }
753
754    /// A frame with **no** `content_digest` — permitted by the protocol, and
755    /// the case whose signature covers no content.
756    fn digestless_frame(id: &str) -> ContextFrame {
757        let mut frame = ContextFrame::full(id, FrameKind::Doc, "Title", "content", 0.9, 4);
758        frame.provenance = vec![Provenance {
759            kind: "file".into(),
760            uri: Some("file:///repo/README.md".into()),
761            range: Some("L1-4".into()),
762            digest: Some(format!("sha256:{}", "ab".repeat(32))),
763            method: None,
764            by: None,
765        }];
766        frame
767    }
768
769    fn signed(frame: &ContextFrame, seed: &[u8; 32]) -> ProvenanceAttestation {
770        sign_frame_attestation(
771            PROVIDER,
772            frame,
773            seed,
774            KEY_ID,
775            "docs-provider",
776            "2026-08-29T00:00:00Z",
777        )
778    }
779
780    fn store_trusting(seed: &[u8; 32]) -> TrustStore {
781        let mut store = TrustStore::new();
782        store.trust(
783            PROVIDER,
784            TrustedKey::ed25519_bytes(KEY_ID, &public_key_for(seed)),
785        );
786        store
787    }
788
789    #[test]
790    fn a_signature_from_a_trusted_key_is_attested() {
791        let frame = frame("frm_1");
792        let attestation = signed(&frame, &SEED);
793        let state = store_trusting(&SEED).check(PROVIDER, &frame, &attestation);
794        assert_eq!(
795            state,
796            AttestationState::Attested {
797                key_id: KEY_ID.to_string(),
798                attester_id: "docs-provider".to_string(),
799                covers_content: true,
800            }
801        );
802        assert!(state.is_attested());
803        assert!(state.covers_content());
804    }
805
806    #[test]
807    fn a_signed_frame_with_no_content_digest_is_attested_over_nothing_it_says() {
808        // The honest reading of §6.5.2: the commitment covers
809        // `(provider_id, frame_id, content_digest)` and the provenance chain,
810        // and `content_digest` is optional. Rewriting the *content* of a frame
811        // that declares none leaves the signature verifying, because the bytes
812        // were never in the preimage. The state says so rather than letting a
813        // reader assume the words were signed.
814        let original = digestless_frame("frm_1");
815        let attestation = signed(&original, &SEED);
816        let store = store_trusting(&SEED);
817
818        let state = store.check(PROVIDER, &original, &attestation);
819        assert!(state.is_attested());
820        assert!(
821            !state.covers_content(),
822            "a frame with no content_digest has no signed bytes"
823        );
824
825        let mut rewritten = original.clone();
826        rewritten.content = Some("words the provider never signed".to_string());
827        assert_eq!(
828            store.check(PROVIDER, &rewritten, &attestation),
829            state,
830            "the same signature still verifies over the rewritten content"
831        );
832    }
833
834    #[test]
835    fn an_unknown_key_id_is_a_configuration_gap_not_a_forgery_finding() {
836        // The store holds the *right* key under a *different* id. Nothing about
837        // the signature is known, and the state must not imply otherwise.
838        let frame = frame("frm_1");
839        let attestation = signed(&frame, &SEED);
840        let mut store = TrustStore::new();
841        store.trust(
842            PROVIDER,
843            TrustedKey::ed25519_bytes("some-other-key", &public_key_for(&SEED)),
844        );
845        assert_eq!(
846            store.check(PROVIDER, &frame, &attestation),
847            AttestationState::NoTrustedKey {
848                key_id: KEY_ID.to_string()
849            }
850        );
851    }
852
853    #[test]
854    fn a_key_trusted_for_another_provider_does_not_carry_over() {
855        // Trust is keyed by (provider_id, key_id): the same key trusted for a
856        // sibling provider must not attest this one's frames.
857        let frame = frame("frm_1");
858        let attestation = signed(&frame, &SEED);
859        let mut store = TrustStore::new();
860        store.trust(
861            "some-other-provider",
862            TrustedKey::ed25519_bytes(KEY_ID, &public_key_for(&SEED)),
863        );
864        assert_eq!(
865            store.check(PROVIDER, &frame, &attestation),
866            AttestationState::NoTrustedKey {
867                key_id: KEY_ID.to_string()
868            }
869        );
870    }
871
872    #[test]
873    fn a_signature_from_the_wrong_key_is_a_bad_signature() {
874        // Signed by OTHER_SEED, checked against SEED under the same key_id —
875        // the shape of a forgery or a swapped provider.
876        let frame = frame("frm_1");
877        let attestation = signed(&frame, &OTHER_SEED);
878        assert_eq!(
879            store_trusting(&SEED).check(PROVIDER, &frame, &attestation),
880            AttestationState::Invalid {
881                verdict: AttestationVerdict::BadSignature
882            }
883        );
884    }
885
886    #[test]
887    fn a_frame_altered_after_signing_is_a_commitment_mismatch() {
888        // Truncating the provenance chain — dropping the link that would reveal
889        // a frame was summarized rather than quoted — is the attack the chain
890        // construction exists to catch (ADR 0010 §1).
891        let original = frame("frm_1");
892        let attestation = signed(&original, &SEED);
893        let mut altered = original.clone();
894        altered.provenance.clear();
895        match store_trusting(&SEED).check(PROVIDER, &altered, &attestation) {
896            AttestationState::Invalid {
897                verdict: AttestationVerdict::CommitmentMismatch { expected, signed },
898            } => {
899                assert_ne!(expected, signed, "the two commitments must differ");
900                assert_eq!(signed, attestation.signed_commitment);
901            }
902            other => panic!("expected a CommitmentMismatch, got {other:?}"),
903        }
904    }
905
906    #[test]
907    fn a_malformed_signature_is_named_malformed_not_forged() {
908        let frame = frame("frm_1");
909        let mut attestation = signed(&frame, &SEED);
910        attestation.signature = "not hex, and not 128 characters either".into();
911        assert_eq!(
912            store_trusting(&SEED).check(PROVIDER, &frame, &attestation),
913            AttestationState::Invalid {
914                verdict: AttestationVerdict::MalformedSignature
915            }
916        );
917    }
918
919    #[test]
920    fn a_malformed_commitment_is_named_before_the_signature_is_touched() {
921        let frame = frame("frm_1");
922        let mut attestation = signed(&frame, &SEED);
923        attestation.signed_commitment = "sha256:nope".into();
924        assert_eq!(
925            store_trusting(&SEED).check(PROVIDER, &frame, &attestation),
926            AttestationState::Invalid {
927                verdict: AttestationVerdict::MalformedCommitment
928            }
929        );
930    }
931
932    #[test]
933    fn an_unrecognised_algorithm_is_uncheckable_not_invalid() {
934        // F8: "I cannot check this" is never "this is forged", and holding a key
935        // does not change that.
936        let frame = frame("frm_1");
937        let mut attestation = signed(&frame, &SEED);
938        attestation.algorithm = "dilithium3".into();
939        assert_eq!(
940            store_trusting(&SEED).check(PROVIDER, &frame, &attestation),
941            AttestationState::UnknownAlgorithm {
942                algorithm: "dilithium3".to_string()
943            }
944        );
945    }
946
947    #[test]
948    fn an_oversized_identifier_cannot_grow_the_audit_record_without_bound() {
949        // Attacker-controlled strings are echoed into the audit; the echo is
950        // capped so a megabyte key_id costs a bounded record.
951        let frame = frame("frm_1");
952        let mut attestation = signed(&frame, &SEED);
953        attestation.key_id = "k".repeat(1_000_000);
954        match store_trusting(&SEED).check(PROVIDER, &frame, &attestation) {
955            AttestationState::NoTrustedKey { key_id } => {
956                assert_eq!(key_id.len(), MAX_ECHOED_IDENTIFIER);
957            }
958            other => panic!("expected NoTrustedKey, got {other:?}"),
959        }
960
961        let mut oversized_algorithm = signed(&frame, &SEED);
962        oversized_algorithm.algorithm = "å".repeat(1_000);
963        match store_trusting(&SEED).check(PROVIDER, &frame, &oversized_algorithm) {
964            AttestationState::UnknownAlgorithm { algorithm } => {
965                assert!(algorithm.len() <= MAX_ECHOED_IDENTIFIER);
966                // Cut at a char boundary: the echo is still a valid string.
967                assert!(algorithm.chars().all(|c| c == 'å'));
968            }
969            other => panic!("expected UnknownAlgorithm, got {other:?}"),
970        }
971    }
972
973    #[test]
974    fn an_enormous_signature_is_rejected_on_its_length_before_any_decoding() {
975        // The bound that matters on a fan-out: a ten-megabyte signature must
976        // cost a length comparison, not a ten-megabyte hex decode.
977        let frame = frame("frm_1");
978        let mut attestation = signed(&frame, &SEED);
979        attestation.signature = "ab".repeat(5_000_000);
980        assert_eq!(
981            store_trusting(&SEED).check(PROVIDER, &frame, &attestation),
982            AttestationState::Invalid {
983                verdict: AttestationVerdict::MalformedSignature
984            }
985        );
986    }
987
988    #[test]
989    fn a_result_reports_a_state_for_every_frame_including_the_unsigned_ones() {
990        let signed_frame = frame("frm_signed");
991        let bare_frame = frame("frm_bare");
992        let attestation = signed(&signed_frame, &SEED);
993        let result = ContextQueryResult {
994            frame_attestations: vec![FrameAttestation::signed(
995                signed_frame.identity(PROVIDER),
996                attestation,
997            )],
998            ..ContextQueryResult::unattested(
999                vec![signed_frame.clone(), bare_frame.clone()],
1000                false,
1001                None,
1002            )
1003        };
1004
1005        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1006
1007        assert_eq!(outcomes.len(), 2, "one outcome per frame, always");
1008        assert_eq!(outcomes[0].frame, signed_frame.identity(PROVIDER));
1009        assert!(outcomes[0].state.is_attested());
1010        assert_eq!(outcomes[1].frame, bare_frame.identity(PROVIDER));
1011        assert_eq!(outcomes[1].state, AttestationState::Unattested);
1012    }
1013
1014    /// **The result-level match survives the same split the single-attestation
1015    /// check does.** A provider builds `FrameAttestation.frame` from its own
1016    /// declared name — it has no other identity to build it from — so an
1017    /// operator whose local config id differs from that name must still see
1018    /// the evidence matched, not silently dropped to `Unattested`. This is
1019    /// `check_result_signed_as`'s half of #161's fix; the single-attestation
1020    /// half is
1021    /// `a_local_id_that_differs_from_the_declared_name_still_verifies` above.
1022    #[test]
1023    fn a_result_matches_evidence_built_under_the_declared_name_even_when_the_local_id_differs() {
1024        const DECLARED: &str = "acme-docs";
1025        const LOCAL: &str = "docs-1";
1026        let served = frame("frm_1");
1027        let attestation = sign_frame_attestation(
1028            DECLARED,
1029            &served,
1030            &SEED,
1031            KEY_ID,
1032            DECLARED,
1033            "2026-08-29T00:00:00Z",
1034        );
1035        // The wire identity is built from `DECLARED`, exactly as the reference
1036        // provider builds it — from its own `attestation_provider_id()`, which
1037        // is the only identity it has for itself.
1038        let result = ContextQueryResult {
1039            frame_attestations: vec![FrameAttestation::signed(
1040                served.identity(DECLARED),
1041                attestation,
1042            )],
1043            ..ContextQueryResult::unattested(vec![served.clone()], false, None)
1044        };
1045
1046        let mut store = TrustStore::new();
1047        store.trust(
1048            LOCAL,
1049            TrustedKey::ed25519_bytes(KEY_ID, &public_key_for(&SEED)),
1050        );
1051
1052        let outcomes = store.check_result_signed_as(LOCAL, DECLARED, &result);
1053        assert_eq!(outcomes.len(), 1);
1054        assert!(
1055            outcomes[0].state.is_attested(),
1056            "matching must key on the declared name the provider actually used \
1057             on the wire, got {:?}",
1058            outcomes[0].state
1059        );
1060        assert_eq!(
1061            outcomes[0].frame,
1062            served.identity(LOCAL),
1063            "the returned FrameId is keyed by the operator's local id, matching \
1064             the rest of the composition path"
1065        );
1066    }
1067
1068    #[test]
1069    fn an_attestation_naming_a_frame_that_is_not_in_the_result_is_ignored() {
1070        let served = frame("frm_served");
1071        let elsewhere = frame("frm_elsewhere");
1072        let result = ContextQueryResult {
1073            frame_attestations: vec![FrameAttestation::signed(
1074                elsewhere.identity(PROVIDER),
1075                signed(&elsewhere, &SEED),
1076            )],
1077            ..ContextQueryResult::unattested(vec![served.clone()], false, None)
1078        };
1079        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1080        assert_eq!(outcomes.len(), 1);
1081        assert_eq!(outcomes[0].state, AttestationState::Unattested);
1082    }
1083
1084    #[test]
1085    fn an_entry_matching_the_frame_id_but_not_the_digest_does_not_attest_it() {
1086        // #161: matching is on the whole identity triple. Two frames can share
1087        // an id and differ in bytes, and lending the first one's signature to
1088        // the second is the substitution the binding exists to prevent.
1089        let served = frame("frm_1");
1090        let mut impostor_identity = served.identity(PROVIDER);
1091        impostor_identity.content_digest = Some(format!("sha256:{}", "ee".repeat(32)));
1092        let result = ContextQueryResult {
1093            frame_attestations: vec![FrameAttestation::signed(
1094                impostor_identity,
1095                signed(&served, &SEED),
1096            )],
1097            ..ContextQueryResult::unattested(vec![served.clone()], false, None)
1098        };
1099        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1100        assert_eq!(outcomes[0].state, AttestationState::Unattested);
1101    }
1102
1103    #[test]
1104    fn at_most_one_attestation_per_frame_is_examined() {
1105        // A provider flooding duplicates for one frame buys one verification,
1106        // and the frames are still served either way.
1107        let one = frame("frm_1");
1108        let identity = one.identity(PROVIDER);
1109        let good = signed(&one, &SEED);
1110        let bad = signed(&one, &OTHER_SEED);
1111        // First entry wins, so the leading good one decides.
1112        let result = ContextQueryResult {
1113            frame_attestations: vec![
1114                FrameAttestation::signed(identity.clone(), good),
1115                FrameAttestation::signed(identity.clone(), bad.clone()),
1116            ],
1117            ..ContextQueryResult::unattested(vec![one.clone()], false, None)
1118        };
1119        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1120        assert!(outcomes[0].state.is_attested());
1121
1122        // And the scan is capped at frames.len(), so a leading junk entry is
1123        // what a provider that over-sends gets judged on — a degradation that
1124        // falls on that provider, never a dropped frame.
1125        let mut unrelated = identity.clone();
1126        unrelated.frame_id = "frm_unrelated".into();
1127        let result = ContextQueryResult {
1128            frame_attestations: vec![
1129                FrameAttestation::signed(unrelated, bad),
1130                FrameAttestation::signed(identity, signed(&one, &SEED)),
1131            ],
1132            ..ContextQueryResult::unattested(vec![one.clone()], false, None)
1133        };
1134        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1135        assert_eq!(outcomes.len(), 1);
1136        assert_eq!(outcomes[0].state, AttestationState::Unattested);
1137    }
1138
1139    /// Sign the Merkle root over `frames` and hand back a result whose every
1140    /// frame is attested *only* through an inclusion proof — the cheap shape:
1141    /// one signature for the whole answer, no per-frame signature at all.
1142    fn root_signed_result(frames: Vec<ContextFrame>, seed: &[u8; 32]) -> ContextQueryResult {
1143        use contextgraph_types::{inclusion_proof, result_set_commitments, result_set_root};
1144
1145        // Canonical order is by `FrameId`, not the order the frames were
1146        // served in, so the leaf index comes from the commitment list.
1147        let ordered = result_set_commitments(PROVIDER, &frames);
1148        let commitments: Vec<[u8; 32]> = ordered.iter().map(|(_, c)| *c).collect();
1149        let root = result_set_root(PROVIDER, &frames);
1150        let entries = ordered
1151            .iter()
1152            .enumerate()
1153            .map(|(index, (id, _))| {
1154                FrameAttestation::proven(
1155                    id.clone(),
1156                    inclusion_proof(&commitments, index).expect("index is in range"),
1157                )
1158            })
1159            .collect();
1160        ContextQueryResult {
1161            frame_attestations: entries,
1162            result_attestation: Some(sign_commitment(
1163                &root,
1164                seed,
1165                KEY_ID,
1166                "docs-provider",
1167                "2026-08-29T00:00:00Z",
1168            )),
1169            ..ContextQueryResult::unattested(frames, false, None)
1170        }
1171    }
1172
1173    #[test]
1174    fn a_frame_attested_only_through_an_inclusion_proof_is_attested() {
1175        // #161: the canonical `FrameAttestation` makes `attestation` optional
1176        // so one root signature can stand for n frames. A host that only knew
1177        // how to check per-frame signatures would call every one of these
1178        // unattested, which would make the cheapest honest shape the one
1179        // nothing can verify.
1180        let frames = vec![frame("frm_1"), frame("frm_2"), frame("frm_3")];
1181        let result = root_signed_result(frames.clone(), &SEED);
1182
1183        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1184
1185        assert_eq!(outcomes.len(), 3);
1186        for (outcome, frame) in outcomes.iter().zip(&frames) {
1187            assert_eq!(outcome.frame, frame.identity(PROVIDER));
1188            assert_eq!(
1189                outcome.state,
1190                AttestationState::Attested {
1191                    key_id: KEY_ID.to_string(),
1192                    attester_id: "docs-provider".to_string(),
1193                    covers_content: true,
1194                },
1195                "every leaf of the signed root is attested by the one signature"
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn a_proof_of_a_root_this_host_holds_no_key_for_is_a_configuration_gap() {
1202        let frames = vec![frame("frm_1"), frame("frm_2")];
1203        let result = root_signed_result(frames, &SEED);
1204        let outcomes = TrustStore::new().check_result(PROVIDER, &result);
1205        assert_eq!(
1206            outcomes[0].state,
1207            AttestationState::NoTrustedKey {
1208                key_id: KEY_ID.to_string()
1209            },
1210            "no key means nothing was checked, never that something failed"
1211        );
1212    }
1213
1214    #[test]
1215    fn a_proof_that_recomputes_a_different_root_is_a_commitment_mismatch() {
1216        // The loud case: a frame edited after the root was signed. The frame's
1217        // *identity* is unchanged — provenance is not part of it — so the entry
1218        // still names this frame, and the leaf it recomputes is a different one.
1219        let frames = vec![frame("frm_1"), frame("frm_2")];
1220        let mut result = root_signed_result(frames, &SEED);
1221        result.frames[0].provenance.clear();
1222
1223        match store_trusting(&SEED).check_result(PROVIDER, &result)[0].state {
1224            AttestationState::Invalid {
1225                verdict: AttestationVerdict::CommitmentMismatch { .. },
1226            } => {}
1227            ref other => panic!("expected a CommitmentMismatch, got {other:?}"),
1228        }
1229    }
1230
1231    #[test]
1232    fn an_inclusion_proof_with_no_signed_root_is_unusable_not_unattested() {
1233        // F9 treats it as unattested for every decision, but the state is named
1234        // so an audit can tell "the provider signs nothing" from "the provider
1235        // sent evidence that does not resolve".
1236        let frames = vec![frame("frm_1"), frame("frm_2")];
1237        let mut result = root_signed_result(frames, &SEED);
1238        result.result_attestation = None;
1239
1240        let outcomes = store_trusting(&SEED).check_result(PROVIDER, &result);
1241
1242        assert_eq!(outcomes[0].state, AttestationState::UnusableEvidence);
1243        assert!(!outcomes[0].state.is_attested());
1244        assert!(
1245            outcomes[0].state.was_offered(),
1246            "something was offered; it just could not be checked"
1247        );
1248    }
1249
1250    #[test]
1251    fn an_entry_carrying_neither_a_signature_nor_a_proof_asserts_nothing() {
1252        let one = frame("frm_1");
1253        let mut entry = FrameAttestation::signed(one.identity(PROVIDER), signed(&one, &SEED));
1254        entry.attestation = None;
1255        let result = ContextQueryResult {
1256            frame_attestations: vec![entry.clone()],
1257            ..ContextQueryResult::unattested(vec![one.clone()], false, None)
1258        };
1259        assert!(!entry.carries_evidence());
1260        assert_eq!(
1261            store_trusting(&SEED).check_result(PROVIDER, &result)[0].state,
1262            AttestationState::UnusableEvidence
1263        );
1264    }
1265
1266    #[test]
1267    fn a_per_frame_signature_is_preferred_over_a_proof_on_the_same_entry() {
1268        // Both shapes on one entry: check the narrower claim, which binds this
1269        // frame directly with no tree in between, and costs one verification
1270        // rather than a walk plus one.
1271        let frames = vec![frame("frm_1"), frame("frm_2")];
1272        let mut result = root_signed_result(frames.clone(), &SEED);
1273        // A per-frame signature from a key nobody trusts. If the proof were
1274        // preferred the frame would read as attested; the signature must decide.
1275        result.frame_attestations[0].attestation = Some(signed(&frames[0], &OTHER_SEED));
1276
1277        assert_eq!(
1278            store_trusting(&SEED).check_result(PROVIDER, &result)[0].state,
1279            AttestationState::Invalid {
1280                verdict: AttestationVerdict::BadSignature
1281            }
1282        );
1283    }
1284
1285    #[test]
1286    fn an_unbounded_inclusion_path_is_rejected_on_its_length() {
1287        // Every step of the path costs a hash and the path comes from the
1288        // provider. `MAX_INCLUSION_PATH_STEPS` caps the walk before it starts.
1289        use contextgraph_types::{InclusionStep, MAX_INCLUSION_PATH_STEPS};
1290
1291        let one = frame("frm_1");
1292        let mut result = root_signed_result(vec![one.clone()], &SEED);
1293        result.frame_attestations[0].inclusion_proof = Some(InclusionProof {
1294            leaf_index: 0,
1295            leaf_count: usize::MAX,
1296            path: vec![
1297                InclusionStep {
1298                    sibling: format!("sha256:{}", "11".repeat(32)),
1299                    sibling_is_left: false,
1300                };
1301                MAX_INCLUSION_PATH_STEPS + 1
1302            ],
1303        });
1304
1305        assert_eq!(
1306            store_trusting(&SEED).check_result(PROVIDER, &result)[0].state,
1307            AttestationState::Invalid {
1308                verdict: AttestationVerdict::MalformedCommitment
1309            }
1310        );
1311    }
1312
1313    #[test]
1314    fn an_empty_store_checks_nothing_and_rejects_nothing() {
1315        let frame = frame("frm_1");
1316        let attestation = signed(&frame, &SEED);
1317        let store = TrustStore::new();
1318        assert!(store.is_empty());
1319        assert_eq!(
1320            store.check(PROVIDER, &frame, &attestation),
1321            AttestationState::NoTrustedKey {
1322                key_id: KEY_ID.to_string()
1323            }
1324        );
1325    }
1326
1327    #[test]
1328    fn revoking_a_key_stops_it_attesting_and_reports_whether_it_removed_one() {
1329        let frame = frame("frm_1");
1330        let attestation = signed(&frame, &SEED);
1331        let mut store = store_trusting(&SEED);
1332        assert!(store.check(PROVIDER, &frame, &attestation).is_attested());
1333        assert!(store.revoke(PROVIDER, KEY_ID));
1334        assert!(!store.revoke(PROVIDER, KEY_ID), "already gone");
1335        assert!(store.is_empty());
1336        assert!(!store.check(PROVIDER, &frame, &attestation).is_attested());
1337    }
1338
1339    #[test]
1340    fn a_key_whose_hex_does_not_decode_is_refused_at_the_door() {
1341        assert!(TrustedKey::ed25519_hex("k", "not hex").is_none());
1342        assert!(
1343            TrustedKey::ed25519_hex("k", "ab".repeat(16)).is_none(),
1344            "too short"
1345        );
1346        let valid = encode_hex(&public_key_for(&SEED));
1347        assert!(TrustedKey::ed25519_hex("k", &valid).is_some());
1348    }
1349
1350    #[test]
1351    fn a_stored_key_that_does_not_decode_is_an_operator_bug_named_as_one() {
1352        // Constructed around `ed25519_hex`'s guard, as a hand-edited persisted
1353        // store could be.
1354        let frame = frame("frm_1");
1355        let attestation = signed(&frame, &SEED);
1356        let mut store = TrustStore::new();
1357        store.trust(
1358            PROVIDER,
1359            TrustedKey {
1360                key_id: KEY_ID.into(),
1361                public_key: "zz".repeat(32),
1362            },
1363        );
1364        assert_eq!(
1365            store.check(PROVIDER, &frame, &attestation),
1366            AttestationState::Invalid {
1367                verdict: AttestationVerdict::MalformedKey
1368            }
1369        );
1370    }
1371
1372    #[test]
1373    fn a_fingerprint_is_over_the_key_bytes_and_survives_a_round_trip() {
1374        let key = TrustedKey::ed25519_bytes(KEY_ID, &public_key_for(&SEED));
1375        let fingerprint = key.fingerprint().expect("a well-formed key has one");
1376        assert!(fingerprint.starts_with("sha256:"));
1377        assert_eq!(fingerprint.len(), COMMITMENT_LEN);
1378        // The same key spelled in uppercase hex is the same key.
1379        let shouty = TrustedKey {
1380            key_id: KEY_ID.into(),
1381            public_key: key.public_key.to_uppercase(),
1382        };
1383        assert_eq!(shouty.fingerprint(), Some(fingerprint));
1384    }
1385
1386    #[test]
1387    fn the_store_round_trips_through_serde_so_a_host_can_persist_it() {
1388        let store = store_trusting(&SEED);
1389        let json = serde_json::to_string(&store).expect("serializable");
1390        let back: TrustStore = serde_json::from_str(&json).expect("deserializable");
1391        assert_eq!(store, back);
1392    }
1393
1394    #[test]
1395    fn an_empty_ledger_says_not_checked_rather_than_unattested() {
1396        let ledger = AttestationLedger::new();
1397        let id = frame("frm_1").identity(PROVIDER);
1398        assert!(ledger.is_empty());
1399        assert_eq!(ledger.state_for(&id), AttestationState::NotChecked);
1400        assert!(!ledger.state_for(&id).was_offered());
1401    }
1402
1403    #[test]
1404    fn a_ledger_collects_outcomes_and_answers_by_identity() {
1405        let one = frame("frm_1");
1406        let two = frame("frm_2");
1407        let ledger: AttestationLedger = vec![
1408            FrameAttestationOutcome {
1409                frame: one.identity(PROVIDER),
1410                state: AttestationState::Attested {
1411                    key_id: KEY_ID.into(),
1412                    attester_id: "docs-provider".into(),
1413                    covers_content: true,
1414                },
1415            },
1416            FrameAttestationOutcome {
1417                frame: two.identity(PROVIDER),
1418                state: AttestationState::Unattested,
1419            },
1420        ]
1421        .into_iter()
1422        .collect();
1423
1424        assert_eq!(ledger.len(), 2);
1425        assert!(ledger.state_for(&one.identity(PROVIDER)).is_attested());
1426        assert_eq!(
1427            ledger.state_for(&two.identity(PROVIDER)),
1428            AttestationState::Unattested
1429        );
1430        // A frame from a different provider is a different identity.
1431        assert_eq!(
1432            ledger.state_for(&one.identity("elsewhere")),
1433            AttestationState::NotChecked
1434        );
1435    }
1436
1437    /// **An operator's choice of local name must not read as tampering.**
1438    ///
1439    /// The provider signs over its handshake-declared `provider.name` (§6.5.2 —
1440    /// the only provider id it can possibly know). The operator registered it in
1441    /// their host config under a different local id, and trusted its key there.
1442    /// Recomputing the commitment with the *local* id yields a different
1443    /// preimage and so `CommitmentMismatch` — the verdict §6.5.4 reserves for a
1444    /// frame altered after signing. Every test in this module used one string
1445    /// for both roles, so nothing distinguished them until now.
1446    #[test]
1447    fn a_local_id_that_differs_from_the_declared_name_still_verifies() {
1448        const DECLARED: &str = "acme-docs";
1449        const LOCAL: &str = "docs-1";
1450        let frame = frame("frm");
1451        let attestation = sign_frame_attestation(
1452            DECLARED,
1453            &frame,
1454            &SEED,
1455            KEY_ID,
1456            DECLARED,
1457            "2026-08-29T00:00:00Z",
1458        );
1459
1460        // The operator trusts the key under the id they configured, which is the
1461        // id they also granted consent under.
1462        let mut store = TrustStore::new();
1463        store.trust(
1464            LOCAL,
1465            TrustedKey::ed25519_bytes(KEY_ID, &public_key_for(&SEED)),
1466        );
1467
1468        let state = store.check_signed_as(LOCAL, DECLARED, &frame, &attestation);
1469        assert!(
1470            state.is_attested(),
1471            "an honest signature must verify regardless of what the operator \
1472             named the provider locally, got {state:?}"
1473        );
1474        assert!(
1475            state.covers_content(),
1476            "the frame declares a content_digest"
1477        );
1478
1479        // The trust lookup still keys on the local id: a provider cannot reach a
1480        // key by *declaring* the name it was trusted under.
1481        let impostor = store.check_signed_as(DECLARED, DECLARED, &frame, &attestation);
1482        assert!(
1483            matches!(impostor, AttestationState::NoTrustedKey { .. }),
1484            "no key is trusted under the declared name, got {impostor:?}"
1485        );
1486    }
1487}