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

This commit is contained in:
2026-11-06 23:18:24 +00:00
371 changed files with 182752 additions and 1095 deletions
+122 -60
View File
@@ -1,71 +1,133 @@
# 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`.
# Host API and host pages
`handle(&Api, Caller, Request<Bytes>)` 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.
Successful responses use status 200 and JSON.
JSON routes return 200 on success.
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.
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
Conflicts return 409. The filing budget returns 429.
## Current routes
## Host listener
- `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.
`sausage run WORLD` starts the console and the host listener with one shared `Arc<Cred>`.
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}` returns one request with its diff and summary.
- `POST /cred/requests` accepts `{"kind":"grant","key":"stable-key","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 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}`.
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` accepts `{"host":"example.com","item":"id","payload":"base64"}`.
It returns `{"signature":"base64 SSH wire signature"}`.
- `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.
Identity discovery checks the grant without spending a use.
Signing reserves one grant use.
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 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.
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.
+81
View File
@@ -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);
}
});
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Approve a credential request</title>
<body data-page="approve" data-request="{{request_id}}" data-grant="{{grant_request}}">
<h1>Review the credential request</h1>
<p>{{summary}}</p><pre>{{details}}</pre>
<form><fieldset {{limits_hidden}}><legend>Grant limits</legend>
<label>Duration in seconds <input name="duration" type="number" min="1" required value="{{duration}}"></label>
<label>Maximum uses (optional override) <input name="uses" type="number" min="1" value="{{uses}}" placeholder="Use the requested limit"></label></fieldset>
<button name="decision" value="approve">Approve</button><button name="decision" value="deny">Deny</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
+220
View File
@@ -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<Verified> {
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<cred::items::Field> {
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<Value> {
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<Gate> = 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")),
}
}
+8
View File
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Enter a credential secret</title>
<body data-page="entry" data-item="{{item_id}}" data-field="{{field_id}}" data-public-key="{{public_key}}">
<h1>Enter a secret for {{item_name}}: {{field_name}}</h1>
<p>This host page encrypts the value before it sends the request.</p>
<form><label>Secret value <textarea name="secret" required autocomplete="off" spellcheck="false"></textarea></label><button>Store the secret</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
+33 -11
View File
@@ -1,6 +1,10 @@
//! JSON routes for host.sausage. The transport supplies the trusted caller.
//! Authorization routes remain on hold until the host-origin and proof decisions land.
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
mod auth;
mod pages;
mod server;
mod ssh;
pub use server::{Listener, Options, run_with_userland};
use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use bytes::Bytes;
@@ -9,21 +13,23 @@ use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::Mutex,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub struct Api<'a> {
pub cred: Cred,
pub cred: Arc<Cred>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<'a>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
}
impl<'a> Api<'a> {
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(cred: Cred, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
Self {
cred,
cred: cred.into(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
entry,
requests: Mutex::new(HashMap::new()),
@@ -49,6 +55,7 @@ impl From<cred::Error> for Error {
let status = match error.0 {
"item_deleted" | "request_missing" | "grant_missing" => 404,
"ambiguous_item" | "stale" | "already_decided" | "key_conflict" => 409,
"assertion_required" | "binding_mismatch" => 403,
"storage" => 500,
_ => 400,
};
@@ -64,6 +71,13 @@ fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
.and_then(Value::as_str)
.ok_or(Error(400, "invalid_params"))
}
fn request_key(body: &Value) -> Result<&str> {
let key = string(body, "key")?;
if key.starts_with("inject:") || key.starts_with("ssh:") {
return Err(Error(400, "reserved_key"));
}
Ok(key)
}
fn value<T: serde::Serialize>(data: T) -> Result<Value> {
serde_json::to_value(data).map_err(|_| Error(500, "serialization"))
}
@@ -100,7 +114,18 @@ fn request_view(request: cred::requests::Request) -> Result<Value> {
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = route(ctx, &caller, request);
respond(ctx, caller, request)
}
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = if request.method() == http::Method::GET {
match pages::get(ctx, request.uri().path()) {
Ok(Some(response)) => return response,
Ok(None) => route(ctx, &caller, request),
Err(error) => Err(error),
}
} else {
route(ctx, &caller, request)
};
let (status, body) = match result {
Ok(body) => (
if body.get("error").and_then(Value::as_str) == Some("grant_required") {
@@ -150,10 +175,7 @@ fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Valu
.ok_or(Error(404, "request_missing"))?,
),
("POST", ["cred", "requests"]) => {
let key = string(&body, "key")?;
if key.starts_with("inject:") || key.starts_with("ssh:") {
return Err(Error(400, "reserved_key"));
}
let key = request_key(&body)?;
ctx.rate_limit(caller)?;
let id = match string(&body, "kind")? {
"grant" => ctx
@@ -171,7 +193,7 @@ fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Valu
("POST", ["ssh", action @ ("identity" | "sign")]) => {
ssh::handle(ctx, caller, action, &body)
}
_ => Err(Error(404, "route_missing")),
_ => auth::route(ctx, caller, method, &path, body),
}
}
+117
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result<Option<Response<Bytes>>> {
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::<Vec<_>>(),
)?;
}
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"),
))
}
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Register a passkey</title><body data-page="register">
<h1>Register a passkey</h1><p>The first passkey needs no gate. Later passkeys require an existing passkey.</p>
<form><label>Label <input name="label" required autocomplete="off"></label><button>Register</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
+151
View File
@@ -0,0 +1,151 @@
//! 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<tiny_http::Server>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Listener {
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
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.abort();
let _ = services.await;
result
}
+21 -7
View File
@@ -19,13 +19,13 @@ pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value)
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)?))
let event = if action == "sign" {
"ssh.sign.pending"
} else {
cred.prepare_use(&item, caller, action == "sign")?
.map(|(grant, values)| (Some(grant), values))
"ssh.identity.pending"
};
let Some((grant, values)) = prepared else {
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,
@@ -50,7 +50,8 @@ pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value)
.into_iter()
.flatten()
{
let output = cred::v8::V8
let output = ctx
.script
.evaluate(source, &context.to_string(), None, Default::default())
.map_err(|_| Error(403, "validator_denied"))?;
let output: Value =
@@ -76,6 +77,13 @@ pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value)
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)}),
)
@@ -90,7 +98,13 @@ pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value)
};
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)?;
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}))
}
}