feat(kt): add key revocation and Merkle-log audit support

Add RevocationLog with domain-separated leaf hashes (0x02 prefix) for
tracking revoked identity keys alongside the KT MerkleLog. Includes:

- RevocationLog with O(1) lookup, serialization, and double-revoke guard
- MerkleLog.append_raw() for pre-computed hashes
- MerkleLog.audit_log(start, end) for paginated log retrieval
- RevokeKey (510), CheckRevocation (511), AuditKeyTransparency (520) RPCs
- Server domain logic + v2 handlers + FileBackedStore/SqlStore persistence
- 4 new revocation tests + all 21 KT tests + 65 server tests passing
This commit is contained in:
2026-03-04 20:53:41 +01:00
parent f667281831
commit 1768f85258
11 changed files with 657 additions and 11 deletions

View 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);
}
}
}