Skip to main content

av_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).
17pub const RECEIPT_VERSION: u32 = 1;
18
19/// What this receipt attests.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
22#[non_exhaustive]
23pub enum ReceiptSubject {
24    /// A signed-workflow session: the OCSF event chain.
25    EventChain {
26        /// Head hash of the session event chain (hex).
27        chain_head: String,
28        /// Number of events in the chain.
29        event_count: u64,
30    },
31    /// An unsigned-workflow trajectory promoted retroactively (Module H).
32    AtifTrajectory {
33        /// SHA-256 of the exported trajectory file bytes (hex).
34        trajectory_digest: String,
35        /// Number of steps in the trajectory.
36        step_count: u64,
37        /// Always true for promotions; kept explicit for auditability.
38        retroactive: bool,
39    },
40}
41
42/// Aggregate tool-call statistics for the session.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct ToolCallSummary {
46    /// Total tool calls observed.
47    pub total: u64,
48    /// Calls allowed by policy.
49    pub allowed: u64,
50    /// Calls blocked by policy/budget/schema.
51    pub blocked: u64,
52}
53
54/// Aggregate cost for the session (integers only — JCS-exact).
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct CostSummary {
58    /// Total prompt tokens.
59    pub prompt_tokens: u64,
60    /// Total completion tokens.
61    pub completion_tokens: u64,
62    /// Total provider-cached tokens.
63    pub cached_tokens: u64,
64    /// Cost in micro-USD (1_000_000 = $1).
65    pub cost_usd_micros: u64,
66}
67
68/// The signed body (everything except the signature itself).
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ReceiptBody {
72    /// Receipt format version.
73    pub receipt_version: u32,
74    /// Unique receipt id (UUIDv7).
75    pub receipt_id: String,
76    /// Session this receipt covers.
77    pub session_id: String,
78    /// Issuance time, epoch ms.
79    pub issued_at: u64,
80    /// Issuance time, ISO-8601.
81    pub issued_at_iso: String,
82    /// Agent config-state identity block.
83    pub ai_agent: av_events::AgentIdentity,
84    /// What is attested.
85    pub subject: ReceiptSubject,
86    /// Tool-call summary.
87    pub tool_calls: ToolCallSummary,
88    /// Cost summary.
89    pub cost: CostSummary,
90    /// Final stop reason id.
91    pub stop_reason_id: u8,
92    /// Final stop reason caption.
93    pub stop_reason: String,
94    /// Signer key id.
95    pub key_id: String,
96    /// Signer public key, base64 (self-contained offline verification).
97    pub public_key_b64: String,
98}
99
100/// A complete receipt: body + detached signature over `JCS(body)`.
101///
102/// The wire shape is intentionally flat (13 body fields + `signature_b64`
103/// at the top level) — the schema at `schemas/receipt-v1.schema.json`
104/// commits to it and downstream verifiers (`avctl receipt-verify`,
105/// external tools) consume it that way.
106///
107/// `#[serde(flatten)]` normally silently disables `deny_unknown_fields`
108/// on the inner struct, which would let a hostile issuer add extra
109/// top-level fields ("claim_extra": "grant admin") that the human
110/// reviewer sees but `verify()` accepts (the field never survives the
111/// round-trip to `ReceiptBody` used inside `canonicalize`). To close
112/// that gap without breaking the wire shape, the [`Deserialize`] impl
113/// is written by hand and rejects any top-level key outside the
114/// declared whitelist. See the
115/// `allowed_receipt_top_level_keys_cover_body_fields_exactly`
116/// test for the compile-time-ish drift guard.
117#[derive(Debug, Clone, PartialEq, Serialize)]
118pub struct Receipt {
119    /// Signed body.
120    #[serde(flatten)]
121    pub body: ReceiptBody,
122    /// Ed25519 signature over the JCS canonicalization of the body, base64.
123    pub signature_b64: String,
124}
125
126/// The 14 top-level keys a receipt is allowed to carry over the wire:
127/// the 13 fields of [`ReceiptBody`] plus [`Receipt::signature_b64`].
128///
129/// Kept in lockstep with [`ReceiptBody`] by
130/// `tests::allowed_receipt_top_level_keys_cover_body_fields_exactly`
131/// — adding a field to `ReceiptBody` without updating this list breaks
132/// that test at CI time.
133const ALLOWED_RECEIPT_TOP_LEVEL_KEYS: &[&str] = &[
134    "receipt_version",
135    "receipt_id",
136    "session_id",
137    "issued_at",
138    "issued_at_iso",
139    "ai_agent",
140    "subject",
141    "tool_calls",
142    "cost",
143    "stop_reason_id",
144    "stop_reason",
145    "key_id",
146    "public_key_b64",
147    "signature_b64",
148];
149
150impl<'de> Deserialize<'de> for Receipt {
151    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
152        use serde::de::{Error as _, MapAccess, Visitor};
153        // Custom visitor so we can reject duplicate keys explicitly.
154        // `serde_json::Map::deserialize` silently collapses duplicate
155        // keys (last-wins) — RFC 8259 leaves the behaviour undefined
156        // and other JSON parsers (jq's `--sort-keys`, some strict
157        // Python configs, several audit tools) pick first-wins.
158        // A hostile issuer could sign under the last-wins interpretation
159        // while an auditor reading with a first-wins parser saw the
160        // friendly value; the signature would verify but the audit
161        // would show a different receipt.
162        struct ReceiptVisitor;
163        impl<'de> Visitor<'de> for ReceiptVisitor {
164            type Value = Receipt;
165            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166                f.write_str("an AgentVisor AI Receipt JSON object")
167            }
168            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Receipt, M::Error> {
169                let mut raw = serde_json::Map::new();
170                while let Some(key) = map.next_key::<String>()? {
171                    if !ALLOWED_RECEIPT_TOP_LEVEL_KEYS.contains(&key.as_str()) {
172                        return Err(M::Error::custom(format!(
173                            "unknown field `{key}` in Receipt; a signed receipt \
174                             may only carry the fields declared in ReceiptBody + \
175                             signature_b64. Extra fields would be visible to a raw \
176                             JSON reader but not covered by the signature — treat \
177                             as tampering."
178                        )));
179                    }
180                    if raw.contains_key(&key) {
181                        return Err(M::Error::custom(format!(
182                            "duplicate field `{key}` in Receipt; JSON parsers \
183                             disagree on duplicate-key semantics (last-wins vs \
184                             first-wins), so accepting duplicates would let a \
185                             hostile issuer sign under one interpretation while an \
186                             auditor's parser reports the other"
187                        )));
188                    }
189                    let value: serde_json::Value = map.next_value()?;
190                    raw.insert(key, value);
191                }
192                let signature_value = raw
193                    .remove("signature_b64")
194                    .ok_or_else(|| M::Error::missing_field("signature_b64"))?;
195                let signature_b64: String =
196                    serde_json::from_value(signature_value).map_err(M::Error::custom)?;
197                let body: ReceiptBody =
198                    serde_json::from_value(serde_json::Value::Object(raw)).map_err(M::Error::custom)?;
199                Ok(Receipt { body, signature_b64 })
200            }
201        }
202        deserializer.deserialize_map(ReceiptVisitor)
203    }
204}
205
206/// Receipt errors.
207#[derive(Debug, thiserror::Error)]
208#[non_exhaustive]
209pub enum ReceiptError {
210    /// Canonicalization failed (unsafe numbers in body).
211    #[error("canonicalization: {0}")]
212    Jcs(#[from] crate::jcs::JcsError),
213    /// Serialization failed.
214    #[error("serialization: {0}")]
215    Serde(#[from] serde_json::Error),
216    /// Key/signature failure.
217    #[error("key: {0}")]
218    Key(#[from] KeyError),
219    /// Signature or public key not valid base64.
220    #[error("invalid base64 in receipt")]
221    Base64,
222    /// The receipt's embedded public key does not match the keyring entry for
223    /// its key id (substitution attempt).
224    #[error("embedded public key mismatches keyring entry for {0:?}")]
225    KeyMismatch(String),
226    /// Round-15 F4: a JSON object at some nesting level carried a
227    /// duplicate key. Rejecting these closes the last-wins vs
228    /// first-wins auditor split-brain that would otherwise let a
229    /// hostile issuer sign under one interpretation while an
230    /// external audit tool displayed the other.
231    #[error("duplicate JSON key `{0}` at nesting; strict receipt parsers refuse this")]
232    DuplicateKey(String),
233    /// Round-16 F8: a receipt carries a semantically-invalid field
234    /// (currently: `AtifTrajectory.retroactive == false`). Distinct
235    /// from `Serde` and `DuplicateKey` so ops triage can name the
236    /// class.
237    #[error("semantic invariant violated: {0}")]
238    SemanticInvariant(String),
239}
240
241impl Receipt {
242    /// Deserialize a receipt from wire bytes, rejecting any duplicate
243    /// key at ANY nesting level.
244    ///
245    /// The custom `Deserialize` impl for `Receipt` (below) already
246    /// rejects duplicate top-level keys, but nested structs
247    /// (`ai_agent`, `tool_calls`, `cost`, `subject`) went through
248    /// serde's default derive which relies on `serde_json::Map` /
249    /// `IndexMap` — both silently collapse duplicate keys with
250    /// last-wins semantics. A hostile issuer could then sign a
251    /// receipt whose `ai_agent.instance_uid` appeared twice
252    /// (`"a"` then `"b"`); JCS canonicalisation saw only `"b"` so
253    /// the signature verified, but a first-wins auditor tool (jq's
254    /// default, some Python configs) displayed `"a"` — the exact
255    /// split-brain the top-level guard was written to prevent, one
256    /// level deeper.
257    ///
258    /// Round-15 F4: pre-scan the JSON with a strict duplicate-key
259    /// checker (`check_no_duplicate_keys`) before deserialising into
260    /// `Receipt`. Callers verifying a receipt off the wire should
261    /// prefer this over `serde_json::from_slice::<Receipt>`.
262    pub fn from_json_slice(bytes: &[u8]) -> Result<Self, ReceiptError> {
263        check_no_duplicate_keys(bytes)?;
264        serde_json::from_slice(bytes).map_err(ReceiptError::Serde)
265    }
266
267    /// Same as [`Receipt::from_json_slice`] for owned/borrowed strings.
268    pub fn from_json_str(s: &str) -> Result<Self, ReceiptError> {
269        Self::from_json_slice(s.as_bytes())
270    }
271
272    /// Issue (sign) a receipt over `body` with `signer`.
273    ///
274    /// The `key_id` and `public_key_b64` fields of the body are overwritten
275    /// from the signer — a caller can never claim someone else's key identity.
276    pub fn issue(mut body: ReceiptBody, signer: &dyn Signer) -> Result<Self, ReceiptError> {
277        body.key_id = signer.key_id().to_owned();
278        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
279        let canon = canonicalize(&serde_json::to_value(&body)?)?;
280        let sig = signer.sign(canon.as_bytes());
281        Ok(Self {
282            body,
283            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
284        })
285    }
286
287    /// Verify offline against a keyring. Checks, in execution order:
288    /// 1. `AtifTrajectory.retroactive == true` (round-16 F8 —
289    ///    `retroactive: false` on an ATIF-promoted receipt is
290    ///    semantically nonsense; the schema pins it to `const: true`
291    ///    but the Rust type still accepts `false`; refuse it here so
292    ///    the two agree);
293    /// 2. the embedded public key matches the ring's key for `key_id`
294    ///    (anti-substitution);
295    /// 3. the signature verifies over `JCS(body)`.
296    pub fn verify(&self, ring: &Keyring) -> Result<(), ReceiptError> {
297        self.verify_semantic_invariants()?;
298        let embedded = base64::engine::general_purpose::STANDARD
299            .decode(&self.body.public_key_b64)
300            .map_err(|_| ReceiptError::Base64)?;
301        let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
302        // Re-derive the ring id for the embedded key; it must equal the stated
303        // key id AND exist in the ring with the same bytes.
304        let mut probe = Keyring::new();
305        let derived_id = probe.add_key_bytes(&embedded)?;
306        if derived_id != self.body.key_id {
307            return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
308        }
309        let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
310        let sig = base64::engine::general_purpose::STANDARD
311            .decode(&self.signature_b64)
312            .map_err(|_| ReceiptError::Base64)?;
313        ring.verify(&self.body.key_id, canon.as_bytes(), &sig)?;
314        Ok(())
315    }
316
317    /// Verify self-contained (trusting the embedded public key). Suitable when
318    /// the verifier obtained the receipt over an authenticated channel or
319    /// pins key ids separately. Prefer [`Receipt::verify`] with a ring.
320    pub fn verify_embedded(&self) -> Result<(), ReceiptError> {
321        self.verify_semantic_invariants()?;
322        let embedded = base64::engine::general_purpose::STANDARD
323            .decode(&self.body.public_key_b64)
324            .map_err(|_| ReceiptError::Base64)?;
325        let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
326        let mut ring = Keyring::new();
327        let id = ring.add_key_bytes(&embedded)?;
328        if id != self.body.key_id {
329            return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
330        }
331        let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
332        let sig = base64::engine::general_purpose::STANDARD
333            .decode(&self.signature_b64)
334            .map_err(|_| ReceiptError::Base64)?;
335        ring.verify(&id, canon.as_bytes(), &sig)?;
336        Ok(())
337    }
338
339    /// Round-16 F8: check semantic invariants that the type system
340    /// cannot express. Currently: `AtifTrajectory.retroactive` must
341    /// be `true` (the schema pins it via `const: true`; the Rust
342    /// type is `bool` for auditability). Called from both
343    /// `verify` and `verify_embedded` so no verification path can
344    /// skip it.
345    fn verify_semantic_invariants(&self) -> Result<(), ReceiptError> {
346        if let ReceiptSubject::AtifTrajectory { retroactive, .. } = &self.body.subject {
347            if !retroactive {
348                return Err(ReceiptError::SemanticInvariant(
349                    "AtifTrajectory.retroactive must be true (this receipt attests a retroactive promotion)"
350                        .to_owned(),
351                ));
352            }
353        }
354        Ok(())
355    }
356}
357
358/// Walk a JSON document and reject any object whose keys contain a
359/// duplicate at ANY nesting level.
360///
361/// Used by [`Receipt::from_json_slice`] as a strict pre-parse gate.
362/// serde_json's default `Value` / `Map` uses `IndexMap` (with
363/// `preserve_order`), which silently keeps only the LAST value for a
364/// duplicate key at deserialize time — collapsing evidence that a
365/// hostile issuer signed a document with two spellings of the same
366/// key and a first-wins auditor would see the earlier one. This
367/// walk runs upfront so the collapse never happens.
368///
369/// Round-16 hardening: enforce a hard depth cap so a hostile issuer
370/// cannot use a deeply-nested `[[[[…]]]]` payload to blow the walker's
371/// stack. serde_json's own limit is 128 by default, but the cost of
372/// a per-level check is a single integer increment.
373const MAX_NESTED_DEPTH: usize = 128;
374
375/// Sentinel prefix that lets `check_no_duplicate_keys` distinguish a
376/// duplicate-key rejection (mapped to `ReceiptError::DuplicateKey`)
377/// from a general parse failure (mapped to `ReceiptError::Serde`).
378/// Round-16 F6: the previous blanket `.map_err(DuplicateKey)` folded
379/// serde_json's own EOF / recursion / expected-value errors into the
380/// DuplicateKey variant, actively misleading ops.
381const DUP_KEY_SENTINEL: &str = "__av_dup:";
382
383fn check_no_duplicate_keys(bytes: &[u8]) -> Result<(), ReceiptError> {
384    use serde::Deserializer as _;
385    let mut deser = serde_json::Deserializer::from_slice(bytes);
386    match deser.deserialize_any(NoDupVisitor { depth: 0 }) {
387        Ok(()) => Ok(()),
388        Err(error) => {
389            let msg = error.to_string();
390            if let Some(rest) = msg.strip_prefix(DUP_KEY_SENTINEL) {
391                Err(ReceiptError::DuplicateKey(rest.to_owned()))
392            } else if msg.contains("recursion limit exceeded") {
393                // Preserve the intent of the depth check even when
394                // serde_json fires first, so callers still see a
395                // DuplicateKey-family error class for hostile
396                // deeply-nested input.
397                Err(ReceiptError::DuplicateKey(msg))
398            } else {
399                // Genuine parse error (EOF, expected value, invalid
400                // number). Bubble the underlying serde_json::Error
401                // via Serde so tracing sites don't misattribute.
402                Err(ReceiptError::Serde(error))
403            }
404        }
405    }
406}
407
408struct NoDupVisitor {
409    depth: usize,
410}
411
412impl<'de> serde::de::Visitor<'de> for NoDupVisitor {
413    type Value = ();
414    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        f.write_str("any JSON value (strict-mode duplicate-key check)")
416    }
417    fn visit_bool<E>(self, _: bool) -> Result<(), E> {
418        Ok(())
419    }
420    fn visit_i64<E>(self, _: i64) -> Result<(), E> {
421        Ok(())
422    }
423    fn visit_u64<E>(self, _: u64) -> Result<(), E> {
424        Ok(())
425    }
426    fn visit_f64<E>(self, _: f64) -> Result<(), E> {
427        Ok(())
428    }
429    fn visit_i128<E>(self, _: i128) -> Result<(), E> {
430        Ok(())
431    }
432    fn visit_u128<E>(self, _: u128) -> Result<(), E> {
433        Ok(())
434    }
435    fn visit_str<E>(self, _: &str) -> Result<(), E> {
436        Ok(())
437    }
438    fn visit_string<E>(self, _: String) -> Result<(), E> {
439        Ok(())
440    }
441    fn visit_none<E>(self) -> Result<(), E> {
442        Ok(())
443    }
444    fn visit_unit<E>(self) -> Result<(), E> {
445        Ok(())
446    }
447    fn visit_seq<S: serde::de::SeqAccess<'de>>(self, mut seq: S) -> Result<(), S::Error> {
448        use serde::de::Error as _;
449        let next_depth = self.depth.saturating_add(1);
450        if next_depth > MAX_NESTED_DEPTH {
451            return Err(S::Error::custom(format!(
452                "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
453            )));
454        }
455        while seq.next_element_seed(NoDupSeed { depth: next_depth })?.is_some() {}
456        Ok(())
457    }
458    fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<(), M::Error> {
459        use serde::de::Error as _;
460        let next_depth = self.depth.saturating_add(1);
461        if next_depth > MAX_NESTED_DEPTH {
462            return Err(M::Error::custom(format!(
463                "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
464            )));
465        }
466        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
467        while let Some(key) = map.next_key::<String>()? {
468            if !seen.insert(key.clone()) {
469                // Round-16 F6: escape the key so a hostile spelling
470                // containing `\n` or ANSI escapes cannot inject a
471                // fake extra log line at every tracing site that
472                // logs the resulting error via `%error`.
473                return Err(M::Error::custom(format!(
474                    "{DUP_KEY_SENTINEL}duplicate key `{}` in JSON object",
475                    key.escape_debug()
476                )));
477            }
478            map.next_value_seed(NoDupSeed { depth: next_depth })?;
479        }
480        Ok(())
481    }
482}
483
484struct NoDupSeed {
485    depth: usize,
486}
487
488impl<'de> serde::de::DeserializeSeed<'de> for NoDupSeed {
489    type Value = ();
490    fn deserialize<D: serde::Deserializer<'de>>(self, deser: D) -> Result<(), D::Error> {
491        deser.deserialize_any(NoDupVisitor { depth: self.depth })
492    }
493}
494
495/// Convenience constructor filling issuance time and ids.
496#[allow(clippy::too_many_arguments)]
497pub fn new_body(
498    session_id: String,
499    ai_agent: av_events::AgentIdentity,
500    subject: ReceiptSubject,
501    tool_calls: ToolCallSummary,
502    cost: CostSummary,
503    stop_reason: av_events::StopReason,
504) -> ReceiptBody {
505    let now = av_core::time::now_ms();
506    ReceiptBody {
507        receipt_version: RECEIPT_VERSION,
508        receipt_id: av_core::new_event_uid(),
509        session_id,
510        issued_at: now,
511        issued_at_iso: av_core::time::iso8601_ms(now),
512        ai_agent,
513        subject,
514        tool_calls,
515        cost,
516        stop_reason_id: stop_reason.id(),
517        stop_reason: stop_reason.caption().to_owned(),
518        key_id: String::new(),         // filled by issue()
519        public_key_b64: String::new(), // filled by issue()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    #![allow(
526        clippy::unwrap_used,
527        clippy::expect_used,
528        clippy::panic,
529        clippy::indexing_slicing
530    )]
531
532    use super::*;
533    use crate::keys::Ed25519Signer;
534
535    fn body() -> ReceiptBody {
536        new_body(
537            "sess-77".into(),
538            av_events::AgentIdentity {
539                version: "2.0.1".into(),
540                charter: "payments".into(),
541                instance_uid: "inst-9".into(),
542                ttl_remaining_s: None,
543            },
544            ReceiptSubject::EventChain {
545                chain_head: "ab".repeat(32),
546                event_count: 41,
547            },
548            ToolCallSummary {
549                total: 12,
550                allowed: 10,
551                blocked: 2,
552            },
553            CostSummary {
554                prompt_tokens: 52_000,
555                completion_tokens: 9_000,
556                cached_tokens: 30_000,
557                cost_usd_micros: 137_500,
558            },
559            av_events::StopReason::SessionClosed,
560        )
561    }
562
563    #[test]
564    fn issue_verify_roundtrip() {
565        let signer = Ed25519Signer::generate();
566        let mut ring = Keyring::new();
567        ring.add_signer(&signer).unwrap();
568        let receipt = Receipt::issue(body(), &signer).unwrap();
569        receipt.verify(&ring).unwrap();
570        receipt.verify_embedded().unwrap();
571    }
572
573    /// Round-16 F8 shipped the `AtifTrajectory.retroactive == true`
574    /// semantic invariant without a test — the mutation run caught
575    /// `verify_semantic_invariants -> Ok(())` surviving, meaning a
576    /// forged non-retroactive promotion receipt verified fine. Pin
577    /// both verification paths: a `retroactive: false` receipt must
578    /// fail as SemanticInvariant even though its signature is valid,
579    /// and the honest `retroactive: true` twin must pass.
580    #[test]
581    fn retroactive_false_is_refused_on_both_verify_paths() {
582        let signer = Ed25519Signer::generate();
583        let mut ring = Keyring::new();
584        ring.add_signer(&signer).unwrap();
585        let subject = |retroactive| ReceiptSubject::AtifTrajectory {
586            trajectory_digest: "cd".repeat(32),
587            step_count: 7,
588            retroactive,
589        };
590        let make = |retroactive| {
591            let mut body = body();
592            body.subject = subject(retroactive);
593            Receipt::issue(body, &signer).unwrap()
594        };
595
596        let forged = make(false);
597        for outcome in [forged.verify(&ring), forged.verify_embedded()] {
598            assert!(
599                matches!(outcome, Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("retroactive")),
600                "retroactive=false must be refused, got {outcome:?}"
601            );
602        }
603
604        let honest = make(true);
605        honest.verify(&ring).unwrap();
606        honest.verify_embedded().unwrap();
607    }
608
609    /// The duplicate-key scanner doubles as the nesting-depth guard.
610    /// Pin the boundary exactly: depth == MAX_NESTED_DEPTH - 1 parses,
611    /// depth == MAX_NESTED_DEPTH is refused as the
612    /// DuplicateKey-family class (either our sentinel or serde_json's
613    /// own recursion cap, both mapped identically). Kills the
614    /// `>` -> `>=`/`==` boundary mutants in NoDupVisitor's seq and
615    /// map arms.
616    #[test]
617    fn nesting_depth_boundary_is_exact_for_arrays_and_maps() {
618        let nested_arrays = |depth: usize| format!("{}null{}", "[".repeat(depth), "]".repeat(depth));
619        let nested_maps = |depth: usize| format!("{}null{}", "{\"k\":".repeat(depth), "}".repeat(depth));
620        for build in [&nested_arrays as &dyn Fn(usize) -> String, &nested_maps] {
621            // serde_json's own recursion cap fires at 128 containers, so
622            // the deepest reachable input is 127; the visitor's own
623            // `> MAX_NESTED_DEPTH` arm is defense-in-depth for
624            // hypothetical cap-free parsers. Pin the user-visible
625            // contract: 127 parses, 128 is refused as the DuplicateKey
626            // family (serde's recursion error is mapped there
627            // deliberately so hostile-nesting triage has one class).
628            let deepest_reachable = build(MAX_NESTED_DEPTH - 1);
629            assert!(
630                check_no_duplicate_keys(deepest_reachable.as_bytes()).is_ok(),
631                "depth == MAX_NESTED_DEPTH - 1 must be accepted"
632            );
633            let past_limit = build(MAX_NESTED_DEPTH);
634            let outcome = check_no_duplicate_keys(past_limit.as_bytes());
635            assert!(
636                matches!(outcome, Err(ReceiptError::DuplicateKey(_))),
637                "depth == MAX_NESTED_DEPTH must be refused, got {outcome:?}"
638            );
639        }
640    }
641
642    /// Round-15 F4: `Receipt::from_json_slice` rejects duplicate keys
643    /// at ANY nesting level (top-level, nested inside `ai_agent`,
644    /// nested inside `subject`, deep inside an array element). This
645    /// closes the last-wins vs first-wins auditor split-brain that
646    /// would otherwise let a hostile issuer sign under one
647    /// interpretation while an external audit tool showed the other.
648    #[test]
649    fn from_json_slice_rejects_duplicate_top_level_key() {
650        let bytes = br#"{"receipt_version":1,"receipt_version":2,"receipt_id":"x"}"#;
651        let outcome = Receipt::from_json_slice(bytes);
652        assert!(
653            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("receipt_version")),
654            "expected DuplicateKey rejection, got {outcome:?}"
655        );
656    }
657
658    #[test]
659    fn from_json_slice_rejects_duplicate_key_inside_ai_agent() {
660        let bytes = br#"{"ai_agent":{"instance_uid":"a","instance_uid":"b"}}"#;
661        let outcome = Receipt::from_json_slice(bytes);
662        assert!(
663            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("instance_uid")),
664            "expected DuplicateKey rejection for nested field, got {outcome:?}"
665        );
666    }
667
668    #[test]
669    fn from_json_slice_rejects_duplicate_key_inside_array_element() {
670        // Rare shape but valid JSON that would slip past the top-level
671        // guard historically. Deep nesting → the walker recurses.
672        let bytes = br#"{"weird":[{"dup":1,"dup":2}]}"#;
673        let outcome = Receipt::from_json_slice(bytes);
674        assert!(
675            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("dup")),
676            "expected DuplicateKey rejection deep in an array, got {outcome:?}"
677        );
678    }
679
680    /// Round-16 F6: `check_no_duplicate_keys` must distinguish
681    /// duplicate-key rejections (mapped to `ReceiptError::DuplicateKey`)
682    /// from ordinary parse failures (mapped to `ReceiptError::Serde`).
683    /// Previously the blanket `.map_err(DuplicateKey)` folded EOF,
684    /// "expected value", and recursion-limit hits into the DuplicateKey
685    /// variant, actively misleading ops during triage.
686    #[test]
687    fn from_json_slice_maps_malformed_json_to_serde_not_duplicate_key() {
688        // Genuine parse failure — no duplicate key involved. Must
689        // surface as ReceiptError::Serde so tracing sites and CLI
690        // error text name the actual class.
691        for garbage in [
692            &b"not json"[..],
693            &b""[..],
694            &b"{\"unterminated"[..],
695            &b"{\"x\": }"[..],
696        ] {
697            let outcome = Receipt::from_json_slice(garbage);
698            assert!(
699                matches!(outcome, Err(ReceiptError::Serde(_))),
700                "expected Serde error for {garbage:?}, got {outcome:?}",
701            );
702        }
703    }
704
705    /// The duplicate-key error message must escape any hostile
706    /// control characters in the key so a `\n`-carrying key does not
707    /// inject a fake extra log line at tracing sites that render
708    /// %error into their output.
709    #[test]
710    fn from_json_slice_escapes_control_chars_in_duplicate_key_names() {
711        // JSON allows control chars in keys via \uXXXX escapes.
712        let bytes = br#"{"a\nb":1,"a\nb":2}"#;
713        let outcome = Receipt::from_json_slice(bytes);
714        let msg = match outcome {
715            Err(ReceiptError::DuplicateKey(msg)) => msg,
716            other => panic!("expected DuplicateKey, got {other:?}"),
717        };
718        // A literal newline in the message would let an operator log
719        // `%error` inject an unrelated line; escape_debug renders it
720        // as `\n`.
721        assert!(!msg.contains('\n'), "unescaped newline in error: {msg:?}");
722        assert!(msg.contains(r"\n"), "expected escaped newline, got {msg:?}");
723    }
724
725    /// Round-16: defense-in-depth against stack overflow via
726    /// deeply-nested JSON payload. serde_json's own recursion limit
727    /// is 128 (fires first with "recursion limit exceeded"); our
728    /// walker enforces the same bound explicitly so a future
729    /// serde_json change couldn't quietly reintroduce the hazard.
730    #[test]
731    fn from_json_slice_rejects_deeply_nested_json() {
732        let mut deep = String::from("{\"x\":");
733        for _ in 0..200 {
734            deep.push('[');
735        }
736        for _ in 0..200 {
737            deep.push(']');
738        }
739        deep.push('}');
740        let outcome = Receipt::from_json_slice(deep.as_bytes());
741        // Either serde_json fires first ("recursion limit exceeded")
742        // or our walker fires ("nesting exceeds 128"). Either way,
743        // the DoS is contained without a stack overflow.
744        assert!(
745            matches!(
746                outcome,
747                Err(ReceiptError::DuplicateKey(ref msg))
748                    if msg.contains("nesting exceeds") || msg.contains("recursion limit")
749            ),
750            "expected nesting-cap error, got {outcome:?}",
751        );
752    }
753
754    /// The happy path (well-formed receipt) still deserializes as
755    /// before — the duplicate-key gate is a strict filter, not a
756    /// disruption.
757    #[test]
758    fn from_json_slice_accepts_a_well_formed_receipt() {
759        let signer = Ed25519Signer::generate();
760        let receipt = Receipt::issue(body(), &signer).unwrap();
761        let bytes = serde_json::to_vec(&receipt).unwrap();
762        let restored = Receipt::from_json_slice(&bytes).unwrap();
763        assert_eq!(restored.body.session_id, "sess-77");
764    }
765
766    #[test]
767    fn verification_survives_json_roundtrip() {
768        // Receipts travel as JSON; key order may change en route. JCS must
769        // make verification independent of transport-layer reserialization.
770        let signer = Ed25519Signer::generate();
771        let mut ring = Keyring::new();
772        ring.add_signer(&signer).unwrap();
773        let receipt = Receipt::issue(body(), &signer).unwrap();
774        let json = serde_json::to_string(&receipt).unwrap();
775        let back: Receipt = serde_json::from_str(&json).unwrap();
776        back.verify(&ring).unwrap();
777    }
778
779    #[test]
780    fn issued_receipt_matches_shipped_schema() {
781        let signer = Ed25519Signer::from_seed(&[13; 32]);
782        let receipt = Receipt::issue(body(), &signer).unwrap();
783        let schema: serde_json::Value =
784            serde_json::from_str(include_str!("../../../schemas/receipt-v1.schema.json")).unwrap();
785        let validator = jsonschema::validator_for(&schema).unwrap();
786        let value = serde_json::to_value(receipt).unwrap();
787        let errors: Vec<_> = validator.iter_errors(&value).collect();
788        assert!(errors.is_empty(), "{errors:?}");
789    }
790
791    #[test]
792    fn every_field_tamper_detected() {
793        let signer = Ed25519Signer::generate();
794        let mut ring = Keyring::new();
795        ring.add_signer(&signer).unwrap();
796        let receipt = Receipt::issue(body(), &signer).unwrap();
797        let good = serde_json::to_value(&receipt).unwrap();
798
799        let tampers: Vec<(&str, serde_json::Value)> = vec![
800            ("session_id", "sess-OTHER".into()),
801            ("stop_reason_id", 1.into()),
802            ("receipt_id", "forged".into()),
803            ("issued_at", 1.into()),
804        ];
805        for (field, val) in tampers {
806            let mut bad = good.clone();
807            bad[field] = val;
808            let parsed: Receipt = serde_json::from_value(bad).unwrap();
809            assert!(
810                parsed.verify(&ring).is_err(),
811                "tampered {field} passed verification"
812            );
813        }
814        // Nested tampers.
815        let mut bad = good.clone();
816        bad["cost"]["cost_usd_micros"] = 1.into();
817        let parsed: Receipt = serde_json::from_value(bad).unwrap();
818        assert!(parsed.verify(&ring).is_err(), "tampered cost passed");
819
820        let mut bad = good.clone();
821        bad["ai_agent"]["charter"]["name"] = "swapped-charter".into();
822        let parsed: Receipt = serde_json::from_value(bad).unwrap();
823        assert!(parsed.verify(&ring).is_err(), "tampered charter passed");
824
825        let mut bad = good;
826        bad["subject"]["chain_head"] = "00".repeat(32).into();
827        let parsed: Receipt = serde_json::from_value(bad).unwrap();
828        assert!(parsed.verify(&ring).is_err(), "tampered chain head passed");
829    }
830
831    #[test]
832    fn key_substitution_detected() {
833        // Attacker re-signs a modified receipt with their own key but keeps
834        // the victim's key id.
835        let victim = Ed25519Signer::generate();
836        let attacker = Ed25519Signer::generate();
837        let mut ring = Keyring::new();
838        ring.add_signer(&victim).unwrap();
839
840        let mut receipt = Receipt::issue(body(), &attacker).unwrap();
841        receipt.body.key_id = victim.key_id().to_owned(); // lie about identity
842        assert!(matches!(receipt.verify(&ring), Err(ReceiptError::KeyMismatch(_))));
843
844        // Variant: also swap in the victim's public key (signature then fails).
845        receipt.body.public_key_b64 =
846            base64::engine::general_purpose::STANDARD.encode(victim.public_key_bytes());
847        assert!(receipt.verify(&ring).is_err());
848    }
849
850    #[test]
851    fn caller_cannot_forge_key_fields() {
852        let signer = Ed25519Signer::generate();
853        let mut b = body();
854        b.key_id = "attacker-chosen".into();
855        b.public_key_b64 = "AAAA".into();
856        let receipt = Receipt::issue(b, &signer).unwrap();
857        // issue() must have overwritten both.
858        assert_eq!(receipt.body.key_id, signer.key_id());
859        receipt.verify_embedded().unwrap();
860    }
861
862    #[test]
863    fn retroactive_atif_subject() {
864        let signer = Ed25519Signer::generate();
865        let mut ring = Keyring::new();
866        ring.add_signer(&signer).unwrap();
867        let mut b = body();
868        b.subject = ReceiptSubject::AtifTrajectory {
869            trajectory_digest: "cd".repeat(32),
870            step_count: 18,
871            retroactive: true,
872        };
873        let receipt = Receipt::issue(b, &signer).unwrap();
874        receipt.verify(&ring).unwrap();
875        let v = serde_json::to_value(&receipt).unwrap();
876        assert_eq!(v["subject"]["kind"], "atif_trajectory");
877        assert_eq!(v["subject"]["retroactive"], true);
878    }
879
880    #[test]
881    fn unknown_key_id_fails_ring_verification() {
882        let signer = Ed25519Signer::generate();
883        let receipt = Receipt::issue(body(), &signer).unwrap();
884        let ring = Keyring::new(); // empty
885        assert!(receipt.verify(&ring).is_err());
886    }
887
888    #[test]
889    fn attacker_receipt_does_not_pass_a_ring_seeded_with_only_a_different_key() {
890        let attacker = Ed25519Signer::generate();
891        let honest = Ed25519Signer::generate();
892        assert_ne!(attacker.key_id(), honest.key_id());
893        let forged = Receipt::issue(body(), &attacker).unwrap();
894        let mut ring = Keyring::new();
895        ring.add_signer(&honest).unwrap();
896        assert!(matches!(
897            forged.verify(&ring),
898            Err(ReceiptError::Key(KeyError::UnknownKeyId(_)))
899        ));
900    }
901
902    #[test]
903    fn verify_embedded_rejects_a_tampered_body() {
904        // verify_embedded MUST NOT be reducible to Ok(()): tamper the
905        // session_id and require Err. Catches any stub or short-circuit
906        // that would silently accept every receipt.
907        let signer = Ed25519Signer::generate();
908        let receipt = Receipt::issue(body(), &signer).unwrap();
909        let mut raw = serde_json::to_value(&receipt).unwrap();
910        raw["session_id"] = serde_json::Value::from("attacker");
911        let tampered: Receipt = serde_json::from_value(raw).unwrap();
912        assert!(tampered.verify_embedded().is_err());
913    }
914
915    #[test]
916    fn verify_embedded_rejects_a_swapped_public_key() {
917        // Substitute the embedded pubkey for someone else's — the id/key
918        // binding inside verify_embedded must catch this.
919        let honest = Ed25519Signer::generate();
920        let attacker = Ed25519Signer::generate();
921        let mut receipt = Receipt::issue(body(), &honest).unwrap();
922        receipt.body.public_key_b64 =
923            base64::engine::general_purpose::STANDARD.encode(attacker.public_key_bytes());
924        assert!(receipt.verify_embedded().is_err());
925    }
926
927    /// Stress: for every wall-clock instant sampled during issuance, the
928    /// integer `issued_at` and the human-readable `issued_at_iso` must be
929    /// mutually consistent. If they ever drift, downstream consumers that
930    /// parse only one field could reconstruct a different timestamp than
931    /// the signer intended, and the signature's coverage of both fields
932    /// would still verify (since the signer produced them together).
933    #[test]
934    fn issued_at_and_issued_at_iso_are_always_consistent() {
935        let signer = Ed25519Signer::generate();
936        for _ in 0..64 {
937            let receipt = Receipt::issue(body(), &signer).unwrap();
938            let derived = av_core::time::iso8601_ms(receipt.body.issued_at);
939            assert_eq!(
940                derived, receipt.body.issued_at_iso,
941                "iso/ms mismatch: {} vs {}",
942                receipt.body.issued_at, receipt.body.issued_at_iso,
943            );
944        }
945    }
946
947    /// Cross-machine stress: two independently-issued receipts (simulating
948    /// two harness replicas whose wall clocks drift within a normal
949    /// tolerance) must both verify against the same keyring and carry
950    /// UTC-Z timestamps regardless of what timezone the issuing process
951    /// was configured with.
952    #[test]
953    fn receipts_issued_by_two_machines_both_verify_and_use_utc_zulu() {
954        let signer_a = Ed25519Signer::generate();
955        let signer_b = Ed25519Signer::generate();
956        let mut ring = Keyring::new();
957        ring.add_signer(&signer_a).unwrap();
958        ring.add_signer(&signer_b).unwrap();
959        let ra = Receipt::issue(body(), &signer_a).unwrap();
960        let rb = Receipt::issue(body(), &signer_b).unwrap();
961        ra.verify(&ring).unwrap();
962        rb.verify(&ring).unwrap();
963        assert!(ra.body.issued_at_iso.ends_with('Z'), "{}", ra.body.issued_at_iso,);
964        assert!(rb.body.issued_at_iso.ends_with('Z'), "{}", rb.body.issued_at_iso,);
965        // Both timestamps must be within a minute of each other on the
966        // same physical host running the test.
967        let diff_ms = ra.body.issued_at.abs_diff(rb.body.issued_at);
968        assert!(diff_ms < 60_000, "clocks {diff_ms} ms apart");
969    }
970
971    /// A receipt with an extra top-level field that the signer did not
972    /// include in the signed body would appear in a raw-JSON audit
973    /// reader but be invisible to `verify()` (the field silently drops
974    /// during the ReceiptBody round-trip inside canonicalize). Our
975    /// custom `Deserialize` rejects it at parse time — a corrupted /
976    /// tampered receipt fails before it can be trusted.
977    #[test]
978    fn receipt_with_unknown_top_level_field_is_rejected_on_parse() {
979        let signer = Ed25519Signer::generate();
980        let receipt = Receipt::issue(body(), &signer).unwrap();
981        let mut value: serde_json::Value =
982            serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
983        value
984            .as_object_mut()
985            .unwrap()
986            .insert("claim_extra".to_owned(), serde_json::json!("grant admin"));
987        let tampered = serde_json::to_string(&value).unwrap();
988        let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
989        assert!(
990            error.to_string().contains("claim_extra"),
991            "error should name the offending field: {error}"
992        );
993    }
994
995    /// The manual whitelist must stay in lockstep with the field set of
996    /// [`ReceiptBody`]. If a new field is added there without updating
997    /// [`ALLOWED_RECEIPT_TOP_LEVEL_KEYS`], parsing that field silently
998    /// fails (or worse, drops it back into the same class of bug we
999    /// fixed). Serializing a canonical body and cross-checking is the
1000    /// mechanical guard.
1001    #[test]
1002    fn allowed_receipt_top_level_keys_cover_body_fields_exactly() {
1003        let value = serde_json::to_value(body()).unwrap();
1004        let object = value.as_object().unwrap();
1005        let mut body_keys: Vec<&str> = object.keys().map(String::as_str).collect();
1006        body_keys.push("signature_b64");
1007        body_keys.sort_unstable();
1008        let mut allowed: Vec<&str> = ALLOWED_RECEIPT_TOP_LEVEL_KEYS.to_vec();
1009        allowed.sort_unstable();
1010        assert_eq!(
1011            body_keys, allowed,
1012            "ALLOWED_RECEIPT_TOP_LEVEL_KEYS drift vs ReceiptBody serialization"
1013        );
1014    }
1015
1016    /// A hostile issuer could sign under one JSON parser's
1017    /// duplicate-key semantics (usually last-wins) while an auditor
1018    /// reads the same wire bytes under a different parser
1019    /// (first-wins). Both parses "succeed" but see different receipt
1020    /// contents. Our custom Deserialize now rejects duplicates
1021    /// outright, closing the parser-disagreement audit-spoofing gap.
1022    #[test]
1023    fn receipt_with_duplicate_top_level_key_is_rejected_on_parse() {
1024        // Hand-craft the JSON so both `session_id` entries land at the
1025        // top level and are seen in order by the map visitor. Rust's
1026        // `format!` guarantees a fixed byte sequence.
1027        let signer = Ed25519Signer::generate();
1028        let receipt = Receipt::issue(body(), &signer).unwrap();
1029        let good = serde_json::to_string(&receipt).unwrap();
1030        // Inject a second `session_id` right before the closing `}`.
1031        assert!(good.ends_with('}'));
1032        let tampered = format!(
1033            "{},\"session_id\":\"forged-second-copy\"}}",
1034            &good[..good.len() - 1]
1035        );
1036        let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
1037        assert!(
1038            error.to_string().contains("duplicate field"),
1039            "expected duplicate-key rejection, got: {error}"
1040        );
1041        assert!(
1042            error.to_string().contains("session_id"),
1043            "error should name the offending field, got: {error}"
1044        );
1045    }
1046}