use std::collections::{HashMap, HashSet};
use ant_quic::crypto::raw_public_keys::pqc::{
sign_with_ml_dsa, verify_with_ml_dsa, MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature,
};
use ant_quic::derive_peer_id_from_public_key;
use serde::{Deserialize, Serialize};
use crate::error::IdentityError;
use crate::identity::{AgentCertificate, AgentId, MachineId};
const REVOCATION_MSG_PREFIX: &[u8] = b"x0x-revocation-v1";
const REVOCATIONS_FILE_MAGIC: &[u8; 4] = b"X0XR";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RevokedSubject {
Agent(AgentId),
Machine(MachineId),
}
impl RevokedSubject {
fn tag(&self) -> u8 {
match self {
RevokedSubject::Agent(_) => 0x01,
RevokedSubject::Machine(_) => 0x02,
}
}
fn id_bytes(&self) -> &[u8; 32] {
match self {
RevokedSubject::Agent(id) => id.as_bytes(),
RevokedSubject::Machine(id) => id.as_bytes(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevocationRecord {
pub subject: RevokedSubject,
pub issuer_public_key: Vec<u8>,
pub revoked_at: u64,
pub reason: Option<String>,
pub signature: Vec<u8>,
}
impl RevocationRecord {
fn canonical_message(
subject: &RevokedSubject,
issuer_public_key: &[u8],
revoked_at: u64,
reason: &Option<String>,
) -> Vec<u8> {
let reason_bytes = reason.as_ref().map(|s| s.as_bytes()).unwrap_or(&[]);
let mut msg = Vec::with_capacity(
REVOCATION_MSG_PREFIX.len()
+ 1
+ 32
+ issuer_public_key.len()
+ 8
+ 8
+ reason_bytes.len(),
);
msg.extend_from_slice(REVOCATION_MSG_PREFIX);
msg.push(subject.tag());
msg.extend_from_slice(subject.id_bytes());
msg.extend_from_slice(issuer_public_key);
msg.extend_from_slice(&revoked_at.to_le_bytes());
msg.extend_from_slice(&(reason_bytes.len() as u64).to_le_bytes());
msg.extend_from_slice(reason_bytes);
msg
}
pub fn sign(
subject: RevokedSubject,
issuer_public_key: &MlDsaPublicKey,
issuer_secret_key: &MlDsaSecretKey,
revoked_at: u64,
reason: Option<String>,
) -> Result<Self, IdentityError> {
let issuer_pub_bytes = issuer_public_key.as_bytes().to_vec();
let message = Self::canonical_message(&subject, &issuer_pub_bytes, revoked_at, &reason);
let signature = sign_with_ml_dsa(issuer_secret_key, &message).map_err(|e| {
IdentityError::CertificateVerification(format!("revocation signing failed: {e:?}"))
})?;
Ok(Self {
subject,
issuer_public_key: issuer_pub_bytes,
revoked_at,
reason,
signature: signature.as_bytes().to_vec(),
})
}
pub fn verify_authority(
&self,
subject_cert: Option<&AgentCertificate>,
) -> Result<(), IdentityError> {
let issuer_pubkey = MlDsaPublicKey::from_bytes(&self.issuer_public_key)
.map_err(|_| IdentityError::Revocation("invalid issuer public key".to_string()))?;
let signature = MlDsaSignature::from_bytes(&self.signature)
.map_err(|e| IdentityError::Revocation(format!("invalid signature format: {e:?}")))?;
let message = Self::canonical_message(
&self.subject,
&self.issuer_public_key,
self.revoked_at,
&self.reason,
);
verify_with_ml_dsa(&issuer_pubkey, &message, &signature)
.map_err(|e| IdentityError::Revocation(format!("bad signature: {e:?}")))?;
let issuer_id = derive_peer_id_from_public_key(&issuer_pubkey).0;
if &issuer_id == self.subject.id_bytes() {
return Ok(());
}
if let RevokedSubject::Agent(subject_agent) = &self.subject {
if let Some(cert) = subject_cert {
let cert_binds_subject = cert
.agent_id()
.map(|a| a == *subject_agent)
.unwrap_or(false);
let cert_is_valid = cert.verify().is_ok();
let issuer_is_certifier = cert
.user_id()
.map(|u| u.as_bytes() == &issuer_id)
.unwrap_or(false);
if cert_binds_subject && cert_is_valid && issuer_is_certifier {
return Ok(());
}
}
}
Err(IdentityError::Revocation(
"issuer is neither the subject nor the certifying user".to_string(),
))
}
#[must_use]
pub fn is_self_revocation(&self) -> bool {
match MlDsaPublicKey::from_bytes(&self.issuer_public_key) {
Ok(pk) => &derive_peer_id_from_public_key(&pk).0 == self.subject.id_bytes(),
Err(_) => false,
}
}
#[must_use]
pub fn subject_hex(&self) -> String {
hex::encode(self.subject.id_bytes())
}
#[must_use]
pub fn subject_kind(&self) -> &'static str {
match &self.subject {
RevokedSubject::Agent(_) => "agent",
RevokedSubject::Machine(_) => "machine",
}
}
#[must_use]
pub fn issuer_public_key_hash(&self) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&self.issuer_public_key);
hasher.finalize().into()
}
#[must_use]
pub fn record_hash(&self) -> [u8; 32] {
let message = Self::canonical_message(
&self.subject,
&self.issuer_public_key,
self.revoked_at,
&self.reason,
);
*blake3::hash(&message).as_bytes()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedRevocation {
record: RevocationRecord,
subject_cert: Option<AgentCertificate>,
}
#[derive(Debug, Default, Clone)]
pub struct RevocationSet {
revoked_agents: HashSet<AgentId>,
revoked_machines: HashSet<MachineId>,
records_by_hash: HashMap<[u8; 32], PersistedRevocation>,
}
impl RevocationSet {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn is_agent_revoked(&self, id: &AgentId) -> bool {
self.revoked_agents.contains(id)
}
#[must_use]
pub fn is_machine_revoked(&self, id: &MachineId) -> bool {
self.revoked_machines.contains(id)
}
#[must_use]
pub fn len(&self) -> usize {
self.records_by_hash.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.records_by_hash.is_empty()
}
#[must_use]
pub fn contains_hash(&self, hash: &[u8; 32]) -> bool {
self.records_by_hash.contains_key(hash)
}
pub fn verify_and_insert(
&mut self,
record: RevocationRecord,
subject_cert: Option<&AgentCertificate>,
) -> Result<bool, IdentityError> {
record.verify_authority(subject_cert)?;
let retained_cert = if record.is_self_revocation() {
None
} else {
subject_cert.cloned()
};
Ok(self.insert_verified(PersistedRevocation {
record,
subject_cert: retained_cert,
}))
}
fn insert_verified(&mut self, persisted: PersistedRevocation) -> bool {
let hash = persisted.record.record_hash();
if self.records_by_hash.contains_key(&hash) {
return false;
}
match &persisted.record.subject {
RevokedSubject::Agent(id) => {
self.revoked_agents.insert(*id);
}
RevokedSubject::Machine(id) => {
self.revoked_machines.insert(*id);
}
}
self.records_by_hash.insert(hash, persisted);
true
}
#[must_use]
pub fn all_records(&self) -> Vec<RevocationRecord> {
self.records_by_hash
.values()
.map(|p| p.record.clone())
.collect()
}
pub fn to_bytes(&self) -> Result<Vec<u8>, IdentityError> {
let records: Vec<&PersistedRevocation> = self.records_by_hash.values().collect();
let body = bincode::serialize(&records)
.map_err(|e| IdentityError::Serialization(e.to_string()))?;
let mut out = Vec::with_capacity(REVOCATIONS_FILE_MAGIC.len() + body.len());
out.extend_from_slice(REVOCATIONS_FILE_MAGIC);
out.extend_from_slice(&body);
Ok(out)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityError> {
if bytes.is_empty() {
return Ok(Self::new());
}
if bytes.len() < REVOCATIONS_FILE_MAGIC.len()
|| &bytes[..REVOCATIONS_FILE_MAGIC.len()] != REVOCATIONS_FILE_MAGIC
{
return Err(IdentityError::Serialization(
"revocation file missing X0XR magic".to_string(),
));
}
let persisted: Vec<PersistedRevocation> =
bincode::deserialize(&bytes[REVOCATIONS_FILE_MAGIC.len()..])
.map_err(|e| IdentityError::Serialization(e.to_string()))?;
let mut set = Self::new();
for entry in persisted {
let _ = set.verify_and_insert(entry.record, entry.subject_cert.as_ref());
}
Ok(set)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::identity::{AgentKeypair, MachineKeypair, UserKeypair};
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test]
fn revocation_self_signed_verifies() {
let agent = AgentKeypair::generate().unwrap();
let record = RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
agent.public_key(),
agent.secret_key(),
now(),
Some("compromised".to_string()),
)
.unwrap();
record
.verify_authority(None)
.expect("self-revocation must verify with no certificate");
}
#[test]
fn revocation_machine_self_signed_verifies() {
let machine = MachineKeypair::generate().unwrap();
let record = RevocationRecord::sign(
RevokedSubject::Machine(machine.machine_id()),
machine.public_key(),
machine.secret_key(),
now(),
None,
)
.unwrap();
record
.verify_authority(None)
.expect("machine self-revocation must verify");
}
#[test]
fn revocation_user_signed_for_certified_agent_verifies() {
let user = UserKeypair::generate().unwrap();
let agent = AgentKeypair::generate().unwrap();
let cert = AgentCertificate::issue(&user, &agent).unwrap();
let record = RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
user.public_key(),
user.secret_key(),
now(),
None,
)
.unwrap();
record
.verify_authority(Some(&cert))
.expect("issuer (certifying user) revocation must verify");
}
#[test]
fn revocation_user_without_cert_rejected() {
let user = UserKeypair::generate().unwrap();
let agent = AgentKeypair::generate().unwrap();
let record = RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
user.public_key(),
user.secret_key(),
now(),
None,
)
.unwrap();
assert!(
record.verify_authority(None).is_err(),
"issuer-revocation without the binding certificate must be rejected"
);
}
#[test]
fn revocation_unrelated_key_rejected() {
let user = UserKeypair::generate().unwrap();
let agent = AgentKeypair::generate().unwrap();
let cert = AgentCertificate::issue(&user, &agent).unwrap();
let attacker = UserKeypair::generate().unwrap();
let record = RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
attacker.public_key(),
attacker.secret_key(),
now(),
None,
)
.unwrap();
assert!(
record.verify_authority(Some(&cert)).is_err(),
"an unrelated key must not be able to revoke, even with the cert"
);
}
#[test]
fn revocation_forged_signature_rejected() {
let agent = AgentKeypair::generate().unwrap();
let mut record = RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
agent.public_key(),
agent.secret_key(),
now(),
None,
)
.unwrap();
record.revoked_at = record.revoked_at.wrapping_add(1);
assert!(
record.verify_authority(None).is_err(),
"a tampered record must fail the signature check"
);
}
#[test]
fn revocation_set_merge_grow_only_idempotent() {
let agent = AgentKeypair::generate().unwrap();
let record = RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
agent.public_key(),
agent.secret_key(),
now(),
None,
)
.unwrap();
let mut set = RevocationSet::new();
assert!(
set.verify_and_insert(record.clone(), None).unwrap(),
"first insert is new"
);
assert!(
!set.verify_and_insert(record.clone(), None).unwrap(),
"re-inserting the same record must be idempotent"
);
assert_eq!(set.len(), 1);
assert!(set.is_agent_revoked(&agent.agent_id()));
assert!(!set.is_machine_revoked(&MachineKeypair::generate().unwrap().machine_id()));
}
#[test]
fn revocation_set_persists_and_reloads() {
let agent = AgentKeypair::generate().unwrap();
let machine = MachineKeypair::generate().unwrap();
let user = UserKeypair::generate().unwrap();
let issued_agent = AgentKeypair::generate().unwrap();
let cert = AgentCertificate::issue(&user, &issued_agent).unwrap();
let mut set = RevocationSet::new();
set.verify_and_insert(
RevocationRecord::sign(
RevokedSubject::Agent(agent.agent_id()),
agent.public_key(),
agent.secret_key(),
now(),
None,
)
.unwrap(),
None,
)
.unwrap();
set.verify_and_insert(
RevocationRecord::sign(
RevokedSubject::Machine(machine.machine_id()),
machine.public_key(),
machine.secret_key(),
now(),
None,
)
.unwrap(),
None,
)
.unwrap();
set.verify_and_insert(
RevocationRecord::sign(
RevokedSubject::Agent(issued_agent.agent_id()),
user.public_key(),
user.secret_key(),
now(),
None,
)
.unwrap(),
Some(&cert),
)
.unwrap();
let bytes = set.to_bytes().unwrap();
let reloaded = RevocationSet::from_bytes(&bytes).unwrap();
assert_eq!(reloaded.len(), 3, "all three records must survive reload");
assert!(reloaded.is_agent_revoked(&agent.agent_id()));
assert!(reloaded.is_machine_revoked(&machine.machine_id()));
assert!(
reloaded.is_agent_revoked(&issued_agent.agent_id()),
"issuer-revocation must re-verify on load via its persisted cert"
);
}
#[test]
fn revocation_set_from_bytes_rejects_forged_and_unrelated_records() {
let good_agent = AgentKeypair::generate().unwrap();
let user = UserKeypair::generate().unwrap();
let issued_agent = AgentKeypair::generate().unwrap();
let good_cert = AgentCertificate::issue(&user, &issued_agent).unwrap();
let good_self = PersistedRevocation {
record: RevocationRecord::sign(
RevokedSubject::Agent(good_agent.agent_id()),
good_agent.public_key(),
good_agent.secret_key(),
now(),
None,
)
.unwrap(),
subject_cert: None,
};
let good_issuer = PersistedRevocation {
record: RevocationRecord::sign(
RevokedSubject::Agent(issued_agent.agent_id()),
user.public_key(),
user.secret_key(),
now(),
None,
)
.unwrap(),
subject_cert: Some(good_cert.clone()),
};
let forged_agent = AgentKeypair::generate().unwrap();
let mut forged_record = RevocationRecord::sign(
RevokedSubject::Agent(forged_agent.agent_id()),
forged_agent.public_key(),
forged_agent.secret_key(),
now(),
None,
)
.unwrap();
forged_record.revoked_at = forged_record.revoked_at.wrapping_add(1);
let forged = PersistedRevocation {
record: forged_record,
subject_cert: None,
};
let attacker = UserKeypair::generate().unwrap();
let victim_agent = AgentKeypair::generate().unwrap();
let unrelated = PersistedRevocation {
record: RevocationRecord::sign(
RevokedSubject::Agent(victim_agent.agent_id()),
attacker.public_key(),
attacker.secret_key(),
now(),
None,
)
.unwrap(),
subject_cert: Some(good_cert),
};
let entries = vec![good_self, good_issuer, forged, unrelated];
let mut bytes = REVOCATIONS_FILE_MAGIC.to_vec();
bytes.extend_from_slice(&bincode::serialize(&entries).unwrap());
let loaded = RevocationSet::from_bytes(&bytes).unwrap();
assert_eq!(
loaded.len(),
2,
"only the two authoritative records may survive load"
);
assert!(
loaded.is_agent_revoked(&good_agent.agent_id()),
"legit self-revocation must survive"
);
assert!(
loaded.is_agent_revoked(&issued_agent.agent_id()),
"legit issuer-revocation (with cert) must survive"
);
assert!(
!loaded.is_agent_revoked(&forged_agent.agent_id()),
"forged record must be rejected on load"
);
assert!(
!loaded.is_agent_revoked(&victim_agent.agent_id()),
"unrelated-issuer record must be rejected on load"
);
}
#[test]
fn revocation_set_from_empty_is_empty() {
assert!(RevocationSet::from_bytes(&[]).unwrap().is_empty());
}
}