Move credential ceremonies into the world web UI and ignore proxy environment

This commit is contained in:
2026-11-12 06:57:34 +00:00
parent e0c2d8bc8b
commit a74ebd11ba
22 changed files with 209 additions and 356 deletions
+8 -18
View File
@@ -1,4 +1,4 @@
# Host API and host pages
# Host JSON API
`handle(&Api, Caller, Request<Bytes>)` answers requests without a transport.
The `wasi:http` hook supplies the caller from the process table.
@@ -17,21 +17,11 @@ The host opens no separate network port.
Browser ceremonies derive the passkey RP ID and origin from the request Origin header.
The host retains separate pending ceremony state per origin against the same credential registry.
After the first registration, every decision and further registration requires a passkey assertion.
Host pages use relative assets and routes so they work under `/api/`.
## 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.
The host serves no HTML or UI assets. The world web UI owns credential views in `/srv/www`.
The Passkeys view lists labels and registers passkeys. The bootstrap notice opens that view.
The approval view at `/#approve/ID` reads stored summaries and details through JSON routes.
The secret-entry view at `/#entry/ITEM/FIELD` encrypts the secret before submission.
The host constructs authorization bindings from stored requests and verifies every proof.
## Public JSON routes
@@ -76,8 +66,8 @@ The V8 harness answers queries and invokes actions without a second storage read
- `GET /harness/models`: `{models:[...]}` with public catalog fields, without provider authentication data.
- `GET /harness/families`: `{families:{...}}` from the world's model definitions.
- `GET /harness/extensions`: the loaded extension status, also visible through `/proc/extensions`.
- `GET /harness/credential-requests`: `{requests:[...]}` with pending conversation requests and host approval links.
Approval stays on the host page.
- `GET /harness/credential-requests`: `{requests:[...]}` with pending conversation requests and `approvalUrl: "/#approve/ID"` links.
The web UI opens its internal approval view.
The `after` cursor is an exclusive entry ID. Streams replay retained records before live records.
Commit observation starts before replay, so a concurrent commit cannot create a gap.
-81
View File
@@ -1,81 +0,0 @@
// 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(new URL(path.replace(/^\//, ""), import.meta.url), {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(new URL('cred/passkeys', import.meta.url))).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
@@ -1,11 +0,0 @@
<!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>
-8
View File
@@ -1,8 +0,0 @@
<!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>
+1 -1
View File
@@ -51,7 +51,7 @@ pub(super) async fn handle(
if let Some(requests) = value.as_array_mut() {
for request in requests {
request["approvalUrl"] =
json!(format!("/api/approve/{}", request["id"].as_str().unwrap()));
json!(format!("/#approve/{}", request["id"].as_str().unwrap()));
}
value = json!({"requests":value});
}
+2 -11
View File
@@ -1,5 +1,5 @@
//! JSON routes for host.sausage. The transport supplies the trusted caller.
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
//! JSON ceremonies use passkeys and typed proofs for every authorization.
mod auth;
mod cargo;
mod harness;
@@ -39,7 +39,6 @@ pub(crate) async fn dispatch(
.boxed_unsync()
})
}
mod pages;
mod server;
mod ssh;
mod undo;
@@ -238,15 +237,7 @@ fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes
.body(Bytes::from_static(b"{\"error\":\"origin_required\"}"))
.unwrap();
}
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 result = route(ctx, &caller, request);
let (status, body) = match result {
Ok(body) => (
if body.get("error").and_then(Value::as_str) == Some("grant_required") {
-117
View File
@@ -1,117 +0,0 @@
//! 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
@@ -1,6 +0,0 @@
<!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>
+1 -12
View File
@@ -40,18 +40,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::
})
.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)
});
.and_then(|result| result);
services.shutdown().await;
result
}
-11
View File
@@ -118,17 +118,6 @@ async fn run_with_shared_credentials(
.block_on(run_console(world, exec, cred))
})
.await?
.map_err(|error: anyhow::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)
})
}
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
+1 -27
View File
@@ -3,7 +3,7 @@
//! Layout of a world directory:
//! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it.
//! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it.
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Open, Process};
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process};
use std::{
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
@@ -51,32 +51,6 @@ impl World {
dir.display()
);
let world = Self::open_with_userland(dir, Some(bundle))?;
let path = crate::harness::config::PATH;
let config = world.root.open(path, Open::default()).await?;
let mut text = String::from_utf8(config.read(0, 16 * 1024 * 1024).await?)?;
for (field, variable) in [
("base_url", "PI_PROXY_BASE_URL"),
("api_key", "PI_PROXY_API_KEY"),
] {
if let Ok(value) = std::env::var(variable) {
text = text.replace(
&format!("{field}: \"\""),
&format!("{field}: {}", serde_json::to_string(&value)?),
);
}
}
let file = world
.root
.open(
path,
Open {
write: true,
truncate: true,
..Default::default()
},
)
.await?;
file.write(0, text.into_bytes()).await?;
let exec = crate::process::exec(Self {
root: world.root.clone(),
host_dir: world.host_dir.clone(),