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)} 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.
+ */}
+
+ {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 && (
+
+ )}