| 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; |
| } |