From 33395372e7c56cf90118c9e3047848aead46b124 Mon Sep 17 00:00:00 2001
From: Alexgodoroja
Date: Fri, 11 Sep 2026 15:26:07 -0700
Subject: [PATCH] Put the audit log under the team's key, and show the vault on
Account
What people typed into a session reached the service in plain text, and the
whole audit log sat there readable. It is now sealed in the browser to one
audit key per organization: every member holds its private half, the service
holds the public half and ciphertext, and it can read neither what was typed
nor the key.
- The key's private half reaches each member sealed to their vault by a
teammate, using that teammate's own vault key rather than a throwaway one,
so a browser can tell a copy came from a person and not from the service.
Each browser re-seals its copy to itself, remembers the team's key, and
refuses a different one, or a claim that the team has none.
- Entries are bound to their organization, session, author, kind and time, so
the service cannot move one person's command onto another session or person.
- Search, CSV export and the charts run in the browser, over entries opened
there. The service no longer searches what it cannot read.
- History from before this is sealed in place by an owner's or admin's
browser. That browser is handed the author and time by the service, so the
entry records who sealed it and the log says "sealed later by", and marks
entries still in the clear.
- Session lifecycle entries the service writes itself stay as they were: they
hold session names and addresses it already stores.
Account gains a vault section: what the vault is, that only its owner can see
it, the key's fingerprint, what it holds, each session password shown on
request and hidden again, and a way to lock the vault in this browser.
The sign-up page said typed input was not recorded, which was untrue. It, the
screen that links a terminal, the terms, llms.txt and the security policy now
describe what is recorded, who can read it, and what the service still sees.
---
.github/SECURITY.md | 1 +
CHANGELOG.md | 20 +
app/server/app.test.ts | 322 ++++++++++++++--
app/server/app.ts | 208 +++++++++-
app/server/lib/audit-seal.ts | 53 +++
app/server/lib/migrations/009_team_keys.sql | 30 ++
.../lib/migrations/010_audit_sealed_by.sql | 11 +
app/server/lib/store-conformance.test.ts | 84 +++++
app/server/lib/store-memory.ts | 79 ++++
app/server/lib/store-postgres.ts | 98 ++++-
app/server/lib/store.ts | 24 ++
app/server/lib/types.ts | 35 ++
app/server/routes/audit.test.ts | 15 +-
app/server/routes/audit.ts | 35 +-
app/server/routes/organizations.ts | 6 +
app/src/App.tsx | 3 +
app/src/lib/api.ts | 57 ++-
app/src/lib/audit-csv.test.ts | 47 +++
app/src/lib/audit-csv.ts | 51 +++
app/src/lib/known-keys.ts | Bin 1588 -> 2003 bytes
app/src/lib/team-crypto.test.ts | 158 ++++++++
app/src/lib/team-crypto.ts | Bin 0 -> 9356 bytes
app/src/lib/team-trust.test.ts | 35 ++
app/src/lib/team-trust.ts | 37 ++
app/src/routes/Account.tsx | 37 +-
app/src/routes/Audit.tsx | 186 +++++++--
app/src/routes/CliAuthorize.tsx | 10 +-
app/src/routes/SignUp.tsx | 11 +-
app/src/routes/Terms.tsx | 89 ++++-
app/src/styles/vault.css | 259 +++++++++++++
app/src/terminal/TerminalPane.tsx | 27 +-
app/src/vault/TeamKeyProvider.tsx | 355 ++++++++++++++++++
app/src/vault/VaultGate.tsx | 2 +-
app/src/vault/VaultPanel.tsx | 281 ++++++++++++++
app/src/vault/VaultProvider.tsx | 72 +++-
public/llms.txt | 22 +-
36 files changed, 2638 insertions(+), 122 deletions(-)
create mode 100644 app/server/lib/audit-seal.ts
create mode 100644 app/server/lib/migrations/009_team_keys.sql
create mode 100644 app/server/lib/migrations/010_audit_sealed_by.sql
create mode 100644 app/src/lib/audit-csv.test.ts
create mode 100644 app/src/lib/audit-csv.ts
create mode 100644 app/src/lib/team-crypto.test.ts
create mode 100644 app/src/lib/team-crypto.ts
create mode 100644 app/src/lib/team-trust.test.ts
create mode 100644 app/src/lib/team-trust.ts
create mode 100644 app/src/vault/TeamKeyProvider.tsx
create mode 100644 app/src/vault/VaultPanel.tsx
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 68d88b0..c08a213 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -18,6 +18,7 @@ You should receive an acknowledgement within three business days. We will valida
- `--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.
+- 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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01cc306..61ec20e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve
## Unreleased
+### Added
+
+- Vault management on the Account page: what the vault is, the session
+ passwords it holds, and the team audit key it keeps. Only you can see
+ what is in it.
+- An end-to-end encrypted audit log. What is typed into a session from the
+ browser is encrypted to your team's audit key before it leaves the browser,
+ so every member of the team can read it and shell.online cannot.
+
+### Changed
+
+- Searching and exporting the audit log run in the browser, on the decrypted
+ entries.
+- The service refuses unencrypted input entries for the audit log.
+
+### Fixed
+
+- The sign-up page said typed input was not recorded. It is recorded, now
+ end-to-end encrypted for your team.
+
## [0.12.0] — 2026-09-11
### Added
diff --git a/app/server/app.test.ts b/app/server/app.test.ts
index 553d492..c5cff70 100644
--- a/app/server/app.test.ts
+++ b/app/server/app.test.ts
@@ -1285,13 +1285,6 @@ describe("audit log", () => {
return tokens;
}
- /*
- * Terminal input is recorded in plaintext, and the whole organization can
- * read and export it. That is a deliberate choice rather than an oversight:
- * it was removed once and put back on the operator's instruction, and the
- * terms say so. This test is where the choice is written down, so that
- * removing it again is a decision somebody takes rather than a regression.
- */
it("records the removal of a session and keeps the entry after the row is gone", async () => {
await withSession();
const removed = await call("DELETE", `/api/sessions/${session.id}`, { auth: await idToken() });
@@ -1326,48 +1319,101 @@ describe("audit log", () => {
expect(again.status).toBe(404);
});
- it("records what was typed, in plaintext, for the whole organization", async () => {
+ /*
+ * An entry shaped the way the browser seals one: a real P-256 sender key and
+ * a body of nonce and ciphertext. The service can only check the shape, so
+ * random bytes of the right length stand in for the ciphertext.
+ */
+ async function sealedEntry(bodyBytes = 40) {
+ const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
+ const sender = base64url(Buffer.from(await crypto.subtle.exportKey("raw", pair.publicKey)));
+ return `a1.1.${sender}.${base64url(randomBytes(bodyBytes))}`;
+ }
+
+ /*
+ * What people type into a session is recorded for their team. That is the
+ * operator's choice, made deliberately: it was removed once and put back on
+ * their instruction, and the terms say so. What changed is who can read it.
+ * The browser seals each entry to the team's audit key before sending it,
+ * and the service refuses anything it could read. This test is where that
+ * is written down, so that accepting plaintext again is a decision somebody
+ * takes rather than a regression.
+ */
+ it("records typed input only as ciphertext sealed for the team", async () => {
await withSession();
- const result = await call("POST", "/api/audit", {
+ const plaintext = await call("POST", "/api/audit", {
auth: await idToken(),
body: {
entries: [
- { session_id: session.id, kind: "input", text: "refactor the parser" },
- { session_id: session.id, kind: "interrupt", text: "" },
+ { session_id: session.id, kind: "input", text: "refactor the parser", at: 1000 },
+ { session_id: session.id, kind: "interrupt", text: "", at: 1001 },
],
},
});
- expect(result.status).toBe(200);
- expect(result.body.written).toBe(2);
+ expect(plaintext.body).toEqual({ written: 0, refused: 2 });
+
+ const input = await sealedEntry();
+ const interrupt = await sealedEntry(28);
+ const sealed = await call("POST", "/api/audit", {
+ auth: await idToken(),
+ body: {
+ entries: [
+ { session_id: session.id, kind: "input", text: input, at: 2000 },
+ { session_id: session.id, kind: "interrupt", text: interrupt, at: 2001 },
+ /* The time is bound into the ciphertext, so an entry without one cannot be stored as sealed. */
+ { session_id: session.id, kind: "input", text: await sealedEntry() },
+ ],
+ },
+ });
+ expect(sealed.body).toEqual({ written: 2, refused: 1 });
const log = await call("GET", `/api/audit/${session.id}`, { auth: await idToken() });
- expect(log.body.events).toHaveLength(2);
- expect(log.body.events.map((event: { kind: string }) => event.kind)).toEqual([
- "input",
- "interrupt",
+ expect(
+ log.body.events.map((event: { kind: string; text: string; at: number }) => [event.kind, event.text, event.at]),
+ ).toEqual([
+ ["input", input, 2000],
+ ["interrupt", interrupt, 2001],
]);
- expect(log.body.events[0].text).toBe("refactor the parser");
});
- it("filters and paginates the team trail in the service", async () => {
+ /* Longer than the old plaintext cap: trimming ciphertext would destroy it. */
+ it("stores a long sealed entry whole", async () => {
+ await withSession();
+ const long = await sealedEntry(4000 + 28);
+ const result = await call("POST", "/api/audit", {
+ auth: await idToken(),
+ body: { entries: [{ session_id: session.id, kind: "input", text: long, at: 3000 }] },
+ });
+ expect(result.body.written).toBe(1);
+ const log = await call("GET", `/api/audit/${session.id}`, { auth: await idToken() });
+ expect(log.body.events[0].text).toBe(long);
+ });
+
+ it("filters and pages by who, what and when, and leaves text search to the browser", async () => {
await withSession();
await call("POST", "/api/audit", {
auth: await idToken(),
body: {
entries: [
- { session_id: session.id, kind: "input", text: "npm test", at: 1000 },
- { session_id: session.id, kind: "input", text: "git status", at: 2000 },
- { session_id: session.id, kind: "input", text: "npm run build", at: 3000 },
+ { session_id: session.id, kind: "input", text: await sealedEntry(), at: 1000 },
+ { session_id: session.id, kind: "interrupt", text: await sealedEntry(28), at: 2000 },
+ { session_id: session.id, kind: "input", text: await sealedEntry(), at: 3000 },
],
},
});
- const first = await call("GET", "/api/audit?q=npm&limit=1&page=1", { auth: await idToken() });
- const second = await call("GET", "/api/audit?q=npm&limit=1&page=2", { auth: await idToken() });
+ /* The service cannot search ciphertext, so a text query changes nothing. */
+ const searched = await call("GET", "/api/audit?q=npm", { auth: await idToken() });
+ expect(searched.body.total).toBe(3);
+ const first = await call("GET", "/api/audit?kind=input&limit=1&page=1", { auth: await idToken() });
+ const second = await call("GET", "/api/audit?kind=input&limit=1&page=2", { auth: await idToken() });
expect(first.body).toMatchObject({ total: 2, page: 1, limit: 1 });
- expect(first.body.events.map((entry: { text: string }) => entry.text)).toEqual(["npm run build"]);
- expect(second.body.events.map((entry: { text: string }) => entry.text)).toEqual(["npm test"]);
+ expect(first.body.events.map((entry: { at: number }) => entry.at)).toEqual([3000]);
+ expect(second.body.events.map((entry: { at: number }) => entry.at)).toEqual([1000]);
+
+ const recent = await call("GET", "/api/audit?since_at=2500", { auth: await idToken() });
+ expect(recent.body.total).toBe(1);
});
it("will not read another organization's log", async () => {
@@ -1976,3 +2022,227 @@ describe("DELETE /api/account", () => {
expect((await remove()).status).toBe(200);
});
});
+
+describe("team audit key", () => {
+ const session = {
+ id: "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t",
+ share_url: "https://shell.online/s/qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t",
+ command: "claude",
+ };
+
+ async function publicKey() {
+ const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
+ return base64url(Buffer.from(await crypto.subtle.exportKey("raw", pair.publicKey)));
+ }
+
+ /* A copy the service accepts: t1. and about the size of a sealed PKCS#8 key. */
+ function sealedCopy() {
+ return `t1.${base64url(randomBytes(166))}`;
+ }
+
+ async function sealedEntry() {
+ return `a1.1.${await publicKey()}.${base64url(randomBytes(40))}`;
+ }
+
+ async function withColleague() {
+ await call("GET", "/api/org", { auth: await idToken() });
+ 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 });
+ return colleague;
+ }
+
+ async function giveVault(auth: string, uid: string) {
+ const made = await createVault(uid);
+ await call("POST", "/api/vault", {
+ auth,
+ body: {
+ public_key: made.bundle.publicKey,
+ encrypted_private_key: made.bundle.encryptedPrivateKey,
+ recovery_wrap: made.bundle.recoveryWrap,
+ },
+ });
+ return made.bundle.publicKey;
+ }
+
+ async function makeKey(shares: { uid: string; sealed: string }[]) {
+ return call("POST", "/api/team-key", {
+ auth: await idToken(),
+ body: { public_key: await publicKey(), shares },
+ });
+ }
+
+ it("has none until a member makes one", async () => {
+ const result = await call("GET", "/api/team-key", { auth: await idToken() });
+ expect(result.status).toBe(200);
+ expect(result.body).toMatchObject({ teamKey: null, share: null, missing: [] });
+ expect(result.body.you).toMatchObject({ uid: "uid-1", role: "owner" });
+ expect(typeof result.body.you.orgId).toBe("string");
+ });
+
+ /* Two browsers making one at once must not both believe theirs is the team's. */
+ it("is made once, with the maker's own copy", async () => {
+ const copy = sealedCopy();
+ const created = await makeKey([{ uid: "uid-1", sealed: copy }]);
+ expect(created.status).toBe(201);
+ expect(created.body.teamKey).toMatchObject({ version: 1, createdBy: "uid-1" });
+
+ const again = await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]);
+ expect(again.status).toBe(409);
+
+ const read = await call("GET", "/api/team-key", { auth: await idToken() });
+ expect(read.body.teamKey.publicKey).toBe(created.body.teamKey.publicKey);
+ expect(read.body.share).toEqual({ senderUid: "uid-1", sealed: copy, version: 1 });
+ });
+
+ it("refuses a key without the maker's copy, with a copy for an outsider, or of the wrong shape", async () => {
+ await withColleague();
+ expect((await makeKey([{ uid: "uid-2", sealed: sealedCopy() }])).status).toBe(400);
+ expect((await makeKey([{ uid: "uid-1", sealed: sealedCopy() }, { uid: "uid-outside", sealed: sealedCopy() }])).status)
+ .toBe(400);
+ expect((await makeKey([{ uid: "uid-1", sealed: "not a sealed key" }])).status).toBe(400);
+ const badKey = await call("POST", "/api/team-key", {
+ auth: await idToken(),
+ body: { public_key: "not-a-key", shares: [{ uid: "uid-1", sealed: sealedCopy() }] },
+ });
+ expect(badKey.status).toBe(400);
+ expect((await call("GET", "/api/team-key", { auth: await idToken() })).body.teamKey).toBeNull();
+ });
+
+ it("hands each member only their own copy, and names who still needs one", async () => {
+ const colleague = await withColleague();
+ await giveVault(await idToken(), "uid-1");
+ const colleagueKey = await giveVault(colleague, "uid-2");
+ const mine = sealedCopy();
+ await makeKey([{ uid: "uid-1", sealed: mine }]);
+
+ const owner = await call("GET", "/api/team-key", { auth: await idToken() });
+ expect(owner.body.missing).toEqual([{ uid: "uid-2", accountKey: colleagueKey }]);
+ expect((await call("GET", "/api/team-key", { auth: colleague })).body.share).toBeNull();
+
+ const theirs = sealedCopy();
+ const shared = await call("PUT", "/api/team-key/shares", {
+ auth: await idToken(),
+ body: { version: 1, shares: [{ uid: "uid-2", sealed: theirs }] },
+ });
+ expect(shared.body).toEqual({ shared: 1 });
+
+ expect((await call("GET", "/api/team-key", { auth: colleague })).body.share)
+ .toEqual({ senderUid: "uid-1", sealed: theirs, version: 1 });
+ const after = await call("GET", "/api/team-key", { auth: await idToken() });
+ expect(after.body.share.sealed).toBe(mine);
+ expect(after.body.missing).toEqual([]);
+ });
+
+ /* A working copy cannot be replaced with one that does not open. */
+ it("never replaces a copy that exists", async () => {
+ 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 }] } });
+ 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);
+
+ const overOwner = await call("PUT", "/api/team-key/shares", {
+ auth: colleague,
+ body: { version: 1, shares: [{ uid: "uid-1", sealed: sealedCopy() }] },
+ });
+ expect(overOwner.body.shared).toBe(0);
+ });
+
+ it("refuses copies of a key that is not the current one", async () => {
+ await withColleague();
+ await makeKey([{ uid: "uid-1", sealed: sealedCopy() }]);
+ const stale = await call("PUT", "/api/team-key/shares", {
+ auth: await idToken(),
+ body: { version: 2, shares: [{ uid: "uid-2", sealed: sealedCopy() }] },
+ });
+ expect(stale.status).toBe(409);
+ });
+
+ it("lets a member delete only their own copy", async () => {
+ const colleague = await withColleague();
+ await makeKey([{ uid: "uid-1", sealed: sealedCopy() }, { uid: "uid-2", sealed: sealedCopy() }]);
+ expect((await call("DELETE", "/api/team-key/share", { auth: colleague })).body).toEqual({ deleted: true });
+ expect((await call("DELETE", "/api/team-key/share", { auth: colleague })).body).toEqual({ deleted: false });
+ expect((await call("GET", "/api/team-key", { auth: colleague })).body.share).toBeNull();
+ expect((await call("GET", "/api/team-key", { auth: await idToken() })).body.share).not.toBeNull();
+ });
+
+ it("takes a removed member's copy away", async () => {
+ await withColleague();
+ await makeKey([{ uid: "uid-1", sealed: sealedCopy() }, { uid: "uid-2", sealed: sealedCopy() }]);
+ const { orgId } = (await call("GET", "/api/team-key", { auth: await idToken() })).body.you;
+ const removed = await call("DELETE", "/api/org/members/uid-2", { auth: await idToken() });
+ expect(removed.status).toBe(200);
+ expect((await store.teamKeyShares(orgId)).map((share) => share.uid)).toEqual(["uid-1"]);
+ });
+
+ describe("sealing what was recorded before", () => {
+ async function plaintextRow() {
+ const tokens = await login();
+ await call("POST", "/api/sessions", { auth: tokens.access_token, body: session });
+ const { orgId } = (await call("GET", "/api/team-key", { auth: await idToken() })).body.you;
+ await store.putAudit({
+ id: "aud_plain",
+ orgId,
+ sessionId: session.id,
+ at: 1000,
+ actorUid: "uid-1",
+ actorEmail: "ana@example.com",
+ kind: "input",
+ text: "an old command",
+ });
+ return orgId as string;
+ }
+
+ it("is for an owner or admin only", async () => {
+ await plaintextRow();
+ const colleague = await withColleague();
+ expect((await call("GET", "/api/audit/plaintext", { auth: colleague })).status).toBe(403);
+ const attempt = await call("POST", "/api/audit/seal", {
+ auth: colleague,
+ body: { entries: [{ id: "aud_plain", text: await sealedEntry() }] },
+ });
+ expect(attempt.status).toBe(403);
+ });
+
+ it("replaces plaintext with its sealed form once, and refuses anything else", async () => {
+ await plaintextRow();
+ const listed = await call("GET", "/api/audit/plaintext", { auth: await idToken() });
+ expect(listed.body.events.map((entry: { id: string }) => entry.id)).toEqual(["aud_plain"]);
+
+ const seal = async (text: string) =>
+ call("POST", "/api/audit/seal", { auth: await idToken(), body: { entries: [{ id: "aud_plain", text }] } });
+ expect((await seal("still plaintext")).body).toEqual({ sealed: 0 });
+
+ const envelope = await sealedEntry();
+ expect((await seal(envelope)).body).toEqual({ sealed: 1 });
+ expect((await seal(await sealedEntry())).body).toEqual({ sealed: 0 });
+
+ expect((await call("GET", "/api/audit/plaintext", { auth: await idToken() })).body.events).toEqual([]);
+
+ /*
+ * A re-sealed entry says who sealed it. Their browser was handed the
+ * session, the author and the time by the service, so the entry is only
+ * as trustworthy as they are, and it must not read as first-hand.
+ */
+ const firstHand = await sealedEntry();
+ await call("POST", "/api/audit", {
+ auth: await idToken(),
+ body: { entries: [{ session_id: session.id, kind: "input", text: firstHand, at: 4000 }] },
+ });
+ const trail = await call("GET", `/api/audit/${session.id}`, { auth: await idToken() });
+ const resealed = trail.body.events.find((entry: { id: string }) => entry.id === "aud_plain");
+ expect(resealed.text).toBe(envelope);
+ expect(resealed.sealedBy).toBe("uid-1");
+ const fresh = trail.body.events.find((entry: { text: string }) => entry.text === firstHand);
+ expect(fresh.sealedBy).toBeUndefined();
+
+ const page = await call("GET", "/api/audit", { auth: await idToken() });
+ expect(page.body.events.find((entry: { id: string }) => entry.id === "aud_plain").sealedBy).toBe("uid-1");
+ });
+ });
+});
diff --git a/app/server/app.ts b/app/server/app.ts
index 246298b..1ec20ef 100644
--- a/app/server/app.ts
+++ b/app/server/app.ts
@@ -19,7 +19,8 @@ import {
sessionSource,
} from "./lib/sessions";
import { mintSecret } from "./lib/tokens";
-import { RESET_SIGN_IN_WINDOW_MS, readOwnerShare, readVaultInput, vaultForApi } from "./lib/vault";
+import { RESET_SIGN_IN_WINDOW_MS, isP256PublicKey, readOwnerShare, readVaultInput, vaultForApi } from "./lib/vault";
+import { isAuditEnvelope, isTeamKeyShare } from "./lib/audit-seal";
import {
changeRole,
createInvite,
@@ -158,6 +159,21 @@ function sharedWith(
return (session.keyShares ?? []).map((share) => share.uid).filter((uid) => uid !== membership.uid);
}
+/*
+ * 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
+ * together rather than stored in part.
+ */
+function readTeamShares(value: unknown): { uid: string; sealed: string }[] | null {
+ if (!Array.isArray(value) || value.length === 0 || value.length > 500) return null;
+ const shares = value.map((entry) => (entry ?? {}) as Record);
+ if (shares.some((share) => typeof share.uid !== "string" || !share.uid || !isTeamKeyShare(share.sealed))) {
+ return null;
+ }
+ if (new Set(shares.map((share) => share.uid)).size !== shares.length) return null;
+ return shares.map((share) => ({ uid: share.uid as string, sealed: share.sealed as string }));
+}
+
/**
* The harnesses a polling agent claims, keeping only the recognised ones.
*
@@ -518,12 +534,19 @@ export function createApp(options: AppOptions) {
/* ---- Audit ---- */
+ /*
+ * Typed input, sealed in the browser to the team's audit key. An entry
+ * that is not sealed is refused rather than stored: a browser still
+ * running an older build loses the entry, which is a gap in the trail
+ * and not a plaintext copy of what somebody typed.
+ */
if (route === "POST /api/audit") {
const membership = await requireMember(request);
if (!membership) return send(response, 401, { error: "sign in first" });
const body = (await readBody(request)) as Record;
const entries = Array.isArray(body.entries) ? body.entries : [];
- const written = [];
+ let written = 0;
+ let refused = 0;
for (const entry of entries.slice(0, 100)) {
const candidate = entry as Record;
const result = await recordAudit(store, membership, {
@@ -532,9 +555,175 @@ export function createApp(options: AppOptions) {
text: String(candidate.text ?? ""),
at: typeof candidate.at === "number" ? candidate.at : undefined,
});
- if (result.ok) written.push(result.event);
+ if (result.ok) written += 1;
+ else refused += 1;
+ }
+ return send(response, 200, { written, refused });
+ }
+
+ /*
+ * The team's audit key: its public half, and the caller's own sealed
+ * copy of the private half. Nobody is handed anyone else's copy.
+ * `missing` names the members with a vault and no copy yet, so any
+ * teammate who holds the key can seal one for them.
+ */
+ if (route === "GET /api/team-key") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ const key = await store.teamKey(membership.orgId);
+ const shares = key ? await store.teamKeyShares(membership.orgId) : [];
+ const mine = shares.find((share) => share.uid === membership.uid && share.version === key?.version);
+ const holders = new Set(
+ shares.filter((share) => share.version === key?.version).map((share) => share.uid),
+ );
+ const missing = key
+ ? (await store.members(membership.orgId))
+ .filter((member) => member.accountKey && !holders.has(member.uid))
+ .map((member) => ({ uid: member.uid, accountKey: member.accountKey }))
+ : [];
+ return send(response, 200, {
+ teamKey: key
+ ? { publicKey: key.publicKey, version: key.version, createdBy: key.createdBy, createdAt: key.createdAt }
+ : null,
+ share: mine ? { senderUid: mine.senderUid, sealed: mine.sealed, version: mine.version } : null,
+ missing,
+ you: { uid: membership.uid, role: membership.role, orgId: membership.orgId },
+ });
+ }
+
+ /*
+ * The first member to need the key makes it, in their browser, and seals
+ * the private half to every member with a vault. The service keeps the
+ * public half and the sealed copies. Create-only, so two browsers making
+ * one at once cannot both believe theirs is the team's.
+ */
+ if (route === "POST /api/team-key") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ const body = (await readBody(request)) as Record;
+ if (!(await isP256PublicKey(body.public_key))) {
+ return send(response, 400, { error: "invalid public key" });
+ }
+ const shares = readTeamShares(body.shares);
+ if (!shares) return send(response, 400, { error: "invalid key shares" });
+ 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" });
+ }
+ if (!shares.some((share) => share.uid === membership.uid)) {
+ return send(response, 400, { error: "include your own copy of the key" });
+ }
+ const now = Date.now();
+ const key = {
+ orgId: membership.orgId,
+ publicKey: body.public_key as string,
+ version: 1,
+ createdBy: membership.uid,
+ createdAt: now,
+ };
+ if (!(await store.putTeamKey(key))) {
+ return send(response, 409, { error: "this team already has an audit key" });
+ }
+ await store.putTeamKeyShares(shares.map((share) => ({
+ orgId: membership.orgId,
+ uid: share.uid,
+ version: key.version,
+ senderUid: membership.uid,
+ sealed: share.sealed,
+ createdAt: now,
+ })));
+ return send(response, 201, {
+ teamKey: { publicKey: key.publicKey, version: key.version, createdBy: key.createdBy, createdAt: key.createdAt },
+ });
+ }
+
+ /*
+ * A teammate who holds the key sealing it for members who have none.
+ * Insert-only, so nobody can replace a working copy with one that does
+ * not open; the version must be the current one so a copy of a key that
+ * has been replaced is not handed out.
+ */
+ if (route === "PUT /api/team-key/shares") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ const body = (await readBody(request)) as Record;
+ const key = await store.teamKey(membership.orgId);
+ if (!key) return send(response, 404, { error: "this team has no audit key yet" });
+ if (body.version !== key.version) {
+ return send(response, 409, { error: "the team's audit key has changed; reload and try again" });
+ }
+ const shares = readTeamShares(body.shares);
+ if (!shares) return send(response, 400, { error: "invalid key shares" });
+ 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" });
}
- return send(response, 200, { written: written.length });
+ const now = Date.now();
+ const shared = await store.putTeamKeyShares(shares.map((share) => ({
+ orgId: membership.orgId,
+ uid: share.uid,
+ version: key.version,
+ senderUid: membership.uid,
+ sealed: share.sealed,
+ createdAt: now,
+ })));
+ return send(response, 200, { shared });
+ }
+
+ /*
+ * Only ever the caller's own copy: for when it no longer opens, such as
+ * after a vault reset, so that a teammate can seal a fresh one.
+ */
+ if (route === "DELETE /api/team-key/share") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ return send(response, 200, { deleted: await store.deleteTeamKeyShare(membership.orgId, membership.uid) });
+ }
+
+ /*
+ * Typed input recorded before the audit key existed, still in
+ * plaintext. An owner or admin's browser reads it, seals each entry to
+ * the team key and writes it back, after which the service holds no
+ * readable copy. Limited to them because a sealed entry replaces the
+ * original, and a trail anyone could overwrite would not be a trail.
+ */
+ if (route === "GET /api/audit/plaintext") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ if (membership.role !== "owner" && membership.role !== "admin") {
+ return send(response, 403, { error: "only an owner or admin can seal the team's history" });
+ }
+ const asked = Number(url.searchParams.get("limit") ?? "");
+ const limit = Number.isInteger(asked) && asked > 0 ? Math.min(asked, 200) : 100;
+ return send(response, 200, { events: await store.plaintextAudit(membership.orgId, limit) });
+ }
+
+ if (route === "POST /api/audit/seal") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ if (membership.role !== "owner" && membership.role !== "admin") {
+ return send(response, 403, { error: "only an owner or admin can seal the team's history" });
+ }
+ const body = (await readBody(request)) as Record;
+ const entries = Array.isArray(body.entries) ? body.entries.slice(0, 200) : [];
+ let sealed = 0;
+ for (const entry of entries) {
+ const candidate = entry as Record;
+ if (typeof candidate.id !== "string" || !(await isAuditEnvelope(candidate.text))) continue;
+ /*
+ * Recorded against the person whose browser sealed it. They were
+ * handed the session, the author and the time by this service, so a
+ * re-sealed entry must not read as the words of the person it names.
+ */
+ const done = await store.sealAudit(
+ membership.orgId,
+ candidate.id,
+ candidate.text as string,
+ membership.uid,
+ );
+ if (done) sealed += 1;
+ }
+ return send(response, 200, { sealed });
}
/*
@@ -564,7 +753,11 @@ export function createApp(options: AppOptions) {
sessionId: url.searchParams.get("session")?.slice(0, 64) || undefined,
actorUid: url.searchParams.get("actor")?.slice(0, 256) || undefined,
kind: kinds.has(askedKind) ? askedKind as AuditEvent["kind"] : undefined,
- query: url.searchParams.get("q")?.trim().slice(0, 200) || undefined,
+ /*
+ * No text search. Typed input is ciphertext here, and the service
+ * cannot search what it cannot read; the browser searches what it
+ * has decrypted.
+ */
sinceAt: Number.isFinite(askedSince) && askedSince > 0 ? askedSince : undefined,
});
return send(response, 200, { ...result, page, limit });
@@ -580,6 +773,11 @@ export function createApp(options: AppOptions) {
return send(response, 200, { events: await store.auditFor(membership.orgId, auditRoute[1]) });
}
+ /*
+ * Kept for scripts written against the prerelease API. Typed input in it
+ * is ciphertext now, sealed to the team's audit key: the app builds its
+ * export in the browser, from what it has decrypted.
+ */
if (route === "GET /api/audit.csv") {
const membership = await requireMember(request);
if (!membership) return send(response, 401, { error: "sign in first" });
diff --git a/app/server/lib/audit-seal.ts b/app/server/lib/audit-seal.ts
new file mode 100644
index 0000000..c1145d6
--- /dev/null
+++ b/app/server/lib/audit-seal.ts
@@ -0,0 +1,53 @@
+import { isP256PublicKey } from "./vault";
+
+/**
+ * The shapes the service accepts for the team audit key and for typed input.
+ *
+ * What people type into a session is recorded for their team, sealed in the
+ * browser to a key the team holds and this service does not. The service can
+ * only check that what it is handed looks like that, never what is inside, so
+ * these checks are about shape: enough to refuse plaintext and junk.
+ */
+
+const BASE64URL = /^[A-Za-z0-9_-]+$/;
+
+/** Prefix of typed input sealed to the team's audit key. */
+export const AUDIT_ENVELOPE_PREFIX = "a1.";
+/** Prefix of a member's copy of the team's private audit key. */
+export const TEAM_SHARE_PREFIX = "t1.";
+
+/* An entry holds at most 4,000 characters of input; this leaves room for the envelope. */
+const MAX_ENVELOPE_LENGTH = 8192;
+const NONCE_AND_TAG = 12 + 16;
+/* A PKCS#8 P-256 key is about 138 bytes; anything much smaller is not one. */
+const SHARE_MIN_BYTES = NONCE_AND_TAG + 64;
+const SHARE_MAX_BYTES = 1024;
+
+function decodedLength(value: string): number | null {
+ if (!BASE64URL.test(value) || value.length % 4 === 1) return null;
+ return Math.floor((value.length * 3) / 4);
+}
+
+/**
+ * True for `a1...`, the form
+ * the browser seals typed input in. An empty interrupt still carries a nonce
+ * and a tag, so no well-formed envelope is shorter than that.
+ */
+export async function isAuditEnvelope(text: unknown): Promise {
+ if (typeof text !== "string" || text.length > MAX_ENVELOPE_LENGTH) return false;
+ if (!text.startsWith(AUDIT_ENVELOPE_PREFIX)) return false;
+ const parts = text.slice(AUDIT_ENVELOPE_PREFIX.length).split(".");
+ if (parts.length !== 3) return false;
+ const [version, sender, body] = parts;
+ if (!/^[1-9][0-9]{0,8}$/.test(version)) return false;
+ const length = decodedLength(body);
+ if (length === null || length < NONCE_AND_TAG) return false;
+ return isP256PublicKey(sender);
+}
+
+/** True for `t1.` of about the size a sealed private key is. */
+export function isTeamKeyShare(sealed: unknown): boolean {
+ if (typeof sealed !== "string" || !sealed.startsWith(TEAM_SHARE_PREFIX)) return false;
+ const length = decodedLength(sealed.slice(TEAM_SHARE_PREFIX.length));
+ return length !== null && length >= SHARE_MIN_BYTES && length <= SHARE_MAX_BYTES;
+}
diff --git a/app/server/lib/migrations/009_team_keys.sql b/app/server/lib/migrations/009_team_keys.sql
new file mode 100644
index 0000000..7f9a6c3
--- /dev/null
+++ b/app/server/lib/migrations/009_team_keys.sql
@@ -0,0 +1,30 @@
+-- An organization's audit key.
+--
+-- What people type into a session is recorded for their team. It is sealed in
+-- the browser to this public key before it is sent, so the audit log the
+-- service keeps is one it cannot read. The private half is held by the team's
+-- members, each copy sealed by a teammate to that member's vault key, and
+-- never by this service.
+--
+-- `version` names which key an entry was sealed to, so a key can one day be
+-- replaced without making older entries unreadable.
+CREATE TABLE IF NOT EXISTS team_keys (
+ org_id TEXT PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
+ public_key TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ created_by TEXT NOT NULL,
+ created_at BIGINT NOT NULL
+);
+
+-- One member's copy of the private audit key, sealed to their vault. A copy is
+-- written once and never replaced by anyone else: a member whose copy no
+-- longer opens deletes their own, and a teammate seals a fresh one.
+CREATE TABLE IF NOT EXISTS team_key_shares (
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ uid TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ sender_uid TEXT NOT NULL,
+ sealed TEXT NOT NULL,
+ created_at BIGINT NOT NULL,
+ PRIMARY KEY (org_id, uid)
+);
diff --git a/app/server/lib/migrations/010_audit_sealed_by.sql b/app/server/lib/migrations/010_audit_sealed_by.sql
new file mode 100644
index 0000000..0832b34
--- /dev/null
+++ b/app/server/lib/migrations/010_audit_sealed_by.sql
@@ -0,0 +1,11 @@
+-- Who sealed an audit entry, when it was not sealed first-hand.
+--
+-- Typed input now arrives sealed from the browser that recorded it. A log kept
+-- before the team had an audit key is sealed afterwards instead, by an owner or
+-- an admin, and that browser is handed the session, the author and the time by
+-- this service. So a re-sealed entry is only as trustworthy as whoever sealed
+-- it, and it should not read as the words of the person it names.
+--
+-- Null is first-hand: sealed by the browser that wrote it. A uid names the
+-- person whose browser sealed it later.
+ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS sealed_by TEXT;
diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts
index 5c6d646..c5748e1 100644
--- a/app/server/lib/store-conformance.test.ts
+++ b/app/server/lib/store-conformance.test.ts
@@ -138,6 +138,8 @@ const TABLES = [
"notifications",
"invites",
"memberships",
+ "team_key_shares",
+ "team_keys",
"organizations",
"cli_tokens",
"auth_codes",
@@ -505,6 +507,88 @@ for (const implementation of implementations) {
});
});
+ describe("team audit key", () => {
+ type TeamKeyRecord = Parameters[0];
+ type ShareRecord = Parameters[0][number];
+
+ function teamKey(overrides: Partial = {}): TeamKeyRecord {
+ return { orgId: "org_1", publicKey: "pk-team", version: 1, createdBy: "uid-1", createdAt: 1000, ...overrides };
+ }
+
+ function share(overrides: Partial = {}): ShareRecord {
+ return {
+ orgId: "org_1",
+ uid: "uid-1",
+ version: 1,
+ senderUid: "uid-1",
+ sealed: "t1.one",
+ createdAt: 1000,
+ ...overrides,
+ };
+ }
+
+ beforeEach(async () => {
+ await store.putOrganization(organization());
+ await store.putOrganization(organization({ id: "org_2", name: "Elsewhere" }));
+ });
+
+ /* Two browsers making the key at once must not both believe theirs is the team's. */
+ it("creates a team key once and refuses a second", async () => {
+ expect(await store.putTeamKey(teamKey())).toBe(true);
+ expect(await store.putTeamKey(teamKey({ publicKey: "pk-other", createdBy: "uid-2" }))).toBe(false);
+ expect(await store.teamKey("org_1")).toEqual(teamKey());
+ expect(await store.teamKey("org_2")).toBeNull();
+ });
+
+ it("never overwrites a member's copy of the key", async () => {
+ expect(await store.putTeamKeyShares([share(), share({ uid: "uid-2", sealed: "t1.two" })])).toBe(2);
+ expect(await store.putTeamKeyShares([share({ sealed: "t1.replaced", senderUid: "uid-2" })])).toBe(0);
+ const shares = await store.teamKeyShares("org_1");
+ expect(shares.find((entry) => entry.uid === "uid-1")?.sealed).toBe("t1.one");
+ expect(shares).toHaveLength(2);
+ });
+
+ it("deletes only the copy it names", async () => {
+ await store.putTeamKeyShares([share(), share({ uid: "uid-2", sealed: "t1.two" })]);
+ expect(await store.deleteTeamKeyShare("org_1", "uid-1")).toBe(true);
+ expect(await store.deleteTeamKeyShare("org_1", "uid-1")).toBe(false);
+ expect(await store.deleteTeamKeyShare("org_2", "uid-2")).toBe(false);
+ expect((await store.teamKeyShares("org_1")).map((entry) => entry.uid)).toEqual(["uid-2"]);
+ });
+
+ describe("typed input from before the key", () => {
+ beforeEach(async () => {
+ await store.putAudit(auditEvent({ id: "p1", kind: "input", text: "ls -la", at: 1000 }));
+ await store.putAudit(auditEvent({ id: "s1", kind: "input", text: "a1.1.sender.body", at: 2000 }));
+ await store.putAudit(auditEvent({ id: "h1", kind: "handoff", text: "assigned to bo", at: 3000 }));
+ await store.putAudit(auditEvent({ id: "i1", kind: "interrupt", text: "", at: 4000 }));
+ await store.putAudit(auditEvent({ id: "o1", orgId: "org_2", kind: "input", text: "other", at: 5000 }));
+ });
+
+ it("lists only plaintext typed input, oldest first", async () => {
+ expect((await store.plaintextAudit("org_1", 10)).map((entry) => entry.id)).toEqual(["p1", "i1"]);
+ expect((await store.plaintextAudit("org_1", 1)).map((entry) => entry.id)).toEqual(["p1"]);
+ });
+
+ /* Who sealed it is kept: a re-sealed entry must not pass for first-hand. */
+ it("seals a plaintext entry once, recording who did it, and nothing else", async () => {
+ expect(await store.sealAudit("org_1", "p1", "a1.1.sender.sealed", "uid-2")).toBe(true);
+ expect(await store.sealAudit("org_1", "p1", "a1.1.sender.again", "uid-3")).toBe(false);
+ expect(await store.sealAudit("org_1", "s1", "a1.1.sender.over", "uid-2")).toBe(false);
+ expect(await store.sealAudit("org_1", "h1", "a1.1.sender.over", "uid-2")).toBe(false);
+ expect(await store.sealAudit("org_2", "p1", "a1.1.sender.over", "uid-2")).toBe(false);
+ const trail = await store.auditFor("org_1", "s1");
+ const sealed = trail.find((entry) => entry.id === "p1");
+ expect(sealed?.text).toBe("a1.1.sender.sealed");
+ expect(sealed?.sealedBy).toBe("uid-2");
+ /* An entry that arrived sealed was nobody's to re-seal. */
+ expect(trail.find((entry) => entry.id === "s1")?.sealedBy).toBeUndefined();
+ expect(trail.find((entry) => entry.id === "h1")?.text).toBe("assigned to bo");
+ expect((await store.plaintextAudit("org_1", 10)).map((entry) => entry.id)).toEqual(["i1"]);
+ });
+ });
+ });
+
describe("agent commands", () => {
it("hands queued work out exactly once", async () => {
await store.putCommand(command());
diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts
index 5cb3b48..88bf407 100644
--- a/app/server/lib/store-memory.ts
+++ b/app/server/lib/store-memory.ts
@@ -21,6 +21,8 @@ import type {
Notification,
SessionKeyShare,
SessionRecord,
+ TeamKey,
+ TeamKeyShare,
} from "./types";
/*
@@ -43,6 +45,15 @@ function byTime(time: (record: T) => number, id: (record: T) => string, desce
};
}
+/* Typed input, which the browser seals, as opposed to what the service writes itself. */
+function isTyped(entry: AuditEvent): boolean {
+ return entry.kind === "input" || entry.kind === "interrupt";
+}
+
+function isSealed(text: string): boolean {
+ return text.startsWith("a1.");
+}
+
interface Shape {
codes: AuthorizationCode[];
tokens: CliToken[];
@@ -56,12 +67,15 @@ interface Shape {
notifications: Notification[];
accountKeys: AccountKey[];
deletedAccounts: { uid: string; deletedAt: number }[];
+ teamKeys: TeamKey[];
+ teamKeyShares: TeamKeyShare[];
}
const EMPTY: Shape = {
codes: [], tokens: [], sessions: [], commands: [],
organizations: [], memberships: [], invites: [], audit: [],
comments: [], notifications: [], accountKeys: [], deletedAccounts: [],
+ teamKeys: [], teamKeyShares: [],
};
/**
@@ -126,6 +140,8 @@ export class MemoryStore implements Store {
notifications: parsed.notifications ?? [],
accountKeys: parsed.accountKeys ?? [],
deletedAccounts: parsed.deletedAccounts ?? [],
+ teamKeys: parsed.teamKeys ?? [],
+ teamKeyShares: parsed.teamKeyShares ?? [],
};
} catch {
return structuredClone(EMPTY);
@@ -615,6 +631,69 @@ export class MemoryStore implements Store {
this.flush();
}
+ async plaintextAudit(orgId: string, limit: number): Promise {
+ return this.data.audit
+ .filter((entry) => entry.orgId === orgId && isTyped(entry) && !isSealed(entry.text))
+ .sort(byTime((entry) => entry.at, (entry) => entry.id))
+ .slice(0, limit);
+ }
+
+ async sealAudit(orgId: string, id: string, text: string, sealedBy: string): Promise {
+ const entry = this.data.audit.find((candidate) => candidate.orgId === orgId && candidate.id === id);
+ if (!entry || !isTyped(entry) || isSealed(entry.text)) return false;
+ entry.text = text;
+ entry.sealedBy = sealedBy;
+ this.flush();
+ return true;
+ }
+
+ /* ---------------------------------------------------------------
+ Team audit key
+ --------------------------------------------------------------- */
+
+ async teamKey(orgId: string): Promise {
+ const found = this.data.teamKeys.find((entry) => entry.orgId === orgId);
+ return found ? { ...found } : null;
+ }
+
+ async putTeamKey(key: TeamKey): Promise {
+ if (this.data.teamKeys.some((entry) => entry.orgId === key.orgId)) return false;
+ this.data.teamKeys.push({ ...key });
+ this.flush();
+ return true;
+ }
+
+ async teamKeyShares(orgId: string): Promise {
+ return this.data.teamKeyShares
+ .filter((entry) => entry.orgId === orgId)
+ .sort(byTime((entry) => entry.createdAt, (entry) => entry.uid))
+ .map((entry) => ({ ...entry }));
+ }
+
+ async putTeamKeyShares(shares: TeamKeyShare[]): Promise {
+ let written = 0;
+ for (const share of shares) {
+ const exists = this.data.teamKeyShares.some(
+ (entry) => entry.orgId === share.orgId && entry.uid === share.uid,
+ );
+ if (exists) continue;
+ this.data.teamKeyShares.push({ ...share });
+ written += 1;
+ }
+ if (written > 0) this.flush();
+ return written;
+ }
+
+ async deleteTeamKeyShare(orgId: string, uid: string): Promise {
+ const before = this.data.teamKeyShares.length;
+ this.data.teamKeyShares = this.data.teamKeyShares.filter(
+ (entry) => !(entry.orgId === orgId && entry.uid === uid),
+ );
+ if (this.data.teamKeyShares.length === before) return false;
+ this.flush();
+ return true;
+ }
+
async auditFor(orgId: string, sessionId: string): Promise {
return this.data.audit
.filter((entry) => entry.orgId === orgId && entry.sessionId === sessionId)
diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts
index 93d6225..790d692 100644
--- a/app/server/lib/store-postgres.ts
+++ b/app/server/lib/store-postgres.ts
@@ -23,6 +23,8 @@ import type {
Notification,
SessionKeyShare,
SessionRecord,
+ TeamKey,
+ TeamKeyShare,
} from "./types";
/*
@@ -237,6 +239,27 @@ function toAccountKey(row: Row): AccountKey {
};
}
+function toTeamKey(row: Row): TeamKey {
+ return {
+ orgId: row.org_id as string,
+ publicKey: row.public_key as string,
+ version: row.version as number,
+ createdBy: row.created_by as string,
+ createdAt: row.created_at as number,
+ };
+}
+
+function toTeamKeyShare(row: Row): TeamKeyShare {
+ return {
+ orgId: row.org_id as string,
+ uid: row.uid as string,
+ version: row.version as number,
+ senderUid: row.sender_uid as string,
+ sealed: row.sealed as string,
+ createdAt: row.created_at as number,
+ };
+}
+
function toInvite(row: Row): Invite {
return defined({
id: row.id,
@@ -262,6 +285,7 @@ function toAudit(row: Row): AuditEvent {
actorEmail: row.actor_email as string,
kind: row.kind as AuditEvent["kind"],
text: row.text as string,
+ ...(row.sealed_by ? { sealedBy: row.sealed_by as string } : {}),
};
}
@@ -1205,12 +1229,80 @@ export class PostgresStore implements Store {
await this.pool.query(`UPDATE invites SET ${set.text} WHERE id = $1`, [id, ...set.values]);
}
+ /* ---- Team audit key ---- */
+
+ async teamKey(orgId: string): Promise {
+ const row = await this.row("SELECT * FROM team_keys WHERE org_id = $1", [orgId]);
+ return row ? toTeamKey(row) : null;
+ }
+
+ async putTeamKey(key: TeamKey): Promise {
+ const result = await this.pool.query(
+ `INSERT INTO team_keys (org_id, public_key, version, created_by, created_at)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (org_id) DO NOTHING`,
+ [key.orgId, key.publicKey, key.version, key.createdBy, key.createdAt],
+ );
+ return (result.rowCount ?? 0) > 0;
+ }
+
+ async teamKeyShares(orgId: string): Promise {
+ const rows = await this.rows(
+ 'SELECT * FROM team_key_shares WHERE org_id = $1 ORDER BY created_at ASC, uid COLLATE "C" ASC',
+ [orgId],
+ );
+ return rows.map(toTeamKeyShare);
+ }
+
+ /* One statement per copy, each conditional, so an existing copy is never replaced. */
+ async putTeamKeyShares(shares: TeamKeyShare[]): Promise {
+ let written = 0;
+ for (const share of shares) {
+ const result = await this.pool.query(
+ `INSERT INTO team_key_shares (org_id, uid, version, sender_uid, sealed, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (org_id, uid) DO NOTHING`,
+ [share.orgId, share.uid, share.version, share.senderUid, share.sealed, share.createdAt],
+ );
+ written += result.rowCount ?? 0;
+ }
+ return written;
+ }
+
+ async deleteTeamKeyShare(orgId: string, uid: string): Promise {
+ const result = await this.pool.query("DELETE FROM team_key_shares WHERE org_id = $1 AND uid = $2", [
+ orgId,
+ uid,
+ ]);
+ return (result.rowCount ?? 0) > 0;
+ }
+
+ async plaintextAudit(orgId: string, limit: number): Promise {
+ const rows = await this.rows(
+ `SELECT * FROM audit_events
+ WHERE org_id = $1 AND kind IN ('input', 'interrupt') AND text NOT LIKE 'a1.%'
+ ORDER BY at ASC, id COLLATE "C" ASC
+ LIMIT $2`,
+ [orgId, limit],
+ );
+ return rows.map(toAudit);
+ }
+
+ async sealAudit(orgId: string, id: string, text: string, sealedBy: string): Promise {
+ const result = await this.pool.query(
+ `UPDATE audit_events SET text = $3, sealed_by = $4
+ WHERE org_id = $1 AND id = $2 AND kind IN ('input', 'interrupt') AND text NOT LIKE 'a1.%'`,
+ [orgId, id, text, sealedBy],
+ );
+ return (result.rowCount ?? 0) > 0;
+ }
+
/* ---- Audit ---- */
async putAudit(event: AuditEvent): Promise {
await this.pool.query(
- `INSERT INTO audit_events (id, org_id, session_id, at, actor_uid, actor_email, kind, text)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `INSERT INTO audit_events (id, org_id, session_id, at, actor_uid, actor_email, kind, text, sealed_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING`,
[
event.id,
@@ -1221,6 +1313,8 @@ export class PostgresStore implements Store {
event.actorEmail,
event.kind,
event.text,
+ /* Null is first-hand: sealed by the browser that recorded it. */
+ event.sealedBy ?? null,
],
);
}
diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts
index 92c5922..1063598 100644
--- a/app/server/lib/store.ts
+++ b/app/server/lib/store.ts
@@ -10,6 +10,8 @@ import type {
Notification,
SessionKeyShare,
SessionRecord,
+ TeamKey,
+ TeamKeyShare,
} from "./types";
export * from "./types";
@@ -147,6 +149,28 @@ export interface Store {
/** Whether this uid was deleted at or after `since`. */
recentlyDeleted(uid: string, since: number): Promise;
+ /* ---- Team audit key ---- */
+ teamKey(orgId: string): Promise;
+ /** Creates the team's audit key, and only if it has none. False when one exists. */
+ putTeamKey(key: TeamKey): Promise;
+ teamKeyShares(orgId: string): Promise;
+ /**
+ * Stores members' copies of the team key. Insert-only: a copy that exists is
+ * never overwritten, so nobody can replace a teammate's working copy with
+ * one that does not open. Returns how many were written.
+ */
+ putTeamKeyShares(shares: TeamKeyShare[]): Promise;
+ deleteTeamKeyShare(orgId: string, uid: string): Promise;
+ /** Typed input still stored as plaintext, oldest first, for a team member to seal. */
+ plaintextAudit(orgId: string, limit: number): Promise;
+ /**
+ * Replaces a plaintext input entry with its sealed form, recording who did
+ * it. Only an entry of a typed kind that is still plaintext can change, so
+ * sealing cannot be used to rewrite an entry that is already sealed or one
+ * the service wrote, and a re-sealed entry never passes for first-hand.
+ */
+ sealAudit(orgId: string, id: string, text: string, sealedBy: string): Promise;
+
/* ---- Agent commands ---- */
putCommand(command: AgentCommand): Promise;
claimCommands(deviceId: string, now?: number): Promise;
diff --git a/app/server/lib/types.ts b/app/server/lib/types.ts
index 630d8c7..85604e8 100644
--- a/app/server/lib/types.ts
+++ b/app/server/lib/types.ts
@@ -71,6 +71,14 @@ export interface AuditEvent {
actorEmail: string;
kind: "input" | "interrupt" | "opened" | "handoff" | "stopped" | "deleted";
text: string;
+ /**
+ * Who sealed this entry afterwards, when a team encrypted a log it had kept
+ * before it had an audit key. Absent means first-hand: it arrived sealed
+ * from the browser that recorded it. A re-sealed entry was sealed by
+ * somebody handed its session, author and time by this service, so it is
+ * only as trustworthy as they are, and a reader is told which it is.
+ */
+ sealedBy?: string;
}
/**
@@ -105,6 +113,33 @@ export interface AccountKey {
updatedAt: number;
}
+/**
+ * An organization's audit key.
+ *
+ * The public half of a key pair whose private half the team's members hold,
+ * each copy sealed to that member's vault. Typed input is sealed to it in the
+ * browser, so the audit log the service stores is one it cannot read.
+ */
+export interface TeamKey {
+ orgId: string;
+ publicKey: string;
+ /** Which key an entry was sealed to, so one can be replaced without losing the old. */
+ version: number;
+ createdBy: string;
+ createdAt: number;
+}
+
+/** One member's copy of the team's private audit key, sealed to them by a teammate. */
+export interface TeamKeyShare {
+ orgId: string;
+ uid: string;
+ version: number;
+ /** Who sealed it, so the reader can check the copy came from a teammate's vault. */
+ senderUid: string;
+ sealed: string;
+ createdAt: number;
+}
+
export interface Comment {
id: string;
orgId: string;
diff --git a/app/server/routes/audit.test.ts b/app/server/routes/audit.test.ts
index 9ae0cb2..419de65 100644
--- a/app/server/routes/audit.test.ts
+++ b/app/server/routes/audit.test.ts
@@ -24,12 +24,25 @@ describe("auditCsv", () => {
it("writes a header and a row per event", async () => {
const csv = auditCsv([event()], sessions);
const [header, row] = csv.split("\r\n");
- expect(header).toBe('"timestamp","session","command","actor","kind","text"');
+ expect(header).toBe('"timestamp","session","command","actor","kind","text","sealed_by"');
expect(row).toContain('"ana@example.com"');
expect(row).toContain('"npm test"');
expect(row).toContain('"refactor run"');
});
+ /*
+ * An entry sealed after the fact says who sealed it. Their browser was given
+ * the session, the author and the time by the service, so the export has to
+ * show that it is not the first-hand record the other rows are.
+ */
+ it("names who sealed an entry that was not sealed first-hand", async () => {
+ const [, firstHand] = auditCsv([event()], sessions).split("\r\n");
+ expect(firstHand.endsWith(',""')).toBe(true);
+
+ const [, resealed] = auditCsv([event({ sealedBy: "uid-2" })], sessions).split("\r\n");
+ expect(resealed.endsWith(',"uid-2"')).toBe(true);
+ });
+
it("uses CRLF between rows, as a CSV reader expects", async () => {
const csv = auditCsv([event(), event({ id: "aud_2" })], sessions);
expect(csv.split("\r\n")).toHaveLength(3);
diff --git a/app/server/routes/audit.ts b/app/server/routes/audit.ts
index b95203c..730f1dc 100644
--- a/app/server/routes/audit.ts
+++ b/app/server/routes/audit.ts
@@ -1,9 +1,18 @@
import type { AuditEvent, SessionRecord, Store } from "../lib/store";
import { newId, type Membership } from "../lib/orgs";
+import { isAuditEnvelope } from "../lib/audit-seal";
export const MAX_TEXT = 4100;
const KINDS = new Set(["input", "interrupt", "opened", "handoff", "stopped", "deleted"]);
+/**
+ * The kinds a browser writes: what a person typed. They arrive sealed to the
+ * team's audit key and nothing else is accepted for them. The rest are written
+ * by the service itself and hold only what it already stores: session names
+ * and the addresses of the people involved.
+ */
+export const SEALED_KINDS = new Set(["input", "interrupt"]);
+
export interface RecordInput {
sessionId: string;
kind: string;
@@ -12,12 +21,17 @@ export interface RecordInput {
}
/**
- * Records collaboration metadata for a session.
+ * Records an event in a session's audit trail.
*
* The organization is taken from the actor's membership and the session is
* checked to belong to it, so an event cannot be written into somebody else's
- * organization by asking nicely. Terminal input is deliberately excluded:
- * the accounts service must not receive a plaintext copy of an E2EE stream.
+ * organization by asking nicely.
+ *
+ * Typed input is recorded, by the operator's choice, but only as ciphertext
+ * sealed in the browser to a key the team holds: plaintext is refused. The
+ * envelope is stored exactly as it came, and so is its time, because the
+ * browser binds the time into the ciphertext. Trimming the one or replacing
+ * the other would make the entry unreadable to the team it was written for.
*/
export async function recordAudit(
store: Store,
@@ -27,6 +41,15 @@ export async function recordAudit(
if (!KINDS.has(input.kind)) {
return { ok: false, status: 400, error: "unknown audit kind" };
}
+ const sealed = SEALED_KINDS.has(input.kind);
+ if (sealed) {
+ if (!(await isAuditEnvelope(input.text))) {
+ return { ok: false, status: 400, error: "typed input must be sealed to the team's audit key" };
+ }
+ if (typeof input.at !== "number" || !Number.isInteger(input.at)) {
+ return { ok: false, status: 400, error: "sealed input must carry the time it was sealed with" };
+ }
+ }
const session = await store.sessionInOrg(membership.orgId, input.sessionId);
if (!session) {
return { ok: false, status: 404, error: "no such session in this organization" };
@@ -40,7 +63,7 @@ export async function recordAudit(
actorUid: membership.uid,
actorEmail: membership.email,
kind: input.kind as AuditEvent["kind"],
- text: String(input.text ?? "").slice(0, MAX_TEXT),
+ text: sealed ? input.text : String(input.text ?? "").slice(0, MAX_TEXT),
};
await store.putAudit(event);
return { ok: true, event };
@@ -103,7 +126,7 @@ export async function assignSession(
/** Collaboration metadata as CSV, retained for prerelease API compatibility. */
export function auditCsv(events: AuditEvent[], sessions: SessionRecord[]): string {
const byId = new Map(sessions.map((session) => [session.id, session]));
- const header = ["timestamp", "session", "command", "actor", "kind", "text"];
+ const header = ["timestamp", "session", "command", "actor", "kind", "text", "sealed_by"];
const rows = events.map((event) => {
const session = byId.get(event.sessionId);
return [
@@ -113,6 +136,8 @@ export function auditCsv(events: AuditEvent[], sessions: SessionRecord[]): strin
event.actorEmail,
event.kind,
event.text,
+ /* Empty for an entry that arrived sealed from the browser that wrote it. */
+ event.sealedBy ?? "",
];
});
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n");
diff --git a/app/server/routes/organizations.ts b/app/server/routes/organizations.ts
index 153da09..a69f837 100644
--- a/app/server/routes/organizations.ts
+++ b/app/server/routes/organizations.ts
@@ -249,6 +249,12 @@ export async function removeMember(store: Store, membership: Membership, uid: st
return denied(`you cannot remove ${target.role === "owner" ? "the owner" : "another admin"}`);
}
await store.removeMember(membership.orgId, uid);
+ /*
+ * Their copy of the team's audit key goes with them. A copy they already
+ * opened cannot be taken back out of their browser, but the service stops
+ * handing it to them.
+ */
+ await store.deleteTeamKeyShare(membership.orgId, uid);
return ok({ removed: true });
}
diff --git a/app/src/App.tsx b/app/src/App.tsx
index ce3e467..832d8ac 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -16,12 +16,14 @@ import { Audit } from "./routes/Audit";
import { CliAuthorize } from "./routes/CliAuthorize";
import { VaultProvider } from "./vault/VaultProvider";
import { VaultGate } from "./vault/VaultGate";
+import { TeamKeyProvider } from "./vault/TeamKeyProvider";
export default function App() {
return (
+ } />
} />
} />
+
diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts
index 39a0444..c52c401 100644
--- a/app/src/lib/api.ts
+++ b/app/src/lib/api.ts
@@ -319,6 +319,12 @@ export interface AuditEvent {
actorEmail: string;
kind: "input" | "interrupt" | "opened" | "handoff" | "stopped" | "deleted";
text: string;
+ /**
+ * Who sealed this entry afterwards, when the log recorded before encryption
+ * was sealed. Absent on an entry that arrived sealed from the browser that
+ * wrote it, which is the only kind that speaks for its author.
+ */
+ sealedBy?: string;
}
export interface Device {
@@ -386,7 +392,56 @@ export function fetchSessions() {
export const accountsBaseUrl = BASE;
export function postAudit(entries: { session_id: string; kind: string; text: string; at: number }[]) {
- return request<{ written: number }>("/api/audit", {
+ return request<{ written: number; refused?: number }>("/api/audit", {
+ method: "POST",
+ body: JSON.stringify({ entries }),
+ });
+}
+
+/**
+ * The team audit key as the service holds it: a public key, and this
+ * person's own sealed copy of the private half. Nobody else's copy is ever
+ * returned.
+ */
+export interface TeamKeyView {
+ teamKey: { publicKey: string; version: number; createdBy: string; createdAt: number } | null;
+ share: { senderUid: string; sealed: string; version: number } | null;
+ /** Members with a vault and no copy yet, for someone holding the key to seal to. */
+ missing: { uid: string; accountKey: string }[];
+ you: { uid: string; role: Role; orgId: string };
+}
+
+export function fetchTeamKey() {
+ return request("/api/team-key");
+}
+
+export function createTeamKey(publicKey: string, shares: { uid: string; sealed: string }[]) {
+ return request<{ teamKey: TeamKeyView["teamKey"] }>("/api/team-key", {
+ method: "POST",
+ body: JSON.stringify({ public_key: publicKey, shares }),
+ });
+}
+
+/** Adds copies for members who have none. The service never replaces an existing one. */
+export function putTeamKeyShares(version: number, shares: { uid: string; sealed: string }[]) {
+ return request<{ shared: number }>("/api/team-key/shares", {
+ method: "PUT",
+ body: JSON.stringify({ version, shares }),
+ });
+}
+
+/** Removes this person's own copy, so a teammate can seal a fresh one. */
+export function dropMyTeamKeyShare() {
+ return request<{ deleted: boolean }>("/api/team-key/share", { method: "DELETE" });
+}
+
+/** Entries recorded before encryption, for an owner or admin to seal. */
+export function fetchPlaintextAudit(limit = 100) {
+ return request<{ events: AuditEvent[] }>(`/api/audit/plaintext?limit=${limit}`);
+}
+
+export function sealAuditEntries(entries: { id: string; text: string }[]) {
+ return request<{ sealed: number }>("/api/audit/seal", {
method: "POST",
body: JSON.stringify({ entries }),
});
diff --git a/app/src/lib/audit-csv.test.ts b/app/src/lib/audit-csv.test.ts
new file mode 100644
index 0000000..ae9a8c3
--- /dev/null
+++ b/app/src/lib/audit-csv.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { auditCsv } from "./audit-csv";
+
+describe("the audit log as CSV", () => {
+ const sessions = [{ id: "s1", name: "deploy", command: "claude" }];
+
+ it("writes a header and one row per entry, with the session's name", () => {
+ const csv = auditCsv(
+ [{ at: 0, sessionId: "s1", actorEmail: "ana@example.com", kind: "input", text: "npm test" }],
+ sessions,
+ );
+ expect(csv.split("\r\n")).toEqual([
+ '"timestamp","session","command","actor","kind","text","sealed_by"',
+ '"1970-01-01T00:00:00.000Z","s1","deploy","ana@example.com","input","npm test",""',
+ ]);
+ });
+
+ /* An entry sealed by somebody else afterwards does not speak for its author. */
+ it("says who sealed an entry later, when somebody did", () => {
+ const csv = auditCsv(
+ [{ at: 0, sessionId: "s1", actorEmail: "a", kind: "input", text: "ls", sealedBy: "uid-admin" }],
+ sessions,
+ );
+ expect(csv.split("\r\n")[1]).toContain('"uid-admin"');
+ });
+
+ it("survives commas, quotes and newlines in what was typed", () => {
+ const csv = auditCsv(
+ [{ at: 0, sessionId: "s1", actorEmail: "a", kind: "input", text: 'echo "a, b"\nls' }],
+ sessions,
+ );
+ expect(csv).toContain('"echo ""a, b""\nls"');
+ });
+
+ it("stops a spreadsheet reading a typed line as a formula", () => {
+ const csv = auditCsv(
+ [{ at: 0, sessionId: "s1", actorEmail: "a", kind: "input", text: "=HYPERLINK(\"x\")" }],
+ sessions,
+ );
+ expect(csv).toContain(`"'=HYPERLINK(""x"")"`);
+ });
+
+ it("names a removed session by nothing rather than failing", () => {
+ const csv = auditCsv([{ at: 0, sessionId: "gone", actorEmail: "a", kind: "deleted", text: "x" }], sessions);
+ expect(csv.split("\r\n")[1]).toContain('"gone",""');
+ });
+});
diff --git a/app/src/lib/audit-csv.ts b/app/src/lib/audit-csv.ts
new file mode 100644
index 0000000..aeaf6e9
--- /dev/null
+++ b/app/src/lib/audit-csv.ts
@@ -0,0 +1,51 @@
+/**
+ * The audit log as CSV, built in the browser.
+ *
+ * The service used to build this, and it can no longer: what people typed
+ * reaches it sealed to the team's key. So the browser, which can open it,
+ * writes the file instead.
+ */
+
+export interface CsvEvent {
+ at: number;
+ sessionId: string;
+ actorEmail: string;
+ kind: string;
+ text: string;
+ /** Who sealed the entry later, if anyone; empty for a first-hand entry. */
+ sealedBy?: string;
+}
+
+export interface CsvSession {
+ id: string;
+ name?: string;
+ command: string;
+}
+
+export function auditCsv(events: CsvEvent[], sessions: CsvSession[]): string {
+ const byId = new Map(sessions.map((session) => [session.id, session]));
+ const header = ["timestamp", "session", "command", "actor", "kind", "text", "sealed_by"];
+ const rows = events.map((event) => {
+ const session = byId.get(event.sessionId);
+ return [
+ new Date(event.at).toISOString(),
+ event.sessionId,
+ session?.name || session?.command || "",
+ event.actorEmail,
+ event.kind,
+ event.text,
+ event.sealedBy ?? "",
+ ];
+ });
+ return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n");
+}
+
+/*
+ * RFC 4180 quoting. A recorded command can contain commas, quotes and
+ * newlines, and a leading =, +, - or @ is prefixed because spreadsheets read
+ * those as formulas.
+ */
+function csvCell(value: string): string {
+ const guarded = /^[=+@\t\r-]/.test(value) ? `'${value}` : value;
+ return `"${guarded.replace(/"/g, '""')}"`;
+}
diff --git a/app/src/lib/known-keys.ts b/app/src/lib/known-keys.ts
index cd4f446f979da0dc460c78734cb6ee5bbfa4232f..2eadd2adc5223c90a33b68dc36a490c180047423 100644
GIT binary patch
delta 367
zcmZXQu}T9$5QYm4$RUVrs-G(qL}DL;u@zgjlx%J%xg&S8ady`;DC8mTMJ#+1dmq79
z(AjORhTWh4oByBBjlJUDA8nwHOH33J5Ft{3VPi-30Ot0a>sNZzN6vy;Oz80Uj=Uklpmt-@N+mm!hD|p
IhsV+PA62w|j{pDw
delta 11
Scmcc2zlCQ*9P8x2Y}WuCs0AVb
diff --git a/app/src/lib/team-crypto.test.ts b/app/src/lib/team-crypto.test.ts
new file mode 100644
index 0000000..a761326
--- /dev/null
+++ b/app/src/lib/team-crypto.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from "vitest";
+import { createVault } from "./vault-crypto";
+import {
+ createTeamKey,
+ isAuditEnvelope,
+ openAuditText,
+ openTeamKeyShare,
+ parseAuditEnvelope,
+ sealAuditText,
+ sealTeamKeyShare,
+ teamPrivateKey,
+ type AuditContext,
+} from "./team-crypto";
+
+/*
+ * These tests are the audit log's claim: that what a teammate typed can be
+ * read by the team and by nothing the service holds, including a team key
+ * the service made up itself. Each names the property it keeps.
+ */
+
+async function member(uid: string) {
+ const vault = await createVault(uid);
+ return { uid, publicKey: vault.bundle.publicKey, privateKey: vault.opened.privateKey };
+}
+
+const team = { orgId: "org_1", version: 1 };
+
+describe("sharing the team key", () => {
+ it("reaches a teammate intact, and yields the key the team published", async () => {
+ const ana = await member("ana");
+ const bo = await member("bo");
+ const made = await createTeamKey();
+ const sealed = await sealTeamKeyShare(ana.privateKey, bo.publicKey, { ...team, senderUid: "ana", recipientUid: "bo" }, made.pkcs8);
+
+ const opened = await openTeamKeyShare(bo.privateKey, ana.publicKey, { ...team, senderUid: "ana", recipientUid: "bo" }, sealed);
+ expect(opened).toEqual(made.pkcs8);
+ const key = await teamPrivateKey(opened!, made.publicKey);
+ expect(key).not.toBeNull();
+ expect(key!.extractable).toBe(false);
+ });
+
+ it("works for the member who made it, sealed to themselves", async () => {
+ const ana = await member("ana");
+ const made = await createTeamKey();
+ const context = { ...team, senderUid: "ana", recipientUid: "ana" };
+ const sealed = await sealTeamKeyShare(ana.privateKey, ana.publicKey, context, made.pkcs8);
+ expect(await openTeamKeyShare(ana.privateKey, ana.publicKey, context, sealed)).toEqual(made.pkcs8);
+ });
+
+ /*
+ * The property the design exists for. Anyone can seal to a public key, so a
+ * service could hand a member a team key of its own making. It cannot make
+ * one that opens as coming from a teammate, because that takes the
+ * teammate's vault.
+ */
+ it("refuses a share that did not come from the teammate it names", async () => {
+ const ana = await member("ana");
+ const bo = await member("bo");
+ const impostor = await member("service");
+ const forged = await createTeamKey();
+ const sealed = await sealTeamKeyShare(impostor.privateKey, bo.publicKey, { ...team, senderUid: "ana", recipientUid: "bo" }, forged.pkcs8);
+ expect(await openTeamKeyShare(bo.privateKey, ana.publicKey, { ...team, senderUid: "ana", recipientUid: "bo" }, sealed)).toBeNull();
+ });
+
+ it("refuses a share replayed to a different person, team or version", async () => {
+ const ana = await member("ana");
+ const bo = await member("bo");
+ const made = await createTeamKey();
+ const context = { ...team, senderUid: "ana", recipientUid: "bo" };
+ const sealed = await sealTeamKeyShare(ana.privateKey, bo.publicKey, context, made.pkcs8);
+ expect(await openTeamKeyShare(bo.privateKey, ana.publicKey, { ...context, recipientUid: "cy" }, sealed)).toBeNull();
+ expect(await openTeamKeyShare(bo.privateKey, ana.publicKey, { ...context, orgId: "org_2" }, sealed)).toBeNull();
+ expect(await openTeamKeyShare(bo.privateKey, ana.publicKey, { ...context, version: 2 }, sealed)).toBeNull();
+ });
+
+ it("will not accept a private key that is not the team's published one", async () => {
+ const made = await createTeamKey();
+ const other = await createTeamKey();
+ expect(await teamPrivateKey(made.pkcs8, other.publicKey)).toBeNull();
+ });
+
+ it("does not open with the wrong vault", async () => {
+ const ana = await member("ana");
+ const bo = await member("bo");
+ const cy = await member("cy");
+ const made = await createTeamKey();
+ const context = { ...team, senderUid: "ana", recipientUid: "bo" };
+ const sealed = await sealTeamKeyShare(ana.privateKey, bo.publicKey, context, made.pkcs8);
+ expect(await openTeamKeyShare(cy.privateKey, ana.publicKey, context, sealed)).toBeNull();
+ });
+});
+
+describe("an audit entry", () => {
+ const where: AuditContext = { orgId: "org_1", sessionId: "sess_1", kind: "input", at: 1788000000123, actorUid: "ana" };
+
+ async function teamKey() {
+ const made = await createTeamKey();
+ return { publicKey: made.publicKey, privateKey: (await teamPrivateKey(made.pkcs8, made.publicKey))! };
+ }
+
+ it("opens for the team and does not carry the text in the clear", async () => {
+ const key = await teamKey();
+ const sealed = await sealAuditText(key.publicKey, 1, where, "claude -p 'rotate the prod keys'");
+ expect(isAuditEnvelope(sealed)).toBe(true);
+ expect(sealed).not.toContain("rotate");
+ expect(await openAuditText(key.privateKey, where, sealed)).toBe("claude -p 'rotate the prod keys'");
+ });
+
+ it("does not open for a key that is not the team's", async () => {
+ const key = await teamKey();
+ const other = await teamKey();
+ const sealed = await sealAuditText(key.publicKey, 1, where, "ls");
+ expect(await openAuditText(other.privateKey, where, sealed)).toBeNull();
+ });
+
+ /* The service keeps the metadata; it must not be able to rearrange it. */
+ it("refuses to be moved to another session, person, kind, time or team", async () => {
+ const key = await teamKey();
+ const sealed = await sealAuditText(key.publicKey, 1, where, "ls");
+ for (const moved of [
+ { ...where, sessionId: "sess_2" },
+ { ...where, actorUid: "bo" },
+ { ...where, kind: "interrupt" },
+ { ...where, at: where.at + 1 },
+ { ...where, orgId: "org_2" },
+ ]) {
+ expect(await openAuditText(key.privateKey, moved, sealed)).toBeNull();
+ }
+ });
+
+ it("refuses an entry whose key version was changed", async () => {
+ const key = await teamKey();
+ const sealed = await sealAuditText(key.publicKey, 1, where, "ls");
+ expect(await openAuditText(key.privateKey, where, sealed.replace(/^a1\.1\./, "a1.2."))).toBeNull();
+ });
+
+ it("refuses ciphertext that has been altered", async () => {
+ const key = await teamKey();
+ const sealed = await sealAuditText(key.publicKey, 1, where, "ls -la");
+ const body = sealed.slice(sealed.lastIndexOf(".") + 1);
+ const flipped = (body[20] === "A" ? "B" : "A");
+ const altered = sealed.slice(0, sealed.length - body.length) + body.slice(0, 20) + flipped + body.slice(21);
+ expect(await openAuditText(key.privateKey, where, altered)).toBeNull();
+ });
+
+ it("reads plain text, and junk, as not an envelope", () => {
+ expect(isAuditEnvelope("npm test")).toBe(false);
+ expect(isAuditEnvelope("a1.1.short.body")).toBe(false);
+ expect(parseAuditEnvelope("a1.0." + "A".repeat(87) + ".AAAA")).toBeNull();
+ });
+
+ it("never seals the same text to the same bytes twice", async () => {
+ const key = await teamKey();
+ const first = await sealAuditText(key.publicKey, 1, where, "same");
+ const second = await sealAuditText(key.publicKey, 1, where, "same");
+ expect(first).not.toBe(second);
+ });
+});
diff --git a/app/src/lib/team-crypto.ts b/app/src/lib/team-crypto.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b809d048fbbb5e9386aecbd011539a58efe2fc2b
GIT binary patch
literal 9356
zcmbtaYj@i=676UI3RIhOO2sm5w`n(Z;_Pu8cbk(<;u9zB?z(OYNsz^wB6Ud0it6gW
z@4W+n1ocSM?w7_Q!NI(6=gy$}_wVnh`|9o3sLJSx(z9VwsT;HSqEv%oF|G3JLQltw
z7^}GO$FZ(dnNLif;fux8lq$)pTxnIBvP|+!jf#AtD#+^>`MflRy3YL~KIJZ7loVyR
zO7rVjo$0|?nXD=nDk&jJr)H?erZ7E74~6v<_TtviP)&7GsHrI^EHAEgmi(qGsBk1-
zw9UaDw+7-TX3~dF<2)Tg^Qu(SBDvL-QDdEsl+K0}Mm?eVZ89)wNbR${>cQ$p?m%a%
zFnTB=U;@9ys(wCPD3g{(VS#rUecRdv9}dQl%745<&SqGL&0&`J=B=KkRgC!PikfA{
zK<`Oja0mRS8bc9&n`F?T{%Wera-J8%Se;}Gx{La98QrHTXgces$$)NzO9OM0Y>>`|
zW+>)Tp!EXwsnlF&72*OBVmd#A>6A9eXX#K)^o>!oDfAHEGer4FJe1Ef@sOy1{DKG*
zgac~UpHN&mrftXsY*CmIt#$sHC)L>Ln+;8YB&BF^lC=eq;{Idgo}TLkrNRCq{3l)D
zMG$^^Ow6)h(?er@%TVRR*#LGXh&jWmeI`?w9t_MBX#>}Yk|I}9-da6jF3ra|QlQof
zlGIIx!@!K#-`N@DSy`!z?@!*Gy+40*_TBS;t7Fxv4&%ihqN!woE0gRQ8rtE=tE!+oF<<(_U(x&
zyP+xX<7dY
zK+l#F5OoSY#{q5!zpWe`QJ;OR)TnGdb$Jy;Oi+*!+q4%>RdQ>dqRd9Cuwm;DaGB0f
z%U9UF`ky^d&U6
zDSSkK_;0K+^7*Bt#}&Wcn1?KxKLT2#8=rg%Z~@2y!fid(D0zCg^=*)HHfzJI%km6-
z>DcGnwW;2qeCCrMh$m%4a1C4()EXWmj{t*YiiDtwkhJoNSiLFF-1%sdtZ`v-i{MZ(
z7&Zj81(NIZnXdE^4Q1lNJIFron67iD*T-bHCe2Z2*0c!>P`j$pJ0`CGOTb6$@)F|G
zRFPBVVEs)apr-=Svd|@JSb@A$ya$r6AQ1G;Rw7SkT0_gH#<&Sr3;DyKOA&)}+9<-^
zM03^$SrT|fJw9$(xLzN;aj3aDcor&l3%NfvEgV$ZgB%>?c6M0xO0Z!Q9khMFEoUSV
zm1K_lGZYd=&mL``REU&kLzaF^(*Y`=)%lCli@!beC}@)sB#M*>Fs-IJ&aM2?(<$l#
zy9W~JISI0GqLmb|_zJ9hJ&L9`gYw~DJJY|;ZxGy#XHGa6Hn8sPU}eWzyJ>X*(xMH(
zP5y1wm#Ubtyf~a@*#J=2=TWEzc8Ncz-+x!1^RLAgZUHKPc1j<%xP`cI?HGUawIr%I
z`10Ti)*gH~ctAjsM{;W0$t^kn;18P*x2#%V)mLB1vJFRAupbbBQuipYWAnm
z(rUY&iJc>e8xn4_5*O@;yb8c?lc7`Hpjn9*rg)dMv?~noFTn($^;|r|uIr>M^Fab0
zFjPZeH~U4~exXO~=QA{E(2m_a)ko>YUx>c9*|u?g=uTPa3Z^OY4DCG0k-0PoiBlM~!Q6sFrRhxO9xJmG1l;-q
z;Xo)HY)Thi2UPV9MY)jyXARAuB&V>W6x#!NW^vY~P<*7yto8iTr5P*mKk+eii+YN@2ubgRK%_e7qgBySU=O+el+@R7X_i
zau#3g7?AOH7*sEc)vKAr(qu&XIpBg&?_i<706=k;E#*k&%D@E~O#95@%!bb#?xD&!
zOhs`nkRw(uIzj*r{zfB+A~5_Io{*TYCQR-AwVbi4HBs?M!@SsMw^@SZp~1fKyiL!;Y!~5S%)yF
z8t81Mg*^@h@yZ~!EIw4$je$VK*0YeRFLCHA@Gh(Vid{>`&6r)!Wpdac6VlP663hkX
z;H2Bltm`+P-!oNRdSWrf1;r5~gheA4Lud>+?QW=XXc+fErZ|ey{S?C>%UKl7w>K!1
z#;cPAWtwA3ChQb*qn_$xrn*9L(DGu^`mq${DLk(M`VV@CobyuQGz>^t?v{hX^WudFTUTkP8?)VU_DPQ08ZrS~0hW1&0|{b2
z;fLZ}*Q@{<l~iNqKchI_j5K$z+wi3*&9E_b@0uUlqJJ{yLq%bf|90
ze9wbCx=S(PId0|r3dR>`DK`ebtB7?4obxMuK$k612H5{w`pY`ESaddgO_wCgs?!T_?4tful
zT|Nr$#O@ycef{BX7dK_*gBgf^;h4xu|3|3kD?!>w-drAHE>^2-AANZR^c1D-@2{?)
zHX(@=4F}vcxV4v(@}n;5=Xq*SAIOUwE85~$Tc+!MSZF+>|AdetmSl@WZ3kTVKQ3%|
z$#Bqr1sA?6Ktm2BMhMUZ#Km@9Uf_|EMW?og@<`c{z7O^qmbA!<4Y%0NYpxAW@bV;p
z!k_KxHKNk+9xIfF`%~YvXMr8)pqR_xwXR8N0m%wGc8dCoj9PM=C_917lua<=Ui(tf
vCf;oM>FQ^o5I8JRXI72&9%7VxhdZ`PryvoS;HqUehHAXLZf;dRiA((t<;^@U
literal 0
HcmV?d00001
diff --git a/app/src/lib/team-trust.test.ts b/app/src/lib/team-trust.test.ts
new file mode 100644
index 0000000..1b5d6cf
--- /dev/null
+++ b/app/src/lib/team-trust.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { teamKeyRefusal, teamKeyVerdict } from "./team-trust";
+
+/*
+ * The service decides what a browser is told about the team's key, and a
+ * browser that believes "there is none" will make one and seal it to the
+ * members the service lists. These are the checks on that.
+ */
+describe("whether to believe the team key on offer", () => {
+ it("makes one when there is none and none was ever used here", () => {
+ expect(teamKeyVerdict(null, null)).toBe("create");
+ });
+
+ it("uses the key it already knows", () => {
+ expect(teamKeyVerdict("pk", "pk")).toBe("use");
+ });
+
+ it("uses a key seen for the first time in this browser", () => {
+ expect(teamKeyVerdict(null, "pk")).toBe("use");
+ });
+
+ /* The re-keying attack: "your team has no key" said to a browser that knows better. */
+ it("refuses to make a second key for a team that has one", () => {
+ expect(teamKeyVerdict("pk", null)).toBe("refuse-missing");
+ });
+
+ it("refuses a key that is not the one it used before", () => {
+ expect(teamKeyVerdict("pk", "other")).toBe("refuse-changed");
+ });
+
+ it("says why, in words a person can act on", () => {
+ expect(teamKeyRefusal("refuse-missing")).toMatch(/has none/);
+ expect(teamKeyRefusal("refuse-changed")).toMatch(/not the one/);
+ });
+});
diff --git a/app/src/lib/team-trust.ts b/app/src/lib/team-trust.ts
new file mode 100644
index 0000000..b02c8b6
--- /dev/null
+++ b/app/src/lib/team-trust.ts
@@ -0,0 +1,37 @@
+/**
+ * Whether to believe what the service says about the team's audit key.
+ *
+ * A browser makes the team's key when the service reports there is none. That
+ * makes "there is none" worth checking: a service that said it to a browser
+ * which had already used a team key could have that browser generate a fresh
+ * key and seal it to whatever public keys the service listed as the team's
+ * members, including one of its own. So each browser remembers the key it has
+ * used for a team, and refuses to make or use a different one.
+ *
+ * Legitimate replacement is not a thing yet. When it is, it will arrive as a
+ * new version with a way to say who replaced it, not as a key that quietly
+ * differs from the one this browser knows.
+ */
+
+export type TeamKeyVerdict =
+ /** No key here and none seen before: this browser makes one. */
+ | "create"
+ /** The key on offer is the one this browser knows. */
+ | "use"
+ /** A key was used here before and the service now reports none. */
+ | "refuse-missing"
+ /** The key on offer is not the one this browser used before. */
+ | "refuse-changed";
+
+export function teamKeyVerdict(seen: string | null, offered: string | null): TeamKeyVerdict {
+ if (!offered) return seen ? "refuse-missing" : "create";
+ if (!seen) return "use";
+ return seen === offered ? "use" : "refuse-changed";
+}
+
+/** What to tell someone when the key on offer is not the one this browser knows. */
+export function teamKeyRefusal(verdict: "refuse-missing" | "refuse-changed"): string {
+ return verdict === "refuse-missing"
+ ? "Your team has an audit key that this browser has used before, and shell.online is reporting that it has none. Nothing new will be sealed until that is sorted out."
+ : "The audit key shell.online reports for your team is not the one this browser has used before. Nothing will be sealed to it. Check with your team before going on.";
+}
diff --git a/app/src/routes/Account.tsx b/app/src/routes/Account.tsx
index e182888..ec6706e 100644
--- a/app/src/routes/Account.tsx
+++ b/app/src/routes/Account.tsx
@@ -10,13 +10,7 @@ import { usePageTitle } from "../lib/page-title";
import { authErrorMessage } from "../lib/auth-errors";
import { useVault } from "../vault/VaultProvider";
import { VaultSetup } from "../vault/VaultGate";
-
-const VAULT_STATE: Record = {
- loading: "Checking",
- setup: "Not set up yet. It is set up the first time you open Sessions.",
- locked: "Locked in this browser. Open Sessions to unlock it with your recovery key.",
- error: "Could not be reached",
-};
+import { VaultPanel } from "../vault/VaultPanel";
export function Account() {
usePageTitle("Account");
@@ -80,27 +74,7 @@ export function Account() {
User id
{user.uid}
- {/*
- The fingerprint is what `shell login` prints when a machine first
- trusts the vault, so the two can be compared by eye.
- */}
-
+ {/* The vault has its own section below, which says what it holds. */}
Your data
@@ -110,6 +84,13 @@ export function Account() {
+ {/*
+ What the vault is and what it holds, opened in this browser. The key
+ fingerprint in it is what `shell login` prints when a machine first
+ trusts the vault, so the two can be compared by eye.
+ */}
+
+
{resetting && (
= {
deleted: "Removed",
};
+/**
+ * An entry as shown. `sealed` is whether it arrived encrypted at all: one
+ * recorded before the log was encrypted, and not yet sealed, is still in the
+ * clear on the service, and saying so is the point of showing it.
+ */
+type ShownEvent = AuditEvent & { readable: boolean; sealed: boolean };
+
+/*
+ * Everything the service can filter on. The text is not among them: the
+ * service holds it sealed.
+ */
+function metadataRequest(filters: Filters): AuditPageRequest {
+ return {
+ session: filters.session || undefined,
+ actor: filters.actor || undefined,
+ kind: filters.kind || undefined,
+ sinceAt: filters.since ? Date.now() - filters.since : undefined,
+ };
+}
+
/** A bar chart of when things happened. Inline SVG; no charting library. */
function ActivityChart({ events }: { events: AuditEvent[] }) {
const buckets = useMemo(() => activity(events, 32), [events]);
@@ -136,14 +172,17 @@ function RankChart({
export function Audit() {
usePageTitle("Audit log");
+ const team = useTeamKey();
const [params, setParams] = useSearchParams();
- const [events, setEvents] = useState(null);
+ const [events, setEvents] = useState(null);
const [sessions, setSessions] = useState([]);
const [members, setMembers] = useState([]);
const [error, setError] = useState("");
const [exporting, setExporting] = useState(false);
const [reload, setReload] = useState(0);
const [total, setTotal] = useState(0);
+ /* For a text search: how many entries were searched, and whether there were more. */
+ const [searched, setSearched] = useState<{ count: number; more: boolean } | null>(null);
const [page, setPage] = useState(() => Math.max(1, Number(params.get("page")) || 1));
const [filters, setFilters] = useState({
@@ -157,6 +196,20 @@ export function Audit() {
["actor", "kind", "session", "since"].some((name) => params.has(name)),
);
+ /* Opens what can be opened here; the rest is marked sealed, never shown as blank. */
+ const { openAudit } = team;
+ const readable = useCallback(
+ async (list: AuditEvent[]): Promise =>
+ Promise.all(
+ list.map(async (event) => {
+ if (!isAuditEnvelope(event.text)) return { ...event, readable: true, sealed: false };
+ const opened = await openAudit(event);
+ return { ...event, text: opened ?? "", readable: opened !== null, sealed: true };
+ }),
+ ),
+ [openAudit],
+ );
+
const loadContext = useCallback(async () => {
try {
const list = await fetchSessions();
@@ -173,34 +226,52 @@ export function Audit() {
useEffect(() => {
let current = true;
+ const needle = filters.query.trim();
const timer = window.setTimeout(async () => {
try {
- const trail = await fetchOrgAudit({
- page,
- limit: PAGE_SIZE,
- session: filters.session || undefined,
- actor: filters.actor || undefined,
- kind: filters.kind || undefined,
- query: filters.query || undefined,
- sinceAt: filters.since ? Date.now() - filters.since : undefined,
- });
+ if (!needle) {
+ const trail = await fetchOrgAudit({ ...metadataRequest(filters), page, limit: PAGE_SIZE });
+ const shown = await readable(trail.events);
+ if (!current) return;
+ setEvents(shown);
+ setTotal(trail.total);
+ setSearched(null);
+ setError("");
+ if (trail.events.length === 0 && trail.total > 0 && page > 1) setPage(page - 1);
+ return;
+ }
+
+ /*
+ * A text search runs here: fetch the newest entries that match the
+ * other filters, open them, and search what they say.
+ */
+ const fetched: AuditEvent[] = [];
+ let more = false;
+ for (let next = 1; next <= SEARCH_PAGES; next += 1) {
+ const trail = await fetchOrgAudit({ ...metadataRequest(filters), page: next, limit: FETCH_SIZE });
+ fetched.push(...trail.events);
+ more = fetched.length < trail.total;
+ if (!more || trail.events.length < FETCH_SIZE) break;
+ }
+ const matches = applyFilters(await readable(fetched), { ...EMPTY_FILTERS, query: needle }) as ShownEvent[];
if (!current) return;
- setEvents(trail.events);
- setTotal(trail.total);
+ setEvents(matches.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
+ setTotal(matches.length);
+ setSearched({ count: fetched.length, more });
setError("");
- if (trail.events.length === 0 && trail.total > 0 && page > 1) setPage(page - 1);
} catch (caught) {
if (!current) return;
setEvents([]);
setTotal(0);
setError(caught instanceof Error ? caught.message : "Could not load the audit log.");
}
- }, filters.query ? 180 : 0);
+ }, needle ? 180 : 0);
return () => {
current = false;
window.clearTimeout(timer);
};
- }, [filters, page, reload]);
+ /* team.status: entries opened once the team key arrives. */
+ }, [filters, page, reload, readable, team.status]);
const shown = useMemo(() => events ?? [], [events]);
const summary = useMemo(() => summarise(shown), [shown]);
@@ -251,11 +322,30 @@ export function Audit() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
+ /*
+ * Built here, from entries opened here. The service cannot build it any
+ * more: it holds what was typed sealed to the team's key.
+ */
async function handleExport() {
setExporting(true);
try {
- const blob = await downloadAuditCsv(filters.session || undefined);
- const url = URL.createObjectURL(blob);
+ const fetched: AuditEvent[] = [];
+ for (let next = 1; next <= EXPORT_PAGES; next += 1) {
+ const trail = await fetchOrgAudit({ ...metadataRequest(filters), page: next, limit: FETCH_SIZE });
+ fetched.push(...trail.events);
+ if (fetched.length >= trail.total || trail.events.length < FETCH_SIZE) break;
+ }
+ let rows = await readable(fetched);
+ if (filters.query.trim()) rows = applyFilters(rows, { ...EMPTY_FILTERS, query: filters.query }) as ShownEvent[];
+ const csv = auditCsv(
+ rows.map((event) => ({
+ ...event,
+ text: event.readable ? event.text : "[sealed: this browser does not hold the team's audit key]",
+ sealedBy: event.sealedBy,
+ })),
+ sessions,
+ );
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "shell-online-audit.csv";
@@ -285,6 +375,23 @@ export function Audit() {
terminal, prompts to an agent, and who entered them.
+ {/*
+ What protects this page, said where it is read. The team's audit key
+ opens it; the service stores it sealed.
+ */}
+
+
+
+ {team.status === "ready"
+ ? "End-to-end encrypted for your team. shell.online stores what was typed sealed to your team's key and cannot read it; this page opens it in your browser, where search and export run too."
+ : team.status === "waiting"
+ ? "This browser does not have your team's audit key yet. A teammate's browser seals it to you the next time they open shell.online; until then, what was typed shows as sealed."
+ : team.status === "error"
+ ? team.error
+ : "Opening your team's audit key."}
+
+
+
{error && (
@@ -378,6 +485,13 @@ export function Audit() {
+ {searched && (
+
+ Searched the newest {searched.count} matching entr{searched.count === 1 ? "y" : "ies"} in your browser
+ {searched.more ? ". Narrow the filters or the time range to search further back." : "."}
+
- {group.events.map((event) => (
+ {(group.events as ShownEvent[]).map((event) => (
{new Date(event.at).toLocaleTimeString(undefined, {
@@ -562,11 +676,33 @@ function Trace({
})}
{EVENT_LABEL[event.kind]}
-
- {event.kind === "interrupt"
- ? `^C${event.text ? ` while typing ${event.text}` : ""}`
- : event.text || "—"}
-
+ {event.readable ? (
+
+ {event.kind === "interrupt"
+ ? `^C${event.text ? ` while typing ${event.text}` : ""}`
+ : event.text || "—"}
+
+ ) : (
+
+ Sealed. This browser does not hold the team's key yet.
+
+ )}
+ {/*
+ Where an entry came from, when that is not simply "the
+ person who typed it". One sealed afterwards was bound to
+ this person and time by whoever sealed it, not by them, and
+ one still in the clear is readable by the service.
+ */}
+ {event.sealedBy && (
+
+ sealed later by {displayName(memberOf(members, event.sealedBy))}
+
+ )}
+ {!event.sealed && (event.kind === "input" || event.kind === "interrupt") && (
+
+ recorded before encryption, still in the clear
+
+ )}
))}
diff --git a/app/src/routes/CliAuthorize.tsx b/app/src/routes/CliAuthorize.tsx
index 2faf20f..b5d2c69 100644
--- a/app/src/routes/CliAuthorize.tsx
+++ b/app/src/routes/CliAuthorize.tsx
@@ -165,7 +165,11 @@ function Consent({
Recorded
-
What anyone types into a session from a browser
+
+ What anyone types into a session from a browser, kept in
+ your team’s activity trail and sealed with your
+ team’s key
+
Does not grant
@@ -205,7 +209,9 @@ function Consent({
Terminal output stays end-to-end encrypted, and passwords reach
this account sealed, so shell.online cannot open them. The link,
command line, machine name and timings are shared with your
- team, and what you type from a browser is recorded. Starting
+ team, and what anyone types from a browser is kept in your
+ team’s activity trail, sealed with your team’s key.
+ Starting
sessions from a browser is a separate choice, made in your
terminal. The{" "}
diff --git a/app/src/routes/SignUp.tsx b/app/src/routes/SignUp.tsx
index 8296972..c836736 100644
--- a/app/src/routes/SignUp.tsx
+++ b/app/src/routes/SignUp.tsx
@@ -148,11 +148,14 @@ export function SignUp() {
}
legal={
<>
- Terminal content stays end-to-end encrypted either way. The{" "}
+ Terminal output stays end-to-end encrypted. What you type into a
+ session from the browser is recorded in your team’s audit log,
+ end-to-end encrypted so your team can read it and shell.online
+ cannot. Session details such as the command line are visible to
+ your team. The{" "}
security model covers the
- cryptography; the terms describe the limited account and session
- metadata visible to your team. Terminal input is not copied
- into the accounts service.
+ cryptography, and the{" "}
+ terms list what is kept.
>
}
>
diff --git a/app/src/routes/Terms.tsx b/app/src/routes/Terms.tsx
index e9974aa..6819f4b 100644
--- a/app/src/routes/Terms.tsx
+++ b/app/src/routes/Terms.tsx
@@ -334,7 +334,7 @@ export function Terms() {
Running a session sends a record of it to the accounts service. What
- that record contains:
+ that record contains, and what else the service keeps:
@@ -342,9 +342,37 @@ export function Terms() {
The share URL, the command line, the host name of the machine,
the session name, timings, the session flags, and the exit code.
- The command line is stored as written, so treat a command line
- the way you would treat anything else your team can
- read.
+ Every member of your team sees these records and is notified
+ when a session starts. The command line is stored as written,
+ including any prompt you put on it, so treat a command line the
+ way you would treat anything else your team, and we, can read.
+
+
+
+
Session vault
+
+ Your vault’s public key, your vault’s private key
+ encrypted under a vault key, and that vault key wrapped under
+ your recovery key; and the session passwords saved to your vault
+ or shared with you, each sealed to it. We never receive the
+ recovery key or anything that opens these records, and we cannot
+ recover your vault if you lose your recovery key.
+
+
+
+
Team audit key
+
+ Your team’s audit public key, and one copy of its private
+ key for each member, sealed to that member’s vault by a
+ teammate. We cannot open these copies.
+
+
+
+
Audit records
+
+ For each entry: who made it, in which session, what kind of
+ entry it is, and when. What was typed is stored only as
+ ciphertext; see .
@@ -377,14 +405,34 @@ export function Terms() {
- What you type into a session is recorded in plaintext.{" "}
+
+ What you type into a session from the browser is recorded, and
+ every member of your team can read it.
+ {" "}
Commands, prompts, and anything else entered at the keyboard are
- sent to the accounts service as you commit them, stored without
- encryption, and can be read and exported by every member of your
- team. That includes anything typed by mistake, such as a
- password or a key pasted into the wrong window.
+ recorded in your team’s audit log as you commit them, and
+ can be read and exported by every member of your team. That
+ includes anything typed by mistake, such as a password or a key
+ pasted into the wrong window.
+
+ The audit log is end-to-end encrypted. Your browser encrypts each
+ entry to your team’s audit key before sending it, and only
+ members of your team hold the key that opens it. We store
+ ciphertext and cannot read what was typed. We do see who made each
+ entry, in which session, what kind of entry it is, and when; and we
+ write some entries ourselves when a session is stopped, removed, or
+ handed to someone else, which name the session and the people
+ involved.
+
+
+ Entries recorded before the audit log was encrypted are encrypted in
+ place by the browser of an owner or admin of your team when they
+ next use shell.online. Until then those entries are stored readable,
+ and database backups taken before then keep them until the backups
+ expire.
+
The recording is of what is entered, not of what the session prints
back. Terminal output stays inside the end-to-end encrypted stream
@@ -394,7 +442,7 @@ export function Terms() {
We also retain the collaboration information people deliberately
create outside the terminal: session ownership and assignment,
handoffs, comments, mentions, and notifications. Members of the
- team can see those records.
+ team can see those records, and so can we.
If this is not what you want for a particular session, do not type
@@ -406,16 +454,23 @@ export function Terms() {
-
Three things never reach us.
+
Four things never reach us in readable form.
Terminal output
Terminal traffic is end-to-end encrypted in the browser and on
the machine, and the relay handles ciphertext only, so what
- your program prints is not readable by us. What you type is a
- separate matter: it is recorded, in plaintext, as described
- above.
+ your program prints is not readable by us.
+
+
+
+
What you type
+
+ Input typed into a session from the browser reaches us only as
+ ciphertext for your team’s audit key, which we do not
+ hold. Entries recorded before the audit log was encrypted are
+ the exception described in .
@@ -428,14 +483,16 @@ export function Terms() {
The session password
- It is not sent to us in the clear and we cannot recover it for
+ It reaches us only sealed to a machine or to someone’s
+ session vault, never in the clear, and we cannot recover it for
you.
Because we hold no key and no password, we cannot decrypt a session
- for you, restore one, or recover anything a session displayed.
+ for you, restore one, recover anything a session displayed, or read
+ your team’s audit log.
diff --git a/app/src/styles/vault.css b/app/src/styles/vault.css
index 8a10aa0..36145fb 100644
--- a/app/src/styles/vault.css
+++ b/app/src/styles/vault.css
@@ -132,6 +132,265 @@
letter-spacing: 0.04em;
}
+/* What protects the audit log, said on the page that reads it. */
+.audit-sealed-note {
+ display: flex;
+ gap: 8px;
+ margin: 0 0 16px;
+ padding: 10px 12px;
+ border-radius: var(--radius-control);
+ background: var(--success-wash);
+ font-size: 0.85rem;
+ line-height: 1.5;
+ color: var(--ink);
+}
+
+.audit-sealed-note[data-state="waiting"],
+.audit-sealed-note[data-state="error"] {
+ background: var(--warn-wash, var(--danger-wash));
+}
+
+.audit-sealed-note svg {
+ flex: none;
+ margin-top: 3px;
+ color: var(--success);
+}
+
+.audit-sealed-note[data-state="waiting"] svg,
+.audit-sealed-note[data-state="error"] svg {
+ color: var(--danger);
+}
+
+.audit-search-note {
+ margin: 0 0 12px;
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+
+/* Where an entry came from, when that is not the person who typed it. */
+.trace-provenance {
+ font-size: 0.74rem;
+ letter-spacing: 0.02em;
+ color: var(--muted);
+}
+
+.trace-provenance[data-clear="true"] {
+ color: var(--danger);
+}
+
+/* An entry this browser cannot open: said plainly, never shown as blank. */
+.trace-sealed {
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ font-size: 0.85rem;
+ font-style: italic;
+ color: var(--muted);
+}
+
+/* The vault on the Account page. */
+.vault-panel {
+ display: grid;
+ gap: 14px;
+ margin-top: 28px;
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-panel);
+ background: var(--white);
+}
+
+.vault-panel-head {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.vault-panel-head h2 {
+ margin: 0 0 4px;
+ font-size: 1.05rem;
+}
+
+.vault-panel-head p {
+ max-width: 62ch;
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.55;
+ color: var(--ink-soft);
+}
+
+.vault-panel-mark {
+ display: grid;
+ flex: none;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ background: var(--terminal);
+ color: var(--acid);
+}
+
+.vault-panel-private {
+ display: flex;
+ gap: 8px;
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: var(--radius-control);
+ background: var(--success-wash);
+ font-size: 0.86rem;
+ line-height: 1.5;
+ color: var(--ink);
+}
+
+.vault-panel-private svg {
+ flex: none;
+ margin-top: 3px;
+ color: var(--success);
+}
+
+.vault-facts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
+ gap: 10px 20px;
+ margin: 0;
+}
+
+.vault-facts dt,
+.vault-panel-subhead {
+ font-size: 0.74rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.vault-facts dd {
+ margin: 2px 0 0;
+ font-size: 0.9rem;
+}
+
+.vault-panel-subhead {
+ margin: 6px 0 0;
+}
+
+.vault-holdings {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.vault-holdings li {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ font-size: 0.9rem;
+}
+
+.vault-holdings svg {
+ flex: none;
+ margin-top: 3px;
+ color: var(--muted);
+}
+
+.vault-holdings small {
+ display: block;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--muted);
+}
+
+.vault-sessions {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-top: 1px solid var(--line);
+}
+
+.vault-session {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 6px 14px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.vault-session-main {
+ display: grid;
+ flex: 1 1 16rem;
+ gap: 3px;
+ min-width: 0;
+}
+
+.vault-session-name {
+ font-weight: 600;
+ color: var(--ink);
+ overflow-wrap: anywhere;
+}
+
+.vault-session-meta {
+ font-size: 0.8rem;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.vault-session-password {
+ justify-self: start;
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: var(--paper);
+ font-family: var(--mono);
+ font-size: 0.92rem;
+ overflow-wrap: anywhere;
+ user-select: all;
+}
+
+.vault-session-actions {
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+}
+
+.vault-session-actions .vault-link,
+.vault-more {
+ display: inline-flex;
+ gap: 4px;
+ align-items: center;
+ margin-left: 0;
+}
+
+.vault-more {
+ justify-self: start;
+}
+
+.vault-panel-actions {
+ display: grid;
+ gap: 6px;
+ justify-items: start;
+}
+
+/* An action, not a banner: it takes the width of its label. */
+.vault-panel-actions .btn {
+ width: auto;
+}
+
+/* The unlock form, borrowed from the gate, sits inside the panel here. */
+.vault-panel-unlock {
+ display: grid;
+ gap: 12px;
+}
+
+.vault-panel-unlock .consent-mark {
+ display: none;
+}
+
+.vault-panel-unlock h1 {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
.vault-reset {
margin-top: 20px;
}
diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx
index 677ff11..4442040 100644
--- a/app/src/terminal/TerminalPane.tsx
+++ b/app/src/terminal/TerminalPane.tsx
@@ -10,6 +10,7 @@ import { encryptionFragment, resolveSessionSocket, sessionIdFromShareUrl } from
import { cachedPassword, forgetUnverified, markVerified, rememberVerified } from "../lib/session-passwords";
import { isVaultShare } from "../lib/vault-crypto";
import { useVault } from "../vault/VaultProvider";
+import { useTeamKey } from "../vault/TeamKeyProvider";
import { AuditSink } from "./audit-sink";
import { postAudit } from "../lib/api";
import { Button } from "../components/Button";
@@ -74,6 +75,10 @@ export function TerminalPane({
const vault = useVault();
const vaultRef = useRef(vault);
vaultRef.current = vault;
+ /* The team's audit key, for sealing what is typed here; read the same way. */
+ const team = useTeamKey();
+ const teamRef = useRef(team);
+ teamRef.current = team;
/* The password being tried, and the ones still to try after it. */
const attempt = useRef(null);
const pending = useRef([]);
@@ -259,9 +264,29 @@ export function TerminalPane({
* Input is recorded per session so a team can see what was run
* or asked. It watches the same stream the terminal receives, so it sees
* exactly what was entered and nothing else.
+ *
+ * Each entry is sealed here to the team's audit key before it leaves the
+ * browser, so the team can read it and the service cannot. The time is
+ * part of what is sealed, so it is fixed before sealing and sent as is.
*/
const audited = sessionIdFromShareUrl(shareUrl);
- const sink = audited ? new AuditSink(audited, postAudit) : null;
+ const sink = audited
+ ? new AuditSink(audited, async (entries) =>
+ postAudit(
+ await Promise.all(
+ entries.map(async (entry) => ({
+ ...entry,
+ text: await teamRef.current.sealAudit({
+ sessionId: entry.session_id,
+ kind: entry.kind,
+ at: entry.at,
+ text: entry.text,
+ }),
+ })),
+ ),
+ ),
+ )
+ : null;
const typed = term.onData((data) => {
if (!canType) return;
diff --git a/app/src/vault/TeamKeyProvider.tsx b/app/src/vault/TeamKeyProvider.tsx
new file mode 100644
index 0000000..82d47f7
--- /dev/null
+++ b/app/src/vault/TeamKeyProvider.tsx
@@ -0,0 +1,355 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ createTeamKey as publishTeamKey,
+ dropMyTeamKeyShare,
+ fetchPlaintextAudit,
+ fetchSessions,
+ fetchTeamKey,
+ putTeamKeyShares,
+ sealAuditEntries,
+ type AuditEvent,
+} from "../lib/api";
+import {
+ createTeamKey,
+ isAuditEnvelope,
+ openAuditText,
+ sealAuditText,
+ teamPrivateKey,
+ type Key,
+} from "../lib/team-crypto";
+import { fingerprint } from "../lib/vault-crypto";
+import { keyTrust, knownKey, trustKey } from "../lib/known-keys";
+import { teamKeyRefusal, teamKeyVerdict } from "../lib/team-trust";
+import { useVault } from "./VaultProvider";
+
+/**
+ * The team audit key, as far as this browser holds it.
+ *
+ * "waiting" means the team has a key and nobody has sealed a copy to this
+ * person yet; any teammate who opens shell.online does so. "ready" means a
+ * copy was opened and checked: it came from a teammate whose vault key this
+ * browser knows, and it is the private half of the key the team published.
+ */
+export type TeamKeyStatus = "idle" | "loading" | "waiting" | "ready" | "error";
+
+interface Held {
+ orgId: string;
+ uid: string;
+ version: number;
+ publicKey: string;
+ privateKey: Key;
+}
+
+interface TeamKeyValue {
+ status: TeamKeyStatus;
+ error: string;
+ /** The team key's fingerprint, for comparing between teammates. */
+ fingerprint: string;
+ /** Who made the team's key. */
+ createdBy: string | null;
+ /** Seals one entry of typed input to the team key. Waits briefly for the key if it is on its way. */
+ sealAudit(entry: { sessionId: string; kind: string; at: number; text: string }): Promise;
+ /**
+ * The readable text of an audit entry: plain text as it is, sealed text
+ * opened here. Null when this browser cannot open it.
+ */
+ openAudit(event: Pick): Promise;
+ refresh(): void;
+}
+
+/* How often a browser holding the key looks for teammates who need a copy. */
+const REFRESH_MS = 60_000;
+/* How long typed input waits for a key that is on its way before it is dropped. */
+const WAIT_FOR_KEY_MS = 60_000;
+/* History sealed per visit, in batches, so one visit cannot run forever. */
+const HISTORY_BATCHES = 20;
+const HISTORY_BATCH = 100;
+
+const TeamKeyContext = createContext(null);
+
+export function TeamKeyProvider({ children }: { children: ReactNode }) {
+ const vault = useVault();
+ const [status, setStatus] = useState("idle");
+ const [error, setError] = useState("");
+ const [print, setPrint] = useState("");
+ const [createdBy, setCreatedBy] = useState(null);
+ const [attempt, setAttempt] = useState(0);
+ const held = useRef(null);
+ const waiting = useRef<((key: Held) => void)[]>([]);
+ /* Read by the refresh loop without restarting it whenever the vault re-renders. */
+ const vaultRef = useRef(vault);
+ useEffect(() => {
+ vaultRef.current = vault;
+ }, [vault]);
+
+ useEffect(() => {
+ if (vault.status !== "unlocked") {
+ held.current = null;
+ setStatus("idle");
+ return;
+ }
+ let live = true;
+
+ async function run() {
+ const own = vaultRef.current;
+ if (!own.publicKey) return;
+ let view = await fetchTeamKey();
+ const { members } = await fetchSessions();
+ if (!live) return;
+ const { orgId, uid, role } = view.you;
+
+ /*
+ * Whether to believe what the service says about the team's key. A
+ * browser that takes "there is none" at face value can be made to
+ * generate a key and seal it to whatever public keys the service lists;
+ * see team-trust.ts.
+ */
+ const teamName = `team:${orgId}`;
+ const verdict = teamKeyVerdict(knownKey(uid, teamName), view.teamKey?.publicKey ?? null);
+ if (verdict === "refuse-missing" || verdict === "refuse-changed") {
+ held.current = null;
+ if (live) {
+ setError(teamKeyRefusal(verdict));
+ setStatus("error");
+ }
+ return;
+ }
+
+ /*
+ * The first member here with a vault makes the team's key, sealed to
+ * everyone who has a vault. Making it is create-only on the service, so
+ * of two members doing this at once, one wins and the other reads it.
+ */
+ if (verdict === "create") {
+ const made = await createTeamKey();
+ try {
+ /*
+ * A copy for this person first, from the key this browser checked
+ * when it unlocked rather than from the roster: a roster that has
+ * not caught up with a vault made a moment ago would otherwise
+ * produce a team key its own maker cannot open.
+ *
+ * Teammates are sealed to only at a key this browser has not seen
+ * change, and each is pinned as it is used. Sealing the team's key
+ * to a swapped key would hand the whole log to whoever swapped it.
+ */
+ const recipients = [
+ { uid, accountKey: own.publicKey },
+ ...members
+ .filter(
+ (member) =>
+ member.accountKey &&
+ member.uid !== uid &&
+ keyTrust(uid, member.uid, member.accountKey) !== "changed",
+ )
+ .map((member) => ({ uid: member.uid, accountKey: member.accountKey as string })),
+ ];
+ const shares: { uid: string; sealed: string }[] = [];
+ for (const member of recipients) {
+ const sealed = await own.sealTeamKey(member, { orgId, version: 1 }, made.pkcs8);
+ if (sealed) shares.push({ uid: member.uid, sealed });
+ }
+ await publishTeamKey(made.publicKey, shares).catch(() => undefined);
+ for (const member of recipients) {
+ if (member.uid !== uid) trustKey(uid, member.uid, member.accountKey);
+ }
+ } finally {
+ made.pkcs8.fill(0);
+ }
+ view = await fetchTeamKey();
+ }
+
+ const teamKey = view.teamKey;
+ if (!teamKey) throw new Error("The team's audit key could not be set up. Try again in a moment.");
+ if (!live) return;
+ setCreatedBy(teamKey.createdBy);
+ setPrint(await fingerprint(teamKey.publicKey));
+ const team = { orgId, version: teamKey.version };
+
+ const share = view.share;
+ if (!share || share.version !== teamKey.version) {
+ held.current = null;
+ if (live) setStatus("waiting");
+ return;
+ }
+
+ /*
+ * Only a copy from a teammate is opened, with the vault key this
+ * browser knows for them. A sender who is not in the team, or whose key
+ * changed since, is not believed: that is how a made-up team key from
+ * the service would arrive.
+ */
+ const sender = members.find((member) => member.uid === share.senderUid);
+ const senderKey = share.senderUid === uid ? own.publicKey : sender?.accountKey;
+ const believable =
+ Boolean(senderKey) &&
+ (share.senderUid === uid || keyTrust(uid, share.senderUid, senderKey ?? "") !== "changed");
+ const pkcs8 = believable && senderKey ? await own.openTeamKey(share, senderKey, team) : null;
+ try {
+ const privateKey = pkcs8 ? await teamPrivateKey(pkcs8, teamKey.publicKey) : null;
+ if (!pkcs8 || !privateKey) {
+ /*
+ * A copy that will not open is dropped, so a teammate can seal a
+ * fresh one: after a vault reset it was sealed to a key this person
+ * no longer has.
+ */
+ await dropMyTeamKeyShare().catch(() => undefined);
+ held.current = null;
+ if (live) setStatus("waiting");
+ return;
+ }
+
+ if (share.senderUid !== uid && senderKey) {
+ trustKey(uid, share.senderUid, senderKey);
+ /*
+ * Sealed again to this person, by this person, so the copy no
+ * longer depends on the teammate who sent it staying in the team:
+ * a copy from someone who has left can no longer be checked.
+ */
+ const mine = await own.sealTeamKey({ uid, accountKey: own.publicKey }, team, pkcs8);
+ if (mine) {
+ await dropMyTeamKeyShare().catch(() => undefined);
+ await putTeamKeyShares(team.version, [{ uid, sealed: mine }]).catch(() => undefined);
+ }
+ }
+
+ /* Teammates with a vault and no copy get one, sealed by this vault. */
+ const needing = view.missing.filter(
+ (member) => member.uid !== uid && keyTrust(uid, member.uid, member.accountKey) !== "changed",
+ );
+ const shares: { uid: string; sealed: string }[] = [];
+ for (const member of needing) {
+ const sealed = await own.sealTeamKey(member, team, pkcs8);
+ if (sealed) shares.push({ uid: member.uid, sealed });
+ }
+ if (shares.length > 0) {
+ await putTeamKeyShares(team.version, shares).catch(() => undefined);
+ for (const member of needing) trustKey(uid, member.uid, member.accountKey);
+ }
+
+ /*
+ * Recorded now that a copy has proved to be the private half of the
+ * key the team published. From here on, this browser refuses a
+ * different key for this team, and refuses to make a second one; see
+ * team-trust.ts.
+ */
+ trustKey(uid, teamName, teamKey.publicKey);
+ const next: Held = { orgId, uid, version: team.version, publicKey: teamKey.publicKey, privateKey };
+ held.current = next;
+ for (const resolve of waiting.current.splice(0)) resolve(next);
+ if (!live) return;
+ setError("");
+ setStatus("ready");
+ } finally {
+ pkcs8?.fill(0);
+ }
+
+ if (role === "owner" || role === "admin") await sealHistory(held.current);
+ }
+
+ if (!held.current) setStatus("loading");
+ const tick = () =>
+ void run().catch((caught) => {
+ if (!live) return;
+ setError(caught instanceof Error ? caught.message : "Could not reach the team's audit key.");
+ if (!held.current) setStatus("error");
+ });
+ tick();
+ const timer = window.setInterval(tick, REFRESH_MS);
+ return () => {
+ live = false;
+ window.clearInterval(timer);
+ };
+ }, [vault.status, attempt]);
+
+ const sealAudit = useCallback(
+ async (entry: { sessionId: string; kind: string; at: number; text: string }) => {
+ const key =
+ held.current ??
+ (await new Promise((resolve, reject) => {
+ waiting.current.push(resolve);
+ window.setTimeout(() => reject(new Error("The team's audit key is not here yet.")), WAIT_FOR_KEY_MS);
+ }));
+ return sealAuditText(
+ key.publicKey,
+ key.version,
+ { orgId: key.orgId, sessionId: entry.sessionId, kind: entry.kind, at: entry.at, actorUid: key.uid },
+ entry.text,
+ );
+ },
+ [],
+ );
+
+ const openAudit = useCallback(
+ async (event: Pick) => {
+ if (!isAuditEnvelope(event.text)) return event.text;
+ const key = held.current;
+ if (!key) return null;
+ return openAuditText(
+ key.privateKey,
+ { orgId: key.orgId, sessionId: event.sessionId, kind: event.kind, at: event.at, actorUid: event.actorUid },
+ event.text,
+ );
+ },
+ [],
+ );
+
+ const refresh = useCallback(() => setAttempt((value) => value + 1), []);
+
+ const value = useMemo(
+ () => ({ status, error, fingerprint: print, createdBy, sealAudit, openAudit, refresh }),
+ [status, error, print, createdBy, sealAudit, openAudit, refresh],
+ );
+
+ return {children};
+}
+
+/**
+ * Seals entries recorded before the audit log was encrypted.
+ *
+ * An owner's or admin's browser does this, a batch at a time, whenever it
+ * holds the key. Each entry is bound to the session, person and time the
+ * service recorded for it, and the service replaces only entries that are
+ * still plain text, so running it again, or from two browsers, changes nothing
+ * twice.
+ */
+async function sealHistory(key: Held | null): Promise {
+ if (!key) return;
+ try {
+ for (let batch = 0; batch < HISTORY_BATCHES; batch += 1) {
+ const { events } = await fetchPlaintextAudit(HISTORY_BATCH);
+ if (events.length === 0) return;
+ const entries: { id: string; text: string }[] = [];
+ for (const event of events) {
+ entries.push({
+ id: event.id,
+ text: await sealAuditText(
+ key.publicKey,
+ key.version,
+ { orgId: key.orgId, sessionId: event.sessionId, kind: event.kind, at: event.at, actorUid: event.actorUid },
+ event.text,
+ ),
+ });
+ }
+ await sealAuditEntries(entries);
+ if (events.length < HISTORY_BATCH) return;
+ }
+ } catch {
+ /* Carried on at the next refresh. */
+ }
+}
+
+export function useTeamKey(): TeamKeyValue {
+ const value = useContext(TeamKeyContext);
+ if (!value) throw new Error("useTeamKey must be used inside a TeamKeyProvider.");
+ return value;
+}
diff --git a/app/src/vault/VaultGate.tsx b/app/src/vault/VaultGate.tsx
index 1a7c60c..8dc9ed0 100644
--- a/app/src/vault/VaultGate.tsx
+++ b/app/src/vault/VaultGate.tsx
@@ -216,7 +216,7 @@ export function VaultSetup({ reset, onDone }: { reset: boolean; onDone?: () => v
}
/** Opens an existing vault in a browser that has not opened it before. */
-function VaultUnlock() {
+export function VaultUnlock() {
const vault = useVault();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
diff --git a/app/src/vault/VaultPanel.tsx b/app/src/vault/VaultPanel.tsx
new file mode 100644
index 0000000..d14a32a
--- /dev/null
+++ b/app/src/vault/VaultPanel.tsx
@@ -0,0 +1,281 @@
+import { useEffect, useRef, useState } from "react";
+import { Link } from "react-router-dom";
+import { Check, Copy, Eye, EyeSlash, Key, LockKey, ShieldCheck, UsersThree } from "@phosphor-icons/react";
+import { Button } from "../components/Button";
+import { fetchSessions, type Member, type SessionRecord } from "../lib/api";
+import { COPY_FAILED, useCopy } from "../lib/clipboard";
+import { displayName, findPerson } from "../lib/people";
+import { isVaultShare } from "../lib/vault-crypto";
+import { useVault } from "./VaultProvider";
+import { useTeamKey } from "./TeamKeyProvider";
+import { VaultUnlock } from "./VaultGate";
+
+/* A revealed password goes back into hiding on its own. */
+const REVEAL_MS = 30_000;
+const SHORT_LIST = 12;
+
+/**
+ * The vault on the Account page: what it is, what it holds, and a way to see
+ * any of it.
+ *
+ * Everything shown here is opened in this browser. The service hands this
+ * page only this person's own sealed copies, which it cannot open, and never
+ * anyone else's; the team sees none of it.
+ */
+export function VaultPanel() {
+ const vault = useVault();
+
+ return (
+
+
+
+
+
+
+
Session vault
+
+ Every session is end-to-end encrypted with its own password. Your
+ vault keeps the passwords of the sessions you can open, sealed to a
+ key only you hold, so a session opens on any browser you unlock.
+
+
+
+
+
+
+
+ Only you can see what is in it. Your team cannot see your vault, and
+ shell.online stores it sealed and cannot open it. What this page shows
+ is opened here, in this browser.
+
+
+
+ {vault.status === "loading" &&
Opening your vault
}
+ {vault.status === "error" && (
+
+ {vault.error}{" "}
+
+
+ )}
+ {vault.status === "setup" && (
+
+ You have not set it up yet. Open Sessions to set it up; it
+ takes a minute and shows you a recovery key to keep.
+
+ )}
+ {vault.status === "locked" && (
+
+
+
+ )}
+ {vault.status === "unlocked" && }
+
+ );
+}
+
+function VaultContents() {
+ const vault = useVault();
+ const team = useTeamKey();
+ const [sessions, setSessions] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [error, setError] = useState("");
+ const [showAll, setShowAll] = useState(false);
+ const [locking, setLocking] = useState(false);
+
+ useEffect(() => {
+ let live = true;
+ fetchSessions()
+ .then((result) => {
+ if (!live) return;
+ setSessions(result.sessions);
+ setMembers(result.members ?? []);
+ })
+ .catch((caught) => live && setError(caught instanceof Error ? caught.message : "Could not load your sessions."));
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ /* The copies sealed to this person: to their vault, or to an old browser key. */
+ const held = (sessions ?? []).filter((session) => Boolean(session.keyShare));
+ const running = held.filter((session) => !session.closedAt).length;
+ const shown = showAll ? held : held.slice(0, SHORT_LIST);
+
+ return (
+ <>
+
+ {vault.remembered
+ ? "Keeps it unlocked until you sign out"
+ : "Cannot keep it unlocked, so it asks for your recovery key each visit"}
+
+
+
+
+
What it holds
+
+
+
+
+
+ {sessions === null ? "Session passwords" : `${held.length} session password${held.length === 1 ? "" : "s"}`}
+
+ {sessions !== null && held.length > 0 && ` · ${running} for sessions still running`}
+ Yours, and the ones teammates shared with you. Listed below.
+
+
+
+
+
+ Your copy of the team's audit key
+
+ {team.status === "ready"
+ ? `Held. It lets you read your team's audit log, which is sealed to it. Key ${team.fingerprint}.`
+ : team.status === "waiting"
+ ? "On its way: a teammate's browser seals it to you the next time they open shell.online."
+ : team.status === "error"
+ ? team.error
+ : "Checking."}
+
+
+
+
+ {session.name || session.command}
+
+
+ {[
+ session.host,
+ session.closedAt ? "finished" : "running",
+ session.ownerUid === you ? "yours" : `shared by ${displayName(owner)}`,
+ legacy ? "sealed to an old browser key; moves into your vault when you open it" : "",
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+ {password !== null && (
+
+ {password}
+
+ )}
+ {failed && This browser cannot open that copy.}
+ {failedKey && {COPY_FAILED}}
+
+
+
+ {password !== null && (
+
+ )}
+
+
+ );
+}
diff --git a/app/src/vault/VaultProvider.tsx b/app/src/vault/VaultProvider.tsx
index 2d883d3..25e2b7c 100644
--- a/app/src/vault/VaultProvider.tsx
+++ b/app/src/vault/VaultProvider.tsx
@@ -25,6 +25,7 @@ import {
} from "../lib/vault-crypto";
import { clearLocalVault, loadLocalVault, saveLocalVault } from "../lib/vault-store";
import { openSealed } from "../lib/keypair";
+import { openTeamKeyShare, sealTeamKeyShare, type TeamKeyContext } from "../lib/team-crypto";
/**
* The signed-in person's session vault, and what is open in this browser.
@@ -73,6 +74,27 @@ interface VaultValue {
): Promise;
/** Seals a password to this person's own vault and stores it. False when it could not. */
keep(sessionId: string, password: string): Promise;
+ /** The signed-in person, whose vault this is. */
+ uid: string;
+ /** When this vault was made, from the service's record. */
+ createdAt: number | null;
+ /** Locks the vault in this browser. The vault itself is untouched. */
+ lock(): Promise;
+ /**
+ * Seals the team audit key's private half to a teammate, with this vault's
+ * own key, so they can tell who sent it. Null while locked.
+ */
+ sealTeamKey(
+ recipient: { uid: string; accountKey: string },
+ team: TeamKeyContext,
+ pkcs8: Uint8Array,
+ ): Promise;
+ /** Opens a team audit key share sealed to this vault by the teammate it names. */
+ openTeamKey(
+ share: { senderUid: string; sealed: string },
+ senderAccountKey: string,
+ team: TeamKeyContext,
+ ): Promise | null>;
}
const VaultContext = createContext(null);
@@ -270,6 +292,46 @@ export function VaultProvider({ children }: { children: ReactNode }) {
[uid],
);
+ const lock = useCallback(async () => {
+ await clearLocalVault(uid);
+ opened.current = null;
+ setPublicKey(null);
+ setStatus(remote ? "locked" : "setup");
+ }, [uid, remote]);
+
+ /*
+ * The team audit key is sealed with this vault's own key rather than a
+ * throwaway one, so whoever opens it can tell it came from this person.
+ * See team-crypto.ts for why that matters.
+ */
+ const sealTeamKey = useCallback(
+ async (recipient: { uid: string; accountKey: string }, team: TeamKeyContext, pkcs8: Uint8Array) => {
+ const key = opened.current;
+ if (!key) return null;
+ return sealTeamKeyShare(
+ key.privateKey,
+ recipient.accountKey,
+ { ...team, senderUid: uid, recipientUid: recipient.uid },
+ pkcs8,
+ );
+ },
+ [uid],
+ );
+
+ const openTeamKey = useCallback(
+ async (share: { senderUid: string; sealed: string }, senderAccountKey: string, team: TeamKeyContext) => {
+ const key = opened.current;
+ if (!key) return null;
+ return openTeamKeyShare(
+ key.privateKey,
+ senderAccountKey,
+ { ...team, senderUid: share.senderUid, recipientUid: uid },
+ share.sealed,
+ );
+ },
+ [uid],
+ );
+
const value = useMemo(
() => ({
status,
@@ -285,8 +347,16 @@ export function VaultProvider({ children }: { children: ReactNode }) {
openShare,
sealTo,
keep,
+ uid,
+ createdAt: remote?.createdAt ?? null,
+ lock,
+ sealTeamKey,
+ openTeamKey,
}),
- [status, error, publicKey, print, remembered, remote, prepare, commit, unlock, retry, openShare, sealTo, keep],
+ [
+ status, error, publicKey, print, remembered, remote, prepare, commit, unlock, retry,
+ openShare, sealTo, keep, uid, lock, sealTeamKey, openTeamKey,
+ ],
);
return {children};
diff --git a/public/llms.txt b/public/llms.txt
index bf155c2..7e441b3 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -133,10 +133,24 @@ account can start processes on that machine.
## Collaboration records
-The accounts service records explicit collaboration metadata such as session
-ownership, handoffs, comments, mentions, and notifications. It does not receive
-a plaintext copy of terminal input. Prompts, commands, passwords, and other
-keystrokes remain inside the end-to-end encrypted terminal stream.
+The accounts service records collaboration metadata such as session ownership,
+handoffs, comments, mentions, and notifications, and session records: the share
+URL, the command line as written, the machine's host name, the session name,
+timings, and exit code. Every member of the team sees those.
+
+What anyone types into a session from the browser (commands, agent prompts,
+Ctrl-C) is recorded in the team's audit log. It is end-to-end encrypted in the
+browser to the team's audit key: every member of the organization can read and
+export it, and the accounts service stores only ciphertext it cannot read. The
+service still sees who acted, in which session, what kind of entry it was, and
+when. Anything typed by mistake, such as a pasted password, is recorded and
+readable by the whole team. Typing in the terminal a session was started from
+never reaches the browser or the service, and a --read-only share accepts no
+browser input at all.
+
+Session passwords are sealed to each person's session vault; the service stores
+them sealed and cannot open them. Terminal output stays inside the end-to-end
+encrypted stream.
## Release integrity