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

@@ -10,4 +10,7 @@ pub enum KtError {
#[error("serialisation error: {0}")]
Serialisation(String),
#[error("identity key is already revoked")]
AlreadyRevoked,
}

View File

@@ -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).

View 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);
}
}
}

View File

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

View File

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

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

View File

@@ -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;
}