Skip to main content

exo_avc/
revocation.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! AVC revocations: signed records that block future validation of a
18//! credential, regardless of expiry.
19
20use exo_core::{Did, Hash256, Signature, Timestamp};
21use serde::{Deserialize, Serialize};
22
23use crate::{credential::AVC_SCHEMA_VERSION, error::AvcError};
24
25/// Domain tag for AVC revocations.
26pub 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    /// Compute the canonical signing payload bytes for this revocation.
63    ///
64    /// # Errors
65    /// Returns [`AvcError::Serialization`] when CBOR encoding fails.
66    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
81/// Build and sign a revocation record.
82///
83/// The supplied `sign` closure is invoked exactly once over the
84/// canonical signing payload. The returned record can be inserted into
85/// any registry implementing `AvcRegistryWrite`.
86///
87/// # Errors
88/// Returns [`AvcError`] for structural failures (e.g. empty `Other`
89/// reason) or CBOR encoding failures.
90pub 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}