1use crate::body::Signature;
34use crate::receipt::{
35 is_canonical_ms_utc, ms_rfc3339, preimage_hash_of_object_with, verify_signature_over_hash_with,
36 ReceiptSigner,
37};
38use acdp_jcs::try_canonicalize_value;
39use acdp_primitives::error::AcdpError;
40use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43
44pub const LOG_LEAF_VERSION: &str = "acdp-log-leaf/1";
49
50pub const LOG_CHECKPOINT_VERSION: &str = "acdp-log/1";
54
55pub fn decode_sha256_hex(prefixed: &str) -> Result<[u8; 32], AcdpError> {
59 let hex_part = prefixed.strip_prefix("sha256:").ok_or_else(|| {
60 AcdpError::InvalidLogProof(format!(
61 "hash '{prefixed}' is not of the form 'sha256:<hex>' (RFC-ACDP-0012 §2)"
62 ))
63 })?;
64 if hex_part.len() != 64
65 || !hex_part
66 .bytes()
67 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
68 {
69 return Err(AcdpError::InvalidLogProof(format!(
70 "hash '{prefixed}' is not 'sha256:' + 64 lowercase hex digits (RFC-ACDP-0012 §2)"
71 )));
72 }
73 let bytes = hex::decode(hex_part)
74 .map_err(|e| AcdpError::InvalidLogProof(format!("hash '{prefixed}': {e}")))?;
75 bytes.try_into().map_err(|_| {
76 AcdpError::InvalidLogProof(format!("hash '{prefixed}' does not encode 32 bytes"))
77 })
78}
79
80pub fn encode_sha256_hex(digest: &[u8; 32]) -> String {
83 format!("sha256:{}", hex::encode(digest))
84}
85
86pub fn parse_log_id(log_id: &str) -> Result<(&str, &str), AcdpError> {
90 let (did, instance) = log_id.split_once("/log/").ok_or_else(|| {
91 AcdpError::InvalidLogProof(format!(
92 "log_id '{log_id}' is not '<registry_did>/log/<instance>' (RFC-ACDP-0012 §6)"
93 ))
94 })?;
95 let msi = did.strip_prefix("did:web:").ok_or_else(|| {
96 AcdpError::InvalidLogProof(format!(
97 "log_id '{log_id}' registry DID must be did:web (RFC-ACDP-0012 §6)"
98 ))
99 })?;
100 if msi.is_empty()
101 || !msi
102 .bytes()
103 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'%' | b':' | b'-'))
104 {
105 return Err(AcdpError::InvalidLogProof(format!(
106 "log_id '{log_id}' has a malformed did:web method-specific identifier"
107 )));
108 }
109 if instance.is_empty()
110 || instance.len() > 32
111 || !instance
112 .bytes()
113 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
114 {
115 return Err(AcdpError::InvalidLogProof(format!(
116 "log_id '{log_id}' instance component must match [a-z0-9-]{{1,32}} (RFC-ACDP-0012 §6)"
117 )));
118 }
119 Ok((did, instance))
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct LogLeaf {
137 pub leaf_version: String,
139 pub ctx_id: CtxId,
143 pub lineage_id: LineageId,
145 pub origin_registry: String,
149 #[serde(with = "ms_rfc3339")]
154 pub created_at: DateTime<Utc>,
155 pub content_hash: ContentHash,
157 pub key_fingerprint: String,
160 pub receipt_hash: String,
165}
166
167impl LogLeaf {
168 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
172 let leaf = Self::deserialize(value)
173 .map_err(|e| AcdpError::InvalidLogProof(format!("log leaf does not parse: {e}")))?;
174 if leaf.leaf_version != LOG_LEAF_VERSION {
175 return Err(AcdpError::InvalidLogProof(format!(
176 "log leaf leaf_version '{}' ≠ '{LOG_LEAF_VERSION}' (RFC-ACDP-0012 §4)",
177 leaf.leaf_version
178 )));
179 }
180 let raw = value
181 .get("created_at")
182 .and_then(|v| v.as_str())
183 .ok_or_else(|| {
184 AcdpError::InvalidLogProof("log leaf created_at missing or not a string".into())
185 })?;
186 if !is_canonical_ms_utc(raw) {
187 return Err(AcdpError::InvalidLogProof(format!(
188 "log leaf created_at '{raw}' is not canonical millisecond-precision RFC 3339 \
189 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0012 §4)"
190 )));
191 }
192 Ok(leaf)
193 }
194
195 pub fn canonical_bytes(&self) -> Result<Vec<u8>, AcdpError> {
200 try_canonicalize_value(&serde_json::to_value(self)?)
201 }
202
203 pub fn leaf_hash(&self) -> Result<[u8; 32], AcdpError> {
205 Ok(acdp_crypto::merkle::leaf_hash(&self.canonical_bytes()?))
206 }
207
208 pub fn leaf_hash_hex(&self) -> Result<String, AcdpError> {
210 Ok(encode_sha256_hex(&self.leaf_hash()?))
211 }
212
213 pub fn for_publish(
231 body: &crate::body::Body,
232 content_hash: &ContentHash,
233 key_fingerprint: &str,
234 receipt_value: &serde_json::Value,
235 ) -> Result<Self, AcdpError> {
236 let receipt_hash = crate::receipt::RegistryReceipt::preimage_hash_of_value(receipt_value)?;
237 Ok(Self {
238 leaf_version: LOG_LEAF_VERSION.to_string(),
239 ctx_id: body.ctx_id.clone(),
240 lineage_id: body.lineage_id.clone(),
241 origin_registry: body.origin_registry.clone(),
242 created_at: body.created_at,
243 content_hash: content_hash.clone(),
244 key_fingerprint: key_fingerprint.to_string(),
245 receipt_hash: receipt_hash.0,
246 })
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct LogCheckpoint {
266 pub checkpoint_version: String,
268 pub log_id: String,
272 pub tree_size: u64,
275 pub root_hash: String,
277 #[serde(with = "ms_rfc3339")]
282 pub timestamp: DateTime<Utc>,
283 pub signature: Signature,
287}
288
289impl LogCheckpoint {
290 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
296 let checkpoint = Self::deserialize(value).map_err(|e| {
297 AcdpError::InvalidLogProof(format!("log_checkpoint does not parse: {e}"))
298 })?;
299 if checkpoint.checkpoint_version != LOG_CHECKPOINT_VERSION {
300 return Err(AcdpError::InvalidLogProof(format!(
301 "log_checkpoint checkpoint_version '{}' ≠ '{LOG_CHECKPOINT_VERSION}' \
302 (RFC-ACDP-0012 §9.3 step 1)",
303 checkpoint.checkpoint_version
304 )));
305 }
306 parse_log_id(&checkpoint.log_id)?;
307 decode_sha256_hex(&checkpoint.root_hash)?;
308 let raw = value
309 .get("timestamp")
310 .and_then(|v| v.as_str())
311 .ok_or_else(|| {
312 AcdpError::InvalidLogProof(
313 "log_checkpoint timestamp missing or not a string".into(),
314 )
315 })?;
316 if !is_canonical_ms_utc(raw) {
317 return Err(AcdpError::InvalidLogProof(format!(
318 "log_checkpoint timestamp '{raw}' is not canonical millisecond-precision \
319 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0012 §9.3 step 4)"
320 )));
321 }
322 checkpoint.registry_did()?;
325 Ok(checkpoint)
326 }
327
328 pub fn registry_did(&self) -> Result<&str, AcdpError> {
331 let (did, _instance) = parse_log_id(&self.log_id)?;
332 match self.signature.key_id.split_once('#') {
333 Some((key_did, frag)) if key_did == did && !frag.is_empty() => Ok(did),
334 _ => Err(AcdpError::InvalidLogProof(format!(
335 "log_checkpoint signature.key_id '{}' is not a DID URL under the log_id's \
336 registry DID '{did}' (RFC-ACDP-0012 §6)",
337 self.signature.key_id
338 ))),
339 }
340 }
341
342 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
348 preimage_hash_of_object_with(value, "log_checkpoint", AcdpError::InvalidLogProof)
349 }
350
351 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
356 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
357 }
358
359 pub fn root_hash_bytes(&self) -> Result<[u8; 32], AcdpError> {
361 decode_sha256_hex(&self.root_hash)
362 }
363
364 pub fn verify_signature_with_key(
369 &self,
370 registry_pub_ed25519: Option<&[u8; 32]>,
371 registry_pub_p256_sec1: Option<&[u8]>,
372 ) -> Result<(), AcdpError> {
373 let hash = self.preimage_hash()?;
374 self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
375 }
376
377 pub fn verify_signature_against_hash(
381 &self,
382 hash: &ContentHash,
383 registry_pub_ed25519: Option<&[u8; 32]>,
384 registry_pub_p256_sec1: Option<&[u8]>,
385 ) -> Result<(), AcdpError> {
386 verify_signature_over_hash_with(
387 &self.signature,
388 hash,
389 registry_pub_ed25519,
390 registry_pub_p256_sec1,
391 "log_checkpoint",
392 AcdpError::InvalidLogProof,
393 )
394 }
395
396 pub fn cross_check_registry_binding(
403 &self,
404 serving_authority: &str,
405 capabilities_registry_did: &str,
406 ) -> Result<(), AcdpError> {
407 let did = self.registry_did()?;
408 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
409 if did != expected_did {
410 return Err(AcdpError::InvalidLogProof(format!(
411 "log_checkpoint log_id registry DID '{did}' ≠ serving authority's DID \
412 '{expected_did}' (RFC-ACDP-0012 §9.3 step 3)"
413 )));
414 }
415 if did != capabilities_registry_did {
416 return Err(AcdpError::InvalidLogProof(format!(
417 "log_checkpoint log_id registry DID '{did}' ≠ capabilities.registry_did \
418 '{capabilities_registry_did}' (RFC-ACDP-0012 §9.3 step 3)"
419 )));
420 }
421 Ok(())
422 }
423
424 pub fn check_timestamp_skew(
430 &self,
431 now: DateTime<Utc>,
432 max_clock_skew: chrono::Duration,
433 ) -> Result<(), AcdpError> {
434 if !self
435 .timestamp
436 .timestamp_subsec_nanos()
437 .is_multiple_of(1_000_000)
438 {
439 return Err(AcdpError::InvalidLogProof(
440 "log_checkpoint timestamp is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
441 ));
442 }
443 if self.timestamp > now + max_clock_skew {
444 return Err(AcdpError::InvalidLogProof(format!(
445 "log_checkpoint timestamp '{}' is in the future beyond the {}s clock-skew \
446 allowance (consumer clock '{}') (RFC-ACDP-0012 §9.3 step 4)",
447 self.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
448 max_clock_skew.num_seconds(),
449 now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
450 )));
451 }
452 Ok(())
453 }
454
455 pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
458 now - self.timestamp
459 }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(deny_unknown_fields)]
476pub struct LogInclusion {
477 pub log_id: String,
480 pub leaf_index: u64,
483 pub tree_size: u64,
486 pub inclusion_path: Vec<String>,
490 pub log_checkpoint: LogCheckpoint,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub leaf: Option<LogLeaf>,
500}
501
502impl LogInclusion {
503 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
509 let inclusion = Self::deserialize(value).map_err(|e| {
510 AcdpError::InvalidLogProof(format!("log_inclusion does not parse: {e}"))
511 })?;
512 if inclusion.tree_size < 1 {
513 return Err(AcdpError::InvalidLogProof(
514 "log_inclusion tree_size must be >= 1 (an empty tree has no members)".into(),
515 ));
516 }
517 if let Some(cp) = value.get("log_checkpoint") {
521 LogCheckpoint::from_value(cp)?;
522 }
523 if let Some(leaf) = value.get("leaf") {
524 LogLeaf::from_value(leaf)?;
525 }
526 Ok(inclusion)
527 }
528
529 pub fn cross_check_binding(&self) -> Result<(), AcdpError> {
533 if self.tree_size != self.log_checkpoint.tree_size {
534 return Err(AcdpError::InvalidLogProof(format!(
535 "log_inclusion tree_size {} ≠ log_checkpoint.tree_size {} \
536 (RFC-ACDP-0012 §9.1 step 4)",
537 self.tree_size, self.log_checkpoint.tree_size
538 )));
539 }
540 if self.log_id != self.log_checkpoint.log_id {
541 return Err(AcdpError::InvalidLogProof(format!(
542 "log_inclusion log_id '{}' ≠ log_checkpoint.log_id '{}' \
543 (RFC-ACDP-0012 §9.1 step 4)",
544 self.log_id, self.log_checkpoint.log_id
545 )));
546 }
547 if self.leaf_index >= self.tree_size {
548 return Err(AcdpError::InvalidLogProof(format!(
549 "log_inclusion leaf_index {} is not < tree_size {} (RFC-ACDP-0012 §9.1 step 4)",
550 self.leaf_index, self.tree_size
551 )));
552 }
553 Ok(())
554 }
555
556 pub fn verify_leaf_hash(&self, leaf_hash: &[u8; 32]) -> Result<(), AcdpError> {
564 self.cross_check_binding()?;
565 let path = self
566 .inclusion_path
567 .iter()
568 .map(|p| decode_sha256_hex(p))
569 .collect::<Result<Vec<_>, _>>()?;
570 let root = self.log_checkpoint.root_hash_bytes()?;
571 if !acdp_crypto::merkle::verify_inclusion(
572 leaf_hash,
573 self.leaf_index,
574 self.tree_size,
575 &path,
576 &root,
577 ) {
578 return Err(AcdpError::InvalidLogProof(format!(
579 "inclusion_path for leaf_index {} does not fold to the checkpoint root_hash \
580 '{}' at tree_size {} (RFC-ACDP-0012 §9.1 step 6)",
581 self.leaf_index, self.log_checkpoint.root_hash, self.tree_size
582 )));
583 }
584 Ok(())
585 }
586
587 pub fn verify_reconstructed_leaf(&self, leaf: &LogLeaf) -> Result<(), AcdpError> {
591 self.verify_leaf_hash(&leaf.leaf_hash()?)
592 }
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(deny_unknown_fields)]
609pub struct LogConsistencyProof {
610 pub log_id: String,
613 pub first_tree_size: u64,
615 pub second_tree_size: u64,
617 pub consistency_path: Vec<String>,
620 pub log_checkpoint: LogCheckpoint,
622}
623
624impl LogConsistencyProof {
625 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
629 let proof = Self::deserialize(value).map_err(|e| {
630 AcdpError::InvalidLogProof(format!("consistency proof does not parse: {e}"))
631 })?;
632 if let Some(cp) = value.get("log_checkpoint") {
633 LogCheckpoint::from_value(cp)?;
634 }
635 Ok(proof)
636 }
637
638 pub fn verify_against_first_root(&self, first_root_hash: &str) -> Result<(), AcdpError> {
649 if self.log_id != self.log_checkpoint.log_id {
650 return Err(AcdpError::InvalidLogProof(format!(
651 "consistency proof log_id '{}' ≠ log_checkpoint.log_id '{}' \
652 (consistency proofs exist only within one log_id, RFC-ACDP-0012 §7.4)",
653 self.log_id, self.log_checkpoint.log_id
654 )));
655 }
656 if self.second_tree_size != self.log_checkpoint.tree_size {
657 return Err(AcdpError::InvalidLogProof(format!(
658 "consistency proof second_tree_size {} ≠ log_checkpoint.tree_size {} \
659 (RFC-ACDP-0012 §8.2)",
660 self.second_tree_size, self.log_checkpoint.tree_size
661 )));
662 }
663 let first_root = decode_sha256_hex(first_root_hash)?;
664 let second_root = self.log_checkpoint.root_hash_bytes()?;
665 let path = self
666 .consistency_path
667 .iter()
668 .map(|p| decode_sha256_hex(p))
669 .collect::<Result<Vec<_>, _>>()?;
670 if !acdp_crypto::merkle::verify_consistency(
671 self.first_tree_size,
672 self.second_tree_size,
673 &path,
674 &first_root,
675 &second_root,
676 ) {
677 return Err(AcdpError::InvalidLogProof(format!(
678 "consistency_path does not prove the tree at size {} is a prefix of the tree \
679 at size {} — evidence of a logged-history rewrite; retain both checkpoints \
680 and this path (RFC-ACDP-0012 §9.2)",
681 self.first_tree_size, self.second_tree_size
682 )));
683 }
684 Ok(())
685 }
686}
687
688impl ReceiptSigner {
691 pub fn mint_log_checkpoint(
705 &self,
706 log_id: &str,
707 tree_size: u64,
708 root_hash: &str,
709 timestamp: DateTime<Utc>,
710 ) -> Result<LogCheckpoint, AcdpError> {
711 let (did, _instance) = parse_log_id(log_id)?;
712 if did != self.registry_did() {
713 return Err(AcdpError::SchemaViolation(format!(
714 "cannot mint a checkpoint for log_id '{log_id}' under registry_did '{}' \
715 (RFC-ACDP-0012 §6: the log_id embeds the registry's own DID)",
716 self.registry_did()
717 )));
718 }
719 decode_sha256_hex(root_hash)?;
720 let mut checkpoint = LogCheckpoint {
721 checkpoint_version: LOG_CHECKPOINT_VERSION.to_string(),
722 log_id: log_id.to_string(),
723 tree_size,
724 root_hash: root_hash.to_string(),
725 timestamp: acdp_primitives::time::trunc_ms(timestamp),
726 signature: Signature {
727 algorithm: self.signing_key().algorithm().into(),
728 key_id: self.key_id().to_string(),
729 value: String::new(), },
731 };
732 let hash = checkpoint.preimage_hash()?;
733 let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
734 checkpoint.signature.algorithm = algorithm.into();
735 checkpoint.signature.value = value;
736 Ok(checkpoint)
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use acdp_crypto::SigningKey;
744
745 const REGISTRY_DID: &str = "did:web:registry.example.com";
746 const LOG_ID: &str = "did:web:registry.example.com/log/1";
747
748 fn signer() -> ReceiptSigner {
749 ReceiptSigner::new(
750 SigningKey::from_bytes(&[0x11u8; 32]),
751 REGISTRY_DID,
752 format!("{REGISTRY_DID}#receipt-key-1"),
753 )
754 .unwrap()
755 }
756
757 fn registry_pub() -> [u8; 32] {
758 SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
759 }
760
761 fn leaf(i: u8) -> LogLeaf {
762 let ctx_id =
763 format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
764 LogLeaf {
765 leaf_version: LOG_LEAF_VERSION.into(),
766 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
767 ctx_id: CtxId(ctx_id),
768 origin_registry: "registry.example.com".into(),
769 created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
770 .unwrap()
771 .with_timezone(&Utc),
772 content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
773 key_fingerprint: format!("sha256:{}", "c".repeat(64)),
774 receipt_hash: format!("sha256:{}", "d".repeat(64)),
775 }
776 }
777
778 #[test]
779 fn leaf_closed_schema_and_version_enforced() {
780 let l = leaf(1);
781 let wire = serde_json::to_value(&l).unwrap();
782 assert_eq!(LogLeaf::from_value(&wire).unwrap(), l);
783
784 let mut extra = wire.clone();
785 extra
786 .as_object_mut()
787 .unwrap()
788 .insert("note".into(), serde_json::json!("x"));
789 assert!(matches!(
790 LogLeaf::from_value(&extra).unwrap_err(),
791 AcdpError::InvalidLogProof(_)
792 ));
793
794 let mut wrong = wire.clone();
795 wrong["leaf_version"] = serde_json::json!("acdp-log-leaf/2");
796 assert!(LogLeaf::from_value(&wrong).is_err());
797
798 let mut bad_ts = wire.clone();
799 bad_ts["created_at"] = serde_json::json!("2026-07-01T01:00:00Z");
800 assert!(LogLeaf::from_value(&bad_ts).is_err());
801 }
802
803 #[test]
804 fn checkpoint_mint_verify_round_trip() {
805 let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
806 let cp = signer()
807 .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
808 .unwrap();
809 cp.verify_signature_with_key(Some(®istry_pub()), None)
810 .expect("freshly minted checkpoint must verify");
811 let wire = serde_json::to_value(&cp).unwrap();
812 let parsed = LogCheckpoint::from_value(&wire).unwrap();
813 let raw_hash = LogCheckpoint::preimage_hash_of_value(&wire).unwrap();
814 assert_eq!(raw_hash, cp.preimage_hash().unwrap());
815 parsed
816 .verify_signature_against_hash(&raw_hash, Some(®istry_pub()), None)
817 .unwrap();
818 parsed
819 .cross_check_registry_binding("registry.example.com", REGISTRY_DID)
820 .unwrap();
821 assert!(parsed
822 .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
823 .is_err());
824 parsed
825 .check_timestamp_skew(Utc::now(), chrono::Duration::seconds(120))
826 .unwrap();
827
828 let mut tampered = wire.clone();
830 tampered["root_hash"] = serde_json::json!(format!("sha256:{}", "f".repeat(64)));
831 let t = LogCheckpoint::from_value(&tampered).unwrap();
832 let t_hash = LogCheckpoint::preimage_hash_of_value(&tampered).unwrap();
833 let err = t
834 .verify_signature_against_hash(&t_hash, Some(®istry_pub()), None)
835 .unwrap_err();
836 assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
837 }
838
839 #[test]
840 fn checkpoint_closed_schema_and_domain_separation() {
841 let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
842 let cp = signer()
843 .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
844 .unwrap();
845 let mut wire = serde_json::to_value(&cp).unwrap();
846 wire.as_object_mut()
847 .unwrap()
848 .insert("witnesses".into(), serde_json::json!([]));
849 assert!(matches!(
850 LogCheckpoint::from_value(&wire).unwrap_err(),
851 AcdpError::InvalidLogProof(_)
852 ));
853
854 let mut wire = serde_json::to_value(&cp).unwrap();
855 wire["checkpoint_version"] = serde_json::json!("acdp-lhr/1");
856 assert!(LogCheckpoint::from_value(&wire).is_err());
857
858 let wire = serde_json::to_value(&cp).unwrap();
860 assert!(crate::receipt::LineageHeadReceipt::from_value(&wire).is_err());
861
862 assert!(signer()
864 .mint_log_checkpoint("did:web:evil.example.com/log/1", 0, &root, Utc::now())
865 .is_err());
866 assert!(signer()
867 .mint_log_checkpoint(
868 "did:web:registry.example.com/log/UPPER",
869 0,
870 &root,
871 Utc::now()
872 )
873 .is_err());
874 assert!(signer()
875 .mint_log_checkpoint(LOG_ID, 0, "sha256:short", Utc::now())
876 .is_err());
877 }
878
879 #[test]
882 fn inclusion_and_consistency_round_trip() {
883 let leaves: Vec<LogLeaf> = (0..5).map(leaf).collect();
884 let hashes: Vec<[u8; 32]> = leaves.iter().map(|l| l.leaf_hash().unwrap()).collect();
885 let root = acdp_crypto::merkle_tree_hash(&hashes);
886 let cp = signer()
887 .mint_log_checkpoint(LOG_ID, 5, &encode_sha256_hex(&root), Utc::now())
888 .unwrap();
889
890 for (i, l) in leaves.iter().enumerate() {
891 let path = acdp_crypto::inclusion_path(i, &hashes).unwrap();
892 let inclusion = LogInclusion {
893 log_id: LOG_ID.into(),
894 leaf_index: i as u64,
895 tree_size: 5,
896 inclusion_path: path.iter().map(encode_sha256_hex).collect(),
897 log_checkpoint: cp.clone(),
898 leaf: None,
899 };
900 inclusion.verify_reconstructed_leaf(l).unwrap();
901 let wire = serde_json::to_value(&inclusion).unwrap();
903 assert!(wire.get("leaf").is_none(), "absent, never null");
904 LogInclusion::from_value(&wire)
905 .unwrap()
906 .verify_leaf_hash(&hashes[i])
907 .unwrap();
908
909 if !inclusion.inclusion_path.is_empty() {
911 let mut bad = inclusion.clone();
912 let mut raw = decode_sha256_hex(&bad.inclusion_path[0]).unwrap();
913 raw[0] ^= 1;
914 bad.inclusion_path[0] = encode_sha256_hex(&raw);
915 let err = bad.verify_reconstructed_leaf(l).unwrap_err();
916 assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
917 }
918 }
919
920 let path = acdp_crypto::inclusion_path(0, &hashes).unwrap();
922 let mk = |leaf_index, tree_size, log_id: &str| LogInclusion {
923 log_id: log_id.into(),
924 leaf_index,
925 tree_size,
926 inclusion_path: path.iter().map(encode_sha256_hex).collect(),
927 log_checkpoint: cp.clone(),
928 leaf: None,
929 };
930 assert!(mk(0, 4, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); assert!(mk(5, 5, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); assert!(mk(0, 5, "did:web:registry.example.com/log/2")
933 .verify_leaf_hash(&hashes[0])
934 .is_err()); let first_root = acdp_crypto::merkle_tree_hash(&hashes[..3]);
938 let proof = LogConsistencyProof {
939 log_id: LOG_ID.into(),
940 first_tree_size: 3,
941 second_tree_size: 5,
942 consistency_path: acdp_crypto::consistency_proof(3, &hashes)
943 .unwrap()
944 .iter()
945 .map(encode_sha256_hex)
946 .collect(),
947 log_checkpoint: cp.clone(),
948 };
949 proof
950 .verify_against_first_root(&encode_sha256_hex(&first_root))
951 .unwrap();
952 let err = proof
954 .verify_against_first_root(&format!("sha256:{}", "e".repeat(64)))
955 .unwrap_err();
956 assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
957 }
958
959 #[test]
960 fn log_id_parsing() {
961 assert_eq!(
962 parse_log_id(LOG_ID).unwrap(),
963 ("did:web:registry.example.com", "1")
964 );
965 for bad in [
966 "did:web:registry.example.com", "did:key:z6Mk/log/1", "did:web:registry.example.com/log/", "did:web:registry.example.com/log/UP", &format!("did:web:r.example/log/{}", "a".repeat(33)), ] {
972 assert!(parse_log_id(bad).is_err(), "{bad} must be rejected");
973 }
974 }
975}