api: merge v3 proofs and add host approval pages and listener
This commit is contained in:
+122
-60
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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"),
|
||||
))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user