use std::fs;
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::crl::{CrlVerificationKey, is_jti_revoked, load_crl_claims};
use crate::db::SqliteStore;
use crate::db::now_utc_ms;
use crate::db_verify_cache::{get_verify_cache_entry, upsert_verify_cache_entry};
use crate::did::{did_authority_from_url, parse_agent_did, parse_human_did};
use crate::error::{CoreError, Result};
use crate::http::blocking_client;
pub const REGISTRY_KEYS_CACHE_TTL_MS: i64 = 60 * 60 * 1000;
const REGISTRY_KEYS_CACHE_KEY_PREFIX: &str = "registry-keys::";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegistrySigningKey {
pub kid: String,
pub alg: String,
pub crv: String,
pub x: String,
pub status: String,
}
#[derive(Debug, Deserialize)]
struct RegistryKeysResponse {
keys: Vec<RegistrySigningKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryVerificationKey {
pub kid: String,
pub x: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiedAitClaims {
pub iss: String,
pub sub: String,
pub owner_did: String,
pub jti: String,
pub exp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyResult {
pub passed: bool,
pub reason: String,
pub claims: Option<VerifiedAitClaims>,
}
fn normalize_registry_url(registry_url: &str) -> Result<String> {
url::Url::parse(registry_url)
.map(|value| value.to_string())
.map_err(|_| CoreError::InvalidUrl {
context: "registryUrl",
value: registry_url.to_string(),
})
}
pub fn expected_issuer_for_registry(registry_url: &str) -> Option<String> {
let parsed = url::Url::parse(registry_url).ok()?;
Some(parsed.origin().unicode_serialization())
}
fn resolve_token(token_or_file: &str) -> Result<String> {
let candidate = token_or_file.trim();
if candidate.is_empty() {
return Err(CoreError::InvalidInput("token value is empty".to_string()));
}
let path = Path::new(candidate);
if path.exists() {
let raw = fs::read_to_string(path).map_err(|source| CoreError::Io {
path: path.to_path_buf(),
source,
})?;
let token = raw.trim();
if token.is_empty() {
return Err(CoreError::InvalidInput("token file is empty".to_string()));
}
return Ok(token.to_string());
}
Ok(candidate.to_string())
}
fn parse_active_verification_keys(keys: &[RegistrySigningKey]) -> Vec<RegistryVerificationKey> {
keys.iter()
.filter(|key| key.status == "active")
.map(|key| RegistryVerificationKey {
kid: key.kid.clone(),
x: key.x.clone(),
})
.collect()
}
fn load_registry_keys(store: &SqliteStore, registry_url: &str) -> Result<Vec<RegistrySigningKey>> {
let cache_key = format!("{REGISTRY_KEYS_CACHE_KEY_PREFIX}{registry_url}");
if let Some(cache_entry) = get_verify_cache_entry(store, &cache_key)? {
let age_ms = now_utc_ms() - cache_entry.fetched_at_ms;
if cache_entry.registry_url == registry_url
&& age_ms <= REGISTRY_KEYS_CACHE_TTL_MS
&& let Ok(keys) =
serde_json::from_str::<Vec<RegistrySigningKey>>(&cache_entry.payload_json)
{
return Ok(keys);
}
}
let request_url = url::Url::parse(registry_url)
.map_err(|_| CoreError::InvalidUrl {
context: "registryUrl",
value: registry_url.to_string(),
})?
.join("/.well-known/claw-keys.json")
.map_err(|_| CoreError::InvalidUrl {
context: "registryUrl",
value: registry_url.to_string(),
})?;
let response = blocking_client()?
.get(request_url)
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "verification keys unavailable".to_string());
return Err(CoreError::HttpStatus { status, message });
}
let payload = response
.json::<RegistryKeysResponse>()
.map_err(|error| CoreError::Http(error.to_string()))?;
if payload.keys.is_empty() {
return Err(CoreError::InvalidInput(
"verification keys unavailable (no signing keys)".to_string(),
));
}
upsert_verify_cache_entry(
store,
&cache_key,
registry_url,
&serde_json::to_string(&payload.keys)?,
)?;
Ok(payload.keys)
}
fn decode_base64url(value: &str, context: &str) -> Result<Vec<u8>> {
URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| CoreError::InvalidInput(format!("{context} is invalid base64url")))
}
#[allow(clippy::too_many_lines)]
fn verify_ait_token(
token: &str,
keys: &[RegistryVerificationKey],
expected_issuer: Option<&str>,
) -> Result<VerifiedAitClaims> {
if keys.is_empty() {
return Err(CoreError::InvalidInput(
"verification keys unavailable (no active keys)".to_string(),
));
}
let mut parts = token.split('.');
let header_b64 = parts
.next()
.ok_or_else(|| CoreError::InvalidInput("invalid token".to_string()))?;
let payload_b64 = parts
.next()
.ok_or_else(|| CoreError::InvalidInput("invalid token".to_string()))?;
let signature_b64 = parts
.next()
.ok_or_else(|| CoreError::InvalidInput("invalid token".to_string()))?;
if parts.next().is_some() {
return Err(CoreError::InvalidInput("invalid token".to_string()));
}
let header_bytes = decode_base64url(header_b64, "token header")?;
let payload_bytes = decode_base64url(payload_b64, "token payload")?;
let signature_bytes = decode_base64url(signature_b64, "token signature")?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|_| CoreError::InvalidInput("invalid token header".to_string()))?;
let kid = header
.get("kid")
.and_then(|value| value.as_str())
.ok_or_else(|| CoreError::InvalidInput("invalid token header (missing kid)".to_string()))?;
let key = keys
.iter()
.find(|key| key.kid == kid)
.ok_or_else(|| CoreError::InvalidInput("invalid token (unknown kid)".to_string()))?;
let public_key_bytes = decode_base64url(&key.x, "verification key")?;
let public_key: [u8; 32] = public_key_bytes
.try_into()
.map_err(|_| CoreError::InvalidInput("verification key is invalid".to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&public_key)
.map_err(|_| CoreError::InvalidInput("verification key is invalid".to_string()))?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|_| CoreError::InvalidInput("invalid token signature".to_string()))?;
verifying_key
.verify(format!("{header_b64}.{payload_b64}").as_bytes(), &signature)
.map_err(|_| CoreError::InvalidInput("invalid token signature".to_string()))?;
let claims: VerifiedAitClaims = serde_json::from_slice(&payload_bytes)
.map_err(|_| CoreError::InvalidInput("invalid token payload".to_string()))?;
if claims.exp <= chrono::Utc::now().timestamp() {
return Err(CoreError::InvalidInput("token is expired".to_string()));
}
if let Some(expected_issuer) = expected_issuer
&& claims.iss != expected_issuer
{
return Err(CoreError::InvalidInput(
"token issuer does not match expected issuer".to_string(),
));
}
let sub = parse_agent_did(&claims.sub)
.map_err(|_| CoreError::InvalidInput("token sub must be an agent DID".to_string()))?;
let owner = parse_human_did(&claims.owner_did)
.map_err(|_| CoreError::InvalidInput("token ownerDid must be a human DID".to_string()))?;
let issuer_authority = did_authority_from_url(&claims.iss, "iss")?;
if sub.authority != issuer_authority {
return Err(CoreError::InvalidInput(
"token sub authority must match token issuer host".to_string(),
));
}
if owner.authority != issuer_authority {
return Err(CoreError::InvalidInput(
"token ownerDid authority must match token issuer host".to_string(),
));
}
Ok(claims)
}
pub fn verify_ait_token_with_registry(
store: &SqliteStore,
registry_url: &str,
token_or_file: &str,
) -> Result<VerifyResult> {
let registry_url = normalize_registry_url(registry_url)?;
let token = resolve_token(token_or_file)?;
let expected_issuer = expected_issuer_for_registry(®istry_url);
let keys = load_registry_keys(store, ®istry_url)?;
let verification_keys = parse_active_verification_keys(&keys);
let claims = match verify_ait_token(&token, &verification_keys, expected_issuer.as_deref()) {
Ok(claims) => claims,
Err(error) => {
return Ok(VerifyResult {
passed: false,
reason: error.to_string(),
claims: None,
});
}
};
let crl_keys = verification_keys
.iter()
.map(|key| CrlVerificationKey {
kid: key.kid.clone(),
x: key.x.clone(),
})
.collect::<Vec<_>>();
let crl_claims = load_crl_claims(store, ®istry_url, expected_issuer.as_deref(), &crl_keys)?;
if is_jti_revoked(&crl_claims, &claims.jti) {
return Ok(VerifyResult {
passed: false,
reason: "revoked".to_string(),
claims: Some(claims),
});
}
Ok(VerifyResult {
passed: true,
reason: format!("token verified ({})", claims.sub),
claims: Some(claims),
})
}
#[cfg(test)]
mod tests {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::db::SqliteStore;
use super::verify_ait_token_with_registry;
fn did_authority(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|value| value.host_str().map(ToOwned::to_owned))
.expect("issuer host")
}
fn sign_jwt_token(
_issuer: &str,
kid: &str,
signer: &SigningKey,
claims: serde_json::Value,
) -> String {
let header = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({
"alg":"EdDSA",
"typ":"JWT",
"kid": kid,
}))
.expect("header"),
);
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims"));
let signature = URL_SAFE_NO_PAD.encode(
signer
.sign(format!("{header}.{payload}").as_bytes())
.to_bytes(),
);
format!("{header}.{payload}.{signature}")
}
#[tokio::test]
async fn verifies_token_with_registry_keys_and_crl() {
let server = MockServer::start().await;
let signing_key = SigningKey::from_bytes(&[9_u8; 32]);
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes());
let authority = did_authority(&server.uri());
let ait_claims = serde_json::json!({
"iss": server.uri(),
"sub": format!("did:cdi:{authority}:agent:01HF7YAT00W6W7CM7N3W5FDXT4"),
"ownerDid": format!("did:cdi:{authority}:human:01HF7YAT31JZHSMW1CG6Q6MHB7"),
"jti": "01HF7YAT00W6W7CM7N3W5FDXT5",
"exp": 2_208_988_800_i64
});
let ait_token = sign_jwt_token(&server.uri(), "reg-key-1", &signing_key, ait_claims);
let crl_claims = serde_json::json!({
"iss": server.uri(),
"jti": "01HF7YAT00W6W7CM7N3W5FDXT6",
"iat": 1_700_000_000_i64,
"exp": 2_208_988_800_i64,
"revocations": [{
"jti":"01HF7YAT00W6W7CM7N3W5FDXT9",
"agentDid": format!("did:cdi:{authority}:agent:01HF7YAT00W6W7CM7N3W5FDXT4"),
"revokedAt": 1_700_000_100_i64
}]
});
let crl_token = sign_jwt_token(&server.uri(), "reg-key-1", &signing_key, crl_claims);
Mock::given(method("GET"))
.and(path("/.well-known/claw-keys.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"keys": [{
"kid": "reg-key-1",
"alg": "EdDSA",
"crv": "Ed25519",
"x": public_key,
"status": "active"
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/crl"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"crl": crl_token
})))
.mount(&server)
.await;
let temp = TempDir::new().expect("temp dir");
let store = SqliteStore::open_path(temp.path().join("db.sqlite3")).expect("db");
let store_for_verify = store.clone();
let server_uri = server.uri();
let result = tokio::task::spawn_blocking(move || {
verify_ait_token_with_registry(&store_for_verify, &server_uri, &ait_token)
})
.await
.expect("join")
.expect("verify");
assert!(result.passed);
}
}