api: add public credential routes and SSH signing oracle

This commit is contained in:
2026-11-06 21:05:11 +00:00
parent 16a9a9eb6c
commit c70948efe2
8 changed files with 625 additions and 4 deletions
Generated
+1
View File
@@ -4995,6 +4995,7 @@ dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"bytes",
"chacha20poly1305",
"clap",
"deno_ast",
+1
View File
@@ -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"
+71
View File
@@ -0,0 +1,71 @@
# Host API
`handle(&Api, Caller, Request<Bytes>)` 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.
+207
View File
@@ -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<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 {
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<T> = std::result::Result<T, Error>;
impl From<cred::Error> 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<T: DeserializeOwned>(value: Value) -> Result<T> {
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<T: serde::Serialize>(data: T) -> Result<Value> {
serde_json::to_value(data).map_err(|_| Error(500, "serialization"))
}
fn request_view(request: cred::requests::Request) -> Result<Value> {
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::<Vec<_>>()
.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<Bytes>) -> Response<Bytes> {
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<Bytes>) -> Result<Value> {
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::<Result<Vec<_>>>()
.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(())
}
}
+96
View File
@@ -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<Value> {
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}))
}
}
+15 -4
View File
@@ -139,6 +139,15 @@ impl Cred {
&self,
item: &super::items::Item,
caller: &Caller,
) -> Result<Option<(Grant, serde_json::Value)>> {
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<Option<(Grant, serde_json::Value)>> {
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<max_uses)",params![g.id,now()])?;
if changed != 1 {
return Err(Error("grant_exhausted"));
if consume {
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<max_uses)",params![g.id,now()])?;
if changed != 1 {
return Err(Error("grant_exhausted"));
}
g.uses += 1;
}
g.uses += 1;
tx.commit()?;
Ok(Some((g, credential)))
} else {
+1
View File
@@ -1,6 +1,7 @@
//! The Sausage v3 host: a world folder behind a virtual filesystem, WASI programs in a process table,
//! and the durable agent harness. See `~/research/sausage-v3/PLAN.md` until the docs move here.
pub mod api;
pub mod cred;
pub mod exec;
pub mod harness;
+233
View File
@@ -0,0 +1,233 @@
//! Direct host API tests for reads, request filing, and the SSH signing oracle.
//! Authorization UI routes wait for the security decisions in T25 Note 1.
use anyhow::Result;
use base64::{Engine, engine::general_purpose::STANDARD};
use bytes::Bytes;
use sausage::{
api::{Api, handle},
cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys},
};
use serde_json::{Value, json};
fn caller() -> 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<Value> {
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<cred::items::Item> {
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(())
}