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
12pub const REVOCATION_VERSION: u32 = 1;
14
15pub struct RevocationInput<'a> {
17 pub rid: &'a str,
19 pub identity_did: &'a IdentityDID,
21 pub subject: &'a CanonicalDid,
24 pub device_public_key: &'a [u8],
26 pub device_curve: auths_crypto::CurveType,
28 pub note: Option<String>,
30 pub payload: Option<Value>,
32 pub timestamp: DateTime<Utc>,
34 pub identity_alias: &'a KeyAlias,
36}
37
38pub 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 let revoked_at_value = Some(timestamp_arg);
73 #[allow(clippy::disallowed_methods)]
74 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: ¬e,
84 };
85
86 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 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 #[allow(clippy::disallowed_methods)]
112 let revocation_issuer = CanonicalDid::new_unchecked(identity_did.as_str());
114 Ok(Attestation {
115 version: REVOCATION_VERSION,
116 #[allow(clippy::disallowed_methods)]
117 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
140use crate::domain_separation::REVOCATION_PRESIGNED_CONTEXT;
153use serde::{Deserialize, Serialize};
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PresignedRevocation {
162 pub device_did: String,
164 pub anchor_sn: u128,
167 pub not_before: DateTime<Utc>,
169 pub not_after: DateTime<Utc>,
173 pub issuer: String,
176 pub signature: Vec<u8>,
178}
179
180#[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
191pub 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#[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 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}