| 1 | // The Vault mini-app: a secure store for named secrets. Everything in lchat is |
| 2 | // already end-to-end encrypted, so a vault is almost free — a secret is just a |
| 3 | // named value folded from a thread's event log, so the server only ever sees |
| 4 | // ciphertext, never a name or a value. |
| 5 | // |
| 6 | // It's a `personal` app with `groupCapable: true`, so it works two ways from one |
| 7 | // codebase (see apps/README.md "Group-scoped apps"): |
| 8 | // - PERSONAL: its own full-pane thread in the sidebar, just yours, synced across |
| 9 | // your devices — exactly like To-do. |
| 10 | // - GROUP: added to a group chat (⋮ → Add apps), where ONE shared instance runs |
| 11 | // for everyone. Its events post into the group thread's encrypted log, so every |
| 12 | // member folds the same vault. Nothing about render() changes — the same |
| 13 | // surface, ctx bound to whichever thread it's acting in. |
| 14 | // |
| 15 | // It is a TAKEOVER app (`groupFeedMessage`, never `chatMessage`): the vault |
| 16 | // IS the thread's surface — your personal Vault never grows a Chat view, |
| 17 | // and no secret's NAME ever spills into the conversation, a sidebar preview, or |
| 18 | // push. 🤖 Two places still surface UNNAMED activity, "you"-aware (same as a |
| 19 | // membership line — see `format.js`'s `membershipLine`): |
| 20 | // - The chat list's "last message" preview (`preview` below) — "You added a |
| 21 | // secret to the Vault" / "Ciara updated a secret" — for the vault's own |
| 22 | // thread (personal or group), so the row doesn't sit blank. |
| 23 | // - When the vault is added to a GROUP, an add/remove also posts the same |
| 24 | // unnamed line into that group's chat feed (`chatMessage`'s `{ group }` |
| 25 | // form), so members notice without opening it. |
| 26 | // The name (which — unlike the value — every member can already see inside the |
| 27 | // vault) never lands in either place. The full audit trail, WITH names, still |
| 28 | // lives only inside the vault surface. |
| 29 | // |
| 30 | // Two ideas beyond a plain list: |
| 31 | // - Locked by default. A value renders masked (••••••) and only reveals when you |
| 32 | // click it — so glancing at the screen can't leak a secret to whoever's looking |
| 33 | // over your shoulder. A revealed value auto-re-locks after a moment. |
| 34 | // - An audit trail. Every add / change / remove is recorded in an append-only |
| 35 | // `log` with who did it and when, so you can see how a secret got there. |
| 36 | // |
| 37 | // 🤖 THE LOCK LAYER (docs/locked-vaults.md). A vault can be LOCKED: it gains a |
| 38 | // dedicated ECDH keypair whose public half sits in this state and whose private |
| 39 | // half exists only inside keyring `wraps`, each openable solely by a |
| 40 | // biometric-gated key — a passkey's WebAuthn PRF output here, or a Touch |
| 41 | // ID-guarded keychain key in the eleven CLI. Once locked, values ride and rest |
| 42 | // ECIES-SEALED to the vault public key (`sealed`, never `value`): anyone in the |
| 43 | // thread can still ADD a secret without an unlock (encrypting to a public key |
| 44 | // is free), but READING a value takes a fresh biometric. A signed-in session |
| 45 | // alone — thread key in hand — sees names and the audit trail, never values. |
| 46 | // Access follows THREAD MEMBERSHIP, not a request dance: locking HANDS the vault |
| 47 | // key to each current member (sealed to their roster key — a `member` handoff), |
| 48 | // so every member is entitled by being in the thread. Each member self-enrols |
| 49 | // their own passkey (vault.enroll), and their handoff drops. An already-unlocked |
| 50 | // insider can give a later joiner a fresh handoff (vault.give). There is no |
| 51 | // "request access" — you never ask for a key to a vault you're already in. All of |
| 52 | // it is ordinary events through the same reducer — malformed folds to a no-op, so |
| 53 | // hostile inputs stay safe, which `groupCapable` requires. The crypto lives in |
| 54 | // vaultcrypto.js; reducers here never touch it (folds stay pure and sync). |
| 55 | // |
| 56 | // Files are a deliberate TODO: a value can only be text today because the mini-app |
| 57 | // ctx has no file upload/download surface yet (see the "apps"-tagged issue #408). |
| 58 | // `kind` is stored on every item so file items fold cleanly once it lands. |
| 59 | // |
| 60 | // Relative import (not "/static/apps.js") so the reducer is exercisable in Node |
| 61 | // tests while still resolving to /static/apps.js in the browser. |
| 62 | import { defineApp, addItem, updateItem, removeItem, currentUserId } from "../apps.js"; |
| 63 | import { ICON_LOCK } from "../icons.js"; |
| 64 | import { |
| 65 | registerVaultPasskey, |
| 66 | assertVaultPasskeyFull, |
| 67 | kekFromPRF, |
| 68 | generateVaultKeypair, |
| 69 | wrapVaultPriv, |
| 70 | unwrapVaultPriv, |
| 71 | sealValue, |
| 72 | unsealValue, |
| 73 | sealVaultKeyTo, |
| 74 | } from "../vaultcrypto.js"; |
| 75 | |
| 76 | // How long a revealed value stays visible before it re-locks itself — short, so a |
| 77 | // vault left open on screen doesn't sit unmasked. Clicking it again re-arms it. |
| 78 | const RELOCK_MS = 30000; |
| 79 | // How long an UNLOCK (the vault private key, in memory only) survives before the |
| 80 | // vault forgets it and the next read costs a fresh biometric. Never persisted. |
| 81 | const UNLOCK_TTL_MS = 5 * 60000; |
| 82 | // A fixed-width mask that never leaks the value's length (always the same width, |
| 83 | // whether the secret is 4 chars or 400). |
| 84 | const MASK = "•".repeat(10); |
| 85 | |
| 86 | // parseVaultCommand pulls "<name> = <value>" out of a /vault command's args — |
| 87 | // split on the FIRST "=" so a value may itself contain "=" (base64, query |
| 88 | // strings). Returns { name, value } or null when either side is empty. Pure and |
| 89 | // exported so it's unit-tested without a DOM. |
| 90 | export function parseVaultCommand(args) { |
| 91 | const s = (args || "").trim(); |
| 92 | const i = s.indexOf("="); |
| 93 | if (i < 0) return null; |
| 94 | const name = s.slice(0, i).trim(); |
| 95 | const value = s.slice(i + 1).trim(); |
| 96 | if (!name || !value) return null; |
| 97 | return { name, value }; |
| 98 | } |
| 99 | |
| 100 | // findByName does a case-insensitive lookup so "/vault WiFi = …" updates an |
| 101 | // existing "wifi" secret in place instead of creating a near-duplicate. |
| 102 | function findByName(items, name) { |
| 103 | const n = (name || "").trim().toLowerCase(); |
| 104 | return (items || []).find((it) => (it.name || "").toLowerCase() === n) || null; |
| 105 | } |
| 106 | |
| 107 | // 🤖 wrapShape vets keyring objects arriving in events before the reducer stores |
| 108 | // them — every field a later unlock will read must be a non-empty string, and |
| 109 | // anything extra is dropped (never store a stranger's spare fields). Return the |
| 110 | // cleaned object, or null. Pure; exported for tests. |
| 111 | // Wrap kinds (docs/locked-vaults.md): |
| 112 | // passkey / keychain — a member's DURABLE biometric wrap (WebAuthn PRF KEK / |
| 113 | // CLI Touch ID keychain KEK). "Requires a passkey to open." |
| 114 | // member — a HANDOFF: the vault key sealed to a member's roster |
| 115 | // public key, so that member can SELF-ENROL a passkey without anyone |
| 116 | // approving. Soft (their session key opens it); dropped the moment they |
| 117 | // enrol their own biometric wrap. Carries `uid` = whose handoff it is. |
| 118 | // Every wrap carries `uid` (the member it belongs to) so the reducer can drop a |
| 119 | // member's handoff when they enrol, and the UI can tell whose key is whose. |
| 120 | export function wrapShape(w) { |
| 121 | if (!w || typeof w.id !== "string" || !w.id) return null; |
| 122 | if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "member") return null; |
| 123 | if (typeof w.wrapped !== "string" || !w.wrapped) return null; |
| 124 | const out = { |
| 125 | id: w.id, |
| 126 | kind: w.kind, |
| 127 | uid: typeof w.uid === "string" ? w.uid : "", |
| 128 | wrapped: w.wrapped, |
| 129 | label: typeof w.label === "string" ? w.label : "", |
| 130 | }; |
| 131 | if (typeof w.credId === "string" && w.credId) out.credId = w.credId; |
| 132 | return out; |
| 133 | } |
| 134 | |
| 135 | // State is { items, log, keyring }. |
| 136 | // item: { id, name, value?, sealed?, kind, by, byName, at, updatedBy, updatedByName, updatedAt } |
| 137 | // — exactly one of value (plain vault) / sealed (locked vault: ECIES to the |
| 138 | // vault public key, b64url ephPub||iv||ct; see vaultcrypto.js). |
| 139 | // log: { id (audit id), ref (item id), action, name, by, byName, at } |
| 140 | // — action: add | change | remove | lock | enroll | give | revoke |
| 141 | // keyring (locked vaults only): { v: 1, pub, wraps: [wrapShape] } — see the |
| 142 | // lock-layer note at the top of this file and docs/locked-vaults.md. |
| 143 | // |
| 144 | // The secret's name rides in the event as `label`, NOT `name` — the emit |
| 145 | // transport OVERWRITES `event.name` with the sender's screen name (that's how the |
| 146 | // audit trail learns who acted), so a secret called `name` would be clobbered. So |
| 147 | // the reducer reads `e.label` for the secret's name and `e.name`/`e.uid`/`e.ts` |
| 148 | // for the actor + time (all injected by the transport). Every event also carries |
| 149 | // its own `aid` (audit id) minted fresh at emit, so the append-only log dedupes by |
| 150 | // aid (addItem) — the item id can't, since a change or remove reuses it. The |
| 151 | // reducer is the deterministic arbiter: a malformed event folds to a no-op, so |
| 152 | // anything a member (or bot) can post is a safe input — which is exactly what |
| 153 | // `groupCapable` requires (strangers' events are now first-class inputs). |
| 154 | export const app = defineApp({ |
| 155 | name: "vault", |
| 156 | title: "Vault", |
| 157 | blurb: "Keep secrets safe", // sidebar subtitle before first use (sync w/ native) |
| 158 | glyph: "🔒", |
| 159 | symbol: "lock", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) |
| 160 | logo: ICON_LOCK, |
| 161 | // The SPACE place (a space vault as the team credential shelf — one shared |
| 162 | // set of secrets for everyone the space already trusts) was a deliberate |
| 163 | // choice and the reducer still supports it: every member can read it, |
| 164 | // additions are attributed, every member's events are first-class inputs |
| 165 | // exactly as groupCapable already required. It's parked while the places |
| 166 | // UI is hidden (docs/space-apps.md § The pullback) — restore by adding |
| 167 | // "space" back to this line. |
| 168 | places: ["you", "chat"], |
| 169 | initialState: () => ({ items: [], log: [], keyring: null }), |
| 170 | events: { |
| 171 | "vault.add": (s, e) => { |
| 172 | const label = typeof e.label === "string" ? e.label.trim() : ""; |
| 173 | // Exactly one of value/sealed (sealed must be non-empty; an empty VALUE is |
| 174 | // rejected by the emitters, but an empty string here would still fold — |
| 175 | // matching the old validation, which only type-checked). |
| 176 | const hasValue = typeof e.value === "string"; |
| 177 | const hasSealed = typeof e.sealed === "string" && e.sealed !== ""; |
| 178 | if (!e.id || !e.aid || !label || hasValue === hasSealed) return s; |
| 179 | const items = addItem(s.items, { |
| 180 | id: e.id, |
| 181 | name: label, |
| 182 | ...(hasSealed ? { sealed: e.sealed } : { value: e.value }), |
| 183 | kind: e.kind === "file" ? "file" : "text", |
| 184 | by: e.uid || "", |
| 185 | byName: e.name || "", |
| 186 | at: e.ts || 0, |
| 187 | updatedBy: "", |
| 188 | updatedByName: "", |
| 189 | updatedAt: 0, |
| 190 | }); |
| 191 | return { ...s, items, log: logEntry(s.log, e, "add", label) }; |
| 192 | }, |
| 193 | "vault.change": (s, e) => { |
| 194 | const item = (s.items || []).find((it) => it.id === e.id); |
| 195 | if (!e.id || !e.aid || !item) return s; |
| 196 | const patch = { updatedBy: e.uid || "", updatedByName: e.name || "", updatedAt: e.ts || 0 }; |
| 197 | if (typeof e.label === "string" && e.label.trim()) patch.name = e.label.trim(); |
| 198 | // A change may carry a new value (plain vault) or a new sealed blob |
| 199 | // (locked) — whichever arrives replaces the other form, so re-sealing a |
| 200 | // stale plaintext item is just a change carrying `sealed`. |
| 201 | if (typeof e.sealed === "string" && e.sealed !== "") { |
| 202 | patch.sealed = e.sealed; |
| 203 | patch.value = undefined; |
| 204 | } else if (typeof e.value === "string") { |
| 205 | patch.value = e.value; |
| 206 | patch.sealed = undefined; |
| 207 | } |
| 208 | return { ...s, items: updateItem(s.items, e.id, patch), log: logEntry(s.log, e, "change", patch.name || item.name) }; |
| 209 | }, |
| 210 | "vault.remove": (s, e) => { |
| 211 | const item = (s.items || []).find((it) => it.id === e.id); |
| 212 | if (!e.id || !e.aid) return s; |
| 213 | // Carry the name into the log entry (the item is about to be gone) so the |
| 214 | // audit trail still reads "… removed GITHUB_TOKEN". |
| 215 | return { ...s, items: removeItem(s.items, e.id), log: logEntry(s.log, e, "remove", (item && item.name) || "") }; |
| 216 | }, |
| 217 | |
| 218 | // ---- 🤖 the lock layer (docs/locked-vaults.md) -------------------------- |
| 219 | |
| 220 | // vault.lock turns a shared vault passkey-locked: installs the vault public |
| 221 | // key, seals every value to it, adds the LOCKER's own biometric wrap, and — |
| 222 | // so nobody has to "request" anything — HANDS the vault key to each other |
| 223 | // current member by sealing it to their roster key (`handoffs`, each a |
| 224 | // `member` wrap). Every member is entitled by membership; each self-enrols |
| 225 | // their own passkey next (vault.enroll), and their handoff drops then. |
| 226 | // One-way: a keyring is never replaced. |
| 227 | "vault.lock": (s, e) => { |
| 228 | if (s.keyring) return s; |
| 229 | const wrap = wrapShape(e.wrap); |
| 230 | if (!e.aid || typeof e.pub !== "string" || !e.pub || !wrap) return s; |
| 231 | const sealed = e.sealed && typeof e.sealed === "object" ? e.sealed : {}; |
| 232 | const items = (s.items || []).map((it) => { |
| 233 | const blob = sealed[it.id]; |
| 234 | if (typeof blob !== "string" || !blob) return it; // uncovered items keep value; render flags them |
| 235 | const { value, ...rest } = it; |
| 236 | return { ...rest, sealed: blob }; |
| 237 | }); |
| 238 | let wraps = [wrap]; |
| 239 | for (const h of Array.isArray(e.handoffs) ? e.handoffs : []) { |
| 240 | const hw = wrapShape(h); |
| 241 | if (hw && hw.kind === "member") wraps = addItem(wraps, hw); |
| 242 | } |
| 243 | return { items, log: logEntry(s.log, e, "lock", ""), keyring: { v: 1, pub: e.pub, wraps } }; |
| 244 | }, |
| 245 | |
| 246 | // vault.enroll installs a member's DURABLE biometric wrap (passkey/keychain) |
| 247 | // and DROPS that member's soft `member` handoff — from here on they need |
| 248 | // their passkey. `uid` says whose it is; a member self-enrols with no |
| 249 | // approval (they already hold the key by membership). |
| 250 | "vault.enroll": (s, e) => { |
| 251 | const wrap = wrapShape(e.wrap); |
| 252 | if (!s.keyring || !e.aid || !wrap || wrap.kind === "member") return s; |
| 253 | const uid = wrap.uid || e.uid || ""; |
| 254 | const wraps = (s.keyring.wraps || []).filter((w) => !(w.kind === "member" && uid && w.uid === uid)); |
| 255 | return { |
| 256 | ...s, |
| 257 | log: logEntry(s.log, e, "enroll", wrap.label), |
| 258 | keyring: { ...s.keyring, wraps: addItem(wraps, wrap) }, |
| 259 | }; |
| 260 | }, |
| 261 | |
| 262 | // vault.give: an already-unlocked member admits someone who wasn't present at |
| 263 | // lock time (or lost every device) — seals the vault key to that member's |
| 264 | // roster key as a `member` handoff, which they self-enrol from. The ONLY |
| 265 | // deliberate hand-off, and it's an insider GIVING access, never an outsider |
| 266 | // "requesting" it. `uid` is the recipient. |
| 267 | "vault.give": (s, e) => { |
| 268 | const wrap = wrapShape(e.wrap); |
| 269 | if (!s.keyring || !e.aid || !wrap || wrap.kind !== "member" || !wrap.uid) return s; |
| 270 | return { |
| 271 | ...s, |
| 272 | log: logEntry(s.log, e, "give", wrap.label), |
| 273 | keyring: { ...s.keyring, wraps: addItem(s.keyring.wraps, wrap) }, |
| 274 | }; |
| 275 | }, |
| 276 | |
| 277 | // vault.revoke drops a wrap — housekeeping, NOT crypto revocation (an |
| 278 | // ex-holder saw the values; rotating the real secrets is the honest move, |
| 279 | // docs/locked-vaults.md "What this does not do"). The LAST wrap can never be |
| 280 | // dropped — it would strand the vault keyless. |
| 281 | "vault.revoke": (s, e) => { |
| 282 | if (!s.keyring || !e.aid || typeof e.wrapId !== "string" || !e.wrapId) return s; |
| 283 | const gone = (s.keyring.wraps || []).find((w) => w.id === e.wrapId); |
| 284 | if (!gone || s.keyring.wraps.length <= 1) return s; |
| 285 | return { |
| 286 | ...s, |
| 287 | log: logEntry(s.log, e, "revoke", gone.label), |
| 288 | keyring: { ...s.keyring, wraps: removeItem(s.keyring.wraps, e.wrapId) }, |
| 289 | }; |
| 290 | }, |
| 291 | }, |
| 292 | commands: { |
| 293 | // /vault <name> = <value> — familiar first: a Slack/Discord-style command, |
| 294 | // with the "= value" shape advertised in the "/" typeahead. Because the app is |
| 295 | // groupCapable, the framework routes this to the RIGHT instance: your personal |
| 296 | // vault from anywhere, or the GROUP's shared vault when typed in a chat that has |
| 297 | // Vault added (and "/vault @here …" adds it to a group in one gesture). |
| 298 | "/vault": { |
| 299 | desc: "Store a secret", |
| 300 | args: "<name> = <value>", |
| 301 | run: async (args, ctx) => { |
| 302 | const parsed = parseVaultCommand(args); |
| 303 | if (!parsed) { |
| 304 | // Bad/blank input still gets a hint (private, where you typed) — but not |
| 305 | // the secret typed back at them. |
| 306 | return ctx.reply && ctx.reply({ text: "Usage: /vault <name> = <value> — e.g. /vault WiFi = hunter2", short: "vault usage" }); |
| 307 | } |
| 308 | await addOrUpdate(ctx, parsed.name, parsed.value); |
| 309 | ctx.reply && ctx.reply({ text: `Stored “${parsed.name}” in the vault.`, short: `stored “${parsed.name}”`, actions: [{ label: "Open Vault", run: ctx.open }] }); |
| 310 | }, |
| 311 | }, |
| 312 | }, |
| 313 | |
| 314 | // 🤖 groupFeedMessage is the FEED-ONLY projection hook: the host calls it |
| 315 | // when it folds a GROUP/DM chat's own feed (foldGroupAppEventBubble in |
| 316 | // shell.js) and for nothing else — implementing it (and NOT chatMessage) |
| 317 | // is exactly how the personal Vault stays a takeover with no Chat view |
| 318 | // (appWantsChatView; apps/README.md "Companion projection"). Deliberately |
| 319 | // unnamed and deliberately narrow: an ADD or REMOVE is worth a group |
| 320 | // noticing ("someone touched the vault"), but the secret's name stays out |
| 321 | // of the conversation, its previews, and push — and a value CHANGE (no |
| 322 | // new/gone item, same name) stays silent, since there's nothing new for a |
| 323 | // passerby to learn from it. Locking is group-visible too: reads now cost |
| 324 | // a biometric, worth noticing. |
| 325 | groupFeedMessage(e) { |
| 326 | if (e.type === "vault.add") return { text: "added a secret to the Vault", action: true }; |
| 327 | if (e.type === "vault.remove") return { text: "removed a secret from the Vault", action: true }; |
| 328 | if (e.type === "vault.lock") return { text: "locked the Vault with a passkey", action: true }; |
| 329 | return null; |
| 330 | }, |
| 331 | |
| 332 | // 🤖 The sidebar's "last message" line, for both a personal Vault thread and |
| 333 | // a group's — same unnamed shape as groupFeedMessage above (never the |
| 334 | // secret's name), and on for BOTH places: a preview line doesn't grow a |
| 335 | // Chat view, it's just text on a row you already see. "You" for your own |
| 336 | // action, same idea as the group action line's actor substitution. |
| 337 | preview(e) { |
| 338 | const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone"; |
| 339 | if (e.type === "vault.add") return `🔒 ${who} added a secret to the Vault`; |
| 340 | if (e.type === "vault.remove") return `🔒 ${who} removed a secret from the Vault`; |
| 341 | if (e.type === "vault.change") return `🔒 ${who} updated a secret in the Vault`; |
| 342 | if (e.type === "vault.lock") return `🔒 ${who} locked the Vault`; |
| 343 | return null; |
| 344 | }, |
| 345 | |
| 346 | // This app's CSS travels with it — the framework injects it once. It reads the |
| 347 | // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme. |
| 348 | styles: ` |
| 349 | .vault-wrap { display: flex; flex-direction: column; gap: 12px; max-width: 560px; width: 100%; margin: 0 auto; } |
| 350 | .vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } |
| 351 | .vault-head-left { display: flex; align-items: baseline; gap: 8px; min-width: 0; } |
| 352 | .vault-title { font-size: 15px; font-weight: 700; color: var(--ink); } |
| 353 | .vault-count { font-size: 12px; color: var(--muted); } |
| 354 | .vault-lockchip { |
| 355 | border: 1px solid var(--border); background: var(--panel); color: var(--ink); |
| 356 | font: inherit; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px; |
| 357 | cursor: var(--ui-cursor, pointer); white-space: nowrap; |
| 358 | } |
| 359 | .vault-lockchip:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } |
| 360 | .vault-lockchip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } |
| 361 | .vault-note { font-size: 12px; color: var(--muted); padding: 2px 0; } |
| 362 | .vault-note:empty { display: none; } |
| 363 | .vault-add { display: flex; flex-wrap: wrap; gap: 8px; } |
| 364 | .vault-add input { |
| 365 | flex: 1 1 140px; min-width: 0; padding: 9px 11px; border-radius: 10px; font: inherit; font-size: 14px; |
| 366 | border: 1px solid var(--border); background: var(--panel); color: var(--ink); |
| 367 | } |
| 368 | .vault-add .vault-name { flex: 0 1 150px; } |
| 369 | .vault-add-btn { flex: 0 0 auto; } |
| 370 | .vault-empty { font-size: 13px; color: var(--muted); padding: 4px 0; } |
| 371 | .vault-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } |
| 372 | .vault-item { |
| 373 | display: flex; flex-direction: column; gap: 6px; padding: 11px 12px; |
| 374 | border: 1px solid var(--border); border-radius: 12px; background: var(--panel); |
| 375 | transition: border-color 0.15s; |
| 376 | } |
| 377 | .vault-item:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } |
| 378 | .vault-row { display: flex; align-items: center; gap: 10px; } |
| 379 | .vault-name-label { |
| 380 | flex: 0 0 auto; max-width: 40%; font-size: 13px; font-weight: 700; color: var(--ink); |
| 381 | overflow-wrap: anywhere; letter-spacing: 0.01em; |
| 382 | } |
| 383 | /* The masked value is a full-width button that reads as tappable: click it to |
| 384 | reveal. Locked it's a muted monospace mask; revealed it's crisp and selectable. */ |
| 385 | .vault-value { |
| 386 | flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 7px; |
| 387 | padding: 5px 9px; border: 1px solid transparent; border-radius: 8px; |
| 388 | background: var(--bubble-them); color: var(--muted); font: inherit; |
| 389 | cursor: var(--ui-cursor, pointer); text-align: left; transition: background 0.12s, border-color 0.12s; |
| 390 | } |
| 391 | .vault-value:hover { border-color: var(--border); } |
| 392 | .vault-value:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } |
| 393 | .vault-lock { flex: 0 0 auto; font-size: 12px; line-height: 1; } |
| 394 | .vault-value-text { |
| 395 | flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| 396 | font-size: 13px; letter-spacing: 0.18em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| 397 | } |
| 398 | .vault-value.revealed { background: transparent; color: var(--ink); } |
| 399 | .vault-value.revealed .vault-value-text { |
| 400 | letter-spacing: normal; white-space: pre-wrap; overflow-wrap: anywhere; overflow: visible; user-select: all; |
| 401 | } |
| 402 | .vault-unsealed { color: var(--muted); font-style: italic; } |
| 403 | .vault-actions { flex: 0 0 auto; display: flex; gap: 1px; } |
| 404 | .vault-btn { |
| 405 | border: none; background: transparent; color: var(--link); font: inherit; font-size: 12px; font-weight: 600; |
| 406 | cursor: var(--ui-cursor, pointer); padding: 4px 7px; border-radius: 7px; white-space: nowrap; |
| 407 | } |
| 408 | .vault-btn:hover { background: var(--bubble-them); } |
| 409 | .vault-btn:disabled { color: var(--muted); cursor: default; background: transparent; } |
| 410 | .vault-btn--danger { color: var(--muted); } |
| 411 | .vault-btn--danger:hover { color: #e5484d; background: color-mix(in srgb, #e5484d 12%, transparent); } |
| 412 | .vault-meta { font-size: 11px; color: var(--muted); } |
| 413 | .vault-access { border-top: 1px solid var(--border); padding-top: 8px; } |
| 414 | .vault-access-list { list-style: none; margin: 6px 0 4px; padding: 0; display: flex; flex-direction: column; gap: 5px; } |
| 415 | .vault-access-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } |
| 416 | .vault-access-who { color: var(--ink); font-weight: 600; } |
| 417 | .vault-history { border-top: 1px solid var(--border); padding-top: 8px; } |
| 418 | .vault-history-toggle, .vault-access-toggle { |
| 419 | background: none; border: none; padding: 4px 2px; font: inherit; font-size: 13px; font-weight: 600; |
| 420 | color: var(--muted); cursor: var(--ui-cursor, pointer); |
| 421 | } |
| 422 | .vault-history-toggle:hover, .vault-access-toggle:hover { color: var(--ink); } |
| 423 | .vault-log { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } |
| 424 | .vault-log li { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } |
| 425 | .vault-log .vault-log-who { color: var(--ink); font-weight: 600; } |
| 426 | `, |
| 427 | |
| 428 | // Full-pane render (like To-do): an add form, the secrets, then a collapsible |
| 429 | // history. Rebuilt from state on every change (not incremental) — the proven |
| 430 | // pattern for a groupCapable app, since a group's Interface pane is one shared |
| 431 | // element and other members' edits refold here. Handlers close over `ctx` (fresh |
| 432 | // each render, bound to whichever thread — your personal one or the group's), so |
| 433 | // an emit always lands in the right log. Reveal state lives only on the row DOM, |
| 434 | // so a concurrent edit that rebuilds the list re-locks every value — a feature. |
| 435 | render(state, el, ctx) { |
| 436 | const items = (state && state.items) || []; |
| 437 | const log = (state && state.log) || []; |
| 438 | const keyring = (state && state.keyring) || null; |
| 439 | |
| 440 | // A render that observes the vault becoming locked (this device locked it, |
| 441 | // or a remote lock just folded) revokes any reveal still open from before — |
| 442 | // the belt to doLock's braces, and the whole story for a remote lock. |
| 443 | if (keyring && !lockedSeen[ctx.threadId]) relockOpenReveals(); |
| 444 | lockedSeen[ctx.threadId] = !!keyring; |
| 445 | |
| 446 | const wrap = document.createElement("div"); |
| 447 | wrap.className = "vault-wrap"; |
| 448 | |
| 449 | // One inline status line for the whole surface — the lock/unlock/enrol |
| 450 | // ceremonies report here. In-flow (occupies layout space), never a |
| 451 | // floating toast; role=status so screen readers announce it. |
| 452 | const note = document.createElement("div"); |
| 453 | note.className = "vault-note"; |
| 454 | note.setAttribute("role", "status"); |
| 455 | const say = (msg) => { |
| 456 | note.textContent = msg || ""; |
| 457 | }; |
| 458 | |
| 459 | const head = document.createElement("div"); |
| 460 | head.className = "vault-head"; |
| 461 | const headLeft = document.createElement("div"); |
| 462 | headLeft.className = "vault-head-left"; |
| 463 | const title = document.createElement("span"); |
| 464 | title.className = "vault-title"; |
| 465 | title.textContent = "🔒 Vault"; |
| 466 | const count = document.createElement("span"); |
| 467 | count.className = "vault-count"; |
| 468 | count.textContent = items.length ? (items.length === 1 ? "1 secret" : `${items.length.toLocaleString()} secrets`) : ""; |
| 469 | headLeft.append(title, count); |
| 470 | head.append(headLeft, buildLockChip(keyring, ctx, say)); |
| 471 | wrap.appendChild(head); |
| 472 | wrap.appendChild(note); |
| 473 | |
| 474 | // Add form — emits directly, so the secret never rides a chat composer. |
| 475 | // On a locked vault the value seals to the vault public key before it |
| 476 | // leaves the handler — adding never costs an unlock. |
| 477 | const form = document.createElement("form"); |
| 478 | form.className = "vault-add"; |
| 479 | // Keep password managers OUT of the add form. A "Name" text field beside a |
| 480 | // type=password field reads to 1Password/Chrome/etc. as a username+password |
| 481 | // LOGIN, so they offer to "save" the secret you're storing — clutter, and |
| 482 | // (Paul's report) their save popup reads like a passkey prompt. These are the |
| 483 | // managers' documented opt-outs, plus declaring the whole form non-login so no |
| 484 | // credential pair is inferred. A vault secret is content, never a saved login. |
| 485 | const noPwManager = (el) => { |
| 486 | el.autocomplete = "off"; |
| 487 | el.setAttribute("data-1p-ignore", ""); // 1Password |
| 488 | el.setAttribute("data-lpignore", "true"); // LastPass |
| 489 | el.setAttribute("data-bwignore", ""); // Bitwarden |
| 490 | el.setAttribute("data-form-type", "other"); // Dashlane + a 1Password hint |
| 491 | }; |
| 492 | form.setAttribute("data-form-type", "other"); |
| 493 | form.autocomplete = "off"; |
| 494 | const nameIn = document.createElement("input"); |
| 495 | nameIn.className = "vault-name"; |
| 496 | nameIn.placeholder = "Name"; |
| 497 | nameIn.maxLength = 80; |
| 498 | noPwManager(nameIn); |
| 499 | const valIn = document.createElement("input"); |
| 500 | valIn.className = "vault-val"; |
| 501 | valIn.type = "password"; // masked while typing — this is a secret going in |
| 502 | valIn.placeholder = "Secret value"; |
| 503 | noPwManager(valIn); |
| 504 | const add = document.createElement("button"); |
| 505 | add.type = "submit"; |
| 506 | add.className = "vault-add-btn"; |
| 507 | add.textContent = "Add"; |
| 508 | form.append(nameIn, valIn, add); |
| 509 | form.onsubmit = async (ev) => { |
| 510 | ev.preventDefault(); |
| 511 | const name = nameIn.value.trim(); |
| 512 | const value = valIn.value; // don't trim a value — leading/trailing chars can matter |
| 513 | if (!name || !value) return; |
| 514 | await addOrUpdate(ctx, name, value); |
| 515 | nameIn.value = ""; |
| 516 | valIn.value = ""; |
| 517 | nameIn.focus(); |
| 518 | }; |
| 519 | wrap.appendChild(form); |
| 520 | |
| 521 | if (!items.length) { |
| 522 | const empty = document.createElement("div"); |
| 523 | empty.className = "vault-empty"; |
| 524 | empty.textContent = "No secrets yet — add one above."; |
| 525 | wrap.appendChild(empty); |
| 526 | } else { |
| 527 | const list = document.createElement("ul"); |
| 528 | list.className = "vault-list"; |
| 529 | for (const item of items) list.appendChild(buildRow(item, ctx, keyring, say)); |
| 530 | wrap.appendChild(list); |
| 531 | } |
| 532 | |
| 533 | if (keyring) { |
| 534 | const access = buildAccess(keyring, ctx, say); |
| 535 | if (access) wrap.appendChild(access); // null for a personal vault (no sharing surface) |
| 536 | } |
| 537 | if (log.length) wrap.appendChild(buildHistory(log, ctx)); |
| 538 | |
| 539 | el.appendChild(wrap); |
| 540 | }, |
| 541 | }); |
| 542 | |
| 543 | // ---- 🤖 unlock state (memory only, never persisted) --------------------------- |
| 544 | |
| 545 | // threadId -> { priv (pkcs8 b64url), timer }. Cleared by the timer, by "lock |
| 546 | // now", and never written anywhere — a reload always starts locked. |
| 547 | const unlockedVaults = {}; |
| 548 | |
| 549 | function cacheUnlock(threadId, priv) { |
| 550 | forgetUnlock(threadId); |
| 551 | // When the unlock window lapses, the key leaves memory AND any value still |
| 552 | // on screen must re-mask — same invariant as an explicit lock. |
| 553 | unlockedVaults[threadId] = { |
| 554 | priv, |
| 555 | timer: setTimeout(() => lockNow(threadId), UNLOCK_TTL_MS), |
| 556 | }; |
| 557 | } |
| 558 | |
| 559 | // forgetUnlock drops the in-memory key only — the pure reset cacheUnlock reuses |
| 560 | // before caching a new one (where re-masking reveals would be wrong). |
| 561 | export function forgetUnlock(threadId) { |
| 562 | const u = unlockedVaults[threadId]; |
| 563 | if (u) { |
| 564 | clearTimeout(u.timer); |
| 565 | delete unlockedVaults[threadId]; |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | // lockNow is the user-facing "the vault is locking again" action: forget the |
| 570 | // key AND revoke every open reveal, so plaintext can never outlive the unlock. |
| 571 | // Used by the header "lock now" toggle and the auto-expiry timer; the initial |
| 572 | // lock ceremony (doLock) and a remote lock (render) revoke reveals directly. |
| 573 | function lockNow(threadId) { |
| 574 | forgetUnlock(threadId); |
| 575 | relockOpenReveals(); |
| 576 | } |
| 577 | |
| 578 | function unlockedPriv(threadId) { |
| 579 | return unlockedVaults[threadId] ? unlockedVaults[threadId].priv : null; |
| 580 | } |
| 581 | |
| 582 | // 🤖 A row showing plaintext registers a "slam shut" callback here while it's |
| 583 | // revealed. Locking a vault — from this device, another device, or another |
| 584 | // member — must revoke every open reveal IMMEDIATELY and deterministically: a |
| 585 | // value that was on screen the instant you locked is live secret material, and |
| 586 | // it can't be left to linger until an incidental re-render or a manual Hide. |
| 587 | // Module scope so it survives the per-render rebuild. |
| 588 | const openReveals = new Set(); |
| 589 | |
| 590 | function relockOpenReveals() { |
| 591 | for (const close of [...openReveals]) close(); // each close() removes itself |
| 592 | openReveals.clear(); |
| 593 | } |
| 594 | |
| 595 | // lockedSeen tracks, per thread, whether the last render saw a keyring — so a |
| 596 | // render that observes the unlocked→locked transition (a remote lock) can slam |
| 597 | // open reveals shut, the same as the local doLock path does instantly. |
| 598 | const lockedSeen = {}; |
| 599 | |
| 600 | // ensureUnlocked returns the vault private key, running one passkey assertion |
| 601 | // (one biometric) when it isn't already in memory. Returns null after |
| 602 | // reporting the reason via say(). |
| 603 | // passkeyError turns a WebAuthn/ceremony failure into honest inline copy — and |
| 604 | // console.errors anything that isn't the user simply backing out, so a real |
| 605 | // misconfiguration never reads as "nothing happened". The commonest dev |
| 606 | // gotcha: WebAuthn refuses an IP-address RP id, so the 127.0.0.1 dev server |
| 607 | // throws SecurityError before any OS sheet — open it over http://localhost |
| 608 | // instead. `action` is the verb for the plain-cancel case ("Lock", "Unlock"). |
| 609 | function passkeyError(e, action) { |
| 610 | const name = (e && e.name) || ""; |
| 611 | const msg = (e && e.message) || ""; |
| 612 | if (msg === "no-prf") return "This passkey manager can't protect a vault (no PRF support) — try the browser's own passkeys."; |
| 613 | if (name === "NotAllowedError") return `${action} cancelled.`; // dismissed or timed out |
| 614 | console.error("vault: passkey ceremony failed", e); |
| 615 | 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.`; |
| 616 | return `Couldn't reach a passkey here: ${name || msg || "unknown error"} (details in the console).`; |
| 617 | } |
| 618 | |
| 619 | async function ensureUnlocked(ctx, keyring, say) { |
| 620 | const cached = unlockedPriv(ctx.threadId); |
| 621 | if (cached) return cached; |
| 622 | const passkeys = (keyring.wraps || []).filter((w) => w.kind === "passkey" && w.credId); |
| 623 | if (!passkeys.length) { |
| 624 | say("Only CLI keys are enrolled in this vault — unlock isn't possible in the browser yet. Set up your passkey below."); |
| 625 | return null; |
| 626 | } |
| 627 | let res; |
| 628 | try { |
| 629 | // All enrolled credIds go in the allowlist — the authenticator answers with |
| 630 | // whichever it holds, so "which wrap is mine" never needs guessing. |
| 631 | res = await assertVaultPasskeyFull(passkeys.map((w) => w.credId)); |
| 632 | } catch (e) { |
| 633 | say(passkeyError(e, "Unlock")); |
| 634 | return null; |
| 635 | } |
| 636 | const wrap = passkeys.find((w) => w.credId === res.credId); |
| 637 | if (!wrap) { |
| 638 | say("That passkey isn't enrolled in this vault — set up your passkey below."); |
| 639 | return null; |
| 640 | } |
| 641 | try { |
| 642 | const kek = await kekFromPRF(res.prf); |
| 643 | const priv = await unwrapVaultPriv(wrap.wrapped, kek); |
| 644 | cacheUnlock(ctx.threadId, priv); |
| 645 | return priv; |
| 646 | } catch { |
| 647 | say("Couldn't open the vault with that passkey — its enrolment may be stale. Set up your passkey below."); |
| 648 | return null; |
| 649 | } |
| 650 | } |
| 651 | |
| 652 | // ---- write paths ---------------------------------------------------------------- |
| 653 | |
| 654 | // addOrUpdate is the one write path the command and the in-form Add share: |
| 655 | // overwrite the secret if its name already exists (case-insensitive), else add a |
| 656 | // new one. Each event carries a fresh audit id (aid) so the log records it. On a |
| 657 | // locked vault the value seals to the vault public key first — no unlock needed. |
| 658 | async function addOrUpdate(ctx, name, value) { |
| 659 | const state = ctx.getState() || {}; |
| 660 | const existing = findByName(state.items, name); |
| 661 | const body = state.keyring ? { sealed: await sealValue(state.keyring.pub, value) } : { value }; |
| 662 | // The secret's name travels as `label` (see the reducer note on why not `name`). |
| 663 | if (existing) ctx.emit({ type: "vault.change", id: existing.id, aid: ctx.id(), label: name, ...body }); |
| 664 | else ctx.emit({ type: "vault.add", id: ctx.id(), aid: ctx.id(), label: name, ...body }); |
| 665 | } |
| 666 | |
| 667 | // doLock runs the whole lock ceremony: mint a passkey (verifying PRF end-to-end |
| 668 | // BEFORE anything posts — the no-prf lesson from home), mint the vault keypair, |
| 669 | // re-seal every existing value, and HAND the vault key to every other current |
| 670 | // member (sealed to their roster key — a `member` handoff), so each can set up |
| 671 | // their own passkey without anyone approving. One vault.lock carries it all. The |
| 672 | // locker ends up unlocked, so their next reveal is free. |
| 673 | async function doLock(ctx, say) { |
| 674 | let pk; |
| 675 | try { |
| 676 | pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`); |
| 677 | } catch (e) { |
| 678 | say(passkeyError(e, "Lock")); |
| 679 | return; |
| 680 | } |
| 681 | const kek = await kekFromPRF(pk.prf); |
| 682 | const kp = await generateVaultKeypair(); |
| 683 | const state = ctx.getState() || {}; |
| 684 | const sealed = {}; |
| 685 | for (const it of state.items || []) { |
| 686 | if (typeof it.value === "string") sealed[it.id] = await sealValue(kp.publicKey, it.value); |
| 687 | } |
| 688 | // A handoff per other member: the vault key sealed to their roster key. The |
| 689 | // host excludes me and bots; a personal vault has no other members → none. |
| 690 | const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(ctx.threadId) : []; |
| 691 | const handoffs = []; |
| 692 | for (const m of members) { |
| 693 | handoffs.push({ id: ctx.id(), kind: "member", uid: m.uid, label: m.name || "", wrapped: await sealVaultKeyTo(m.pub, kp.privateKey) }); |
| 694 | } |
| 695 | ctx.emit({ |
| 696 | type: "vault.lock", |
| 697 | aid: ctx.id(), |
| 698 | pub: kp.publicKey, |
| 699 | wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(kp.privateKey, kek) }, |
| 700 | handoffs, |
| 701 | sealed, |
| 702 | }); |
| 703 | // Slam every on-screen reveal shut the instant we lock — don't wait for the |
| 704 | // event to echo back and re-render (a value shown at lock time is live secret |
| 705 | // material). cacheUnlock keeps the locker able to re-reveal with no biometric. |
| 706 | relockOpenReveals(); |
| 707 | cacheUnlock(ctx.threadId, kp.privateKey); |
| 708 | say( |
| 709 | handoffs.length |
| 710 | ? `Vault locked — values now need a passkey to read. ${handoffs.length === 1 ? "1 member" : handoffs.length.toLocaleString() + " members"} can set up their own passkey.` |
| 711 | : "Vault locked — values now need a passkey to read." |
| 712 | ); |
| 713 | } |
| 714 | |
| 715 | // doEnrolMe: a member who holds a `member` handoff (the vault key sealed to them |
| 716 | // at lock, or by a give) turns it into a durable passkey wrap. Open the handoff |
| 717 | // with my session key (host.openMine), verify it actually opens the vault, mint |
| 718 | // a passkey, wrap the vault key under it, emit vault.enroll — which DROPS my |
| 719 | // handoff, so from here on I need my passkey. No approval: I already hold the |
| 720 | // key by membership. |
| 721 | async function doEnrolMe(ctx, keyring, say) { |
| 722 | const mine = (keyring.wraps || []).find((w) => w.kind === "member" && w.uid === ctx.me); |
| 723 | if (!mine) { |
| 724 | say("You don't have a key for this vault yet — ask someone already in to give you access."); |
| 725 | return; |
| 726 | } |
| 727 | if (!ctx.host || !ctx.host.vault) { |
| 728 | say("Setting up a vault passkey isn't supported here yet."); |
| 729 | return; |
| 730 | } |
| 731 | const opened = await ctx.host.vault.openMine(mine.wrapped); |
| 732 | const vaultPriv = opened && typeof opened.k === "string" ? opened.k : null; |
| 733 | if (!vaultPriv) { |
| 734 | say("Couldn't open your vault key — ask someone in the vault to give you access again."); |
| 735 | return; |
| 736 | } |
| 737 | // Integrity: the handoff key must actually open this vault. If any value is |
| 738 | // sealed, it has to decrypt — a mismatched or hostile handoff fails HERE, |
| 739 | // before it gets wrapped under a passkey. An empty vault has nothing to check. |
| 740 | const sample = ((ctx.getState() || {}).items || []).find((it) => typeof it.sealed === "string" && it.sealed); |
| 741 | if (sample) { |
| 742 | try { |
| 743 | await unsealValue(sample.sealed, vaultPriv); |
| 744 | } catch { |
| 745 | say("The key you were given doesn't match this vault — ask for access again."); |
| 746 | return; |
| 747 | } |
| 748 | } |
| 749 | let pk; |
| 750 | try { |
| 751 | pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`); |
| 752 | } catch (e) { |
| 753 | say(passkeyError(e, "Set up")); |
| 754 | return; |
| 755 | } |
| 756 | const kek = await kekFromPRF(pk.prf); |
| 757 | ctx.emit({ |
| 758 | type: "vault.enroll", |
| 759 | aid: ctx.id(), |
| 760 | wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(vaultPriv, kek) }, |
| 761 | }); |
| 762 | cacheUnlock(ctx.threadId, vaultPriv); |
| 763 | say("You're set — this vault now opens with your passkey."); |
| 764 | } |
| 765 | |
| 766 | // doGive: an unlocked member admits someone in the thread who has no key yet — |
| 767 | // seal the vault key to that member's roster key as a `member` handoff, which |
| 768 | // they self-enrol from. One biometric (the unlock). The deliberate hand-off, and |
| 769 | // it's an insider GIVING, never an outsider requesting. |
| 770 | async function doGive(ctx, keyring, member, say) { |
| 771 | const priv = await ensureUnlocked(ctx, keyring, say); |
| 772 | if (!priv) return; |
| 773 | if (!ctx.host || !ctx.host.vault) { |
| 774 | say("Giving access isn't supported here yet."); |
| 775 | return; |
| 776 | } |
| 777 | ctx.emit({ |
| 778 | type: "vault.give", |
| 779 | aid: ctx.id(), |
| 780 | wrap: { id: ctx.id(), kind: "member", uid: member.uid, label: member.name || "", wrapped: await sealVaultKeyTo(member.pub, priv) }, |
| 781 | }); |
| 782 | say(`${member.name || "They"} can now set up their passkey.`); |
| 783 | } |
| 784 | |
| 785 | // doEnrollHere: an already-enrolled member adds another passkey (a new browser |
| 786 | // or device whose passkeys don't sync) — unlock, mint, wrap, enroll. |
| 787 | async function doEnrollHere(ctx, keyring, say) { |
| 788 | const priv = await ensureUnlocked(ctx, keyring, say); |
| 789 | if (!priv) return; |
| 790 | let pk; |
| 791 | try { |
| 792 | pk = await registerVaultPasskey(`Vault — ${ctx.meName || "me"}`); |
| 793 | } catch (e) { |
| 794 | say(passkeyError(e, "Enrol")); |
| 795 | return; |
| 796 | } |
| 797 | const kek = await kekFromPRF(pk.prf); |
| 798 | ctx.emit({ |
| 799 | type: "vault.enroll", |
| 800 | aid: ctx.id(), |
| 801 | wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(priv, kek) }, |
| 802 | }); |
| 803 | say("Passkey added — this device can now unlock the vault."); |
| 804 | } |
| 805 | |
| 806 | // logEntry appends one audit record, deduped by its audit id (addItem) so a |
| 807 | // re-fold of the window never double-logs. The actor is the transport-injected |
| 808 | // `e.uid` / `e.name`; `at` is their send time (e.ts). |
| 809 | function logEntry(log, e, action, name) { |
| 810 | return addItem(log || [], { id: e.aid, ref: e.id || "", action, name, by: e.uid || "", byName: e.name || "", at: e.ts || 0 }); |
| 811 | } |
| 812 | |
| 813 | // ---- surface pieces --------------------------------------------------------------- |
| 814 | |
| 815 | // 🤖 buildLockChip is the header's one lock affordance: plain vault → offer the |
| 816 | // lock ceremony (two-step arm — it's a one-way door); locked → Unlock, or |
| 817 | // "lock now" to drop the in-memory key early. |
| 818 | function buildLockChip(keyring, ctx, say) { |
| 819 | const chip = document.createElement("button"); |
| 820 | chip.type = "button"; |
| 821 | chip.className = "vault-lockchip"; |
| 822 | if (!keyring) { |
| 823 | chip.textContent = "Lock vault…"; |
| 824 | chip.setAttribute("aria-label", "Lock this vault with a passkey"); |
| 825 | chip.onclick = () => { |
| 826 | if (!chip.dataset.armed) { |
| 827 | chip.dataset.armed = "1"; |
| 828 | chip.textContent = "Lock with a passkey?"; |
| 829 | 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."); |
| 830 | setTimeout(() => { |
| 831 | delete chip.dataset.armed; |
| 832 | if (chip.isConnected) { |
| 833 | chip.textContent = "Lock vault…"; |
| 834 | say(""); |
| 835 | } |
| 836 | }, 8000); |
| 837 | return; |
| 838 | } |
| 839 | delete chip.dataset.armed; |
| 840 | doLock(ctx, say); |
| 841 | }; |
| 842 | return chip; |
| 843 | } |
| 844 | const paint = () => { |
| 845 | const open = !!unlockedPriv(ctx.threadId); |
| 846 | chip.textContent = open ? "🔓 Unlocked · lock now" : "🔐 Unlock"; |
| 847 | chip.setAttribute("aria-label", open ? "Vault is unlocked on this device — click to lock now" : "Unlock this vault with your passkey"); |
| 848 | }; |
| 849 | chip.onclick = async () => { |
| 850 | if (unlockedPriv(ctx.threadId)) { |
| 851 | lockNow(ctx.threadId); // drop the key AND re-mask anything still revealed |
| 852 | say("Locked — the key is out of memory."); |
| 853 | } else if (await ensureUnlocked(ctx, keyring, say)) { |
| 854 | say("Unlocked on this device for a few minutes."); |
| 855 | } |
| 856 | paint(); |
| 857 | }; |
| 858 | paint(); |
| 859 | return chip; |
| 860 | } |
| 861 | |
| 862 | // buildRow renders one secret: the name, its masked value, and Reveal / Copy / |
| 863 | // Remove actions, plus a small "added / updated by" audit line. The value starts |
| 864 | // LOCKED; clicking it (or Reveal) swaps in the plaintext and arms a re-lock timer. |
| 865 | // A sealed item resolves its plaintext through the vault key (at most one |
| 866 | // biometric); a plaintext item in a LOCKED vault is flagged and gets a one-click |
| 867 | // Seal — sealing needs no unlock, so anyone can do the tidying. |
| 868 | function buildRow(item, ctx, keyring, say) { |
| 869 | const li = document.createElement("li"); |
| 870 | li.className = "vault-item"; |
| 871 | |
| 872 | const row = document.createElement("div"); |
| 873 | row.className = "vault-row"; |
| 874 | |
| 875 | const name = document.createElement("span"); |
| 876 | name.className = "vault-name-label"; |
| 877 | name.textContent = item.name; // textContent — never innerHTML for user content |
| 878 | |
| 879 | // The value is a button so it's keyboard-focusable and reads as tappable — the |
| 880 | // literal "click to reveal" affordance. A small lock/unlock glyph leads it. |
| 881 | const val = document.createElement("button"); |
| 882 | val.type = "button"; |
| 883 | val.className = "vault-value"; |
| 884 | const lock = document.createElement("span"); |
| 885 | lock.className = "vault-lock"; |
| 886 | lock.setAttribute("aria-hidden", "true"); |
| 887 | const valText = document.createElement("span"); |
| 888 | valText.className = "vault-value-text"; |
| 889 | val.append(lock, valText); |
| 890 | |
| 891 | let revealed = false; |
| 892 | let shown = ""; // the plaintext while revealed — sealed items decrypt on demand |
| 893 | let relock = null; |
| 894 | const reveal = document.createElement("button"); // declared here so paint() can label it |
| 895 | // closeReveal masks this row now; it's what a lock (or the re-lock timer) |
| 896 | // calls. Registered in openReveals only while the row is actually showing. |
| 897 | const closeReveal = () => { |
| 898 | if (revealed) { |
| 899 | revealed = false; |
| 900 | shown = ""; |
| 901 | paint(); |
| 902 | } |
| 903 | }; |
| 904 | const paint = () => { |
| 905 | if (relock) { |
| 906 | clearTimeout(relock); |
| 907 | relock = null; |
| 908 | } |
| 909 | lock.textContent = revealed ? "🔓" : "🔒"; |
| 910 | valText.textContent = revealed ? shown : MASK; |
| 911 | val.classList.toggle("revealed", revealed); |
| 912 | val.setAttribute("aria-label", revealed ? `${item.name}: shown, click to hide` : `${item.name}: hidden, click to reveal`); |
| 913 | val.title = revealed ? "Click to hide" : "Click to reveal"; |
| 914 | reveal.textContent = revealed ? "Hide" : "Reveal"; |
| 915 | // Track the live reveal so a lock can revoke it, and re-lock after a beat so |
| 916 | // a revealed secret doesn't sit on screen indefinitely. |
| 917 | if (revealed) { |
| 918 | openReveals.add(closeReveal); |
| 919 | relock = setTimeout(closeReveal, RELOCK_MS); |
| 920 | } else { |
| 921 | openReveals.delete(closeReveal); |
| 922 | } |
| 923 | }; |
| 924 | |
| 925 | // plaintextOf resolves this item's value — directly for a plain item, through |
| 926 | // the vault key for a sealed one. null = couldn't (reason already said). |
| 927 | const plaintextOf = async () => { |
| 928 | if (typeof item.value === "string") return item.value; |
| 929 | if (!keyring) { |
| 930 | say("This item is sealed but the vault has no keyring — it can't be read."); |
| 931 | return null; |
| 932 | } |
| 933 | const priv = await ensureUnlocked(ctx, keyring, say); |
| 934 | if (!priv) return null; |
| 935 | try { |
| 936 | return await unsealValue(item.sealed, priv); |
| 937 | } catch { |
| 938 | say(`“${item.name}” won't open with this vault's key — it may have been sealed to an older one.`); |
| 939 | return null; |
| 940 | } |
| 941 | }; |
| 942 | |
| 943 | const toggleReveal = async () => { |
| 944 | if (revealed) { |
| 945 | revealed = false; |
| 946 | shown = ""; |
| 947 | paint(); |
| 948 | return; |
| 949 | } |
| 950 | const v = await plaintextOf(); |
| 951 | if (v == null) return; |
| 952 | shown = v; |
| 953 | revealed = true; |
| 954 | paint(); |
| 955 | }; |
| 956 | val.onclick = toggleReveal; |
| 957 | |
| 958 | const actions = document.createElement("div"); |
| 959 | actions.className = "vault-actions"; |
| 960 | reveal.type = "button"; |
| 961 | reveal.className = "vault-btn"; |
| 962 | reveal.onclick = toggleReveal; |
| 963 | const copy = document.createElement("button"); |
| 964 | copy.type = "button"; |
| 965 | copy.className = "vault-btn"; |
| 966 | copy.textContent = "Copy"; |
| 967 | copy.onclick = async () => { |
| 968 | const v = await plaintextOf(); |
| 969 | if (v == null) return; |
| 970 | try { |
| 971 | await navigator.clipboard.writeText(v); |
| 972 | copy.textContent = "Copied"; |
| 973 | setTimeout(() => (copy.textContent = "Copy"), 1200); |
| 974 | } catch { |
| 975 | // Clipboard blocked (no permission / insecure context) — reveal instead so |
| 976 | // the user can select the value by hand. |
| 977 | shown = v; |
| 978 | revealed = true; |
| 979 | paint(); |
| 980 | } |
| 981 | }; |
| 982 | const remove = document.createElement("button"); |
| 983 | remove.type = "button"; |
| 984 | remove.className = "vault-btn vault-btn--danger"; |
| 985 | remove.textContent = "Remove"; |
| 986 | remove.onclick = () => { |
| 987 | // Two-step inline confirm (embedded webviews have no confirm() panel): the |
| 988 | // first click arms it, a second within a few seconds removes. |
| 989 | if (!remove.dataset.armed) { |
| 990 | remove.dataset.armed = "1"; |
| 991 | remove.textContent = "Remove?"; |
| 992 | setTimeout(() => { |
| 993 | delete remove.dataset.armed; |
| 994 | if (remove.isConnected) remove.textContent = "Remove"; |
| 995 | }, 4000); |
| 996 | return; |
| 997 | } |
| 998 | ctx.emit({ type: "vault.remove", id: item.id, aid: ctx.id() }); |
| 999 | }; |
| 1000 | actions.append(reveal, copy); |
| 1001 | if (keyring && typeof item.value === "string") { |
| 1002 | const seal = document.createElement("button"); |
| 1003 | seal.type = "button"; |
| 1004 | seal.className = "vault-btn"; |
| 1005 | seal.textContent = "Seal"; |
| 1006 | seal.setAttribute("aria-label", `Seal ${item.name} with the vault key`); |
| 1007 | seal.onclick = async () => { |
| 1008 | ctx.emit({ type: "vault.change", id: item.id, aid: ctx.id(), sealed: await sealValue(keyring.pub, item.value) }); |
| 1009 | }; |
| 1010 | actions.append(seal); |
| 1011 | } |
| 1012 | actions.append(remove); |
| 1013 | paint(); // start locked, with the lock glyph + labels set |
| 1014 | |
| 1015 | row.append(name, val, actions); |
| 1016 | li.appendChild(row); |
| 1017 | |
| 1018 | const meta = document.createElement("div"); |
| 1019 | meta.className = "vault-meta"; |
| 1020 | meta.textContent = auditLine(item); |
| 1021 | if (keyring && typeof item.value === "string") { |
| 1022 | const flag = document.createElement("span"); |
| 1023 | flag.className = "vault-unsealed"; |
| 1024 | flag.textContent = " · not sealed yet"; |
| 1025 | meta.appendChild(flag); |
| 1026 | } |
| 1027 | li.appendChild(meta); |
| 1028 | |
| 1029 | // Clear the re-lock timer and drop this row's reveal callback when the surface |
| 1030 | // is torn down while a value is showing (a redraw or thread switch) so neither |
| 1031 | // fires against a detached node. |
| 1032 | ctx.onCleanup(() => { |
| 1033 | if (relock) clearTimeout(relock); |
| 1034 | openReveals.delete(closeReveal); |
| 1035 | }); |
| 1036 | |
| 1037 | return li; |
| 1038 | } |
| 1039 | |
| 1040 | // auditLine is the per-item "who put this here" summary: who added it, and — if |
| 1041 | // it's since been edited — who last changed it. |
| 1042 | function auditLine(item) { |
| 1043 | const who = item.byName || "someone"; |
| 1044 | let line = `Added by ${who}${item.at ? " · " + shortWhen(item.at) : ""}`; |
| 1045 | if (item.updatedAt) line += ` · updated by ${item.updatedByName || "someone"} ${shortWhen(item.updatedAt)}`; |
| 1046 | return line; |
| 1047 | } |
| 1048 | |
| 1049 | // The access/history sections' expand state, per thread (module scope so it |
| 1050 | // survives the rebuild-on-every-change render — transient UI, not fold state). |
| 1051 | const historyOpen = {}; |
| 1052 | const accessOpen = {}; |
| 1053 | |
| 1054 | // 🤖 buildAccess renders a SHARED locked vault's access: who can open it (the |
| 1055 | // durable wraps + any pending handoffs), the one action that's mine ("Set up |
| 1056 | // your passkey" if I hold a handoff, else "Add a passkey on this device"), and |
| 1057 | // "Give <name> access" for each thread member who has no key yet. There is NO |
| 1058 | // "request access" — membership is the entitlement. A PERSONAL vault (no other |
| 1059 | // members) has no sharing surface at all, so this returns null there and the |
| 1060 | // header lock chip is the whole story. Collapsed by default; auto-opens while I |
| 1061 | // have a handoff to set up, or someone's waiting to be given access. |
| 1062 | function buildAccess(keyring, ctx, say) { |
| 1063 | const tid = ctx.threadId; |
| 1064 | const wraps = keyring.wraps || []; |
| 1065 | const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(tid) : []; |
| 1066 | |
| 1067 | // Who I am to this vault: a durable holder, a pending handoff, or neither. |
| 1068 | const iAmEnrolled = wraps.some((w) => (w.kind === "passkey" || w.kind === "keychain") && w.uid === ctx.me); |
| 1069 | const myHandoff = wraps.find((w) => w.kind === "member" && w.uid === ctx.me); |
| 1070 | // Thread members with no key of any kind — an insider can give them one. |
| 1071 | const held = new Set(wraps.map((w) => w.uid).filter(Boolean)); |
| 1072 | const missing = members.filter((m) => !held.has(m.uid)); |
| 1073 | |
| 1074 | // Personal vault (no other members) with nothing of mine to set up → no |
| 1075 | // sharing surface. The lock chip already handles lock/unlock; a lone vault |
| 1076 | // never shows "access controls". |
| 1077 | if (!members.length && !myHandoff && iAmEnrolled) return null; |
| 1078 | |
| 1079 | const wrapEl = document.createElement("div"); |
| 1080 | wrapEl.className = "vault-access"; |
| 1081 | const open = accessOpen[tid] !== undefined ? !!accessOpen[tid] : (!!myHandoff || missing.length > 0); |
| 1082 | |
| 1083 | const toggle = document.createElement("button"); |
| 1084 | toggle.type = "button"; |
| 1085 | toggle.className = "vault-access-toggle"; |
| 1086 | const keyCount = wraps.length === 1 ? "1 key" : `${wraps.length.toLocaleString()} keys`; |
| 1087 | toggle.textContent = `${open ? "▾" : "▸"} Access (${keyCount})`; |
| 1088 | toggle.setAttribute("aria-expanded", open ? "true" : "false"); |
| 1089 | toggle.onclick = () => { |
| 1090 | accessOpen[tid] = !open; |
| 1091 | const state = ctx.getState() || {}; |
| 1092 | const next = buildAccess(state.keyring || keyring, ctx, say); |
| 1093 | if (next) wrapEl.replaceWith(next); |
| 1094 | }; |
| 1095 | wrapEl.appendChild(toggle); |
| 1096 | |
| 1097 | if (!open) return wrapEl; |
| 1098 | |
| 1099 | const list = document.createElement("ul"); |
| 1100 | list.className = "vault-access-list"; |
| 1101 | for (const w of wraps) { |
| 1102 | const li = document.createElement("li"); |
| 1103 | const who = document.createElement("span"); |
| 1104 | who.className = "vault-access-who"; |
| 1105 | who.textContent = w.label || "someone"; |
| 1106 | // A `member` wrap is a handoff not yet turned into a passkey — an invite in |
| 1107 | // flight, not a key that can open the vault. |
| 1108 | const kindLabel = |
| 1109 | w.kind === "keychain" |
| 1110 | ? " · CLI key" |
| 1111 | : w.kind === "member" |
| 1112 | ? " · invited — waiting to set up a passkey" |
| 1113 | : " · passkey"; |
| 1114 | li.append(who, document.createTextNode(kindLabel)); |
| 1115 | // Revoke drops a wrap (housekeeping, not crypto revocation). Never the last |
| 1116 | // — so we never offer a Revoke that would silently no-op. |
| 1117 | if (wraps.length > 1) { |
| 1118 | const revoke = document.createElement("button"); |
| 1119 | revoke.type = "button"; |
| 1120 | revoke.className = "vault-btn vault-btn--danger"; |
| 1121 | revoke.textContent = "Revoke"; |
| 1122 | revoke.setAttribute("aria-label", `Revoke the vault key for ${w.label || "this member"}`); |
| 1123 | revoke.onclick = () => { |
| 1124 | if (!revoke.dataset.armed) { |
| 1125 | revoke.dataset.armed = "1"; |
| 1126 | revoke.textContent = "Revoke?"; |
| 1127 | setTimeout(() => { |
| 1128 | delete revoke.dataset.armed; |
| 1129 | if (revoke.isConnected) revoke.textContent = "Revoke"; |
| 1130 | }, 4000); |
| 1131 | return; |
| 1132 | } |
| 1133 | ctx.emit({ type: "vault.revoke", aid: ctx.id(), wrapId: w.id }); |
| 1134 | }; |
| 1135 | li.appendChild(revoke); |
| 1136 | } |
| 1137 | list.appendChild(li); |
| 1138 | } |
| 1139 | wrapEl.appendChild(list); |
| 1140 | |
| 1141 | // My action: set up my handoff into a passkey, or add another device's |
| 1142 | // passkey once I'm already in. (Only one applies at a time.) |
| 1143 | if (myHandoff && !iAmEnrolled) { |
| 1144 | const setup = document.createElement("button"); |
| 1145 | setup.type = "button"; |
| 1146 | setup.className = "vault-btn"; |
| 1147 | setup.textContent = "Set up your passkey"; |
| 1148 | setup.setAttribute("aria-label", "Set up your passkey to open this vault"); |
| 1149 | setup.onclick = () => doEnrolMe(ctx, keyring, say); |
| 1150 | wrapEl.appendChild(setup); |
| 1151 | } else if (iAmEnrolled) { |
| 1152 | const enroll = document.createElement("button"); |
| 1153 | enroll.type = "button"; |
| 1154 | enroll.className = "vault-btn"; |
| 1155 | enroll.textContent = "Add a passkey on this device"; |
| 1156 | enroll.onclick = () => doEnrollHere(ctx, keyring, say); |
| 1157 | wrapEl.appendChild(enroll); |
| 1158 | } else { |
| 1159 | // A member with no key and no handoff yet — the honest state is "wait to be |
| 1160 | // given access", never a request button. |
| 1161 | const note = document.createElement("div"); |
| 1162 | note.className = "vault-note"; |
| 1163 | note.textContent = "You don't have a key yet — ask someone already in the vault to give you access."; |
| 1164 | wrapEl.appendChild(note); |
| 1165 | } |
| 1166 | |
| 1167 | // Give access to each thread member who has no key — only offered to someone |
| 1168 | // who can actually unlock (doGive needs the vault key). CLI-only holders can |
| 1169 | // still give from their own end. |
| 1170 | if (iAmEnrolled) { |
| 1171 | for (const m of missing) { |
| 1172 | const give = document.createElement("button"); |
| 1173 | give.type = "button"; |
| 1174 | give.className = "vault-btn"; |
| 1175 | give.textContent = `Give ${m.name || "them"} access`; |
| 1176 | give.setAttribute("aria-label", `Give ${m.name || "this member"} a key to this vault`); |
| 1177 | give.onclick = () => doGive(ctx, keyring, m, say); |
| 1178 | wrapEl.appendChild(give); |
| 1179 | } |
| 1180 | } |
| 1181 | |
| 1182 | return wrapEl; |
| 1183 | } |
| 1184 | |
| 1185 | // buildHistory renders the collapsible audit trail — every add / change / remove, |
| 1186 | // newest first — for the app's current thread. Collapsed by default. |
| 1187 | function buildHistory(log, ctx) { |
| 1188 | const tid = ctx.threadId; |
| 1189 | const wrap = document.createElement("div"); |
| 1190 | wrap.className = "vault-history"; |
| 1191 | const open = !!historyOpen[tid]; |
| 1192 | |
| 1193 | const toggle = document.createElement("button"); |
| 1194 | toggle.type = "button"; |
| 1195 | toggle.className = "vault-history-toggle"; |
| 1196 | toggle.textContent = `${open ? "▾" : "▸"} History (${log.length.toLocaleString()})`; |
| 1197 | toggle.onclick = () => { |
| 1198 | historyOpen[tid] = !open; |
| 1199 | // Re-render just this section in place (the rest of the surface is unaffected). |
| 1200 | const next = buildHistory(((ctx.getState() || {}).log) || log, ctx); |
| 1201 | wrap.replaceWith(next); |
| 1202 | }; |
| 1203 | wrap.appendChild(toggle); |
| 1204 | |
| 1205 | if (open) { |
| 1206 | const ul = document.createElement("ul"); |
| 1207 | ul.className = "vault-log"; |
| 1208 | for (const entry of [...log].reverse()) ul.appendChild(logRow(entry)); |
| 1209 | wrap.appendChild(ul); |
| 1210 | } |
| 1211 | return wrap; |
| 1212 | } |
| 1213 | |
| 1214 | // logRow renders one audit-trail line: "<who> <verb> <name> · <when>". The name |
| 1215 | // and actor are user content, so they go in via textContent nodes, never markup. |
| 1216 | // Item actions name the secret; keyring actions carry a member/device label. |
| 1217 | const LOG_VERBS = { |
| 1218 | add: " added ", |
| 1219 | remove: " removed ", |
| 1220 | change: " changed ", |
| 1221 | lock: " locked the vault", |
| 1222 | enroll: " enrolled a key", |
| 1223 | give: " gave access", |
| 1224 | revoke: " revoked a key", |
| 1225 | }; |
| 1226 | |
| 1227 | function logRow(entry) { |
| 1228 | const li = document.createElement("li"); |
| 1229 | const who = document.createElement("span"); |
| 1230 | who.className = "vault-log-who"; |
| 1231 | who.textContent = entry.byName || entry.by || "someone"; |
| 1232 | const isItemAction = entry.action === "add" || entry.action === "remove" || entry.action === "change" || !LOG_VERBS[entry.action]; |
| 1233 | li.append(who, document.createTextNode(LOG_VERBS[entry.action] || " changed ")); |
| 1234 | if (isItemAction) { |
| 1235 | const nm = document.createElement("span"); |
| 1236 | nm.textContent = entry.name || "a secret"; |
| 1237 | li.append(nm); |
| 1238 | } else if (entry.name) { |
| 1239 | const nm = document.createElement("span"); |
| 1240 | nm.textContent = ` (${entry.name})`; |
| 1241 | li.append(nm); |
| 1242 | } |
| 1243 | if (entry.at) li.append(document.createTextNode(" · " + shortWhen(entry.at))); |
| 1244 | return li; |
| 1245 | } |
| 1246 | |
| 1247 | // shortWhen is a compact relative-ish timestamp for the audit lines — "just now", |
| 1248 | // "5m ago", "3h ago", or a date for anything older. Pure enough to keep inline. |
| 1249 | function shortWhen(ts) { |
| 1250 | const now = Date.now(); |
| 1251 | const diff = now - ts; |
| 1252 | if (diff < 60000) return "just now"; |
| 1253 | if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; |
| 1254 | if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; |
| 1255 | try { |
| 1256 | return new Date(ts).toLocaleDateString(); |
| 1257 | } catch { |
| 1258 | return ""; |
| 1259 | } |
| 1260 | } |
| 1261 | |