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, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165#[non_exhaustive]
166pub enum ReceiptKeyStatus {
167 Current,
170 Historical,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct RegistryReceipt {
183 pub registry_did: String,
186 pub ctx_id: CtxId,
188 pub lineage_id: LineageId,
190 pub origin_registry: String,
192 #[serde(with = "ms_rfc3339")]
197 pub created_at: DateTime<Utc>,
198 pub content_hash: ContentHash,
200 pub key_fingerprint: String,
203 pub signature: Signature,
205}
206
207pub(crate) mod ms_rfc3339 {
211 use chrono::{DateTime, Utc};
212 use serde::{Deserialize, Deserializer, Serializer};
213
214 pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
215 s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
216 }
217
218 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
219 let raw = String::deserialize(d)?;
220 DateTime::parse_from_rfc3339(&raw)
221 .map(|t| t.with_timezone(&Utc))
222 .map_err(serde::de::Error::custom)
223 }
224}
225
226impl RegistryReceipt {
227 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
230 Self::deserialize(value)
231 .map_err(|e| AcdpError::InvalidReceipt(format!("registry_receipt does not parse: {e}")))
232 }
233
234 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
243 preimage_hash_of_object(value, "receipt")
244 }
245
246 pub fn validate_created_at_form(value: &serde_json::Value) -> Result<(), AcdpError> {
250 let raw = value
251 .get("created_at")
252 .and_then(|v| v.as_str())
253 .ok_or_else(|| {
254 AcdpError::InvalidReceipt("receipt created_at missing or not a string".into())
255 })?;
256 if !is_canonical_ms_utc(raw) {
257 return Err(AcdpError::InvalidReceipt(format!(
258 "receipt created_at '{raw}' is not canonical millisecond-precision \
259 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0010 §8 step 6)"
260 )));
261 }
262 Ok(())
263 }
264
265 pub fn cross_check_body(&self, body: &crate::body::Body) -> Result<(), AcdpError> {
270 if self.lineage_id != body.lineage_id {
271 return Err(AcdpError::InvalidReceipt(format!(
272 "receipt lineage_id '{}' ≠ body lineage_id '{}'",
273 self.lineage_id, body.lineage_id
274 )));
275 }
276 if self.origin_registry != body.origin_registry {
277 return Err(AcdpError::InvalidReceipt(format!(
278 "receipt origin_registry '{}' ≠ body origin_registry '{}'",
279 self.origin_registry, body.origin_registry
280 )));
281 }
282 if self.created_at != body.created_at {
283 return Err(AcdpError::InvalidReceipt(format!(
284 "receipt created_at '{}' ≠ body created_at '{}'",
285 self.created_at, body.created_at
286 )));
287 }
288 Ok(())
289 }
290
291 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
299 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
300 }
301
302 pub fn verify_signature_with_key(
307 &self,
308 registry_pub_ed25519: Option<&[u8; 32]>,
309 registry_pub_p256_sec1: Option<&[u8]>,
310 ) -> Result<(), AcdpError> {
311 let hash = self.preimage_hash()?;
312 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
313 }
314
315 pub fn verify_signature_against_hash(
319 &self,
320 hash: &ContentHash,
321 registry_pub_ed25519: Option<&[u8; 32]>,
322 registry_pub_p256_sec1: Option<&[u8]>,
323 ) -> Result<(), AcdpError> {
324 verify_receipt_signature_over_hash(
325 &self.signature,
326 hash,
327 registry_pub_ed25519,
328 registry_pub_p256_sec1,
329 )
330 }
331
332 pub fn cross_check(
346 &self,
347 expected_ctx_id: &CtxId,
348 recomputed_body_hash: &ContentHash,
349 producer_key_fingerprint: &str,
350 ) -> Result<(), AcdpError> {
351 if &self.ctx_id != expected_ctx_id {
352 return Err(AcdpError::InvalidReceipt(format!(
353 "receipt ctx_id '{}' ≠ requested '{expected_ctx_id}'",
354 self.ctx_id
355 )));
356 }
357 if &self.content_hash != recomputed_body_hash {
358 return Err(AcdpError::InvalidReceipt(format!(
359 "receipt content_hash '{}' ≠ recomputed body hash '{recomputed_body_hash}'",
360 self.content_hash
361 )));
362 }
363 if self.key_fingerprint != producer_key_fingerprint {
364 return Err(AcdpError::InvalidReceipt(format!(
365 "receipt key_fingerprint '{}' ≠ resolved producer key '{producer_key_fingerprint}'",
366 self.key_fingerprint
367 )));
368 }
369 if !self
370 .created_at
371 .timestamp_subsec_nanos()
372 .is_multiple_of(1_000_000)
373 {
374 return Err(AcdpError::InvalidReceipt(
375 "receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
376 ));
377 }
378 let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
379 if self.registry_did != expected_did {
380 return Err(AcdpError::InvalidReceipt(format!(
381 "receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
382 self.registry_did
383 )));
384 }
385 Ok(())
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
407#[serde(deny_unknown_fields)]
408pub struct LineageHeadReceipt {
409 pub receipt_version: String,
411 pub registry_did: String,
415 pub lineage_id: LineageId,
417 pub head_ctx_id: CtxId,
421 pub head_version: u32,
423 pub head_status: String,
431 #[serde(with = "ms_rfc3339")]
435 pub as_of: DateTime<Utc>,
436 pub signature: Signature,
439}
440
441impl LineageHeadReceipt {
442 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
449 let receipt = Self::deserialize(value).map_err(|e| {
450 AcdpError::InvalidReceipt(format!("lineage_head_receipt does not parse: {e}"))
451 })?;
452 if receipt.receipt_version != LINEAGE_HEAD_RECEIPT_VERSION {
453 return Err(AcdpError::InvalidReceipt(format!(
454 "lineage_head_receipt receipt_version '{}' ≠ '{LINEAGE_HEAD_RECEIPT_VERSION}' \
455 (RFC-ACDP-0011 §4)",
456 receipt.receipt_version
457 )));
458 }
459 if receipt.head_version < 1 {
460 return Err(AcdpError::InvalidReceipt(
461 "lineage_head_receipt head_version must be >= 1".into(),
462 ));
463 }
464 let status = Status::parse(&receipt.head_status).map_err(|e| {
468 AcdpError::InvalidReceipt(format!("lineage_head_receipt head_status: {e}"))
469 })?;
470 if matches!(status, Status::Superseded | Status::Retracted) {
471 return Err(AcdpError::InvalidReceipt(format!(
472 "lineage_head_receipt head_status must never be '{}' \
473 (RFC-ACDP-0011 §4: a superseded version is never the head; \
474 RFC-ACDP-0013 §8.3: a retracted version is never served as head)",
475 receipt.head_status
476 )));
477 }
478 if !receipt.registry_did.starts_with("did:web:") {
479 return Err(AcdpError::InvalidReceipt(format!(
480 "lineage_head_receipt registry_did '{}' must be did:web \
481 (RFC-ACDP-0011 §4)",
482 receipt.registry_did
483 )));
484 }
485 Self::validate_as_of_form(value)?;
488 Ok(receipt)
489 }
490
491 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
496 preimage_hash_of_object(value, "lineage_head_receipt")
497 }
498
499 pub fn validate_as_of_form(value: &serde_json::Value) -> Result<(), AcdpError> {
503 let raw = value.get("as_of").and_then(|v| v.as_str()).ok_or_else(|| {
504 AcdpError::InvalidReceipt("lineage_head_receipt as_of missing or not a string".into())
505 })?;
506 if !is_canonical_ms_utc(raw) {
507 return Err(AcdpError::InvalidReceipt(format!(
508 "lineage_head_receipt as_of '{raw}' is not canonical millisecond-precision \
509 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0011 §4)"
510 )));
511 }
512 Ok(())
513 }
514
515 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
519 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
520 }
521
522 pub fn verify_signature_with_key(
527 &self,
528 registry_pub_ed25519: Option<&[u8; 32]>,
529 registry_pub_p256_sec1: Option<&[u8]>,
530 ) -> Result<(), AcdpError> {
531 let hash = self.preimage_hash()?;
532 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
533 }
534
535 pub fn verify_signature_against_hash(
539 &self,
540 hash: &ContentHash,
541 registry_pub_ed25519: Option<&[u8; 32]>,
542 registry_pub_p256_sec1: Option<&[u8]>,
543 ) -> Result<(), AcdpError> {
544 verify_receipt_signature_over_hash(
545 &self.signature,
546 hash,
547 registry_pub_ed25519,
548 registry_pub_p256_sec1,
549 )
550 }
551
552 pub fn cross_check_registry_binding(
562 &self,
563 serving_authority: &str,
564 capabilities_registry_did: &str,
565 ) -> Result<(), AcdpError> {
566 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
567 if self.registry_did != expected_did {
568 return Err(AcdpError::InvalidReceipt(format!(
569 "lineage_head_receipt registry_did '{}' ≠ serving authority's DID \
570 '{expected_did}' (RFC-ACDP-0011 §7 step 3)",
571 self.registry_did
572 )));
573 }
574 if self.registry_did != capabilities_registry_did {
575 return Err(AcdpError::InvalidReceipt(format!(
576 "lineage_head_receipt registry_did '{}' ≠ capabilities.registry_did \
577 '{capabilities_registry_did}' (RFC-ACDP-0011 §7 step 3)",
578 self.registry_did
579 )));
580 }
581 match self.signature.key_id.split_once('#') {
582 Some((did, frag)) if did == self.registry_did && !frag.is_empty() => {}
583 _ => {
584 return Err(AcdpError::InvalidReceipt(format!(
585 "lineage_head_receipt signature.key_id '{}' is not a DID URL under \
586 registry_did '{}' (RFC-ACDP-0011 §7 step 3)",
587 self.signature.key_id, self.registry_did
588 )));
589 }
590 }
591 let head_authority_did = acdp_did::web::authority_to_did_web(self.head_ctx_id.authority());
592 if head_authority_did != self.registry_did {
593 return Err(AcdpError::InvalidReceipt(format!(
594 "lineage_head_receipt head_ctx_id authority '{}' ≠ registry_did '{}' \
595 (RFC-ACDP-0011 §7 step 3: lineages are single-registry)",
596 self.head_ctx_id.authority(),
597 self.registry_did
598 )));
599 }
600 Ok(())
601 }
602
603 pub fn cross_check_lineage(&self, requested: &LineageId) -> Result<(), AcdpError> {
607 if &self.lineage_id != requested {
608 return Err(AcdpError::InvalidReceipt(format!(
609 "lineage_head_receipt lineage_id '{}' ≠ requested lineage '{requested}' \
610 (RFC-ACDP-0011 §7 step 4)",
611 self.lineage_id
612 )));
613 }
614 Ok(())
615 }
616
617 pub fn cross_check_head(
630 &self,
631 served_ctx_id: &CtxId,
632 served_version: u32,
633 served_status: &Status,
634 on_current_endpoint: bool,
635 ) -> Result<(), AcdpError> {
636 if on_current_endpoint || &self.head_ctx_id == served_ctx_id {
637 if &self.head_ctx_id != served_ctx_id {
638 return Err(AcdpError::InvalidReceipt(format!(
639 "lineage_head_receipt head_ctx_id '{}' ≠ served ctx_id '{served_ctx_id}' \
640 (RFC-ACDP-0011 §7 step 5: /current must serve the attested head)",
641 self.head_ctx_id
642 )));
643 }
644 if self.head_version != served_version {
645 return Err(AcdpError::InvalidReceipt(format!(
646 "lineage_head_receipt head_version {} ≠ served body.version \
647 {served_version} (RFC-ACDP-0011 §7 step 5)",
648 self.head_version
649 )));
650 }
651 if self.head_status != served_status.as_str() {
652 return Err(AcdpError::InvalidReceipt(format!(
653 "lineage_head_receipt head_status '{}' ≠ served registry_state.status \
654 '{}' (RFC-ACDP-0011 §7 step 5)",
655 self.head_status,
656 served_status.as_str()
657 )));
658 }
659 return Ok(());
660 }
661 if self.head_version <= served_version {
663 return Err(AcdpError::InvalidReceipt(format!(
664 "lineage_head_receipt names a different head '{}' but head_version {} is \
665 not greater than the served body.version {served_version} \
666 (RFC-ACDP-0011 §7 step 5b)",
667 self.head_ctx_id, self.head_version
668 )));
669 }
670 let non_head_served = matches!(served_status, Status::Superseded | Status::Retracted);
671 if !non_head_served {
672 return Err(AcdpError::InvalidReceipt(format!(
673 "lineage_head_receipt names a different head '{}' but the served context's \
674 status is '{}', not 'superseded' (or 'retracted', RFC-ACDP-0013 §7.2) — \
675 self-contradictory response (RFC-ACDP-0011 §7 step 5b)",
676 self.head_ctx_id,
677 served_status.as_str()
678 )));
679 }
680 Ok(())
681 }
682
683 pub fn check_as_of_skew(
693 &self,
694 now: DateTime<Utc>,
695 max_clock_skew: chrono::Duration,
696 ) -> Result<(), AcdpError> {
697 if !self
698 .as_of
699 .timestamp_subsec_nanos()
700 .is_multiple_of(1_000_000)
701 {
702 return Err(AcdpError::InvalidReceipt(
703 "lineage_head_receipt as_of is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
704 .into(),
705 ));
706 }
707 if self.as_of > now + max_clock_skew {
708 return Err(AcdpError::InvalidReceipt(format!(
709 "lineage_head_receipt as_of '{}' is in the future beyond the {}s clock-skew \
710 allowance (consumer clock '{}') — forged freshness claim \
711 (RFC-ACDP-0011 §7 step 6)",
712 self.as_of.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
713 max_clock_skew.num_seconds(),
714 now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
715 )));
716 }
717 Ok(())
718 }
719
720 pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
725 now - self.as_of
726 }
727}
728
729pub struct ReceiptSigner {
738 key: acdp_crypto::sign::AcdpSigningKey,
739 key_id: String,
741 registry_did: String,
743}
744
745impl ReceiptSigner {
746 pub fn new(
750 key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
751 registry_did: impl Into<String>,
752 key_id: impl Into<String>,
753 ) -> Result<Self, AcdpError> {
754 let registry_did = registry_did.into();
755 let key_id = key_id.into();
756 if !registry_did.starts_with("did:web:") {
757 return Err(AcdpError::SchemaViolation(format!(
758 "receipt signer registry_did must be did:web, got '{registry_did}'"
759 )));
760 }
761 match key_id.split_once('#') {
762 Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
763 _ => {
764 return Err(AcdpError::SchemaViolation(format!(
765 "receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
766 )));
767 }
768 }
769 Ok(Self {
770 key: key.into(),
771 key_id,
772 registry_did,
773 })
774 }
775
776 pub fn registry_did(&self) -> &str {
778 &self.registry_did
779 }
780
781 pub fn key_id(&self) -> &str {
784 &self.key_id
785 }
786
787 pub(crate) fn signing_key(&self) -> &acdp_crypto::sign::AcdpSigningKey {
791 &self.key
792 }
793
794 pub fn mint(
801 &self,
802 ctx_id: &CtxId,
803 lineage_id: &LineageId,
804 origin_registry: &str,
805 created_at: DateTime<Utc>,
806 content_hash: &ContentHash,
807 producer_key_fingerprint: &str,
808 ) -> Result<RegistryReceipt, AcdpError> {
809 let mut receipt = RegistryReceipt {
810 registry_did: self.registry_did.clone(),
811 ctx_id: ctx_id.clone(),
812 lineage_id: lineage_id.clone(),
813 origin_registry: origin_registry.to_string(),
814 created_at: acdp_primitives::time::trunc_ms(created_at),
815 content_hash: content_hash.clone(),
816 key_fingerprint: producer_key_fingerprint.to_string(),
817 signature: Signature {
818 algorithm: self.key.algorithm().into(),
819 key_id: self.key_id.clone(),
820 value: String::new(), },
822 };
823 let hash = receipt.preimage_hash()?;
824 let (algorithm, value) = self.key.sign_content_hash(&hash);
825 receipt.signature.algorithm = algorithm.into();
826 receipt.signature.value = value;
827 Ok(receipt)
828 }
829
830 pub fn mint_lineage_head(
845 &self,
846 lineage_id: &LineageId,
847 head_ctx_id: &CtxId,
848 head_version: u32,
849 head_status: &Status,
850 as_of: DateTime<Utc>,
851 ) -> Result<LineageHeadReceipt, AcdpError> {
852 if matches!(head_status, Status::Superseded | Status::Retracted) {
853 return Err(AcdpError::SchemaViolation(format!(
854 "cannot mint a lineage-head receipt with head_status '{}' — a superseded \
855 version is never the head (RFC-ACDP-0011 §4) and a retracted version is \
856 never served as head (RFC-ACDP-0013 §8.3)",
857 head_status.as_str()
858 )));
859 }
860 if head_version < 1 {
861 return Err(AcdpError::SchemaViolation(
862 "cannot mint a lineage-head receipt with head_version 0 (RFC-ACDP-0011 §4)".into(),
863 ));
864 }
865 let head_authority_did = acdp_did::web::authority_to_did_web(head_ctx_id.authority());
866 if head_authority_did != self.registry_did {
867 return Err(AcdpError::SchemaViolation(format!(
868 "cannot mint a lineage-head receipt for head_ctx_id authority '{}' under \
869 registry_did '{}' (RFC-ACDP-0011 §4: lineages are single-registry)",
870 head_ctx_id.authority(),
871 self.registry_did
872 )));
873 }
874 let mut receipt = LineageHeadReceipt {
875 receipt_version: LINEAGE_HEAD_RECEIPT_VERSION.to_string(),
876 registry_did: self.registry_did.clone(),
877 lineage_id: lineage_id.clone(),
878 head_ctx_id: head_ctx_id.clone(),
879 head_version,
880 head_status: head_status.as_str().to_string(),
881 as_of: acdp_primitives::time::trunc_ms(as_of),
882 signature: Signature {
883 algorithm: self.key.algorithm().into(),
884 key_id: self.key_id.clone(),
885 value: String::new(), },
887 };
888 let hash = receipt.preimage_hash()?;
889 let (algorithm, value) = self.key.sign_content_hash(&hash);
890 receipt.signature.algorithm = algorithm.into();
891 receipt.signature.value = value;
892 Ok(receipt)
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use acdp_crypto::SigningKey;
900
901 fn test_signer() -> ReceiptSigner {
902 ReceiptSigner::new(
903 SigningKey::from_bytes(&[1u8; 32]),
904 "did:web:registry.example.com",
905 "did:web:registry.example.com#receipt-key-1",
906 )
907 .unwrap()
908 }
909
910 fn test_receipt() -> RegistryReceipt {
911 test_signer()
912 .mint(
913 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
914 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
915 "registry.example.com",
916 chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
917 .unwrap()
918 .with_timezone(&chrono::Utc),
919 &ContentHash(format!("sha256:{}", "b".repeat(64))),
920 "sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
921 )
922 .unwrap()
923 }
924
925 fn registry_pub() -> [u8; 32] {
926 SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
927 }
928
929 #[test]
930 fn mint_verify_round_trip() {
931 let receipt = test_receipt();
932 receipt
933 .verify_signature_with_key(Some(®istry_pub()), None)
934 .expect("freshly minted receipt must verify");
935 }
936
937 #[test]
938 fn tampered_fields_fail_verification() {
939 let pubkey = registry_pub();
941 let mut r = test_receipt();
942 r.created_at += chrono::Duration::milliseconds(1);
943 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
944
945 let mut r = test_receipt();
946 r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
947 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
948
949 let mut r = test_receipt();
950 r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
951 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
952 }
953
954 #[test]
957 fn unknown_receipt_fields_rejected() {
958 let mut wire = serde_json::to_value(test_receipt()).unwrap();
959 wire.as_object_mut()
960 .unwrap()
961 .insert("transparency_log_index".into(), serde_json::json!(42));
962 let err = RegistryReceipt::from_value(&wire).unwrap_err();
963 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
964 }
965
966 #[test]
972 fn raw_and_struct_preimages_agree_incl_whole_second() {
973 let receipt = test_receipt();
974 let wire = serde_json::to_value(&receipt).unwrap();
975 assert_eq!(
976 RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
977 receipt.preimage_hash().unwrap()
978 );
979 RegistryReceipt::validate_created_at_form(&wire).unwrap();
980
981 let signer = test_signer();
983 let r = signer
984 .mint(
985 &receipt.ctx_id,
986 &receipt.lineage_id,
987 "registry.example.com",
988 chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
989 .unwrap()
990 .with_timezone(&chrono::Utc),
991 &receipt.content_hash,
992 &receipt.key_fingerprint,
993 )
994 .unwrap();
995 let wire = serde_json::to_value(&r).unwrap();
996 assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
997 RegistryReceipt::validate_created_at_form(&wire).unwrap();
998 let parsed = RegistryReceipt::from_value(&wire).unwrap();
999 parsed
1000 .verify_signature_with_key(Some(®istry_pub()), None)
1001 .expect("whole-second receipt must round-trip and verify");
1002 }
1003
1004 #[test]
1005 fn cross_checks_fire() {
1006 let r = test_receipt();
1007 let ctx = r.ctx_id.clone();
1008 let hash = r.content_hash.clone();
1009 let fp = r.key_fingerprint.clone();
1010
1011 r.cross_check(&ctx, &hash, &fp).expect("all aligned");
1012
1013 let other =
1015 CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
1016 assert!(matches!(
1017 r.cross_check(&other, &hash, &fp).unwrap_err(),
1018 AcdpError::InvalidReceipt(_)
1019 ));
1020 assert!(r
1022 .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
1023 .is_err());
1024 assert!(r
1026 .cross_check(
1027 &ctx,
1028 &ContentHash(format!("sha256:{}", "c".repeat(64))),
1029 &fp
1030 )
1031 .is_err());
1032 }
1033
1034 #[test]
1035 fn signer_rejects_malformed_identity() {
1036 assert!(ReceiptSigner::new(
1037 SigningKey::from_bytes(&[1u8; 32]),
1038 "did:key:zNotWeb",
1039 "did:key:zNotWeb#k",
1040 )
1041 .is_err());
1042 assert!(ReceiptSigner::new(
1043 SigningKey::from_bytes(&[1u8; 32]),
1044 "did:web:registry.example.com",
1045 "did:web:other.example.com#k",
1046 )
1047 .is_err());
1048 assert!(ReceiptSigner::new(
1049 SigningKey::from_bytes(&[1u8; 32]),
1050 "did:web:registry.example.com",
1051 "did:web:registry.example.com",
1052 )
1053 .is_err());
1054 }
1055
1056 fn test_head_receipt() -> LineageHeadReceipt {
1059 test_signer()
1060 .mint_lineage_head(
1061 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
1062 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
1063 2,
1064 &Status::Active,
1065 chrono::DateTime::parse_from_rfc3339("2026-07-04T09:00:00.123Z")
1066 .unwrap()
1067 .with_timezone(&chrono::Utc),
1068 )
1069 .unwrap()
1070 }
1071
1072 #[test]
1073 fn head_receipt_mint_verify_round_trip() {
1074 let r = test_head_receipt();
1075 assert_eq!(r.receipt_version, LINEAGE_HEAD_RECEIPT_VERSION);
1076 r.verify_signature_with_key(Some(®istry_pub()), None)
1077 .expect("freshly minted head receipt must verify");
1078 let wire = serde_json::to_value(&r).unwrap();
1080 LineageHeadReceipt::validate_as_of_form(&wire).unwrap();
1081 let parsed = LineageHeadReceipt::from_value(&wire).unwrap();
1082 parsed
1083 .verify_signature_with_key(Some(®istry_pub()), None)
1084 .unwrap();
1085 assert_eq!(
1086 LineageHeadReceipt::preimage_hash_of_value(&wire).unwrap(),
1087 r.preimage_hash().unwrap()
1088 );
1089 }
1090
1091 #[test]
1095 fn head_receipt_domain_separation_and_closed_schema() {
1096 let r = test_head_receipt();
1097 let mut wire = serde_json::to_value(&r).unwrap();
1098
1099 wire.as_object_mut()
1101 .unwrap()
1102 .insert("freshness_proof".into(), serde_json::json!(true));
1103 assert!(matches!(
1104 LineageHeadReceipt::from_value(&wire).unwrap_err(),
1105 AcdpError::InvalidReceipt(_)
1106 ));
1107
1108 let mut wire = serde_json::to_value(&r).unwrap();
1110 wire["receipt_version"] = serde_json::json!("acdp-lhr/2");
1111 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1112
1113 let wire = serde_json::to_value(&r).unwrap();
1116 assert!(RegistryReceipt::from_value(&wire).is_err());
1117 let rcpt_wire = serde_json::to_value(test_receipt()).unwrap();
1118 assert!(LineageHeadReceipt::from_value(&rcpt_wire).is_err());
1119 }
1120
1121 #[test]
1122 fn head_receipt_semantic_invariants_rejected() {
1123 let r = test_head_receipt();
1124
1125 let mut wire = serde_json::to_value(&r).unwrap();
1127 wire["head_status"] = serde_json::json!("superseded");
1128 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1129 assert!(test_signer()
1130 .mint_lineage_head(
1131 &r.lineage_id,
1132 &r.head_ctx_id,
1133 1,
1134 &Status::Superseded,
1135 chrono::Utc::now(),
1136 )
1137 .is_err());
1138
1139 let mut wire = serde_json::to_value(&r).unwrap();
1141 wire["head_status"] = serde_json::json!("retracted");
1142 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1143 assert!(test_signer()
1144 .mint_lineage_head(
1145 &r.lineage_id,
1146 &r.head_ctx_id,
1147 1,
1148 &Status::parse("retracted").unwrap(),
1149 chrono::Utc::now(),
1150 )
1151 .is_err());
1152
1153 let mut wire = serde_json::to_value(&r).unwrap();
1155 wire["head_version"] = serde_json::json!(0);
1156 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1157
1158 let mut wire = serde_json::to_value(&r).unwrap();
1160 wire["as_of"] = serde_json::json!("2026-07-04T09:00:00Z");
1161 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1162
1163 assert!(test_signer()
1165 .mint_lineage_head(
1166 &r.lineage_id,
1167 &CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into()),
1168 1,
1169 &Status::Active,
1170 chrono::Utc::now(),
1171 )
1172 .is_err());
1173 }
1174
1175 #[test]
1176 fn head_receipt_cross_checks_fire() {
1177 let r = test_head_receipt();
1178
1179 r.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
1181 .unwrap();
1182 r.cross_check_lineage(&r.lineage_id).unwrap();
1183 r.cross_check_head(&r.head_ctx_id, 2, &Status::Active, true)
1184 .unwrap();
1185
1186 assert!(r
1188 .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
1189 .is_err());
1190 assert!(r
1191 .cross_check_registry_binding("registry.example.com", "did:web:other.example")
1192 .is_err());
1193
1194 assert!(r
1196 .cross_check_lineage(&LineageId(format!("lin:sha256:{}", "f".repeat(64))))
1197 .is_err());
1198
1199 let other =
1201 CtxId("acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into());
1202 assert!(r
1203 .cross_check_head(&other, 3, &Status::Active, true)
1204 .is_err());
1205 assert!(r
1207 .cross_check_head(&r.head_ctx_id, 1, &Status::Active, true)
1208 .is_err());
1209 assert!(r
1210 .cross_check_head(&r.head_ctx_id, 2, &Status::Expired, true)
1211 .is_err());
1212
1213 r.cross_check_head(&other, 1, &Status::Superseded, false)
1217 .unwrap();
1218 r.cross_check_head(&other, 1, &Status::parse("retracted").unwrap(), false)
1219 .unwrap();
1220 assert!(r
1222 .cross_check_head(&other, 1, &Status::Active, false)
1223 .is_err());
1224 assert!(r
1225 .cross_check_head(&other, 1, &Status::Expired, false)
1226 .is_err());
1227 assert!(r
1228 .cross_check_head(&other, 2, &Status::Superseded, false)
1229 .is_err());
1230 }
1231
1232 #[test]
1236 fn head_receipt_as_of_skew_and_age() {
1237 let r = test_head_receipt();
1238 let skew = chrono::Duration::seconds(120);
1239
1240 let now = r.as_of - chrono::Duration::seconds(30); r.check_as_of_skew(now, skew).expect("within skew");
1242
1243 let now = r.as_of - chrono::Duration::seconds(300); let err = r.check_as_of_skew(now, skew).unwrap_err();
1245 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
1246
1247 let now = r.as_of + chrono::Duration::seconds(3600);
1249 r.check_as_of_skew(now, skew)
1250 .expect("stale is not a step-6 failure");
1251 assert_eq!(r.age_at(now), chrono::Duration::seconds(3600));
1252 }
1253}