1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub struct RevocationList {
23 pub version: String,
25 pub issuer: Aid,
28 pub published_at: Timestamp,
30 pub expires_at: Timestamp,
32 pub entries: Vec<RevocationEntry>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(deny_unknown_fields)]
39pub struct RevocationEntry {
40 pub jti: Uuid,
42 pub revoked_at: Timestamp,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub reason: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(deny_unknown_fields)]
57pub struct RevocationListEnvelope {
58 pub revocation_list: RevocationList,
60 pub signature: String,
63}
64
65#[derive(Serialize)]
69struct RevocationListSigningView<'a> {
70 revocation_list: &'a RevocationList,
71}
72
73pub 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
95pub 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
132pub struct VerifyRevocationListContext<'a> {
134 pub expected_issuer: &'a Aid,
136 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 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 let key = AitpSigningKey::from_seed(&[0u8; 32]); 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}