Move credential ceremonies into the world web UI and ignore proxy environment
This commit is contained in:
+8
-18
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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('&', "&")
|
||||
.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"),
|
||||
))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user