#[cfg(feature = "tee")]
mod attestation;
mod auth;
mod auth_portal;
mod backup_blob;
mod bootstrap;
#[cfg(feature = "webvh")]
mod did_webvh;
mod health;
#[cfg(feature = "webvh")]
mod passkey_vms;
pub mod rate_limit;
#[cfg(feature = "webvh")]
mod self_hosted_did;
use std::time::Duration;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderName, HeaderValue, Method};
use axum::routing::{get, post};
use ipnetwork::IpNetwork;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::timeout::TimeoutLayer;
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use crate::server::AppState;
pub use rate_limit::{Limiter, Quota, QuotaSource, RateLimits};
#[derive(OpenApi)]
#[openapi(
info(
title = "Verifiable Trust Agent (VTA) API",
description = "Key-management, DID-webvh, provisioning, and runtime \
service-management REST surface of a Verifiable Trust Agent.",
version = env!("CARGO_PKG_VERSION"),
),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_jwt",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
async fn serve_openapi(api: utoipa::openapi::OpenApi) -> axum::Json<utoipa::openapi::OpenApi> {
axum::Json(api)
}
const MAX_BODY_SIZE: usize = 1024 * 1024;
const UNAUTH_BODY_SIZE: usize = 64 * 1024;
pub(super) const BACKUP_BLOB_BODY_SIZE: usize = 100 * 1024 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
pub fn health_router() -> Router<AppState> {
Router::new().route("/health", get(health::health))
}
pub fn health_router_with_cors(allowed_origins: &[String]) -> Router<AppState> {
let router = health_router();
match build_cors_layer(allowed_origins) {
Some(cors) => router.layer(cors),
None => router,
}
}
fn build_cors_layer(allowed_origins: &[String]) -> Option<CorsLayer> {
if allowed_origins.is_empty() {
return None;
}
let parsed: Vec<HeaderValue> = allowed_origins
.iter()
.filter(|o| !o.is_empty() && *o != "*")
.filter_map(|o| HeaderValue::from_str(o).ok())
.collect();
if parsed.is_empty() {
return None;
}
Some(
CorsLayer::new()
.allow_origin(AllowOrigin::list(parsed))
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::PATCH])
.allow_headers([
HeaderName::from_static("content-type"),
HeaderName::from_static("authorization"),
HeaderName::from_static("x-backup-token"),
])
.max_age(std::time::Duration::from_secs(60)),
)
}
pub fn router() -> Router<AppState> {
router_with_cors(&[], &[], QuotaSource::Fixed(RateLimits::default()))
}
fn build_api_router(trust_xff_cidrs: &[IpNetwork], quotas: QuotaSource) -> OpenApiRouter<AppState> {
let unauth = OpenApiRouter::new()
.routes(routes!(bootstrap::request))
.routes(routes!(auth::passkey_login_start))
.routes(routes!(auth::passkey_login_finish));
let unauth = unauth.layer(DefaultBodyLimit::max(UNAUTH_BODY_SIZE));
let unauth = rate_limit::apply(unauth, Limiter::Auth, trust_xff_cidrs, "as);
#[allow(unused_mut)]
let mut did_log = OpenApiRouter::new();
#[cfg(feature = "tee")]
{
did_log = did_log.routes(routes!(attestation::did_log));
}
#[cfg(feature = "webvh")]
{
did_log = did_log
.routes(routes!(did_webvh::get_did_log_public_handler))
.routes(routes!(self_hosted_did::get_vta_well_known_did_log_handler))
.route(
"/{*did_log_path}",
get(self_hosted_did::get_vta_canonical_did_log_handler),
);
}
let did_log = did_log.layer(DefaultBodyLimit::max(UNAUTH_BODY_SIZE));
let did_log = rate_limit::apply(did_log, Limiter::DidLog, trust_xff_cidrs, "as);
let auth_portal_router =
OpenApiRouter::new().route("/auth/portal", get(auth_portal::portal_handler));
let trust_tasks = OpenApiRouter::new().route(
"/trust-tasks",
post(crate::trust_tasks::dispatch_trust_task),
);
let trust_tasks =
rate_limit::apply_anonymous(trust_tasks, Limiter::Auth, trust_xff_cidrs, "as);
let router = OpenApiRouter::with_openapi(ApiDoc::openapi())
.merge(unauth)
.merge(did_log);
let router = router.merge(auth_portal_router);
let router = router.merge(trust_tasks);
#[cfg(feature = "webvh")]
let router = router
.routes(routes!(passkey_vms::enroll_challenge_handler))
.routes(routes!(
passkey_vms::enroll_submit_handler,
passkey_vms::list_passkeys_handler
))
.routes(routes!(passkey_vms::revoke_passkey_handler));
let backup_blob_router = OpenApiRouter::new()
.routes(routes!(backup_blob::get_blob, backup_blob::post_blob))
.layer(DefaultBodyLimit::max(BACKUP_BLOB_BODY_SIZE));
let backup_blob_router = rate_limit::apply(
backup_blob_router,
Limiter::BackupBlob,
trust_xff_cidrs,
"as,
);
let router = router.merge(backup_blob_router);
router
}
pub fn openapi_spec() -> utoipa::openapi::OpenApi {
build_api_router(&[], QuotaSource::Fixed(RateLimits::default()))
.split_for_parts()
.1
}
pub fn router_with_cors(
allowed_origins: &[String],
trust_xff_cidrs: &[IpNetwork],
quotas: QuotaSource,
) -> Router<AppState> {
let (router, api) = build_api_router(trust_xff_cidrs, quotas).split_for_parts();
let router = router.route("/openapi.json", get(move || serve_openapi(api.clone())));
let router =
router
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
.layer(TimeoutLayer::with_status_code(
axum::http::StatusCode::REQUEST_TIMEOUT,
REQUEST_TIMEOUT,
));
match build_cors_layer(allowed_origins) {
Some(cors) => router.layer(cors),
None => router,
}
}
#[cfg(test)]
mod cors_tests {
use super::*;
#[test]
fn empty_list_disables_cors_entirely() {
assert!(build_cors_layer(&[]).is_none());
}
#[test]
fn explicit_origin_produces_layer() {
let layer = build_cors_layer(&["http://localhost:8000".to_string()]);
assert!(layer.is_some());
}
#[test]
fn invalid_origin_filtered_out_and_empty_result_returns_none() {
let bad_origin = "http://localhost:8000\n".to_string();
assert!(build_cors_layer(&[bad_origin]).is_none());
}
#[test]
fn wildcard_alone_yields_no_layer() {
assert!(
build_cors_layer(&["*".to_string()]).is_none(),
"wildcard must be filtered to None, never partial-applied"
);
}
#[test]
fn wildcard_mixed_with_explicit_origins_drops_wildcard_keeps_others() {
let layer = build_cors_layer(&["*".to_string(), "http://localhost:8000".to_string()]);
assert!(layer.is_some());
}
#[test]
fn empty_origin_string_filtered() {
let layer = build_cors_layer(&["".to_string(), "http://x".to_string()]);
assert!(layer.is_some());
}
#[test]
fn openapi_spec_describes_registered_routes() {
let spec = openapi_spec();
assert_eq!(spec.info.title, "Verifiable Trust Agent (VTA) API");
let schemes = &spec
.components
.as_ref()
.expect("components present once a route contributes a schema")
.security_schemes;
assert!(
schemes.contains_key("bearer_jwt"),
"bearer_jwt security scheme must be registered"
);
let passkey_start = spec
.paths
.paths
.get("/auth/passkey-login/start")
.expect("/auth/passkey-login/start operation must be in the spec");
assert!(
passkey_start.post.is_some(),
"/auth/passkey-login/start must document a POST operation"
);
}
const REST_EXCEPTIONS: &[(&str, &str, &str)] = &[
(
"POST",
"/bootstrap/request",
"sealed-bootstrap Mode B; there is no identity yet to sign a Trust Task document with",
),
(
"GET",
"/backup/blob/{bundle_id}",
"a raw sealed-bootstrap blob transfer, not representable as a Trust Task document",
),
(
"POST",
"/backup/blob/{bundle_id}",
"a raw sealed-bootstrap blob transfer, not representable as a Trust Task document",
),
];
fn is_public_health_or_webauthn(path: &str) -> bool {
path.starts_with("/health")
|| path.contains("passkey")
|| path == "/.well-known/did.jsonl"
|| path == "/did/{did}/log"
|| path == "/attestation/did-log"
}
#[test]
fn every_mounted_rest_route_is_justified() {
let spec = openapi_spec();
let mut unjustified = Vec::new();
for (path, item) in &spec.paths.paths {
for (method, op) in [
("GET", &item.get),
("POST", &item.post),
("PATCH", &item.patch),
("DELETE", &item.delete),
("PUT", &item.put),
] {
if op.is_none() {
continue;
}
if is_public_health_or_webauthn(path) {
continue;
}
if REST_EXCEPTIONS
.iter()
.any(|(m, p, _)| *m == method && *p == path)
{
continue;
}
unjustified.push(format!("{method} {path}"));
}
}
unjustified.sort();
assert!(
unjustified.is_empty(),
"these REST routes are mounted but not reasoned about — add each to \
REST_EXCEPTIONS with the reason it still needs a dedicated REST mount, or confirm \
it belongs in `is_public_health_or_webauthn` and extend that instead:\n {}",
unjustified.join("\n ")
);
let stale: Vec<&str> = REST_EXCEPTIONS
.iter()
.filter(|(m, p, _)| {
!spec
.paths
.paths
.get(*p)
.is_some_and(|item| op_for(item, m).is_some())
})
.map(|(_, p, _)| *p)
.collect();
assert!(
stale.is_empty(),
"REST_EXCEPTIONS entries for routes that are no longer mounted — remove them: \
{stale:?}"
);
}
fn op_for<'a>(
item: &'a utoipa::openapi::path::PathItem,
method: &str,
) -> Option<&'a utoipa::openapi::path::Operation> {
match method {
"GET" => item.get.as_ref(),
"POST" => item.post.as_ref(),
"PATCH" => item.patch.as_ref(),
"DELETE" => item.delete.as_ref(),
"PUT" => item.put.as_ref(),
_ => None,
}
}
#[test]
fn health_router_with_cors_builds_both_branches() {
let _with = health_router_with_cors(&["http://localhost:8000".to_string()]);
let _without = health_router_with_cors(&[]);
let _wildcard_only = health_router_with_cors(&["*".to_string()]);
}
#[test]
fn build_api_router_accepts_custom_rate_limit() {
let _ = build_api_router(
&[],
QuotaSource::Fixed(RateLimits::new(Quota::new(50, 100), Quota::new(2, 500))),
);
let _ = build_api_router(
&["127.0.0.1/32".parse().unwrap()],
QuotaSource::Fixed(RateLimits::new(Quota::new(1000, 2000), Quota::new(1, 1))),
);
}
#[test]
fn router_with_cors_passes_rate_limit_through() {
let _ = router_with_cors(
&[],
&[],
QuotaSource::Fixed(RateLimits::new(Quota::new(50, 100), Quota::new(3, 30))),
);
}
#[test]
fn zero_rate_limit_config_is_clamped_not_panicked() {
let server = crate::config::ServerConfig {
rate_limit_interval_secs: 0,
rate_limit_burst: 0,
did_log_rate_limit_interval_secs: 0,
did_log_rate_limit_burst: 0,
..Default::default()
};
let limits = QuotaSource::Fixed(RateLimits::from_server_config(&server));
let _ = build_api_router(&[], limits.clone());
let _ = build_api_router(&["127.0.0.1/32".parse().unwrap()], limits.clone());
let _ = router_with_cors(&[], &[], limits);
}
}
#[cfg(test)]
mod rate_limit_tests {
use std::sync::Arc;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use tower::ServiceExt;
use tower_governor::GovernorLayer;
use tower_governor::governor::GovernorConfigBuilder;
async fn handler() -> &'static str {
"ok"
}
fn limited_router(interval_secs: u64, burst: u32) -> axum::Router {
let cfg = Arc::new(
GovernorConfigBuilder::default()
.per_second(interval_secs)
.burst_size(burst)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish()
.unwrap(),
);
axum::Router::new()
.route("/test", get(handler))
.layer(GovernorLayer::new(cfg))
}
#[tokio::test]
async fn burst_2_rejects_third_request() {
let app = limited_router(60, 2);
let mut statuses = Vec::new();
for _ in 0..4 {
let req = Request::builder()
.uri("/test")
.header("x-forwarded-for", "10.0.0.1")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
statuses.push(resp.status());
}
assert_eq!(statuses[0], StatusCode::OK);
assert_eq!(statuses[1], StatusCode::OK);
assert_eq!(
statuses[2],
StatusCode::TOO_MANY_REQUESTS,
"third request must be rejected when burst=2"
);
}
#[tokio::test]
async fn burst_5_allows_five_then_rejects() {
let app = limited_router(60, 5);
let mut ok_count = 0u32;
let mut rejected = false;
for _ in 0..8 {
let req = Request::builder()
.uri("/test")
.header("x-forwarded-for", "10.0.0.1")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
ok_count += 1;
} else if resp.status() == StatusCode::TOO_MANY_REQUESTS {
rejected = true;
break;
}
}
assert_eq!(ok_count, 5, "expected exactly 5 OK before rejection");
assert!(rejected, "expected 429 after burst exhausted");
}
#[test]
fn zero_period_or_burst_has_no_governor_config() {
let zero_period = GovernorConfigBuilder::default()
.per_second(0)
.burst_size(10)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish();
assert!(zero_period.is_none(), "zero period must not yield a config");
let zero_burst = GovernorConfigBuilder::default()
.per_second(5)
.burst_size(0)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish();
assert!(zero_burst.is_none(), "zero burst must not yield a config");
}
}