api: merge v3 proofs and add host approval pages and listener

This commit is contained in:
2026-11-06 23:18:24 +00:00
371 changed files with 182752 additions and 1095 deletions
+33 -11
View File
@@ -1,6 +1,10 @@
//! JSON routes for host.sausage. The transport supplies the trusted caller.
//! Authorization routes remain on hold until the host-origin and proof decisions land.
//! 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;
@@ -9,21 +13,23 @@ use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::Mutex,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub struct Api<'a> {
pub cred: Cred,
pub cred: Arc<Cred>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<'a>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
}
impl<'a> Api<'a> {
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(cred: Cred, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
Self {
cred,
cred: cred.into(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
entry,
requests: Mutex::new(HashMap::new()),
@@ -49,6 +55,7 @@ impl From<cred::Error> for Error {
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,
};
@@ -64,6 +71,13 @@ fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
.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<T: serde::Serialize>(data: T) -> Result<Value> {
serde_json::to_value(data).map_err(|_| Error(500, "serialization"))
}
@@ -100,7 +114,18 @@ fn request_view(request: cred::requests::Request) -> Result<Value> {
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = route(ctx, &caller, request);
respond(ctx, caller, request)
}
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
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") {
@@ -150,10 +175,7 @@ fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Valu
.ok_or(Error(404, "request_missing"))?,
),
("POST", ["cred", "requests"]) => {
let key = string(&body, "key")?;
if key.starts_with("inject:") || key.starts_with("ssh:") {
return Err(Error(400, "reserved_key"));
}
let key = request_key(&body)?;
ctx.rate_limit(caller)?;
let id = match string(&body, "kind")? {
"grant" => ctx
@@ -171,7 +193,7 @@ fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Valu
("POST", ["ssh", action @ ("identity" | "sign")]) => {
ssh::handle(ctx, caller, action, &body)
}
_ => Err(Error(404, "route_missing")),
_ => auth::route(ctx, caller, method, &path, body),
}
}