#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![allow(
clippy::significant_drop_tightening,
clippy::too_long_first_doc_paragraph
)]
use std::sync::Mutex;
use commonware_cryptography::{Hasher as _, Sha256, sha256::Digest as Sha256Digest};
use commonware_storage::merkle::{
Bagging::ForwardFold,
Location, Proof,
mmr::{StandardHasher as Standard, mem::Mmr},
};
use polyc_crypto::signing_role::JournalAttestationSigner;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum MmrError {
#[error("position {position} is not a known leaf (size {size})")]
UnknownPosition {
position: u64,
size: u64,
},
#[error("proof: {0}")]
Proof(String),
#[error("mmr lock poisoned")]
Poisoned,
}
#[must_use]
pub fn leaf_digest(position: u64, kind: &str, payload: &[u8]) -> Sha256Digest {
let mut hasher = Sha256::new();
hasher.update(&position.to_be_bytes());
hasher.update(&(kind.len() as u64).to_be_bytes());
hasher.update(kind.as_bytes());
hasher.update(&(payload.len() as u64).to_be_bytes());
hasher.update(payload);
hasher.finalize()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedRoot {
pub issuer: String,
pub key_id: String,
pub root_hex: String,
pub leaf_count: u64,
pub signature_hex: String,
pub signer_pk_hex: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InclusionProof {
pub position: u64,
pub leaf_count: u64,
pub inactive_peaks: u64,
pub digests_hex: Vec<String>,
}
const ROOT_SIGNING_NAMESPACE: &[u8] = b"polychrome.mmr.signed_root.v1";
pub struct VerifiableLog {
inner: Mutex<Inner>,
}
struct Inner {
mmr: Mmr<Sha256Digest>,
hasher: Standard<Sha256>,
leaf_count: u64,
}
impl Default for VerifiableLog {
fn default() -> Self {
Self::new()
}
}
impl VerifiableLog {
#[must_use]
pub const fn new() -> Self {
Self {
inner: Mutex::new(Inner {
mmr: Mmr::new(),
hasher: Standard::new(ForwardFold),
leaf_count: 0,
}),
}
}
pub fn append(&self, kind: &str, payload: &[u8]) -> Result<u64, MmrError> {
let mut guard = self.inner.lock().map_err(|_| MmrError::Poisoned)?;
let position = guard.leaf_count;
let digest = leaf_digest(position, kind, payload);
let batch = guard
.mmr
.new_batch()
.add(&guard.hasher, &digest)
.merkleize(&guard.mmr, &guard.hasher);
guard
.mmr
.apply_batch(&batch)
.map_err(|e| MmrError::Proof(format!("apply_batch: {e:?}")))?;
guard.leaf_count += 1;
Ok(position)
}
pub fn rebuild<'a, I>(leaves: I) -> Result<Self, MmrError>
where
I: IntoIterator<Item = (&'a str, &'a [u8])>,
{
let log = Self::new();
for (kind, payload) in leaves {
log.append(kind, payload)?;
}
Ok(log)
}
pub fn leaf_count(&self) -> Result<u64, MmrError> {
Ok(self
.inner
.lock()
.map_err(|_| MmrError::Poisoned)?
.leaf_count)
}
pub fn root(&self) -> Result<Sha256Digest, MmrError> {
let guard = self.inner.lock().map_err(|_| MmrError::Poisoned)?;
guard
.mmr
.root(&guard.hasher, 0)
.map_err(|e| MmrError::Proof(format!("root: {e:?}")))
}
pub fn sign_root(&self, signer: &JournalAttestationSigner) -> Result<SignedRoot, MmrError> {
let (root, leaf_count) = {
let guard = self.inner.lock().map_err(|_| MmrError::Poisoned)?;
let root = guard
.mmr
.root(&guard.hasher, 0)
.map_err(|e| MmrError::Proof(format!("root: {e:?}")))?;
(root, guard.leaf_count)
};
let mut msg = Vec::with_capacity(8 + 32);
msg.extend_from_slice(&leaf_count.to_be_bytes());
msg.extend_from_slice(root.as_ref());
let mut to_sign = Vec::with_capacity(ROOT_SIGNING_NAMESPACE.len() + msg.len());
to_sign.extend_from_slice(ROOT_SIGNING_NAMESPACE);
to_sign.extend_from_slice(&msg);
let signature = signer.sign_journal_root(&to_sign);
let identity = signer.identity();
Ok(SignedRoot {
issuer: identity.issuer().to_owned(),
key_id: identity.key_id().to_owned(),
root_hex: hex::encode(root.as_ref()),
leaf_count,
signature_hex: hex::encode(signature),
signer_pk_hex: hex::encode(identity.public_key()),
})
}
pub fn inclusion_proof(&self, position: u64) -> Result<InclusionProof, MmrError> {
let guard = self.inner.lock().map_err(|_| MmrError::Poisoned)?;
if position >= guard.leaf_count {
return Err(MmrError::UnknownPosition {
position,
size: guard.leaf_count,
});
}
let proof = guard
.mmr
.proof(&guard.hasher, Location::new(position), 0)
.map_err(|e| MmrError::Proof(format!("proof: {e:?}")))?;
let inactive_peaks = proof.inactive_peaks as u64;
Ok(InclusionProof {
position,
leaf_count: guard.leaf_count,
inactive_peaks,
digests_hex: proof
.digests
.iter()
.map(|d| hex::encode(d.as_ref()))
.collect(),
})
}
}
pub fn verify(
root: &SignedRoot,
proof: &InclusionProof,
kind: &str,
payload: &[u8],
) -> Result<bool, String> {
if proof.leaf_count != root.leaf_count {
return Ok(false);
}
let leaf = leaf_digest(proof.position, kind, payload);
let mut digests = Vec::with_capacity(proof.digests_hex.len());
for hex_d in &proof.digests_hex {
let bytes = hex::decode(hex_d).map_err(|e| format!("digest hex: {e}"))?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| "digest length != 32".to_owned())?;
digests.push(Sha256Digest::from(arr));
}
let root_bytes = hex::decode(&root.root_hex).map_err(|e| format!("root hex: {e}"))?;
let arr: [u8; 32] = root_bytes
.try_into()
.map_err(|_| "root length != 32".to_owned())?;
let root_digest = Sha256Digest::from(arr);
let inactive_peaks = usize::try_from(proof.inactive_peaks).map_err(|_| {
format!(
"inactive_peaks {} exceeds usize on this target",
proof.inactive_peaks
)
})?;
let merkle_proof = Proof::<commonware_storage::merkle::mmr::Family, Sha256Digest> {
leaves: Location::new(proof.leaf_count),
inactive_peaks,
digests,
};
let hasher: Standard<Sha256> = Standard::new(ForwardFold);
Ok(merkle_proof.verify_element_inclusion(
&hasher,
&leaf,
Location::new(proof.position),
&root_digest,
))
}
pub fn verify_root_signature(root: &SignedRoot, expected_pk_hex: &str) -> Result<bool, String> {
if !root.signer_pk_hex.eq_ignore_ascii_case(expected_pk_hex) {
return Ok(false);
}
let pk_bytes = hex::decode(&root.signer_pk_hex).map_err(|e| format!("pk hex: {e}"))?;
let identity = polyc_crypto::signing_role::SigningKeyIdentity::checked::<
polyc_crypto::signing_role::JournalAttestationRole,
>(root.issuer.clone(), root.key_id.clone(), pk_bytes)
.map_err(|error| format!("journal signing identity: {error}"))?;
let trust = polyc_crypto::signing_role::RoleTrustSet::<
polyc_crypto::signing_role::JournalAttestationRole,
>::checked(vec![identity])
.map_err(|error| format!("journal signing trust: {error}"))?;
let sig_bytes = hex::decode(&root.signature_hex).map_err(|e| format!("sig hex: {e}"))?;
let root_bytes = hex::decode(&root.root_hex).map_err(|e| format!("root hex: {e}"))?;
if root_bytes.len() != 32 {
return Err("root length != 32".to_owned());
}
let mut msg = Vec::with_capacity(ROOT_SIGNING_NAMESPACE.len() + 8 + 32);
msg.extend_from_slice(ROOT_SIGNING_NAMESPACE);
msg.extend_from_slice(&root.leaf_count.to_be_bytes());
msg.extend_from_slice(&root_bytes);
Ok(trust.verify_journal_root(&root.key_id, &msg, &sig_bytes))
}
pub fn verify_root_signature_with_trust(
root: &SignedRoot,
trust: &polyc_crypto::signing_role::RoleTrustSet<
polyc_crypto::signing_role::JournalAttestationRole,
>,
) -> Result<bool, String> {
use polyc_crypto::signing_role::SigningRole as _;
if root.issuer != polyc_crypto::signing_role::JournalAttestationRole::ISSUER {
return Ok(false);
}
let pk_bytes = hex::decode(&root.signer_pk_hex).map_err(|e| format!("pk hex: {e}"))?;
let identity = polyc_crypto::signing_role::SigningKeyIdentity::checked::<
polyc_crypto::signing_role::JournalAttestationRole,
>(root.issuer.clone(), root.key_id.clone(), pk_bytes)
.map_err(|error| format!("journal signing identity: {error}"))?;
let Some(trusted) = trust
.keys()
.iter()
.find(|candidate| candidate.key_id() == root.key_id)
else {
return Ok(false);
};
if trusted != &identity {
return Ok(false);
}
let sig_bytes = hex::decode(&root.signature_hex).map_err(|e| format!("sig hex: {e}"))?;
let root_bytes = hex::decode(&root.root_hex).map_err(|e| format!("root hex: {e}"))?;
if root_bytes.len() != 32 {
return Err("root length != 32".to_owned());
}
let mut message = Vec::with_capacity(ROOT_SIGNING_NAMESPACE.len() + 8 + 32);
message.extend_from_slice(ROOT_SIGNING_NAMESPACE);
message.extend_from_slice(&root.leaf_count.to_be_bytes());
message.extend_from_slice(&root_bytes);
Ok(trust.verify_journal_root(&root.key_id, &message, &sig_bytes))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn signer() -> JournalAttestationSigner {
JournalAttestationSigner::from_seed(42)
}
#[test]
fn leaf_digest_is_deterministic() {
assert_eq!(leaf_digest(0, "k", b"v"), leaf_digest(0, "k", b"v"));
}
#[test]
fn leaf_digest_distinguishes_position() {
assert_ne!(leaf_digest(0, "k", b"v"), leaf_digest(1, "k", b"v"));
}
#[test]
fn leaf_digest_distinguishes_kind() {
assert_ne!(leaf_digest(0, "k1", b"v"), leaf_digest(0, "k2", b"v"));
}
#[test]
fn leaf_digest_resists_concat_collision() {
assert_ne!(leaf_digest(0, "ab", b"c"), leaf_digest(0, "a", b"bc"));
}
#[test]
fn leaf_digest_v1_preimage_is_frozen() {
assert_eq!(
hex::encode(leaf_digest(7, "turn_committed", b"polychrome").as_ref()),
"945f08559c55be3c952efc86963fbb374fc8fd3c8d94301dbdd1da83014d64c3",
"the v1 leaf preimage changed; widening it requires a formal amendment, not an in-place edit"
);
}
#[test]
fn appending_assigns_monotonic_positions() {
let log = VerifiableLog::new();
assert_eq!(log.append("k", b"v0").unwrap(), 0);
assert_eq!(log.append("k", b"v1").unwrap(), 1);
assert_eq!(log.append("k", b"v2").unwrap(), 2);
assert_eq!(log.leaf_count().unwrap(), 3);
}
#[test]
fn round_trip_single_event() {
let log = VerifiableLog::new();
let _pos = log.append("user_msg", b"hello").unwrap();
let proof = log.inclusion_proof(0).unwrap();
let signed = log.sign_root(&signer()).unwrap();
assert!(verify(&signed, &proof, "user_msg", b"hello").unwrap());
}
#[test]
fn round_trip_many_events() {
let log = VerifiableLog::new();
let events: Vec<(String, Vec<u8>)> = (0..32)
.map(|i| (format!("k{i}"), format!("payload-{i}").into_bytes()))
.collect();
for (k, v) in &events {
log.append(k, v).unwrap();
}
let signed = log.sign_root(&signer()).unwrap();
for (i, (k, v)) in events.iter().enumerate() {
let proof = log.inclusion_proof(i as u64).unwrap();
assert!(
verify(&signed, &proof, k, v).unwrap(),
"event {i} ({k}) should verify"
);
}
}
#[test]
fn tampered_payload_does_not_verify() {
let log = VerifiableLog::new();
log.append("k", b"original").unwrap();
let proof = log.inclusion_proof(0).unwrap();
let signed = log.sign_root(&signer()).unwrap();
assert!(!verify(&signed, &proof, "k", b"tampered").unwrap());
}
#[test]
fn tampered_kind_does_not_verify() {
let log = VerifiableLog::new();
log.append("k1", b"v").unwrap();
let proof = log.inclusion_proof(0).unwrap();
let signed = log.sign_root(&signer()).unwrap();
assert!(!verify(&signed, &proof, "k2", b"v").unwrap());
}
#[test]
fn proof_for_out_of_range_position_errors() {
let log = VerifiableLog::new();
log.append("k", b"v").unwrap();
let err = log.inclusion_proof(1).unwrap_err();
match err {
MmrError::UnknownPosition { position, size } => {
assert_eq!(position, 1);
assert_eq!(size, 1);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn signed_root_signature_verifies_under_correct_pk() {
let s = signer();
let log = VerifiableLog::new();
log.append("k", b"v").unwrap();
let signed = log.sign_root(&s).unwrap();
let pk_hex = hex::encode(s.public_key_bytes());
assert!(verify_root_signature(&signed, &pk_hex).unwrap());
}
#[test]
fn signed_root_signature_rejects_wrong_pk() {
let log = VerifiableLog::new();
log.append("k", b"v").unwrap();
let signed = log.sign_root(&signer()).unwrap();
let other = JournalAttestationSigner::from_seed(999);
let wrong_pk = hex::encode(other.public_key_bytes());
assert!(!verify_root_signature(&signed, &wrong_pk).unwrap());
}
#[test]
fn mmr_rebuild_reproduces_original_root() {
let original = VerifiableLog::new();
let events: Vec<(String, Vec<u8>)> = (0..10)
.map(|i| (format!("k{i}"), format!("payload-{i}").into_bytes()))
.collect();
for (k, v) in &events {
original.append(k, v).unwrap();
}
let original_root = original.root().unwrap();
let original_count = original.leaf_count().unwrap();
let rebuilt =
VerifiableLog::rebuild(events.iter().map(|(k, v)| (k.as_str(), v.as_slice()))).unwrap();
assert_eq!(rebuilt.root().unwrap(), original_root);
assert_eq!(rebuilt.leaf_count().unwrap(), original_count);
}
#[test]
fn mmr_rebuild_of_empty_is_empty() {
let rebuilt = VerifiableLog::rebuild(std::iter::empty()).unwrap();
assert_eq!(rebuilt.leaf_count().unwrap(), 0);
}
#[test]
fn mmr_rebuild_then_append_continues_the_same_sequence() {
let events: Vec<(String, Vec<u8>)> = vec![
("a".to_owned(), b"1".to_vec()),
("b".to_owned(), b"2".to_vec()),
];
let rebuilt =
VerifiableLog::rebuild(events.iter().map(|(k, v)| (k.as_str(), v.as_slice()))).unwrap();
let pos = rebuilt.append("c", b"3").unwrap();
assert_eq!(pos, 2, "next position continues from the rebuilt count");
}
#[test]
fn verify_consults_inactive_peaks_field() {
let log = VerifiableLog::new();
log.append("user_msg", b"hello").unwrap();
let proof = log.inclusion_proof(0).unwrap();
let signed = log.sign_root(&signer()).unwrap();
assert!(verify(&signed, &proof, "user_msg", b"hello").unwrap());
let mut tampered = proof.clone();
tampered.inactive_peaks = 1;
assert!(!verify(&signed, &tampered, "user_msg", b"hello").unwrap());
}
}