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}")]
|
#[error("serialisation error: {0}")]
|
||||||
Serialisation(String),
|
Serialisation(String),
|
||||||
|
|
||||||
|
#[error("identity key is already revoked")]
|
||||||
|
AlreadyRevoked,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ use sha2::{Digest, Sha256};
|
|||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod proof;
|
mod proof;
|
||||||
|
pub mod revocation;
|
||||||
mod tree;
|
mod tree;
|
||||||
|
|
||||||
pub use error::KtError;
|
pub use error::KtError;
|
||||||
pub use proof::{verify_inclusion, InclusionProof};
|
pub use proof::{verify_inclusion, InclusionProof};
|
||||||
|
pub use revocation::{RevocationEntry, RevocationLog, RevocationReason};
|
||||||
pub use tree::MerkleLog;
|
pub use tree::MerkleLog;
|
||||||
|
|
||||||
/// Domain-separation prefix for leaf nodes (RFC 6962 §2.1).
|
/// 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)
|
.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).
|
/// Serialise the log to bytes (bincode).
|
||||||
pub fn to_bytes(&self) -> Result<Vec<u8>, KtError> {
|
pub fn to_bytes(&self) -> Result<Vec<u8>, KtError> {
|
||||||
bincode::serialize(self)
|
bincode::serialize(self)
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ pub mod method_ids {
|
|||||||
pub const RESOLVE_USER: u16 = 500;
|
pub const RESOLVE_USER: u16 = 500;
|
||||||
pub const RESOLVE_IDENTITY: u16 = 501;
|
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)
|
// Blob (600-601)
|
||||||
pub const UPLOAD_BLOB: u16 = 600;
|
pub const UPLOAD_BLOB: u16 = 600;
|
||||||
pub const DOWNLOAD_BLOB: u16 = 601;
|
pub const DOWNLOAD_BLOB: u16 = 601;
|
||||||
|
|||||||
@@ -208,6 +208,44 @@ pub struct FetchHybridKeysResp {
|
|||||||
pub keys: Vec<Vec<u8>>,
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Channel ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct CreateChannelReq {
|
pub struct CreateChannelReq {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use quicproquo_kt::MerkleLog;
|
use quicproquo_kt::{MerkleLog, RevocationLog, RevocationReason};
|
||||||
|
|
||||||
use crate::storage::Store;
|
use crate::storage::Store;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ use super::types::*;
|
|||||||
pub struct UserService {
|
pub struct UserService {
|
||||||
pub store: Arc<dyn Store>,
|
pub store: Arc<dyn Store>,
|
||||||
pub kt_log: Arc<Mutex<MerkleLog>>,
|
pub kt_log: Arc<Mutex<MerkleLog>>,
|
||||||
|
pub revocation_log: Arc<Mutex<RevocationLog>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserService {
|
impl UserService {
|
||||||
@@ -60,4 +61,86 @@ impl UserService {
|
|||||||
|
|
||||||
Ok(ResolveIdentityResp { username })
|
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.
|
/// Load the persisted KT Merkle log, if any.
|
||||||
fn load_kt_log(&self) -> Result<Option<Vec<u8>>, StorageError>;
|
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`).
|
/// Store an OPAQUE user record (serialized `ServerRegistration`).
|
||||||
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError>;
|
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError>;
|
||||||
|
|
||||||
@@ -397,6 +403,7 @@ pub struct FileBackedStore {
|
|||||||
setup_path: PathBuf,
|
setup_path: PathBuf,
|
||||||
signing_key_path: PathBuf,
|
signing_key_path: PathBuf,
|
||||||
kt_log_path: PathBuf,
|
kt_log_path: PathBuf,
|
||||||
|
revocation_log_path: PathBuf,
|
||||||
users_path: PathBuf,
|
users_path: PathBuf,
|
||||||
identity_keys_path: PathBuf,
|
identity_keys_path: PathBuf,
|
||||||
channels_path: PathBuf,
|
channels_path: PathBuf,
|
||||||
@@ -452,6 +459,7 @@ impl FileBackedStore {
|
|||||||
let setup_path = dir.join("server_setup.bin");
|
let setup_path = dir.join("server_setup.bin");
|
||||||
let signing_key_path = dir.join("server_signing_key.bin");
|
let signing_key_path = dir.join("server_signing_key.bin");
|
||||||
let kt_log_path = dir.join("kt_log.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 users_path = dir.join("users.bin");
|
||||||
let identity_keys_path = dir.join("identity_keys.bin");
|
let identity_keys_path = dir.join("identity_keys.bin");
|
||||||
let channels_path = dir.join("channels.bin");
|
let channels_path = dir.join("channels.bin");
|
||||||
@@ -470,6 +478,7 @@ impl FileBackedStore {
|
|||||||
setup_path,
|
setup_path,
|
||||||
signing_key_path,
|
signing_key_path,
|
||||||
kt_log_path,
|
kt_log_path,
|
||||||
|
revocation_log_path,
|
||||||
users_path,
|
users_path,
|
||||||
identity_keys_path,
|
identity_keys_path,
|
||||||
channels_path,
|
channels_path,
|
||||||
@@ -815,6 +824,26 @@ impl Store for FileBackedStore {
|
|||||||
Ok(Some(bytes))
|
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> {
|
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError> {
|
||||||
let mut map = lock(&self.users)?;
|
let mut map = lock(&self.users)?;
|
||||||
match map.entry(username.to_string()) {
|
match map.entry(username.to_string()) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub struct ServerState {
|
|||||||
pub hooks: Arc<dyn ServerHooks>,
|
pub hooks: Arc<dyn ServerHooks>,
|
||||||
pub signing_key: Arc<quicproquo_core::IdentityKeypair>,
|
pub signing_key: Arc<quicproquo_core::IdentityKeypair>,
|
||||||
pub kt_log: Arc<std::sync::Mutex<quicproquo_kt::MerkleLog>>,
|
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 data_dir: PathBuf,
|
||||||
pub redact_logs: bool,
|
pub redact_logs: bool,
|
||||||
/// Structured audit logger for security-relevant events.
|
/// 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,
|
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.
|
// Blob (600-601) — longer timeout for file transfers.
|
||||||
reg.register_with_timeout(
|
reg.register_with_timeout(
|
||||||
method_ids::UPLOAD_BLOB,
|
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;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -7,11 +8,21 @@ use prost::Message;
|
|||||||
use quicproquo_proto::qpq::v1;
|
use quicproquo_proto::qpq::v1;
|
||||||
use quicproquo_rpc::method::{HandlerResult, RequestContext};
|
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 crate::domain::users::UserService;
|
||||||
|
|
||||||
use super::{domain_err, require_auth, ServerState};
|
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 {
|
pub async fn handle_resolve_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||||
let _identity_key = match require_auth(&state, &ctx) {
|
let _identity_key = match require_auth(&state, &ctx) {
|
||||||
Ok(ik) => ik,
|
Ok(ik) => ik,
|
||||||
@@ -28,10 +39,7 @@ pub async fn handle_resolve_user(state: Arc<ServerState>, ctx: RequestContext) -
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let svc = UserService {
|
let svc = user_svc(&state);
|
||||||
store: Arc::clone(&state.store),
|
|
||||||
kt_log: Arc::clone(&state.kt_log),
|
|
||||||
};
|
|
||||||
|
|
||||||
let domain_req = ResolveUserReq {
|
let domain_req = ResolveUserReq {
|
||||||
username: req.username,
|
username: req.username,
|
||||||
@@ -68,10 +76,7 @@ pub async fn handle_resolve_identity(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let svc = UserService {
|
let svc = user_svc(&state);
|
||||||
store: Arc::clone(&state.store),
|
|
||||||
kt_log: Arc::clone(&state.kt_log),
|
|
||||||
};
|
|
||||||
|
|
||||||
let domain_req = ResolveIdentityReq {
|
let domain_req = ResolveIdentityReq {
|
||||||
identity_key: req.identity_key,
|
identity_key: req.identity_key,
|
||||||
@@ -87,3 +92,122 @@ pub async fn handle_resolve_identity(
|
|||||||
Err(e) => domain_err(e),
|
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 {
|
message FetchHybridKeysResponse {
|
||||||
repeated bytes keys = 1;
|
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