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

@@ -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 {

View File

@@ -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,
})
}
}

View File

@@ -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()) {

View File

@@ -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,

View File

@@ -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),
}
}