Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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$''']
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 190 additions & 1 deletion app/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`;
Expand All @@ -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, {
Expand Down Expand Up @@ -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<string, unknown> = {}) =>
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);
});
});
Loading
Loading