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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions .github/audit/canaries/passkey-removal-keeps-sessions.patch
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
Domain: hosted
Rule: S100 — or a removed passkey's sessions survive its removal
Expect: hosted/server/relay-api.ts
Expect: hosted/server/relay-api.ts hosted/server/dormouse-migrations/010_relay_session_credential_required.sql
Source: synthetic; a session no longer names the passkey that signed it in, so removing that passkey on the account page leaves its sessions live

diff --git a/hosted/server/dormouse-migrations/010_relay_session_credential_required.sql b/hosted/server/dormouse-migrations/010_relay_session_credential_required.sql
index 319cd5c41..b5300f378 100644
--- a/hosted/server/dormouse-migrations/010_relay_session_credential_required.sql
+++ b/hosted/server/dormouse-migrations/010_relay_session_credential_required.sql
@@ -1,9 +1,6 @@
-- Up Migration
--- Every session names the passkey that signed it in, so removing that passkey
--- ends it (docs/specs/hosted.md -> "Burrow enrollment"). A session minted
--- before 009 names none and is deleted here; its holder signs in again.
+-- A session minted before 009 names no passkey, so removing the passkey that
+-- signed it in left it live; it is deleted here, and its holder signs in again.
DELETE FROM dormouse_relay_sessions WHERE "credentialId" IS NULL;
-ALTER TABLE dormouse_relay_sessions ALTER COLUMN "credentialId" SET NOT NULL;

-- Down Migration
-ALTER TABLE dormouse_relay_sessions ALTER COLUMN "credentialId" DROP NOT NULL;
diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts
index b227f87c4..918689c6e 100644
index a68a75d36..d6f13c53a 100644
--- a/hosted/server/relay-api.ts
+++ b/hosted/server/relay-api.ts
@@ -754,17 +754,14 @@ export async function admit(
@@ -746,17 +746,14 @@ export async function admit(
});
}

Expand All @@ -28,11 +44,39 @@ index b227f87c4..918689c6e 100644
RELAY_SESSION_TTL_MS,
);
return { sessionToken, expiresAt: expiresAt! };
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index 09806ad75..cb7005345 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -32,7 +32,7 @@ const PINNED: Record<string, string> = {
"009_relay_passkey_removal.sql":
"c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928",
"010_relay_session_credential_required.sql":
- "f385398d16126381b6a976d74e23e10b07370c1a1da3205e71697aa62a3399f5",
+ "892b0116797b52e9f42617be5703d2e516f3ec5c6d37cbc8c4f8504e4fcce969",
};

const directory = new URL("../dormouse-migrations/", import.meta.url);
@@ -47,7 +47,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => {
}
});

