From 985a9cf33f548414308a0a48d56b0c1fbd0415c0 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 11 Sep 2026 13:40:14 -0700 Subject: [PATCH 1/3] Keep session passwords in an end-to-end encrypted vault A session's password lived only in the browser that chose or was told it, so clearing that browser, opening the session elsewhere, or closing the terminal that printed it could lose the session for good. A frame that failed to decrypt also deleted the only stored copy. Each account now has a session vault: a P-256 key pair whose private half the service stores only encrypted under a random vault key, itself wrapped by a 160-bit recovery key shown once and never sent. Session passwords are sealed to the account key (ECDH, HKDF-SHA256, AES-256-GCM bound to the session and recipient), so any browser that unlocks the vault opens them and the service opens none of them. - Web: required one-time setup with a confirmed recovery key; unlock in a new browser; the unlocked key is a non-extractable CryptoKey in IndexedDB; sign-out locks it; reset needs a fresh sign-in and carries running sessions across. - CLI: seals each encrypted session's password to the vault at registration, pinning the key the browser hands over at `shell login` (or on first use for machines linked earlier) and refusing a key that changed. - Sharing seals to colleagues' vault keys and asks before sealing to a key that changed since it was last used. - Migration from before the vault is idempotent: cached passwords and shares sealed to old browser keys move into the vault once, never overwriting a copy that may be right with one that is only a guess. - A password is no longer deleted on a decryption failure; only an unproven guess is, and every saved source is tried before asking. - Browser-started sessions use 128-bit passwords and are refused on machines that cannot receive one. --- .github/SECURITY.md | 1 + CHANGELOG.md | 28 ++ app/server/app.test.ts | 191 ++++++++++- app/server/app.ts | 136 +++++++- app/server/lib/firebase-token.ts | 7 + .../lib/migrations/007_account_keys.sql | 19 ++ app/server/lib/orgs.ts | 6 + app/server/lib/store-conformance.test.ts | 52 +++ app/server/lib/store-memory.ts | 30 +- app/server/lib/store-postgres.ts | 48 ++- app/server/lib/store.ts | 11 + app/server/lib/types.ts | 27 +- app/server/lib/vault.test.ts | 132 ++++++++ app/server/lib/vault.ts | 124 +++++++ app/src/App.tsx | 12 +- app/src/auth/AuthProvider.tsx | 14 +- app/src/components/NewSessionModal.tsx | 7 +- app/src/components/SessionAudience.tsx | 126 ++++--- app/src/components/SessionClipboard.tsx | 26 +- app/src/lib/api.ts | 40 ++- app/src/lib/keypair.ts | 10 +- app/src/lib/known-keys.ts | Bin 0 -> 1588 bytes app/src/lib/seal.ts | 10 +- app/src/lib/session-passwords.test.ts | Bin 6799 -> 11047 bytes app/src/lib/session-passwords.ts | Bin 4610 -> 7119 bytes app/src/lib/session-share.test.ts | 16 +- app/src/lib/session-share.ts | 10 +- app/src/lib/vault-crypto.test.ts | 206 ++++++++++++ app/src/lib/vault-crypto.ts | Bin 0 -> 12927 bytes app/src/lib/vault-store.ts | 93 ++++++ app/src/main.tsx | 1 + app/src/routes/Account.tsx | 52 +++ app/src/routes/CliAuthorize.tsx | 62 +++- app/src/routes/Team.tsx | 4 +- app/src/routes/Workspace.tsx | 165 ++++++--- app/src/styles/vault.css | 181 ++++++++++ app/src/terminal/TerminalPane.tsx | 139 ++++++-- app/src/terminal/connection.test.ts | 63 ++++ app/src/terminal/connection.ts | 12 + app/src/vault/VaultGate.tsx | 290 ++++++++++++++++ app/src/vault/VaultProvider.tsx | 316 ++++++++++++++++++ cmd/shell/main.go | 2 +- cmd/shell/session_link.go | 69 +++- cmd/shell/session_link_test.go | 20 +- cmd/shell/session_link_vault_test.go | 233 +++++++++++++ internal/account/client.go | 58 +++- internal/account/credentials.go | 9 + internal/account/login.go | 24 +- .../testdata/vault-share-v2-browser.json | 9 + .../account/testdata/vault-share-v2-go.json | 9 + internal/account/vault.go | 164 +++++++++ internal/account/vault_client_test.go | 207 ++++++++++++ internal/account/vault_test.go | 290 ++++++++++++++++ 53 files changed, 3561 insertions(+), 200 deletions(-) create mode 100644 app/server/lib/migrations/007_account_keys.sql create mode 100644 app/server/lib/vault.test.ts create mode 100644 app/server/lib/vault.ts create mode 100644 app/src/lib/known-keys.ts create mode 100644 app/src/lib/vault-crypto.test.ts create mode 100644 app/src/lib/vault-crypto.ts create mode 100644 app/src/lib/vault-store.ts create mode 100644 app/src/styles/vault.css create mode 100644 app/src/vault/VaultGate.tsx create mode 100644 app/src/vault/VaultProvider.tsx create mode 100644 cmd/shell/session_link_vault_test.go create mode 100644 internal/account/testdata/vault-share-v2-browser.json create mode 100644 internal/account/testdata/vault-share-v2-go.json create mode 100644 internal/account/vault.go create mode 100644 internal/account/vault_client_test.go create mode 100644 internal/account/vault_test.go diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 967ca71..68d88b0 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -17,6 +17,7 @@ You should receive an acknowledgement within three business days. We will valida - By default, every session created by the current CLI encrypts terminal payloads between the CLI and browser. Cloudflare still receives frame opcodes and traffic/lifecycle metadata. Plaintext disclosure to the relay, nonce/key reuse that compromises confidentiality, acceptance of modified ciphertext, or cross-language key-derivation incompatibility is in scope. Older clients that predate default E2EE are unsupported and should be upgraded. - `--no-e2ee` is an explicit opt-out for compatibility or debugging. In that mode HTTPS/WSS protects each transport hop, but terminal payloads intentionally pass through Cloudflare in memory. Relay access to that plaintext is expected behavior and the CLI must label it clearly; accidental fallback from the default encrypted mode is in scope. - E2EE access remains a bearer capability. Possession of both the complete salted URL and its browser password intentionally grants decryption. Relay-side dropping, delaying, and replaying of valid ciphertext are documented protocol limits rather than confidentiality claims. +- Session passwords are kept in a per-account session vault. The accounts service stores each password only sealed to the account's vault public key (ephemeral ECDH P-256, HKDF-SHA256, AES-256-GCM bound to the session id and recipient). The matching private key is stored encrypted under a random vault key, and the vault key is wrapped only by a 160-bit recovery key the service never receives. Any way for the accounts service, the relay, or a copy of the database to open a sealed password, the private key, or the vault key is in scope, as is substituting a vault public key without the browser or the CLI refusing it. Two points are trust-on-first-use by design: the first key seen for a colleague, and the account key a machine linked before the vault existed learns on its next session. The web app served by shell.online performs the unlock and is trusted to, as it is trusted with a typed session password. - The CLI generates an eight-character base64url password with 48 bits of entropy when no password is supplied. This is an explicit convenience/security tradeoff for task-bound shares, not a claim of passphrase-strength protection. `SHELL_ONLINE_E2EE_PASSWORD` accepts a longer unique password for sensitive or long-lived sessions; recipients should receive the URL and password through separate channels when appropriate. - Persistent state files and Docker state volumes intentionally contain the host credential, browser password, and E2EE key material. Files created by shell.online must be owner-only. A saved password cannot be changed in place because the stable URL and key are bound to it; password rotation creates new state and a new URL. Disclosure caused by publishing, broadly mounting, or backing up that state outside shell.online is not a product vulnerability. - Active-session records in the per-user local control directory intentionally retain the browser password so `shell list` can reconstruct usable access. The directory and records must remain owner-only and are deleted when their processes close. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e48d16..36f62d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ## Unreleased +### Added + +- A session vault. Every session's password is sealed to your account, so it + opens in any browser you unlock, including sessions started in a terminal. + Setting one up is a one-time step that shows a recovery key; shell.online + stores the vault sealed and cannot open it. +- `shell` seals each encrypted session's password to your vault when it + registers the session, using the vault key the browser handed it at + `shell login`, and will not seal to a key that has changed since. + +### Changed + +- Sessions started from the browser use 128-bit passwords, since nobody types + them. +- Sharing a session seals its password to the colleague's vault rather than + to one of their browsers, so it opens on every device they use. A colleague + whose vault key changed is confirmed before anything is sealed to them. +- Signing out locks the vault in that browser. + +### Fixed + +- A stored password is no longer deleted when a frame fails to decrypt, which + could lose a browser-started session for good. +- The browser's password cache drops the least recently used entry rather than + the oldest written. +- Starting a session on a machine that cannot receive a password is refused, + instead of producing a session nobody can open. + ## [0.11.3] — 2026-09-11 ### Changed diff --git a/app/server/app.test.ts b/app/server/app.test.ts index 2fb2942..501683f 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -7,6 +7,7 @@ import { deferred } from "./lib/store-deferred"; import type { Store } from "./lib/store"; import { createVerifier, localKeySet } from "./lib/firebase-token"; import { base64url, deriveChallenge } from "./lib/pkce"; +import { createVault, sealToAccount } from "../src/lib/vault-crypto"; const PROJECT = "test-firebase-project"; const REDIRECT = "http://127.0.0.1:51234/callback"; @@ -1156,6 +1157,33 @@ describe("session ownership and handoff", () => { expect(handed.body.session.assigneeUid).toBeUndefined(); }); + it("lets a colleague keep their own copy of a key, and nobody else's", async () => { + const { colleague } = await orgWithColleague(); + const path = `/api/sessions/${session.id}/keys`; + const share = { sender_public_key: "BASE64_PUBLIC_KEY", sealed: "v2.BASE64_SEALED" }; + + const forOwner = await call("PUT", path, { auth: colleague, body: { shares: [{ uid: "uid-1", ...share }] } }); + expect(forOwner.status).toBe(403); + + /* A password they typed and saw work, sealed to their own vault. */ + const own = await call("PUT", path, { auth: colleague, body: { shares: [{ uid: "uid-2", ...share }] } }); + expect(own.status).toBe(200); + const listed = await call("GET", "/api/sessions", { auth: colleague }); + expect(listed.body.sessions[0].keyShare).toMatchObject({ sealed: "v2.BASE64_SEALED" }); + }); + + it("tells the owner, and only the owner, who holds a copy", async () => { + const { colleague } = await orgWithColleague(); + await call("PUT", `/api/sessions/${session.id}/keys`, { + auth: await idToken(), + body: { shares: [{ uid: "uid-2", sender_public_key: "K", sealed: "S" }] }, + }); + const owner = await call("GET", "/api/sessions", { auth: await idToken() }); + expect(owner.body.sessions[0].sharedWith).toEqual(["uid-2"]); + const other = await call("GET", "/api/sessions", { auth: colleague }); + expect(other.body.sessions[0].sharedWith).toBeUndefined(); + }); + it("lets only the owner share a session key, and only with current members", async () => { const { colleague } = await orgWithColleague(); const path = `/api/sessions/${session.id}/keys`; @@ -1174,7 +1202,11 @@ describe("session ownership and handoff", () => { sealed: "BASE64_SEALED_PASSWORD", }); - const overwritten = await call("PUT", path, { auth: colleague, body: { shares } }); + /* A colleague may keep their own copy, but cannot write one for anyone else. */ + const overwritten = await call("PUT", path, { + auth: colleague, + body: { shares: [{ ...shares[0], uid: "uid-1" }] }, + }); expect(overwritten.status).toBe(403); const outsider = await call("PUT", path, { @@ -1719,3 +1751,160 @@ describe("inviting someone by email", () => { expect(sent[0].html).toMatch(/Join [^<]*<\/a>/); }); }); + +describe("session vault", () => { + const session = { + id: "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", + share_url: "https://shell.online/s/qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t#salt=AAAAAAAAAAAAAAAAAAAAAA", + command: "claude", + encrypted: true, + }; + + async function vaultBody(uid = "uid-1") { + const made = await createVault(uid); + return { + made, + body: { + public_key: made.bundle.publicKey, + encrypted_private_key: made.bundle.encryptedPrivateKey, + recovery_wrap: made.bundle.recoveryWrap, + }, + }; + } + + /* A token from a sign-in that just happened, which is what a reset asks for. */ + const freshSignIn = (claims: Record = {}) => + idToken({ auth_time: Math.floor(Date.now() / 1000), ...claims }); + + it("has none until one is set up", async () => { + const result = await call("GET", "/api/vault", { auth: await idToken() }); + expect(result).toMatchObject({ status: 200, body: { vault: null } }); + }); + + it("keeps the vault it is given and hands it back whole", async () => { + const { body } = await vaultBody(); + const created = await call("POST", "/api/vault", { auth: await idToken(), body }); + expect(created.status).toBe(201); + const fetched = await call("GET", "/api/vault", { auth: await idToken() }); + expect(fetched.body.vault).toMatchObject({ + publicKey: body.public_key, + encryptedPrivateKey: body.encrypted_private_key, + recoveryWrap: body.recovery_wrap, + version: 1, + }); + }); + + it("will not overwrite a vault by setting one up again", async () => { + await call("POST", "/api/vault", { auth: await idToken(), body: (await vaultBody()).body }); + const again = await call("POST", "/api/vault", { auth: await idToken(), body: (await vaultBody()).body }); + expect(again.status).toBe(409); + }); + + it("refuses a vault that is not shaped like one", async () => { + const { body } = await vaultBody(); + const result = await call("POST", "/api/vault", { + auth: await idToken(), + body: { ...body, recovery_wrap: "too-short" }, + }); + expect(result.status).toBe(400); + }); + + it("keeps each person's vault to themselves", async () => { + await call("POST", "/api/vault", { auth: await idToken(), body: (await vaultBody()).body }); + const other = await call("GET", "/api/vault", { auth: await idToken({ sub: "uid-2" }) }); + expect(other.body.vault).toBeNull(); + }); + + /* + * A reset puts a new key where colleagues and machines seal passwords, so a + * token that has merely been refreshed must not be enough for it. + */ + it("refuses a reset without a recent sign-in", async () => { + await call("POST", "/api/vault", { auth: await idToken(), body: (await vaultBody()).body }); + const stale = await call("POST", "/api/vault", { + auth: await idToken({ auth_time: Math.floor(Date.now() / 1000) - 3600 }), + body: { ...(await vaultBody()).body, replace_version: 1 }, + }); + expect(stale).toMatchObject({ status: 403, body: { reauthenticate: true } }); + const unknown = await call("POST", "/api/vault", { + auth: await idToken(), + body: { ...(await vaultBody()).body, replace_version: 1 }, + }); + expect(unknown.status).toBe(403); + }); + + it("replaces the vault on a reset from a fresh sign-in, and counts it", async () => { + await call("POST", "/api/vault", { auth: await idToken(), body: (await vaultBody()).body }); + const { body } = await vaultBody(); + const reset = await call("POST", "/api/vault", { + auth: await freshSignIn(), + body: { ...body, replace_version: 1 }, + }); + expect(reset.status).toBe(200); + expect(reset.body.vault).toMatchObject({ publicKey: body.public_key, version: 2 }); + }); + + it("refuses to reset a vault that changed since the page loaded", async () => { + await call("POST", "/api/vault", { auth: await idToken(), body: (await vaultBody()).body }); + const reset = await call("POST", "/api/vault", { + auth: await freshSignIn(), + body: { ...(await vaultBody()).body, replace_version: 4 }, + }); + expect(reset.status).toBe(409); + }); + + it("tells a linked machine which key to seal to, once there is one", async () => { + const tokens = await login(); + const before = await call("GET", "/api/account/key", { auth: tokens.access_token }); + expect(before.status).toBe(404); + + const { body } = await vaultBody(); + await call("POST", "/api/vault", { auth: await idToken(), body }); + const after = await call("GET", "/api/account/key", { auth: tokens.access_token }); + expect(after.body).toEqual({ public_key: body.public_key, version: 1 }); + }); + + it("does not hand the key to a caller that is not a linked machine", async () => { + const result = await call("GET", "/api/account/key", { auth: await idToken() }); + expect(result.status).toBe(401); + }); + + it("keeps the CLI's sealed copy of a password as the owner's", async () => { + const tokens = await login(); + const { made, body } = await vaultBody(); + await call("POST", "/api/vault", { auth: await idToken(), body }); + const share = await sealToAccount(made.bundle.publicKey, session.id, "uid-1", "Kw9eHbru"); + + const registered = await call("POST", "/api/sessions", { + auth: tokens.access_token, + body: { ...session, owner_share: { sender_public_key: share.senderPublicKey, sealed: share.sealed } }, + }); + expect(registered.status).toBe(201); + + const listed = await call("GET", "/api/sessions", { auth: await idToken() }); + expect(listed.body.sessions[0].keyShare).toMatchObject(share); + }); + + it("still registers a session whose sealed copy is not shaped like one", async () => { + const tokens = await login(); + const registered = await call("POST", "/api/sessions", { + auth: tokens.access_token, + body: { ...session, owner_share: { sender_public_key: "junk", sealed: "junk" } }, + }); + expect(registered.status).toBe(201); + const listed = await call("GET", "/api/sessions", { auth: await idToken() }); + expect(listed.body.sessions[0].keyShare).toBeUndefined(); + }); + + it("gives colleagues each member's vault key to seal to", async () => { + const invite = await call("POST", "/api/org/invites", { auth: await idToken(), body: { role: "member" } }); + const colleague = await idToken({ sub: "uid-2", email: "colleague@example.com" }); + await call("GET", `/api/org?invite=${invite.body.invite.id}`, { auth: colleague }); + const { body } = await vaultBody("uid-2"); + await call("POST", "/api/vault", { auth: colleague, body }); + + const listed = await call("GET", "/api/sessions", { auth: await idToken() }); + const member = listed.body.members.find((entry: { uid: string }) => entry.uid === "uid-2"); + expect(member.accountKey).toBe(body.public_key); + }); +}); diff --git a/app/server/app.ts b/app/server/app.ts index 947b6fc..089a841 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -19,6 +19,7 @@ import { sessionSource, } from "./lib/sessions"; import { mintSecret } from "./lib/tokens"; +import { RESET_SIGN_IN_WINDOW_MS, readOwnerShare, readVaultInput, vaultForApi } from "./lib/vault"; import { changeRole, createInvite, @@ -142,6 +143,19 @@ function ownsSession(membership: Membership, session: { ownerUid?: string; uid: return (session.ownerUid ?? session.uid) === membership.uid; } +/* + * Who besides the owner holds a sealed copy of a session's password. Told to + * the owner only: it is theirs to know, and the service knows it anyway + * because it stores the copies. + */ +function sharedWith( + membership: Membership, + session: { ownerUid?: string; uid: string; keyShares?: { uid: string }[] }, +): string[] | undefined { + if (!ownsSession(membership, session)) return undefined; + return (session.keyShares ?? []).map((share) => share.uid).filter((uid) => uid !== membership.uid); +} + /** * The harnesses a polling agent claims, keeping only the recognised ones. * @@ -580,6 +594,87 @@ export function createApp(options: AppOptions) { return; } + /* ---- Session vault ---- */ + + /* + * A vault is a public key and two pieces of ciphertext. Its owner gets it + * back whole, since none of it opens without the recovery key, and + * nobody else gets any of it but the public key. + */ + if (route === "GET /api/vault") { + const identity = await requireUser(request); + if (!identity) return send(response, 401, { error: "sign in first" }); + const key = await store.accountKey(identity.uid); + return send(response, 200, { vault: key ? vaultForApi(key) : null }); + } + + if (route === "POST /api/vault") { + const identity = await requireUser(request); + if (!identity) return send(response, 401, { error: "sign in first" }); + const input = await readVaultInput((await readBody(request)) as Record); + if (!input.ok) return send(response, 400, { error: input.reason }); + const { publicKey, encryptedPrivateKey, recoveryWrap, replaceVersion } = input.value; + const now = Date.now(); + + if (replaceVersion === undefined) { + const created = await store.putAccountKey({ + uid: identity.uid, + publicKey, + encryptedPrivateKey, + recoveryWrap, + version: 1, + createdAt: now, + updatedAt: now, + }); + if (!created) return send(response, 409, { error: "this account already has a vault" }); + const stored = await store.accountKey(identity.uid); + return send(response, 201, { vault: stored ? vaultForApi(stored) : null }); + } + + /* + * A reset puts a new key where colleagues and machines seal passwords, + * so a token that has only been refreshed is not enough for it. The + * person has to have signed in recently. + */ + if (!identity.authTime || now - identity.authTime > RESET_SIGN_IN_WINDOW_MS) { + return send(response, 403, { + error: "Resetting your vault needs a recent sign-in. Sign out, sign back in, and reset within ten minutes.", + reauthenticate: true, + }); + } + const existing = await store.accountKey(identity.uid); + const stale = { error: "Your vault changed since this page loaded. Reload and try again." }; + if (!existing || existing.version !== replaceVersion) return send(response, 409, stale); + const replaced = await store.putAccountKey( + { + uid: identity.uid, + publicKey, + encryptedPrivateKey, + recoveryWrap, + version: existing.version + 1, + createdAt: existing.createdAt, + updatedAt: now, + }, + existing.version, + ); + if (!replaced) return send(response, 409, stale); + const stored = await store.accountKey(identity.uid); + return send(response, 200, { vault: stored ? vaultForApi(stored) : null }); + } + + /* + * What a machine seals its sessions' passwords to. The CLI pins the key + * it was given when it signed in, and uses this only to notice a change + * or to learn a key it was never given. + */ + if (route === "GET /api/account/key") { + const token = await requireCli(request); + if (!token) return send(response, 401, { error: "not signed in" }); + const key = await store.accountKey(token.uid); + if (!key) return send(response, 404, { error: "no vault" }); + return send(response, 200, { public_key: key.publicKey, version: key.version }); + } + /* ---- Session registry ---- */ if (route === "POST /api/sessions") { const token = await requireCli(request); @@ -603,6 +698,20 @@ export function createApp(options: AppOptions) { deviceId: token.id, }); if (!result.ok) return send(response, 400, { error: result.reason }); + /* + * The CLI's own copy of the password, sealed to this account's vault, + * so the web app can open the session without asking anyone to type + * it. Optional and opaque: one that is not shaped like a share is + * dropped, and never allowed to fail the registration. + */ + const ownerShare = result.session.encrypted && result.session.orgId + ? await readOwnerShare(body.owner_share) + : null; + if (ownerShare && result.session.orgId) { + await store.putKeyShares(result.session.orgId, result.session.id, [ + { uid: token.uid, ...ownerShare }, + ]); + } /* Only on first sight, so a persistent session restarting is silent. */ if (result.isNew && membership) { await notifySessionStarted( @@ -687,7 +796,12 @@ export function createApp(options: AppOptions) { */ const sessions = (await store.listOrgSessions(membership.orgId)).map((session) => { const mine = session.keyShares?.find((share) => share.uid === membership.uid); - return { ...sessionForApi(session), keyShares: undefined, keyShare: mine }; + return { + ...sessionForApi(session), + keyShares: undefined, + keyShare: mine, + sharedWith: sharedWith(membership, session), + }; }); return send(response, 200, { sessions, @@ -702,9 +816,13 @@ export function createApp(options: AppOptions) { if (!membership) return send(response, 401, { error: "sign in first" }); const session = await store.sessionInOrg(membership.orgId, shareRoute[1]); if (!session) return send(response, 404, { error: "no such session" }); - if (!ownsSession(membership, session)) { - return send(response, 403, { error: "only the session owner can share its key" }); - } + /* + * The owner shares with colleagues. Anyone else may keep only their + * own copy: a password they typed and saw work, sealed to their own + * vault so they need not type it again. That grants them nothing they + * did not already hold. + */ + const isOwner = ownsSession(membership, session); const body = (await readBody(request)) as Record; const incoming = Array.isArray(body.shares) ? body.shares : []; @@ -729,6 +847,9 @@ export function createApp(options: AppOptions) { )) { return send(response, 400, { error: "invalid key share" }); } + if (!isOwner && shares.some((share) => share.uid !== membership.uid)) { + return send(response, 403, { error: "only the session owner can share its key" }); + } const memberIds = new Set((await store.members(membership.orgId)).map((member) => member.uid)); if (shares.some((share) => !memberIds.has(share.uid))) { @@ -966,7 +1087,12 @@ export function createApp(options: AppOptions) { if (!session) return send(response, 404, { error: "no such session" }); const mine = session.keyShares?.find((share) => share.uid === membership.uid); return send(response, 200, { - session: { ...sessionForApi(session), keyShares: undefined, keyShare: mine }, + session: { + ...sessionForApi(session), + keyShares: undefined, + keyShare: mine, + sharedWith: sharedWith(membership, session), + }, members: await store.members(membership.orgId), you: membership, comments: await store.comments(membership.orgId, oneSession[1]), diff --git a/app/server/lib/firebase-token.ts b/app/server/lib/firebase-token.ts index c1a2ec9..5503bf4 100644 --- a/app/server/lib/firebase-token.ts +++ b/app/server/lib/firebase-token.ts @@ -7,6 +7,12 @@ export interface Identity { uid: string; email: string; name: string; + /** + * When this person last actually signed in, in milliseconds. A refreshed + * token keeps the original time, so this is how an operation that deserves + * a fresh sign-in can ask for one. + */ + authTime?: number; } export type VerifyResult = @@ -51,6 +57,7 @@ export function createVerifier(projectId: string, keys?: KeyLookup) { uid, email: typeof payload.email === "string" ? payload.email : "", name: typeof payload.name === "string" ? payload.name : "", + authTime: typeof payload.auth_time === "number" ? payload.auth_time * 1000 : undefined, }, }; }; diff --git a/app/server/lib/migrations/007_account_keys.sql b/app/server/lib/migrations/007_account_keys.sql new file mode 100644 index 0000000..fca96bb --- /dev/null +++ b/app/server/lib/migrations/007_account_keys.sql @@ -0,0 +1,19 @@ +-- A person's session vault. +-- +-- Every session password is sealed to one key pair per account, so it outlives +-- the browser that first held it. The public key is what colleagues and the +-- CLI seal to. The private key is kept here only encrypted under a vault key, +-- and the vault key only wrapped under a recovery key this service never +-- receives. None of these columns opens anything by itself. +-- +-- `version` counts resets. A reset replaces the key pair outright, and the +-- version is how a write says which vault it means to replace. +CREATE TABLE IF NOT EXISTS account_keys ( + uid TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + encrypted_private_key TEXT NOT NULL, + recovery_wrap TEXT NOT NULL, + version INTEGER NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/app/server/lib/orgs.ts b/app/server/lib/orgs.ts index 110e4c3..1d82d53 100644 --- a/app/server/lib/orgs.ts +++ b/app/server/lib/orgs.ts @@ -29,6 +29,12 @@ export interface Membership { * password to them. Absent until they have signed in somewhere. */ publicKey?: string; + /** + * Their session vault's public key, once they have set one up. Session + * passwords shared with them are sealed to this, so every browser they + * unlock can open them. Read from the vault, never written through here. + */ + accountKey?: string; } export interface Invite { diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index 0dc1805..31294ff 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -128,6 +128,7 @@ function notification(overrides: Partial = {}): Notification { type Implementation = { name: string; open: () => Promise; reset: (store: Store) => Promise }; const TABLES = [ + "account_keys", "session_key_shares", "sessions", "agent_commands", @@ -452,6 +453,57 @@ for (const implementation of implementations) { }); }); + describe("session vault", () => { + type AccountKey = Parameters[0]; + + function vault(overrides: Partial = {}): AccountKey { + return { + uid: "uid-1", + publicKey: "pk-1", + encryptedPrivateKey: "enc-1", + recoveryWrap: "wrap-1", + version: 1, + createdAt: 1000, + updatedAt: 1000, + ...overrides, + }; + } + + /* Two browsers setting up at once must not both believe they won. */ + it("creates a vault once and refuses a second", async () => { + expect(await store.putAccountKey(vault())).toBe(true); + expect(await store.putAccountKey(vault({ publicKey: "pk-2" }))).toBe(false); + expect(await store.accountKey("uid-1")).toEqual(vault()); + }); + + it("replaces a vault only at the version the reset expects", async () => { + await store.putAccountKey(vault()); + const next = vault({ publicKey: "pk-2", version: 2, updatedAt: 2000 }); + expect(await store.putAccountKey(next, 5)).toBe(false); + expect(await store.putAccountKey(next, 1)).toBe(true); + expect(await store.accountKey("uid-1")).toEqual(next); + /* The same reset replayed finds the version has moved on. */ + expect(await store.putAccountKey(vault({ publicKey: "pk-3", version: 2 }), 1)).toBe(false); + }); + + it("does not replace a vault that does not exist", async () => { + expect(await store.putAccountKey(vault(), 1)).toBe(false); + expect(await store.accountKey("uid-1")).toBeNull(); + }); + + it("hands out each member's vault key with the roster, and only theirs", async () => { + await store.putOrganization(organization()); + await store.putMembership(membership()); + await store.putMembership( + membership({ uid: "uid-2", email: "bo@example.com", role: "member", joinedAt: 2000 }), + ); + await store.putAccountKey(vault({ uid: "uid-2", publicKey: "pk-bo" })); + const roster = await store.members("org_1"); + expect(roster.find((entry) => entry.uid === "uid-2")?.accountKey).toBe("pk-bo"); + expect(roster.find((entry) => entry.uid === "uid-1")?.accountKey).toBeUndefined(); + }); + }); + describe("agent commands", () => { it("hands queued work out exactly once", async () => { await store.putCommand(command()); diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 8eec4fe..7ab6385 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import type { Invite, Membership, Organization, Role } from "./orgs"; import type { AuditPage, AuditPageQuery, Store } from "./store"; import type { + AccountKey, AgentCommand, AuditEvent, AuthorizationCode, @@ -46,12 +47,13 @@ interface Shape { audit: AuditEvent[]; comments: Comment[]; notifications: Notification[]; + accountKeys: AccountKey[]; } const EMPTY: Shape = { codes: [], tokens: [], sessions: [], commands: [], organizations: [], memberships: [], invites: [], audit: [], - comments: [], notifications: [], + comments: [], notifications: [], accountKeys: [], }; /** @@ -114,6 +116,7 @@ export class MemoryStore implements Store { audit: parsed.audit ?? [], comments: parsed.comments ?? [], notifications: parsed.notifications ?? [], + accountKeys: parsed.accountKeys ?? [], }; } catch { return structuredClone(EMPTY); @@ -224,6 +227,24 @@ export class MemoryStore implements Store { return true; } + async accountKey(uid: string): Promise { + const found = this.data.accountKeys.find((entry) => entry.uid === uid); + return found ? { ...found } : null; + } + + async putAccountKey(key: AccountKey, expectedVersion?: number): Promise { + const index = this.data.accountKeys.findIndex((entry) => entry.uid === key.uid); + if (expectedVersion === undefined) { + if (index >= 0) return false; + this.data.accountKeys.push({ ...key }); + } else { + if (index < 0 || this.data.accountKeys[index].version !== expectedVersion) return false; + this.data.accountKeys[index] = { ...key }; + } + this.flush(); + return true; + } + /** Records that `shell agent` is polling, and what it publishes about itself. */ async markAgentSeen( id: string, @@ -455,7 +476,12 @@ export class MemoryStore implements Store { async members(orgId: string): Promise { return this.data.memberships .filter((entry) => entry.orgId === orgId) - .sort(byTime((entry) => entry.joinedAt, (entry) => entry.uid)); + .sort(byTime((entry) => entry.joinedAt, (entry) => entry.uid)) + .map((entry) => { + /* Each member's vault key rides along, so a password can be sealed to it. */ + const accountKey = this.data.accountKeys.find((key) => key.uid === entry.uid)?.publicKey; + return accountKey ? { ...entry, accountKey } : entry; + }); } async removeMember(orgId: string, uid: string): Promise { diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index 772c193..8698a14 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -6,6 +6,7 @@ import pg from "pg"; import type { Invite, Membership, Organization, Role } from "./orgs"; import type { AuditPage, AuditPageQuery, Store } from "./store"; import type { + AccountKey, AgentCommand, AuditEvent, AuthorizationCode, @@ -213,9 +214,22 @@ function toMembership(row: Row): Membership { role: row.role, joinedAt: row.joined_at, publicKey: row.public_key, + accountKey: row.account_key, }) as unknown as Membership; } +function toAccountKey(row: Row): AccountKey { + return { + uid: row.uid as string, + publicKey: row.public_key as string, + encryptedPrivateKey: row.encrypted_private_key as string, + recoveryWrap: row.recovery_wrap as string, + version: row.version as number, + createdAt: row.created_at as number, + updatedAt: row.updated_at as number, + }; +} + function toInvite(row: Row): Invite { return defined({ id: row.id, @@ -812,6 +826,35 @@ export class PostgresStore implements Store { return true; } + /* ---- Session vault ---- */ + + async accountKey(uid: string): Promise { + const row = await this.row("SELECT * FROM account_keys WHERE uid = $1", [uid]); + return row ? toAccountKey(row) : null; + } + + /* + * Each branch is one conditional statement, so no other request can land + * between the check and the write. + */ + async putAccountKey(key: AccountKey, expectedVersion?: number): Promise { + const result = expectedVersion === undefined + ? await this.pool.query( + `INSERT INTO account_keys + (uid, public_key, encrypted_private_key, recovery_wrap, version, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (uid) DO NOTHING`, + [key.uid, key.publicKey, key.encryptedPrivateKey, key.recoveryWrap, key.version, key.createdAt, key.updatedAt], + ) + : await this.pool.query( + `UPDATE account_keys + SET public_key = $2, encrypted_private_key = $3, recovery_wrap = $4, version = $5, updated_at = $6 + WHERE uid = $1 AND version = $7`, + [key.uid, key.publicKey, key.encryptedPrivateKey, key.recoveryWrap, key.version, key.updatedAt, expectedVersion], + ); + return (result.rowCount ?? 0) > 0; + } + /* ---- Agent commands ---- */ async putCommand(command: AgentCommand): Promise { @@ -1003,8 +1046,11 @@ export class PostgresStore implements Store { } async members(orgId: string): Promise { + /* Each member's vault key rides along, so a password can be sealed to it. */ const rows = await this.rows( - 'SELECT * FROM memberships WHERE org_id = $1 ORDER BY joined_at ASC, uid COLLATE "C" ASC', + `SELECT m.*, a.public_key AS account_key + FROM memberships m LEFT JOIN account_keys a ON a.uid = m.uid + WHERE m.org_id = $1 ORDER BY m.joined_at ASC, m.uid COLLATE "C" ASC`, [orgId], ); return rows.map(toMembership); diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 5343516..904a177 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -1,5 +1,6 @@ import type { Invite, Membership, Organization, Role } from "./orgs"; import type { + AccountKey, AgentCommand, AuditEvent, AuthorizationCode, @@ -100,6 +101,16 @@ export interface Store { deleteSession(orgId: string, id: string): Promise; putKeyShares(orgId: string, sessionId: string, shares: SessionKeyShare[]): Promise; + /* ---- Session vault ---- */ + accountKey(uid: string): Promise; + /** + * Writes a vault. Without `expectedVersion` it only creates one, and fails + * if one exists; with it, it replaces only the vault at that version. False + * means the condition failed, so two browsers setting up at once cannot both + * believe they won, and a reset cannot overwrite a reset it has not seen. + */ + putAccountKey(key: AccountKey, expectedVersion?: number): Promise; + /* ---- Agent commands ---- */ putCommand(command: AgentCommand): Promise; claimCommands(deviceId: string, now?: number): Promise; diff --git a/app/server/lib/types.ts b/app/server/lib/types.ts index c1a2162..630d8c7 100644 --- a/app/server/lib/types.ts +++ b/app/server/lib/types.ts @@ -73,13 +73,38 @@ export interface AuditEvent { text: string; } -/** A session password sealed to one member's browser key. */ +/** + * A session password sealed to one member. + * + * Older shares are sealed to a browser key; newer ones to the member's account + * key, marked by a "v2." prefix on `sealed`. The service cannot tell them + * apart any better than that and does not need to. + */ export interface SessionKeyShare { uid: string; senderPublicKey: string; sealed: string; } +/** + * A person's session vault. + * + * The public key is what session passwords are sealed to. The private key is + * held only encrypted under a vault key, and the vault key only wrapped under + * a recovery key this service never receives, so none of this opens anything + * here. + */ +export interface AccountKey { + uid: string; + publicKey: string; + encryptedPrivateKey: string; + recoveryWrap: string; + /** Starts at 1 and counts resets. */ + version: number; + createdAt: number; + updatedAt: number; +} + export interface Comment { id: string; orgId: string; diff --git a/app/server/lib/vault.test.ts b/app/server/lib/vault.test.ts new file mode 100644 index 0000000..d30e352 --- /dev/null +++ b/app/server/lib/vault.test.ts @@ -0,0 +1,132 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { isP256PublicKey, readOwnerShare, readVaultInput } from "./vault"; +import { createVault, openFromAccount, sealToAccount } from "../../src/lib/vault-crypto"; + +/* + * The service cannot open a vault, so all it can hold the line on is shape. + * These tests feed it exactly what the browser makes, so a change on either + * side that the other would reject fails here rather than at sign-up. + */ + +function encode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function decode(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); +} + +async function browserVault() { + const made = await createVault("uid-1"); + return { + public_key: made.bundle.publicKey, + encrypted_private_key: made.bundle.encryptedPrivateKey, + recovery_wrap: made.bundle.recoveryWrap, + }; +} + +describe("a vault the service is handed", () => { + it("accepts what the browser makes", async () => { + const body = await browserVault(); + const result = await readVaultInput(body); + expect(result).toMatchObject({ ok: true, value: { publicKey: body.public_key } }); + }); + + it("refuses a public key that is not a point on the curve", async () => { + const body = await browserVault(); + const offCurve = new Uint8Array(65); + offCurve[0] = 0x04; + offCurve[64] = 0x07; + expect(await isP256PublicKey(encode(offCurve))).toBe(false); + expect(await readVaultInput({ ...body, public_key: encode(offCurve) })).toMatchObject({ ok: false }); + }); + + it("refuses fields that are not the size the browser produces", async () => { + const body = await browserVault(); + expect(await readVaultInput({ ...body, recovery_wrap: encode(new Uint8Array(59)) })).toMatchObject({ ok: false }); + expect(await readVaultInput({ ...body, encrypted_private_key: "short" })).toMatchObject({ ok: false }); + expect(await readVaultInput({ ...body, encrypted_private_key: "not base64url!" })).toMatchObject({ ok: false }); + expect(await readVaultInput({ ...body, public_key: undefined })).toMatchObject({ ok: false }); + }); + + it("refuses a replace_version that is not a version", async () => { + const body = await browserVault(); + for (const replace_version of [0, -1, 1.5, "1", null]) { + expect(await readVaultInput({ ...body, replace_version })).toMatchObject({ ok: false }); + } + expect(await readVaultInput({ ...body, replace_version: 3 })).toMatchObject({ + ok: true, + value: { replaceVersion: 3 }, + }); + }); +}); + +describe("the CLI's own copy of a password", () => { + it("is accepted when it is a vault share", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "Kw9eHbru"); + expect(await readOwnerShare({ sender_public_key: share.senderPublicKey, sealed: share.sealed })).toEqual(share); + }); + + it("is dropped when it is anything else", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "Kw9eHbru"); + expect(await readOwnerShare(undefined)).toBeNull(); + expect(await readOwnerShare("share")).toBeNull(); + /* Without the prefix it is a browser-key share, which the CLI never makes. */ + expect(await readOwnerShare({ sender_public_key: share.senderPublicKey, sealed: share.sealed.slice(3) })).toBeNull(); + expect(await readOwnerShare({ sender_public_key: "junk", sealed: share.sealed })).toBeNull(); + expect(await readOwnerShare({ sender_public_key: share.senderPublicKey, sealed: "v2." })).toBeNull(); + }); +}); + +/* + * The CLI seals a session's password when it registers the session, and the + * browser opens it. The vector was sealed by the Go code with fixed inputs + * (internal/account/vault_test.go regenerates and checks it), so the two + * implementations cannot drift apart unnoticed. The Go tests open the + * browser's vector in the other direction. + */ +describe("a password the CLI sealed", () => { + it("opens in the browser", async () => { + const vector = JSON.parse( + readFileSync( + fileURLToPath(new URL("../../../internal/account/testdata/vault-share-v2-go.json", import.meta.url)), + "utf8", + ), + ) as { + recipient_private_key_hex: string; + recipient_public_key: string; + session_id: string; + uid: string; + password: string; + sender_public_key: string; + sealed: string; + }; + const point = decode(vector.recipient_public_key); + const scalar = Uint8Array.from(vector.recipient_private_key_hex.match(/../g)!, (pair) => parseInt(pair, 16)); + const privateKey = await crypto.subtle.importKey( + "jwk", + { + kty: "EC", + crv: "P-256", + d: encode(scalar), + x: encode(point.slice(1, 33)), + y: encode(point.slice(33, 65)), + }, + { name: "ECDH", namedCurve: "P-256" }, + false, + ["deriveBits"], + ); + const opened = await openFromAccount(privateKey, vector.session_id, vector.uid, { + senderPublicKey: vector.sender_public_key, + sealed: vector.sealed, + }); + expect(opened).toBe(vector.password); + }); +}); diff --git a/app/server/lib/vault.ts b/app/server/lib/vault.ts new file mode 100644 index 0000000..4b9e116 --- /dev/null +++ b/app/server/lib/vault.ts @@ -0,0 +1,124 @@ +import type { AccountKey, SessionKeyShare } from "./types"; + +/** + * The shapes a session vault arrives in, checked before anything is stored. + * + * Nothing here can be verified cryptographically: the service holds no key + * that opens any of it, which is the point. What it can check is that each + * field is the size and encoding the browser produces, so the store cannot + * fill with junk that looks like somebody's vault, and that the public key is + * a real P-256 point, since colleagues and the CLI will seal passwords to it. + */ + +const BASE64URL = /^[A-Za-z0-9_-]+$/; + +/* An uncompressed P-256 point is 65 bytes, which is 87 base64url characters. */ +const PUBLIC_KEY_LENGTH = 87; +/* Nonce, a 32-byte vault key, and the GCM tag. */ +const RECOVERY_WRAP_BYTES = 12 + 32 + 16; +/* Nonce, a PKCS#8 P-256 key (about 138 bytes), and the tag, with room to spare. */ +const PRIVATE_KEY_MIN_BYTES = 12 + 64 + 16; +const PRIVATE_KEY_MAX_BYTES = 512; +/* A share holds a password of at most 1,024 bytes, which is what the CLI accepts. */ +const SHARE_MAX_BYTES = 12 + 1024 + 16; + +/** The prefix of a password sealed to an account key rather than a browser key. */ +export const VAULT_SHARE_PREFIX = "v2."; + +/** + * How recently someone must have signed in to replace their vault. + * + * A reset puts a new key where colleagues will seal passwords, so it is the + * one vault operation worth more than a long-lived session token. Signing in + * again is the proof asked for. + */ +export const RESET_SIGN_IN_WINDOW_MS = 10 * 60_000; + +function decodedLength(value: string): number | null { + if (!BASE64URL.test(value) || value.length % 4 === 1) return null; + return Math.floor((value.length * 3) / 4); +} + +function fromBase64Url(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); +} + +/** True for a base64url P-256 public key that is really on the curve. */ +export async function isP256PublicKey(value: unknown): Promise { + if (typeof value !== "string" || value.length !== PUBLIC_KEY_LENGTH || decodedLength(value) !== 65) { + return false; + } + try { + await crypto.subtle.importKey("raw", fromBase64Url(value), { name: "ECDH", namedCurve: "P-256" }, true, []); + return true; + } catch { + return false; + } +} + +export interface VaultInput { + publicKey: string; + encryptedPrivateKey: string; + recoveryWrap: string; + /** Present when the caller means to replace the vault at this version. */ + replaceVersion?: number; +} + +export type VaultInputResult = { ok: true; value: VaultInput } | { ok: false; reason: string }; + +export async function readVaultInput(body: Record): Promise { + const { public_key: publicKey, encrypted_private_key: encryptedPrivateKey, recovery_wrap: recoveryWrap } = body; + if (!(await isP256PublicKey(publicKey))) return { ok: false, reason: "invalid public key" }; + + const privateLength = typeof encryptedPrivateKey === "string" ? decodedLength(encryptedPrivateKey) : null; + if (privateLength === null || privateLength < PRIVATE_KEY_MIN_BYTES || privateLength > PRIVATE_KEY_MAX_BYTES) { + return { ok: false, reason: "invalid encrypted private key" }; + } + if (typeof recoveryWrap !== "string" || decodedLength(recoveryWrap) !== RECOVERY_WRAP_BYTES) { + return { ok: false, reason: "invalid recovery wrap" }; + } + + const replace = body.replace_version; + if (replace !== undefined && (typeof replace !== "number" || !Number.isInteger(replace) || replace < 1)) { + return { ok: false, reason: "invalid replace_version" }; + } + + return { + ok: true, + value: { + publicKey: publicKey as string, + encryptedPrivateKey: encryptedPrivateKey as string, + recoveryWrap, + replaceVersion: replace as number | undefined, + }, + }; +} + +/** What a browser is told about its own vault: everything, since none of it is readable without the recovery key. */ +export function vaultForApi(key: AccountKey) { + return { + publicKey: key.publicKey, + encryptedPrivateKey: key.encryptedPrivateKey, + recoveryWrap: key.recoveryWrap, + version: key.version, + createdAt: key.createdAt, + updatedAt: key.updatedAt, + }; +} + +/** + * The copy of a session password the CLI seals to its own account. + * + * Returns null for anything that is not shaped like one. A session must still + * register when this is wrong, so the caller drops it rather than failing. + */ +export async function readOwnerShare(value: unknown): Promise | null> { + if (!value || typeof value !== "object") return null; + const { sender_public_key: senderPublicKey, sealed } = value as Record; + if (!(await isP256PublicKey(senderPublicKey))) return null; + if (typeof sealed !== "string" || !sealed.startsWith(VAULT_SHARE_PREFIX)) return null; + const length = decodedLength(sealed.slice(VAULT_SHARE_PREFIX.length)); + if (length === null || length < 12 + 16 + 1 || length > SHARE_MAX_BYTES) return null; + return { senderPublicKey: senderPublicKey as string, sealed }; +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 533720f..2394d7b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,11 +13,14 @@ import { Terms } from "./routes/Terms"; import { Session } from "./routes/Session"; import { Audit } from "./routes/Audit"; import { CliAuthorize } from "./routes/CliAuthorize"; +import { VaultProvider } from "./vault/VaultProvider"; +import { VaultGate } from "./vault/VaultGate"; export default function App() { return ( + } /> - + + + } /> @@ -92,7 +97,9 @@ export default function App() { path="/sessions" element={ - + + + } /> @@ -103,6 +110,7 @@ export default function App() { } /> } /> + ); diff --git a/app/src/auth/AuthProvider.tsx b/app/src/auth/AuthProvider.tsx index b195f1f..7f468f2 100644 --- a/app/src/auth/AuthProvider.tsx +++ b/app/src/auth/AuthProvider.tsx @@ -20,6 +20,7 @@ import { } from "firebase/auth"; import { auth, googleProvider } from "../lib/firebase"; import { setPasswordOwner } from "../lib/session-passwords"; +import { clearLocalVault } from "../lib/vault-store"; interface AuthValue { user: User | null; @@ -94,11 +95,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signOutUser = useCallback(async () => { /* - * Stored session passwords are keyed by account, so signing out does not - * expose them to whoever signs in next. They are deliberately kept: they - * are how this person reopens their own sessions, and how they share them - * with colleagues who join later. + * Signing out locks the vault in this browser, so a shared computer does + * not keep an unlocked key for whoever signed out. The vault itself is + * untouched and the next sign-in unlocks it with the recovery key. + * + * Cached session passwords are keyed by account, so they are not exposed + * to whoever signs in next, and they are only a cache now: the vault holds + * the copies that matter. */ + const current = auth.currentUser; + if (current) await clearLocalVault(current.uid); await signOut(auth); }, []); diff --git a/app/src/components/NewSessionModal.tsx b/app/src/components/NewSessionModal.tsx index f0e5991..95f5eda 100644 --- a/app/src/components/NewSessionModal.tsx +++ b/app/src/components/NewSessionModal.tsx @@ -229,8 +229,9 @@ export function NewSessionModal({ {chosenMachine.label} is running an older build that - cannot receive a password. Update shell there, or the session - will ask for one you cannot see. + cannot receive a password, so a session started there could + never be opened here. Update shell on that machine, then try + again.

)} @@ -308,7 +309,7 @@ export function NewSessionModal({ type="submit" busy={busy} busyLabel="Starting" - disabled={!command || !machine || !machineReady} + disabled={!command || !machine || !machineReady || !chosenMachine?.agentPublicKey} > Start session diff --git a/app/src/components/SessionAudience.tsx b/app/src/components/SessionAudience.tsx index 79c9112..41614fe 100644 --- a/app/src/components/SessionAudience.tsx +++ b/app/src/components/SessionAudience.tsx @@ -1,22 +1,23 @@ import { useEffect, useState } from "react"; -import { UserPlus } from "@phosphor-icons/react"; +import { UserPlus, Warning } from "@phosphor-icons/react"; import { Avatar } from "./Avatar"; +import { Button } from "./Button"; import { PersonPicker } from "./PersonPicker"; import { shareSessionKeys, type Member, type SessionRecord } from "../lib/api"; -import { openSealed, sealForMembers } from "../lib/keypair"; import { addToAudience, audienceFor, passwordFor } from "../lib/session-passwords"; +import { keyTrust, trustKey } from "../lib/known-keys"; import { displayName } from "../lib/people"; +import { useVault } from "../vault/VaultProvider"; /** * Who else can open this session. * - * The password is sealed once per person, so this list is the whole of the - * answer: a colleague who is not on it holds nothing, whatever the session - * says about who it is assigned to. Assigning a session to somebody used to - * appear to hand it over because the password had already gone to everyone. + * The password is sealed once per person, to their vault, so this list is the + * whole of the answer: a colleague who is not on it holds nothing, whatever + * the session says about who it is assigned to. * - * Only ever adds. Removing somebody here would not reach into their browser - * and take back the copy they hold, so a control that offered it would be + * Only ever adds. Removing somebody here would not reach into their vault and + * take back the copy they hold, so a control that offered it would be * describing something that did not happen. */ export function SessionAudience({ @@ -28,55 +29,65 @@ export function SessionAudience({ members: Member[]; you: Member | null; }) { + const vault = useVault(); const [audience, setAudience] = useState(() => audienceFor(session.id)); + const [password, setPassword] = useState(null); const [busy, setBusy] = useState(""); const [error, setError] = useState(""); - const [held, setHeld] = useState(false); + /* Someone whose vault key changed, waiting for the owner to say go ahead. */ + const [confirming, setConfirming] = useState(null); const isOwner = Boolean(you && session.ownerUid === you.uid); + const sealed = session.keyShare ? `${session.keyShare.senderPublicKey}:${session.keyShare.sealed}` : ""; /* Only somebody holding the password has anything to seal. */ useEffect(() => { let live = true; void (async () => { - const own = passwordFor(session.id); - if (own) return live && setHeld(true); - const share = session.keyShare; - const opened = share ? await openSealed(share.senderPublicKey, share.sealed) : null; - if (live) setHeld(Boolean(opened)); + const opened = passwordFor(session.id) ?? (await vault.openShare(session.id, session.keyShare)); + if (live) setPassword(opened); })(); return () => { live = false; }; - }, [session]); + /* eslint-disable-next-line react-hooks/exhaustive-deps -- content, not identity */ + }, [session.id, sealed, vault.openShare]); - if (!isOwner || !held) return null; + if (!isOwner || !password || !you) return null; - const shared = members.filter((member) => audience.includes(member.uid)); + /* Who holds a copy: what the service stores, and what this browser has sent. */ + const holders = new Set([...(session.sharedWith ?? []), ...audience]); + const shared = members.filter((member) => member.uid !== you.uid && holders.has(member.uid)); const candidates = members.filter( - (member) => - member.uid !== you?.uid && !audience.includes(member.uid) && Boolean(member.publicKey), + (member) => member.uid !== you.uid && !holders.has(member.uid) && Boolean(member.accountKey), ); - async function add(uid: string) { + async function add(uid: string, confirmedChange = false) { const member = members.find((candidate) => candidate.uid === uid); - if (!member?.publicKey) return; - const password = passwordFor(session.id); - if (!password) return setError("This browser no longer holds the password for this session."); + if (!member?.accountKey || !password || !you) return; + + /* + * A key this browser has sealed to before, and that has since changed, is + * either a colleague who reset their vault or a key that is not theirs. + * Nothing is sealed to it until the owner has seen that and said so. + */ + if (keyTrust(you.uid, member.uid, member.accountKey) === "changed" && !confirmedChange) { + setConfirming(uid); + return; + } setBusy(uid); setError(""); + setConfirming(null); try { - const sealed = await sealForMembers([member], password); - await shareSessionKeys( - session.id, - sealed.map((entry) => ({ - uid: entry.uid, - sender_public_key: entry.senderPublicKey, - sealed: entry.sealed, - })), - ); - setAudience(addToAudience(session.id, [uid])); + const share = await vault.sealTo(member, session.id, password); + if (!share) throw new Error(`${displayName(member)} has not set up a vault yet.`); + await shareSessionKeys(session.id, [ + { uid: member.uid, sender_public_key: share.senderPublicKey, sealed: share.sealed }, + ]); + trustKey(you.uid, member.uid, member.accountKey); + const kept = addToAudience(session.id, [uid]); + setAudience((current) => [...new Set([...current, ...kept, uid])]); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not share the session."); } finally { @@ -84,6 +95,8 @@ export function SessionAudience({ } } + const waiting = confirming ? members.find((member) => member.uid === confirming) : undefined; + return (

Who can open it

@@ -92,15 +105,50 @@ export function SessionAudience({

Only you. Add someone and they can open it straight away.

) : (
    - {shared.map((member) => ( -
  • - - {displayName(member)} -
  • - ))} + {shared.map((member) => { + const changed = + member.accountKey && keyTrust(you.uid, member.uid, member.accountKey) === "changed"; + return ( +
  • + + {displayName(member)} + {changed && ( + + )} +
  • + ); + })}
)} + {waiting && ( +
+

+ + + {displayName(waiting)}'s vault key has changed since you last + shared with them. That happens when someone resets their vault. If + you did not expect it, check with them before sharing. + +

+
+ + +
+
+ )} + {candidates.length > 0 && (
diff --git a/app/src/components/SessionClipboard.tsx b/app/src/components/SessionClipboard.tsx index f38f13f..92abae9 100644 --- a/app/src/components/SessionClipboard.tsx +++ b/app/src/components/SessionClipboard.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { CaretDown, Copy, Check, Link as LinkIcon, Lock, Terminal, Warning } from "@phosphor-icons/react"; import type { Member, SessionRecord } from "../lib/api"; import { passwordFor } from "../lib/session-passwords"; -import { openSealed } from "../lib/keypair"; import { COPY_FAILED, useCopy } from "../lib/clipboard"; +import { useVault } from "../vault/VaultProvider"; /** * The one place a session can be copied from. @@ -20,17 +20,16 @@ type Item = "link" | "password" | "attach"; * Where the password comes from, and why the service is not in the list. * * A session password never reaches the service in the clear. This browser - * either chose it, or holds a copy a colleague sealed to this browser's public - * key. So the answer to "may this person copy it" is not a permission the - * service grants: they either hold a copy or they do not, and someone outside - * the team holds nothing whatever the interface says. + * either holds it already, or opens the copy sealed to this person's vault. + * So the answer to "may this person copy it" is not a permission the service + * grants: they either hold a copy or they do not, and someone outside the + * team holds nothing whatever the interface says. */ -async function readPassword(session: SessionRecord): Promise { - const own = passwordFor(session.id); - if (own) return own; - const share = session.keyShare; - if (!share) return null; - return openSealed(share.senderPublicKey, share.sealed); +async function readPassword( + session: SessionRecord, + openShare: (sessionId: string, share: SessionRecord["keyShare"]) => Promise, +): Promise { + return passwordFor(session.id) ?? openShare(session.id, session.keyShare); } export function SessionClipboard({ @@ -67,16 +66,17 @@ export function SessionClipboard({ const sealed = session.keyShare ? `${session.keyShare.senderPublicKey}:${session.keyShare.sealed}` : ""; + const { openShare } = useVault(); useEffect(() => { let live = true; - void readPassword(session).then((value) => { + void readPassword(session, openShare).then((value) => { if (live) setPassword(value); }); return () => { live = false; }; /* eslint-disable-next-line react-hooks/exhaustive-deps -- see above */ - }, [session.id, sealed]); + }, [session.id, sealed, openShare]); useEffect(() => { if (!open) return; diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 95dad3f..92141da 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -26,8 +26,10 @@ export interface Member { name: string; role: Role; joinedAt: number; - /** Their browser key, so a session password can be sealed to them. */ + /** A browser key from before the vault. Kept only so old shares still name it. */ publicKey?: string; + /** Their vault's public key; absent until they have set one up. */ + accountKey?: string; } export interface Team { @@ -108,6 +110,36 @@ export function shareSessionKeys( ); } +/** Your vault as the service holds it: a public key and two pieces of ciphertext. */ +export interface VaultRecord { + publicKey: string; + encryptedPrivateKey: string; + recoveryWrap: string; + version: number; + createdAt: number; + updatedAt: number; +} + +export function fetchVault() { + return request<{ vault: VaultRecord | null }>("/api/vault"); +} + +/** Saves a new vault, or replaces the one at `replaceVersion` when resetting. */ +export function saveVault( + bundle: { publicKey: string; encryptedPrivateKey: string; recoveryWrap: string }, + replaceVersion?: number, +) { + return request<{ vault: VaultRecord | null }>("/api/vault", { + method: "POST", + body: JSON.stringify({ + public_key: bundle.publicKey, + encrypted_private_key: bundle.encryptedPrivateKey, + recovery_wrap: bundle.recoveryWrap, + replace_version: replaceVersion, + }), + }); +} + export function assignSession(sessionId: string, uids: string[]) { return request<{ session: SessionRecord }>( `/api/sessions/${encodeURIComponent(sessionId)}/assignee`, @@ -186,6 +218,12 @@ export interface SessionRecord { deviceId?: string; /** The password sealed to the caller, when one has been shared with them. */ keyShare?: { senderPublicKey: string; sealed: string }; + /** + * For the session's owner: who else holds a sealed copy. The service knows + * this because it stores the copies; it is shown, never used to decide who + * to seal to. + */ + sharedWith?: string[]; readOnly: boolean; encrypted: boolean; persistent: boolean; diff --git a/app/src/lib/keypair.ts b/app/src/lib/keypair.ts index 4cd4b9c..1157b25 100644 --- a/app/src/lib/keypair.ts +++ b/app/src/lib/keypair.ts @@ -1,5 +1,13 @@ /** - * This browser's key pair, used to receive session passwords from colleagues. + * This browser's key pair, from before the session vault. + * + * Kept only to open passwords a colleague sealed to this browser before the + * vault existed. Nothing new is sealed to it and it is no longer published: + * shares now go to the account's vault key (see vault-crypto.ts), which every + * browser the person unlocks can open. Each old share is resealed to the vault + * the first time it opens a session here. + * + * What follows describes how it worked. * * A session's password is chosen by whoever starts it. For anyone else in the * team to open that session, the password has to reach them without diff --git a/app/src/lib/known-keys.ts b/app/src/lib/known-keys.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd4f446f979da0dc460c78734cb6ee5bbfa4232f GIT binary patch literal 1588 zcmb7E(Q4Z;6y3AG;w}`Hgv@2H>9R4#9=gH?YhjErsA^xwqK;%FIc~`E?>kqP({z-< zo@~qa9-VXUkCV~Sv!hBoAIPg7%e=|a zD%~Hb82ev&YP-8e7XpCg4STAb-AFwFVXF%UtSL#2pZKK$^~wY;1ua-|tYSf}9G9r` zURy)G?V6HmX|`PefNWta(!rDM4Qb;88gse7#rx0@z$;D5J?OYg5GdKfIQD^ za=HxCaZ=7(b~}<%K~q&=w@giua{(@E2m}Ia2z2;)bb|U?gm{kIYL|SqPOv)+(jSK6_w>~(~T7Z;1-=T^hTI(Pp7W3}Lb-)L< z;eJBDX%b=M+u%nj&{b!OAVZT;PG`%s%|0Uv@Kl}zuY{^YnpV_nQ`$anY@wR#V4YfX zzUFWlcr%5V*+uMd96AS3w7jS3=j*Ru@>V&|Q>@G+tx?U4mb5=e#{m-!MV%LFLA|mp-5<>`MR(z&ITfZoJmk6g7PUzym#J0W_n0f-9OnXY zoD3Vtnqv5p>T&FyF2@fTFsw!kl6SXf``B@KakfY8;r3$SGpFx05)B$Lzj89cM-Dwg zI;@R%NR2T`oCo%IjN}i@QqYbB0TRRZ4~~m;+g$N(WGh)4Qz|hVLMA61DI3!G^F;cB zAY=AR!lEmhzLdfl>SE4f#^qP~9#B@R$6XP_@c=Ao@^}E^kml)7!Tfkv#oIJFVhM7& y2*d4&GYF&NeExPJrU8on+l6C#2M1!dj~0lixbln_C>ri?>?(C9X9Gb#WPbn>oD^gL literal 0 HcmV?d00001 diff --git a/app/src/lib/seal.ts b/app/src/lib/seal.ts index 9088af9..aa6908c 100644 --- a/app/src/lib/seal.ts +++ b/app/src/lib/seal.ts @@ -27,11 +27,15 @@ function fromBase64Url(value: string): Uint8Array { } /** - * A browser password in the shape the CLI generates: eight base64url - * characters from six random bytes. + * A browser password for a session started here: base64url from sixteen + * random bytes. + * + * The CLI's own default is eight characters, a trade for someone who has to + * type it. Nobody types this one. It travels to the machine sealed and to the + * vault sealed, so there is no reason for it to be weaker than a key. */ export function generatePassword(): string { - return toBase64Url(crypto.getRandomValues(new Uint8Array(6))); + return toBase64Url(crypto.getRandomValues(new Uint8Array(16))); } export interface Sealed { diff --git a/app/src/lib/session-passwords.test.ts b/app/src/lib/session-passwords.test.ts index 621b350893fbad8c804f136c07c2143b09a76ebc..ece8630fd45b1dc14ef84a490d34e9fc1175a5ce 100644 GIT binary patch literal 11047 zcmd5?ZEqVl628y=6@+j=lz22csaY^q>5G2$tC9z&DRqn17A@F~{ zXNE7!l~ym#3(%siZHb)a%seym%y9K$X>2V%h>4mStA3Q(d?a#JWmZqrNT~NqmDMAm z@!v{|J2ACpAqFd5tEwK{oN2%PObD6hugtS1*Q(60Q|4w_zp#3yOZ?$Kes1hYWO#!s9Dd zhsV8Gm&%ULf+FKbm8vRj%8MYdf>vkKrp#)5DyH&Qy{wIuGc`OHA8?G!lvRzxEX5O1 zsiYwHV&2&9z^6E}r~AlE(O!K0g;v zpNg`90kB;NoO*W;+#riabwsR;&RGt2kQ~3}+=+p4%u4M_;-|;0xhhnx_!MqiRw!w4 z-grpwr2OWQ+XmV=No|XZ@Y}*TgoY0b#+#;V^&{Kpzh1v{-$9KCaC_)vv$}0gsm_ z!IuaP8%`BD+&TuEQXm%$2mF9an6lX5e-!VPfdLCylf_&}A<)ax)FLaSUO1!3g^D)+ z1Z$k4-hnk&r(1Q~1P43;sa;PX2An_0U=#_A{8%@Hzz8S4L*5s)P-4_FOhiQHJFky_6YP^CdqC}+?RCFJ%#fwi{S7yO^< z(#BplOxzLLXgi|UPCRx{pl2nDY$X&Nw-LHrHc-w=ILN|%vV><1l@jp>GUt}}d(s7E zisd?J)3X5igw})=JNhm(iXZF|{P#Yr+?#K(;())Z#;!D_MbZN`6WAneno_Q$F65+8 zTT1Os`ixMqmHI#UPh&krM*aZ9AyacL{?S@Wj1v@$TV(9>8*%4~SyE2#vjrCQrStS% zd+MG$hpSdXAd;UUe)S6ZQn*Sds<~;39NcSWWsYbzKEPqX5hV`#M2gJHYQ7!9t}ylc z-_;XB$@v&kP%B=My&Ok5kj@SR`BtfA<#IYa4L=*o1>GVkxT+4pD})AFy1c=U@5R+O z{L5EQ#N+dx68Mfv;NL&!yLTh;Zn<{9`Xaak->ZLo7okhPA1ju;#=&=pzvbHHp z3SM8$A5^h|h&A|}TIme5NK7_js;BkFNdhBADl?_3#^PuAol-aReHdzuQiZCA2YaZy zGDc7mYs%_at@#{d7DiQyN+&tOVGWcN&WS3x6LV-dYmn=_-n)4QtDl3-ztRh3s{nu4 z#xrzBKvn&6Q-P?{s^R0emzS3wQyseIs{xfDa_X&G*1@Iypnsf#zRhBk{W?#)q9=;4 zgpN<`KKC8hraj?-N<((TKLGRZ@TQP6N!=F!45P!k@Bu?)VWJr$UVHTarlWt_f1Pds zuQ}rW+a&G}nBd#bV}iW}jGgAA3$(1`y1kov;*mNWa35J9mV!F)Cs6iZC5tY~$S;)o znGaF~bNcrkEJu@TzjDNZa?DMF3ldwNqHe;I$o;v>?1uQ>dakoMxf3xhBnbb(Z+kq#abNQnChVrXOWf*8IOGxRnUddv+fy2y2v zNsDw(YZ0s9lWl|LP8S-LxQB~08o{;PbV8H4F&C4h8|&!79G0|3tt3uM7O7mP(UH`N zl%0l(rIZ(r)!uNN>||)}y!zN*JBWxp5MkdV(gUbF;fQ+}b-&0uo4=U+fw9W)LrkgH zU2*8({iNes)V5KKo(_|ANfE8W1~n>*L39&4W;>ZBpakT!&FFq}#Uny3NHigveTqIT zCskxBfheyDr%6!ze#`b8Apq+6{?4~!A4gDj>_fdOCBr!?*vA?OGp%{d_dQvWVbodV zdWe2qI}1O>-cC?z@IA;0w1pu?!5wTX(4B@xgkXa*&b9)hIM8CH*qN(>Xg7z~KFj2K zIz@gdqX~~F{d62+!813;5kDm9uOk_wz({op-LXX4@(*UkTw*;fBvQ2w`7%Bs*ubk6 z4BP|W;h+_ssN!SJ_Mf7ipS$viF?Y$&Cs7NH zHjYQgy7VJKcZ}L9iOLH}3mS{!CuTD=Bh4i0?N%{O-0o`1j#?d0Q!#~js0qo1n#l~z zpAHp{9wNyfp61tF**j8%??}h#+?9wb&5(zlVh$?cwisgoi22X8Cm`(*{d)EFAhh29 ze)-~8jCN_Rp{E-ZJvW^Fo8QEn-@-YAYc%sAR8SDWrizXA2S^Mh!Q z-b0up(Oi0e{IkJi`M@)Rd1%OtS~c=E+*gA!Ob+J7T$=eML7i_?PDdG*QA>!4#MWAblZ+OL|u5 zf=V+n`G%$&7&IVpqgGO~r;HoB-2)d!)IcYe-svQ^uVzkZBTnG~sa#I7{b#(#ouGbh)I$Tr_qUUi%hC)f03I35wT&Qii_{ixF`J+QnjydBL!|r+5 z6-8Q?>FhksuW|Qsjs~A%*jDO+^WDo;iBWo3C57PzO*Y~g;YaZ(a3A&gT#`bWz_nwi zy`460|HEkRxZwi6J6H_NIYk`BKV7)hrpZ`q=o34E9L;FY^37d3J`Ml^FuB3fyj>+nz&JMhw@NLXV8I`x#UcD z-^kK|l~b2pVH)OXc{mXKz|-N{$`z(egn4jppQB^qK*e$Qnr?PUF5@DBV8N>uj@O1I z{H4O(Q>L6WPYRr$J;SF0M0e8L^#bz!O*I!1%*9kO?T(-^?y}T1kUTf`iG1|f8vvgV zVlk>l;x$uf1_Zt6@b%!0cl{~oJ04hjZ@q)JJCGVzc!Ow|Fzh|2OA-;+<=BLAx#oV> zmu|XWaAy7j*1Nx@dbK9@Rb`e1n9>x4W{q*FQowyrOHuX+j|N0X24v>D6!aSHZNN8Vj+uFao$%6$KHP2c- zn8phXa4F5q=xP{gjBY4K0_xL;%$O~hI1%3J7JWY5@OW0+J4$!+4J2S*i3@<1C+8Yp z;wtc?;g=x9wICO|4AtwDB*Xf~JxTmTZ{o^=-fKY=dE3&6#x)GB_wfb=4{?H$AFa<0 z)psl^_5SHM2T6@j?bK2|Vic!Xsdq=PM<)>{DaqFdhtR~bZY-X3aJbeDviNewzk-BI wRgAhGY>$19gi@mOjxF&*6D7JfkWMq@0YZHIzQ5-khG_Gk^*#%>rX%zHAJ)>O_y7O^ delta 91 zcmZ1;)^9qYcw)0KUqNDVae01Gid%k>4wu5lBWpRiEexy`G8MFK6$~{eZ&VeYJb_D> u*W6M`M?tALwYWIm+;Wnfq5x1pQ&X=b-zil?sh~XGeDZ&;b(;lb#Q6bVY8+7j diff --git a/app/src/lib/session-passwords.ts b/app/src/lib/session-passwords.ts index 20ecf3aa1a7c40c955141e18c776d8c8929bf25a..48e9161a55add02e290e28886bd0e9c5cd41ab9d 100644 GIT binary patch literal 7119 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 delta 722 zcmY*XO=}ZD7-lzUn#5`?DOFqbRixCA4?Rfn6oU59N>!?Y5Q|K9-(<(^&V-psLqHJ( z@nA19XT^j6U@v<1EDHVrul@k>;z8e8Q*HM4em!5apVyvUXf!I7B2?fu(|ROTsG%zr zXw;fZp}|C4Lr2MxMg@H#N3R;IPh0zQ(1t04A#{~YAVP%@F%1j`6SV33Ad(uZpe424 zF4vA(NFotEFcJm?GcZm;9a;xK#slKuf;Q+7x; z2B!NOqzDica~cSb32FfDq8fw3ShBDV8-~E_d+*v+U_l^Ok`CQwxxmxwVF($%HbtH#z=z@R_W)>9&^cEny zR^$Tjiv5~Tvph|xi<~+MRd(Q5aR(yD3<>CqZ<=9O6 zqcrsFqs7xy3o$63L)#&bxhf>x^X>5(K8X$V5@ci;rh+H+Z*e5F4{Lsm;V5i CoBNmm diff --git a/app/src/lib/session-share.test.ts b/app/src/lib/session-share.test.ts index 22ec332..38991f5 100644 --- a/app/src/lib/session-share.test.ts +++ b/app/src/lib/session-share.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { sealTargets, shareCandidates } from "./session-share"; import type { Member } from "./api"; -function member(uid: string, publicKey?: string): Member { +function member(uid: string, accountKey?: string): Member { return { orgId: "org", uid, @@ -10,7 +10,7 @@ function member(uid: string, publicKey?: string): Member { name: uid, role: "member", joinedAt: 0, - publicKey, + accountKey, }; } @@ -22,7 +22,7 @@ describe("who can be offered a session password", () => { expect(reachable.map((entry) => entry.uid)).toEqual(["a"]); }); - it("separates people with no browser key from people with one", () => { + it("separates people with no vault from people with one", () => { const { reachable, unreachable } = shareCandidates( [you, member("a", "key-a"), member("b")], you, @@ -31,6 +31,14 @@ describe("who can be offered a session password", () => { expect(unreachable.map((entry) => entry.uid)).toEqual(["b"]); }); + /* A browser key from before the vault is not somewhere to seal a new password. */ + it("does not count an old browser key as a vault", () => { + const legacy: Member = { ...member("c"), publicKey: "browser-key" }; + const { reachable, unreachable } = shareCandidates([you, legacy], you); + expect(reachable).toEqual([]); + expect(unreachable.map((entry) => entry.uid)).toEqual(["c"]); + }); + it("offers the whole team to somebody who is not in it", () => { const { reachable } = shareCandidates([member("a", "key-a")], null); expect(reachable.map((entry) => entry.uid)).toEqual(["a"]); @@ -55,7 +63,7 @@ describe("who a session password is sealed to", () => { expect(targets.map((entry) => entry.uid)).toEqual(["a"]); }); - it("skips somebody chosen who has no key to seal to", () => { + it("skips somebody chosen who has no vault to seal to", () => { const targets = sealTargets({ members: team, you, chosen: ["c"] }); expect(targets).toEqual([]); }); diff --git a/app/src/lib/session-share.ts b/app/src/lib/session-share.ts index 92f876d..0e9c1c1 100644 --- a/app/src/lib/session-share.ts +++ b/app/src/lib/session-share.ts @@ -13,9 +13,9 @@ import type { Member } from "./api"; /** * Splits the team into people a password can reach and people it cannot. * - * A member who has never opened the app in a browser has published no public - * key, so there is nowhere to send them anything. Offering them a tick box - * would be offering something that silently does nothing. + * A member who has not set up their vault has no key to seal to, so there is + * nowhere to send them anything. Offering them a tick box would be offering + * something that silently does nothing. */ export function shareCandidates( members: Member[], @@ -23,8 +23,8 @@ export function shareCandidates( ): { reachable: Member[]; unreachable: Member[] } { const others = members.filter((member) => member.uid !== you?.uid); return { - reachable: others.filter((member) => Boolean(member.publicKey)), - unreachable: others.filter((member) => !member.publicKey), + reachable: others.filter((member) => Boolean(member.accountKey)), + unreachable: others.filter((member) => !member.accountKey), }; } diff --git a/app/src/lib/vault-crypto.test.ts b/app/src/lib/vault-crypto.test.ts new file mode 100644 index 0000000..de41bcc --- /dev/null +++ b/app/src/lib/vault-crypto.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { + createVault, + fingerprint, + formatRecoveryKey, + isVaultShare, + openFromAccount, + openVault, + parseRecoveryKey, + sealToAccount, + VaultError, +} from "./vault-crypto"; + +/* + * These tests are the vault's claim: that a password sealed to an account can + * be opened by that account in any browser holding its recovery key, and by + * nothing the service holds. Each names the property it keeps. + * + * That the CLI and this code agree on the share format is held by + * server/lib/vault.test.ts, which can read the Go test vector from disk. + */ + +function decode(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); +} + +function encode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/* Flips a bit in the bytes, not the text; see keypair.test.ts for why. */ +function tamper(envelope: string, index: number): string { + const bytes = decode(envelope); + bytes[index] ^= 0x01; + return encode(bytes); +} + +describe("recovery keys", () => { + it("are eight groups of four from an alphabet with nothing that reads as something else", () => { + const text = formatRecoveryKey(crypto.getRandomValues(new Uint8Array(20))); + expect(text).toMatch(/^([0-9A-HJKMNP-TV-Z]{4}-){7}[0-9A-HJKMNP-TV-Z]{4}$/); + }); + + it("read back to the same bytes", () => { + const bytes = crypto.getRandomValues(new Uint8Array(20)); + expect(parseRecoveryKey(formatRecoveryKey(bytes))).toEqual(bytes); + }); + + it("forgive the ways a person types one back", () => { + const bytes = new Uint8Array(20).fill(0); + bytes[19] = 1; + const text = formatRecoveryKey(bytes); + expect(text).toBe("0000-0000-0000-0000-0000-0000-0000-0001"); + const typed = "oooo oooo OOOO-0000 0000 0000 0000 000l"; + expect(parseRecoveryKey(typed)).toEqual(bytes); + expect(parseRecoveryKey(text.toLowerCase().replace(/-/g, ""))).toEqual(bytes); + }); + + it("refuse anything that is not one", () => { + expect(parseRecoveryKey("")).toBeNull(); + expect(parseRecoveryKey("0000-0000")).toBeNull(); + expect(parseRecoveryKey("UUUU-0000-0000-0000-0000-0000-0000-0000")).toBeNull(); + }); +}); + +describe("a vault", () => { + it("opens with its recovery key and yields the same key pair", async () => { + const made = await createVault("uid-1"); + const opened = await openVault("uid-1", made.bundle, parseRecoveryKey(made.recoveryKey)!); + expect(opened.publicKey).toBe(made.bundle.publicKey); + + /* What one sealed, the other opens: they are one key pair. */ + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "Kw9eHbru"); + expect(await openFromAccount(opened.privateKey, "sess_1", "uid-1", share)).toBe("Kw9eHbru"); + expect(await openFromAccount(made.opened.privateKey, "sess_1", "uid-1", share)).toBe("Kw9eHbru"); + }); + + it("keeps its private key unreadable by script once opened", async () => { + const made = await createVault("uid-1"); + const opened = await openVault("uid-1", made.bundle, parseRecoveryKey(made.recoveryKey)!); + expect(opened.privateKey.extractable).toBe(false); + expect(made.opened.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey("pkcs8", opened.privateKey)).rejects.toThrow(); + }); + + it("gives the service nothing that contains the recovery key", async () => { + const made = await createVault("uid-1"); + const stored = JSON.stringify(made.bundle); + expect(stored).not.toContain(made.recoveryKey); + expect(stored).not.toContain(made.recoveryKey.replace(/-/g, "")); + }); + + it("refuses the wrong recovery key", async () => { + const made = await createVault("uid-1"); + const other = await createVault("uid-1"); + await expect(openVault("uid-1", made.bundle, parseRecoveryKey(other.recoveryKey)!)).rejects.toMatchObject({ + kind: "wrong-recovery-key", + }); + }); + + it("belongs to one account: its recovery key opens nothing under another uid", async () => { + const made = await createVault("uid-1"); + await expect( + openVault("uid-2", made.bundle, parseRecoveryKey(made.recoveryKey)!), + ).rejects.toBeInstanceOf(VaultError); + }); + + /* + * The public key is the one field the service could swap without holding a + * secret. Sealing to a swapped key would hand it every password, so opening + * checks the pair and refuses. + */ + it("refuses a public key the service swapped in", async () => { + const made = await createVault("uid-1"); + const impostor = await createVault("uid-1"); + await expect( + openVault("uid-1", { ...made.bundle, publicKey: impostor.bundle.publicKey }, parseRecoveryKey(made.recoveryKey)!), + ).rejects.toMatchObject({ kind: "damaged" }); + }); + + it("refuses an encrypted private key that has been altered", async () => { + const made = await createVault("uid-1"); + await expect( + openVault( + "uid-1", + { ...made.bundle, encryptedPrivateKey: tamper(made.bundle.encryptedPrivateKey, 20) }, + parseRecoveryKey(made.recoveryKey)!, + ), + ).rejects.toMatchObject({ kind: "damaged" }); + }); + + it("refuses a wrapped vault key moved into the private key's place", async () => { + const made = await createVault("uid-1"); + await expect( + openVault( + "uid-1", + { ...made.bundle, recoveryWrap: made.bundle.encryptedPrivateKey }, + parseRecoveryKey(made.recoveryKey)!, + ), + ).rejects.toBeInstanceOf(VaultError); + }); +}); + +describe("a password sealed to an account", () => { + it("is marked as a vault share and does not contain the password", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "correct horse"); + expect(isVaultShare(share.sealed)).toBe(true); + expect(share.sealed).not.toContain("correct"); + expect(atob(share.sealed.slice(3).replace(/-/g, "+").replace(/_/g, "/"))).not.toContain("horse"); + }); + + it("opens only for the session it was sealed for", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "pw"); + expect(await openFromAccount(made.opened.privateKey, "sess_2", "uid-1", share)).toBeNull(); + }); + + it("opens only for the person it was sealed for", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "pw"); + expect(await openFromAccount(made.opened.privateKey, "sess_1", "uid-2", share)).toBeNull(); + }); + + it("does not open with another account's key", async () => { + const mine = await createVault("uid-1"); + const theirs = await createVault("uid-1"); + const share = await sealToAccount(mine.bundle.publicKey, "sess_1", "uid-1", "pw"); + expect(await openFromAccount(theirs.opened.privateKey, "sess_1", "uid-1", share)).toBeNull(); + }); + + it("refuses ciphertext that has been altered", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "pw"); + const altered = { ...share, sealed: `v2.${tamper(share.sealed.slice(3), 14)}` }; + expect(await openFromAccount(made.opened.privateKey, "sess_1", "uid-1", altered)).toBeNull(); + }); + + it("leaves a share from before the vault to the old browser key", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "pw"); + const legacy = { ...share, sealed: share.sealed.slice(3) }; + expect(isVaultShare(legacy.sealed)).toBe(false); + expect(await openFromAccount(made.opened.privateKey, "sess_1", "uid-1", legacy)).toBeNull(); + }); + + it("never seals the same password to the same bytes twice", async () => { + const made = await createVault("uid-1"); + const first = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "same"); + const second = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "same"); + expect(first.sealed).not.toBe(second.sealed); + expect(first.senderPublicKey).not.toBe(second.senderPublicKey); + }); +}); + +describe("fingerprints", () => { + it("are four groups of four hex digits, stable for one key", async () => { + const made = await createVault("uid-1"); + const print = await fingerprint(made.bundle.publicKey); + expect(print).toMatch(/^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/); + expect(await fingerprint(made.bundle.publicKey)).toBe(print); + }); +}); diff --git a/app/src/lib/vault-crypto.ts b/app/src/lib/vault-crypto.ts new file mode 100644 index 0000000000000000000000000000000000000000..c833309e3f7de898c12384f8b67cd092d4825b2e GIT binary patch literal 12927 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 Z+oC(rPIS%v6rwSTk8n*rwOBo~_#XjKn(F`n literal 0 HcmV?d00001 diff --git a/app/src/lib/vault-store.ts b/app/src/lib/vault-store.ts new file mode 100644 index 0000000..2328131 --- /dev/null +++ b/app/src/lib/vault-store.ts @@ -0,0 +1,93 @@ +/** + * Where this browser keeps its unlocked vault. + * + * IndexedDB rather than localStorage, because it can hold a CryptoKey as the + * browser's own object. The key was imported non-extractable, so script on + * this page can use it to open a password but cannot read it out, not even + * script that should not be here. The browser key pair this replaces sat in + * localStorage as a readable JWK. + * + * Losing this costs a recovery key, not a session: the vault itself is kept + * by the service, sealed, and this is only the unlocked copy. + */ + +const DATABASE = "shell.online:vault"; +const STORE = "vaults"; + +export interface LocalVault { + uid: string; + publicKey: string; + /** The vault version this key belongs to, so a reset elsewhere is noticed. */ + version: number; + privateKey: CryptoKey; +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE, { keyPath: "uid" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function run( + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest, +): Promise { + const database = await openDatabase(); + try { + return await new Promise((resolve, reject) => { + const transaction = database.transaction(STORE, mode); + const request = action(transaction.objectStore(STORE)); + transaction.oncomplete = () => resolve(request.result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + } finally { + database.close(); + } +} + +function isLocalVault(value: unknown): value is LocalVault { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.uid === "string" && + typeof candidate.publicKey === "string" && + typeof candidate.version === "number" && + candidate.privateKey instanceof CryptoKey + ); +} + +/** The unlocked vault for this account, or null when this browser has none. */ +export async function loadLocalVault(uid: string): Promise { + try { + const found = await run("readonly", (store) => store.get(uid)); + return isLocalVault(found) && found.uid === uid ? found : null; + } catch { + /* Private windows and blocked storage: the vault asks again next visit. */ + return null; + } +} + +/** Keeps the unlocked vault. False when this browser cannot keep it. */ +export async function saveLocalVault(vault: LocalVault): Promise { + try { + await run("readwrite", (store) => store.put(vault)); + return true; + } catch { + return false; + } +} + +/** Locks the vault in this browser. The vault itself is untouched. */ +export async function clearLocalVault(uid: string): Promise { + try { + await run("readwrite", (store) => store.delete(uid)); + } catch { + /* nothing kept, nothing to clear */ + } +} diff --git a/app/src/main.tsx b/app/src/main.tsx index 06322e0..782da3e 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -10,6 +10,7 @@ import "./styles/people.css"; import "./styles/collab.css"; import "./styles/audit.css"; import "./styles/terms.css"; +import "./styles/vault.css"; createRoot(document.getElementById("root")!).render( diff --git a/app/src/routes/Account.tsx b/app/src/routes/Account.tsx index 4c21d43..15f0a5d 100644 --- a/app/src/routes/Account.tsx +++ b/app/src/routes/Account.tsx @@ -6,13 +6,24 @@ import { Alert } from "../components/Alert"; import { useAuth } from "../auth/AuthProvider"; import { usePageTitle } from "../lib/page-title"; import { authErrorMessage } from "../lib/auth-errors"; +import { useVault } from "../vault/VaultProvider"; +import { VaultSetup } from "../vault/VaultGate"; + +const VAULT_STATE: Record = { + loading: "Checking", + setup: "Not set up yet. It is set up the first time you open Sessions.", + locked: "Locked in this browser. Open Sessions to unlock it with your recovery key.", + error: "Could not be reached", +}; export function Account() { usePageTitle("Account"); const { user, resendVerification, signOutUser } = useAuth(); + const vault = useVault(); const [notice, setNotice] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const [resetting, setResetting] = useState(false); if (!user) return null; @@ -66,8 +77,44 @@ export function Account() {
User id
{user.uid}
+ {/* + The fingerprint is what `shell login` prints when a machine first + trusts the vault, so the two can be compared by eye. + */} +
+
Session vault
+
+ {vault.status === "unlocked" ? ( + <> + Unlocked, key {vault.fingerprint} + {!vault.remembered && ( + + this browser cannot keep it unlocked + + )} + + ) : ( + VAULT_STATE[vault.status] + )} +
+
+ {resetting && ( +
+ { + setResetting(false); + setNotice("Your new vault is on. Link your machines again with shell login."); + }} + /> + +
+ )} + {/* Sign out lives here as well as in the sidebar. On a phone the sidebar becomes a bar of destinations with no room for the account row, and @@ -86,6 +133,11 @@ export function Account() { Resend verification )} + {(vault.status === "unlocked" || vault.status === "locked") && !resetting && ( + + )} + + + ); + } + if (vault.status === "setup") { + return ( + + + + ); + } + return ( + + + + ); +} + +function VaultFrame({ children }: { children: ReactNode }) { + const { user, signOutUser } = useAuth(); + return ( +
+
+ + + {user?.email} + {/* The gate is on the way in, so it has to have a way out too. */} + + +
+
{children}
+
+ ); +} + +/** + * Makes a vault, shows its recovery key, and only saves it once the key has + * been confirmed. A vault whose key nobody kept cannot be opened anywhere + * else, so the order matters: nothing is sent until the last group is typed. + */ +export function VaultSetup({ reset, onDone }: { reset: boolean; onDone?: () => void }) { + const vault = useVault(); + const { user } = useAuth(); + const [prepared, setPrepared] = useState(null); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const { copiedKey, failedKey, copy } = useCopy<"key">(); + const started = useRef(false); + + useEffect(() => { + /* Once: a second key pair would silently replace the key being shown. */ + if (started.current) return; + started.current = true; + vault + .prepare(reset) + .then(setPrepared) + .catch((caught) => setError(caught instanceof Error ? caught.message : "Could not make a vault here.")); + }, [vault, reset]); + + const groups = prepared?.recoveryKey.split("-") ?? []; + const lastGroup = groups.at(-1) ?? ""; + const confirmed = confirm.trim().toUpperCase() === lastGroup && lastGroup !== ""; + + function download() { + if (!prepared) return; + const text = [ + "shell.online recovery key", + "", + prepared.recoveryKey, + "", + `Account: ${user?.email ?? ""}`, + `Made: ${new Date().toISOString().slice(0, 10)}`, + "", + "This key opens your session vault in a new browser.", + "shell.online does not have a copy and cannot recover it.", + "", + ].join("\n"); + const url = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + const link = document.createElement("a"); + link.href = url; + link.download = "shell-online-recovery-key.txt"; + link.click(); + URL.revokeObjectURL(url); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!prepared || !confirmed) return; + setBusy(true); + setError(""); + try { + await vault.commit(prepared); + onDone?.(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not save your vault."); + setBusy(false); + } + } + + return ( + <> + +

{reset ? "Make a new vault" : "Set up your session vault"}

+

+ Every session is end-to-end encrypted with its own password. Your vault + keeps those passwords sealed, so a session opens on any browser you + unlock, including ones started in a terminal. shell.online cannot open + your vault. +

+ + {reset && ( + + Passwords sealed to your old vault can no longer be opened. Running + sessions this browser can still read move across. Machines linked + with shell login need to sign in again before they save + to the new vault. + + )} + + {error && {error}} + + {prepared && ( +
+
+ Your recovery key + + {groups.map((group, index) => ( + {group} + ))} + +
+ + +
+ {failedKey &&

{COPY_FAILED}

} +
+ +

+ + + This is the only way into your vault from a new browser. Keep it + in a password manager or somewhere offline. shell.online does not + have a copy and cannot recover it for you. + +

+ + + setConfirm(event.target.value)} + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + maxLength={4} + placeholder="XXXX" + /> + +
+ +
+
+ )} + + ); +} + +/** Opens an existing vault in a browser that has not opened it before. */ +function VaultUnlock() { + const vault = useVault(); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [resetting, setResetting] = useState(false); + + if (resetting) return ; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!text.trim()) return; + setBusy(true); + setError(""); + try { + await vault.unlock(text); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not unlock your vault."); + setBusy(false); + } + } + + return ( + <> + +

Unlock your session vault

+

+ This browser has not opened your vault yet. Enter the recovery key you + saved when you set it up. The key is used here to open the vault and is + never sent to shell.online. +

+ + {error && {error}} + +
+ + setText(event.target.value)} + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" + /> +
+ +
+
+ +
+ Lost your recovery key? +

+ You can make a new vault. Passwords sealed to the old one cannot be + opened again, which is what keeps them out of anyone else's reach + too. A session that is still running can be reopened with{" "} + shell sessions on the machine running it. +

+ +
+ + ); +} diff --git a/app/src/vault/VaultProvider.tsx b/app/src/vault/VaultProvider.tsx new file mode 100644 index 0000000..2193efe --- /dev/null +++ b/app/src/vault/VaultProvider.tsx @@ -0,0 +1,316 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useAuth } from "../auth/AuthProvider"; +import { fetchSessions, fetchVault, saveVault, shareSessionKeys, type VaultRecord } from "../lib/api"; +import { + createVault, + fingerprint, + isVaultShare, + openFromAccount, + openVault, + parseRecoveryKey, + sealToAccount, + VaultError, + type OpenedVault, + type SealedShare, + type VaultBundle, +} from "../lib/vault-crypto"; +import { clearLocalVault, loadLocalVault, saveLocalVault } from "../lib/vault-store"; +import { openSealed } from "../lib/keypair"; + +/** + * The signed-in person's session vault, and what is open in this browser. + * + * "setup" means the account has no vault yet; "locked" means it has one this + * browser has not opened; "unlocked" means a key that opens it is held here. + * Everything that reads or seals a session password goes through this, so + * there is one place that decides which key is used for what. + */ +export type VaultStatus = "loading" | "setup" | "locked" | "unlocked" | "error"; + +/** A vault made in this browser and not yet handed to the service. */ +export interface PreparedVault { + bundle: VaultBundle; + recoveryKey: string; + opened: OpenedVault; + /** The version a reset replaces. Absent when setting up the first vault. */ + replaces?: number; +} + +interface VaultValue { + status: VaultStatus; + error: string; + /** + * The vault's public key as this browser verified it when unlocking, not as + * the service last described it. This is what is handed to the CLI. + */ + publicKey: string | null; + /** A short form of the public key, for comparing with what the CLI prints. */ + fingerprint: string; + /** False when this browser could not keep the unlocked vault, as in a private window. */ + remembered: boolean; + /** The version on the service, when there is a vault at all. */ + version: number | null; + prepare(reset: boolean): Promise; + commit(prepared: PreparedVault): Promise; + unlock(recoveryKey: string): Promise; + retry(): void; + /** Opens a password sealed to this person: to their vault, or to this browser's old key. */ + openShare(sessionId: string, share: SealedShare | undefined): Promise; + /** Seals a password to a colleague's vault. Null when they have none. */ + sealTo( + recipient: { uid: string; accountKey?: string }, + sessionId: string, + password: string, + ): Promise; + /** Seals a password to this person's own vault and stores it. False when it could not. */ + keep(sessionId: string, password: string): Promise; +} + +const VaultContext = createContext(null); + +export function VaultProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const uid = user?.uid ?? ""; + const [status, setStatus] = useState("loading"); + const [error, setError] = useState(""); + const [remote, setRemote] = useState(null); + const [publicKey, setPublicKey] = useState(null); + const [print, setPrint] = useState(""); + const [remembered, setRemembered] = useState(true); + const [attempt, setAttempt] = useState(0); + /* + * Read by the callbacks below rather than closed over, so that unlocking + * does not hand every consumer new functions and tear down what they built + * with the old ones, such as an open terminal. + */ + const opened = useRef(null); + + const becomeUnlocked = useCallback(async (next: OpenedVault, kept: boolean) => { + opened.current = next; + setPublicKey(next.publicKey); + setPrint(await fingerprint(next.publicKey)); + setRemembered(kept); + setError(""); + setStatus("unlocked"); + }, []); + + useEffect(() => { + opened.current = null; + setPublicKey(null); + if (!uid) { + setStatus("loading"); + setRemote(null); + return; + } + let live = true; + setStatus("loading"); + void (async () => { + try { + const [{ vault }, local] = await Promise.all([fetchVault(), loadLocalVault(uid)]); + if (!live) return; + setRemote(vault); + if (!vault) { + if (local) await clearLocalVault(uid); + setStatus("setup"); + return; + } + /* + * Only a key this browser unlocked itself counts, and only for the + * vault as it stands now. A reset elsewhere replaces the key pair, and + * sealing to a stale one would make passwords nobody can open. + */ + if (local && local.publicKey === vault.publicKey && local.version === vault.version) { + await becomeUnlocked({ publicKey: local.publicKey, privateKey: local.privateKey }, true); + return; + } + setStatus("locked"); + } catch (caught) { + if (!live) return; + setError(caught instanceof Error ? caught.message : "Could not reach your vault."); + setStatus("error"); + } + })(); + return () => { + live = false; + }; + }, [uid, attempt, becomeUnlocked]); + + const prepare = useCallback( + async (reset: boolean): Promise => { + const made = await createVault(uid); + return { ...made, replaces: reset && remote ? remote.version : undefined }; + }, + [uid, remote], + ); + + const commit = useCallback( + async (prepared: PreparedVault) => { + const previous = opened.current; + let vault: VaultRecord | null; + try { + vault = (await saveVault(prepared.bundle, prepared.replaces)).vault; + } catch (caught) { + /* + * Setting up is create-only on the service, so of two windows setting + * up at once the second is refused. Rather than leave it at a dead + * end, look again: in this browser the one that won is already + * unlocked, and anywhere else it asks for that window's key. + */ + const winner = prepared.replaces === undefined + ? await fetchVault().then((result) => result.vault, () => null) + : null; + if (winner) { + setAttempt((value) => value + 1); + throw new VaultError( + "damaged", + "A vault was set up for this account in another window. Use the recovery key shown there.", + ); + } + throw caught; + } + /* The service answers with what it stored; it must be what was sent. */ + if (!vault || vault.publicKey !== prepared.bundle.publicKey) { + throw new VaultError("damaged", "The vault that was saved is not the one made here. Reload and try again."); + } + setRemote(vault); + const kept = await saveLocalVault({ + uid, + publicKey: vault.publicKey, + version: vault.version, + privateKey: prepared.opened.privateKey, + }); + await becomeUnlocked(prepared.opened, kept); + + /* + * A reset made from a browser that still held the old vault can carry + * the passwords of running sessions across: it opens each with the old + * key and seals it to the new one. From anywhere else they are gone, + * which is what losing a recovery key has to mean. + */ + if (previous && prepared.replaces !== undefined) { + await carryOver(uid, previous, prepared.opened); + } + }, + [uid, becomeUnlocked], + ); + + const unlock = useCallback( + async (text: string) => { + if (!remote) throw new VaultError("damaged", "There is no vault to unlock. Reload and try again."); + const recovery = parseRecoveryKey(text); + if (!recovery) { + throw new VaultError( + "wrong-recovery-key", + "A recovery key is 32 letters and digits, in eight groups of four.", + ); + } + try { + const next = await openVault(uid, remote, recovery); + const kept = await saveLocalVault({ + uid, + publicKey: next.publicKey, + version: remote.version, + privateKey: next.privateKey, + }); + await becomeUnlocked(next, kept); + } finally { + recovery.fill(0); + } + }, + [uid, remote, becomeUnlocked], + ); + + const retry = useCallback(() => setAttempt((value) => value + 1), []); + + const openShare = useCallback( + async (sessionId: string, share: SealedShare | undefined) => { + if (!share) return null; + if (isVaultShare(share.sealed)) { + const key = opened.current; + return key ? openFromAccount(key.privateKey, sessionId, uid, share) : null; + } + /* Shared before the vault existed, to the key this browser had then. */ + return openSealed(share.senderPublicKey, share.sealed); + }, + [uid], + ); + + const sealTo = useCallback( + async (recipient: { uid: string; accountKey?: string }, sessionId: string, password: string) => { + if (!recipient.accountKey) return null; + return sealToAccount(recipient.accountKey, sessionId, recipient.uid, password); + }, + [], + ); + + const keep = useCallback( + async (sessionId: string, password: string) => { + /* The key this browser verified when it unlocked, not one fetched since. */ + const key = opened.current; + if (!key) return false; + try { + const share = await sealToAccount(key.publicKey, sessionId, uid, password); + await shareSessionKeys(sessionId, [ + { uid, sender_public_key: share.senderPublicKey, sealed: share.sealed }, + ]); + return true; + } catch { + return false; + } + }, + [uid], + ); + + const value = useMemo( + () => ({ + status, + error, + publicKey, + fingerprint: print, + remembered, + version: remote?.version ?? null, + prepare, + commit, + unlock, + retry, + openShare, + sealTo, + keep, + }), + [status, error, publicKey, print, remembered, remote, prepare, commit, unlock, retry, openShare, sealTo, keep], + ); + + return {children}; +} + +async function carryOver(uid: string, previous: OpenedVault, next: OpenedVault): Promise { + try { + const { sessions } = await fetchSessions(); + for (const session of sessions) { + if (session.closedAt || !session.keyShare) continue; + const password = await openFromAccount(previous.privateKey, session.id, uid, session.keyShare); + if (!password) continue; + const share = await sealToAccount(next.publicKey, session.id, uid, password); + await shareSessionKeys(session.id, [ + { uid, sender_public_key: share.senderPublicKey, sealed: share.sealed }, + ]); + } + } catch { + /* Best effort. A session missed here still opens with `shell sessions`. */ + } +} + +export function useVault(): VaultValue { + const value = useContext(VaultContext); + if (!value) throw new Error("useVault must be used inside a VaultProvider."); + return value; +} diff --git a/cmd/shell/main.go b/cmd/shell/main.go index 19cd6da..00ad336 100644 --- a/cmd/shell/main.go +++ b/cmd/shell/main.go @@ -238,7 +238,7 @@ func run(arguments []string, stdout, stderr io.Writer) int { Encrypted: session.Encrypted, Persistent: session.Persistent, StartedAt: processStartedAt.UnixMilli(), - }) + }, password) if isBackgroundChild() { sendBackgroundResult(backgroundLaunchResult{ OK: true, diff --git a/cmd/shell/session_link.go b/cmd/shell/session_link.go index b3a1951..3909c6d 100644 --- a/cmd/shell/session_link.go +++ b/cmd/shell/session_link.go @@ -55,6 +55,10 @@ type sessionLink struct { accessToken string sessionID string warn io.Writer + // credentials and path are kept so a vault key seen for the first time + // can be pinned into the file it came from. + credentials account.Credentials + path string } // openSessionLink loads credentials, refreshes them when stale, and returns a @@ -91,14 +95,27 @@ func openSessionLink(ctx context.Context, warn io.Writer) *sessionLink { } } - return &sessionLink{client: client, accessToken: credentials.AccessToken, warn: warn} + return &sessionLink{ + client: client, + accessToken: credentials.AccessToken, + warn: warn, + credentials: credentials, + path: path, + } } // Register publishes the session. Failure is reported, never fatal. -func (link *sessionLink) Register(ctx context.Context, input account.SessionInput) { +// +// password is the session's browser password, or empty when it has none. A +// password is sealed to the account's vault so the session can be opened from +// the web app after this terminal is gone; it is never sent any other way. +func (link *sessionLink) Register(ctx context.Context, input account.SessionInput, password string) { if link == nil { return } + if password != "" { + input.OwnerShare = link.vaultShare(ctx, input.ID, password) + } if input.Host == "" { if host, err := os.Hostname(); err == nil { input.Host = host @@ -132,6 +149,54 @@ func (link *sessionLink) Register(ctx context.Context, input account.SessionInpu link.sessionID = input.ID } +// vaultShare seals a session password to the account's vault key, or returns +// nil when that cannot be done safely. Failing here costs only the saved copy; +// the session itself is unaffected. +// +// The key is checked against the one this machine already trusts. The first +// key seen is pinned; a different one later is refused, because the accounts +// service is the thing that hands the key over and the thing that must not be +// able to read what is sealed to it. +func (link *sessionLink) vaultShare(ctx context.Context, sessionID, password string) *account.KeyShare { + const notSaved = "shell: this session's password was not saved to your vault" + if link.credentials.UID == "" { + fmt.Fprintf(link.warn, "%s: this machine's sign-in predates the vault; run 'shell login'\n", notSaved) + return nil + } + + keyContext, cancel := context.WithTimeout(ctx, linkTimeout) + defer cancel() + fetched, ok, err := link.client.AccountKey(keyContext, link.accessToken) + if err != nil { + fmt.Fprintf(link.warn, "%s: %v\n", notSaved, err) + return nil + } + if !ok { + return nil + } + + switch pinned := link.credentials.AccountKey; { + case pinned == "": + link.credentials.AccountKey = fetched + if saveErr := account.Save(link.path, link.credentials); saveErr != nil { + fmt.Fprintf(link.warn, "shell: could not remember your vault key: %v\n", saveErr) + } + fingerprint, _ := account.Fingerprint(fetched) + fmt.Fprintf(link.warn, "shell: saving session passwords to your vault (key %s)\n", fingerprint) + case pinned != fetched: + fmt.Fprintf(link.warn, "shell: your vault key changed since this machine signed in. "+ + "Run 'shell login' to trust the new one; %s.\n", strings.TrimPrefix(notSaved, "shell: ")) + return nil + } + + sender, sealed, err := account.SealToAccount(link.credentials.AccountKey, sessionID, link.credentials.UID, password) + if err != nil { + fmt.Fprintf(link.warn, "%s: %v\n", notSaved, err) + return nil + } + return &account.KeyShare{SenderPublicKey: sender, Sealed: sealed} +} + // Close marks the session finished in the account. // // It deliberately uses a fresh background context: the process context is diff --git a/cmd/shell/session_link_test.go b/cmd/shell/session_link_test.go index d5ef643..79a202e 100644 --- a/cmd/shell/session_link_test.go +++ b/cmd/shell/session_link_test.go @@ -40,7 +40,7 @@ func TestOpenSessionLinkReturnsNilWhenNotSignedIn(t *testing.T) { func TestNilLinkMethodsAreSafe(t *testing.T) { // The launch path calls these unconditionally, so nil must be inert. var link *sessionLink - link.Register(context.Background(), sampleSessionInput()) + link.Register(context.Background(), sampleSessionInput(), "") exitCode := 0 link.Close(&exitCode) } @@ -63,7 +63,7 @@ func TestRegisterPublishesTheSession(t *testing.T) { if link == nil { t.Fatal("expected a link for a signed-in machine") } - link.Register(context.Background(), sampleSessionInput()) + link.Register(context.Background(), sampleSessionInput(), "") if authorization != "Bearer sha_access" { t.Fatalf("Authorization = %q", authorization) @@ -92,7 +92,7 @@ func TestRegisterWarnsButDoesNotFailTheLaunch(t *testing.T) { linkedAccount(t, service.URL) var warn bytes.Buffer link := openSessionLink(context.Background(), &warn) - link.Register(context.Background(), sampleSessionInput()) + link.Register(context.Background(), sampleSessionInput(), "") if !strings.Contains(warn.String(), "will not appear in your account") { t.Fatalf("warning = %q", warn.String()) @@ -113,7 +113,7 @@ func TestCloseIsSkippedWhenRegistrationNeverSucceeded(t *testing.T) { linkedAccount(t, service.URL) var warn bytes.Buffer link := openSessionLink(context.Background(), &warn) - link.Register(context.Background(), sampleSessionInput()) + link.Register(context.Background(), sampleSessionInput(), "") exitCode := 0 link.Close(&exitCode) @@ -141,7 +141,7 @@ func TestCloseMarksTheSessionFinished(t *testing.T) { var warn bytes.Buffer link := openSessionLink(context.Background(), &warn) input := sampleSessionInput() - link.Register(context.Background(), input) + link.Register(context.Background(), input, "") exitCode := 3 link.Close(&exitCode) @@ -171,7 +171,7 @@ func TestCloseRunsEvenAfterTheProcessContextIsCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var warn bytes.Buffer link := openSessionLink(ctx, &warn) - link.Register(ctx, sampleSessionInput()) + link.Register(ctx, sampleSessionInput(), "") cancel() exitCode := 0 @@ -217,7 +217,7 @@ func TestExpiredCredentialsAreRefreshedAndPersisted(t *testing.T) { if link == nil { t.Fatalf("expected a link after refresh; warnings %q", warn.String()) } - link.Register(context.Background(), sampleSessionInput()) + link.Register(context.Background(), sampleSessionInput(), "") if !refreshed.Load() { t.Fatal("a stale access token should have been refreshed") @@ -281,7 +281,7 @@ func TestSharingUsesTheServiceRecordedAtLoginNotTheEnvironment(t *testing.T) { if link == nil { t.Fatalf("a linked machine must produce a link; warnings %q", warn.String()) } - link.Register(context.Background(), sampleSessionInput()) + link.Register(context.Background(), sampleSessionInput(), "") if !reached { t.Fatal("the share was not published to the service recorded at login") @@ -319,7 +319,7 @@ func TestEveryShellInvocationPublishesOnALinkedMachine(t *testing.T) { link := openSessionLink(context.Background(), &warn) input := sampleSessionInput() input.ID = id - link.Register(context.Background(), input) + link.Register(context.Background(), input, "") } if len(published) != 3 { @@ -346,7 +346,7 @@ func TestPublishedLinkKeepsTheSaltSoItCanBeOpened(t *testing.T) { link := openSessionLink(context.Background(), &warn) input := sampleSessionInput() input.ShareURL += "#salt=i6AaAzfYyklCDqgMRgEIDw" - link.Register(context.Background(), input) + link.Register(context.Background(), input, "") if !strings.Contains(shareURL, "#salt=") { t.Fatalf("a link without its salt cannot be opened from the web app: %q", shareURL) diff --git a/cmd/shell/session_link_vault_test.go b/cmd/shell/session_link_vault_test.go new file mode 100644 index 0000000..adf19ed --- /dev/null +++ b/cmd/shell/session_link_vault_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "shell.online/internal/account" +) + +const vaultTestPassword = "Kw9eHbru" + +// vaultService stands in for the accounts service: it answers the key request +// with keyStatus and records what each registration carried. +type vaultService struct { + server *httptest.Server + mu sync.Mutex + keyAsks int + published []map[string]any +} + +func newVaultService(t *testing.T, keyStatus int, publicKey string) *vaultService { + t.Helper() + service := &vaultService{} + service.server = httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + service.mu.Lock() + defer service.mu.Unlock() + writer.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/api/account/key": + service.keyAsks++ + writer.WriteHeader(keyStatus) + switch keyStatus { + case http.StatusOK: + _ = json.NewEncoder(writer).Encode(map[string]any{"public_key": publicKey, "version": 1}) + case http.StatusNotFound: + _, _ = writer.Write([]byte(`{"error":"no vault"}`)) + default: + _, _ = writer.Write([]byte(`{"error":"database down"}`)) + } + case "/api/sessions": + var body map[string]any + _ = json.NewDecoder(request.Body).Decode(&body) + service.published = append(service.published, body) + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{}`)) + default: + http.NotFound(writer, request) + } + })) + t.Cleanup(service.server.Close) + return service +} + +func (service *vaultService) lastShare(t *testing.T) map[string]any { + t.Helper() + service.mu.Lock() + defer service.mu.Unlock() + if len(service.published) != 1 { + t.Fatalf("published %d sessions, want 1", len(service.published)) + } + share, _ := service.published[0]["owner_share"].(map[string]any) + return share +} + +func testAccountKey(t *testing.T) string { + t.Helper() + private, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate: %v", err) + } + return base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes()) +} + +func pinAccountKey(t *testing.T, path, key string) { + t.Helper() + credentials, err := account.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + credentials.AccountKey = key + if err := account.Save(path, credentials); err != nil { + t.Fatalf("Save: %v", err) + } +} + +func registerWithPassword(t *testing.T, password string) string { + t.Helper() + var warn bytes.Buffer + link := openSessionLink(context.Background(), &warn) + if link == nil { + t.Fatal("expected a link for a signed-in machine") + } + link.Register(context.Background(), sampleSessionInput(), password) + if strings.Contains(warn.String(), password) && password != "" { + t.Fatalf("the password was printed: %q", warn.String()) + } + return warn.String() +} + +func TestFirstVaultKeyIsPinnedAndUsed(t *testing.T) { + key := testAccountKey(t) + service := newVaultService(t, http.StatusOK, key) + path := linkedAccount(t, service.server.URL) + + warn := registerWithPassword(t, vaultTestPassword) + + share := service.lastShare(t) + if share == nil || !strings.HasPrefix(share["sealed"].(string), "v2.") { + t.Fatalf("owner_share = %+v, want a v2 envelope", share) + } + if strings.Contains(share["sealed"].(string), vaultTestPassword) { + t.Fatal("the password was sent in the clear") + } + stored, err := account.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if stored.AccountKey != key { + t.Fatalf("pinned %q, want the key the service reported", stored.AccountKey) + } + fingerprint, _ := account.Fingerprint(key) + if !strings.Contains(warn, "saving session passwords to your vault (key "+fingerprint+")") { + t.Fatalf("output = %q, want the fingerprint announced once", warn) + } +} + +func TestPinnedVaultKeyIsUsedSilently(t *testing.T) { + key := testAccountKey(t) + service := newVaultService(t, http.StatusOK, key) + pinAccountKey(t, linkedAccount(t, service.server.URL), key) + + warn := registerWithPassword(t, vaultTestPassword) + + if service.lastShare(t) == nil { + t.Fatal("no owner_share for a key that matches the pin") + } + if warn != "" { + t.Fatalf("a matching key should stay silent, got %q", warn) + } +} + +// The service hands the key over. If it could swap it at will, it could read +// every password sealed afterwards, so a changed key is refused. +func TestChangedVaultKeyIsRefused(t *testing.T) { + service := newVaultService(t, http.StatusOK, testAccountKey(t)) + path := linkedAccount(t, service.server.URL) + pinned := testAccountKey(t) + pinAccountKey(t, path, pinned) + + warn := registerWithPassword(t, vaultTestPassword) + + if share := service.lastShare(t); share != nil { + t.Fatalf("sealed to a key that does not match the pin: %+v", share) + } + if !strings.Contains(warn, "your vault key changed since this machine signed in") { + t.Fatalf("output = %q", warn) + } + stored, _ := account.Load(path) + if stored.AccountKey != pinned { + t.Fatal("the pin was replaced by the service's key") + } +} + +func TestNoVaultMeansNoShareAndNoNoise(t *testing.T) { + service := newVaultService(t, http.StatusNotFound, "") + linkedAccount(t, service.server.URL) + + warn := registerWithPassword(t, vaultTestPassword) + + if share := service.lastShare(t); share != nil { + t.Fatalf("owner_share = %+v with no vault", share) + } + if warn != "" { + t.Fatalf("an account without a vault is normal; got %q", warn) + } +} + +func TestVaultFailureStillPublishesTheSession(t *testing.T) { + service := newVaultService(t, http.StatusInternalServerError, "") + linkedAccount(t, service.server.URL) + + warn := registerWithPassword(t, vaultTestPassword) + + if share := service.lastShare(t); share != nil { + t.Fatalf("owner_share = %+v after a failed key request", share) + } + if !strings.Contains(warn, "this session's password was not saved to your vault") { + t.Fatalf("output = %q", warn) + } +} + +func TestSessionWithoutAPasswordDoesNotAskForTheKey(t *testing.T) { + service := newVaultService(t, http.StatusOK, testAccountKey(t)) + linkedAccount(t, service.server.URL) + + registerWithPassword(t, "") + + if service.keyAsks != 0 { + t.Fatalf("asked for the vault key %d times for a session with no password", service.keyAsks) + } + if share := service.lastShare(t); share != nil { + t.Fatalf("owner_share = %+v", share) + } +} + +func TestSignInWithoutAUIDSkipsTheVault(t *testing.T) { + service := newVaultService(t, http.StatusOK, testAccountKey(t)) + path := linkedAccount(t, service.server.URL) + credentials, _ := account.Load(path) + credentials.UID = "" + if err := account.Save(path, credentials); err != nil { + t.Fatalf("Save: %v", err) + } + + warn := registerWithPassword(t, vaultTestPassword) + + if share := service.lastShare(t); share != nil { + t.Fatalf("sealed without knowing whose vault it is: %+v", share) + } + if !strings.Contains(warn, "run 'shell login'") { + t.Fatalf("output = %q", warn) + } +} diff --git a/internal/account/client.go b/internal/account/client.go index 95ba1a4..9d9ac28 100644 --- a/internal/account/client.go +++ b/internal/account/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -49,6 +50,16 @@ type errorResponse struct { Error string `json:"error"` } +// statusError is a failing answer from the service. It keeps the status so a +// caller can tell "there is nothing here" from "something went wrong"; its +// message is exactly what callers have always seen. +type statusError struct { + status int + message string +} + +func (failure *statusError) Error() string { return failure.message } + // maxErrorBytes caps how much of a failing body is read into an error message. const maxErrorBytes = 4 << 10 @@ -92,14 +103,17 @@ func (client *Client) do(ctx context.Context, method, path, bearer string, body if response.StatusCode < 200 || response.StatusCode > 299 { var failure errorResponse if json.Unmarshal(contents, &failure) == nil && failure.Error != "" { - return nil, fmt.Errorf("accounts service: %s", failure.Error) + return nil, &statusError{status: response.StatusCode, message: "accounts service: " + failure.Error} } snippet := contents if len(snippet) > maxErrorBytes { snippet = snippet[:maxErrorBytes] } - return nil, fmt.Errorf("accounts service returned %d: %s", - response.StatusCode, strings.TrimSpace(string(snippet))) + return nil, &statusError{ + status: response.StatusCode, + message: fmt.Sprintf("accounts service returned %d: %s", + response.StatusCode, strings.TrimSpace(string(snippet))), + } } return contents, nil } @@ -192,6 +206,44 @@ type SessionInput struct { Persistent bool `json:"persistent"` Host string `json:"host"` StartedAt int64 `json:"started_at"` + // OwnerShare is the session password sealed to the account's vault key, + // so any of the person's browsers can open the session later. Absent when + // the session has no password or the account has no vault. + OwnerShare *KeyShare `json:"owner_share,omitempty"` +} + +// KeyShare is a session password sealed to one account key. The accounts +// service stores it and cannot open it. +type KeyShare struct { + SenderPublicKey string `json:"sender_public_key"` + Sealed string `json:"sealed"` +} + +// AccountKey asks for the account's vault public key. +// +// ok is false, with no error, when the account has no vault yet: that is the +// state of every account until its owner sets one up in the browser, and not +// something to warn about. A key that does not parse is an error rather than +// a missing vault, so a broken answer is not mistaken for an absent one. +func (client *Client) AccountKey(ctx context.Context, accessToken string) (key string, ok bool, err error) { + contents, err := client.do(ctx, http.MethodGet, "/api/account/key", accessToken, nil) + var failure *statusError + if errors.As(err, &failure) && failure.status == http.StatusNotFound { + return "", false, nil + } + if err != nil { + return "", false, err + } + var decoded struct { + PublicKey string `json:"public_key"` + } + if err := json.Unmarshal(contents, &decoded); err != nil { + return "", false, fmt.Errorf("decode account key: %w", err) + } + if err := ParseAccountKey(decoded.PublicKey); err != nil { + return "", false, fmt.Errorf("the accounts service returned an unusable key: %w", err) + } + return decoded.PublicKey, true, nil } // RegisterSession publishes a session so it appears in the account. diff --git a/internal/account/credentials.go b/internal/account/credentials.go index 351ac5a..4d0e3f4 100644 --- a/internal/account/credentials.go +++ b/internal/account/credentials.go @@ -26,6 +26,15 @@ type Credentials struct { // not a decision worth holding someone to, so the next login asks again, // while agreeing is never asked about twice. RemoteStart bool `json:"remote_start,omitempty"` + + // AccountKey is the account's vault public key, as this machine trusts it. + // + // It arrives from the signed-in browser on the login callback, which never + // passes through the accounts service, or failing that is pinned the first + // time the service reports one. After that a different key from the + // service is refused rather than believed: sealing a password to a key the + // service chose would let the service read it. + AccountKey string `json:"account_key,omitempty"` } // ErrNotLinked reports that no account has been linked on this machine. diff --git a/internal/account/login.go b/internal/account/login.go index 06761fa..1bf37bd 100644 --- a/internal/account/login.go +++ b/internal/account/login.go @@ -19,7 +19,10 @@ const LoginTimeout = 5 * time.Minute // callbackResult carries what the browser handed back on the loopback listener. type callbackResult struct { code string - err error + // accountKey is the vault public key the browser vouched for, or empty + // when it sent none or sent something that is not a key. + accountKey string + err error } // callbackPage is what the person sees after approving, in the browser tab the @@ -123,7 +126,17 @@ func newCallbackHandler(state, webURL string, results chan<- callbackResult) htt return } - deliver(callbackResult{code: code}) + // Read only once the state has matched, so it comes from the browser + // this login opened. It travels browser to loopback directly, which is + // why it is trusted over whatever the accounts service says later. A + // malformed value is dropped rather than failing the login: the key is + // an addition to signing in, not a condition of it. + accountKey := query.Get("account_key") + if ParseAccountKey(accountKey) != nil { + accountKey = "" + } + + deliver(callbackResult{code: code, accountKey: accountKey}) if onwards != "" { // 303, because the browser should follow this with a GET and not // re-send anything from the request that got it here. @@ -231,7 +244,12 @@ func Login(ctx context.Context, client *Client, options Options) (Credentials, e if label == "" { label = defaultLabel() } - return client.Exchange(ctx, result.code, verifier, redirectURI, label, options.MachineID) + credentials, err := client.Exchange(ctx, result.code, verifier, redirectURI, label, options.MachineID) + if err != nil { + return Credentials{}, err + } + credentials.AccountKey = result.accountKey + return credentials, nil case <-waitContext.Done(): if errors.Is(waitContext.Err(), context.DeadlineExceeded) { return Credentials{}, fmt.Errorf("timed out after %s waiting for the browser", timeout) diff --git a/internal/account/testdata/vault-share-v2-browser.json b/internal/account/testdata/vault-share-v2-browser.json new file mode 100644 index 0000000..b60c43b --- /dev/null +++ b/internal/account/testdata/vault-share-v2-browser.json @@ -0,0 +1,9 @@ +{ + "recipient_private_key_hex": "8b434a305a04bf2e54de3c66aa69cd39aea84a6b69c01bd8cedf6fc5148ba94c", + "recipient_public_key": "BBtGQCwqwBUH69JfdTY7YLUVEp9Ya6Cx7vTUmkr5p6MCowr_BKtP3H_vVacze6qj3IhF_ggCteJqtmpFcoOgGKY", + "session_id": "Vb3xTm9Ld2RqN7wKavh4YsPcE8UjZgF6", + "uid": "uid-browser", + "password": "t9QmZ2vXk4LrP8sWc1Nb3A", + "sender_public_key": "BDxrse1_E7EAHDreFfDYFkHs7kcn3d2n_BqKorrlu6H-9FarvjSDUCUSY3EOYKRBJusTV2E2GwRZLdplZc3UbQY", + "sealed": "v2.0mhlCmb_jlmlzkl_w7-05CaLbx6gee9_yqin1Q-kpk5ajPrqmhImctabXk6A1A9s_Ts" +} diff --git a/internal/account/testdata/vault-share-v2-go.json b/internal/account/testdata/vault-share-v2-go.json new file mode 100644 index 0000000..0b69fb1 --- /dev/null +++ b/internal/account/testdata/vault-share-v2-go.json @@ -0,0 +1,9 @@ +{ + "recipient_private_key_hex": "1e395c47c72458f35b7540c47e2afacefbf0bc3b80ed6957aca1e004e8b35a9a", + "recipient_public_key": "BNZ6xhNEUxRDRRaBHa4iAlfrLu2fVGhk3mgx-XiVt7Owr4o_BN2axcRLKFLf9eXLtwJ9vEBQgiBArM7fIggxvXA", + "session_id": "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", + "uid": "uid-vector", + "password": "Kw9eHbru", + "sender_public_key": "BM4rJdocNKu-sk24tVjh1QKxfdLJN43q2NVO3NElj_H09ORvqFD6ZcX7xJ_DTef8pYUGo0AJz9bnFV8oxvkBElc", + "sealed": "v2.AAECAwQFBgcICQoLu1eCNMH4llbSSuHT4tJhJ5i2IkW1aTNL" +} diff --git a/internal/account/vault.go b/internal/account/vault.go new file mode 100644 index 0000000..48c8d44 --- /dev/null +++ b/internal/account/vault.go @@ -0,0 +1,164 @@ +package account + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// A session's password is chosen on the machine that runs it, and until now it +// lived only there and in whichever browser happened to be told it. Close the +// terminal window, clear the browser, and the session could never be opened +// again. +// +// So the CLI now seals every password to the account's own public key when it +// publishes the session. The private half never leaves the person's browsers +// unencrypted: the accounts service keeps it wrapped under a recovery key it +// has never seen. The service stores this envelope and cannot open it, which +// is the same promise the agent envelope in sealed.go makes, extended from one +// agent run to the account. +// +// The session id and the recipient's uid are bound in as associated data. The +// service decides which envelope goes with which session, and without the +// binding it could hand one session's password to another session's pane. + +// vaultShareInfo separates this derivation from every other use of the curve. +// It differs from sealInfo on purpose: the two envelopes carry the same kind of +// secret to different keys, and must never be mistaken for each other. +const vaultShareInfo = "shell.online session vault v2" + +// vaultSharePrefix versions the envelope in the value itself, so a later format +// can be told apart without a schema change on the service. +const vaultSharePrefix = "v2." + +// accountKeyBytes is an uncompressed P-256 point. +const accountKeyBytes = 65 + +// ParseAccountKey reports whether value is an account public key: base64url +// without padding, decoding to an uncompressed P-256 point. +func ParseAccountKey(value string) error { + _, err := decodeAccountKey(value) + return err +} + +func decodeAccountKey(value string) (*ecdh.PublicKey, error) { + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("account key is not base64url: %w", err) + } + if len(raw) != accountKeyBytes { + return nil, fmt.Errorf("account key is %d bytes, want %d", len(raw), accountKeyBytes) + } + key, err := ecdh.P256().NewPublicKey(raw) + if err != nil { + return nil, fmt.Errorf("account key is not a P-256 point: %w", err) + } + return key, nil +} + +// Fingerprint is the short form of an account key that a person compares by +// eye: the first eight bytes of its SHA-256, as four groups of four hex digits. +// The web app's account page shows the same value. +func Fingerprint(accountPublicKey string) (string, error) { + key, err := decodeAccountKey(accountPublicKey) + if err != nil { + return "", err + } + sum := sha256.Sum256(key.Bytes()) + digits := hex.EncodeToString(sum[:8]) + return digits[0:4] + "-" + digits[4:8] + "-" + digits[8:12] + "-" + digits[12:16], nil +} + +// SealToAccount seals a session password so that only the holder of the +// account's private key can read it, and only as the password of this session +// for this person. +func SealToAccount(accountPublicKey, sessionID, recipientUID, password string) (senderPublicKey, sealed string, err error) { + ephemeral, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return "", "", fmt.Errorf("generate ephemeral key: %w", err) + } + nonce := make([]byte, sealNonceBytes) + if _, err := rand.Read(nonce); err != nil { + return "", "", fmt.Errorf("generate nonce: %w", err) + } + return sealToAccountWith(ephemeral, nonce, accountPublicKey, sessionID, recipientUID, password) +} + +// sealToAccountWith is SealToAccount with the randomness supplied, so a test +// can produce a fixed vector for the browser to check against. +func sealToAccountWith( + ephemeral *ecdh.PrivateKey, nonce []byte, accountPublicKey, sessionID, recipientUID, password string, +) (string, string, error) { + if sessionID == "" || recipientUID == "" { + return "", "", errors.New("a vault share needs a session id and a recipient") + } + recipient, err := decodeAccountKey(accountPublicKey) + if err != nil { + return "", "", err + } + aead, err := vaultShareCipher(ephemeral, recipient) + if err != nil { + return "", "", err + } + ciphertext := aead.Seal(nil, nonce, []byte(password), vaultShareAAD(sessionID, recipientUID)) + envelope := append(append([]byte{}, nonce...), ciphertext...) + return base64.RawURLEncoding.EncodeToString(ephemeral.PublicKey().Bytes()), + vaultSharePrefix + base64.RawURLEncoding.EncodeToString(envelope), + nil +} + +// openFromAccount is the browser's half, kept here so the Go tests check the +// envelope end to end. The CLI itself never holds an account private key. +func openFromAccount(private *ecdh.PrivateKey, sessionID, recipientUID, senderPublicKey, sealed string) (string, error) { + if !strings.HasPrefix(sealed, vaultSharePrefix) { + return "", errors.New("not a v2 vault share") + } + sender, err := decodeAccountKey(senderPublicKey) + if err != nil { + return "", fmt.Errorf("sender key: %w", err) + } + envelope, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(sealed, vaultSharePrefix)) + if err != nil { + return "", fmt.Errorf("vault share is not base64url: %w", err) + } + if len(envelope) <= sealNonceBytes { + return "", errors.New("vault share is too short") + } + aead, err := vaultShareCipher(private, sender) + if err != nil { + return "", err + } + plaintext, err := aead.Open(nil, envelope[:sealNonceBytes], envelope[sealNonceBytes:], vaultShareAAD(sessionID, recipientUID)) + if err != nil { + return "", errors.New("the vault share could not be opened") + } + return string(plaintext), nil +} + +func vaultShareAAD(sessionID, recipientUID string) []byte { + return []byte(sessionID + "\x00" + recipientUID) +} + +func vaultShareCipher(private *ecdh.PrivateKey, public *ecdh.PublicKey) (cipher.AEAD, error) { + shared, err := private.ECDH(public) + if err != nil { + return nil, fmt.Errorf("derive shared secret: %w", err) + } + derived, err := hkdf.Key(sha256.New, shared, nil, vaultShareInfo, 32) + if err != nil { + return nil, fmt.Errorf("derive key: %w", err) + } + block, err := aes.NewCipher(derived) + if err != nil { + return nil, fmt.Errorf("build cipher: %w", err) + } + return cipher.NewGCM(block) +} diff --git a/internal/account/vault_client_test.go b/internal/account/vault_client_test.go new file mode 100644 index 0000000..1d533e0 --- /dev/null +++ b/internal/account/vault_client_test.go @@ -0,0 +1,207 @@ +package account + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestCallbackHandlerCarriesTheAccountKey(t *testing.T) { + _, public := newAccountKey(t) + results := make(chan callbackResult, 1) + handler := newCallbackHandler("state-123", "", results) + + handler.ServeHTTP(httptest.NewRecorder(), + callbackRequest("code=shc_abc&state=state-123&account_key="+public)) + + result := <-results + if result.err != nil || result.code != "shc_abc" { + t.Fatalf("result = %+v", result) + } + if result.accountKey != public { + t.Fatalf("accountKey = %q, want the key the browser sent", result.accountKey) + } +} + +// A malformed key must not stop someone signing in: it is simply not trusted. +func TestCallbackHandlerDropsAMalformedAccountKey(t *testing.T) { + results := make(chan callbackResult, 1) + handler := newCallbackHandler("state-123", "", results) + + handler.ServeHTTP(httptest.NewRecorder(), + callbackRequest("code=shc_abc&state=state-123&account_key=not-a-key")) + + result := <-results + if result.err != nil || result.code != "shc_abc" { + t.Fatalf("result = %+v", result) + } + if result.accountKey != "" { + t.Fatalf("accountKey = %q, want nothing trusted", result.accountKey) + } +} + +func TestCallbackHandlerTrustsNoKeyWhenTheStateIsWrong(t *testing.T) { + _, public := newAccountKey(t) + results := make(chan callbackResult, 1) + handler := newCallbackHandler("state-123", "", results) + + handler.ServeHTTP(httptest.NewRecorder(), + callbackRequest("code=shc_abc&state=forged&account_key="+public)) + + result := <-results + if result.err == nil { + t.Fatal("a forged callback was accepted") + } + if result.accountKey != "" { + t.Fatal("a forged callback got its key trusted") + } +} + +// End to end: the key the browser puts on the callback is the key the +// credentials carry away. +func TestLoginPinsTheAccountKeyFromTheBrowser(t *testing.T) { + _, public := newAccountKey(t) + service := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, http.StatusOK, map[string]any{ + "access_token": "sha_access", + "refresh_token": "shr_refresh", + "expires_in": 3600, + "account": map[string]string{"uid": "uid-1", "email": "ana@example.com"}, + }) + })) + defer service.Close() + + browser := func(authorize string) error { + parsed, err := url.Parse(authorize) + if err != nil { + return err + } + query := parsed.Query() + callback := fmt.Sprintf("%s?code=shc_abc&state=%s&account_key=%s", + query.Get("redirect_uri"), url.QueryEscape(query.Get("state")), public) + go func() { + response, err := http.Get(callback) + if err == nil { + response.Body.Close() + } + }() + return nil + } + + credentials, err := Login(context.Background(), NewClient(service.URL, "test"), Options{ + WebURL: "https://app.shell.online", + OpenBrowser: browser, + Timeout: 10 * time.Second, + }) + if err != nil { + t.Fatalf("Login: %v", err) + } + if credentials.AccountKey != public { + t.Fatalf("AccountKey = %q, want the key from the callback", credentials.AccountKey) + } + if credentials.UID != "uid-1" { + t.Fatalf("UID = %q", credentials.UID) + } +} + +func TestAccountKey(t *testing.T) { + _, public := newAccountKey(t) + tests := []struct { + name string + status int + body any + wantKey string + wantOK bool + wantErr string + }{ + {"a vault", http.StatusOK, map[string]any{"public_key": public, "version": 1}, public, true, ""}, + {"no vault yet", http.StatusNotFound, map[string]string{"error": "no vault"}, "", false, ""}, + {"a failure", http.StatusInternalServerError, map[string]string{"error": "database down"}, "", false, "database down"}, + {"a key that is not one", http.StatusOK, map[string]any{"public_key": "junk"}, "", false, "unusable key"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var authorization, path string + service := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + authorization = request.Header.Get("Authorization") + path = request.URL.Path + writeJSON(writer, test.status, test.body) + })) + defer service.Close() + + key, ok, err := NewClient(service.URL, "test").AccountKey(context.Background(), "sha_token") + if path != "/api/account/key" || authorization != "Bearer sha_token" { + t.Fatalf("asked %q with %q", path, authorization) + } + if test.wantErr == "" && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)) { + t.Fatalf("err = %v, want one mentioning %q", err, test.wantErr) + } + if key != test.wantKey || ok != test.wantOK { + t.Fatalf("AccountKey = %q, %v; want %q, %v", key, ok, test.wantKey, test.wantOK) + } + }) + } +} + +// Existing callers match on these messages, so wrapping the status in a type +// must not have changed a word of them. +func TestFailingResponsesKeepTheirMessages(t *testing.T) { + service := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/json" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "bad thing"}) + return + } + writer.WriteHeader(http.StatusBadGateway) + _, _ = writer.Write([]byte(" upstream ")) + })) + defer service.Close() + client := NewClient(service.URL, "test") + + if _, err := client.do(context.Background(), http.MethodGet, "/json", "", nil); err == nil || + err.Error() != "accounts service: bad thing" { + t.Fatalf("err = %v", err) + } + if _, err := client.do(context.Background(), http.MethodGet, "/plain", "", nil); err == nil || + err.Error() != "accounts service returned 502: upstream" { + t.Fatalf("err = %v", err) + } +} + +func TestRegisterSessionSendsTheOwnerShare(t *testing.T) { + var body map[string]any + service := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + decodeJSON(t, request, &body) + writeJSON(writer, http.StatusCreated, map[string]any{}) + })) + defer service.Close() + client := NewClient(service.URL, "test") + + input := SessionInput{ID: testSessionID, ShareURL: "https://shell.online/s/x", Command: "claude"} + if err := client.RegisterSession(context.Background(), "sha", input); err != nil { + t.Fatalf("RegisterSession: %v", err) + } + if _, present := body["owner_share"]; present { + t.Fatalf("a session without a share sent one: %+v", body) + } + + input.OwnerShare = &KeyShare{SenderPublicKey: "sender", Sealed: "v2.sealed"} + if err := client.RegisterSession(context.Background(), "sha", input); err != nil { + t.Fatalf("RegisterSession: %v", err) + } + share, ok := body["owner_share"].(map[string]any) + if !ok || share["sender_public_key"] != "sender" || share["sealed"] != "v2.sealed" { + t.Fatalf("owner_share = %+v", body["owner_share"]) + } +} diff --git a/internal/account/vault_test.go b/internal/account/vault_test.go new file mode 100644 index 0000000..db0d583 --- /dev/null +++ b/internal/account/vault_test.go @@ -0,0 +1,290 @@ +package account + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +const ( + testSessionID = "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t" + testUID = "uid-1" +) + +func newAccountKey(t *testing.T) (*ecdh.PrivateKey, string) { + t.Helper() + private, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate: %v", err) + } + return private, base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes()) +} + +func TestVaultShareRoundTrips(t *testing.T) { + private, public := newAccountKey(t) + sender, sealed, err := SealToAccount(public, testSessionID, testUID, "Kw9eHbru") + if err != nil { + t.Fatalf("SealToAccount: %v", err) + } + if !strings.HasPrefix(sealed, "v2.") { + t.Fatalf("sealed = %q, want the v2. prefix", sealed) + } + got, err := openFromAccount(private, testSessionID, testUID, sender, sealed) + if err != nil { + t.Fatalf("open: %v", err) + } + if got != "Kw9eHbru" { + t.Fatalf("open = %q", got) + } +} + +func TestVaultShareHidesThePassword(t *testing.T) { + _, public := newAccountKey(t) + _, sealed, err := SealToAccount(public, testSessionID, testUID, "Kw9eHbru") + if err != nil { + t.Fatalf("SealToAccount: %v", err) + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(sealed, "v2.")) + if err != nil { + t.Fatalf("decode: %v", err) + } + if bytes.Contains(raw, []byte("Kw9eHbru")) { + t.Fatal("the password appears in the envelope in the clear") + } +} + +// The service chooses which envelope goes with which session. Each of these is +// a way it could try to misuse one, and each must fail to open. +func TestVaultShareRefusesToOpenOutOfPlace(t *testing.T) { + private, public := newAccountKey(t) + sender, sealed, err := SealToAccount(public, testSessionID, testUID, "Kw9eHbru") + if err != nil { + t.Fatalf("SealToAccount: %v", err) + } + other, _ := newAccountKey(t) + + raw, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(sealed, "v2.")) + raw[len(raw)-1] ^= 0x01 + tampered := "v2." + base64.RawURLEncoding.EncodeToString(raw) + + tests := []struct { + name string + private *ecdh.PrivateKey + sessionID string + uid string + sender string + sealed string + }{ + {"another session", private, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", testUID, sender, sealed}, + {"another person", private, testSessionID, "uid-2", sender, sealed}, + {"another account key", other, testSessionID, testUID, sender, sealed}, + {"a flipped ciphertext bit", private, testSessionID, testUID, sender, tampered}, + {"no version prefix", private, testSessionID, testUID, sender, strings.TrimPrefix(sealed, "v2.")}, + {"an agent-style envelope", private, testSessionID, testUID, sender, "v1." + strings.TrimPrefix(sealed, "v2.")}, + {"a sender that is not a key", private, testSessionID, testUID, "junk", sealed}, + {"an envelope that is only a nonce", private, testSessionID, testUID, sender, + "v2." + base64.RawURLEncoding.EncodeToString(make([]byte, 12))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := openFromAccount(test.private, test.sessionID, test.uid, test.sender, test.sealed); err == nil { + t.Fatal("opened an envelope that should have been refused") + } + }) + } +} + +func TestSealToAccountRejectsBadInput(t *testing.T) { + _, public := newAccountKey(t) + tests := []struct { + name, key, sessionID, uid string + }{ + {"key is not base64url", "not base64!", testSessionID, testUID}, + {"key is not a point", base64.RawURLEncoding.EncodeToString(make([]byte, 65)), testSessionID, testUID}, + {"no session id", public, "", testUID}, + {"no recipient", public, testSessionID, ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, _, err := SealToAccount(test.key, test.sessionID, test.uid, "pw"); err == nil { + t.Fatal("SealToAccount accepted bad input") + } + }) + } +} + +func TestParseAccountKey(t *testing.T) { + _, public := newAccountKey(t) + private, _ := newAccountKey(t) + compressed := base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes()[:33]) + + if err := ParseAccountKey(public); err != nil { + t.Fatalf("a real key was refused: %v", err) + } + for name, value := range map[string]string{ + "empty": "", + "not base64url": "abc$def", + "padded base64": public + "=", + "too short": compressed, + "all zero point": base64.RawURLEncoding.EncodeToString(make([]byte, 65)), + "random 65 bytes": base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x04, 0x11}, 33)[:65]), + "standard alphabet": strings.NewReplacer("-", "+", "_", "/").Replace(public) + "+/", + } { + t.Run(name, func(t *testing.T) { + if err := ParseAccountKey(value); err == nil { + t.Fatalf("ParseAccountKey accepted %q", value) + } + }) + } +} + +func TestFingerprintIsShortAndStable(t *testing.T) { + _, public := newAccountKey(t) + first, err := Fingerprint(public) + if err != nil { + t.Fatalf("Fingerprint: %v", err) + } + if !regexp.MustCompile(`^[0-9a-f]{4}(-[0-9a-f]{4}){3}$`).MatchString(first) { + t.Fatalf("fingerprint = %q, want four groups of four hex digits", first) + } + raw, _ := base64.RawURLEncoding.DecodeString(public) + sum := sha256.Sum256(raw) + if strings.ReplaceAll(first, "-", "") != hex.EncodeToString(sum[:8]) { + t.Fatalf("fingerprint %q is not the first eight bytes of the key's SHA-256", first) + } + if _, err := Fingerprint("junk"); err == nil { + t.Fatal("Fingerprint accepted something that is not a key") + } +} + +/* + * The cross-language vectors. + * + * The browser opens what the CLI seals and seals what the web app stores, so + * the two implementations must agree byte for byte on the derivation. Each side + * writes a fixed vector the other opens in its own tests. + */ + +type vaultVector struct { + RecipientPrivateKeyHex string `json:"recipient_private_key_hex"` + RecipientPublicKey string `json:"recipient_public_key"` + SessionID string `json:"session_id"` + UID string `json:"uid"` + Password string `json:"password"` + SenderPublicKey string `json:"sender_public_key"` + Sealed string `json:"sealed"` +} + +const goVectorPath = "testdata/vault-share-v2-go.json" +const browserVectorPath = "testdata/vault-share-v2-browser.json" + +func fixedScalar(t *testing.T, label string) *ecdh.PrivateKey { + t.Helper() + sum := sha256.Sum256([]byte(label)) + key, err := ecdh.P256().NewPrivateKey(sum[:]) + if err != nil { + t.Fatalf("fixed key %q: %v", label, err) + } + return key +} + +func buildGoVector(t *testing.T) vaultVector { + t.Helper() + recipient := fixedScalar(t, "shell.online vault vector recipient") + ephemeral := fixedScalar(t, "shell.online vault vector ephemeral") + nonce := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} + public := base64.RawURLEncoding.EncodeToString(recipient.PublicKey().Bytes()) + vector := vaultVector{ + RecipientPrivateKeyHex: hex.EncodeToString(recipient.Bytes()), + RecipientPublicKey: public, + SessionID: testSessionID, + UID: "uid-vector", + Password: "Kw9eHbru", + } + sender, sealed, err := sealToAccountWith(ephemeral, nonce, public, vector.SessionID, vector.UID, vector.Password) + if err != nil { + t.Fatalf("seal: %v", err) + } + vector.SenderPublicKey = sender + vector.Sealed = sealed + return vector +} + +func openVector(t *testing.T, vector vaultVector) string { + t.Helper() + scalar, err := hex.DecodeString(vector.RecipientPrivateKeyHex) + if err != nil { + t.Fatalf("private key hex: %v", err) + } + private, err := ecdh.P256().NewPrivateKey(scalar) + if err != nil { + t.Fatalf("private key: %v", err) + } + if got := base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes()); got != vector.RecipientPublicKey { + t.Fatalf("the vector's public key does not belong to its private key") + } + password, err := openFromAccount(private, vector.SessionID, vector.UID, vector.SenderPublicKey, vector.Sealed) + if err != nil { + t.Fatalf("open vector: %v", err) + } + return password +} + +// The file is what the browser test reads, so it must be exactly what this +// code produces. Regenerate with UPDATE_VAULT_VECTOR=1 after a deliberate +// change to the format, never by hand. +func TestGoVaultVectorMatchesTestdata(t *testing.T) { + vector := buildGoVector(t) + encoded, err := json.MarshalIndent(vector, "", " ") + if err != nil { + t.Fatalf("encode: %v", err) + } + encoded = append(encoded, '\n') + + if os.Getenv("UPDATE_VAULT_VECTOR") == "1" { + if err := os.MkdirAll(filepath.Dir(goVectorPath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(goVectorPath, encoded, 0o644); err != nil { + t.Fatalf("write: %v", err) + } + } + + stored, err := os.ReadFile(goVectorPath) + if err != nil { + t.Fatalf("read %s: %v", goVectorPath, err) + } + if !bytes.Equal(stored, encoded) { + t.Fatalf("%s no longer matches what this code seals; the browser test is checking a stale vector", goVectorPath) + } + if got := openVector(t, vector); got != vector.Password { + t.Fatalf("vector opened to %q", got) + } +} + +func TestOpenBrowserVaultVector(t *testing.T) { + contents, err := os.ReadFile(browserVectorPath) + if errors.Is(err, os.ErrNotExist) { + t.Skipf("%s has not been generated by the web app yet", browserVectorPath) + } + if err != nil { + t.Fatalf("read: %v", err) + } + var vector vaultVector + if err := json.Unmarshal(contents, &vector); err != nil { + t.Fatalf("decode: %v", err) + } + if got := openVector(t, vector); got != vector.Password { + t.Fatalf("the browser's envelope opened to %q, want %q", got, vector.Password) + } +} From f5fdcc49ba16c92a5f67d6d56a69f886e3b1e9a1 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 11 Sep 2026 14:08:05 -0700 Subject: [PATCH 2/3] Seal passwords to assignees and fix what review found Assigning a session from the owner's browser now seals its password to the people added, so whoever is responsible opens it without being told the password. An assignee made elsewhere is sealed to automatically only if this browser already trusts their key; otherwise the session page offers it to the owner in one click, so the service's assignee list alone never decides who can read a session. From review: - A password that opened a session is sealed into the vault only when the vault does not already hold exactly it, and a changed share no longer rebuilds an open terminal. Together these ended a reconnect every poll. - A typed password is written only once it has opened the session, and a guess can never replace a password known to work. - A vault reset carries finished sessions across too. - Everyone holding a copy can be shared with again, for copies sealed to an old browser key or a vault since reset. - A proven password is tried before a vault share, which anyone holding the public key could have made. On phones, the consent and vault buttons no longer collapse to the height of their text, and the recovery key field shows the whole key. A gitleaks allowlist covers the two fixed test vectors, whose keys exist only for those tests. --- .gitleaks.toml | 11 +++ app/src/components/SessionAudience.tsx | 77 ++++++++++++++----- app/src/lib/session-passwords.test.ts | Bin 11047 -> 11912 bytes app/src/lib/session-passwords.ts | Bin 7119 -> 7798 bytes app/src/lib/session-share.test.ts | 36 ++++++++- app/src/lib/session-share.ts | 27 +++++++ app/src/routes/Session.tsx | 12 +++ app/src/routes/Workspace.tsx | 32 +++++++- app/src/styles/auth.css | 8 ++ app/src/styles/vault.css | 25 +++++- app/src/terminal/TerminalPane.tsx | 101 ++++++++++++++++--------- app/src/vault/VaultGate.tsx | 15 +++- app/src/vault/VaultProvider.tsx | 3 +- app/src/vault/share-with.ts | 48 ++++++++++++ 14 files changed, 334 insertions(+), 61 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 app/src/vault/share-with.ts diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b22cc5e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,11 @@ +title = "shell.online" + +[extend] +useDefault = true + +# Fixed cross-language test vectors for the session vault. The private keys +# and passwords in them are throwaway values made for these tests alone, so a +# scanner flagging them is a false positive. Nothing else is exempt. +[allowlist] +description = "Session vault test vectors" +paths = ['''(^|/)internal/account/testdata/vault-share-v2-(go|browser)\.json$'''] diff --git a/app/src/components/SessionAudience.tsx b/app/src/components/SessionAudience.tsx index 41614fe..e165454 100644 --- a/app/src/components/SessionAudience.tsx +++ b/app/src/components/SessionAudience.tsx @@ -7,6 +7,7 @@ import { shareSessionKeys, type Member, type SessionRecord } from "../lib/api"; import { addToAudience, audienceFor, passwordFor } from "../lib/session-passwords"; import { keyTrust, trustKey } from "../lib/known-keys"; import { displayName } from "../lib/people"; +import { assigneeIds } from "../lib/session-view"; import { useVault } from "../vault/VaultProvider"; /** @@ -96,6 +97,15 @@ export function SessionAudience({ } const waiting = confirming ? members.find((member) => member.uid === confirming) : undefined; + /* + * Assigned, but holding no copy. Usually someone assigned from another + * browser, whose key this one has never sealed to: the service's say-so is + * not enough to seal to it unasked, so it is offered here in one click. + */ + const assigned = new Set(assigneeIds(session)); + const assignedWithout = members.filter( + (member) => member.uid !== you.uid && assigned.has(member.uid) && !holders.has(member.uid), + ); return (
@@ -105,26 +115,53 @@ export function SessionAudience({

Only you. Add someone and they can open it straight away.

) : (
    - {shared.map((member) => { - const changed = - member.accountKey && keyTrust(you.uid, member.uid, member.accountKey) === "changed"; - return ( -
  • - - {displayName(member)} - {changed && ( - - )} -
  • - ); - })} + {/* + Everyone listed can be shared with again. The service knows who + holds a copy but not whether it still opens: one sealed to a + browser key from before the vault, or to a vault its owner has + since reset, does not. Sealing again is harmless, so the way back + is offered to everyone rather than guessed at. + */} + {shared.map((member) => ( +
  • + + {displayName(member)} + {member.accountKey && ( + + )} +
  • + ))} +
+ )} + + {assignedWithout.length > 0 && ( +
    + {assignedWithout.map((member) => ( +
  • + + {displayName(member)} is assigned but cannot open it yet + {member.accountKey ? "." : ", and has not set up a vault."} + + {member.accountKey && ( + + )} +
  • + ))}
)} diff --git a/app/src/lib/session-passwords.test.ts b/app/src/lib/session-passwords.test.ts index ece8630fd45b1dc14ef84a490d34e9fc1175a5ce..a531527e6013c15eedb3163855c027b7282ee8ae 100644 GIT binary patch delta 654 zcmZ9KzfTlF6vtz7655rbQ}wT@;LjqFHF zj=q1a?zHN+0H6*}AvgLpD#(Skg*FMeR5<7enOW#)143z+q5*@#Y6TWmV)+h2j>Z?9 z_Pai;D;zc%>9+H-Y7lxB8L9bso{2VsbPa!hN5MKmVsxJKx!3WEQgs_pq_Um%c^P&b zczr&2T#9DedUa{RI?Z@|Bdz0#W*f*hT(f-H;#ImROf*^#fD9c7==PAG`&K4>FXEr+ zO{I&{3y)xvepMgRi{LIj2_DeP_+G{Pp|xO+-i`(Id19P&4d^t8mM&e{zwAP~^eVR; zW-2Uf^sIvteo>7U%0|L?#W&0ST+b&8Ew}kFBC4=1FPiCHf?(BIRSV3y9~fq9>U z=p_;r+bL3xkv;vFC(p`Yo0FxB>Ll&X+@W`sn=}BjHtL?*9c<1sDING-`oEmBBSC`c?WF3&GYQ2-LzsX!_tH4iA9l9`s4 zT9lesqL81Ls*qW%P*Pbi`GbVaXB}FI4OPI11 qrzYp;rA#gmlH9yq^0DCLx7rsb%Zu}ar6f-REwhIV<;?DU^Zn-G^w-7bZ{un1JQMh2xbZf?HH_9fyMYM1@3tg+R0u-Vf^I53>Id=OaghyTF_^`xgYVBxj+b<8LV_$a5j43Wjrkn=Zv1)p zD$XWfDOr)Rrw;}f|F}~6!jjOZfScEyJlUGFp{jWROLzO z4h4Bpk>tnmc;B^jwOl~zDa5CaMli;K(ii&7Lai-BV4r9hJ=H}Kt^ypaFs=31ej+yH$( BE0zEN diff --git a/app/src/lib/session-share.test.ts b/app/src/lib/session-share.test.ts index 38991f5..d8cd5b5 100644 --- a/app/src/lib/session-share.test.ts +++ b/app/src/lib/session-share.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sealTargets, shareCandidates } from "./session-share"; +import { sealTargets, shareCandidates, trustedAssignees } from "./session-share"; import type { Member } from "./api"; function member(uid: string, accountKey?: string): Member { @@ -83,3 +83,37 @@ describe("who a session password is sealed to", () => { expect(targets).toEqual([]); }); }); + +/* + * An assignee should be able to open what they are responsible for, but the + * assignee list is the service's word. These hold the line between the two. + */ +describe("which assignees are sealed to without asking", () => { + const team = [you, member("a", "key-a"), member("b", "key-b"), member("c")]; + const knownA = (entry: Member) => entry.uid === "a"; + const uids = (members: Member[]) => members.map((entry) => entry.uid); + + it("includes an assignee whose key this browser has sealed to before", () => { + expect(uids(trustedAssignees({ assignees: ["a"], members: team, you, holders: [], isKnown: knownA }))).toEqual(["a"]); + }); + + it("leaves an assignee with a key never seen here to the owner", () => { + expect(trustedAssignees({ assignees: ["b"], members: team, you, holders: [], isKnown: knownA })).toEqual([]); + }); + + it("does not seal again to an assignee who already holds a copy", () => { + expect(trustedAssignees({ assignees: ["a"], members: team, you, holders: ["a"], isKnown: knownA })).toEqual([]); + }); + + it("skips an assignee who has no vault", () => { + expect(trustedAssignees({ assignees: ["c"], members: team, you, holders: [], isKnown: () => true })).toEqual([]); + }); + + it("never includes you", () => { + expect(trustedAssignees({ assignees: ["me"], members: team, you, holders: [], isKnown: () => true })).toEqual([]); + }); + + it("ignores someone named as an assignee who is not in the team", () => { + expect(trustedAssignees({ assignees: ["stranger"], members: team, you, holders: [], isKnown: () => true })).toEqual([]); + }); +}); diff --git a/app/src/lib/session-share.ts b/app/src/lib/session-share.ts index 0e9c1c1..c2f5e7b 100644 --- a/app/src/lib/session-share.ts +++ b/app/src/lib/session-share.ts @@ -49,3 +49,30 @@ export function sealTargets(input: { (member) => chosen.has(member.uid) && !done.has(member.uid), ); } + +/** + * Assignees whose copy can be sealed without asking anyone. + * + * Being made responsible for a session should mean being able to open it. But + * the list of assignees comes from the service, and a service that could name + * anyone an assignee and have the password sealed to them would be choosing + * who reads the session. So only assignees whose key this browser already + * trusts, because it has sealed to that key before, go without asking. The + * rest are offered to the owner on the session page, and anyone the owner + * assigns from their own browser is sealed to there and then. + */ +export function trustedAssignees(input: { + assignees: readonly string[]; + members: Member[]; + you: Member | null; + /** Who already holds a copy. */ + holders: readonly string[]; + /** Whether this browser has sealed to this member's current key before. */ + isKnown: (member: Member) => boolean; +}): Member[] { + const assigned = new Set(input.assignees); + const holders = new Set(input.holders); + return shareCandidates(input.members, input.you).reachable.filter( + (member) => assigned.has(member.uid) && !holders.has(member.uid) && input.isKnown(member), + ); +} diff --git a/app/src/routes/Session.tsx b/app/src/routes/Session.tsx index 6d442a1..9970c63 100644 --- a/app/src/routes/Session.tsx +++ b/app/src/routes/Session.tsx @@ -30,6 +30,8 @@ import { displayName, findPerson } from "../lib/people"; import { usePageTitle } from "../lib/page-title"; import { assigneeIds, canRemove, canStop } from "../lib/session-view"; import { ago, elapsed } from "../lib/time"; +import { useVault } from "../vault/VaultProvider"; +import { shareWith } from "../vault/share-with"; function CommentBody({ body, members }: { body: string; members: Member[] }) { return ( @@ -77,6 +79,7 @@ export function Session() { const navigate = useNavigate(); const assignmentRevision = useRef(0); const assignmentQueue = useRef>(Promise.resolve()); + const vault = useVault(); const load = useCallback(async () => { try { @@ -204,6 +207,15 @@ export function Session() { try { await request; if (assignmentRevision.current === revision) setError(""); + /* + * Whoever is made responsible can open it straight away. The owner + * assigning from their own browser is the say-so, the same as sharing, + * and only the owner holds the password to seal. + */ + const added = uids.filter((uid) => !previous.includes(uid)); + if (session.ownerUid === you.uid && added.length > 0) { + void shareWith(vault, you.uid, session, members.filter((member) => added.includes(member.uid))); + } } catch (caught) { if (assignmentRevision.current === revision) { setDetail((current) => current ? { diff --git a/app/src/routes/Workspace.tsx b/app/src/routes/Workspace.tsx index 17438c8..5ac5a61 100644 --- a/app/src/routes/Workspace.tsx +++ b/app/src/routes/Workspace.tsx @@ -28,11 +28,12 @@ import { type SessionRecord, } from "../lib/api"; import { generatePassword, sealPassword } from "../lib/seal"; -import { sealTargets } from "../lib/session-share"; +import { sealTargets, trustedAssignees } from "../lib/session-share"; import { shareSessionKeys } from "../lib/api"; import { keyTrust, trustKey } from "../lib/known-keys"; import { isVaultShare } from "../lib/vault-crypto"; import { useVault } from "../vault/VaultProvider"; +import { shareWith } from "../vault/share-with"; import { adoptOrigin, audienceFor, @@ -461,10 +462,23 @@ export function Workspace() { /* Only the owner shares with colleagues. */ if (!me || session.ownerUid !== me.uid) continue; + /* + * Everyone chosen in this browser, and every assignee whose key this + * browser already trusts. An assignee it has never sealed to is left to + * the owner on the session page; see trustedAssignees. + */ + const assignees = trustedAssignees({ + assignees: assigneeIds(session), + members: roster, + you: me, + holders: session.sharedWith ?? [], + isKnown: (member) => + Boolean(member.accountKey) && keyTrust(me.uid, member.uid, member.accountKey ?? "") === "known", + }).map((member) => member.uid); const missing = sealTargets({ members: roster, you: me, - chosen: audienceFor(session.id), + chosen: [...audienceFor(session.id), ...assignees], done: sharedWith.current.get(session.id), }); if (missing.length === 0) continue; @@ -517,6 +531,20 @@ export function Workspace() { setSessions((current) => current?.map( (entry) => entry.id === updated.id ? updated : entry, ) ?? null); + /* + * Whoever is made responsible can open it straight away. The owner + * assigning from their own browser is the say-so, the same as sharing, + * and only the owner holds the password to seal. + */ + const added = uids.filter((uid) => !previous.includes(uid)); + if (you && session.ownerUid === you.uid && added.length > 0) { + void shareWith( + vaultRef.current, + you.uid, + session, + members.filter((member) => added.includes(member.uid)), + ); + } const names = members .filter((member) => uids.includes(member.uid)) .map((member) => member.name || member.email); diff --git a/app/src/styles/auth.css b/app/src/styles/auth.css index fabe047..945d474 100644 --- a/app/src/styles/auth.css +++ b/app/src/styles/auth.css @@ -1069,6 +1069,14 @@ flex-direction: column-reverse; } + /* + * In a column, `flex: 1` sets the height basis to zero and the buttons + * collapse to the height of their text. Stacked, they take their own height. + */ + .consent-actions .btn { + flex: none; + } + .session { align-items: flex-start; flex-direction: column; diff --git a/app/src/styles/vault.css b/app/src/styles/vault.css index d493011..8a10aa0 100644 --- a/app/src/styles/vault.css +++ b/app/src/styles/vault.css @@ -7,7 +7,7 @@ .vault-form { display: grid; gap: 12px; - margin-top: 4px; + margin-top: 14px; } .vault-label { @@ -74,8 +74,13 @@ border-radius: var(--radius-control); } +/* A whole key is 39 characters, so it wraps at its dashes rather than scrolling. */ .vault-input-wide { width: 100%; + line-height: 1.5; + letter-spacing: 0.04em; + resize: none; + overflow-wrap: anywhere; } .vault-input:focus-visible { @@ -131,6 +136,24 @@ margin-top: 20px; } +/* Assignees who cannot open the session yet, each with the way to let them. */ +.audience-assigned { + display: grid; + gap: 6px; + margin: 10px 0 0; + padding: 0; + list-style: none; + font-size: 0.86rem; + color: var(--muted); +} + +.audience-assigned li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + /* Under the password gate: where a terminal-started session's password is. */ .pane-gate-hint { font-size: 0.82rem; diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index cbb91e9..677ff11 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type FormEvent, useMemo } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { Terminal } from "@xterm/xterm"; import { ArrowClockwise, LockKey } from "@phosphor-icons/react"; import "@xterm/xterm/css/xterm.css"; @@ -7,7 +7,7 @@ import { DESKTOP_TERMINAL_GRID, type TerminalGrid } from "./terminal-grid"; import { fittedTerminal, type TerminalCell } from "./terminal-fit"; import { cellMeasurer, terminalBox } from "./terminal-metrics"; import { encryptionFragment, resolveSessionSocket, sessionIdFromShareUrl } from "./socket-url"; -import { cachedPassword, forgetUnverified, markVerified, rememberFor } from "../lib/session-passwords"; +import { cachedPassword, forgetUnverified, markVerified, rememberVerified } from "../lib/session-passwords"; import { isVaultShare } from "../lib/vault-crypto"; import { useVault } from "../vault/VaultProvider"; import { AuditSink } from "./audit-sink"; @@ -143,25 +143,23 @@ export function TerminalPane({ }, []); /* - * A stable identity for the sealed password. - * - * The sessions list is refetched every few seconds and every fetch builds - * new objects, so the keyShare prop is a different object each time even - * when the bytes are identical. It is in the dependency list of the effect - * below, which builds the terminal and opens the socket, so an unstable - * identity tears the terminal down and reconnects it on every poll. What - * matters is the content, so that is what is compared. + * The sealed password, read when the terminal is built rather than being a + * reason to build it again. The session list is refetched every few + * seconds, and a copy saved to the vault has different bytes every time it + * is sealed, so rebuilding on a change tore an open terminal down and put + * it back, scrollback and all. A share that arrives while the pane is still + * asking for a password is picked up by the effect after the one below. */ + const shareRef = useRef(keyShare); + shareRef.current = keyShare; const sealed = keyShare ? `${keyShare.senderPublicKey}:${keyShare.sealed}` : ""; - const stableShare = useMemo( - () => keyShare, - // eslint-disable-next-line react-hooks/exhaustive-deps -- content, not identity - [sealed], - ); + /* Shares already tried in this pane, so one that does not open is not tried in a loop. */ + const tried = useRef(new Set()); useEffect(() => { const node = mount.current; if (!node) return; + tried.current = new Set(); /* A pane reused for another session starts from the default again. */ grid.current = DESKTOP_TERMINAL_GRID; @@ -203,15 +201,16 @@ export function TerminalPane({ let shown = message; if (next === "needs-password" && message) { /* - * An attempt failed. Only a guess is thrown away. A password from - * the vault, or one that has opened this session before, is kept: - * a frame can fail to open for reasons other than a wrong - * password, and deleting the only copy of a right one is how - * sessions used to be lost for good. + * An attempt failed. Only a cached guess is thrown away. A + * password from the vault, or one that has opened this session + * before, is kept: a frame can fail to open for reasons other than + * a wrong password, and deleting the only copy of a right one is + * how sessions used to be lost for good. A typed password was + * never written, so there is nothing of it to remove. */ const failed = attempt.current; attempt.current = null; - if (failed && sessionId && (failed.source === "cache" || failed.source === "typed")) { + if (failed && sessionId && failed.source === "cache") { forgetUnverified(sessionId, failed.password); } /* Another source may still hold the right one. */ @@ -229,15 +228,15 @@ export function TerminalPane({ attempt.current = null; pending.current = []; if (!worked || !sessionId) return; - if (worked.source === "cache" || worked.source === "typed") { - markVerified(sessionId, worked.password); - } + /* Written only now that it has proved itself; see handleUnlock. */ + if (worked.source === "typed") rememberVerified(sessionId, worked.password); + if (worked.source === "cache") markVerified(sessionId, worked.password); /* * A password that opened the session but did not come from the * vault goes into it now, so no browser has to be told it again. * That includes one a colleague sealed to this browser's old key. */ - if (worked.source !== "vault") void vaultRef.current.keep(sessionId, worked.password); + if (worked.source !== "vault") void keepIfMissing(sessionId, worked.password); }, onData: (bytes, reset) => { if (reset) term.reset(); @@ -293,6 +292,8 @@ export function TerminalPane({ * one a colleague sealed to this browser's old key. The gate appears only * when all of them fail, or there are none. */ + const initial = shareRef.current; + if (initial) tried.current.add(`${initial.senderPublicKey}:${initial.sealed}`); void connected.start().then(async () => { if (!connected.needsPassword || !sessionId) return; const found: Attempt[] = []; @@ -302,13 +303,27 @@ export function TerminalPane({ const opener = vaultRef.current; const cached = cachedPassword(sessionId); if (cached?.verified) add("cache", cached.password); - if (stableShare && isVaultShare(stableShare.sealed)) add("vault", await opener.openShare(sessionId, stableShare)); + if (initial && isVaultShare(initial.sealed)) add("vault", await opener.openShare(sessionId, initial)); add("cache", cached?.password); - if (stableShare && !isVaultShare(stableShare.sealed)) add("legacy", await opener.openShare(sessionId, stableShare)); + if (initial && !isVaultShare(initial.sealed)) add("legacy", await opener.openShare(sessionId, initial)); pending.current = found; tryNext(); }); + /* + * Seals a password that worked into the vault, unless the vault already + * holds exactly this one. Sealing is never byte-for-byte repeatable, so + * doing it regardless would rewrite the share on every open. Only a vault + * share counts as held: one sealed to an old browser key is the thing + * being moved into the vault. + */ + async function keepIfMissing(id: string, password: string): Promise { + const opener = vaultRef.current; + const share = shareRef.current; + if (share && isVaultShare(share.sealed) && (await opener.openShare(id, share)) === password) return; + await opener.keep(id, password); + } + const observer = new ResizeObserver(() => refit()); observer.observe(node); const frame = requestAnimationFrame(refit); @@ -324,7 +339,27 @@ export function TerminalPane({ measure.current = null; connection.current = null; }; - }, [shareUrl, refit, canType, stableShare]); + }, [shareUrl, refit, canType]); + + /* + * A share that arrives while the pane is asking for a password, such as the + * CLI's own copy landing a moment after the session appears, is tried + * without anyone having to reload. Each share is tried once. + */ + useEffect(() => { + const share = shareRef.current; + const id = sessionIdFromShareUrl(shareUrl); + if (status !== "needs-password" || !share || !id || attempt.current) return; + const key = `${share.senderPublicKey}:${share.sealed}`; + if (tried.current.has(key)) return; + tried.current.add(key); + void vaultRef.current.openShare(id, share).then((password) => { + if (!password || !connection.current || attempt.current) return; + pending.current = []; + attempt.current = { source: isVaultShare(share.sealed) ? "vault" : "legacy", password }; + void connection.current.submitPassword(password); + }); + }, [sealed, status, shareUrl]); /* A hidden pane measures as zero, so it has to be refitted when it returns. */ useEffect(() => { @@ -342,13 +377,11 @@ export function TerminalPane({ setUnlocking(true); setDetail(""); /* - * Cached as a guess now, and kept for good once a frame opens with it: it - * is sealed into the vault then, so this is the last time it is typed for - * this session in any browser. A wrong one is dropped by the failure path - * above. + * Nothing is written until the password opens the session. Then it is + * cached as proven and sealed into the vault, so this is the last time it + * is typed for this session in any browser. A typo is simply forgotten, + * and never takes the place of a password that works. */ - const sessionId = sessionIdFromShareUrl(shareUrl); - if (sessionId) rememberFor(sessionId, password); pending.current = []; attempt.current = { source: "typed", password }; await connection.current.submitPassword(password); diff --git a/app/src/vault/VaultGate.tsx b/app/src/vault/VaultGate.tsx index 87e6c07..1a7c60c 100644 --- a/app/src/vault/VaultGate.tsx +++ b/app/src/vault/VaultGate.tsx @@ -256,15 +256,26 @@ function VaultUnlock() { - setText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }} autoComplete="off" autoCapitalize="characters" spellCheck={false} - placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" + placeholder="Paste or type your recovery key" />