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,52 @@
package ai.openclaw.android.gateway
internal object DeviceAuthPayload {
fun buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val platformNorm = normalizeMetadataField(platform)
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
return listOf(
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
platformNorm,
deviceFamilyNorm,
).joinToString("|")
}
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
val out = StringBuilder(trimmed.length)
for (ch in trimmed) {
if (ch in 'A'..'Z') {
out.append((ch.code + 32).toChar())
} else {
out.append(ch)
}
}
return out.toString()
}
}

View File

@@ -372,7 +372,7 @@ class GatewaySession(
val signedAtMs = System.currentTimeMillis()
val payload =
buildDeviceAuthPayloadV3(
DeviceAuthPayload.buildV3(
deviceId = identity.deviceId,
clientId = client.id,
clientMode = client.mode,
@@ -584,42 +584,6 @@ class GatewaySession(
}
}
private fun buildDeviceAuthPayloadV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val platformNorm = normalizeDeviceMetadataField(platform)
val deviceFamilyNorm = normalizeDeviceMetadataField(deviceFamily)
val parts =
mutableListOf(
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
platformNorm,
deviceFamilyNorm,
)
return parts.joinToString("|")
}
private fun normalizeDeviceMetadataField(value: String?): String =
value?.trim()?.lowercase(Locale.ROOT).orEmpty()
private fun normalizeCanvasHostUrl(
raw: String?,
endpoint: GatewayEndpoint,

View File

@@ -0,0 +1,35 @@
package ai.openclaw.android.gateway
import org.junit.Assert.assertEquals
import org.junit.Test
class DeviceAuthPayloadTest {
@Test
fun buildV3_matchesCanonicalVector() {
val payload =
DeviceAuthPayload.buildV3(
deviceId = "dev-1",
clientId = "openclaw-macos",
clientMode = "ui",
role = "operator",
scopes = listOf("operator.admin", "operator.read"),
signedAtMs = 1_700_000_000_000,
token = "tok-123",
nonce = "nonce-abc",
platform = " IOS ",
deviceFamily = " iPhone ",
)
assertEquals(
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone",
payload,
)
}
@Test
fun normalizeMetadataField_asciiOnlyLowercase() {
assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS "))
assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC "))
assertEquals("", DeviceAuthPayload.normalizeMetadataField(null))
}
}

View File

@@ -280,7 +280,7 @@ actor GatewayWizardClient {
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let payload = buildDeviceAuthPayloadV3(
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: clientId,
clientMode: clientMode,
@@ -327,44 +327,6 @@ actor GatewayWizardClient {
}
}
private func buildDeviceAuthPayloadV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?) -> String
{
let scopeString = scopes.joined(separator: ",")
let authToken = token ?? ""
let normalizedPlatform = normalizeMetadataField(platform)
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
return [
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
String(signedAtMs),
authToken,
nonce,
normalizedPlatform,
normalizedDeviceFamily,
].joined(separator: "|")
}
private func normalizeMetadataField(_ value: String?) -> String {
guard let value else { return "" }
return value
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(with: Locale(identifier: "en_US_POSIX"))
}
private func waitForConnectChallenge() async throws -> String {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(

View File

@@ -0,0 +1,55 @@
import Foundation
public enum GatewayDeviceAuthPayload {
public static func buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?) -> String
{
let scopeString = scopes.joined(separator: ",")
let authToken = token ?? ""
let normalizedPlatform = normalizeMetadataField(platform)
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
return [
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
String(signedAtMs),
authToken,
nonce,
normalizedPlatform,
normalizedDeviceFamily,
].joined(separator: "|")
}
static func normalizeMetadataField(_ value: String?) -> String {
guard let value else { return "" }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
var output = String()
output.reserveCapacity(trimmed.count)
for scalar in trimmed.unicodeScalars {
let codePoint = scalar.value
if codePoint >= 65, codePoint <= 90, let lowered = UnicodeScalar(codePoint + 32) {
output.unicodeScalars.append(lowered)
} else {
output.unicodeScalars.append(scalar)
}
}
return output
}
}

View File

@@ -399,7 +399,7 @@ public actor GatewayChannelActor {
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge()
if includeDeviceIdentity, let identity {
let payload = buildDeviceAuthPayloadV3(
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: clientId,
clientMode: clientMode,
@@ -443,44 +443,6 @@ public actor GatewayChannelActor {
}
}
private func buildDeviceAuthPayloadV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?) -> String
{
let scopeString = scopes.joined(separator: ",")
let authToken = token ?? ""
let normalizedPlatform = normalizeMetadataField(platform)
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
return [
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
String(signedAtMs),
authToken,
nonce,
normalizedPlatform,
normalizedDeviceFamily,
].joined(separator: "|")
}
private func normalizeMetadataField(_ value: String?) -> String {
guard let value else { return "" }
return value
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(with: Locale(identifier: "en_US_POSIX"))
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,

View File

@@ -0,0 +1,30 @@
import Testing
@testable import OpenClawKit
@Suite("DeviceAuthPayload")
struct DeviceAuthPayloadTests {
@Test("builds canonical v3 payload vector")
func buildsCanonicalV3PayloadVector() {
let payload = GatewayDeviceAuthPayload.buildV3(
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
== "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
}
@Test("normalizes metadata with ASCII-only lowercase")
func normalizesMetadataWithAsciiLowercase() {
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos")
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac")
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "")
}
}

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)