contextgraph_types/attest.rs
1//! Provenance attestation — turning "we have a trace" into "we have evidence"
2//! (`SPEC.md` §6.5, [ADR 0010](../../docs/adr/0010-provenance-attestation.md)).
3//!
4//! A [`Provenance`] link carries a `digest`. A digest is **tamper-evident only
5//! to a party that already trusts whoever recorded it**: it proves the bytes
6//! did not change *since someone wrote that number down*, and says nothing
7//! about who wrote it or whether they were entitled to. For a host reading its
8//! own cache that is enough. For the auditor asking "prove this citation is
9//! what the provider actually served," it is not — the digest and the frame it
10//! describes were produced by the same unauthenticated party, so a provider
11//! that fabricates a frame simply fabricates a matching digest.
12//!
13//! A signature closes that gap, and it is the only thing that does. This module
14//! defines the three constructions that make a frame's provenance verifiable
15//! **offline**, by a third party, with no network and no trust in the host that
16//! stored it:
17//!
18//! 1. A **provenance chain hash** ([`provenance_chain_head`]) — a hash chain
19//! over a frame's ordered [`Provenance`] links, folded source-first, so no
20//! link can be inserted, removed, reordered, or edited without changing the
21//! head.
22//! 2. A **frame commitment** ([`frame_commitment`]) — the chain head bound to
23//! the frame's full [`FrameId`] identity.
24//! 3. A **Merkle root** ([`merkle_root`]) over a whole result set, with
25//! [`InclusionProof`]s, so one frame can be proven a member of a signed
26//! answer without disclosing its siblings.
27//!
28//! [`ProvenanceAttestation`] is the detached Ed25519 signature over (1)–(3).
29//! It reaches a host on the `frames` envelope, in
30//! [`ContextQueryResult::frame_attestations`](crate::ContextQueryResult::frame_attestations)
31//! and
32//! [`ContextQueryResult::result_attestation`](crate::ContextQueryResult::result_attestation)
33//! — *beside* the frames, never inside one (`SPEC.md` §6.5.5, F6/F11).
34//!
35//! # Why the frame identity is inside the signed preimage
36//!
37//! Signing a bare chain head would be a forgery primitive, not a defense. Two
38//! frames citing the same source share a chain head, so a signature over the
39//! head alone can be lifted from an innocuous frame and stapled onto a
40//! fabricated one: the signature verifies, the evidence is invented. The signed
41//! preimage therefore commits to `(provider_id, frame_id, content_digest)` —
42//! the whole [`FrameId`] triple — *and* the chain head. A signature binds to one
43//! frame served by one provider, or it binds to nothing.
44//!
45//! # Why the encoding is length-prefixed rather than canonical JSON
46//!
47//! The lifecycle profile's `record_hash` canonicalizes with RFC 8785 (JCS),
48//! which is the right choice there: a record's hash covers a whole open-ended
49//! JSON document. A provenance chain is a fixed list of six optional strings,
50//! and for that shape JCS is a liability — it makes every implementation depend
51//! on a conforming JSON canonicalizer, whose number formatting and Unicode
52//! escaping rules are exactly where cross-language implementations silently
53//! disagree.
54//!
55//! This module encodes the typed fields directly, each length-prefixed
56//! (`SPEC.md` §6.5.1). Length prefixing is not decoration:
57//! naive concatenation is ambiguous, and a chain with `uri: "ab", range: "c"`
58//! would otherwise hash identically to one with `uri: "a", range: "bc"` — a
59//! collision an adversary chooses, not one they have to find. A four-byte
60//! big-endian length in front of every field makes the encoding injective, and
61//! any language can produce it from the typed fields with no library at all.
62//!
63//! # Cryptography is optional; the preimage rule is not
64//!
65//! Hashing and signature verification live behind the off-by-default
66//! `attestation` feature, so `contextgraph-types` keeps its "zero dependencies
67//! beyond serde" promise for the pure wire consumer. [`ProvenanceAttestation`]
68//! itself is a **wire type and always compiles** — a host must be able to parse,
69//! relay, and store an attestation it has not been built to check, exactly as it
70//! relays a frame kind it does not recognize.
71//!
72//! The protocol defines the *preimage*; it does not define your signing
73//! backend. [`frame_commitment`] and [`merkle_root`] are public so a provider
74//! holding keys in an HSM, a KMS, or a hardware token signs the bytes itself
75//! and never hands this crate a secret. [`sign_frame_attestation`] exists for
76//! providers and tests that are content to sign in-process.
77
78use serde::{Deserialize, Serialize};
79
80use crate::frame::Provenance;
81use crate::identity::FrameId;
82
83/// The signature algorithm this revision defines. `algorithm` is a string, not
84/// an enum, precisely so a post-quantum successor is an additive change rather
85/// than a new major family — see [`ProvenanceAttestation::algorithm`].
86pub const ALGORITHM_ED25519: &str = "ed25519";
87
88/// The domain-separation tags and Merkle prefixes the hashing rules use
89/// (`SPEC.md` §6.5.1). Only referenced by the gated hashing code, but normative:
90/// a reimplementation in another language must use these exact byte strings or
91/// it will compute different commitments and interoperate with nothing.
92#[cfg(feature = "attestation")]
93mod domain {
94 /// Domain-separation tag for the hash-chain genesis.
95 pub(super) const GENESIS: &[u8] = b"contextgraph/attest/1/genesis";
96 /// Domain-separation tag for one provenance link.
97 pub(super) const LINK: &[u8] = b"contextgraph/attest/1/link";
98 /// Domain-separation tag for a frame commitment.
99 pub(super) const FRAME: &[u8] = b"contextgraph/attest/1/frame";
100 /// Domain-separation tag for an empty Merkle tree.
101 pub(super) const MERKLE_EMPTY: &[u8] = b"contextgraph/attest/1/merkle-empty";
102 /// RFC 6962 leaf prefix. Distinct from [`MERKLE_NODE`] so a leaf hash can
103 /// never be reinterpreted as an interior node — the second-preimage defense
104 /// that makes a Merkle proof mean what it claims.
105 pub(super) const MERKLE_LEAF: &[u8] = &[0x00];
106 /// RFC 6962 interior-node prefix.
107 pub(super) const MERKLE_NODE: &[u8] = &[0x01];
108}
109
110/// A detached attestation binding one frame's provenance to a signing identity
111/// (`SPEC.md` §6.5).
112///
113/// **Detached, always.** Like the lifecycle profile's
114/// [`RecordAttestation`](crate::RecordAttestation), this never travels inside
115/// the preimage it signs. Re-signing after a key rotation, or a second attester
116/// countersigning the same frame, must not perturb the frame's content-addressed
117/// identity — and it cannot, because the attestation is metadata beside the
118/// frame rather than a field within it.
119///
120/// It is a **distinct type** from `RecordAttestation` even though five of six
121/// fields match. The two sign different preimages under different domain tags,
122/// and a shared type would invite the one mistake the domain separation exists
123/// to prevent: presenting a record attestation as a frame attestation. The
124/// cryptography already refuses that; the type system should make it unsayable.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct ProvenanceAttestation {
127 /// The `sha256:<hex>` commitment this attestation signs — a
128 /// [`frame_commitment`] for a single frame, or a [`merkle_root`] for a
129 /// result set.
130 pub signed_commitment: String,
131 /// The signing key's id. Rotation is expressed by a new `key_id`, never by
132 /// reusing one, so an archived attestation always names the exact key that
133 /// produced it.
134 pub key_id: String,
135 /// The signature scheme, e.g. [`ALGORITHM_ED25519`].
136 ///
137 /// A string rather than an enum: a verifier that does not recognize the
138 /// value returns [`AttestationVerdict::UnknownAlgorithm`] and declines,
139 /// which is a *safe* failure. Freezing the set into an enum would make
140 /// adopting a post-quantum scheme a breaking wire change, and this protocol
141 /// promises no flag day inside a major family.
142 pub algorithm: String,
143 /// The attesting authority — who is accountable for the claim, as distinct
144 /// from which key mechanically produced it.
145 pub attester_id: String,
146 /// The detached signature, lowercase hex.
147 ///
148 /// Hex rather than base64 to match the `sha256:<hex>` convention every other
149 /// digest in this protocol already uses; one encoding across the wire
150 /// surface is worth more than the 40 bytes base64 would save.
151 pub signature: String,
152 /// When the attestation was issued (a `SPEC.md` §F4 protocol timestamp).
153 pub issued_at: String,
154}
155
156impl ProvenanceAttestation {
157 /// Build an attestation from its parts.
158 pub fn new(
159 signed_commitment: impl Into<String>,
160 key_id: impl Into<String>,
161 algorithm: impl Into<String>,
162 attester_id: impl Into<String>,
163 signature: impl Into<String>,
164 issued_at: impl Into<String>,
165 ) -> Self {
166 Self {
167 signed_commitment: signed_commitment.into(),
168 key_id: key_id.into(),
169 algorithm: algorithm.into(),
170 attester_id: attester_id.into(),
171 signature: signature.into(),
172 issued_at: issued_at.into(),
173 }
174 }
175
176 /// Whether this attestation names a scheme this revision defines.
177 ///
178 /// Advisory: a verifier reports [`AttestationVerdict::UnknownAlgorithm`]
179 /// rather than treating an unrecognized scheme as a failure to *validate*.
180 /// The distinction matters to an auditor — "I cannot check this" is a
181 /// different finding from "this is forged."
182 pub fn uses_known_algorithm(&self) -> bool {
183 self.algorithm == ALGORITHM_ED25519
184 }
185
186 /// Whether `issued_at` is a well-formed protocol timestamp (`SPEC.md` §F4).
187 pub fn has_well_formed_issued_at(&self) -> bool {
188 crate::validate::is_protocol_timestamp(&self.issued_at)
189 }
190}
191
192/// One step of a Merkle [`InclusionProof`]: the sibling hash, and which side it
193/// sits on.
194///
195/// RFC 6962 lets a verifier recover the side from index arithmetic. This carries
196/// it explicitly instead. The redundancy costs one bool per step and removes an
197/// entire class of verifier bug — an off-by-one in the index recursion produces
198/// a *wrong root* rather than a silently-accepted proof, and a hand-written
199/// verifier in another language is far likelier to get a stated side right than
200/// to re-derive the split correctly.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct InclusionStep {
203 /// The sibling subtree hash, `sha256:<hex>`.
204 pub sibling: String,
205 /// Whether the sibling is the **left** operand at this level.
206 pub sibling_is_left: bool,
207}
208
209/// The longest inclusion path this crate will walk (`SPEC.md` §6.5.3).
210///
211/// A path of *n* steps describes a Merkle tree over up to 2ⁿ leaves, so 64
212/// steps covers every answer that could exist and then some. The cap is not
213/// about correctness — a wrong path yields a wrong root and fails the
214/// comparison — it is about work: each step costs a hash, the path arrives from
215/// the provider, and a verifier that walked an arbitrary one would hash for as
216/// long as a peer cared to make it.
217pub const MAX_INCLUSION_PATH_STEPS: usize = 64;
218
219/// A proof that one frame commitment is a leaf of a signed [`merkle_root`]
220/// (`SPEC.md` §6.5.3).
221///
222/// This is what makes a signed answer *selectively* disclosable. A host that
223/// served twelve frames can prove to an auditor that one specific frame was in
224/// the signed set — and prove the provider committed to it before knowing which
225/// one would be questioned — while disclosing nothing about the other eleven
226/// beyond their hashes.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct InclusionProof {
229 /// The leaf's index in canonical order.
230 pub leaf_index: usize,
231 /// How many leaves the tree held. Part of the proof because a root alone
232 /// does not pin the tree's size, and a verifier that ignores it can be shown
233 /// a proof from a differently-shaped tree.
234 pub leaf_count: usize,
235 /// Sibling hashes from the leaf upward.
236 pub path: Vec<InclusionStep>,
237}
238
239/// What a result set says about one frame's attestation — the wire carrier that
240/// keeps a [`ProvenanceAttestation`] *beside* the frame it covers
241/// (`SPEC.md` §6.5.5, F11).
242///
243/// # Why the identity is echoed in full
244///
245/// A parallel array indexed by position would be smaller and unusable as
246/// evidence: a provider that reorders, omits, or duplicates a frame would shift
247/// an attestation onto the wrong one, and a host filtering the set — which is
248/// the normal case — would have to re-derive the mapping from an order nobody
249/// wrote down. Carrying the whole
250/// [`FrameId`] triple makes an entry self-describing, which is the same
251/// reasoning [`FrameVerdict`](crate::FrameVerdict) already applies to
252/// `context/verify`. It is also exactly what a verifier needs: `provider_id`
253/// and `content_digest` are two of the three inputs to
254/// [`frame_commitment`], and neither is recoverable from the frame body alone.
255///
256/// # Why both members are optional
257///
258/// The cheapest honest way to sign an answer is **one** signature over the
259/// result-set Merkle root, with a per-frame inclusion proof and no per-frame
260/// signature at all. Requiring `attestation` would make that shape
261/// unrepresentable and force a provider into *n* signatures to say what one
262/// says. Requiring `inclusion_proof` would tax a provider that signs frames
263/// individually and publishes no root. An entry carrying neither is noise, and
264/// [`carries_evidence`](Self::carries_evidence) is how a host says so.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct FrameAttestation {
267 /// The frame this entry attests, named in full rather than by position.
268 pub frame: FrameId,
269 /// A detached signature over this frame's own [`frame_commitment`].
270 ///
271 /// Absent when the provider signed only the result-set root: the frame is
272 /// then attested *through* `inclusion_proof`, not on its own.
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub attestation: Option<ProvenanceAttestation>,
275 /// A proof that this frame's commitment is a leaf of the signed
276 /// `result_attestation` root (`SPEC.md` §6.5.3).
277 ///
278 /// Optional on the wire, and the reason is a host that keeps a *subset*:
279 /// once frames are dropped their sibling commitments are gone, and the root
280 /// can never be recomputed again. See
281 /// [ADR 0014](https://github.com/macanderson/context-graph-protocol/blob/main/docs/adr/0014-attestations-on-the-wire.md).
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub inclusion_proof: Option<InclusionProof>,
284}
285
286impl FrameAttestation {
287 /// A per-frame signature with no inclusion proof.
288 pub fn signed(frame: FrameId, attestation: ProvenanceAttestation) -> Self {
289 Self {
290 frame,
291 attestation: Some(attestation),
292 inclusion_proof: None,
293 }
294 }
295
296 /// Membership of a signed result set, with no per-frame signature.
297 pub fn proven(frame: FrameId, inclusion_proof: InclusionProof) -> Self {
298 Self {
299 frame,
300 attestation: None,
301 inclusion_proof: Some(inclusion_proof),
302 }
303 }
304
305 /// Whether this entry carries anything a verifier can act on.
306 ///
307 /// An entry with neither a signature nor a proof names a frame and asserts
308 /// nothing about it. A host **MUST NOT** read that as attested — it is
309 /// wire noise, and F9's "unverifiable degrades to unattested" covers it.
310 pub fn carries_evidence(&self) -> bool {
311 self.attestation.is_some() || self.inclusion_proof.is_some()
312 }
313
314 /// Attach an inclusion proof, so a per-frame signature and result-set
315 /// membership travel together.
316 pub fn with_inclusion_proof(mut self, proof: InclusionProof) -> Self {
317 self.inclusion_proof = Some(proof);
318 self
319 }
320}
321
322/// The outcome of checking a [`ProvenanceAttestation`] (`SPEC.md` §6.5.4).
323///
324/// Every failure is *named*. A boolean would collapse "this signature is
325/// forged" into "I was handed a truncated key," and those call for opposite
326/// responses: the first is an incident, the second is a configuration bug.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum AttestationVerdict {
329 /// The signature verifies against the recomputed commitment, and that
330 /// commitment binds the frame's content.
331 Valid,
332 /// The signature verifies, but over a preimage that does not bind the
333 /// frame's content: the frame declared no `content_digest`, so
334 /// [`frame_commitment`] hashed the *absence* of one (`SPEC.md` §6.5.2).
335 ///
336 /// What this does and does not prove is the whole reason the variant
337 /// exists. It proves the named provider issued a frame with this id and
338 /// this provenance chain. It does not prove the bytes served under that id
339 /// are the bytes that were signed — the provider can serve one document
340 /// today and a different one tomorrow, and this same signature keeps
341 /// verifying, because the content was never in the preimage.
342 ///
343 /// Before this variant existed, that case returned [`Valid`](Self::Valid)
344 /// and a verifier had no way to tell the two apart (#128). A host that
345 /// rendered such a frame as "signed" was making a claim the signature did
346 /// not support.
347 ///
348 /// [`is_valid`](Self::is_valid) is **false** here, so the default answer is
349 /// the safe one. A host that has its own reason to accept an identity-only
350 /// attestation must say so by matching this variant or calling
351 /// [`signature_verifies`](Self::signature_verifies) — which is the point:
352 /// the decision becomes visible in the code that makes it.
353 ///
354 /// A conformant attester does not produce this. `SPEC.md` §6.5.2 requires a
355 /// provider that signs a frame to populate `content_digest`; encountering
356 /// this verdict means the frame was signed by a non-conformant attester, or
357 /// predates that requirement.
358 ValidIdentityOnly,
359 /// The signature is well-formed and verifies, but over a *different*
360 /// commitment than this frame produces — the frame or its provenance was
361 /// altered after signing. The loudest possible finding.
362 CommitmentMismatch {
363 /// The commitment recomputed from the frame in hand.
364 expected: String,
365 /// The commitment the attestation claims to sign.
366 signed: String,
367 },
368 /// The commitment matches but the signature does not verify under the
369 /// supplied key: a forgery, or the wrong key.
370 BadSignature,
371 /// The named algorithm is not one this build can check. Not a failure to
372 /// validate — a refusal to guess.
373 UnknownAlgorithm(String),
374 /// The public key was not a well-formed key for the named algorithm.
375 MalformedKey,
376 /// The signature field was not well-formed for the named algorithm.
377 MalformedSignature,
378 /// `signed_commitment` was not a well-formed `sha256:<hex>` digest.
379 MalformedCommitment,
380}
381
382impl AttestationVerdict {
383 /// Whether this verdict is [`Valid`](Self::Valid) — the signature checks
384 /// out *and* it binds the frame's content.
385 ///
386 /// A host **MUST NOT** treat any other verdict as provisionally acceptable:
387 /// the point of an attestation is that "I could not check it" and "it is
388 /// good" are never the same answer.
389 ///
390 /// That includes [`ValidIdentityOnly`](Self::ValidIdentityOnly), which is
391 /// deliberately not valid here. Its signature does verify, but over a
392 /// preimage that says nothing about the bytes in hand, and a host asking
393 /// "is this good?" is asking about the bytes. Use
394 /// [`signature_verifies`](Self::signature_verifies) to ask the narrower
395 /// question on purpose.
396 pub fn is_valid(&self) -> bool {
397 matches!(self, Self::Valid)
398 }
399
400 /// Whether the signature itself checked out, whatever it covers.
401 ///
402 /// True for [`Valid`](Self::Valid) and
403 /// [`ValidIdentityOnly`](Self::ValidIdentityOnly). This is the question to
404 /// ask when the caller genuinely wants provider identity and provenance
405 /// without a claim about content — an audit trail of who answered, say,
406 /// rather than a check that an answer is unaltered.
407 ///
408 /// It is a separate method rather than a looser `is_valid` because the
409 /// difference between them is the whole of #128: one accepts a frame whose
410 /// content can be swapped without disturbing the signature, and the other
411 /// does not. Whichever a caller wants, it should be legible at the call
412 /// site which one they asked for.
413 pub fn signature_verifies(&self) -> bool {
414 matches!(self, Self::Valid | Self::ValidIdentityOnly)
415 }
416
417 /// Whether the verified commitment binds the frame's content bytes.
418 ///
419 /// Only [`Valid`](Self::Valid) does. A verdict that did not verify at all
420 /// binds nothing, so this is false for every failure too.
421 pub fn binds_content(&self) -> bool {
422 matches!(self, Self::Valid)
423 }
424}
425
426// ---------------------------------------------------------------------------
427// Canonical encoding (`SPEC.md` §6.5.1) — dependency-free, so the rule is
428// readable and reimplementable even in a build with `attestation` disabled.
429// ---------------------------------------------------------------------------
430
431/// Append a length-prefixed string: `u32be(len) || utf8`.
432fn enc_str(out: &mut Vec<u8>, s: &str) {
433 out.extend_from_slice(&(s.len() as u32).to_be_bytes());
434 out.extend_from_slice(s.as_bytes());
435}
436
437/// Append a length-prefixed optional string: `0x00` for absent, `0x01 ||
438/// enc_str` for present.
439///
440/// The presence byte is what keeps absent distinct from empty. Without it
441/// `uri: None` and `uri: Some("")` would encode identically, and a provider
442/// could drop a URI from a signed chain without disturbing the hash.
443fn enc_opt(out: &mut Vec<u8>, s: Option<&str>) {
444 match s {
445 None => out.push(0x00),
446 Some(s) => {
447 out.push(0x01);
448 enc_str(out, s);
449 }
450 }
451}
452
453/// The canonical encoding of one provenance link (`SPEC.md` §6.5.1).
454///
455/// Field order is fixed by the struct's declaration order and pinned by the
456/// spec — it is part of the normative rule, not an implementation detail, and
457/// changing it is a breaking wire change.
458pub fn encode_provenance_link(link: &Provenance) -> Vec<u8> {
459 let mut out = Vec::new();
460 enc_str(&mut out, &link.kind);
461 enc_opt(&mut out, link.uri.as_deref());
462 enc_opt(&mut out, link.range.as_deref());
463 enc_opt(&mut out, link.digest.as_deref());
464 enc_opt(&mut out, link.method.as_deref());
465 enc_opt(&mut out, link.by.as_deref());
466 out
467}
468
469/// Render 32 raw bytes as this protocol's `sha256:<hex>` digest string.
470pub fn digest_string(bytes: &[u8; 32]) -> String {
471 let mut s = String::with_capacity(7 + 64);
472 s.push_str("sha256:");
473 for b in bytes {
474 // Lowercase hex, two chars per byte — the form `is_well_formed_digest`
475 // accepts and every other digest in the protocol already uses.
476 s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble is < 16"));
477 s.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble is < 16"));
478 }
479 s
480}
481
482/// Parse **lowercase** hex into bytes. `None` on any byte outside `0-9a-f`, or
483/// on an odd length.
484///
485/// Lowercase-only is the protocol's grammar, not a preference. `SPEC.md` spells
486/// a digest as 64 lowercase hex characters and
487/// [`is_well_formed_digest`](crate::is_well_formed_digest) enforces exactly
488/// that — its own doctest asserts the uppercase form is rejected.
489///
490/// This function did not, and the divergence it created is the kind §6.5 exists
491/// to eliminate (#145). `to_digit(16)` accepts `A`-`F`, and both `parse_digest`
492/// and the signature branch of [`verify_commitment`] decode through here — so an
493/// attestation carrying an uppercase `signed_commitment` or signature verified
494/// against the Rust reference while every SDK port rejected it as malformed.
495/// The same bytes, read by two conforming implementations, produced opposite
496/// verdicts. An auditor's answer must not depend on which language opened the
497/// file.
498///
499/// Nothing emits uppercase — `digest_string` and `sign_commitment` both write
500/// lowercase — so this narrows what is *accepted*, never what is produced.
501#[cfg(feature = "attestation")]
502fn from_hex(s: &str) -> Option<Vec<u8>> {
503 if !s.len().is_multiple_of(2) {
504 return None;
505 }
506 let mut out = Vec::with_capacity(s.len() / 2);
507 // `as_chunks::<2>()` over `chunks_exact(2)`: the length check above already
508 // rules out a remainder, and the fixed-size chunk lets the compiler see both
509 // indexes are in bounds.
510 let (pairs, _) = s.as_bytes().as_chunks::<2>();
511 for pair in pairs {
512 let hi = lowercase_hex_digit(pair[0])?;
513 let lo = lowercase_hex_digit(pair[1])?;
514 out.push(hi * 16 + lo);
515 }
516 Some(out)
517}
518
519/// One lowercase hex digit's value, or `None` for anything else — uppercase
520/// `A`-`F` included.
521///
522/// Spelled out rather than reached through `char::to_digit(16)`, which accepts
523/// both cases and is what let the uppercase form through. Matching on the byte
524/// makes the accepted set visible at the point it is decided.
525#[cfg(feature = "attestation")]
526fn lowercase_hex_digit(byte: u8) -> Option<u8> {
527 match byte {
528 b'0'..=b'9' => Some(byte - b'0'),
529 b'a'..=b'f' => Some(byte - b'a' + 10),
530 _ => None,
531 }
532}
533
534/// Parse a `sha256:<hex>` digest string into its 32 raw bytes.
535#[cfg(feature = "attestation")]
536fn parse_digest(digest: &str) -> Option<[u8; 32]> {
537 let hex = digest.strip_prefix("sha256:")?;
538 let bytes = from_hex(hex)?;
539 bytes.try_into().ok()
540}
541
542// ---------------------------------------------------------------------------
543// Hashing and signing — gated, because they need real cryptography.
544// ---------------------------------------------------------------------------
545
546#[cfg(feature = "attestation")]
547mod crypto {
548 use super::*;
549 use crate::frame::ContextFrame;
550 use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
551 use sha2::{Digest, Sha256};
552
553 /// SHA-256 over a sequence of parts, hashed in order without any separator
554 /// beyond the parts' own length prefixes.
555 fn sha256(parts: &[&[u8]]) -> [u8; 32] {
556 let mut hasher = Sha256::new();
557 for part in parts {
558 hasher.update(part);
559 }
560 hasher.finalize().into()
561 }
562
563 /// The head of a frame's provenance hash chain (`SPEC.md` §6.5.2).
564 ///
565 /// Links fold **source-first**, matching the order [`Provenance`] is
566 /// documented to carry (closest-to-source first), so each link commits to
567 /// everything nearer the source than itself:
568 ///
569 /// ```text
570 /// h₋₁ = SHA256(domain::GENESIS)
571 /// hᵢ = SHA256(domain::LINK ‖ hᵢ₋₁ ‖ encode(linkᵢ))
572 /// head = hₙ₋₁ (or h₋₁ for an empty chain)
573 /// ```
574 ///
575 /// Because every step consumes the previous head, no link can be inserted,
576 /// dropped, reordered, or edited without changing the result — which is the
577 /// property a bare per-link digest never had. An empty chain hashes to the
578 /// genesis value rather than to zero or to a sentinel, so "no provenance" is
579 /// a *stated* claim a signature can cover, not a gap.
580 pub fn provenance_chain_head(links: &[Provenance]) -> [u8; 32] {
581 let mut head = sha256(&[domain::GENESIS]);
582 for link in links {
583 let encoded = encode_provenance_link(link);
584 head = sha256(&[domain::LINK, &head, &encoded]);
585 }
586 head
587 }
588
589 /// The commitment binding one frame's identity to its provenance chain
590 /// (`SPEC.md` §6.5.2) — the preimage a single-frame attestation signs.
591 ///
592 /// ```text
593 /// SHA256(
594 /// domain::FRAME ‖ enc(provider_id) ‖ enc(frame.id)
595 /// ‖ enc_opt(frame.content_digest) ‖ chain_head
596 /// )
597 /// ```
598 ///
599 /// `content_digest` is included so that, **when the frame declares one**,
600 /// the signature covers the frame's *bytes* and not merely its name.
601 ///
602 /// When the frame declares none, it does not. `enc_opt` writes a single
603 /// `0x00` presence byte, so the preimage records the absence honestly
604 /// rather than substituting a placeholder — but what gets signed is then
605 /// identity and provenance alone, and a provider can re-serve entirely
606 /// different content under the same frame id with that signature still
607 /// checking out. The encoding is doing its job; the guarantee is simply
608 /// narrower than the presence of a signature suggests.
609 ///
610 /// Two things follow, and both are load-bearing (#128):
611 ///
612 /// - `SPEC.md` §6.5.2 requires a provider that *signs* a frame to populate
613 /// `content_digest`. A digest-less frame remains conformant; signing one
614 /// is not. This function still computes the commitment for such a frame,
615 /// because a verifier has to be able to check signatures produced before
616 /// that rule, or by an implementation that ignores it.
617 /// - [`verify_frame_attestation`] returns
618 /// [`AttestationVerdict::ValidIdentityOnly`] rather than
619 /// [`AttestationVerdict::Valid`] for exactly that case, so no caller can
620 /// mistake the narrower guarantee for the wider one.
621 ///
622 /// A frame that declares no digest and carries no attestation is a
623 /// different thing again: unverifiable by design
624 /// (`docs/context-reuse.md` §4), and no rule here applies to it.
625 pub fn frame_commitment(provider_id: &str, frame: &ContextFrame) -> [u8; 32] {
626 let chain_head = provenance_chain_head(&frame.provenance);
627 let mut preimage = Vec::new();
628 enc_str(&mut preimage, provider_id);
629 enc_str(&mut preimage, &frame.id);
630 enc_opt(&mut preimage, frame.content_digest.as_deref());
631 sha256(&[domain::FRAME, &preimage, &chain_head])
632 }
633
634 /// Every frame of a result set paired with its commitment, in the
635 /// protocol's canonical [`FrameId`] order (`SPEC.md` §6.3, §6.5.3).
636 ///
637 /// The order is the whole point: a Merkle root is a function of leaf
638 /// *sequence*, so a provider and a verifier that sort differently compute
639 /// different roots from identical frames. Sorting by the identity triple —
640 /// the order deterministic composition already uses — means neither side
641 /// has to preserve, transmit, or agree on the order the frames happened to
642 /// arrive in.
643 pub fn result_set_commitments(
644 provider_id: &str,
645 frames: &[ContextFrame],
646 ) -> Vec<(FrameId, [u8; 32])> {
647 let mut ordered: Vec<(FrameId, &ContextFrame)> = frames
648 .iter()
649 .map(|frame| (frame.identity(provider_id), frame))
650 .collect();
651 ordered.sort_by(|(a, _), (b, _)| a.cmp(b));
652 ordered
653 .into_iter()
654 .map(|(id, frame)| {
655 let commitment = frame_commitment(provider_id, frame);
656 (id, commitment)
657 })
658 .collect()
659 }
660
661 /// The Merkle root a provider signs to attest a whole answer
662 /// (`SPEC.md` §6.5.3, F12).
663 ///
664 /// The leaves are exactly the frames carried in the result — never a larger
665 /// candidate set the provider truncated away. A root over frames the host
666 /// never received is unverifiable by construction, and an unverifiable root
667 /// is worse than none: it looks like evidence.
668 pub fn result_set_root(provider_id: &str, frames: &[ContextFrame]) -> [u8; 32] {
669 let commitments: Vec<[u8; 32]> = result_set_commitments(provider_id, frames)
670 .into_iter()
671 .map(|(_, commitment)| commitment)
672 .collect();
673 merkle_root(&commitments)
674 }
675
676 /// A Merkle leaf hash, RFC 6962 style: `SHA256(0x00 ‖ commitment)`.
677 fn leaf_hash(commitment: &[u8; 32]) -> [u8; 32] {
678 sha256(&[domain::MERKLE_LEAF, commitment])
679 }
680
681 /// A Merkle interior node, RFC 6962 style: `SHA256(0x01 ‖ left ‖ right)`.
682 fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
683 sha256(&[domain::MERKLE_NODE, left, right])
684 }
685
686 /// The largest power of two strictly less than `n` (RFC 6962's split point).
687 /// Only meaningful for `n >= 2`.
688 fn split_point(n: usize) -> usize {
689 let mut k = 1;
690 while k * 2 < n {
691 k *= 2;
692 }
693 k
694 }
695
696 /// The Merkle root over a set of frame commitments (`SPEC.md` §6.5.3).
697 ///
698 /// RFC 6962's tree shape, chosen over "duplicate the last leaf on an odd
699 /// level" because that shortcut admits two distinct leaf sets with the same
700 /// root — an ambiguity that is fine for a checksum and disqualifying for
701 /// evidence. Callers pass commitments in the protocol's canonical
702 /// [`FrameId`](crate::FrameId) order so the root is reproducible.
703 pub fn merkle_root(commitments: &[[u8; 32]]) -> [u8; 32] {
704 match commitments.len() {
705 0 => sha256(&[domain::MERKLE_EMPTY]),
706 1 => leaf_hash(&commitments[0]),
707 n => {
708 let k = split_point(n);
709 node_hash(
710 &merkle_root(&commitments[..k]),
711 &merkle_root(&commitments[k..]),
712 )
713 }
714 }
715 }
716
717 /// Build an [`InclusionProof`] for `leaf_index` within `commitments`.
718 /// `None` if the index is out of range.
719 pub fn inclusion_proof(commitments: &[[u8; 32]], leaf_index: usize) -> Option<InclusionProof> {
720 if leaf_index >= commitments.len() {
721 return None;
722 }
723 let mut path = Vec::new();
724 collect_path(commitments, leaf_index, &mut path);
725 Some(InclusionProof {
726 leaf_index,
727 leaf_count: commitments.len(),
728 path,
729 })
730 }
731
732 /// Walk down the tree accumulating sibling hashes, leaf-upward.
733 fn collect_path(commitments: &[[u8; 32]], index: usize, path: &mut Vec<InclusionStep>) {
734 if commitments.len() <= 1 {
735 return;
736 }
737 let k = split_point(commitments.len());
738 if index < k {
739 collect_path(&commitments[..k], index, path);
740 path.push(InclusionStep {
741 sibling: digest_string(&merkle_root(&commitments[k..])),
742 sibling_is_left: false,
743 });
744 } else {
745 collect_path(&commitments[k..], index - k, path);
746 path.push(InclusionStep {
747 sibling: digest_string(&merkle_root(&commitments[..k])),
748 sibling_is_left: true,
749 });
750 }
751 }
752
753 /// Recompute a Merkle root from a leaf commitment and its proof.
754 ///
755 /// This is the whole offline story: an auditor holding one frame, its proof,
756 /// and a signed root needs nothing else — no network, no host, no provider.
757 /// `None` if any sibling in the path is malformed.
758 pub fn root_from_proof(commitment: &[u8; 32], proof: &InclusionProof) -> Option<[u8; 32]> {
759 if proof.leaf_index >= proof.leaf_count {
760 return None;
761 }
762 let mut acc = leaf_hash(commitment);
763 for step in &proof.path {
764 let sibling = parse_digest(&step.sibling)?;
765 acc = if step.sibling_is_left {
766 node_hash(&sibling, &acc)
767 } else {
768 node_hash(&acc, &sibling)
769 };
770 }
771 Some(acc)
772 }
773
774 /// Verify a detached attestation over a single frame (`SPEC.md` §6.5.4).
775 ///
776 /// Pure and offline. `public_key` is raw bytes rather than an
777 /// `ed25519_dalek` type on purpose: the public API of this crate names no
778 /// cryptography library, so the backend can be replaced — or a
779 /// post-quantum scheme added — without a breaking change to callers.
780 pub fn verify_frame_attestation(
781 provider_id: &str,
782 frame: &ContextFrame,
783 attestation: &ProvenanceAttestation,
784 public_key: &[u8],
785 ) -> AttestationVerdict {
786 let expected = frame_commitment(provider_id, frame);
787 let verdict = verify_commitment(&expected, attestation, public_key);
788 // A frame with no `content_digest` was committed to by id and
789 // provenance alone, so a passing signature says nothing about the bytes
790 // (#128). Downgrade the verdict rather than let `Valid` carry a
791 // guarantee this preimage never made. Every failing verdict is left
792 // exactly as it is — it is already the more specific answer.
793 match verdict {
794 AttestationVerdict::Valid if frame.content_digest.is_none() => {
795 AttestationVerdict::ValidIdentityOnly
796 }
797 other => other,
798 }
799 }
800
801 /// Verify that a frame was a leaf of a signed result-set root
802 /// (`SPEC.md` §6.5.3, F13).
803 ///
804 /// This is the other half of §6.5. A provider that signs **one** root and
805 /// ships a per-frame [`InclusionProof`] has attested every frame it served
806 /// with a single signature, and the `attestation` member of the matching
807 /// [`FrameAttestation`] entry is then absent. A verifier that only knew how
808 /// to check per-frame signatures would report every such frame as
809 /// unattested — the cheapest honest signing shape would be the one nothing
810 /// could check — which is why this lives beside
811 /// [`verify_frame_attestation`] rather than inside one host.
812 ///
813 /// `result_attestation` is the answer-level attestation, whose
814 /// `signed_commitment` must equal the root this proof recomputes from the
815 /// frame's own commitment.
816 ///
817 /// The content-binding rule is [`verify_frame_attestation`]'s, unchanged
818 /// and for the same reason (#128): the leaf is a [`frame_commitment`], so a
819 /// frame that declares no `content_digest` is committed to by identity and
820 /// provenance alone however many hashes sit between it and the signature.
821 ///
822 /// # Bounded work
823 ///
824 /// Every field of `proof` comes from the provider, and each step of the
825 /// path costs a hash. [`MAX_INCLUSION_PATH_STEPS`] caps that before any
826 /// hashing starts: a longer path describes a tree with more leaves than any
827 /// answer can hold, and is rejected on its length rather than walked.
828 pub fn verify_frame_inclusion(
829 provider_id: &str,
830 frame: &ContextFrame,
831 proof: &InclusionProof,
832 result_attestation: &ProvenanceAttestation,
833 public_key: &[u8],
834 ) -> AttestationVerdict {
835 if proof.path.len() > MAX_INCLUSION_PATH_STEPS {
836 return AttestationVerdict::MalformedCommitment;
837 }
838 let commitment = frame_commitment(provider_id, frame);
839 let Some(root) = root_from_proof(&commitment, proof) else {
840 // A malformed sibling, or a leaf index outside the tree the proof
841 // describes. Either way there is no root to compare against, and
842 // that is a malformed commitment rather than a bad signature.
843 return AttestationVerdict::MalformedCommitment;
844 };
845 match verify_commitment(&root, result_attestation, public_key) {
846 AttestationVerdict::Valid if frame.content_digest.is_none() => {
847 AttestationVerdict::ValidIdentityOnly
848 }
849 other => other,
850 }
851 }
852
853 /// Verify a detached attestation over an already-computed commitment — a
854 /// [`merkle_root`] for a result set, or a [`frame_commitment`].
855 pub fn verify_commitment(
856 expected: &[u8; 32],
857 attestation: &ProvenanceAttestation,
858 public_key: &[u8],
859 ) -> AttestationVerdict {
860 if attestation.algorithm != ALGORITHM_ED25519 {
861 return AttestationVerdict::UnknownAlgorithm(attestation.algorithm.clone());
862 }
863 let Some(signed) = parse_digest(&attestation.signed_commitment) else {
864 return AttestationVerdict::MalformedCommitment;
865 };
866 // Compare commitments *before* touching the signature. A mismatch means
867 // the frame changed after signing, and saying so is far more useful to
868 // an operator than the "bad signature" a naive order would report.
869 if signed != *expected {
870 return AttestationVerdict::CommitmentMismatch {
871 expected: digest_string(expected),
872 signed: attestation.signed_commitment.clone(),
873 };
874 }
875 let Ok(key_bytes) = <[u8; 32]>::try_from(public_key) else {
876 return AttestationVerdict::MalformedKey;
877 };
878 let Ok(verifying_key) = VerifyingKey::from_bytes(&key_bytes) else {
879 return AttestationVerdict::MalformedKey;
880 };
881 let Some(sig_bytes) = from_hex(&attestation.signature) else {
882 return AttestationVerdict::MalformedSignature;
883 };
884 let Ok(sig_bytes) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
885 return AttestationVerdict::MalformedSignature;
886 };
887 let signature = Signature::from_bytes(&sig_bytes);
888 // `verify_strict` rejects small-order public keys and the malleable
889 // signature forms `verify` tolerates. For evidence, the strict variant
890 // is the only defensible choice: a signature that two verifiers can
891 // disagree about is not evidence.
892 match verifying_key.verify_strict(&signed, &signature) {
893 Ok(()) => AttestationVerdict::Valid,
894 Err(_) => AttestationVerdict::BadSignature,
895 }
896 }
897
898 /// Sign a frame's commitment in-process, for providers content to hold key
899 /// material in memory.
900 ///
901 /// A provider using an HSM or KMS instead calls [`frame_commitment`],
902 /// signs the 32 bytes with its own backend, and assembles the
903 /// [`ProvenanceAttestation`] by hand — the protocol specifies the preimage,
904 /// never the custody of the key.
905 pub fn sign_frame_attestation(
906 provider_id: &str,
907 frame: &ContextFrame,
908 signing_key_seed: &[u8; 32],
909 key_id: impl Into<String>,
910 attester_id: impl Into<String>,
911 issued_at: impl Into<String>,
912 ) -> ProvenanceAttestation {
913 let commitment = frame_commitment(provider_id, frame);
914 sign_commitment(
915 &commitment,
916 signing_key_seed,
917 key_id,
918 attester_id,
919 issued_at,
920 )
921 }
922
923 /// Sign an arbitrary commitment (a frame commitment or a Merkle root).
924 pub fn sign_commitment(
925 commitment: &[u8; 32],
926 signing_key_seed: &[u8; 32],
927 key_id: impl Into<String>,
928 attester_id: impl Into<String>,
929 issued_at: impl Into<String>,
930 ) -> ProvenanceAttestation {
931 let signing_key = SigningKey::from_bytes(signing_key_seed);
932 let signature = signing_key.sign(commitment);
933 let mut hex = String::with_capacity(128);
934 for b in signature.to_bytes() {
935 hex.push(char::from_digit((b >> 4) as u32, 16).expect("nibble is < 16"));
936 hex.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble is < 16"));
937 }
938 ProvenanceAttestation::new(
939 digest_string(commitment),
940 key_id,
941 ALGORITHM_ED25519,
942 attester_id,
943 hex,
944 issued_at,
945 )
946 }
947
948 /// The public key matching a signing seed, as raw bytes — the form
949 /// [`verify_frame_attestation`] accepts.
950 pub fn public_key_for(signing_key_seed: &[u8; 32]) -> [u8; 32] {
951 SigningKey::from_bytes(signing_key_seed)
952 .verifying_key()
953 .to_bytes()
954 }
955}
956
957#[cfg(feature = "attestation")]
958pub use crypto::{
959 frame_commitment, inclusion_proof, merkle_root, provenance_chain_head, public_key_for,
960 result_set_commitments, result_set_root, root_from_proof, sign_commitment,
961 sign_frame_attestation, verify_commitment, verify_frame_attestation, verify_frame_inclusion,
962};
963
964#[cfg(all(test, feature = "attestation"))]
965mod tests {
966 use super::*;
967 use crate::frame::{ContextFrame, FrameKind};
968
969 /// A deterministic seed — tests need reproducible signatures, and this key
970 /// signs nothing outside this file.
971 const SEED: [u8; 32] = [7u8; 32];
972
973 fn link(kind: &str, uri: Option<&str>, digest: Option<&str>) -> Provenance {
974 Provenance {
975 kind: kind.into(),
976 uri: uri.map(Into::into),
977 range: None,
978 digest: digest.map(Into::into),
979 method: None,
980 by: None,
981 }
982 }
983
984 fn frame_with(id: &str, provenance: Vec<Provenance>) -> ContextFrame {
985 let mut frame = ContextFrame::full(id, FrameKind::Doc, "Retry policy", "body", 0.9, 1);
986 frame.content_digest = Some("sha256:abcd".into());
987 frame.provenance = provenance;
988 frame
989 }
990
991 #[test]
992 fn the_encoding_is_injective_across_field_boundaries() {
993 // The attack length-prefixing exists to stop: without it, ("ab", "c")
994 // and ("a", "bc") concatenate to the same bytes and an adversary picks
995 // the collision rather than searching for one.
996 let a = link("file", Some("ab"), Some("c"));
997 let b = link("file", Some("a"), Some("bc"));
998 assert_ne!(encode_provenance_link(&a), encode_provenance_link(&b));
999 }
1000
1001 #[test]
1002 fn an_absent_field_never_encodes_like_an_empty_one() {
1003 let absent = link("file", None, None);
1004 let empty = link("file", Some(""), None);
1005 assert_ne!(
1006 encode_provenance_link(&absent),
1007 encode_provenance_link(&empty),
1008 "the presence byte must keep None distinct from Some(\"\")"
1009 );
1010 }
1011
1012 #[test]
1013 fn an_empty_chain_has_a_stated_head_not_a_zero() {
1014 let head = provenance_chain_head(&[]);
1015 assert_ne!(head, [0u8; 32], "\"no provenance\" is a claim, not a gap");
1016 // Stable across calls — the genesis is a constant, not a nonce.
1017 assert_eq!(head, provenance_chain_head(&[]));
1018 }
1019
1020 #[test]
1021 fn reordering_the_chain_changes_the_head() {
1022 let a = link("file", Some("src/a.rs"), Some("sha256:aa"));
1023 let b = link("derivation", Some("summary"), Some("sha256:bb"));
1024 let forward = provenance_chain_head(&[a.clone(), b.clone()]);
1025 let reversed = provenance_chain_head(&[b, a]);
1026 assert_ne!(
1027 forward, reversed,
1028 "a hash chain must bind order; per-link digests never did"
1029 );
1030 }
1031
1032 #[test]
1033 fn dropping_a_link_changes_the_head() {
1034 let a = link("file", Some("src/a.rs"), Some("sha256:aa"));
1035 let b = link("derivation", None, None);
1036 assert_ne!(
1037 provenance_chain_head(&[a.clone(), b]),
1038 provenance_chain_head(&[a]),
1039 "truncating provenance must be detectable"
1040 );
1041 }
1042
1043 #[test]
1044 fn a_signed_frame_verifies_against_its_own_key() {
1045 let frame = frame_with(
1046 "f1",
1047 vec![link("file", Some("src/a.rs"), Some("sha256:aa"))],
1048 );
1049 let attestation = sign_frame_attestation(
1050 "repo-graph",
1051 &frame,
1052 &SEED,
1053 "key-1",
1054 "oxagen",
1055 "2026-08-27T00:00:00Z",
1056 );
1057 let key = public_key_for(&SEED);
1058 assert_eq!(
1059 verify_frame_attestation("repo-graph", &frame, &attestation, &key),
1060 AttestationVerdict::Valid
1061 );
1062 assert!(attestation.uses_known_algorithm());
1063 assert!(attestation.has_well_formed_issued_at());
1064 }
1065
1066 #[test]
1067 fn editing_provenance_after_signing_is_caught_as_a_mismatch() {
1068 let frame = frame_with(
1069 "f1",
1070 vec![link("file", Some("src/a.rs"), Some("sha256:aa"))],
1071 );
1072 let attestation = sign_frame_attestation(
1073 "repo-graph",
1074 &frame,
1075 &SEED,
1076 "key-1",
1077 "oxagen",
1078 "2026-08-27T00:00:00Z",
1079 );
1080 // Rewrite the source URI — the exact tamper a bare digest cannot see,
1081 // because the tamperer simply rewrites the digest too.
1082 let mut tampered = frame.clone();
1083 tampered.provenance[0].uri = Some("src/evil.rs".into());
1084 tampered.provenance[0].digest = Some("sha256:ff".into());
1085
1086 let key = public_key_for(&SEED);
1087 let verdict = verify_frame_attestation("repo-graph", &tampered, &attestation, &key);
1088 assert!(
1089 matches!(verdict, AttestationVerdict::CommitmentMismatch { .. }),
1090 "expected a commitment mismatch, got {verdict:?}"
1091 );
1092 assert!(!verdict.is_valid());
1093 }
1094
1095 #[test]
1096 fn a_signature_cannot_be_lifted_onto_another_frame() {
1097 // The forgery the FrameId binding exists to prevent. Both frames cite
1098 // exactly the same source, so they share a chain head; only the identity
1099 // binding distinguishes them.
1100 let shared = vec![link("file", Some("src/a.rs"), Some("sha256:aa"))];
1101 let honest = frame_with("f1", shared.clone());
1102 let forged = frame_with("f2", shared);
1103 assert_eq!(
1104 provenance_chain_head(&honest.provenance),
1105 provenance_chain_head(&forged.provenance),
1106 "precondition: identical provenance means an identical chain head"
1107 );
1108
1109 let attestation = sign_frame_attestation(
1110 "repo-graph",
1111 &honest,
1112 &SEED,
1113 "key-1",
1114 "oxagen",
1115 "2026-08-27T00:00:00Z",
1116 );
1117 let key = public_key_for(&SEED);
1118 assert!(
1119 matches!(
1120 verify_frame_attestation("repo-graph", &forged, &attestation, &key),
1121 AttestationVerdict::CommitmentMismatch { .. }
1122 ),
1123 "a stolen signature must not validate a different frame"
1124 );
1125 }
1126
1127 #[test]
1128 fn the_same_frame_from_another_provider_does_not_verify() {
1129 let frame = frame_with("f1", vec![link("file", Some("src/a.rs"), None)]);
1130 let attestation = sign_frame_attestation(
1131 "repo-graph",
1132 &frame,
1133 &SEED,
1134 "key-1",
1135 "oxagen",
1136 "2026-08-27T00:00:00Z",
1137 );
1138 let key = public_key_for(&SEED);
1139 assert!(
1140 matches!(
1141 verify_frame_attestation("impostor", &frame, &attestation, &key),
1142 AttestationVerdict::CommitmentMismatch { .. }
1143 ),
1144 "the provider id is part of the signed identity"
1145 );
1146 }
1147
1148 #[test]
1149 fn re_serving_different_bytes_under_the_same_id_is_caught() {
1150 let frame = frame_with("f1", vec![link("file", Some("src/a.rs"), None)]);
1151 let attestation = sign_frame_attestation(
1152 "repo-graph",
1153 &frame,
1154 &SEED,
1155 "key-1",
1156 "oxagen",
1157 "2026-08-27T00:00:00Z",
1158 );
1159 let mut swapped = frame.clone();
1160 swapped.content_digest = Some("sha256:0000".into());
1161 let key = public_key_for(&SEED);
1162 assert!(
1163 matches!(
1164 verify_frame_attestation("repo-graph", &swapped, &attestation, &key),
1165 AttestationVerdict::CommitmentMismatch { .. }
1166 ),
1167 "the signature covers the frame's bytes, not just its name"
1168 );
1169 }
1170
1171 #[test]
1172 fn a_wrong_key_is_a_bad_signature_not_a_mismatch() {
1173 let frame = frame_with("f1", vec![]);
1174 let attestation = sign_frame_attestation(
1175 "repo-graph",
1176 &frame,
1177 &SEED,
1178 "key-1",
1179 "oxagen",
1180 "2026-08-27T00:00:00Z",
1181 );
1182 let other = public_key_for(&[9u8; 32]);
1183 assert_eq!(
1184 verify_frame_attestation("repo-graph", &frame, &attestation, &other),
1185 AttestationVerdict::BadSignature,
1186 "the commitment is intact; only the key is wrong"
1187 );
1188 }
1189
1190 #[test]
1191 fn an_unknown_algorithm_is_declined_rather_than_failed() {
1192 let frame = frame_with("f1", vec![]);
1193 let mut attestation = sign_frame_attestation(
1194 "repo-graph",
1195 &frame,
1196 &SEED,
1197 "key-1",
1198 "oxagen",
1199 "2026-08-27T00:00:00Z",
1200 );
1201 attestation.algorithm = "dilithium3".into();
1202 let key = public_key_for(&SEED);
1203 let verdict = verify_frame_attestation("repo-graph", &frame, &attestation, &key);
1204 assert_eq!(
1205 verdict,
1206 AttestationVerdict::UnknownAlgorithm("dilithium3".into())
1207 );
1208 assert!(!verdict.is_valid(), "declining is still not accepting");
1209 assert!(!attestation.uses_known_algorithm());
1210 }
1211
1212 #[test]
1213 fn malformed_keys_and_signatures_are_named_distinctly() {
1214 let frame = frame_with("f1", vec![]);
1215 let attestation = sign_frame_attestation(
1216 "repo-graph",
1217 &frame,
1218 &SEED,
1219 "key-1",
1220 "oxagen",
1221 "2026-08-27T00:00:00Z",
1222 );
1223 assert_eq!(
1224 verify_frame_attestation("repo-graph", &frame, &attestation, &[0u8; 5]),
1225 AttestationVerdict::MalformedKey
1226 );
1227
1228 let mut truncated = attestation.clone();
1229 truncated.signature = "abcd".into();
1230 assert_eq!(
1231 verify_frame_attestation("repo-graph", &frame, &truncated, &public_key_for(&SEED)),
1232 AttestationVerdict::MalformedSignature
1233 );
1234
1235 let mut bad_commitment = attestation;
1236 bad_commitment.signed_commitment = "not-a-digest".into();
1237 assert_eq!(
1238 verify_frame_attestation(
1239 "repo-graph",
1240 &frame,
1241 &bad_commitment,
1242 &public_key_for(&SEED)
1243 ),
1244 AttestationVerdict::MalformedCommitment
1245 );
1246 }
1247
1248 #[test]
1249 fn an_attestation_round_trips_through_json() {
1250 let frame = frame_with("f1", vec![link("file", Some("a"), None)]);
1251 let attestation = sign_frame_attestation(
1252 "repo-graph",
1253 &frame,
1254 &SEED,
1255 "key-1",
1256 "oxagen",
1257 "2026-08-27T00:00:00Z",
1258 );
1259 let json = serde_json::to_string(&attestation).unwrap();
1260 let back: ProvenanceAttestation = serde_json::from_str(&json).unwrap();
1261 assert_eq!(back, attestation);
1262 }
1263
1264 #[test]
1265 fn every_leaf_of_a_signed_set_proves_its_own_membership() {
1266 let commitments: Vec<[u8; 32]> = (0..7)
1267 .map(|i| frame_commitment("repo-graph", &frame_with(&format!("f{i}"), vec![])))
1268 .collect();
1269 let root = merkle_root(&commitments);
1270
1271 for (index, commitment) in commitments.iter().enumerate() {
1272 let proof = inclusion_proof(&commitments, index).expect("index is in range");
1273 assert_eq!(proof.leaf_index, index);
1274 assert_eq!(proof.leaf_count, 7);
1275 assert_eq!(
1276 root_from_proof(commitment, &proof),
1277 Some(root),
1278 "leaf {index} must recompute the signed root"
1279 );
1280 }
1281 }
1282
1283 #[test]
1284 fn a_proof_does_not_validate_a_commitment_that_was_not_in_the_set() {
1285 let commitments: Vec<[u8; 32]> = (0..4)
1286 .map(|i| frame_commitment("repo-graph", &frame_with(&format!("f{i}"), vec![])))
1287 .collect();
1288 let root = merkle_root(&commitments);
1289 let proof = inclusion_proof(&commitments, 1).unwrap();
1290
1291 let outsider = frame_commitment("repo-graph", &frame_with("intruder", vec![]));
1292 assert_ne!(
1293 root_from_proof(&outsider, &proof),
1294 Some(root),
1295 "an unsigned frame must not ride someone else's proof"
1296 );
1297 }
1298
1299 #[test]
1300 fn a_single_frame_set_still_produces_a_usable_proof() {
1301 let commitments = vec![frame_commitment("repo-graph", &frame_with("only", vec![]))];
1302 let root = merkle_root(&commitments);
1303 let proof = inclusion_proof(&commitments, 0).unwrap();
1304 assert!(proof.path.is_empty(), "a lone leaf needs no siblings");
1305 assert_eq!(root_from_proof(&commitments[0], &proof), Some(root));
1306 }
1307
1308 #[test]
1309 fn an_empty_set_has_a_distinct_root() {
1310 let empty = merkle_root(&[]);
1311 let lone = merkle_root(&[frame_commitment("repo-graph", &frame_with("only", vec![]))]);
1312 assert_ne!(empty, lone);
1313 assert!(inclusion_proof(&[], 0).is_none());
1314 }
1315
1316 /// The whole cheap-signing path in one helper: sign the root over `frames`
1317 /// and hand back the root attestation plus each frame's inclusion proof, in
1318 /// canonical order.
1319 fn root_signed(
1320 provider_id: &str,
1321 frames: &[ContextFrame],
1322 ) -> (ProvenanceAttestation, Vec<InclusionProof>) {
1323 let commitments: Vec<[u8; 32]> = result_set_commitments(provider_id, frames)
1324 .into_iter()
1325 .map(|(_, commitment)| commitment)
1326 .collect();
1327 let root = merkle_root(&commitments);
1328 let proofs = (0..commitments.len())
1329 .map(|index| inclusion_proof(&commitments, index).expect("index is in range"))
1330 .collect();
1331 let attestation = sign_commitment(
1332 &root,
1333 &SEED,
1334 "repo-graph-2026-08",
1335 "repo-graph",
1336 "2026-08-29T00:00:00Z",
1337 );
1338 (attestation, proofs)
1339 }
1340
1341 #[test]
1342 fn one_root_signature_attests_every_frame_it_covers() {
1343 // The point of §6.5.3: a provider signs once, and every frame in the
1344 // answer is verifiable from its own proof. A verifier that only knew
1345 // how to check per-frame signatures would call all of these unattested.
1346 let frames = vec![
1347 frame_with("a", vec![link("file", Some("src/a.rs"), Some("sha256:aa"))]),
1348 frame_with("b", vec![]),
1349 frame_with("c", vec![]),
1350 ];
1351 // Canonical order is by `FrameId`, which is what the proofs index into.
1352 let ordered: Vec<ContextFrame> = {
1353 let mut sorted = frames.clone();
1354 sorted.sort_by_key(|frame| frame.identity("repo-graph"));
1355 sorted
1356 };
1357 let (root, proofs) = root_signed("repo-graph", &frames);
1358 let public_key = public_key_for(&SEED);
1359
1360 for (frame, proof) in ordered.iter().zip(&proofs) {
1361 assert_eq!(
1362 verify_frame_inclusion("repo-graph", frame, proof, &root, &public_key),
1363 AttestationVerdict::Valid,
1364 "frame `{}` is a leaf of the signed root",
1365 frame.id
1366 );
1367 }
1368 }
1369
1370 #[test]
1371 fn a_frame_edited_after_the_root_was_signed_recomputes_a_different_root() {
1372 let frames = vec![frame_with("a", vec![]), frame_with("b", vec![])];
1373 let (root, proofs) = root_signed("repo-graph", &frames);
1374 let mut ordered = frames.clone();
1375 ordered.sort_by_key(|frame| frame.identity("repo-graph"));
1376 ordered[0].provenance.push(link("derivation", None, None));
1377
1378 assert!(
1379 matches!(
1380 verify_frame_inclusion(
1381 "repo-graph",
1382 &ordered[0],
1383 &proofs[0],
1384 &root,
1385 &public_key_for(&SEED),
1386 ),
1387 AttestationVerdict::CommitmentMismatch { .. }
1388 ),
1389 "a proof must not launder an edit the root never covered"
1390 );
1391 }
1392
1393 #[test]
1394 fn a_digest_less_frame_proven_through_a_root_binds_no_content() {
1395 // #128's rule survives the tree: the leaf is a frame commitment, so a
1396 // frame declaring no `content_digest` is committed to by identity and
1397 // provenance alone however many hashes sit above it.
1398 let mut frame = frame_with("a", vec![]);
1399 frame.content_digest = None;
1400 let frames = vec![frame.clone()];
1401 let (root, proofs) = root_signed("repo-graph", &frames);
1402
1403 let verdict = verify_frame_inclusion(
1404 "repo-graph",
1405 &frame,
1406 &proofs[0],
1407 &root,
1408 &public_key_for(&SEED),
1409 );
1410 assert_eq!(verdict, AttestationVerdict::ValidIdentityOnly);
1411 assert!(verdict.signature_verifies());
1412 assert!(!verdict.binds_content());
1413 }
1414
1415 #[test]
1416 fn a_root_signed_by_the_wrong_key_is_a_bad_signature_not_a_mismatch() {
1417 let frames = vec![frame_with("a", vec![])];
1418 let (root, proofs) = root_signed("repo-graph", &frames);
1419 let impostor = public_key_for(&[8u8; 32]);
1420 assert_eq!(
1421 verify_frame_inclusion("repo-graph", &frames[0], &proofs[0], &root, &impostor),
1422 AttestationVerdict::BadSignature
1423 );
1424 }
1425
1426 #[test]
1427 fn an_inclusion_path_longer_than_the_cap_is_rejected_before_it_is_walked() {
1428 // Every step costs a hash and the path comes from the provider, so the
1429 // length is checked before any hashing starts.
1430 let frames = vec![frame_with("a", vec![])];
1431 let (root, _) = root_signed("repo-graph", &frames);
1432 let oversized = InclusionProof {
1433 leaf_index: 0,
1434 leaf_count: usize::MAX,
1435 path: vec![
1436 InclusionStep {
1437 sibling: digest_string(&[1u8; 32]),
1438 sibling_is_left: false,
1439 };
1440 MAX_INCLUSION_PATH_STEPS + 1
1441 ],
1442 };
1443 assert_eq!(
1444 verify_frame_inclusion(
1445 "repo-graph",
1446 &frames[0],
1447 &oversized,
1448 &root,
1449 &public_key_for(&SEED)
1450 ),
1451 AttestationVerdict::MalformedCommitment
1452 );
1453 }
1454
1455 #[test]
1456 fn leaf_and_node_hashing_are_domain_separated() {
1457 // Without the RFC 6962 prefixes, an interior node's hash could be
1458 // presented as a leaf, letting a subtree masquerade as a single frame.
1459 let a = frame_commitment("repo-graph", &frame_with("a", vec![]));
1460 let b = frame_commitment("repo-graph", &frame_with("b", vec![]));
1461 let pair_root = merkle_root(&[a, b]);
1462 // The two-leaf root must not equal the one-leaf root of any commitment.
1463 assert_ne!(pair_root, merkle_root(&[a]));
1464 assert_ne!(pair_root, merkle_root(&[b]));
1465 }
1466
1467 #[test]
1468 fn a_signed_merkle_root_verifies_for_the_whole_result_set() {
1469 let commitments: Vec<[u8; 32]> = (0..3)
1470 .map(|i| frame_commitment("repo-graph", &frame_with(&format!("f{i}"), vec![])))
1471 .collect();
1472 let root = merkle_root(&commitments);
1473 let attestation = sign_commitment(&root, &SEED, "key-1", "oxagen", "2026-08-27T00:00:00Z");
1474 let key = public_key_for(&SEED);
1475 assert_eq!(
1476 verify_commitment(&root, &attestation, &key),
1477 AttestationVerdict::Valid
1478 );
1479 }
1480
1481 #[test]
1482 fn digest_strings_are_well_formed_protocol_digests() {
1483 let head = provenance_chain_head(&[link("file", Some("a"), None)]);
1484 let rendered = digest_string(&head);
1485 assert!(
1486 crate::validate::is_well_formed_digest(&rendered),
1487 "{rendered} must satisfy the protocol digest grammar"
1488 );
1489 }
1490}
1491
1492#[cfg(all(test, feature = "attestation"))]
1493mod content_binding_tests {
1494 use super::*;
1495 use crate::frame::{ContextFrame, FrameKind};
1496
1497 const SEED: [u8; 32] = [7u8; 32];
1498 const PROVIDER: &str = "acme.docs";
1499
1500 fn frame(id: &str, content: &str, digest: Option<&str>) -> ContextFrame {
1501 let mut f = ContextFrame::full(id, FrameKind::Doc, "Retry policy", content, 0.9, 1);
1502 f.content_digest = digest.map(Into::into);
1503 f
1504 }
1505
1506 fn attest(frame: &ContextFrame) -> ProvenanceAttestation {
1507 sign_frame_attestation(PROVIDER, frame, &SEED, "k1", "acme", "2026-09-10T00:00:00Z")
1508 }
1509
1510 /// The demonstration from #128, kept as a test so the guarantee cannot
1511 /// quietly revert: sign a frame that declares no `content_digest`, rewrite
1512 /// its content, and check that the verdict does not claim the signature
1513 /// still covers it.
1514 ///
1515 /// Before the fix this asserted `Valid` — twice, for two different sets of
1516 /// bytes — and a host had no way to tell that the second answer was not the
1517 /// one that had been signed.
1518 #[test]
1519 fn a_signed_frame_with_no_content_digest_is_attested_over_nothing_it_says() {
1520 let signed = frame("f1", "retry three times", None);
1521 let attestation = attest(&signed);
1522 let key = public_key_for(&SEED);
1523
1524 let before = verify_frame_attestation(PROVIDER, &signed, &attestation, &key);
1525 assert_eq!(before, AttestationVerdict::ValidIdentityOnly);
1526
1527 // The same provider re-serves entirely different content under the same
1528 // frame id. The signature is untouched and still verifies, because the
1529 // content was never in the preimage — that is the defect. What must not
1530 // happen is a verdict that calls it `Valid`.
1531 let mut rewritten = signed.clone();
1532 rewritten.content = Some("retry zero times, drop the request".into());
1533
1534 let after = verify_frame_attestation(PROVIDER, &rewritten, &attestation, &key);
1535 assert_eq!(
1536 after,
1537 AttestationVerdict::ValidIdentityOnly,
1538 "rewriting the content of a digest-less frame does not disturb the signature"
1539 );
1540
1541 assert!(
1542 !after.is_valid(),
1543 "an identity-only attestation is not `is_valid`"
1544 );
1545 assert!(!after.binds_content(), "it binds nothing about the content");
1546 assert!(
1547 after.signature_verifies(),
1548 "the signature itself is genuine — that is why this is subtle"
1549 );
1550 }
1551
1552 /// The contrasting case: a frame that declares a digest *is* bound, and
1553 /// altering it is caught as the loud failure it should be.
1554 #[test]
1555 fn a_frame_that_declares_a_digest_is_bound_to_it() {
1556 let signed = frame("f2", "retry three times", Some("sha256:aaaa"));
1557 let attestation = attest(&signed);
1558 let key = public_key_for(&SEED);
1559
1560 let verdict = verify_frame_attestation(PROVIDER, &signed, &attestation, &key);
1561 assert_eq!(verdict, AttestationVerdict::Valid);
1562 assert!(verdict.is_valid() && verdict.binds_content());
1563
1564 // Changing the declared digest changes the preimage, so the recomputed
1565 // commitment no longer matches the signed one.
1566 let mut altered = signed.clone();
1567 altered.content_digest = Some("sha256:bbbb".into());
1568 let verdict = verify_frame_attestation(PROVIDER, &altered, &attestation, &key);
1569 assert!(
1570 matches!(verdict, AttestationVerdict::CommitmentMismatch { .. }),
1571 "got {verdict:?}"
1572 );
1573 }
1574
1575 /// Dropping the digest from a frame that was signed *with* one is a
1576 /// mismatch, not a downgrade. The downgrade path must not become a way to
1577 /// launder a tampered frame into a passing verdict.
1578 #[test]
1579 fn stripping_a_digest_after_signing_is_a_mismatch_not_a_downgrade() {
1580 let signed = frame("f3", "retry three times", Some("sha256:aaaa"));
1581 let attestation = attest(&signed);
1582 let key = public_key_for(&SEED);
1583
1584 let mut stripped = signed.clone();
1585 stripped.content_digest = None;
1586
1587 let verdict = verify_frame_attestation(PROVIDER, &stripped, &attestation, &key);
1588 assert!(
1589 matches!(verdict, AttestationVerdict::CommitmentMismatch { .. }),
1590 "stripping the digest must not downgrade to ValidIdentityOnly; got {verdict:?}"
1591 );
1592 }
1593}
1594
1595#[cfg(all(test, feature = "attestation"))]
1596mod lowercase_hex_tests {
1597 use super::*;
1598
1599 const SEED: [u8; 32] = [9u8; 32];
1600 const PROVIDER: &str = "acme.docs";
1601
1602 fn frame() -> crate::frame::ContextFrame {
1603 let mut f = crate::frame::ContextFrame::full(
1604 "f1",
1605 crate::frame::FrameKind::Doc,
1606 "Retry policy",
1607 "body",
1608 0.9,
1609 1,
1610 );
1611 f.content_digest = Some("sha256:aaaa".into());
1612 f
1613 }
1614
1615 /// The divergence #145 reported: the same attestation, read by the Rust
1616 /// reference and by any SDK port, must reach the same verdict. Uppercase is
1617 /// outside `SPEC.md`'s grammar, every SDK rejects it, and the reference
1618 /// accepted it.
1619 #[test]
1620 fn an_uppercase_commitment_is_malformed_not_valid() {
1621 let f = frame();
1622 let mut att =
1623 sign_frame_attestation(PROVIDER, &f, &SEED, "k1", "acme", "2026-09-10T00:00:00Z");
1624 let key = public_key_for(&SEED);
1625
1626 // Baseline: as emitted, it verifies.
1627 assert_eq!(
1628 verify_frame_attestation(PROVIDER, &f, &att, &key),
1629 AttestationVerdict::Valid
1630 );
1631
1632 // Upper-casing only the hex body leaves the same bytes, spelled the way
1633 // the grammar forbids.
1634 let (scheme, hex) = att.signed_commitment.split_once(':').expect("scheme");
1635 att.signed_commitment = format!("{scheme}:{}", hex.to_uppercase());
1636
1637 assert_eq!(
1638 verify_frame_attestation(PROVIDER, &f, &att, &key),
1639 AttestationVerdict::MalformedCommitment,
1640 "uppercase hex is outside SPEC.md's digest grammar and every SDK rejects it"
1641 );
1642 }
1643
1644 #[test]
1645 fn an_uppercase_signature_is_malformed_not_valid() {
1646 let f = frame();
1647 let mut att =
1648 sign_frame_attestation(PROVIDER, &f, &SEED, "k1", "acme", "2026-09-10T00:00:00Z");
1649 let key = public_key_for(&SEED);
1650
1651 att.signature = att.signature.to_uppercase();
1652
1653 assert_eq!(
1654 verify_frame_attestation(PROVIDER, &f, &att, &key),
1655 AttestationVerdict::MalformedSignature
1656 );
1657 }
1658
1659 /// The narrowing must not touch what the reference *emits*, only what it
1660 /// accepts — otherwise it would break every attestation already written.
1661 #[test]
1662 fn everything_this_module_emits_is_still_lowercase() {
1663 let f = frame();
1664 let att = sign_frame_attestation(PROVIDER, &f, &SEED, "k1", "acme", "2026-09-10T00:00:00Z");
1665 assert_eq!(att.signed_commitment, att.signed_commitment.to_lowercase());
1666 assert_eq!(att.signature, att.signature.to_lowercase());
1667 assert_eq!(
1668 digest_string(&frame_commitment(PROVIDER, &f)),
1669 digest_string(&frame_commitment(PROVIDER, &f)).to_lowercase()
1670 );
1671 }
1672
1673 #[test]
1674 fn lowercase_digits_decode_and_uppercase_ones_do_not() {
1675 assert_eq!(lowercase_hex_digit(b'0'), Some(0));
1676 assert_eq!(lowercase_hex_digit(b'9'), Some(9));
1677 assert_eq!(lowercase_hex_digit(b'a'), Some(10));
1678 assert_eq!(lowercase_hex_digit(b'f'), Some(15));
1679 for byte in *b"AFgG :" {
1680 assert_eq!(
1681 lowercase_hex_digit(byte),
1682 None,
1683 "byte {byte:?} must not decode"
1684 );
1685 }
1686 }
1687}