| 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,5 +1,7 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | ||
| 4 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 7 | import * 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 @@ |
| 1 | import * as NodeOS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | 2 | ||
| 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | ||
| 5 | import * as Clock from "effect/Clock"; | ||
| 4 | import * as Context from "effect/Context"; | 6 | import * as Context from "effect/Context"; |
| 5 | import * as Crypto from "effect/Crypto"; | 7 | import * as Crypto from "effect/Crypto"; |
| 6 | import * as Effect from "effect/Effect"; | 8 | import * 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 a | 63 | // 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") {} |
| 66 | 72 | ||
| ... | @@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () { | ... | @@ -258,6 +264,7 @@ const readBackendObservabilitySettings = Effect.gen(function* () { |
| 258 | 264 | ||
| 259 | interface SharedBootstrapInput { | 265 | interface SharedBootstrapInput { |
| 260 | readonly bootstrapToken: string; | 266 | readonly bootstrapToken: string; |
| 267 | readonly bootstrapSecret: string; | ||
| 261 | readonly observabilitySettings: BackendObservabilitySettings; | 268 | readonly observabilitySettings: BackendObservabilitySettings; |
| 262 | } | 269 | } |
| 263 | 270 | ||
| ... | @@ -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 still | 647 | // PortSchema rejects 0, so when tailscale serve is disabled we still |
| 639 | // need a valid number in this slot. The backend reads tailscaleServePort | 648 | // need a valid number in this slot. The backend reads tailscaleServePort |
| 640 | // only when tailscaleServeEnabled is true, so the actual value here is | 649 | // 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 can | 841 | // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can |
| 833 | // resolve concurrently; with a plain Ref both could observe None, generate | 842 | // resolve concurrently; with a plain Ref both could observe None, generate |
| 834 | // distinct tokens, and one would overwrite the other — leaving the two | 843 | // distinct secrets, and one would overwrite the other — leaving the two |
| 835 | // backends holding mismatched tokens and breaking the shared-token | 844 | // backends deriving mismatched tokens and breaking the shared-token |
| 836 | // invariant the renderer relies on. modifyEffect serializes the whole | 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. | 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 next | 864 | // 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 | }); |
| 865 | 882 | ||
| 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 | }); |
| 920 | 937 | ||
| 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"; |
| 5 | import * as HttpClient from "effect/http/HttpClient"; | 5 | import * as HttpClient from "effect/http/HttpClient"; |
| 6 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | 6 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 7 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | 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 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; | 14 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; |
| 10 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; | 15 | import * 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 @@ |
| 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | 2 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 3 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | ||
| 4 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 7 | import * 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 | })); |
| 21 | 21 | ||
| 22 | import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; | ||
| 22 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | 23 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 23 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | 24 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 24 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | 25 | import * 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 | }; |
| 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 | describe("getLocalEnvironmentBootstraps", () => { | 88 | describe("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 | ); |
| 91 | 137 | ||
| 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 | }); |
| 126 | 172 | ||
| 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 | }); |
| 153 | 199 |
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"; |
| 23 | import * as Option from "effect/Option"; | 23 | import * as Option from "effect/Option"; |
| 24 | import * as Schema from "effect/Schema"; | 24 | import * as Schema from "effect/Schema"; |
| 25 | 25 | ||
| 26 | import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; | ||
| 26 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | 27 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 27 | import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; | 28 | import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; |
| 28 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | 29 | import * 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.desktopBootstrapToken | 147 | // 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 | ); |
| 76 | 77 |
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"; |
| 6 | import * as Stream from "effect/Stream"; | 6 | import * as Stream from "effect/Stream"; |
| 7 | import * as Queue from "effect/Queue"; | 7 | import * as Queue from "effect/Queue"; |
| 8 | import * as TestClock from "effect/testing/TestClock"; | 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 | import * as ServerConfig from "../config.ts"; | 14 | import * as ServerConfig from "../config.ts"; |
| 11 | import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; | 15 | import * 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"; |
| 14 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 18 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 15 | 19 | ||
| 16 | const layerServerConfig = ( | 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 | 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 | ); |
| 31 | 37 | ||
| 32 | const layerPairingGrantStore = ( | 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 | 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 | ); |
| 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 | 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"; |
| 8 | import { | ||
| 9 | DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, | ||
| 10 | isValidDesktopBootstrapToken, | ||
| 11 | } from "@t3tools/shared/desktopBootstrapToken"; | ||
| 8 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; | 13 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as DateTime from "effect/DateTime"; | 14 | import * 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); |
| 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 | 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 @@ | ||
| 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 | } | ||