//! 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 { 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, /// 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, /// Index from identity_key bytes to entry index for O(1) lookup. #[serde(skip)] index: HashMap, 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 { 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, 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 { 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); } } }