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.timestamp.timestamp_subsec_nanos() % 1_000_000 != 0 {
435 return Err(AcdpError::InvalidLogProof(
436 "log_checkpoint timestamp is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
437 ));
438 }
439 if self.timestamp > now + max_clock_skew {
440 return Err(AcdpError::InvalidLogProof(format!(
441 "log_checkpoint timestamp '{}' is in the future beyond the {}s clock-skew \
442 allowance (consumer clock '{}') (RFC-ACDP-0012 §9.3 step 4)",
443 self.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
444 max_clock_skew.num_seconds(),
445 now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
446 )));
447 }
448 Ok(())
449 }
450
451 pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
454 now - self.timestamp
455 }
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct LogInclusion {
473 pub log_id: String,
476 pub leaf_index: u64,
479 pub tree_size: u64,
482 pub inclusion_path: Vec<String>,
486 pub log_checkpoint: LogCheckpoint,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub leaf: Option<LogLeaf>,
496}
497
498impl LogInclusion {
499 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
505 let inclusion = Self::deserialize(value).map_err(|e| {
506 AcdpError::InvalidLogProof(format!("log_inclusion does not parse: {e}"))
507 })?;
508 if inclusion.tree_size < 1 {
509 return Err(AcdpError::InvalidLogProof(
510 "log_inclusion tree_size must be >= 1 (an empty tree has no members)".into(),
511 ));
512 }
513 if let Some(cp) = value.get("log_checkpoint") {
517 LogCheckpoint::from_value(cp)?;
518 }
519 if let Some(leaf) = value.get("leaf") {
520 LogLeaf::from_value(leaf)?;
521 }
522 Ok(inclusion)
523 }
524
525 pub fn cross_check_binding(&self) -> Result<(), AcdpError> {
529 if self.tree_size != self.log_checkpoint.tree_size {
530 return Err(AcdpError::InvalidLogProof(format!(
531 "log_inclusion tree_size {} ≠ log_checkpoint.tree_size {} \
532 (RFC-ACDP-0012 §9.1 step 4)",
533 self.tree_size, self.log_checkpoint.tree_size
534 )));
535 }
536 if self.log_id != self.log_checkpoint.log_id {
537 return Err(AcdpError::InvalidLogProof(format!(
538 "log_inclusion log_id '{}' ≠ log_checkpoint.log_id '{}' \
539 (RFC-ACDP-0012 §9.1 step 4)",
540 self.log_id, self.log_checkpoint.log_id
541 )));
542 }
543 if self.leaf_index >= self.tree_size {
544 return Err(AcdpError::InvalidLogProof(format!(
545 "log_inclusion leaf_index {} is not < tree_size {} (RFC-ACDP-0012 §9.1 step 4)",
546 self.leaf_index, self.tree_size
547 )));
548 }
549 Ok(())
550 }
551
552 pub fn verify_leaf_hash(&self, leaf_hash: &[u8; 32]) -> Result<(), AcdpError> {
560 self.cross_check_binding()?;
561 let path = self
562 .inclusion_path
563 .iter()
564 .map(|p| decode_sha256_hex(p))
565 .collect::<Result<Vec<_>, _>>()?;
566 let root = self.log_checkpoint.root_hash_bytes()?;
567 if !acdp_crypto::merkle::verify_inclusion(
568 leaf_hash,
569 self.leaf_index,
570 self.tree_size,
571 &path,
572 &root,
573 ) {
574 return Err(AcdpError::InvalidLogProof(format!(
575 "inclusion_path for leaf_index {} does not fold to the checkpoint root_hash \
576 '{}' at tree_size {} (RFC-ACDP-0012 §9.1 step 6)",
577 self.leaf_index, self.log_checkpoint.root_hash, self.tree_size
578 )));
579 }
580 Ok(())
581 }
582
583 pub fn verify_reconstructed_leaf(&self, leaf: &LogLeaf) -> Result<(), AcdpError> {
587 self.verify_leaf_hash(&leaf.leaf_hash()?)
588 }
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(deny_unknown_fields)]
605pub struct LogConsistencyProof {
606 pub log_id: String,
609 pub first_tree_size: u64,
611 pub second_tree_size: u64,
613 pub consistency_path: Vec<String>,
616 pub log_checkpoint: LogCheckpoint,
618}
619
620impl LogConsistencyProof {
621 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
625 let proof = Self::deserialize(value).map_err(|e| {
626 AcdpError::InvalidLogProof(format!("consistency proof does not parse: {e}"))
627 })?;
628 if let Some(cp) = value.get("log_checkpoint") {
629 LogCheckpoint::from_value(cp)?;
630 }
631 Ok(proof)
632 }
633
634 pub fn verify_against_first_root(&self, first_root_hash: &str) -> Result<(), AcdpError> {
645 if self.log_id != self.log_checkpoint.log_id {
646 return Err(AcdpError::InvalidLogProof(format!(
647 "consistency proof log_id '{}' ≠ log_checkpoint.log_id '{}' \
648 (consistency proofs exist only within one log_id, RFC-ACDP-0012 §7.4)",
649 self.log_id, self.log_checkpoint.log_id
650 )));
651 }
652 if self.second_tree_size != self.log_checkpoint.tree_size {
653 return Err(AcdpError::InvalidLogProof(format!(
654 "consistency proof second_tree_size {} ≠ log_checkpoint.tree_size {} \
655 (RFC-ACDP-0012 §8.2)",
656 self.second_tree_size, self.log_checkpoint.tree_size
657 )));
658 }
659 let first_root = decode_sha256_hex(first_root_hash)?;
660 let second_root = self.log_checkpoint.root_hash_bytes()?;
661 let path = self
662 .consistency_path
663 .iter()
664 .map(|p| decode_sha256_hex(p))
665 .collect::<Result<Vec<_>, _>>()?;
666 if !acdp_crypto::merkle::verify_consistency(
667 self.first_tree_size,
668 self.second_tree_size,
669 &path,
670 &first_root,
671 &second_root,
672 ) {
673 return Err(AcdpError::InvalidLogProof(format!(
674 "consistency_path does not prove the tree at size {} is a prefix of the tree \
675 at size {} — evidence of a logged-history rewrite; retain both checkpoints \
676 and this path (RFC-ACDP-0012 §9.2)",
677 self.first_tree_size, self.second_tree_size
678 )));
679 }
680 Ok(())
681 }
682}
683
684impl ReceiptSigner {
687 pub fn mint_log_checkpoint(
701 &self,
702 log_id: &str,
703 tree_size: u64,
704 root_hash: &str,
705 timestamp: DateTime<Utc>,
706 ) -> Result<LogCheckpoint, AcdpError> {
707 let (did, _instance) = parse_log_id(log_id)?;
708 if did != self.registry_did() {
709 return Err(AcdpError::SchemaViolation(format!(
710 "cannot mint a checkpoint for log_id '{log_id}' under registry_did '{}' \
711 (RFC-ACDP-0012 §6: the log_id embeds the registry's own DID)",
712 self.registry_did()
713 )));
714 }
715 decode_sha256_hex(root_hash)?;
716 let mut checkpoint = LogCheckpoint {
717 checkpoint_version: LOG_CHECKPOINT_VERSION.to_string(),
718 log_id: log_id.to_string(),
719 tree_size,
720 root_hash: root_hash.to_string(),
721 timestamp: acdp_primitives::time::trunc_ms(timestamp),
722 signature: Signature {
723 algorithm: self.signing_key().algorithm().into(),
724 key_id: self.key_id().to_string(),
725 value: String::new(), },
727 };
728 let hash = checkpoint.preimage_hash()?;
729 let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
730 checkpoint.signature.algorithm = algorithm.into();
731 checkpoint.signature.value = value;
732 Ok(checkpoint)
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use acdp_crypto::SigningKey;
740
741 const REGISTRY_DID: &str = "did:web:registry.example.com";
742 const LOG_ID: &str = "did:web:registry.example.com/log/1";
743
744 fn signer() -> ReceiptSigner {
745 ReceiptSigner::new(
746 SigningKey::from_bytes(&[0x11u8; 32]),
747 REGISTRY_DID,
748 format!("{REGISTRY_DID}#receipt-key-1"),
749 )
750 .unwrap()
751 }
752
753 fn registry_pub() -> [u8; 32] {
754 SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
755 }
756
757 fn leaf(i: u8) -> LogLeaf {
758 let ctx_id =
759 format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
760 LogLeaf {
761 leaf_version: LOG_LEAF_VERSION.into(),
762 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
763 ctx_id: CtxId(ctx_id),
764 origin_registry: "registry.example.com".into(),
765 created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
766 .unwrap()
767 .with_timezone(&Utc),
768 content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
769 key_fingerprint: format!("sha256:{}", "c".repeat(64)),
770 receipt_hash: format!("sha256:{}", "d".repeat(64)),
771 }
772 }
773
774 #[test]
775 fn leaf_closed_schema_and_version_enforced() {
776 let l = leaf(1);
777 let wire = serde_json::to_value(&l).unwrap();
778 assert_eq!(LogLeaf::from_value(&wire).unwrap(), l);
779
780 let mut extra = wire.clone();
781 extra
782 .as_object_mut()
783 .unwrap()
784 .insert("note".into(), serde_json::json!("x"));
785 assert!(matches!(
786 LogLeaf::from_value(&extra).unwrap_err(),
787 AcdpError::InvalidLogProof(_)
788 ));
789
790 let mut wrong = wire.clone();
791 wrong["leaf_version"] = serde_json::json!("acdp-log-leaf/2");
792 assert!(LogLeaf::from_value(&wrong).is_err());
793
794 let mut bad_ts = wire.clone();
795 bad_ts["created_at"] = serde_json::json!("2026-07-01T01:00:00Z");
796 assert!(LogLeaf::from_value(&bad_ts).is_err());
797 }
798
799 #[test]
800 fn checkpoint_mint_verify_round_trip() {
801 let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
802 let cp = signer()
803 .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
804 .unwrap();
805 cp.verify_signature_with_key(Some(®istry_pub()), None)
806 .expect("freshly minted checkpoint must verify");
807 let wire = serde_json::to_value(&cp).unwrap();
808 let parsed = LogCheckpoint::from_value(&wire).unwrap();
809 let raw_hash = LogCheckpoint::preimage_hash_of_value(&wire).unwrap();
810 assert_eq!(raw_hash, cp.preimage_hash().unwrap());
811 parsed
812 .verify_signature_against_hash(&raw_hash, Some(®istry_pub()), None)
813 .unwrap();
814 parsed
815 .cross_check_registry_binding("registry.example.com", REGISTRY_DID)
816 .unwrap();
817 assert!(parsed
818 .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
819 .is_err());
820 parsed
821 .check_timestamp_skew(Utc::now(), chrono::Duration::seconds(120))
822 .unwrap();
823
824 let mut tampered = wire.clone();
826 tampered["root_hash"] = serde_json::json!(format!("sha256:{}", "f".repeat(64)));
827 let t = LogCheckpoint::from_value(&tampered).unwrap();
828 let t_hash = LogCheckpoint::preimage_hash_of_value(&tampered).unwrap();
829 let err = t
830 .verify_signature_against_hash(&t_hash, Some(®istry_pub()), None)
831 .unwrap_err();
832 assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
833 }
834
835 #[test]
836 fn checkpoint_closed_schema_and_domain_separation() {
837 let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
838 let cp = signer()
839 .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
840 .unwrap();
841 let mut wire = serde_json::to_value(&cp).unwrap();
842 wire.as_object_mut()
843 .unwrap()
844 .insert("witnesses".into(), serde_json::json!([]));
845 assert!(matches!(
846 LogCheckpoint::from_value(&wire).unwrap_err(),
847 AcdpError::InvalidLogProof(_)
848 ));
849
850 let mut wire = serde_json::to_value(&cp).unwrap();
851 wire["checkpoint_version"] = serde_json::json!("acdp-lhr/1");
852 assert!(LogCheckpoint::from_value(&wire).is_err());
853
854 let wire = serde_json::to_value(&cp).unwrap();
856 assert!(crate::receipt::LineageHeadReceipt::from_value(&wire).is_err());
857
858 assert!(signer()
860 .mint_log_checkpoint("did:web:evil.example.com/log/1", 0, &root, Utc::now())
861 .is_err());
862 assert!(signer()
863 .mint_log_checkpoint(
864 "did:web:registry.example.com/log/UPPER",
865 0,
866 &root,
867 Utc::now()
868 )
869 .is_err());
870 assert!(signer()
871 .mint_log_checkpoint(LOG_ID, 0, "sha256:short", Utc::now())
872 .is_err());
873 }
874
875 #[test]
878 fn inclusion_and_consistency_round_trip() {
879 let leaves: Vec<LogLeaf> = (0..5).map(leaf).collect();
880 let hashes: Vec<[u8; 32]> = leaves.iter().map(|l| l.leaf_hash().unwrap()).collect();
881 let root = acdp_crypto::merkle_tree_hash(&hashes);
882 let cp = signer()
883 .mint_log_checkpoint(LOG_ID, 5, &encode_sha256_hex(&root), Utc::now())
884 .unwrap();
885
886 for (i, l) in leaves.iter().enumerate() {
887 let path = acdp_crypto::inclusion_path(i, &hashes).unwrap();
888 let inclusion = LogInclusion {
889 log_id: LOG_ID.into(),
890 leaf_index: i as u64,
891 tree_size: 5,
892 inclusion_path: path.iter().map(encode_sha256_hex).collect(),
893 log_checkpoint: cp.clone(),
894 leaf: None,
895 };
896 inclusion.verify_reconstructed_leaf(l).unwrap();
897 let wire = serde_json::to_value(&inclusion).unwrap();
899 assert!(wire.get("leaf").is_none(), "absent, never null");
900 LogInclusion::from_value(&wire)
901 .unwrap()
902 .verify_leaf_hash(&hashes[i])
903 .unwrap();
904
905 if !inclusion.inclusion_path.is_empty() {
907 let mut bad = inclusion.clone();
908 let mut raw = decode_sha256_hex(&bad.inclusion_path[0]).unwrap();
909 raw[0] ^= 1;
910 bad.inclusion_path[0] = encode_sha256_hex(&raw);
911 let err = bad.verify_reconstructed_leaf(l).unwrap_err();
912 assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
913 }
914 }
915
916 let path = acdp_crypto::inclusion_path(0, &hashes).unwrap();
918 let mk = |leaf_index, tree_size, log_id: &str| LogInclusion {
919 log_id: log_id.into(),
920 leaf_index,
921 tree_size,
922 inclusion_path: path.iter().map(encode_sha256_hex).collect(),
923 log_checkpoint: cp.clone(),
924 leaf: None,
925 };
926 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")
929 .verify_leaf_hash(&hashes[0])
930 .is_err()); let first_root = acdp_crypto::merkle_tree_hash(&hashes[..3]);
934 let proof = LogConsistencyProof {
935 log_id: LOG_ID.into(),
936 first_tree_size: 3,
937 second_tree_size: 5,
938 consistency_path: acdp_crypto::consistency_proof(3, &hashes)
939 .unwrap()
940 .iter()
941 .map(encode_sha256_hex)
942 .collect(),
943 log_checkpoint: cp.clone(),
944 };
945 proof
946 .verify_against_first_root(&encode_sha256_hex(&first_root))
947 .unwrap();
948 let err = proof
950 .verify_against_first_root(&format!("sha256:{}", "e".repeat(64)))
951 .unwrap_err();
952 assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
953 }
954
955 #[test]
956 fn log_id_parsing() {
957 assert_eq!(
958 parse_log_id(LOG_ID).unwrap(),
959 ("did:web:registry.example.com", "1")
960 );
961 for bad in [
962 "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)), ] {
968 assert!(parse_log_id(bad).is_err(), "{bad} must be rejected");
969 }
970 }
971}