Skip to main content

auths_id/attestation/
revoke.rs

1use crate::attestation::create::{CanonicalRevocationData, canonicalize_revocation_data};
2use auths_core::signing::{PassphraseProvider, SecureSigner};
3use auths_core::storage::keychain::{IdentityDID, KeyAlias};
4use auths_verifier::core::{Attestation, Ed25519Signature, ResourceId};
5use auths_verifier::error::AttestationError;
6use auths_verifier::types::CanonicalDid;
7
8use chrono::{DateTime, Utc};
9use log::{debug, warn};
10use serde_json::Value;
11
12/// Revocation version - stays at v1 since revocations don't need org fields
13pub const REVOCATION_VERSION: u32 = 1;
14
15/// Inputs for `create_signed_revocation`.
16pub struct RevocationInput<'a> {
17    /// Resource identifier of the attestation being revoked.
18    pub rid: &'a str,
19    /// Issuing identity DID (`did:keri:…`).
20    pub identity_did: &'a IdentityDID,
21    /// Subject DID being revoked. Wire-format compatible: accepts
22    /// either `did:key:` or `did:keri:` forms.
23    pub subject: &'a CanonicalDid,
24    /// Raw device public key bytes (32 Ed25519, 33 P-256 compressed).
25    pub device_public_key: &'a [u8],
26    /// Signing curve of `device_public_key`.
27    pub device_curve: auths_crypto::CurveType,
28    /// Optional note explaining the revocation reason.
29    pub note: Option<String>,
30    /// Optional JSON payload (usually `None`).
31    pub payload: Option<Value>,
32    /// Timestamp of the revocation.
33    pub timestamp: DateTime<Utc>,
34    /// Identity-key alias in the keychain.
35    pub identity_alias: &'a KeyAlias,
36}
37
38/// Creates a signed revocation attestation using the provided SecureSigner.
39///
40/// Constructs the canonical revocation data, signs it using the identity
41/// key via the signer, and returns the complete revocation attestation.
42///
43/// Args:
44/// * `input`: Revocation payload + metadata (see [`RevocationInput`]).
45/// * `signer`: SecureSigner implementation.
46/// * `passphrase_provider`: Provider for obtaining passphrases during signing.
47///
48/// Usage:
49/// ```ignore
50/// let revocation = create_signed_revocation(input, signer, passphrase)?;
51/// ```
52pub fn create_signed_revocation(
53    input: RevocationInput<'_>,
54    signer: &dyn SecureSigner,
55    passphrase_provider: &dyn PassphraseProvider,
56) -> Result<Attestation, AttestationError> {
57    let RevocationInput {
58        rid,
59        identity_did,
60        subject,
61        device_public_key,
62        device_curve,
63        note,
64        payload: payload_arg,
65        timestamp: timestamp_arg,
66        identity_alias,
67    } = input;
68
69    warn!("Creating revocation for subject {}", subject);
70
71    // 1. Construct the revocation-specific canonical data
72    let revoked_at_value = Some(timestamp_arg);
73    #[allow(clippy::disallowed_methods)]
74    // INVARIANT: identity_did is an IdentityDID which guarantees valid DID format
75    let issuer_canonical = CanonicalDid::new_unchecked(identity_did.as_str());
76    let data_to_canonicalize_revocation = CanonicalRevocationData {
77        version: REVOCATION_VERSION,
78        rid,
79        issuer: &issuer_canonical,
80        subject,
81        timestamp: &Some(timestamp_arg),
82        revoked_at: &revoked_at_value,
83        note: &note,
84    };
85
86    // 2. Canonicalize the revocation data
87    let canonical_bytes = canonicalize_revocation_data(&data_to_canonicalize_revocation)?;
88    debug!(
89        "Canonical revocation data: {}",
90        String::from_utf8_lossy(&canonical_bytes)
91    );
92
93    // 3. Sign with the identity key
94    debug!(
95        "Signing revocation with identity alias '{}'",
96        identity_alias
97    );
98    let identity_sig_bytes = signer
99        .sign_with_alias(identity_alias, passphrase_provider, &canonical_bytes)
100        .map_err(|e| {
101            AttestationError::SigningError(format!(
102                "Failed to sign revocation with identity key '{}': {}",
103                identity_alias, e
104            ))
105        })?;
106    let identity_signature = Ed25519Signature::try_from_slice(&identity_sig_bytes)
107        .map_err(|e| AttestationError::SigningError(e.to_string()))?;
108    debug!("Revocation signature obtained successfully");
109
110    // 4. Return the final revocation attestation object
111    #[allow(clippy::disallowed_methods)]
112    // INVARIANT: identity_did is an IdentityDID which guarantees valid DID format
113    let revocation_issuer = CanonicalDid::new_unchecked(identity_did.as_str());
114    Ok(Attestation {
115        version: REVOCATION_VERSION,
116        #[allow(clippy::disallowed_methods)]
117        // INVARIANT: subject is a validated CanonicalDid from the caller
118        subject: CanonicalDid::new_unchecked(subject.as_str()),
119        issuer: revocation_issuer,
120        rid: ResourceId::new(rid),
121        payload: payload_arg.clone(),
122        timestamp: Some(timestamp_arg),
123        expires_at: None,
124        revoked_at: Some(timestamp_arg),
125        note: note.clone(),
126        device_public_key: auths_verifier::DevicePublicKey::try_new(device_curve, device_public_key)
127            .map_err(|e| AttestationError::InvalidInput(e.to_string()))?,
128        identity_signature,
129        device_signature: Ed25519Signature::empty(),
130        delegated_by: None,
131        signer_type: None,
132        environment_claim: None,
133        commit_sha: None,
134        commit_message: None,
135        author: None,
136        oidc_binding: None,
137    })
138}
139
140// =============================================================================
141// Pre-signed revocations
142//
143// At pair time the controller identity pre-signs a revocation bound to a
144// specific `(device_did, anchor_sn, not_before, not_after)` tuple and stores
145// it locally. On device compromise — even without the controlling device
146// being online — the stored cert can be submitted to the transparency log
147// and (once witnessed) propagated to verifiers. The trade-off is well-
148// known: theft of the cert is a DoS (the device is still revokable by
149// normal rotation), not a key-material compromise.
150// =============================================================================
151
152use crate::domain_separation::REVOCATION_PRESIGNED_CONTEXT;
153use serde::{Deserialize, Serialize};
154
155/// A revocation attestation signed at pair time and held for future
156/// use. The signature covers a canonical byte string that includes
157/// [`REVOCATION_PRESIGNED_CONTEXT`], which domain-separates this
158/// blob from live-signed revocations so a live-ctx signature cannot
159/// be replayed as a pre-signed cert (or vice versa).
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PresignedRevocation {
162    /// The device this revocation targets (`did:key:z…`).
163    pub device_did: String,
164    /// KEL sequence number at which the cert is anchored. Prevents
165    /// replaying an old cert against a post-rotation device state.
166    pub anchor_sn: u128,
167    /// Earliest time this cert is valid to publish.
168    pub not_before: DateTime<Utc>,
169    /// Latest time this cert is valid to publish. Set to the time of
170    /// the next planned rotation in production — bounded-validity
171    /// cert is strictly stronger than the PGP-lifetime model.
172    pub not_after: DateTime<Utc>,
173    /// The controller identity DID (`did:keri:…`) that signed this
174    /// cert.
175    pub issuer: String,
176    /// Ed25519 signature (64 bytes) over the canonical byte string.
177    pub signature: Vec<u8>,
178}
179
180/// Errors specific to pre-signed revocation certs.
181#[derive(Debug, thiserror::Error)]
182pub enum PresignedRevocationError {
183    #[error("signing failed: {0}")]
184    Signing(String),
185    #[error("window validation failed: not_before {nb} >= not_after {na}")]
186    InvalidWindow { nb: String, na: String },
187    #[error("attestation error: {0}")]
188    Attestation(#[from] AttestationError),
189}
190
191/// Canonical signing bytes for a pre-signed revocation.
192///
193/// `<context>\n<issuer>\n<device_did>\n<anchor_sn>\n<not_before>\n<not_after>`
194///
195/// `<context>` is [`REVOCATION_PRESIGNED_CONTEXT`]. Encoding
196/// choices: `anchor_sn` as decimal, timestamps as RFC 3339. The
197/// exact bytes are what gets signed — drift from this format breaks
198/// every existing cert.
199pub fn canonicalize_presigned_revocation(
200    issuer: &str,
201    device_did: &str,
202    anchor_sn: u128,
203    not_before: DateTime<Utc>,
204    not_after: DateTime<Utc>,
205) -> Vec<u8> {
206    let mut out = Vec::with_capacity(256);
207    out.extend_from_slice(REVOCATION_PRESIGNED_CONTEXT);
208    out.push(b'\n');
209    out.extend_from_slice(issuer.as_bytes());
210    out.push(b'\n');
211    out.extend_from_slice(device_did.as_bytes());
212    out.push(b'\n');
213    out.extend_from_slice(anchor_sn.to_string().as_bytes());
214    out.push(b'\n');
215    out.extend_from_slice(not_before.to_rfc3339().as_bytes());
216    out.push(b'\n');
217    out.extend_from_slice(not_after.to_rfc3339().as_bytes());
218    out
219}
220
221/// Pre-sign a revocation cert for a device key at pair time.
222///
223/// Args:
224/// * `identity_did`: The controller identity DID (`did:keri:…`).
225/// * `device_did`: The device DID (`did:key:z…`) this cert revokes.
226/// * `anchor_sn`: The device-keyring KEL sequence number at pair time.
227/// * `not_before` / `not_after`: Validity window. `not_before` typically
228///   = now at pair time; `not_after` should bound to the next planned
229///   rotation. Ten years is a reasonable ceiling for long-lived
230///   devices; shorter windows are strictly stronger.
231/// * `signer` / `passphrase_provider` / `identity_alias`: Signing key
232///   handle, mirroring the live-revocation API shape.
233///
234/// Usage:
235/// ```ignore
236/// let cert = create_presigned_revocation(
237///     &identity_did, &device_did, anchor_sn, now, now + Duration::days(365),
238///     &*signer, &*passphrase_provider, &identity_alias,
239/// )?;
240/// storage.save_presigned_revocation(&device_did, &cert)?;
241/// // On compromise: submit `cert` to the transparency log + propagate.
242/// ```
243#[allow(clippy::too_many_arguments)]
244pub fn create_presigned_revocation(
245    identity_did: &IdentityDID,
246    device_did: &CanonicalDid,
247    anchor_sn: u128,
248    not_before: DateTime<Utc>,
249    not_after: DateTime<Utc>,
250    signer: &dyn SecureSigner,
251    passphrase_provider: &dyn PassphraseProvider,
252    identity_alias: &KeyAlias,
253) -> Result<PresignedRevocation, PresignedRevocationError> {
254    if not_before >= not_after {
255        return Err(PresignedRevocationError::InvalidWindow {
256            nb: not_before.to_rfc3339(),
257            na: not_after.to_rfc3339(),
258        });
259    }
260
261    let canonical = canonicalize_presigned_revocation(
262        identity_did.as_str(),
263        device_did.as_str(),
264        anchor_sn,
265        not_before,
266        not_after,
267    );
268    let sig_bytes = signer
269        .sign_with_alias(identity_alias, passphrase_provider, &canonical)
270        .map_err(|e| PresignedRevocationError::Signing(e.to_string()))?;
271
272    Ok(PresignedRevocation {
273        device_did: device_did.as_str().to_string(),
274        anchor_sn,
275        not_before,
276        not_after,
277        issuer: identity_did.as_str().to_string(),
278        signature: sig_bytes,
279    })
280}
281
282#[cfg(test)]
283mod presigned_tests {
284    use super::*;
285
286    #[test]
287    fn canonical_bytes_include_context_label() {
288        let bytes = canonicalize_presigned_revocation(
289            "did:keri:ETest",
290            "did:key:z6MkTest",
291            42,
292            Utc::now(),
293            Utc::now() + chrono::Duration::days(1),
294        );
295        let s = String::from_utf8_lossy(&bytes);
296        assert!(s.starts_with("auths-revocation-presigned-v1\n"));
297        assert!(s.contains("\ndid:keri:ETest\n"));
298        assert!(s.contains("\ndid:key:z6MkTest\n"));
299        assert!(s.contains("\n42\n"));
300    }
301
302    #[test]
303    fn canonical_bytes_differ_from_live_revocation_context() {
304        // The pre-signed and live-signed contexts must be distinct so
305        // cross-context replay is impossible.
306        use crate::domain_separation::REVOCATION_LIVE_CONTEXT;
307        assert_ne!(REVOCATION_LIVE_CONTEXT, REVOCATION_PRESIGNED_CONTEXT);
308    }
309
310    #[test]
311    fn canonical_bytes_include_timestamps_in_rfc3339_form() {
312        use chrono::TimeZone;
313        let nb = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
314        let na = Utc.with_ymd_and_hms(2036, 1, 1, 0, 0, 0).unwrap();
315        let bytes =
316            canonicalize_presigned_revocation("did:keri:ETest", "did:key:z6MkTest", 7, nb, na);
317        let s = String::from_utf8_lossy(&bytes);
318        assert!(s.contains("2026-01-01T00:00:00+00:00"));
319        assert!(s.contains("2036-01-01T00:00:00+00:00"));
320    }
321}