-test("010 deletes the sessions that name no passkey, and every later one must name one", async ({
+test("010 deletes the sessions that name no passkey", async ({
onTestFinished,
}) => {
// The Dormouse migrations through 009, then 010 over the rows they allowed.
@@ -73,5 +73,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na
copyFileSync(join(source, "010_relay_session_credential_required.sql"), join(before, "010_relay_session_credential_required.sql"));
await migrate(context.database.url, await readMigrations([betterAuthMigrations, before]));
expect(await sql(`SELECT "tokenHash" FROM dormouse_relay_sessions`)).toEqual([{ tokenHash: "named" }]);
- await expect(session("later", null)).rejects.toThrow(/credentialId/);
});
diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts
index f26bd4613..6f64d96af 100644
index 424ccd2ba..3441c7e86 100644
--- a/hosted/server/tests/relay.test.ts
+++ b/hosted/server/tests/relay.test.ts
@@ -454,32 +454,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
@@ -456,32 +456,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
expect(await minter(phone)).toEqual([{ burrowId: null }]);
expect((await f.call("GET", API_ROUTES.burrows, { bearer: setupSession })).status).toBe(200);

Expand Down
2 changes: 1 addition & 1 deletion .github/audit/ci-and-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Delegate only by these streams (`.github/audit/_preamble.md` -> "Work streams").

- `actions` — `docs/specs/security-ci.md`: "GitHub Actions Policies", "Automated Maintainer (tend)".
- `releases` — `docs/specs/security-ci.md`: "Hosted Deployments", "VS Code Extension Releases", "Desktop Releases".
- `audit` — `docs/specs/security-audit.md`: "Schedule and gate", "Deterministic checks", "Skipping an unchanged audit", "Domains", "Orchestration", "Outcomes and reporting", "Findings", "Embargo", "Findings ledger", "Disclosure", "Environment and `AUDIT_PAT`", "Canary recall", "Red team".
- `audit` — `docs/specs/security-audit.md`: "Schedule and gate", "Deterministic checks", "Skipping an unchanged audit", "Domains", "Orchestration", "Outcomes and reporting", "Findings", "Embargo", "Findings ledger", "Disclosure", "Environment and `AUDIT_PAT`", "Canary recall", "Second-opinion audit".
- `qualitative` — the qualitative pass below, once, over the whole of this domain's scope.

## Qualitative pass
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/codex-audit-report.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: codex-audit-report

# Publishes the red team's report (docs/specs/security-audit.md -> "Red team"):
# what a maintainer's `scripts/security-audit-local.sh --engine codex` run
# computed, typed in as dispatch inputs. Nothing here runs a model or reads a
# secret, and nothing here gates a release: release.yml waits on
# security-audit.yaml alone. The inputs are non-secret by construction —
# Publishes the second-opinion audit's report (docs/specs/security-audit.md ->
# "Second-opinion audit"): what a maintainer's `scripts/security-audit-local.sh
# --engine codex` run computed, typed in as dispatch inputs. Nothing here runs
# a model or reads a secret, and nothing here gates a release: release.yml
# waits on security-audit.yaml alone. The inputs are non-secret by construction —
# computed verdicts and counts, never a finding — and
# `scripts/security-audit-public-body.mjs` checks each against its pattern,
# and the counts against the CI canary run they share seeds with, before it
Expand Down Expand Up @@ -38,7 +38,7 @@ on:
type: string
default: ""
canary_either:
description: "Seeds Claude or Codex caught"
description: "Seeds Claude or Codex caught; empty when not computed"
type: string
default: ""
canary_unfinished:
Expand All @@ -65,7 +65,7 @@ jobs:
- name: Refuse any ref but main
run: |
[ "$GITHUB_REF" = refs/heads/main ] && exit 0
echo "::error::A red-team report publishes from main only."
echo "::error::A second-opinion report publishes from main only."
exit 1

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -79,7 +79,7 @@ jobs:

# Inputs reach the builder through the environment, never interpolated
# into the script. It refuses a dispatch by anyone but an admin.
- name: Publish the red-team report
- name: Publish the second-opinion report
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Expand All @@ -103,7 +103,7 @@ jobs:

# The counts website/scripts/generate-canary-recall.js reads beside the
# CI canary run's own `canary-recall`.
- name: Upload the red-team recall
- name: Upload the second-opinion recall
if: inputs.canary_run != ''
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their
- **Must count every approval attempt** against `RELAY_APPROVE_LIMIT` (10 a minute per account) before reading the body: 429 with `Retry-After` past it.
- **Must answer a malformed code 400**, forgiving case, spaces, and dashes, and a second approval of a live code 409 `ALREADY_APPROVED`, whichever account sends it: a live approval never moves. An expired one is replaced.
- **Must remove by deleting the Burrow's row**, its setup tokens and setup challenges cascading, so its token authenticates nothing on any relay route, **then close its live socket** ("Relay sockets"). **Never remove a passkey with its Burrow**: it is left with no Burrow (rationale).
- **Must remove a passkey by deleting its row**, every session it signed in cascading, so it signs in to nothing again. A session from before sessions named their passkey lives out its expiry.
- **Must remove a passkey by deleting its row**, every session it signed in cascading, so it signs in to nothing again; every session names the passkey that signed it in, its `"credentialId"` `NOT NULL`.
- **Must take the `/enroll` fragment before render and erase it from history, and hold the code in memory only**: through email sign-in and back, never into storage. Provider sign-in leaves the page, so the user opens the link again.

**Must bind every approval to its purpose, defaulting omitted purpose to remote control.** Voice approvals use a distinct code derivation and scope. Voice clients require begin to acknowledge their purpose before displaying or polling a code. Voice redemption atomically marks the approval and mints a token, without a Burrow or its cap; later polls report it spent. A remote poll cannot redeem a voice approval, or vice versa. Both purposes share approval gates and expiry.
Expand Down
Loading
Loading