diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts new file mode 100644 index 000000000..68ec4e1a1 --- /dev/null +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { AuthRateLimiter } from "../../auth-rate-limit.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; +import type { ConnectParams } from "../../protocol/index.js"; +import { + BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP, + resolveHandshakeBrowserSecurityContext, + resolveUnauthorizedHandshakeContext, + shouldAllowSilentLocalPairing, + shouldSkipBackendSelfPairing, +} from "./handshake-auth-helpers.js"; + +describe("handshake auth helpers", () => { + it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => { + const rateLimiter: AuthRateLimiter = { + check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }), + reset: () => {}, + recordFailure: () => {}, + size: () => 0, + prune: () => {}, + dispose: () => {}, + }; + const browserRateLimiter: AuthRateLimiter = { + check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }), + reset: () => {}, + recordFailure: () => {}, + size: () => 0, + prune: () => {}, + dispose: () => {}, + }; + const resolved = resolveHandshakeBrowserSecurityContext({ + requestOrigin: "https://app.example", + clientIp: "127.0.0.1", + rateLimiter, + browserRateLimiter, + }); + + expect(resolved).toMatchObject({ + hasBrowserOriginHeader: true, + enforceOriginCheckForAnyClient: true, + rateLimitClientIp: BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP, + authRateLimiter: browserRateLimiter, + }); + }); + + it("recommends device-token retry only for shared-token mismatch with device identity", () => { + const resolved = resolveUnauthorizedHandshakeContext({ + connectAuth: { token: "shared-token" }, + failedAuth: { ok: false, reason: "token_mismatch" }, + hasDeviceIdentity: true, + }); + + expect(resolved).toEqual({ + authProvided: "token", + canRetryWithDeviceToken: true, + recommendedNextStep: "retry_with_device_token", + }); + }); + + it("treats explicit device-token mismatch as credential update guidance", () => { + const resolved = resolveUnauthorizedHandshakeContext({ + connectAuth: { deviceToken: "device-token" }, + failedAuth: { ok: false, reason: "device_token_mismatch" }, + hasDeviceIdentity: true, + }); + + expect(resolved).toEqual({ + authProvided: "device-token", + canRetryWithDeviceToken: false, + recommendedNextStep: "update_auth_credentials", + }); + }); + + it("allows silent local pairing only for not-paired and scope upgrades", () => { + expect( + shouldAllowSilentLocalPairing({ + isLocalClient: true, + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "not-paired", + }), + ).toBe(true); + expect( + shouldAllowSilentLocalPairing({ + isLocalClient: true, + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(false); + }); + + it("skips backend self-pairing only for local shared-secret backend clients", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + } as ConnectParams; + + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(true); + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + }); +}); diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts new file mode 100644 index 000000000..cce5b979b --- /dev/null +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -0,0 +1,211 @@ +import { verifyDeviceSignature } from "../../../infra/device-identity.js"; +import type { AuthRateLimiter } from "../../auth-rate-limit.js"; +import type { GatewayAuthResult } from "../../auth.js"; +import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js"; +import { isLoopbackAddress } from "../../net.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; +import type { ConnectParams } from "../../protocol/index.js"; +import type { AuthProvidedKind } from "./auth-messages.js"; + +export const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1"; + +export type HandshakeBrowserSecurityContext = { + hasBrowserOriginHeader: boolean; + enforceOriginCheckForAnyClient: boolean; + rateLimitClientIp: string | undefined; + authRateLimiter?: AuthRateLimiter; +}; + +type HandshakeConnectAuth = { + token?: string; + bootstrapToken?: string; + deviceToken?: string; + password?: string; +}; + +export function resolveHandshakeBrowserSecurityContext(params: { + requestOrigin?: string; + clientIp: string | undefined; + rateLimiter?: AuthRateLimiter; + browserRateLimiter?: AuthRateLimiter; +}): HandshakeBrowserSecurityContext { + const hasBrowserOriginHeader = Boolean( + params.requestOrigin && params.requestOrigin.trim() !== "", + ); + return { + hasBrowserOriginHeader, + enforceOriginCheckForAnyClient: hasBrowserOriginHeader, + rateLimitClientIp: + hasBrowserOriginHeader && isLoopbackAddress(params.clientIp) + ? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP + : params.clientIp, + authRateLimiter: + hasBrowserOriginHeader && params.browserRateLimiter + ? params.browserRateLimiter + : params.rateLimiter, + }; +} + +export function shouldAllowSilentLocalPairing(params: { + isLocalClient: boolean; + hasBrowserOriginHeader: boolean; + isControlUi: boolean; + isWebchat: boolean; + reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade"; +}): boolean { + return ( + params.isLocalClient && + (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) && + (params.reason === "not-paired" || params.reason === "scope-upgrade") + ); +} + +export function shouldSkipBackendSelfPairing(params: { + connectParams: ConnectParams; + isLocalClient: boolean; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const isGatewayBackendClient = + params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; + if (!isGatewayBackendClient) { + return false; + } + const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + return ( + params.isLocalClient && + !params.hasBrowserOriginHeader && + params.sharedAuthOk && + usesSharedSecretAuth + ); +} + +function resolveSignatureToken(connectParams: ConnectParams): string | null { + return ( + connectParams.auth?.token ?? + connectParams.auth?.deviceToken ?? + connectParams.auth?.bootstrapToken ?? + null + ); +} + +export function resolveDeviceSignaturePayloadVersion(params: { + device: { + id: string; + signature: string; + publicKey: string; + }; + connectParams: ConnectParams; + role: string; + scopes: string[]; + signedAtMs: number; + nonce: string; +}): "v3" | "v2" | null { + const signatureToken = resolveSignatureToken(params.connectParams); + const payloadV3 = buildDeviceAuthPayloadV3({ + deviceId: params.device.id, + clientId: params.connectParams.client.id, + clientMode: params.connectParams.client.mode, + role: params.role, + scopes: params.scopes, + signedAtMs: params.signedAtMs, + token: signatureToken, + nonce: params.nonce, + platform: params.connectParams.client.platform, + deviceFamily: params.connectParams.client.deviceFamily, + }); + if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) { + return "v3"; + } + + const payloadV2 = buildDeviceAuthPayload({ + deviceId: params.device.id, + clientId: params.connectParams.client.id, + clientMode: params.connectParams.client.mode, + role: params.role, + scopes: params.scopes, + signedAtMs: params.signedAtMs, + token: signatureToken, + nonce: params.nonce, + }); + if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) { + return "v2"; + } + return null; +} + +export function resolveAuthProvidedKind( + connectAuth: HandshakeConnectAuth | null | undefined, +): AuthProvidedKind { + return connectAuth?.password + ? "password" + : connectAuth?.token + ? "token" + : connectAuth?.bootstrapToken + ? "bootstrap-token" + : connectAuth?.deviceToken + ? "device-token" + : "none"; +} + +export function resolveUnauthorizedHandshakeContext(params: { + connectAuth: HandshakeConnectAuth | null | undefined; + failedAuth: GatewayAuthResult; + hasDeviceIdentity: boolean; +}): { + authProvided: AuthProvidedKind; + canRetryWithDeviceToken: boolean; + recommendedNextStep: + | "retry_with_device_token" + | "update_auth_configuration" + | "update_auth_credentials" + | "wait_then_retry" + | "review_auth_configuration"; +} { + const authProvided = resolveAuthProvidedKind(params.connectAuth); + const canRetryWithDeviceToken = + params.failedAuth.reason === "token_mismatch" && + params.hasDeviceIdentity && + authProvided === "token" && + !params.connectAuth?.deviceToken; + if (canRetryWithDeviceToken) { + return { + authProvided, + canRetryWithDeviceToken, + recommendedNextStep: "retry_with_device_token", + }; + } + switch (params.failedAuth.reason) { + case "token_missing": + case "token_missing_config": + case "password_missing": + case "password_missing_config": + return { + authProvided, + canRetryWithDeviceToken, + recommendedNextStep: "update_auth_configuration", + }; + case "token_mismatch": + case "password_mismatch": + case "device_token_mismatch": + return { + authProvided, + canRetryWithDeviceToken, + recommendedNextStep: "update_auth_credentials", + }; + case "rate_limited": + return { + authProvided, + canRetryWithDeviceToken, + recommendedNextStep: "wait_then_retry", + }; + default: + return { + authProvided, + canRetryWithDeviceToken, + recommendedNextStep: "review_auth_configuration", + }; + } +} diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d0b6e5790..d3d98da46 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -6,7 +6,6 @@ import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js"; import { deriveDeviceIdFromPublicKey, normalizeDevicePublicKeyBase64Url, - verifyDeviceSignature, } from "../../../infra/device-identity.js"; import { approveDevicePairing, @@ -33,11 +32,7 @@ import { CANVAS_CAPABILITY_TTL_MS, mintCanvasCapabilityToken, } from "../../canvas-capability.js"; -import { - buildDeviceAuthPayload, - buildDeviceAuthPayloadV3, - normalizeDeviceMetadataForAuth, -} from "../../device-auth.js"; +import { normalizeDeviceMetadataForAuth } from "../../device-auth.js"; import { isLocalishHost, isLoopbackAddress, @@ -46,7 +41,7 @@ import { } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { checkBrowserOrigin } from "../../origin-check.js"; -import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; +import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; import { ConnectErrorDetailCodes, resolveDeviceAuthConnectErrorDetailCode, @@ -83,143 +78,30 @@ import { } from "../health-state.js"; import type { GatewayWsClient } from "../ws-types.js"; import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js"; -import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js"; +import { formatGatewayAuthFailureMessage } from "./auth-messages.js"; import { evaluateMissingDeviceIdentity, isTrustedProxyControlUiOperatorAuth, resolveControlUiAuthPolicy, shouldSkipControlUiPairing, } from "./connect-policy.js"; +import { + resolveDeviceSignaturePayloadVersion, + resolveHandshakeBrowserSecurityContext, + resolveUnauthorizedHandshakeContext, + shouldAllowSilentLocalPairing, + shouldSkipBackendSelfPairing, +} from "./handshake-auth-helpers.js"; import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js"; type SubsystemLogger = ReturnType; const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000; -const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1"; export type WsOriginCheckMetrics = { hostHeaderFallbackAccepted: number; }; -type HandshakeBrowserSecurityContext = { - hasBrowserOriginHeader: boolean; - enforceOriginCheckForAnyClient: boolean; - rateLimitClientIp: string | undefined; - authRateLimiter?: AuthRateLimiter; -}; - -function resolveHandshakeBrowserSecurityContext(params: { - requestOrigin?: string; - hasProxyHeaders: boolean; - clientIp: string | undefined; - rateLimiter?: AuthRateLimiter; - browserRateLimiter?: AuthRateLimiter; -}): HandshakeBrowserSecurityContext { - const hasBrowserOriginHeader = Boolean( - params.requestOrigin && params.requestOrigin.trim() !== "", - ); - return { - hasBrowserOriginHeader, - enforceOriginCheckForAnyClient: hasBrowserOriginHeader, - rateLimitClientIp: - hasBrowserOriginHeader && isLoopbackAddress(params.clientIp) - ? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP - : params.clientIp, - authRateLimiter: - hasBrowserOriginHeader && params.browserRateLimiter - ? params.browserRateLimiter - : params.rateLimiter, - }; -} - -function shouldAllowSilentLocalPairing(params: { - isLocalClient: boolean; - hasBrowserOriginHeader: boolean; - isControlUi: boolean; - isWebchat: boolean; - reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade"; -}): boolean { - return ( - params.isLocalClient && - (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) && - (params.reason === "not-paired" || params.reason === "scope-upgrade") - ); -} - -function shouldSkipBackendSelfPairing(params: { - connectParams: ConnectParams; - isLocalClient: boolean; - hasBrowserOriginHeader: boolean; - sharedAuthOk: boolean; - authMethod: GatewayAuthResult["method"]; -}): boolean { - const isGatewayBackendClient = - params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && - params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; - if (!isGatewayBackendClient) { - return false; - } - const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; - return ( - params.isLocalClient && - !params.hasBrowserOriginHeader && - params.sharedAuthOk && - usesSharedSecretAuth - ); -} - -function resolveDeviceSignaturePayloadVersion(params: { - device: { - id: string; - signature: string; - publicKey: string; - }; - connectParams: ConnectParams; - role: string; - scopes: string[]; - signedAtMs: number; - nonce: string; -}): "v3" | "v2" | null { - const payloadV3 = buildDeviceAuthPayloadV3({ - deviceId: params.device.id, - clientId: params.connectParams.client.id, - clientMode: params.connectParams.client.mode, - role: params.role, - scopes: params.scopes, - signedAtMs: params.signedAtMs, - token: - params.connectParams.auth?.token ?? - params.connectParams.auth?.deviceToken ?? - params.connectParams.auth?.bootstrapToken ?? - null, - nonce: params.nonce, - platform: params.connectParams.client.platform, - deviceFamily: params.connectParams.client.deviceFamily, - }); - if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) { - return "v3"; - } - - const payloadV2 = buildDeviceAuthPayload({ - deviceId: params.device.id, - clientId: params.connectParams.client.id, - clientMode: params.connectParams.client.mode, - role: params.role, - scopes: params.scopes, - signedAtMs: params.signedAtMs, - token: - params.connectParams.auth?.token ?? - params.connectParams.auth?.deviceToken ?? - params.connectParams.auth?.bootstrapToken ?? - null, - nonce: params.nonce, - }); - if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) { - return "v2"; - } - return null; -} - function resolvePinnedClientMetadata(params: { claimedPlatform?: string; claimedDeviceFamily?: string; @@ -362,7 +244,6 @@ export function attachGatewayWsMessageHandler(params: { const unauthorizedFloodGuard = new UnauthorizedFloodGuard(); const browserSecurity = resolveHandshakeBrowserSecurityContext({ requestOrigin, - hasProxyHeaders, clientIp, rateLimiter, browserRateLimiter, @@ -589,57 +470,21 @@ export function attachGatewayWsMessageHandler(params: { clientIp: browserRateLimitClientIp, }); const rejectUnauthorized = (failedAuth: GatewayAuthResult) => { - const canRetryWithDeviceToken = - failedAuth.reason === "token_mismatch" && - Boolean(device) && - hasSharedAuth && - !connectParams.auth?.deviceToken; - const recommendedNextStep = (() => { - if (canRetryWithDeviceToken) { - return "retry_with_device_token"; - } - switch (failedAuth.reason) { - case "token_missing": - case "token_missing_config": - case "password_missing": - case "password_missing_config": - return "update_auth_configuration"; - case "token_mismatch": - case "password_mismatch": - case "device_token_mismatch": - return "update_auth_credentials"; - case "rate_limited": - return "wait_then_retry"; - default: - return "review_auth_configuration"; - } - })(); + const { authProvided, canRetryWithDeviceToken, recommendedNextStep } = + resolveUnauthorizedHandshakeContext({ + connectAuth: connectParams.auth, + failedAuth, + hasDeviceIdentity: Boolean(device), + }); markHandshakeFailure("unauthorized", { authMode: resolvedAuth.mode, - authProvided: connectParams.auth?.password - ? "password" - : connectParams.auth?.token - ? "token" - : connectParams.auth?.bootstrapToken - ? "bootstrap-token" - : connectParams.auth?.deviceToken - ? "device-token" - : "none", + authProvided, authReason: failedAuth.reason, allowTailscale: resolvedAuth.allowTailscale, }); logWsControl.warn( `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${failedAuth.reason ?? "unknown"}`, ); - const authProvided: AuthProvidedKind = connectParams.auth?.password - ? "password" - : connectParams.auth?.token - ? "token" - : connectParams.auth?.bootstrapToken - ? "bootstrap-token" - : connectParams.auth?.deviceToken - ? "device-token" - : "none"; const authMessage = formatGatewayAuthFailureMessage({ authMode: resolvedAuth.mode, authProvided,