//! JSON routes for host.sausage. The transport supplies the trusted caller. //! Host-owned pages use passkeys and typed proofs for every authorization ceremony. mod auth; mod pages; mod server; mod ssh; pub use server::{Listener, Options, run_with_userland}; use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys}; use bytes::Bytes; use http::{Request, Response}; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use std::{ collections::HashMap, sync::{Arc, Mutex}, time::{Duration, Instant}, }; pub struct Api<'a> { pub cred: Arc, script: cred::quickjs::QuickJs, pub passkeys: Mutex>, pub entry: EntryKey, requests: Mutex>, } impl<'a> Api<'a> { /// Passkeys owns the RP configuration. Its database must remain host-only. pub fn new(cred: impl Into>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self { Self { cred: cred.into(), script: cred::quickjs::QuickJs::default(), passkeys: Mutex::new(passkeys), entry, requests: Mutex::new(HashMap::new()), } } fn rate_limit(&self, caller: &Caller) -> Result<()> { let now = Instant::now(); let mut requests = self.requests.lock().map_err(|_| Error(500, "storage"))?; requests.retain(|_, (start, _)| now.duration_since(*start) < Duration::from_secs(60)); let (_, count) = requests.entry(caller.pid).or_insert((now, 0)); if *count >= 32 { return Err(Error(429, "rate_limited")); } *count += 1; Ok(()) } } struct Error(u16, &'static str); type Result = std::result::Result; impl From for Error { fn from(error: cred::Error) -> Self { let status = match error.0 { "item_deleted" | "request_missing" | "grant_missing" => 404, "ambiguous_item" | "stale" | "already_decided" | "key_conflict" => 409, "assertion_required" | "binding_mismatch" => 403, "storage" => 500, _ => 400, }; Self(status, error.0) } } fn parse(value: Value) -> Result { serde_json::from_value(value).map_err(|_| Error(400, "invalid_json")) } fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> { value .get(key) .and_then(Value::as_str) .ok_or(Error(400, "invalid_params")) } fn request_key(body: &Value) -> Result<&str> { let key = string(body, "key")?; if key.starts_with("inject:") || key.starts_with("ssh:") { return Err(Error(400, "reserved_key")); } Ok(key) } fn value(data: T) -> Result { serde_json::to_value(data).map_err(|_| Error(500, "serialization")) } fn request_view(request: cred::requests::Request) -> Result { let summary = if let Some(requested) = request.payload.get("requested") { let requested: cred::edits::OperationRequest = parse(requested.clone())?; requested .change .iter() .map(cred::summary::operation_summary) .collect::>() .join(" ") } else if request.payload.get("grant_revoke").is_some() { "Revoke the grant. All access through this grant will stop.".into() } else if request.payload.get("grant_create").is_some() { "Create a grant. Review the subject and limits in Details.".into() } else if request.payload.get("delete").is_some() { "Delete the item and permanently discard its field values.".into() } else if request.payload.get("revert").is_some() { "Restore the public configuration. Discarded secret values cannot be recovered.".into() } else { let grant: cred::requests::GrantRequest = parse(request.payload.clone())?; format!( "Give access to {} credential items for {}.", grant.items.len(), cred::summary::human_duration(grant.duration_secs.unwrap_or(3600)) ) }; let mut out = value(&request)?; out["diff"] = request.payload; out["summary"] = json!(summary); Ok(out) } /// Handle a request without a network transport. Errors contain only static error kinds. pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request) -> Response { respond(ctx, caller, request) } fn respond(ctx: &Api<'_>, caller: Caller, request: Request) -> Response { let result = if request.method() == http::Method::GET { match pages::get(ctx, request.uri().path()) { Ok(Some(response)) => return response, Ok(None) => route(ctx, &caller, request), Err(error) => Err(error), } } else { route(ctx, &caller, request) }; let (status, body) = match result { Ok(body) => ( if body.get("error").and_then(Value::as_str) == Some("grant_required") { 403 } else { 200 }, body, ), Err(Error(status, kind)) => (status, json!({"error":kind,"message":kind})), }; Response::builder() .status(status) .header("content-type", "application/json") .body(Bytes::from(serde_json::to_vec(&body).expect("JSON value"))) .expect("valid response") } fn route(ctx: &Api<'_>, caller: &Caller, request: Request) -> Result { let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect(); let method = request.method().as_str(); let body: Value = if request.body().is_empty() { json!({}) } else { serde_json::from_slice(request.body()).map_err(|_| Error(400, "invalid_json"))? }; match (method, path.as_slice()) { ("GET", ["whoami"]) => value(caller), ("GET", ["cred", "items"]) => value(ctx.cred.item_list()?), ("GET", ["cred", "items", id]) => value(ctx.cred.item_get(id)?), ("GET", ["cred", "requests"]) => { let state = url::form_urlencoded::parse(request.uri().query().unwrap_or("").as_bytes()) .find(|(key, _)| key == "state") .map(|(_, state)| state.into_owned()); ctx.cred .request_list()? .into_iter() .filter(|r| state.as_ref().is_none_or(|s| s == &r.state)) .map(request_view) .collect::>>() .map(Value::Array) } ("GET", ["cred", "requests", id]) => request_view( ctx.cred .request_list()? .into_iter() .find(|r| r.id == *id) .ok_or(Error(404, "request_missing"))?, ), ("POST", ["cred", "requests"]) => { let key = request_key(&body)?; ctx.rate_limit(caller)?; let id = match string(&body, "kind")? { "grant" => ctx .cred .file_request(caller, key, parse(body["request"].clone())?)?, "edit" => { ctx.cred .file_operation_request(caller, key, parse(body["request"].clone())?)? } _ => return Err(Error(400, "invalid_kind")), }; Ok(json!({"request":id})) } ("GET", ["cred", "grants"]) => value(ctx.cred.grant_list()?), ("POST", ["ssh", action @ ("identity" | "sign")]) => { ssh::handle(ctx, caller, action, &body) } _ => auth::route(ctx, caller, method, &path, body), } } #[cfg(test)] mod tests { use super::*; #[test] fn request_budget_is_per_caller_and_bounded() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let db = rusqlite::Connection::open_in_memory()?; let api = Api::new( Cred::open(dir.path())?, Passkeys::new(&db, "localhost", "http://localhost:8080")?, EntryKey::open(&dir.path().join("entry.key"))?, ); let caller = Caller::default(); for _ in 0..32 { assert!(api.rate_limit(&caller).is_ok()); } assert!(matches!( api.rate_limit(&caller), Err(Error(429, "rate_limited")) )); assert!( api.rate_limit(&Caller { pid: 1, ..Default::default() }) .is_ok() ); Ok(()) } }