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