mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 19:51:22 +00:00
refactor(gateway): unify v3 auth payload builders and vectors
This commit is contained in:
29
src/gateway/device-auth.test.ts
Normal file
29
src/gateway/device-auth.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user