1use exo_core::{Did, Hash256, Signature, Timestamp};
21use serde::{Deserialize, Serialize};
22
23use crate::{credential::AVC_SCHEMA_VERSION, error::AvcError};
24
25pub const AVC_REVOCATION_SIGNING_DOMAIN: &str = "exo.avc.revocation.v1";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub enum AvcRevocationReason {
30 IssuerRevoked,
31 PrincipalRevoked,
32 ExpiredAuthority,
33 CompromisedKey,
34 PolicyViolation,
35 SybilChallenge,
36 EmergencyStop,
37 Superseded,
38 Other(String),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct AvcRevocation {
43 pub schema_version: u16,
44 pub credential_id: Hash256,
45 pub revoker_did: Did,
46 pub reason: AvcRevocationReason,
47 pub created_at: Timestamp,
48 pub signature: Signature,
49}
50
51#[derive(Serialize)]
52struct RevocationSigningPayload<'a> {
53 domain: &'static str,
54 schema_version: u16,
55 credential_id: &'a Hash256,
56 revoker_did: &'a Did,
57 reason: &'a AvcRevocationReason,
58 created_at: &'a Timestamp,
59}
60
61impl AvcRevocation {
62 pub fn signing_payload(&self) -> Result<Vec<u8>, AvcError> {
67 let payload = RevocationSigningPayload {
68 domain: AVC_REVOCATION_SIGNING_DOMAIN,
69 schema_version: self.schema_version,
70 credential_id: &self.credential_id,
71 revoker_did: &self.revoker_did,
72 reason: &self.reason,
73 created_at: &self.created_at,
74 };
75 let mut buf = Vec::new();
76 ciborium::ser::into_writer(&payload, &mut buf)?;
77 Ok(buf)
78 }
79}
80
81pub fn revoke_avc<F>(
91 credential_id: Hash256,
92 revoker_did: Did,
93 reason: AvcRevocationReason,
94 now: Timestamp,
95 sign: F,
96) -> Result<AvcRevocation, AvcError>
97where
98 F: FnOnce(&[u8]) -> Signature,
99{
100 if let AvcRevocationReason::Other(text) = &reason {
101 if text.trim().is_empty() {
102 return Err(AvcError::EmptyField {
103 field: "revocation.reason.Other",
104 });
105 }
106 }
107
108 let mut revocation = AvcRevocation {
109 schema_version: AVC_SCHEMA_VERSION,
110 credential_id,
111 revoker_did,
112 reason,
113 created_at: now,
114 signature: Signature::empty(),
115 };
116 let payload = revocation.signing_payload()?;
117 revocation.signature = sign(&payload);
118 Ok(revocation)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::credential::test_support::{did, h256, ts};
125
126 fn fixed_signature() -> Signature {
127 Signature::from_bytes([7u8; 64])
128 }
129
130 #[test]
131 fn revoke_avc_signs_canonical_payload() {
132 let revocation = revoke_avc(
133 h256(0xAA),
134 did("revoker"),
135 AvcRevocationReason::IssuerRevoked,
136 ts(1_000),
137 |_| fixed_signature(),
138 )
139 .unwrap();
140 assert_eq!(revocation.signature, fixed_signature());
141 assert_eq!(revocation.credential_id, h256(0xAA));
142 assert_eq!(revocation.schema_version, AVC_SCHEMA_VERSION);
143 }
144
145 #[test]
146 fn revoke_avc_payload_contains_domain_tag() {
147 let revocation = revoke_avc(
148 h256(0xAA),
149 did("revoker"),
150 AvcRevocationReason::PrincipalRevoked,
151 ts(1_000),
152 |_| fixed_signature(),
153 )
154 .unwrap();
155 let payload = revocation.signing_payload().unwrap();
156 let needle = AVC_REVOCATION_SIGNING_DOMAIN.as_bytes();
157 assert!(payload.windows(needle.len()).any(|w| w == needle));
158 }
159
160 #[test]
161 fn revoke_avc_changes_payload_with_reason() {
162 let r1 = revoke_avc(
163 h256(0xAA),
164 did("revoker"),
165 AvcRevocationReason::CompromisedKey,
166 ts(1_000),
167 |_| fixed_signature(),
168 )
169 .unwrap();
170 let r2 = revoke_avc(
171 h256(0xAA),
172 did("revoker"),
173 AvcRevocationReason::Superseded,
174 ts(1_000),
175 |_| fixed_signature(),
176 )
177 .unwrap();
178 assert_ne!(r1.signing_payload().unwrap(), r2.signing_payload().unwrap());
179 }
180
181 #[test]
182 fn revoke_avc_rejects_empty_other_reason() {
183 let err = revoke_avc(
184 h256(0xAA),
185 did("revoker"),
186 AvcRevocationReason::Other(" ".into()),
187 ts(1_000),
188 |_| fixed_signature(),
189 )
190 .unwrap_err();
191 assert!(matches!(err, AvcError::EmptyField { .. }));
192 }
193
194 #[test]
195 fn revoke_avc_accepts_non_empty_other_reason() {
196 let revocation = revoke_avc(
197 h256(0xAA),
198 did("revoker"),
199 AvcRevocationReason::Other("legal hold".into()),
200 ts(1_000),
201 |_| fixed_signature(),
202 )
203 .unwrap();
204 assert!(matches!(revocation.reason, AvcRevocationReason::Other(_)));
205 }
206
207 #[test]
208 fn revoke_avc_covers_every_reason_variant() {
209 let reasons = vec![
210 AvcRevocationReason::IssuerRevoked,
211 AvcRevocationReason::PrincipalRevoked,
212 AvcRevocationReason::ExpiredAuthority,
213 AvcRevocationReason::CompromisedKey,
214 AvcRevocationReason::PolicyViolation,
215 AvcRevocationReason::SybilChallenge,
216 AvcRevocationReason::EmergencyStop,
217 AvcRevocationReason::Superseded,
218 AvcRevocationReason::Other("audit".into()),
219 ];
220 for reason in reasons {
221 let revocation = revoke_avc(
222 h256(0xAA),
223 did("revoker"),
224 reason.clone(),
225 ts(1_000),
226 |_| fixed_signature(),
227 )
228 .unwrap();
229 assert_eq!(revocation.reason, reason);
230 }
231 }
232
233 #[test]
234 fn round_trip_serialization() {
235 let revocation = revoke_avc(
236 h256(0xAA),
237 did("revoker"),
238 AvcRevocationReason::EmergencyStop,
239 ts(1_000),
240 |_| fixed_signature(),
241 )
242 .unwrap();
243 let mut buf = Vec::new();
244 ciborium::ser::into_writer(&revocation, &mut buf).unwrap();
245 let decoded: AvcRevocation = ciborium::de::from_reader(buf.as_slice()).unwrap();
246 assert_eq!(decoded, revocation);
247 }
248}