Skip to main content

aitp_tct/
revocation.rs

1//! Signed revocation snapshots (RFC-AITP-0008 §1.5).
2//!
3//! An issuing peer publishes a periodically-refreshed signed snapshot
4//! of every TCT JTI it has revoked. Consuming peers cache the snapshot
5//! per `expires_at` and consult it before honoring a TCT. An empty
6//! `entries` array is itself a meaningful signed assertion that nothing
7//! has been revoked since the previous snapshot — this defends against
8//! a network attacker that suppresses fresher snapshots to roll back
9//! revocations.
10
11use aitp_core::{jcs, Aid, Timestamp};
12use aitp_crypto::{AitpSigningKey, AitpVerifyingKey, Signature};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use uuid::Uuid;
16
17use crate::TctError;
18
19/// Inner body of a signed revocation snapshot.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub struct RevocationList {
23    /// MUST be `"aitp/0.2"`.
24    pub version: String,
25    /// The issuing peer's AID. MUST equal the `issuer` of every TCT
26    /// covered by `entries`.
27    pub issuer: Aid,
28    /// Unix timestamp when this snapshot was signed.
29    pub published_at: Timestamp,
30    /// Unix timestamp after which this snapshot MUST NOT be cached.
31    pub expires_at: Timestamp,
32    /// Revoked-entry records. MAY be empty.
33    pub entries: Vec<RevocationEntry>,
34}
35
36/// A single revoked-TCT record.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(deny_unknown_fields)]
39pub struct RevocationEntry {
40    /// JTI of the revoked TCT.
41    pub jti: Uuid,
42    /// Unix timestamp when the issuing peer revoked the TCT.
43    pub revoked_at: Timestamp,
44    /// Optional human-readable reason. Not used in trust decisions.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub reason: Option<String>,
47}
48
49/// On-wire envelope: `{"revocation_list": {...}, "signature": "..."}`.
50///
51/// Per RFC-AITP-0008 §1.5, both `revocation_list` and `signature` are
52/// REQUIRED. `signature` is base64url over
53/// `sha256(JCS({"revocation_list": {...}}))` — the envelope minus the
54/// signature field — per the v0.2 `kat-revocation-001` vector.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(deny_unknown_fields)]
57pub struct RevocationListEnvelope {
58    /// The signed inner snapshot.
59    pub revocation_list: RevocationList,
60    /// Issuer's base64url signature over JCS-canonical bytes of
61    /// `{"revocation_list": {...}}`.
62    pub signature: String,
63}
64
65/// Signing view: the wrapped `{"revocation_list": {...}}` form (the
66/// envelope minus `signature`), per the v0.2 `kat-revocation-001`
67/// vector.
68#[derive(Serialize)]
69struct RevocationListSigningView<'a> {
70    revocation_list: &'a RevocationList,
71}
72
73/// Sign a [`RevocationList`] body with the issuer's signing key.
74///
75/// Returns the on-wire [`RevocationListEnvelope`] with `signature`
76/// populated. The signing input is `sha256(JCS({"revocation_list": {...}}))`
77/// per the v0.2 `kat-revocation-001` vector.
78pub fn sign_revocation_list(
79    body: RevocationList,
80    issuer_key: &AitpSigningKey,
81) -> Result<RevocationListEnvelope, TctError> {
82    let view = RevocationListSigningView {
83        revocation_list: &body,
84    };
85    let canonical = jcs::canonicalize_serializable(&view)
86        .map_err(|e| TctError::Canonicalization(e.to_string()))?;
87    let digest = Sha256::digest(&canonical);
88    let sig = issuer_key.sign(&digest);
89    Ok(RevocationListEnvelope {
90        revocation_list: body,
91        signature: sig.into_string(),
92    })
93}
94
95/// Verify a [`RevocationListEnvelope`].
96///
97/// 1. `revocation_list.expires_at >= ctx.now` — else `TctError::Expired`.
98/// 2. `revocation_list.issuer` resolves to a public key matching
99///    `ctx.expected_issuer` (else `TctError::CnfMalformed` — chosen
100///    rather than introducing a new error variant for v0.1).
101/// 3. `signature` is present and verifies under that public key over
102///    `sha256(JCS(envelope without signature))`.
103pub fn verify_revocation_list(
104    envelope: &RevocationListEnvelope,
105    ctx: &VerifyRevocationListContext<'_>,
106) -> Result<(), TctError> {
107    if envelope.revocation_list.version != aitp_core::PROTOCOL_VERSION {
108        return Err(TctError::VersionUnknown);
109    }
110    if envelope.revocation_list.expires_at.is_in_the_past(ctx.now) {
111        return Err(TctError::Expired);
112    }
113    if &envelope.revocation_list.issuer != ctx.expected_issuer {
114        return Err(TctError::CnfMalformed);
115    }
116
117    let pubkey =
118        AitpVerifyingKey::from_aid(&envelope.revocation_list.issuer).map_err(TctError::Crypto)?;
119    let sig = Signature::parse(&envelope.signature).map_err(|_| TctError::SignatureInvalid)?;
120
121    let view = RevocationListSigningView {
122        revocation_list: &envelope.revocation_list,
123    };
124    let canonical = jcs::canonicalize_serializable(&view)
125        .map_err(|e| TctError::Canonicalization(e.to_string()))?;
126    pubkey
127        .verify(&Sha256::digest(&canonical), &sig)
128        .map_err(|_| TctError::SignatureInvalid)?;
129    Ok(())
130}
131
132/// Context for [`verify_revocation_list`].
133pub struct VerifyRevocationListContext<'a> {
134    /// The AID the verifier expects this snapshot to be from.
135    pub expected_issuer: &'a Aid,
136    /// Verifier's clock for `expires_at` check.
137    pub now: Timestamp,
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn issuer_key() -> AitpSigningKey {
145        AitpSigningKey::from_seed(&[0xA0; 32])
146    }
147
148    fn sample_body(issuer: Aid) -> RevocationList {
149        RevocationList {
150            version: "aitp/0.2".into(),
151            issuer,
152            published_at: Timestamp(1_700_000_000),
153            expires_at: Timestamp(1_700_003_600),
154            entries: vec![RevocationEntry {
155                jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
156                revoked_at: Timestamp(1_700_001_000),
157                reason: None,
158            }],
159        }
160    }
161
162    #[test]
163    fn sign_then_verify_round_trips() {
164        let key = issuer_key();
165        let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
166        let ctx = VerifyRevocationListContext {
167            expected_issuer: key.aid(),
168            now: Timestamp(1_700_001_000),
169        };
170        verify_revocation_list(&env, &ctx).expect("fresh snapshot verifies");
171    }
172
173    #[test]
174    fn expired_is_rejected() {
175        let key = issuer_key();
176        let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
177        let ctx = VerifyRevocationListContext {
178            expected_issuer: key.aid(),
179            now: Timestamp(1_700_999_999),
180        };
181        assert!(matches!(
182            verify_revocation_list(&env, &ctx),
183            Err(TctError::Expired)
184        ));
185    }
186
187    #[test]
188    fn wrong_issuer_is_rejected() {
189        let key = issuer_key();
190        let env = sign_revocation_list(sample_body(key.aid().clone()), &key).unwrap();
191        let other = AitpSigningKey::from_seed(&[0xB0; 32]);
192        let ctx = VerifyRevocationListContext {
193            expected_issuer: other.aid(),
194            now: Timestamp(1_700_001_000),
195        };
196        assert!(matches!(
197            verify_revocation_list(&env, &ctx),
198            Err(TctError::CnfMalformed)
199        ));
200    }
201
202    #[test]
203    fn empty_entries_round_trips() {
204        let key = issuer_key();
205        let mut body = sample_body(key.aid().clone());
206        body.entries.clear();
207        let env = sign_revocation_list(body, &key).unwrap();
208        let ctx = VerifyRevocationListContext {
209            expected_issuer: key.aid(),
210            now: Timestamp(1_700_001_000),
211        };
212        verify_revocation_list(&env, &ctx).expect("empty list still verifies");
213    }
214
215    #[test]
216    fn rfc_kat_canonical_bytes_match() {
217        // Vector kat-revocation-001 from the v0.2 spec
218        // schemas/conformance/known-answer/jcs-sha256.json: signed view
219        // is the wrapped `{"revocation_list": {...}}` form, version
220        // literal `aitp/0.2`.
221        let body = RevocationList {
222            version: "aitp/0.2".into(),
223            issuer: Aid::parse("aid:pubkey:O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik").unwrap(),
224            published_at: Timestamp(1_711_900_000),
225            expires_at: Timestamp(1_711_903_600),
226            entries: vec![RevocationEntry {
227                jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
228                revoked_at: Timestamp(1_711_901_000),
229                reason: None,
230            }],
231        };
232        let view = RevocationListSigningView {
233            revocation_list: &body,
234        };
235        let canonical = jcs::canonicalize_serializable(&view).unwrap();
236        let expected_hex = "7b227265766f636174696f6e5f6c697374223a7b22656e7472696573223a5b7b226a7469223a2235353065383430302d653239622d343164342d613731362d343436363535343430303030222c227265766f6b65645f6174223a313731313930313030307d5d2c22657870697265735f6174223a313731313930333630302c22697373756572223a226169643a7075626b65793a4f326f6e764d3632704331696f366a514b6d384e6332557946586364346b4f6d4f7342496f59745a32696b222c227075626c69736865645f6174223a313731313930303030302c2276657273696f6e223a22616974702f302e32227d7d";
237        assert_eq!(
238            hex::encode(&canonical),
239            expected_hex,
240            "canonical bytes diverge from spec v0.2 kat-revocation-001"
241        );
242        let digest = Sha256::digest(&canonical);
243        assert_eq!(
244            hex::encode(digest),
245            "739feb36cc2530ad3188f6c3a9ee7459820533382ee24387a8c261787397e0d9"
246        );
247    }
248
249    #[test]
250    fn spec_signed_example_snapshot_verifies() {
251        // signed-examples/revocation/kat-keypair-001-snapshot.json:
252        // re-mint from the pinned seed and verify byte-stable signature.
253        let key = AitpSigningKey::from_seed(&[0u8; 32]); // kat-keypair-001
254        let body = RevocationList {
255            version: "aitp/0.2".into(),
256            issuer: key.aid().clone(),
257            published_at: Timestamp(1_711_900_000),
258            expires_at: Timestamp(1_711_903_600),
259            entries: vec![RevocationEntry {
260                jti: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440099").unwrap(),
261                revoked_at: Timestamp(1_711_900_060),
262                reason: Some("key_compromised".into()),
263            }],
264        };
265        let env = sign_revocation_list(body, &key).unwrap();
266        assert_eq!(
267            env.signature,
268            "2OYmur9NnrFsrz4Qeso_fGj2Bk0g2y6yNf4H7dqrqEvKZ-YfndY3GavquOIodWGs4EFdgmaHoer0NWc7sPF1DQ",
269            "signature diverges from the spec signed-example vector"
270        );
271        let ctx = VerifyRevocationListContext {
272            expected_issuer: key.aid(),
273            now: Timestamp(1_711_900_100),
274        };
275        verify_revocation_list(&env, &ctx).expect("spec vector verifies");
276    }
277}