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
+3 -2
View File
@@ -18,7 +18,7 @@ At world creation, the host copies its tools into `/bin` as `<command>.wasm` fil
The bundle ships plain source files. `sausage init` creates their repository in `/src/sausage` with one commit on `main`.
The world config lives at `/etc/sausage/config.yaml`.
It defines the proxy, standing agent, roster, and shared system prompt.
`init` fills proxy settings from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` when present. It asks nothing.
Run `sausage init`, then fill `proxy.base_url` and `proxy.api_key` in that config, then run `sausage run`.
The host and chat can start with an empty proxy key. Model requests then name the config file in their error.
The proxy key in that file is intentionally visible to agents. Other managed secrets stay outside the world.
The chat and console use the configured standing conversation. A rename keeps that conversation.
@@ -26,7 +26,8 @@ Model, prompt, and proxy edits apply without a host restart.
The host creates `world/` for the shared filesystem and `host/` for private runtime state under the supplied directory.
Services come from `/etc/systemd/system` in the world. `web.service` serves fresh `/srv/www` files on port 8080.
Use `http://localhost:8080` for chat and `/api/register` for passkey registration.
Use `http://localhost:8080` for chat, passkeys, approvals, and secret entry.
The first-passkey notice opens the Passkeys view. Pending requests link to `/#approve/ID`.
The `/api/` mount forwards to `host.sausage`, including NDJSON streams. The host opens no separate port.
## Repository
+23 -14
View File
@@ -1,6 +1,6 @@
# Run Sausage v3 on Citadel
Use the web chat for conversations. The same web port serves host credential pages through `/api/`.
Use the web UI for conversations, passkeys, approvals, and secret entry. The host serves only JSON through `/api/`.
**TL;DR:** Start a separate v3 world, create the SSH tunnel below, and open http://localhost:8080.
The Nix package supplies the userland bundle, source tree, and enabled chat service.
@@ -75,7 +75,7 @@ The chat selects the configured standing agent by default. Choose another conver
Root #1 remains only as history. A standing-agent rename keeps the same conversation.
The page polls for new entries and shows text, collapsed tools, and pending credential requests.
Use **Send** to submit a message and **Stop** to stop the selected conversation's active turn.
The chat never approves credential requests. Its links open the host's approval pages.
The pending-requests banner opens the approval view in the page. The host verifies the passkey assertion.
Agents edit `/srv/www/index.html`. Each request reads the file fresh without a build or restart.
The terminal console remains optional. Each nonempty console line submits a message to the standing conversation.
@@ -105,7 +105,7 @@ From the laptop, create one SSH tunnel:
ssh -N -L 8080:127.0.0.1:8080 citadel.vpn.surma.technology
```
Open **http://localhost:8080** for chat and **http://localhost:8080/api/register** for the first passkey.
Open **http://localhost:8080**. The first-passkey notice opens the Passkeys view.
The host derives the passkey RP ID and origin from the browser request Origin header.
For direct VPN access, passkeys need HTTPS because non-localhost HTTP lacks a secure context.
Passkeys registered for localhost do not transfer to a production hostname.
@@ -113,13 +113,13 @@ Do not expose the server to other people before the first registration.
### Register, create an item, enter a secret
1. Open `/api/register` and choose a label for the first passkey.
1. Select **Register your first passkey** in the chat notice and choose a label.
2. Complete the browser's passkey ceremony.
3. Ask the agent to file an item creation request through `POST /cred/requests`.
4. Review the request on `/api/approve/REQUEST_ID` and approve it with your passkey.
4. Select the request in the pending-requests banner and approve it with your passkey.
5. Read the item's public metadata to find its item ID and stable field ID.
6. Open `/api/entry/ITEM_ID/FIELD_ID` on the web origin.
7. Enter the secret in the blank host form and complete its passkey ceremony.
6. Select **Enter a secret**, then select the item's secret field.
7. Enter the secret in the blank form and complete its passkey ceremony.
Until the first passkey exists, D53 permits unlocked decisions and first registration.
After registration, decisions require a passkey. Additional registrations require an existing passkey.
@@ -139,12 +139,21 @@ They do not return stored secret values.
### Approve a request
The agent or world UI obtains pending requests from `/harness/credential-requests` or `/cred/requests?state=pending`.
Follow the `/api/approve/ID` link on the web origin through the tunnel.
Follow the `/#approve/ID` link on the web origin through the tunnel.
Review the stored summary and public details, choose limits when applicable, then approve or deny.
The world can file a request. It cannot approve its own request.
Sources: [`host API`](../host/src/api/README.md), [`pages.rs`](../host/src/api/pages.rs),
[`edits.rs`](../host/src/cred/edits.rs), and [`app.js`](../host/src/api/app.js).
For a manual Chrome check against a fresh world on port 8080, run:
```sh
node userland/web/browser-check.mjs
```
The script uses agent-browser and a CDP virtual authenticator. It registers a passkey and approves an item creation request.
Set `AGENT_BROWSER_EXECUTABLE_PATH` if Chrome needs an explicit path.
Sources: [`host API`](../host/src/api/README.md), [`credential views`](../userland/web/credentials-view.js),
[`edits.rs`](../host/src/cred/edits.rs), and [`browser cryptography`](../userland/web/credentials.js).
## Models
@@ -155,8 +164,8 @@ There are no model families or project overlays.
Every agent receives the global `prompt` before its role prompt.
The shared prompt explains how agents inspect their own model and the live harness state.
`sausage init` fills `proxy.base_url` and `proxy.api_key` from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` when present.
Otherwise, it leaves them empty. The host and chat still start.
Run `sausage init`, fill `proxy.base_url` and `proxy.api_key` in the config, then run `sausage run`.
Init leaves both settings empty. The host and chat still start.
A model request with missing settings fails with an error that names the config file.
The proxy no longer requires an item in the credentials manager.
The proxy key is intentionally visible to agents. Do not print it or commit the world config.
@@ -225,7 +234,7 @@ World programs call **`http://host.sausage`** through standard WASI HTTP.
The world web server forwards `/api/PATH` to `http://host.sausage/PATH` through WASI HTTP.
It streams response chunks instead of buffering the entire NDJSON body.
The bootstrap page polls `entries?after=` once per second rather than using a persistent stream.
Keep credential approval and secret entry on host pages.
Use the credential views in the web UI. They call only `/api/cred/...` JSON routes.
| Method and route | Result or request |
| --- | --- |
@@ -238,7 +247,7 @@ Keep credential approval and secret entry on host pages.
| `POST /harness/extensions/NAME/actions/ACTION` | `{args:{...},conversationId?:number}` invokes an exported user action. |
| `GET /harness/models` | Public model catalog without authentication data. |
| `GET /harness/extensions` | Loaded extension status. |
| `GET /harness/credential-requests` | Pending conversation requests with host approval links. |
| `GET /harness/credential-requests` | Pending conversation requests with `/#approve/ID` links. |
The `after` cursor is exclusive. Reconnect with the last record ID.
Streams close when the client disconnects or the harness stops.
+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.
-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(),
+11 -26
View File
@@ -49,14 +49,11 @@ async fn page(api: &Api, path: &str) -> Result<String> {
http::Request::builder().uri(path).body(Bytes::new())?,
)
.await;
assert_eq!(response.status(), 200);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
assert_eq!(response.status(), 404);
assert_eq!(response.headers()["content-type"], "application/json");
let text = std::str::from_utf8(response.body())?;
assert!(!text.contains(SECRET));
assert!(text.contains("app.js"));
assert!(text.contains("route_missing"));
Ok(text.into())
}
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
@@ -161,11 +158,9 @@ async fn approvals_registration_and_revocation() -> Result<()> {
let id = file(&api, &second.id, "approval").await?;
let other = file(&api, &second.id, "other").await?;
let path = format!("/cred/requests/{id}");
let review = page(&api, &format!("/approve/{id}")).await?;
assert!(review.contains("data-grant=\"true\""));
assert!(review.contains("value=\"300\""));
assert!(review.contains("&lt;script&gt;not markup&lt;/script&gt;"));
assert!(!review.contains("<script>not markup</script>"));
page(&api, &format!("/approve/{id}")).await?;
let review = ok(&api, "GET", &path, json!({})).await?;
assert_eq!(review["payload"]["duration_secs"], 300);
assert_ne!(
send(
&api,
@@ -268,9 +263,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
assert!(api.cred.grant_status(&grant.id)?.revoked);
let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":second.id,"base_version":api.cred.item_get(&second.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?;
let edit = edit["request"].as_str().unwrap();
let edit_page = page(&api, &format!("/approve/{edit}")).await?;
assert!(edit_page.contains("data-grant=\"false\""));
assert!(edit_page.contains("<fieldset hidden disabled>"));
page(&api, &format!("/approve/{edit}")).await?;
let edit_gate = assertion(
&api,
&mut auth,
@@ -314,16 +307,9 @@ async fn approvals_registration_and_revocation() -> Result<()> {
Ok(())
}
async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
async fn encrypt(_api: &Api, public_key: Value) -> Result<Envelope> {
use deno_core::{JsRuntime, RuntimeOptions};
let script = handle(
api,
caller(),
http::Request::builder().uri("/app.js").body(Bytes::new())?,
)
.await;
assert_eq!(script.status(), 200);
assert!(!std::str::from_utf8(script.body())?.contains(SECRET));
let script = include_str!("../../../userland/web/credentials.js");
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
@@ -340,8 +326,7 @@ async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
runtime.execute_script("browser.js","globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script(
"app.js",
std::str::from_utf8(script.body())?
.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
script,
)?;
runtime.execute_script("encrypt.js",format!("globalThis.done = encrypt({public_key},'{SECRET}').then(result => globalThis.envelope = JSON.stringify(result.envelope));"))?;
runtime.run_event_loop(Default::default()).await?;
@@ -372,7 +357,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
&format!("/entry/{}/{}", item.id, item.fields[0].field_id),
)
.await?;
assert!(entry_page.contains(&public["public_key"].to_string()));
assert!(!entry_page.contains(&public["public_key"].to_string()));
let envelope = encrypt(&api, public["public_key"].clone()).await?;
let path = format!("/cred/items/{}/fields/{}", item.id, item.fields[0].field_id);
for target in [
+8 -5
View File
@@ -20,10 +20,12 @@ fn listener_serves_host_pages() -> Result<()> {
api_origin: "http://localhost:8090".into(),
},
)?;
for (path, expected) in [
("/register", "Register a passkey"),
("/app.js", "navigator.credentials"),
("/whoami", "/host/browser"),
for (path, status, expected) in [
("/register", 404, "route_missing"),
("/approve/example", 404, "route_missing"),
("/entry/item/field", 404, "route_missing"),
("/app.js", 404, "route_missing"),
("/whoami", 200, "/host/browser"),
] {
let mut connection = std::net::TcpStream::connect(listener.address())?;
write!(
@@ -32,7 +34,8 @@ fn listener_serves_host_pages() -> Result<()> {
)?;
let mut response = String::new();
connection.read_to_string(&mut response)?;
assert!(response.starts_with("HTTP/1.1 200"));
assert!(response.starts_with(&format!("HTTP/1.1 {status}")));
assert!(response.to_ascii_lowercase().contains("content-type: application/json"));
assert!(response.contains(expected));
assert!(!response.contains("PRIVATE KEY"));
}
+3 -13
View File
@@ -306,14 +306,11 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
let value: serde_json::Value = serde_json::from_slice(&body)?;
assert!(
value["requests"].as_array().unwrap().iter().any(
|r| r["id"] == pending && r["approvalUrl"] == format!("/api/approve/{pending}")
|r| r["id"] == pending && r["approvalUrl"] == format!("/#approve/{pending}")
)
);
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
assert!(
String::from_utf8_lossy(&request(port, "/api/register", None).await?.1)
.contains("Register a passkey")
);
assert_eq!(request(port, "/api/register", None).await?.0, 404);
use sausage::cred::webauthn::Challenge;
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse};
@@ -346,14 +343,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
.0,
403
);
assert!(
String::from_utf8_lossy(
&request(port, &format!("/api/approve/{pending}"), None)
.await?
.1
)
.contains("Approve")
);
assert_eq!(request(port, &format!("/api/approve/{pending}"), None).await?.0, 404);
let choice = r#"{"decision":"approve","overrides":{}}"#;
assert_ne!(
request(
+5 -3
View File
@@ -77,9 +77,11 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
);
let world = World::open(&destination)?;
assert!(destination.join("world/bin/git").exists());
let config = sausage::harness::config::Config::read(&world.root).await?;
assert_eq!(config.proxy.base_url, "https://proxy.test");
assert_eq!(config.proxy.api_key, "fixture-key");
let config = world.root.open("/etc/sausage/config.yaml", Default::default()).await?;
let config = String::from_utf8(config.read(0, 65536).await?)?;
assert!(!config.contains("https://proxy.test"));
assert!(!config.contains("fixture-key"));
assert_eq!(config, include_str!("../../../config/config.yaml"));
let source = destination.join("world/src/sausage");
for (args, expected) in [
(vec!["symbolic-ref", "HEAD"], "refs/heads/main"),
+4 -26
View File
@@ -89,11 +89,7 @@ impl Browser {
Ok(json!({"challenge_id":start.id,"assertion":assertion}))
}
fn approve(&mut self, id: &str) -> Result<()> {
ensure!(
self.raw("GET", &format!("/approve/{id}"), Value::Null)?
.contains("/app.js"),
"approval page missing"
);
self.json("GET", &format!("/cred/requests/{id}"), Value::Null)?;
let path = format!("/cred/requests/{id}");
let gate = self.assertion(&format!("{path}/challenge"), json!({"decision":"approve"}))?;
self.json("POST", &format!("{path}/decide"), gate)?;
@@ -118,21 +114,8 @@ impl Browser {
item["id"].as_str().unwrap(),
item["fields"][0]["field_id"].as_str().unwrap()
);
ensure!(
self.raw(
"GET",
&format!(
"/entry/{}/{}",
item["id"].as_str().unwrap(),
item["fields"][0]["field_id"].as_str().unwrap()
),
Value::Null
)?
.contains("/app.js"),
"secret-entry page missing"
);
let public = self.json("GET", "/cred/entry/key", Value::Null)?;
let script = self.raw("GET", "/app.js", Value::Null)?;
let script = include_str!("../../../userland/web/credentials.js");
let mut runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
@@ -149,7 +132,7 @@ impl Browser {
runtime.execute_script("browser.js", "globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script(
"app.js",
script.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
script,
)?;
runtime.execute_script("entry.js", format!("encrypt({},{}).then(value => globalThis.envelope = JSON.stringify(value.envelope))", public["public_key"], serde_json::to_string(secret)?))?;
runtime.run_event_loop(Default::default()).await?;
@@ -387,12 +370,7 @@ async fn request_approve_fetch_and_ssh_push() -> Result<()> {
origin,
auth: WebauthnAuthenticator::new(SoftPasskey::new(true)),
};
ensure!(
browser
.raw("GET", "/register", Value::Null)?
.contains("src=\"app.js\""),
"registration page missing"
);
ensure!(browser.json("GET", "/cred/passkeys", Value::Null)?.as_array().unwrap().is_empty());
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(browser.json(
"POST",
"/cred/passkeys/register/start",
+4
View File
@@ -45,6 +45,10 @@ def main [destination?: path] {
mkdir $www
^cp --remove-destination ($here | path join web index.html) ($www | path join index.html)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'web page copy failed'} }
for file in [credentials.js credentials-view.js] {
^cp --remove-destination ($here | path join web $file) ($www | path join $file)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'credential UI copy failed'} }
}
^cp --remove-destination ($here | path join web web.service) ($units | path join web.service)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'web unit copy failed'} }
'' | save --force ($units | path join multi-user.target.wants web.service)
+42
View File
@@ -0,0 +1,42 @@
// Manual check: run against a fresh world on localhost:8080. Requires agent-browser and Chrome.
// Set AGENT_BROWSER_EXECUTABLE_PATH if Chrome is not available at the default path.
import {execFileSync} from 'node:child_process';
import assert from 'node:assert/strict';
const session='sausage-credential-ui-check';
const browser=(...args)=>execFileSync('agent-browser',['--session',session,...args],{encoding:'utf8'}).trim();
let socket;
try{
console.log(browser('open','http://localhost:8080/'));
socket=new WebSocket(browser('get','cdp-url'));
await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;});
let next=0;
const pending=new Map();
socket.onmessage=event=>{const message=JSON.parse(event.data);const promise=pending.get(message.id);if(promise){pending.delete(message.id);message.error?promise.reject(new Error(JSON.stringify(message.error))):promise.resolve(message.result);}};
const cdp=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params,sessionId}));});
const {targetInfos}=await cdp('Target.getTargets');
const page=targetInfos.find(target=>target.type==='page'&&target.url==='http://localhost:8080/');
assert.ok(page,'Chat tab exists');
const {sessionId}=await cdp('Target.attachToTarget',{targetId:page.targetId,flatten:true});
await cdp('WebAuthn.enable',{},sessionId);
await cdp('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true}},sessionId);
browser('wait','#credential-notice:not([hidden])');
browser('click','#register-first');
browser('fill','input[name=label]','Browser check');
browser('click','#credential-view form button');
browser('wait','--text','Complete.');
const name='Browser check '+Date.now();
const filed=await fetch('http://localhost:8080/api/cred/requests',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'edit',key:name,request:{item:null,base_version:null,change:[{op:'create_item',config:{name,rules:[],transform_js:'',fields:[]}}]}})});
assert.equal(filed.status,200);
const {request}=await filed.json();
browser('click','#credential-close');
browser('wait',`#approvals a[href="/#approve/${request}"]`);
browser('click',`#approvals a[href="/#approve/${request}"]`);
browser('click','#credential-view button[value=approve]');
browser('wait','--text','Complete.');
const record=await (await fetch('http://localhost:8080/api/cred/requests/'+request)).json();
assert.equal(record.state,'approved');
const items=await (await fetch('http://localhost:8080/api/cred/items')).json();
assert.ok(items.some(item=>item.name===name));
console.log('PASS: first registration, passkey approval, approved request, and created item.');
console.log(browser('screenshot','/tmp/sausage-credential-check.png'));
}finally{socket?.close();browser('close');}
+86
View File
@@ -0,0 +1,86 @@
// Credential views use only the host's JSON API. Secrets leave the browser encrypted.
const credentialView=document.getElementById('credential-view');
const credentialNotice=document.getElementById('credential-notice');
document.getElementById('register-first').onclick=()=>{location.hash='passkeys';};
async function credentialGet(path){
const response=await fetch('/api/cred/'+path);
const value=await response.json();
if(!response.ok)throw new Error(value.message||value.error);
return value;
}
function credentialError(error){(document.getElementById('credential-status')||document.getElementById('error')).textContent=error.message;}
function credentialForm(action){
credentialView.querySelector('form').onsubmit=async event=>{
event.preventDefault();
const buttons=[...event.target.querySelectorAll('button')];
buttons.forEach(button=>button.disabled=true);
try{await action(new FormData(event.target),event.submitter.value);document.getElementById('credential-status').textContent='Complete.';await credentialState();}
catch(error){credentialError(error);}
finally{buttons.forEach(button=>button.disabled=false);}
};
}
async function credentialState(){
const passkeys=await credentialGet('passkeys');
credentialNotice.hidden=passkeys.length>0;
return passkeys;
}
async function credentialNavigate(){
const parts=location.hash.slice(1).split('/').map(decodeURIComponent);
credentialView.hidden=!['passkeys','approve','entry'].includes(parts[0]);
if(credentialView.hidden)return;
credentialView.innerHTML='<button type="button" id="credential-close">Return to chat</button><h2></h2><div id="credential-content"></div><p id="credential-status" role="status"></p>';
document.getElementById('credential-close').onclick=()=>{location.hash='';};
const content=document.getElementById('credential-content');
try{
if(parts[0]==='passkeys'){
credentialView.querySelector('h2').textContent='Passkeys';
const passkeys=await credentialState();
const list=document.createElement('ul');
for(const key of passkeys){const row=document.createElement('li');row.textContent=key.label;list.append(row);}
content.append(list);
const form=document.createElement('form');form.innerHTML='<label>Passkey label <input name="label" required></label><button>Register passkey</button>';content.append(form);
credentialForm(async data=>{
const label=data.get('label');let gate;
if((await credentialGet('passkeys')).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)});
await credentialNavigate();
});
}else if(parts[0]==='approve'){
credentialView.querySelector('h2').textContent='Review credential request';
const id=parts[1],request=await credentialGet('requests/'+encodeURIComponent(id));
const summary=document.createElement('p'),details=document.createElement('pre');summary.textContent=request.summary;details.textContent=JSON.stringify(request,null,2);content.append(summary,details);
const grant=request.payload.grant_create||request.payload;
const limits=!!request.payload.grant_create||!!request.payload.items?.length;
const form=document.createElement('form');
form.innerHTML='<fieldset '+(limits?'':'hidden disabled')+'><legend>Limits</legend><label>Duration in seconds <input type="number" name="duration" min="1" required></label><label>Maximum uses <input type="number" name="uses" min="1" placeholder="Unlimited"></label></fieldset><button value="approve">Approve</button><button value="deny" formnovalidate>Deny</button>';
form.elements.duration.value=grant.duration_secs||(grant.exp?Math.max(1,grant.exp-Math.floor(Date.now()/1000)):3600);form.elements.uses.value=grant.max_uses||'';content.append(form);
credentialForm(async(data,decision)=>{
const path='/cred/requests/'+encodeURIComponent(id);
const overrides=decision==='deny'||!limits?{}:{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{
credentialView.querySelector('h2').textContent='Enter a credential secret';
const items=await credentialGet('items');
const form=document.createElement('form');form.innerHTML='<label>Secret field <select name="field" required></select></label><label>Secret <textarea name="secret" required></textarea></label><button>Encrypt and submit</button>';content.append(form);
for(const item of items)for(const field of item.fields.filter(field=>!field.public)){
const option=document.createElement('option');option.value=JSON.stringify([item.id,field.field_id]);option.textContent=item.name+' / '+field.name;form.elements.field.append(option);
if(item.id===parts[1]&&field.field_id===parts[2])option.selected=true;
}
credentialForm(async data=>{
const [item,field]=JSON.parse(data.get('field'));
const path='/cred/items/'+encodeURIComponent(item)+'/fields/'+encodeURIComponent(field);
const {public_key}=await credentialGet('entry/key');
const encrypted=await encrypt(public_key,data.get('secret'));form.elements.secret.value='';
const proof=await assertion(await post(path+'/challenge',{ciphertext_sha256:encrypted.ciphertext_sha256}));
await post(path,{...proof,envelope:encrypted.envelope});
});
}
}catch(error){credentialError(error);}
}
window.addEventListener('hashchange',credentialNavigate);
credentialNavigate();
credentialState().catch(credentialError);
@@ -1,8 +1,8 @@
// Host-only ceremonies. No world scripts or external dependencies run here.
// Browser WebAuthn and secret encryption helpers. The host verifies every proof.
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 response = await fetch('/api/' + path.replace(/^\//, ''), {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;
@@ -39,43 +39,3 @@ async function encrypt(publicKey, secret) {
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);
}
});
+5 -3
View File
@@ -4,9 +4,11 @@
<style>
body{font:16px system-ui;margin:0;color:#202124;background:#fafafa}header{padding:1rem;border-bottom:1px solid #ccc}main{display:flex;height:calc(100dvh - 5rem)}nav{width:16rem;overflow:auto;padding:.6rem;border-right:1px solid #ccc}button,textarea{font:inherit}button{cursor:pointer;padding:.5rem}nav button{display:block;width:100%;text-align:left;margin-bottom:.4rem}button[aria-current=true]{background:#dceaff}section{flex:1;min-width:0;display:flex;flex-direction:column;padding:1rem}#entries{flex:1;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}.entry{padding:.7rem;border-bottom:1px solid #ddd}.user{background:#eef4ff}pre{white-space:pre-wrap;overflow-wrap:anywhere}form{display:flex;gap:.5rem;padding-top:1rem}textarea{flex:1;min-width:0}#approvals{background:#fff1c1}#error{color:#a00}small{display:block}a{margin-right:1rem}@media(max-width:600px){nav{width:8rem}section{padding:.5rem}form{flex-wrap:wrap}}
.markdown{white-space:normal}.markdown pre,.markdown code,.tool pre{font-family:ui-monospace,monospace}.markdown pre,.tool pre{background:#f0f0f0;padding:.6rem}.markdown table{border-collapse:collapse}.markdown th,.markdown td{border:1px solid #ccc;padding:.4rem}.markdown a{margin-right:0}.thinking{display:block;color:#666;font-style:italic;white-space:pre-wrap}.tool{border:1px solid #ccc;border-radius:.4rem;padding:.6rem;margin:.6rem 0;white-space:normal}.tool.error{border-color:#b33}.tool.error summary{color:#a00}.tool summary{cursor:pointer}.tool pre{margin:.4rem 0}
[hidden]{display:none!important}#credential-view{position:fixed;inset:5rem 0 0;background:#fafafa;overflow:auto}#credential-view form{flex-wrap:wrap}#credential-view label{display:block}
</style>
<header><strong>Sausage chat</strong><span id="error" role="status"></span><div id="approvals" aria-live="polite"></div></header>
<header><strong>Sausage chat</strong><span id="error" role="status"></span><a href="#passkeys">Passkeys</a><a href="#entry">Enter a secret</a><div id="credential-notice" hidden>No passkey exists. Register one to protect credential decisions. <button type="button" id="register-first">Register your first passkey</button></div><div id="approvals" aria-live="polite"></div></header>
<main><nav id="conversations" aria-label="Conversations"></nav><section><h1 id="title">Conversation</h1><div id="entries" role="log" aria-live="polite"></div><form id="compose"><textarea id="input" aria-label="Message" aria-keyshortcuts="Control+Enter Meta+Enter" title="Enter inserts a newline. Ctrl+Enter or Cmd+Enter sends." rows="3" required></textarea><button>Send</button><button id="stop" type="button">Stop</button></form></section></main>
<section id="credential-view" hidden aria-label="Credentials"></section>
<script>
/* Vendored Marked 15.0.12, MIT. See marked-LICENSE.md. */
/**
@@ -100,7 +102,7 @@ async function refresh(){
}
const {requests}=await api('credential-requests');$('approvals').replaceChildren();
for(const request of requests){
const link=document.createElement('a');link.textContent='Approve: '+(request.summary||request.id);link.href=request.approvalUrl;link.target='_blank';link.rel='noopener';$('approvals').append(link);
const link=document.createElement('a');link.textContent='Approve: '+(request.summary||request.id);link.href=request.approvalUrl;$('approvals').append(link);
}
$('error').textContent='';
}catch(error){$('error').textContent=' '+error.message;}
@@ -117,4 +119,4 @@ $('compose').onsubmit=async event=>{
};
$('stop').onclick=async()=>{if(selected!==null)try{await api('conversations/'+selected+'/stop',{});}catch(error){$('error').textContent=' '+error.message;}};
refresh();
</script></html>
</script><script src="credentials.js"></script><script src="credentials-view.js"></script></html>