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/.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/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..16a4aab 100644 --- a/app/src/components/SessionAudience.tsx +++ b/app/src/components/SessionAudience.tsx @@ -1,22 +1,24 @@ 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 { assigneeIds } from "../lib/session-view"; +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 +30,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 +96,18 @@ export function SessionAudience({ } } + const waiting = confirming ? members.find((member) => member.uid === confirming) : undefined; + /* + * Assigned, but holding no copy: someone assigned by an admin, or from + * another browser. The assignee list is the service's word, and owners and + * admins can assign anyone, themselves included, so it never seals a + * password on its own. The owner lets them open it 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 (

Who can open it

@@ -92,15 +116,77 @@ export function SessionAudience({

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

) : (
    + {/* + 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 && ( + + )} +
  • + ))} +
+ )} + + {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 0000000..cd4f446 Binary files /dev/null and b/app/src/lib/known-keys.ts differ 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 621b350..a531527 100644 Binary files a/app/src/lib/session-passwords.test.ts and b/app/src/lib/session-passwords.test.ts differ diff --git a/app/src/lib/session-passwords.ts b/app/src/lib/session-passwords.ts index 20ecf3a..d8c0989 100644 Binary files a/app/src/lib/session-passwords.ts and b/app/src/lib/session-passwords.ts differ 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 0000000..c833309 Binary files /dev/null and b/app/src/lib/vault-crypto.ts differ 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}} + +
+ + {/* + Two lines, so the whole key is in view on a phone: a single line + scrolled most of it out of sight, which is no way to check it. + */} +