From 0f6e3b1b32e9854e3350535446cdcec0814f60b7 Mon Sep 17 00:00:00 2001 From: Sivert Date: Tue, 15 Sep 2026 23:05:02 +0200 Subject: [PATCH] Retire access tokens leaked before the server:clients fix (GRYT-1259) Before GRYT-1239, every member's live access token reached every other member through server:clients, and a token that leaked can't be recalled. This bumps server_config.token_version once, on the upgrade that carries the fix, so every access token minted before it stops passing requireAuth and token:refresh. It runs as a schema_meta-marked one-shot in runMigrations, so it fires once and never again. It skips a database nobody has joined, since a fresh install never issued a token to leak, and still writes the marker there so a later boot never reconsiders. One log line when it fires. Refresh tokens are untouched, so a live client falls back to its refresh token and re-mints at the new version on its own. Only a captured access token stops working. Verified on a throwaway server: joined on the old code, captured the token, restarted onto this branch. The migration bumped the counter and logged once, the old token was refused at requireAuth and token:refresh, the refresh token minted a new one, and the new one restored the session. A second restart did not bump again. Co-Authored-By: Claude Opus 5 --- src/db/sqlite/connection.ts | 5 + src/db/sqlite/tokenReissueMigration.test.ts | 199 ++++++++++++++++++++ src/db/sqlite/tokenReissueMigration.ts | 55 ++++++ 3 files changed, 259 insertions(+) create mode 100644 src/db/sqlite/tokenReissueMigration.test.ts create mode 100644 src/db/sqlite/tokenReissueMigration.ts diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index efcfb158..6789c657 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -4,6 +4,7 @@ import { dirname, join } from "path"; import { AVATAR_THUMB_PX } from "../../constants/media"; import { migrateRankGatesToScopes } from "./rankGateMigration"; +import { reissueAccessTokensAfterLeak } from "./tokenReissueMigration"; import { backfillFor, BUILT_IN_ROLES, @@ -821,6 +822,10 @@ function runMigrations(d: DatabaseSync): void { // After the roles, whose ranks it reads, and before anything serves a request, // or a gated channel is open to everybody in between. migrateRankGatesToScopes(d); + + // Last, and only on an existing server: retires access tokens minted before + // the GRYT-1239 fix, since one that leaked cannot be recalled. + reissueAccessTokensAfterLeak(d); } /** diff --git a/src/db/sqlite/tokenReissueMigration.test.ts b/src/db/sqlite/tokenReissueMigration.test.ts new file mode 100644 index 00000000..67f6c936 --- /dev/null +++ b/src/db/sqlite/tokenReissueMigration.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, beforeEach, describe, it } from "node:test"; + +import { requireAuth } from "../../socket/middleware/auth"; +import { registerJoinHelpers } from "../../socket/handlers/joinHelpers"; +import type { HandlerContext } from "../../socket/handlers/types"; +import type { Clients } from "../../types"; +import { generateAccessToken } from "../../utils/jwt"; +import { getSqliteDb, initSqlite } from "./connection"; +import { createServerConfigIfNotExists, setServerRole } from "./servers"; +import { createRefreshToken } from "./tokens"; +import { upsertUser } from "./users"; +import { reissueAccessTokensAfterLeak, TOKEN_REISSUE_MIGRATION_KEY } from "./tokenReissueMigration"; + +/** + * The one-shot that retires access tokens leaked before GRYT-1239, and proof it + * fires once, spares a fresh install, and refuses the old token afterwards. + */ + +const HOST = "reissue.test:5001"; + +let dir: string; + +function tokenVersion(): number { + const row = getSqliteDb().prepare(`SELECT token_version FROM server_config`).get() as + | { token_version: number } + | undefined; + return row?.token_version ?? 0; +} + +function marker(): string | null { + const row = getSqliteDb().prepare(`SELECT value FROM schema_meta WHERE key = ?`).get(TOKEN_REISSUE_MIGRATION_KEY) as + | { value: string } + | undefined; + return row?.value ?? null; +} + +/** initSqlite has already run the migration once against the empty database, so + every test starts from a clean slate: no marker, and the counter at zero. */ +beforeEach(() => { + const db = getSqliteDb(); + db.prepare(`DELETE FROM schema_meta WHERE key = ?`).run(TOKEN_REISSUE_MIGRATION_KEY); + db.prepare(`UPDATE server_config SET token_version = 0`).run(); + db.prepare(`DELETE FROM users`).run(); +}); + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-reissue-")); + process.env.DATA_DIR = dir; + await initSqlite(); + await createServerConfigIfNotExists(); +}); + +after(() => { + delete process.env.DATA_DIR; + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Windows keeps the SQLite file open past the run. + } +}); + +describe("retiring the leaked access tokens", () => { + it("bumps the counter once on a server that had members", async () => { + await upsertUser("account-alice", "Alice"); + assert.equal(tokenVersion(), 0); + + const to = reissueAccessTokensAfterLeak(getSqliteDb()); + assert.equal(to, 1); + assert.equal(tokenVersion(), 1); + assert.ok(marker(), "the marker was not written"); + }); + + it("does nothing on the second boot", async () => { + await upsertUser("account-alice", "Alice"); + reissueAccessTokensAfterLeak(getSqliteDb()); + assert.equal(tokenVersion(), 1); + + const again = reissueAccessTokensAfterLeak(getSqliteDb()); + assert.equal(again, null, "it ran a second time"); + assert.equal(tokenVersion(), 1, "the second run bumped the counter again"); + }); + + it("spares a fresh install that never had a member", () => { + assert.equal(tokenVersion(), 0); + const to = reissueAccessTokensAfterLeak(getSqliteDb()); + assert.equal(to, null, "it bumped a database nobody had joined"); + assert.equal(tokenVersion(), 0); + assert.ok(marker(), "the marker must still be set, so it never reconsiders"); + }); +}); + +/** A fake socket that records what it was emitted, enough for requireAuth and the + token:refresh handler. */ +function fakeSocket() { + const emitted: { event: string; payload?: unknown }[] = []; + const socket = { + id: "sock-alice", + rooms: new Set(["sock-alice"]), + handshake: { headers: { host: HOST }, address: "127.0.0.1" }, + emit(event: string, payload?: unknown) { + emitted.push({ event, payload }); + return true; + }, + join() {}, + leave() {}, + to: () => ({ emit() {} }), + }; + return { socket, emitted }; +} + +describe("what the bump does to the tokens that leaked", () => { + let serverUserId: string; + let oldAccessToken: string; + + beforeEach(async () => { + const alice = await upsertUser("account-alice", "Alice"); + serverUserId = alice.server_user_id; + await setServerRole(serverUserId, "member"); + // Minted at the version before the bump, the way a captured token was. + oldAccessToken = generateAccessToken({ + grytUserId: "account-alice", + serverUserId, + nickname: "Alice", + serverHost: HOST, + tokenVersion: 0, + userTokenVersion: alice.token_version ?? 0, + }); + reissueAccessTokensAfterLeak(getSqliteDb()); + assert.equal(tokenVersion(), 1); + }); + + it("refuses the old access token at requireAuth", async () => { + const { socket, emitted } = fakeSocket(); + const auth = await requireAuth(socket as never, { accessToken: oldAccessToken }); + assert.equal(auth, null, "requireAuth accepted a token from before the bump"); + assert.ok( + emitted.some((e) => e.event === "token:revoked" && (e.payload as { reason?: string })?.reason === "token_version_mismatch"), + `expected token:revoked, got ${JSON.stringify(emitted)}`, + ); + }); + + it("refuses the old access token at token:refresh", async () => { + const { socket, emitted } = fakeSocket(); + const clientsInfo: Clients = { + "sock-alice": { serverUserId, grytUserId: "account-alice", nickname: "Alice" } as Clients[string], + }; + const ctx = { + io: { sockets: { sockets: new Map() }, to: () => ({ emit() {} }), emit() {} }, + socket, + clientId: "sock-alice", + serverId: "reissue-test", + clientsInfo, + sfuClient: null, + getClientIp: () => "127.0.0.1", + clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + + await registerJoinHelpers(ctx)["token:refresh"]({ accessToken: oldAccessToken }); + assert.ok( + emitted.some((e) => e.event === "token:revoked"), + `token:refresh renewed a stale token: ${JSON.stringify(emitted)}`, + ); + assert.ok( + !emitted.some((e) => e.event === "token:refreshed"), + "token:refresh handed back a fresh token for a stale one", + ); + }); + + it("still mints a fresh access token from a refresh token", async () => { + const { socket, emitted } = fakeSocket(); + const record = await createRefreshToken({ grytUserId: "account-alice", serverUserId }); + const clientsInfo: Clients = { + "sock-alice": { serverUserId, grytUserId: "account-alice", nickname: "Alice", isServerMuted: false, isServerDeafened: false } as Clients[string], + }; + const ctx = { + io: { sockets: { sockets: new Map() }, to: () => ({ emit() {} }), emit() {} }, + socket, + clientId: "sock-alice", + serverId: "reissue-test", + clientsInfo, + sfuClient: null, + getClientIp: () => "127.0.0.1", + clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + + await registerJoinHelpers(ctx)["token:refresh"]({ refreshToken: record.token_id }); + const refreshed = emitted.find((e) => e.event === "token:refreshed")?.payload as { accessToken?: string } | undefined; + assert.ok(refreshed?.accessToken, `a refresh token did not mint a new access token: ${JSON.stringify(emitted)}`); + + // The new token carries the current version, so it is accepted where the old one was refused. + const { socket: s2, emitted: e2 } = fakeSocket(); + const auth = await requireAuth(s2 as never, { accessToken: refreshed.accessToken }); + assert.ok(auth, `the freshly minted token was refused: ${JSON.stringify(e2)}`); + }); +}); diff --git a/src/db/sqlite/tokenReissueMigration.ts b/src/db/sqlite/tokenReissueMigration.ts new file mode 100644 index 00000000..c15050bf --- /dev/null +++ b/src/db/sqlite/tokenReissueMigration.ts @@ -0,0 +1,55 @@ +import consola from "consola"; +import type { DatabaseSync } from "node:sqlite"; + +/* Every member's access token leaked through server:clients until GRYT-1239, and + a leaked token cannot be recalled. This retires them once on upgrade. */ + +/* The bump moves server_config.token_version, which requireAuth and token:refresh + compare against. Refresh tokens are untouched, so a live client re-mints. */ + +export const TOKEN_REISSUE_MIGRATION_KEY = "access_token_reissue_gryt1239"; + +/** + * Bumps `server_config.token_version` once, and only on a database that already + * had members. Returns the new version, or null when nothing was bumped. + */ +export function reissueAccessTokensAfterLeak(d: DatabaseSync): number | null { + const already = d.prepare(`SELECT value FROM schema_meta WHERE key = ?`).get(TOKEN_REISSUE_MIGRATION_KEY) as + | { value: string } + | undefined; + if (already) return null; + + // Nobody ever joined, so no token was ever minted and nothing leaked. The + // marker is still written below, so a later boot never re-checks a fresh DB. + const members = d.prepare(`SELECT COUNT(*) AS n FROM users`).get() as { n: number }; + const now = new Date().toISOString(); + + d.exec("BEGIN"); + try { + let bumpedTo: number | null = null; + if (members.n > 0) { + d.prepare(`UPDATE server_config SET token_version = token_version + 1`).run(); + const row = d.prepare(`SELECT token_version FROM server_config`).get() as + | { token_version: number } + | undefined; + bumpedTo = row?.token_version ?? null; + } + // In the same transaction as the bump, or a crash between them retires + // tokens again on the next boot. + d.prepare( + `INSERT INTO schema_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?`, + ).run(TOKEN_REISSUE_MIGRATION_KEY, now, now); + d.exec("COMMIT"); + + if (bumpedTo !== null) { + consola.info( + `[migration] GRYT-1239: retired access tokens minted before this version; ` + + `server_config.token_version is now ${bumpedTo}. Live clients re-mint from their refresh token.`, + ); + } + return bumpedTo; + } catch (err) { + d.exec("ROLLBACK"); + throw err; + } +}