refactor(gateway)!: remove legacy v1 device-auth handshake

This commit is contained in:
Peter Steinberger
2026-02-22 09:26:49 +01:00
parent ed38b50fa5
commit 8887f41d7d
17 changed files with 404 additions and 210 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Breaking
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
### Fixes

View File

@@ -178,7 +178,7 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val connectNonceDeferred = CompletableDeferred<String>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "OpenClawGateway"
@@ -296,7 +296,7 @@ class GatewaySession(
}
}
private suspend fun sendConnect(connectNonce: String?) {
private suspend fun sendConnect(connectNonce: String) {
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
@@ -332,7 +332,7 @@ class GatewaySession(
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
connectNonce: String,
authToken: String,
authPassword: String?,
): JsonObject {
@@ -385,9 +385,7 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
put("nonce", JsonPrimitive(connectNonce))
}
} else {
null
@@ -447,8 +445,8 @@ class GatewaySession(
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
connectNonceDeferred.complete(nonce.trim())
}
return
}
@@ -459,12 +457,11 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
private suspend fun awaitConnectNonce(): String {
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
} catch (err: Throwable) {
throw IllegalStateException("connect challenge timeout", err)
}
}
@@ -595,14 +592,13 @@ class GatewaySession(
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String?,
nonce: String,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
val parts =
mutableListOf(
version,
"v2",
deviceId,
clientId,
clientMode,
@@ -610,10 +606,8 @@ class GatewaySession(
scopeString,
signedAtMs.toString(),
authToken,
nonce,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
}

View File

@@ -281,8 +281,8 @@ actor GatewayWizardClient {
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
let payloadParts = [
"v2",
identity.deviceId,
clientId,
clientMode,
@@ -290,23 +290,19 @@ actor GatewayWizardClient {
scopesValue,
String(signedAtMs),
self.token ?? "",
connectNonce,
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
let device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
"nonce": ProtoAnyCodable(connectNonce),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
@@ -333,29 +329,24 @@ actor GatewayWizardClient {
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String
{
return nonce
}
}
private func waitForConnectChallenge() async throws -> String {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String,
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
return nonce
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
})
}
}

View File

@@ -146,8 +146,8 @@ public actor GatewayChannelActor {
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
// and we must include the nonce once the gateway requires v2 signing.
// Remote gateways (tailscale/wan) can take longer to deliver connect.challenge.
// Connect now requires this nonce before we send device-auth.
private let connectTimeoutSeconds: Double = 12
private let connectChallengeTimeoutSeconds: Double = 6.0
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
@@ -391,8 +391,8 @@ public actor GatewayChannelActor {
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge()
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
let payloadParts = [
"v2",
identity?.deviceId ?? "",
clientId,
clientMode,
@@ -400,23 +400,19 @@ public actor GatewayChannelActor {
scopesValue,
String(signedAtMs),
authToken ?? "",
connectNonce,
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if includeDeviceIdentity, let identity {
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
var device: [String: ProtoAnyCodable] = [
let device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
"nonce": ProtoAnyCodable(connectNonce),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
}
@@ -545,33 +541,26 @@ public actor GatewayChannelActor {
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
private func waitForConnectChallenge() async throws -> String {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { throw ConnectChallengeError.timeout }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String,
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
{
return nonce
}
})
} catch {
if error is ConnectChallengeError {
self.logger.warning("gateway connect challenge timed out")
return nil
}
throw error
}
}
})
}
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {

View File

@@ -97,8 +97,8 @@ sequenceDiagram
for subsequent connects.
- **Local** connects (loopback or the gateway hosts own tailnet address) can be
autoapproved to keep samehost UX smooth.
- **Nonlocal** connects must sign the `connect.challenge` nonce and require
explicit approval.
- All connects must sign the `connect.challenge` nonce.
- **Nonlocal** connects still require explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.

View File

@@ -206,7 +206,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use.
- Non-local connections must sign the server-provided `connect.challenge` nonce.
- All connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning

View File

@@ -223,6 +223,12 @@ export class GatewayClient {
if (this.connectSent) {
return;
}
const nonce = this.connectNonce?.trim() ?? "";
if (!nonce) {
this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
this.ws?.close(1008, "connect challenge missing nonce");
return;
}
this.connectSent = true;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
@@ -243,7 +249,6 @@ export class GatewayClient {
}
: undefined;
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const scopes = this.opts.scopes ?? ["operator.admin"];
const device = (() => {
if (!this.opts.deviceIdentity) {
@@ -332,10 +337,13 @@ export class GatewayClient {
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
this.sendConnect();
if (!nonce || nonce.trim().length === 0) {
this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
this.ws?.close(1008, "connect challenge missing nonce");
return;
}
this.connectNonce = nonce.trim();
this.sendConnect();
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
@@ -378,16 +386,20 @@ export class GatewayClient {
this.connectNonce = null;
this.connectSent = false;
const rawConnectDelayMs = this.opts.connectDelayMs;
const connectDelayMs =
const connectChallengeTimeoutMs =
typeof rawConnectDelayMs === "number" && Number.isFinite(rawConnectDelayMs)
? Math.max(0, Math.min(5_000, rawConnectDelayMs))
: 750;
? Math.max(250, Math.min(10_000, rawConnectDelayMs))
: 2_000;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
}
this.connectTimer = setTimeout(() => {
this.sendConnect();
}, connectDelayMs);
if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
return;
}
this.opts.onConnectError?.(new Error("gateway connect challenge timeout"));
this.ws?.close(1008, "connect challenge timeout");
}, connectChallengeTimeoutMs);
}
private scheduleReconnect() {

View File

@@ -6,16 +6,14 @@ export type DeviceAuthPayloadParams = {
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: "v1" | "v2";
nonce: string;
};
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const version = params.version ?? (params.nonce ? "v2" : "v1");
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const base = [
version,
return [
"v2",
params.deviceId,
params.clientId,
params.clientMode,
@@ -23,9 +21,6 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
scopes,
String(params.signedAtMs),
token,
];
if (version === "v2") {
base.push(params.nonce ?? "");
}
return base.join("|");
params.nonce,
].join("|");
}

View File

@@ -47,7 +47,7 @@ export const ConnectParamsSchema = Type.Object(
publicKey: NonEmptyString,
signature: NonEmptyString,
signedAt: Type.Integer({ minimum: 0 }),
nonce: Type.Optional(NonEmptyString),
nonce: NonEmptyString,
},
{ additionalProperties: false },
),

View File

@@ -7,12 +7,14 @@ import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
connectReq,
getTrackedConnectChallengeNonce,
getFreePort,
installGatewayTestHooks,
onceMessage,
rpcReq,
startGatewayServer,
startServerWithClient,
trackConnectChallengeNonce,
testTailscaleWhois,
testState,
withGatewayServer,
@@ -35,10 +37,26 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
const openWs = async (port: number, headers?: Record<string, string>) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
return ws;
};
const readConnectChallengeNonce = async (ws: WebSocket) => {
const cached = getTrackedConnectChallengeNonce(ws);
if (cached) {
return cached;
}
const challenge = await onceMessage<{
type?: string;
event?: string;
payload?: Record<string, unknown> | null;
}>(ws, (o) => o.type === "event" && o.event === "connect.challenge");
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
return String(nonce);
};
const openTailscaleWs = async (port: number) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: {
@@ -50,6 +68,7 @@ const openTailscaleWs = async (port: number) => {
"tailscale-user-name": "Peter",
},
});
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
return ws;
};
@@ -132,7 +151,7 @@ async function createSignedDevice(params: {
clientId: string;
clientMode: string;
identityPath?: string;
nonce?: string;
nonce: string;
signedAtMs?: number;
}) {
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
@@ -434,6 +453,7 @@ describe("gateway server auth/connect", () => {
test("does not grant admin when scopes are omitted", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { randomUUID } = await import("node:crypto");
const os = await import("node:os");
@@ -445,6 +465,7 @@ describe("gateway server auth/connect", () => {
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`),
nonce,
});
const connectRes = await sendRawConnectReq(ws, {
@@ -480,12 +501,14 @@ describe("gateway server auth/connect", () => {
test("rejects device signature when scopes are omitted but signed with admin", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token,
scopes: ["operator.admin"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
nonce,
});
const connectRes = await sendRawConnectReq(ws, {
@@ -537,15 +560,26 @@ describe("gateway server auth/connect", () => {
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("requires nonce when host is non-local", async () => {
test("requires nonce for device auth", async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { host: "example.com" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws);
const { device } = await createSignedDevice({
token: "secret",
scopes: ["operator.admin"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
nonce: "nonce-not-sent",
});
const { nonce: _nonce, ...deviceWithoutNonce } = device;
const res = await connectReq(ws, {
token: "secret",
device: deviceWithoutNonce,
});
expect(res.ok).toBe(false);
expect(res.error?.message).toBe("device nonce required");
expect(res.error?.message ?? "").toContain("must have required property 'nonce'");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
@@ -836,12 +870,16 @@ describe("gateway server auth/connect", () => {
const challenge = await challengePromise;
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
const { randomUUID } = await import("node:crypto");
const os = await import("node:os");
const path = await import("node:path");
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const { device } = await createSignedDevice({
token: "secret",
scopes,
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
identityPath: path.join(os.tmpdir(), `openclaw-controlui-device-${randomUUID()}.json`),
nonce: String(nonce),
});
const res = await connectReq(ws, {
@@ -869,12 +907,15 @@ describe("gateway server auth/connect", () => {
try {
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, { origin: originForPort(port) });
const challengeNonce = await readConnectChallengeNonce(ws);
expect(challengeNonce).toBeTruthy();
const { device } = await createSignedDevice({
token: "secret",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
signedAtMs: Date.now() - 60 * 60 * 1000,
nonce: String(challengeNonce),
});
const res = await connectReq(ws, {
token: "secret",
@@ -901,8 +942,7 @@ describe("gateway server auth/connect", () => {
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const ws2 = await openWs(port);
const res2 = await connectReq(ws2, { token: deviceToken });
expect(res2.ok).toBe(true);
@@ -984,7 +1024,7 @@ describe("gateway server auth/connect", () => {
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (scopes: string[]) => {
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
@@ -994,19 +1034,22 @@ describe("gateway server auth/connect", () => {
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const initialNonce = await readConnectChallengeNonce(ws);
const initial = await connectReq(ws, {
token: "secret",
scopes: ["operator.read"],
client,
device: buildDevice(["operator.read"]),
device: buildDevice(["operator.read"], initialNonce),
});
if (!initial.ok) {
await approvePendingPairingIfNeeded();
@@ -1017,13 +1060,13 @@ describe("gateway server auth/connect", () => {
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const ws2 = await openWs(port);
const nonce2 = await readConnectChallengeNonce(ws2);
const res = await connectReq(ws2, {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"]),
device: buildDevice(["operator.admin"], nonce2),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
@@ -1031,13 +1074,13 @@ describe("gateway server auth/connect", () => {
await approvePendingPairingIfNeeded();
ws2.close();
const ws3 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws3.once("open", resolve));
const ws3 = await openWs(port);
const nonce3 = await readConnectChallengeNonce(ws3);
const approved = await connectReq(ws3, {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"]),
device: buildDevice(["operator.admin"], nonce3),
});
expect(approved.ok).toBe(true);
paired = await getPairedDevice(identity.deviceId);
@@ -1066,7 +1109,7 @@ describe("gateway server auth/connect", () => {
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (role: "operator" | "node", scopes: string[], nonce?: string) => {
const buildDevice = (role: "operator" | "node", scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
@@ -1164,7 +1207,7 @@ describe("gateway server auth/connect", () => {
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (scopes: string[]) => {
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
@@ -1174,20 +1217,23 @@ describe("gateway server auth/connect", () => {
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const initialNonce = await readConnectChallengeNonce(ws);
const initial = await connectReq(ws, {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"]),
device: buildDevice(["operator.admin"], initialNonce),
});
if (!initial.ok) {
await approvePendingPairingIfNeeded();
@@ -1195,13 +1241,13 @@ describe("gateway server auth/connect", () => {
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const ws2 = await openWs(port);
const nonce2 = await readConnectChallengeNonce(ws2);
const res = await connectReq(ws2, {
token: "secret",
scopes: ["operator.read"],
client,
device: buildDevice(["operator.read"]),
device: buildDevice(["operator.read"], nonce2),
});
expect(res.ok).toBe(true);
ws2.close();
@@ -1214,26 +1260,47 @@ describe("gateway server auth/connect", () => {
});
test("allows legacy paired devices missing role/scope metadata", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js");
const { writeJsonAtomic } = await import("../infra/json-files.js");
const { getPairedDevice } = await import("../infra/device-pairing.js");
const {
device,
identity: { deviceId },
} = await createSignedDevice({
token: "secret",
scopes: ["operator.read"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
});
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const deviceId = identity.deviceId;
const buildDevice = (nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId,
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
role: "operator",
scopes: ["operator.read"],
signedAtMs,
token: "secret",
nonce,
});
return {
id: deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const { server, ws, port, prevToken } = await startServerWithClient("secret");
let ws2: WebSocket | undefined;
try {
const initialNonce = await readConnectChallengeNonce(ws);
const initial = await connectReq(ws, {
token: "secret",
scopes: ["operator.read"],
client: TEST_OPERATOR_CLIENT,
device,
device: buildDevice(initialNonce),
});
if (!initial.ok) {
await approvePendingPairingIfNeeded();
@@ -1256,14 +1323,14 @@ describe("gateway server auth/connect", () => {
await writeJsonAtomic(pairedPath, paired);
ws.close();
const wsReconnect = new WebSocket(`ws://127.0.0.1:${port}`);
const wsReconnect = await openWs(port);
ws2 = wsReconnect;
await new Promise<void>((resolve) => wsReconnect.once("open", resolve));
const reconnectNonce = await readConnectChallengeNonce(wsReconnect);
const reconnect = await connectReq(wsReconnect, {
token: "secret",
scopes: ["operator.read"],
client: TEST_OPERATOR_CLIENT,
device,
device: buildDevice(reconnectNonce),
});
expect(reconnect.ok).toBe(true);
@@ -1302,7 +1369,7 @@ describe("gateway server auth/connect", () => {
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (scopes: string[]) => {
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
@@ -1312,20 +1379,23 @@ describe("gateway server auth/connect", () => {
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const initialNonce = await readConnectChallengeNonce(ws);
const initial = await connectReq(ws, {
token: "secret",
scopes: ["operator.read"],
client,
device: buildDevice(["operator.read"]),
device: buildDevice(["operator.read"], initialNonce),
});
if (!initial.ok) {
const list = await listDevicePairing();
@@ -1349,14 +1419,14 @@ describe("gateway server auth/connect", () => {
delete legacy.scopes;
await writeJsonAtomic(pairedPath, paired);
const wsUpgrade = new WebSocket(`ws://127.0.0.1:${port}`);
const wsUpgrade = await openWs(port);
ws2 = wsUpgrade;
await new Promise<void>((resolve) => wsUpgrade.once("open", resolve));
const upgradeNonce = await readConnectChallengeNonce(wsUpgrade);
const upgraded = await connectReq(wsUpgrade, {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"]),
device: buildDevice(["operator.admin"], upgradeNonce),
});
expect(upgraded.ok).toBe(false);
expect(upgraded.error?.message ?? "").toContain("pairing required");
@@ -1389,8 +1459,7 @@ describe("gateway server auth/connect", () => {
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const ws2 = await openWs(port);
const res2 = await connectReq(ws2, { token: deviceToken });
expect(res2.ok).toBe(false);

View File

@@ -13,8 +13,10 @@ import { buildDeviceAuthPayload } from "./device-auth.js";
import {
connectReq,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -78,15 +80,33 @@ describe("node.invoke approval bypass", () => {
const connectOperatorWithRetry = async (
scopes: string[],
resolveDevice?: () => NonNullable<Parameters<typeof connectReq>[1]>["device"],
resolveDevice?: (nonce: string) => NonNullable<Parameters<typeof connectReq>[1]>["device"],
) => {
const connectOnce = async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
const challengePromise = resolveDevice
? onceMessage<{
type?: string;
event?: string;
payload?: Record<string, unknown> | null;
}>(ws, (o) => o.type === "event" && o.event === "connect.challenge")
: null;
await new Promise<void>((resolve) => ws.once("open", resolve));
const nonce = (() => {
if (!challengePromise) {
return Promise.resolve("");
}
return challengePromise.then((challenge) => {
const value = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof value).toBe("string");
return String(value);
});
})();
const res = await connectReq(ws, {
token: "secret",
scopes,
...(resolveDevice ? { device: resolveDevice() } : {}),
...(resolveDevice ? { device: resolveDevice(await nonce) } : {}),
});
return { ws, res };
};
@@ -116,22 +136,26 @@ describe("node.invoke approval bypass", () => {
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
expect(deviceId).toBeTruthy();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: deviceId!,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes,
signedAtMs,
token: "secret",
return await connectOperatorWithRetry(scopes, (nonce) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: deviceId!,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: deviceId!,
publicKey: publicKeyRaw,
signature: signDevicePayload(privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
});
return await connectOperatorWithRetry(scopes, () => ({
id: deviceId!,
publicKey: publicKeyRaw,
signature: signDevicePayload(privateKeyPem, payload),
signedAt: signedAtMs,
}));
};
const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => {

View File

@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js";
import {
connectOk,
installGatewayTestHooks,
readConnectChallengeNonce,
rpcReq,
} from "./test-helpers.js";
import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
async function createFreshOperatorDevice(scopes: string[]) {
async function createFreshOperatorDevice(scopes: string[], nonce: string) {
const { randomUUID } = await import("node:crypto");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -24,6 +29,7 @@ async function createFreshOperatorDevice(scopes: string[]) {
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
@@ -31,6 +37,7 @@ async function createFreshOperatorDevice(scopes: string[]) {
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
}
@@ -51,10 +58,12 @@ describe("gateway talk.config", () => {
});
await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"]),
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
});
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
ws,
@@ -76,10 +85,12 @@ describe("gateway talk.config", () => {
});
await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"]),
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
});
const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
expect(res.ok).toBe(false);
@@ -96,14 +107,15 @@ describe("gateway talk.config", () => {
});
await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
device: await createFreshOperatorDevice([
"operator.read",
"operator.write",
"operator.talk.secrets",
]),
device: await createFreshOperatorDevice(
["operator.read", "operator.write", "operator.talk.secrets"],
String(nonce),
),
});
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
includeSecrets: true,

View File

@@ -10,7 +10,13 @@ describe("ws connect policy", () => {
const bypass = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
deviceRaw: { id: "dev-1", publicKey: "pk", signature: "sig", signedAt: Date.now() },
deviceRaw: {
id: "dev-1",
publicKey: "pk",
signature: "sig",
signedAt: Date.now(),
nonce: "nonce-1",
},
});
expect(bypass.allowBypass).toBe(true);
expect(bypass.device).toBeNull();
@@ -18,7 +24,13 @@ describe("ws connect policy", () => {
const regular = resolveControlUiAuthPolicy({
isControlUi: false,
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
deviceRaw: { id: "dev-2", publicKey: "pk", signature: "sig", signedAt: Date.now() },
deviceRaw: {
id: "dev-2",
publicKey: "pk",
signature: "sig",
signedAt: Date.now(),
nonce: "nonce-2",
},
});
expect(regular.allowBypass).toBe(false);
expect(regular.device?.id).toBe("dev-2");

View File

@@ -80,7 +80,7 @@ import {
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
export function attachGatewayWsMessageHandler(params: {
socket: WebSocket;
@@ -528,13 +528,12 @@ export function attachGatewayWsMessageHandler(params: {
rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
return;
}
const nonceRequired = !isLocalClient;
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) {
if (!providedNonce) {
rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
return;
}
if (providedNonce && providedNonce !== connectNonce) {
if (providedNonce !== connectNonce) {
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
return;
}
@@ -546,31 +545,12 @@ export function attachGatewayWsMessageHandler(params: {
scopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? null,
nonce: providedNonce || undefined,
version: providedNonce ? "v2" : "v1",
nonce: providedNonce,
});
const rejectDeviceSignatureInvalid = () =>
rejectDeviceAuthInvalid("device-signature", "device signature invalid");
const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature);
const allowLegacy = !nonceRequired && !providedNonce;
if (!signatureOk && allowLegacy) {
const legacyPayload = buildDeviceAuthPayload({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? null,
version: "v1",
});
if (verifyDeviceSignature(device.publicKey, legacyPayload, device.signature)) {
// accepted legacy loopback signature
} else {
rejectDeviceSignatureInvalid();
return;
}
} else if (!signatureOk) {
if (!signatureOk) {
rejectDeviceSignatureInvalid();
return;
}

View File

@@ -88,7 +88,43 @@ export async function connectGatewayClient(params: {
export async function connectDeviceAuthReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url);
const connectNoncePromise = new Promise<string>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("timeout waiting for connect challenge")),
5000,
);
const closeHandler = (code: number, reason: Buffer) => {
clearTimeout(timer);
ws.off("message", handler);
reject(new Error(`closed ${code}: ${rawDataToString(reason)}`));
};
const handler = (data: WebSocket.RawData) => {
try {
const obj = JSON.parse(rawDataToString(data)) as {
type?: unknown;
event?: unknown;
payload?: { nonce?: unknown } | null;
};
if (obj.type !== "event" || obj.event !== "connect.challenge") {
return;
}
const nonce = obj.payload?.nonce;
if (typeof nonce !== "string" || nonce.trim().length === 0) {
return;
}
clearTimeout(timer);
ws.off("message", handler);
ws.off("close", closeHandler);
resolve(nonce.trim());
} catch {
// ignore parse errors while waiting for challenge
}
};
ws.on("message", handler);
ws.once("close", closeHandler);
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const connectNonce = await connectNoncePromise;
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
@@ -99,12 +135,14 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
scopes: [],
signedAtMs,
token: params.token ?? null,
nonce: connectNonce,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce: connectNonce,
};
ws.send(
JSON.stringify({

View File

@@ -242,6 +242,37 @@ type GatewayTestMessage = {
[key: string]: unknown;
};
const CONNECT_CHALLENGE_NONCE_KEY = "__openclawTestConnectChallengeNonce";
const CONNECT_CHALLENGE_TRACKED_KEY = "__openclawTestConnectChallengeTracked";
type TrackedWs = WebSocket & Record<string, unknown>;
export function getTrackedConnectChallengeNonce(ws: WebSocket): string | undefined {
const tracked = (ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY];
return typeof tracked === "string" && tracked.trim().length > 0 ? tracked.trim() : undefined;
}
export function trackConnectChallengeNonce(ws: WebSocket): void {
const trackedWs = ws as TrackedWs;
if (trackedWs[CONNECT_CHALLENGE_TRACKED_KEY] === true) {
return;
}
trackedWs[CONNECT_CHALLENGE_TRACKED_KEY] = true;
ws.on("message", (data) => {
try {
const obj = JSON.parse(rawDataToString(data)) as GatewayTestMessage;
if (obj.type !== "event" || obj.event !== "connect.challenge") {
return;
}
const nonce = (obj.payload as { nonce?: unknown } | undefined)?.nonce;
if (typeof nonce === "string" && nonce.trim().length > 0) {
trackedWs[CONNECT_CHALLENGE_NONCE_KEY] = nonce.trim();
}
} catch {
// ignore parse errors in nonce tracker
}
});
}
export function onceMessage<T extends GatewayTestMessage = GatewayTestMessage>(
ws: WebSocket,
filter: (obj: T) => boolean,
@@ -345,6 +376,7 @@ export async function startServerWithClient(
`ws://127.0.0.1:${port}`,
wsHeaders ? { headers: wsHeaders } : undefined,
);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
const cleanup = () => {
@@ -380,6 +412,32 @@ type ConnectResponse = {
error?: { message?: string };
};
export async function readConnectChallengeNonce(
ws: WebSocket,
timeoutMs = 2_000,
): Promise<string | undefined> {
const cached = getTrackedConnectChallengeNonce(ws);
if (cached) {
return cached;
}
trackConnectChallengeNonce(ws);
try {
const evt = await onceMessage<{
type?: string;
event?: string;
payload?: Record<string, unknown> | null;
}>(ws, (o) => o.type === "event" && o.event === "connect.challenge", timeoutMs);
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
if (typeof nonce === "string" && nonce.trim().length > 0) {
(ws as TrackedWs)[CONNECT_CHALLENGE_NONCE_KEY] = nonce.trim();
return nonce.trim();
}
return undefined;
} catch {
return undefined;
}
}
export async function connectReq(
ws: WebSocket,
opts?: {
@@ -410,6 +468,7 @@ export async function connectReq(
signedAt: number;
nonce?: string;
} | null;
skipConnectChallengeNonce?: boolean;
},
): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto");
@@ -440,6 +499,11 @@ export async function connectReq(
: role === "operator"
? ["operator.admin"]
: [];
if (opts?.skipConnectChallengeNonce && opts?.device === undefined) {
throw new Error("skipConnectChallengeNonce requires an explicit device override");
}
const connectChallengeNonce =
opts?.device !== undefined ? undefined : await readConnectChallengeNonce(ws);
const device = (() => {
if (opts?.device === null) {
return undefined;
@@ -447,6 +511,9 @@ export async function connectReq(
if (opts?.device) {
return opts.device;
}
if (!connectChallengeNonce) {
throw new Error("missing connect.challenge nonce");
}
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
@@ -457,13 +524,14 @@ export async function connectReq(
scopes: requestedScopes,
signedAtMs,
token: token ?? null,
nonce: connectChallengeNonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce: opts?.device?.nonce,
nonce: connectChallengeNonce,
};
})();
ws.send(

View File

@@ -129,6 +129,11 @@ export class GatewayBrowserClient {
if (this.connectSent) {
return;
}
const nonce = this.connectNonce?.trim() ?? "";
if (!nonce) {
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce");
return;
}
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
@@ -169,13 +174,12 @@ export class GatewayBrowserClient {
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
nonce: string;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
@@ -249,10 +253,12 @@ export class GatewayBrowserClient {
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
void this.sendConnect();
if (!nonce || nonce.trim().length === 0) {
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce");
return;
}
this.connectNonce = nonce.trim();
void this.sendConnect();
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
@@ -306,7 +312,10 @@ export class GatewayBrowserClient {
window.clearTimeout(this.connectTimer);
}
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
return;
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge timeout");
}, 2_000);
}
}