diff --git a/.github/SECURITY.md b/.github/SECURITY.md index c08a213..f0a0c88 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -20,8 +20,10 @@ You should receive an acknowledgement within three business days. We will valida - 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. - Input typed into a session from the browser is recorded in the team's audit log and encrypted in the browser to the team's audit public key (ephemeral ECDH P-256, HKDF-SHA256, AES-256-GCM bound to the organization, session, entry kind, time and author). The matching private key reaches each member sealed to their session vault by a teammate's own vault key, and the service stores only the public key, the sealed copies and ciphertext. Any way for the accounts service, the relay, or a copy of the database to read audit input text or the team audit key is in scope, as is the service substituting a team key that members then encrypt to. Metadata stays readable by the service by design: who acted, in which session, the entry kind and time, and the lifecycle entries the service writes itself. Trust-on-first-use applies to a teammate's first-seen vault key, and the web app served by shell.online performs the encryption. Entries recorded before encryption existed may remain readable until an owner's or admin's browser encrypts them in place, and in database backups taken before then until those expire. - 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. +- 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. Live rotation replaces the salt, password, and key in that state before the host changes ciphers; the stable session path remains the same. 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 password ` can recover it. Human `shell list` output does not print every password; `--json` deliberately includes them for agents. The directory and records must remain owner-only and are deleted when their processes close. +- `shell password rotate ` changes only future access: it cannot erase output a viewer already received. The host rejects old-key input as soon as it swaps ciphers, the relay disconnects current viewers without receiving either credential, and the account registry atomically replaces sealed copies from the previous generation. Old URL/password pairs may still connect to an anonymous relay socket but cannot authenticate later encrypted frames. +- Removing a team member deletes every password copy still sealed to that account, but cannot make them forget a password or terminal output they already received. Rotate each active session that person could open when immediate revocation matters. - Reports about leaked links are actionable when shell.online itself disclosed or made them predictable; links forwarded or published by their owner are not a product vulnerability. - Availability reports should demonstrate a way to bypass the configured rate, frame-size, audience, or lifetime limits. - The statistics dashboard is private and password-protected. Do not test it with credential stuffing or high-volume traffic. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d133d8..ef32451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,3 +214,16 @@ jobs: - run: docker build --build-arg VERSION=test -t shell-online:test . - run: test "$(docker run --rm --entrypoint shell shell-online:test --version)" = "shell test" - run: test "$(docker image inspect shell-online:test --format '{{.Config.User}}')" = "shellonline" + - name: Build the standalone relay image + run: docker build -f standalone/Dockerfile -t shell-online-relay:test . + - name: Exercise standalone health and static delivery + run: | + docker run -d --name shell-online-relay -p 18080:8080 shell-online-relay:test + trap 'docker rm -f shell-online-relay' EXIT + for attempt in $(seq 1 30); do + curl -fsS http://127.0.0.1:18080/api/health && break + test "$attempt" -lt 30 + sleep 1 + done + curl -fsS http://127.0.0.1:18080/ | grep -q 'shell.online' + test "$(docker image inspect shell-online-relay:test --format '{{.Config.User}}')" = "shellonline" diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 58e1cbe..329d656 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -57,3 +57,45 @@ jobs: cache-to: type=gha,mode=max,ignore-error=true provenance: mode=max sbom: true + + publish-standalone-relay: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: metadata + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/teoslayer/shell.online-relay + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + labels: | + org.opencontainers.image.title=shell.online standalone relay + org.opencontainers.image.description=Portable single-node shell.online WebSocket relay + org.opencontainers.image.url=https://shell.online/self-hosting/ + org.opencontainers.image.source=https://github.com/TeoSlayer/shell.online + org.opencontainers.image.licenses=MIT + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: standalone/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha,scope=standalone-relay + cache-to: type=gha,scope=standalone-relay,mode=max,ignore-error=true + provenance: mode=max + sbom: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2ac92..7c9bd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,52 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ## Unreleased +## [0.12.2] — 2026-09-12 + +### Added + +- A standalone, single-node relay for ordinary Docker hosts. It uses Node.js, + WebSockets, local metadata state and Caddy-managed TLS, and requires no + Cloudflare account or credentials. +- A versioned self-hosting documentation page and a release image at + `ghcr.io/teoslayer/shell.online-relay` for amd64 and arm64. +- `shell password ` retrieves an active session password from the local + owner-only record. `shell password rotate ` changes credentials without + restarting the process and persists the new generation for stable sessions. + +### Changed + +- `--no-e2ee` output refers to the configured relay instead of assuming every + deployment runs on Cloudflare. +- Session assignments and permission handoffs now update in place, without a + reconnect or a window where the former writer can still send input. + ### Fixed - Keep `--auto-close today` valid throughout the final second of the local day, rather than expiring at the instant that second begins. +- Keep notifications, audit entries, session password shares, and member + removal inside the active organization. +- Escape CLI login callback content, keep the mobile account menu usable, and + reject invalid terminal dimensions before they reach a PTY. + +### Security + +- Password rotation switches the host cipher before disconnecting existing + viewers, atomically replaces the owner's sealed account-vault copy, and + removes stale teammate copies. The relay receives neither old nor new + plaintext credentials. +- A verified browser cache can no longer overwrite a newer vault generation. + Vault credentials are tried first and replace stale local cache entries only + after successfully opening a live encrypted frame. +- Removing a team member now deletes every session-password copy sealed to + that account. Owners must still rotate active sessions to revoke passwords a + former member may already have seen. +- Targeted email invitations require a verified Firebase email. Team-key and + session-key shares now reject invalid P-256 identities and oversized or + malformed ciphertext, and key distributors must already hold the team key. +- The accounts app now sends a restrictive browser security policy from both + its Node server and Cloudflare Worker deployment. ## [0.12.1] — 2026-09-12 diff --git a/README.md b/README.md index 703b60f..da76fd7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,22 @@ Homebrew 6 asks you to trust a third-party tap once. Older versions have no Installers verify checksums. Release binaries and `SHA256SUMS` are available on the [releases page](https://github.com/TeoSlayer/shell.online/releases). +## Platform compatibility + +| OS | Architectures | Verification | +| --- | --- | --- | +| macOS | amd64, arm64 | Build | +| Windows | 386, amd64, arm64 | Native ConPTY on amd64; build on others | +| Linux | 386, amd64, armv5/6/7, arm64, LoongArch64, MIPS/MIPSLE/MIPS64/MIPS64LE, PPC64/PPC64LE, RISC-V 64, s390x | Runtime under QEMU | +| FreeBSD | 386, amd64, armv7, arm64 | Build | +| OpenBSD | 386, amd64, armv7, arm64, ppc64, riscv64 | Build | +| NetBSD | 386, amd64, armv7, arm64 | Build | +| DragonFly BSD | amd64 | Build | +| Solaris | amd64 | Build | + +See [platform details](https://shell.online/platforms/) for PTY, router, ROS, +installer, and test caveats. + ## Usage ```sh @@ -53,6 +69,8 @@ shell --auto-close 5m # set an earlier deadline shell --persistent # reuse a URL and password shell list # list local sessions (adapts to terminal width) +shell password # retrieve an active password locally +shell password rotate # revoke it without restarting the process shell attach # attach locally shell kill # stop a session ``` @@ -72,6 +90,10 @@ type with the permissions of the wrapped process; use `--read-only` when viewers should only watch. See the [security model](https://shell.online/security/) and [the security policy](.github/SECURITY.md). +Active passwords remain recoverable on their owner machine; account-linked +passwords are also sealed into the user's E2EE vault. Without either owner-held +copy there is intentionally no service-side recovery key. + ## Accounts and containers Accounts are optional. `shell login` groups sessions from linked machines in @@ -85,6 +107,10 @@ docker compose up -d docker compose logs shell-online ``` +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=TeoSlayer/shell.online&type=Date)](https://www.star-history.com/#TeoSlayer/shell.online&Date) + ## Documentation - [Quick start](https://shell.online/docs/) @@ -94,7 +120,7 @@ docker compose logs shell-online - [End-to-end encryption](https://shell.online/e2ee/) - [Containers](https://shell.online/docker/) - [Platforms](https://shell.online/platforms/) -- [Self-hosting](docs/self-hosting.md) +- [Self-hosting](https://shell.online/self-hosting/) — Docker or Cloudflare ## Development @@ -109,9 +135,9 @@ npm run test:app See [the contribution guide](.github/CONTRIBUTING.md) before opening a pull request. -## Star History +## Contributors -[![Star History Chart](https://api.star-history.com/svg?repos=TeoSlayer/shell.online&type=Date)](https://www.star-history.com/#TeoSlayer/shell.online&Date) +[![shell.online contributors](https://contrib.rocks/image?repo=TeoSlayer/shell.online)](https://github.com/TeoSlayer/shell.online/graphs/contributors) MIT licensed. See [`LICENSE`](LICENSE). diff --git a/app/server/app.test.ts b/app/server/app.test.ts index c5cff70..cb12107 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -12,6 +12,10 @@ import { createVault, sealToAccount } from "../src/lib/vault-crypto"; const PROJECT = "test-firebase-project"; const REDIRECT = "http://127.0.0.1:51234/callback"; const ORIGIN = "http://localhost:5173"; +const P256_PUBLIC_KEY_A = "BDxrse1_E7EAHDreFfDYFkHs7kcn3d2n_BqKorrlu6H-9FarvjSDUCUSY3EOYKRBJusTV2E2GwRZLdplZc3UbQY"; +const P256_PUBLIC_KEY_B = "BM4rJdocNKu-sk24tVjh1QKxfdLJN43q2NVO3NElj_H09ORvqFD6ZcX7xJ_DTef8pYUGo0AJz9bnFV8oxvkBElc"; +const SESSION_SHARE_A = base64url(Buffer.alloc(40, 0x41)); +const SESSION_SHARE_B = base64url(Buffer.alloc(40, 0x42)); let privateKey: KeyObject; let verifyIdToken: (token: string) => Promise<{ ok: boolean }>; @@ -21,7 +25,7 @@ let verifier: string; /* Signs a token that looks exactly like a Firebase ID token, minus Google. */ async function idToken(overrides: Record = {}) { - return new SignJWT({ email: "ana@example.com", name: "Ana Ferreira", ...overrides }) + return new SignJWT({ email: "ana@example.com", name: "Ana Ferreira", email_verified: true, ...overrides }) .setProtectedHeader({ alg: "RS256", kid: "test-key" }) .setIssuer(String(overrides.iss ?? `https://securetoken.google.com/${PROJECT}`)) .setAudience(String(overrides.aud ?? PROJECT)) @@ -867,16 +871,27 @@ describe("relaying a sealed password", () => { it("records the agent's published key so a browser can seal to it", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=AGENT_PUBLIC_KEY", { + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}`, { auth: tokens.access_token, }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); - expect(listed.body.devices[0].agentPublicKey).toBe("AGENT_PUBLIC_KEY"); + expect(listed.body.devices[0].agentPublicKey).toBe(P256_PUBLIC_KEY_A); + }); + + it("refuses a malformed agent key instead of publishing it", async () => { + const tokens = await login(); + const result = await call("GET", "/api/agent/commands?key=not-a-p256-key", { + auth: tokens.access_token, + }); + expect(result.status).toBe(400); + const listed = await call("GET", "/api/devices", { auth: await idToken() }); + expect(listed.body.devices[0].agentPublicKey).toBeUndefined(); + expect(listed.body.devices[0].agentSeenAt).toBeUndefined(); }); it("records the harnesses a polling agent found on its machine", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=K&harnesses=claude-code,openclaw", { + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}&harnesses=claude-code,openclaw`, { auth: tokens.access_token, }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); @@ -895,7 +910,7 @@ describe("relaying a sealed password", () => { it("says nothing about a machine that has never reported its harnesses", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=K", { auth: tokens.access_token }); + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}`, { auth: tokens.access_token }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); /* Undefined is "not known", which the browser must not read as "absent". */ expect(listed.body.devices[0].harnesses).toBeUndefined(); @@ -910,10 +925,10 @@ describe("relaying a sealed password", () => { it("takes a new key when the agent restarts", async () => { const tokens = await login(); - await call("GET", "/api/agent/commands?key=FIRST", { auth: tokens.access_token }); - await call("GET", "/api/agent/commands?key=SECOND", { auth: tokens.access_token }); + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_A}`, { auth: tokens.access_token }); + await call("GET", `/api/agent/commands?key=${P256_PUBLIC_KEY_B}`, { auth: tokens.access_token }); const listed = await call("GET", "/api/devices", { auth: await idToken() }); - expect(listed.body.devices[0].agentPublicKey).toBe("SECOND"); + expect(listed.body.devices[0].agentPublicKey).toBe(P256_PUBLIC_KEY_B); }); it("ties a session back to the request that started it", async () => { @@ -958,6 +973,14 @@ describe("organizations", () => { expect(first.organization.id).not.toBe(second.organization.id); }); + it("refuses a malformed browser public key", async () => { + const result = await call("GET", "/api/org?key=not-a-p256-key", { + auth: await idToken(), + }); + expect(result.status).toBe(400); + expect((await store.membershipOf("uid-1"))?.publicKey).toBeUndefined(); + }); + it("puts someone who follows an invite into that organization", async () => { await orgFor("uid-1", "owner@acme.com"); const invite = await call("POST", "/api/org/invites", { @@ -1015,6 +1038,35 @@ describe("organizations", () => { expect(wrong.body.inviteError).toContain("different email"); }); + it("requires a verified identity before accepting an email-targeted invite", async () => { + await orgFor("uid-1", "owner@acme.com"); + const invite = await call("POST", "/api/org/invites", { + auth: await idToken({ sub: "uid-1", email: "owner@acme.com" }), + body: { role: "member", email: "wanted@acme.com" }, + }); + const attempted = await call("GET", `/api/org?invite=${invite.body.invite.id}`, { + auth: await idToken({ + sub: "uid-unverified", + email: "wanted@acme.com", + email_verified: false, + }), + }); + expect(attempted.body.joined).toBe(false); + expect(attempted.body.inviteError).toContain("Verify that email"); + }); + + it("keeps open-link invites available to unverified identities", async () => { + await orgFor("uid-1", "owner@acme.com"); + const invite = await call("POST", "/api/org/invites", { + auth: await idToken({ sub: "uid-1", email: "owner@acme.com" }), + body: { role: "member" }, + }); + const joined = await call("GET", `/api/org?invite=${invite.body.invite.id}`, { + auth: await idToken({ sub: "uid-unverified", email_verified: false }), + }); + expect(joined.body.joined).toBe(true); + }); + it("cannot use one invite twice", async () => { await orgFor("uid-1", "owner@acme.com"); const invite = await call("POST", "/api/org/invites", { @@ -1146,6 +1198,41 @@ describe("session ownership and handoff", () => { expect(handed.body.session.assigneeUid).toBe("uid-1"); }); + it("returns only the caller's password copy after a handoff", async () => { + const { colleague } = await orgWithColleague(); + const path = `/api/sessions/${session.id}/keys`; + await call("PUT", path, { + auth: await idToken(), + body: { + shares: [ + { uid: "uid-1", sender_public_key: P256_PUBLIC_KEY_A, sealed: SESSION_SHARE_A }, + { uid: "uid-2", sender_public_key: P256_PUBLIC_KEY_B, sealed: SESSION_SHARE_B }, + ], + }, + }); + + const handed = await call("PUT", `/api/sessions/${session.id}/assignee`, { + auth: await idToken(), + body: { uids: ["uid-2"] }, + }); + expect(handed.status).toBe(200); + expect(handed.body.session.keyShare).toEqual({ + uid: "uid-1", + senderPublicKey: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, + }); + expect(handed.body.session.keyShares).toBeUndefined(); + expect(handed.body.session.sharedWith).toEqual(["uid-2"]); + + const colleagueView = await call("GET", `/api/sessions/${session.id}`, { auth: colleague }); + expect(colleagueView.body.session.keyShare).toMatchObject({ + uid: "uid-2", + sealed: SESSION_SHARE_B, + }); + expect(colleagueView.body.session.keyShares).toBeUndefined(); + expect(colleagueView.body.session.sharedWith).toBeUndefined(); + }); + it("allows a session to be left unassigned", async () => { await orgWithColleague(); const handed = await call("PUT", `/api/sessions/${session.id}/assignee`, { @@ -1160,7 +1247,7 @@ describe("session ownership and handoff", () => { 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 share = { sender_public_key: P256_PUBLIC_KEY_A, sealed: `v2.${SESSION_SHARE_A}` }; const forOwner = await call("PUT", path, { auth: colleague, body: { shares: [{ uid: "uid-1", ...share }] } }); expect(forOwner.status).toBe(403); @@ -1169,14 +1256,14 @@ describe("session ownership and handoff", () => { 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" }); + expect(listed.body.sessions[0].keyShare).toMatchObject({ sealed: `v2.${SESSION_SHARE_A}` }); }); 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" }] }, + body: { shares: [{ uid: "uid-2", sender_public_key: P256_PUBLIC_KEY_A, sealed: SESSION_SHARE_A }] }, }); const owner = await call("GET", "/api/sessions", { auth: await idToken() }); expect(owner.body.sessions[0].sharedWith).toEqual(["uid-2"]); @@ -1189,8 +1276,8 @@ describe("session ownership and handoff", () => { const path = `/api/sessions/${session.id}/keys`; const shares = [{ uid: "uid-2", - sender_public_key: "BASE64_PUBLIC_KEY", - sealed: "BASE64_SEALED_PASSWORD", + sender_public_key: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }]; const shared = await call("PUT", path, { auth: await idToken(), body: { shares } }); @@ -1198,8 +1285,8 @@ describe("session ownership and handoff", () => { const listed = await call("GET", "/api/sessions", { auth: colleague }); expect(listed.body.sessions[0].keyShare).toMatchObject({ - senderPublicKey: "BASE64_PUBLIC_KEY", - sealed: "BASE64_SEALED_PASSWORD", + senderPublicKey: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }); /* A colleague may keep their own copy, but cannot write one for anyone else. */ @@ -1214,8 +1301,8 @@ describe("session ownership and handoff", () => { body: { shares: [{ uid: "uid-outside", - sender_public_key: "BASE64_PUBLIC_KEY", - sealed: "BASE64_SEALED_PASSWORD", + sender_public_key: P256_PUBLIC_KEY_A, + sealed: SESSION_SHARE_A, }], }, }); @@ -1253,6 +1340,29 @@ describe("session ownership and handoff", () => { expect(attempt.status).toBe(404); }); + it("keeps another organization outside every session mutation path", async () => { + const tokens = await login(); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); + const stranger = await idToken({ sub: "uid-9", email: "stranger@elsewhere.com" }); + + expect((await call("GET", `/api/sessions/${session.id}`, { auth: stranger })).status).toBe(404); + expect((await call("PUT", `/api/sessions/${session.id}/assignee`, { + auth: stranger, + body: { uids: ["uid-9"] }, + })).status).toBe(404); + expect((await call("PUT", `/api/sessions/${session.id}/keys`, { + auth: stranger, + body: { + shares: [{ uid: "uid-9", sender_public_key: "STRANGER_KEY", sealed: "STRANGER_SHARE" }], + }, + })).status).toBe(404); + expect((await call("DELETE", `/api/sessions/${session.id}`, { auth: stranger })).status).toBe(404); + expect((await call("POST", "/api/commands", { + auth: stranger, + body: { kind: "kill", session_id: session.id }, + })).status).toBe(404); + }); + it("keeps an assignment when a persistent session re-registers", async () => { const tokens = await login(); await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); @@ -1376,6 +1486,51 @@ describe("audit log", () => { ]); }); + it("does not let a browser forge service-owned audit events", async () => { + await withSession(); + const forged = await call("POST", "/api/audit", { + auth: await idToken(), + body: { + entries: [ + { session_id: session.id, kind: "handoff", text: "assigned to attacker@example.com" }, + { session_id: session.id, kind: "stopped", text: "stopped" }, + { session_id: session.id, kind: "deleted", text: "deleted" }, + ], + }, + }); + expect(forged.body).toEqual({ written: 0, refused: 3 }); + const log = await call("GET", `/api/audit/${session.id}`, { auth: await idToken() }); + expect(log.body.events).toEqual([]); + }); + + it("accepts terminal input only from an owner or assignee", async () => { + const tokens = await login(); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); + 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 input = { session_id: session.id, kind: "input", text: await sealedEntry(), at: 2000 }; + + const watching = await call("POST", "/api/audit", { + auth: colleague, + body: { entries: [input] }, + }); + expect(watching.body).toEqual({ written: 0, refused: 1 }); + + await call("PUT", `/api/sessions/${session.id}/assignee`, { + auth: await idToken(), + body: { uids: ["uid-2"] }, + }); + const assigned = await call("POST", "/api/audit", { + auth: colleague, + body: { entries: [input] }, + }); + expect(assigned.body).toEqual({ written: 1, refused: 0 }); + }); + /* Longer than the old plaintext cap: trimming ciphertext would destroy it. */ it("stores a long sealed entry whole", async () => { await withSession(); @@ -1931,6 +2086,40 @@ describe("session vault", () => { expect(listed.body.sessions[0].keyShare).toMatchObject(share); }); + it("replaces stale vault copies when the CLI rotates an active password", async () => { + const tokens = await login(); + 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 { made, body } = await vaultBody(); + await call("POST", "/api/vault", { auth: await idToken(), body }); + const oldShare = await sealToAccount(made.bundle.publicKey, session.id, "uid-1", "old-pass"); + await call("POST", "/api/sessions", { + auth: tokens.access_token, + body: { ...session, owner_share: { sender_public_key: oldShare.senderPublicKey, sealed: oldShare.sealed } }, + }); + await call("PUT", `/api/sessions/${session.id}/keys`, { + auth: await idToken(), + body: { shares: [{ uid: "uid-2", sender_public_key: "old-sender", sealed: "old-copy" }] }, + }); + + const freshShare = await sealToAccount(made.bundle.publicKey, session.id, "uid-1", "new-pass"); + const rotated = await call("POST", "/api/sessions", { + auth: tokens.access_token, + body: { + ...session, + share_url: `${session.share_url.slice(0, -22)}BBBBBBBBBBBBBBBBBBBBBB`, + credential_rotation: true, + owner_share: { sender_public_key: freshShare.senderPublicKey, sealed: freshShare.sealed }, + }, + }); + expect(rotated.status).toBe(201); + const owner = await call("GET", "/api/sessions", { auth: await idToken() }); + expect(owner.body.sessions[0].keyShare).toMatchObject(freshShare); + const other = await call("GET", "/api/sessions", { auth: colleague }); + expect(other.body.sessions[0].keyShare).toBeUndefined(); + }); + 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", { @@ -2139,8 +2328,8 @@ describe("team audit key", () => { const colleague = await withColleague(); await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]); const first = sealedCopy(); - const put = (sealed: string) => - call("PUT", "/api/team-key/shares", { auth: colleague, body: { version: 1, shares: [{ uid: "uid-2", sealed }] } }); + const put = async (sealed: string) => + call("PUT", "/api/team-key/shares", { auth: await idToken(), body: { version: 1, shares: [{ uid: "uid-2", sealed }] } }); expect((await put(first)).body.shared).toBe(1); expect((await put(sealedCopy())).body.shared).toBe(0); expect((await call("GET", "/api/team-key", { auth: colleague })).body.share.sealed).toBe(first); @@ -2152,6 +2341,17 @@ describe("team audit key", () => { expect(overOwner.body.shared).toBe(0); }); + it("does not let a member without the team key poison missing shares", async () => { + const colleague = await withColleague(); + await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]); + const attempt = await call("PUT", "/api/team-key/shares", { + auth: colleague, + body: { version: 1, shares: [{ uid: "uid-2", sealed: sealedCopy() }] }, + }); + expect(attempt.status).toBe(403); + expect((await call("GET", "/api/team-key", { auth: colleague })).body.share).toBeNull(); + }); + it("refuses copies of a key that is not the current one", async () => { await withColleague(); await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]); diff --git a/app/server/app.ts b/app/server/app.ts index 1ec20ef..97dcf2c 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { Store } from "./lib/store"; import type { Invite, Membership } from "./lib/orgs"; -import type { AuditEvent } from "./lib/types"; +import type { AuditEvent, SessionRecord } from "./lib/types"; import type { VerifyResult } from "./lib/firebase-token"; import { exchangeCode, issueCode } from "./lib/codes"; import { @@ -19,7 +19,14 @@ import { sessionSource, } from "./lib/sessions"; import { mintSecret } from "./lib/tokens"; -import { RESET_SIGN_IN_WINDOW_MS, isP256PublicKey, readOwnerShare, readVaultInput, vaultForApi } from "./lib/vault"; +import { + RESET_SIGN_IN_WINDOW_MS, + isP256PublicKey, + readOwnerShare, + readSessionKeyShare, + readVaultInput, + vaultForApi, +} from "./lib/vault"; import { isAuditEnvelope, isTeamKeyShare } from "./lib/audit-seal"; import { changeRole, @@ -32,7 +39,7 @@ import { notifyInvited, revokeInvite, } from "./routes/organizations"; -import { recordAudit, assignSession, auditCsv } from "./routes/audit"; +import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/audit"; import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social"; import { deleteAccount } from "./routes/account"; import { callerAddress, rateLimiter } from "./lib/rate-limit"; @@ -159,6 +166,24 @@ function sharedWith( return (session.keyShares ?? []).map((share) => share.uid).filter((uid) => uid !== membership.uid); } +/** + * The session shape one signed-in member may receive. + * + * A stored session carries one sealed password per recipient. Even though a + * member cannot decrypt somebody else's copy, sending the whole array leaks + * who has a credential and makes an optimistic assignment response lose the + * caller's singular `keyShare` shape until the next poll. Every app response + * therefore goes through the same projection as the list and detail routes. + */ +function sessionForMember(membership: Membership, session: SessionRecord) { + const mine = session.keyShares?.find((share) => share.uid === membership.uid); + return { + ...sessionForApi(session), + keyShare: mine, + sharedWith: sharedWith(membership, session), + }; +} + /* * Copies of the team audit key as a browser sends them. Null for anything that * is not a list of well-formed copies, one per person: they are refused @@ -465,6 +490,9 @@ export function createApp(options: AppOptions) { /* Publishing the browser key here keeps it current without a separate call on every sign-in. */ const publicKey = url.searchParams.get("key"); + if (publicKey && !(await isP256PublicKey(publicKey))) { + return send(response, 400, { error: "invalid browser public key" }); + } if (publicKey) await store.setMemberKey(identity.uid, publicKey); const described = await describeOrganization(store, resolved.membership); return send(response, described.status, { @@ -549,6 +577,15 @@ export function createApp(options: AppOptions) { let refused = 0; for (const entry of entries.slice(0, 100)) { const candidate = entry as Record; + /* + * Handoffs, stops and deletions are service facts written beside the + * action itself. Letting a browser submit those kinds would let any + * member forge the team's audit trail with a plain API request. + */ + if (!SEALED_KINDS.has(String(candidate.kind ?? "input"))) { + refused += 1; + continue; + } const result = await recordAudit(store, membership, { sessionId: String(candidate.session_id ?? ""), kind: String(candidate.kind ?? "input"), @@ -654,6 +691,14 @@ export function createApp(options: AppOptions) { } const shares = readTeamShares(body.shares); if (!shares) return send(response, 400, { error: "invalid key shares" }); + const current = await store.teamKeyShares(membership.orgId); + if (!current.some((share) => + share.uid === membership.uid && share.version === key.version + )) { + return send(response, 403, { + error: "open your own copy of the team key before sharing it", + }); + } const memberIds = new Set((await store.members(membership.orgId)).map((member) => member.uid)); if (shares.some((share) => !memberIds.has(share.uid))) { return send(response, 400, { error: "key shares may only be sent to organization members" }); @@ -895,6 +940,33 @@ export function createApp(options: AppOptions) { if (!token) return send(response, 401, { error: "not signed in" }); const body = (await readBody(request)) as Record; const membership = await store.membershipOf(token.uid); + const rotation = body.credential_rotation === true; + const previous = rotation && membership + ? await store.sessionInOrg(membership.orgId, String(body.id ?? "")) + : null; + if (rotation && (!previous || (previous.ownerUid ?? previous.uid) !== token.uid)) { + return send(response, 404, { error: "no such owned session to rotate" }); + } + if (rotation) { + const nextURL = String(body.share_url ?? ""); + if ( + !previous?.encrypted || body.encrypted !== true || + nextURL === previous.shareUrl || !/#salt=[A-Za-z0-9_-]{22}$/.test(nextURL) + ) { + return send(response, 400, { error: "invalid credential rotation" }); + } + const ownerShare = await readOwnerShare(body.owner_share); + const shares = ownerShare ? [{ uid: token.uid, ...ownerShare }] : []; + const rotated = await store.rotateSessionCredentials( + membership!.orgId, + previous!.id, + token.uid, + nextURL, + shares, + ); + if (!rotated) return send(response, 404, { error: "no such owned session to rotate" }); + return send(response, 201, { session: sessionForApi(rotated) }); + } const result = await registerSession(store, token.uid, { id: String(body.id ?? ""), shareUrl: String(body.share_url ?? ""), @@ -1008,15 +1080,9 @@ export function createApp(options: AppOptions) { * everyone's would be pointless, since they cannot open them, and * would put more sealed material on the wire than anyone needs. */ - 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, - sharedWith: sharedWith(membership, session), - }; - }); + const sessions = (await store.listOrgSessions(membership.orgId)).map((session) => + sessionForMember(membership, session) + ); return send(response, 200, { sessions, members: await store.members(membership.orgId), @@ -1040,27 +1106,22 @@ export function createApp(options: AppOptions) { const body = (await readBody(request)) as Record; const incoming = Array.isArray(body.shares) ? body.shares : []; - const shares = incoming - .map((entry) => entry as Record) - .filter( - (entry) => - typeof entry.uid === "string" && - typeof entry.sender_public_key === "string" && - typeof entry.sealed === "string", - ) - .slice(0, 100) - .map((entry) => ({ - uid: String(entry.uid), - senderPublicKey: String(entry.sender_public_key ?? ""), - sealed: String(entry.sealed), - })); - - if (shares.length !== incoming.length || shares.some( - (share) => !share.uid || !share.senderPublicKey || !share.sealed || - share.senderPublicKey.length > 512 || share.sealed.length > 4096, - )) { + if (incoming.length > 100) { return send(response, 400, { error: "invalid key share" }); } + const shares: { uid: string; senderPublicKey: string; sealed: string }[] = []; + for (const candidate of incoming) { + if (!candidate || typeof candidate !== "object") { + return send(response, 400, { error: "invalid key share" }); + } + const entry = candidate as Record; + if (typeof entry.uid !== "string" || !entry.uid) { + return send(response, 400, { error: "invalid key share" }); + } + const parsed = await readSessionKeyShare(entry); + if (!parsed) return send(response, 400, { error: "invalid key share" }); + shares.push({ uid: entry.uid, ...parsed }); + } if (!isOwner && shares.some((share) => share.uid !== membership.uid)) { return send(response, 403, { error: "only the session owner can share its key" }); } @@ -1095,7 +1156,7 @@ export function createApp(options: AppOptions) { result.session.name || result.session.command, ); } - return send(response, 200, { session: result.session }); + return send(response, 200, { session: sessionForMember(membership, result.session) }); } /* ---- Driving a machine from the browser ---- */ @@ -1245,9 +1306,13 @@ export function createApp(options: AppOptions) { const token = await requireCli(request); if (!token) return send(response, 401, { error: "not signed in" }); /* The agent publishes its key on every poll, so a restart re-keys. */ + const agentPublicKey = url.searchParams.get("key") ?? undefined; + if (agentPublicKey && !(await isP256PublicKey(agentPublicKey))) { + return send(response, 400, { error: "invalid agent public key" }); + } await store.markAgentSeen( token.id, - url.searchParams.get("key") ?? undefined, + agentPublicKey, readHarnesses(url), ); return send(response, 200, { commands: await store.claimCommands(token.id) }); @@ -1299,14 +1364,8 @@ export function createApp(options: AppOptions) { if (!membership) return send(response, 401, { error: "sign in first" }); const session = await store.sessionInOrg(membership.orgId, oneSession[1]); 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, - sharedWith: sharedWith(membership, session), - }, + session: sessionForMember(membership, session), members: await store.members(membership.orgId), you: membership, comments: await store.comments(membership.orgId, oneSession[1]), @@ -1338,9 +1397,9 @@ export function createApp(options: AppOptions) { if (!membership) return send(response, 401, { error: "sign in first" }); const body = (await readBody(request)) as Record; if (typeof body.id === "string") { - await store.markNotificationRead(membership.uid, body.id); + await store.markNotificationRead(membership.orgId, membership.uid, body.id); } else { - await store.markAllNotificationsRead(membership.uid); + await store.markAllNotificationsRead(membership.orgId, membership.uid); } return send(response, 200, await inbox(store, membership)); } diff --git a/app/server/lib/browser-headers.ts b/app/server/lib/browser-headers.ts new file mode 100644 index 0000000..0f4db41 --- /dev/null +++ b/app/server/lib/browser-headers.ts @@ -0,0 +1,22 @@ +/** Security policy shared by the Node and Worker app frontends. */ +export const BROWSER_SECURITY_HEADERS: Readonly> = { + /* Firebase Auth uses a popup plus a small iframe on its hosted auth domain. */ + "Content-Security-Policy": [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: https:", + "connect-src 'self' wss: https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://firebaseinstallations.googleapis.com", + "frame-src https://*.firebaseapp.com https://*.web.app https://accounts.google.com", + "form-action 'self'", + ].join("; "), + "Cross-Origin-Opener-Policy": "same-origin-allow-popups", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", +}; diff --git a/app/server/lib/firebase-token.ts b/app/server/lib/firebase-token.ts index 5503bf4..b232063 100644 --- a/app/server/lib/firebase-token.ts +++ b/app/server/lib/firebase-token.ts @@ -7,6 +7,8 @@ export interface Identity { uid: string; email: string; name: string; + /** True only when the identity provider has verified ownership of email. */ + emailVerified: boolean; /** * When this person last actually signed in, in milliseconds. A refreshed * token keeps the original time, so this is how an operation that deserves @@ -57,6 +59,7 @@ export function createVerifier(projectId: string, keys?: KeyLookup) { uid, email: typeof payload.email === "string" ? payload.email : "", name: typeof payload.name === "string" ? payload.name : "", + emailVerified: payload.email_verified === true, authTime: typeof payload.auth_time === "number" ? payload.auth_time * 1000 : undefined, }, }; diff --git a/app/server/lib/sessions.ts b/app/server/lib/sessions.ts index faa3be6..563138c 100644 --- a/app/server/lib/sessions.ts +++ b/app/server/lib/sessions.ts @@ -48,7 +48,13 @@ export function sessionSource(session: Pick): SessionSo /** Removes the storage envelope before a session is sent to the browser. */ export function sessionForApi(session: SessionRecord) { const source = sessionSource(session); - return { ...session, origin: source.origin, deviceId: source.deviceId }; + /* + * Key shares are recipient-specific credentials. Routes serving a browser + * add back only that caller's `keyShare`; CLI registration/close responses + * need none of them. + */ + const { keyShares: _keyShares, ...safe } = session; + return { ...safe, origin: source.origin, deviceId: source.deviceId }; } export type RegisterResult = diff --git a/app/server/lib/static-files.test.ts b/app/server/lib/static-files.test.ts index 4893760..cf62167 100644 --- a/app/server/lib/static-files.test.ts +++ b/app/server/lib/static-files.test.ts @@ -51,6 +51,8 @@ describe("staticFiles", () => { expect(response.headers.get("x-frame-options")).toBe("DENY"); expect(response.headers.get("referrer-policy")).toBe("no-referrer"); expect(response.headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()"); + expect(response.headers.get("content-security-policy")).toContain("script-src 'self'"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); }); it("caches fingerprinted assets forever and the document never", async () => { diff --git a/app/server/lib/static-files.ts b/app/server/lib/static-files.ts index ed51d38..f1d3f2a 100644 --- a/app/server/lib/static-files.ts +++ b/app/server/lib/static-files.ts @@ -2,6 +2,7 @@ import { createReadStream } from "node:fs"; import { stat, realpath } from "node:fs/promises"; import { extname, join, resolve, sep } from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { BROWSER_SECURITY_HEADERS } from "./browser-headers"; /** * Serves the built client, so the app and its API share an origin. @@ -33,14 +34,6 @@ const TYPES: Record = { * resolves. same-origin-allow-popups keeps the isolation and the handle. The * dev server sends the same header; this is the production half of it. */ -const DOCUMENT_HEADERS: Record = { - "Cross-Origin-Opener-Policy": "same-origin-allow-popups", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "no-referrer", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()", -}; - export interface StaticFiles { (request: IncomingMessage, response: ServerResponse): Promise; } @@ -127,7 +120,7 @@ export function staticFiles(root: string): StaticFiles { "Content-Length": found.size, "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache", "Last-Modified": found.mtime.toUTCString(), - ...DOCUMENT_HEADERS, + ...BROWSER_SECURITY_HEADERS, }); if (request.method === "HEAD") { response.end(); diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index c5748e1..918ec33 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -425,6 +425,32 @@ for (const implementation of implementations) { expect(shares.find((share) => share.uid === "uid-1")?.sealed).toBe("one"); }); + it("rotates the public salt and sealed copies as one owner-scoped generation", async () => { + await store.upsertSession(session({ shareUrl: "https://shell.online/s/s1#salt=old" })); + await store.putKeyShares("org_1", "s1", [ + { uid: "uid-1", senderPublicKey: "pk1", sealed: "old-owner" }, + { uid: "uid-2", senderPublicKey: "pk1", sealed: "old-member" }, + ]); + expect(await store.rotateSessionCredentials( + "org_1", + "s1", + "uid-2", + "https://shell.online/s/s1#salt=forbidden", + [], + )).toBeNull(); + const rotated = await store.rotateSessionCredentials( + "org_1", + "s1", + "uid-1", + "https://shell.online/s/s1#salt=new", + [{ uid: "uid-1", senderPublicKey: "pk2", sealed: "new-owner" }], + ); + expect(rotated?.shareUrl).toBe("https://shell.online/s/s1#salt=new"); + expect(rotated?.keyShares).toEqual([ + { uid: "uid-1", senderPublicKey: "pk2", sealed: "new-owner" }, + ]); + }); + it("removes a session's row and reports whether there was one", async () => { await store.upsertSession(session()); expect(await store.deleteSession("org_1", "s1")).toBe(true); @@ -655,11 +681,20 @@ for (const implementation of implementations) { it("changes a role and removes a member", async () => { await store.putMembership(membership()); + await store.putMembership(membership({ uid: "uid-2", role: "member" })); + await store.upsertSession(session()); + await store.putKeyShares("org_1", "s1", [ + { uid: "uid-1", senderPublicKey: "owner-key", sealed: "for-owner" }, + { uid: "uid-2", senderPublicKey: "owner-key", sealed: "for-member" }, + ]); expect(await store.setRole("org_1", "uid-1", "admin")).toBe(true); expect((await store.membershipOf("uid-1"))?.role).toBe("admin"); - expect(await store.removeMember("org_1", "uid-1")).toBe(true); - expect(await store.removeMember("org_1", "uid-1")).toBe(false); - expect(await store.membershipOf("uid-1")).toBeNull(); + expect(await store.removeMember("org_1", "uid-2")).toBe(true); + expect(await store.removeMember("org_1", "uid-2")).toBe(false); + expect(await store.membershipOf("uid-2")).toBeNull(); + expect((await store.sessionInOrg("org_1", "s1"))?.keyShares).toEqual([ + { uid: "uid-1", senderPublicKey: "owner-key", sealed: "for-owner" }, + ]); }); }); @@ -800,7 +835,8 @@ for (const implementation of implementations) { await store.putNotification(notification()); await store.putNotification(notification({ id: "ntf_2", at: 3000 })); await store.putNotification(notification({ id: "ntf_3", uid: "uid-3" })); - expect((await store.notificationsFor("uid-2")).map((entry) => entry.id)).toEqual([ + await store.putNotification(notification({ id: "ntf_old_team", orgId: "org_2", at: 4000 })); + expect((await store.notificationsFor("org_1", "uid-2")).map((entry) => entry.id)).toEqual([ "ntf_2", "ntf_1", ]); @@ -810,23 +846,27 @@ for (const implementation of implementations) { for (const id of ["ntf_b", "ntf_c", "ntf_a"]) { await store.putNotification(notification({ id, at: 1000 })); } - const ids = (await store.notificationsFor("uid-2")).map((entry) => entry.id); + const ids = (await store.notificationsFor("org_1", "uid-2")).map((entry) => entry.id); expect(ids).toEqual(["ntf_c", "ntf_b", "ntf_a"]); }); it("marks one as read, once, and only for its owner", async () => { await store.putNotification(notification()); - expect(await store.markNotificationRead("uid-3", "ntf_1", 5000)).toBe(false); - expect(await store.markNotificationRead("uid-2", "ntf_1", 5000)).toBe(true); - expect(await store.markNotificationRead("uid-2", "ntf_1", 5001)).toBe(false); - expect((await store.notificationsFor("uid-2"))[0].readAt).toBe(5000); + await store.putNotification(notification({ id: "ntf_other_team", orgId: "org_2" })); + expect(await store.markNotificationRead("org_1", "uid-3", "ntf_1", 5000)).toBe(false); + expect(await store.markNotificationRead("org_1", "uid-2", "ntf_other_team", 5000)).toBe(false); + expect(await store.markNotificationRead("org_1", "uid-2", "ntf_1", 5000)).toBe(true); + expect(await store.markNotificationRead("org_1", "uid-2", "ntf_1", 5001)).toBe(false); + expect((await store.notificationsFor("org_1", "uid-2"))[0].readAt).toBe(5000); }); it("counts what marking everything read actually changed", async () => { await store.putNotification(notification()); await store.putNotification(notification({ id: "ntf_2", readAt: 100 })); - expect(await store.markAllNotificationsRead("uid-2", 5000)).toBe(1); - expect(await store.markAllNotificationsRead("uid-2", 5000)).toBe(0); + await store.putNotification(notification({ id: "ntf_other_team", orgId: "org_2" })); + expect(await store.markAllNotificationsRead("org_1", "uid-2", 5000)).toBe(1); + expect(await store.markAllNotificationsRead("org_1", "uid-2", 5000)).toBe(0); + expect((await store.notificationsFor("org_2", "uid-2"))[0].readAt).toBeUndefined(); }); }); @@ -962,8 +1002,8 @@ for (const implementation of implementations) { expect(await store.listSessions("uid-1")).toEqual([]); expect(await store.accountKey("uid-1")).toBeNull(); expect(await store.comments("org_1", "s2")).toEqual([]); - expect(await store.notificationsFor("uid-1")).toEqual([]); - expect(await store.notificationsFor("uid-2")).toEqual([]); + expect(await store.notificationsFor("org_1", "uid-1")).toEqual([]); + expect(await store.notificationsFor("org_1", "uid-2")).toEqual([]); const kept = (await store.listOrgSessions("org_1")).find((entry) => entry.id === "s2"); expect(kept?.assigneeUids).toEqual(["uid-3"]); diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 88bf407..1ce90c1 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -252,6 +252,21 @@ export class MemoryStore implements Store { return true; } + async rotateSessionCredentials( + orgId: string, + sessionId: string, + ownerUid: string, + shareUrl: string, + shares: SessionKeyShare[], + ): Promise { + const session = await this.sessionInOrg(orgId, sessionId); + if (!session || (session.ownerUid ?? session.uid) !== ownerUid || !session.encrypted) return null; + session.shareUrl = shareUrl; + session.keyShares = [...shares]; + this.flush(); + return session; + } + async accountKey(uid: string): Promise { const found = this.data.accountKeys.find((entry) => entry.uid === uid); return found ? { ...found } : null; @@ -573,6 +588,11 @@ export class MemoryStore implements Store { (entry) => !(entry.orgId === orgId && entry.uid === uid), ); if (this.data.memberships.length === before) return false; + for (const session of this.data.sessions) { + if (session.orgId === orgId && session.keyShares) { + session.keyShares = session.keyShares.filter((share) => share.uid !== uid); + } + } this.flush(); return true; } @@ -745,16 +765,16 @@ export class MemoryStore implements Store { this.flush(); } - async notificationsFor(uid: string, limit = 100): Promise { + async notificationsFor(orgId: string, uid: string, limit = 100): Promise { return this.data.notifications - .filter((entry) => entry.uid === uid) + .filter((entry) => entry.orgId === orgId && entry.uid === uid) .sort(byTime((entry) => entry.at, (entry) => entry.id, true)) .slice(0, limit); } - async markNotificationRead(uid: string, id: string, now = Date.now()): Promise { + async markNotificationRead(orgId: string, uid: string, id: string, now = Date.now()): Promise { const notification = this.data.notifications.find( - (entry) => entry.id === id && entry.uid === uid, + (entry) => entry.orgId === orgId && entry.id === id && entry.uid === uid, ); if (!notification || notification.readAt) return false; notification.readAt = now; @@ -762,10 +782,10 @@ export class MemoryStore implements Store { return true; } - async markAllNotificationsRead(uid: string, now = Date.now()): Promise { + async markAllNotificationsRead(orgId: string, uid: string, now = Date.now()): Promise { let count = 0; for (const notification of this.data.notifications) { - if (notification.uid === uid && !notification.readAt) { + if (notification.orgId === orgId && notification.uid === uid && !notification.readAt) { notification.readAt = now; count += 1; } diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index 790d692..ffe32aa 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -857,6 +857,54 @@ export class PostgresStore implements Store { return true; } + async rotateSessionCredentials( + orgId: string, + sessionId: string, + ownerUid: string, + shareUrl: string, + shares: SessionKeyShare[], + ): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const found = await client.query( + `SELECT * FROM sessions + WHERE org_id = $1 AND id = $2 AND COALESCE(owner_uid, uid) = $3 AND encrypted = true + FOR UPDATE`, + [orgId, sessionId, ownerUid], + ); + const row = found.rows[0]; + if (!row) { + await client.query("ROLLBACK"); + return null; + } + const sessionUid = row.uid as string; + await client.query( + "UPDATE sessions SET share_url = $4 WHERE org_id = $1 AND id = $2 AND uid = $3", + [orgId, sessionId, sessionUid, shareUrl], + ); + await client.query( + "DELETE FROM session_key_shares WHERE session_uid = $1 AND session_id = $2", + [sessionUid, sessionId], + ); + for (const share of shares) { + await client.query( + `INSERT INTO session_key_shares + (session_uid, session_id, uid, sender_public_key, sealed) + VALUES ($1, $2, $3, $4, $5)`, + [sessionUid, sessionId, share.uid, share.senderPublicKey, share.sealed], + ); + } + await client.query("COMMIT"); + return toSession({ ...row, share_url: shareUrl }, shares); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + /* ---- Session vault ---- */ async accountKey(uid: string): Promise { @@ -1159,11 +1207,30 @@ export class PostgresStore implements Store { } async removeMember(orgId: string, uid: string): Promise { - const result = await this.pool.query( - "DELETE FROM memberships WHERE org_id = $1 AND uid = $2", - [orgId, uid], - ); - return (result.rowCount ?? 0) > 0; + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await client.query( + "DELETE FROM memberships WHERE org_id = $1 AND uid = $2", + [orgId, uid], + ); + if ((result.rowCount ?? 0) > 0) { + await client.query( + `DELETE FROM session_key_shares AS keys + USING sessions + WHERE keys.session_uid = sessions.uid AND keys.session_id = sessions.id + AND sessions.org_id = $1 AND keys.uid = $2`, + [orgId, uid], + ); + } + await client.query("COMMIT"); + return (result.rowCount ?? 0) > 0; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } } async setRole(orgId: string, uid: string, role: Role): Promise { @@ -1419,26 +1486,26 @@ export class PostgresStore implements Store { ); } - async notificationsFor(uid: string, limit = 100): Promise { + async notificationsFor(orgId: string, uid: string, limit = 100): Promise { const rows = await this.rows( - 'SELECT * FROM notifications WHERE uid = $1 ORDER BY at DESC, id COLLATE "C" DESC LIMIT $2', - [uid, limit], + 'SELECT * FROM notifications WHERE org_id = $1 AND uid = $2 ORDER BY at DESC, id COLLATE "C" DESC LIMIT $3', + [orgId, uid, limit], ); return rows.map(toNotification); } - async markNotificationRead(uid: string, id: string, now = Date.now()): Promise { + async markNotificationRead(orgId: string, uid: string, id: string, now = Date.now()): Promise { const result = await this.pool.query( - "UPDATE notifications SET read_at = $3 WHERE id = $2 AND uid = $1 AND read_at IS NULL", - [uid, id, now], + "UPDATE notifications SET read_at = $4 WHERE org_id = $1 AND uid = $2 AND id = $3 AND read_at IS NULL", + [orgId, uid, id, now], ); return (result.rowCount ?? 0) > 0; } - async markAllNotificationsRead(uid: string, now = Date.now()): Promise { + async markAllNotificationsRead(orgId: string, uid: string, now = Date.now()): Promise { const result = await this.pool.query( - "UPDATE notifications SET read_at = $2 WHERE uid = $1 AND read_at IS NULL", - [uid, now], + "UPDATE notifications SET read_at = $3 WHERE org_id = $1 AND uid = $2 AND read_at IS NULL", + [orgId, uid, now], ); return result.rowCount ?? 0; } diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 1063598..e1a7307 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -121,6 +121,14 @@ export interface Store { */ deleteSession(orgId: string, id: string): Promise; putKeyShares(orgId: string, sessionId: string, shares: SessionKeyShare[]): Promise; + /** Changes the public salt and every sealed copy as one credential generation. */ + rotateSessionCredentials( + orgId: string, + sessionId: string, + ownerUid: string, + shareUrl: string, + shares: SessionKeyShare[], + ): Promise; /* ---- Session vault ---- */ accountKey(uid: string): Promise; @@ -215,9 +223,9 @@ export interface Store { putComment(comment: Comment): Promise; comments(orgId: string, sessionId: string): Promise; putNotification(notification: Notification): Promise; - notificationsFor(uid: string, limit?: number): Promise; - markNotificationRead(uid: string, id: string, now?: number): Promise; - markAllNotificationsRead(uid: string, now?: number): Promise; + notificationsFor(orgId: string, uid: string, limit?: number): Promise; + markNotificationRead(orgId: string, uid: string, id: string, now?: number): Promise; + markAllNotificationsRead(orgId: string, uid: string, now?: number): Promise; /* ---- Housekeeping ---- */ purgeExpired(now?: number): Promise; diff --git a/app/server/lib/vault.test.ts b/app/server/lib/vault.test.ts index d30e352..ca45dd9 100644 --- a/app/server/lib/vault.test.ts +++ b/app/server/lib/vault.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { isP256PublicKey, readOwnerShare, readVaultInput } from "./vault"; +import { isP256PublicKey, readOwnerShare, readSessionKeyShare, readVaultInput } from "./vault"; import { createVault, openFromAccount, sealToAccount } from "../../src/lib/vault-crypto"; /* @@ -83,6 +83,16 @@ describe("the CLI's own copy of a password", () => { expect(await readOwnerShare({ sender_public_key: "junk", sealed: share.sealed })).toBeNull(); expect(await readOwnerShare({ sender_public_key: share.senderPublicKey, sealed: "v2." })).toBeNull(); }); + + it("accepts both vault and legacy browser envelopes but rejects malformed ciphertext", async () => { + const made = await createVault("uid-1"); + const share = await sealToAccount(made.bundle.publicKey, "sess_1", "uid-1", "Kw9eHbru"); + const input = { sender_public_key: share.senderPublicKey, sealed: share.sealed }; + expect(await readSessionKeyShare(input)).toEqual(share); + expect(await readSessionKeyShare({ ...input, sealed: share.sealed.slice(3) })) + .toEqual({ ...share, sealed: share.sealed.slice(3) }); + expect(await readSessionKeyShare({ ...input, sealed: "not base64" })).toBeNull(); + }); }); /* diff --git a/app/server/lib/vault.ts b/app/server/lib/vault.ts index 4b9e116..55f6b17 100644 --- a/app/server/lib/vault.ts +++ b/app/server/lib/vault.ts @@ -114,11 +114,27 @@ export function vaultForApi(key: AccountKey) { * register when this is wrong, so the caller drops it rather than failing. */ export async function readOwnerShare(value: unknown): Promise | null> { + const parsed = await readSessionKeyShare(value); + return parsed?.sealed.startsWith(VAULT_SHARE_PREFIX) ? parsed : null; +} + +/** + * A session-password copy, either the current account-vault envelope (`v2.`) + * or the legacy browser-key envelope. The relay cannot authenticate its + * plaintext, but it can reject malformed points and oversized/non-base64 + * ciphertext before either reaches persistent storage. + */ +export async function readSessionKeyShare( + 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 (typeof sealed !== "string") return null; + const body = sealed.startsWith(VAULT_SHARE_PREFIX) + ? sealed.slice(VAULT_SHARE_PREFIX.length) + : sealed; + const length = decodedLength(body); if (length === null || length < 12 + 16 + 1 || length > SHARE_MAX_BYTES) return null; return { senderPublicKey: senderPublicKey as string, sealed }; } diff --git a/app/server/mobile-layout.test.ts b/app/server/mobile-layout.test.ts new file mode 100644 index 0000000..8a862a9 --- /dev/null +++ b/app/server/mobile-layout.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync(new URL("../src/styles/shell.css", import.meta.url), "utf8"); + +describe("mobile account menu", () => { + it("opens below the header account chip instead of beyond the top edge", () => { + const mobileRule = css.match(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.account-pop\s*\{([\s\S]*?)\}/)?.[1] ?? ""; + expect(mobileRule).toMatch(/top:\s*calc\(100% \+ 8px\)/); + expect(mobileRule).toMatch(/right:\s*0/); + expect(mobileRule).toMatch(/bottom:\s*auto/); + }); +}); diff --git a/app/server/routes/audit.ts b/app/server/routes/audit.ts index 730f1dc..1587feb 100644 --- a/app/server/routes/audit.ts +++ b/app/server/routes/audit.ts @@ -54,6 +54,17 @@ export async function recordAudit( if (!session) { return { ok: false, status: 404, error: "no such session in this organization" }; } + if (sealed) { + const owner = session.ownerUid ?? session.uid; + const assignees = session.assigneeUids?.length + ? session.assigneeUids + : session.assigneeUid + ? [session.assigneeUid] + : []; + if (session.readOnly || (membership.uid !== owner && !assignees.includes(membership.uid))) { + return { ok: false, status: 403, error: "only someone who can type may record terminal input" }; + } + } const event: AuditEvent = { id: newId("aud"), diff --git a/app/server/routes/organizations.ts b/app/server/routes/organizations.ts index a69f837..0ff09f9 100644 --- a/app/server/routes/organizations.ts +++ b/app/server/routes/organizations.ts @@ -18,6 +18,26 @@ export interface Result { body: unknown; } +/** + * A targeted invitation proves who the link was intended for only after the + * identity provider has verified that address. Without this check, someone + * who can create an unverified account for another address and obtains the + * bearer invite link can claim that person's seat. + */ +function checkInviteForIdentity( + invite: Invite | undefined, + identity: Identity, +): ReturnType { + const checked = checkInvite(invite, identity.email); + if (checked.ok && checked.invite.email && !identity.emailVerified) { + return { + ok: false, + reason: "Verify that email address before accepting this invitation.", + }; + } + return checked; +} + const ok = (body: unknown): Result => ({ status: 200, body }); const created = (body: unknown): Result => ({ status: 201, body }); const bad = (error: string): Result => ({ status: 400, body: { error } }); @@ -52,7 +72,7 @@ export async function ensureMembership( } if (inviteId) { - const check = checkInvite(await store.invite(inviteId), identity.email); + const check = checkInviteForIdentity(await store.invite(inviteId), identity); if (!check.ok) { /* * A bad invite still gets an organization, because the alternative is an @@ -99,7 +119,7 @@ async function acceptAsExistingMember( existing: Membership, inviteId: string, ): Promise<{ membership: Membership; joined: boolean; error?: string }> { - const check = checkInvite(await store.invite(inviteId), identity.email); + const check = checkInviteForIdentity(await store.invite(inviteId), identity); if (!check.ok) return { membership: existing, joined: false, error: check.reason }; if (check.invite.orgId === existing.orgId) { diff --git a/app/server/routes/social.ts b/app/server/routes/social.ts index e639eb7..5dd30ad 100644 --- a/app/server/routes/social.ts +++ b/app/server/routes/social.ts @@ -129,7 +129,7 @@ export async function inbox( unreadAssignments: number; members: Awaited>; }> { - const notifications = await store.notificationsFor(membership.uid); + const notifications = await store.notificationsFor(membership.orgId, membership.uid); return { members: await store.members(membership.orgId), notifications, diff --git a/app/src/components/SessionAudience.tsx b/app/src/components/SessionAudience.tsx index 16a4aab..2a3c785 100644 --- a/app/src/components/SessionAudience.tsx +++ b/app/src/components/SessionAudience.tsx @@ -4,11 +4,12 @@ import { Avatar } from "./Avatar"; import { Button } from "./Button"; import { PersonPicker } from "./PersonPicker"; import { shareSessionKeys, type Member, type SessionRecord } from "../lib/api"; -import { addToAudience, audienceFor, passwordFor } from "../lib/session-passwords"; +import { addToAudience, audienceFor, verifiedPasswordFor } 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"; +import { isVaultShare } from "../lib/vault-crypto"; /** * Who else can open this session. @@ -45,7 +46,10 @@ export function SessionAudience({ useEffect(() => { let live = true; void (async () => { - const opened = passwordFor(session.id) ?? (await vault.openShare(session.id, session.keyShare)); + const opened = isVaultShare(session.keyShare?.sealed) + ? await vault.openShare(session.id, session.keyShare) + : verifiedPasswordFor(session.id, session.shareUrl) ?? + (await vault.openShare(session.id, session.keyShare)); if (live) setPassword(opened); })(); return () => { diff --git a/app/src/components/SessionClipboard.tsx b/app/src/components/SessionClipboard.tsx index 92abae9..234f2c8 100644 --- a/app/src/components/SessionClipboard.tsx +++ b/app/src/components/SessionClipboard.tsx @@ -1,9 +1,10 @@ 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 { verifiedPasswordFor } from "../lib/session-passwords"; import { COPY_FAILED, useCopy } from "../lib/clipboard"; import { useVault } from "../vault/VaultProvider"; +import { isVaultShare } from "../lib/vault-crypto"; /** * The one place a session can be copied from. @@ -29,7 +30,10 @@ async function readPassword( session: SessionRecord, openShare: (sessionId: string, share: SessionRecord["keyShare"]) => Promise, ): Promise { - return passwordFor(session.id) ?? openShare(session.id, session.keyShare); + /* A vault share is the current credential generation. A locally verified + * cache may be from before rotation and is only a fallback for legacy rows. */ + if (isVaultShare(session.keyShare?.sealed)) return openShare(session.id, session.keyShare); + return verifiedPasswordFor(session.id, session.shareUrl) ?? openShare(session.id, session.keyShare); } export function SessionClipboard({ diff --git a/app/src/lib/session-passwords.test.ts b/app/src/lib/session-passwords.test.ts index a531527..7221241 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 d8c0989..92588c7 100644 Binary files a/app/src/lib/session-passwords.ts and b/app/src/lib/session-passwords.ts differ diff --git a/app/src/routes/Workspace.tsx b/app/src/routes/Workspace.tsx index 1de609b..d90cb3a 100644 --- a/app/src/routes/Workspace.tsx +++ b/app/src/routes/Workspace.tsx @@ -14,7 +14,7 @@ import { AppShell } from "../components/AppShell"; import { useAuth } from "../auth/AuthProvider"; import { Alert } from "../components/Alert"; import { TerminalPane } from "../terminal/TerminalPane"; -import { EMPTY, reduce, tabFor } from "../terminal/tabs"; +import { EMPTY, reduce, sessionToOpen, tabFor } from "../terminal/tabs"; import { readOpenTabs, writeOpenTabs } from "../terminal/tab-store"; import { assignSession, @@ -41,6 +41,8 @@ import { forget, rememberFor, rememberForOrigin, + passwordToSeedVault, + verifiedPasswordFor, } from "../lib/session-passwords"; import { useKeyboardInset } from "../terminal/keyboard-inset"; import { elapsed } from "../lib/time"; @@ -200,8 +202,13 @@ export function Workspace() { const load = useCallback(async () => { try { const result = await fetchSessions(); + /* The service's current generation is authoritative. Rotation clears + * old recipients, so an in-memory "already shared" set must clear too. */ + for (const session of result.sessions) { + sharedWith.current.set(session.id, new Set(session.sharedWith ?? [])); + } /* A session that came from this browser inherits the password it chose. */ - for (const session of result.sessions) adoptOrigin(session.origin, session.id); + for (const session of result.sessions) adoptOrigin(session.origin, session.id, session.shareUrl); /* * A machine has to poll, launch and publish before a session exists, so * the row arrives some seconds after the request. Saying "it will turn @@ -264,6 +271,27 @@ export function Workspace() { dispatch({ type: "restore", tabs, activeId: remembered.activeId }); }, [sessions, you, user]); + /* + * The session detail page's primary action returns here with `?open=`. + * Consume it once the list is available, then remove it from the address so + * a later refresh does not reopen a tab somebody deliberately closed. + */ + const requestedSessionId = search.get("open"); + useEffect(() => { + if (!requestedSessionId || sessions === null) return; + const requested = sessionToOpen(sessions, requestedSessionId); + setSearch((current) => { + const next = new URLSearchParams(current); + next.delete("open"); + return next; + }, { replace: true }); + if (!requested) { + setError("That session has finished or is no longer available."); + return; + } + dispatch({ type: "open", session: requested, canType: canEdit(requested, you) }); + }, [requestedSessionId, sessions, you, setSearch]); + /* Written only after the restore, so an empty first render cannot erase it. */ useEffect(() => { if (!restoredTabs.current) return; @@ -413,9 +441,11 @@ export function Workspace() { * safe to repeat: it compares with what the vault holds and writes only * when that is missing or known to be wrong. Sources, best first: * - * - a password cached here and known to be right, because this browser - * chose it or it opened the session. It replaces a vault copy that - * differs, since a session has one password and this one is proven; + * - the vault copy, when present. A locally verified password can be from + * the credential generation before a live rotation and therefore must + * never overwrite a newer vault copy merely because it worked once; + * - a password cached here and proven against this exact salted URL seeds + * an empty vault; * - a copy a colleague sealed to this browser's key before the vault. The * service holds it in the slot the vault copy takes, so keeping it is * moving it; @@ -428,12 +458,16 @@ export function Workspace() { */ async function keepOwnCopy(session: SessionRecord): Promise { const share = session.keyShare; - const inVault = isVaultShare(share?.sealed) ? await vault.openShare(session.id, share) : null; + const hasVaultShare = isVaultShare(share?.sealed); + const inVault = hasVaultShare ? await vault.openShare(session.id, share) : null; const cached = cachedPassword(session.id); - if (cached?.verified) { - return cached.password === inVault || vault.keep(session.id, cached.password); - } if (inVault) return true; + /* A v2 share is authoritative even when this browser cannot open it. + * Only a password that opens a live frame may replace that generation; + * TerminalPane performs that proof-bound write. */ + if (hasVaultShare) return false; + const seed = passwordToSeedVault(session.id, false, session.shareUrl); + if (seed) return vault.keep(session.id, seed); const legacyShare = share && !isVaultShare(share.sealed) ? await vault.openShare(session.id, share) : null; const candidate = legacyShare ?? (cached?.legacy ? cached.password : null); if (candidate) return vault.keep(session.id, candidate); @@ -446,7 +480,7 @@ export function Workspace() { const pending = session.origin ? pendingShares.current.get(session.origin) : undefined; if (pending) { pendingShares.current.delete(session.origin!); - rememberFor(session.id, pending); + rememberFor(session.id, pending, session.shareUrl); } /* @@ -458,7 +492,6 @@ export function Workspace() { } if (session.closedAt) continue; - const cached = cachedPassword(session.id); /* Only the owner shares with colleagues. */ if (!me || session.ownerUid !== me.uid) continue; @@ -477,9 +510,10 @@ export function Workspace() { done: sharedWith.current.get(session.id), }); if (missing.length === 0) continue; - /* A proven password before a vault copy nobody can vouch for; see TerminalPane. */ - const password = - (cached?.verified ? cached.password : null) ?? (await vault.openShare(session.id, session.keyShare)); + const vaultPassword = await vault.openShare(session.id, session.keyShare); + const password = isVaultShare(session.keyShare?.sealed) + ? vaultPassword + : verifiedPasswordFor(session.id, session.shareUrl) ?? vaultPassword; if (!password) continue; const done = sharedWith.current.get(session.id) ?? new Set(); diff --git a/app/src/styles/shell.css b/app/src/styles/shell.css index 4b69bad..59200ea 100644 --- a/app/src/styles/shell.css +++ b/app/src/styles/shell.css @@ -496,7 +496,9 @@ } .account-pop { + top: calc(100% + 8px); right: 0; + bottom: auto; left: auto; width: 232px; } diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index 4442040..b4c7e0e 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -93,6 +93,14 @@ export function TerminalPane({ const [password, setPassword] = useState(""); const [unlocking, setUnlocking] = useState(false); + /* + * Assignment can change while this pane is open. Read the current answer + * from a ref inside xterm's long-lived input callback, rather than rebuilding + * the terminal and dropping its socket and scrollback on every handoff. + */ + const canTypeRef = useRef(canType); + canTypeRef.current = canType; + /* * Only the visible pane measures itself. A hidden one is still laid out, so * it would measure fine here, but refusing to refit it at all means no @@ -234,8 +242,11 @@ export function TerminalPane({ pending.current = []; if (!worked || !sessionId) return; /* Written only now that it has proved itself; see handleUnlock. */ - if (worked.source === "typed") rememberVerified(sessionId, worked.password); - if (worked.source === "cache") markVerified(sessionId, worked.password); + if (worked.source === "typed" || worked.source === "vault") { + /* Also replaces a locally verified password from before rotation. */ + rememberVerified(sessionId, worked.password, shareUrl); + } + if (worked.source === "cache") markVerified(sessionId, worked.password, shareUrl); /* * A password that opened the session but did not come from the * vault goes into it now, so no browser has to be told it again. @@ -249,7 +260,7 @@ export function TerminalPane({ }, onReadOnly: (value) => { setReadOnly(value); - term.options.disableStdin = value; + term.options.disableStdin = value || !canTypeRef.current; }, /* A portrait viewer takes a capable session to 80x40, and back when it leaves. */ onGrid: (next) => { @@ -289,11 +300,11 @@ export function TerminalPane({ : null; const typed = term.onData((data) => { - if (!canType) return; + if (!canTypeRef.current) return; connected.send(data); sink?.observe(data); }); - term.options.disableStdin = !canType; + term.options.disableStdin = !canTypeRef.current; const sessionId = sessionIdFromShareUrl(shareUrl); attempt.current = null; @@ -310,10 +321,9 @@ export function TerminalPane({ /* * Every password within reach is tried before anyone is asked. One this - * browser has already seen open the session goes first: a vault copy is - * sealed with an ephemeral key, so anyone holding the public key could - * have made one, and a proven password should not give way to a copy - * nobody can vouch for. Then the vault's copy, then a cached guess, then + * The current vault copy goes first. A password cached as verified may + * belong to the credential generation before a live rotation; "worked in + * the past" is not proof that it is current. Then a cached password, then * one a colleague sealed to this browser's old key. The gate appears only * when all of them fail, or there are none. */ @@ -327,7 +337,6 @@ export function TerminalPane({ }; const opener = vaultRef.current; const cached = cachedPassword(sessionId); - if (cached?.verified) add("cache", cached.password); if (initial && isVaultShare(initial.sealed)) add("vault", await opener.openShare(sessionId, initial)); add("cache", cached?.password); if (initial && !isVaultShare(initial.sealed)) add("legacy", await opener.openShare(sessionId, initial)); @@ -364,7 +373,18 @@ export function TerminalPane({ measure.current = null; connection.current = null; }; - }, [shareUrl, refit, canType]); + }, [shareUrl, refit]); + + /* + * Apply a handoff in place. The relay's own read-only bit still wins, and + * the callback above checks the same ref as a second guard against input + * arriving between a render and this effect. + */ + useEffect(() => { + canTypeRef.current = canType; + if (!terminal.current) return; + terminal.current.options.disableStdin = readOnly || !canType; + }, [canType, readOnly]); /* * A share that arrives while the pane is asking for a password, such as the @@ -391,10 +411,10 @@ export function TerminalPane({ if (!active) return; const frame = requestAnimationFrame(() => { refit(); - if (!readOnly) terminal.current?.focus(); + if (!readOnly && canType) terminal.current?.focus(); }); return () => cancelAnimationFrame(frame); - }, [active, readOnly, refit]); + }, [active, readOnly, canType, refit]); async function handleUnlock(event: FormEvent) { event.preventDefault(); diff --git a/app/src/terminal/tabs.test.ts b/app/src/terminal/tabs.test.ts index 0eba35c..18503c2 100644 --- a/app/src/terminal/tabs.test.ts +++ b/app/src/terminal/tabs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { EMPTY, MAX_TABS, reduce, tabFor, type TabState } from "./tabs"; +import { EMPTY, MAX_TABS, reduce, sessionToOpen, tabFor, type TabState } from "./tabs"; import type { SessionRecord } from "../lib/api"; function session(id: string, command = "top"): SessionRecord { @@ -75,6 +75,26 @@ describe("open", () => { }); }); +describe("an open request from the session detail page", () => { + const sessions = [session("first"), session("second")]; + + it("selects the exact session named in the URL", () => { + expect(sessionToOpen(sessions, "second")?.id).toBe("second"); + }); + + it("does not treat a prefix as a session id", () => { + expect(sessionToOpen(sessions, "sec")).toBeNull(); + }); + + it("does not reopen a process that finished while its detail page was open", () => { + expect(sessionToOpen([{ ...session("done"), closedAt: 2 }], "done")).toBeNull(); + }); + + it("does nothing when there is no open request", () => { + expect(sessionToOpen(sessions, null)).toBeNull(); + }); +}); + describe("close", () => { it("removes the tab", () => { const state = reduce(open(open(EMPTY, "a"), "b"), { type: "close", id: "a" }); diff --git a/app/src/terminal/tabs.ts b/app/src/terminal/tabs.ts index ab4eba6..bc54a88 100644 --- a/app/src/terminal/tabs.ts +++ b/app/src/terminal/tabs.ts @@ -21,6 +21,22 @@ export const EMPTY: TabState = { tabs: [], activeId: null }; export const MAX_TABS = 8; +/** + * Resolves an `?open=` request against the current live session list. + * + * The detail page uses this route to hand one exact session back to the + * workspace. Keeping the lookup here makes two important rules explicit and + * testable: ids are exact (never prefixes), and a process that finished while + * the person was reading its details is not reopened as a dead terminal tab. + */ +export function sessionToOpen( + sessions: readonly SessionRecord[], + requestedId: string | null, +): SessionRecord | null { + if (!requestedId) return null; + return sessions.find((session) => session.id === requestedId && !session.closedAt) ?? null; +} + export type TabAction = | { type: "open"; session: SessionRecord; canType?: boolean } | { type: "close"; id: string } diff --git a/app/src/vault/share-with.ts b/app/src/vault/share-with.ts index 278103d..b56912f 100644 --- a/app/src/vault/share-with.ts +++ b/app/src/vault/share-with.ts @@ -1,7 +1,8 @@ import { shareSessionKeys, type Member, type SessionRecord } from "../lib/api"; -import { addToAudience, cachedPassword } from "../lib/session-passwords"; +import { addToAudience, verifiedPasswordFor } from "../lib/session-passwords"; import { keyTrust, trustKey } from "../lib/known-keys"; import type { useVault } from "./VaultProvider"; +import { isVaultShare } from "../lib/vault-crypto"; type Vault = Pick, "openShare" | "sealTo">; @@ -18,13 +19,13 @@ type Vault = Pick, "openShare" | "sealTo">; export async function shareWith( vault: Vault, owner: string, - session: Pick, + session: Pick, recipients: Member[], ): Promise { - /* A proven password before a vault copy nobody can vouch for; see TerminalPane. */ - const cached = cachedPassword(session.id); - const password = - (cached?.verified ? cached.password : null) ?? (await vault.openShare(session.id, session.keyShare)); + const vaultPassword = await vault.openShare(session.id, session.keyShare); + const password = isVaultShare(session.keyShare?.sealed) + ? vaultPassword + : verifiedPasswordFor(session.id, session.shareUrl) ?? vaultPassword; if (!password) return []; const eligible = recipients.filter( diff --git a/app/worker/index.test.ts b/app/worker/index.test.ts index d1d7241..838aede 100644 --- a/app/worker/index.test.ts +++ b/app/worker/index.test.ts @@ -35,5 +35,7 @@ describe("Cloudflare app assets", () => { expect(response.headers.get("X-Frame-Options")).toBe("DENY"); expect(response.headers.get("Referrer-Policy")).toBe("no-referrer"); expect(response.headers.get("Permissions-Policy")).toBe("camera=(), microphone=(), geolocation=()"); + expect(response.headers.get("Content-Security-Policy")).toContain("script-src 'self'"); + expect(response.headers.get("Content-Security-Policy")).toContain("frame-ancestors 'none'"); }); }); diff --git a/app/worker/index.ts b/app/worker/index.ts index e6e041c..e43f310 100644 --- a/app/worker/index.ts +++ b/app/worker/index.ts @@ -6,6 +6,7 @@ import type { Store } from "../server/lib/store"; import { PostgresStore } from "../server/lib/store-postgres"; import { callNodeHandler, type NodeHandler } from "./node-adapter"; import { allowedOriginsFor } from "../server/lib/config"; +import { BROWSER_SECURITY_HEADERS } from "../server/lib/browser-headers"; /** * The Worker deployment of the accounts service. @@ -59,14 +60,6 @@ const RELAY_PREFIX = "/relay"; * wrangler.jsonc sets run_worker_first so Cloudflare Assets cannot bypass * this function for a file that already exists. */ -const CLIENT_HEADERS: Record = { - "Cross-Origin-Opener-Policy": "same-origin-allow-popups", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "no-referrer", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()", -}; - /* * The store belonging to the request being served. * @@ -187,7 +180,7 @@ async function toRelay(request: Request, relayUrl: string): Promise { async function toClient(request: Request, env: Env): Promise { const served = await env.ASSETS.fetch(request); const response = new Response(served.body, served); - for (const [name, value] of Object.entries(CLIENT_HEADERS)) response.headers.set(name, value); + for (const [name, value] of Object.entries(BROWSER_SECURITY_HEADERS)) response.headers.set(name, value); return response; } diff --git a/cmd/shell/help.go b/cmd/shell/help.go index c7cec95..1de7dac 100644 --- a/cmd/shell/help.go +++ b/cmd/shell/help.go @@ -19,6 +19,8 @@ both. Shares are interactive by default and end-to-end encrypted. Then shell list See active shares and uptime + shell password Print an active share's password locally + shell password rotate Revoke it and make a fresh password shell attach Rejoin locally; browser access stays live Press Ctrl-X, then D to detach Leave the process running shell kill Safely stop the process and close its link @@ -35,7 +37,7 @@ Common options --persistent Keep one encrypted URL across restarts --auto-close