refactor(gateway): unify v3 auth payload builders and vectors

This commit is contained in:
Peter Steinberger
2026-02-26 15:08:40 +01:00
parent 8315c58675
commit 081b1aa1ed
10 changed files with 313 additions and 168 deletions

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildDeviceAuthPayloadV3, normalizeDeviceMetadataForAuth } from "./device-auth.js";
describe("device-auth payload vectors", () => {
it("builds canonical v3 payload", () => {
const payload = buildDeviceAuthPayloadV3({
deviceId: "dev-1",
clientId: "openclaw-macos",
clientMode: "ui",
role: "operator",
scopes: ["operator.admin", "operator.read"],
signedAtMs: 1_700_000_000_000,
token: "tok-123",
nonce: "nonce-abc",
platform: " IOS ",
deviceFamily: " iPhone ",
});
expect(payload).toBe(
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone",
);
});
it("normalizes metadata with ASCII-only lowercase", () => {
expect(normalizeDeviceMetadataForAuth(" İOS ")).toBe("İos");
expect(normalizeDeviceMetadataForAuth(" MAC ")).toBe("mac");
expect(normalizeDeviceMetadataForAuth(undefined)).toBe("");
});
});

View File

@@ -14,11 +14,21 @@ export type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
deviceFamily?: string | null;
};
function normalizeMetadataField(value?: string | null): string {
function toLowerAscii(input: string): string {
return input.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
}
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
const trimmed = value.trim();
if (!trimmed) {
return "";
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin) by only
// lowercasing ASCII metadata fields used in auth payloads.
return toLowerAscii(trimmed);
}
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
@@ -40,8 +50,8 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const platform = normalizeMetadataField(params.platform);
const deviceFamily = normalizeMetadataField(params.deviceFamily);
const platform = normalizeDeviceMetadataForAuth(params.platform);
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily);
return [
"v3",
params.deviceId,

View File

@@ -32,7 +32,11 @@ import {
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
} from "../../canvas-capability.js";
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
import {
buildDeviceAuthPayload,
buildDeviceAuthPayloadV3,
normalizeDeviceMetadataForAuth,
} from "../../device-auth.js";
import {
isLocalishHost,
isLoopbackAddress,
@@ -131,8 +135,75 @@ function shouldAllowSilentLocalPairing(params: {
);
}
function normalizeClientMetadataForComparison(value: string | undefined): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
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 ?? 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 ?? null,
nonce: params.nonce,
});
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
return "v2";
}
return null;
}
function resolvePinnedClientMetadata(params: {
claimedPlatform?: string;
claimedDeviceFamily?: string;
pairedPlatform?: string;
pairedDeviceFamily?: string;
}): {
platformMismatch: boolean;
deviceFamilyMismatch: boolean;
pinnedPlatform?: string;
pinnedDeviceFamily?: string;
} {
const claimedPlatform = normalizeDeviceMetadataForAuth(params.claimedPlatform);
const claimedDeviceFamily = normalizeDeviceMetadataForAuth(params.claimedDeviceFamily);
const pairedPlatform = normalizeDeviceMetadataForAuth(params.pairedPlatform);
const pairedDeviceFamily = normalizeDeviceMetadataForAuth(params.pairedDeviceFamily);
const hasPinnedPlatform = pairedPlatform !== "";
const hasPinnedDeviceFamily = pairedDeviceFamily !== "";
const platformMismatch = hasPinnedPlatform && claimedPlatform !== pairedPlatform;
const deviceFamilyMismatch = hasPinnedDeviceFamily && claimedDeviceFamily !== pairedDeviceFamily;
return {
platformMismatch,
deviceFamilyMismatch,
pinnedPlatform: hasPinnedPlatform ? params.pairedPlatform : undefined,
pinnedDeviceFamily: hasPinnedDeviceFamily ? params.pairedDeviceFamily : undefined,
};
}
export function attachGatewayWsMessageHandler(params: {
@@ -588,42 +659,21 @@ export function attachGatewayWsMessageHandler(params: {
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
return;
}
const payloadV3 = buildDeviceAuthPayloadV3({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null,
nonce: providedNonce,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
});
const payloadV2 = buildDeviceAuthPayload({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null,
nonce: providedNonce,
});
const rejectDeviceSignatureInvalid = () =>
rejectDeviceAuthInvalid("device-signature", "device signature invalid");
const signatureOkV3 = verifyDeviceSignature(
device.publicKey,
payloadV3,
device.signature,
);
const signatureOkV2 =
!signatureOkV3 && verifyDeviceSignature(device.publicKey, payloadV2, device.signature);
if (!signatureOkV3 && !signatureOkV2) {
const payloadVersion = resolveDeviceSignaturePayloadVersion({
device,
connectParams,
role,
scopes,
signedAtMs: signedAt,
nonce: providedNonce,
});
if (!payloadVersion) {
rejectDeviceSignatureInvalid();
return;
}
deviceAuthPayloadVersion = signatureOkV3 ? "v3" : "v2";
deviceAuthPayloadVersion = payloadVersion;
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
if (!devicePublicKey) {
rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
@@ -784,17 +834,13 @@ export function attachGatewayWsMessageHandler(params: {
const pairedPlatform = paired.platform;
const claimedDeviceFamily = connectParams.client.deviceFamily;
const pairedDeviceFamily = paired.deviceFamily;
const hasPinnedPlatform = normalizeClientMetadataForComparison(pairedPlatform) !== "";
const hasPinnedDeviceFamily =
normalizeClientMetadataForComparison(pairedDeviceFamily) !== "";
const platformMismatch =
hasPinnedPlatform &&
normalizeClientMetadataForComparison(claimedPlatform) !==
normalizeClientMetadataForComparison(pairedPlatform);
const deviceFamilyMismatch =
hasPinnedDeviceFamily &&
normalizeClientMetadataForComparison(claimedDeviceFamily) !==
normalizeClientMetadataForComparison(pairedDeviceFamily);
const metadataPinning = resolvePinnedClientMetadata({
claimedPlatform,
claimedDeviceFamily,
pairedPlatform,
pairedDeviceFamily,
});
const { platformMismatch, deviceFamilyMismatch } = metadataPinning;
if (platformMismatch || deviceFamilyMismatch) {
logGateway.warn(
`security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
@@ -804,11 +850,11 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
} else {
if (hasPinnedPlatform && pairedPlatform) {
connectParams.client.platform = pairedPlatform;
if (metadataPinning.pinnedPlatform) {
connectParams.client.platform = metadataPinning.pinnedPlatform;
}
if (hasPinnedDeviceFamily) {
connectParams.client.deviceFamily = pairedDeviceFamily;
if (metadataPinning.pinnedDeviceFamily) {
connectParams.client.deviceFamily = metadataPinning.pinnedDeviceFamily;
}
}
const pairedRoles = Array.isArray(paired.roles)