feat: Phase 9 — developer experience, extensibility, and community growth

New crates:
- quicproquo-bot: Bot SDK with polling API + JSON pipe mode
- quicproquo-kt: Key Transparency Merkle log (RFC 9162 subset)
- quicproquo-plugin-api: no_std C-compatible plugin vtable API
- quicproquo-gen: scaffolding tool (qpq-gen plugin/bot/rpc/hook)

Server features:
- ServerHooks trait wired into all RPC handlers (enqueue, fetch, auth,
  channel, registration) with plugin rejection support
- Dynamic plugin loader (libloading) with --plugin-dir config
- Delivery proof canary tokens (Ed25519 server signatures on enqueue)
- Key Transparency Merkle log with inclusion proofs on resolveUser

Core library:
- Safety numbers (60-digit HMAC-SHA256 key verification codes)
- Verifiable transcript archive (CBOR + ChaCha20-Poly1305 + hash chain)
- Delivery proof verification utility
- Criterion benchmarks (hybrid KEM, MLS, identity, sealed sender, padding)

Client:
- /verify REPL command for out-of-band key verification
- Full-screen TUI via Ratatui (feature-gated --features tui)
- qpq export / qpq export-verify CLI subcommands
- KT inclusion proof verification on user resolution

Also: ROADMAP Phase 9 added, bot SDK docs, server hooks docs,
crate-responsibilities updated, example plugins (rate_limit, logging).
This commit is contained in:
2026-03-03 22:47:38 +01:00
parent b6483dedbc
commit dc4e4e49a0
62 changed files with 6959 additions and 62 deletions

View File

@@ -0,0 +1,13 @@
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),
}

View File

@@ -0,0 +1,62 @@
//! 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;
mod tree;
pub use error::KtError;
pub use proof::{verify_inclusion, InclusionProof};
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()
}

View File

@@ -0,0 +1,188 @@
//! 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, &current)
} else {
// Sibling is right, current is left.
node_hash(&current, &step.hash)
};
}
Ok(current)
}
#[cfg(test)]
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();
}
}

View File

@@ -0,0 +1,262 @@
//! 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)
}
/// 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)]
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);
}
}