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