Skip to main content

idprova_registry/
lib.rs

1//! # idprova-registry
2//!
3//! HTTP registry server for AID resolution and management.
4//!
5//! Re-exports `build_app()` for integration testing.
6
7pub mod store;
8
9use axum::{
10    body::Body,
11    extract::{ConnectInfo, Path, State},
12    http::{HeaderValue, Method, Request, StatusCode},
13    middleware::{self, Next},
14    response::{Json, Response},
15    routing::{delete, get, post, put},
16    Router,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20use std::collections::{HashMap, VecDeque};
21use std::net::{IpAddr, SocketAddr};
22use std::sync::{Arc, Mutex};
23use std::time::Instant;
24use tower_http::cors::{AllowOrigin, Any, CorsLayer};
25use tower_http::limit::RequestBodyLimitLayer;
26
27use store::{AidListEntry, AidStore, RevocationRecord};
28
29// ── Registry admin public key ─────────────────────────────────────────────────
30
31/// Load the registry admin public key from the `REGISTRY_ADMIN_PUBKEY` environment variable.
32pub fn load_admin_pubkey() -> Option<[u8; 32]> {
33    let hex_str = std::env::var("REGISTRY_ADMIN_PUBKEY").ok()?;
34    let bytes = hex::decode(hex_str.trim()).ok()?;
35    bytes.try_into().ok()
36}
37
38// ── Per-IP rate limiter ───────────────────────────────────────────────────────
39
40/// Maximum number of unique IPs tracked by the rate limiter.
41/// When exceeded, the oldest entries are evicted (LRU).
42const RATE_LIMITER_MAX_ENTRIES: usize = 10_000;
43
44#[derive(Default)]
45struct RateLimiter {
46    /// Per-IP request timestamps within the current window.
47    windows: HashMap<String, Vec<Instant>>,
48    /// Insertion-order queue for LRU eviction.
49    order: VecDeque<String>,
50}
51
52impl RateLimiter {
53    fn check_and_record(&mut self, ip: &str, limit: usize) -> bool {
54        let now = Instant::now();
55
56        // LRU eviction: if at capacity and this is a new IP, evict oldest entries
57        if !self.windows.contains_key(ip) && self.windows.len() >= RATE_LIMITER_MAX_ENTRIES {
58            // Evict 10% of oldest entries to amortize cleanup
59            let evict_count = RATE_LIMITER_MAX_ENTRIES / 10;
60            for _ in 0..evict_count {
61                if let Some(old_ip) = self.order.pop_front() {
62                    self.windows.remove(&old_ip);
63                }
64            }
65        }
66
67        let is_new = !self.windows.contains_key(ip);
68        let window = self.windows.entry(ip.to_string()).or_default();
69        window.retain(|t| now.duration_since(*t).as_secs() < 60);
70        if window.len() >= limit {
71            return false;
72        }
73        window.push(now);
74
75        // Track insertion order for LRU
76        if is_new {
77            self.order.push_back(ip.to_string());
78        }
79        true
80    }
81}
82
83/// Shared application state.
84///
85/// `AidStore` uses an r2d2 connection pool internally, so no Mutex is needed for it.
86#[derive(Clone)]
87pub struct AppState {
88    pub store: AidStore,
89    /// Ed25519 public key for admin DAT verification. None = open (dev mode).
90    pub admin_pubkey: Option<[u8; 32]>,
91    /// Per-IP rate limiter.
92    rate_limiter: Arc<Mutex<RateLimiter>>,
93}
94
95impl AppState {
96    pub fn new(store: AidStore, admin_pubkey: Option<[u8; 32]>) -> Self {
97        Self {
98            store,
99            admin_pubkey,
100            rate_limiter: Arc::new(Mutex::new(RateLimiter::default())),
101        }
102    }
103}
104
105type SharedState = Arc<AppState>;
106
107/// Load CORS allowed origins from the `CORS_ALLOWED_ORIGINS` env var.
108///
109/// Format: comma-separated list of origins (e.g. "https://idprova.dev,https://app.idprova.dev").
110/// If unset or empty, all origins are allowed (development mode).
111fn load_cors_origins() -> Option<Vec<HeaderValue>> {
112    let raw = std::env::var("CORS_ALLOWED_ORIGINS").ok()?;
113    let trimmed = raw.trim();
114    if trimmed.is_empty() {
115        return None;
116    }
117    let origins: Vec<HeaderValue> = trimmed
118        .split(',')
119        .filter_map(|s| {
120            let s = s.trim();
121            if s.is_empty() {
122                None
123            } else {
124                HeaderValue::from_str(s).ok()
125            }
126        })
127        .collect();
128    if origins.is_empty() {
129        None
130    } else {
131        Some(origins)
132    }
133}
134
135/// Build the registry router for the given state.
136///
137/// Exposed for integration testing — call with an in-memory store.
138pub fn build_app(state: AppState) -> Router {
139    let shared: SharedState = Arc::new(state);
140
141    // CORS: restrict write-method origins when CORS_ALLOWED_ORIGINS is set.
142    // GET/HEAD/OPTIONS remain permissive for public reads.
143    let cors = match load_cors_origins() {
144        Some(origins) => CorsLayer::new()
145            .allow_methods([Method::GET, Method::HEAD, Method::OPTIONS,
146                           Method::POST, Method::PUT, Method::DELETE])
147            .allow_headers(Any)
148            .allow_origin(AllowOrigin::list(origins)),
149        None => CorsLayer::new()
150            .allow_methods(Any)
151            .allow_headers(Any)
152            .allow_origin(Any),
153    };
154
155    Router::new()
156        .route("/health", get(health))
157        .route("/v1/meta", get(meta))
158        .route("/v1/aids", get(list_aids))
159        .route("/v1/aid/:id", put(register_aid))
160        .route("/v1/aid/:id", get(resolve_aid))
161        .route("/v1/aid/:id", delete(deactivate_aid))
162        .route("/v1/aid/:id/key", get(get_public_key))
163        .route("/v1/dat/verify", post(verify_dat))
164        .route("/v1/dat/revoke", post(revoke_dat))
165        .route("/v1/dat/revoked/:jti", get(check_revocation))
166        .layer(middleware::from_fn_with_state(
167            shared.clone(),
168            rate_limit_middleware,
169        ))
170        .layer(middleware::from_fn(security_headers))
171        .layer(RequestBodyLimitLayer::new(1024 * 1024))
172        .layer(cors)
173        .with_state(shared)
174}
175
176/// Axum middleware that appends security headers to every response.
177async fn security_headers(request: Request<Body>, next: Next) -> Response {
178    let mut response = next.run(request).await;
179    let headers = response.headers_mut();
180    headers.insert(
181        "X-Content-Type-Options",
182        HeaderValue::from_static("nosniff"),
183    );
184    headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
185    headers.insert(
186        "Strict-Transport-Security",
187        HeaderValue::from_static("max-age=31536000; includeSubDomains"),
188    );
189    headers.insert(
190        "X-XSS-Protection",
191        HeaderValue::from_static("1; mode=block"),
192    );
193    headers.insert(
194        "Content-Security-Policy",
195        HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
196    );
197    response
198}
199
200// ── Write authorization helper ────────────────────────────────────────────────
201
202fn require_write_auth(
203    state: &AppState,
204    headers: &axum::http::HeaderMap,
205) -> Result<(), (StatusCode, Json<Value>)> {
206    let pubkey = match state.admin_pubkey {
207        Some(k) => k,
208        None => return Ok(()),
209    };
210
211    let auth = headers.get("Authorization").ok_or_else(|| {
212        (
213            StatusCode::UNAUTHORIZED,
214            Json(json!({ "error": "Authorization header required for write operations" })),
215        )
216    })?;
217
218    let auth_str = auth.to_str().unwrap_or("");
219    let token = auth_str.strip_prefix("Bearer ").unwrap_or("").trim();
220    if token.is_empty() {
221        return Err((
222            StatusCode::UNAUTHORIZED,
223            Json(json!({ "error": "Bearer token required" })),
224        ));
225    }
226
227    let ctx = idprova_core::dat::constraints::EvaluationContext::default();
228    idprova_verify::verify_dat(token, &pubkey, "", &ctx).map_err(|e| {
229        (
230            StatusCode::UNAUTHORIZED,
231            Json(json!({ "error": format!("invalid admin token: {e}") })),
232        )
233    })?;
234
235    Ok(())
236}
237
238// ── Rate limiting middleware ───────────────────────────────────────────────────
239
240async fn rate_limit_middleware(
241    State(state): State<SharedState>,
242    connect_info: Option<ConnectInfo<SocketAddr>>,
243    req: Request<Body>,
244    next: Next,
245) -> Response {
246    // Determine client IP: prefer proxy headers, fall back to peer socket address.
247    let ip = req
248        .headers()
249        .get("X-Forwarded-For")
250        .and_then(|v| v.to_str().ok())
251        .and_then(|s| s.split(',').next())
252        .map(|s| s.trim().to_string())
253        .or_else(|| {
254            req.headers()
255                .get("X-Real-IP")
256                .and_then(|v| v.to_str().ok())
257                .map(|s| s.trim().to_string())
258        })
259        .or_else(|| connect_info.map(|ci| ci.0.ip().to_string()))
260        .unwrap_or_else(|| "unknown".to_string());
261
262    let allowed = {
263        let mut limiter = state
264            .rate_limiter
265            .lock()
266            .unwrap_or_else(|e| e.into_inner());
267        limiter.check_and_record(&ip, 120)
268    };
269
270    if allowed {
271        next.run(req).await
272    } else {
273        let body = serde_json::to_string(&json!({
274            "error": "rate limit exceeded — max 120 requests per 60 seconds per IP"
275        }))
276        .unwrap_or_default();
277        Response::builder()
278            .status(StatusCode::TOO_MANY_REQUESTS)
279            .header("Content-Type", "application/json")
280            .header("Retry-After", "60")
281            .body(Body::from(body))
282            .unwrap()
283    }
284}
285
286async fn health() -> Json<Value> {
287    Json(json!({
288        "status": "ok",
289        "version": env!("CARGO_PKG_VERSION"),
290        "protocol": "idprova/0.1"
291    }))
292}
293
294async fn meta() -> Json<Value> {
295    Json(json!({
296        "protocolVersion": "0.1",
297        "registryVersion": env!("CARGO_PKG_VERSION"),
298        "didMethod": "did:idprova",
299        "supportedAlgorithms": ["EdDSA"],
300        "supportedHashAlgorithms": ["blake3", "sha-256"]
301    }))
302}
303
304async fn list_aids(
305    State(state): State<SharedState>,
306) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
307    let entries: Vec<AidListEntry> = state.store.list_active().map_err(|e| {
308        (
309            StatusCode::INTERNAL_SERVER_ERROR,
310            Json(json!({ "error": format!("storage error: {e}") })),
311        )
312    })?;
313    Ok(Json(json!({
314        "total": entries.len(),
315        "aids": entries
316    })))
317}
318
319async fn register_aid(
320    State(state): State<SharedState>,
321    headers: axum::http::HeaderMap,
322    Path(id): Path<String>,
323    Json(body): Json<Value>,
324) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
325    require_write_auth(&state, &headers)?;
326
327    let did = format!("did:idprova:{id}");
328
329    let doc: idprova_core::aid::AidDocument = serde_json::from_value(body).map_err(|e| {
330        (
331            StatusCode::BAD_REQUEST,
332            Json(json!({ "error": format!("invalid AID document: {e}") })),
333        )
334    })?;
335
336    if let Err(e) = doc.validate() {
337        return Err((
338            StatusCode::BAD_REQUEST,
339            Json(json!({ "error": format!("AID validation failed: {e}") })),
340        ));
341    }
342
343    let is_new = state.store.put(&did, &doc).map_err(|e| {
344        (
345            StatusCode::INTERNAL_SERVER_ERROR,
346            Json(json!({ "error": format!("storage error: {e}") })),
347        )
348    })?;
349
350    let status = if is_new {
351        StatusCode::CREATED
352    } else {
353        StatusCode::OK
354    };
355
356    Ok((
357        status,
358        Json(json!({
359            "id": did,
360            "status": if is_new { "created" } else { "updated" }
361        })),
362    ))
363}
364
365async fn resolve_aid(
366    State(state): State<SharedState>,
367    Path(id): Path<String>,
368) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
369    let did = format!("did:idprova:{id}");
370
371    match state.store.get(&did) {
372        Ok(Some(doc)) => Ok(Json(serde_json::to_value(doc).unwrap())),
373        Ok(None) => Err((
374            StatusCode::NOT_FOUND,
375            Json(json!({ "error": format!("AID not found: {did}") })),
376        )),
377        Err(e) => Err((
378            StatusCode::INTERNAL_SERVER_ERROR,
379            Json(json!({ "error": format!("storage error: {e}") })),
380        )),
381    }
382}
383
384async fn deactivate_aid(
385    State(state): State<SharedState>,
386    headers: axum::http::HeaderMap,
387    Path(id): Path<String>,
388) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
389    require_write_auth(&state, &headers)?;
390    let did = format!("did:idprova:{id}");
391
392    match state.store.delete(&did) {
393        Ok(true) => Ok(Json(json!({ "id": did, "status": "deactivated" }))),
394        Ok(false) => Err((
395            StatusCode::NOT_FOUND,
396            Json(json!({ "error": format!("AID not found: {did}") })),
397        )),
398        Err(e) => Err((
399            StatusCode::INTERNAL_SERVER_ERROR,
400            Json(json!({ "error": format!("storage error: {e}") })),
401        )),
402    }
403}
404
405async fn get_public_key(
406    State(state): State<SharedState>,
407    Path(id): Path<String>,
408) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
409    let did = format!("did:idprova:{id}");
410
411    match state.store.get(&did) {
412        Ok(Some(doc)) => {
413            let keys: Vec<Value> = doc
414                .verification_method
415                .iter()
416                .map(|vm| {
417                    json!({
418                        "id": vm.id,
419                        "type": vm.key_type,
420                        "publicKeyMultibase": vm.public_key_multibase
421                    })
422                })
423                .collect();
424            Ok(Json(json!({ "id": did, "keys": keys })))
425        }
426        Ok(None) => Err((
427            StatusCode::NOT_FOUND,
428            Json(json!({ "error": format!("AID not found: {did}") })),
429        )),
430        Err(e) => Err((
431            StatusCode::INTERNAL_SERVER_ERROR,
432            Json(json!({ "error": format!("storage error: {e}") })),
433        )),
434    }
435}
436
437// ────────────────────────────────────────────────────────────────────────────
438// POST /v1/dat/verify
439// ────────────────────────────────────────────────────────────────────────────
440
441/// Request body for DAT verification.
442#[derive(Debug, Deserialize, Serialize)]
443pub struct DatVerifyRequest {
444    pub token: String,
445    #[serde(default)]
446    pub scope: String,
447    pub request_ip: Option<String>,
448    pub trust_level: Option<u8>,
449    #[serde(default)]
450    pub delegation_depth: u32,
451    #[serde(default)]
452    pub actions_in_window: u64,
453    pub country_code: Option<String>,
454    pub agent_config_hash: Option<String>,
455}
456
457/// Response from DAT verification.
458#[derive(Debug, Serialize)]
459pub struct DatVerifyResponse {
460    pub valid: bool,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub issuer: Option<String>,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub subject: Option<String>,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub scopes: Option<Vec<String>>,
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub jti: Option<String>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub error: Option<String>,
471}
472
473async fn verify_dat(
474    State(state): State<SharedState>,
475    Json(req): Json<DatVerifyRequest>,
476) -> Result<Json<DatVerifyResponse>, (StatusCode, Json<DatVerifyResponse>)> {
477    use idprova_core::crypto::KeyPair;
478    use idprova_core::dat::constraints::EvaluationContext;
479    use idprova_core::dat::Dat;
480
481    let err_resp = |msg: String| {
482        (
483            StatusCode::BAD_REQUEST,
484            Json(DatVerifyResponse {
485                valid: false,
486                issuer: None,
487                subject: None,
488                scopes: None,
489                jti: None,
490                error: Some(msg),
491            }),
492        )
493    };
494
495    // 1. Decode token (no sig check yet)
496    let dat =
497        Dat::from_compact(&req.token).map_err(|e| err_resp(format!("malformed token: {e}")))?;
498
499    let issuer_did = dat.claims.iss.clone();
500    let subject = dat.claims.sub.clone();
501    let scopes = dat.claims.scope.clone();
502    let jti = dat.claims.jti.clone();
503
504    // 1b. Revocation check — fail fast before any crypto work
505    match state.store.get_revocation(&jti) {
506        Ok(Some(rev)) => {
507            tracing::info!("Rejected revoked DAT jti={jti} reason={}", rev.reason);
508            return Ok(Json(DatVerifyResponse {
509                valid: false,
510                issuer: Some(issuer_did),
511                subject: Some(subject),
512                scopes: Some(scopes),
513                jti: Some(jti),
514                error: Some(format!(
515                    "DAT revoked at {} by {}: {}",
516                    rev.revoked_at, rev.revoked_by, rev.reason
517                )),
518            }));
519        }
520        Ok(None) => {} // not revoked, continue
521        Err(e) => return Err(err_resp(format!("revocation check error: {e}"))),
522    }
523
524    // 2. Extract DID from kid: "{did}#key-ed25519"
525    let kid = &dat.header.kid;
526    let issuer_from_kid = kid
527        .split('#')
528        .next()
529        .ok_or_else(|| err_resp(format!("kid has unexpected format: {kid}")))?
530        .to_string();
531
532    // 3. Resolve AID from registry store
533    let doc = state
534        .store
535        .get(&issuer_from_kid)
536        .map_err(|e| err_resp(format!("store error: {e}")))?
537        .ok_or_else(|| err_resp(format!("issuer AID not found: {issuer_from_kid}")))?;
538
539    // 4. Find the verification key matching the kid
540    let kid_fragment = kid.split('#').nth(1).unwrap_or("");
541    let vm = doc
542        .verification_method
543        .iter()
544        .find(|vm| {
545            vm.id == *kid
546                || vm.id == format!("{issuer_from_kid}#key-ed25519")
547                || vm.id == format!("#{kid_fragment}")
548        })
549        .ok_or_else(|| err_resp(format!("key '{kid}' not found in issuer AID")))?;
550
551    let pub_key_bytes = KeyPair::decode_multibase_pubkey(&vm.public_key_multibase)
552        .map_err(|e| err_resp(format!("key decode error: {e}")))?;
553
554    // 5. Build evaluation context from request fields
555    let request_ip: Option<IpAddr> = req.request_ip.as_deref().and_then(|s| s.parse().ok());
556
557    let ctx = EvaluationContext {
558        actions_in_window: req.actions_in_window,
559        request_ip,
560        agent_trust_level: req.trust_level,
561        delegation_depth: req.delegation_depth,
562        country_code: req.country_code,
563        current_timestamp: None,
564        agent_config_hash: req.agent_config_hash,
565    };
566
567    // 6. Full verification pipeline
568    match dat.verify(&pub_key_bytes, &req.scope, &ctx) {
569        Ok(()) => Ok(Json(DatVerifyResponse {
570            valid: true,
571            issuer: Some(issuer_did),
572            subject: Some(subject),
573            scopes: Some(scopes),
574            jti: Some(jti),
575            error: None,
576        })),
577        Err(e) => {
578            tracing::warn!("DAT verification failed for jti={jti}: {e}");
579            Ok(Json(DatVerifyResponse {
580                valid: false,
581                issuer: Some(issuer_did),
582                subject: Some(subject),
583                scopes: Some(scopes),
584                jti: Some(jti),
585                error: Some(e.to_string()),
586            }))
587        }
588    }
589}
590
591// ────────────────────────────────────────────────────────────────────────────
592// POST /v1/dat/revoke
593// ────────────────────────────────────────────────────────────────────────────
594
595#[derive(Debug, Deserialize)]
596pub struct RevokeRequest {
597    pub jti: String,
598    #[serde(default)]
599    pub reason: String,
600    #[serde(default)]
601    pub revoked_by: String,
602}
603
604async fn revoke_dat(
605    State(state): State<SharedState>,
606    headers: axum::http::HeaderMap,
607    Json(req): Json<RevokeRequest>,
608) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
609    require_write_auth(&state, &headers)?;
610
611    if req.jti.is_empty() {
612        return Err((
613            StatusCode::BAD_REQUEST,
614            Json(json!({ "error": "jti must not be empty" })),
615        ));
616    }
617    if req.jti.len() > 128 {
618        return Err((
619            StatusCode::BAD_REQUEST,
620            Json(json!({ "error": "jti exceeds maximum length of 128 characters" })),
621        ));
622    }
623    if req.reason.len() > 512 {
624        return Err((
625            StatusCode::BAD_REQUEST,
626            Json(json!({ "error": "reason exceeds maximum length of 512 characters" })),
627        ));
628    }
629    if req.revoked_by.len() > 256 {
630        return Err((
631            StatusCode::BAD_REQUEST,
632            Json(json!({ "error": "revoked_by exceeds maximum length of 256 characters" })),
633        ));
634    }
635
636    match state.store.revoke(&req.jti, &req.reason, &req.revoked_by) {
637        Ok(true) => {
638            tracing::info!("Revoked DAT jti={} by={}", req.jti, req.revoked_by);
639            Ok(Json(json!({
640                "jti": req.jti,
641                "status": "revoked",
642                "reason": req.reason,
643                "revoked_by": req.revoked_by
644            })))
645        }
646        Ok(false) => Ok(Json(json!({
647            "jti": req.jti,
648            "status": "already_revoked"
649        }))),
650        Err(e) => Err((
651            StatusCode::INTERNAL_SERVER_ERROR,
652            Json(json!({ "error": format!("store error: {e}") })),
653        )),
654    }
655}
656
657// ────────────────────────────────────────────────────────────────────────────
658// GET /v1/dat/revoked/:jti
659// ────────────────────────────────────────────────────────────────────────────
660
661async fn check_revocation(
662    State(state): State<SharedState>,
663    Path(jti): Path<String>,
664) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
665    match state.store.get_revocation(&jti) {
666        Ok(Some(RevocationRecord {
667            jti,
668            reason,
669            revoked_by,
670            revoked_at,
671        })) => Ok(Json(json!({
672            "revoked": true,
673            "jti": jti,
674            "reason": reason,
675            "revoked_by": revoked_by,
676            "revoked_at": revoked_at
677        }))),
678        Ok(None) => Ok(Json(json!({ "revoked": false, "jti": jti }))),
679        Err(e) => Err((
680            StatusCode::INTERNAL_SERVER_ERROR,
681            Json(json!({ "error": format!("store error: {e}") })),
682        )),
683    }
684}