Skip to main content

clawdentity_core/registry/
crl.rs

1use 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
144/// TODO(clawdentity): document `is_jti_revoked`.
145pub fn is_jti_revoked(claims: &CrlClaims, jti: &str) -> bool {
146    claims.revocations.iter().any(|entry| entry.jti == jti)
147}
148
149/// TODO(clawdentity): document `load_crl_claims`.
150#[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}