authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 19:38:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 19:38:14-07:00
log5afe18eaf9d20c1633b4ec9177c6c9309a8f3d99
treef2c6b6b4de44d1e76a81a1c2fdd31144aa119b06
parentf36df1852423fef7f7855f86946c9f9a95c3429f
signature Signed by PGP key B5690EEEBB952194

feat(desktop): the renderer's bootstrap token rotates every 12 hours (#16275)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

16 files changed, 369 insertions(+), 23 deletions(-)

apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+20-2
......@@ -1,5 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, describe, it } from "@effect/vitest";
3import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
4import * as Clock from "effect/Clock";
35import * as Effect from "effect/Effect";
46import * as FileSystem from "effect/FileSystem";
57import * as Layer from "effect/Layer";
......@@ -255,8 +257,24 @@ describe("DesktopBackendConfiguration", () => {
255257 assert.equal(first.bootstrap.t3Home, environment.baseDir);
256258 assert.equal(first.bootstrap.tailscaleServeEnabled, true);
257259 assert.equal(first.bootstrap.tailscaleServePort, 8443);
258 assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i);
259 assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken);
260 assert.match(first.bootstrap.desktopBootstrapSecret ?? "", /^[0-9a-f]{64}$/i);
261 assert.equal(
262 second.bootstrap.desktopBootstrapSecret,
263 first.bootstrap.desktopBootstrapSecret,
264 );
265 // The launch token is the secret's token for the current window, and
266 // the renderer is handed the same one.
267 assert.equal(
268 first.bootstrap.desktopBootstrapToken,
269 currentDesktopBootstrapToken(
270 first.bootstrap.desktopBootstrapSecret ?? "",
271 yield* Clock.currentTimeMillis,
272 ),
273 );
274 assert.equal(
275 yield* configuration.currentBootstrapToken,
276 first.bootstrap.desktopBootstrapToken,
277 );
260278 }),
261279 ),
262280 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+34-11
......@@ -1,6 +1,8 @@
11import * as NodeOS from "node:os";
22
33import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
5import * as Clock from "effect/Clock";
46import * as Context from "effect/Context";
57import * as Crypto from "effect/Crypto";
68import * as Effect from "effect/Effect";
......@@ -61,6 +63,10 @@ export class DesktopBackendConfiguration extends Context.Service<
6163 // fall-back to Windows), so the env switcher can't show "WSL" for a
6264 // backend that actually resolved to Windows.
6365 readonly resolvePrimaryLabel: Effect.Effect<string>;
66 // The bootstrap token the renderer should present right now. It rotates
67 // every window (derived from the secret every backend was launched with),
68 // so the renderer never holds one long-lived admin credential.
69 readonly currentBootstrapToken: Effect.Effect<string, PlatformError.PlatformError>;
6470 }
6571>()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}
6672
......@@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () {
258264
259265interface SharedBootstrapInput {
260266 readonly bootstrapToken: string;
267 readonly bootstrapSecret: string;
261268 readonly observabilitySettings: BackendObservabilitySettings;
262269}
263270
......@@ -556,6 +563,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
556563 t3Home: environment.baseDir,
557564 host: backendExposure.bindHost,
558565 desktopBootstrapToken: input.bootstrapToken,
566 desktopBootstrapSecret: input.bootstrapSecret,
559567 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
560568 tailscaleServePort: backendExposure.tailscaleServePort,
561569 desktopTelemetryFd: 4,
......@@ -635,6 +643,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
635643 // the SQLite file with the primary).
636644 host: wslBindHost,
637645 desktopBootstrapToken: input.bootstrapToken,
646 desktopBootstrapSecret: input.bootstrapSecret,
638647 // PortSchema rejects 0, so when tailscale serve is disabled we still
639648 // need a valid number in this slot. The backend reads tailscaleServePort
640649 // only when tailscaleServeEnabled is true, so the actual value here is
......@@ -831,19 +840,19 @@ export const make = Effect.gen(function* () {
831840 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
832841 // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can
833842 // resolve concurrently; with a plain Ref both could observe None, generate
834 // distinct tokens, and one would overwrite the other — leaving the two
835 // backends holding mismatched tokens and breaking the shared-token
843 // distinct secrets, and one would overwrite the other — leaving the two
844 // backends deriving mismatched tokens and breaking the shared-token
836845 // invariant the renderer relies on. modifyEffect serializes the whole
837 // get-or-create so the first caller wins and the rest reuse its token.
838 const tokenRef = yield* SynchronizedRef.make(Option.none<string>());
839 const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) =>
846 // get-or-create so the first caller wins and the rest reuse its secret.
847 const secretRef = yield* SynchronizedRef.make(Option.none<string>());
848 const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) =>
840849 Option.match(current, {
841 onSome: (token) => Effect.succeed([token, current] as const),
850 onSome: (secret) => Effect.succeed([secret, current] as const),
842851 onNone: () =>
843 crypto.randomBytes(24).pipe(
852 crypto.randomBytes(32).pipe(
844853 Effect.map((bytes) => {
845 const token = Hex.encode(bytes);
846 return [token, Option.some(token)] as const;
854 const secret = Hex.encode(bytes);
855 return [secret, Option.some(secret)] as const;
847856 }),
848857 ),
849858 }),
......@@ -855,12 +864,20 @@ export const make = Effect.gen(function* () {
855864 // hot-swap of the server-settings file is picked up on the next
856865 // restart cycle without having to bounce the desktop process.
857866 const sharedInputs = Effect.gen(function* () {
858 const bootstrapToken = yield* getOrCreateBootstrapToken;
867 const bootstrapSecret = yield* getOrCreateBootstrapSecret;
868 const bootstrapToken = currentDesktopBootstrapToken(
869 bootstrapSecret,
870 yield* Clock.currentTimeMillis,
871 );
859872 const observabilitySettings = yield* readBackendObservabilitySettings.pipe(
860873 Effect.provideService(FileSystem.FileSystem, fileSystem),
861874 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
862875 );
863 return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput;
876 return {
877 bootstrapToken,
878 bootstrapSecret,
879 observabilitySettings,
880 } satisfies SharedBootstrapInput;
864881 });
865882
866883 const buildWslPrimaryConfig = Effect.gen(function* () {
......@@ -918,7 +935,13 @@ export const make = Effect.gen(function* () {
918935 return { useWsl, wslRequested, distro: persistedSettings.wslDistro };
919936 });
920937
938 const currentBootstrapToken = Effect.gen(function* () {
939 const secret = yield* getOrCreateBootstrapSecret;
940 return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis);
941 });
942
921943 return DesktopBackendConfiguration.of({
944 currentBootstrapToken,
922945 resolvePrimary: Effect.gen(function* () {
923946 const { useWsl, wslRequested } = yield* describePrimary;
924947 if (useWsl) {
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
......@@ -79,6 +79,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
7979 resolvePrimary: Effect.die("unexpected primary config resolve"),
8080 resolvePrimaryLabel: Ref.get(labelRef),
8181 resolveWsl: () => Effect.die("unexpected WSL config resolve"),
82 currentBootstrapToken: Effect.die("unexpected bootstrap token read"),
8283 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),
8384 DesktopAppSettings.layerTest(),
8485 DesktopWslEnvironment.layerTest(),
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+67
......@@ -5,6 +5,11 @@ import * as Ref from "effect/Ref";
55import * as HttpClient from "effect/http/HttpClient";
66import * as HttpClientResponse from "effect/http/HttpClientResponse";
77import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
8import {
9 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
10 currentDesktopBootstrapToken,
11} from "@t3tools/shared/desktopBootstrapToken";
12import * as TestClock from "effect/testing/TestClock";
813
914import * as DesktopBackendPool from "./DesktopBackendPool.ts";
1015import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";
......@@ -77,4 +82,66 @@ describe("DesktopLocalEnvironmentAuth", () => {
7782 assert.strictEqual(yield* Ref.get(requestCount), 1);
7883 }),
7984 );
85
86 it.effect(
87 "exchanges the current window's token when the backend was launched with a secret",
88 () =>
89 Effect.gen(function* () {
90 const presented = yield* Ref.make<string | null>(null);
91 const httpClientLayer = Layer.succeed(
92 HttpClient.HttpClient,
93 HttpClient.make((request) => {
94 const body =
95 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
96 return Ref.set(presented, new URLSearchParams(body).get("subject_token")).pipe(
97 Effect.as(
98 HttpClientResponse.fromWeb(
99 request,
100 new Response(
101 JSON.stringify({
102 access_token: "desktop-bearer-token",
103 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
104 token_type: "Bearer",
105 expires_in: 3600,
106 scope: "orchestration:read",
107 }),
108 { status: 200, headers: { "content-type": "application/json" } },
109 ),
110 ),
111 ),
112 );
113 }),
114 );
115 const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
116 list: Effect.succeed([
117 {
118 id: PRIMARY_LOCAL_ENVIRONMENT_ID,
119 label: Effect.succeed("Windows"),
120 currentConfig: Effect.succeedSome({
121 ...config,
122 bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" },
123 }),
124 },
125 ]),
126 } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);
127
128 // The first exchange happens a day after launch, past the launch token's windows.
129 yield* TestClock.setTime(DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1);
130 yield* Effect.gen(function* () {
131 const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
132 return yield* auth.getBearerToken;
133 }).pipe(
134 Effect.provide(
135 DesktopLocalEnvironmentAuth.layer.pipe(
136 Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)),
137 ),
138 ),
139 );
140
141 assert.strictEqual(
142 yield* Ref.get(presented),
143 currentDesktopBootstrapToken("desktop-secret", DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1),
144 );
145 }).pipe(Effect.provide(TestClock.layer())),
146 );
80147});
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+10-1
......@@ -1,5 +1,7 @@
11import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
22import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
3import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
4import * as Clock from "effect/Clock";
35import * as Context from "effect/Context";
46import * as Effect from "effect/Effect";
57import * as Layer from "effect/Layer";
......@@ -64,7 +66,14 @@ export const make = Effect.gen(function* () {
6466 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
6567 }
6668 const config = configOption.value;
67 const credential = config.bootstrap.desktopBootstrapToken;
69 // A backend launched with the desktop secret accepts the current
70 // window's token, not the one frozen into its launch config; this
71 // exchange can run long after launch (e.g. after a suspend).
72 const secret = config.bootstrap.desktopBootstrapSecret;
73 const credential =
74 secret === undefined
75 ? config.bootstrap.desktopBootstrapToken
76 : currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis);
6877 if (!credential) {
6978 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
7079 }
apps/desktop/src/ipc/methods/window.test.ts+49-3
......@@ -19,6 +19,7 @@ vi.mock("electron", () => ({
1919 BrowserWindow: { fromWebContents: ownerWindow },
2020}));
2121
22import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
2223import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
2324import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
2425import * as ElectronDialog from "../../electron/ElectronDialog.ts";
......@@ -71,6 +72,19 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = {
7172 waitForReady: () => Effect.succeed(true),
7273};
7374
75const backendConfigurationLayer = Layer.succeed(
76 DesktopBackendConfiguration.DesktopBackendConfiguration,
77 {
78 resolvePrimary: Effect.die("unexpected resolvePrimary"),
79 resolvePrimaryLabel: Effect.succeed("Windows"),
80 resolveWsl: () => Effect.die("unexpected resolveWsl"),
81 currentBootstrapToken: Effect.succeed("current-window-token"),
82 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"],
83);
84
85const bootstrapsLayer = (instances: ReadonlyArray<DesktopBackendManager.DesktopBackendInstance>) =>
86 Layer.merge(DesktopBackendPool.layerTest([...instances]), backendConfigurationLayer);
87
7488describe("getLocalEnvironmentBootstraps", () => {
7589 it.effect("publishes the concrete running distro without replacing the stable instance id", () =>
7690 Effect.gen(function* () {
......@@ -86,7 +100,39 @@ describe("getLocalEnvironmentBootstraps", () => {
86100 bootstrapToken: "bootstrap-token",
87101 },
88102 ]);
89 }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))),
103 }).pipe(Effect.provide(bootstrapsLayer([defaultWslInstance]))),
104 );
105
106 it.effect("hands out the current window's token to a backend launched with the secret", () =>
107 Effect.gen(function* () {
108 const result = yield* getLocalEnvironmentBootstraps.handler();
109
110 assert.deepEqual(result, [
111 {
112 id: "wsl:default",
113 label: "WSL (Ubuntu)",
114 runningDistro: "Ubuntu",
115 httpBaseUrl: "http://127.0.0.1:3774/",
116 wsBaseUrl: "ws://127.0.0.1:3774/",
117 bootstrapToken: "current-window-token",
118 },
119 ]);
120 }).pipe(
121 Effect.provide(
122 bootstrapsLayer([
123 {
124 ...defaultWslInstance,
125 currentConfig: Effect.succeedSome({
126 ...readyWslConfig,
127 bootstrap: {
128 ...readyWslConfig.bootstrap,
129 desktopBootstrapSecret: "desktop-secret",
130 },
131 }),
132 },
133 ]),
134 ),
135 ),
90136 );
91137
92138 it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => {
......@@ -121,7 +167,7 @@ describe("getLocalEnvironmentBootstraps", () => {
121167 wsBaseUrl: null,
122168 },
123169 ]);
124 }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance])));
170 }).pipe(Effect.provide(bootstrapsLayer([retryingInstance])));
125171 });
126172
127173 it.effect("omits a bounded transient bootstrap after retries stop", () => {
......@@ -147,7 +193,7 @@ describe("getLocalEnvironmentBootstraps", () => {
147193 return Effect.gen(function* () {
148194 const result = yield* getLocalEnvironmentBootstraps.handler();
149195 assert.deepEqual(result, []);
150 }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance])));
196 }).pipe(Effect.provide(bootstrapsLayer([stoppedInstance])));
151197 });
152198});
153199
apps/desktop/src/ipc/methods/window.ts+12-3
......@@ -23,6 +23,7 @@ import * as Effect from "effect/Effect";
2323import * as Option from "effect/Option";
2424import * as Schema from "effect/Schema";
2525
26import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
2627import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
2728import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";
2829import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
......@@ -95,6 +96,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
9596 result: Schema.Array(DesktopEnvironmentBootstrapSchema),
9697 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () {
9798 const pool = yield* DesktopBackendPool.DesktopBackendPool;
99 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
98100 const instances = yield* pool.list;
99101 const bootstraps: DesktopEnvironmentBootstrap[] = [];
100102 for (const instance of instances) {
......@@ -142,9 +144,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
142144 runningDistro,
143145 httpBaseUrl: httpBaseUrl.href,
144146 wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),
145 ...(bootstrap.desktopBootstrapToken
146 ? { bootstrapToken: bootstrap.desktopBootstrapToken }
147 : {}),
147 // A backend launched with the desktop secret accepts whichever token
148 // the secret derives for the current window, so hand out that one
149 // rather than the token frozen into its launch config. Every backend
150 // the desktop launches (primary, staged or mounted WSL runtime) is the
151 // server build bundled with this desktop, so it understands the secret.
152 ...(bootstrap.desktopBootstrapSecret
153 ? { bootstrapToken: yield* configuration.currentBootstrapToken }
154 : bootstrap.desktopBootstrapToken
155 ? { bootstrapToken: bootstrap.desktopBootstrapToken }
156 : {}),
148157 });
149158 }
150159 return bootstraps;
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1
......@@ -71,6 +71,7 @@ const layerBackendConfiguration = Layer.succeed(
7171 resolvePrimary: Effect.die("unexpected resolvePrimary"),
7272 resolvePrimaryLabel: Effect.succeed("Windows"),
7373 resolveWsl: () => Effect.die("unexpected resolveWsl"),
74 currentBootstrapToken: Effect.die("unexpected currentBootstrapToken"),
7475 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"],
7576);
7677
apps/server/src/auth/PairingGrantStore.test.ts+46-2
......@@ -6,6 +6,10 @@ import * as Layer from "effect/Layer";
66import * as Stream from "effect/Stream";
77import * as Queue from "effect/Queue";
88import * as TestClock from "effect/testing/TestClock";
9import {
10 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
11 currentDesktopBootstrapToken,
12} from "@t3tools/shared/desktopBootstrapToken";
913
1014import * as ServerConfig from "../config.ts";
1115import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
......@@ -14,7 +18,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
1418import * as PairingGrantStore from "./PairingGrantStore.ts";
1519
1620const layerServerConfig = (
17 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
21 overrides?: Partial<
22 Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret">
23 >,
1824) =>
1925 Layer.effect(
2026 ServerConfig.ServerConfig,
......@@ -30,7 +36,9 @@ const layerServerConfig = (
3036 );
3137
3238const layerPairingGrantStore = (
33 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
39 overrides?: Partial<
40 Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret">
41 >,
3442) =>
3543 PairingGrantStore.layer.pipe(
3644 Layer.provide(SqlitePersistence.layerMemory),
......@@ -195,6 +203,42 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
195203 ),
196204 );
197205
206 it.effect("accepts rotating desktop bootstrap tokens derived from the desktop secret", () =>
207 Effect.gen(function* () {
208 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
209 const window = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS;
210
211 // A desktop open for days hands the renderer a fresh token each window.
212 yield* TestClock.adjust(Duration.days(5));
213 const now = 5 * 24 * 60 * 60 * 1000;
214 const current = yield* bootstrapCredentials.consume(
215 currentDesktopBootstrapToken("desktop-secret", now),
216 );
217 expect(current.method).toBe("desktop-bootstrap");
218
219 const stale = yield* Effect.flip(
220 bootstrapCredentials.consume(
221 currentDesktopBootstrapToken("desktop-secret", now - 2 * window),
222 ),
223 );
224 expect(stale._tag).toBe("UnknownBootstrapCredentialError");
225
226 // The launch token is not accepted on its own once a secret is present.
227 const launch = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
228 expect(launch._tag).toBe("UnknownBootstrapCredentialError");
229 }).pipe(
230 Effect.provide(
231 Layer.merge(
232 layerPairingGrantStore({
233 desktopBootstrapToken: "desktop-bootstrap-token",
234 desktopBootstrapSecret: "desktop-secret",
235 }),
236 TestClock.layer(),
237 ),
238 ),
239 ),
240 );
241
198242 it.effect("keeps credentials out of pairing lists and change events", () =>
199243 Effect.gen(function* () {
200244 const grants = yield* PairingGrantStore.PairingGrantStore;
apps/server/src/auth/PairingGrantStore.ts+23-1
......@@ -5,6 +5,10 @@ import {
55 type AuthPairingLink,
66 type ServerAuthBootstrapMethod,
77} from "@t3tools/contracts";
8import {
9 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
10 isValidDesktopBootstrapToken,
11} from "@t3tools/shared/desktopBootstrapToken";
812import * as Context from "effect/Context";
913import * as Crypto from "effect/Crypto";
1014import * as DateTime from "effect/DateTime";
......@@ -309,7 +313,15 @@ export const make = Effect.gen(function* () {
309313 id,
310314 }).pipe(Effect.asVoid);
311315
312 if (config.desktopBootstrapToken) {
316 // A desktop that sends its secret rotates the renderer's token, so accept
317 // whichever token the secret derives for the current window instead of
318 // seeding one fixed token. Older desktops only send the token.
319 const desktopBootstrapSecret = config.desktopBootstrapSecret;
320 const consumeRotatingDesktopToken = (credential: string, nowMs: number) =>
321 desktopBootstrapSecret !== undefined &&
322 isValidDesktopBootstrapToken(desktopBootstrapSecret, credential, nowMs);
323
324 if (config.desktopBootstrapToken && desktopBootstrapSecret === undefined) {
313325 const now = yield* DateTime.now;
314326 yield* seedGrant(config.desktopBootstrapToken, {
315327 method: "desktop-bootstrap",
......@@ -432,6 +444,16 @@ export const make = Effect.gen(function* () {
432444 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
433445 function* (credential, input) {
434446 const now = yield* DateTime.now;
447 if (consumeRotatingDesktopToken(credential, now.epochMilliseconds)) {
448 return {
449 method: "desktop-bootstrap",
450 scopes: AuthAdministrativeScopes,
451 subject: "desktop-bootstrap",
452 expiresAt: DateTime.add(now, {
453 milliseconds: DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
454 }),
455 } satisfies BootstrapGrant;
456 }
435457 const seededResult: ConsumeResult = yield* Ref.modify(
436458 seededGrantsRef,
437459 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
apps/server/src/cli/config.ts+2
......@@ -359,6 +359,7 @@ export const resolveServerConfig = (
359359 () => mode === "desktop",
360360 );
361361 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;
362363 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
363364 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
364365 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
......@@ -464,6 +465,7 @@ export const resolveServerConfig = (
464465 noBrowser,
465466 startupPresentation,
466467 desktopBootstrapToken,
468 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),
467469 desktopTelemetryFd,
468470 desktopTelemetryControlFd,
469471 resourceMonitorPath,
apps/server/src/config.ts+1
......@@ -94,6 +94,7 @@ export class ServerConfig extends Context.Service<
9494 readonly noBrowser: boolean;
9595 readonly startupPresentation: StartupPresentation;
9696 readonly desktopBootstrapToken: string | undefined;
97 readonly desktopBootstrapSecret?: string | undefined;
9798 readonly desktopTelemetryFd?: number | undefined;
9899 readonly desktopTelemetryControlFd?: number | undefined;
99100 readonly resourceMonitorPath?: string | undefined;
packages/contracts/src/desktopBootstrap.ts+5
......@@ -12,6 +12,11 @@ export const DesktopBackendBootstrap = Schema.Struct({
1212 t3Home: Schema.optional(Schema.String),
1313 host: Schema.String,
1414 desktopBootstrapToken: Schema.String,
15 // Present when the desktop rotates the renderer's bootstrap token: the
16 // backend derives the accepted tokens from this secret instead of trusting
17 // `desktopBootstrapToken` for its whole run. See
18 // `@t3tools/shared/desktopBootstrapToken`.
19 desktopBootstrapSecret: Schema.optionalKey(Schema.String),
1520 tailscaleServeEnabled: Schema.Boolean,
1621 tailscaleServePort: PortSchema,
1722 otlpTracesUrl: Schema.optional(Schema.String),
packages/shared/package.json+4
......@@ -167,6 +167,10 @@
167167 "types": "./src/dpop.ts",
168168 "import": "./src/dpop.ts"
169169 },
170 "./desktopBootstrapToken": {
171 "types": "./src/desktopBootstrapToken.ts",
172 "import": "./src/desktopBootstrapToken.ts"
173 },
170174 "./dpopCommon": {
171175 "types": "./src/dpopCommon.ts",
172176 "import": "./src/dpopCommon.ts"
packages/shared/src/desktopBootstrapToken.test.ts created+50
......@@ -0,0 +1,50 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
5 currentDesktopBootstrapToken,
6 isValidDesktopBootstrapToken,
7} from "./desktopBootstrapToken.ts";
8
9const SECRET = "desktop-secret";
10const WINDOW = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS;
11
12describe("desktop bootstrap token", () => {
13 it("rotates every window and stays accepted for one more", () => {
14 const issuedAt = 10 * WINDOW + 1;
15 const token = currentDesktopBootstrapToken(SECRET, issuedAt);
16
17 expect(currentDesktopBootstrapToken(SECRET, issuedAt + WINDOW)).not.toBe(token);
18 expect(isValidDesktopBootstrapToken(SECRET, token, issuedAt)).toBe(true);
19 expect(isValidDesktopBootstrapToken(SECRET, token, issuedAt + WINDOW)).toBe(true);
20 expect(isValidDesktopBootstrapToken(SECRET, token, issuedAt + 2 * WINDOW)).toBe(false);
21 });
22
23 it("accepts a token from a desktop clock slightly ahead of the backend", () => {
24 // Windows crossed the boundary, the WSL clock has not yet.
25 const desktopNow = 10 * WINDOW + 500;
26 const backendNow = 10 * WINDOW - 500;
27
28 expect(
29 isValidDesktopBootstrapToken(
30 SECRET,
31 currentDesktopBootstrapToken(SECRET, desktopNow),
32 backendNow,
33 ),
34 ).toBe(true);
35 expect(
36 isValidDesktopBootstrapToken(
37 SECRET,
38 currentDesktopBootstrapToken(SECRET, desktopNow + WINDOW),
39 backendNow,
40 ),
41 ).toBe(false);
42 });
43
44 it("rejects tokens derived from another secret", () => {
45 const token = currentDesktopBootstrapToken("other-secret", WINDOW);
46
47 expect(isValidDesktopBootstrapToken(SECRET, token, WINDOW)).toBe(false);
48 expect(isValidDesktopBootstrapToken(SECRET, "short", WINDOW)).toBe(false);
49 });
50});
packages/shared/src/desktopBootstrapToken.ts created+44
......@@ -0,0 +1,44 @@
1import * as NodeCrypto from "node:crypto";
2
3/**
4 * The desktop app and the backends it launches share one secret, delivered
5 * over the backend's bootstrap channel and never sent to the renderer. Both
6 * sides derive the bootstrap token for a time window from it, so the token the
7 * renderer holds rotates every window without the desktop having to reach a
8 * running backend. A backend accepts the previous, current and next window's
9 * token: the previous one so a token works for between one and two windows,
10 * and the next one because a WSL backend's clock can trail the Windows clock
11 * that derived the token, which would otherwise reject every fresh token for
12 * a moment after each boundary. A token is still dead two windows after it
13 * was issued.
14 */
15export const DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS = 12 * 60 * 60 * 1000;
16
17function windowIndex(nowMs: number): number {
18 return Math.floor(nowMs / DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS);
19}
20
21function deriveToken(secret: string, window: number): string {
22 return NodeCrypto.createHmac("sha256", secret)
23 .update(`t3-desktop-bootstrap:${window}`)
24 .digest("hex");
25}
26
27/** The token the desktop hands out at `nowMs`. */
28export function currentDesktopBootstrapToken(secret: string, nowMs: number): string {
29 return deriveToken(secret, windowIndex(nowMs));
30}
31
32/** Whether `token` is the previous, current or next window's token at `nowMs`. */
33export function isValidDesktopBootstrapToken(
34 secret: string,
35 token: string,
36 nowMs: number,
37): boolean {
38 const presented = Buffer.from(token);
39 const current = windowIndex(nowMs);
40 return [current - 1, current, current + 1].some((window) => {
41 const expected = Buffer.from(deriveToken(secret, window));
42 return expected.length === presented.length && NodeCrypto.timingSafeEqual(expected, presented);
43 });
44}