Move credential ceremonies into the world web UI and ignore proxy environment
This commit is contained in:
@@ -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
@@ -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
@@ -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,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(),
|
||||
|
||||
+11
-26
@@ -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("<script>not markup</script>"));
|
||||
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 [
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user