Skip to main content

clawdentity_core/
verify.rs

1use std::fs;
2use std::path::Path;
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use ed25519_dalek::{Signature, Verifier, VerifyingKey};
7use serde::{Deserialize, Serialize};
8
9use crate::crl::{CrlVerificationKey, is_jti_revoked, load_crl_claims};
10use crate::db::SqliteStore;
11use crate::db::now_utc_ms;
12use crate::db_verify_cache::{get_verify_cache_entry, upsert_verify_cache_entry};
13use crate::did::{did_authority_from_url, parse_agent_did, parse_human_did};
14use crate::error::{CoreError, Result};
15use crate::http::blocking_client;
16
17pub const REGISTRY_KEYS_CACHE_TTL_MS: i64 = 60 * 60 * 1000;
18const REGISTRY_KEYS_CACHE_KEY_PREFIX: &str = "registry-keys::";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct RegistrySigningKey {
23    pub kid: String,
24    pub alg: String,
25    pub crv: String,
26    pub x: String,
27    pub status: String,
28}
29
30#[derive(Debug, Deserialize)]
31struct RegistryKeysResponse {
32    keys: Vec<RegistrySigningKey>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RegistryVerificationKey {
37    pub kid: String,
38    pub x: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct VerifiedAitClaims {
44    pub iss: String,
45    pub sub: String,
46    pub owner_did: String,
47    pub jti: String,
48    pub exp: i64,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct VerifyResult {
53    pub passed: bool,
54    pub reason: String,
55    pub claims: Option<VerifiedAitClaims>,
56}
57
58fn normalize_registry_url(registry_url: &str) -> Result<String> {
59    url::Url::parse(registry_url)
60        .map(|value| value.to_string())
61        .map_err(|_| CoreError::InvalidUrl {
62            context: "registryUrl",
63            value: registry_url.to_string(),
64        })
65}
66
67/// TODO(clawdentity): document `expected_issuer_for_registry`.
68pub fn expected_issuer_for_registry(registry_url: &str) -> Option<String> {
69    let parsed = url::Url::parse(registry_url).ok()?;
70    Some(parsed.origin().unicode_serialization())
71}
72
73fn resolve_token(token_or_file: &str) -> Result<String> {
74    let candidate = token_or_file.trim();
75    if candidate.is_empty() {
76        return Err(CoreError::InvalidInput("token value is empty".to_string()));
77    }
78
79    let path = Path::new(candidate);
80    if path.exists() {
81        let raw = fs::read_to_string(path).map_err(|source| CoreError::Io {
82            path: path.to_path_buf(),
83            source,
84        })?;
85        let token = raw.trim();
86        if token.is_empty() {
87            return Err(CoreError::InvalidInput("token file is empty".to_string()));
88        }
89        return Ok(token.to_string());
90    }
91
92    Ok(candidate.to_string())
93}
94
95fn parse_active_verification_keys(keys: &[RegistrySigningKey]) -> Vec<RegistryVerificationKey> {
96    keys.iter()
97        .filter(|key| key.status == "active")
98        .map(|key| RegistryVerificationKey {
99            kid: key.kid.clone(),
100            x: key.x.clone(),
101        })
102        .collect()
103}
104
105fn load_registry_keys(store: &SqliteStore, registry_url: &str) -> Result<Vec<RegistrySigningKey>> {
106    let cache_key = format!("{REGISTRY_KEYS_CACHE_KEY_PREFIX}{registry_url}");
107    if let Some(cache_entry) = get_verify_cache_entry(store, &cache_key)? {
108        let age_ms = now_utc_ms() - cache_entry.fetched_at_ms;
109        if cache_entry.registry_url == registry_url
110            && age_ms <= REGISTRY_KEYS_CACHE_TTL_MS
111            && let Ok(keys) =
112                serde_json::from_str::<Vec<RegistrySigningKey>>(&cache_entry.payload_json)
113        {
114            return Ok(keys);
115        }
116    }
117
118    let request_url = url::Url::parse(registry_url)
119        .map_err(|_| CoreError::InvalidUrl {
120            context: "registryUrl",
121            value: registry_url.to_string(),
122        })?
123        .join("/.well-known/claw-keys.json")
124        .map_err(|_| CoreError::InvalidUrl {
125            context: "registryUrl",
126            value: registry_url.to_string(),
127        })?;
128    let response = blocking_client()?
129        .get(request_url)
130        .send()
131        .map_err(|error| CoreError::Http(error.to_string()))?;
132    if !response.status().is_success() {
133        let status = response.status().as_u16();
134        let message = response
135            .text()
136            .unwrap_or_else(|_| "verification keys unavailable".to_string());
137        return Err(CoreError::HttpStatus { status, message });
138    }
139    let payload = response
140        .json::<RegistryKeysResponse>()
141        .map_err(|error| CoreError::Http(error.to_string()))?;
142    if payload.keys.is_empty() {
143        return Err(CoreError::InvalidInput(
144            "verification keys unavailable (no signing keys)".to_string(),
145        ));
146    }
147    upsert_verify_cache_entry(
148        store,
149        &cache_key,
150        registry_url,
151        &serde_json::to_string(&payload.keys)?,
152    )?;
153    Ok(payload.keys)
154}
155
156fn decode_base64url(value: &str, context: &str) -> Result<Vec<u8>> {
157    URL_SAFE_NO_PAD
158        .decode(value)
159        .map_err(|_| CoreError::InvalidInput(format!("{context} is invalid base64url")))
160}
161
162#[allow(clippy::too_many_lines)]
163fn verify_ait_token(
164    token: &str,
165    keys: &[RegistryVerificationKey],
166    expected_issuer: Option<&str>,
167) -> Result<VerifiedAitClaims> {
168    if keys.is_empty() {
169        return Err(CoreError::InvalidInput(
170            "verification keys unavailable (no active keys)".to_string(),
171        ));
172    }
173
174    let mut parts = token.split('.');
175    let header_b64 = parts
176        .next()
177        .ok_or_else(|| CoreError::InvalidInput("invalid token".to_string()))?;
178    let payload_b64 = parts
179        .next()
180        .ok_or_else(|| CoreError::InvalidInput("invalid token".to_string()))?;
181    let signature_b64 = parts
182        .next()
183        .ok_or_else(|| CoreError::InvalidInput("invalid token".to_string()))?;
184    if parts.next().is_some() {
185        return Err(CoreError::InvalidInput("invalid token".to_string()));
186    }
187
188    let header_bytes = decode_base64url(header_b64, "token header")?;
189    let payload_bytes = decode_base64url(payload_b64, "token payload")?;
190    let signature_bytes = decode_base64url(signature_b64, "token signature")?;
191
192    let header: serde_json::Value = serde_json::from_slice(&header_bytes)
193        .map_err(|_| CoreError::InvalidInput("invalid token header".to_string()))?;
194    let kid = header
195        .get("kid")
196        .and_then(|value| value.as_str())
197        .ok_or_else(|| CoreError::InvalidInput("invalid token header (missing kid)".to_string()))?;
198    let key = keys
199        .iter()
200        .find(|key| key.kid == kid)
201        .ok_or_else(|| CoreError::InvalidInput("invalid token (unknown kid)".to_string()))?;
202
203    let public_key_bytes = decode_base64url(&key.x, "verification key")?;
204    let public_key: [u8; 32] = public_key_bytes
205        .try_into()
206        .map_err(|_| CoreError::InvalidInput("verification key is invalid".to_string()))?;
207    let verifying_key = VerifyingKey::from_bytes(&public_key)
208        .map_err(|_| CoreError::InvalidInput("verification key is invalid".to_string()))?;
209    let signature = Signature::from_slice(&signature_bytes)
210        .map_err(|_| CoreError::InvalidInput("invalid token signature".to_string()))?;
211    verifying_key
212        .verify(format!("{header_b64}.{payload_b64}").as_bytes(), &signature)
213        .map_err(|_| CoreError::InvalidInput("invalid token signature".to_string()))?;
214
215    let claims: VerifiedAitClaims = serde_json::from_slice(&payload_bytes)
216        .map_err(|_| CoreError::InvalidInput("invalid token payload".to_string()))?;
217    if claims.exp <= chrono::Utc::now().timestamp() {
218        return Err(CoreError::InvalidInput("token is expired".to_string()));
219    }
220    if let Some(expected_issuer) = expected_issuer
221        && claims.iss != expected_issuer
222    {
223        return Err(CoreError::InvalidInput(
224            "token issuer does not match expected issuer".to_string(),
225        ));
226    }
227
228    let sub = parse_agent_did(&claims.sub)
229        .map_err(|_| CoreError::InvalidInput("token sub must be an agent DID".to_string()))?;
230    let owner = parse_human_did(&claims.owner_did)
231        .map_err(|_| CoreError::InvalidInput("token ownerDid must be a human DID".to_string()))?;
232    let issuer_authority = did_authority_from_url(&claims.iss, "iss")?;
233    if sub.authority != issuer_authority {
234        return Err(CoreError::InvalidInput(
235            "token sub authority must match token issuer host".to_string(),
236        ));
237    }
238    if owner.authority != issuer_authority {
239        return Err(CoreError::InvalidInput(
240            "token ownerDid authority must match token issuer host".to_string(),
241        ));
242    }
243
244    Ok(claims)
245}
246
247/// TODO(clawdentity): document `verify_ait_token_with_registry`.
248pub fn verify_ait_token_with_registry(
249    store: &SqliteStore,
250    registry_url: &str,
251    token_or_file: &str,
252) -> Result<VerifyResult> {
253    let registry_url = normalize_registry_url(registry_url)?;
254    let token = resolve_token(token_or_file)?;
255    let expected_issuer = expected_issuer_for_registry(&registry_url);
256
257    let keys = load_registry_keys(store, &registry_url)?;
258    let verification_keys = parse_active_verification_keys(&keys);
259    let claims = match verify_ait_token(&token, &verification_keys, expected_issuer.as_deref()) {
260        Ok(claims) => claims,
261        Err(error) => {
262            return Ok(VerifyResult {
263                passed: false,
264                reason: error.to_string(),
265                claims: None,
266            });
267        }
268    };
269
270    let crl_keys = verification_keys
271        .iter()
272        .map(|key| CrlVerificationKey {
273            kid: key.kid.clone(),
274            x: key.x.clone(),
275        })
276        .collect::<Vec<_>>();
277    let crl_claims = load_crl_claims(store, &registry_url, expected_issuer.as_deref(), &crl_keys)?;
278    if is_jti_revoked(&crl_claims, &claims.jti) {
279        return Ok(VerifyResult {
280            passed: false,
281            reason: "revoked".to_string(),
282            claims: Some(claims),
283        });
284    }
285
286    Ok(VerifyResult {
287        passed: true,
288        reason: format!("token verified ({})", claims.sub),
289        claims: Some(claims),
290    })
291}
292
293#[cfg(test)]
294mod tests {
295    use base64::Engine;
296    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
297    use ed25519_dalek::{Signer, SigningKey};
298    use tempfile::TempDir;
299    use wiremock::matchers::{method, path};
300    use wiremock::{Mock, MockServer, ResponseTemplate};
301
302    use crate::db::SqliteStore;
303
304    use super::verify_ait_token_with_registry;
305
306    fn did_authority(url: &str) -> String {
307        url::Url::parse(url)
308            .ok()
309            .and_then(|value| value.host_str().map(ToOwned::to_owned))
310            .expect("issuer host")
311    }
312
313    fn sign_jwt_token(
314        _issuer: &str,
315        kid: &str,
316        signer: &SigningKey,
317        claims: serde_json::Value,
318    ) -> String {
319        let header = URL_SAFE_NO_PAD.encode(
320            serde_json::to_vec(&serde_json::json!({
321                "alg":"EdDSA",
322                "typ":"JWT",
323                "kid": kid,
324            }))
325            .expect("header"),
326        );
327        let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims"));
328        let signature = URL_SAFE_NO_PAD.encode(
329            signer
330                .sign(format!("{header}.{payload}").as_bytes())
331                .to_bytes(),
332        );
333        format!("{header}.{payload}.{signature}")
334    }
335
336    #[tokio::test]
337    async fn verifies_token_with_registry_keys_and_crl() {
338        let server = MockServer::start().await;
339        let signing_key = SigningKey::from_bytes(&[9_u8; 32]);
340        let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes());
341        let authority = did_authority(&server.uri());
342        let ait_claims = serde_json::json!({
343            "iss": server.uri(),
344            "sub": format!("did:cdi:{authority}:agent:01HF7YAT00W6W7CM7N3W5FDXT4"),
345            "ownerDid": format!("did:cdi:{authority}:human:01HF7YAT31JZHSMW1CG6Q6MHB7"),
346            "jti": "01HF7YAT00W6W7CM7N3W5FDXT5",
347            "exp": 2_208_988_800_i64
348        });
349        let ait_token = sign_jwt_token(&server.uri(), "reg-key-1", &signing_key, ait_claims);
350        let crl_claims = serde_json::json!({
351            "iss": server.uri(),
352            "jti": "01HF7YAT00W6W7CM7N3W5FDXT6",
353            "iat": 1_700_000_000_i64,
354            "exp": 2_208_988_800_i64,
355            "revocations": [{
356                "jti":"01HF7YAT00W6W7CM7N3W5FDXT9",
357                "agentDid": format!("did:cdi:{authority}:agent:01HF7YAT00W6W7CM7N3W5FDXT4"),
358                "revokedAt": 1_700_000_100_i64
359            }]
360        });
361        let crl_token = sign_jwt_token(&server.uri(), "reg-key-1", &signing_key, crl_claims);
362
363        Mock::given(method("GET"))
364            .and(path("/.well-known/claw-keys.json"))
365            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
366                "keys": [{
367                    "kid": "reg-key-1",
368                    "alg": "EdDSA",
369                    "crv": "Ed25519",
370                    "x": public_key,
371                    "status": "active"
372                }]
373            })))
374            .mount(&server)
375            .await;
376        Mock::given(method("GET"))
377            .and(path("/v1/crl"))
378            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
379                "crl": crl_token
380            })))
381            .mount(&server)
382            .await;
383
384        let temp = TempDir::new().expect("temp dir");
385        let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
386        let store_for_verify = store.clone();
387        let server_uri = server.uri();
388        let result = tokio::task::spawn_blocking(move || {
389            verify_ait_token_with_registry(&store_for_verify, &server_uri, &ait_token)
390        })
391        .await
392        .expect("join")
393        .expect("verify");
394        assert!(result.passed);
395    }
396}