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
45pub(crate) fn preimage_hash_of_map(
52 mut map: serde_json::Map<String, serde_json::Value>,
53) -> Result<ContentHash, AcdpError> {
54 map.remove("signature");
55 let canonical = try_canonicalize_value(&serde_json::Value::Object(map))?;
56 let digest = Sha256::digest(&canonical);
57 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
58}
59
60pub(crate) fn preimage_hash_of_object_with(
65 value: &serde_json::Value,
66 what: &str,
67 mk_err: fn(String) -> AcdpError,
68) -> Result<ContentHash, AcdpError> {
69 let map = value
70 .as_object()
71 .cloned()
72 .ok_or_else(|| mk_err(format!("{what} must be a JSON object")))?;
73 preimage_hash_of_map(map)
74}
75
76fn preimage_hash_of_object(
79 value: &serde_json::Value,
80 what: &str,
81) -> Result<ContentHash, AcdpError> {
82 preimage_hash_of_object_with(value, what, AcdpError::InvalidReceipt)
83}
84
85pub(crate) fn verify_signature_over_hash_with(
92 signature: &Signature,
93 hash: &ContentHash,
94 registry_pub_ed25519: Option<&[u8; 32]>,
95 registry_pub_p256_sec1: Option<&[u8]>,
96 what: &str,
97 mk_err: fn(String) -> AcdpError,
98) -> Result<(), AcdpError> {
99 match signature.algorithm.as_str() {
100 "ed25519" => {
101 let key = registry_pub_ed25519.ok_or_else(|| {
102 mk_err(format!(
103 "{what} declares ed25519 but no ed25519 registry key was resolved"
104 ))
105 })?;
106 acdp_crypto::verify::verify_ed25519(key, &signature.value, hash.as_str())
107 .map_err(|e| mk_err(format!("{what} signature: {e}")))
108 }
109 "ecdsa-p256" => {
110 let key = registry_pub_p256_sec1.ok_or_else(|| {
111 mk_err(format!(
112 "{what} declares ecdsa-p256 but no p256 registry key was resolved"
113 ))
114 })?;
115 acdp_crypto::verify::verify_ecdsa_p256(key, &signature.value, hash.as_str())
116 .map_err(|e| mk_err(format!("{what} signature: {e}")))
117 }
118 other => Err(mk_err(format!(
119 "{what} signature algorithm '{other}' is not supported"
120 ))),
121 }
122}
123
124fn verify_receipt_signature_over_hash(
127 signature: &Signature,
128 hash: &ContentHash,
129 registry_pub_ed25519: Option<&[u8; 32]>,
130 registry_pub_p256_sec1: Option<&[u8]>,
131) -> Result<(), AcdpError> {
132 verify_signature_over_hash_with(
133 signature,
134 hash,
135 registry_pub_ed25519,
136 registry_pub_p256_sec1,
137 "receipt",
138 AcdpError::InvalidReceipt,
139 )
140}
141
142pub(crate) fn is_canonical_ms_utc(raw: &str) -> bool {
146 let b = raw.as_bytes();
147 b.len() == 24
148 && b[10] == b'T'
149 && b[19] == b'.'
150 && b[23] == b'Z'
151 && b[20..23].iter().all(u8::is_ascii_digit)
152 && chrono::DateTime::parse_from_rfc3339(raw).is_ok()
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct RegistryReceipt {
164 pub registry_did: String,
167 pub ctx_id: CtxId,
169 pub lineage_id: LineageId,
171 pub origin_registry: String,
173 #[serde(with = "ms_rfc3339")]
178 pub created_at: DateTime<Utc>,
179 pub content_hash: ContentHash,
181 pub key_fingerprint: String,
184 pub signature: Signature,
186}
187
188pub(crate) mod ms_rfc3339 {
192 use chrono::{DateTime, Utc};
193 use serde::{Deserialize, Deserializer, Serializer};
194
195 pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
196 s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
197 }
198
199 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
200 let raw = String::deserialize(d)?;
201 DateTime::parse_from_rfc3339(&raw)
202 .map(|t| t.with_timezone(&Utc))
203 .map_err(serde::de::Error::custom)
204 }
205}
206
207impl RegistryReceipt {
208 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
211 Self::deserialize(value)
212 .map_err(|e| AcdpError::InvalidReceipt(format!("registry_receipt does not parse: {e}")))
213 }
214
215 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
224 preimage_hash_of_object(value, "receipt")
225 }
226
227 pub fn validate_created_at_form(value: &serde_json::Value) -> Result<(), AcdpError> {
231 let raw = value
232 .get("created_at")
233 .and_then(|v| v.as_str())
234 .ok_or_else(|| {
235 AcdpError::InvalidReceipt("receipt created_at missing or not a string".into())
236 })?;
237 if !is_canonical_ms_utc(raw) {
238 return Err(AcdpError::InvalidReceipt(format!(
239 "receipt created_at '{raw}' is not canonical millisecond-precision \
240 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0010 §8 step 6)"
241 )));
242 }
243 Ok(())
244 }
245
246 pub fn cross_check_body(&self, body: &crate::body::Body) -> Result<(), AcdpError> {
251 if self.lineage_id != body.lineage_id {
252 return Err(AcdpError::InvalidReceipt(format!(
253 "receipt lineage_id '{}' ≠ body lineage_id '{}'",
254 self.lineage_id, body.lineage_id
255 )));
256 }
257 if self.origin_registry != body.origin_registry {
258 return Err(AcdpError::InvalidReceipt(format!(
259 "receipt origin_registry '{}' ≠ body origin_registry '{}'",
260 self.origin_registry, body.origin_registry
261 )));
262 }
263 if self.created_at != body.created_at {
264 return Err(AcdpError::InvalidReceipt(format!(
265 "receipt created_at '{}' ≠ body created_at '{}'",
266 self.created_at, body.created_at
267 )));
268 }
269 Ok(())
270 }
271
272 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
280 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
281 }
282
283 pub fn verify_signature_with_key(
288 &self,
289 registry_pub_ed25519: Option<&[u8; 32]>,
290 registry_pub_p256_sec1: Option<&[u8]>,
291 ) -> Result<(), AcdpError> {
292 let hash = self.preimage_hash()?;
293 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
294 }
295
296 pub fn verify_signature_against_hash(
300 &self,
301 hash: &ContentHash,
302 registry_pub_ed25519: Option<&[u8; 32]>,
303 registry_pub_p256_sec1: Option<&[u8]>,
304 ) -> Result<(), AcdpError> {
305 verify_receipt_signature_over_hash(
306 &self.signature,
307 hash,
308 registry_pub_ed25519,
309 registry_pub_p256_sec1,
310 )
311 }
312
313 pub fn cross_check(
327 &self,
328 expected_ctx_id: &CtxId,
329 recomputed_body_hash: &ContentHash,
330 producer_key_fingerprint: &str,
331 ) -> Result<(), AcdpError> {
332 if &self.ctx_id != expected_ctx_id {
333 return Err(AcdpError::InvalidReceipt(format!(
334 "receipt ctx_id '{}' ≠ requested '{expected_ctx_id}'",
335 self.ctx_id
336 )));
337 }
338 if &self.content_hash != recomputed_body_hash {
339 return Err(AcdpError::InvalidReceipt(format!(
340 "receipt content_hash '{}' ≠ recomputed body hash '{recomputed_body_hash}'",
341 self.content_hash
342 )));
343 }
344 if self.key_fingerprint != producer_key_fingerprint {
345 return Err(AcdpError::InvalidReceipt(format!(
346 "receipt key_fingerprint '{}' ≠ resolved producer key '{producer_key_fingerprint}'",
347 self.key_fingerprint
348 )));
349 }
350 if self.created_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
351 return Err(AcdpError::InvalidReceipt(
352 "receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
353 ));
354 }
355 let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
356 if self.registry_did != expected_did {
357 return Err(AcdpError::InvalidReceipt(format!(
358 "receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
359 self.registry_did
360 )));
361 }
362 Ok(())
363 }
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct LineageHeadReceipt {
386 pub receipt_version: String,
388 pub registry_did: String,
392 pub lineage_id: LineageId,
394 pub head_ctx_id: CtxId,
398 pub head_version: u32,
400 pub head_status: String,
408 #[serde(with = "ms_rfc3339")]
412 pub as_of: DateTime<Utc>,
413 pub signature: Signature,
416}
417
418impl LineageHeadReceipt {
419 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
426 let receipt = Self::deserialize(value).map_err(|e| {
427 AcdpError::InvalidReceipt(format!("lineage_head_receipt does not parse: {e}"))
428 })?;
429 if receipt.receipt_version != LINEAGE_HEAD_RECEIPT_VERSION {
430 return Err(AcdpError::InvalidReceipt(format!(
431 "lineage_head_receipt receipt_version '{}' ≠ '{LINEAGE_HEAD_RECEIPT_VERSION}' \
432 (RFC-ACDP-0011 §4)",
433 receipt.receipt_version
434 )));
435 }
436 if receipt.head_version < 1 {
437 return Err(AcdpError::InvalidReceipt(
438 "lineage_head_receipt head_version must be >= 1".into(),
439 ));
440 }
441 let status = Status::parse(&receipt.head_status).map_err(|e| {
445 AcdpError::InvalidReceipt(format!("lineage_head_receipt head_status: {e}"))
446 })?;
447 if matches!(status, Status::Superseded | Status::Retracted) {
448 return Err(AcdpError::InvalidReceipt(format!(
449 "lineage_head_receipt head_status must never be '{}' \
450 (RFC-ACDP-0011 §4: a superseded version is never the head; \
451 RFC-ACDP-0013 §8.3: a retracted version is never served as head)",
452 receipt.head_status
453 )));
454 }
455 if !receipt.registry_did.starts_with("did:web:") {
456 return Err(AcdpError::InvalidReceipt(format!(
457 "lineage_head_receipt registry_did '{}' must be did:web \
458 (RFC-ACDP-0011 §4)",
459 receipt.registry_did
460 )));
461 }
462 Self::validate_as_of_form(value)?;
465 Ok(receipt)
466 }
467
468 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
473 preimage_hash_of_object(value, "lineage_head_receipt")
474 }
475
476 pub fn validate_as_of_form(value: &serde_json::Value) -> Result<(), AcdpError> {
480 let raw = value.get("as_of").and_then(|v| v.as_str()).ok_or_else(|| {
481 AcdpError::InvalidReceipt("lineage_head_receipt as_of missing or not a string".into())
482 })?;
483 if !is_canonical_ms_utc(raw) {
484 return Err(AcdpError::InvalidReceipt(format!(
485 "lineage_head_receipt as_of '{raw}' is not canonical millisecond-precision \
486 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0011 §4)"
487 )));
488 }
489 Ok(())
490 }
491
492 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
496 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
497 }
498
499 pub fn verify_signature_with_key(
504 &self,
505 registry_pub_ed25519: Option<&[u8; 32]>,
506 registry_pub_p256_sec1: Option<&[u8]>,
507 ) -> Result<(), AcdpError> {
508 let hash = self.preimage_hash()?;
509 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
510 }
511
512 pub fn verify_signature_against_hash(
516 &self,
517 hash: &ContentHash,
518 registry_pub_ed25519: Option<&[u8; 32]>,
519 registry_pub_p256_sec1: Option<&[u8]>,
520 ) -> Result<(), AcdpError> {
521 verify_receipt_signature_over_hash(
522 &self.signature,
523 hash,
524 registry_pub_ed25519,
525 registry_pub_p256_sec1,
526 )
527 }
528
529 pub fn cross_check_registry_binding(
539 &self,
540 serving_authority: &str,
541 capabilities_registry_did: &str,
542 ) -> Result<(), AcdpError> {
543 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
544 if self.registry_did != expected_did {
545 return Err(AcdpError::InvalidReceipt(format!(
546 "lineage_head_receipt registry_did '{}' ≠ serving authority's DID \
547 '{expected_did}' (RFC-ACDP-0011 §7 step 3)",
548 self.registry_did
549 )));
550 }
551 if self.registry_did != capabilities_registry_did {
552 return Err(AcdpError::InvalidReceipt(format!(
553 "lineage_head_receipt registry_did '{}' ≠ capabilities.registry_did \
554 '{capabilities_registry_did}' (RFC-ACDP-0011 §7 step 3)",
555 self.registry_did
556 )));
557 }
558 match self.signature.key_id.split_once('#') {
559 Some((did, frag)) if did == self.registry_did && !frag.is_empty() => {}
560 _ => {
561 return Err(AcdpError::InvalidReceipt(format!(
562 "lineage_head_receipt signature.key_id '{}' is not a DID URL under \
563 registry_did '{}' (RFC-ACDP-0011 §7 step 3)",
564 self.signature.key_id, self.registry_did
565 )));
566 }
567 }
568 let head_authority_did = acdp_did::web::authority_to_did_web(self.head_ctx_id.authority());
569 if head_authority_did != self.registry_did {
570 return Err(AcdpError::InvalidReceipt(format!(
571 "lineage_head_receipt head_ctx_id authority '{}' ≠ registry_did '{}' \
572 (RFC-ACDP-0011 §7 step 3: lineages are single-registry)",
573 self.head_ctx_id.authority(),
574 self.registry_did
575 )));
576 }
577 Ok(())
578 }
579
580 pub fn cross_check_lineage(&self, requested: &LineageId) -> Result<(), AcdpError> {
584 if &self.lineage_id != requested {
585 return Err(AcdpError::InvalidReceipt(format!(
586 "lineage_head_receipt lineage_id '{}' ≠ requested lineage '{requested}' \
587 (RFC-ACDP-0011 §7 step 4)",
588 self.lineage_id
589 )));
590 }
591 Ok(())
592 }
593
594 pub fn cross_check_head(
607 &self,
608 served_ctx_id: &CtxId,
609 served_version: u32,
610 served_status: &Status,
611 on_current_endpoint: bool,
612 ) -> Result<(), AcdpError> {
613 if on_current_endpoint || &self.head_ctx_id == served_ctx_id {
614 if &self.head_ctx_id != served_ctx_id {
615 return Err(AcdpError::InvalidReceipt(format!(
616 "lineage_head_receipt head_ctx_id '{}' ≠ served ctx_id '{served_ctx_id}' \
617 (RFC-ACDP-0011 §7 step 5: /current must serve the attested head)",
618 self.head_ctx_id
619 )));
620 }
621 if self.head_version != served_version {
622 return Err(AcdpError::InvalidReceipt(format!(
623 "lineage_head_receipt head_version {} ≠ served body.version \
624 {served_version} (RFC-ACDP-0011 §7 step 5)",
625 self.head_version
626 )));
627 }
628 if self.head_status != served_status.as_str() {
629 return Err(AcdpError::InvalidReceipt(format!(
630 "lineage_head_receipt head_status '{}' ≠ served registry_state.status \
631 '{}' (RFC-ACDP-0011 §7 step 5)",
632 self.head_status,
633 served_status.as_str()
634 )));
635 }
636 return Ok(());
637 }
638 if self.head_version <= served_version {
640 return Err(AcdpError::InvalidReceipt(format!(
641 "lineage_head_receipt names a different head '{}' but head_version {} is \
642 not greater than the served body.version {served_version} \
643 (RFC-ACDP-0011 §7 step 5b)",
644 self.head_ctx_id, self.head_version
645 )));
646 }
647 let non_head_served = matches!(served_status, Status::Superseded | Status::Retracted);
648 if !non_head_served {
649 return Err(AcdpError::InvalidReceipt(format!(
650 "lineage_head_receipt names a different head '{}' but the served context's \
651 status is '{}', not 'superseded' (or 'retracted', RFC-ACDP-0013 §7.2) — \
652 self-contradictory response (RFC-ACDP-0011 §7 step 5b)",
653 self.head_ctx_id,
654 served_status.as_str()
655 )));
656 }
657 Ok(())
658 }
659
660 pub fn check_as_of_skew(
670 &self,
671 now: DateTime<Utc>,
672 max_clock_skew: chrono::Duration,
673 ) -> Result<(), AcdpError> {
674 if self.as_of.timestamp_subsec_nanos() % 1_000_000 != 0 {
675 return Err(AcdpError::InvalidReceipt(
676 "lineage_head_receipt as_of is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
677 .into(),
678 ));
679 }
680 if self.as_of > now + max_clock_skew {
681 return Err(AcdpError::InvalidReceipt(format!(
682 "lineage_head_receipt as_of '{}' is in the future beyond the {}s clock-skew \
683 allowance (consumer clock '{}') — forged freshness claim \
684 (RFC-ACDP-0011 §7 step 6)",
685 self.as_of.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
686 max_clock_skew.num_seconds(),
687 now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
688 )));
689 }
690 Ok(())
691 }
692
693 pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
698 now - self.as_of
699 }
700}
701
702pub struct ReceiptSigner {
711 key: acdp_crypto::sign::AcdpSigningKey,
712 key_id: String,
714 registry_did: String,
716}
717
718impl ReceiptSigner {
719 pub fn new(
723 key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
724 registry_did: impl Into<String>,
725 key_id: impl Into<String>,
726 ) -> Result<Self, AcdpError> {
727 let registry_did = registry_did.into();
728 let key_id = key_id.into();
729 if !registry_did.starts_with("did:web:") {
730 return Err(AcdpError::SchemaViolation(format!(
731 "receipt signer registry_did must be did:web, got '{registry_did}'"
732 )));
733 }
734 match key_id.split_once('#') {
735 Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
736 _ => {
737 return Err(AcdpError::SchemaViolation(format!(
738 "receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
739 )));
740 }
741 }
742 Ok(Self {
743 key: key.into(),
744 key_id,
745 registry_did,
746 })
747 }
748
749 pub fn registry_did(&self) -> &str {
751 &self.registry_did
752 }
753
754 pub fn key_id(&self) -> &str {
757 &self.key_id
758 }
759
760 pub(crate) fn signing_key(&self) -> &acdp_crypto::sign::AcdpSigningKey {
764 &self.key
765 }
766
767 pub fn mint(
774 &self,
775 ctx_id: &CtxId,
776 lineage_id: &LineageId,
777 origin_registry: &str,
778 created_at: DateTime<Utc>,
779 content_hash: &ContentHash,
780 producer_key_fingerprint: &str,
781 ) -> Result<RegistryReceipt, AcdpError> {
782 let mut receipt = RegistryReceipt {
783 registry_did: self.registry_did.clone(),
784 ctx_id: ctx_id.clone(),
785 lineage_id: lineage_id.clone(),
786 origin_registry: origin_registry.to_string(),
787 created_at: acdp_primitives::time::trunc_ms(created_at),
788 content_hash: content_hash.clone(),
789 key_fingerprint: producer_key_fingerprint.to_string(),
790 signature: Signature {
791 algorithm: self.key.algorithm().into(),
792 key_id: self.key_id.clone(),
793 value: String::new(), },
795 };
796 let hash = receipt.preimage_hash()?;
797 let (algorithm, value) = self.key.sign_content_hash(&hash);
798 receipt.signature.algorithm = algorithm.into();
799 receipt.signature.value = value;
800 Ok(receipt)
801 }
802
803 pub fn mint_lineage_head(
818 &self,
819 lineage_id: &LineageId,
820 head_ctx_id: &CtxId,
821 head_version: u32,
822 head_status: &Status,
823 as_of: DateTime<Utc>,
824 ) -> Result<LineageHeadReceipt, AcdpError> {
825 if matches!(head_status, Status::Superseded | Status::Retracted) {
826 return Err(AcdpError::SchemaViolation(format!(
827 "cannot mint a lineage-head receipt with head_status '{}' — a superseded \
828 version is never the head (RFC-ACDP-0011 §4) and a retracted version is \
829 never served as head (RFC-ACDP-0013 §8.3)",
830 head_status.as_str()
831 )));
832 }
833 if head_version < 1 {
834 return Err(AcdpError::SchemaViolation(
835 "cannot mint a lineage-head receipt with head_version 0 (RFC-ACDP-0011 §4)".into(),
836 ));
837 }
838 let head_authority_did = acdp_did::web::authority_to_did_web(head_ctx_id.authority());
839 if head_authority_did != self.registry_did {
840 return Err(AcdpError::SchemaViolation(format!(
841 "cannot mint a lineage-head receipt for head_ctx_id authority '{}' under \
842 registry_did '{}' (RFC-ACDP-0011 §4: lineages are single-registry)",
843 head_ctx_id.authority(),
844 self.registry_did
845 )));
846 }
847 let mut receipt = LineageHeadReceipt {
848 receipt_version: LINEAGE_HEAD_RECEIPT_VERSION.to_string(),
849 registry_did: self.registry_did.clone(),
850 lineage_id: lineage_id.clone(),
851 head_ctx_id: head_ctx_id.clone(),
852 head_version,
853 head_status: head_status.as_str().to_string(),
854 as_of: acdp_primitives::time::trunc_ms(as_of),
855 signature: Signature {
856 algorithm: self.key.algorithm().into(),
857 key_id: self.key_id.clone(),
858 value: String::new(), },
860 };
861 let hash = receipt.preimage_hash()?;
862 let (algorithm, value) = self.key.sign_content_hash(&hash);
863 receipt.signature.algorithm = algorithm.into();
864 receipt.signature.value = value;
865 Ok(receipt)
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use acdp_crypto::SigningKey;
873
874 fn test_signer() -> ReceiptSigner {
875 ReceiptSigner::new(
876 SigningKey::from_bytes(&[1u8; 32]),
877 "did:web:registry.example.com",
878 "did:web:registry.example.com#receipt-key-1",
879 )
880 .unwrap()
881 }
882
883 fn test_receipt() -> RegistryReceipt {
884 test_signer()
885 .mint(
886 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
887 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
888 "registry.example.com",
889 chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
890 .unwrap()
891 .with_timezone(&chrono::Utc),
892 &ContentHash(format!("sha256:{}", "b".repeat(64))),
893 "sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
894 )
895 .unwrap()
896 }
897
898 fn registry_pub() -> [u8; 32] {
899 SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
900 }
901
902 #[test]
903 fn mint_verify_round_trip() {
904 let receipt = test_receipt();
905 receipt
906 .verify_signature_with_key(Some(®istry_pub()), None)
907 .expect("freshly minted receipt must verify");
908 }
909
910 #[test]
911 fn tampered_fields_fail_verification() {
912 let pubkey = registry_pub();
914 let mut r = test_receipt();
915 r.created_at += chrono::Duration::milliseconds(1);
916 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
917
918 let mut r = test_receipt();
919 r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
920 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
921
922 let mut r = test_receipt();
923 r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
924 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
925 }
926
927 #[test]
930 fn unknown_receipt_fields_rejected() {
931 let mut wire = serde_json::to_value(test_receipt()).unwrap();
932 wire.as_object_mut()
933 .unwrap()
934 .insert("transparency_log_index".into(), serde_json::json!(42));
935 let err = RegistryReceipt::from_value(&wire).unwrap_err();
936 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
937 }
938
939 #[test]
945 fn raw_and_struct_preimages_agree_incl_whole_second() {
946 let receipt = test_receipt();
947 let wire = serde_json::to_value(&receipt).unwrap();
948 assert_eq!(
949 RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
950 receipt.preimage_hash().unwrap()
951 );
952 RegistryReceipt::validate_created_at_form(&wire).unwrap();
953
954 let signer = test_signer();
956 let r = signer
957 .mint(
958 &receipt.ctx_id,
959 &receipt.lineage_id,
960 "registry.example.com",
961 chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
962 .unwrap()
963 .with_timezone(&chrono::Utc),
964 &receipt.content_hash,
965 &receipt.key_fingerprint,
966 )
967 .unwrap();
968 let wire = serde_json::to_value(&r).unwrap();
969 assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
970 RegistryReceipt::validate_created_at_form(&wire).unwrap();
971 let parsed = RegistryReceipt::from_value(&wire).unwrap();
972 parsed
973 .verify_signature_with_key(Some(®istry_pub()), None)
974 .expect("whole-second receipt must round-trip and verify");
975 }
976
977 #[test]
978 fn cross_checks_fire() {
979 let r = test_receipt();
980 let ctx = r.ctx_id.clone();
981 let hash = r.content_hash.clone();
982 let fp = r.key_fingerprint.clone();
983
984 r.cross_check(&ctx, &hash, &fp).expect("all aligned");
985
986 let other =
988 CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
989 assert!(matches!(
990 r.cross_check(&other, &hash, &fp).unwrap_err(),
991 AcdpError::InvalidReceipt(_)
992 ));
993 assert!(r
995 .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
996 .is_err());
997 assert!(r
999 .cross_check(
1000 &ctx,
1001 &ContentHash(format!("sha256:{}", "c".repeat(64))),
1002 &fp
1003 )
1004 .is_err());
1005 }
1006
1007 #[test]
1008 fn signer_rejects_malformed_identity() {
1009 assert!(ReceiptSigner::new(
1010 SigningKey::from_bytes(&[1u8; 32]),
1011 "did:key:zNotWeb",
1012 "did:key:zNotWeb#k",
1013 )
1014 .is_err());
1015 assert!(ReceiptSigner::new(
1016 SigningKey::from_bytes(&[1u8; 32]),
1017 "did:web:registry.example.com",
1018 "did:web:other.example.com#k",
1019 )
1020 .is_err());
1021 assert!(ReceiptSigner::new(
1022 SigningKey::from_bytes(&[1u8; 32]),
1023 "did:web:registry.example.com",
1024 "did:web:registry.example.com",
1025 )
1026 .is_err());
1027 }
1028
1029 fn test_head_receipt() -> LineageHeadReceipt {
1032 test_signer()
1033 .mint_lineage_head(
1034 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
1035 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
1036 2,
1037 &Status::Active,
1038 chrono::DateTime::parse_from_rfc3339("2026-07-04T09:00:00.123Z")
1039 .unwrap()
1040 .with_timezone(&chrono::Utc),
1041 )
1042 .unwrap()
1043 }
1044
1045 #[test]
1046 fn head_receipt_mint_verify_round_trip() {
1047 let r = test_head_receipt();
1048 assert_eq!(r.receipt_version, LINEAGE_HEAD_RECEIPT_VERSION);
1049 r.verify_signature_with_key(Some(®istry_pub()), None)
1050 .expect("freshly minted head receipt must verify");
1051 let wire = serde_json::to_value(&r).unwrap();
1053 LineageHeadReceipt::validate_as_of_form(&wire).unwrap();
1054 let parsed = LineageHeadReceipt::from_value(&wire).unwrap();
1055 parsed
1056 .verify_signature_with_key(Some(®istry_pub()), None)
1057 .unwrap();
1058 assert_eq!(
1059 LineageHeadReceipt::preimage_hash_of_value(&wire).unwrap(),
1060 r.preimage_hash().unwrap()
1061 );
1062 }
1063
1064 #[test]
1068 fn head_receipt_domain_separation_and_closed_schema() {
1069 let r = test_head_receipt();
1070 let mut wire = serde_json::to_value(&r).unwrap();
1071
1072 wire.as_object_mut()
1074 .unwrap()
1075 .insert("freshness_proof".into(), serde_json::json!(true));
1076 assert!(matches!(
1077 LineageHeadReceipt::from_value(&wire).unwrap_err(),
1078 AcdpError::InvalidReceipt(_)
1079 ));
1080
1081 let mut wire = serde_json::to_value(&r).unwrap();
1083 wire["receipt_version"] = serde_json::json!("acdp-lhr/2");
1084 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1085
1086 let wire = serde_json::to_value(&r).unwrap();
1089 assert!(RegistryReceipt::from_value(&wire).is_err());
1090 let rcpt_wire = serde_json::to_value(test_receipt()).unwrap();
1091 assert!(LineageHeadReceipt::from_value(&rcpt_wire).is_err());
1092 }
1093
1094 #[test]
1095 fn head_receipt_semantic_invariants_rejected() {
1096 let r = test_head_receipt();
1097
1098 let mut wire = serde_json::to_value(&r).unwrap();
1100 wire["head_status"] = serde_json::json!("superseded");
1101 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1102 assert!(test_signer()
1103 .mint_lineage_head(
1104 &r.lineage_id,
1105 &r.head_ctx_id,
1106 1,
1107 &Status::Superseded,
1108 chrono::Utc::now(),
1109 )
1110 .is_err());
1111
1112 let mut wire = serde_json::to_value(&r).unwrap();
1114 wire["head_status"] = serde_json::json!("retracted");
1115 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1116 assert!(test_signer()
1117 .mint_lineage_head(
1118 &r.lineage_id,
1119 &r.head_ctx_id,
1120 1,
1121 &Status::parse("retracted").unwrap(),
1122 chrono::Utc::now(),
1123 )
1124 .is_err());
1125
1126 let mut wire = serde_json::to_value(&r).unwrap();
1128 wire["head_version"] = serde_json::json!(0);
1129 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1130
1131 let mut wire = serde_json::to_value(&r).unwrap();
1133 wire["as_of"] = serde_json::json!("2026-07-04T09:00:00Z");
1134 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1135
1136 assert!(test_signer()
1138 .mint_lineage_head(
1139 &r.lineage_id,
1140 &CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into()),
1141 1,
1142 &Status::Active,
1143 chrono::Utc::now(),
1144 )
1145 .is_err());
1146 }
1147
1148 #[test]
1149 fn head_receipt_cross_checks_fire() {
1150 let r = test_head_receipt();
1151
1152 r.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
1154 .unwrap();
1155 r.cross_check_lineage(&r.lineage_id).unwrap();
1156 r.cross_check_head(&r.head_ctx_id, 2, &Status::Active, true)
1157 .unwrap();
1158
1159 assert!(r
1161 .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
1162 .is_err());
1163 assert!(r
1164 .cross_check_registry_binding("registry.example.com", "did:web:other.example")
1165 .is_err());
1166
1167 assert!(r
1169 .cross_check_lineage(&LineageId(format!("lin:sha256:{}", "f".repeat(64))))
1170 .is_err());
1171
1172 let other =
1174 CtxId("acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into());
1175 assert!(r
1176 .cross_check_head(&other, 3, &Status::Active, true)
1177 .is_err());
1178 assert!(r
1180 .cross_check_head(&r.head_ctx_id, 1, &Status::Active, true)
1181 .is_err());
1182 assert!(r
1183 .cross_check_head(&r.head_ctx_id, 2, &Status::Expired, true)
1184 .is_err());
1185
1186 r.cross_check_head(&other, 1, &Status::Superseded, false)
1190 .unwrap();
1191 r.cross_check_head(&other, 1, &Status::parse("retracted").unwrap(), false)
1192 .unwrap();
1193 assert!(r
1195 .cross_check_head(&other, 1, &Status::Active, false)
1196 .is_err());
1197 assert!(r
1198 .cross_check_head(&other, 1, &Status::Expired, false)
1199 .is_err());
1200 assert!(r
1201 .cross_check_head(&other, 2, &Status::Superseded, false)
1202 .is_err());
1203 }
1204
1205 #[test]
1209 fn head_receipt_as_of_skew_and_age() {
1210 let r = test_head_receipt();
1211 let skew = chrono::Duration::seconds(120);
1212
1213 let now = r.as_of - chrono::Duration::seconds(30); r.check_as_of_skew(now, skew).expect("within skew");
1215
1216 let now = r.as_of - chrono::Duration::seconds(300); let err = r.check_as_of_skew(now, skew).unwrap_err();
1218 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
1219
1220 let now = r.as_of + chrono::Duration::seconds(3600);
1222 r.check_as_of_skew(now, skew)
1223 .expect("stale is not a step-6 failure");
1224 assert_eq!(r.age_at(now), chrono::Duration::seconds(3600));
1225 }
1226}