1use crate::jcs::canonicalize;
12use crate::keys::{KeyError, Keyring, Signer};
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15
16pub const RECEIPT_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
22#[non_exhaustive]
23pub enum ReceiptSubject {
24 EventChain {
26 chain_head: String,
28 event_count: u64,
30 },
31 AtifTrajectory {
33 trajectory_digest: String,
35 step_count: u64,
37 retroactive: bool,
39 },
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct ToolCallSummary {
46 pub total: u64,
48 pub allowed: u64,
50 pub blocked: u64,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct CostSummary {
58 pub prompt_tokens: u64,
60 pub completion_tokens: u64,
62 pub cached_tokens: u64,
64 pub cost_usd_micros: u64,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ReceiptBody {
72 pub receipt_version: u32,
74 pub receipt_id: String,
76 pub session_id: String,
78 pub issued_at: u64,
80 pub issued_at_iso: String,
82 pub ai_agent: av_events::AgentIdentity,
84 pub subject: ReceiptSubject,
86 pub tool_calls: ToolCallSummary,
88 pub cost: CostSummary,
90 pub stop_reason_id: u8,
92 pub stop_reason: String,
94 pub key_id: String,
96 pub public_key_b64: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize)]
118pub struct Receipt {
119 #[serde(flatten)]
121 pub body: ReceiptBody,
122 pub signature_b64: String,
124}
125
126const ALLOWED_RECEIPT_TOP_LEVEL_KEYS: &[&str] = &[
134 "receipt_version",
135 "receipt_id",
136 "session_id",
137 "issued_at",
138 "issued_at_iso",
139 "ai_agent",
140 "subject",
141 "tool_calls",
142 "cost",
143 "stop_reason_id",
144 "stop_reason",
145 "key_id",
146 "public_key_b64",
147 "signature_b64",
148];
149
150impl<'de> Deserialize<'de> for Receipt {
151 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
152 use serde::de::{Error as _, MapAccess, Visitor};
153 struct ReceiptVisitor;
163 impl<'de> Visitor<'de> for ReceiptVisitor {
164 type Value = Receipt;
165 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.write_str("an AgentVisor AI Receipt JSON object")
167 }
168 fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Receipt, M::Error> {
169 let mut raw = serde_json::Map::new();
170 while let Some(key) = map.next_key::<String>()? {
171 if !ALLOWED_RECEIPT_TOP_LEVEL_KEYS.contains(&key.as_str()) {
172 return Err(M::Error::custom(format!(
173 "unknown field `{key}` in Receipt; a signed receipt \
174 may only carry the fields declared in ReceiptBody + \
175 signature_b64. Extra fields would be visible to a raw \
176 JSON reader but not covered by the signature — treat \
177 as tampering."
178 )));
179 }
180 if raw.contains_key(&key) {
181 return Err(M::Error::custom(format!(
182 "duplicate field `{key}` in Receipt; JSON parsers \
183 disagree on duplicate-key semantics (last-wins vs \
184 first-wins), so accepting duplicates would let a \
185 hostile issuer sign under one interpretation while an \
186 auditor's parser reports the other"
187 )));
188 }
189 let value: serde_json::Value = map.next_value()?;
190 raw.insert(key, value);
191 }
192 let signature_value = raw
193 .remove("signature_b64")
194 .ok_or_else(|| M::Error::missing_field("signature_b64"))?;
195 let signature_b64: String =
196 serde_json::from_value(signature_value).map_err(M::Error::custom)?;
197 let body: ReceiptBody =
198 serde_json::from_value(serde_json::Value::Object(raw)).map_err(M::Error::custom)?;
199 Ok(Receipt { body, signature_b64 })
200 }
201 }
202 deserializer.deserialize_map(ReceiptVisitor)
203 }
204}
205
206#[derive(Debug, thiserror::Error)]
208#[non_exhaustive]
209pub enum ReceiptError {
210 #[error("canonicalization: {0}")]
212 Jcs(#[from] crate::jcs::JcsError),
213 #[error("serialization: {0}")]
215 Serde(#[from] serde_json::Error),
216 #[error("key: {0}")]
218 Key(#[from] KeyError),
219 #[error("invalid base64 in receipt")]
221 Base64,
222 #[error("embedded public key mismatches keyring entry for {0:?}")]
225 KeyMismatch(String),
226 #[error("duplicate JSON key `{0}` at nesting; strict receipt parsers refuse this")]
232 DuplicateKey(String),
233 #[error("semantic invariant violated: {0}")]
238 SemanticInvariant(String),
239}
240
241impl Receipt {
242 pub fn from_json_slice(bytes: &[u8]) -> Result<Self, ReceiptError> {
263 check_no_duplicate_keys(bytes)?;
264 serde_json::from_slice(bytes).map_err(ReceiptError::Serde)
265 }
266
267 pub fn from_json_str(s: &str) -> Result<Self, ReceiptError> {
269 Self::from_json_slice(s.as_bytes())
270 }
271
272 pub fn issue(mut body: ReceiptBody, signer: &dyn Signer) -> Result<Self, ReceiptError> {
277 body.key_id = signer.key_id().to_owned();
278 body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
279 let canon = canonicalize(&serde_json::to_value(&body)?)?;
280 let sig = signer.sign(canon.as_bytes());
281 Ok(Self {
282 body,
283 signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
284 })
285 }
286
287 pub fn verify(&self, ring: &Keyring) -> Result<(), ReceiptError> {
297 self.verify_semantic_invariants()?;
298 let embedded = base64::engine::general_purpose::STANDARD
299 .decode(&self.body.public_key_b64)
300 .map_err(|_| ReceiptError::Base64)?;
301 let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
302 let mut probe = Keyring::new();
305 let derived_id = probe.add_key_bytes(&embedded)?;
306 if derived_id != self.body.key_id {
307 return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
308 }
309 let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
310 let sig = base64::engine::general_purpose::STANDARD
311 .decode(&self.signature_b64)
312 .map_err(|_| ReceiptError::Base64)?;
313 ring.verify(&self.body.key_id, canon.as_bytes(), &sig)?;
314 Ok(())
315 }
316
317 pub fn verify_embedded(&self) -> Result<(), ReceiptError> {
321 self.verify_semantic_invariants()?;
322 let embedded = base64::engine::general_purpose::STANDARD
323 .decode(&self.body.public_key_b64)
324 .map_err(|_| ReceiptError::Base64)?;
325 let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
326 let mut ring = Keyring::new();
327 let id = ring.add_key_bytes(&embedded)?;
328 if id != self.body.key_id {
329 return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
330 }
331 let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
332 let sig = base64::engine::general_purpose::STANDARD
333 .decode(&self.signature_b64)
334 .map_err(|_| ReceiptError::Base64)?;
335 ring.verify(&id, canon.as_bytes(), &sig)?;
336 Ok(())
337 }
338
339 fn verify_semantic_invariants(&self) -> Result<(), ReceiptError> {
346 if let ReceiptSubject::AtifTrajectory { retroactive, .. } = &self.body.subject {
347 if !retroactive {
348 return Err(ReceiptError::SemanticInvariant(
349 "AtifTrajectory.retroactive must be true (this receipt attests a retroactive promotion)"
350 .to_owned(),
351 ));
352 }
353 }
354 Ok(())
355 }
356}
357
358const MAX_NESTED_DEPTH: usize = 128;
374
375const DUP_KEY_SENTINEL: &str = "__av_dup:";
382
383fn check_no_duplicate_keys(bytes: &[u8]) -> Result<(), ReceiptError> {
384 use serde::Deserializer as _;
385 let mut deser = serde_json::Deserializer::from_slice(bytes);
386 match deser.deserialize_any(NoDupVisitor { depth: 0 }) {
387 Ok(()) => Ok(()),
388 Err(error) => {
389 let msg = error.to_string();
390 if let Some(rest) = msg.strip_prefix(DUP_KEY_SENTINEL) {
391 Err(ReceiptError::DuplicateKey(rest.to_owned()))
392 } else if msg.contains("recursion limit exceeded") {
393 Err(ReceiptError::DuplicateKey(msg))
398 } else {
399 Err(ReceiptError::Serde(error))
403 }
404 }
405 }
406}
407
408struct NoDupVisitor {
409 depth: usize,
410}
411
412impl<'de> serde::de::Visitor<'de> for NoDupVisitor {
413 type Value = ();
414 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 f.write_str("any JSON value (strict-mode duplicate-key check)")
416 }
417 fn visit_bool<E>(self, _: bool) -> Result<(), E> {
418 Ok(())
419 }
420 fn visit_i64<E>(self, _: i64) -> Result<(), E> {
421 Ok(())
422 }
423 fn visit_u64<E>(self, _: u64) -> Result<(), E> {
424 Ok(())
425 }
426 fn visit_f64<E>(self, _: f64) -> Result<(), E> {
427 Ok(())
428 }
429 fn visit_i128<E>(self, _: i128) -> Result<(), E> {
430 Ok(())
431 }
432 fn visit_u128<E>(self, _: u128) -> Result<(), E> {
433 Ok(())
434 }
435 fn visit_str<E>(self, _: &str) -> Result<(), E> {
436 Ok(())
437 }
438 fn visit_string<E>(self, _: String) -> Result<(), E> {
439 Ok(())
440 }
441 fn visit_none<E>(self) -> Result<(), E> {
442 Ok(())
443 }
444 fn visit_unit<E>(self) -> Result<(), E> {
445 Ok(())
446 }
447 fn visit_seq<S: serde::de::SeqAccess<'de>>(self, mut seq: S) -> Result<(), S::Error> {
448 use serde::de::Error as _;
449 let next_depth = self.depth.saturating_add(1);
450 if next_depth > MAX_NESTED_DEPTH {
451 return Err(S::Error::custom(format!(
452 "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
453 )));
454 }
455 while seq.next_element_seed(NoDupSeed { depth: next_depth })?.is_some() {}
456 Ok(())
457 }
458 fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<(), M::Error> {
459 use serde::de::Error as _;
460 let next_depth = self.depth.saturating_add(1);
461 if next_depth > MAX_NESTED_DEPTH {
462 return Err(M::Error::custom(format!(
463 "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
464 )));
465 }
466 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
467 while let Some(key) = map.next_key::<String>()? {
468 if !seen.insert(key.clone()) {
469 return Err(M::Error::custom(format!(
474 "{DUP_KEY_SENTINEL}duplicate key `{}` in JSON object",
475 key.escape_debug()
476 )));
477 }
478 map.next_value_seed(NoDupSeed { depth: next_depth })?;
479 }
480 Ok(())
481 }
482}
483
484struct NoDupSeed {
485 depth: usize,
486}
487
488impl<'de> serde::de::DeserializeSeed<'de> for NoDupSeed {
489 type Value = ();
490 fn deserialize<D: serde::Deserializer<'de>>(self, deser: D) -> Result<(), D::Error> {
491 deser.deserialize_any(NoDupVisitor { depth: self.depth })
492 }
493}
494
495#[allow(clippy::too_many_arguments)]
497pub fn new_body(
498 session_id: String,
499 ai_agent: av_events::AgentIdentity,
500 subject: ReceiptSubject,
501 tool_calls: ToolCallSummary,
502 cost: CostSummary,
503 stop_reason: av_events::StopReason,
504) -> ReceiptBody {
505 let now = av_core::time::now_ms();
506 ReceiptBody {
507 receipt_version: RECEIPT_VERSION,
508 receipt_id: av_core::new_event_uid(),
509 session_id,
510 issued_at: now,
511 issued_at_iso: av_core::time::iso8601_ms(now),
512 ai_agent,
513 subject,
514 tool_calls,
515 cost,
516 stop_reason_id: stop_reason.id(),
517 stop_reason: stop_reason.caption().to_owned(),
518 key_id: String::new(), public_key_b64: String::new(), }
521}
522
523#[cfg(test)]
524mod tests {
525 #![allow(
526 clippy::unwrap_used,
527 clippy::expect_used,
528 clippy::panic,
529 clippy::indexing_slicing
530 )]
531
532 use super::*;
533 use crate::keys::Ed25519Signer;
534
535 fn body() -> ReceiptBody {
536 new_body(
537 "sess-77".into(),
538 av_events::AgentIdentity {
539 version: "2.0.1".into(),
540 charter: "payments".into(),
541 instance_uid: "inst-9".into(),
542 ttl_remaining_s: None,
543 },
544 ReceiptSubject::EventChain {
545 chain_head: "ab".repeat(32),
546 event_count: 41,
547 },
548 ToolCallSummary {
549 total: 12,
550 allowed: 10,
551 blocked: 2,
552 },
553 CostSummary {
554 prompt_tokens: 52_000,
555 completion_tokens: 9_000,
556 cached_tokens: 30_000,
557 cost_usd_micros: 137_500,
558 },
559 av_events::StopReason::SessionClosed,
560 )
561 }
562
563 #[test]
564 fn issue_verify_roundtrip() {
565 let signer = Ed25519Signer::generate();
566 let mut ring = Keyring::new();
567 ring.add_signer(&signer).unwrap();
568 let receipt = Receipt::issue(body(), &signer).unwrap();
569 receipt.verify(&ring).unwrap();
570 receipt.verify_embedded().unwrap();
571 }
572
573 #[test]
581 fn retroactive_false_is_refused_on_both_verify_paths() {
582 let signer = Ed25519Signer::generate();
583 let mut ring = Keyring::new();
584 ring.add_signer(&signer).unwrap();
585 let subject = |retroactive| ReceiptSubject::AtifTrajectory {
586 trajectory_digest: "cd".repeat(32),
587 step_count: 7,
588 retroactive,
589 };
590 let make = |retroactive| {
591 let mut body = body();
592 body.subject = subject(retroactive);
593 Receipt::issue(body, &signer).unwrap()
594 };
595
596 let forged = make(false);
597 for outcome in [forged.verify(&ring), forged.verify_embedded()] {
598 assert!(
599 matches!(outcome, Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("retroactive")),
600 "retroactive=false must be refused, got {outcome:?}"
601 );
602 }
603
604 let honest = make(true);
605 honest.verify(&ring).unwrap();
606 honest.verify_embedded().unwrap();
607 }
608
609 #[test]
617 fn nesting_depth_boundary_is_exact_for_arrays_and_maps() {
618 let nested_arrays = |depth: usize| format!("{}null{}", "[".repeat(depth), "]".repeat(depth));
619 let nested_maps = |depth: usize| format!("{}null{}", "{\"k\":".repeat(depth), "}".repeat(depth));
620 for build in [&nested_arrays as &dyn Fn(usize) -> String, &nested_maps] {
621 let deepest_reachable = build(MAX_NESTED_DEPTH - 1);
629 assert!(
630 check_no_duplicate_keys(deepest_reachable.as_bytes()).is_ok(),
631 "depth == MAX_NESTED_DEPTH - 1 must be accepted"
632 );
633 let past_limit = build(MAX_NESTED_DEPTH);
634 let outcome = check_no_duplicate_keys(past_limit.as_bytes());
635 assert!(
636 matches!(outcome, Err(ReceiptError::DuplicateKey(_))),
637 "depth == MAX_NESTED_DEPTH must be refused, got {outcome:?}"
638 );
639 }
640 }
641
642 #[test]
649 fn from_json_slice_rejects_duplicate_top_level_key() {
650 let bytes = br#"{"receipt_version":1,"receipt_version":2,"receipt_id":"x"}"#;
651 let outcome = Receipt::from_json_slice(bytes);
652 assert!(
653 matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("receipt_version")),
654 "expected DuplicateKey rejection, got {outcome:?}"
655 );
656 }
657
658 #[test]
659 fn from_json_slice_rejects_duplicate_key_inside_ai_agent() {
660 let bytes = br#"{"ai_agent":{"instance_uid":"a","instance_uid":"b"}}"#;
661 let outcome = Receipt::from_json_slice(bytes);
662 assert!(
663 matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("instance_uid")),
664 "expected DuplicateKey rejection for nested field, got {outcome:?}"
665 );
666 }
667
668 #[test]
669 fn from_json_slice_rejects_duplicate_key_inside_array_element() {
670 let bytes = br#"{"weird":[{"dup":1,"dup":2}]}"#;
673 let outcome = Receipt::from_json_slice(bytes);
674 assert!(
675 matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("dup")),
676 "expected DuplicateKey rejection deep in an array, got {outcome:?}"
677 );
678 }
679
680 #[test]
687 fn from_json_slice_maps_malformed_json_to_serde_not_duplicate_key() {
688 for garbage in [
692 &b"not json"[..],
693 &b""[..],
694 &b"{\"unterminated"[..],
695 &b"{\"x\": }"[..],
696 ] {
697 let outcome = Receipt::from_json_slice(garbage);
698 assert!(
699 matches!(outcome, Err(ReceiptError::Serde(_))),
700 "expected Serde error for {garbage:?}, got {outcome:?}",
701 );
702 }
703 }
704
705 #[test]
710 fn from_json_slice_escapes_control_chars_in_duplicate_key_names() {
711 let bytes = br#"{"a\nb":1,"a\nb":2}"#;
713 let outcome = Receipt::from_json_slice(bytes);
714 let msg = match outcome {
715 Err(ReceiptError::DuplicateKey(msg)) => msg,
716 other => panic!("expected DuplicateKey, got {other:?}"),
717 };
718 assert!(!msg.contains('\n'), "unescaped newline in error: {msg:?}");
722 assert!(msg.contains(r"\n"), "expected escaped newline, got {msg:?}");
723 }
724
725 #[test]
731 fn from_json_slice_rejects_deeply_nested_json() {
732 let mut deep = String::from("{\"x\":");
733 for _ in 0..200 {
734 deep.push('[');
735 }
736 for _ in 0..200 {
737 deep.push(']');
738 }
739 deep.push('}');
740 let outcome = Receipt::from_json_slice(deep.as_bytes());
741 assert!(
745 matches!(
746 outcome,
747 Err(ReceiptError::DuplicateKey(ref msg))
748 if msg.contains("nesting exceeds") || msg.contains("recursion limit")
749 ),
750 "expected nesting-cap error, got {outcome:?}",
751 );
752 }
753
754 #[test]
758 fn from_json_slice_accepts_a_well_formed_receipt() {
759 let signer = Ed25519Signer::generate();
760 let receipt = Receipt::issue(body(), &signer).unwrap();
761 let bytes = serde_json::to_vec(&receipt).unwrap();
762 let restored = Receipt::from_json_slice(&bytes).unwrap();
763 assert_eq!(restored.body.session_id, "sess-77");
764 }
765
766 #[test]
767 fn verification_survives_json_roundtrip() {
768 let signer = Ed25519Signer::generate();
771 let mut ring = Keyring::new();
772 ring.add_signer(&signer).unwrap();
773 let receipt = Receipt::issue(body(), &signer).unwrap();
774 let json = serde_json::to_string(&receipt).unwrap();
775 let back: Receipt = serde_json::from_str(&json).unwrap();
776 back.verify(&ring).unwrap();
777 }
778
779 #[test]
780 fn issued_receipt_matches_shipped_schema() {
781 let signer = Ed25519Signer::from_seed(&[13; 32]);
782 let receipt = Receipt::issue(body(), &signer).unwrap();
783 let schema: serde_json::Value =
784 serde_json::from_str(include_str!("../../../schemas/receipt-v1.schema.json")).unwrap();
785 let validator = jsonschema::validator_for(&schema).unwrap();
786 let value = serde_json::to_value(receipt).unwrap();
787 let errors: Vec<_> = validator.iter_errors(&value).collect();
788 assert!(errors.is_empty(), "{errors:?}");
789 }
790
791 #[test]
792 fn every_field_tamper_detected() {
793 let signer = Ed25519Signer::generate();
794 let mut ring = Keyring::new();
795 ring.add_signer(&signer).unwrap();
796 let receipt = Receipt::issue(body(), &signer).unwrap();
797 let good = serde_json::to_value(&receipt).unwrap();
798
799 let tampers: Vec<(&str, serde_json::Value)> = vec![
800 ("session_id", "sess-OTHER".into()),
801 ("stop_reason_id", 1.into()),
802 ("receipt_id", "forged".into()),
803 ("issued_at", 1.into()),
804 ];
805 for (field, val) in tampers {
806 let mut bad = good.clone();
807 bad[field] = val;
808 let parsed: Receipt = serde_json::from_value(bad).unwrap();
809 assert!(
810 parsed.verify(&ring).is_err(),
811 "tampered {field} passed verification"
812 );
813 }
814 let mut bad = good.clone();
816 bad["cost"]["cost_usd_micros"] = 1.into();
817 let parsed: Receipt = serde_json::from_value(bad).unwrap();
818 assert!(parsed.verify(&ring).is_err(), "tampered cost passed");
819
820 let mut bad = good.clone();
821 bad["ai_agent"]["charter"]["name"] = "swapped-charter".into();
822 let parsed: Receipt = serde_json::from_value(bad).unwrap();
823 assert!(parsed.verify(&ring).is_err(), "tampered charter passed");
824
825 let mut bad = good;
826 bad["subject"]["chain_head"] = "00".repeat(32).into();
827 let parsed: Receipt = serde_json::from_value(bad).unwrap();
828 assert!(parsed.verify(&ring).is_err(), "tampered chain head passed");
829 }
830
831 #[test]
832 fn key_substitution_detected() {
833 let victim = Ed25519Signer::generate();
836 let attacker = Ed25519Signer::generate();
837 let mut ring = Keyring::new();
838 ring.add_signer(&victim).unwrap();
839
840 let mut receipt = Receipt::issue(body(), &attacker).unwrap();
841 receipt.body.key_id = victim.key_id().to_owned(); assert!(matches!(receipt.verify(&ring), Err(ReceiptError::KeyMismatch(_))));
843
844 receipt.body.public_key_b64 =
846 base64::engine::general_purpose::STANDARD.encode(victim.public_key_bytes());
847 assert!(receipt.verify(&ring).is_err());
848 }
849
850 #[test]
851 fn caller_cannot_forge_key_fields() {
852 let signer = Ed25519Signer::generate();
853 let mut b = body();
854 b.key_id = "attacker-chosen".into();
855 b.public_key_b64 = "AAAA".into();
856 let receipt = Receipt::issue(b, &signer).unwrap();
857 assert_eq!(receipt.body.key_id, signer.key_id());
859 receipt.verify_embedded().unwrap();
860 }
861
862 #[test]
863 fn retroactive_atif_subject() {
864 let signer = Ed25519Signer::generate();
865 let mut ring = Keyring::new();
866 ring.add_signer(&signer).unwrap();
867 let mut b = body();
868 b.subject = ReceiptSubject::AtifTrajectory {
869 trajectory_digest: "cd".repeat(32),
870 step_count: 18,
871 retroactive: true,
872 };
873 let receipt = Receipt::issue(b, &signer).unwrap();
874 receipt.verify(&ring).unwrap();
875 let v = serde_json::to_value(&receipt).unwrap();
876 assert_eq!(v["subject"]["kind"], "atif_trajectory");
877 assert_eq!(v["subject"]["retroactive"], true);
878 }
879
880 #[test]
881 fn unknown_key_id_fails_ring_verification() {
882 let signer = Ed25519Signer::generate();
883 let receipt = Receipt::issue(body(), &signer).unwrap();
884 let ring = Keyring::new(); assert!(receipt.verify(&ring).is_err());
886 }
887
888 #[test]
889 fn attacker_receipt_does_not_pass_a_ring_seeded_with_only_a_different_key() {
890 let attacker = Ed25519Signer::generate();
891 let honest = Ed25519Signer::generate();
892 assert_ne!(attacker.key_id(), honest.key_id());
893 let forged = Receipt::issue(body(), &attacker).unwrap();
894 let mut ring = Keyring::new();
895 ring.add_signer(&honest).unwrap();
896 assert!(matches!(
897 forged.verify(&ring),
898 Err(ReceiptError::Key(KeyError::UnknownKeyId(_)))
899 ));
900 }
901
902 #[test]
903 fn verify_embedded_rejects_a_tampered_body() {
904 let signer = Ed25519Signer::generate();
908 let receipt = Receipt::issue(body(), &signer).unwrap();
909 let mut raw = serde_json::to_value(&receipt).unwrap();
910 raw["session_id"] = serde_json::Value::from("attacker");
911 let tampered: Receipt = serde_json::from_value(raw).unwrap();
912 assert!(tampered.verify_embedded().is_err());
913 }
914
915 #[test]
916 fn verify_embedded_rejects_a_swapped_public_key() {
917 let honest = Ed25519Signer::generate();
920 let attacker = Ed25519Signer::generate();
921 let mut receipt = Receipt::issue(body(), &honest).unwrap();
922 receipt.body.public_key_b64 =
923 base64::engine::general_purpose::STANDARD.encode(attacker.public_key_bytes());
924 assert!(receipt.verify_embedded().is_err());
925 }
926
927 #[test]
934 fn issued_at_and_issued_at_iso_are_always_consistent() {
935 let signer = Ed25519Signer::generate();
936 for _ in 0..64 {
937 let receipt = Receipt::issue(body(), &signer).unwrap();
938 let derived = av_core::time::iso8601_ms(receipt.body.issued_at);
939 assert_eq!(
940 derived, receipt.body.issued_at_iso,
941 "iso/ms mismatch: {} vs {}",
942 receipt.body.issued_at, receipt.body.issued_at_iso,
943 );
944 }
945 }
946
947 #[test]
953 fn receipts_issued_by_two_machines_both_verify_and_use_utc_zulu() {
954 let signer_a = Ed25519Signer::generate();
955 let signer_b = Ed25519Signer::generate();
956 let mut ring = Keyring::new();
957 ring.add_signer(&signer_a).unwrap();
958 ring.add_signer(&signer_b).unwrap();
959 let ra = Receipt::issue(body(), &signer_a).unwrap();
960 let rb = Receipt::issue(body(), &signer_b).unwrap();
961 ra.verify(&ring).unwrap();
962 rb.verify(&ring).unwrap();
963 assert!(ra.body.issued_at_iso.ends_with('Z'), "{}", ra.body.issued_at_iso,);
964 assert!(rb.body.issued_at_iso.ends_with('Z'), "{}", rb.body.issued_at_iso,);
965 let diff_ms = ra.body.issued_at.abs_diff(rb.body.issued_at);
968 assert!(diff_ms < 60_000, "clocks {diff_ms} ms apart");
969 }
970
971 #[test]
978 fn receipt_with_unknown_top_level_field_is_rejected_on_parse() {
979 let signer = Ed25519Signer::generate();
980 let receipt = Receipt::issue(body(), &signer).unwrap();
981 let mut value: serde_json::Value =
982 serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
983 value
984 .as_object_mut()
985 .unwrap()
986 .insert("claim_extra".to_owned(), serde_json::json!("grant admin"));
987 let tampered = serde_json::to_string(&value).unwrap();
988 let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
989 assert!(
990 error.to_string().contains("claim_extra"),
991 "error should name the offending field: {error}"
992 );
993 }
994
995 #[test]
1002 fn allowed_receipt_top_level_keys_cover_body_fields_exactly() {
1003 let value = serde_json::to_value(body()).unwrap();
1004 let object = value.as_object().unwrap();
1005 let mut body_keys: Vec<&str> = object.keys().map(String::as_str).collect();
1006 body_keys.push("signature_b64");
1007 body_keys.sort_unstable();
1008 let mut allowed: Vec<&str> = ALLOWED_RECEIPT_TOP_LEVEL_KEYS.to_vec();
1009 allowed.sort_unstable();
1010 assert_eq!(
1011 body_keys, allowed,
1012 "ALLOWED_RECEIPT_TOP_LEVEL_KEYS drift vs ReceiptBody serialization"
1013 );
1014 }
1015
1016 #[test]
1023 fn receipt_with_duplicate_top_level_key_is_rejected_on_parse() {
1024 let signer = Ed25519Signer::generate();
1028 let receipt = Receipt::issue(body(), &signer).unwrap();
1029 let good = serde_json::to_string(&receipt).unwrap();
1030 assert!(good.ends_with('}'));
1032 let tampered = format!(
1033 "{},\"session_id\":\"forged-second-copy\"}}",
1034 &good[..good.len() - 1]
1035 );
1036 let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
1037 assert!(
1038 error.to_string().contains("duplicate field"),
1039 "expected duplicate-key rejection, got: {error}"
1040 );
1041 assert!(
1042 error.to_string().contains("session_id"),
1043 "error should name the offending field, got: {error}"
1044 );
1045 }
1046}