//! Inclusion proof types and verification. use serde::{Deserialize, Serialize}; use crate::{node_hash, KtError}; /// A single step in an inclusion proof path. /// /// `hash` is the sibling hash; `sibling_is_left` is `true` when the sibling /// is the left child (meaning the node being proved is the right child). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PathStep { pub hash: [u8; 32], pub sibling_is_left: bool, } /// A Merkle inclusion proof for a single leaf. /// /// ## Wire format /// /// Serialised with `bincode` and transported as the `inclusionProof :Data` field /// in the `resolveUser` Cap'n Proto response. Clients call `verify_inclusion` to /// authenticate the server's response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InclusionProof { /// 0-based index of this leaf in the log. pub leaf_index: u64, /// Number of leaves in the tree at the time the proof was generated. pub tree_size: u64, /// The 32-byte leaf hash (pre-computed from `leaf_hash(username, identity_key)`). pub leaf_hash: [u8; 32], /// Path steps from leaf level to root level (leaf-to-root order). pub path: Vec, /// Merkle root at the time the proof was generated. pub root: [u8; 32], } impl InclusionProof { /// Serialise to bytes (bincode). pub fn to_bytes(&self) -> Result, KtError> { bincode::serialize(self) .map_err(|e| KtError::Serialisation(e.to_string())) } /// Deserialise from bytes (bincode). pub fn from_bytes(bytes: &[u8]) -> Result { bincode::deserialize(bytes) .map_err(|e| KtError::Serialisation(e.to_string())) } } /// Verify that `(username, identity_key)` appears at `proof.leaf_index` in a /// Merkle log with root `proof.root` and `proof.tree_size` leaves. /// /// Returns `Ok(())` on success, `Err(KtError::RootMismatch)` on failure. /// /// The caller should additionally check that `proof.root` matches a root they /// obtained from a trusted source (e.g. a previously-pinned root or one returned /// by a second server for cross-verification). pub fn verify_inclusion( proof: &InclusionProof, username: &str, identity_key: &[u8], ) -> Result<(), KtError> { let expected_leaf = crate::leaf_hash(username, identity_key); if expected_leaf != proof.leaf_hash { return Err(KtError::RootMismatch); } let computed_root = recompute_root(proof.leaf_hash, &proof.path)?; if computed_root != proof.root { return Err(KtError::RootMismatch); } Ok(()) } /// Recompute the Merkle root from a leaf hash + direction-annotated sibling path. /// /// Each `PathStep` records the sibling hash and whether that sibling is on the /// left (meaning the current node is on the right). This is leaf-to-root order. fn recompute_root(leaf: [u8; 32], path: &[PathStep]) -> Result<[u8; 32], KtError> { let mut current = leaf; for step in path { current = if step.sibling_is_left { // Sibling is left, current is right. node_hash(&step.hash, ¤t) } else { // Sibling is right, current is left. node_hash(¤t, &step.hash) }; } Ok(current) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::tree::MerkleLog; fn log_with(entries: &[(&str, &[u8])]) -> MerkleLog { let mut log = MerkleLog::new(); for (u, k) in entries { log.append(u, k); } log } fn verify_all(log: &MerkleLog, entries: &[(&str, &[u8])]) { for (i, (u, k)) in entries.iter().enumerate() { let proof = log.inclusion_proof(i as u64).unwrap(); verify_inclusion(&proof, u, k).unwrap_or_else(|e| { panic!("proof verification failed for leaf {i}: {e}"); }); } } #[test] fn single_leaf_verifies() { let log = log_with(&[("alice", b"KEY1")]); verify_all(&log, &[("alice", b"KEY1")]); } #[test] fn two_leaves_verify() { let log = log_with(&[("alice", b"K1"), ("bob", b"K2")]); verify_all(&log, &[("alice", b"K1"), ("bob", b"K2")]); } #[test] fn three_leaves_verify() { let log = log_with(&[("alice", b"K1"), ("bob", b"K2"), ("charlie", b"K3")]); verify_all(&log, &[("alice", b"K1"), ("bob", b"K2"), ("charlie", b"K3")]); } #[test] fn power_of_two_leaves_verify() { let entries: Vec<(String, Vec)> = (0u8..8) .map(|i| (format!("user{i}"), vec![i; 32])) .collect(); let refs: Vec<(&str, &[u8])> = entries.iter().map(|(u, k)| (u.as_str(), k.as_slice())).collect(); let log = log_with(&refs); verify_all(&log, &refs); } #[test] fn seven_leaves_all_verify() { let entries: Vec<(String, Vec)> = (0u8..7) .map(|i| (format!("u{i}"), vec![i; 32])) .collect(); let refs: Vec<(&str, &[u8])> = entries.iter().map(|(u, k)| (u.as_str(), k.as_slice())).collect(); let log = log_with(&refs); verify_all(&log, &refs); } #[test] fn wrong_identity_key_fails() { let log = log_with(&[("alice", b"REAL_KEY")]); let proof = log.inclusion_proof(0).unwrap(); assert!(matches!( verify_inclusion(&proof, "alice", b"WRONG_KEY"), Err(KtError::RootMismatch) )); } #[test] fn tampered_sibling_fails() { let log = log_with(&[("alice", b"K1"), ("bob", b"K2"), ("charlie", b"K3")]); let mut proof = log.inclusion_proof(0).unwrap(); if !proof.path.is_empty() { proof.path[0].hash[0] ^= 0xff; } assert!(matches!( verify_inclusion(&proof, "alice", b"K1"), Err(KtError::RootMismatch) )); } #[test] fn proof_serialise_roundtrip() { let log = log_with(&[("alice", b"K1"), ("bob", b"K2")]); let proof = log.inclusion_proof(0).unwrap(); let bytes = proof.to_bytes().unwrap(); let proof2 = InclusionProof::from_bytes(&bytes).unwrap(); verify_inclusion(&proof2, "alice", b"K1").unwrap(); } }