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