diff --git a/Cargo.lock b/Cargo.lock index 3385650..7359c40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,6 +885,12 @@ dependencies = [ "serde", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -5043,6 +5049,7 @@ dependencies = [ "sys_traits", "tempfile", "thiserror 2.0.21", + "tiny_http", "tokio", "tokio-rustls", "toml", @@ -6303,6 +6310,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.5" diff --git a/host/Cargo.toml b/host/Cargo.toml index 03a8ac3..f1e3a08 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -61,6 +61,7 @@ http-body = "1.1.0" http-body-util = "0.1.5" hyper-util = { version = "0.1.21", features = ["tokio"] } hyper = { version = "1.12.0", features = ["client", "http1", "server"] } +tiny_http = "0.12" [dev-dependencies] tempfile = "3" diff --git a/host/src/api/README.md b/host/src/api/README.md new file mode 100644 index 0000000..a48639c --- /dev/null +++ b/host/src/api/README.md @@ -0,0 +1,133 @@ +# Host API and host pages + +`handle(&Api, Caller, Request)` answers requests without a transport. +The later `wasi:http` hook supplies the caller from the process table. +It must not accept caller identity from headers, environment variables, or JSON. +Reads are open to every caller. +JSON routes return 200 on success. +Errors use `{"error":"kind","message":"kind"}` with static, secret-free text. +Missing routes return 404. Invalid parameters return 400. Authorization failures return 403. +Conflicts return 409. The filing budget returns 429. + +## Host listener + +`sausage run WORLD` starts the console and the host listener with one shared `Arc`. +The listener owns its passkey connection and handles requests on a dedicated thread. +The console receives the same credentials store, including its decision notifications. +The listener stops when the console returns. +No protected harness or process module needs a change. + +Options on `sausage run`: + +- `--api-listen 127.0.0.1:8081`: the default listener address. +- `--api-rp-id localhost`: the default WebAuthn RP ID. +- `--api-origin http://localhost:8081`: the default browser origin. + +The origin must name the host pages, not the world server. +The listener speaks HTTP. A production HTTPS origin needs a reverse proxy to this listener. +The world's UI can link to the host pages. It does not serve their code. +The listener identifies browser calls as `/host/browser`, with the host PID and no conversation. +Passkey proofs supply the approver identity independently of that caller. + +## Pages and assets + +- `GET /approve/{request}` renders the host's stored summary and public details. + The page sends only the human's decision and optional limit overrides. + The host builds the binding from the stored request and its payload digest. +- `GET /register` registers the first passkey or adds another passkey. +- `GET /entry/{item}/{field}` displays a blank secret-entry form. + The host embeds its public encryption key and the selected field's public metadata. +- `GET /app.js` serves the shared ceremony code and WebCrypto encryption. + +The checked-in pages use plain HTML and JavaScript with no build step or external dependencies. +The host escapes review data before it enters HTML. +No page includes a stored secret value. + +## Public JSON routes + +- `GET /whoami`: the transport's caller. +- `GET /cred/items`: public item snapshots. +- `GET /cred/items/{id}`: one public snapshot. +- `GET /cred/requests?state=pending`: requests with diffs and summaries. + Omit `state` to return every request. +- `GET /cred/requests/{id}`: one request with its diff and summary. +- `POST /cred/requests`: `{"kind":"grant","key":"stable-key","request":{...}}`. + The request contains `items` and optional `duration_secs`, `max_uses`, and `validator`. + Kind `edit` accepts `OperationRequest` with `item`, `base_version`, and `change`. + The response is `{"request":"id"}`. Filing does not approve anything. +- `GET /cred/grants`: the grant ledger. + +Explicit request keys cannot begin with `inject:` or `ssh:`. +Those prefixes belong to automatic requests. +Explicit requests, automatic SSH requests, and new ceremonies share a per-process budget. +The budget permits 32 attempts per 60-second window. +Reads and granted SSH operations do not spend that budget. + +## Decisions + +- `POST /cred/requests/{id}/challenge`: `{"decision":"approve","overrides":{...}}`. + Decisions are `approve` or `deny`. + Overrides use T27's typed `Overrides` structure. Unknown fields fail. + A denial accepts only default overrides. + With no passkey, the response is `{"unlocked":true}`. + Otherwise, the response is `{"id":"challenge id","options":{...}}`. +- `POST /cred/requests/{id}/decide`: `{"challenge_id":"id","assertion":{...}}`. + The host reads the decision and overrides from the verified binding. + An optional explicit decision must match that binding. + The library checks the request ID, decision, overrides, and stored payload digest inside its transaction. + Without passkeys, this route accepts `{"decision":"approve","overrides":{...}}` instead. + The library checks its `Unlocked` proof inside the transaction. +- `POST /cred/grants/{id}/revoke`: `{"key":"stable-key"}`. + This route files a revocation request and returns `{"request":"id"}`. + Use the normal challenge and decision routes to approve it. + +Successful mutations return `{"ok":true}`. +A successful assertion produces a non-cloneable `Verified` proof. +A replay or a mismatched binding cannot authorize another action. + +## Passkeys + +- `GET /cred/passkeys`: public credential IDs and labels, not serialized passkeys. +- `POST /cred/passkeys/register/challenge`: `{"label":"new label"}`. + The returned assertion challenge binds the exact label. +- `POST /cred/passkeys/register/start`: `{"label":"new label","gate":{"id":"challenge id","response":{...}}}`. + The first registration permits an absent gate, as D53 requires. + Every later registration needs the existing passkey's enrollment assertion. +- `POST /cred/passkeys/register/finish`: `{"challenge_id":"id","response":{...}}`. + +WebAuthn options and responses use the library's browser JSON formats. +The host retains the RP configuration and pending ceremony state. +Restart discards pending ceremonies but retains registered passkeys. + +## Secret entry + +- `GET /cred/entry/key`: `{"public_key":[...]}` in SEC1 format. +- `POST /cred/items/{id}/fields/{field}/challenge`: `{"ciphertext_sha256":"lowercase hex"}`. + The host loads the field metadata and constructs the binding itself. +- `POST /cred/items/{id}/fields/{field}`: `{"challenge_id":"id","assertion":{...},"envelope":{...}}`. + +The field parameter is its stable ID. +Secret entry requires an assertion. Register a passkey first. +The host compares the item, field, and envelope hash before it calls the encrypted field API. +That API checks the typed proof and metadata before it decrypts inside its transaction. +Public fields cannot use secret entry. +Non-UTF-8 plaintext and invalid envelopes fail without an echo. +See `cred/entry.rs` for the ECDH, HKDF, AES-GCM, and hash formats. + +## SSH + +- `POST /ssh/identity`: `{"host":"example.com"}`. + The response is `{"item":"id","public_key":"base64 SSH public key blob","user":null}`. + A public `user` field supplies the optional default user. +- `POST /ssh/sign`: `{"host":"example.com","item":"id","payload":"base64"}`. + The response is `{"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 through QuickJS, as D58 requires. +Identity discovery checks access without spending a signing use. +Signing reserves one use. Preparation commits its pending audit row in the same transaction. +Successful operations complete that row. Signing records only the payload hash, never the payload. +An ungranted caller receives 403 with `error`, `message`, `item`, and `request`. +The host files the request before it responds. +Two matching items produce 409. The signing item must match the selected host rule. +No SSH response returns a private key or decrypted field value. diff --git a/host/src/api/app.js b/host/src/api/app.js new file mode 100644 index 0000000..d289b42 --- /dev/null +++ b/host/src/api/app.js @@ -0,0 +1,81 @@ +// Host-only ceremonies. No world scripts or external dependencies run here. +const bytes = text => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); +const encoded = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +const post = async (path, body) => { + const response = await fetch(path, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)}); + const result = await response.json(); + if (!response.ok) throw new Error(result.message); + return result; +}; +function options(challenge) { + const value = challenge.options; + value.publicKey.challenge = bytes(value.publicKey.challenge); + if (value.publicKey.user) value.publicKey.user.id = bytes(value.publicKey.user.id); + for (const key of ['allowCredentials', 'excludeCredentials']) { + for (const credential of value.publicKey[key] || []) credential.id = bytes(credential.id); + } + return value; +} +function serialize(credential) { + const response = {}; + for (const key of ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature', 'userHandle']) { + if (credential.response[key]) response[key] = encoded(credential.response[key]); + } + return {id: credential.id, rawId: encoded(credential.rawId), type: credential.type, response, clientExtensionResults: credential.getClientExtensionResults()}; +} +async function assertion(challenge) { + return {challenge_id: challenge.id, assertion: serialize(await navigator.credentials.get(options(challenge)))}; +} +async function encrypt(publicKey, secret) { + const host = await crypto.subtle.importKey('raw', new Uint8Array(publicKey), {name: 'ECDH', namedCurve: 'P-256'}, false, []); + const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, true, ['deriveBits']); + const shared = await crypto.subtle.deriveBits({name: 'ECDH', public: host}, pair.privateKey, 256); + const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); + const key = await crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new TextEncoder().encode('sausage/secret-entry/v1')}, material, {name: 'AES-GCM', length: 256}, false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(secret)); + const ephemeral_public_key = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); + const envelope = {ephemeral_public_key: [...ephemeral_public_key], iv: [...iv], ciphertext: [...new Uint8Array(ciphertext)]}; + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...envelope.ephemeral_public_key, ...envelope.iv, ...envelope.ciphertext]))); + return {envelope, ciphertext_sha256: [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('')}; +} +const form = document.querySelector('form'); +const status = document.querySelector('[role=status]'); +form.addEventListener('submit', async event => { + event.preventDefault(); + const buttons = [...form.querySelectorAll('button')]; + buttons.forEach(button => button.disabled = true); + status.textContent = 'Complete the passkey prompt, if one appears.'; + try { + const data = new FormData(form); + if (document.body.dataset.page === 'approve') { + const path = `/cred/requests/${encodeURIComponent(document.body.dataset.request)}`; + const decision = event.submitter.value; + const overrides = decision === 'deny' || document.body.dataset.grant !== 'true' ? {} : {duration_secs: Number(data.get('duration')), max_uses: data.get('uses') ? Number(data.get('uses')) : null}; + const challenge = await post(`${path}/challenge`, {decision, overrides}); + await post(`${path}/decide`, challenge.unlocked ? {decision, overrides} : await assertion(challenge)); + } else if (document.body.dataset.page === 'register') { + const label = data.get('label'); + const state = await (await fetch('/cred/passkeys')).json(); + let gate; + if (state.length) { + const proof = await assertion(await post('/cred/passkeys/register/challenge', {label})); + gate = {id: proof.challenge_id, response: proof.assertion}; + } + const challenge = await post('/cred/passkeys/register/start', {label, gate}); + const credential = await navigator.credentials.create(options(challenge)); + await post('/cred/passkeys/register/finish', {challenge_id: challenge.id, response: serialize(credential)}); + } else { + const path = `/cred/items/${encodeURIComponent(document.body.dataset.item)}/fields/${encodeURIComponent(document.body.dataset.field)}`; + const encrypted = await encrypt(JSON.parse(document.body.dataset.publicKey), data.get('secret')); + form.elements.secret.value = ''; + const challenge = await post(`${path}/challenge`, {ciphertext_sha256: encrypted.ciphertext_sha256}); + await post(path, {...await assertion(challenge), envelope: encrypted.envelope}); + } + status.textContent = 'Complete.'; + } catch { + status.textContent = 'The request failed. Review the page and try again.'; + } finally { + buttons.forEach(button => button.disabled = false); + } +}); diff --git a/host/src/api/approve.html b/host/src/api/approve.html new file mode 100644 index 0000000..bfa30f1 --- /dev/null +++ b/host/src/api/approve.html @@ -0,0 +1,11 @@ + + +Approve a credential request + +

Review the credential request

+

{{summary}}

{{details}}
+
Grant limits + +
+
+

diff --git a/host/src/api/auth.rs b/host/src/api/auth.rs new file mode 100644 index 0000000..b7d3e8d --- /dev/null +++ b/host/src/api/auth.rs @@ -0,0 +1,220 @@ +//! Authorization ceremonies. Build bindings from stored data and consume typed proofs. +use super::{Api, Error, Result, parse, string, value}; +use crate::cred::{ + self, Caller, + requests::Overrides, + webauthn::{Binding, Decision, Gate, Proof, Verified}, +}; +use serde_json::{Value, json}; + +fn choice(body: &Value) -> Result<(Decision, Overrides)> { + let decision: Decision = parse(body["decision"].clone())?; + let overrides: Overrides = parse(body.get("overrides").cloned().unwrap_or(json!({})))?; + if decision == Decision::Deny && overrides != Overrides::default() { + return Err(Error(400, "invalid_overrides")); + } + Ok((decision, overrides)) +} +fn verify(ctx: &Api<'_>, body: &Value) -> Result { + ctx.passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .finish_assertion( + string(body, "challenge_id")?, + parse(body["assertion"].clone())?, + ) + .map_err(|_| Error(403, "assertion_failed")) +} +pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result { + let field = ctx + .cred + .item_get(item)? + .fields + .into_iter() + .find(|f| f.field_id == field) + .ok_or(Error(404, "field_missing"))?; + if field.public { + return Err(Error(400, "secret_field_required")); + } + Ok(field) +} +pub(super) fn route( + ctx: &Api<'_>, + caller: &Caller, + method: &str, + path: &[&str], + body: Value, +) -> Result { + match (method, path) { + ("POST", ["cred", "requests", id, "challenge"]) => { + ctx.rate_limit(caller)?; + let (decision, overrides) = choice(&body)?; + let binding = ctx.cred.decision_binding(id, decision, overrides)?; + let mut passkeys = ctx.passkeys.lock().map_err(|_| Error(500, "storage"))?; + if passkeys.is_unlocked() { + return Ok(json!({"unlocked":true})); + } + value( + passkeys + .start_assertion(binding) + .map_err(|_| Error(403, "assertion_failed"))?, + ) + } + ("POST", ["cred", "requests", id, "decide"]) => { + let (decision, overrides, proof) = if ctx + .passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .is_unlocked() + { + let (decision, overrides) = choice(&body)?; + (decision, overrides, Proof::from(ctx.cred.unlocked()?)) + } else { + let verified = verify(ctx, &body)?; + let Binding::Decision { + request_id, + decision, + overrides, + .. + } = verified.binding() + else { + return Err(Error(403, "binding_mismatch")); + }; + if request_id.as_str() != *id + || (body.get("decision").is_some() + && choice(&body)? != (*decision, overrides.clone())) + { + return Err(Error(403, "binding_mismatch")); + } + (*decision, overrides.clone(), Proof::from(verified)) + }; + match decision { + Decision::Approve => ctx.cred.approve(id, decision, overrides, proof)?, + Decision::Deny => ctx.cred.deny(id, decision, proof)?, + } + Ok(json!({"ok":true})) + } + ("GET", ["cred", "passkeys"]) => value( + ctx.passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .list() + .map_err(|_| Error(500, "storage"))?, + ), + ("POST", ["cred", "passkeys", "register", "challenge"]) => { + ctx.rate_limit(caller)?; + value( + ctx.passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .start_assertion(Binding::Enrollment { + label: string(&body, "label")?.into(), + }) + .map_err(|_| Error(403, "assertion_failed"))?, + ) + } + ("POST", ["cred", "passkeys", "register", "start"]) => { + ctx.rate_limit(caller)?; + let gate: Option = body + .get("gate") + .filter(|v| !v.is_null()) + .cloned() + .map(parse) + .transpose()?; + value( + ctx.passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .start_registration(string(&body, "label")?.into(), gate) + .map_err(|_| Error(403, "registration_failed"))?, + ) + } + ("POST", ["cred", "passkeys", "register", "finish"]) => { + ctx.passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .finish_registration( + string(&body, "challenge_id")?, + parse(body["response"].clone())?, + ) + .map_err(|_| Error(403, "registration_failed"))?; + Ok(json!({"ok":true})) + } + ("GET", ["cred", "entry", "key"]) => Ok(json!({"public_key":ctx.entry.public_key()})), + ("POST", ["cred", "items", item, "fields", name, "challenge"]) => { + ctx.rate_limit(caller)?; + let field = field(ctx, item, name)?; + let hash = string(&body, "ciphertext_sha256")?; + if hash.len() != 64 + || !hash + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error(400, "invalid_hash")); + } + let binding = Binding::SecretEntry { + item: (*item).into(), + field: (*name).into(), + ciphertext_sha256: hash.into(), + field_type: Some(field.field_type), + key_type: field.key_type, + }; + value( + ctx.passkeys + .lock() + .map_err(|_| Error(500, "storage"))? + .start_assertion(binding) + .map_err(|_| Error(403, "assertion_failed"))?, + ) + } + ("POST", ["cred", "items", item, "fields", name]) => { + let envelope: cred::entry::Envelope = parse(body["envelope"].clone())?; + let proof = verify(ctx, &body)?; + let Binding::SecretEntry { + item: bound_item, + field: bound_field, + ciphertext_sha256, + field_type, + key_type, + } = proof.binding() + else { + return Err(Error(403, "binding_mismatch")); + }; + if bound_item.as_str() != *item + || bound_field.as_str() != *name + || *ciphertext_sha256 != envelope.sha256() + { + return Err(Error(403, "binding_mismatch")); + } + field(ctx, item, name)?; + let input = cred::items::FieldInput { + value: cred::items::FieldValue::Encrypted { + key: &ctx.entry, + envelope: &envelope, + }, + field_type: field_type.clone(), + key_type: key_type.clone(), + }; + ctx.cred + .set_field_input(item, name, &input, caller, proof)?; + Ok(json!({"ok":true})) + } + ("POST", ["cred", "grants", id, "revoke"]) => { + ctx.rate_limit(caller)?; + ctx.cred.grant_status(id)?; + let request = ctx.cred.file_operation_request( + caller, + super::request_key(&body)?, + cred::edits::OperationRequest { + item: None, + base_version: None, + change: vec![cred::edits::Operation::GrantRevoke { + target: (*id).into(), + }], + }, + )?; + Ok(json!({"request":request})) + } + _ => Err(Error(404, "route_missing")), + } +} diff --git a/host/src/api/entry.html b/host/src/api/entry.html new file mode 100644 index 0000000..a853843 --- /dev/null +++ b/host/src/api/entry.html @@ -0,0 +1,8 @@ + + +Enter a credential secret + +

Enter a secret for {{item_name}}: {{field_name}}

+

This host page encrypts the value before it sends the request.

+
+

diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs new file mode 100644 index 0000000..123148a --- /dev/null +++ b/host/src/api/mod.rs @@ -0,0 +1,229 @@ +//! 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(()) + } +} diff --git a/host/src/api/pages.rs b/host/src/api/pages.rs new file mode 100644 index 0000000..0bc796c --- /dev/null +++ b/host/src/api/pages.rs @@ -0,0 +1,117 @@ +//! Host-owned HTML and JavaScript. Public review data enters HTML only as escaped text. +use super::{Api, Error, Result, request_view}; +use bytes::Bytes; +use http::Response; + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} +pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result>> { + let parts: Vec<_> = path.trim_matches('/').split('/').collect(); + let (mime, text) = match parts.as_slice() { + ["app.js"] => ( + "text/javascript; charset=utf-8", + include_str!("app.js").to_string(), + ), + ["register"] => ( + "text/html; charset=utf-8", + include_str!("register.html").to_string(), + ), + ["approve", id] => { + let request = ctx + .cred + .request_list()? + .into_iter() + .find(|request| request.id == *id) + .ok_or(Error(404, "request_missing"))?; + let items = request + .payload + .get("items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let grant = request + .payload + .get("grant_create") + .unwrap_or(&request.payload); + let grant_request = request.payload.get("grant_create").is_some() || !items.is_empty(); + let duration = grant + .get("duration_secs") + .and_then(serde_json::Value::as_u64) + .or_else(|| { + grant + .get("exp") + .and_then(serde_json::Value::as_i64) + .map(|expiry| { + expiry.saturating_sub(crate::cred::store::now()).max(1) as u64 + }) + }) + .unwrap_or(3600); + let uses = grant + .get("max_uses") + .and_then(serde_json::Value::as_u64) + .map(|uses| uses.to_string()) + .unwrap_or_default(); + let mut review = request_view(request)?; + if !items.is_empty() { + review["items"] = super::value( + ctx.cred + .item_list()? + .into_iter() + .filter(|item| items.iter().any(|id| id.as_str() == Some(&item.id))) + .collect::>(), + )?; + } + let text = include_str!("approve.html") + .replace( + "{{grant_request}}", + if grant_request { "true" } else { "false" }, + ) + .replace( + "{{limits_hidden}}", + if grant_request { "" } else { "hidden disabled" }, + ) + .replace("{{duration}}", &duration.to_string()) + .replace("{{uses}}", &uses) + .replace("{{request_id}}", &escape(id)) + .replace( + "{{summary}}", + &escape(review["summary"].as_str().expect("summary string")), + ) + .replace( + "{{details}}", + &escape( + &serde_json::to_string_pretty(&review) + .map_err(|_| Error(500, "serialization"))?, + ), + ); + ("text/html; charset=utf-8", text) + } + ["entry", item, field] => { + let snapshot = ctx.cred.item_get(item)?; + let selected = super::auth::field(ctx, item, field)?; + let text = include_str!("entry.html") + .replace("{{item_id}}", &escape(item)) + .replace("{{field_id}}", &escape(field)) + .replace("{{item_name}}", &escape(&snapshot.name)) + .replace("{{field_name}}", &escape(&selected.name)) + .replace( + "{{public_key}}", + &serde_json::to_string(&ctx.entry.public_key()) + .map_err(|_| Error(500, "serialization"))?, + ); + ("text/html; charset=utf-8", text) + } + _ => return Ok(None), + }; + Ok(Some( + Response::builder() + .header("content-type", mime) + .body(Bytes::from(text)) + .expect("static headers"), + )) +} diff --git a/host/src/api/register.html b/host/src/api/register.html new file mode 100644 index 0000000..b49d554 --- /dev/null +++ b/host/src/api/register.html @@ -0,0 +1,6 @@ + + +Register a passkey +

Register a passkey

The first passkey needs no gate. Later passkeys require an existing passkey.

+
+

diff --git a/host/src/api/server.rs b/host/src/api/server.rs new file mode 100644 index 0000000..3adec06 --- /dev/null +++ b/host/src/api/server.rs @@ -0,0 +1,150 @@ +//! The host HTTP listener and console startup with one shared credentials store. +use super::{Api, respond}; +use crate::cred::{Caller, Cred, entry::EntryKey, webauthn::Passkeys}; +use std::{net::SocketAddr, path::Path, sync::Arc}; + +#[derive(Clone, Debug, clap::Args)] +pub struct Options { + /// Address of the host's approval, enrollment, and secret-entry listener. + #[arg(long, default_value = "127.0.0.1:8081")] + pub api_listen: SocketAddr, + /// WebAuthn relying-party ID for the host pages. + #[arg(long, default_value = "localhost")] + pub api_rp_id: String, + /// Browser origin of the host pages, not the world's origin. + #[arg(long, default_value = "http://localhost:8081")] + pub api_origin: String, +} + +pub struct Listener { + server: Arc, + thread: Option>, +} +impl Listener { + pub fn start(cred: Arc, host_dir: &Path, options: Options) -> anyhow::Result { + let server = Arc::new( + tiny_http::Server::http(options.api_listen) + .map_err(|_| anyhow::anyhow!("host API address unavailable"))?, + ); + let serving = server.clone(); + let host_dir = host_dir.to_owned(); + let (ready, started) = std::sync::mpsc::sync_channel(1); + let thread = std::thread::spawn(move || { + let setup = || -> anyhow::Result<_> { + Ok(( + rusqlite::Connection::open(host_dir.join("cred.sqlite"))?, + EntryKey::open(&host_dir.join("entry.key"))?, + )) + }; + let serve = || -> anyhow::Result<()> { + let (db, entry) = setup()?; + let passkeys = Passkeys::new(&db, &options.api_rp_id, &options.api_origin)?; + let api = Api::new(cred, passkeys, entry); + let _ = ready.send(true); + for mut request in serving.incoming_requests() { + let response = (|| -> anyhow::Result<_> { + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body)?; + let request = http::Request::builder() + .method(request.method().as_str()) + .uri(request.url()) + .body(bytes::Bytes::from(body))?; + Ok(respond( + &api, + Caller { + program: "/host/browser".into(), + pid: std::process::id(), + conversations: Vec::new(), + }, + request, + )) + })(); + let response = response.unwrap_or_else(|_| { + http::Response::builder() + .status(400) + .header("content-type", "application/json") + .body(bytes::Bytes::from_static( + b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}", + )) + .expect("static response") + }); + let content_type = response.headers()["content-type"].as_bytes(); + let reply = tiny_http::Response::from_data(response.body().to_vec()) + .with_status_code(response.status().as_u16()) + .with_header( + tiny_http::Header::from_bytes(b"Content-Type", content_type) + .expect("static header"), + ); + let _ = request.respond(reply); + } + Ok(()) + }; + if serve().is_err() { + let _ = ready.send(false); + } + }); + if !started.recv().unwrap_or(false) { + let _ = thread.join(); + anyhow::bail!("host API initialization failed"); + } + Ok(Self { + server, + thread: Some(thread), + }) + } + pub fn address(&self) -> SocketAddr { + self.server.server_addr().to_ip().expect("TCP listener") + } +} +impl Drop for Listener { + fn drop(&mut self) { + self.server.unblock(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// Preserve the console lifecycle while sharing Cred with the host page listener. +pub async fn run_with_userland( + path: &Path, + userland: Option<&Path>, + options: Options, +) -> anyhow::Result<()> { + let world = crate::world::World::open_with_userland(path, userland)?; + let cred = Arc::new(Cred::open(&world.host_dir)?); + let _listener = Listener::start(cred.clone(), &world.host_dir, options)?; + let runtime = crate::process::Runtime::new(crate::world::World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + })?; + let services = crate::process::supervise(runtime.clone()); + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::harness::run_module_with_credentials( + world, + Arc::new(runtime), + cred, + include_str!("../../js/bundle.js"), + )) + }) + .await + .map_err(anyhow::Error::from) + .and_then(|result| result) + .map_err(|error| { + let mut text = error.to_string(); + for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] { + if let Ok(value) = std::env::var(key) + && !value.is_empty() + { + text = text.replace(&value, "[redacted]"); + } + } + anyhow::anyhow!(text) + }); + services.shutdown().await; + result +} diff --git a/host/src/api/ssh.rs b/host/src/api/ssh.rs new file mode 100644 index 0000000..51bfed3 --- /dev/null +++ b/host/src/api/ssh.rs @@ -0,0 +1,110 @@ +//! 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 event = if action == "sign" { + "ssh.sign.pending" + } else { + "ssh.identity.pending" + }; + let prepared = cred.prepare_use(&item, caller, action == "sign", event)?; + let Some((grant, values, audit_id)) = 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 = ctx + .script + .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"))?; + cred.access()? + .db + .execute( + "UPDATE audit SET event='ssh.identity' WHERE id=?", + [audit_id], + ) + .map_err(cred::Error::from)?; + 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.access()? + .db + .execute( + "UPDATE audit SET event='sign',payload_sha256=?1 WHERE id=?2", + rusqlite::params![cred::webauthn::digest(&payload), audit_id], + ) + .map_err(cred::Error::from)?; + Ok(json!({"signature":signature.signature})) + } +} diff --git a/host/src/cred/grants.rs b/host/src/cred/grants.rs index b652e99..a04414b 100644 --- a/host/src/cred/grants.rs +++ b/host/src/cred/grants.rs @@ -188,6 +188,16 @@ impl Cred { &self, item: &super::items::Item, caller: &Caller, + ) -> Result, serde_json::Value, i64)>> { + self.prepare_use(item, caller, true, "fetch.pending") + } + /// Identity discovery checks access without spending a signing use. + pub(crate) fn prepare_use( + &self, + item: &super::items::Item, + caller: &Caller, + consume: bool, + event: &str, ) -> Result, serde_json::Value, i64)>> { let mut s = self.access()?; let master = s.master.clone(); @@ -205,14 +215,16 @@ impl Cred { return Err(Error("stale")); } let credential = super::items::credential(&tx, &master, item, true)?; - if let Some(g) = &mut grant { + if let Some(g) = &mut grant + && 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 Passkeys<'a> { ) .unwrap_or(false) } + /// Public labels and credential IDs, without serialized passkey records. + pub fn list(&self) -> Result> { + let mut statement = self + .db + .prepare("SELECT id,label FROM passkey_credentials ORDER BY rowid")?; + let rows = statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + rows.map(|row| { + let (id, label) = row?; + let id: CredentialID = serde_json::from_str(&id)?; + Ok(serde_json::json!({"id":id,"label":label})) + }) + .collect() + } fn credentials(&self) -> Result> { let mut q = self .db diff --git a/host/src/harness/mod.rs b/host/src/harness/mod.rs index a70c774..05b08b5 100644 --- a/host/src/harness/mod.rs +++ b/host/src/harness/mod.rs @@ -258,7 +258,6 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow:: )?; let services = crate::process::supervise(runtime.clone()); let result = run_with_shared_credentials(world, Arc::new(runtime), cred).await; - services.abort(); - let _ = services.await; + services.shutdown().await; result } diff --git a/host/src/lib.rs b/host/src/lib.rs index d2182e3..048b719 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -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; diff --git a/host/src/main.rs b/host/src/main.rs index 879e293..cda1166 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -19,6 +19,8 @@ enum Command { world: PathBuf, #[arg(long)] userland: Option, + #[command(flatten)] + api: sausage::api::Options, }, /// Run a shell in a world. With `-c`, run one command and exit with its status. Sh { @@ -37,10 +39,14 @@ async fn main() -> anyhow::Result<()> { sausage::process::configure_cache(cache)?; } match cli.command { - Command::Run { world, userland } => { + Command::Run { + world, + userland, + api, + } => { let bundle = userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from)); - sausage::harness::run_with_userland(&world, bundle.as_deref()).await + sausage::api::run_with_userland(&world, bundle.as_deref(), api).await } Command::Sh { world, diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index ae3e35b..2efb082 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -41,6 +41,7 @@ mod bindings { with: { "wasi:io": wasmtime_wasi::p2::bindings::io, "sausage:process/host.child": super::Child, + "sausage:process/host.watcher": super::Watcher, }, }); } @@ -55,6 +56,8 @@ pub fn default_script() -> Arc { } pub use cache::configure as configure_cache; mod services; +mod watcher; +pub use watcher::Watcher; mod stdio; pub use services::supervise; use stdio::Io; @@ -567,7 +570,23 @@ impl Host for State { Ok(self.runtime.kill_pid(pid).map_err(|e| e.to_string())) } } +#[async_trait::async_trait] +impl wasmtime_wasi::p2::Pollable for Child { + async fn ready(&mut self) { + while self.code.borrow_and_update().is_none() { + if self.code.changed().await.is_err() { + break; + } + } + } +} impl HostChild for State { + async fn subscribe( + &mut self, + child: Resource, + ) -> wasmtime::Result> { + wasmtime_wasi::p2::subscribe(&mut self.table, child) + } async fn pid(&mut self, child: Resource) -> wasmtime::Result { Ok(self.table.get(&child)?.pid) } diff --git a/host/src/process/services.rs b/host/src/process/services.rs index 9b814bb..f7b06f6 100644 --- a/host/src/process/services.rs +++ b/host/src/process/services.rs @@ -1,49 +1,28 @@ -//! Supervises the world's services list, live edits, crash limits, and graceful stops. -use super::{GroupGuard, Request, Runtime, Started, Stdio}; -use serde::Deserialize; -use std::{ - collections::{BTreeMap, VecDeque}, - sync::Arc, - time::Duration, -}; -use tokio::time::Instant; - -#[derive(Clone, Deserialize, PartialEq, Eq)] -struct Spec { - name: String, - argv: Vec, - #[serde(default = "root")] - cwd: String, - #[serde(default)] - env: BTreeMap, +//! Starts world init, reaps its command tree, and applies host restart and shutdown deadlines. +use super::{GroupGuard, Request, Runtime, Stdio}; +use std::{sync::Arc, time::Duration}; +pub struct Supervisor { + shutdown: tokio::sync::oneshot::Sender<()>, + task: tokio::task::JoinHandle<()>, } -fn root() -> String { - "/".into() +impl Supervisor { + pub async fn shutdown(self) { + let _ = self.shutdown.send(()); + let _ = self.task.await; + } } -struct Live { - child: Started, - _guard: GroupGuard, -} -struct Service { - spec: Spec, - live: Option, - crashes: VecDeque, - stopped: bool, -} -impl Service { - async fn start(&mut self, runtime: &Arc) { - match runtime - .start( +/// The host knows only init. Unit policy belongs to the WASI program. +pub fn supervise(runtime: Arc) -> Supervisor { + let (shutdown, mut stop) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let mut backoff = Duration::from_secs(1); + loop { + let start = runtime.start( Request { - program: self.spec.argv.first().cloned().unwrap_or_default(), - argv: self.spec.argv.clone(), - cwd: self.spec.cwd.clone(), - env: self - .spec - .env - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), + program: "/bin/init.wasm".into(), + argv: vec!["/bin/init.wasm".into()], + cwd: "/".into(), + env: Vec::new(), group: None, stdin: Stdio::Pipe, stdout: Stdio::Null, @@ -54,116 +33,56 @@ impl Service { 0, Vec::new(), None, - ) - .await - { - Ok(child) => { + ); + let child = tokio::select! { _ = &mut stop => return, child = start => child }; + if let Ok(mut child) = child { let guard = GroupGuard { runtime: runtime.clone(), group: child.pid, }; - self.live = Some(Live { - child, - _guard: guard, - }); + let exiting = async { + while child.code.borrow_and_update().is_none() { + if child.code.changed().await.is_err() { + break; + } + } + }; + let shutdown = tokio::select! { _ = &mut stop => true, _ = exiting => false }; + if shutdown { + drop(child.input.take()); + let _ = tokio::time::timeout(Duration::from_secs(15), async { + while child.code.borrow_and_update().is_none() { + if child.code.changed().await.is_err() { + break; + } + } + }) + .await; + drop(guard); + reaped(&runtime, child.pid).await; + return; + } + // This guard kills all descendants, including separate process groups. + drop(guard); + reaped(&runtime, child.pid).await; } - Err(_) => self.crashed(), + tokio::select! { _ = &mut stop => return, _ = tokio::time::sleep(backoff) => () } + backoff = (backoff * 2).min(Duration::from_secs(30)); } - } - fn crashed(&mut self) { - let now = Instant::now(); - while self - .crashes - .front() - .is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(10)) + }); + Supervisor { shutdown, task } +} +async fn reaped(runtime: &Runtime, command: u32) { + loop { + if !runtime + .entries + .lock() + .unwrap() + .values() + .any(|entry| entry.command == command) { - self.crashes.pop_front(); - } - self.crashes.push_back(now); - self.stopped = self.crashes.len() > 5; - } - async fn stop(&mut self) { - if let Some(mut live) = self.live.take() { - drop(live.child.input.take()); - let _ = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if live.child.code.borrow().is_some() { - break; - } - if live.child.code.changed().await.is_err() { - break; - } - } - }) - .await; - // The guard stops remaining descendants, or the process after its grace period. + return; } + tokio::task::yield_now().await; } } -/// Start the supervisor. Aborting its task stops its service processes. -/// The YAML file contains name, argv, optional cwd, and an optional map of environment variables. -pub fn supervise(runtime: Arc) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut services: BTreeMap = BTreeMap::new(); - let mut previous = Vec::new(); - let mut interval = tokio::time::interval(Duration::from_millis(100)); - loop { - interval.tick().await; - let content = match runtime - .world - .root - .open("/config/services.yaml", Default::default()) - .await - { - Ok(file) => match file.stat().await { - Ok(stat) => file.read(0, stat.size as usize).await.ok(), - Err(_) => None, - }, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(b"[]".to_vec()), - Err(_) => None, - }; - if let Some(content) = content.filter(|c| *c != previous) - && let Ok(specs) = serde_yaml_ng::from_slice::>(&content) - { - let specs: BTreeMap<_, _> = - specs.into_iter().map(|s| (s.name.clone(), s)).collect(); - let removed: Vec<_> = services - .keys() - .filter(|name| !specs.contains_key(*name)) - .cloned() - .collect(); - for name in removed { - services.remove(&name).unwrap().stop().await; - } - for (name, spec) in specs { - let service = services.entry(name).or_insert_with(|| Service { - spec: spec.clone(), - live: None, - crashes: VecDeque::new(), - stopped: false, - }); - if service.spec != spec { - service.stop().await; - } - service.spec = spec; - service.crashes.clear(); - service.stopped = false; - } - previous = content; - } - for service in services.values_mut() { - if service - .live - .as_ref() - .is_some_and(|live| live.child.code.borrow().is_some()) - { - service.live = None; - service.crashed(); - } - if service.live.is_none() && !service.stopped { - service.start(&runtime).await; - } - } - } - }) -} diff --git a/host/src/process/watcher.rs b/host/src/process/watcher.rs new file mode 100644 index 0000000..88c8963 --- /dev/null +++ b/host/src/process/watcher.rs @@ -0,0 +1,111 @@ +//! Adapts VFS observation to bounded, pollable world-path change queues. +use super::{ + State, + bindings::sausage::process::host::{Change, ChangeKind, HostWatcher}, +}; +use sausage_vfs::observe::{ChangeKind as Kind, under}; +use tokio::sync::broadcast::{Receiver, error::TryRecvError}; +use wasmtime::component::Resource; +use wasmtime_wasi::p2::{DynPollable, Pollable, subscribe}; + +pub struct Watcher { + paths: Vec, + events: Receiver<(String, Kind)>, + pending: Vec, +} +impl Watcher { + fn push(&mut self, path: String, kind: Kind) { + if self.paths.iter().any(|base| under(&path, base)) { + if self + .pending + .iter() + .any(|c| matches!(c.kind, ChangeKind::Overflow)) + { + return; + } + if self.pending.len() >= 256 { + self.overflow(); + return; + } + self.pending.push(Change { + path, + kind: match kind { + Kind::Created => ChangeKind::Created, + Kind::Modified => ChangeKind::Modified, + Kind::Removed => ChangeKind::Removed, + }, + }); + } + } + fn overflow(&mut self) { + self.pending.clear(); + self.pending.push(Change { + path: "/".into(), + kind: ChangeKind::Overflow, + }); + } + fn drain(&mut self) { + loop { + match self.events.try_recv() { + Ok((path, kind)) => self.push(path, kind), + Err(TryRecvError::Lagged(_)) => self.overflow(), + Err(_) => break, + } + } + } +} +#[async_trait::async_trait] +impl Pollable for Watcher { + async fn ready(&mut self) { + loop { + self.drain(); + if !self.pending.is_empty() { + return; + } + match self.events.recv().await { + Ok((path, kind)) => self.push(path, kind), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => self.overflow(), + Err(_) => return, + } + } + } +} +impl HostWatcher for State { + async fn new(&mut self, paths: Vec) -> wasmtime::Result> { + let observer = self + .runtime + .world + .root + .observer() + .ok_or_else(|| wasmtime::format_err!("filesystem has no observer"))?; + let paths: Vec = paths + .iter() + .map(|p| { + sausage_vfs::resolve("/", p) + .trim_end_matches('/') + .to_owned() + }) + .map(|p| if p.is_empty() { "/".into() } else { p }) + .collect(); + Ok(self.table.push(Watcher { + events: observer.subscribe(paths.clone()), + paths, + pending: Vec::new(), + })?) + } + async fn subscribe( + &mut self, + watcher: Resource, + ) -> wasmtime::Result> { + subscribe(&mut self.table, watcher) + } + async fn changes(&mut self, watcher: Resource) -> wasmtime::Result> { + let watcher = self.table.get_mut(&watcher)?; + watcher.drain(); + Ok(std::mem::take(&mut watcher.pending)) + } + async fn drop(&mut self, watcher: Resource) -> wasmtime::Result<()> { + self.table.delete(watcher)?; + Ok(()) + } +} diff --git a/host/tests/api.rs b/host/tests/api.rs new file mode 100644 index 0000000..d5a7987 --- /dev/null +++ b/host/tests/api.rs @@ -0,0 +1,235 @@ +//! Direct host API tests for reads, request filing, and the SSH signing oracle. +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 { + 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(),cred.unlocked()?)?) +} + +#[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(), + cred.unlocked()?, + )?; + 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(), + api.cred.unlocked()?, + )?; + 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 audit = api.cred.audit_list()?; + assert!( + audit + .iter() + .any(|row| row.event == "sign" && row.payload_sha256.is_some()) + ); + assert!( + !audit + .iter() + .any(|row| row.event == "ssh.sign.pending" || row.event == "fetch.pending") + ); + 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?; + 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(()) +} diff --git a/host/tests/api_auth.rs b/host/tests/api_auth.rs new file mode 100644 index 0000000..48fc7bd --- /dev/null +++ b/host/tests/api_auth.rs @@ -0,0 +1,433 @@ +//! Host page routes with a software authenticator and the page's WebCrypto implementation. +use anyhow::Result; +use bytes::Bytes; +use sausage::{ + api::{Api, handle}, + cred::{ + self, Caller, Cred, + entry::{EntryKey, Envelope}, + webauthn::{Challenge, Passkeys}, + }, +}; +use serde_json::{Value, json}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse, Url}; +const ORIGIN: &str = "http://localhost:8081"; +const SECRET: &str = "browser-only-value"; +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 response = handle( + api, + caller(), + http::Request::builder() + .method(method) + .uri(path) + .body(Bytes::from(serde_json::to_vec(&body)?))?, + ) + .await; + assert!(!std::str::from_utf8(response.body())?.contains(SECRET)); + 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) +} +async fn page(api: &Api<'_>, path: &str) -> Result { + let response = handle( + api, + caller(), + http::Request::builder().uri(path).body(Bytes::new())?, + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + let text = std::str::from_utf8(response.body())?; + assert!(!text.contains(SECRET)); + assert!(text.contains("/app.js")); + Ok(text.into()) +} +fn item(cred: &Cred, name: &str) -> Result { + Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?) +} +async fn file(api: &Api<'_>, item: &str, key: &str) -> Result { + let result = ok( + api, + "POST", + "/cred/requests", + json!({"kind":"grant","key":key,"request":{"items":[item],"duration_secs":300}}), + ) + .await?; + Ok(result["request"].as_str().unwrap().into()) +} +async fn register(api: &Api<'_>) -> Result> { + let start: Challenge = serde_json::from_value( + ok( + api, + "POST", + "/cred/passkeys/register/start", + json!({"label":"browser"}), + ) + .await?, + )?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let response = auth.do_registration(Url::parse(ORIGIN)?, start.options)?; + ok( + api, + "POST", + "/cred/passkeys/register/finish", + json!({"challenge_id":start.id,"response":response}), + ) + .await?; + Ok(auth) +} +async fn assertion( + api: &Api<'_>, + auth: &mut WebauthnAuthenticator, + path: &str, + body: Value, +) -> Result { + let start: Challenge = + serde_json::from_value(ok(api, "POST", path, body).await?)?; + let assertion = auth.do_authentication(Url::parse(ORIGIN)?, start.options)?; + Ok(json!({"challenge_id":start.id,"assertion":assertion})) +} + +#[tokio::test(flavor = "current_thread")] +async fn approvals_registration_and_revocation() -> Result<()> { + let dir = tempfile::tempdir()?; + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?; + let cred = Cred::open(dir.path())?; + let first = item(&cred, "bootstrap")?; + let second = item(&cred, "review ")?; + let api = Api::new( + cred, + Passkeys::new(&db, "localhost", ORIGIN)?, + EntryKey::open(&dir.path().join("entry.key"))?, + ); + page(&api, "/register").await?; + let bootstrap = file(&api, &first.id, "bootstrap").await?; + let bootstrap_path = format!("/cred/requests/{bootstrap}"); + assert_eq!( + ok( + &api, + "POST", + &format!("{bootstrap_path}/challenge"), + json!({"decision":"approve"}) + ) + .await?["unlocked"], + true + ); + ok( + &api, + "POST", + &format!("{bootstrap_path}/decide"), + json!({"decision":"approve"}), + ) + .await?; + assert!( + api.cred + .grant_list()? + .iter() + .any(|g| g.items.contains(&first.id)) + ); + let mut auth = register(&api).await?; + let list = ok(&api, "GET", "/cred/passkeys", json!({})).await?; + assert_eq!(list[0]["label"], "browser"); + assert!(!list[0]["id"].as_str().unwrap().contains('"')); + assert_eq!( + send( + &api, + "POST", + "/cred/passkeys/register/start", + json!({"label":"ungated"}) + ) + .await? + .0, + 403 + ); + let id = file(&api, &second.id, "approval").await?; + let other = file(&api, &second.id, "other").await?; + let path = format!("/cred/requests/{id}"); + let review = page(&api, &format!("/approve/{id}")).await?; + assert!(review.contains("data-grant=\"true\"")); + assert!(review.contains("value=\"300\"")); + assert!(review.contains("<script>not markup</script>")); + assert!(!review.contains("")); + assert_ne!( + send( + &api, + "POST", + &format!("{path}/decide"), + json!({"decision":"approve"}) + ) + .await? + .0, + 200 + ); + assert_eq!( + send( + &api, + "POST", + &format!("{path}/challenge"), + json!({"decision":"approve","overrides":{"max_use":1}}) + ) + .await? + .0, + 400 + ); + let gate = assertion( + &api, + &mut auth, + &format!("{path}/challenge"), + json!({"decision":"approve"}), + ) + .await?; + assert_eq!( + send( + &api, + "POST", + &format!("/cred/requests/{other}/decide"), + gate.clone() + ) + .await? + .0, + 403 + ); + assert_eq!( + send(&api, "POST", &format!("{path}/decide"), gate).await?.0, + 403 + ); + let mut gate = assertion( + &api, + &mut auth, + &format!("{path}/challenge"), + json!({"decision":"deny"}), + ) + .await?; + gate["decision"] = json!("approve"); + assert_eq!( + send(&api, "POST", &format!("{path}/decide"), gate).await?.0, + 403 + ); + let gate = assertion( + &api, + &mut auth, + &format!("{path}/challenge"), + json!({"decision":"approve","overrides":{"max_uses":2}}), + ) + .await?; + ok(&api, "POST", &format!("{path}/decide"), gate.clone()).await?; + assert_eq!( + send(&api, "POST", &format!("{path}/decide"), gate).await?.0, + 403 + ); + let grant = api + .cred + .grant_list()? + .into_iter() + .find(|g| g.items.contains(&second.id)) + .unwrap(); + assert!(grant.subject.covers(&caller())); + assert_eq!(grant.max_uses, Some(2)); + let revoke = ok( + &api, + "POST", + &format!("/cred/grants/{}/revoke", grant.id), + json!({"key":"revoke"}), + ) + .await?; + assert!(!api.cred.grant_status(&grant.id)?.revoked); + let revoke = revoke["request"].as_str().unwrap(); + let gate = assertion( + &api, + &mut auth, + &format!("/cred/requests/{revoke}/challenge"), + json!({"decision":"approve"}), + ) + .await?; + ok( + &api, + "POST", + &format!("/cred/requests/{revoke}/decide"), + gate, + ) + .await?; + assert!(api.cred.grant_status(&grant.id)?.revoked); + let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":second.id,"base_version":api.cred.item_get(&second.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?; + let edit = edit["request"].as_str().unwrap(); + let edit_page = page(&api, &format!("/approve/{edit}")).await?; + assert!(edit_page.contains("data-grant=\"false\"")); + assert!(edit_page.contains("