clawdentity_core/registry/
crl.rs1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use ed25519_dalek::{Signature, Verifier, VerifyingKey};
4use serde::{Deserialize, Serialize};
5
6use crate::db::SqliteStore;
7use crate::db::now_utc_ms;
8use crate::db_verify_cache::{get_verify_cache_entry, upsert_verify_cache_entry};
9use crate::did::{did_authority_from_url, parse_agent_did};
10use crate::error::{CoreError, Result};
11use crate::http::blocking_client;
12
13pub const CRL_CACHE_TTL_MS: i64 = 15 * 60 * 1000;
14const CRL_CACHE_KEY_PREFIX: &str = "crl::";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct CrlVerificationKey {
18 pub kid: String,
19 pub x: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct CrlRevocation {
25 pub jti: String,
26 pub agent_did: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub reason: Option<String>,
29 pub revoked_at: i64,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CrlClaims {
34 pub iss: String,
35 pub jti: String,
36 pub iat: i64,
37 pub exp: i64,
38 pub revocations: Vec<CrlRevocation>,
39}
40
41#[derive(Debug, Deserialize)]
42struct CrlResponse {
43 crl: String,
44}
45
46fn parse_jwt_parts(token: &str) -> Result<(&str, &str, &str)> {
47 let mut parts = token.split('.');
48 let header = parts
49 .next()
50 .ok_or_else(|| CoreError::InvalidInput("CRL token is invalid".to_string()))?;
51 let payload = parts
52 .next()
53 .ok_or_else(|| CoreError::InvalidInput("CRL token is invalid".to_string()))?;
54 let signature = parts
55 .next()
56 .ok_or_else(|| CoreError::InvalidInput("CRL token is invalid".to_string()))?;
57 if parts.next().is_some() {
58 return Err(CoreError::InvalidInput("CRL token is invalid".to_string()));
59 }
60 Ok((header, payload, signature))
61}
62
63fn decode_base64url(value: &str, context: &str) -> Result<Vec<u8>> {
64 URL_SAFE_NO_PAD
65 .decode(value)
66 .map_err(|_| CoreError::InvalidInput(format!("{context} is invalid base64url")))
67}
68
69fn verify_jwt_payload(
70 token: &str,
71 keys: &[CrlVerificationKey],
72 expected_issuer: Option<&str>,
73) -> Result<serde_json::Value> {
74 let (header_b64, payload_b64, signature_b64) = parse_jwt_parts(token)?;
75 let header_bytes = decode_base64url(header_b64, "CRL header")?;
76 let payload_bytes = decode_base64url(payload_b64, "CRL payload")?;
77 let signature_bytes = decode_base64url(signature_b64, "CRL signature")?;
78
79 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
80 .map_err(|_| CoreError::InvalidInput("CRL header is invalid".to_string()))?;
81 let kid = header
82 .get("kid")
83 .and_then(|value| value.as_str())
84 .ok_or_else(|| CoreError::InvalidInput("CRL header missing kid".to_string()))?;
85 let key = keys
86 .iter()
87 .find(|candidate| candidate.kid == kid)
88 .ok_or_else(|| CoreError::InvalidInput("CRL key id is unknown".to_string()))?;
89
90 let public_key_bytes = decode_base64url(&key.x, "CRL key x")?;
91 let public_key: [u8; 32] = public_key_bytes
92 .try_into()
93 .map_err(|_| CoreError::InvalidInput("CRL key x must decode to 32 bytes".to_string()))?;
94 let verifying_key = VerifyingKey::from_bytes(&public_key)
95 .map_err(|_| CoreError::InvalidInput("CRL key is invalid".to_string()))?;
96 let signature = Signature::from_slice(&signature_bytes)
97 .map_err(|_| CoreError::InvalidInput("CRL signature is invalid".to_string()))?;
98 let signed_message = format!("{header_b64}.{payload_b64}");
99 verifying_key
100 .verify(signed_message.as_bytes(), &signature)
101 .map_err(|_| CoreError::InvalidInput("CRL signature verification failed".to_string()))?;
102
103 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
104 .map_err(|_| CoreError::InvalidInput("CRL payload is invalid".to_string()))?;
105 if let Some(expected_issuer) = expected_issuer {
106 let issuer = payload
107 .get("iss")
108 .and_then(|value| value.as_str())
109 .ok_or_else(|| CoreError::InvalidInput("CRL payload missing iss".to_string()))?;
110 if issuer != expected_issuer {
111 return Err(CoreError::InvalidInput(
112 "CRL issuer does not match expected issuer".to_string(),
113 ));
114 }
115 }
116 Ok(payload)
117}
118
119fn parse_crl_claims(payload: serde_json::Value) -> Result<CrlClaims> {
120 let claims: CrlClaims = serde_json::from_value(payload)
121 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
122 if claims.exp <= claims.iat {
123 return Err(CoreError::InvalidInput(
124 "CRL claims exp must be greater than iat".to_string(),
125 ));
126 }
127 let issuer_authority = did_authority_from_url(&claims.iss, "iss")?;
128 for revocation in &claims.revocations {
129 let parsed = parse_agent_did(&revocation.agent_did).map_err(|_| {
130 CoreError::InvalidInput("CRL revocation agentDid must be an agent DID".to_string())
131 })?;
132 if parsed.authority != issuer_authority {
133 return Err(CoreError::InvalidInput(
134 "CRL revocation agentDid authority must match issuer host".to_string(),
135 ));
136 }
137 }
138 if claims.exp <= chrono::Utc::now().timestamp() {
139 return Err(CoreError::InvalidInput("CRL token is expired".to_string()));
140 }
141 Ok(claims)
142}
143
144pub fn is_jti_revoked(claims: &CrlClaims, jti: &str) -> bool {
146 claims.revocations.iter().any(|entry| entry.jti == jti)
147}
148
149#[allow(clippy::too_many_lines)]
151pub fn load_crl_claims(
152 store: &SqliteStore,
153 registry_url: &str,
154 expected_issuer: Option<&str>,
155 verification_keys: &[CrlVerificationKey],
156) -> Result<CrlClaims> {
157 if verification_keys.is_empty() {
158 return Err(CoreError::InvalidInput(
159 "at least one verification key is required".to_string(),
160 ));
161 }
162
163 let cache_key = format!("{CRL_CACHE_KEY_PREFIX}{registry_url}");
164 if let Some(cache_entry) = get_verify_cache_entry(store, &cache_key)? {
165 let age_ms = now_utc_ms() - cache_entry.fetched_at_ms;
166 if cache_entry.registry_url == registry_url
167 && age_ms <= CRL_CACHE_TTL_MS
168 && let Ok(claims) = serde_json::from_str::<CrlClaims>(&cache_entry.payload_json)
169 {
170 return Ok(claims);
171 }
172 }
173
174 let request_url = url::Url::parse(registry_url)
175 .map_err(|_| CoreError::InvalidUrl {
176 context: "registryUrl",
177 value: registry_url.to_string(),
178 })?
179 .join("/v1/crl")
180 .map_err(|_| CoreError::InvalidUrl {
181 context: "registryUrl",
182 value: registry_url.to_string(),
183 })?;
184 let response = blocking_client()?
185 .get(request_url)
186 .send()
187 .map_err(|error| CoreError::Http(error.to_string()))?;
188 if !response.status().is_success() {
189 let status = response.status().as_u16();
190 let message = response
191 .text()
192 .unwrap_or_else(|_| "failed to fetch CRL".to_string());
193 return Err(CoreError::HttpStatus { status, message });
194 }
195 let payload = response
196 .json::<CrlResponse>()
197 .map_err(|error| CoreError::Http(error.to_string()))?;
198 let verified_payload = verify_jwt_payload(&payload.crl, verification_keys, expected_issuer)?;
199 let claims = parse_crl_claims(verified_payload)?;
200 upsert_verify_cache_entry(
201 store,
202 &cache_key,
203 registry_url,
204 &serde_json::to_string(&claims)?,
205 )?;
206 Ok(claims)
207}
208
209#[cfg(test)]
210mod tests {
211 use base64::Engine;
212 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
213 use ed25519_dalek::{Signer, SigningKey};
214 use tempfile::TempDir;
215 use wiremock::matchers::{method, path};
216 use wiremock::{Mock, MockServer, ResponseTemplate};
217
218 use crate::db::SqliteStore;
219
220 use super::{CrlVerificationKey, load_crl_claims};
221
222 fn sign_crl_token(registry_url: &str, signer: &SigningKey, kid: &str) -> String {
223 let authority = url::Url::parse(registry_url)
224 .ok()
225 .and_then(|value| value.host_str().map(ToOwned::to_owned))
226 .expect("registry host");
227 let header = URL_SAFE_NO_PAD.encode(
228 serde_json::to_vec(&serde_json::json!({
229 "alg":"EdDSA",
230 "typ":"JWT",
231 "kid": kid,
232 }))
233 .expect("header"),
234 );
235 let payload = URL_SAFE_NO_PAD.encode(
236 serde_json::to_vec(&serde_json::json!({
237 "iss": registry_url,
238 "jti": "01HF7YAT00W6W7CM7N3W5FDXT4",
239 "iat": 1_700_000_000_i64,
240 "exp": 2_208_988_800_i64,
241 "revocations": [{
242 "jti":"01HF7YAT00W6W7CM7N3W5FDXT5",
243 "agentDid": format!("did:cdi:{authority}:agent:01HF7YAT00W6W7CM7N3W5FDXT6"),
244 "revokedAt": 1_700_000_010_i64
245 }]
246 }))
247 .expect("payload"),
248 );
249 let signature = URL_SAFE_NO_PAD.encode(
250 signer
251 .sign(format!("{header}.{payload}").as_bytes())
252 .to_bytes(),
253 );
254 format!("{header}.{payload}.{signature}")
255 }
256
257 #[tokio::test]
258 async fn fetches_verifies_and_caches_crl_claims() {
259 let server = MockServer::start().await;
260 let signing_key = SigningKey::from_bytes(&[5_u8; 32]);
261 let token = sign_crl_token(&server.uri(), &signing_key, "reg-key-1");
262 Mock::given(method("GET"))
263 .and(path("/v1/crl"))
264 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
265 "crl": token
266 })))
267 .mount(&server)
268 .await;
269
270 let temp = TempDir::new().expect("temp dir");
271 let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
272 let store_for_claims = store.clone();
273 let server_uri = server.uri();
274 let expected_issuer = server.uri();
275 let key = CrlVerificationKey {
276 kid: "reg-key-1".to_string(),
277 x: URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes()),
278 };
279 let claims = tokio::task::spawn_blocking(move || {
280 load_crl_claims(
281 &store_for_claims,
282 &server_uri,
283 Some(&expected_issuer),
284 &[key],
285 )
286 })
287 .await
288 .expect("join")
289 .expect("claims");
290 assert_eq!(claims.revocations.len(), 1);
291 }
292}