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.
+
+
+ {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
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
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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
m3
zK)!5*pFZrr(tVqnU{H|m3SS8Sw7qSjDhAap)-|eNj|$}kNKB~~im0mhLU6kYM(msB
z;Z=O4y`UJgpS=MEZIP`A}5^mn%)&q@YU5(!9vwoH$SExFVJNw90M~1
z`Cn8KR@tF5mD)sLnjSwf!ViMZ?b~#pOKk9=*a|It(FmP|dn0t{g7Kk|yoSosBq#|j
z+Q~bX{;uOsg{2L=L8luD7)K%n+Go%)?33{mCzLvb8N?X6;VT9mS|*47R!xS@HiDCi
zi{_zYEhIlwj8T%aSq1b+5DfXF1W4v{L5HGqD+uFPm>IG+?0SpZD1KD=Bt#1z
z8XmdE^_B^nyw_ntAp_Ut+3Fa5Vbn;>L8Bo1VW?k{_IN9-G7~zCpb@!Xm&?-QAm`Bf
zpU8D9fbFm}maGg{{216OrxU1=l&~i>m*@gVU*~(QtMnNLxXYDorq>UycbD_l5
zEv&mVzZ0{%nV0$HilOWbShS5A_qoJhV0=B1mO`m2wXVtOYs|mUr@<2er3ux=QK^aL(*Hs%{$NbfAkN+Z2srm8};*^Q$K|
z{3_!KjECsy=w)hGD5(tnu|LKpjQ9N~Kw2$ZTQfO~5pI~Nd+3PO!*gb>j}K(9coCya
z6_?ExDlhhrO3LS?zs%u;etZf9IKZH9yo%RCUD#s;0;az%?%t0jSLM+HT(u2
zg~MD4sX>4tb0;x4>slWi{xgw_2?&KPUQ@UweOYH4#^|7
ztPRt225P|=v8W;Pz>oWdYlLJBYn=(za~@%r4ttyvTs9r__q9%_3LX>D+^SzlGNCFE
zr*NQacBxtVL`e!kQ48`(RIS?zycTLoE!o5!eqyg)G}MUMYoxSP8GvtgAU|b|;1r`;{;@Vg)lv&Q_0|oT
z=>Jq{0!lgQx36k~c;CIuX^XI$d2SBBVJA9At_C79#t
zAY}xMFzFL$m3=l_iZ^3ofGS16FLW=TB-=Njx9lK|6ZkSo62i4Q0
zDwumZ`KXcQq4@70FY$GppUJvL-9q6;bWgG!ee25D?7$u_zE+_WstK#-3O^#G+!*2V
zyH9NDy@6GeOgj`tht_TJ=<*De+o~zV|E{iM&g89FEQo%px~dzR=OXnmH5YZX<)ejz
zm+@lRf{P?U56!}}0scnr-d!AqH;3S14W{pgfclSBidujM^m$P#Y+qzRnhVd8!kSkX
ziL^*+%RRGnPbP1>&jwk(vrFb*(oOX5za5jLJAur?aaNv3NSn^T@5CBsmTa(P+TSr&yjKms#^=P8kYZ%#)^DEX|0=$4Ab?jwachl%SiuI(`(Roph-{lMyz+t
z_T9cZ)l@}Vs+lzzPGtbDA*tZ$fW#6Xu&S2+@vMbi8FSt4q6)!|sG>sPG!r)u
z(=|`S0*+5tP*Z|s^2ASG{rEUhJ@m#q4D{)$mN#^Bke#93jK}qO#eS)cJ61n@(E>T7
z+Ofv)5&xk8HPkCDT628ina>OC@C`I@_iH5(V#nC#0yU}7;Px<#~ESv7x`
zBhbB3Fga~zLDkKTp43FT#q#n@A8IKWrx+M?KZGmLcXE8G#Zd~{>BsVxF4}w`@57E6
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.
+ */}
+
+ {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 && (
+
+ )}