| author | |
| committer | |
| log | 5afe18eaf9d20c1633b4ec9177c6c9309a8f3d99 |
| tree | f2c6b6b4de44d1e76a81a1c2fdd31144aa119b06 |
| parent | f36df1852423fef7f7855f86946c9f9a95c3429f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | |
| 4 | import * as Clock from "effect/Clock"; | |
| 3 | 5 | import * as Effect from "effect/Effect"; |
| 4 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -255,8 +257,24 @@ describe("DesktopBackendConfiguration", () => { |
| 255 | 257 | assert.equal(first.bootstrap.t3Home, environment.baseDir); |
| 256 | 258 | assert.equal(first.bootstrap.tailscaleServeEnabled, true); |
| 257 | 259 | 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 | ); | |
| 260 | 278 | }), |
| 261 | 279 | ), |
| 262 | 280 | ); |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+34-11| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import * as NodeOS from "node:os"; |
| 2 | 2 | |
| 3 | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 4 | 6 | import * as Context from "effect/Context"; |
| 5 | 7 | import * as Crypto from "effect/Crypto"; |
| 6 | 8 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -61,6 +63,10 @@ export class DesktopBackendConfiguration extends Context.Service< |
| 61 | 63 | // fall-back to Windows), so the env switcher can't show "WSL" for a |
| 62 | 64 | // backend that actually resolved to Windows. |
| 63 | 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 | 71 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} |
| 66 | 72 | |
| ... | ... | @@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () { |
| 258 | 264 | |
| 259 | 265 | interface SharedBootstrapInput { |
| 260 | 266 | readonly bootstrapToken: string; |
| 267 | readonly bootstrapSecret: string; | |
| 261 | 268 | readonly observabilitySettings: BackendObservabilitySettings; |
| 262 | 269 | } |
| 263 | 270 | |
| ... | ... | @@ -556,6 +563,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 556 | 563 | t3Home: environment.baseDir, |
| 557 | 564 | host: backendExposure.bindHost, |
| 558 | 565 | desktopBootstrapToken: input.bootstrapToken, |
| 566 | desktopBootstrapSecret: input.bootstrapSecret, | |
| 559 | 567 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, |
| 560 | 568 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 561 | 569 | desktopTelemetryFd: 4, |
| ... | ... | @@ -635,6 +643,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 635 | 643 | // the SQLite file with the primary). |
| 636 | 644 | host: wslBindHost, |
| 637 | 645 | desktopBootstrapToken: input.bootstrapToken, |
| 646 | desktopBootstrapSecret: input.bootstrapSecret, | |
| 638 | 647 | // PortSchema rejects 0, so when tailscale serve is disabled we still |
| 639 | 648 | // need a valid number in this slot. The backend reads tailscaleServePort |
| 640 | 649 | // only when tailscaleServeEnabled is true, so the actual value here is |
| ... | ... | @@ -831,19 +840,19 @@ export const make = Effect.gen(function* () { |
| 831 | 840 | // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. |
| 832 | 841 | // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can |
| 833 | 842 | // 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 | |
| 836 | 845 | // 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) => | |
| 840 | 849 | Option.match(current, { |
| 841 | onSome: (token) => Effect.succeed([token, current] as const), | |
| 850 | onSome: (secret) => Effect.succeed([secret, current] as const), | |
| 842 | 851 | onNone: () => |
| 843 | crypto.randomBytes(24).pipe( | |
| 852 | crypto.randomBytes(32).pipe( | |
| 844 | 853 | 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; | |
| 847 | 856 | }), |
| 848 | 857 | ), |
| 849 | 858 | }), |
| ... | ... | @@ -855,12 +864,20 @@ export const make = Effect.gen(function* () { |
| 855 | 864 | // hot-swap of the server-settings file is picked up on the next |
| 856 | 865 | // restart cycle without having to bounce the desktop process. |
| 857 | 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 | 872 | const observabilitySettings = yield* readBackendObservabilitySettings.pipe( |
| 860 | 873 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 861 | 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 | }); |
| 865 | 882 | |
| 866 | 883 | const buildWslPrimaryConfig = Effect.gen(function* () { |
| ... | ... | @@ -918,7 +935,13 @@ export const make = Effect.gen(function* () { |
| 918 | 935 | return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; |
| 919 | 936 | }); |
| 920 | 937 | |
| 938 | const currentBootstrapToken = Effect.gen(function* () { | |
| 939 | const secret = yield* getOrCreateBootstrapSecret; | |
| 940 | return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis); | |
| 941 | }); | |
| 942 | ||
| 921 | 943 | return DesktopBackendConfiguration.of({ |
| 944 | currentBootstrapToken, | |
| 922 | 945 | resolvePrimary: Effect.gen(function* () { |
| 923 | 946 | const { useWsl, wslRequested } = yield* describePrimary; |
| 924 | 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 | 79 | resolvePrimary: Effect.die("unexpected primary config resolve"), |
| 80 | 80 | resolvePrimaryLabel: Ref.get(labelRef), |
| 81 | 81 | resolveWsl: () => Effect.die("unexpected WSL config resolve"), |
| 82 | currentBootstrapToken: Effect.die("unexpected bootstrap token read"), | |
| 82 | 83 | } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), |
| 83 | 84 | DesktopAppSettings.layerTest(), |
| 84 | 85 | DesktopWslEnvironment.layerTest(), |
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+67| ... | ... | @@ -5,6 +5,11 @@ import * as Ref from "effect/Ref"; |
| 5 | 5 | import * as HttpClient from "effect/http/HttpClient"; |
| 6 | 6 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 7 | 7 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 8 | import { | |
| 9 | DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, | |
| 10 | currentDesktopBootstrapToken, | |
| 11 | } from "@t3tools/shared/desktopBootstrapToken"; | |
| 12 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | 13 | |
| 9 | 14 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; |
| 10 | 15 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; |
| ... | ... | @@ -77,4 +82,66 @@ describe("DesktopLocalEnvironmentAuth", () => { |
| 77 | 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 | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | 2 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 3 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | |
| 4 | import * as Clock from "effect/Clock"; | |
| 3 | 5 | import * as Context from "effect/Context"; |
| 4 | 6 | import * as Effect from "effect/Effect"; |
| 5 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -64,7 +66,14 @@ export const make = Effect.gen(function* () { |
| 64 | 66 | return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); |
| 65 | 67 | } |
| 66 | 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 | 77 | if (!credential) { |
| 69 | 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 | 19 | BrowserWindow: { fromWebContents: ownerWindow }, |
| 20 | 20 | })); |
| 21 | 21 | |
| 22 | import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; | |
| 22 | 23 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 23 | 24 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 24 | 25 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| ... | ... | @@ -71,6 +72,19 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { |
| 71 | 72 | waitForReady: () => Effect.succeed(true), |
| 72 | 73 | }; |
| 73 | 74 | |
| 75 | const 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 | ||
| 85 | const bootstrapsLayer = (instances: ReadonlyArray<DesktopBackendManager.DesktopBackendInstance>) => | |
| 86 | Layer.merge(DesktopBackendPool.layerTest([...instances]), backendConfigurationLayer); | |
| 87 | ||
| 74 | 88 | describe("getLocalEnvironmentBootstraps", () => { |
| 75 | 89 | it.effect("publishes the concrete running distro without replacing the stable instance id", () => |
| 76 | 90 | Effect.gen(function* () { |
| ... | ... | @@ -86,7 +100,39 @@ describe("getLocalEnvironmentBootstraps", () => { |
| 86 | 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 | ); |
| 91 | 137 | |
| 92 | 138 | it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { |
| ... | ... | @@ -121,7 +167,7 @@ describe("getLocalEnvironmentBootstraps", () => { |
| 121 | 167 | wsBaseUrl: null, |
| 122 | 168 | }, |
| 123 | 169 | ]); |
| 124 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); | |
| 170 | }).pipe(Effect.provide(bootstrapsLayer([retryingInstance]))); | |
| 125 | 171 | }); |
| 126 | 172 | |
| 127 | 173 | it.effect("omits a bounded transient bootstrap after retries stop", () => { |
| ... | ... | @@ -147,7 +193,7 @@ describe("getLocalEnvironmentBootstraps", () => { |
| 147 | 193 | return Effect.gen(function* () { |
| 148 | 194 | const result = yield* getLocalEnvironmentBootstraps.handler(); |
| 149 | 195 | assert.deepEqual(result, []); |
| 150 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); | |
| 196 | }).pipe(Effect.provide(bootstrapsLayer([stoppedInstance]))); | |
| 151 | 197 | }); |
| 152 | 198 | }); |
| 153 | 199 |
apps/desktop/src/ipc/methods/window.ts+12-3| ... | ... | @@ -23,6 +23,7 @@ import * as Effect from "effect/Effect"; |
| 23 | 23 | import * as Option from "effect/Option"; |
| 24 | 24 | import * as Schema from "effect/Schema"; |
| 25 | 25 | |
| 26 | import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; | |
| 26 | 27 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 27 | 28 | import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; |
| 28 | 29 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; |
| ... | ... | @@ -95,6 +96,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ |
| 95 | 96 | result: Schema.Array(DesktopEnvironmentBootstrapSchema), |
| 96 | 97 | handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { |
| 97 | 98 | const pool = yield* DesktopBackendPool.DesktopBackendPool; |
| 99 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 98 | 100 | const instances = yield* pool.list; |
| 99 | 101 | const bootstraps: DesktopEnvironmentBootstrap[] = []; |
| 100 | 102 | for (const instance of instances) { |
| ... | ... | @@ -142,9 +144,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ |
| 142 | 144 | runningDistro, |
| 143 | 145 | httpBaseUrl: httpBaseUrl.href, |
| 144 | 146 | 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 | : {}), | |
| 148 | 157 | }); |
| 149 | 158 | } |
| 150 | 159 | return bootstraps; |
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1| ... | ... | @@ -71,6 +71,7 @@ const layerBackendConfiguration = Layer.succeed( |
| 71 | 71 | resolvePrimary: Effect.die("unexpected resolvePrimary"), |
| 72 | 72 | resolvePrimaryLabel: Effect.succeed("Windows"), |
| 73 | 73 | resolveWsl: () => Effect.die("unexpected resolveWsl"), |
| 74 | currentBootstrapToken: Effect.die("unexpected currentBootstrapToken"), | |
| 74 | 75 | } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], |
| 75 | 76 | ); |
| 76 | 77 |
apps/server/src/auth/PairingGrantStore.test.ts+46-2| ... | ... | @@ -6,6 +6,10 @@ import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Stream from "effect/Stream"; |
| 7 | 7 | import * as Queue from "effect/Queue"; |
| 8 | 8 | import * as TestClock from "effect/testing/TestClock"; |
| 9 | import { | |
| 10 | DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, | |
| 11 | currentDesktopBootstrapToken, | |
| 12 | } from "@t3tools/shared/desktopBootstrapToken"; | |
| 9 | 13 | |
| 10 | 14 | import * as ServerConfig from "../config.ts"; |
| 11 | 15 | import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; |
| ... | ... | @@ -14,7 +18,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 14 | 18 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 15 | 19 | |
| 16 | 20 | const layerServerConfig = ( |
| 17 | overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>, | |
| 21 | overrides?: Partial< | |
| 22 | Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret"> | |
| 23 | >, | |
| 18 | 24 | ) => |
| 19 | 25 | Layer.effect( |
| 20 | 26 | ServerConfig.ServerConfig, |
| ... | ... | @@ -30,7 +36,9 @@ const layerServerConfig = ( |
| 30 | 36 | ); |
| 31 | 37 | |
| 32 | 38 | const layerPairingGrantStore = ( |
| 33 | overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>, | |
| 39 | overrides?: Partial< | |
| 40 | Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret"> | |
| 41 | >, | |
| 34 | 42 | ) => |
| 35 | 43 | PairingGrantStore.layer.pipe( |
| 36 | 44 | Layer.provide(SqlitePersistence.layerMemory), |
| ... | ... | @@ -195,6 +203,42 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 195 | 203 | ), |
| 196 | 204 | ); |
| 197 | 205 | |
| 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 | 242 | it.effect("keeps credentials out of pairing lists and change events", () => |
| 199 | 243 | Effect.gen(function* () { |
| 200 | 244 | const grants = yield* PairingGrantStore.PairingGrantStore; |
apps/server/src/auth/PairingGrantStore.ts+23-1| ... | ... | @@ -5,6 +5,10 @@ import { |
| 5 | 5 | type AuthPairingLink, |
| 6 | 6 | type ServerAuthBootstrapMethod, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import { | |
| 9 | DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, | |
| 10 | isValidDesktopBootstrapToken, | |
| 11 | } from "@t3tools/shared/desktopBootstrapToken"; | |
| 8 | 12 | import * as Context from "effect/Context"; |
| 9 | 13 | import * as Crypto from "effect/Crypto"; |
| 10 | 14 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -309,7 +313,15 @@ export const make = Effect.gen(function* () { |
| 309 | 313 | id, |
| 310 | 314 | }).pipe(Effect.asVoid); |
| 311 | 315 | |
| 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 | 325 | const now = yield* DateTime.now; |
| 314 | 326 | yield* seedGrant(config.desktopBootstrapToken, { |
| 315 | 327 | method: "desktop-bootstrap", |
| ... | ... | @@ -432,6 +444,16 @@ export const make = Effect.gen(function* () { |
| 432 | 444 | const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( |
| 433 | 445 | function* (credential, input) { |
| 434 | 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 | 457 | const seededResult: ConsumeResult = yield* Ref.modify( |
| 436 | 458 | seededGrantsRef, |
| 437 | 459 | (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => { |
apps/server/src/cli/config.ts+2| ... | ... | @@ -359,6 +359,7 @@ export const resolveServerConfig = ( |
| 359 | 359 | () => mode === "desktop", |
| 360 | 360 | ); |
| 361 | 361 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; |
| 362 | const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; | |
| 362 | 363 | const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; |
| 363 | 364 | const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; |
| 364 | 365 | const resourceMonitorPath = bootstrap?.resourceMonitorPath; |
| ... | ... | @@ -464,6 +465,7 @@ export const resolveServerConfig = ( |
| 464 | 465 | noBrowser, |
| 465 | 466 | startupPresentation, |
| 466 | 467 | desktopBootstrapToken, |
| 468 | ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), | |
| 467 | 469 | desktopTelemetryFd, |
| 468 | 470 | desktopTelemetryControlFd, |
| 469 | 471 | resourceMonitorPath, |
apps/server/src/config.ts+1| ... | ... | @@ -94,6 +94,7 @@ export class ServerConfig extends Context.Service< |
| 94 | 94 | readonly noBrowser: boolean; |
| 95 | 95 | readonly startupPresentation: StartupPresentation; |
| 96 | 96 | readonly desktopBootstrapToken: string | undefined; |
| 97 | readonly desktopBootstrapSecret?: string | undefined; | |
| 97 | 98 | readonly desktopTelemetryFd?: number | undefined; |
| 98 | 99 | readonly desktopTelemetryControlFd?: number | undefined; |
| 99 | 100 | readonly resourceMonitorPath?: string | undefined; |
packages/contracts/src/desktopBootstrap.ts+5| ... | ... | @@ -12,6 +12,11 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 12 | 12 | t3Home: Schema.optional(Schema.String), |
| 13 | 13 | host: Schema.String, |
| 14 | 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 | 20 | tailscaleServeEnabled: Schema.Boolean, |
| 16 | 21 | tailscaleServePort: PortSchema, |
| 17 | 22 | otlpTracesUrl: Schema.optional(Schema.String), |
packages/shared/package.json+4| ... | ... | @@ -167,6 +167,10 @@ |
| 167 | 167 | "types": "./src/dpop.ts", |
| 168 | 168 | "import": "./src/dpop.ts" |
| 169 | 169 | }, |
| 170 | "./desktopBootstrapToken": { | |
| 171 | "types": "./src/desktopBootstrapToken.ts", | |
| 172 | "import": "./src/desktopBootstrapToken.ts" | |
| 173 | }, | |
| 170 | 174 | "./dpopCommon": { |
| 171 | 175 | "types": "./src/dpopCommon.ts", |
| 172 | 176 | "import": "./src/dpopCommon.ts" |
packages/shared/src/desktopBootstrapToken.test.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, | |
| 5 | currentDesktopBootstrapToken, | |
| 6 | isValidDesktopBootstrapToken, | |
| 7 | } from "./desktopBootstrapToken.ts"; | |
| 8 | ||
| 9 | const SECRET = "desktop-secret"; | |
| 10 | const WINDOW = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS; | |
| 11 | ||
| 12 | describe("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 @@ |
| 1 | import * 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 | */ | |
| 15 | export const DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS = 12 * 60 * 60 * 1000; | |
| 16 | ||
| 17 | function windowIndex(nowMs: number): number { | |
| 18 | return Math.floor(nowMs / DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS); | |
| 19 | } | |
| 20 | ||
| 21 | function 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`. */ | |
| 28 | export 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`. */ | |
| 33 | export 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 | } |