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}