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:
@@ -10,4 +10,7 @@ pub enum KtError {
|
||||
|
||||
#[error("serialisation error: {0}")]
|
||||
Serialisation(String),
|
||||
|
||||
#[error("identity key is already revoked")]
|
||||
AlreadyRevoked,
|
||||
}
|
||||
|
||||
@@ -25,10 +25,12 @@ 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).
|
||||
|
||||
278
crates/quicproquo-kt/src/revocation.rs
Normal file
278
crates/quicproquo-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,33 @@ impl MerkleLog {
|
||||
.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)
|
||||
|
||||
@@ -107,6 +107,11 @@ pub mod method_ids {
|
||||
pub const RESOLVE_USER: u16 = 500;
|
||||
pub const RESOLVE_IDENTITY: u16 = 501;
|
||||
|
||||
// Key Transparency (510-520)
|
||||
pub const REVOKE_KEY: u16 = 510;
|
||||
pub const CHECK_REVOCATION: u16 = 511;
|
||||
pub const AUDIT_KEY_TRANSPARENCY: u16 = 520;
|
||||
|
||||
// Blob (600-601)
|
||||
pub const UPLOAD_BLOB: u16 = 600;
|
||||
pub const DOWNLOAD_BLOB: u16 = 601;
|
||||
|
||||
@@ -208,6 +208,44 @@ pub struct FetchHybridKeysResp {
|
||||
pub keys: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
// ── Key Transparency / Revocation ────────────────────────────────────
|
||||
|
||||
pub struct RevokeKeyReq {
|
||||
pub identity_key: Vec<u8>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub struct RevokeKeyResp {
|
||||
pub success: bool,
|
||||
pub leaf_index: u64,
|
||||
}
|
||||
|
||||
pub struct CheckRevocationReq {
|
||||
pub identity_key: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct CheckRevocationResp {
|
||||
pub revoked: bool,
|
||||
pub reason: String,
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
pub struct AuditKeyTransparencyReq {
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
}
|
||||
|
||||
pub struct AuditLogEntry {
|
||||
pub index: u64,
|
||||
pub leaf_hash: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct AuditKeyTransparencyResp {
|
||||
pub entries: Vec<AuditLogEntry>,
|
||||
pub tree_size: u64,
|
||||
pub root: Vec<u8>,
|
||||
}
|
||||
|
||||
// ── Channel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct CreateChannelReq {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use quicproquo_kt::MerkleLog;
|
||||
use quicproquo_kt::{MerkleLog, RevocationLog, RevocationReason};
|
||||
|
||||
use crate::storage::Store;
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::types::*;
|
||||
pub struct UserService {
|
||||
pub store: Arc<dyn Store>,
|
||||
pub kt_log: Arc<Mutex<MerkleLog>>,
|
||||
pub revocation_log: Arc<Mutex<RevocationLog>>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
@@ -60,4 +61,86 @@ impl UserService {
|
||||
|
||||
Ok(ResolveIdentityResp { username })
|
||||
}
|
||||
|
||||
/// Revoke an identity key in the Key Transparency log.
|
||||
pub fn revoke_key(&self, req: RevokeKeyReq) -> Result<RevokeKeyResp, DomainError> {
|
||||
if req.identity_key.len() != 32 {
|
||||
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||
}
|
||||
|
||||
let reason = RevocationReason::from_tag(&req.reason)
|
||||
.ok_or_else(|| DomainError::BadParams(format!("invalid revocation reason: {}", req.reason)))?;
|
||||
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut kt = self.kt_log.lock().map_err(|e| DomainError::Io(e.to_string()))?;
|
||||
let mut revlog = self.revocation_log.lock().map_err(|e| DomainError::Io(e.to_string()))?;
|
||||
|
||||
let leaf_index = revlog
|
||||
.revoke(&mut kt, &req.identity_key, reason, timestamp_ms)
|
||||
.map_err(|e| DomainError::BadParams(e.to_string()))?;
|
||||
|
||||
// Persist updated logs.
|
||||
if let Ok(bytes) = kt.to_bytes() {
|
||||
let _ = self.store.save_kt_log(bytes);
|
||||
}
|
||||
if let Ok(bytes) = revlog.to_bytes() {
|
||||
let _ = self.store.save_revocation_log(bytes);
|
||||
}
|
||||
|
||||
Ok(RevokeKeyResp {
|
||||
success: true,
|
||||
leaf_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if an identity key has been revoked.
|
||||
pub fn check_revocation(&self, req: CheckRevocationReq) -> Result<CheckRevocationResp, DomainError> {
|
||||
let revlog = self.revocation_log.lock().map_err(|e| DomainError::Io(e.to_string()))?;
|
||||
|
||||
if let Some(entry) = revlog.get(&req.identity_key) {
|
||||
Ok(CheckRevocationResp {
|
||||
revoked: true,
|
||||
reason: entry.reason.as_tag().to_string(),
|
||||
timestamp_ms: entry.timestamp_ms,
|
||||
})
|
||||
} else {
|
||||
Ok(CheckRevocationResp {
|
||||
revoked: false,
|
||||
reason: String::new(),
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a range of KT log entries for client-side audit.
|
||||
pub fn audit_key_transparency(
|
||||
&self,
|
||||
req: AuditKeyTransparencyReq,
|
||||
) -> Result<AuditKeyTransparencyResp, DomainError> {
|
||||
let kt = self.kt_log.lock().map_err(|e| DomainError::Io(e.to_string()))?;
|
||||
|
||||
let end = if req.end == 0 { kt.len() } else { req.end };
|
||||
let log_entries = kt.audit_log(req.start, end);
|
||||
|
||||
let entries: Vec<AuditLogEntry> = log_entries
|
||||
.into_iter()
|
||||
.map(|(index, hash)| AuditLogEntry {
|
||||
index,
|
||||
leaf_hash: hash.to_vec(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tree_size = kt.len();
|
||||
let root = kt.root().map(|r| r.to_vec()).unwrap_or_default();
|
||||
|
||||
Ok(AuditKeyTransparencyResp {
|
||||
entries,
|
||||
tree_size,
|
||||
root,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,12 @@ pub trait Store: Send + Sync {
|
||||
/// Load the persisted KT Merkle log, if any.
|
||||
fn load_kt_log(&self) -> Result<Option<Vec<u8>>, StorageError>;
|
||||
|
||||
/// Persist the Key Transparency revocation log (bincode-serialised).
|
||||
fn save_revocation_log(&self, bytes: Vec<u8>) -> Result<(), StorageError>;
|
||||
|
||||
/// Load the persisted revocation log, if any.
|
||||
fn load_revocation_log(&self) -> Result<Option<Vec<u8>>, StorageError>;
|
||||
|
||||
/// Store an OPAQUE user record (serialized `ServerRegistration`).
|
||||
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError>;
|
||||
|
||||
@@ -397,6 +403,7 @@ pub struct FileBackedStore {
|
||||
setup_path: PathBuf,
|
||||
signing_key_path: PathBuf,
|
||||
kt_log_path: PathBuf,
|
||||
revocation_log_path: PathBuf,
|
||||
users_path: PathBuf,
|
||||
identity_keys_path: PathBuf,
|
||||
channels_path: PathBuf,
|
||||
@@ -452,6 +459,7 @@ impl FileBackedStore {
|
||||
let setup_path = dir.join("server_setup.bin");
|
||||
let signing_key_path = dir.join("server_signing_key.bin");
|
||||
let kt_log_path = dir.join("kt_log.bin");
|
||||
let revocation_log_path = dir.join("revocation_log.bin");
|
||||
let users_path = dir.join("users.bin");
|
||||
let identity_keys_path = dir.join("identity_keys.bin");
|
||||
let channels_path = dir.join("channels.bin");
|
||||
@@ -470,6 +478,7 @@ impl FileBackedStore {
|
||||
setup_path,
|
||||
signing_key_path,
|
||||
kt_log_path,
|
||||
revocation_log_path,
|
||||
users_path,
|
||||
identity_keys_path,
|
||||
channels_path,
|
||||
@@ -815,6 +824,26 @@ impl Store for FileBackedStore {
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
fn save_revocation_log(&self, bytes: Vec<u8>) -> Result<(), StorageError> {
|
||||
if let Some(parent) = self.revocation_log_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
}
|
||||
fs::write(&self.revocation_log_path, &bytes)
|
||||
.map_err(|e| StorageError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn load_revocation_log(&self) -> Result<Option<Vec<u8>>, StorageError> {
|
||||
if !self.revocation_log_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes =
|
||||
fs::read(&self.revocation_log_path).map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError> {
|
||||
let mut map = lock(&self.users)?;
|
||||
match map.entry(username.to_string()) {
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct ServerState {
|
||||
pub hooks: Arc<dyn ServerHooks>,
|
||||
pub signing_key: Arc<quicproquo_core::IdentityKeypair>,
|
||||
pub kt_log: Arc<std::sync::Mutex<quicproquo_kt::MerkleLog>>,
|
||||
pub revocation_log: Arc<std::sync::Mutex<quicproquo_kt::RevocationLog>>,
|
||||
pub data_dir: PathBuf,
|
||||
pub redact_logs: bool,
|
||||
/// Structured audit logger for security-relevant events.
|
||||
@@ -281,6 +282,23 @@ pub fn build_registry(default_rpc_timeout: std::time::Duration) -> MethodRegistr
|
||||
user::handle_resolve_identity,
|
||||
);
|
||||
|
||||
// Key Transparency (510-520)
|
||||
reg.register(
|
||||
method_ids::REVOKE_KEY,
|
||||
"RevokeKey",
|
||||
user::handle_revoke_key,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::CHECK_REVOCATION,
|
||||
"CheckRevocation",
|
||||
user::handle_check_revocation,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::AUDIT_KEY_TRANSPARENCY,
|
||||
"AuditKeyTransparency",
|
||||
user::handle_audit_key_transparency,
|
||||
);
|
||||
|
||||
// Blob (600-601) — longer timeout for file transfers.
|
||||
reg.register_with_timeout(
|
||||
method_ids::UPLOAD_BLOB,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! User resolution handlers — username <-> identity key lookups.
|
||||
//! User resolution handlers — username <-> identity key lookups,
|
||||
//! key revocation, and KT audit.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,11 +8,21 @@ use prost::Message;
|
||||
use quicproquo_proto::qpq::v1;
|
||||
use quicproquo_rpc::method::{HandlerResult, RequestContext};
|
||||
|
||||
use crate::domain::types::{ResolveIdentityReq, ResolveUserReq};
|
||||
use crate::domain::types::{
|
||||
AuditKeyTransparencyReq, CheckRevocationReq, ResolveIdentityReq, ResolveUserReq, RevokeKeyReq,
|
||||
};
|
||||
use crate::domain::users::UserService;
|
||||
|
||||
use super::{domain_err, require_auth, ServerState};
|
||||
|
||||
fn user_svc(state: &Arc<ServerState>) -> UserService {
|
||||
UserService {
|
||||
store: Arc::clone(&state.store),
|
||||
kt_log: Arc::clone(&state.kt_log),
|
||||
revocation_log: Arc::clone(&state.revocation_log),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_resolve_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
@@ -28,10 +39,7 @@ pub async fn handle_resolve_user(state: Arc<ServerState>, ctx: RequestContext) -
|
||||
}
|
||||
};
|
||||
|
||||
let svc = UserService {
|
||||
store: Arc::clone(&state.store),
|
||||
kt_log: Arc::clone(&state.kt_log),
|
||||
};
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = ResolveUserReq {
|
||||
username: req.username,
|
||||
@@ -68,10 +76,7 @@ pub async fn handle_resolve_identity(
|
||||
}
|
||||
};
|
||||
|
||||
let svc = UserService {
|
||||
store: Arc::clone(&state.store),
|
||||
kt_log: Arc::clone(&state.kt_log),
|
||||
};
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = ResolveIdentityReq {
|
||||
identity_key: req.identity_key,
|
||||
@@ -87,3 +92,122 @@ pub async fn handle_resolve_identity(
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_revoke_key(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::RevokeKeyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicproquo_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = RevokeKeyReq {
|
||||
identity_key: req.identity_key,
|
||||
reason: req.reason,
|
||||
};
|
||||
|
||||
match svc.revoke_key(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::RevokeKeyResponse {
|
||||
success: resp.success,
|
||||
leaf_index: resp.leaf_index,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_check_revocation(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::CheckRevocationRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicproquo_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = CheckRevocationReq {
|
||||
identity_key: req.identity_key,
|
||||
};
|
||||
|
||||
match svc.check_revocation(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::CheckRevocationResponse {
|
||||
revoked: resp.revoked,
|
||||
reason: resp.reason,
|
||||
timestamp_ms: resp.timestamp_ms,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_audit_key_transparency(
|
||||
state: Arc<ServerState>,
|
||||
ctx: RequestContext,
|
||||
) -> HandlerResult {
|
||||
let _identity_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let req = match v1::AuditKeyTransparencyRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicproquo_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = user_svc(&state);
|
||||
|
||||
let domain_req = AuditKeyTransparencyReq {
|
||||
start: req.start,
|
||||
end: req.end,
|
||||
};
|
||||
|
||||
match svc.audit_key_transparency(domain_req) {
|
||||
Ok(resp) => {
|
||||
let proto = v1::AuditKeyTransparencyResponse {
|
||||
entries: resp
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| v1::LogEntry {
|
||||
index: e.index,
|
||||
leaf_hash: e.leaf_hash,
|
||||
})
|
||||
.collect(),
|
||||
tree_size: resp.tree_size,
|
||||
root: resp.root,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,3 +43,42 @@ message FetchHybridKeysRequest {
|
||||
message FetchHybridKeysResponse {
|
||||
repeated bytes keys = 1;
|
||||
}
|
||||
|
||||
// Key revocation (method ID 510).
|
||||
message RevokeKeyRequest {
|
||||
bytes identity_key = 1;
|
||||
string reason = 2; // "compromised", "superseded", "user_revoked"
|
||||
}
|
||||
|
||||
message RevokeKeyResponse {
|
||||
bool success = 1;
|
||||
uint64 leaf_index = 2; // Index of revocation entry in the KT Merkle log
|
||||
}
|
||||
|
||||
// Check revocation status (method ID 511).
|
||||
message CheckRevocationRequest {
|
||||
bytes identity_key = 1;
|
||||
}
|
||||
|
||||
message CheckRevocationResponse {
|
||||
bool revoked = 1;
|
||||
string reason = 2;
|
||||
uint64 timestamp_ms = 3;
|
||||
}
|
||||
|
||||
// KT audit log retrieval (method ID 520).
|
||||
message AuditKeyTransparencyRequest {
|
||||
uint64 start = 1;
|
||||
uint64 end = 2; // 0 = up to current size
|
||||
}
|
||||
|
||||
message AuditKeyTransparencyResponse {
|
||||
repeated LogEntry entries = 1;
|
||||
uint64 tree_size = 2;
|
||||
bytes root = 3;
|
||||
}
|
||||
|
||||
message LogEntry {
|
||||
uint64 index = 1;
|
||||
bytes leaf_hash = 2;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user