1use crate::body::Signature;
32use acdp_jcs::try_canonicalize_value;
33use acdp_primitives::error::AcdpError;
34use acdp_primitives::primitives::{ContentHash, CtxId, LineageId, Status};
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38
39pub const LINEAGE_HEAD_RECEIPT_VERSION: &str = "acdp-lhr/1";
44
45fn preimage_hash_of_object(
48 value: &serde_json::Value,
49 what: &str,
50) -> Result<ContentHash, AcdpError> {
51 let mut map = value
52 .as_object()
53 .cloned()
54 .ok_or_else(|| AcdpError::InvalidReceipt(format!("{what} must be a JSON object")))?;
55 map.remove("signature");
56 let canonical = try_canonicalize_value(&serde_json::Value::Object(map))?;
57 let digest = Sha256::digest(&canonical);
58 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
59}
60
61fn verify_receipt_signature_over_hash(
66 signature: &Signature,
67 hash: &ContentHash,
68 registry_pub_ed25519: Option<&[u8; 32]>,
69 registry_pub_p256_sec1: Option<&[u8]>,
70) -> Result<(), AcdpError> {
71 match signature.algorithm.as_str() {
72 "ed25519" => {
73 let key = registry_pub_ed25519.ok_or_else(|| {
74 AcdpError::InvalidReceipt(
75 "receipt declares ed25519 but no ed25519 registry key was resolved".into(),
76 )
77 })?;
78 acdp_crypto::verify::verify_ed25519(key, &signature.value, hash.as_str())
79 .map_err(|e| AcdpError::InvalidReceipt(format!("receipt signature: {e}")))
80 }
81 "ecdsa-p256" => {
82 let key = registry_pub_p256_sec1.ok_or_else(|| {
83 AcdpError::InvalidReceipt(
84 "receipt declares ecdsa-p256 but no p256 registry key was resolved".into(),
85 )
86 })?;
87 acdp_crypto::verify::verify_ecdsa_p256(key, &signature.value, hash.as_str())
88 .map_err(|e| AcdpError::InvalidReceipt(format!("receipt signature: {e}")))
89 }
90 other => Err(AcdpError::InvalidReceipt(format!(
91 "receipt signature algorithm '{other}' is not supported"
92 ))),
93 }
94}
95
96fn is_canonical_ms_utc(raw: &str) -> bool {
100 let b = raw.as_bytes();
101 b.len() == 24
102 && b[10] == b'T'
103 && b[19] == b'.'
104 && b[23] == b'Z'
105 && b[20..23].iter().all(u8::is_ascii_digit)
106 && chrono::DateTime::parse_from_rfc3339(raw).is_ok()
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct RegistryReceipt {
118 pub registry_did: String,
121 pub ctx_id: CtxId,
123 pub lineage_id: LineageId,
125 pub origin_registry: String,
127 #[serde(with = "ms_rfc3339")]
132 pub created_at: DateTime<Utc>,
133 pub content_hash: ContentHash,
135 pub key_fingerprint: String,
138 pub signature: Signature,
140}
141
142mod ms_rfc3339 {
145 use chrono::{DateTime, Utc};
146 use serde::{Deserialize, Deserializer, Serializer};
147
148 pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
149 s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
150 }
151
152 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
153 let raw = String::deserialize(d)?;
154 DateTime::parse_from_rfc3339(&raw)
155 .map(|t| t.with_timezone(&Utc))
156 .map_err(serde::de::Error::custom)
157 }
158}
159
160impl RegistryReceipt {
161 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
164 Self::deserialize(value)
165 .map_err(|e| AcdpError::InvalidReceipt(format!("registry_receipt does not parse: {e}")))
166 }
167
168 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
177 preimage_hash_of_object(value, "receipt")
178 }
179
180 pub fn validate_created_at_form(value: &serde_json::Value) -> Result<(), AcdpError> {
184 let raw = value
185 .get("created_at")
186 .and_then(|v| v.as_str())
187 .ok_or_else(|| {
188 AcdpError::InvalidReceipt("receipt created_at missing or not a string".into())
189 })?;
190 if !is_canonical_ms_utc(raw) {
191 return Err(AcdpError::InvalidReceipt(format!(
192 "receipt created_at '{raw}' is not canonical millisecond-precision \
193 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0010 §8 step 6)"
194 )));
195 }
196 Ok(())
197 }
198
199 pub fn cross_check_body(&self, body: &crate::body::Body) -> Result<(), AcdpError> {
204 if self.lineage_id != body.lineage_id {
205 return Err(AcdpError::InvalidReceipt(format!(
206 "receipt lineage_id '{}' ≠ body lineage_id '{}'",
207 self.lineage_id, body.lineage_id
208 )));
209 }
210 if self.origin_registry != body.origin_registry {
211 return Err(AcdpError::InvalidReceipt(format!(
212 "receipt origin_registry '{}' ≠ body origin_registry '{}'",
213 self.origin_registry, body.origin_registry
214 )));
215 }
216 if self.created_at != body.created_at {
217 return Err(AcdpError::InvalidReceipt(format!(
218 "receipt created_at '{}' ≠ body created_at '{}'",
219 self.created_at, body.created_at
220 )));
221 }
222 Ok(())
223 }
224
225 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
233 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
234 }
235
236 pub fn verify_signature_with_key(
241 &self,
242 registry_pub_ed25519: Option<&[u8; 32]>,
243 registry_pub_p256_sec1: Option<&[u8]>,
244 ) -> Result<(), AcdpError> {
245 let hash = self.preimage_hash()?;
246 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
247 }
248
249 pub fn verify_signature_against_hash(
253 &self,
254 hash: &ContentHash,
255 registry_pub_ed25519: Option<&[u8; 32]>,
256 registry_pub_p256_sec1: Option<&[u8]>,
257 ) -> Result<(), AcdpError> {
258 verify_receipt_signature_over_hash(
259 &self.signature,
260 hash,
261 registry_pub_ed25519,
262 registry_pub_p256_sec1,
263 )
264 }
265
266 pub fn cross_check(
280 &self,
281 expected_ctx_id: &CtxId,
282 recomputed_body_hash: &ContentHash,
283 producer_key_fingerprint: &str,
284 ) -> Result<(), AcdpError> {
285 if &self.ctx_id != expected_ctx_id {
286 return Err(AcdpError::InvalidReceipt(format!(
287 "receipt ctx_id '{}' ≠ requested '{expected_ctx_id}'",
288 self.ctx_id
289 )));
290 }
291 if &self.content_hash != recomputed_body_hash {
292 return Err(AcdpError::InvalidReceipt(format!(
293 "receipt content_hash '{}' ≠ recomputed body hash '{recomputed_body_hash}'",
294 self.content_hash
295 )));
296 }
297 if self.key_fingerprint != producer_key_fingerprint {
298 return Err(AcdpError::InvalidReceipt(format!(
299 "receipt key_fingerprint '{}' ≠ resolved producer key '{producer_key_fingerprint}'",
300 self.key_fingerprint
301 )));
302 }
303 if self.created_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
304 return Err(AcdpError::InvalidReceipt(
305 "receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
306 ));
307 }
308 let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
309 if self.registry_did != expected_did {
310 return Err(AcdpError::InvalidReceipt(format!(
311 "receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
312 self.registry_did
313 )));
314 }
315 Ok(())
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct LineageHeadReceipt {
339 pub receipt_version: String,
341 pub registry_did: String,
345 pub lineage_id: LineageId,
347 pub head_ctx_id: CtxId,
351 pub head_version: u32,
353 pub head_status: String,
361 #[serde(with = "ms_rfc3339")]
365 pub as_of: DateTime<Utc>,
366 pub signature: Signature,
369}
370
371impl LineageHeadReceipt {
372 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
379 let receipt = Self::deserialize(value).map_err(|e| {
380 AcdpError::InvalidReceipt(format!("lineage_head_receipt does not parse: {e}"))
381 })?;
382 if receipt.receipt_version != LINEAGE_HEAD_RECEIPT_VERSION {
383 return Err(AcdpError::InvalidReceipt(format!(
384 "lineage_head_receipt receipt_version '{}' ≠ '{LINEAGE_HEAD_RECEIPT_VERSION}' \
385 (RFC-ACDP-0011 §4)",
386 receipt.receipt_version
387 )));
388 }
389 if receipt.head_version < 1 {
390 return Err(AcdpError::InvalidReceipt(
391 "lineage_head_receipt head_version must be >= 1".into(),
392 ));
393 }
394 let status = Status::parse(&receipt.head_status).map_err(|e| {
398 AcdpError::InvalidReceipt(format!("lineage_head_receipt head_status: {e}"))
399 })?;
400 if matches!(status, Status::Superseded) || receipt.head_status == "retracted" {
401 return Err(AcdpError::InvalidReceipt(format!(
402 "lineage_head_receipt head_status must never be '{}' \
403 (RFC-ACDP-0011 §4: a superseded version is never the head; \
404 RFC-ACDP-0013 §8.3: a retracted version is never served as head)",
405 receipt.head_status
406 )));
407 }
408 if !receipt.registry_did.starts_with("did:web:") {
409 return Err(AcdpError::InvalidReceipt(format!(
410 "lineage_head_receipt registry_did '{}' must be did:web \
411 (RFC-ACDP-0011 §4)",
412 receipt.registry_did
413 )));
414 }
415 Self::validate_as_of_form(value)?;
418 Ok(receipt)
419 }
420
421 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
426 preimage_hash_of_object(value, "lineage_head_receipt")
427 }
428
429 pub fn validate_as_of_form(value: &serde_json::Value) -> Result<(), AcdpError> {
433 let raw = value.get("as_of").and_then(|v| v.as_str()).ok_or_else(|| {
434 AcdpError::InvalidReceipt("lineage_head_receipt as_of missing or not a string".into())
435 })?;
436 if !is_canonical_ms_utc(raw) {
437 return Err(AcdpError::InvalidReceipt(format!(
438 "lineage_head_receipt as_of '{raw}' is not canonical millisecond-precision \
439 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0011 §4)"
440 )));
441 }
442 Ok(())
443 }
444
445 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
449 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
450 }
451
452 pub fn verify_signature_with_key(
457 &self,
458 registry_pub_ed25519: Option<&[u8; 32]>,
459 registry_pub_p256_sec1: Option<&[u8]>,
460 ) -> Result<(), AcdpError> {
461 let hash = self.preimage_hash()?;
462 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
463 }
464
465 pub fn verify_signature_against_hash(
469 &self,
470 hash: &ContentHash,
471 registry_pub_ed25519: Option<&[u8; 32]>,
472 registry_pub_p256_sec1: Option<&[u8]>,
473 ) -> Result<(), AcdpError> {
474 verify_receipt_signature_over_hash(
475 &self.signature,
476 hash,
477 registry_pub_ed25519,
478 registry_pub_p256_sec1,
479 )
480 }
481
482 pub fn cross_check_registry_binding(
492 &self,
493 serving_authority: &str,
494 capabilities_registry_did: &str,
495 ) -> Result<(), AcdpError> {
496 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
497 if self.registry_did != expected_did {
498 return Err(AcdpError::InvalidReceipt(format!(
499 "lineage_head_receipt registry_did '{}' ≠ serving authority's DID \
500 '{expected_did}' (RFC-ACDP-0011 §7 step 3)",
501 self.registry_did
502 )));
503 }
504 if self.registry_did != capabilities_registry_did {
505 return Err(AcdpError::InvalidReceipt(format!(
506 "lineage_head_receipt registry_did '{}' ≠ capabilities.registry_did \
507 '{capabilities_registry_did}' (RFC-ACDP-0011 §7 step 3)",
508 self.registry_did
509 )));
510 }
511 match self.signature.key_id.split_once('#') {
512 Some((did, frag)) if did == self.registry_did && !frag.is_empty() => {}
513 _ => {
514 return Err(AcdpError::InvalidReceipt(format!(
515 "lineage_head_receipt signature.key_id '{}' is not a DID URL under \
516 registry_did '{}' (RFC-ACDP-0011 §7 step 3)",
517 self.signature.key_id, self.registry_did
518 )));
519 }
520 }
521 let head_authority_did = acdp_did::web::authority_to_did_web(self.head_ctx_id.authority());
522 if head_authority_did != self.registry_did {
523 return Err(AcdpError::InvalidReceipt(format!(
524 "lineage_head_receipt head_ctx_id authority '{}' ≠ registry_did '{}' \
525 (RFC-ACDP-0011 §7 step 3: lineages are single-registry)",
526 self.head_ctx_id.authority(),
527 self.registry_did
528 )));
529 }
530 Ok(())
531 }
532
533 pub fn cross_check_lineage(&self, requested: &LineageId) -> Result<(), AcdpError> {
537 if &self.lineage_id != requested {
538 return Err(AcdpError::InvalidReceipt(format!(
539 "lineage_head_receipt lineage_id '{}' ≠ requested lineage '{requested}' \
540 (RFC-ACDP-0011 §7 step 4)",
541 self.lineage_id
542 )));
543 }
544 Ok(())
545 }
546
547 pub fn cross_check_head(
560 &self,
561 served_ctx_id: &CtxId,
562 served_version: u32,
563 served_status: &Status,
564 on_current_endpoint: bool,
565 ) -> Result<(), AcdpError> {
566 if on_current_endpoint || &self.head_ctx_id == served_ctx_id {
567 if &self.head_ctx_id != served_ctx_id {
568 return Err(AcdpError::InvalidReceipt(format!(
569 "lineage_head_receipt head_ctx_id '{}' ≠ served ctx_id '{served_ctx_id}' \
570 (RFC-ACDP-0011 §7 step 5: /current must serve the attested head)",
571 self.head_ctx_id
572 )));
573 }
574 if self.head_version != served_version {
575 return Err(AcdpError::InvalidReceipt(format!(
576 "lineage_head_receipt head_version {} ≠ served body.version \
577 {served_version} (RFC-ACDP-0011 §7 step 5)",
578 self.head_version
579 )));
580 }
581 if self.head_status != served_status.as_str() {
582 return Err(AcdpError::InvalidReceipt(format!(
583 "lineage_head_receipt head_status '{}' ≠ served registry_state.status \
584 '{}' (RFC-ACDP-0011 §7 step 5)",
585 self.head_status,
586 served_status.as_str()
587 )));
588 }
589 return Ok(());
590 }
591 if self.head_version <= served_version {
593 return Err(AcdpError::InvalidReceipt(format!(
594 "lineage_head_receipt names a different head '{}' but head_version {} is \
595 not greater than the served body.version {served_version} \
596 (RFC-ACDP-0011 §7 step 5b)",
597 self.head_ctx_id, self.head_version
598 )));
599 }
600 let non_head_served =
601 matches!(served_status, Status::Superseded) || served_status.as_str() == "retracted";
602 if !non_head_served {
603 return Err(AcdpError::InvalidReceipt(format!(
604 "lineage_head_receipt names a different head '{}' but the served context's \
605 status is '{}', not 'superseded' (or 'retracted', RFC-ACDP-0013 §7.2) — \
606 self-contradictory response (RFC-ACDP-0011 §7 step 5b)",
607 self.head_ctx_id,
608 served_status.as_str()
609 )));
610 }
611 Ok(())
612 }
613
614 pub fn check_as_of_skew(
624 &self,
625 now: DateTime<Utc>,
626 max_clock_skew: chrono::Duration,
627 ) -> Result<(), AcdpError> {
628 if self.as_of.timestamp_subsec_nanos() % 1_000_000 != 0 {
629 return Err(AcdpError::InvalidReceipt(
630 "lineage_head_receipt as_of is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
631 .into(),
632 ));
633 }
634 if self.as_of > now + max_clock_skew {
635 return Err(AcdpError::InvalidReceipt(format!(
636 "lineage_head_receipt as_of '{}' is in the future beyond the {}s clock-skew \
637 allowance (consumer clock '{}') — forged freshness claim \
638 (RFC-ACDP-0011 §7 step 6)",
639 self.as_of.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
640 max_clock_skew.num_seconds(),
641 now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
642 )));
643 }
644 Ok(())
645 }
646
647 pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
652 now - self.as_of
653 }
654}
655
656pub struct ReceiptSigner {
665 key: acdp_crypto::sign::AcdpSigningKey,
666 key_id: String,
668 registry_did: String,
670}
671
672impl ReceiptSigner {
673 pub fn new(
677 key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
678 registry_did: impl Into<String>,
679 key_id: impl Into<String>,
680 ) -> Result<Self, AcdpError> {
681 let registry_did = registry_did.into();
682 let key_id = key_id.into();
683 if !registry_did.starts_with("did:web:") {
684 return Err(AcdpError::SchemaViolation(format!(
685 "receipt signer registry_did must be did:web, got '{registry_did}'"
686 )));
687 }
688 match key_id.split_once('#') {
689 Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
690 _ => {
691 return Err(AcdpError::SchemaViolation(format!(
692 "receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
693 )));
694 }
695 }
696 Ok(Self {
697 key: key.into(),
698 key_id,
699 registry_did,
700 })
701 }
702
703 pub fn registry_did(&self) -> &str {
705 &self.registry_did
706 }
707
708 pub fn mint(
715 &self,
716 ctx_id: &CtxId,
717 lineage_id: &LineageId,
718 origin_registry: &str,
719 created_at: DateTime<Utc>,
720 content_hash: &ContentHash,
721 producer_key_fingerprint: &str,
722 ) -> Result<RegistryReceipt, AcdpError> {
723 let mut receipt = RegistryReceipt {
724 registry_did: self.registry_did.clone(),
725 ctx_id: ctx_id.clone(),
726 lineage_id: lineage_id.clone(),
727 origin_registry: origin_registry.to_string(),
728 created_at: acdp_primitives::time::trunc_ms(created_at),
729 content_hash: content_hash.clone(),
730 key_fingerprint: producer_key_fingerprint.to_string(),
731 signature: Signature {
732 algorithm: self.key.algorithm().into(),
733 key_id: self.key_id.clone(),
734 value: String::new(), },
736 };
737 let hash = receipt.preimage_hash()?;
738 let (algorithm, value) = self.key.sign_content_hash(&hash);
739 receipt.signature.algorithm = algorithm.into();
740 receipt.signature.value = value;
741 Ok(receipt)
742 }
743
744 pub fn mint_lineage_head(
759 &self,
760 lineage_id: &LineageId,
761 head_ctx_id: &CtxId,
762 head_version: u32,
763 head_status: &Status,
764 as_of: DateTime<Utc>,
765 ) -> Result<LineageHeadReceipt, AcdpError> {
766 if matches!(head_status, Status::Superseded) || head_status.as_str() == "retracted" {
767 return Err(AcdpError::SchemaViolation(format!(
768 "cannot mint a lineage-head receipt with head_status '{}' — a superseded \
769 version is never the head (RFC-ACDP-0011 §4) and a retracted version is \
770 never served as head (RFC-ACDP-0013 §8.3)",
771 head_status.as_str()
772 )));
773 }
774 if head_version < 1 {
775 return Err(AcdpError::SchemaViolation(
776 "cannot mint a lineage-head receipt with head_version 0 (RFC-ACDP-0011 §4)".into(),
777 ));
778 }
779 let head_authority_did = acdp_did::web::authority_to_did_web(head_ctx_id.authority());
780 if head_authority_did != self.registry_did {
781 return Err(AcdpError::SchemaViolation(format!(
782 "cannot mint a lineage-head receipt for head_ctx_id authority '{}' under \
783 registry_did '{}' (RFC-ACDP-0011 §4: lineages are single-registry)",
784 head_ctx_id.authority(),
785 self.registry_did
786 )));
787 }
788 let mut receipt = LineageHeadReceipt {
789 receipt_version: LINEAGE_HEAD_RECEIPT_VERSION.to_string(),
790 registry_did: self.registry_did.clone(),
791 lineage_id: lineage_id.clone(),
792 head_ctx_id: head_ctx_id.clone(),
793 head_version,
794 head_status: head_status.as_str().to_string(),
795 as_of: acdp_primitives::time::trunc_ms(as_of),
796 signature: Signature {
797 algorithm: self.key.algorithm().into(),
798 key_id: self.key_id.clone(),
799 value: String::new(), },
801 };
802 let hash = receipt.preimage_hash()?;
803 let (algorithm, value) = self.key.sign_content_hash(&hash);
804 receipt.signature.algorithm = algorithm.into();
805 receipt.signature.value = value;
806 Ok(receipt)
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use acdp_crypto::SigningKey;
814
815 fn test_signer() -> ReceiptSigner {
816 ReceiptSigner::new(
817 SigningKey::from_bytes(&[1u8; 32]),
818 "did:web:registry.example.com",
819 "did:web:registry.example.com#receipt-key-1",
820 )
821 .unwrap()
822 }
823
824 fn test_receipt() -> RegistryReceipt {
825 test_signer()
826 .mint(
827 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
828 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
829 "registry.example.com",
830 chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
831 .unwrap()
832 .with_timezone(&chrono::Utc),
833 &ContentHash(format!("sha256:{}", "b".repeat(64))),
834 "sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
835 )
836 .unwrap()
837 }
838
839 fn registry_pub() -> [u8; 32] {
840 SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
841 }
842
843 #[test]
844 fn mint_verify_round_trip() {
845 let receipt = test_receipt();
846 receipt
847 .verify_signature_with_key(Some(®istry_pub()), None)
848 .expect("freshly minted receipt must verify");
849 }
850
851 #[test]
852 fn tampered_fields_fail_verification() {
853 let pubkey = registry_pub();
855 let mut r = test_receipt();
856 r.created_at += chrono::Duration::milliseconds(1);
857 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
858
859 let mut r = test_receipt();
860 r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
861 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
862
863 let mut r = test_receipt();
864 r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
865 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
866 }
867
868 #[test]
871 fn unknown_receipt_fields_rejected() {
872 let mut wire = serde_json::to_value(test_receipt()).unwrap();
873 wire.as_object_mut()
874 .unwrap()
875 .insert("transparency_log_index".into(), serde_json::json!(42));
876 let err = RegistryReceipt::from_value(&wire).unwrap_err();
877 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
878 }
879
880 #[test]
886 fn raw_and_struct_preimages_agree_incl_whole_second() {
887 let receipt = test_receipt();
888 let wire = serde_json::to_value(&receipt).unwrap();
889 assert_eq!(
890 RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
891 receipt.preimage_hash().unwrap()
892 );
893 RegistryReceipt::validate_created_at_form(&wire).unwrap();
894
895 let signer = test_signer();
897 let r = signer
898 .mint(
899 &receipt.ctx_id,
900 &receipt.lineage_id,
901 "registry.example.com",
902 chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
903 .unwrap()
904 .with_timezone(&chrono::Utc),
905 &receipt.content_hash,
906 &receipt.key_fingerprint,
907 )
908 .unwrap();
909 let wire = serde_json::to_value(&r).unwrap();
910 assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
911 RegistryReceipt::validate_created_at_form(&wire).unwrap();
912 let parsed = RegistryReceipt::from_value(&wire).unwrap();
913 parsed
914 .verify_signature_with_key(Some(®istry_pub()), None)
915 .expect("whole-second receipt must round-trip and verify");
916 }
917
918 #[test]
919 fn cross_checks_fire() {
920 let r = test_receipt();
921 let ctx = r.ctx_id.clone();
922 let hash = r.content_hash.clone();
923 let fp = r.key_fingerprint.clone();
924
925 r.cross_check(&ctx, &hash, &fp).expect("all aligned");
926
927 let other =
929 CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
930 assert!(matches!(
931 r.cross_check(&other, &hash, &fp).unwrap_err(),
932 AcdpError::InvalidReceipt(_)
933 ));
934 assert!(r
936 .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
937 .is_err());
938 assert!(r
940 .cross_check(
941 &ctx,
942 &ContentHash(format!("sha256:{}", "c".repeat(64))),
943 &fp
944 )
945 .is_err());
946 }
947
948 #[test]
949 fn signer_rejects_malformed_identity() {
950 assert!(ReceiptSigner::new(
951 SigningKey::from_bytes(&[1u8; 32]),
952 "did:key:zNotWeb",
953 "did:key:zNotWeb#k",
954 )
955 .is_err());
956 assert!(ReceiptSigner::new(
957 SigningKey::from_bytes(&[1u8; 32]),
958 "did:web:registry.example.com",
959 "did:web:other.example.com#k",
960 )
961 .is_err());
962 assert!(ReceiptSigner::new(
963 SigningKey::from_bytes(&[1u8; 32]),
964 "did:web:registry.example.com",
965 "did:web:registry.example.com",
966 )
967 .is_err());
968 }
969
970 fn test_head_receipt() -> LineageHeadReceipt {
973 test_signer()
974 .mint_lineage_head(
975 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
976 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
977 2,
978 &Status::Active,
979 chrono::DateTime::parse_from_rfc3339("2026-07-04T09:00:00.123Z")
980 .unwrap()
981 .with_timezone(&chrono::Utc),
982 )
983 .unwrap()
984 }
985
986 #[test]
987 fn head_receipt_mint_verify_round_trip() {
988 let r = test_head_receipt();
989 assert_eq!(r.receipt_version, LINEAGE_HEAD_RECEIPT_VERSION);
990 r.verify_signature_with_key(Some(®istry_pub()), None)
991 .expect("freshly minted head receipt must verify");
992 let wire = serde_json::to_value(&r).unwrap();
994 LineageHeadReceipt::validate_as_of_form(&wire).unwrap();
995 let parsed = LineageHeadReceipt::from_value(&wire).unwrap();
996 parsed
997 .verify_signature_with_key(Some(®istry_pub()), None)
998 .unwrap();
999 assert_eq!(
1000 LineageHeadReceipt::preimage_hash_of_value(&wire).unwrap(),
1001 r.preimage_hash().unwrap()
1002 );
1003 }
1004
1005 #[test]
1009 fn head_receipt_domain_separation_and_closed_schema() {
1010 let r = test_head_receipt();
1011 let mut wire = serde_json::to_value(&r).unwrap();
1012
1013 wire.as_object_mut()
1015 .unwrap()
1016 .insert("freshness_proof".into(), serde_json::json!(true));
1017 assert!(matches!(
1018 LineageHeadReceipt::from_value(&wire).unwrap_err(),
1019 AcdpError::InvalidReceipt(_)
1020 ));
1021
1022 let mut wire = serde_json::to_value(&r).unwrap();
1024 wire["receipt_version"] = serde_json::json!("acdp-lhr/2");
1025 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1026
1027 let wire = serde_json::to_value(&r).unwrap();
1030 assert!(RegistryReceipt::from_value(&wire).is_err());
1031 let rcpt_wire = serde_json::to_value(test_receipt()).unwrap();
1032 assert!(LineageHeadReceipt::from_value(&rcpt_wire).is_err());
1033 }
1034
1035 #[test]
1036 fn head_receipt_semantic_invariants_rejected() {
1037 let r = test_head_receipt();
1038
1039 let mut wire = serde_json::to_value(&r).unwrap();
1041 wire["head_status"] = serde_json::json!("superseded");
1042 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1043 assert!(test_signer()
1044 .mint_lineage_head(
1045 &r.lineage_id,
1046 &r.head_ctx_id,
1047 1,
1048 &Status::Superseded,
1049 chrono::Utc::now(),
1050 )
1051 .is_err());
1052
1053 let mut wire = serde_json::to_value(&r).unwrap();
1055 wire["head_status"] = serde_json::json!("retracted");
1056 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1057 assert!(test_signer()
1058 .mint_lineage_head(
1059 &r.lineage_id,
1060 &r.head_ctx_id,
1061 1,
1062 &Status::parse("retracted").unwrap(),
1063 chrono::Utc::now(),
1064 )
1065 .is_err());
1066
1067 let mut wire = serde_json::to_value(&r).unwrap();
1069 wire["head_version"] = serde_json::json!(0);
1070 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1071
1072 let mut wire = serde_json::to_value(&r).unwrap();
1074 wire["as_of"] = serde_json::json!("2026-07-04T09:00:00Z");
1075 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1076
1077 assert!(test_signer()
1079 .mint_lineage_head(
1080 &r.lineage_id,
1081 &CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into()),
1082 1,
1083 &Status::Active,
1084 chrono::Utc::now(),
1085 )
1086 .is_err());
1087 }
1088
1089 #[test]
1090 fn head_receipt_cross_checks_fire() {
1091 let r = test_head_receipt();
1092
1093 r.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
1095 .unwrap();
1096 r.cross_check_lineage(&r.lineage_id).unwrap();
1097 r.cross_check_head(&r.head_ctx_id, 2, &Status::Active, true)
1098 .unwrap();
1099
1100 assert!(r
1102 .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
1103 .is_err());
1104 assert!(r
1105 .cross_check_registry_binding("registry.example.com", "did:web:other.example")
1106 .is_err());
1107
1108 assert!(r
1110 .cross_check_lineage(&LineageId(format!("lin:sha256:{}", "f".repeat(64))))
1111 .is_err());
1112
1113 let other =
1115 CtxId("acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into());
1116 assert!(r
1117 .cross_check_head(&other, 3, &Status::Active, true)
1118 .is_err());
1119 assert!(r
1121 .cross_check_head(&r.head_ctx_id, 1, &Status::Active, true)
1122 .is_err());
1123 assert!(r
1124 .cross_check_head(&r.head_ctx_id, 2, &Status::Expired, true)
1125 .is_err());
1126
1127 r.cross_check_head(&other, 1, &Status::Superseded, false)
1131 .unwrap();
1132 r.cross_check_head(&other, 1, &Status::parse("retracted").unwrap(), false)
1133 .unwrap();
1134 assert!(r
1136 .cross_check_head(&other, 1, &Status::Active, false)
1137 .is_err());
1138 assert!(r
1139 .cross_check_head(&other, 1, &Status::Expired, false)
1140 .is_err());
1141 assert!(r
1142 .cross_check_head(&other, 2, &Status::Superseded, false)
1143 .is_err());
1144 }
1145
1146 #[test]
1150 fn head_receipt_as_of_skew_and_age() {
1151 let r = test_head_receipt();
1152 let skew = chrono::Duration::seconds(120);
1153
1154 let now = r.as_of - chrono::Duration::seconds(30); r.check_as_of_skew(now, skew).expect("within skew");
1156
1157 let now = r.as_of - chrono::Duration::seconds(300); let err = r.check_as_of_skew(now, skew).unwrap_err();
1159 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
1160
1161 let now = r.as_of + chrono::Duration::seconds(3600);
1163 r.check_as_of_skew(now, skew)
1164 .expect("stale is not a step-6 failure");
1165 assert_eq!(r.age_at(now), chrono::Duration::seconds(3600));
1166 }
1167}