Skip to main content

confium_wasm/
transparency.rs

1//! `MerkleTree` + `InclusionProof` — RFC 6962 transparency log proofs.
2
3use confium_transparency::merkle::{Hash, InclusionProof as RustInclusionProof};
4use wasm_bindgen::prelude::*;
5
6/// Append-only Merkle tree mirroring `confium_transparency::MerkleTree`.
7///
8/// Browser-side consumers typically receive a tree head (root hash + size)
9/// from a server and verify inclusion proofs against it. This class also
10/// supports building trees locally for testing.
11#[wasm_bindgen]
12pub struct MerkleTree {
13    inner: std::cell::RefCell<confium_transparency::merkle::MerkleTree>,
14    /// Stored entry hashes keyed by sequence, so inclusion proofs can be
15    /// verified without round-tripping entries back through the boundary.
16    leaf_hashes: std::cell::RefCell<std::collections::HashMap<u64, Hash>>,
17}
18
19#[wasm_bindgen]
20impl MerkleTree {
21    /// Construct an empty tree.
22    #[wasm_bindgen(constructor)]
23    pub fn new() -> MerkleTree {
24        Self {
25            inner: std::cell::RefCell::new(confium_transparency::merkle::MerkleTree::new()),
26            leaf_hashes: std::cell::RefCell::new(Default::default()),
27        }
28    }
29
30    /// Append a 32-byte artifact hash. Returns the assigned sequence number.
31    pub fn append(&self, artifact_hash: &[u8]) -> Result<u64, JsValue> {
32        if artifact_hash.len() != 32 {
33            return Err(JsValue::from_str(&format!(
34                "artifact_hash must be exactly 32 bytes, got {}",
35                artifact_hash.len()
36            )));
37        }
38        let mut hash_arr = [0u8; 32];
39        hash_arr.copy_from_slice(artifact_hash);
40        // Pre-compute the sequence the tree will assign so the entry_hash
41        // we use for the leaf_hash matches what the tree will store
42        // internally. (entry_hash depends on sequence; if we let the tree
43        // rewrite sequence==0 to N after the fact, our cached leaf_hash
44        // would be wrong for every leaf past the first.)
45        let seq = self.inner.borrow().len() as u64;
46        let entry = confium_transparency::entry::MerkleEntry::new(
47            seq,
48            confium_transparency::entry::ArtifactType::CertificateIssuance,
49            hash_arr,
50        );
51        let leaf_hash = hash_leaf(entry.entry_hash());
52        let assigned = self.inner.borrow_mut().append(entry);
53        debug_assert_eq!(assigned, seq, "predicted sequence must match assigned");
54        self.leaf_hashes.borrow_mut().insert(assigned, leaf_hash);
55        Ok(assigned)
56    }
57
58    /// Current number of leaves.
59    #[wasm_bindgen(getter)]
60    pub fn length(&self) -> usize {
61        self.inner.borrow().len()
62    }
63
64    /// Current 32-byte root.
65    #[wasm_bindgen]
66    pub fn root(&self) -> Vec<u8> {
67        self.inner.borrow().root().to_vec()
68    }
69
70    /// Build a consistency proof (RFC 6962 §2.1.2) for `old_size`.
71    /// Returns a flat array of 32-byte subtree hashes concatenated
72    /// (total length = proof.len() * 32).
73    pub fn consistency_proof(&self, old_size: usize) -> Result<Vec<u8>, JsValue> {
74        let proof = self
75            .inner
76            .borrow()
77            .consistency_proof(old_size)
78            .map_err(|e| JsValue::from_str(&e.to_string()))?;
79        let mut flat = Vec::with_capacity(proof.len() * 32);
80        for h in &proof {
81            flat.extend_from_slice(h);
82        }
83        Ok(flat)
84    }
85
86    /// Build an inclusion proof for `sequence`.
87    pub fn inclusion_proof(&self, sequence: u64) -> Result<InclusionProof, JsValue> {
88        let proof = self
89            .inner
90            .borrow()
91            .inclusion_proof(sequence)
92            .map_err(|e| JsValue::from_str(&e.to_string()))?;
93        let leaf = self
94            .leaf_hashes
95            .borrow()
96            .get(&sequence)
97            .copied()
98            .ok_or_else(|| JsValue::from_str("missing leaf hash for sequence"))?;
99        Ok(InclusionProof {
100            inner: proof,
101            leaf_hash: leaf,
102        })
103    }
104}
105
106impl Default for MerkleTree {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112/// RFC 6962 inclusion proof: list of (sibling_hash, side) steps from the
113/// leaf to the root.
114#[wasm_bindgen]
115pub struct InclusionProof {
116    inner: RustInclusionProof,
117    leaf_hash: Hash,
118}
119
120#[wasm_bindgen]
121impl InclusionProof {
122    /// Sequence number of the leaf this proof is for.
123    #[wasm_bindgen(getter)]
124    pub fn sequence(&self) -> u64 {
125        self.inner.sequence
126    }
127
128    /// Verify the proof against a 32-byte root. Returns true if the leaf
129    /// hashes up to the root.
130    #[wasm_bindgen]
131    pub fn verify(&self, root: &[u8]) -> Result<bool, JsValue> {
132        if root.len() != 32 {
133            return Err(JsValue::from_str(&format!(
134                "root must be exactly 32 bytes, got {}",
135                root.len()
136            )));
137        }
138        let mut root_arr = [0u8; 32];
139        root_arr.copy_from_slice(root);
140        let mut current = self.leaf_hash;
141        for step in &self.inner.steps {
142            current = match step.side {
143                confium_transparency::merkle::Side::Left => hash_internal(step.sibling, current),
144                confium_transparency::merkle::Side::Right => hash_internal(current, step.sibling),
145            };
146        }
147        use subtle::ConstantTimeEq;
148        Ok(current.ct_eq(&root_arr).into())
149    }
150}
151
152// Domain-separated hash helpers — mirror the tree's internal algorithm
153// (0x01 prefix for leaf, 0x02 prefix for internal). The transparency
154// crate doesn't currently re-export them.
155fn hash_leaf(entry_hash: Hash) -> Hash {
156    use sha2::{Digest, Sha256};
157    let mut h = Sha256::new();
158    h.update([0x01]);
159    h.update(entry_hash);
160    let r = h.finalize();
161    let mut out = [0u8; 32];
162    out.copy_from_slice(&r);
163    out
164}
165
166/// Compute the SHA-256 of an artifact's bytes. Useful when a client
167/// only has the artifact (e.g. a cert DER) and needs the leaf hash
168/// input for the inclusion-proof verifier.
169///
170/// # Example
171///
172/// ```js
173/// import init, { compute_artifact_hash } from "@confium/confium-wasm";
174/// await init();
175/// const h = compute_artifact_hash(certDerBytes);  // Uint8Array(32)
176/// ```
177#[wasm_bindgen]
178pub fn compute_artifact_hash(artifact_bytes: &[u8]) -> Vec<u8> {
179    use sha2::{Digest, Sha256};
180    let mut h = Sha256::new();
181    h.update(artifact_bytes);
182    let r = h.finalize();
183    r.to_vec()
184}
185
186/// Compute the Merkle leaf hash for an entry. The leaf hash is
187/// `SHA-256(0x01 || entry_hash)` where `entry_hash` is
188/// `SHA-256(sequence_le_bytes || timestamp_micros_le_bytes || artifact_hash)`.
189///
190/// Callers who already know the leaf hash for a given sequence should
191/// pass that directly to [`verify_inclusion_with_head`]. This helper is
192/// for callers who only have the raw artifact + the sequence metadata
193/// published by the log.
194///
195/// # Arguments
196///
197/// * `sequence` — monotonic sequence number assigned by the log.
198/// * `timestamp_ms` — Unix epoch milliseconds when the entry was
199///   appended (matches the entry's timestamp in the log).
200/// * `artifact_bytes` — the raw artifact whose SHA-256 was anchored.
201///
202/// # Returns
203///
204/// 32-byte leaf hash as `Uint8Array`.
205#[wasm_bindgen]
206pub fn compute_leaf_hash(sequence: u64, timestamp_ms: f64, artifact_bytes: &[u8]) -> Vec<u8> {
207    use sha2::{Digest, Sha256};
208    // entry_hash = SHA-256(sequence_le || timestamp_micros_le || artifact_hash)
209    let artifact_hash = {
210        let mut h = Sha256::new();
211        h.update(artifact_bytes);
212        let r = h.finalize();
213        let mut out = [0u8; 32];
214        out.copy_from_slice(&r);
215        out
216    };
217    let mut entry_hasher = Sha256::new();
218    entry_hasher.update(sequence.to_le_bytes());
219    entry_hasher.update((timestamp_ms as i64).to_le_bytes());
220    entry_hasher.update(artifact_hash);
221    let entry_hash: [u8; 32] = {
222        let r = entry_hasher.finalize();
223        let mut out = [0u8; 32];
224        out.copy_from_slice(&r);
225        out
226    };
227    hash_leaf(entry_hash).to_vec()
228}
229
230fn hash_internal(left: Hash, right: Hash) -> Hash {
231    use sha2::{Digest, Sha256};
232    let mut h = Sha256::new();
233    h.update([0x02]);
234    h.update(left);
235    h.update(right);
236    let r = h.finalize();
237    let mut out = [0u8; 32];
238    out.copy_from_slice(&r);
239    out
240}
241
242/// Tree head — snapshot of a transparency log at a given size. JSON shape:
243/// `{ "size": number, "root": number[] }` (root is a 32-element Uint8Array
244/// marshaled via serde as a number array). Use [`tree_head_from_json`] /
245/// [`tree_head_to_json`] to round-trip heads published by a transparency-log
246/// server.
247#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
248pub struct TreeHead {
249    /// Number of leaves in the tree at this head.
250    pub size: usize,
251    /// 32-byte SHA-256 root hash.
252    pub root: Vec<u8>,
253}
254
255/// Parse a tree head from JSON. Returns a JSON string with `{ size, root_hex }`
256/// (root as hex string because wasm-bindgen doesn't natively marshal Vec<u8>
257/// in return position of free functions easily).
258#[wasm_bindgen]
259pub fn tree_head_from_json(json: &str) -> Result<String, JsValue> {
260    let head: TreeHead = serde_json::from_str(json)
261        .map_err(|e| JsValue::from_str(&format!("TreeHead JSON parse error: {e}")))?;
262    serde_json::to_string(&serde_json::json!({
263        "size": head.size,
264        "root_hex": head.root.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
265    }))
266    .map_err(|e| JsValue::from_str(&format!("serialize: {e}")))
267}
268
269/// Verify an inclusion proof against a tree head, without needing to build
270/// the tree. Caller supplies the leaf's entry hash (the digest of the
271/// artifact being proven present), the proof itself (JSON form as produced
272/// by `MerkleTree::inclusion_proof` + serde), and the tree head (root +
273/// size).
274#[wasm_bindgen]
275pub fn verify_inclusion_with_head(
276    leaf_entry_hash: &[u8],
277    proof_json: &str,
278    head_json: &str,
279) -> Result<bool, JsValue> {
280    if leaf_entry_hash.len() != 32 {
281        return Err(JsValue::from_str(&format!(
282            "leaf_entry_hash must be 32 bytes, got {}",
283            leaf_entry_hash.len()
284        )));
285    }
286    let mut leaf_arr = [0u8; 32];
287    leaf_arr.copy_from_slice(leaf_entry_hash);
288
289    let proof: RustInclusionProof = serde_json::from_str(proof_json)
290        .map_err(|e| JsValue::from_str(&format!("proof JSON parse: {e}")))?;
291    let head: TreeHead = serde_json::from_str(head_json)
292        .map_err(|e| JsValue::from_str(&format!("head JSON parse: {e}")))?;
293    if head.root.len() != 32 {
294        return Err(JsValue::from_str("head.root must be 32 bytes"));
295    }
296    let mut root_arr = [0u8; 32];
297    root_arr.copy_from_slice(&head.root);
298
299    let mut current = hash_leaf(leaf_arr);
300    for step in &proof.steps {
301        current = match step.side {
302            confium_transparency::merkle::Side::Left => hash_internal(step.sibling, current),
303            confium_transparency::merkle::Side::Right => hash_internal(current, step.sibling),
304        };
305    }
306    use subtle::ConstantTimeEq;
307    Ok(current.ct_eq(&root_arr).into())
308}