From 5afe18eaf9d20c1633b4ec9177c6c9309a8f3d99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 5 Oct 2026 19:38:14 -0700 Subject: [PATCH] feat(desktop): the renderer's bootstrap token rotates every 12 hours (#16275) Co-authored-by: Claude Opus 5.5 (1M context) --- .../DesktopBackendConfiguration.test.ts | 22 +++++- .../backend/DesktopBackendConfiguration.ts | 45 ++++++++++--- .../src/backend/DesktopBackendPool.test.ts | 1 + .../DesktopLocalEnvironmentAuth.test.ts | 67 +++++++++++++++++++ .../backend/DesktopLocalEnvironmentAuth.ts | 11 ++- apps/desktop/src/ipc/methods/window.test.ts | 52 +++++++++++++- apps/desktop/src/ipc/methods/window.ts | 15 ++++- .../desktop/src/wsl/DesktopWslBackend.test.ts | 1 + .../server/src/auth/PairingGrantStore.test.ts | 48 ++++++++++++- apps/server/src/auth/PairingGrantStore.ts | 24 ++++++- apps/server/src/cli/config.ts | 2 + apps/server/src/config.ts | 1 + packages/contracts/src/desktopBootstrap.ts | 5 ++ packages/shared/package.json | 4 ++ .../shared/src/desktopBootstrapToken.test.ts | 50 ++++++++++++++ packages/shared/src/desktopBootstrapToken.ts | 44 ++++++++++++ 16 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 packages/shared/src/desktopBootstrapToken.test.ts create mode 100644 packages/shared/src/desktopBootstrapToken.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index a24f4f6a00792e0dcd320bd26ff5f963b9f250b7..66fffb84f10989d6b079ae20095ed04d5c445935 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -255,8 +257,24 @@ describe("DesktopBackendConfiguration", () => { assert.equal(first.bootstrap.t3Home, environment.baseDir); assert.equal(first.bootstrap.tailscaleServeEnabled, true); assert.equal(first.bootstrap.tailscaleServePort, 8443); - assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i); - assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken); + assert.match(first.bootstrap.desktopBootstrapSecret ?? "", /^[0-9a-f]{64}$/i); + assert.equal( + second.bootstrap.desktopBootstrapSecret, + first.bootstrap.desktopBootstrapSecret, + ); + // The launch token is the secret's token for the current window, and + // the renderer is handed the same one. + assert.equal( + first.bootstrap.desktopBootstrapToken, + currentDesktopBootstrapToken( + first.bootstrap.desktopBootstrapSecret ?? "", + yield* Clock.currentTimeMillis, + ), + ); + assert.equal( + yield* configuration.currentBootstrapToken, + first.bootstrap.desktopBootstrapToken, + ); }), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index e920c0d73971818509536b92270be0f98eba17a9..1a95d5b9ef90554916c07b46dd9ec8df04ab7770 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,6 +1,8 @@ import * as NodeOS from "node:os"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -61,6 +63,10 @@ export class DesktopBackendConfiguration extends Context.Service< // fall-back to Windows), so the env switcher can't show "WSL" for a // backend that actually resolved to Windows. readonly resolvePrimaryLabel: Effect.Effect; + // The bootstrap token the renderer should present right now. It rotates + // every window (derived from the secret every backend was launched with), + // so the renderer never holds one long-lived admin credential. + readonly currentBootstrapToken: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} @@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () { interface SharedBootstrapInput { readonly bootstrapToken: string; + readonly bootstrapSecret: string; readonly observabilitySettings: BackendObservabilitySettings; } @@ -556,6 +563,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv t3Home: environment.baseDir, host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, + desktopBootstrapSecret: input.bootstrapSecret, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, desktopTelemetryFd: 4, @@ -635,6 +643,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // the SQLite file with the primary). host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, + desktopBootstrapSecret: input.bootstrapSecret, // PortSchema rejects 0, so when tailscale serve is disabled we still // need a valid number in this slot. The backend reads tailscaleServePort // only when tailscaleServeEnabled is true, so the actual value here is @@ -831,19 +840,19 @@ export const make = Effect.gen(function* () { // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can // resolve concurrently; with a plain Ref both could observe None, generate - // distinct tokens, and one would overwrite the other — leaving the two - // backends holding mismatched tokens and breaking the shared-token + // distinct secrets, and one would overwrite the other — leaving the two + // backends deriving mismatched tokens and breaking the shared-token // invariant the renderer relies on. modifyEffect serializes the whole - // get-or-create so the first caller wins and the rest reuse its token. - const tokenRef = yield* SynchronizedRef.make(Option.none()); - const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) => + // get-or-create so the first caller wins and the rest reuse its secret. + const secretRef = yield* SynchronizedRef.make(Option.none()); + const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { - onSome: (token) => Effect.succeed([token, current] as const), + onSome: (secret) => Effect.succeed([secret, current] as const), onNone: () => - crypto.randomBytes(24).pipe( + crypto.randomBytes(32).pipe( Effect.map((bytes) => { - const token = Hex.encode(bytes); - return [token, Option.some(token)] as const; + const secret = Hex.encode(bytes); + return [secret, Option.some(secret)] as const; }), ), }), @@ -855,12 +864,20 @@ export const make = Effect.gen(function* () { // hot-swap of the server-settings file is picked up on the next // restart cycle without having to bounce the desktop process. const sharedInputs = Effect.gen(function* () { - const bootstrapToken = yield* getOrCreateBootstrapToken; + const bootstrapSecret = yield* getOrCreateBootstrapSecret; + const bootstrapToken = currentDesktopBootstrapToken( + bootstrapSecret, + yield* Clock.currentTimeMillis, + ); const observabilitySettings = yield* readBackendObservabilitySettings.pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput; + return { + bootstrapToken, + bootstrapSecret, + observabilitySettings, + } satisfies SharedBootstrapInput; }); const buildWslPrimaryConfig = Effect.gen(function* () { @@ -918,7 +935,13 @@ export const make = Effect.gen(function* () { return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); + const currentBootstrapToken = Effect.gen(function* () { + const secret = yield* getOrCreateBootstrapSecret; + return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis); + }); + return DesktopBackendConfiguration.of({ + currentBootstrapToken, resolvePrimary: Effect.gen(function* () { const { useWsl, wslRequested } = yield* describePrimary; if (useWsl) { diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 3ac935c84129b424266a0899963fbf71018d36ad..5b6839a0290b408d86594db4f7fb332ed53c98e9 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -79,6 +79,7 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer Effect.die("unexpected WSL config resolve"), + currentBootstrapToken: Effect.die("unexpected bootstrap token read"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), DesktopWslEnvironment.layerTest(), diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 948bb041df8012e755ee2e7b2d254b2f1e986cae..bd844624d8b6c5521b7a6a28c74efb9c5dcac136 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -5,6 +5,11 @@ import * as Ref from "effect/Ref"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; +import { + DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, + currentDesktopBootstrapToken, +} from "@t3tools/shared/desktopBootstrapToken"; +import * as TestClock from "effect/testing/TestClock"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; @@ -77,4 +82,66 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.strictEqual(yield* Ref.get(requestCount), 1); }), ); + + it.effect( + "exchanges the current window's token when the backend was launched with a secret", + () => + Effect.gen(function* () { + const presented = yield* Ref.make(null); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + return Ref.set(presented, new URLSearchParams(body).get("subject_token")).pipe( + Effect.as( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ), + ); + }), + ); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + currentConfig: Effect.succeedSome({ + ...config, + bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" }, + }), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + + // The first exchange happens a day after launch, past the launch token's windows. + yield* TestClock.setTime(DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1); + yield* Effect.gen(function* () { + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + return yield* auth.getBearerToken; + }).pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), + ), + ), + ); + + assert.strictEqual( + yield* Ref.get(presented), + currentDesktopBootstrapToken("desktop-secret", DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1), + ); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 33ba2e40334c3232f367ab91b3f595d5af37697b..51109f8a4ecf562e51ca7de102554179a51f7bf3 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,5 +1,7 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; +import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -64,7 +66,14 @@ export const make = Effect.gen(function* () { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } const config = configOption.value; - const credential = config.bootstrap.desktopBootstrapToken; + // A backend launched with the desktop secret accepts the current + // window's token, not the one frozen into its launch config; this + // exchange can run long after launch (e.g. after a suspend). + const secret = config.bootstrap.desktopBootstrapSecret; + const credential = + secret === undefined + ? config.bootstrap.desktopBootstrapToken + : currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis); if (!credential) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 1dd1ca8d0efb98d46840f45b7ed2618eef213aac..41d93ecc4969d582435fcabf44867bf6133c9940 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -19,6 +19,7 @@ vi.mock("electron", () => ({ BrowserWindow: { fromWebContents: ownerWindow }, })); +import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; @@ -71,6 +72,19 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { waitForReady: () => Effect.succeed(true), }; +const backendConfigurationLayer = Layer.succeed( + DesktopBackendConfiguration.DesktopBackendConfiguration, + { + resolvePrimary: Effect.die("unexpected resolvePrimary"), + resolvePrimaryLabel: Effect.succeed("Windows"), + resolveWsl: () => Effect.die("unexpected resolveWsl"), + currentBootstrapToken: Effect.succeed("current-window-token"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], +); + +const bootstrapsLayer = (instances: ReadonlyArray) => + Layer.merge(DesktopBackendPool.layerTest([...instances]), backendConfigurationLayer); + describe("getLocalEnvironmentBootstraps", () => { it.effect("publishes the concrete running distro without replacing the stable instance id", () => Effect.gen(function* () { @@ -86,7 +100,39 @@ describe("getLocalEnvironmentBootstraps", () => { bootstrapToken: "bootstrap-token", }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), + }).pipe(Effect.provide(bootstrapsLayer([defaultWslInstance]))), + ); + + it.effect("hands out the current window's token to a backend launched with the secret", () => + Effect.gen(function* () { + const result = yield* getLocalEnvironmentBootstraps.handler(); + + assert.deepEqual(result, [ + { + id: "wsl:default", + label: "WSL (Ubuntu)", + runningDistro: "Ubuntu", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + bootstrapToken: "current-window-token", + }, + ]); + }).pipe( + Effect.provide( + bootstrapsLayer([ + { + ...defaultWslInstance, + currentConfig: Effect.succeedSome({ + ...readyWslConfig, + bootstrap: { + ...readyWslConfig.bootstrap, + desktopBootstrapSecret: "desktop-secret", + }, + }), + }, + ]), + ), + ), ); it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { @@ -121,7 +167,7 @@ describe("getLocalEnvironmentBootstraps", () => { wsBaseUrl: null, }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); + }).pipe(Effect.provide(bootstrapsLayer([retryingInstance]))); }); it.effect("omits a bounded transient bootstrap after retries stop", () => { @@ -147,7 +193,7 @@ describe("getLocalEnvironmentBootstraps", () => { return Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); assert.deepEqual(result, []); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); + }).pipe(Effect.provide(bootstrapsLayer([stoppedInstance]))); }); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07ab1e3e0db56afcab93c3cf517ce563e..64e0566d3e708c68f70a095ce43fb5f894e99aaa 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -23,6 +23,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; @@ -95,6 +96,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ result: Schema.Array(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const instances = yield* pool.list; const bootstraps: DesktopEnvironmentBootstrap[] = []; for (const instance of instances) { @@ -142,9 +144,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - ...(bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), + // A backend launched with the desktop secret accepts whichever token + // the secret derives for the current window, so hand out that one + // rather than the token frozen into its launch config. Every backend + // the desktop launches (primary, staged or mounted WSL runtime) is the + // server build bundled with this desktop, so it understands the secret. + ...(bootstrap.desktopBootstrapSecret + ? { bootstrapToken: yield* configuration.currentBootstrapToken } + : bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), }); } return bootstraps; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index f98f41c3036e6de9057d02dad1e2614d4f05cf4b..8afc1bdd948b8c5bf0b808706d271d0b3ae23df8 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -71,6 +71,7 @@ const layerBackendConfiguration = Layer.succeed( resolvePrimary: Effect.die("unexpected resolvePrimary"), resolvePrimaryLabel: Effect.succeed("Windows"), resolveWsl: () => Effect.die("unexpected resolveWsl"), + currentBootstrapToken: Effect.die("unexpected currentBootstrapToken"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], ); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index c19d64b3b5a963b0f1f9a81e5314ef21ae97c08a..2103768d71f881fc848c874b54a3fecc3db9322b 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -6,6 +6,10 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as Queue from "effect/Queue"; import * as TestClock from "effect/testing/TestClock"; +import { + DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, + currentDesktopBootstrapToken, +} from "@t3tools/shared/desktopBootstrapToken"; import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; @@ -14,7 +18,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; const layerServerConfig = ( - overrides?: Partial>, + overrides?: Partial< + Pick + >, ) => Layer.effect( ServerConfig.ServerConfig, @@ -30,7 +36,9 @@ const layerServerConfig = ( ); const layerPairingGrantStore = ( - overrides?: Partial>, + overrides?: Partial< + Pick + >, ) => PairingGrantStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), @@ -195,6 +203,42 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { ), ); + it.effect("accepts rotating desktop bootstrap tokens derived from the desktop secret", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const window = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS; + + // A desktop open for days hands the renderer a fresh token each window. + yield* TestClock.adjust(Duration.days(5)); + const now = 5 * 24 * 60 * 60 * 1000; + const current = yield* bootstrapCredentials.consume( + currentDesktopBootstrapToken("desktop-secret", now), + ); + expect(current.method).toBe("desktop-bootstrap"); + + const stale = yield* Effect.flip( + bootstrapCredentials.consume( + currentDesktopBootstrapToken("desktop-secret", now - 2 * window), + ), + ); + expect(stale._tag).toBe("UnknownBootstrapCredentialError"); + + // The launch token is not accepted on its own once a secret is present. + const launch = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + expect(launch._tag).toBe("UnknownBootstrapCredentialError"); + }).pipe( + Effect.provide( + Layer.merge( + layerPairingGrantStore({ + desktopBootstrapToken: "desktop-bootstrap-token", + desktopBootstrapSecret: "desktop-secret", + }), + TestClock.layer(), + ), + ), + ), + ); + it.effect("keeps credentials out of pairing lists and change events", () => Effect.gen(function* () { const grants = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index ec27c0a4e1473f092af1dcd7059fb2e70c671b8c..322f60d76899a0cf645bb441f506512d7d57fce5 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -5,6 +5,10 @@ import { type AuthPairingLink, type ServerAuthBootstrapMethod, } from "@t3tools/contracts"; +import { + DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, + isValidDesktopBootstrapToken, +} from "@t3tools/shared/desktopBootstrapToken"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -309,7 +313,15 @@ export const make = Effect.gen(function* () { id, }).pipe(Effect.asVoid); - if (config.desktopBootstrapToken) { + // A desktop that sends its secret rotates the renderer's token, so accept + // whichever token the secret derives for the current window instead of + // seeding one fixed token. Older desktops only send the token. + const desktopBootstrapSecret = config.desktopBootstrapSecret; + const consumeRotatingDesktopToken = (credential: string, nowMs: number) => + desktopBootstrapSecret !== undefined && + isValidDesktopBootstrapToken(desktopBootstrapSecret, credential, nowMs); + + if (config.desktopBootstrapToken && desktopBootstrapSecret === undefined) { const now = yield* DateTime.now; yield* seedGrant(config.desktopBootstrapToken, { method: "desktop-bootstrap", @@ -432,6 +444,16 @@ export const make = Effect.gen(function* () { const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { const now = yield* DateTime.now; + if (consumeRotatingDesktopToken(credential, now.epochMilliseconds)) { + return { + method: "desktop-bootstrap", + scopes: AuthAdministrativeScopes, + subject: "desktop-bootstrap", + expiresAt: DateTime.add(now, { + milliseconds: DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, + }), + } satisfies BootstrapGrant; + } const seededResult: ConsumeResult = yield* Ref.modify( seededGrantsRef, (current): readonly [ConsumeResult, Map] => { diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 7fb547e1a69b85a30f8c09616ebdccc751b50071..dd40205fb283bdb49e6579194e0476bc342265f6 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -359,6 +359,7 @@ export const resolveServerConfig = ( () => mode === "desktop", ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; const resourceMonitorPath = bootstrap?.resourceMonitorPath; @@ -464,6 +465,7 @@ export const resolveServerConfig = ( noBrowser, startupPresentation, desktopBootstrapToken, + ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), desktopTelemetryFd, desktopTelemetryControlFd, resourceMonitorPath, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7fbe95bb21aba756b092dd5e8de7bf803047f29a..3fb8677a02a69091a3730ae5a5af68df963d47a1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -94,6 +94,7 @@ export class ServerConfig extends Context.Service< readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; + readonly desktopBootstrapSecret?: string | undefined; readonly desktopTelemetryFd?: number | undefined; readonly desktopTelemetryControlFd?: number | undefined; readonly resourceMonitorPath?: string | undefined; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index 31c604ffd7fe659c2aab1467fac2c35ea2f9289b..351fa97652cb3a378d4e82f07cfa1827d6a3d077 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -12,6 +12,11 @@ export const DesktopBackendBootstrap = Schema.Struct({ t3Home: Schema.optional(Schema.String), host: Schema.String, desktopBootstrapToken: Schema.String, + // Present when the desktop rotates the renderer's bootstrap token: the + // backend derives the accepted tokens from this secret instead of trusting + // `desktopBootstrapToken` for its whole run. See + // `@t3tools/shared/desktopBootstrapToken`. + desktopBootstrapSecret: Schema.optionalKey(Schema.String), tailscaleServeEnabled: Schema.Boolean, tailscaleServePort: PortSchema, otlpTracesUrl: Schema.optional(Schema.String), diff --git a/packages/shared/package.json b/packages/shared/package.json index 32607316f6c2156b8295bf7283410745c0566277..3732ebd5cfa67ae9d87459dc46bc480b44ee28e8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -167,6 +167,10 @@ "types": "./src/dpop.ts", "import": "./src/dpop.ts" }, + "./desktopBootstrapToken": { + "types": "./src/desktopBootstrapToken.ts", + "import": "./src/desktopBootstrapToken.ts" + }, "./dpopCommon": { "types": "./src/dpopCommon.ts", "import": "./src/dpopCommon.ts" diff --git a/packages/shared/src/desktopBootstrapToken.test.ts b/packages/shared/src/desktopBootstrapToken.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdf0199850d43b7404e858bfa2c72ee2a436e463 --- /dev/null +++ b/packages/shared/src/desktopBootstrapToken.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, + currentDesktopBootstrapToken, + isValidDesktopBootstrapToken, +} from "./desktopBootstrapToken.ts"; + +const SECRET = "desktop-secret"; +const WINDOW = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS; + +describe("desktop bootstrap token", () => { + it("rotates every window and stays accepted for one more", () => { + const issuedAt = 10 * WINDOW + 1; + const token = currentDesktopBootstrapToken(SECRET, issuedAt); + + expect(currentDesktopBootstrapToken(SECRET, issuedAt + WINDOW)).not.toBe(token); + expect(isValidDesktopBootstrapToken(SECRET, token, issuedAt)).toBe(true); + expect(isValidDesktopBootstrapToken(SECRET, token, issuedAt + WINDOW)).toBe(true); + expect(isValidDesktopBootstrapToken(SECRET, token, issuedAt + 2 * WINDOW)).toBe(false); + }); + + it("accepts a token from a desktop clock slightly ahead of the backend", () => { + // Windows crossed the boundary, the WSL clock has not yet. + const desktopNow = 10 * WINDOW + 500; + const backendNow = 10 * WINDOW - 500; + + expect( + isValidDesktopBootstrapToken( + SECRET, + currentDesktopBootstrapToken(SECRET, desktopNow), + backendNow, + ), + ).toBe(true); + expect( + isValidDesktopBootstrapToken( + SECRET, + currentDesktopBootstrapToken(SECRET, desktopNow + WINDOW), + backendNow, + ), + ).toBe(false); + }); + + it("rejects tokens derived from another secret", () => { + const token = currentDesktopBootstrapToken("other-secret", WINDOW); + + expect(isValidDesktopBootstrapToken(SECRET, token, WINDOW)).toBe(false); + expect(isValidDesktopBootstrapToken(SECRET, "short", WINDOW)).toBe(false); + }); +}); diff --git a/packages/shared/src/desktopBootstrapToken.ts b/packages/shared/src/desktopBootstrapToken.ts new file mode 100644 index 0000000000000000000000000000000000000000..b13f370e3fe2ef6d89f77d2cfa04481b4e92ab4f --- /dev/null +++ b/packages/shared/src/desktopBootstrapToken.ts @@ -0,0 +1,44 @@ +import * as NodeCrypto from "node:crypto"; + +/** + * The desktop app and the backends it launches share one secret, delivered + * over the backend's bootstrap channel and never sent to the renderer. Both + * sides derive the bootstrap token for a time window from it, so the token the + * renderer holds rotates every window without the desktop having to reach a + * running backend. A backend accepts the previous, current and next window's + * token: the previous one so a token works for between one and two windows, + * and the next one because a WSL backend's clock can trail the Windows clock + * that derived the token, which would otherwise reject every fresh token for + * a moment after each boundary. A token is still dead two windows after it + * was issued. + */ +export const DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS = 12 * 60 * 60 * 1000; + +function windowIndex(nowMs: number): number { + return Math.floor(nowMs / DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS); +} + +function deriveToken(secret: string, window: number): string { + return NodeCrypto.createHmac("sha256", secret) + .update(`t3-desktop-bootstrap:${window}`) + .digest("hex"); +} + +/** The token the desktop hands out at `nowMs`. */ +export function currentDesktopBootstrapToken(secret: string, nowMs: number): string { + return deriveToken(secret, windowIndex(nowMs)); +} + +/** Whether `token` is the previous, current or next window's token at `nowMs`. */ +export function isValidDesktopBootstrapToken( + secret: string, + token: string, + nowMs: number, +): boolean { + const presented = Buffer.from(token); + const current = windowIndex(nowMs); + return [current - 1, current, current + 1].some((window) => { + const expected = Buffer.from(deriveToken(secret, window)); + return expected.length === presented.length && NodeCrypto.timingSafeEqual(expected, presented); + }); +} -- 2.54.0