Skip to main content

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}