1pub 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
29pub 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
38const RATE_LIMITER_MAX_ENTRIES: usize = 10_000;
43
44#[derive(Default)]
45struct RateLimiter {
46 windows: HashMap<String, Vec<Instant>>,
48 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 if !self.windows.contains_key(ip) && self.windows.len() >= RATE_LIMITER_MAX_ENTRIES {
58 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 if is_new {
77 self.order.push_back(ip.to_string());
78 }
79 true
80 }
81}
82
83#[derive(Clone)]
87pub struct AppState {
88 pub store: AidStore,
89 pub admin_pubkey: Option<[u8; 32]>,
91 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
107fn 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
135pub fn build_app(state: AppState) -> Router {
139 let shared: SharedState = Arc::new(state);
140
141 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
176async 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
200fn 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
238async 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 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#[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#[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 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 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) => {} Err(e) => return Err(err_resp(format!("revocation check error: {e}"))),
522 }
523
524 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 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 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 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 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#[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
657async 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}