chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
16
crates/quicprochat-kt/Cargo.toml
Normal file
16
crates/quicprochat-kt/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "quicprochat-kt"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Key Transparency: append-only SHA-256 Merkle log for (username, identity_key) bindings."
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
16
crates/quicprochat-kt/src/error.rs
Normal file
16
crates/quicprochat-kt/src/error.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum KtError {
|
||||
#[error("leaf index {index} is out of range for tree size {tree_size}")]
|
||||
IndexOutOfRange { index: u64, tree_size: u64 },
|
||||
|
||||
#[error("inclusion proof verification failed: root mismatch")]
|
||||
RootMismatch,
|
||||
|
||||
#[error("serialisation error: {0}")]
|
||||
Serialisation(String),
|
||||
|
||||
#[error("identity key is already revoked")]
|
||||
AlreadyRevoked,
|
||||
}
|
||||
64
crates/quicprochat-kt/src/lib.rs
Normal file
64
crates/quicprochat-kt/src/lib.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Key Transparency: append-only SHA-256 Merkle log for (username, identity_key) bindings.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! A lightweight subset of RFC 9162 (Certificate Transparency v2) adapted for identity keys:
|
||||
//!
|
||||
//! - Leaf nodes hash as: `SHA-256(0x00 || SHA-256(username || 0x00 || identity_key))`
|
||||
//! - Internal nodes hash as: `SHA-256(0x01 || left_hash || right_hash)`
|
||||
//!
|
||||
//! The 0x00/0x01 domain-separation prefixes prevent second-preimage attacks on
|
||||
//! the tree structure (RFC 6962 §2.1).
|
||||
//!
|
||||
//! ## Inclusion proof
|
||||
//!
|
||||
//! An inclusion proof for leaf at index `i` in a tree of `n` leaves is the list of
|
||||
//! sibling hashes from leaf to root. The verifier recomputes the root from the leaf
|
||||
//! hash + siblings and compares it to the known root.
|
||||
//!
|
||||
//! ## Wire format
|
||||
//!
|
||||
//! Inclusion proofs are serialised as `bincode(InclusionProof)` for transport over
|
||||
//! the Cap'n Proto `inclusionProof :Data` field.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
mod error;
|
||||
mod proof;
|
||||
pub mod revocation;
|
||||
mod tree;
|
||||
|
||||
pub use error::KtError;
|
||||
pub use proof::{verify_inclusion, InclusionProof};
|
||||
pub use revocation::{RevocationEntry, RevocationLog, RevocationReason};
|
||||
pub use tree::MerkleLog;
|
||||
|
||||
/// Domain-separation prefix for leaf nodes (RFC 6962 §2.1).
|
||||
const LEAF_PREFIX: u8 = 0x00;
|
||||
/// Domain-separation prefix for internal nodes.
|
||||
const INTERNAL_PREFIX: u8 = 0x01;
|
||||
|
||||
/// SHA-256 of a leaf entry: `H(0x00 || H(username || 0x00 || identity_key))`.
|
||||
pub fn leaf_hash(username: &str, identity_key: &[u8]) -> [u8; 32] {
|
||||
// Inner hash commits to both fields with a 0x00 separator.
|
||||
let mut inner = Sha256::new();
|
||||
inner.update(username.as_bytes());
|
||||
inner.update([0x00]);
|
||||
inner.update(identity_key);
|
||||
let inner_digest: [u8; 32] = inner.finalize().into();
|
||||
|
||||
// Outer hash adds the leaf domain-separation prefix.
|
||||
let mut outer = Sha256::new();
|
||||
outer.update([LEAF_PREFIX]);
|
||||
outer.update(inner_digest);
|
||||
outer.finalize().into()
|
||||
}
|
||||
|
||||
/// SHA-256 of an internal node: `H(0x01 || left || right)`.
|
||||
pub(crate) fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
|
||||
let mut h = Sha256::new();
|
||||
h.update([INTERNAL_PREFIX]);
|
||||
h.update(left);
|
||||
h.update(right);
|
||||
h.finalize().into()
|
||||
}
|
||||
189
crates/quicprochat-kt/src/proof.rs
Normal file
189
crates/quicprochat-kt/src/proof.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! 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<PathStep>,
|
||||
/// 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<Vec<u8>, KtError> {
|
||||
bincode::serialize(self)
|
||||
.map_err(|e| KtError::Serialisation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deserialise from bytes (bincode).
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KtError> {
|
||||
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<u8>)> = (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<u8>)> = (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();
|
||||
}
|
||||
}
|
||||
278
crates/quicprochat-kt/src/revocation.rs
Normal file
278
crates/quicprochat-kt/src/revocation.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! Key revocation tracking for the Key Transparency log.
|
||||
//!
|
||||
//! Revocation entries are appended to the same Merkle log as regular key
|
||||
//! bindings, using a distinct leaf hash prefix to differentiate them. A
|
||||
//! separate in-memory index tracks which identity keys have been revoked,
|
||||
//! enabling O(1) revocation checks.
|
||||
//!
|
||||
//! ## Revocation leaf hash
|
||||
//!
|
||||
//! ```text
|
||||
//! SHA-256(0x02 || SHA-256(identity_key || 0x00 || reason_bytes))
|
||||
//! ```
|
||||
//!
|
||||
//! The 0x02 prefix domain-separates revocation leaves from binding leaves (0x00)
|
||||
//! and internal nodes (0x01).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{KtError, MerkleLog};
|
||||
|
||||
/// Domain-separation prefix for revocation leaves.
|
||||
const REVOCATION_PREFIX: u8 = 0x02;
|
||||
|
||||
/// Reason for key revocation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum RevocationReason {
|
||||
/// Key material was compromised.
|
||||
Compromised,
|
||||
/// Key was superseded by a new key.
|
||||
Superseded,
|
||||
/// User-initiated revocation (e.g. account deletion).
|
||||
UserRevoked,
|
||||
}
|
||||
|
||||
impl RevocationReason {
|
||||
fn as_bytes(&self) -> &[u8] {
|
||||
match self {
|
||||
RevocationReason::Compromised => b"compromised",
|
||||
RevocationReason::Superseded => b"superseded",
|
||||
RevocationReason::UserRevoked => b"user_revoked",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from a string tag.
|
||||
pub fn from_tag(tag: &str) -> Option<Self> {
|
||||
match tag {
|
||||
"compromised" => Some(RevocationReason::Compromised),
|
||||
"superseded" => Some(RevocationReason::Superseded),
|
||||
"user_revoked" => Some(RevocationReason::UserRevoked),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the string tag for serialization.
|
||||
pub fn as_tag(&self) -> &str {
|
||||
match self {
|
||||
RevocationReason::Compromised => "compromised",
|
||||
RevocationReason::Superseded => "superseded",
|
||||
RevocationReason::UserRevoked => "user_revoked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A record of a key revocation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RevocationEntry {
|
||||
/// The 32-byte identity key that was revoked.
|
||||
pub identity_key: Vec<u8>,
|
||||
/// Reason for revocation.
|
||||
pub reason: RevocationReason,
|
||||
/// Timestamp (ms since UNIX epoch) when the revocation was recorded.
|
||||
pub timestamp_ms: u64,
|
||||
/// Index of the revocation leaf in the Merkle log.
|
||||
pub leaf_index: u64,
|
||||
}
|
||||
|
||||
/// Tracks revoked identity keys alongside the Merkle log.
|
||||
///
|
||||
/// Revocation entries are appended to the Merkle log (with a distinct prefix)
|
||||
/// and indexed in-memory by identity key for O(1) lookup.
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
pub struct RevocationLog {
|
||||
/// Revocation entries in append order.
|
||||
entries: Vec<RevocationEntry>,
|
||||
/// Index from identity_key bytes to entry index for O(1) lookup.
|
||||
#[serde(skip)]
|
||||
index: HashMap<Vec<u8>, usize>,
|
||||
}
|
||||
|
||||
impl RevocationLog {
|
||||
/// Create an empty revocation log.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Rebuild the in-memory index from the entries list.
|
||||
///
|
||||
/// Must be called after deserialization.
|
||||
pub fn rebuild_index(&mut self) {
|
||||
self.index.clear();
|
||||
for (i, entry) in self.entries.iter().enumerate() {
|
||||
self.index.insert(entry.identity_key.clone(), i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a key revocation, appending a revocation leaf to the Merkle log.
|
||||
///
|
||||
/// Returns the leaf index in the Merkle log, or an error if the key is
|
||||
/// already revoked.
|
||||
pub fn revoke(
|
||||
&mut self,
|
||||
kt_log: &mut MerkleLog,
|
||||
identity_key: &[u8],
|
||||
reason: RevocationReason,
|
||||
timestamp_ms: u64,
|
||||
) -> Result<u64, KtError> {
|
||||
if self.index.contains_key(identity_key) {
|
||||
return Err(KtError::AlreadyRevoked);
|
||||
}
|
||||
|
||||
// Compute revocation leaf hash and append to the Merkle log.
|
||||
let leaf = revocation_leaf_hash(identity_key, &reason);
|
||||
let leaf_index = kt_log.append_raw(leaf);
|
||||
|
||||
let entry = RevocationEntry {
|
||||
identity_key: identity_key.to_vec(),
|
||||
reason,
|
||||
timestamp_ms,
|
||||
leaf_index,
|
||||
};
|
||||
|
||||
let entry_idx = self.entries.len();
|
||||
self.entries.push(entry);
|
||||
self.index.insert(identity_key.to_vec(), entry_idx);
|
||||
|
||||
Ok(leaf_index)
|
||||
}
|
||||
|
||||
/// Check if an identity key has been revoked.
|
||||
pub fn is_revoked(&self, identity_key: &[u8]) -> bool {
|
||||
self.index.contains_key(identity_key)
|
||||
}
|
||||
|
||||
/// Get the revocation entry for an identity key, if revoked.
|
||||
pub fn get(&self, identity_key: &[u8]) -> Option<&RevocationEntry> {
|
||||
self.index
|
||||
.get(identity_key)
|
||||
.map(|&idx| &self.entries[idx])
|
||||
}
|
||||
|
||||
/// Return all revocation entries in append order.
|
||||
pub fn entries(&self) -> &[RevocationEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Number of revoked keys.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Return `true` if no keys have been revoked.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Serialise the revocation log to bytes (bincode).
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, KtError> {
|
||||
bincode::serialize(self).map_err(|e| KtError::Serialisation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deserialise from bytes and rebuild the in-memory index.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KtError> {
|
||||
let mut log: Self =
|
||||
bincode::deserialize(bytes).map_err(|e| KtError::Serialisation(e.to_string()))?;
|
||||
log.rebuild_index();
|
||||
Ok(log)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the leaf hash for a revocation entry.
|
||||
///
|
||||
/// `SHA-256(0x02 || SHA-256(identity_key || 0x00 || reason_bytes))`
|
||||
pub fn revocation_leaf_hash(identity_key: &[u8], reason: &RevocationReason) -> [u8; 32] {
|
||||
let mut inner = Sha256::new();
|
||||
inner.update(identity_key);
|
||||
inner.update([0x00]);
|
||||
inner.update(reason.as_bytes());
|
||||
let inner_digest: [u8; 32] = inner.finalize().into();
|
||||
|
||||
let mut outer = Sha256::new();
|
||||
outer.update([REVOCATION_PREFIX]);
|
||||
outer.update(inner_digest);
|
||||
outer.finalize().into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revoke_and_check() {
|
||||
let mut kt = MerkleLog::new();
|
||||
let mut revlog = RevocationLog::new();
|
||||
|
||||
// Append a normal binding first.
|
||||
kt.append("alice", &[1u8; 32]);
|
||||
|
||||
// Revoke alice's key.
|
||||
let leaf_idx = revlog
|
||||
.revoke(&mut kt, &[1u8; 32], RevocationReason::Compromised, 1000)
|
||||
.unwrap();
|
||||
assert_eq!(leaf_idx, 1); // second leaf in the log
|
||||
|
||||
assert!(revlog.is_revoked(&[1u8; 32]));
|
||||
assert!(!revlog.is_revoked(&[2u8; 32]));
|
||||
|
||||
let entry = revlog.get(&[1u8; 32]).unwrap();
|
||||
assert_eq!(entry.reason, RevocationReason::Compromised);
|
||||
assert_eq!(entry.timestamp_ms, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_revoke_fails() {
|
||||
let mut kt = MerkleLog::new();
|
||||
let mut revlog = RevocationLog::new();
|
||||
|
||||
revlog
|
||||
.revoke(&mut kt, &[1u8; 32], RevocationReason::Compromised, 1000)
|
||||
.unwrap();
|
||||
let result = revlog.revoke(&mut kt, &[1u8; 32], RevocationReason::Superseded, 2000);
|
||||
assert!(matches!(result, Err(KtError::AlreadyRevoked)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_leaf_is_distinct_from_binding_leaf() {
|
||||
let binding_hash = crate::leaf_hash("alice", &[1u8; 32]);
|
||||
let revocation_hash =
|
||||
revocation_leaf_hash(&[1u8; 32], &RevocationReason::Compromised);
|
||||
assert_ne!(binding_hash, revocation_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_roundtrip() {
|
||||
let mut kt = MerkleLog::new();
|
||||
let mut revlog = RevocationLog::new();
|
||||
|
||||
revlog
|
||||
.revoke(&mut kt, &[1u8; 32], RevocationReason::Compromised, 1000)
|
||||
.unwrap();
|
||||
revlog
|
||||
.revoke(&mut kt, &[2u8; 32], RevocationReason::Superseded, 2000)
|
||||
.unwrap();
|
||||
|
||||
let bytes = revlog.to_bytes().unwrap();
|
||||
let restored = RevocationLog::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert_eq!(restored.len(), 2);
|
||||
assert!(restored.is_revoked(&[1u8; 32]));
|
||||
assert!(restored.is_revoked(&[2u8; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_tag_roundtrip() {
|
||||
for reason in &[
|
||||
RevocationReason::Compromised,
|
||||
RevocationReason::Superseded,
|
||||
RevocationReason::UserRevoked,
|
||||
] {
|
||||
let tag = reason.as_tag();
|
||||
let parsed = RevocationReason::from_tag(tag).unwrap();
|
||||
assert_eq!(*reason, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
290
crates/quicprochat-kt/src/tree.rs
Normal file
290
crates/quicprochat-kt/src/tree.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! Append-only Merkle log backed by a flat `Vec` of all leaf hashes.
|
||||
//!
|
||||
//! The tree structure is virtual — roots and paths are computed on-demand from the
|
||||
//! leaf array. This keeps the storage footprint to `32 * n` bytes for `n` leaves.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{leaf_hash, node_hash, KtError};
|
||||
use crate::proof::{InclusionProof, PathStep};
|
||||
|
||||
/// An append-only Merkle log of `(username, identity_key)` leaf entries.
|
||||
///
|
||||
/// Internally stores only the 32-byte SHA-256 leaf hashes. Roots and inclusion
|
||||
/// proofs are recomputed from the flat list on demand.
|
||||
///
|
||||
/// Persistence: the caller serialises the whole struct with `bincode` and stores
|
||||
/// the bytes in the DB (`kt_log` table). The log is load-on-startup, append-on-write.
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct MerkleLog {
|
||||
/// All leaf hashes in append order.
|
||||
leaves: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl MerkleLog {
|
||||
/// Create an empty log.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of leaves in the log.
|
||||
pub fn len(&self) -> u64 {
|
||||
self.leaves.len() as u64
|
||||
}
|
||||
|
||||
/// Return `true` if the log has no leaves.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.leaves.is_empty()
|
||||
}
|
||||
|
||||
/// Append a `(username, identity_key)` binding and return the leaf's index.
|
||||
///
|
||||
/// The leaf hash is computed using the canonical formula:
|
||||
/// `SHA-256(0x00 || SHA-256(username || 0x00 || identity_key))`.
|
||||
pub fn append(&mut self, username: &str, identity_key: &[u8]) -> u64 {
|
||||
let h = leaf_hash(username, identity_key);
|
||||
let idx = self.leaves.len() as u64;
|
||||
self.leaves.push(h);
|
||||
idx
|
||||
}
|
||||
|
||||
/// Return the current Merkle root hash, or `None` if the log is empty.
|
||||
pub fn root(&self) -> Option<[u8; 32]> {
|
||||
if self.leaves.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(merkle_root(&self.leaves))
|
||||
}
|
||||
|
||||
/// Generate an inclusion proof for the leaf at `index`.
|
||||
///
|
||||
/// Returns `Err` if `index >= self.len()`.
|
||||
pub fn inclusion_proof(&self, index: u64) -> Result<InclusionProof, KtError> {
|
||||
let n = self.len();
|
||||
if index >= n {
|
||||
return Err(KtError::IndexOutOfRange { index, tree_size: n });
|
||||
}
|
||||
|
||||
let raw_path = compute_path(&self.leaves, index as usize, self.leaves.len());
|
||||
let path: Vec<PathStep> = raw_path
|
||||
.into_iter()
|
||||
.map(|(hash, sibling_is_left)| PathStep { hash, sibling_is_left })
|
||||
.collect();
|
||||
let root = merkle_root(&self.leaves);
|
||||
|
||||
Ok(InclusionProof {
|
||||
leaf_index: index,
|
||||
tree_size: n,
|
||||
leaf_hash: self.leaves[index as usize],
|
||||
path,
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the leaf index for a `(username, identity_key)` pair, if present.
|
||||
///
|
||||
/// O(n) scan — suitable for small logs. For large-scale deployments a
|
||||
/// username→index index would be maintained separately.
|
||||
pub fn find(&self, username: &str, identity_key: &[u8]) -> Option<u64> {
|
||||
let target = leaf_hash(username, identity_key);
|
||||
self.leaves
|
||||
.iter()
|
||||
.position(|h| h == &target)
|
||||
.map(|i| i as u64)
|
||||
}
|
||||
|
||||
/// Append a pre-computed leaf hash directly (used by revocation entries).
|
||||
///
|
||||
/// Returns the leaf index.
|
||||
pub fn append_raw(&mut self, hash: [u8; 32]) -> u64 {
|
||||
let idx = self.leaves.len() as u64;
|
||||
self.leaves.push(hash);
|
||||
idx
|
||||
}
|
||||
|
||||
/// Return log entries in the range `[start, end)` as `(index, leaf_hash)` pairs.
|
||||
///
|
||||
/// Used for KT audit — clients download the full log and verify inclusion proofs.
|
||||
/// Returns an empty vec if `start >= self.len()`.
|
||||
pub fn audit_log(&self, start: u64, end: u64) -> Vec<(u64, [u8; 32])> {
|
||||
let n = self.len();
|
||||
let start = start.min(n) as usize;
|
||||
let end = end.min(n) as usize;
|
||||
if start >= end {
|
||||
return Vec::new();
|
||||
}
|
||||
self.leaves[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &h)| ((start + i) as u64, h))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Serialise the log to bytes (bincode).
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, KtError> {
|
||||
bincode::serialize(self)
|
||||
.map_err(|e| KtError::Serialisation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deserialise a log from bytes (bincode).
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KtError> {
|
||||
bincode::deserialize(bytes)
|
||||
.map_err(|e| KtError::Serialisation(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the Merkle root over a non-empty slice of leaf hashes.
|
||||
///
|
||||
/// Uses RFC 9162 §2.1 balanced tree construction: when the number of leaves is
|
||||
/// odd, the rightmost leaf is promoted (not duplicated — that's vulnerable to
|
||||
/// second-preimage attacks). Specifically:
|
||||
///
|
||||
/// - `MTH({d[0]}) = H(0x00 || d[0])` (already computed as `leaf_hash`)
|
||||
/// - `MTH(D[n]) = H(0x01 || MTH(D[0..k]) || MTH(D[k..n]))` where `k` is the
|
||||
/// largest power of two strictly less than `n`.
|
||||
///
|
||||
/// This is a standard SHA-256 Merkle tree — the leaves are already hashed
|
||||
/// so the recursion just applies the internal-node formula.
|
||||
pub(crate) fn merkle_root(leaves: &[[u8; 32]]) -> [u8; 32] {
|
||||
match leaves.len() {
|
||||
0 => unreachable!("merkle_root called on empty slice"),
|
||||
1 => leaves[0],
|
||||
n => {
|
||||
let k = largest_power_of_two_less_than(n);
|
||||
let left = merkle_root(&leaves[..k]);
|
||||
let right = merkle_root(&leaves[k..]);
|
||||
node_hash(&left, &right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the path (list of `(sibling_hash, sibling_is_on_left)`) from
|
||||
/// `leaf_idx` to the root, in leaf-to-root order.
|
||||
///
|
||||
/// `sibling_is_on_left` is `true` when the sibling is the LEFT child of their
|
||||
/// common parent, i.e., the current node being proved is on the RIGHT.
|
||||
pub(crate) fn compute_path(
|
||||
leaves: &[[u8; 32]],
|
||||
leaf_idx: usize,
|
||||
n: usize,
|
||||
) -> Vec<([u8; 32], bool)> {
|
||||
let mut path = Vec::new();
|
||||
collect_path(&leaves[..n], leaf_idx, &mut path);
|
||||
path
|
||||
}
|
||||
|
||||
/// Recurse into the subtree `leaves` (already sub-sliced to the right window).
|
||||
fn collect_path(
|
||||
leaves: &[[u8; 32]],
|
||||
leaf_idx: usize,
|
||||
path: &mut Vec<([u8; 32], bool)>,
|
||||
) {
|
||||
let n = leaves.len();
|
||||
if n <= 1 {
|
||||
return;
|
||||
}
|
||||
let k = largest_power_of_two_less_than(n);
|
||||
if leaf_idx < k {
|
||||
// Leaf is in the left subtree; sibling is the right subtree.
|
||||
collect_path(&leaves[..k], leaf_idx, path);
|
||||
let right_root = merkle_root(&leaves[k..]);
|
||||
path.push((right_root, false)); // sibling is on the RIGHT
|
||||
} else {
|
||||
// Leaf is in the right subtree; sibling is the left subtree.
|
||||
collect_path(&leaves[k..], leaf_idx - k, path);
|
||||
let left_root = merkle_root(&leaves[..k]);
|
||||
path.push((left_root, true)); // sibling is on the LEFT
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest power of two strictly less than `n`.
|
||||
/// Panics if `n < 2`.
|
||||
fn largest_power_of_two_less_than(n: usize) -> usize {
|
||||
assert!(n >= 2, "n must be >= 2");
|
||||
let mut k = 1usize;
|
||||
while k * 2 < n {
|
||||
k *= 2;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_log_has_no_root() {
|
||||
let log = MerkleLog::new();
|
||||
assert_eq!(log.root(), None);
|
||||
assert_eq!(log.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_leaf_root_equals_leaf_hash() {
|
||||
let mut log = MerkleLog::new();
|
||||
log.append("alice", b"A" as &[u8]);
|
||||
let lh = leaf_hash("alice", b"A");
|
||||
assert_eq!(log.root(), Some(lh));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_returns_correct_index() {
|
||||
let mut log = MerkleLog::new();
|
||||
assert_eq!(log.append("a", b"k1"), 0);
|
||||
assert_eq!(log.append("b", b"k2"), 1);
|
||||
assert_eq!(log.append("c", b"k3"), 2);
|
||||
assert_eq!(log.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_changes_on_append() {
|
||||
let mut log = MerkleLog::new();
|
||||
log.append("alice", b"K1");
|
||||
let root1 = log.root();
|
||||
log.append("bob", b"K2");
|
||||
let root2 = log.root();
|
||||
assert_ne!(root1, root2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_returns_correct_index() {
|
||||
let mut log = MerkleLog::new();
|
||||
log.append("alice", b"K1");
|
||||
log.append("bob", b"K2");
|
||||
log.append("charlie", b"K3");
|
||||
assert_eq!(log.find("bob", b"K2"), Some(1));
|
||||
assert_eq!(log.find("missing", b""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inclusion_proof_out_of_range() {
|
||||
let mut log = MerkleLog::new();
|
||||
log.append("alice", b"K");
|
||||
assert!(matches!(
|
||||
log.inclusion_proof(1),
|
||||
Err(KtError::IndexOutOfRange { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_roundtrip() {
|
||||
let mut log = MerkleLog::new();
|
||||
log.append("alice", b"K1");
|
||||
log.append("bob", b"K2");
|
||||
let bytes = log.to_bytes().unwrap();
|
||||
let log2 = MerkleLog::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(log2.root(), log.root());
|
||||
assert_eq!(log2.len(), log.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn largest_power_of_two_less_than_values() {
|
||||
assert_eq!(largest_power_of_two_less_than(2), 1);
|
||||
assert_eq!(largest_power_of_two_less_than(3), 2);
|
||||
assert_eq!(largest_power_of_two_less_than(4), 2);
|
||||
assert_eq!(largest_power_of_two_less_than(5), 4);
|
||||
assert_eq!(largest_power_of_two_less_than(8), 4);
|
||||
assert_eq!(largest_power_of_two_less_than(9), 8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user