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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user