Skip to main content

acdp_server/registry/
log.rs

1//! Reference in-memory transparency log (RFC-ACDP-0012) — an
2//! append-only RFC 6962-style Merkle tree over publish-event leaves.
3//!
4//! [`MerkleLog`] is a **building block**, deliberately not wired into
5//! [`crate::registry::RegistryServer`] or the store: real registries
6//! (the separate `acdp-registry-*` crates) own the §7.1 atomicity rule
7//! — body, receipt, and leaf commit together, or none does — and the §8
8//! endpoint surface. What this type provides is the tree arithmetic and
9//! the signed artifacts:
10//!
11//! - [`MerkleLog::append`] — one accepted publish → one leaf, forever,
12//!   `leaf_index` assigned consecutively from 0 in acceptance order
13//!   (§5.3, §7.1 rule 2);
14//! - [`MerkleLog::checkpoint`] / [`MerkleLog::checkpoint_at`] — signed
15//!   tree heads under the RFC-ACDP-0010 receipt signing key (§6);
16//! - [`MerkleLog::inclusion_proof`] — RFC 6962 §2.1.1 audit paths
17//!   packaged as [`LogInclusion`] (§8.2 inclusion mode);
18//! - [`MerkleLog::consistency_proof`] — RFC 6962 §2.1.2 proofs
19//!   (§8.2 consistency mode).
20//!
21//! Everything a checkpoint commits to is recomputable from the ordered
22//! leaf hashes alone (§8.3), so the in-memory representation is exactly
23//! that: the leaves plus their §5.1 hashes.
24
25use acdp_primitives::error::AcdpError;
26use acdp_types::log::{
27    encode_sha256_hex, parse_log_id, LogCheckpoint, LogConsistencyProof, LogInclusion, LogLeaf,
28};
29use acdp_types::receipt::ReceiptSigner;
30use chrono::{DateTime, Utc};
31use std::collections::HashSet;
32
33/// An append-only, in-memory RFC 6962-style Merkle tree over
34/// transparency-log leaves (RFC-ACDP-0012 §5).
35#[derive(Debug, Clone)]
36pub struct MerkleLog {
37    /// The log instantiation identifier
38    /// (`"<registry_did>/log/<instance>"`, §6). One live instantiation
39    /// per registry; a new `log_id` is an explicit history reset (§7.4).
40    log_id: String,
41    /// Leaves in append (acceptance) order — never reordered or
42    /// deleted (§5.3).
43    leaves: Vec<LogLeaf>,
44    /// §5.1 leaf hashes, index-aligned with `leaves`.
45    leaf_hashes: Vec<[u8; 32]>,
46    /// Exactly one leaf per ctx_id (§4) — bodies are immutable, so a
47    /// publish event happens once.
48    ctx_ids: HashSet<String>,
49}
50
51impl MerkleLog {
52    /// Create an empty log for `log_id`
53    /// (`"<registry_did>/log/<instance>"`, validated per RFC-ACDP-0012
54    /// §6).
55    pub fn new(log_id: impl Into<String>) -> Result<Self, AcdpError> {
56        let log_id = log_id.into();
57        parse_log_id(&log_id)?;
58        Ok(Self {
59            log_id,
60            leaves: Vec::new(),
61            leaf_hashes: Vec::new(),
62            ctx_ids: HashSet::new(),
63        })
64    }
65
66    /// The log instantiation identifier.
67    pub fn log_id(&self) -> &str {
68        &self.log_id
69    }
70
71    /// The current tree size (number of leaves).
72    pub fn tree_size(&self) -> u64 {
73        self.leaves.len() as u64
74    }
75
76    /// The leaf at `leaf_index`, if present.
77    pub fn leaf(&self, leaf_index: u64) -> Option<&LogLeaf> {
78        usize::try_from(leaf_index)
79            .ok()
80            .and_then(|i| self.leaves.get(i))
81    }
82
83    /// The §5.1 leaf hash at `leaf_index`, wire form
84    /// (`"sha256:<hex>"`) — what `GET /log/entries` serves for every
85    /// entry regardless of visibility (§8.3).
86    pub fn leaf_hash_hex(&self, leaf_index: u64) -> Option<String> {
87        usize::try_from(leaf_index)
88            .ok()
89            .and_then(|i| self.leaf_hashes.get(i))
90            .map(encode_sha256_hex)
91    }
92
93    /// Append a leaf, returning its assigned `leaf_index`
94    /// (consecutive from 0 in acceptance order, §5.3 / §7.1 rule 2).
95    ///
96    /// Enforces the §4 leaf invariants at the door: exact
97    /// `leaf_version` (via the closed reparse), one leaf per `ctx_id`,
98    /// and `origin_registry` equal to both the `ctx_id` authority and
99    /// the log's registry DID authority. Rejected publishes MUST NOT be
100    /// logged — append only what has a receipt (§4).
101    pub fn append(&mut self, leaf: LogLeaf) -> Result<u64, AcdpError> {
102        // Closed-schema + version + timestamp-form invariants (§4).
103        let leaf = LogLeaf::from_value(&serde_json::to_value(&leaf)?)?;
104        if self.ctx_ids.contains(leaf.ctx_id.as_str()) {
105            return Err(AcdpError::SchemaViolation(format!(
106                "transparency log already holds a leaf for ctx_id '{}' — exactly one leaf \
107                 per publish event (RFC-ACDP-0012 §4)",
108                leaf.ctx_id
109            )));
110        }
111        if leaf.ctx_id.authority() != leaf.origin_registry {
112            return Err(AcdpError::SchemaViolation(format!(
113                "leaf origin_registry '{}' ≠ ctx_id authority '{}' (RFC-ACDP-0012 §4)",
114                leaf.origin_registry,
115                leaf.ctx_id.authority()
116            )));
117        }
118        let (registry_did, _) = parse_log_id(&self.log_id)?;
119        let leaf_did = acdp_did::web::authority_to_did_web(&leaf.origin_registry);
120        if leaf_did != registry_did {
121            return Err(AcdpError::SchemaViolation(format!(
122                "leaf origin_registry '{}' is not this log's registry ('{registry_did}') \
123                 (RFC-ACDP-0012 §4)",
124                leaf.origin_registry
125            )));
126        }
127        let hash = leaf.leaf_hash()?;
128        let index = self.leaves.len() as u64;
129        self.ctx_ids.insert(leaf.ctx_id.as_str().to_string());
130        self.leaves.push(leaf);
131        self.leaf_hashes.push(hash);
132        Ok(index)
133    }
134
135    /// `MTH(D[tree_size])` — the current root, raw digest (§5.2). The
136    /// empty tree's root is SHA-256 of the empty string.
137    pub fn root(&self) -> [u8; 32] {
138        acdp_crypto::merkle::merkle_tree_hash(&self.leaf_hashes)
139    }
140
141    /// The current root in wire form: `"sha256:" + lowercase_hex(MTH)`.
142    pub fn root_hash(&self) -> String {
143        encode_sha256_hex(&self.root())
144    }
145
146    /// The root at a historical `tree_size` (`MTH(D[0:tree_size])`) —
147    /// every prefix root remains committed by consistency (§8.2).
148    pub fn root_at(&self, tree_size: u64) -> Result<[u8; 32], AcdpError> {
149        let n = self.checked_size(tree_size)?;
150        Ok(acdp_crypto::merkle::merkle_tree_hash(
151            &self.leaf_hashes[..n],
152        ))
153    }
154
155    /// Sign a checkpoint over the **current** tree (§6): the §7.2
156    /// serve-on-demand head. `timestamp` is the registry-clock
157    /// evaluation time (truncated to milliseconds by the minter).
158    ///
159    /// The signer is the RFC-ACDP-0010 **receipt** signer — the log
160    /// introduces no new key role — and its `registry_did` MUST match
161    /// the DID embedded in this log's `log_id`.
162    pub fn checkpoint(
163        &self,
164        signer: &ReceiptSigner,
165        timestamp: DateTime<Utc>,
166    ) -> Result<LogCheckpoint, AcdpError> {
167        self.checkpoint_at(signer, self.tree_size(), timestamp)
168    }
169
170    /// Sign a checkpoint at a historical `tree_size` (§8.2: "for a
171    /// historical tree_size the registry serves a checkpoint it signed
172    /// at that size, or signs one on demand — both roots are equally
173    /// committed by consistency").
174    pub fn checkpoint_at(
175        &self,
176        signer: &ReceiptSigner,
177        tree_size: u64,
178        timestamp: DateTime<Utc>,
179    ) -> Result<LogCheckpoint, AcdpError> {
180        let root = self.root_at(tree_size)?;
181        signer.mint_log_checkpoint(
182            &self.log_id,
183            tree_size,
184            &encode_sha256_hex(&root),
185            timestamp,
186        )
187    }
188
189    /// Build the §8.2 inclusion-mode proof object for `leaf_index`
190    /// against `checkpoint` (which MUST be a checkpoint of this log at
191    /// `leaf_index < tree_size ≤` current size — pass a
192    /// [`Self::checkpoint`]/[`Self::checkpoint_at`] product).
193    ///
194    /// The `leaf` echo is left absent; per §8.2 it is echoed only to
195    /// requesters authorized to retrieve the context, which is the
196    /// caller's visibility decision — use [`Self::leaf`] and
197    /// `LogInclusion::leaf` to attach it.
198    pub fn inclusion_proof(
199        &self,
200        leaf_index: u64,
201        checkpoint: &LogCheckpoint,
202    ) -> Result<LogInclusion, AcdpError> {
203        if checkpoint.log_id != self.log_id {
204            return Err(AcdpError::SchemaViolation(format!(
205                "checkpoint log_id '{}' is not this log ('{}')",
206                checkpoint.log_id, self.log_id
207            )));
208        }
209        let n = self.checked_size(checkpoint.tree_size)?;
210        let m = usize::try_from(leaf_index)
211            .ok()
212            .filter(|m| *m < n)
213            .ok_or_else(|| {
214                AcdpError::SchemaViolation(format!(
215                    "leaf_index {leaf_index} is not < tree_size {} (RFC-ACDP-0012 §8.2)",
216                    checkpoint.tree_size
217                ))
218            })?;
219        let path = acdp_crypto::merkle::inclusion_path(m, &self.leaf_hashes[..n])
220            .expect("bounds checked above");
221        Ok(LogInclusion {
222            log_id: self.log_id.clone(),
223            leaf_index,
224            tree_size: checkpoint.tree_size,
225            inclusion_path: path.iter().map(encode_sha256_hex).collect(),
226            log_checkpoint: checkpoint.clone(),
227            leaf: None,
228        })
229    }
230
231    /// The RFC 6962 §2.1.2 consistency path `PROOF(first, D[second])`
232    /// between two tree sizes of this log, wire form (§8.2 consistency
233    /// mode; `0 < first ≤ second ≤` current size; empty when
234    /// `first == second`).
235    pub fn consistency_proof(&self, first: u64, second: u64) -> Result<Vec<String>, AcdpError> {
236        let n = self.checked_size(second)?;
237        if first == 0 || first > second {
238            return Err(AcdpError::SchemaViolation(format!(
239                "consistency proof requires 0 < first ({first}) ≤ second ({second}) \
240                 (RFC-ACDP-0012 §8.2)"
241            )));
242        }
243        let m = usize::try_from(first).expect("first ≤ second ≤ len");
244        let path = acdp_crypto::merkle::consistency_proof(m, &self.leaf_hashes[..n])
245            .expect("bounds checked above");
246        Ok(path.iter().map(encode_sha256_hex).collect())
247    }
248
249    /// Build the full §8.2 consistency-mode response object:
250    /// `PROOF(first, D[checkpoint.tree_size])` packaged with the
251    /// checkpoint at the second size.
252    pub fn consistency_proof_response(
253        &self,
254        first: u64,
255        checkpoint: &LogCheckpoint,
256    ) -> Result<LogConsistencyProof, AcdpError> {
257        if checkpoint.log_id != self.log_id {
258            return Err(AcdpError::SchemaViolation(format!(
259                "checkpoint log_id '{}' is not this log ('{}')",
260                checkpoint.log_id, self.log_id
261            )));
262        }
263        Ok(LogConsistencyProof {
264            log_id: self.log_id.clone(),
265            first_tree_size: first,
266            second_tree_size: checkpoint.tree_size,
267            consistency_path: self.consistency_proof(first, checkpoint.tree_size)?,
268            log_checkpoint: checkpoint.clone(),
269        })
270    }
271
272    /// Validate `tree_size ≤ current size` and convert to a slice bound.
273    fn checked_size(&self, tree_size: u64) -> Result<usize, AcdpError> {
274        usize::try_from(tree_size)
275            .ok()
276            .filter(|n| *n <= self.leaves.len())
277            .ok_or_else(|| {
278                AcdpError::SchemaViolation(format!(
279                    "tree_size {tree_size} exceeds the current log size {} (RFC-ACDP-0012 §8.2)",
280                    self.leaves.len()
281                ))
282            })
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use acdp_crypto::SigningKey;
290    use acdp_types::log::{decode_sha256_hex, LOG_LEAF_VERSION};
291    use acdp_types::primitives::{ContentHash, CtxId};
292
293    const REGISTRY_DID: &str = "did:web:registry.example.com";
294    const LOG_ID: &str = "did:web:registry.example.com/log/1";
295
296    fn signer() -> ReceiptSigner {
297        ReceiptSigner::new(
298            SigningKey::from_bytes(&[0x11u8; 32]),
299            REGISTRY_DID,
300            format!("{REGISTRY_DID}#receipt-key-1"),
301        )
302        .unwrap()
303    }
304
305    fn registry_pub() -> [u8; 32] {
306        SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
307    }
308
309    fn leaf(i: u8) -> LogLeaf {
310        let ctx_id =
311            format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
312        LogLeaf {
313            leaf_version: LOG_LEAF_VERSION.into(),
314            lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
315            ctx_id: CtxId(ctx_id),
316            origin_registry: "registry.example.com".into(),
317            created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
318                .unwrap()
319                .with_timezone(&Utc),
320            content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
321            key_fingerprint: format!("sha256:{}", "c".repeat(64)),
322            receipt_hash: format!("sha256:{}", "d".repeat(64)),
323        }
324    }
325
326    /// Round trip: append N, checkpoint, prove each leaf, verify every
327    /// proof against the signed checkpoint; consistency between all
328    /// size pairs.
329    #[test]
330    fn append_prove_verify_round_trip() {
331        for n in 1..=8u8 {
332            let mut log = MerkleLog::new(LOG_ID).unwrap();
333            let mut roots = vec![log.root()]; // size 0
334            for i in 0..n {
335                let idx = log.append(leaf(i)).unwrap();
336                assert_eq!(idx, u64::from(i), "acceptance-order indexing (§5.3)");
337                roots.push(log.root());
338            }
339            assert_eq!(log.tree_size(), u64::from(n));
340
341            let cp = log.checkpoint(&signer(), Utc::now()).unwrap();
342            assert_eq!(cp.tree_size, u64::from(n));
343            assert_eq!(cp.root_hash, log.root_hash());
344            cp.verify_signature_with_key(Some(&registry_pub()), None)
345                .expect("checkpoint signature");
346            cp.cross_check_registry_binding("registry.example.com", REGISTRY_DID)
347                .unwrap();
348
349            for i in 0..u64::from(n) {
350                let proof = log.inclusion_proof(i, &cp).unwrap();
351                // §9.1 step 1: reconstruct the leaf independently — here
352                // from the log's own copy, hashed fresh.
353                proof
354                    .verify_reconstructed_leaf(log.leaf(i).unwrap())
355                    .expect("inclusion proof verifies");
356            }
357
358            // Consistency between every historical pair (m ≤ k ≤ n).
359            for m in 1..=u64::from(n) {
360                for k in m..=u64::from(n) {
361                    let cp_k = log.checkpoint_at(&signer(), k, Utc::now()).unwrap();
362                    cp_k.verify_signature_with_key(Some(&registry_pub()), None)
363                        .unwrap();
364                    let resp = log.consistency_proof_response(m, &cp_k).unwrap();
365                    resp.verify_against_first_root(&encode_sha256_hex(
366                        &roots[usize::try_from(m).unwrap()],
367                    ))
368                    .expect("consistency proof verifies");
369                }
370            }
371        }
372    }
373
374    /// Append-only invariants: duplicate ctx_id, foreign authority, and
375    /// out-of-range proof requests are refused; the empty log roots to
376    /// SHA-256("").
377    #[test]
378    fn append_invariants_and_bounds() {
379        let mut log = MerkleLog::new(LOG_ID).unwrap();
380        assert_eq!(
381            log.root_hash(),
382            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
383            "empty tree root (RFC-ACDP-0012 §5.2)"
384        );
385        // An empty checkpoint (tree_size 0) is valid and signable.
386        let cp0 = log.checkpoint(&signer(), Utc::now()).unwrap();
387        assert_eq!(cp0.tree_size, 0);
388        cp0.verify_signature_with_key(Some(&registry_pub()), None)
389            .unwrap();
390
391        log.append(leaf(0)).unwrap();
392        let err = log.append(leaf(0)).unwrap_err();
393        assert!(matches!(err, AcdpError::SchemaViolation(_)), "got {err:?}");
394
395        // Foreign origin_registry refused.
396        let mut foreign = leaf(1);
397        foreign.origin_registry = "evil.example.com".into();
398        assert!(log.append(foreign).is_err());
399        // origin_registry ≠ ctx_id authority refused.
400        let mut mismatched = leaf(2);
401        mismatched.ctx_id =
402            CtxId("acdp://other.example.com/00000000-0000-4000-8000-000000000002".into());
403        assert!(log.append(mismatched).is_err());
404
405        // Bounds.
406        let cp = log.checkpoint(&signer(), Utc::now()).unwrap();
407        assert!(log.inclusion_proof(1, &cp).is_err());
408        assert!(log.checkpoint_at(&signer(), 2, Utc::now()).is_err());
409        assert!(log.consistency_proof(0, 1).is_err());
410        assert!(log.consistency_proof(1, 2).is_err());
411        assert!(log.consistency_proof(1, 1).unwrap().is_empty());
412
413        // A checkpoint from another instantiation is refused.
414        let other = MerkleLog::new("did:web:registry.example.com/log/2").unwrap();
415        let cp_other = other.checkpoint(&signer(), Utc::now()).unwrap();
416        assert!(log.inclusion_proof(0, &cp_other).is_err());
417        assert!(log.consistency_proof_response(1, &cp_other).is_err());
418
419        // Malformed log_id refused at construction.
420        assert!(MerkleLog::new("did:web:registry.example.com").is_err());
421        assert!(MerkleLog::new("did:web:registry.example.com/log/UPPER").is_err());
422
423        // Wire-form leaf hash accessor matches the leaf's own.
424        assert_eq!(
425            decode_sha256_hex(&log.leaf_hash_hex(0).unwrap()).unwrap(),
426            log.leaf(0).unwrap().leaf_hash().unwrap()
427        );
428        assert!(log.leaf_hash_hex(1).is_none());
429    }
430}