mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
refactor(gateway)!: remove legacy v1 device-auth handshake
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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("|")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -97,8 +97,8 @@ sequenceDiagram
|
||||
for subsequent connects.
|
||||
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||
auto‑approved to keep same‑host UX smooth.
|
||||
- **Non‑local** connects must sign the `connect.challenge` nonce and require
|
||||
explicit approval.
|
||||
- All connects must sign the `connect.challenge` nonce.
|
||||
- **Non‑local** connects still require explicit approval.
|
||||
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
|
||||
remote.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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("|");
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user