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.created_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
370 return Err(AcdpError::InvalidReceipt(
371 "receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
372 ));
373 }
374 let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
375 if self.registry_did != expected_did {
376 return Err(AcdpError::InvalidReceipt(format!(
377 "receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
378 self.registry_did
379 )));
380 }
381 Ok(())
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
403#[serde(deny_unknown_fields)]
404pub struct LineageHeadReceipt {
405 pub receipt_version: String,
407 pub registry_did: String,
411 pub lineage_id: LineageId,
413 pub head_ctx_id: CtxId,
417 pub head_version: u32,
419 pub head_status: String,
427 #[serde(with = "ms_rfc3339")]
431 pub as_of: DateTime<Utc>,
432 pub signature: Signature,
435}
436
437impl LineageHeadReceipt {
438 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
445 let receipt = Self::deserialize(value).map_err(|e| {
446 AcdpError::InvalidReceipt(format!("lineage_head_receipt does not parse: {e}"))
447 })?;
448 if receipt.receipt_version != LINEAGE_HEAD_RECEIPT_VERSION {
449 return Err(AcdpError::InvalidReceipt(format!(
450 "lineage_head_receipt receipt_version '{}' ≠ '{LINEAGE_HEAD_RECEIPT_VERSION}' \
451 (RFC-ACDP-0011 §4)",
452 receipt.receipt_version
453 )));
454 }
455 if receipt.head_version < 1 {
456 return Err(AcdpError::InvalidReceipt(
457 "lineage_head_receipt head_version must be >= 1".into(),
458 ));
459 }
460 let status = Status::parse(&receipt.head_status).map_err(|e| {
464 AcdpError::InvalidReceipt(format!("lineage_head_receipt head_status: {e}"))
465 })?;
466 if matches!(status, Status::Superseded | Status::Retracted) {
467 return Err(AcdpError::InvalidReceipt(format!(
468 "lineage_head_receipt head_status must never be '{}' \
469 (RFC-ACDP-0011 §4: a superseded version is never the head; \
470 RFC-ACDP-0013 §8.3: a retracted version is never served as head)",
471 receipt.head_status
472 )));
473 }
474 if !receipt.registry_did.starts_with("did:web:") {
475 return Err(AcdpError::InvalidReceipt(format!(
476 "lineage_head_receipt registry_did '{}' must be did:web \
477 (RFC-ACDP-0011 §4)",
478 receipt.registry_did
479 )));
480 }
481 Self::validate_as_of_form(value)?;
484 Ok(receipt)
485 }
486
487 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
492 preimage_hash_of_object(value, "lineage_head_receipt")
493 }
494
495 pub fn validate_as_of_form(value: &serde_json::Value) -> Result<(), AcdpError> {
499 let raw = value.get("as_of").and_then(|v| v.as_str()).ok_or_else(|| {
500 AcdpError::InvalidReceipt("lineage_head_receipt as_of missing or not a string".into())
501 })?;
502 if !is_canonical_ms_utc(raw) {
503 return Err(AcdpError::InvalidReceipt(format!(
504 "lineage_head_receipt as_of '{raw}' is not canonical millisecond-precision \
505 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0011 §4)"
506 )));
507 }
508 Ok(())
509 }
510
511 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
515 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
516 }
517
518 pub fn verify_signature_with_key(
523 &self,
524 registry_pub_ed25519: Option<&[u8; 32]>,
525 registry_pub_p256_sec1: Option<&[u8]>,
526 ) -> Result<(), AcdpError> {
527 let hash = self.preimage_hash()?;
528 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
529 }
530
531 pub fn verify_signature_against_hash(
535 &self,
536 hash: &ContentHash,
537 registry_pub_ed25519: Option<&[u8; 32]>,
538 registry_pub_p256_sec1: Option<&[u8]>,
539 ) -> Result<(), AcdpError> {
540 verify_receipt_signature_over_hash(
541 &self.signature,
542 hash,
543 registry_pub_ed25519,
544 registry_pub_p256_sec1,
545 )
546 }
547
548 pub fn cross_check_registry_binding(
558 &self,
559 serving_authority: &str,
560 capabilities_registry_did: &str,
561 ) -> Result<(), AcdpError> {
562 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
563 if self.registry_did != expected_did {
564 return Err(AcdpError::InvalidReceipt(format!(
565 "lineage_head_receipt registry_did '{}' ≠ serving authority's DID \
566 '{expected_did}' (RFC-ACDP-0011 §7 step 3)",
567 self.registry_did
568 )));
569 }
570 if self.registry_did != capabilities_registry_did {
571 return Err(AcdpError::InvalidReceipt(format!(
572 "lineage_head_receipt registry_did '{}' ≠ capabilities.registry_did \
573 '{capabilities_registry_did}' (RFC-ACDP-0011 §7 step 3)",
574 self.registry_did
575 )));
576 }
577 match self.signature.key_id.split_once('#') {
578 Some((did, frag)) if did == self.registry_did && !frag.is_empty() => {}
579 _ => {
580 return Err(AcdpError::InvalidReceipt(format!(
581 "lineage_head_receipt signature.key_id '{}' is not a DID URL under \
582 registry_did '{}' (RFC-ACDP-0011 §7 step 3)",
583 self.signature.key_id, self.registry_did
584 )));
585 }
586 }
587 let head_authority_did = acdp_did::web::authority_to_did_web(self.head_ctx_id.authority());
588 if head_authority_did != self.registry_did {
589 return Err(AcdpError::InvalidReceipt(format!(
590 "lineage_head_receipt head_ctx_id authority '{}' ≠ registry_did '{}' \
591 (RFC-ACDP-0011 §7 step 3: lineages are single-registry)",
592 self.head_ctx_id.authority(),
593 self.registry_did
594 )));
595 }
596 Ok(())
597 }
598
599 pub fn cross_check_lineage(&self, requested: &LineageId) -> Result<(), AcdpError> {
603 if &self.lineage_id != requested {
604 return Err(AcdpError::InvalidReceipt(format!(
605 "lineage_head_receipt lineage_id '{}' ≠ requested lineage '{requested}' \
606 (RFC-ACDP-0011 §7 step 4)",
607 self.lineage_id
608 )));
609 }
610 Ok(())
611 }
612
613 pub fn cross_check_head(
626 &self,
627 served_ctx_id: &CtxId,
628 served_version: u32,
629 served_status: &Status,
630 on_current_endpoint: bool,
631 ) -> Result<(), AcdpError> {
632 if on_current_endpoint || &self.head_ctx_id == served_ctx_id {
633 if &self.head_ctx_id != served_ctx_id {
634 return Err(AcdpError::InvalidReceipt(format!(
635 "lineage_head_receipt head_ctx_id '{}' ≠ served ctx_id '{served_ctx_id}' \
636 (RFC-ACDP-0011 §7 step 5: /current must serve the attested head)",
637 self.head_ctx_id
638 )));
639 }
640 if self.head_version != served_version {
641 return Err(AcdpError::InvalidReceipt(format!(
642 "lineage_head_receipt head_version {} ≠ served body.version \
643 {served_version} (RFC-ACDP-0011 §7 step 5)",
644 self.head_version
645 )));
646 }
647 if self.head_status != served_status.as_str() {
648 return Err(AcdpError::InvalidReceipt(format!(
649 "lineage_head_receipt head_status '{}' ≠ served registry_state.status \
650 '{}' (RFC-ACDP-0011 §7 step 5)",
651 self.head_status,
652 served_status.as_str()
653 )));
654 }
655 return Ok(());
656 }
657 if self.head_version <= served_version {
659 return Err(AcdpError::InvalidReceipt(format!(
660 "lineage_head_receipt names a different head '{}' but head_version {} is \
661 not greater than the served body.version {served_version} \
662 (RFC-ACDP-0011 §7 step 5b)",
663 self.head_ctx_id, self.head_version
664 )));
665 }
666 let non_head_served = matches!(served_status, Status::Superseded | Status::Retracted);
667 if !non_head_served {
668 return Err(AcdpError::InvalidReceipt(format!(
669 "lineage_head_receipt names a different head '{}' but the served context's \
670 status is '{}', not 'superseded' (or 'retracted', RFC-ACDP-0013 §7.2) — \
671 self-contradictory response (RFC-ACDP-0011 §7 step 5b)",
672 self.head_ctx_id,
673 served_status.as_str()
674 )));
675 }
676 Ok(())
677 }
678
679 pub fn check_as_of_skew(
689 &self,
690 now: DateTime<Utc>,
691 max_clock_skew: chrono::Duration,
692 ) -> Result<(), AcdpError> {
693 if self.as_of.timestamp_subsec_nanos() % 1_000_000 != 0 {
694 return Err(AcdpError::InvalidReceipt(
695 "lineage_head_receipt as_of is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
696 .into(),
697 ));
698 }
699 if self.as_of > now + max_clock_skew {
700 return Err(AcdpError::InvalidReceipt(format!(
701 "lineage_head_receipt as_of '{}' is in the future beyond the {}s clock-skew \
702 allowance (consumer clock '{}') — forged freshness claim \
703 (RFC-ACDP-0011 §7 step 6)",
704 self.as_of.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
705 max_clock_skew.num_seconds(),
706 now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
707 )));
708 }
709 Ok(())
710 }
711
712 pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
717 now - self.as_of
718 }
719}
720
721pub struct ReceiptSigner {
730 key: acdp_crypto::sign::AcdpSigningKey,
731 key_id: String,
733 registry_did: String,
735}
736
737impl ReceiptSigner {
738 pub fn new(
742 key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
743 registry_did: impl Into<String>,
744 key_id: impl Into<String>,
745 ) -> Result<Self, AcdpError> {
746 let registry_did = registry_did.into();
747 let key_id = key_id.into();
748 if !registry_did.starts_with("did:web:") {
749 return Err(AcdpError::SchemaViolation(format!(
750 "receipt signer registry_did must be did:web, got '{registry_did}'"
751 )));
752 }
753 match key_id.split_once('#') {
754 Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
755 _ => {
756 return Err(AcdpError::SchemaViolation(format!(
757 "receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
758 )));
759 }
760 }
761 Ok(Self {
762 key: key.into(),
763 key_id,
764 registry_did,
765 })
766 }
767
768 pub fn registry_did(&self) -> &str {
770 &self.registry_did
771 }
772
773 pub fn key_id(&self) -> &str {
776 &self.key_id
777 }
778
779 pub(crate) fn signing_key(&self) -> &acdp_crypto::sign::AcdpSigningKey {
783 &self.key
784 }
785
786 pub fn mint(
793 &self,
794 ctx_id: &CtxId,
795 lineage_id: &LineageId,
796 origin_registry: &str,
797 created_at: DateTime<Utc>,
798 content_hash: &ContentHash,
799 producer_key_fingerprint: &str,
800 ) -> Result<RegistryReceipt, AcdpError> {
801 let mut receipt = RegistryReceipt {
802 registry_did: self.registry_did.clone(),
803 ctx_id: ctx_id.clone(),
804 lineage_id: lineage_id.clone(),
805 origin_registry: origin_registry.to_string(),
806 created_at: acdp_primitives::time::trunc_ms(created_at),
807 content_hash: content_hash.clone(),
808 key_fingerprint: producer_key_fingerprint.to_string(),
809 signature: Signature {
810 algorithm: self.key.algorithm().into(),
811 key_id: self.key_id.clone(),
812 value: String::new(), },
814 };
815 let hash = receipt.preimage_hash()?;
816 let (algorithm, value) = self.key.sign_content_hash(&hash);
817 receipt.signature.algorithm = algorithm.into();
818 receipt.signature.value = value;
819 Ok(receipt)
820 }
821
822 pub fn mint_lineage_head(
837 &self,
838 lineage_id: &LineageId,
839 head_ctx_id: &CtxId,
840 head_version: u32,
841 head_status: &Status,
842 as_of: DateTime<Utc>,
843 ) -> Result<LineageHeadReceipt, AcdpError> {
844 if matches!(head_status, Status::Superseded | Status::Retracted) {
845 return Err(AcdpError::SchemaViolation(format!(
846 "cannot mint a lineage-head receipt with head_status '{}' — a superseded \
847 version is never the head (RFC-ACDP-0011 §4) and a retracted version is \
848 never served as head (RFC-ACDP-0013 §8.3)",
849 head_status.as_str()
850 )));
851 }
852 if head_version < 1 {
853 return Err(AcdpError::SchemaViolation(
854 "cannot mint a lineage-head receipt with head_version 0 (RFC-ACDP-0011 §4)".into(),
855 ));
856 }
857 let head_authority_did = acdp_did::web::authority_to_did_web(head_ctx_id.authority());
858 if head_authority_did != self.registry_did {
859 return Err(AcdpError::SchemaViolation(format!(
860 "cannot mint a lineage-head receipt for head_ctx_id authority '{}' under \
861 registry_did '{}' (RFC-ACDP-0011 §4: lineages are single-registry)",
862 head_ctx_id.authority(),
863 self.registry_did
864 )));
865 }
866 let mut receipt = LineageHeadReceipt {
867 receipt_version: LINEAGE_HEAD_RECEIPT_VERSION.to_string(),
868 registry_did: self.registry_did.clone(),
869 lineage_id: lineage_id.clone(),
870 head_ctx_id: head_ctx_id.clone(),
871 head_version,
872 head_status: head_status.as_str().to_string(),
873 as_of: acdp_primitives::time::trunc_ms(as_of),
874 signature: Signature {
875 algorithm: self.key.algorithm().into(),
876 key_id: self.key_id.clone(),
877 value: String::new(), },
879 };
880 let hash = receipt.preimage_hash()?;
881 let (algorithm, value) = self.key.sign_content_hash(&hash);
882 receipt.signature.algorithm = algorithm.into();
883 receipt.signature.value = value;
884 Ok(receipt)
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use acdp_crypto::SigningKey;
892
893 fn test_signer() -> ReceiptSigner {
894 ReceiptSigner::new(
895 SigningKey::from_bytes(&[1u8; 32]),
896 "did:web:registry.example.com",
897 "did:web:registry.example.com#receipt-key-1",
898 )
899 .unwrap()
900 }
901
902 fn test_receipt() -> RegistryReceipt {
903 test_signer()
904 .mint(
905 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
906 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
907 "registry.example.com",
908 chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
909 .unwrap()
910 .with_timezone(&chrono::Utc),
911 &ContentHash(format!("sha256:{}", "b".repeat(64))),
912 "sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
913 )
914 .unwrap()
915 }
916
917 fn registry_pub() -> [u8; 32] {
918 SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
919 }
920
921 #[test]
922 fn mint_verify_round_trip() {
923 let receipt = test_receipt();
924 receipt
925 .verify_signature_with_key(Some(®istry_pub()), None)
926 .expect("freshly minted receipt must verify");
927 }
928
929 #[test]
930 fn tampered_fields_fail_verification() {
931 let pubkey = registry_pub();
933 let mut r = test_receipt();
934 r.created_at += chrono::Duration::milliseconds(1);
935 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
936
937 let mut r = test_receipt();
938 r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
939 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
940
941 let mut r = test_receipt();
942 r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
943 assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
944 }
945
946 #[test]
949 fn unknown_receipt_fields_rejected() {
950 let mut wire = serde_json::to_value(test_receipt()).unwrap();
951 wire.as_object_mut()
952 .unwrap()
953 .insert("transparency_log_index".into(), serde_json::json!(42));
954 let err = RegistryReceipt::from_value(&wire).unwrap_err();
955 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
956 }
957
958 #[test]
964 fn raw_and_struct_preimages_agree_incl_whole_second() {
965 let receipt = test_receipt();
966 let wire = serde_json::to_value(&receipt).unwrap();
967 assert_eq!(
968 RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
969 receipt.preimage_hash().unwrap()
970 );
971 RegistryReceipt::validate_created_at_form(&wire).unwrap();
972
973 let signer = test_signer();
975 let r = signer
976 .mint(
977 &receipt.ctx_id,
978 &receipt.lineage_id,
979 "registry.example.com",
980 chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
981 .unwrap()
982 .with_timezone(&chrono::Utc),
983 &receipt.content_hash,
984 &receipt.key_fingerprint,
985 )
986 .unwrap();
987 let wire = serde_json::to_value(&r).unwrap();
988 assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
989 RegistryReceipt::validate_created_at_form(&wire).unwrap();
990 let parsed = RegistryReceipt::from_value(&wire).unwrap();
991 parsed
992 .verify_signature_with_key(Some(®istry_pub()), None)
993 .expect("whole-second receipt must round-trip and verify");
994 }
995
996 #[test]
997 fn cross_checks_fire() {
998 let r = test_receipt();
999 let ctx = r.ctx_id.clone();
1000 let hash = r.content_hash.clone();
1001 let fp = r.key_fingerprint.clone();
1002
1003 r.cross_check(&ctx, &hash, &fp).expect("all aligned");
1004
1005 let other =
1007 CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
1008 assert!(matches!(
1009 r.cross_check(&other, &hash, &fp).unwrap_err(),
1010 AcdpError::InvalidReceipt(_)
1011 ));
1012 assert!(r
1014 .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
1015 .is_err());
1016 assert!(r
1018 .cross_check(
1019 &ctx,
1020 &ContentHash(format!("sha256:{}", "c".repeat(64))),
1021 &fp
1022 )
1023 .is_err());
1024 }
1025
1026 #[test]
1027 fn signer_rejects_malformed_identity() {
1028 assert!(ReceiptSigner::new(
1029 SigningKey::from_bytes(&[1u8; 32]),
1030 "did:key:zNotWeb",
1031 "did:key:zNotWeb#k",
1032 )
1033 .is_err());
1034 assert!(ReceiptSigner::new(
1035 SigningKey::from_bytes(&[1u8; 32]),
1036 "did:web:registry.example.com",
1037 "did:web:other.example.com#k",
1038 )
1039 .is_err());
1040 assert!(ReceiptSigner::new(
1041 SigningKey::from_bytes(&[1u8; 32]),
1042 "did:web:registry.example.com",
1043 "did:web:registry.example.com",
1044 )
1045 .is_err());
1046 }
1047
1048 fn test_head_receipt() -> LineageHeadReceipt {
1051 test_signer()
1052 .mint_lineage_head(
1053 &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
1054 &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
1055 2,
1056 &Status::Active,
1057 chrono::DateTime::parse_from_rfc3339("2026-07-04T09:00:00.123Z")
1058 .unwrap()
1059 .with_timezone(&chrono::Utc),
1060 )
1061 .unwrap()
1062 }
1063
1064 #[test]
1065 fn head_receipt_mint_verify_round_trip() {
1066 let r = test_head_receipt();
1067 assert_eq!(r.receipt_version, LINEAGE_HEAD_RECEIPT_VERSION);
1068 r.verify_signature_with_key(Some(®istry_pub()), None)
1069 .expect("freshly minted head receipt must verify");
1070 let wire = serde_json::to_value(&r).unwrap();
1072 LineageHeadReceipt::validate_as_of_form(&wire).unwrap();
1073 let parsed = LineageHeadReceipt::from_value(&wire).unwrap();
1074 parsed
1075 .verify_signature_with_key(Some(®istry_pub()), None)
1076 .unwrap();
1077 assert_eq!(
1078 LineageHeadReceipt::preimage_hash_of_value(&wire).unwrap(),
1079 r.preimage_hash().unwrap()
1080 );
1081 }
1082
1083 #[test]
1087 fn head_receipt_domain_separation_and_closed_schema() {
1088 let r = test_head_receipt();
1089 let mut wire = serde_json::to_value(&r).unwrap();
1090
1091 wire.as_object_mut()
1093 .unwrap()
1094 .insert("freshness_proof".into(), serde_json::json!(true));
1095 assert!(matches!(
1096 LineageHeadReceipt::from_value(&wire).unwrap_err(),
1097 AcdpError::InvalidReceipt(_)
1098 ));
1099
1100 let mut wire = serde_json::to_value(&r).unwrap();
1102 wire["receipt_version"] = serde_json::json!("acdp-lhr/2");
1103 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1104
1105 let wire = serde_json::to_value(&r).unwrap();
1108 assert!(RegistryReceipt::from_value(&wire).is_err());
1109 let rcpt_wire = serde_json::to_value(test_receipt()).unwrap();
1110 assert!(LineageHeadReceipt::from_value(&rcpt_wire).is_err());
1111 }
1112
1113 #[test]
1114 fn head_receipt_semantic_invariants_rejected() {
1115 let r = test_head_receipt();
1116
1117 let mut wire = serde_json::to_value(&r).unwrap();
1119 wire["head_status"] = serde_json::json!("superseded");
1120 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1121 assert!(test_signer()
1122 .mint_lineage_head(
1123 &r.lineage_id,
1124 &r.head_ctx_id,
1125 1,
1126 &Status::Superseded,
1127 chrono::Utc::now(),
1128 )
1129 .is_err());
1130
1131 let mut wire = serde_json::to_value(&r).unwrap();
1133 wire["head_status"] = serde_json::json!("retracted");
1134 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1135 assert!(test_signer()
1136 .mint_lineage_head(
1137 &r.lineage_id,
1138 &r.head_ctx_id,
1139 1,
1140 &Status::parse("retracted").unwrap(),
1141 chrono::Utc::now(),
1142 )
1143 .is_err());
1144
1145 let mut wire = serde_json::to_value(&r).unwrap();
1147 wire["head_version"] = serde_json::json!(0);
1148 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1149
1150 let mut wire = serde_json::to_value(&r).unwrap();
1152 wire["as_of"] = serde_json::json!("2026-07-04T09:00:00Z");
1153 assert!(LineageHeadReceipt::from_value(&wire).is_err());
1154
1155 assert!(test_signer()
1157 .mint_lineage_head(
1158 &r.lineage_id,
1159 &CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into()),
1160 1,
1161 &Status::Active,
1162 chrono::Utc::now(),
1163 )
1164 .is_err());
1165 }
1166
1167 #[test]
1168 fn head_receipt_cross_checks_fire() {
1169 let r = test_head_receipt();
1170
1171 r.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
1173 .unwrap();
1174 r.cross_check_lineage(&r.lineage_id).unwrap();
1175 r.cross_check_head(&r.head_ctx_id, 2, &Status::Active, true)
1176 .unwrap();
1177
1178 assert!(r
1180 .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
1181 .is_err());
1182 assert!(r
1183 .cross_check_registry_binding("registry.example.com", "did:web:other.example")
1184 .is_err());
1185
1186 assert!(r
1188 .cross_check_lineage(&LineageId(format!("lin:sha256:{}", "f".repeat(64))))
1189 .is_err());
1190
1191 let other =
1193 CtxId("acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into());
1194 assert!(r
1195 .cross_check_head(&other, 3, &Status::Active, true)
1196 .is_err());
1197 assert!(r
1199 .cross_check_head(&r.head_ctx_id, 1, &Status::Active, true)
1200 .is_err());
1201 assert!(r
1202 .cross_check_head(&r.head_ctx_id, 2, &Status::Expired, true)
1203 .is_err());
1204
1205 r.cross_check_head(&other, 1, &Status::Superseded, false)
1209 .unwrap();
1210 r.cross_check_head(&other, 1, &Status::parse("retracted").unwrap(), false)
1211 .unwrap();
1212 assert!(r
1214 .cross_check_head(&other, 1, &Status::Active, false)
1215 .is_err());
1216 assert!(r
1217 .cross_check_head(&other, 1, &Status::Expired, false)
1218 .is_err());
1219 assert!(r
1220 .cross_check_head(&other, 2, &Status::Superseded, false)
1221 .is_err());
1222 }
1223
1224 #[test]
1228 fn head_receipt_as_of_skew_and_age() {
1229 let r = test_head_receipt();
1230 let skew = chrono::Duration::seconds(120);
1231
1232 let now = r.as_of - chrono::Duration::seconds(30); r.check_as_of_skew(now, skew).expect("within skew");
1234
1235 let now = r.as_of - chrono::Duration::seconds(300); let err = r.check_as_of_skew(now, skew).unwrap_err();
1237 assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
1238
1239 let now = r.as_of + chrono::Duration::seconds(3600);
1241 r.check_as_of_skew(now, skew)
1242 .expect("stale is not a step-6 failure");
1243 assert_eq!(r.age_at(now), chrono::Duration::seconds(3600));
1244 }
1245}