use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::Record;
use crate::wire::CommittedRecord;
const LEAF_PREFIX: u8 = 0x00;
const NODE_PREFIX: u8 = 0x01;
pub type Hash = [u8; 32];
#[derive(Debug, thiserror::Error)]
pub enum MerkleError {
#[error("record `{key}` could not be canonicalised for commitment: {source}")]
Canonicalise {
key: String,
#[source]
source: serde_json::Error,
},
#[error("`{value}` is not a usable data commitment: {why}")]
NotACommitment { value: String, why: &'static str },
}
pub fn leaf_hash(record: &CommittedRecord) -> Result<Hash, MerkleError> {
let canonical =
serde_json_canonicalizer::to_vec(record).map_err(|source| MerkleError::Canonicalise {
key: record.key.clone(),
source,
})?;
let mut hasher = Sha256::new();
hasher.update([LEAF_PREFIX]);
hasher.update(&canonical);
Ok(hasher.finalize().into())
}
fn node_hash(left: &Hash, right: &Hash) -> Hash {
let mut hasher = Sha256::new();
hasher.update([NODE_PREFIX]);
hasher.update(left);
hasher.update(right);
hasher.finalize().into()
}
#[must_use]
pub fn empty_root() -> Hash {
Sha256::new().finalize().into()
}
#[must_use]
pub fn root_of(leaves: &[Hash]) -> Hash {
if leaves.is_empty() {
return empty_root();
}
let mut level: Vec<Hash> = leaves.to_vec();
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let (pairs, remainder) = level.as_chunks::<2>();
for [left, right] in pairs {
next.push(node_hash(left, right));
}
if let [odd] = remainder {
next.push(*odd);
}
level = next;
}
level[0]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TreeHead {
pub root: Hash,
pub record_count: u64,
pub head_version: u64,
}
pub fn tree_head(records: &mut [Record]) -> Result<TreeHead, MerkleError> {
records.sort_by(|a, b| a.key.cmp(&b.key));
let leaves = records
.iter()
.map(|r| leaf_hash(&r.committed()))
.collect::<Result<Vec<_>, _>>()?;
Ok(TreeHead {
root: root_of(&leaves),
record_count: leaves.len() as u64,
head_version: records.iter().map(|r| r.version).max().unwrap_or(0),
})
}
pub fn commit_records(records: &mut [Record]) -> Result<Hash, MerkleError> {
tree_head(records).map(|head| head.root)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProofStep {
#[serde(with = "multibase_hash")]
pub sibling: Hash,
pub sibling_is_left: bool,
}
pub type InclusionProof = Vec<ProofStep>;
#[must_use]
pub fn inclusion_proof(leaves: &[Hash], index: usize) -> Option<InclusionProof> {
if index >= leaves.len() {
return None;
}
let mut proof = Vec::new();
let mut level: Vec<Hash> = leaves.to_vec();
let mut idx = index;
while level.len() > 1 {
let has_sibling = !(idx == level.len() - 1 && level.len() % 2 == 1);
if has_sibling {
let sibling_is_left = idx % 2 == 1;
let sibling_idx = if sibling_is_left { idx - 1 } else { idx + 1 };
proof.push(ProofStep {
sibling: level[sibling_idx],
sibling_is_left,
});
}
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let (pairs, remainder) = level.as_chunks::<2>();
for [left, right] in pairs {
next.push(node_hash(left, right));
}
if let [odd] = remainder {
next.push(*odd);
}
level = next;
idx /= 2;
}
Some(proof)
}
#[must_use]
pub fn verify_inclusion(root: &Hash, leaf: &Hash, proof: &InclusionProof) -> bool {
let mut current = *leaf;
for step in proof {
current = if step.sibling_is_left {
node_hash(&step.sibling, ¤t)
} else {
node_hash(¤t, &step.sibling)
};
}
¤t == root
}
mod multibase_hash {
use super::{Hash, from_multibase, to_multibase};
use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
pub fn serialize<S: Serializer>(value: &Hash, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&to_multibase(value))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Hash, D::Error> {
let text = String::deserialize(d)?;
from_multibase(&text).map_err(D::Error::custom)
}
}
#[must_use]
pub fn to_multibase(hash: &Hash) -> String {
let mut mh = Vec::with_capacity(34);
mh.extend_from_slice(&[0x12, 0x20]);
mh.extend_from_slice(hash);
multibase::encode(multibase::Base::Base58Btc, mh)
}
pub fn from_multibase(value: &str) -> Result<Hash, MerkleError> {
let (_, bytes) = multibase::decode(value).map_err(|_| MerkleError::NotACommitment {
value: value.to_string(),
why: "not valid multibase",
})?;
let Some((&[0x12, 0x20], digest)) = bytes.split_at_checked(2) else {
return Err(MerkleError::NotACommitment {
value: value.to_string(),
why: "not a sha2-256 multihash (expected the 0x12 0x20 prefix)",
});
};
digest.try_into().map_err(|_| MerkleError::NotACommitment {
value: value.to_string(),
why: "a sha2-256 multihash carries exactly 32 bytes",
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RecordStatus;
fn record(key: &str, version: u64) -> Record {
Record {
key: key.to_string(),
version,
epoch: Some(3),
status: RecordStatus::Active,
pinned: false,
sealed: Some("Zm9v".into()),
nonce: Some("YmFy".into()),
cleartext: None,
author: None,
updated_at: 1_700_000_000,
}
}
#[test]
fn an_empty_room_commits_to_a_distinguished_value() {
assert_eq!(root_of(&[]), empty_root());
assert_ne!(empty_root(), [0u8; 32]);
}
#[test]
fn omitting_a_record_changes_the_commitment() {
let mut all = vec![record("a", 1), record("b", 2), record("c", 3)];
let mut fewer = vec![record("a", 1), record("c", 3)];
let full = commit_records(&mut all).expect("commits");
let short = commit_records(&mut fewer).expect("commits");
assert_ne!(
full, short,
"a host could drop a record without moving the root"
);
}
#[test]
fn changing_any_field_changes_the_commitment() {
let base = record("a", 1);
let mut cases = vec![
(
"version",
Record {
version: 2,
..base.clone()
},
),
(
"status",
Record {
status: RecordStatus::Retracted,
..base.clone()
},
),
(
"pinned",
Record {
pinned: true,
..base.clone()
},
),
(
"epoch",
Record {
epoch: Some(4),
..base.clone()
},
),
(
"author",
Record {
author: Some("did:example:someone".into()),
..base.clone()
},
),
(
"ciphertext",
Record {
sealed: Some("YmF6".into()),
..base.clone()
},
),
(
"updated_at",
Record {
updated_at: 1_700_000_001,
..base.clone()
},
),
];
let original = leaf_hash(&base.committed()).expect("hashes");
for (what, altered) in &mut cases {
assert_ne!(
leaf_hash(&altered.committed()).expect("hashes"),
original,
"a host could change `{what}` without moving the leaf"
);
}
}
#[test]
fn the_commitment_does_not_depend_on_input_order() {
let mut forwards = vec![record("a", 1), record("b", 2), record("c", 3)];
let mut backwards = vec![record("c", 3), record("b", 2), record("a", 1)];
assert_eq!(
commit_records(&mut forwards).expect("commits"),
commit_records(&mut backwards).expect("commits"),
);
}
#[test]
fn a_leaf_and_a_node_over_the_same_bytes_differ() {
let a = leaf_hash(&record("a", 1).committed()).expect("hashes");
let b = leaf_hash(&record("b", 2).committed()).expect("hashes");
let parent = node_hash(&a, &b);
let mut undomained = Sha256::new();
undomained.update(a);
undomained.update(b);
let raw: Hash = undomained.finalize().into();
assert_ne!(parent, raw, "internal nodes are not domain-separated");
}
#[test]
fn every_leaf_proves_against_the_root() {
for count in 1..=9usize {
let mut records: Vec<Record> = (0..count)
.map(|i| record(&format!("k{i:02}"), i as u64))
.collect();
let root = commit_records(&mut records).expect("commits");
let leaves: Vec<Hash> = records
.iter()
.map(|r| leaf_hash(&r.committed()).expect("hashes"))
.collect();
for (i, leaf) in leaves.iter().enumerate() {
let proof = inclusion_proof(&leaves, i)
.unwrap_or_else(|| panic!("proof for {i} of {count}"));
assert!(
verify_inclusion(&root, leaf, &proof),
"leaf {i} of {count} did not prove"
);
}
}
}
#[test]
fn a_proof_for_a_record_not_in_the_tree_fails() {
let mut records = vec![record("a", 1), record("b", 2), record("c", 3)];
let root = commit_records(&mut records).expect("commits");
let leaves: Vec<Hash> = records
.iter()
.map(|r| leaf_hash(&r.committed()).expect("hashes"))
.collect();
let proof = inclusion_proof(&leaves, 0).expect("proof");
let outsider = leaf_hash(&record("zzz", 99).committed()).expect("hashes");
assert!(
!verify_inclusion(&root, &outsider, &proof),
"a record the room does not hold proved against its root"
);
}
#[test]
fn a_tampered_proof_step_fails() {
let mut records = vec![
record("a", 1),
record("b", 2),
record("c", 3),
record("d", 4),
];
let root = commit_records(&mut records).expect("commits");
let leaves: Vec<Hash> = records
.iter()
.map(|r| leaf_hash(&r.committed()).expect("hashes"))
.collect();
let mut proof = inclusion_proof(&leaves, 1).expect("proof");
proof[0].sibling[0] ^= 0xff;
assert!(!verify_inclusion(&root, &leaves[1], &proof));
let mut flipped = inclusion_proof(&leaves, 1).expect("proof");
flipped[0].sibling_is_left = !flipped[0].sibling_is_left;
assert!(
!verify_inclusion(&root, &leaves[1], &flipped),
"the side a sibling sits on is part of the proof"
);
}
#[test]
fn an_index_outside_the_tree_has_no_proof() {
let leaves = [leaf_hash(&record("a", 1).committed()).expect("hashes")];
assert!(inclusion_proof(&leaves, 1).is_none());
assert!(inclusion_proof(&[], 0).is_none());
}
#[test]
fn a_commitment_encodes_as_a_sha2_256_multihash() {
let root = commit_records(&mut [record("a", 1)]).expect("commits");
let encoded = to_multibase(&root);
assert!(
encoded.starts_with('z'),
"base58btc is RECOMMENDED: {encoded}"
);
let (_, bytes) = multibase::decode(&encoded).expect("valid multibase");
assert_eq!(&bytes[..2], &[0x12, 0x20], "sha2-256 multihash prefix");
assert_eq!(bytes.len(), 34);
assert_eq!(from_multibase(&encoded).expect("round-trips"), root);
}
#[test]
fn something_that_is_not_a_commitment_is_refused() {
assert!(from_multibase(&to_hex_for_test(&empty_root())).is_err());
let mut other = vec![0x13, 0x20];
other.extend_from_slice(&[7u8; 32]);
let encoded = multibase::encode(multibase::Base::Base58Btc, other);
assert!(
from_multibase(&encoded).is_err(),
"accepted a non-sha2-256 digest"
);
let short = multibase::encode(multibase::Base::Base58Btc, vec![0x12, 0x20, 1, 2, 3]);
assert!(from_multibase(&short).is_err());
assert!(from_multibase("not multibase at all").is_err());
}
fn to_hex_for_test(hash: &Hash) -> String {
hash.iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn a_step_round_trips_through_json() {
let leaves: Vec<Hash> = ["a", "b", "c"]
.iter()
.map(|k| leaf_hash(&record(k, 1).committed()).expect("hashes"))
.collect();
let proof = inclusion_proof(&leaves, 0).expect("proof");
let json = serde_json::to_string(&proof).expect("serialises");
let back: InclusionProof = serde_json::from_str(&json).expect("deserialises");
assert_eq!(proof, back);
}
}