| author | |
| committer | |
| log | 99db70cf1a1f24deca80e40a94a6cdbbaf511da6 |
| tree | 26629e23bfd4ad25cff9dd93000c1113afed0b61 |
| parent | 68e50db20b3784bad69dd754165020a08f2cd42a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 77 insertions(+), 100 deletions(-)
apps/server/src/auth/replayMarkers.ts+1-1| ... | @@ -7,7 +7,7 @@ import * as Option from "effect/Option"; | ... | @@ -7,7 +7,7 @@ import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 8 | import * as Schedule from "effect/Schedule"; | 8 | import * as Schedule from "effect/Schedule"; |
| 9 | 9 | ||
| 10 | import { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/linkChecks.ts"; | 10 | import { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/CloudLink.ts"; |
| 11 | import * as ServerConfig from "../config.ts"; | 11 | import * as ServerConfig from "../config.ts"; |
| 12 | import { forkParked } from "../serverActivation.ts"; | 12 | import { forkParked } from "../serverActivation.ts"; |
| 13 | import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts"; | 13 | import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts"; |
apps/server/src/cloud/CloudLink.lifecycle.test.ts+42| ... | @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 11 | import * as PlatformError from "effect/PlatformError"; | ||
| 11 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; | 13 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| ... | @@ -40,6 +41,7 @@ import { | ... | @@ -40,6 +41,7 @@ import { |
| 40 | RELAY_URL_SECRET, | 41 | RELAY_URL_SECRET, |
| 41 | } from "./config.ts"; | 42 | } from "./config.ts"; |
| 42 | import * as CloudLink from "./CloudLink.ts"; | 43 | import * as CloudLink from "./CloudLink.ts"; |
| 44 | import { consumeCloudReplayGuards } from "./CloudLink.ts"; | ||
| 43 | import { pendingServiceUpdateExists } from "./updateHandoff.ts"; | 45 | import { pendingServiceUpdateExists } from "./updateHandoff.ts"; |
| 44 | import { | 46 | import { |
| 45 | managedTunnelStartupAction, | 47 | managedTunnelStartupAction, |
| ... | @@ -47,6 +49,17 @@ import { | ... | @@ -47,6 +49,17 @@ import { |
| 47 | } from "./managedTunnelStartup.ts"; | 49 | } from "./managedTunnelStartup.ts"; |
| 48 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | 50 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 49 | 51 | ||
| 52 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | ||
| 53 | new ServerSecretStore.SecretStorePersistError({ | ||
| 54 | resource: "cloud replay guard", | ||
| 55 | cause: PlatformError.systemError({ | ||
| 56 | _tag: tag, | ||
| 57 | module: "FileSystem", | ||
| 58 | method: "open", | ||
| 59 | pathOrDescriptor: "cloud-replay-guard.bin", | ||
| 60 | }), | ||
| 61 | }); | ||
| 62 | |||
| 50 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); | 63 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); |
| 51 | // Linking wakes the awareness relay; these tests do not run it. | 64 | // Linking wakes the awareness relay; these tests do not run it. |
| 52 | const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ | 65 | const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ |
| ... | @@ -102,6 +115,35 @@ it("preserves messages surfaced by cloud 500 responses", () => { | ... | @@ -102,6 +115,35 @@ it("preserves messages surfaced by cloud 500 responses", () => { |
| 102 | ]); | 115 | ]); |
| 103 | }); | 116 | }); |
| 104 | 117 | ||
| 118 | describe("consumeCloudReplayGuards", () => { | ||
| 119 | it.effect("reports already-created guards as replay conflicts", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const consumed = yield* consumeCloudReplayGuards({ | ||
| 122 | secrets: makeSecretStore(() => Effect.fail(storeFailure("AlreadyExists"))), | ||
| 123 | names: ["cloud-jti", "cloud-nonce"], | ||
| 124 | value: new Uint8Array(), | ||
| 125 | }); | ||
| 126 | |||
| 127 | expect(consumed).toBe(false); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | |||
| 131 | it.effect("preserves replay-store availability failures", () => | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const failure = storeFailure("PermissionDenied"); | ||
| 134 | const error = yield* Effect.flip( | ||
| 135 | consumeCloudReplayGuards({ | ||
| 136 | secrets: makeSecretStore(() => Effect.fail(failure)), | ||
| 137 | names: ["cloud-jti", "cloud-nonce"], | ||
| 138 | value: new Uint8Array(), | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(error).toBe(failure); | ||
| 143 | }), | ||
| 144 | ); | ||
| 145 | }); | ||
| 146 | |||
| 105 | describe("reconcileDesiredCloudLink", () => { | 147 | describe("reconcileDesiredCloudLink", () => { |
| 106 | it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => | 148 | it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => |
| 107 | Effect.gen(function* () { | 149 | Effect.gen(function* () { |
apps/server/src/cloud/CloudLink.ts+32-5| ... | @@ -107,11 +107,6 @@ import { | ... | @@ -107,11 +107,6 @@ import { |
| 107 | shouldRetryRelayRequest, | 107 | shouldRetryRelayRequest, |
| 108 | } from "./relayResponse.ts"; | 108 | } from "./relayResponse.ts"; |
| 109 | import { | 109 | import { |
| 110 | CLOUD_HEALTH_JTI_PREFIX, | ||
| 111 | CLOUD_HEALTH_NONCE_PREFIX, | ||
| 112 | CLOUD_MINT_JTI_PREFIX, | ||
| 113 | CLOUD_MINT_NONCE_PREFIX, | ||
| 114 | consumeCloudReplayGuards, | ||
| 115 | hasBoundedCloudProofLifetime, | 110 | hasBoundedCloudProofLifetime, |
| 116 | hasExactScope, | 111 | hasExactScope, |
| 117 | hasForwardedAuthorityHeaders, | 112 | hasForwardedAuthorityHeaders, |
| ... | @@ -124,6 +119,17 @@ import { | ... | @@ -124,6 +119,17 @@ import { |
| 124 | } from "./linkChecks.ts"; | 119 | } from "./linkChecks.ts"; |
| 125 | import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; | 120 | import { desktopUpdateRestartPending, pendingUpdateHandoffExists } from "./updateHandoff.ts"; |
| 126 | 121 | ||
| 122 | const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; | ||
| 123 | const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; | ||
| 124 | const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; | ||
| 125 | const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; | ||
| 126 | /** Secret store name prefixes of cloud replay markers. The server prunes expired ones. */ | ||
| 127 | export const CLOUD_REPLAY_MARKER_PREFIXES = [ | ||
| 128 | CLOUD_MINT_NONCE_PREFIX, | ||
| 129 | CLOUD_MINT_JTI_PREFIX, | ||
| 130 | CLOUD_HEALTH_NONCE_PREFIX, | ||
| 131 | CLOUD_HEALTH_JTI_PREFIX, | ||
| 132 | ] as const; | ||
| 127 | const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); | 133 | const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); |
| 128 | const RELAY_CONFIG_FIELD_MESSAGES = { | 134 | const RELAY_CONFIG_FIELD_MESSAGES = { |
| 129 | relayUrl: "Relay URL must be a secure absolute HTTPS URL.", | 135 | relayUrl: "Relay URL must be a secure absolute HTTPS URL.", |
| ... | @@ -325,6 +331,27 @@ function stringToBytes(value: string): Uint8Array { | ... | @@ -325,6 +331,27 @@ function stringToBytes(value: string): Uint8Array { |
| 325 | return new TextEncoder().encode(value); | 331 | return new TextEncoder().encode(value); |
| 326 | } | 332 | } |
| 327 | 333 | ||
| 334 | /** Records each one-shot marker; false when any already existed, so the request is a replay. */ | ||
| 335 | export function consumeCloudReplayGuards(input: { | ||
| 336 | readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; | ||
| 337 | readonly names: ReadonlyArray<string>; | ||
| 338 | readonly value: Uint8Array; | ||
| 339 | }) { | ||
| 340 | return Effect.forEach( | ||
| 341 | input.names, | ||
| 342 | (name) => | ||
| 343 | input.secrets.create(name, input.value).pipe( | ||
| 344 | Effect.as(true), | ||
| 345 | Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => | ||
| 346 | ServerSecretStore.isSecretAlreadyExistsError(error) | ||
| 347 | ? Effect.succeed(false) | ||
| 348 | : Effect.fail(error), | ||
| 349 | ), | ||
| 350 | ), | ||
| 351 | { concurrency: input.names.length }, | ||
| 352 | ).pipe(Effect.map((created) => created.every(Boolean))); | ||
| 353 | } | ||
| 354 | |||
| 328 | function validateCloudMintPublicKey( | 355 | function validateCloudMintPublicKey( |
| 329 | publicKey: string, | 356 | publicKey: string, |
| 330 | ): Effect.Effect<void, CloudLinkRelayConfigInvalidError> { | 357 | ): Effect.Effect<void, CloudLinkRelayConfigInvalidError> { |
apps/server/src/cloud/linkChecks.test.ts-58| ... | @@ -1,70 +1,12 @@ | ... | @@ -1,70 +1,12 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as PlatformError from "effect/PlatformError"; | ||
| 4 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; | 2 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; |
| 5 | 3 | ||
| 6 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 7 | import { | 4 | import { |
| 8 | consumeCloudReplayGuards, | ||
| 9 | isSupportedLinkProviderKind, | 5 | isSupportedLinkProviderKind, |
| 10 | linkProofScopes, | 6 | linkProofScopes, |
| 11 | parseManagedEndpointLocalOrigin, | 7 | parseManagedEndpointLocalOrigin, |
| 12 | } from "./linkChecks.ts"; | 8 | } from "./linkChecks.ts"; |
| 13 | 9 | ||
| 14 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | ||
| 15 | new ServerSecretStore.SecretStorePersistError({ | ||
| 16 | resource: "cloud replay guard", | ||
| 17 | cause: PlatformError.systemError({ | ||
| 18 | _tag: tag, | ||
| 19 | module: "FileSystem", | ||
| 20 | method: "open", | ||
| 21 | pathOrDescriptor: "cloud-replay-guard.bin", | ||
| 22 | }), | ||
| 23 | }); | ||
| 24 | |||
| 25 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); | ||
| 26 | |||
| 27 | function makeSecretStore( | ||
| 28 | create: ServerSecretStore.ServerSecretStore["Service"]["create"], | ||
| 29 | ): ServerSecretStore.ServerSecretStore["Service"] { | ||
| 30 | return { | ||
| 31 | get: unusedSecretStoreOperation, | ||
| 32 | set: unusedSecretStoreOperation, | ||
| 33 | create, | ||
| 34 | getOrCreateRandom: unusedSecretStoreOperation, | ||
| 35 | remove: unusedSecretStoreOperation, | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | describe("consumeCloudReplayGuards", () => { | ||
| 40 | it.effect("reports already-created guards as replay conflicts", () => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const consumed = yield* consumeCloudReplayGuards({ | ||
| 43 | secrets: makeSecretStore(() => Effect.fail(storeFailure("AlreadyExists"))), | ||
| 44 | names: ["cloud-jti", "cloud-nonce"], | ||
| 45 | value: new Uint8Array(), | ||
| 46 | }); | ||
| 47 | |||
| 48 | expect(consumed).toBe(false); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | it.effect("preserves replay-store availability failures", () => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const failure = storeFailure("PermissionDenied"); | ||
| 55 | const error = yield* Effect.flip( | ||
| 56 | consumeCloudReplayGuards({ | ||
| 57 | secrets: makeSecretStore(() => Effect.fail(failure)), | ||
| 58 | names: ["cloud-jti", "cloud-nonce"], | ||
| 59 | value: new Uint8Array(), | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | |||
| 63 | expect(error).toBe(failure); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | }); | ||
| 67 | |||
| 68 | describe("parseManagedEndpointLocalOrigin", () => { | 10 | describe("parseManagedEndpointLocalOrigin", () => { |
| 69 | it.each([ | 11 | it.each([ |
| 70 | { | 12 | { |
apps/server/src/cloud/linkChecks.ts+2-36| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | /** | 1 | /** |
| 2 | * Pure checks behind T3 Connect link proofs and relay requests: which local | 2 | * Pure checks behind T3 Connect link proofs and relay requests: which local |
| 3 | * origin a link may point at, which scopes a proof claims and accepts, and the | 3 | * origin a link may point at, and which scopes and lifetimes a proof claims and |
| 4 | * one-shot replay guards for signed relay requests. | 4 | * accepts. |
| 5 | */ | 5 | */ |
| 6 | import type { | 6 | import type { |
| 7 | RelayEnvironmentLinkProofPayload, | 7 | RelayEnvironmentLinkProofPayload, |
| ... | @@ -9,46 +9,12 @@ import type { | ... | @@ -9,46 +9,12 @@ import type { |
| 9 | RelayManagedEndpointOrigin, | 9 | RelayManagedEndpointOrigin, |
| 10 | RelayManagedEndpointRuntimeConfig, | 10 | RelayManagedEndpointRuntimeConfig, |
| 11 | } from "@t3tools/contracts/relay"; | 11 | } from "@t3tools/contracts/relay"; |
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import type { HttpServerRequest } from "effect/http"; | 12 | import type { HttpServerRequest } from "effect/http"; |
| 14 | 13 | ||
| 15 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 16 | |||
| 17 | export const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; | ||
| 18 | export const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; | ||
| 19 | export const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; | ||
| 20 | export const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; | ||
| 21 | /** Secret store name prefixes of cloud replay markers. The server prunes expired ones. */ | ||
| 22 | export const CLOUD_REPLAY_MARKER_PREFIXES = [ | ||
| 23 | CLOUD_MINT_NONCE_PREFIX, | ||
| 24 | CLOUD_MINT_JTI_PREFIX, | ||
| 25 | CLOUD_HEALTH_NONCE_PREFIX, | ||
| 26 | CLOUD_HEALTH_JTI_PREFIX, | ||
| 27 | ] as const; | ||
| 28 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; | 14 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; |
| 29 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; | 15 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; |
| 30 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 16 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 31 | 17 | ||
| 32 | export function consumeCloudReplayGuards(input: { | ||
| 33 | readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; | ||
| 34 | readonly names: ReadonlyArray<string>; | ||
| 35 | readonly value: Uint8Array; | ||
| 36 | }) { | ||
| 37 | return Effect.forEach( | ||
| 38 | input.names, | ||
| 39 | (name) => | ||
| 40 | input.secrets.create(name, input.value).pipe( | ||
| 41 | Effect.as(true), | ||
| 42 | Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => | ||
| 43 | ServerSecretStore.isSecretAlreadyExistsError(error) | ||
| 44 | ? Effect.succeed(false) | ||
| 45 | : Effect.fail(error), | ||
| 46 | ), | ||
| 47 | ), | ||
| 48 | { concurrency: input.names.length }, | ||
| 49 | ).pipe(Effect.map((created) => created.every(Boolean))); | ||
| 50 | } | ||
| 51 | |||
| 52 | function normalizeHostname(hostname: string): string { | 18 | function normalizeHostname(hostname: string): string { |
| 53 | return hostname | 19 | return hostname |
| 54 | .trim() | 20 | .trim() |