Skip to main content

pv_receipts/
receipt.rs

1//! The Receipt: an offline-verifiable, Ed25519-signed record of a session.
2//!
3//! Payload per the brief Module G: session id, agent identity block
4//! (version/charter/instance_uid), tool-call summary, cost, stop reason,
5//! event-chain hash, signature, signer public-key reference. Subjects are an
6//! enum so the same envelope covers signed-workflow chains and retroactive
7//! ATIF promotions (Module H reconciliation).
8//!
9//! Money is carried as integer micro-USD — floats never touch a signed field.
10
11use crate::jcs::canonicalize;
12use crate::keys::{KeyError, Keyring, Signer};
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15
16/// Version of the receipt format itself (evolution surface).
17///
18/// Version history:
19/// * **1** — the signature covers the bare JCS canonicalization of the
20///   body. Legacy; still verifiable (see [`signing_message`]) but never
21///   issued anymore.
22/// * **2** — the signature covers a domain-separated message:
23///   [`RECEIPT_DOMAIN_TAG_V2`] + the u64 big-endian byte length of the
24///   JCS canonical body + the JCS canonical body bytes. This closes a
25///   cross-protocol confusion risk: the same Ed25519 key also
26///   feeds the journal-MAC KDF (`journal.rs`), and a signature over
27///   un-tagged bytes could in principle be replayed as a signature over
28///   some other protocol's message. Mirrors the framing style of
29///   `journal.rs` (`b"agentvisor-journal-v1\0"` + BE length prefixes).
30pub const RECEIPT_VERSION: u32 = 2;
31
32/// Domain-separation tag prepended to every version-2 receipt signing
33/// message. The trailing NUL makes the tag self-delimiting so no JCS
34/// body could extend it into a different tag.
35///
36/// Wire format: every v2 receipt ever issued is signed over these bytes.
37/// They predate the ProofVisor name and must never be renamed.
38pub const RECEIPT_DOMAIN_TAG_V2: &[u8] = b"agentvisor-receipt-v2\0";
39
40/// Build the exact byte message the receipt signature covers for a
41/// given `receipt_version`, from the JCS canonical form of the body.
42///
43/// * version 1: the bare canonical bytes (legacy wire format);
44/// * version 2: `RECEIPT_DOMAIN_TAG_V2 || len(canonical) as u64 BE ||
45///   canonical` — domain-separated and length-framed, mirroring
46///   `journal.rs`;
47/// * any other version: refused with
48///   [`ReceiptError::SemanticInvariant`] (there is no defined framing,
49///   so neither signing nor verifying may proceed).
50///
51/// Public so external tooling and tests can reproduce the signing
52/// message byte-for-byte.
53pub fn signing_message(receipt_version: u32, canonical_body: &str) -> Result<Vec<u8>, ReceiptError> {
54    match receipt_version {
55        1 => Ok(canonical_body.as_bytes().to_vec()),
56        2 => {
57            let mut message = Vec::with_capacity(RECEIPT_DOMAIN_TAG_V2.len() + 8 + canonical_body.len());
58            message.extend_from_slice(RECEIPT_DOMAIN_TAG_V2);
59            message.extend_from_slice(&(canonical_body.len() as u64).to_be_bytes());
60            message.extend_from_slice(canonical_body.as_bytes());
61            Ok(message)
62        }
63        other => Err(ReceiptError::SemanticInvariant(format!(
64            "receipt_version {other} has no defined signature framing (supported: 1, 2)"
65        ))),
66    }
67}
68
69/// What this receipt attests.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
72#[non_exhaustive]
73pub enum ReceiptSubject {
74    /// A signed-workflow session: the OCSF event chain.
75    EventChain {
76        /// Head hash of the session event chain (hex).
77        chain_head: String,
78        /// Number of events in the chain.
79        event_count: u64,
80    },
81    /// An unsigned-workflow trajectory promoted retroactively (Module H).
82    AtifTrajectory {
83        /// SHA-256 of the exported trajectory file bytes (hex).
84        trajectory_digest: String,
85        /// Number of steps in the trajectory.
86        step_count: u64,
87        /// Always true for promotions; kept explicit for auditability.
88        retroactive: bool,
89    },
90}
91
92/// Aggregate tool-call statistics for the session.
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ToolCallSummary {
96    /// Total tool calls observed.
97    pub total: u64,
98    /// Calls allowed by policy.
99    pub allowed: u64,
100    /// Calls blocked by policy/budget/schema.
101    pub blocked: u64,
102}
103
104/// Aggregate cost for the session (integers only — JCS-exact).
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct CostSummary {
108    /// Total prompt tokens.
109    pub prompt_tokens: u64,
110    /// Total completion tokens.
111    pub completion_tokens: u64,
112    /// Total provider-cached tokens.
113    pub cached_tokens: u64,
114    /// Cost in micro-USD (1_000_000 = $1).
115    pub cost_usd_micros: u64,
116}
117
118/// The signed body (everything except the signature itself).
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct ReceiptBody {
122    /// Receipt format version.
123    pub receipt_version: u32,
124    /// Unique receipt id (UUIDv7).
125    pub receipt_id: String,
126    /// Session this receipt covers.
127    pub session_id: String,
128    /// Issuance time, epoch ms.
129    pub issued_at: u64,
130    /// Issuance time, ISO-8601.
131    pub issued_at_iso: String,
132    /// Agent config-state identity block.
133    pub ai_agent: pv_events::AgentIdentity,
134    /// What is attested.
135    pub subject: ReceiptSubject,
136    /// Tool-call summary.
137    pub tool_calls: ToolCallSummary,
138    /// Cost summary.
139    pub cost: CostSummary,
140    /// Final stop reason id.
141    pub stop_reason_id: u8,
142    /// Final stop reason caption.
143    pub stop_reason: String,
144    /// Signer key id.
145    pub key_id: String,
146    /// Signer public key, base64 (self-contained offline verification).
147    pub public_key_b64: String,
148}
149
150/// A complete receipt: body + detached signature over the version's
151/// signing message (see [`signing_message`] — bare `JCS(body)` for v1,
152/// domain-tagged and length-framed `JCS(body)` for v2).
153///
154/// The wire shape is intentionally flat (13 body fields + `signature_b64`
155/// at the top level) — the schemas at `schemas/receipt-v1.schema.json`
156/// and `schemas/receipt-v2.schema.json` commit to it (they differ only
157/// in the pinned `receipt_version`) and downstream verifiers
158/// (`pvctl receipt-verify`, external tools) consume it that way.
159///
160/// `#[serde(flatten)]` normally silently disables `deny_unknown_fields`
161/// on the inner struct, which would let a hostile issuer add extra
162/// top-level fields ("claim_extra": "grant admin") that the human
163/// reviewer sees but `verify()` accepts (the field never survives the
164/// round-trip to `ReceiptBody` used inside `canonicalize`). To close
165/// that gap without breaking the wire shape, the [`Deserialize`] impl
166/// is written by hand and rejects any top-level key outside the
167/// declared whitelist. See the
168/// `allowed_receipt_top_level_keys_cover_body_fields_exactly`
169/// test for the compile-time-ish drift guard.
170#[derive(Debug, Clone, PartialEq, Serialize)]
171pub struct Receipt {
172    /// Signed body.
173    #[serde(flatten)]
174    pub body: ReceiptBody,
175    /// Ed25519 signature, base64. Covers the version's signing message:
176    /// bare `JCS(body)` for v1, [`RECEIPT_DOMAIN_TAG_V2`] + u64-BE
177    /// length + `JCS(body)` for v2.
178    pub signature_b64: String,
179}
180
181/// The 14 top-level keys a receipt is allowed to carry over the wire:
182/// the 13 fields of [`ReceiptBody`] plus [`Receipt::signature_b64`].
183///
184/// Kept in lockstep with [`ReceiptBody`] by
185/// `tests::allowed_receipt_top_level_keys_cover_body_fields_exactly`
186/// — adding a field to `ReceiptBody` without updating this list breaks
187/// that test at CI time.
188const ALLOWED_RECEIPT_TOP_LEVEL_KEYS: &[&str] = &[
189    "receipt_version",
190    "receipt_id",
191    "session_id",
192    "issued_at",
193    "issued_at_iso",
194    "ai_agent",
195    "subject",
196    "tool_calls",
197    "cost",
198    "stop_reason_id",
199    "stop_reason",
200    "key_id",
201    "public_key_b64",
202    "signature_b64",
203];
204
205impl<'de> Deserialize<'de> for Receipt {
206    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
207        use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
208
209        /// Recursively-checked JSON value: refuses duplicate keys and
210        /// explicit `null`s at EVERY depth while parsing. The top-level
211        /// duplicate guard below is not enough — `next_value::<Value>()`
212        /// collapses NESTED duplicates last-wins (serde_json), so a
213        /// hostile issuer could ship `"prompt_tokens":0,
214        /// "prompt_tokens":100` inside `cost`: the signature verifies
215        /// (canonicalization sees the collapsed value) while a
216        /// first-wins parser shows an auditor the other number — the
217        /// exact equivocation the top-level guard exists to refuse.
218        /// Explicit nulls are refused for the same display-vs-canonical
219        /// reason: every optional field is written with
220        /// `skip_serializing_if`, so a null can only be a planted
221        /// artifact that vanishes on reserialization.
222        /// (`Receipt::from_json_slice` enforces both at the bytes level;
223        /// this closes the plain `serde_json::from_*::<Receipt>` path.)
224        struct CheckedValue;
225        struct CheckedValueVisitor;
226        impl<'de> Visitor<'de> for CheckedValueVisitor {
227            type Value = serde_json::Value;
228            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229                f.write_str("a JSON value without duplicate keys or nulls")
230            }
231            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
232                Ok(serde_json::Value::Bool(v))
233            }
234            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
235                Ok(serde_json::Value::from(v))
236            }
237            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
238                Ok(serde_json::Value::from(v))
239            }
240            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
241                Ok(serde_json::Value::from(v))
242            }
243            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
244                Ok(serde_json::Value::String(v.to_owned()))
245            }
246            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
247                Ok(serde_json::Value::String(v))
248            }
249            fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
250                Err(E::custom(
251                    "explicit null in Receipt; optional fields are omitted when absent, \
252                     so a null is a planted artifact that would vanish on reserialization",
253                ))
254            }
255            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
256                let mut items = Vec::new();
257                while let Some(item) = seq.next_element_seed(CheckedValue)? {
258                    items.push(item);
259                }
260                Ok(serde_json::Value::Array(items))
261            }
262            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
263                let mut object = serde_json::Map::new();
264                while let Some(key) = map.next_key::<String>()? {
265                    if object.contains_key(&key) {
266                        return Err(A::Error::custom(format!(
267                            "duplicate field `{key}` nested in Receipt; parsers disagree on \
268                             duplicate-key semantics, so accepting one would let a hostile \
269                             issuer sign one interpretation while auditors read the other"
270                        )));
271                    }
272                    let value = map.next_value_seed(CheckedValue)?;
273                    object.insert(key, value);
274                }
275                Ok(serde_json::Value::Object(object))
276            }
277        }
278        impl<'de> DeserializeSeed<'de> for CheckedValue {
279            type Value = serde_json::Value;
280            fn deserialize<D2: serde::Deserializer<'de>>(
281                self,
282                deserializer: D2,
283            ) -> Result<Self::Value, D2::Error> {
284                deserializer.deserialize_any(CheckedValueVisitor)
285            }
286        }
287
288        // Custom visitor so we can reject duplicate keys explicitly.
289        // `serde_json::Map::deserialize` silently collapses duplicate
290        // keys (last-wins) — RFC 8259 leaves the behaviour undefined
291        // and other JSON parsers (jq's `--sort-keys`, some strict
292        // Python configs, several audit tools) pick first-wins.
293        // A hostile issuer could sign under the last-wins interpretation
294        // while an auditor reading with a first-wins parser saw the
295        // friendly value; the signature would verify but the audit
296        // would show a different receipt.
297        struct ReceiptVisitor;
298        impl<'de> Visitor<'de> for ReceiptVisitor {
299            type Value = Receipt;
300            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301                f.write_str("an ProofVisor Receipt JSON object")
302            }
303            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Receipt, M::Error> {
304                let mut raw = serde_json::Map::new();
305                while let Some(key) = map.next_key::<String>()? {
306                    if !ALLOWED_RECEIPT_TOP_LEVEL_KEYS.contains(&key.as_str()) {
307                        return Err(M::Error::custom(format!(
308                            "unknown field `{key}` in Receipt; a signed receipt \
309                             may only carry the fields declared in ReceiptBody + \
310                             signature_b64. Extra fields would be visible to a raw \
311                             JSON reader but not covered by the signature — treat \
312                             as tampering."
313                        )));
314                    }
315                    if raw.contains_key(&key) {
316                        return Err(M::Error::custom(format!(
317                            "duplicate field `{key}` in Receipt; JSON parsers \
318                             disagree on duplicate-key semantics (last-wins vs \
319                             first-wins), so accepting duplicates would let a \
320                             hostile issuer sign under one interpretation while an \
321                             auditor's parser reports the other"
322                        )));
323                    }
324                    let value = map.next_value_seed(CheckedValue)?;
325                    raw.insert(key, value);
326                }
327                let signature_value = raw
328                    .remove("signature_b64")
329                    .ok_or_else(|| M::Error::missing_field("signature_b64"))?;
330                let signature_b64: String =
331                    serde_json::from_value(signature_value).map_err(M::Error::custom)?;
332                let body: ReceiptBody =
333                    serde_json::from_value(serde_json::Value::Object(raw)).map_err(M::Error::custom)?;
334                Ok(Receipt { body, signature_b64 })
335            }
336        }
337        deserializer.deserialize_map(ReceiptVisitor)
338    }
339}
340
341/// Receipt errors.
342#[derive(Debug, thiserror::Error)]
343#[non_exhaustive]
344pub enum ReceiptError {
345    /// Canonicalization failed (unsafe numbers in body).
346    #[error("canonicalization: {0}")]
347    Jcs(#[from] crate::jcs::JcsError),
348    /// Serialization failed.
349    #[error("serialization: {0}")]
350    Serde(#[from] serde_json::Error),
351    /// Key/signature failure.
352    #[error("key: {0}")]
353    Key(#[from] KeyError),
354    /// Signature or public key not valid base64.
355    #[error("invalid base64 in receipt")]
356    Base64,
357    /// The receipt's embedded public key does not match the keyring entry for
358    /// its key id (substitution attempt).
359    #[error("embedded public key mismatches keyring entry for {0:?}")]
360    KeyMismatch(String),
361    /// A JSON object at some nesting level carried a
362    /// duplicate key. Rejecting these closes the last-wins vs
363    /// first-wins auditor split-brain that would otherwise let a
364    /// hostile issuer sign under one interpretation while an
365    /// external audit tool displayed the other.
366    ///
367    /// The payload is a complete scanner message (duplicate key,
368    /// nesting-depth, or explicit-null refusal), so the template
369    /// must not re-wrap it in a `key`-shaped slot — a
370    /// "duplicate JSON key `{0}` at nesting" template produced garbled
371    /// double-wrapped diagnostics like ``duplicate JSON key
372    /// `duplicate key `x` in JSON object …` at nesting``.
373    #[error("{0}; strict receipt parsers refuse this")]
374    DuplicateKey(String),
375    /// A receipt carries a semantically-invalid field.
376    /// Enforced by `verify_semantic_invariants`: supported
377    /// `receipt_version` ∈ {1, 2}, `ai_agent.charter.type_id == 1`
378    /// (OCSF Regular File), non-empty required string fields
379    /// (`receipt_id`, `session_id`, `ai_agent.charter.name`,
380    /// `ai_agent.charter.instance_uid`), 64-hex `chain_head` and
381    /// per-step `trajectory_digest`, `issued_at_iso ==
382    /// iso8601_ms(issued_at)`, stop-reason ↔ stop-reason-id
383    /// cross-wiring, and `AtifTrajectory.retroactive == true`.
384    /// Distinct from `Serde` and `DuplicateKey` so ops triage
385    /// can name the class.
386    #[error("semantic invariant violated: {0}")]
387    SemanticInvariant(String),
388}
389
390impl Receipt {
391    /// Deserialize a receipt from wire bytes, rejecting any duplicate
392    /// key at ANY nesting level.
393    ///
394    /// The custom `Deserialize` impl for `Receipt` (below) already
395    /// rejects duplicate top-level keys, but nested structs
396    /// (`ai_agent`, `tool_calls`, `cost`, `subject`) went through
397    /// serde's default derive which relies on `serde_json::Map` /
398    /// `IndexMap` — both silently collapse duplicate keys with
399    /// last-wins semantics. A hostile issuer could then sign a
400    /// receipt whose `ai_agent.instance_uid` appeared twice
401    /// (`"a"` then `"b"`); JCS canonicalisation saw only `"b"` so
402    /// the signature verified, but a first-wins auditor tool (jq's
403    /// default, some Python configs) displayed `"a"` — the exact
404    /// split-brain the top-level guard was written to prevent, one
405    /// level deeper.
406    ///
407    /// Pre-scans the JSON with a strict duplicate-key
408    /// checker (`check_no_duplicate_keys`) before deserialising into
409    /// `Receipt`. Callers verifying a receipt off the wire should
410    /// prefer this over `serde_json::from_slice::<Receipt>`.
411    pub fn from_json_slice(bytes: &[u8]) -> Result<Self, ReceiptError> {
412        check_no_duplicate_keys(bytes)?;
413        serde_json::from_slice(bytes).map_err(ReceiptError::Serde)
414    }
415
416    /// Same as [`Receipt::from_json_slice`] for owned/borrowed strings.
417    pub fn from_json_str(s: &str) -> Result<Self, ReceiptError> {
418        Self::from_json_slice(s.as_bytes())
419    }
420
421    /// Issue (sign) a receipt over `body` with `signer`.
422    ///
423    /// The `key_id` and `public_key_b64` fields of the body are overwritten
424    /// from the signer — a caller can never claim someone else's key identity.
425    /// `receipt_version` is likewise overwritten to [`RECEIPT_VERSION`]:
426    /// every freshly issued receipt uses the current (domain-separated)
427    /// signing format, so no issuance path can produce a legacy v1
428    /// signature.
429    pub fn issue(mut body: ReceiptBody, signer: &dyn Signer) -> Result<Self, ReceiptError> {
430        body.receipt_version = RECEIPT_VERSION;
431        body.key_id = signer.key_id().to_owned();
432        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
433        let canon = canonicalize(&serde_json::to_value(&body)?)?;
434        let message = signing_message(body.receipt_version, &canon)?;
435        let sig = signer.sign(&message);
436        let receipt = Self {
437            body,
438            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
439        };
440        // Never mint a receipt this crate's own verifier refuses:
441        // issuance used to sign ANY canonicalizable body (empty
442        // session_id, retroactive:false trajectory subjects,
443        // inconsistent timestamps, allowed+blocked > total) and the
444        // dead receipt only surfaced when a verifier finally ran —
445        // long after the session evidence that could have re-minted
446        // it was gone. verify_embedded runs the full semantic
447        // invariant set plus the signature round-trip (~one Ed25519
448        // verify per session close — noise next to the sign itself).
449        receipt.verify_embedded()?;
450        Ok(receipt)
451    }
452
453    /// Sign WITHOUT the issue-time validation — test-only, for pinning
454    /// that `verify` refuses hostile-keyholder receipts that `issue`
455    /// now refuses to mint in the first place.
456    #[cfg(test)]
457    pub(crate) fn issue_unchecked(mut body: ReceiptBody, signer: &dyn Signer) -> Result<Self, ReceiptError> {
458        body.receipt_version = RECEIPT_VERSION;
459        body.key_id = signer.key_id().to_owned();
460        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
461        let canon = canonicalize(&serde_json::to_value(&body)?)?;
462        let message = signing_message(body.receipt_version, &canon)?;
463        let sig = signer.sign(&message);
464        Ok(Self {
465            body,
466            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
467        })
468    }
469
470    /// Verify offline against a keyring. Checks, in execution order:
471    /// 1. `AtifTrajectory.retroactive == true` (
472    ///    `retroactive: false` on an ATIF-promoted receipt is
473    ///    semantically nonsense; the schema pins it to `const: true`
474    ///    but the Rust type still accepts `false`; refuse it here so
475    ///    the two agree);
476    /// 2. the embedded public key matches the ring's key for `key_id`
477    ///    (anti-substitution);
478    /// 3. the signature verifies over the version's signing message
479    ///    (bare `JCS(body)` for v1; domain-tagged, length-framed
480    ///    `JCS(body)` for v2 — see [`signing_message`]).
481    pub fn verify(&self, ring: &Keyring) -> Result<(), ReceiptError> {
482        self.verify_semantic_invariants()?;
483        let embedded = base64::engine::general_purpose::STANDARD
484            .decode(&self.body.public_key_b64)
485            .map_err(|_| ReceiptError::Base64)?;
486        let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
487        // Re-derive the ring id for the embedded key; it must equal the stated
488        // key id AND exist in the ring with the same bytes.
489        let mut probe = Keyring::new();
490        let derived_id = probe.add_key_bytes(&embedded)?;
491        if derived_id != self.body.key_id {
492            return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
493        }
494        let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
495        let message = signing_message(self.body.receipt_version, &canon)?;
496        let sig = base64::engine::general_purpose::STANDARD
497            .decode(&self.signature_b64)
498            .map_err(|_| ReceiptError::Base64)?;
499        ring.verify(&self.body.key_id, &message, &sig)?;
500        Ok(())
501    }
502
503    /// **SECURITY WARNING — proves internal consistency, NOT authenticity.**
504    ///
505    /// Verifies the signature under the receipt's OWN embedded
506    /// `public_key_b64`. An attacker who generates a fresh Ed25519 keypair
507    /// and signs an arbitrary body with it will pass this check. The
508    /// result is equivalent to "this JSON blob is syntactically a
509    /// well-formed receipt whose signature is consistent with the key it
510    /// embeds"; it says nothing about whether the KEY is one the verifier
511    /// should trust.
512    ///
513    /// This method is safe ONLY when:
514    ///   1. The receipt was obtained over an authenticated channel that
515    ///      binds it to a trusted origin (e.g. a mutually-authenticated
516    ///      TLS connection to a known signer), **and/or**
517    ///   2. The caller separately pins `receipt.body.key_id` against a
518    ///      known-good allowlist and rejects any mismatch BEFORE reading
519    ///      any receipt content.
520    ///
521    /// For general receipt verification, use [`Receipt::verify`] with a
522    /// [`Keyring`] pre-populated with trusted keys. The browser `/verify`
523    /// page enforces the same discipline via `TRUSTED_RECEIPT_KEYS`
524    /// (docs/verify/verify.js) and shows an "internally consistent — trust
525    /// anchor NOT verified" warning without a pinned key; downstream Rust
526    /// callers must maintain the same distinction.
527    pub fn verify_embedded(&self) -> Result<(), ReceiptError> {
528        self.verify_semantic_invariants()?;
529        let embedded = base64::engine::general_purpose::STANDARD
530            .decode(&self.body.public_key_b64)
531            .map_err(|_| ReceiptError::Base64)?;
532        let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
533        let mut ring = Keyring::new();
534        let id = ring.add_key_bytes(&embedded)?;
535        if id != self.body.key_id {
536            return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
537        }
538        let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
539        let message = signing_message(self.body.receipt_version, &canon)?;
540        let sig = base64::engine::general_purpose::STANDARD
541            .decode(&self.signature_b64)
542            .map_err(|_| ReceiptError::Base64)?;
543        ring.verify(&id, &message, &sig)?;
544        Ok(())
545    }
546
547    /// Check semantic invariants that the type system
548    /// cannot express. Currently enforces: supported
549    /// `receipt_version` ∈ {1, 2}, `ai_agent.charter.type_id == 1`
550    /// (OCSF Regular File), non-empty required string fields
551    /// (`receipt_id`, `session_id`, `ai_agent.charter.name`,
552    /// `ai_agent.charter.instance_uid`), 64-hex `chain_head` and
553    /// per-step `trajectory_digest`, `issued_at_iso ==
554    /// iso8601_ms(issued_at)`, stop-reason ↔ stop-reason-id
555    /// cross-wiring, and `AtifTrajectory.retroactive == true`
556    /// (schema pins it via `const: true`; the Rust type is `bool`
557    /// for auditability). Called from both `verify` and
558    /// `verify_embedded` so no verification path can skip it.
559    fn verify_semantic_invariants(&self) -> Result<(), ReceiptError> {
560        // Schema parity: `receipt_version` is pinned per-version in the
561        // shipped schemas (`const: 1` in receipt-v1, `const: 2` in
562        // receipt-v2) and the subject digests are pinned
563        // `^[0-9a-f]{64}$`, but verification never checked either — a
564        // keyholder could issue `receipt_version: 999` or free-text
565        // digests that `pvctl receipt-verify` accepted while
566        // schema-based external verifiers rejected (split
567        // offline-verification verdicts). Versions 1 (bare-JCS
568        // signature) and 2 (domain-separated signature) are the only
569        // ones with a defined signing message; anything else is
570        // refused before a signature check could misinterpret it.
571        if !matches!(self.body.receipt_version, 1 | 2) {
572            return Err(ReceiptError::SemanticInvariant(format!(
573                "receipt_version {} is not a supported version (1 or 2)",
574                self.body.receipt_version
575            )));
576        }
577        // Schema parity: the receipt schema (and the events validator)
578        // pin `ai_agent.charter.type_id` to the OCSF Regular File
579        // const 1; in-tree issuers always set it, so this only rejects
580        // inbound documents that external schema verifiers already
581        // refuse — keeping the two verification paths in agreement.
582        if self.body.ai_agent.charter.type_id != 1 {
583            return Err(ReceiptError::SemanticInvariant(format!(
584                "ai_agent.charter.type_id {} is not Regular File (1)",
585                self.body.ai_agent.charter.type_id
586            )));
587        }
588        // Schema parity: the shipped schema pins `minLength: 1` on these
589        // string fields. In-tree issuers always populate them, so this
590        // only rejects documents that external schema-based verifiers
591        // already refuse — without it, a keyholder could sign a receipt
592        // with an empty session_id/instance_uid that `pvctl
593        // receipt-verify` accepted while schema verifiers rejected
594        // (split offline-verification verdicts, same class as the
595        // receipt_version / issued_at_iso checks around this one).
596        for (field, value) in [
597            ("receipt_id", self.body.receipt_id.as_str()),
598            ("session_id", self.body.session_id.as_str()),
599            ("ai_agent.version", self.body.ai_agent.version.as_str()),
600            ("ai_agent.charter.name", self.body.ai_agent.charter.name.as_str()),
601            ("ai_agent.instance_uid", self.body.ai_agent.instance_uid.as_str()),
602        ] {
603            if value.is_empty() {
604                return Err(ReceiptError::SemanticInvariant(format!(
605                    "{field} is empty (schema requires minLength 1)"
606                )));
607            }
608        }
609        // Schema parity, upper bound: the shipped schemas pin
610        // `maxLength: 128` (code points) on the identifier fields, and
611        // RFC 3339 `date-time` cannot express years past 9999. A
612        // keyholder could otherwise sign a 10-KB session_id or a
613        // year-10000 timestamp that this verifier accepted while every
614        // schema-based verifier refused — the same split-verdict class
615        // as the emptiness checks above, in the other direction.
616        for (field, value) in [
617            ("receipt_id", self.body.receipt_id.as_str()),
618            ("session_id", self.body.session_id.as_str()),
619            ("ai_agent.version", self.body.ai_agent.version.as_str()),
620            ("ai_agent.charter.name", self.body.ai_agent.charter.name.as_str()),
621            ("ai_agent.instance_uid", self.body.ai_agent.instance_uid.as_str()),
622        ] {
623            if value.chars().count() > 128 {
624                return Err(ReceiptError::SemanticInvariant(format!(
625                    "{field} exceeds 128 characters (schema maxLength)"
626                )));
627            }
628        }
629        // 9999-12-31T23:59:59.999Z in epoch ms — the last instant RFC
630        // 3339 date-time (and iso8601_ms's 24-char shape) can carry.
631        const MAX_ISSUED_AT_MS: u64 = 253_402_300_799_999;
632        if self.body.issued_at > MAX_ISSUED_AT_MS {
633            return Err(ReceiptError::SemanticInvariant(format!(
634                "issued_at {} is beyond year 9999 (RFC 3339 date-time cannot express it)",
635                self.body.issued_at
636            )));
637        }
638        let digest_ok = |digest: &str| {
639            digest.len() == 64
640                && digest
641                    .bytes()
642                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
643        };
644        match &self.body.subject {
645            ReceiptSubject::EventChain { chain_head, .. } => {
646                if !digest_ok(chain_head) {
647                    return Err(ReceiptError::SemanticInvariant(
648                        "chain_head is not a lowercase 64-hex digest".to_owned(),
649                    ));
650                }
651            }
652            ReceiptSubject::AtifTrajectory {
653                trajectory_digest, ..
654            } => {
655                if !digest_ok(trajectory_digest) {
656                    return Err(ReceiptError::SemanticInvariant(
657                        "trajectory_digest is not a lowercase 64-hex digest".to_owned(),
658                    ));
659                }
660            }
661        }
662        if let ReceiptSubject::AtifTrajectory { retroactive, .. } = &self.body.subject {
663            if !retroactive {
664                return Err(ReceiptError::SemanticInvariant(
665                    "AtifTrajectory.retroactive must be true (this receipt attests a retroactive promotion)"
666                        .to_owned(),
667                ));
668            }
669        }
670        // Cross-check `issued_at_iso` against the
671        // authoritative epoch-ms `issued_at`. Checking
672        // only shapes plus the retroactive invariant would let a
673        // hostile-but-keyholding issuer sign a receipt whose ISO
674        // caption and epoch-ms disagreed — auditors reading ISO and
675        // tooling reading ms would see different facts from the same
676        // verified receipt. Derive the ISO deterministically and
677        // require equality.
678        let expected_iso = pv_core::time::iso8601_ms(self.body.issued_at);
679        if self.body.issued_at_iso != expected_iso {
680            return Err(ReceiptError::SemanticInvariant(format!(
681                "issued_at_iso {:?} does not match issued_at ({}); expected {:?}",
682                self.body.issued_at_iso, self.body.issued_at, expected_iso
683            )));
684        }
685        // Cross-check the stop-reason dual representation — the same
686        // split-brain class as issued_at/issued_at_iso above, but with
687        // the same tolerance the events validator uses:
688        // `stop_reason` may legitimately carry
689        // a provider-native or free-text caption, so equality with the
690        // canonical caption cannot be required. Refuse only the
691        // unambiguous cross-wiring where the caption IS the canonical
692        // caption of a DIFFERENT known variant than `stop_reason_id`
693        // names (e.g. id 92 "Budget Exceeded" paired with caption
694        // "Stop") — the two signed fields then assert contradictory
695        // outcomes to auditors reading the caption vs tooling reading
696        // the id.
697        let expected_caption = pv_events::StopReason::from_id(self.body.stop_reason_id).caption();
698        let id_is_known = matches!(self.body.stop_reason_id, 0..=4 | 90..=94 | 99);
699        let caption_is_canonical_of_other_variant = id_is_known
700            && self.body.stop_reason != expected_caption
701            && [
702                pv_events::StopReason::Unknown,
703                pv_events::StopReason::Stop,
704                pv_events::StopReason::MaxTokens,
705                pv_events::StopReason::ToolUse,
706                pv_events::StopReason::SessionClosed,
707                pv_events::StopReason::ContentFilter,
708                pv_events::StopReason::LoopDetected,
709                pv_events::StopReason::BudgetExceeded,
710                pv_events::StopReason::PolicyBlocked,
711                pv_events::StopReason::IdentityRejected,
712                pv_events::StopReason::Other,
713            ]
714            .iter()
715            .any(|variant| variant.caption() == self.body.stop_reason);
716        if caption_is_canonical_of_other_variant {
717            return Err(ReceiptError::SemanticInvariant(format!(
718                "stop_reason {:?} is the canonical caption of a different variant than stop_reason_id ({}); expected {:?} or a non-canonical native caption",
719                self.body.stop_reason, self.body.stop_reason_id, expected_caption
720            )));
721        }
722        // Tool-accounting invariant: classified outcomes can never
723        // exceed observed calls. The issuer maintains this by
724        // construction and journal recovery hard-fails on its violation
725        // (`RecoveredTotals::validate_tool_accounting`), but
726        // verification never checked it — a keyholding issuer could
727        // sign `total: 1, allowed: 1_000_000` and every offline
728        // verifier would attest arithmetic the pipeline itself refuses
729        // to recover. Same cross-field class as issued_at/issued_at_iso
730        // above (the JSON Schema cannot express cross-field sums).
731        let classified = self
732            .body
733            .tool_calls
734            .allowed
735            .checked_add(self.body.tool_calls.blocked);
736        if classified.is_none_or(|classified| classified > self.body.tool_calls.total) {
737            return Err(ReceiptError::SemanticInvariant(format!(
738                "tool_calls allowed ({}) + blocked ({}) exceeds total ({})",
739                self.body.tool_calls.allowed, self.body.tool_calls.blocked, self.body.tool_calls.total
740            )));
741        }
742        Ok(())
743    }
744}
745
746/// Walk a JSON document and reject any object whose keys contain a
747/// duplicate at ANY nesting level.
748///
749/// Used by [`Receipt::from_json_slice`] as a strict pre-parse gate.
750/// serde_json's default `Value` / `Map` uses `IndexMap` (with
751/// `preserve_order`), which silently keeps only the LAST value for a
752/// duplicate key at deserialize time — collapsing evidence that a
753/// hostile issuer signed a document with two spellings of the same
754/// key and a first-wins auditor would see the earlier one. This
755/// walk runs upfront so the collapse never happens.
756///
757/// Also enforce a hard depth cap so a hostile issuer
758/// cannot use a deeply-nested `[[[[…]]]]` payload to blow the walker's
759/// stack. serde_json's own limit is 128 by default, but the cost of
760/// a per-level check is a single integer increment.
761const MAX_NESTED_DEPTH: usize = 128;
762
763/// Sentinel prefix that lets `check_no_duplicate_keys` distinguish a
764/// duplicate-key rejection (mapped to `ReceiptError::DuplicateKey`)
765/// from a general parse failure (mapped to `ReceiptError::Serde`).
766/// A blanket `.map_err(DuplicateKey)` would fold
767/// serde_json's own EOF / recursion / expected-value errors into the
768/// DuplicateKey variant, actively misleading ops.
769const DUP_KEY_SENTINEL: &str = "__av_dup:";
770
771fn check_no_duplicate_keys(bytes: &[u8]) -> Result<(), ReceiptError> {
772    use serde::Deserializer as _;
773    let mut deser = serde_json::Deserializer::from_slice(bytes);
774    match deser.deserialize_any(NoDupVisitor { depth: 0 }) {
775        Ok(()) => Ok(()),
776        Err(error) => {
777            let msg = error.to_string();
778            if let Some(rest) = msg.strip_prefix(DUP_KEY_SENTINEL) {
779                Err(ReceiptError::DuplicateKey(rest.to_owned()))
780            } else if msg.contains("recursion limit exceeded") {
781                // Preserve the intent of the depth check even when
782                // serde_json fires first, so callers still see a
783                // DuplicateKey-family error class for hostile
784                // deeply-nested input.
785                Err(ReceiptError::DuplicateKey(msg))
786            } else {
787                // Genuine parse error (EOF, expected value, invalid
788                // number). Bubble the underlying serde_json::Error
789                // via Serde so tracing sites don't misattribute.
790                Err(ReceiptError::Serde(error))
791            }
792        }
793    }
794}
795
796struct NoDupVisitor {
797    depth: usize,
798}
799
800impl<'de> serde::de::Visitor<'de> for NoDupVisitor {
801    type Value = ();
802    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803        f.write_str("any JSON value (strict-mode duplicate-key check)")
804    }
805    fn visit_bool<E>(self, _: bool) -> Result<(), E> {
806        Ok(())
807    }
808    fn visit_i64<E>(self, _: i64) -> Result<(), E> {
809        Ok(())
810    }
811    fn visit_u64<E>(self, _: u64) -> Result<(), E> {
812        Ok(())
813    }
814    fn visit_f64<E>(self, _: f64) -> Result<(), E> {
815        Ok(())
816    }
817    fn visit_i128<E>(self, _: i128) -> Result<(), E> {
818        Ok(())
819    }
820    fn visit_u128<E>(self, _: u128) -> Result<(), E> {
821        Ok(())
822    }
823    fn visit_str<E>(self, _: &str) -> Result<(), E> {
824        Ok(())
825    }
826    fn visit_string<E>(self, _: String) -> Result<(), E> {
827        Ok(())
828    }
829    /// Serde may invoke `visit_none` for `Option` layers that are
830    /// already Nothing; `deserialize_any` on JSON never routes there
831    /// (JSON null goes through `visit_unit`), so this remains a
832    /// no-op fallback that only fires if a future caller layers a
833    /// typed deserializer on top.
834    fn visit_none<E>(self) -> Result<(), E> {
835        Ok(())
836    }
837
838    /// Reject explicit JSON `null` anywhere
839    /// in the receipt wire form. Every optional field on `ReceiptBody`
840    /// / `AgentIdentity` / `EventMetrics` / … carries
841    /// `#[serde(skip_serializing_if = "Option::is_none")]`, so
842    /// `Option::None` is emitted as *field absence*. But
843    /// `serde_json::from_slice` also accepts `"field": null` for those
844    /// same Options — and `Receipt::verify` re-canonicalises via
845    /// `serde_json::to_value(&self.body)`, which drops the `None`
846    /// again. The consequence: an intermediary can flip `"…": null`
847    /// on/off in the raw bytes without invalidating the signature,
848    /// violating the "same signature ⇔ same bytes" auditor
849    /// invariant. Refuse explicit null across the whole payload
850    /// (receipts contain no legitimate JSON null anywhere — top level
851    /// is an object; every field is either a value type, a container
852    /// with omit-on-none, or a bounded set-of-strings enum).
853    fn visit_unit<E: serde::de::Error>(self) -> Result<(), E> {
854        Err(E::custom(format!(
855            "{DUP_KEY_SENTINEL}explicit JSON null is not permitted (a signed absent-Option \
856             cannot be re-serialised as null without breaking canonicalisation invariance)"
857        )))
858    }
859    fn visit_seq<S: serde::de::SeqAccess<'de>>(self, mut seq: S) -> Result<(), S::Error> {
860        use serde::de::Error as _;
861        let next_depth = self.depth.saturating_add(1);
862        if next_depth > MAX_NESTED_DEPTH {
863            return Err(S::Error::custom(format!(
864                "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
865            )));
866        }
867        while seq.next_element_seed(NoDupSeed { depth: next_depth })?.is_some() {}
868        Ok(())
869    }
870    fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<(), M::Error> {
871        use serde::de::Error as _;
872        let next_depth = self.depth.saturating_add(1);
873        if next_depth > MAX_NESTED_DEPTH {
874            return Err(M::Error::custom(format!(
875                "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
876            )));
877        }
878        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
879        while let Some(key) = map.next_key::<String>()? {
880            if !seen.insert(key.clone()) {
881                // Escape the key so a hostile spelling
882                // containing `\n` or ANSI escapes cannot inject a
883                // fake extra log line at every tracing site that
884                // logs the resulting error via `%error`.
885                return Err(M::Error::custom(format!(
886                    "{DUP_KEY_SENTINEL}duplicate key `{}` in JSON object",
887                    key.escape_debug()
888                )));
889            }
890            map.next_value_seed(NoDupSeed { depth: next_depth })?;
891        }
892        Ok(())
893    }
894}
895
896struct NoDupSeed {
897    depth: usize,
898}
899
900impl<'de> serde::de::DeserializeSeed<'de> for NoDupSeed {
901    type Value = ();
902    fn deserialize<D: serde::Deserializer<'de>>(self, deser: D) -> Result<(), D::Error> {
903        deser.deserialize_any(NoDupVisitor { depth: self.depth })
904    }
905}
906
907/// Convenience constructor filling issuance time and ids.
908#[allow(clippy::too_many_arguments)]
909pub fn new_body(
910    session_id: String,
911    ai_agent: pv_events::AgentIdentity,
912    subject: ReceiptSubject,
913    tool_calls: ToolCallSummary,
914    cost: CostSummary,
915    stop_reason: pv_events::StopReason,
916) -> ReceiptBody {
917    let now = pv_core::time::now_ms();
918    ReceiptBody {
919        receipt_version: RECEIPT_VERSION,
920        receipt_id: pv_core::new_event_uid(),
921        session_id,
922        issued_at: now,
923        issued_at_iso: pv_core::time::iso8601_ms(now),
924        ai_agent,
925        subject,
926        tool_calls,
927        cost,
928        stop_reason_id: stop_reason.id(),
929        stop_reason: stop_reason.caption().to_owned(),
930        key_id: String::new(),         // filled by issue()
931        public_key_b64: String::new(), // filled by issue()
932    }
933}
934
935#[cfg(test)]
936mod tests {
937    #![allow(
938        clippy::unwrap_used,
939        clippy::expect_used,
940        clippy::panic,
941        clippy::indexing_slicing
942    )]
943
944    use super::*;
945    use crate::keys::Ed25519Signer;
946
947    fn body() -> ReceiptBody {
948        new_body(
949            "sess-77".into(),
950            pv_events::AgentIdentity {
951                version: "2.0.1".into(),
952                charter: "payments".into(),
953                instance_uid: "inst-9".into(),
954                ttl_remaining_s: None,
955            },
956            ReceiptSubject::EventChain {
957                chain_head: "ab".repeat(32),
958                event_count: 41,
959            },
960            ToolCallSummary {
961                total: 12,
962                allowed: 10,
963                blocked: 2,
964            },
965            CostSummary {
966                prompt_tokens: 52_000,
967                completion_tokens: 9_000,
968                cached_tokens: 30_000,
969                cost_usd_micros: 137_500,
970            },
971            pv_events::StopReason::SessionClosed,
972        )
973    }
974
975    #[test]
976    fn issue_verify_roundtrip() {
977        let signer = Ed25519Signer::generate();
978        let mut ring = Keyring::new();
979        ring.add_signer(&signer).unwrap();
980        let receipt = Receipt::issue(body(), &signer).unwrap();
981        assert_eq!(
982            receipt.body.receipt_version, 2,
983            "freshly issued receipts must use the domain-separated v2 format"
984        );
985        receipt.verify(&ring).unwrap();
986        receipt.verify_embedded().unwrap();
987    }
988
989    /// `issue` must overwrite a caller-supplied `receipt_version` the
990    /// same way it overwrites `key_id`/`public_key_b64` — otherwise a
991    /// caller passing a stale body could still mint bare-JCS (v1)
992    /// signatures, reopening the cross-protocol confusion the v2
993    /// domain tag closes.
994    #[test]
995    fn issue_overwrites_caller_supplied_receipt_version() {
996        let signer = Ed25519Signer::generate();
997        let mut b = body();
998        b.receipt_version = 1;
999        let receipt = Receipt::issue(b, &signer).unwrap();
1000        assert_eq!(receipt.body.receipt_version, RECEIPT_VERSION);
1001        receipt.verify_embedded().unwrap();
1002    }
1003
1004    /// Sign `body` under the LEGACY v1 wire format: `receipt_version: 1`
1005    /// and a signature over the bare JCS canonical bytes (no domain tag,
1006    /// no length framing). This is what every pre-v2 deployment wrote
1007    /// to disk; the verifier must keep accepting it.
1008    fn issue_v1(mut body: ReceiptBody, signer: &Ed25519Signer) -> Receipt {
1009        body.receipt_version = 1;
1010        body.key_id = signer.key_id().to_owned();
1011        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
1012        let canon = canonicalize(&serde_json::to_value(&body).unwrap()).unwrap();
1013        let sig = signer.sign(canon.as_bytes());
1014        Receipt {
1015            body,
1016            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
1017        }
1018    }
1019
1020    /// Migration window: a v1 receipt (bare-JCS signature) issued by an
1021    /// old deployment must still verify on both paths.
1022    #[test]
1023    fn legacy_v1_receipt_still_verifies_on_both_paths() {
1024        let signer = Ed25519Signer::generate();
1025        let mut ring = Keyring::new();
1026        ring.add_signer(&signer).unwrap();
1027        let receipt = issue_v1(body(), &signer);
1028        assert_eq!(receipt.body.receipt_version, 1);
1029        receipt.verify(&ring).unwrap();
1030        receipt.verify_embedded().unwrap();
1031    }
1032
1033    /// Cross-version confusion: a receipt labeled `receipt_version:
1034    /// 2` whose signature was computed WITHOUT the domain prefix (i.e. a
1035    /// v1-style bare-JCS signature) must be refused as `BadSignature` —
1036    /// the domain tag is not decorative, it is signature-covered.
1037    #[test]
1038    fn v2_receipt_with_bare_jcs_signature_is_refused() {
1039        let signer = Ed25519Signer::generate();
1040        let mut ring = Keyring::new();
1041        ring.add_signer(&signer).unwrap();
1042        let mut b = body();
1043        b.receipt_version = 2;
1044        b.key_id = signer.key_id().to_owned();
1045        b.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
1046        let canon = canonicalize(&serde_json::to_value(&b).unwrap()).unwrap();
1047        // Bare-JCS signature (no tag, no length prefix) over a body
1048        // that CLAIMS the domain-separated format.
1049        let sig = signer.sign(canon.as_bytes());
1050        let confused = Receipt {
1051            body: b,
1052            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
1053        };
1054        assert!(
1055            matches!(
1056                confused.verify(&ring),
1057                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1058            ),
1059            "v2 receipt with an un-tagged signature must fail as BadSignature"
1060        );
1061        assert!(
1062            matches!(
1063                confused.verify_embedded(),
1064                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1065            ),
1066            "embedded path must refuse the un-tagged v2 signature too"
1067        );
1068    }
1069
1070    /// Relabeling both ways: a body honestly signed under one
1071    /// version must not verify when its `receipt_version` label claims
1072    /// the other version. The version field is inside the signed body,
1073    /// so relabeling changes the canonical bytes AND selects a
1074    /// different signing message — both paths must land in
1075    /// `BadSignature`, never in silent acceptance.
1076    #[test]
1077    fn version_relabeling_is_refused_in_both_directions() {
1078        let signer = Ed25519Signer::generate();
1079        let mut ring = Keyring::new();
1080        ring.add_signer(&signer).unwrap();
1081
1082        // v1-signed body relabeled as v2.
1083        let mut up = issue_v1(body(), &signer);
1084        up.body.receipt_version = 2;
1085        assert!(
1086            matches!(
1087                up.verify(&ring),
1088                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1089            ),
1090            "v1 signature relabeled as v2 must fail as BadSignature"
1091        );
1092        assert!(matches!(
1093            up.verify_embedded(),
1094            Err(ReceiptError::Key(KeyError::BadSignature(_)))
1095        ));
1096
1097        // v2-signed body relabeled as v1.
1098        let mut down = Receipt::issue(body(), &signer).unwrap();
1099        down.body.receipt_version = 1;
1100        assert!(
1101            matches!(
1102                down.verify(&ring),
1103                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1104            ),
1105            "v2 signature relabeled as v1 must fail as BadSignature"
1106        );
1107        assert!(matches!(
1108            down.verify_embedded(),
1109            Err(ReceiptError::Key(KeyError::BadSignature(_)))
1110        ));
1111    }
1112
1113    /// The v2 signing message framing is pinned byte-for-byte: tag,
1114    /// u64 big-endian length of the canonical bytes, canonical bytes.
1115    /// An external reimplementation (VERIFYING-A-RECEIPT.md §5) relies
1116    /// on exactly this layout.
1117    #[test]
1118    fn v2_signing_message_framing_is_tag_belen_canon() {
1119        let canon = r#"{"a":1}"#;
1120        let message = signing_message(2, canon).unwrap();
1121        let mut expected = b"agentvisor-receipt-v2\0".to_vec();
1122        expected.extend_from_slice(&(canon.len() as u64).to_be_bytes());
1123        expected.extend_from_slice(canon.as_bytes());
1124        assert_eq!(message, expected);
1125        // v1 is the bare canonical bytes.
1126        assert_eq!(signing_message(1, canon).unwrap(), canon.as_bytes());
1127        // Undefined versions have no framing.
1128        assert!(matches!(
1129            signing_message(3, canon),
1130            Err(ReceiptError::SemanticInvariant(_))
1131        ));
1132    }
1133
1134    /// The `AtifTrajectory.retroactive == true`
1135    /// semantic invariant needs a test — a mutation run caught
1136    /// `verify_semantic_invariants -> Ok(())` surviving, meaning a
1137    /// forged non-retroactive promotion receipt verified fine. Pin
1138    /// both verification paths: a `retroactive: false` receipt must
1139    /// fail as SemanticInvariant even though its signature is valid,
1140    /// and the honest `retroactive: true` twin must pass.
1141    #[test]
1142    fn retroactive_false_is_refused_on_both_verify_paths() {
1143        let signer = Ed25519Signer::generate();
1144        let mut ring = Keyring::new();
1145        ring.add_signer(&signer).unwrap();
1146        let subject = |retroactive| ReceiptSubject::AtifTrajectory {
1147            trajectory_digest: "cd".repeat(32),
1148            step_count: 7,
1149            retroactive,
1150        };
1151        let make = |retroactive| {
1152            let mut body = body();
1153            body.subject = subject(retroactive);
1154            Receipt::issue_unchecked(body, &signer).unwrap()
1155        };
1156
1157        let forged = make(false);
1158        for outcome in [forged.verify(&ring), forged.verify_embedded()] {
1159            assert!(
1160                matches!(outcome, Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("retroactive")),
1161                "retroactive=false must be refused, got {outcome:?}"
1162            );
1163        }
1164
1165        let honest = make(true);
1166        honest.verify(&ring).unwrap();
1167        honest.verify_embedded().unwrap();
1168    }
1169
1170    /// The duplicate-key scanner doubles as the nesting-depth guard.
1171    /// Pin the boundary exactly: depth == MAX_NESTED_DEPTH - 1 parses,
1172    /// depth == MAX_NESTED_DEPTH is refused as the
1173    /// DuplicateKey-family class (either our sentinel or serde_json's
1174    /// own recursion cap, both mapped identically). Kills the
1175    /// `>` -> `>=`/`==` boundary mutants in NoDupVisitor's seq and
1176    /// map arms.
1177    #[test]
1178    fn nesting_depth_boundary_is_exact_for_arrays_and_maps() {
1179        // The leaf is `0` (not `null`) because the
1180        // strict receipt scanner refuses explicit JSON null
1181        // everywhere. A number at the leaf preserves the boundary
1182        // test's intent (depth mechanics only) without exercising
1183        // the unrelated null-rejection path.
1184        let nested_arrays = |depth: usize| format!("{}0{}", "[".repeat(depth), "]".repeat(depth));
1185        let nested_maps = |depth: usize| format!("{}0{}", "{\"k\":".repeat(depth), "}".repeat(depth));
1186        for build in [&nested_arrays as &dyn Fn(usize) -> String, &nested_maps] {
1187            // serde_json's own recursion cap fires at 128 containers, so
1188            // the deepest reachable input is 127; the visitor's own
1189            // `> MAX_NESTED_DEPTH` arm is defense-in-depth for
1190            // hypothetical cap-free parsers. Pin the user-visible
1191            // contract: 127 parses, 128 is refused as the DuplicateKey
1192            // family (serde's recursion error is mapped there
1193            // deliberately so hostile-nesting triage has one class).
1194            let deepest_reachable = build(MAX_NESTED_DEPTH - 1);
1195            assert!(
1196                check_no_duplicate_keys(deepest_reachable.as_bytes()).is_ok(),
1197                "depth == MAX_NESTED_DEPTH - 1 must be accepted"
1198            );
1199            let past_limit = build(MAX_NESTED_DEPTH);
1200            let outcome = check_no_duplicate_keys(past_limit.as_bytes());
1201            assert!(
1202                matches!(outcome, Err(ReceiptError::DuplicateKey(_))),
1203                "depth == MAX_NESTED_DEPTH must be refused, got {outcome:?}"
1204            );
1205        }
1206    }
1207
1208    /// `Receipt::from_json_slice` rejects duplicate keys
1209    /// at ANY nesting level (top-level, nested inside `ai_agent`,
1210    /// nested inside `subject`, deep inside an array element). This
1211    /// closes the last-wins vs first-wins auditor split-brain that
1212    /// would otherwise let a hostile issuer sign under one
1213    /// interpretation while an external audit tool showed the other.
1214    #[test]
1215    fn from_json_slice_rejects_explicit_null_option_field() {
1216        // `AgentIdentity.ttl_remaining_s` is
1217        // `Option<u64>` with `skip_serializing_if = Option::is_none`,
1218        // so an honest issuer omits the field. `serde_json::from_slice`
1219        // would then accept an explicit `null` for the same field,
1220        // and `Receipt::verify` would re-canonicalise via
1221        // `serde_json::to_value(&self.body)`, dropping the null. The
1222        // consequence: a valid signature over TWO different wire
1223        // encodings — a "same signature ⇔ same bytes" violation.
1224        // `from_json_slice` must refuse the null variant.
1225        let bytes = br#"{"ai_agent":{"instance_uid":"a","ttl_remaining_s":null}}"#;
1226        let outcome = Receipt::from_json_slice(bytes);
1227        assert!(
1228            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("null")),
1229            "expected null-rejection (DuplicateKey family), got {outcome:?}"
1230        );
1231    }
1232
1233    #[test]
1234    fn from_json_slice_rejects_duplicate_top_level_key() {
1235        let bytes = br#"{"receipt_version":1,"receipt_version":2,"receipt_id":"x"}"#;
1236        let outcome = Receipt::from_json_slice(bytes);
1237        assert!(
1238            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("receipt_version")),
1239            "expected DuplicateKey rejection, got {outcome:?}"
1240        );
1241    }
1242
1243    /// `from_json_str` is public API and must not go
1244    /// untested. Prove the roundtrip (an issued
1245    /// receipt re-parses and still verifies) AND that the strict
1246    /// duplicate-key gate applies on the &str path too — a regression
1247    /// that routed it around `check_no_duplicate_keys` would reopen
1248    /// the split-brain the slice path closes.
1249    #[test]
1250    fn from_json_str_roundtrips_and_applies_the_duplicate_key_gate() {
1251        let signer = Ed25519Signer::generate();
1252        let issued = Receipt::issue(body(), &signer).unwrap();
1253        let text = serde_json::to_string(&issued).unwrap();
1254        let reparsed = Receipt::from_json_str(&text).unwrap();
1255        assert_eq!(reparsed, issued);
1256        reparsed.verify_embedded().unwrap();
1257
1258        let outcome = Receipt::from_json_str(r#"{"receipt_version":1,"receipt_version":2}"#);
1259        assert!(
1260            matches!(outcome, Err(ReceiptError::DuplicateKey(_))),
1261            "the &str path must apply the same duplicate-key gate; got {outcome:?}"
1262        );
1263    }
1264
1265    #[test]
1266    fn from_json_slice_rejects_duplicate_key_inside_ai_agent() {
1267        let bytes = br#"{"ai_agent":{"instance_uid":"a","instance_uid":"b"}}"#;
1268        let outcome = Receipt::from_json_slice(bytes);
1269        assert!(
1270            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("instance_uid")),
1271            "expected DuplicateKey rejection for nested field, got {outcome:?}"
1272        );
1273    }
1274
1275    #[test]
1276    fn from_json_slice_rejects_duplicate_key_inside_array_element() {
1277        // Rare shape but valid JSON that would slip past the top-level
1278        // guard historically. Deep nesting → the walker recurses.
1279        let bytes = br#"{"weird":[{"dup":1,"dup":2}]}"#;
1280        let outcome = Receipt::from_json_slice(bytes);
1281        assert!(
1282            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("dup")),
1283            "expected DuplicateKey rejection deep in an array, got {outcome:?}"
1284        );
1285    }
1286
1287    /// `check_no_duplicate_keys` must distinguish
1288    /// duplicate-key rejections (mapped to `ReceiptError::DuplicateKey`)
1289    /// from ordinary parse failures (mapped to `ReceiptError::Serde`).
1290    /// A blanket `.map_err(DuplicateKey)` folds EOF,
1291    /// "expected value", and recursion-limit hits into the DuplicateKey
1292    /// variant, actively misleading ops during triage.
1293    #[test]
1294    fn from_json_slice_maps_malformed_json_to_serde_not_duplicate_key() {
1295        // Genuine parse failure — no duplicate key involved. Must
1296        // surface as ReceiptError::Serde so tracing sites and CLI
1297        // error text name the actual class.
1298        for garbage in [
1299            &b"not json"[..],
1300            &b""[..],
1301            &b"{\"unterminated"[..],
1302            &b"{\"x\": }"[..],
1303        ] {
1304            let outcome = Receipt::from_json_slice(garbage);
1305            assert!(
1306                matches!(outcome, Err(ReceiptError::Serde(_))),
1307                "expected Serde error for {garbage:?}, got {outcome:?}",
1308            );
1309        }
1310    }
1311
1312    /// The duplicate-key error message must escape any hostile
1313    /// control characters in the key so a `\n`-carrying key does not
1314    /// inject a fake extra log line at tracing sites that render
1315    /// %error into their output.
1316    #[test]
1317    fn from_json_slice_escapes_control_chars_in_duplicate_key_names() {
1318        // JSON allows control chars in keys via \uXXXX escapes.
1319        let bytes = br#"{"a\nb":1,"a\nb":2}"#;
1320        let outcome = Receipt::from_json_slice(bytes);
1321        let msg = match outcome {
1322            Err(ReceiptError::DuplicateKey(msg)) => msg,
1323            other => panic!("expected DuplicateKey, got {other:?}"),
1324        };
1325        // A literal newline in the message would let an operator log
1326        // `%error` inject an unrelated line; escape_debug renders it
1327        // as `\n`.
1328        assert!(!msg.contains('\n'), "unescaped newline in error: {msg:?}");
1329        assert!(msg.contains(r"\n"), "expected escaped newline, got {msg:?}");
1330    }
1331
1332    /// Defense-in-depth against stack overflow via
1333    /// deeply-nested JSON payload. serde_json's own recursion limit
1334    /// is 128 (fires first with "recursion limit exceeded"); our
1335    /// walker enforces the same bound explicitly so a future
1336    /// serde_json change couldn't quietly reintroduce the hazard.
1337    #[test]
1338    fn from_json_slice_rejects_deeply_nested_json() {
1339        let mut deep = String::from("{\"x\":");
1340        for _ in 0..200 {
1341            deep.push('[');
1342        }
1343        for _ in 0..200 {
1344            deep.push(']');
1345        }
1346        deep.push('}');
1347        let outcome = Receipt::from_json_slice(deep.as_bytes());
1348        // Either serde_json fires first ("recursion limit exceeded")
1349        // or our walker fires ("nesting exceeds 128"). Either way,
1350        // the DoS is contained without a stack overflow.
1351        assert!(
1352            matches!(
1353                outcome,
1354                Err(ReceiptError::DuplicateKey(ref msg))
1355                    if msg.contains("nesting exceeds") || msg.contains("recursion limit")
1356            ),
1357            "expected nesting-cap error, got {outcome:?}",
1358        );
1359    }
1360
1361    /// Mutation-run hardening: nothing pinned the *pass* side of the
1362    /// nesting guard. The observable boundary today is serde_json's own
1363    /// recursion limit (fires entering level 128, before our
1364    /// `MAX_NESTED_DEPTH` walk — which is deliberate defense-in-depth
1365    /// behind it): 127 levels must clear the gate (rejection is the
1366    /// schema/parse `Serde` error, not the nesting cap), 128 must be
1367    /// refused by the recursion/nesting guard class.
1368    #[test]
1369    fn nesting_gate_boundary_is_exactly_the_recursion_limit() {
1370        let nested = |levels: usize| {
1371            let mut doc = String::new();
1372            for _ in 0..levels {
1373                doc.push('[');
1374            }
1375            for _ in 0..levels {
1376                doc.push(']');
1377            }
1378            doc
1379        };
1380        // Just under the limit: the gate passes; the failure is the
1381        // schema mismatch (an array is not a Receipt), NOT the cap.
1382        let under_cap = Receipt::from_json_slice(nested(127).as_bytes());
1383        assert!(
1384            matches!(under_cap, Err(ReceiptError::Serde(_))),
1385            "127 levels must clear the nesting gate, got {under_cap:?}",
1386        );
1387        // At the limit: refused by the nesting/recursion guard.
1388        let at_cap = Receipt::from_json_slice(nested(128).as_bytes());
1389        assert!(
1390            matches!(
1391                at_cap,
1392                Err(ReceiptError::DuplicateKey(ref msg))
1393                    if msg.contains("nesting exceeds") || msg.contains("recursion limit")
1394            ),
1395            "128 levels must hit the nesting cap, got {at_cap:?}",
1396        );
1397    }
1398
1399    /// The happy path (well-formed receipt) still deserializes as
1400    /// before — the duplicate-key gate is a strict filter, not a
1401    /// disruption.
1402    #[test]
1403    fn from_json_slice_accepts_a_well_formed_receipt() {
1404        let signer = Ed25519Signer::generate();
1405        let receipt = Receipt::issue(body(), &signer).unwrap();
1406        let bytes = serde_json::to_vec(&receipt).unwrap();
1407        let restored = Receipt::from_json_slice(&bytes).unwrap();
1408        assert_eq!(restored.body.session_id, "sess-77");
1409    }
1410
1411    #[test]
1412    fn verification_survives_json_roundtrip() {
1413        // Receipts travel as JSON; key order may change en route. JCS must
1414        // make verification independent of transport-layer reserialization.
1415        let signer = Ed25519Signer::generate();
1416        let mut ring = Keyring::new();
1417        ring.add_signer(&signer).unwrap();
1418        let receipt = Receipt::issue(body(), &signer).unwrap();
1419        let json = serde_json::to_string(&receipt).unwrap();
1420        let back: Receipt = serde_json::from_str(&json).unwrap();
1421        back.verify(&ring).unwrap();
1422    }
1423
1424    #[test]
1425    fn issued_receipt_matches_shipped_schema() {
1426        let signer = Ed25519Signer::from_seed(&[13; 32]);
1427        let receipt = Receipt::issue(body(), &signer).unwrap();
1428        let schema: serde_json::Value =
1429            serde_json::from_str(include_str!("../../../schemas/receipt-v2.schema.json")).unwrap();
1430        let validator = jsonschema::validator_for(&schema).unwrap();
1431        let value = serde_json::to_value(receipt).unwrap();
1432        let errors: Vec<_> = validator.iter_errors(&value).collect();
1433        assert!(errors.is_empty(), "{errors:?}");
1434    }
1435
1436    /// The v1 schema stays shipped so existing v1 artifacts keep
1437    /// validating: a legacy-signed v1 receipt matches receipt-v1, and
1438    /// the two schemas partition by `receipt_version` (a v2 receipt is
1439    /// refused by the v1 schema and vice versa).
1440    #[test]
1441    fn legacy_v1_receipt_matches_the_shipped_v1_schema() {
1442        let signer = Ed25519Signer::from_seed(&[13; 32]);
1443        let v1_receipt = serde_json::to_value(issue_v1(body(), &signer)).unwrap();
1444        let v2_receipt = serde_json::to_value(Receipt::issue(body(), &signer).unwrap()).unwrap();
1445        let v1_schema: serde_json::Value =
1446            serde_json::from_str(include_str!("../../../schemas/receipt-v1.schema.json")).unwrap();
1447        let v2_schema: serde_json::Value =
1448            serde_json::from_str(include_str!("../../../schemas/receipt-v2.schema.json")).unwrap();
1449        let v1_validator = jsonschema::validator_for(&v1_schema).unwrap();
1450        let v2_validator = jsonschema::validator_for(&v2_schema).unwrap();
1451        let errors: Vec<_> = v1_validator.iter_errors(&v1_receipt).collect();
1452        assert!(errors.is_empty(), "v1 receipt vs v1 schema: {errors:?}");
1453        assert!(
1454            !v1_validator.is_valid(&v2_receipt),
1455            "v2 receipt must not satisfy the v1 schema (receipt_version const)"
1456        );
1457        assert!(
1458            !v2_validator.is_valid(&v1_receipt),
1459            "v1 receipt must not satisfy the v2 schema (receipt_version const)"
1460        );
1461    }
1462
1463    #[test]
1464    fn every_field_tamper_detected() {
1465        let signer = Ed25519Signer::generate();
1466        let mut ring = Keyring::new();
1467        ring.add_signer(&signer).unwrap();
1468        let receipt = Receipt::issue(body(), &signer).unwrap();
1469        let good = serde_json::to_value(&receipt).unwrap();
1470
1471        let tampers: Vec<(&str, serde_json::Value)> = vec![
1472            ("session_id", "sess-OTHER".into()),
1473            ("stop_reason_id", 1.into()),
1474            ("receipt_id", "forged".into()),
1475            ("issued_at", 1.into()),
1476        ];
1477        for (field, val) in tampers {
1478            let mut bad = good.clone();
1479            bad[field] = val;
1480            let parsed: Receipt = serde_json::from_value(bad).unwrap();
1481            assert!(
1482                parsed.verify(&ring).is_err(),
1483                "tampered {field} passed verification"
1484            );
1485        }
1486        // Nested tampers.
1487        let mut bad = good.clone();
1488        bad["cost"]["cost_usd_micros"] = 1.into();
1489        let parsed: Receipt = serde_json::from_value(bad).unwrap();
1490        assert!(parsed.verify(&ring).is_err(), "tampered cost passed");
1491
1492        let mut bad = good.clone();
1493        bad["ai_agent"]["charter"]["name"] = "swapped-charter".into();
1494        let parsed: Receipt = serde_json::from_value(bad).unwrap();
1495        assert!(parsed.verify(&ring).is_err(), "tampered charter passed");
1496
1497        let mut bad = good;
1498        bad["subject"]["chain_head"] = "00".repeat(32).into();
1499        let parsed: Receipt = serde_json::from_value(bad).unwrap();
1500        assert!(parsed.verify(&ring).is_err(), "tampered chain head passed");
1501    }
1502
1503    #[test]
1504    fn key_substitution_detected() {
1505        // Attacker re-signs a modified receipt with their own key but keeps
1506        // the victim's key id.
1507        let victim = Ed25519Signer::generate();
1508        let attacker = Ed25519Signer::generate();
1509        let mut ring = Keyring::new();
1510        ring.add_signer(&victim).unwrap();
1511
1512        let mut receipt = Receipt::issue(body(), &attacker).unwrap();
1513        receipt.body.key_id = victim.key_id().to_owned(); // lie about identity
1514        assert!(matches!(receipt.verify(&ring), Err(ReceiptError::KeyMismatch(_))));
1515
1516        // Variant: also swap in the victim's public key (signature then fails).
1517        receipt.body.public_key_b64 =
1518            base64::engine::general_purpose::STANDARD.encode(victim.public_key_bytes());
1519        assert!(receipt.verify(&ring).is_err());
1520    }
1521
1522    #[test]
1523    fn caller_cannot_forge_key_fields() {
1524        let signer = Ed25519Signer::generate();
1525        let mut b = body();
1526        b.key_id = "attacker-chosen".into();
1527        b.public_key_b64 = "AAAA".into();
1528        let receipt = Receipt::issue(b, &signer).unwrap();
1529        // issue() must have overwritten both.
1530        assert_eq!(receipt.body.key_id, signer.key_id());
1531        receipt.verify_embedded().unwrap();
1532    }
1533
1534    #[test]
1535    fn retroactive_atif_subject() {
1536        let signer = Ed25519Signer::generate();
1537        let mut ring = Keyring::new();
1538        ring.add_signer(&signer).unwrap();
1539        let mut b = body();
1540        b.subject = ReceiptSubject::AtifTrajectory {
1541            trajectory_digest: "cd".repeat(32),
1542            step_count: 18,
1543            retroactive: true,
1544        };
1545        let receipt = Receipt::issue(b, &signer).unwrap();
1546        receipt.verify(&ring).unwrap();
1547        let v = serde_json::to_value(&receipt).unwrap();
1548        assert_eq!(v["subject"]["kind"], "atif_trajectory");
1549        assert_eq!(v["subject"]["retroactive"], true);
1550    }
1551
1552    #[test]
1553    fn unknown_key_id_fails_ring_verification() {
1554        let signer = Ed25519Signer::generate();
1555        let receipt = Receipt::issue(body(), &signer).unwrap();
1556        let ring = Keyring::new(); // empty
1557        assert!(receipt.verify(&ring).is_err());
1558    }
1559
1560    #[test]
1561    fn attacker_receipt_does_not_pass_a_ring_seeded_with_only_a_different_key() {
1562        let attacker = Ed25519Signer::generate();
1563        let honest = Ed25519Signer::generate();
1564        assert_ne!(attacker.key_id(), honest.key_id());
1565        let forged = Receipt::issue(body(), &attacker).unwrap();
1566        let mut ring = Keyring::new();
1567        ring.add_signer(&honest).unwrap();
1568        assert!(matches!(
1569            forged.verify(&ring),
1570            Err(ReceiptError::Key(KeyError::UnknownKeyId(_)))
1571        ));
1572    }
1573
1574    #[test]
1575    fn verify_embedded_rejects_a_tampered_body() {
1576        // verify_embedded MUST NOT be reducible to Ok(()): tamper the
1577        // session_id and require Err. Catches any stub or short-circuit
1578        // that would silently accept every receipt.
1579        let signer = Ed25519Signer::generate();
1580        let receipt = Receipt::issue(body(), &signer).unwrap();
1581        let mut raw = serde_json::to_value(&receipt).unwrap();
1582        raw["session_id"] = serde_json::Value::from("attacker");
1583        let tampered: Receipt = serde_json::from_value(raw).unwrap();
1584        assert!(tampered.verify_embedded().is_err());
1585    }
1586
1587    #[test]
1588    fn verify_embedded_rejects_a_swapped_public_key() {
1589        // Substitute the embedded pubkey for someone else's — the id/key
1590        // binding inside verify_embedded must catch this AS a
1591        // KeyMismatch, not defer to the signature check downstream.
1592        // The previous bare `is_err()` still passed with the whole
1593        // key_id ↔ pubkey binding block deleted (the signature-verify
1594        // arm catches it too), reopening the substitution-attempt
1595        // detection this test names.
1596        let honest = Ed25519Signer::generate();
1597        let attacker = Ed25519Signer::generate();
1598        let mut receipt = Receipt::issue(body(), &honest).unwrap();
1599        receipt.body.public_key_b64 =
1600            base64::engine::general_purpose::STANDARD.encode(attacker.public_key_bytes());
1601        let err = receipt
1602            .verify_embedded()
1603            .expect_err("swapped embedded pubkey must be refused");
1604        assert!(
1605            matches!(err, ReceiptError::KeyMismatch(_)),
1606            "the id/key binding in verify_embedded must recognise the substitution \
1607             as KeyMismatch (not fall through to the signature-verify arm); got {err:?}"
1608        );
1609    }
1610
1611    /// Schema parity: the shipped schema pins `minLength: 1` on these
1612    /// string fields; a keyholder signing a receipt with an empty
1613    /// session_id / instance_uid must be refused by the Rust verifier
1614    /// too, or `pvctl receipt-verify` and external schema verifiers
1615    /// return split verdicts on the same document.
1616    #[test]
1617    fn empty_min_length_fields_are_refused_by_verify() {
1618        let signer = Ed25519Signer::generate();
1619        for field in [
1620            "receipt_id",
1621            "session_id",
1622            "ai_agent.version",
1623            "ai_agent.charter.name",
1624            "ai_agent.instance_uid",
1625        ] {
1626            let mut b = body();
1627            match field {
1628                "receipt_id" => b.receipt_id = String::new(),
1629                "session_id" => b.session_id = String::new(),
1630                "ai_agent.version" => b.ai_agent.version = String::new(),
1631                "ai_agent.charter.name" => b.ai_agent.charter.name = String::new(),
1632                _ => b.ai_agent.instance_uid = String::new(),
1633            }
1634            let receipt = Receipt::issue_unchecked(b, &signer).unwrap();
1635            assert!(
1636                matches!(
1637                    receipt.verify_embedded(),
1638                    Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains(field)
1639                ),
1640                "signed receipt with empty {field} verified"
1641            );
1642        }
1643    }
1644
1645    /// An unknown `receipt_version` must be refused by
1646    /// BOTH verification paths — a future v3 receipt that
1647    /// reinterprets a field must not verify under v1/v2 semantics on a
1648    /// shipped verifier. `receipt_version` sits inside `body`
1649    /// so a version tamper ALSO fails the signature — but the semantic
1650    /// gate must fire first with an actionable message (and protects
1651    /// against a legitimately-signed future-version receipt).
1652    #[test]
1653    fn unknown_receipt_version_is_refused_by_both_verify_paths() {
1654        let signer = Ed25519Signer::generate();
1655        let mut receipt = Receipt::issue(body(), &signer).unwrap();
1656        receipt.body.receipt_version = 3;
1657        let mut ring = Keyring::new();
1658        ring.add_key_bytes(&signer.public_key_bytes()).unwrap();
1659        assert!(
1660            matches!(receipt.verify(&ring), Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("receipt_version 3")),
1661            "ring verify must refuse an unknown receipt_version"
1662        );
1663        assert!(
1664            matches!(receipt.verify_embedded(), Err(ReceiptError::SemanticInvariant(_))),
1665            "embedded verify must refuse an unknown receipt_version"
1666        );
1667    }
1668
1669    /// Tool accounting must be arithmetically possible: `allowed +
1670    /// blocked` can never exceed `total` (journal recovery refuses to
1671    /// recover such totals, and the issuer maintains it by
1672    /// construction). A keyholder-signed receipt violating it must fail
1673    /// verification rather than attest impossible statistics — including
1674    /// the u64-overflow corner where `allowed + blocked` wraps.
1675    #[test]
1676    fn impossible_tool_accounting_is_refused() {
1677        let signer = Ed25519Signer::generate();
1678        let mut ring = Keyring::new();
1679        ring.add_key_bytes(&signer.public_key_bytes()).unwrap();
1680        for (total, allowed, blocked) in [
1681            (1u64, 1_000_000u64, 0u64),
1682            (0, 0, 1),
1683            (5, 3, 3),
1684            // JCS caps every integer at 2^53-1, so the u64 checked_add
1685            // overflow arm is unreachable through an issuable receipt;
1686            // the largest issuable sum is still refused.
1687            (0, (1 << 53) - 1, (1 << 53) - 1),
1688        ] {
1689            let mut b = body();
1690            b.tool_calls = ToolCallSummary {
1691                total,
1692                allowed,
1693                blocked,
1694            };
1695            let receipt = Receipt::issue_unchecked(b, &signer).unwrap();
1696            assert!(
1697                matches!(
1698                    receipt.verify(&ring),
1699                    Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("tool_calls")
1700                ),
1701                "signed receipt with impossible tool accounting ({total}/{allowed}/{blocked}) verified"
1702            );
1703            assert!(
1704                matches!(receipt.verify_embedded(), Err(ReceiptError::SemanticInvariant(_))),
1705                "embedded verify accepted impossible tool accounting ({total}/{allowed}/{blocked})"
1706            );
1707        }
1708        // Boundary: exactly-classified and under-classified totals verify.
1709        for (total, allowed, blocked) in [(2u64, 1u64, 1u64), (3, 1, 1)] {
1710            let mut b = body();
1711            b.tool_calls = ToolCallSummary {
1712                total,
1713                allowed,
1714                blocked,
1715            };
1716            let receipt = Receipt::issue_unchecked(b, &signer).unwrap();
1717            receipt.verify(&ring).unwrap();
1718        }
1719    }
1720
1721    /// Stress: for every wall-clock instant sampled during issuance, the
1722    /// integer `issued_at` and the human-readable `issued_at_iso` must be
1723    /// mutually consistent. If they ever drift, downstream consumers that
1724    /// parse only one field could reconstruct a different timestamp than
1725    /// the signer intended, and the signature's coverage of both fields
1726    /// would still verify (since the signer produced them together).
1727    #[test]
1728    fn issued_at_and_issued_at_iso_are_always_consistent() {
1729        let signer = Ed25519Signer::generate();
1730        for _ in 0..64 {
1731            let receipt = Receipt::issue(body(), &signer).unwrap();
1732            let derived = pv_core::time::iso8601_ms(receipt.body.issued_at);
1733            assert_eq!(
1734                derived, receipt.body.issued_at_iso,
1735                "iso/ms mismatch: {} vs {}",
1736                receipt.body.issued_at, receipt.body.issued_at_iso,
1737            );
1738        }
1739    }
1740
1741    /// A keyholding issuer must not be able to sign a receipt whose
1742    /// `stop_reason` caption names a DIFFERENT known variant than
1743    /// `stop_reason_id` — auditors reading the caption and tooling
1744    /// reading the id would extract contradictory outcomes from one
1745    /// verified receipt. Provider-native / free-text captions (which
1746    /// coincide with no canonical caption) remain accepted, matching
1747    /// the events validator's native-caption tolerance.
1748    #[test]
1749    fn subject_digest_conjuncts_are_each_enforced() {
1750        // Pin BOTH conjuncts of the digest shape check (length == 64
1751        // AND lowercase hex): a mutant flipping && to || previously
1752        // survived because no test failed exactly one conjunct.
1753        let signer = Ed25519Signer::generate();
1754        let mut ring = Keyring::new();
1755        ring.add_signer(&signer).unwrap();
1756        for bad_head in [
1757            "zz".repeat(32), // 64 chars, not hex
1758            "ab".repeat(31), // hex, wrong length
1759            "AB".repeat(32), // uppercase hex refused
1760        ] {
1761            let mut body = body();
1762            body.subject = ReceiptSubject::EventChain {
1763                chain_head: bad_head.clone(),
1764                event_count: 1,
1765            };
1766            let error = Receipt::issue_unchecked(body, &signer)
1767                .unwrap()
1768                .verify(&ring)
1769                .unwrap_err();
1770            assert!(
1771                matches!(error, ReceiptError::SemanticInvariant(ref m) if m.contains("chain_head")),
1772                "chain_head {bad_head:?} must fail: {error:?}"
1773            );
1774        }
1775    }
1776
1777    #[test]
1778    fn cross_wired_stop_reason_caption_is_refused_but_native_captions_pass() {
1779        let signer = Ed25519Signer::generate();
1780        let mut ring = Keyring::new();
1781        ring.add_signer(&signer).unwrap();
1782        // Cross-wired: id 92 (Budget Exceeded) with the canonical
1783        // caption of variant 1 (Stop).
1784        let mut wired = body();
1785        wired.stop_reason_id = 92;
1786        wired.stop_reason = "Stop".to_owned();
1787        let error = Receipt::issue_unchecked(wired, &signer)
1788            .unwrap()
1789            .verify(&ring)
1790            .unwrap_err();
1791        assert!(
1792            matches!(error, ReceiptError::SemanticInvariant(ref m) if m.contains("canonical caption")),
1793            "cross-wired caption must fail verify: {error:?}"
1794        );
1795        // Native caption: id 1 (Stop) with OpenAI's native "stop".
1796        let mut native = body();
1797        native.stop_reason_id = 1;
1798        native.stop_reason = "stop".to_owned();
1799        Receipt::issue_unchecked(native, &signer)
1800            .unwrap()
1801            .verify(&ring)
1802            .unwrap();
1803        // Canonical pairing passes.
1804        let mut canonical = body();
1805        canonical.stop_reason_id = 1;
1806        canonical.stop_reason = "Stop".to_owned();
1807        Receipt::issue_unchecked(canonical, &signer)
1808            .unwrap()
1809            .verify(&ring)
1810            .unwrap();
1811    }
1812
1813    /// Cross-machine stress: two independently-issued receipts (simulating
1814    /// two harness replicas whose wall clocks drift within a normal
1815    /// tolerance) must both verify against the same keyring and carry
1816    /// UTC-Z timestamps regardless of what timezone the issuing process
1817    /// was configured with.
1818    #[test]
1819    fn receipts_issued_by_two_machines_both_verify_and_use_utc_zulu() {
1820        let signer_a = Ed25519Signer::generate();
1821        let signer_b = Ed25519Signer::generate();
1822        let mut ring = Keyring::new();
1823        ring.add_signer(&signer_a).unwrap();
1824        ring.add_signer(&signer_b).unwrap();
1825        let ra = Receipt::issue(body(), &signer_a).unwrap();
1826        let rb = Receipt::issue(body(), &signer_b).unwrap();
1827        ra.verify(&ring).unwrap();
1828        rb.verify(&ring).unwrap();
1829        assert!(ra.body.issued_at_iso.ends_with('Z'), "{}", ra.body.issued_at_iso,);
1830        assert!(rb.body.issued_at_iso.ends_with('Z'), "{}", rb.body.issued_at_iso,);
1831        // Both timestamps must be within a minute of each other on the
1832        // same physical host running the test.
1833        let diff_ms = ra.body.issued_at.abs_diff(rb.body.issued_at);
1834        assert!(diff_ms < 60_000, "clocks {diff_ms} ms apart");
1835    }
1836
1837    /// A receipt with an extra top-level field that the signer did not
1838    /// include in the signed body would appear in a raw-JSON audit
1839    /// reader but be invisible to `verify()` (the field silently drops
1840    /// during the ReceiptBody round-trip inside canonicalize). Our
1841    /// custom `Deserialize` rejects it at parse time — a corrupted /
1842    /// tampered receipt fails before it can be trusted.
1843    #[test]
1844    fn receipt_with_unknown_top_level_field_is_rejected_on_parse() {
1845        let signer = Ed25519Signer::generate();
1846        let receipt = Receipt::issue(body(), &signer).unwrap();
1847        let mut value: serde_json::Value =
1848            serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
1849        value
1850            .as_object_mut()
1851            .unwrap()
1852            .insert("claim_extra".to_owned(), serde_json::json!("grant admin"));
1853        let tampered = serde_json::to_string(&value).unwrap();
1854        let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
1855        assert!(
1856            error.to_string().contains("claim_extra"),
1857            "error should name the offending field: {error}"
1858        );
1859    }
1860
1861    /// The manual whitelist must stay in lockstep with the field set of
1862    /// [`ReceiptBody`]. If a new field is added there without updating
1863    /// [`ALLOWED_RECEIPT_TOP_LEVEL_KEYS`], parsing that field silently
1864    /// fails (or worse, drops it back into the same class of bug we
1865    /// fixed). Serializing a canonical body and cross-checking is the
1866    /// mechanical guard.
1867    #[test]
1868    fn allowed_receipt_top_level_keys_cover_body_fields_exactly() {
1869        let value = serde_json::to_value(body()).unwrap();
1870        let object = value.as_object().unwrap();
1871        let mut body_keys: Vec<&str> = object.keys().map(String::as_str).collect();
1872        body_keys.push("signature_b64");
1873        body_keys.sort_unstable();
1874        let mut allowed: Vec<&str> = ALLOWED_RECEIPT_TOP_LEVEL_KEYS.to_vec();
1875        allowed.sort_unstable();
1876        assert_eq!(
1877            body_keys, allowed,
1878            "ALLOWED_RECEIPT_TOP_LEVEL_KEYS drift vs ReceiptBody serialization"
1879        );
1880    }
1881
1882    /// A hostile issuer could sign under one JSON parser's
1883    /// duplicate-key semantics (usually last-wins) while an auditor
1884    /// reads the same wire bytes under a different parser
1885    /// (first-wins). Both parses "succeed" but see different receipt
1886    /// contents. Our custom Deserialize now rejects duplicates
1887    /// outright, closing the parser-disagreement audit-spoofing gap.
1888    #[test]
1889    fn receipt_with_duplicate_top_level_key_is_rejected_on_parse() {
1890        // Hand-craft the JSON so both `session_id` entries land at the
1891        // top level and are seen in order by the map visitor. Rust's
1892        // `format!` guarantees a fixed byte sequence.
1893        let signer = Ed25519Signer::generate();
1894        let receipt = Receipt::issue(body(), &signer).unwrap();
1895        let good = serde_json::to_string(&receipt).unwrap();
1896        // Inject a second `session_id` right before the closing `}`.
1897        assert!(good.ends_with('}'));
1898        let tampered = format!(
1899            "{},\"session_id\":\"forged-second-copy\"}}",
1900            &good[..good.len() - 1]
1901        );
1902        let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
1903        assert!(
1904            error.to_string().contains("duplicate field"),
1905            "expected duplicate-key rejection, got: {error}"
1906        );
1907        assert!(
1908            error.to_string().contains("session_id"),
1909            "error should name the offending field, got: {error}"
1910        );
1911    }
1912}