use aitp_core::{jcs, Aid, Timestamp};
use aitp_crypto::{AitpSigningKey, AitpVerifyingKey, Signature};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::TctError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevocationList {
pub version: String,
pub issuer: Aid,
pub published_at: Timestamp,
pub expires_at: Timestamp,
pub entries: Vec<RevocationEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevocationEntry {
pub jti: Uuid,
pub revoked_at: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevocationListEnvelope {
pub revocation_list: RevocationList,
pub signature: String,
}
type RevocationListSigningView<'a> = &'a RevocationList;
pub fn sign_revocation_list(
body: RevocationList,
issuer_key: &AitpSigningKey,
) -> Result<RevocationListEnvelope, TctError> {
let view: RevocationListSigningView = &body;
let canonical = jcs::canonicalize_serializable(view)
.map_err(|e| TctError::Canonicalization(e.to_string()))?;
let digest = Sha256::digest(&canonical);
let sig = issuer_key.sign(&digest);
Ok(RevocationListEnvelope {
revocation_list: body,
signature: sig.into_string(),
})
}
pub fn verify_revocation_list(
envelope: &RevocationListEnvelope,
ctx: &VerifyRevocationListContext<'_>,
) -> Result<(), TctError> {
if envelope.revocation_list.version != "aitp/0.1" {
return Err(TctError::VersionUnknown);
}
if envelope.revocation_list.expires_at.is_in_the_past(ctx.now) {
return Err(TctError::Expired);
}
if &envelope.revocation_list.issuer != ctx.expected_issuer {
return Err(TctError::CnfMalformed);
}
let pubkey =
AitpVerifyingKey::from_aid(&envelope.revocation_list.issuer).map_err(TctError::Crypto)?;
let sig = Signature::parse(&envelope.signature).map_err(|_| TctError::SignatureInvalid)?;
let inner: RevocationListSigningView = &envelope.revocation_list;
let canonical_inner = jcs::canonicalize_serializable(inner)
.map_err(|e| TctError::Canonicalization(e.to_string()))?;
if pubkey
.verify(&Sha256::digest(&canonical_inner), &sig)
.is_ok()
{
return Ok(());
}
#[derive(Serialize)]
struct LegacyWrapped<'a> {
revocation_list: &'a RevocationList,
}
let wrapped = LegacyWrapped {
revocation_list: &envelope.revocation_list,
};
let canonical_wrapped = jcs::canonicalize_serializable(&wrapped)
.map_err(|e| TctError::Canonicalization(e.to_string()))?;
pubkey
.verify(&Sha256::digest(&canonical_wrapped), &sig)
.map_err(|_| TctError::SignatureInvalid)?;
Ok(())
}
pub struct VerifyRevocationListContext<'a> {
pub expected_issuer: &'a Aid,
pub now: Timestamp,
}
#[cfg(test)]
mod tests {
use super::*;
fn issuer_key() -> AitpSigningKey {
AitpSigningKey::from_seed(&[0xA0; 32])
}
fn sample_body(issuer: Aid) -> RevocationList {
RevocationList {
version: "aitp/0.1".into(),
issuer,
published_at: Timestamp(1_700_000_000),
expires_at: Timestamp(1_700_003_600),
entries: vec![RevocationEntry {
jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
revoked_at: Timestamp(1_700_001_000),
reason: None,
}],
}
}
#[test]
fn sign_then_verify_round_trips() {
let key = issuer_key();
let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
let ctx = VerifyRevocationListContext {
expected_issuer: key.aid(),
now: Timestamp(1_700_001_000),
};
verify_revocation_list(&env, &ctx).expect("fresh snapshot verifies");
}
#[test]
fn expired_is_rejected() {
let key = issuer_key();
let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
let ctx = VerifyRevocationListContext {
expected_issuer: key.aid(),
now: Timestamp(1_700_999_999),
};
assert!(matches!(
verify_revocation_list(&env, &ctx),
Err(TctError::Expired)
));
}
#[test]
fn wrong_issuer_is_rejected() {
let key = issuer_key();
let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
let other = AitpSigningKey::from_seed(&[0xB0; 32]);
let ctx = VerifyRevocationListContext {
expected_issuer: other.aid(),
now: Timestamp(1_700_001_000),
};
assert!(matches!(
verify_revocation_list(&env, &ctx),
Err(TctError::CnfMalformed)
));
}
#[test]
fn empty_entries_round_trips() {
let key = issuer_key();
let mut body = sample_body(key.aid().clone());
body.entries.clear();
let env = sign_revocation_list(body, &key).unwrap();
let ctx = VerifyRevocationListContext {
expected_issuer: key.aid(),
now: Timestamp(1_700_001_000),
};
verify_revocation_list(&env, &ctx).expect("empty list still verifies");
}
#[test]
fn rfc_kat_canonical_bytes_match() {
let body = RevocationList {
version: "aitp/0.1".into(),
issuer: Aid::parse("aid:pubkey:O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik").unwrap(),
published_at: Timestamp(1_711_900_000),
expires_at: Timestamp(1_711_903_600),
entries: vec![RevocationEntry {
jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
revoked_at: Timestamp(1_711_901_000),
reason: None,
}],
};
let view: RevocationListSigningView = &body;
let canonical = jcs::canonicalize_serializable(view).unwrap();
let expected_hex = "7b22656e7472696573223a5b7b226a7469223a2235353065383430302d653239622d343164342d613731362d343436363535343430303030222c227265766f6b65645f6174223a313731313930313030307d5d2c22657870697265735f6174223a313731313930333630302c22697373756572223a226169643a7075626b65793a4f326f6e764d3632704331696f366a514b6d384e6332557946586364346b4f6d4f7342496f59745a32696b222c227075626c69736865645f6174223a313731313930303030302c2276657273696f6e223a22616974702f302e31227d";
assert_eq!(
hex::encode(&canonical),
expected_hex,
"canonical bytes diverge from spec rc.4 kat-revocation-001"
);
let digest = Sha256::digest(&canonical);
assert_eq!(
hex::encode(digest),
"a825796920dcd964489b8417a92061664dccd976b8271e6b01d80b49973ace74"
);
}
}