From c70948efe21022b519c2f5c311b775a20779549e Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 21:05:11 +0000 Subject: [PATCH] api: add public credential routes and SSH signing oracle --- Cargo.lock | 1 + host/Cargo.toml | 1 + host/src/api/README.md | 71 ++++++++++++ host/src/api/mod.rs | 207 +++++++++++++++++++++++++++++++++++ host/src/api/ssh.rs | 96 +++++++++++++++++ host/src/cred/grants.rs | 19 +++- host/src/lib.rs | 1 + host/tests/api.rs | 233 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 625 insertions(+), 4 deletions(-) create mode 100644 host/src/api/README.md create mode 100644 host/src/api/mod.rs create mode 100644 host/src/api/ssh.rs create mode 100644 host/tests/api.rs diff --git a/Cargo.lock b/Cargo.lock index 7d50be6..dd5aa83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4995,6 +4995,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "bytes", "chacha20poly1305", "clap", "deno_ast", diff --git a/host/Cargo.toml b/host/Cargo.toml index 88863a7..983031f 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -51,6 +51,7 @@ ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rs uuid = { version = "1.29.0", features = ["v4"] } webauthn-rs = "0.5" openssl = { version = "0.10.82", features = ["vendored"] } +bytes = "1" [dev-dependencies] tempfile = "3" diff --git a/host/src/api/README.md b/host/src/api/README.md new file mode 100644 index 0000000..1e3ce76 --- /dev/null +++ b/host/src/api/README.md @@ -0,0 +1,71 @@ +# Host API + +`handle(&Api, Caller, Request)` answers JSON requests for `host.sausage`. +The transport supplies the caller from the process table, never from request JSON or headers. +The transport hook is outside this module. +Use one `Cred` per host process. +SQLite and script work must run on a blocking thread in the transport. + +T25 Note 1 pauses authorization routes for security decisions C1 and H2. +T27 will change the mutation methods to require typed proofs. +This module currently exposes only reads, request filing, and SSH operations. +It does not expose decisions, enrollment, revocation, or secret entry. +Those routes return 404 until the orchestrator authorizes further work. +The API retains the passkey manager and entry key for the later routes. +The host supplies their configuration and host-only paths. +Tests use RP ID `localhost` and origin `http://localhost:8080`. + +Reads are open to every caller. +Successful responses use status 200 and JSON. +Errors use `{"error":"kind","message":"kind"}` with static, secret-free text. +Unknown routes return 404. +Invalid JSON or parameters return 400. +Conflicts return 409. +Request filing permits 32 attempts per process within a 60-second window. +The next attempt returns 429 until that window expires. +Explicit requests and automatic SSH requests share this budget. +Reads and granted SSH operations do not spend the budget. + +## Current routes + +- `GET /whoami` returns the trusted caller. +- `GET /cred/items` returns public item snapshots. +- `GET /cred/items/{id}` returns one public snapshot. +- `GET /cred/requests?state=pending` returns requests with diffs and summaries. + Omit `state` to return every request. +- `GET /cred/requests/{id}` returns one request with its diff and summary. +- `POST /cred/requests` accepts `{"kind":"grant","key":"stable-key","request":{...}}`. + The request contains `items` and optional `duration_secs`, `max_uses`, and `validator`. + Kind `edit` accepts an `OperationRequest` with `item`, `base_version`, and `change`. + This route returns `{"request":"id"}` without approving the request. + The key prefixes `inject:` and `ssh:` belong to automatic requests. + An explicit request with either prefix returns 400. +- `GET /cred/grants` returns the grant ledger. +- `POST /ssh/identity` accepts `{"host":"example.com"}`. + It returns `{"item":"id","public_key":"base64 SSH public key blob","user":null}`. + A public `user` field supplies the optional default user. +- `POST /ssh/sign` accepts `{"host":"example.com","item":"id","payload":"base64"}`. + It returns `{"signature":"base64 SSH wire signature"}`. + +SSH operations match SSH rules and enforce free use or a caller grant. +Both operations evaluate item and grant validators. +Identity discovery checks the grant without spending a use. +Signing reserves one grant use. +An ungranted caller receives 403 with `error`, `message`, `item`, and `request`. +The host files that request before it responds. +Two matching items produce 409. +The signing item must match the selected host rule. +The host never returns a private key or decrypted field value. + +## Held routes + +The original brief also requests these route groups: + +- Request challenges and decisions. +- Passkey enrollment and listing. +- Grant revocation through an approved request. +- The entry public key, entry challenges, and encrypted field writes. + +Their final contracts depend on the security decisions and T27's typed proof interface. +The host must not trust world-served pages to describe the approved action. +No public route accepts a caller identity, a raw binding, or an approver string. diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs new file mode 100644 index 0000000..57d0191 --- /dev/null +++ b/host/src/api/mod.rs @@ -0,0 +1,207 @@ +//! JSON routes for host.sausage. The transport supplies the trusted caller. +//! Authorization routes remain on hold until the host-origin and proof decisions land. +mod ssh; + +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::Mutex, + time::{Duration, Instant}, +}; + +pub struct Api<'a> { + pub cred: Cred, + 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: Cred, passkeys: Passkeys<'a>, entry: EntryKey) -> Self { + Self { + cred, + 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, + "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 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 { + let result = 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 = string(&body, "key")?; + if key.starts_with("inject:") || key.starts_with("ssh:") { + return Err(Error(400, "reserved_key")); + } + 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) + } + _ => Err(Error(404, "route_missing")), + } +} + +#[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(()) + } +} diff --git a/host/src/api/ssh.rs b/host/src/api/ssh.rs new file mode 100644 index 0000000..adf80f6 --- /dev/null +++ b/host/src/api/ssh.rs @@ -0,0 +1,96 @@ +//! SSH signing oracle. Rules and grants precede every key operation. +use super::{Api, Error, Result, string}; +use crate::cred::{self, Caller, script::Script, types::FieldType}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; + +pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result { + let cred = &ctx.cred; + let host = string(body, "host")?; + let mut items = cred.item_list()?.into_iter().filter(|item| { + item.rules + .iter() + .any(|rule| cred::items::rule_matches(rule, "ssh", host, "/", "POST")) + }); + let item = items.next().ok_or(Error(404, "item_missing"))?; + if items.next().is_some() { + return Err(Error(409, "ambiguous_item")); + } + if action == "sign" && string(body, "item")? != item.id { + return Err(Error(403, "item_mismatch")); + } + let prepared = if item.free_use { + Some((None, cred.credential(&item, true)?)) + } else { + cred.prepare_use(&item, caller, action == "sign")? + .map(|(grant, values)| (Some(grant), values)) + }; + let Some((grant, values)) = prepared else { + ctx.rate_limit(caller)?; + let request = cred.file_request( + caller, + &format!("ssh:{}", item.id), + cred::requests::GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + return Ok( + json!({"error":"grant_required","message":"grant_required","item":item.id,"request":request}), + ); + }; + let public = cred.credential(&item, false)?; + let context = json!({"request":{"method":"POST","url":format!("ssh://{host}/"),"headers":[]},"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {cred::types::Subject::Conversation(id)=>Some(id),cred::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))}); + for source in [ + item.validate_js.as_deref(), + grant.as_ref().and_then(|g| g.validator.as_deref()), + ] + .into_iter() + .flatten() + { + let output = cred::v8::V8 + .evaluate(source, &context.to_string(), None, Default::default()) + .map_err(|_| Error(403, "validator_denied"))?; + let output: Value = + serde_json::from_str(&output).map_err(|_| Error(403, "validator_denied"))?; + if output + .as_bool() + .or_else(|| output.get("allow").and_then(Value::as_bool)) + != Some(true) + { + return Err(Error(403, "validator_denied")); + } + } + let field = item + .fields + .iter() + .find(|field| field.field_type == FieldType::SshPrivateKey) + .ok_or(Error(400, "signing_key_missing"))?; + let secret = values + .get(&field.name) + .and_then(Value::as_str) + .ok_or(Error(400, "signing_key_missing"))?; + let format = field.key_type.as_deref().ok_or(Error(400, "invalid_key"))?; + if action == "identity" { + let public_key = cred::sign::public_key(secret, None, format, "ssh") + .map_err(|_| Error(400, "invalid_key"))?; + Ok( + json!({"item":item.id,"public_key":public_key,"user":public.get("user").and_then(Value::as_str)}), + ) + } else { + let payload = STANDARD + .decode(string(body, "payload")?) + .map_err(|_| Error(400, "invalid_payload"))?; + let (padding, hash) = match format { + "rs256" | "rsa-sha2-256" => (Some("pkcs1v15"), Some("sha256")), + "rsa-sha2-512" => (Some("pkcs1v15"), Some("sha512")), + _ => (None, None), + }; + let signature = cred::sign::sign(secret, None, format, &payload, "ssh", padding, hash) + .map_err(|_| Error(400, "sign_failed"))?; + cred.audit_sign(&item.id, caller, &payload)?; + Ok(json!({"signature":signature.signature})) + } +} diff --git a/host/src/cred/grants.rs b/host/src/cred/grants.rs index 3d04654..caa2f5d 100644 --- a/host/src/cred/grants.rs +++ b/host/src/cred/grants.rs @@ -139,6 +139,15 @@ impl Cred { &self, item: &super::items::Item, caller: &Caller, + ) -> Result> { + self.prepare_use(item, caller, true) + } + /// Public-key discovery checks grants without spending a signing use. + pub(crate) fn prepare_use( + &self, + item: &super::items::Item, + caller: &Caller, + consume: bool, ) -> Result> { let mut s = self.access()?; let master = s.master.clone(); @@ -156,11 +165,13 @@ impl Cred { return Err(Error("stale")); } let credential = super::items::credential(&tx, &master, item, true)?; - let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses?2) AND (max_uses IS NULL OR uses Caller { + Caller { + pid: 17, + program: "/bin/agent".into(), + conversations: vec!["conversation".into()], + } +} +async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> { + let request = http::Request::builder() + .method(method) + .uri(path) + .header("x-sausage-pid", "999") + .body(Bytes::from(serde_json::to_vec(&body)?))?; + let response = handle(api, caller(), request).await; + let text = std::str::from_utf8(response.body())?; + assert!(!text.contains("browser-only-value")); + assert!(!text.contains(&STANDARD.encode([7u8; 32]))); + assert_eq!(response.headers()["content-type"], "application/json"); + Ok(( + response.status().as_u16(), + serde_json::from_slice(response.body())?, + )) +} +async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result { + let (status, value) = send(api, method, path, body).await?; + assert_eq!(status, 200, "unexpected API status"); + Ok(value) +} +fn item(cred: &Cred, name: &str) -> Result { + Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[{"scheme":"ssh","hosts":["example.com"],"path_prefixes":["/"]}],"transform_js":"","fields":[{"name":"secret","public":false,"field_type":"ssh_private_key","key_type":"ed25519-raw"}]}))?,&caller(),"fixture")?) +} + +#[tokio::test(flavor = "current_thread")] +async fn public_routes_and_ssh() -> Result<()> { + let dir = tempfile::tempdir()?; + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?; + let cred = Cred::open(dir.path())?; + let item = item(&cred, "ssh")?; + cred.set_field( + &item.id, + &item.fields[0].field_id, + &STANDARD.encode([7u8; 32]), + &caller(), + "fixture", + )?; + let api = Api::new( + cred, + Passkeys::new(&db, "localhost", "http://localhost:8080")?, + EntryKey::open(&dir.path().join("entry.key"))?, + ); + let who = ok( + &api, + "GET", + "/whoami", + json!({"pid":999,"conversations":["forged"]}), + ) + .await?; + assert_eq!(who["pid"], 17); + assert_eq!(who["conversations"], json!(["conversation"])); + let mut pending = String::new(); + for action in ["identity", "sign"] { + let (status, denied) = send( + &api, + "POST", + &format!("/ssh/{action}"), + json!({"host":"example.com","item":item.id,"payload":"YWJj"}), + ) + .await?; + assert_eq!(status, 403); + assert_eq!(denied["item"], item.id); + let request = denied["request"].as_str().unwrap(); + if pending.is_empty() { + pending = request.into(); + } else { + assert_eq!(pending, request); + } + } + for prefix in ["inject:", "ssh:"] { + assert_eq!( + send( + &api, + "POST", + "/cred/requests", + json!({"kind":"grant","key":format!("{prefix}spoof"),"request":{"items":[item.id]}}) + ) + .await? + .1["error"], + "reserved_key" + ); + } + let filed = ok( + &api, + "POST", + "/cred/requests", + json!({"kind":"grant","key":"explicit","request":{"items":[item.id]},"caller":{"pid":999}}), + ) + .await?; + let id = filed["request"].as_str().unwrap(); + let request = ok(&api, "GET", &format!("/cred/requests/{id}"), json!({})).await?; + assert_eq!(request["pid"], 17); + assert_eq!( + request["subject"], + json!({"kind":"conversation","id":"conversation"}) + ); + assert!(request["diff"].is_object()); + assert!(request["summary"].is_string()); + assert_eq!( + ok(&api, "GET", "/cred/requests?state=approved", json!({})).await?, + json!([]) + ); + assert_eq!( + ok(&api, "GET", "/cred/requests?state=pending", json!({})) + .await? + .as_array() + .unwrap() + .len(), + 2 + ); + let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":item.id,"base_version":api.cred.item_get(&item.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?; + let view = ok( + &api, + "GET", + &format!("/cred/requests/{}", edit["request"].as_str().unwrap()), + json!({}), + ) + .await?; + assert!(view["summary"].as_str().unwrap().contains("Rename")); + assert_eq!(api.cred.item_get(&item.id)?.name, "ssh"); + // Trusted fixture setup only. No world route creates this grant. + api.cred.grant_create( + cred::grants::Grant { + id: String::new(), + items: vec![item.id.clone()], + subject: cred::types::Subject::Conversation("conversation".into()), + exp: None, + max_uses: Some(1), + uses: 0, + revoked: false, + validator: None, + note: None, + }, + &caller(), + Some("fixture"), + )?; + let identity = ok(&api, "POST", "/ssh/identity", json!({"host":"example.com"})).await?; + assert_eq!(identity["item"], item.id); + assert_eq!(api.cred.grant_list()?[0].uses, 0); + let public = STANDARD.decode(identity["public_key"].as_str().unwrap())?; + let expected = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]).verifying_key(); + assert!(public.ends_with(expected.as_bytes())); + assert_ne!( + send( + &api, + "POST", + "/ssh/sign", + json!({"host":"example.com","item":"different","payload":"YWJj"}) + ) + .await? + .0, + 200 + ); + let signed = ok( + &api, + "POST", + "/ssh/sign", + json!({"host":"example.com","item":item.id,"payload":STANDARD.encode(b"payload")}), + ) + .await?; + assert_eq!(api.cred.grant_list()?[0].uses, 1); + let signature = STANDARD.decode(signed["signature"].as_str().unwrap())?; + use ed25519_dalek::Verifier; + assert!(signature.starts_with(b"\0\0\0\x0bssh-ed25519\0\0\0\x40")); + expected.verify( + b"payload", + &ed25519_dalek::Signature::from_slice(&signature[19..])?, + )?; + assert_eq!( + send( + &api, + "POST", + "/ssh/sign", + json!({"host":"example.com","item":item.id,"payload":"YWJj"}) + ) + .await? + .0, + 403 + ); + ok(&api, "GET", "/cred/items", json!({})).await?; + ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?; + ok(&api, "GET", "/cred/grants", json!({})).await?; + for path in [ + "/cred/passkeys/register/start", + "/cred/items/x/fields/y", + "/cred/requests/x/decide", + "/cred/grants/x/revoke", + ] { + assert_eq!(send(&api, "POST", path, json!({})).await?.0, 404); + } + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn ssh_rejects_ambiguous_items() -> Result<()> { + let dir = tempfile::tempdir()?; + let db = rusqlite::Connection::open_in_memory()?; + let cred = Cred::open(dir.path())?; + item(&cred, "one")?; + item(&cred, "two")?; + let api = Api::new( + cred, + Passkeys::new(&db, "localhost", "http://localhost:8080")?, + EntryKey::open(&dir.path().join("entry.key"))?, + ); + assert_eq!( + send(&api, "POST", "/ssh/identity", json!({"host":"example.com"})) + .await? + .0, + 409 + ); + assert!(api.cred.request_list()?.is_empty()); + Ok(()) +}