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 @@...@@ -1,5 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
4import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -255,8 +257,24 @@ describe("DesktopBackendConfiguration", () => {...@@ -255,8 +257,24 @@ describe("DesktopBackendConfiguration", () => {
255 assert.equal(first.bootstrap.t3Home, environment.baseDir);257 assert.equal(first.bootstrap.t3Home, environment.baseDir);
256 assert.equal(first.bootstrap.tailscaleServeEnabled, true);258 assert.equal(first.bootstrap.tailscaleServeEnabled, true);
257 assert.equal(first.bootstrap.tailscaleServePort, 8443);259 assert.equal(first.bootstrap.tailscaleServePort, 8443);
258 assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i);260 assert.match(first.bootstrap.desktopBootstrapSecret ?? "", /^[0-9a-f]{64}$/i);
259 assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken);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 );
260 }),278 }),
261 ),279 ),
262 );280 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+34-11
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import * as NodeOS from "node:os";1import * as NodeOS from "node:os";
22
3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
5import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";6import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";7import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -61,6 +63,10 @@ export class DesktopBackendConfiguration extends Context.Service<...@@ -61,6 +63,10 @@ export class DesktopBackendConfiguration extends Context.Service<
61 // fall-back to Windows), so the env switcher can't show "WSL" for a63 // fall-back to Windows), so the env switcher can't show "WSL" for a
62 // backend that actually resolved to Windows.64 // backend that actually resolved to Windows.
63 readonly resolvePrimaryLabel: Effect.Effect<string>;65 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>;
64 }70 }
65>()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}71>()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}
6672
...@@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () {...@@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () {
258264
259interface SharedBootstrapInput {265interface SharedBootstrapInput {
260 readonly bootstrapToken: string;266 readonly bootstrapToken: string;
267 readonly bootstrapSecret: string;
261 readonly observabilitySettings: BackendObservabilitySettings;268 readonly observabilitySettings: BackendObservabilitySettings;
262}269}
263270
...@@ -556,6 +563,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv...@@ -556,6 +563,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
556 t3Home: environment.baseDir,563 t3Home: environment.baseDir,
557 host: backendExposure.bindHost,564 host: backendExposure.bindHost,
558 desktopBootstrapToken: input.bootstrapToken,565 desktopBootstrapToken: input.bootstrapToken,
566 desktopBootstrapSecret: input.bootstrapSecret,
559 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,567 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
560 tailscaleServePort: backendExposure.tailscaleServePort,568 tailscaleServePort: backendExposure.tailscaleServePort,
561 desktopTelemetryFd: 4,569 desktopTelemetryFd: 4,
...@@ -635,6 +643,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -635,6 +643,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
635 // the SQLite file with the primary).643 // the SQLite file with the primary).
636 host: wslBindHost,644 host: wslBindHost,
637 desktopBootstrapToken: input.bootstrapToken,645 desktopBootstrapToken: input.bootstrapToken,
646 desktopBootstrapSecret: input.bootstrapSecret,
638 // PortSchema rejects 0, so when tailscale serve is disabled we still647 // PortSchema rejects 0, so when tailscale serve is disabled we still
639 // need a valid number in this slot. The backend reads tailscaleServePort648 // need a valid number in this slot. The backend reads tailscaleServePort
640 // only when tailscaleServeEnabled is true, so the actual value here is649 // only when tailscaleServeEnabled is true, so the actual value here is
...@@ -831,19 +840,19 @@ export const make = Effect.gen(function* () {...@@ -831,19 +840,19 @@ export const make = Effect.gen(function* () {
831 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.840 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
832 // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can841 // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can
833 // resolve concurrently; with a plain Ref both could observe None, generate842 // resolve concurrently; with a plain Ref both could observe None, generate
834 // distinct tokens, and one would overwrite the other — leaving the two843 // distinct secrets, and one would overwrite the other — leaving the two
835 // backends holding mismatched tokens and breaking the shared-token844 // backends deriving mismatched tokens and breaking the shared-token
836 // invariant the renderer relies on. modifyEffect serializes the whole845 // invariant the renderer relies on. modifyEffect serializes the whole
837 // get-or-create so the first caller wins and the rest reuse its token.846 // get-or-create so the first caller wins and the rest reuse its secret.
838 const tokenRef = yield* SynchronizedRef.make(Option.none<string>());847 const secretRef = yield* SynchronizedRef.make(Option.none<string>());
839 const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) =>848 const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) =>
840 Option.match(current, {849 Option.match(current, {
841 onSome: (token) => Effect.succeed([token, current] as const),850 onSome: (secret) => Effect.succeed([secret, current] as const),
842 onNone: () =>851 onNone: () =>
843 crypto.randomBytes(24).pipe(852 crypto.randomBytes(32).pipe(
844 Effect.map((bytes) => {853 Effect.map((bytes) => {
845 const token = Hex.encode(bytes);854 const secret = Hex.encode(bytes);
846 return [token, Option.some(token)] as const;855 return [secret, Option.some(secret)] as const;
847 }),856 }),
848 ),857 ),
849 }),858 }),
...@@ -855,12 +864,20 @@ export const make = Effect.gen(function* () {...@@ -855,12 +864,20 @@ export const make = Effect.gen(function* () {
855 // hot-swap of the server-settings file is picked up on the next864 // hot-swap of the server-settings file is picked up on the next
856 // restart cycle without having to bounce the desktop process.865 // restart cycle without having to bounce the desktop process.
857 const sharedInputs = Effect.gen(function* () {866 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 );
859 const observabilitySettings = yield* readBackendObservabilitySettings.pipe(872 const observabilitySettings = yield* readBackendObservabilitySettings.pipe(
860 Effect.provideService(FileSystem.FileSystem, fileSystem),873 Effect.provideService(FileSystem.FileSystem, fileSystem),
861 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),874 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
862 );875 );
863 return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput;876 return {
877 bootstrapToken,
878 bootstrapSecret,
879 observabilitySettings,
880 } satisfies SharedBootstrapInput;
864 });881 });
865882
866 const buildWslPrimaryConfig = Effect.gen(function* () {883 const buildWslPrimaryConfig = Effect.gen(function* () {
...@@ -918,7 +935,13 @@ export const make = Effect.gen(function* () {...@@ -918,7 +935,13 @@ export const make = Effect.gen(function* () {
918 return { useWsl, wslRequested, distro: persistedSettings.wslDistro };935 return { useWsl, wslRequested, distro: persistedSettings.wslDistro };
919 });936 });
920937
938 const currentBootstrapToken = Effect.gen(function* () {
939 const secret = yield* getOrCreateBootstrapSecret;
940 return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis);
941 });
942
921 return DesktopBackendConfiguration.of({943 return DesktopBackendConfiguration.of({
944 currentBootstrapToken,
922 resolvePrimary: Effect.gen(function* () {945 resolvePrimary: Effect.gen(function* () {
923 const { useWsl, wslRequested } = yield* describePrimary;946 const { useWsl, wslRequested } = yield* describePrimary;
924 if (useWsl) {947 if (useWsl) {
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
...@@ -79,6 +79,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De...@@ -79,6 +79,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
79 resolvePrimary: Effect.die("unexpected primary config resolve"),79 resolvePrimary: Effect.die("unexpected primary config resolve"),
80 resolvePrimaryLabel: Ref.get(labelRef),80 resolvePrimaryLabel: Ref.get(labelRef),
81 resolveWsl: () => Effect.die("unexpected WSL config resolve"),81 resolveWsl: () => Effect.die("unexpected WSL config resolve"),
82 currentBootstrapToken: Effect.die("unexpected bootstrap token read"),
82 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),83 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),
83 DesktopAppSettings.layerTest(),84 DesktopAppSettings.layerTest(),
84 DesktopWslEnvironment.layerTest(),85 DesktopWslEnvironment.layerTest(),
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+67
...@@ -5,6 +5,11 @@ import * as Ref from "effect/Ref";...@@ -5,6 +5,11 @@ import * as Ref from "effect/Ref";
5import * as HttpClient from "effect/http/HttpClient";5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientResponse from "effect/http/HttpClientResponse";6import * as HttpClientResponse from "effect/http/HttpClientResponse";
7import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";7import { 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
9import * as DesktopBackendPool from "./DesktopBackendPool.ts";14import * as DesktopBackendPool from "./DesktopBackendPool.ts";
10import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";15import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";
...@@ -77,4 +82,66 @@ describe("DesktopLocalEnvironmentAuth", () => {...@@ -77,4 +82,66 @@ describe("DesktopLocalEnvironmentAuth", () => {
77 assert.strictEqual(yield* Ref.get(requestCount), 1);82 assert.strictEqual(yield* Ref.get(requestCount), 1);
78 }),83 }),
79 );84 );
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 );
80});147});
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+10-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";1import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
2import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";2import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
3import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
4import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";5import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -64,7 +66,14 @@ export const make = Effect.gen(function* () {...@@ -64,7 +66,14 @@ export const make = Effect.gen(function* () {
64 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();66 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
65 }67 }
66 const config = configOption.value;68 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);
68 if (!credential) {77 if (!credential) {
69 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();78 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
70 }79 }
apps/desktop/src/ipc/methods/window.test.ts+49-3
...@@ -19,6 +19,7 @@ vi.mock("electron", () => ({...@@ -19,6 +19,7 @@ vi.mock("electron", () => ({
19 BrowserWindow: { fromWebContents: ownerWindow },19 BrowserWindow: { fromWebContents: ownerWindow },
20}));20}));
2121
22import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
22import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";23import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
23import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";24import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
24import * as ElectronDialog from "../../electron/ElectronDialog.ts";25import * as ElectronDialog from "../../electron/ElectronDialog.ts";
...@@ -71,6 +72,19 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = {...@@ -71,6 +72,19 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = {
71 waitForReady: () => Effect.succeed(true),72 waitForReady: () => Effect.succeed(true),
72};73};
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
74describe("getLocalEnvironmentBootstraps", () => {88describe("getLocalEnvironmentBootstraps", () => {
75 it.effect("publishes the concrete running distro without replacing the stable instance id", () =>89 it.effect("publishes the concrete running distro without replacing the stable instance id", () =>
76 Effect.gen(function* () {90 Effect.gen(function* () {
...@@ -86,7 +100,39 @@ describe("getLocalEnvironmentBootstraps", () => {...@@ -86,7 +100,39 @@ describe("getLocalEnvironmentBootstraps", () => {
86 bootstrapToken: "bootstrap-token",100 bootstrapToken: "bootstrap-token",
87 },101 },
88 ]);102 ]);
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 ),
90 );136 );
91137
92 it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => {138 it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => {
...@@ -121,7 +167,7 @@ describe("getLocalEnvironmentBootstraps", () => {...@@ -121,7 +167,7 @@ describe("getLocalEnvironmentBootstraps", () => {
121 wsBaseUrl: null,167 wsBaseUrl: null,
122 },168 },
123 ]);169 ]);
124 }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance])));170 }).pipe(Effect.provide(bootstrapsLayer([retryingInstance])));
125 });171 });
126172
127 it.effect("omits a bounded transient bootstrap after retries stop", () => {173 it.effect("omits a bounded transient bootstrap after retries stop", () => {
...@@ -147,7 +193,7 @@ describe("getLocalEnvironmentBootstraps", () => {...@@ -147,7 +193,7 @@ describe("getLocalEnvironmentBootstraps", () => {
147 return Effect.gen(function* () {193 return Effect.gen(function* () {
148 const result = yield* getLocalEnvironmentBootstraps.handler();194 const result = yield* getLocalEnvironmentBootstraps.handler();
149 assert.deepEqual(result, []);195 assert.deepEqual(result, []);
150 }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance])));196 }).pipe(Effect.provide(bootstrapsLayer([stoppedInstance])));
151 });197 });
152});198});
153199
apps/desktop/src/ipc/methods/window.ts+12-3
...@@ -23,6 +23,7 @@ import * as Effect from "effect/Effect";...@@ -23,6 +23,7 @@ import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";23import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
2525
26import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
26import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";27import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
27import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";28import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";
28import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";29import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
...@@ -95,6 +96,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({...@@ -95,6 +96,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
95 result: Schema.Array(DesktopEnvironmentBootstrapSchema),96 result: Schema.Array(DesktopEnvironmentBootstrapSchema),
96 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () {97 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () {
97 const pool = yield* DesktopBackendPool.DesktopBackendPool;98 const pool = yield* DesktopBackendPool.DesktopBackendPool;
99 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
98 const instances = yield* pool.list;100 const instances = yield* pool.list;
99 const bootstraps: DesktopEnvironmentBootstrap[] = [];101 const bootstraps: DesktopEnvironmentBootstrap[] = [];
100 for (const instance of instances) {102 for (const instance of instances) {
...@@ -142,9 +144,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({...@@ -142,9 +144,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
142 runningDistro,144 runningDistro,
143 httpBaseUrl: httpBaseUrl.href,145 httpBaseUrl: httpBaseUrl.href,
144 wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),146 wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),
145 ...(bootstrap.desktopBootstrapToken147 // A backend launched with the desktop secret accepts whichever token
146 ? { bootstrapToken: bootstrap.desktopBootstrapToken }148 // the secret derives for the current window, so hand out that one
147 : {}),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 : {}),
148 });157 });
149 }158 }
150 return bootstraps;159 return bootstraps;
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1
...@@ -71,6 +71,7 @@ const layerBackendConfiguration = Layer.succeed(...@@ -71,6 +71,7 @@ const layerBackendConfiguration = Layer.succeed(
71 resolvePrimary: Effect.die("unexpected resolvePrimary"),71 resolvePrimary: Effect.die("unexpected resolvePrimary"),
72 resolvePrimaryLabel: Effect.succeed("Windows"),72 resolvePrimaryLabel: Effect.succeed("Windows"),
73 resolveWsl: () => Effect.die("unexpected resolveWsl"),73 resolveWsl: () => Effect.die("unexpected resolveWsl"),
74 currentBootstrapToken: Effect.die("unexpected currentBootstrapToken"),
74 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"],75 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"],
75);76);
7677
apps/server/src/auth/PairingGrantStore.test.ts+46-2
...@@ -6,6 +6,10 @@ import * as Layer from "effect/Layer";...@@ -6,6 +6,10 @@ import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";6import * as Stream from "effect/Stream";
7import * as Queue from "effect/Queue";7import * as Queue from "effect/Queue";
8import * as TestClock from "effect/testing/TestClock";8import * as TestClock from "effect/testing/TestClock";
9import {
10 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
11 currentDesktopBootstrapToken,
12} from "@t3tools/shared/desktopBootstrapToken";
913
10import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
11import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";15import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
...@@ -14,7 +18,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";...@@ -14,7 +18,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
14import * as PairingGrantStore from "./PairingGrantStore.ts";18import * as PairingGrantStore from "./PairingGrantStore.ts";
1519
16const layerServerConfig = (20const layerServerConfig = (
17 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,21 overrides?: Partial<
22 Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret">
23 >,
18) =>24) =>
19 Layer.effect(25 Layer.effect(
20 ServerConfig.ServerConfig,26 ServerConfig.ServerConfig,
...@@ -30,7 +36,9 @@ const layerServerConfig = (...@@ -30,7 +36,9 @@ const layerServerConfig = (
30 );36 );
3137
32const layerPairingGrantStore = (38const layerPairingGrantStore = (
33 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,39 overrides?: Partial<
40 Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret">
41 >,
34) =>42) =>
35 PairingGrantStore.layer.pipe(43 PairingGrantStore.layer.pipe(
36 Layer.provide(SqlitePersistence.layerMemory),44 Layer.provide(SqlitePersistence.layerMemory),
...@@ -195,6 +203,42 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -195,6 +203,42 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
195 ),203 ),
196 );204 );
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
198 it.effect("keeps credentials out of pairing lists and change events", () =>242 it.effect("keeps credentials out of pairing lists and change events", () =>
199 Effect.gen(function* () {243 Effect.gen(function* () {
200 const grants = yield* PairingGrantStore.PairingGrantStore;244 const grants = yield* PairingGrantStore.PairingGrantStore;
apps/server/src/auth/PairingGrantStore.ts+23-1
...@@ -5,6 +5,10 @@ import {...@@ -5,6 +5,10 @@ import {
5 type AuthPairingLink,5 type AuthPairingLink,
6 type ServerAuthBootstrapMethod,6 type ServerAuthBootstrapMethod,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {
9 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
10 isValidDesktopBootstrapToken,
11} from "@t3tools/shared/desktopBootstrapToken";
8import * as Context from "effect/Context";12import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";13import * as Crypto from "effect/Crypto";
10import * as DateTime from "effect/DateTime";14import * as DateTime from "effect/DateTime";
...@@ -309,7 +313,15 @@ export const make = Effect.gen(function* () {...@@ -309,7 +313,15 @@ export const make = Effect.gen(function* () {
309 id,313 id,
310 }).pipe(Effect.asVoid);314 }).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) {
313 const now = yield* DateTime.now;325 const now = yield* DateTime.now;
314 yield* seedGrant(config.desktopBootstrapToken, {326 yield* seedGrant(config.desktopBootstrapToken, {
315 method: "desktop-bootstrap",327 method: "desktop-bootstrap",
...@@ -432,6 +444,16 @@ export const make = Effect.gen(function* () {...@@ -432,6 +444,16 @@ export const make = Effect.gen(function* () {
432 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(444 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
433 function* (credential, input) {445 function* (credential, input) {
434 const now = yield* DateTime.now;446 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 }
435 const seededResult: ConsumeResult = yield* Ref.modify(457 const seededResult: ConsumeResult = yield* Ref.modify(
436 seededGrantsRef,458 seededGrantsRef,
437 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {459 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
apps/server/src/cli/config.ts+2
...@@ -359,6 +359,7 @@ export const resolveServerConfig = (...@@ -359,6 +359,7 @@ export const resolveServerConfig = (
359 () => mode === "desktop",359 () => mode === "desktop",
360 );360 );
361 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;361 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;
362 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;363 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
363 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;364 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
364 const resourceMonitorPath = bootstrap?.resourceMonitorPath;365 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
...@@ -464,6 +465,7 @@ export const resolveServerConfig = (...@@ -464,6 +465,7 @@ export const resolveServerConfig = (
464 noBrowser,465 noBrowser,
465 startupPresentation,466 startupPresentation,
466 desktopBootstrapToken,467 desktopBootstrapToken,
468 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),
467 desktopTelemetryFd,469 desktopTelemetryFd,
468 desktopTelemetryControlFd,470 desktopTelemetryControlFd,
469 resourceMonitorPath,471 resourceMonitorPath,
apps/server/src/config.ts+1
...@@ -94,6 +94,7 @@ export class ServerConfig extends Context.Service<...@@ -94,6 +94,7 @@ export class ServerConfig extends Context.Service<
94 readonly noBrowser: boolean;94 readonly noBrowser: boolean;
95 readonly startupPresentation: StartupPresentation;95 readonly startupPresentation: StartupPresentation;
96 readonly desktopBootstrapToken: string | undefined;96 readonly desktopBootstrapToken: string | undefined;
97 readonly desktopBootstrapSecret?: string | undefined;
97 readonly desktopTelemetryFd?: number | undefined;98 readonly desktopTelemetryFd?: number | undefined;
98 readonly desktopTelemetryControlFd?: number | undefined;99 readonly desktopTelemetryControlFd?: number | undefined;
99 readonly resourceMonitorPath?: string | undefined;100 readonly resourceMonitorPath?: string | undefined;
packages/contracts/src/desktopBootstrap.ts+5
...@@ -12,6 +12,11 @@ export const DesktopBackendBootstrap = Schema.Struct({...@@ -12,6 +12,11 @@ export const DesktopBackendBootstrap = Schema.Struct({
12 t3Home: Schema.optional(Schema.String),12 t3Home: Schema.optional(Schema.String),
13 host: Schema.String,13 host: Schema.String,
14 desktopBootstrapToken: Schema.String,14 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),
15 tailscaleServeEnabled: Schema.Boolean,20 tailscaleServeEnabled: Schema.Boolean,
16 tailscaleServePort: PortSchema,21 tailscaleServePort: PortSchema,
17 otlpTracesUrl: Schema.optional(Schema.String),22 otlpTracesUrl: Schema.optional(Schema.String),
packages/shared/package.json+4
...@@ -167,6 +167,10 @@...@@ -167,6 +167,10 @@
167 "types": "./src/dpop.ts",167 "types": "./src/dpop.ts",
168 "import": "./src/dpop.ts"168 "import": "./src/dpop.ts"
169 },169 },
170 "./desktopBootstrapToken": {
171 "types": "./src/desktopBootstrapToken.ts",
172 "import": "./src/desktopBootstrapToken.ts"
173 },
170 "./dpopCommon": {174 "./dpopCommon": {
171 "types": "./src/dpopCommon.ts",175 "types": "./src/dpopCommon.ts",
172 "import": "./src/dpopCommon.ts"176 "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}