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
67pub 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
247pub 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(®istry_url);
256
257 let keys = load_registry_keys(store, ®istry_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, ®istry_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}