| 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 | import * as Path from "effect/Path"; |
| 8 | 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 | 11 | import * as ServerConfig from "../config.ts"; |
| 12 | 12 | import { forkParked } from "../serverActivation.ts"; |
| 13 | 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 | 8 | import * as Layer from "effect/Layer"; |
| 9 | 9 | import * as Option from "effect/Option"; |
| 10 | 10 | import * as Path from "effect/Path"; |
| 11 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | 12 | import * as Schema from "effect/Schema"; |
| 12 | 13 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | 14 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -40,6 +41,7 @@ import { |
| 40 | 41 | RELAY_URL_SECRET, |
| 41 | 42 | } from "./config.ts"; |
| 42 | 43 | import * as CloudLink from "./CloudLink.ts"; |
| 44 | import { consumeCloudReplayGuards } from "./CloudLink.ts"; | |
| 43 | 45 | import { pendingServiceUpdateExists } from "./updateHandoff.ts"; |
| 44 | 46 | import { |
| 45 | 47 | managedTunnelStartupAction, |
| ... | ... | @@ -47,6 +49,17 @@ import { |
| 47 | 49 | } from "./managedTunnelStartup.ts"; |
| 48 | 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 | 63 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); |
| 51 | 64 | // Linking wakes the awareness relay; these tests do not run it. |
| 52 | 65 | const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ |
| ... | ... | @@ -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 | 147 | describe("reconcileDesiredCloudLink", () => { |
| 106 | 148 | it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => |
| 107 | 149 | Effect.gen(function* () { |
apps/server/src/cloud/CloudLink.ts+32-5| ... | ... | @@ -107,11 +107,6 @@ import { |
| 107 | 107 | shouldRetryRelayRequest, |
| 108 | 108 | } from "./relayResponse.ts"; |
| 109 | 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 | 110 | hasBoundedCloudProofLifetime, |
| 116 | 111 | hasExactScope, |
| 117 | 112 | hasForwardedAuthorityHeaders, |
| ... | ... | @@ -124,6 +119,17 @@ import { |
| 124 | 119 | } from "./linkChecks.ts"; |
| 125 | 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 | 133 | const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); |
| 128 | 134 | const RELAY_CONFIG_FIELD_MESSAGES = { |
| 129 | 135 | relayUrl: "Relay URL must be a secure absolute HTTPS URL.", |
| ... | ... | @@ -325,6 +331,27 @@ function stringToBytes(value: string): Uint8Array { |
| 325 | 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 | 355 | function validateCloudMintPublicKey( |
| 329 | 356 | publicKey: string, |
| 330 | 357 | ): Effect.Effect<void, CloudLinkRelayConfigInvalidError> { |
apps/server/src/cloud/linkChecks.test.ts-58| ... | ... | @@ -1,70 +1,12 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as PlatformError from "effect/PlatformError"; | |
| 4 | 2 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; |
| 5 | 3 | |
| 6 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 7 | 4 | import { |
| 8 | consumeCloudReplayGuards, | |
| 9 | 5 | isSupportedLinkProviderKind, |
| 10 | 6 | linkProofScopes, |
| 11 | 7 | parseManagedEndpointLocalOrigin, |
| 12 | 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 | 10 | describe("parseManagedEndpointLocalOrigin", () => { |
| 69 | 11 | it.each([ |
| 70 | 12 | { |
apps/server/src/cloud/linkChecks.ts+2-36| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | /** |
| 2 | 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 | |
| 4 | * one-shot replay guards for signed relay requests. | |
| 3 | * origin a link may point at, and which scopes and lifetimes a proof claims and | |
| 4 | * accepts. | |
| 5 | 5 | */ |
| 6 | 6 | import type { |
| 7 | 7 | RelayEnvironmentLinkProofPayload, |
| ... | ... | @@ -9,46 +9,12 @@ import type { |
| 9 | 9 | RelayManagedEndpointOrigin, |
| 10 | 10 | RelayManagedEndpointRuntimeConfig, |
| 11 | 11 | } from "@t3tools/contracts/relay"; |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | 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 | 14 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; |
| 29 | 15 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; |
| 30 | 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 | 18 | function normalizeHostname(hostname: string): string { |
| 53 | 19 | return hostname |
| 54 | 20 | .trim() |