eleven / ocho Public

web: vault lock layer — passkey/biometric sealing for Vault values (#957)

* web: the vault lock layer — passkey (Touch ID / Face ID) sealing for Vault values (#946)

Values in a locked vault are ECIES-sealed to a dedicated vault public
key; the private half exists only in keyring wraps openable by a
passkey's WebAuthn PRF output (home's kekFromPRF pattern, fully
client-side — zero server change). Adds seal to the public key with no
unlock; reads cost one biometric, cached in memory for five minutes.
Access moves by request/grant events through the same pure reducer.
vaultcrypto.js reuses crypto.js's sealForPub/wrapThreadKey wire shapes,
so the Go sidecar and CLI (#947, #948) speak it natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: a CLI-kind grant finishes in the terminal, never the browser

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: surface passkey ceremony failures instead of a silent 'cancelled'

A WebAuthn error other than the user backing out (a SecurityError from an
IP-address origin like the 127.0.0.1 dev server, an unsupported
authenticator) was swallowed as 'Lock cancelled.' — reading as 'nothing
happened'. passkeyError() now names the real failure inline, points the
dev-server case at http://localhost, and console.errors the raw error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: locking a vault instantly re-masks any revealed value

A value shown at the moment you lock stayed on screen until Hide or a
reload — the row's plaintext DOM survived the lock. Reveals now register
a revoke callback in module scope; doLock slams them all shut
synchronously (before the event even echoes), and a render that sees the
unlocked→locked transition does the same (covering a lock from another
device). A revealed secret is live material — its teardown is now
explicit, not incidental to a re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: dropping the vault key ('lock now' / auto-expiry) also re-masks reveals

The screenshot bug: with the vault unlocked and secrets revealed, clicking
'lock now' (or the 5-min unlock lapsing) dropped the in-memory key but left
the already-decrypted values on screen — plaintext outliving the key. New
lockNow() helper forgets the key AND revokes every open reveal; the header
toggle and the expiry timer use it. forgetUnlock stays a pure key-forget
(cacheUnlock's internal reset must not re-mask). Completes the invariant:
plaintext never outlives the in-memory key, on any lock path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ba086159e91833f8f7948be8241e8e9cb709ad10 parent ad50674
6 files changed, +1144 −48
  • changelog/2026-07-15-locked-vaults.md +7 −0
  • package.json +1 −1
  • test/apps.test.mjs +110 −0
  • test/vaultcrypto.test.mjs +110 −0
  • web/static/apps/vault.js +751 −47
  • web/static/apps/vaultcrypto.js +165 −0
diff --git a/changelog/2026-07-15-locked-vaults.md b/changelog/2026-07-15-locked-vaults.md
new file mode 100644
index 0000000..b53890c
--- /dev/null
+++ b/changelog/2026-07-15-locked-vaults.md
@@ -0,0 +1,7 @@
+🤖 The Vault can now be **locked with a passkey**. A locked vault's values only
+open after Touch ID / Face ID — on any device, in any browser where you've
+enrolled. Anyone in the chat can still *add* a secret without unlocking, and
+names and history stay visible; only reading a value asks for the biometric.
+Teammates without access tap **Request access**, and anyone who can unlock the
+vault approves them — no secrets change hands outside the encryption. Locking
+is one-way, and as ever, the server only ever sees ciphertext.
diff --git a/package.json b/package.json
index e2f7436..4394afe 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 5e5448e..3fe1e7e 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -1984,5 +1984,115 @@ await test("/roll emits a dice.roll event and replies in place with an Open acti
assert.equal(replies[0].actions[0].label, "Roll history");
});
+// ---- 🤖 the vault lock layer (docs/locked-vaults.md) --------------------------
+
+// A well-formed keyring wrap/request for reuse below. `wrapped`/`pub`/`granted`
+// are opaque b64url blobs to the reducer — no crypto runs in a fold.
+const W = (id, extra) => ({ id, kind: "passkey", label: "Paul", credId: "cred-" + id, wrapped: "wrap-" + id, ...extra });
+const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1"), sealed: {}, ...over });
+const vreduce = vaultApp.reduce;
+
+await test("vault.lock installs the keyring, seals covered items, keeps uncovered plaintext", () => {
+ let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" });
+ s = vreduce(s, { type: "vault.add", id: "s2", aid: "a2", label: "Door", value: "0000" });
+ s = vreduce(s, LOCK({ sealed: { s1: "sealed-s1" } }));
+ assert.equal(s.keyring.pub, "vaultpub");
+ assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1"]);
+ const s1 = s.items.find((i) => i.id === "s1");
+ assert.equal(s1.sealed, "sealed-s1");
+ assert.equal("value" in s1, false, "sealed item drops its plaintext");
+ assert.equal(s.items.find((i) => i.id === "s2").value, "0000", "uncovered item keeps plaintext (render flags it)");
+ assert.equal(s.log.at(-1).action, "lock");
+});
+
+await test("vault.lock is one-way and malformed locks fold to no-ops", () => {
+ const locked = vreduce(undefined, LOCK());
+ const again = vreduce(locked, LOCK({ aid: "al2", pub: "otherpub", wrap: W("w9") }));
+ assert.equal(again, locked, "a second lock never replaces the keyring");
+ for (const bad of [LOCK({ pub: "" }), LOCK({ wrap: null }), LOCK({ wrap: { id: "x" } }), LOCK({ aid: "" })]) {
+ assert.deepEqual(vreduce(undefined, bad).keyring ?? null, null);
+ }
+});
+
+await test("vault.lock does not mutate the prior state (snapshot-safe)", () => {
+ const s0 = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" });
+ const s1 = vreduce(s0, LOCK({ sealed: { s1: "sealed-s1" } }));
+ assert.equal(s0.items[0].value, "hunter2", "prior state untouched");
+ assert.equal(s1.items[0].sealed, "sealed-s1");
+});
+
+await test("vault.add accepts exactly one of value/sealed", () => {
+ let s = vreduce(undefined, LOCK());
+ s = vreduce(s, { type: "vault.add", id: "s1", aid: "a1", label: "Token", sealed: "sealed-blob" });
+ assert.equal(s.items[0].sealed, "sealed-blob");
+ // both forms, neither form, empty sealed — all fold to no-ops
+ for (const bad of [
+ { type: "vault.add", id: "x", aid: "ax", label: "B", value: "v", sealed: "s" },
+ { type: "vault.add", id: "y", aid: "ay", label: "B" },
+ { type: "vault.add", id: "z", aid: "az", label: "B", sealed: "" },
+ ]) {
+ assert.equal(vreduce(s, bad).items.length, 1);
+ }
+});
+
+await test("vault.change carrying sealed replaces a plaintext value (the Seal path) and vice versa", () => {
+ let s = vreduce(undefined, { type: "vault.add", id: "s1", aid: "a1", label: "WiFi", value: "hunter2" });
+ s = vreduce(s, LOCK()); // lock WITHOUT covering s1 — the stale-plaintext case
+ assert.equal(s.items[0].value, "hunter2");
+ s = vreduce(s, { type: "vault.change", id: "s1", aid: "a2", sealed: "sealed-now" });
+ assert.equal(s.items[0].sealed, "sealed-now");
+ assert.equal(s.items[0].value, undefined, "sealing a change drops the plaintext");
+});
+
+await test("vault.enroll adds a wrap, dedupes by id, and rid clears the pending request", () => {
+ let s = vreduce(undefined, LOCK());
+ s = vreduce(s, { type: "vault.request", aid: "a1", rid: "r1", pub: "ephpub", ephWrapped: "ephw", kind: "passkey", credId: "c2", label: "James", uid: "u2", name: "James" });
+ assert.equal(s.keyring.requests.length, 1);
+ assert.equal(s.keyring.requests[0].by, "u2");
+ s = vreduce(s, { type: "vault.grant", aid: "a2", rid: "r1", granted: "granted-blob" });
+ assert.equal(s.keyring.requests[0].granted, "granted-blob");
+ s = vreduce(s, { type: "vault.enroll", aid: "a3", rid: "r1", wrap: W("w2", { label: "James" }) });
+ assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "w2"]);
+ assert.equal(s.keyring.requests.length, 0, "enroll with rid clears the request");
+ assert.equal(vreduce(s, { type: "vault.enroll", aid: "a4", wrap: W("w2") }).keyring.wraps.length, 2, "duplicate wrap id dedupes");
+ // no keyring → enroll no-ops
+ assert.equal(vreduce(undefined, { type: "vault.enroll", aid: "a5", wrap: W("w9") }).keyring ?? null, null);
+});
+
+await test("vault.grant needs a matching request; a stranger's junk folds to no-ops", () => {
+ const s = vreduce(undefined, LOCK());
+ assert.equal(vreduce(s, { type: "vault.grant", aid: "a1", rid: "ghost", granted: "blob" }), s);
+ assert.equal(vreduce(s, { type: "vault.request", aid: "a2", rid: "", pub: "p", ephWrapped: "e", kind: "passkey" }), s);
+ assert.equal(vreduce(s, { type: "vault.request", aid: "a3", rid: "r", pub: "p", ephWrapped: "e", kind: "usb-fob" }), s, "unknown wrap kind rejected");
+});
+
+await test("vault.revoke drops a wrap but never the last one", () => {
+ let s = vreduce(undefined, LOCK());
+ assert.equal(vreduce(s, { type: "vault.revoke", aid: "a1", wrapId: "w1" }), s, "the last wrap is unrevokable");
+ s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2") });
+ s = vreduce(s, { type: "vault.revoke", aid: "a3", wrapId: "w1" });
+ assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w2"]);
+ assert.equal(s.log.at(-1).action, "revoke");
+});
+
+await test("vault: keyring wraps are sanitised — a stranger's spare fields never fold in", () => {
+ const s = vreduce(undefined, LOCK({ wrap: { ...W("w1"), evil: "payload", credId: 42 } }));
+ const w = s.keyring.wraps[0];
+ assert.equal("evil" in w, false);
+ assert.equal("credId" in w, false, "non-string credId dropped");
+ assert.deepEqual(Object.keys(w).sort(), ["id", "kind", "label", "wrapped"]);
+});
+
+await test("vault: lock/request/grant surface in previews and the group chat projection", () => {
+ assert.equal(vaultApp.preview({ type: "vault.lock", uid: "u2", name: "Ciara" }), "🔒 Ciara locked the Vault");
+ assert.equal(vaultApp.preview({ type: "vault.request", uid: "u1", name: "me" }), "🔒 You requested access to the Vault");
+ assert.equal(vaultApp.preview({ type: "vault.grant", uid: "u2", name: "Ciara" }), "🔒 Ciara granted access to the Vault");
+ const line = vaultApp.chatMessage({ type: "vault.lock" }, { group: true });
+ assert.equal(line.action, true);
+ assert.match(line.text, /locked the Vault/);
+ // enroll/revoke stay out of the group feed — routine key housekeeping.
+ assert.equal(vaultApp.chatMessage({ type: "vault.enroll" }, { group: true }), null);
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/test/vaultcrypto.test.mjs b/test/vaultcrypto.test.mjs
new file mode 100644
index 0000000..3f19e47
--- /dev/null
+++ b/test/vaultcrypto.test.mjs
@@ -0,0 +1,110 @@
+// Tests for the vault lock layer's crypto (web/static/apps/vaultcrypto.js), run
+// in Node (Node 22 has Web Crypto). node test/vaultcrypto.test.mjs
+//
+// The WebAuthn ceremonies need an authenticator, so — like home's crypto tests —
+// PRF outputs here are synthetic 32-byte secrets: kekFromPRF only ever sees the
+// bytes, so the pipeline from PRF output to unsealed value is exercised end to
+// end, with only the authenticator itself stubbed out.
+import assert from "node:assert/strict";
+import {
+ prfEvalInput,
+ kekFromPRF,
+ importKEK,
+ generateVaultKeypair,
+ wrapVaultPriv,
+ unwrapVaultPriv,
+ sealValue,
+ unsealValue,
+ grantVaultKey,
+ acceptGrant,
+} from "../web/static/apps/vaultcrypto.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const prf = () => crypto.getRandomValues(new Uint8Array(32));
+
+await test("prfEvalInput is the fixed versioned constant", () => {
+ assert.equal(new TextDecoder().decode(prfEvalInput()), "eleven-vault-kek-v1");
+});
+
+await test("kekFromPRF is deterministic per PRF output", async () => {
+ const out = prf();
+ const kek1 = await kekFromPRF(out);
+ const kek2 = await kekFromPRF(out);
+ const kp = await generateVaultKeypair();
+ // Same PRF bytes → interchangeable KEKs (wrap with one, unwrap with the other).
+ const wrapped = await wrapVaultPriv(kp.privateKey, kek1);
+ assert.equal(await unwrapVaultPriv(wrapped, kek2), kp.privateKey);
+});
+
+await test("seal to the vault public key → unseal with the private key (incl. unicode + '=' values)", async () => {
+ const kp = await generateVaultKeypair();
+ for (const v of ["hunter2", "pässwörd 🌍", "BASE64==", "KEY=VALUE\nOTHER=x", " leading and trailing "]) {
+ const sealed = await sealValue(kp.publicKey, v);
+ assert.equal(await unsealValue(sealed, kp.privateKey), v);
+ }
+});
+
+await test("unseal with the wrong private key throws (never a wrong value)", async () => {
+ const kp = await generateVaultKeypair();
+ const other = await generateVaultKeypair();
+ const sealed = await sealValue(kp.publicKey, "secret");
+ await assert.rejects(() => unsealValue(sealed, other.privateKey));
+});
+
+await test("wrapVaultPriv under one KEK never opens under another", async () => {
+ const kp = await generateVaultKeypair();
+ const wrapped = await wrapVaultPriv(kp.privateKey, await kekFromPRF(prf()));
+ await assert.rejects(async () => unwrapVaultPriv(wrapped, await kekFromPRF(prf())));
+});
+
+await test("importKEK: raw 32 bytes (the CLI keychain shape) drive the same wrap format", async () => {
+ const kp = await generateVaultKeypair();
+ const raw = crypto.getRandomValues(new Uint8Array(32));
+ const wrapped = await wrapVaultPriv(kp.privateKey, await importKEK(raw));
+ assert.equal(await unwrapVaultPriv(wrapped, await importKEK(raw)), kp.privateKey);
+});
+
+await test("the full grant ceremony: lock → request → grant → finish → unseal", async () => {
+ // Paul locks: vault keypair, his wrap under his (synthetic) PRF KEK.
+ const paulKek = await kekFromPRF(prf());
+ const vault = await generateVaultKeypair();
+ const paulWrap = await wrapVaultPriv(vault.privateKey, paulKek);
+ const sealed = await sealValue(vault.publicKey, "LCHAT_ADMIN_TOKEN=abc123");
+
+ // James requests: his own KEK, an ephemeral keypair, its private half sealed
+ // under HIS kek (this is `ephWrapped` riding inside the request event).
+ const jamesKek = await kekFromPRF(prf());
+ const eph = await generateVaultKeypair();
+ const ephWrapped = await wrapVaultPriv(eph.privateKey, jamesKek);
+
+ // Paul approves: unlock (his kek → vault priv), wrap the vault key to the
+ // request's ephemeral public key (this is `granted` in vault.grant).
+ const paulPriv = await unwrapVaultPriv(paulWrap, paulKek);
+ const granted = await grantVaultKey(paulPriv, eph.publicKey);
+
+ // James finishes: his kek opens ephWrapped, the ephemeral key opens the
+ // grant, and his own durable wrap goes into the keyring.
+ const ephPriv = await unwrapVaultPriv(ephWrapped, jamesKek);
+ const jamesPriv = await acceptGrant(granted, ephPriv);
+ const jamesWrap = await wrapVaultPriv(jamesPriv, jamesKek);
+
+ // Both members now unseal the same value through their own biometric-gated wrap.
+ assert.equal(await unsealValue(sealed, await unwrapVaultPriv(paulWrap, paulKek)), "LCHAT_ADMIN_TOKEN=abc123");
+ assert.equal(await unsealValue(sealed, await unwrapVaultPriv(jamesWrap, jamesKek)), "LCHAT_ADMIN_TOKEN=abc123");
+ // …and a third party holding only the grant (no ephemeral key) gets nothing.
+ const stranger = await generateVaultKeypair();
+ await assert.rejects(() => acceptGrant(granted, stranger.privateKey));
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
index 1d33e15..c2e402d 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -34,6 +34,22 @@
// - An audit trail. Every add / change / remove is recorded in an append-only
// `log` with who did it and when, so you can see how a secret got there.
//
+// 🤖 THE LOCK LAYER (docs/locked-vaults.md). A vault can be LOCKED: it gains a
+// dedicated ECDH keypair whose public half sits in this state and whose private
+// half exists only inside keyring `wraps`, each openable solely by a
+// biometric-gated key — a passkey's WebAuthn PRF output here, or a Touch
+// ID-guarded keychain key in the eleven CLI. Once locked, values ride and rest
+// ECIES-SEALED to the vault public key (`sealed`, never `value`): anyone in the
+// thread can still ADD a secret without an unlock (encrypting to a public key
+// is free), but READING a value takes a fresh biometric. A signed-in session
+// alone — thread key in hand — sees names and the audit trail, never values.
+// Access moves by request/grant events: a member without a wrap publishes an
+// ephemeral public key; any unlocked member wraps the vault key to it; the
+// requester finishes by enrolling a durable wrap of their own. All of it is
+// ordinary events through the same reducer — malformed folds to a no-op, so
+// hostile inputs stay safe, which `groupCapable` requires. The crypto lives in
+// vaultcrypto.js; reducers here never touch it (folds stay pure and sync).
+//
// Files are a deliberate TODO: a value can only be text today because the mini-app
// ctx has no file upload/download surface yet (see the "apps"-tagged issue #408).
// `kind` is stored on every item so file items fold cleanly once it lands.
@@ -42,10 +58,25 @@
// tests while still resolving to /static/apps.js in the browser.
import { defineApp, addItem, updateItem, removeItem, currentUserId } from "../apps.js";
import { ICON_LOCK } from "../icons.js";
+import {
+ registerVaultPasskey,
+ assertVaultPasskeyFull,
+ kekFromPRF,
+ generateVaultKeypair,
+ wrapVaultPriv,
+ unwrapVaultPriv,
+ sealValue,
+ unsealValue,
+ grantVaultKey,
+ acceptGrant,
+} from "./vaultcrypto.js";
// How long a revealed value stays visible before it re-locks itself — short, so a
// vault left open on screen doesn't sit unmasked. Clicking it again re-arms it.
const RELOCK_MS = 30000;
+// How long an UNLOCK (the vault private key, in memory only) survives before the
+// vault forgets it and the next read costs a fresh biometric. Never persisted.
+const UNLOCK_TTL_MS = 5 * 60000;
// A fixed-width mask that never leaks the value's length (always the same width,
// whether the secret is 4 chars or 400).
const MASK = "•".repeat(10);
@@ -71,9 +102,45 @@ function findByName(items, name) {
return (items || []).find((it) => (it.name || "").toLowerCase() === n) || null;
}
-// State is { items, log }.
-// item: { id, name, value, kind, by, byName, at, updatedBy, updatedByName, updatedAt }
-// log: { id (audit id), ref (item id), action: "add"|"change"|"remove", name, by, byName, at }
+// 🤖 wrapShape / requestShape vet keyring objects arriving in events before the
+// reducer stores them — every field a later unlock will read must be a
+// non-empty string, and anything extra is dropped (never store a stranger's
+// spare fields). Return the cleaned object, or null. Pure; exported for tests.
+export function wrapShape(w) {
+ if (!w || typeof w.id !== "string" || !w.id) return null;
+ if (w.kind !== "passkey" && w.kind !== "keychain") return null;
+ if (typeof w.wrapped !== "string" || !w.wrapped) return null;
+ const out = { id: w.id, kind: w.kind, wrapped: w.wrapped, label: typeof w.label === "string" ? w.label : "" };
+ if (typeof w.credId === "string" && w.credId) out.credId = w.credId;
+ return out;
+}
+
+export function requestShape(e) {
+ if (typeof e.rid !== "string" || !e.rid) return null;
+ if (typeof e.pub !== "string" || !e.pub) return null;
+ if (typeof e.ephWrapped !== "string" || !e.ephWrapped) return null;
+ if (e.kind !== "passkey" && e.kind !== "keychain") return null;
+ const out = {
+ id: e.rid, // addItem/updateItem/removeItem key on `id`, so the request id IS its id
+ pub: e.pub,
+ ephWrapped: e.ephWrapped,
+ kind: e.kind,
+ label: typeof e.label === "string" ? e.label : "",
+ granted: "",
+ };
+ if (typeof e.credId === "string" && e.credId) out.credId = e.credId;
+ return out;
+}
+
+// State is { items, log, keyring }.
+// item: { id, name, value?, sealed?, kind, by, byName, at, updatedBy, updatedByName, updatedAt }
+// — exactly one of value (plain vault) / sealed (locked vault: ECIES to the
+// vault public key, b64url ephPub||iv||ct; see vaultcrypto.js).
+// log: { id (audit id), ref (item id), action, name, by, byName, at }
+// — action: add | change | remove | lock | enroll | revoke | request | grant
+// keyring (locked vaults only): { v: 1, pub, wraps: [wrapShape],
+// requests: [requestShape + { by, byName, at }] } — see the lock-layer note
+// at the top of this file and docs/locked-vaults.md.
//
// The secret's name rides in the event as `label`, NOT `name` — the emit
// transport OVERWRITES `event.name` with the sender's screen name (that's how the
@@ -94,15 +161,20 @@ export const app = defineApp({
scope: "personal",
groupCapable: true, // can also be added to a group chat, one shared instance for all
groupChatOnly: true, // chatMessage below projects into a GROUP's chat feed only — never a Chat view of the personal thread (see apps/README.md "Companion projection")
- initialState: () => ({ items: [], log: [] }),
+ initialState: () => ({ items: [], log: [], keyring: null }),
events: {
"vault.add": (s, e) => {
const label = typeof e.label === "string" ? e.label.trim() : "";
- if (!e.id || !e.aid || !label || typeof e.value !== "string") return s;
+ // Exactly one of value/sealed (sealed must be non-empty; an empty VALUE is
+ // rejected by the emitters, but an empty string here would still fold —
+ // matching the old validation, which only type-checked).
+ const hasValue = typeof e.value === "string";
+ const hasSealed = typeof e.sealed === "string" && e.sealed !== "";
+ if (!e.id || !e.aid || !label || hasValue === hasSealed) return s;
const items = addItem(s.items, {
id: e.id,
name: label,
- value: e.value,
+ ...(hasSealed ? { sealed: e.sealed } : { value: e.value }),
kind: e.kind === "file" ? "file" : "text",
by: e.uid || "",
byName: e.name || "",
@@ -111,22 +183,114 @@ export const app = defineApp({
updatedByName: "",
updatedAt: 0,
});
- return { items, log: logEntry(s.log, e, "add", label) };
+ return { ...s, items, log: logEntry(s.log, e, "add", label) };
},
"vault.change": (s, e) => {
const item = (s.items || []).find((it) => it.id === e.id);
if (!e.id || !e.aid || !item) return s;
const patch = { updatedBy: e.uid || "", updatedByName: e.name || "", updatedAt: e.ts || 0 };
if (typeof e.label === "string" && e.label.trim()) patch.name = e.label.trim();
- if (typeof e.value === "string") patch.value = e.value;
- return { items: updateItem(s.items, e.id, patch), log: logEntry(s.log, e, "change", patch.name || item.name) };
+ // A change may carry a new value (plain vault) or a new sealed blob
+ // (locked) — whichever arrives replaces the other form, so re-sealing a
+ // stale plaintext item is just a change carrying `sealed`.
+ if (typeof e.sealed === "string" && e.sealed !== "") {
+ patch.sealed = e.sealed;
+ patch.value = undefined;
+ } else if (typeof e.value === "string") {
+ patch.value = e.value;
+ patch.sealed = undefined;
+ }
+ return { ...s, items: updateItem(s.items, e.id, patch), log: logEntry(s.log, e, "change", patch.name || item.name) };
},
"vault.remove": (s, e) => {
const item = (s.items || []).find((it) => it.id === e.id);
if (!e.id || !e.aid) return s;
// Carry the name into the log entry (the item is about to be gone) so the
// audit trail still reads "… removed GITHUB_TOKEN".
- return { items: removeItem(s.items, e.id), log: logEntry(s.log, e, "remove", (item && item.name) || "") };
+ return { ...s, items: removeItem(s.items, e.id), log: logEntry(s.log, e, "remove", (item && item.name) || "") };
+ },
+
+ // ---- 🤖 the lock layer (docs/locked-vaults.md) --------------------------
+
+ // vault.lock installs the keyring: the vault public key, the locker's first
+ // wrap, and a re-seal of every existing value (item id → sealed blob,
+ // produced client-side by the locker, who could read them). One-way: a
+ // vault with a keyring never accepts another lock.
+ "vault.lock": (s, e) => {
+ if (s.keyring) return s;
+ const wrap = wrapShape(e.wrap);
+ if (!e.aid || typeof e.pub !== "string" || !e.pub || !wrap) return s;
+ const sealed = e.sealed && typeof e.sealed === "object" ? e.sealed : {};
+ const items = (s.items || []).map((it) => {
+ const blob = sealed[it.id];
+ if (typeof blob !== "string" || !blob) return it; // uncovered items keep value; render flags them
+ const { value, ...rest } = it;
+ return { ...rest, sealed: blob };
+ });
+ return {
+ items,
+ log: logEntry(s.log, e, "lock", ""),
+ keyring: { v: 1, pub: e.pub, wraps: [wrap], requests: [] },
+ };
+ },
+
+ // vault.enroll adds a wrap — a new device/passkey of an unlocked member, or
+ // a requester completing a grant (rid clears their pending request).
+ "vault.enroll": (s, e) => {
+ const wrap = wrapShape(e.wrap);
+ if (!s.keyring || !e.aid || !wrap) return s;
+ const requests = typeof e.rid === "string" && e.rid ? removeItem(s.keyring.requests, e.rid) : s.keyring.requests;
+ return {
+ ...s,
+ log: logEntry(s.log, e, "enroll", wrap.label),
+ keyring: { ...s.keyring, wraps: addItem(s.keyring.wraps, wrap), requests },
+ };
+ },
+
+ // vault.revoke drops a wrap — housekeeping, NOT crypto revocation (an
+ // ex-holder saw the values; rotating the real secrets is the honest move,
+ // docs/locked-vaults.md "What this does not do"). The LAST wrap can never
+ // be dropped: that would strand the vault with no openable key, forever.
+ "vault.revoke": (s, e) => {
+ if (!s.keyring || !e.aid || typeof e.wrapId !== "string" || !e.wrapId) return s;
+ const gone = (s.keyring.wraps || []).find((w) => w.id === e.wrapId);
+ if (!gone || s.keyring.wraps.length <= 1) return s;
+ return {
+ ...s,
+ log: logEntry(s.log, e, "revoke", gone.label),
+ keyring: { ...s.keyring, wraps: removeItem(s.keyring.wraps, e.wrapId) },
+ };
+ },
+
+ // vault.request: a member with no wrap asks in — an ephemeral public key to
+ // grant to, plus their own ephemeral private half sealed under their own
+ // biometric KEK (ephWrapped), so the pending request survives reloads yet
+ // only they can complete it.
+ "vault.request": (s, e) => {
+ const req = requestShape(e);
+ if (!s.keyring || !e.aid || !req) return s;
+ return {
+ ...s,
+ log: logEntry(s.log, e, "request", req.label),
+ keyring: {
+ ...s.keyring,
+ requests: addItem(s.keyring.requests, { ...req, by: e.uid || "", byName: e.name || "", at: e.ts || 0 }),
+ },
+ };
+ },
+
+ // vault.grant: an unlocked member wrapped the vault key to a request's
+ // ephemeral public key. A bogus blob just fails to open at finish time —
+ // folding it is safe, and the audit trail says who posted it.
+ "vault.grant": (s, e) => {
+ if (!s.keyring || !e.aid || typeof e.rid !== "string" || typeof e.granted !== "string" || !e.granted) return s;
+ const req = (s.keyring.requests || []).find((r) => r.id === e.rid);
+ if (!req) return s;
+ return {
+ ...s,
+ log: logEntry(s.log, e, "grant", req.label),
+ keyring: { ...s.keyring, requests: updateItem(s.keyring.requests, e.rid, { granted: e.granted }) },
+ };
},
},
commands: {
@@ -138,14 +302,14 @@ export const app = defineApp({
"/vault": {
desc: "Store a secret",
args: "<name> = <value>",
- run: (args, ctx) => {
+ run: async (args, ctx) => {
const parsed = parseVaultCommand(args);
if (!parsed) {
// Bad/blank input still gets a hint (private, where you typed) — but not
// the secret typed back at them.
return ctx.reply && ctx.reply({ text: "Usage: /vault <name> = <value> — e.g. /vault WiFi = hunter2", short: "vault usage" });
}
- addOrUpdate(ctx, parsed.name, parsed.value);
+ await addOrUpdate(ctx, parsed.name, parsed.value);
ctx.reply && ctx.reply({ text: `Stored “${parsed.name}” in the vault.`, short: `stored “${parsed.name}”`, actions: [{ label: "Open Vault", run: ctx.open }] });
},
},
@@ -160,10 +324,12 @@ export const app = defineApp({
// touched the vault"), but the secret's name stays out of the conversation,
// its previews, and push — and a value CHANGE (no new/gone item, same name)
// stays silent, since there's nothing new for a passerby to learn from it.
+ // Locking is group-visible too: reads now cost a biometric, worth noticing.
chatMessage(e, opts) {
if (!opts || !opts.group) return null;
if (e.type === "vault.add") return { text: "added a secret to the Vault", action: true };
if (e.type === "vault.remove") return { text: "removed a secret from the Vault", action: true };
+ if (e.type === "vault.lock") return { text: "locked the Vault with a passkey", action: true };
return null;
},
@@ -177,6 +343,9 @@ export const app = defineApp({
if (e.type === "vault.add") return `🔒 ${who} added a secret to the Vault`;
if (e.type === "vault.remove") return `🔒 ${who} removed a secret from the Vault`;
if (e.type === "vault.change") return `🔒 ${who} updated a secret in the Vault`;
+ if (e.type === "vault.lock") return `🔒 ${who} locked the Vault`;
+ if (e.type === "vault.request") return `🔒 ${who} requested access to the Vault`;
+ if (e.type === "vault.grant") return `🔒 ${who} granted access to the Vault`;
return null;
},
@@ -185,8 +354,18 @@ export const app = defineApp({
styles: `
.vault-wrap { display: flex; flex-direction: column; gap: 12px; max-width: 560px; width: 100%; margin: 0 auto; }
.vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
+.vault-head-left { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.vault-title { font-size: 15px; font-weight: 700; color: var(--ink); }
.vault-count { font-size: 12px; color: var(--muted); }
+.vault-lockchip {
+ border: 1px solid var(--border); background: var(--panel); color: var(--ink);
+ font: inherit; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px;
+ cursor: var(--ui-cursor, pointer); white-space: nowrap;
+}
+.vault-lockchip:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
+.vault-lockchip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.vault-note { font-size: 12px; color: var(--muted); padding: 2px 0; }
+.vault-note:empty { display: none; }
.vault-add { display: flex; flex-wrap: wrap; gap: 8px; }
.vault-add input {
flex: 1 1 140px; min-width: 0; padding: 9px 11px; border-radius: 10px; font: inherit; font-size: 14px;
@@ -226,21 +405,27 @@ export const app = defineApp({
.vault-value.revealed .vault-value-text {
letter-spacing: normal; white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible; user-select: all;
}
+.vault-unsealed { color: var(--muted); font-style: italic; }
.vault-actions { flex: 0 0 auto; display: flex; gap: 1px; }
.vault-btn {
border: none; background: transparent; color: var(--link); font: inherit; font-size: 12px; font-weight: 600;
cursor: var(--ui-cursor, pointer); padding: 4px 7px; border-radius: 7px; white-space: nowrap;
}
.vault-btn:hover { background: var(--bubble-them); }
+.vault-btn:disabled { color: var(--muted); cursor: default; background: transparent; }
.vault-btn--danger { color: var(--muted); }
.vault-btn--danger:hover { color: #e5484d; background: color-mix(in srgb, #e5484d 12%, transparent); }
.vault-meta { font-size: 11px; color: var(--muted); }
+.vault-access { border-top: 1px solid var(--border); padding-top: 8px; }
+.vault-access-list { list-style: none; margin: 6px 0 4px; padding: 0; display: flex; flex-direction: column; gap: 5px; }
+.vault-access-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
+.vault-access-who { color: var(--ink); font-weight: 600; }
.vault-history { border-top: 1px solid var(--border); padding-top: 8px; }
-.vault-history-toggle {
+.vault-history-toggle, .vault-access-toggle {
background: none; border: none; padding: 4px 2px; font: inherit; font-size: 13px; font-weight: 600;
color: var(--muted); cursor: var(--ui-cursor, pointer);
}
-.vault-history-toggle:hover { color: var(--ink); }
+.vault-history-toggle:hover, .vault-access-toggle:hover { color: var(--ink); }
.vault-log { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.vault-log li { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
.vault-log .vault-log-who { color: var(--ink); font-weight: 600; }
@@ -256,22 +441,45 @@ export const app = defineApp({
render(state, el, ctx) {
const items = (state && state.items) || [];
const log = (state && state.log) || [];
+ const keyring = (state && state.keyring) || null;
+
+ // A render that observes the vault becoming locked (this device locked it,
+ // or a remote lock just folded) revokes any reveal still open from before —
+ // the belt to doLock's braces, and the whole story for a remote lock.
+ if (keyring && !lockedSeen[ctx.threadId]) relockOpenReveals();
+ lockedSeen[ctx.threadId] = !!keyring;
const wrap = document.createElement("div");
wrap.className = "vault-wrap";
+ // One inline status line for the whole surface — the lock/unlock/grant
+ // ceremonies report here. In-flow (occupies layout space), never a
+ // floating toast; role=status so screen readers announce it.
+ const note = document.createElement("div");
+ note.className = "vault-note";
+ note.setAttribute("role", "status");
+ const say = (msg) => {
+ note.textContent = msg || "";
+ };
+
const head = document.createElement("div");
head.className = "vault-head";
+ const headLeft = document.createElement("div");
+ headLeft.className = "vault-head-left";
const title = document.createElement("span");
title.className = "vault-title";
title.textContent = "🔒 Vault";
const count = document.createElement("span");
count.className = "vault-count";
- count.textContent = items.length ? (items.length === 1 ? "1 secret" : `${items.length} secrets`) : "";
- head.append(title, count);
+ count.textContent = items.length ? (items.length === 1 ? "1 secret" : `${items.length.toLocaleString()} secrets`) : "";
+ headLeft.append(title, count);
+ head.append(headLeft, buildLockChip(keyring, ctx, say));
wrap.appendChild(head);
+ wrap.appendChild(note);
// Add form — emits directly, so the secret never rides a chat composer.
+ // On a locked vault the value seals to the vault public key before it
+ // leaves the handler — adding never costs an unlock.
const form = document.createElement("form");
form.className = "vault-add";
const nameIn = document.createElement("input");
@@ -288,12 +496,12 @@ export const app = defineApp({
add.className = "vault-add-btn";
add.textContent = "Add";
form.append(nameIn, valIn, add);
- form.onsubmit = (ev) => {
+ form.onsubmit = async (ev) => {
ev.preventDefault();
const name = nameIn.value.trim();
const value = valIn.value; // don't trim a value — leading/trailing chars can matter
if (!name || !value) return;
- addOrUpdate(ctx, name, value);
+ await addOrUpdate(ctx, name, value);
nameIn.value = "";
valIn.value = "";
nameIn.focus();
@@ -308,37 +516,323 @@ export const app = defineApp({
} else {
const list = document.createElement("ul");
list.className = "vault-list";
- for (const item of items) list.appendChild(buildRow(item, ctx));
+ for (const item of items) list.appendChild(buildRow(item, ctx, keyring, say));
wrap.appendChild(list);
}
+ if (keyring) wrap.appendChild(buildAccess(keyring, ctx, say));
if (log.length) wrap.appendChild(buildHistory(log, ctx));
el.appendChild(wrap);
},
});
+// ---- 🤖 unlock state (memory only, never persisted) ---------------------------
+
+// threadId -> { priv (pkcs8 b64url), timer }. Cleared by the timer, by "lock
+// now", and never written anywhere — a reload always starts locked.
+const unlockedVaults = {};
+
+function cacheUnlock(threadId, priv) {
+ forgetUnlock(threadId);
+ // When the unlock window lapses, the key leaves memory AND any value still
+ // on screen must re-mask — same invariant as an explicit lock.
+ unlockedVaults[threadId] = {
+ priv,
+ timer: setTimeout(() => lockNow(threadId), UNLOCK_TTL_MS),
+ };
+}
+
+// forgetUnlock drops the in-memory key only — the pure reset cacheUnlock reuses
+// before caching a new one (where re-masking reveals would be wrong).
+export function forgetUnlock(threadId) {
+ const u = unlockedVaults[threadId];
+ if (u) {
+ clearTimeout(u.timer);
+ delete unlockedVaults[threadId];
+ }
+}
+
+// lockNow is the user-facing "the vault is locking again" action: forget the
+// key AND revoke every open reveal, so plaintext can never outlive the unlock.
+// Used by the header "lock now" toggle and the auto-expiry timer; the initial
+// lock ceremony (doLock) and a remote lock (render) revoke reveals directly.
+function lockNow(threadId) {
+ forgetUnlock(threadId);
+ relockOpenReveals();
+}
+
+function unlockedPriv(threadId) {
+ return unlockedVaults[threadId] ? unlockedVaults[threadId].priv : null;
+}
+
+// 🤖 A row showing plaintext registers a "slam shut" callback here while it's
+// revealed. Locking a vault — from this device, another device, or another
+// member — must revoke every open reveal IMMEDIATELY and deterministically: a
+// value that was on screen the instant you locked is live secret material, and
+// it can't be left to linger until an incidental re-render or a manual Hide.
+// Module scope so it survives the per-render rebuild.
+const openReveals = new Set();
+
+function relockOpenReveals() {
+ for (const close of [...openReveals]) close(); // each close() removes itself
+ openReveals.clear();
+}
+
+// lockedSeen tracks, per thread, whether the last render saw a keyring — so a
+// render that observes the unlocked→locked transition (a remote lock) can slam
+// open reveals shut, the same as the local doLock path does instantly.
+const lockedSeen = {};
+
+// ensureUnlocked returns the vault private key, running one passkey assertion
+// (one biometric) when it isn't already in memory. Returns null after
+// reporting the reason via say().
+// passkeyError turns a WebAuthn/ceremony failure into honest inline copy — and
+// console.errors anything that isn't the user simply backing out, so a real
+// misconfiguration never reads as "nothing happened". The commonest dev
+// gotcha: WebAuthn refuses an IP-address RP id, so the 127.0.0.1 dev server
+// throws SecurityError before any OS sheet — open it over http://localhost
+// instead. `action` is the verb for the plain-cancel case ("Lock", "Unlock").
+function passkeyError(e, action) {
+ const name = (e && e.name) || "";
+ const msg = (e && e.message) || "";
+ if (msg === "no-prf") return "This passkey manager can't protect a vault (no PRF support) — try the browser's own passkeys.";
+ if (name === "NotAllowedError") return `${action} cancelled.`; // dismissed or timed out
+ console.error("vault: passkey ceremony failed", e);
+ if (name === "SecurityError") return `Passkeys can't run on this address (${location.origin}). Open the app over https or http://localhost — not an IP like 127.0.0.1.`;
+ return `Couldn't reach a passkey here: ${name || msg || "unknown error"} (details in the console).`;
+}
+
+async function ensureUnlocked(ctx, keyring, say) {
+ const cached = unlockedPriv(ctx.threadId);
+ if (cached) return cached;
+ const passkeys = (keyring.wraps || []).filter((w) => w.kind === "passkey" && w.credId);
+ if (!passkeys.length) {
+ say("Only CLI keys are enrolled in this vault — unlock isn't possible in the browser yet. Request access below.");
+ return null;
+ }
+ let res;
+ try {
+ // All enrolled credIds go in the allowlist — the authenticator answers with
+ // whichever it holds, so "which wrap is mine" never needs guessing.
+ res = await assertVaultPasskeyFull(passkeys.map((w) => w.credId));
+ } catch (e) {
+ say(passkeyError(e, "Unlock"));
+ return null;
+ }
+ const wrap = passkeys.find((w) => w.credId === res.credId);
+ if (!wrap) {
+ say("That passkey isn't enrolled in this vault — request access below.");
+ return null;
+ }
+ try {
+ const kek = await kekFromPRF(res.prf);
+ const priv = await unwrapVaultPriv(wrap.wrapped, kek);
+ cacheUnlock(ctx.threadId, priv);
+ return priv;
+ } catch {
+ say("Couldn't open the vault with that passkey — its enrolment may be stale. Request access below.");
+ return null;
+ }
+}
+
+// ---- write paths ----------------------------------------------------------------
+
// addOrUpdate is the one write path the command and the in-form Add share:
// overwrite the secret if its name already exists (case-insensitive), else add a
-// new one. Each event carries a fresh audit id (aid) so the log records it.
-function addOrUpdate(ctx, name, value) {
- const existing = findByName((ctx.getState() || {}).items, name);
+// new one. Each event carries a fresh audit id (aid) so the log records it. On a
+// locked vault the value seals to the vault public key first — no unlock needed.
+async function addOrUpdate(ctx, name, value) {
+ const state = ctx.getState() || {};
+ const existing = findByName(state.items, name);
+ const body = state.keyring ? { sealed: await sealValue(state.keyring.pub, value) } : { value };
// The secret's name travels as `label` (see the reducer note on why not `name`).
- if (existing) ctx.emit({ type: "vault.change", id: existing.id, aid: ctx.id(), label: name, value });
- else ctx.emit({ type: "vault.add", id: ctx.id(), aid: ctx.id(), label: name, value });
+ if (existing) ctx.emit({ type: "vault.change", id: existing.id, aid: ctx.id(), label: name, ...body });
+ else ctx.emit({ type: "vault.add", id: ctx.id(), aid: ctx.id(), label: name, ...body });
+}
+
+// doLock runs the whole lock ceremony: mint a passkey (verifying PRF end-to-end
+// BEFORE anything posts — the no-prf lesson from home), mint the vault keypair,
+// re-seal every existing value, emit one vault.lock. The locker ends up
+// unlocked, so their next reveal is free.
+async function doLock(ctx, say) {
+ let pk;
+ try {
+ pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`);
+ } catch (e) {
+ say(passkeyError(e, "Lock"));
+ return;
+ }
+ const kek = await kekFromPRF(pk.prf);
+ const kp = await generateVaultKeypair();
+ const state = ctx.getState() || {};
+ const sealed = {};
+ for (const it of state.items || []) {
+ if (typeof it.value === "string") sealed[it.id] = await sealValue(kp.publicKey, it.value);
+ }
+ ctx.emit({
+ type: "vault.lock",
+ aid: ctx.id(),
+ pub: kp.publicKey,
+ wrap: { id: ctx.id(), kind: "passkey", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(kp.privateKey, kek) },
+ sealed,
+ });
+ // Slam every on-screen reveal shut the instant we lock — don't wait for the
+ // event to echo back and re-render (a value shown at lock time is live secret
+ // material). cacheUnlock keeps the locker able to re-reveal with no biometric.
+ relockOpenReveals();
+ cacheUnlock(ctx.threadId, kp.privateKey);
+ say("Vault locked — values now need a passkey to read.");
+}
+
+// doRequest: a member with no wrap mints their own passkey + an ephemeral
+// keypair and asks in. Their ephemeral private half rides the request sealed
+// under their own KEK, so only they can complete the grant — from any device
+// holding the passkey.
+async function doRequest(ctx, say) {
+ let pk;
+ try {
+ pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`);
+ } catch (e) {
+ say(passkeyError(e, "Request"));
+ return;
+ }
+ const kek = await kekFromPRF(pk.prf);
+ const eph = await generateVaultKeypair();
+ ctx.emit({
+ type: "vault.request",
+ aid: ctx.id(),
+ rid: ctx.id(),
+ pub: eph.publicKey,
+ ephWrapped: await wrapVaultPriv(eph.privateKey, kek),
+ kind: "passkey",
+ credId: pk.credId,
+ label: ctx.meName || "",
+ });
+ say("Access requested — anyone who can unlock the vault can approve it.");
+}
+
+// doGrant: an unlocked member approves a request — one biometric (the unlock),
+// then the vault key wraps to the request's ephemeral public key.
+async function doGrant(ctx, keyring, req, say) {
+ const priv = await ensureUnlocked(ctx, keyring, say);
+ if (!priv) return;
+ ctx.emit({ type: "vault.grant", aid: ctx.id(), rid: req.id, granted: await grantVaultKey(priv, req.pub) });
+ say(`Granted — ${req.byName || "they"} can finish on their side.`);
+}
+
+// doFinish: the requester completes a grant — one biometric opens their
+// ephemeral key, which opens the grant, and a durable wrap of their own goes
+// into the keyring (rid clears the pending request).
+async function doFinish(ctx, req, say) {
+ let res;
+ try {
+ res = await assertVaultPasskeyFull(req.credId ? [req.credId] : []);
+ } catch (e) {
+ say(passkeyError(e, "Finish"));
+ return;
+ }
+ try {
+ const kek = await kekFromPRF(res.prf);
+ const ephPriv = await unwrapVaultPriv(req.ephWrapped, kek);
+ const priv = await acceptGrant(req.granted, ephPriv);
+ ctx.emit({
+ type: "vault.enroll",
+ aid: ctx.id(),
+ rid: req.id,
+ wrap: { id: ctx.id(), kind: "passkey", label: req.label, credId: req.credId, wrapped: await wrapVaultPriv(priv, kek) },
+ });
+ cacheUnlock(ctx.threadId, priv);
+ say("You're in — this vault now opens with your passkey.");
+ } catch {
+ say("Couldn't finish — the grant doesn't match this passkey's request.");
+ }
+}
+
+// doEnrollHere: an already-enrolled member adds another passkey (a new browser
+// or device) — unlock, mint, wrap, enroll.
+async function doEnrollHere(ctx, keyring, say) {
+ const priv = await ensureUnlocked(ctx, keyring, say);
+ if (!priv) return;
+ let pk;
+ try {
+ pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`);
+ } catch (e) {
+ say(passkeyError(e, "Enrol"));
+ return;
+ }
+ const kek = await kekFromPRF(pk.prf);
+ ctx.emit({
+ type: "vault.enroll",
+ aid: ctx.id(),
+ wrap: { id: ctx.id(), kind: "passkey", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(priv, kek) },
+ });
+ say("Passkey added — this device can now unlock the vault.");
}
// logEntry appends one audit record, deduped by its audit id (addItem) so a
// re-fold of the window never double-logs. The actor is the transport-injected
// `e.uid` / `e.name`; `at` is their send time (e.ts).
function logEntry(log, e, action, name) {
- return addItem(log || [], { id: e.aid, ref: e.id, action, name, by: e.uid || "", byName: e.name || "", at: e.ts || 0 });
+ return addItem(log || [], { id: e.aid, ref: e.id || "", action, name, by: e.uid || "", byName: e.name || "", at: e.ts || 0 });
+}
+
+// ---- surface pieces ---------------------------------------------------------------
+
+// 🤖 buildLockChip is the header's one lock affordance: plain vault → offer the
+// lock ceremony (two-step arm — it's a one-way door); locked → Unlock, or
+// "lock now" to drop the in-memory key early.
+function buildLockChip(keyring, ctx, say) {
+ const chip = document.createElement("button");
+ chip.type = "button";
+ chip.className = "vault-lockchip";
+ if (!keyring) {
+ chip.textContent = "Lock vault…";
+ chip.setAttribute("aria-label", "Lock this vault with a passkey");
+ chip.onclick = () => {
+ if (!chip.dataset.armed) {
+ chip.dataset.armed = "1";
+ chip.textContent = "Lock with a passkey?";
+ say("Locking is one-way: values will only open with a passkey (Touch ID / Face ID) from then on — here, and via Touch ID in the eleven CLI.");
+ setTimeout(() => {
+ delete chip.dataset.armed;
+ if (chip.isConnected) {
+ chip.textContent = "Lock vault…";
+ say("");
+ }
+ }, 8000);
+ return;
+ }
+ delete chip.dataset.armed;
+ doLock(ctx, say);
+ };
+ return chip;
+ }
+ const paint = () => {
+ const open = !!unlockedPriv(ctx.threadId);
+ chip.textContent = open ? "🔓 Unlocked · lock now" : "🔐 Unlock";
+ chip.setAttribute("aria-label", open ? "Vault is unlocked on this device — click to lock now" : "Unlock this vault with your passkey");
+ };
+ chip.onclick = async () => {
+ if (unlockedPriv(ctx.threadId)) {
+ lockNow(ctx.threadId); // drop the key AND re-mask anything still revealed
+ say("Locked — the key is out of memory.");
+ } else if (await ensureUnlocked(ctx, keyring, say)) {
+ say("Unlocked on this device for a few minutes.");
+ }
+ paint();
+ };
+ paint();
+ return chip;
}
// buildRow renders one secret: the name, its masked value, and Reveal / Copy /
// Remove actions, plus a small "added / updated by" audit line. The value starts
// LOCKED; clicking it (or Reveal) swaps in the plaintext and arms a re-lock timer.
-function buildRow(item, ctx) {
+// A sealed item resolves its plaintext through the vault key (at most one
+// biometric); a plaintext item in a LOCKED vault is flagged and gets a one-click
+// Seal — sealing needs no unlock, so anyone can do the tidying.
+function buildRow(item, ctx, keyring, say) {
const li = document.createElement("li");
li.className = "vault-item";
@@ -362,47 +856,92 @@ function buildRow(item, ctx) {
val.append(lock, valText);
let revealed = false;
+ let shown = ""; // the plaintext while revealed — sealed items decrypt on demand
let relock = null;
const reveal = document.createElement("button"); // declared here so paint() can label it
+ // closeReveal masks this row now; it's what a lock (or the re-lock timer)
+ // calls. Registered in openReveals only while the row is actually showing.
+ const closeReveal = () => {
+ if (revealed) {
+ revealed = false;
+ shown = "";
+ paint();
+ }
+ };
const paint = () => {
if (relock) {
clearTimeout(relock);
relock = null;
}
lock.textContent = revealed ? "🔓" : "🔒";
- valText.textContent = revealed ? item.value : MASK;
+ valText.textContent = revealed ? shown : MASK;
val.classList.toggle("revealed", revealed);
val.setAttribute("aria-label", revealed ? `${item.name}: shown, click to hide` : `${item.name}: hidden, click to reveal`);
val.title = revealed ? "Click to hide" : "Click to reveal";
reveal.textContent = revealed ? "Hide" : "Reveal";
- // Re-lock after a beat so a revealed secret doesn't sit on screen indefinitely.
- if (revealed) relock = setTimeout(() => { revealed = false; paint(); }, RELOCK_MS);
+ // Track the live reveal so a lock can revoke it, and re-lock after a beat so
+ // a revealed secret doesn't sit on screen indefinitely.
+ if (revealed) {
+ openReveals.add(closeReveal);
+ relock = setTimeout(closeReveal, RELOCK_MS);
+ } else {
+ openReveals.delete(closeReveal);
+ }
+ };
+
+ // plaintextOf resolves this item's value — directly for a plain item, through
+ // the vault key for a sealed one. null = couldn't (reason already said).
+ const plaintextOf = async () => {
+ if (typeof item.value === "string") return item.value;
+ if (!keyring) {
+ say("This item is sealed but the vault has no keyring — it can't be read.");
+ return null;
+ }
+ const priv = await ensureUnlocked(ctx, keyring, say);
+ if (!priv) return null;
+ try {
+ return await unsealValue(item.sealed, priv);
+ } catch {
+ say(`“${item.name}” won't open with this vault's key — it may have been sealed to an older one.`);
+ return null;
+ }
};
- val.onclick = () => {
- revealed = !revealed;
+
+ const toggleReveal = async () => {
+ if (revealed) {
+ revealed = false;
+ shown = "";
+ paint();
+ return;
+ }
+ const v = await plaintextOf();
+ if (v == null) return;
+ shown = v;
+ revealed = true;
paint();
};
+ val.onclick = toggleReveal;
const actions = document.createElement("div");
actions.className = "vault-actions";
reveal.type = "button";
reveal.className = "vault-btn";
- reveal.onclick = () => {
- revealed = !revealed;
- paint();
- };
+ reveal.onclick = toggleReveal;
const copy = document.createElement("button");
copy.type = "button";
copy.className = "vault-btn";
copy.textContent = "Copy";
copy.onclick = async () => {
+ const v = await plaintextOf();
+ if (v == null) return;
try {
- await navigator.clipboard.writeText(item.value);
+ await navigator.clipboard.writeText(v);
copy.textContent = "Copied";
setTimeout(() => (copy.textContent = "Copy"), 1200);
} catch {
// Clipboard blocked (no permission / insecure context) — reveal instead so
// the user can select the value by hand.
+ shown = v;
revealed = true;
paint();
}
@@ -425,7 +964,19 @@ function buildRow(item, ctx) {
}
ctx.emit({ type: "vault.remove", id: item.id, aid: ctx.id() });
};
- actions.append(reveal, copy, remove);
+ actions.append(reveal, copy);
+ if (keyring && typeof item.value === "string") {
+ const seal = document.createElement("button");
+ seal.type = "button";
+ seal.className = "vault-btn";
+ seal.textContent = "Seal";
+ seal.setAttribute("aria-label", `Seal ${item.name} with the vault key`);
+ seal.onclick = async () => {
+ ctx.emit({ type: "vault.change", id: item.id, aid: ctx.id(), sealed: await sealValue(keyring.pub, item.value) });
+ };
+ actions.append(seal);
+ }
+ actions.append(remove);
paint(); // start locked, with the lock glyph + labels set
row.append(name, val, actions);
@@ -434,12 +985,20 @@ function buildRow(item, ctx) {
const meta = document.createElement("div");
meta.className = "vault-meta";
meta.textContent = auditLine(item);
+ if (keyring && typeof item.value === "string") {
+ const flag = document.createElement("span");
+ flag.className = "vault-unsealed";
+ flag.textContent = " · not sealed yet";
+ meta.appendChild(flag);
+ }
li.appendChild(meta);
- // Clear the re-lock timer when the surface is torn down while a value is showing
- // (a redraw or thread switch) so it can't fire against a detached node.
+ // Clear the re-lock timer and drop this row's reveal callback when the surface
+ // is torn down while a value is showing (a redraw or thread switch) so neither
+ // fires against a detached node.
ctx.onCleanup(() => {
if (relock) clearTimeout(relock);
+ openReveals.delete(closeReveal);
});
return li;
@@ -454,9 +1013,136 @@ function auditLine(item) {
return line;
}
-// The history section's expand state, per thread (module scope so it survives the
-// rebuild-on-every-change render — it's transient UI, not fold state).
+// The access/history sections' expand state, per thread (module scope so it
+// survives the rebuild-on-every-change render — transient UI, not fold state).
const historyOpen = {};
+const accessOpen = {};
+
+// 🤖 buildAccess renders the keyring: who can unlock (the wraps), pending
+// requests (Approve for members who can unlock, Finish setup for the
+// requester), and the self-service actions — request access / add a passkey.
+// Collapsed by default, like History; auto-opens while requests are pending so
+// an approval is never buried.
+function buildAccess(keyring, ctx, say) {
+ const tid = ctx.threadId;
+ const wrapEl = document.createElement("div");
+ wrapEl.className = "vault-access";
+ const wraps = keyring.wraps || [];
+ const requests = keyring.requests || [];
+ const open = accessOpen[tid] !== undefined ? !!accessOpen[tid] : requests.length > 0;
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "vault-access-toggle";
+ const keyCount = wraps.length === 1 ? "1 key" : `${wraps.length.toLocaleString()} keys`;
+ const reqCount = requests.length ? (requests.length === 1 ? " · 1 request" : ` · ${requests.length.toLocaleString()} requests`) : "";
+ toggle.textContent = `${open ? "▾" : "▸"} Access (${keyCount}${reqCount})`;
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
+ toggle.onclick = () => {
+ accessOpen[tid] = !open;
+ const state = ctx.getState() || {};
+ wrapEl.replaceWith(buildAccess(state.keyring || keyring, ctx, say));
+ };
+ wrapEl.appendChild(toggle);
+
+ if (!open) return wrapEl;
+
+ const list = document.createElement("ul");
+ list.className = "vault-access-list";
+ for (const w of wraps) {
+ const li = document.createElement("li");
+ const who = document.createElement("span");
+ who.className = "vault-access-who";
+ who.textContent = w.label || "someone";
+ li.append(who, document.createTextNode(w.kind === "keychain" ? " · CLI key" : " · passkey"));
+ if (wraps.length > 1) {
+ const revoke = document.createElement("button");
+ revoke.type = "button";
+ revoke.className = "vault-btn vault-btn--danger";
+ revoke.textContent = "Revoke";
+ revoke.setAttribute("aria-label", `Revoke the vault key for ${w.label || "this member"}`);
+ revoke.onclick = () => {
+ if (!revoke.dataset.armed) {
+ revoke.dataset.armed = "1";
+ revoke.textContent = "Revoke?";
+ setTimeout(() => {
+ delete revoke.dataset.armed;
+ if (revoke.isConnected) revoke.textContent = "Revoke";
+ }, 4000);
+ return;
+ }
+ ctx.emit({ type: "vault.revoke", aid: ctx.id(), wrapId: w.id });
+ };
+ li.appendChild(revoke);
+ }
+ list.appendChild(li);
+ }
+
+ for (const r of requests) {
+ const li = document.createElement("li");
+ const who = document.createElement("span");
+ who.className = "vault-access-who";
+ who.textContent = r.byName || r.label || "someone";
+ // A CLI-kind request can only finish where its keychain key lives — the
+ // browser offers Finish solely for passkey requests.
+ const granted = r.granted
+ ? r.kind === "keychain"
+ ? " · granted — finish in the terminal (eleven vault finish)"
+ : " · granted — waiting for them to finish"
+ : " · wants access";
+ li.append(who, document.createTextNode(granted));
+ if (r.by === ctx.me && r.granted && r.kind === "passkey") {
+ const finish = document.createElement("button");
+ finish.type = "button";
+ finish.className = "vault-btn";
+ finish.textContent = "Finish setup";
+ finish.onclick = () => doFinish(ctx, r, say);
+ li.appendChild(finish);
+ } else if (!r.granted) {
+ const approve = document.createElement("button");
+ approve.type = "button";
+ approve.className = "vault-btn";
+ approve.textContent = "Approve";
+ approve.setAttribute("aria-label", `Approve vault access for ${r.byName || "this member"}`);
+ approve.onclick = () => doGrant(ctx, keyring, r, say);
+ li.appendChild(approve);
+ }
+ list.appendChild(li);
+ }
+ wrapEl.appendChild(list);
+
+ // Self-service. "Is one of these passkeys mine" can't be known for sure
+ // locally (passkeys sync across a person's devices), so when passkey wraps
+ // exist the emphasis is "add a passkey" (unlock first proves membership) and
+ // "request access" stays reachable beside it; with no passkey wraps at all
+ // (CLI-only keyring), requesting is the only path.
+ const pendingMine = requests.some((r) => r.by === ctx.me);
+ if (pendingMine) {
+ const pending = document.createElement("button");
+ pending.type = "button";
+ pending.className = "vault-btn";
+ pending.textContent = "Request pending…";
+ pending.disabled = true;
+ wrapEl.appendChild(pending);
+ } else {
+ if (wraps.some((w) => w.kind === "passkey")) {
+ const enroll = document.createElement("button");
+ enroll.type = "button";
+ enroll.className = "vault-btn";
+ enroll.textContent = "Add a passkey on this device";
+ enroll.onclick = () => doEnrollHere(ctx, keyring, say);
+ wrapEl.appendChild(enroll);
+ }
+ const req = document.createElement("button");
+ req.type = "button";
+ req.className = "vault-btn";
+ req.textContent = "Request access";
+ req.onclick = () => doRequest(ctx, say);
+ wrapEl.appendChild(req);
+ }
+
+ return wrapEl;
+}
// buildHistory renders the collapsible audit trail — every add / change / remove,
// newest first — for the app's current thread. Collapsed by default.
@@ -469,7 +1155,7 @@ function buildHistory(log, ctx) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "vault-history-toggle";
- toggle.textContent = `${open ? "▾" : "▸"} History (${log.length})`;
+ toggle.textContent = `${open ? "▾" : "▸"} History (${log.length.toLocaleString()})`;
toggle.onclick = () => {
historyOpen[tid] = !open;
// Re-render just this section in place (the rest of the surface is unaffected).
@@ -489,16 +1175,34 @@ function buildHistory(log, ctx) {
// logRow renders one audit-trail line: "<who> <verb> <name> · <when>". The name
// and actor are user content, so they go in via textContent nodes, never markup.
+// Item actions name the secret; keyring actions carry a member/device label.
+const LOG_VERBS = {
+ add: " added ",
+ remove: " removed ",
+ change: " changed ",
+ lock: " locked the vault",
+ enroll: " enrolled a key",
+ revoke: " revoked a key",
+ request: " requested access",
+ grant: " granted access",
+};
+
function logRow(entry) {
const li = document.createElement("li");
const who = document.createElement("span");
who.className = "vault-log-who";
who.textContent = entry.byName || entry.by || "someone";
- const verb = entry.action === "add" ? " added " : entry.action === "remove" ? " removed " : " changed ";
- li.append(who, document.createTextNode(verb));
- const nm = document.createElement("span");
- nm.textContent = entry.name || "a secret";
- li.append(nm);
+ const isItemAction = entry.action === "add" || entry.action === "remove" || entry.action === "change" || !LOG_VERBS[entry.action];
+ li.append(who, document.createTextNode(LOG_VERBS[entry.action] || " changed "));
+ if (isItemAction) {
+ const nm = document.createElement("span");
+ nm.textContent = entry.name || "a secret";
+ li.append(nm);
+ } else if (entry.name) {
+ const nm = document.createElement("span");
+ nm.textContent = ` (${entry.name})`;
+ li.append(nm);
+ }
if (entry.at) li.append(document.createTextNode(" · " + shortWhen(entry.at)));
return li;
}
diff --git a/web/static/apps/vaultcrypto.js b/web/static/apps/vaultcrypto.js
new file mode 100644
index 0000000..9c5822e
--- /dev/null
+++ b/web/static/apps/vaultcrypto.js
@@ -0,0 +1,165 @@
+// The Vault lock layer's crypto (docs/locked-vaults.md). A locked vault holds a
+// dedicated ECDH P-256 "vault keypair": every value is ECIES-sealed to the
+// vault PUBLIC key (so adding a secret never needs an unlock — encrypting to a
+// public key is free), while the PRIVATE half exists only inside keyring
+// "wraps", each openable solely by a biometric-gated KEK — a passkey's WebAuthn
+// PRF output in the browser (exactly home's kekFromPRF pattern), or a Touch
+// ID-guarded keychain key in the eleven CLI. A signed-in session alone (thread
+// key in hand, no biometric) can read names and the audit trail, and can add —
+// but never read a value.
+//
+// Everything here reuses crypto.js primitives — sealForPub/openSealed for the
+// values, wrapThreadKey/unwrapThreadKey for handing the private key around —
+// so the wire shapes stay the ones the Go sidecar and home already speak
+// (ephPub(65)||iv(12)||ct, b64url iv||ct). Pure functions only; the two
+// WebAuthn ceremonies touch `navigator` strictly inside their bodies so this
+// module stays importable (and the pure parts testable) in Node.
+import {
+ b64urlToBytes,
+ bytesToB64url,
+ encryptBytes,
+ decryptBytes,
+ generateUserKeypair,
+ sealForPub,
+ openSealed,
+ wrapThreadKey,
+ unwrapThreadKey,
+} from "../crypto.js";
+
+// ---- PRF -> KEK (the browser end) ------------------------------------------
+
+// The PRF eval input is a fixed public constant — the secrecy lives in the
+// passkey (the authenticator mixes it with a per-credential secret). Versioned
+// so a future KDF change can request both and migrate (same shape as home's
+// "eleven-home-kek-v1").
+export function prfEvalInput() {
+ return new TextEncoder().encode("eleven-vault-kek-v1");
+}
+
+// kekFromPRF derives the AES-GCM key-encryption key from a PRF output
+// (ArrayBuffer/Uint8Array from getClientExtensionResults().prf.results.first).
+export async function kekFromPRF(prfOutput) {
+ const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]);
+ return crypto.subtle.deriveKey(
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("eleven-vault-kek") },
+ ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
+ );
+}
+
+// importKEK turns 32 raw KEK bytes into an AES-GCM key — the CLI/keychain path
+// (the Go side holds the same 32 bytes behind a biometry ACL). Also what tests
+// use in place of a real authenticator's PRF output.
+export async function importKEK(rawBytes) {
+ return crypto.subtle.importKey("raw", rawBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
+}
+
+// ---- the vault keypair -------------------------------------------------------
+
+// generateVaultKeypair mints the vault's ECDH P-256 pair: { publicKey (raw
+// point b64url), privateKey (pkcs8 b64url) } — the same shapes as a user
+// keypair, so every existing wrap/seal helper applies unchanged.
+export const generateVaultKeypair = generateUserKeypair;
+
+// wrapVaultPriv/unwrapVaultPriv: the private key's pkcs8 bytes under a KEK, as
+// b64url(iv||ct). One of these per keyring wrap.
+export async function wrapVaultPriv(privB64, kek) {
+ return bytesToB64url(await encryptBytes(kek, b64urlToBytes(privB64)));
+}
+
+export async function unwrapVaultPriv(wrappedB64, kek) {
+ return bytesToB64url(await decryptBytes(kek, b64urlToBytes(wrappedB64)));
+}
+
+// ---- sealing values ----------------------------------------------------------
+
+// sealValue ECIES-seals one secret value to the vault public key. The value
+// rides inside a tiny JSON envelope so future fields (e.g. a content type for
+// file items) can join without a format break.
+export async function sealValue(vaultPubB64, value) {
+ return sealForPub(vaultPubB64, { v: value });
+}
+
+// unsealValue recovers the plaintext value with the vault private key. Throws
+// on a blob not sealed to this vault (surfaced by the caller as "can't read").
+export async function unsealValue(sealedB64, vaultPrivB64) {
+ const obj = await openSealed(sealedB64, vaultPrivB64);
+ if (typeof obj.v !== "string") throw new Error("bad sealed value");
+ return obj.v;
+}
+
+// ---- the grant ceremony (handing the private key to a new member) -----------
+
+// A requester publishes an ephemeral P-256 public key (vault.request); an
+// unlocked member wraps the vault private key to it (vault.grant); the
+// requester opens the grant with the ephemeral private half — which only ever
+// existed sealed under their own KEK (ephWrapped) — then enrols a durable wrap
+// of their own. wrapThreadKey's format, because the payload IS key bytes.
+export async function grantVaultKey(vaultPrivB64, requesterEphPubB64) {
+ return wrapThreadKey(vaultPrivB64, requesterEphPubB64);
+}
+
+export async function acceptGrant(grantedB64, ephPrivB64) {
+ return unwrapThreadKey(grantedB64, ephPrivB64);
+}
+
+// ---- WebAuthn ceremonies (browser only) --------------------------------------
+//
+// Both are FULLY client-side: the challenge is local randomness because nothing
+// is being authenticated to a server — the ceremony exists only to make the
+// authenticator evaluate PRF (user verification = the biometric). No WebAuthn
+// endpoints, no attestation, nothing stored server-side. Mirrors home's
+// backup-core.js, including the authenticators that only evaluate PRF during an
+// assertion (not at create) — enrolment verifies PRF END-TO-END before the
+// caller posts any wrap, so a no-PRF authenticator fails here, loudly, never
+// after a wrap is already in the keyring (the 1Password-on-macOS lesson).
+
+// registerVaultPasskey creates a resident passkey with PRF for this vault and
+// returns { credId (b64url), prf (Uint8Array) }. Throws Error("no-prf") when
+// the authenticator can't do PRF — callers show "try another password manager /
+// browser" copy. `label` becomes the passkey's user.name in the picker.
+export async function registerVaultPasskey(label) {
+ const cred = await navigator.credentials.create({
+ publicKey: {
+ rp: { name: "Vault", id: location.hostname },
+ user: {
+ id: crypto.getRandomValues(new Uint8Array(16)),
+ name: label || "Vault key",
+ displayName: label || "Vault key",
+ },
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
+ authenticatorSelection: { residentKey: "required", userVerification: "required" },
+ extensions: { prf: { eval: { first: prfEvalInput() } } },
+ },
+ });
+ const ext = cred.getClientExtensionResults();
+ if (!ext.prf?.enabled && !ext.prf?.results) throw new Error("no-prf");
+ const credId = bytesToB64url(new Uint8Array(cred.rawId));
+ // Some authenticators evaluate PRF at create; the rest only in an assertion.
+ let prf = ext.prf?.results?.first;
+ if (!prf) prf = await assertVaultPasskey([credId]);
+ return { credId, prf: new Uint8Array(prf) };
+}
+
+// assertVaultPasskey runs one assertion against the keyring's enrolled
+// credentials and returns the PRF output (Uint8Array) — one biometric prompt.
+// Also returns which credential answered via the out-param shape when the
+// caller needs to pick the matching wrap: use assertVaultPasskeyFull.
+export async function assertVaultPasskey(credIdsB64) {
+ const { prf } = await assertVaultPasskeyFull(credIdsB64);
+ return prf;
+}
+
+export async function assertVaultPasskeyFull(credIdsB64) {
+ const cred = await navigator.credentials.get({
+ publicKey: {
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ allowCredentials: (credIdsB64 || []).map((id) => ({ type: "public-key", id: b64urlToBytes(id) })),
+ userVerification: "required",
+ extensions: { prf: { eval: { first: prfEvalInput() } } },
+ },
+ });
+ const prf = cred.getClientExtensionResults().prf?.results?.first;
+ if (!prf) throw new Error("no-prf");
+ return { prf: new Uint8Array(prf), credId: bytesToB64url(new Uint8Array(cred.rawId)) };
+}