diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e80263ab443..f62a88f17ee 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -215,6 +215,28 @@ The Gateway treats these as **claims** and enforces server-side allowlists. Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth` is enabled for break-glass use. - All connections must sign the server-provided `connect.challenge` nonce. + +### Device auth migration diagnostics + +For legacy clients that still use pre-challenge signing behavior, `connect` now returns +`DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`. + +Common migration failures: + +| Message | details.code | details.reason | Meaning | +| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- | +| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). | +| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. | +| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. | +| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. | +| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. | +| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. | + +Migration target: + +- Always wait for `connect.challenge`. +- Sign the v2 payload that includes the server nonce. +- Send the same nonce in `connect.params.device.nonce`. - Preferred signature payload is `v3`, which binds `platform` and `deviceFamily` in addition to device/client/role/scopes/token/nonce fields. - Legacy `v2` signatures remain accepted for compatibility, but paired-device diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 45963f15579..86874820b41 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -80,9 +80,27 @@ Look for: Common signatures: - `device identity required` → non-secure context or missing device auth. +- `device nonce required` / `device nonce mismatch` → client is not completing the + challenge-based device auth flow (`connect.challenge` + `device.nonce`). +- `device signature invalid` / `device signature expired` → client signed the wrong + payload (or stale timestamp) for the current handshake. - `unauthorized` / reconnect loop → token/password mismatch. - `gateway connect failed:` → wrong host/port/url target. +Device auth v2 migration check: + +```bash +openclaw --version +openclaw doctor +openclaw gateway status +``` + +If logs show nonce/signature errors, update the connecting client and verify it: + +1. waits for `connect.challenge` +2. signs the challenge-bound payload +3. sends `connect.params.device.nonce` with the same challenge nonce + Related: - [/web/control-ui](/web/control-ui) diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 5a0975fed78..62286092671 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -16,6 +16,12 @@ export const ConnectErrorDetailCodes = { CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED", DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID", + DEVICE_AUTH_DEVICE_ID_MISMATCH: "DEVICE_AUTH_DEVICE_ID_MISMATCH", + DEVICE_AUTH_SIGNATURE_EXPIRED: "DEVICE_AUTH_SIGNATURE_EXPIRED", + DEVICE_AUTH_NONCE_REQUIRED: "DEVICE_AUTH_NONCE_REQUIRED", + DEVICE_AUTH_NONCE_MISMATCH: "DEVICE_AUTH_NONCE_MISMATCH", + DEVICE_AUTH_SIGNATURE_INVALID: "DEVICE_AUTH_SIGNATURE_INVALID", + DEVICE_AUTH_PUBLIC_KEY_INVALID: "DEVICE_AUTH_PUBLIC_KEY_INVALID", PAIRING_REQUIRED: "PAIRING_REQUIRED", } as const; @@ -57,6 +63,27 @@ export function resolveAuthConnectErrorDetailCode( } } +export function resolveDeviceAuthConnectErrorDetailCode( + reason: string | undefined, +): ConnectErrorDetailCode { + switch (reason) { + case "device-id-mismatch": + return ConnectErrorDetailCodes.DEVICE_AUTH_DEVICE_ID_MISMATCH; + case "device-signature-stale": + return ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_EXPIRED; + case "device-nonce-missing": + return ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED; + case "device-nonce-mismatch": + return ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH; + case "device-signature": + return ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID; + case "device-public-key": + return ConnectErrorDetailCodes.DEVICE_AUTH_PUBLIC_KEY_INVALID; + default: + return ConnectErrorDetailCodes.DEVICE_AUTH_INVALID; + } +} + export function readConnectErrorDetailCode(details: unknown): string | null { if (!details || typeof details !== "object" || Array.isArray(details)) { return null; diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index c5a82390cea..0d08d1be332 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -253,7 +253,13 @@ async function sendRawConnectReq( id?: string; ok?: boolean; payload?: Record | null; - error?: { message?: string }; + error?: { + message?: string; + details?: { + code?: string; + reason?: string; + }; + }; }>(ws, isConnectResMessage(params.id)); } @@ -548,6 +554,10 @@ describe("gateway server auth/connect", () => { }); expect(connectRes.ok).toBe(false); expect(connectRes.error?.message ?? "").toContain("device signature invalid"); + expect(connectRes.error?.details?.code).toBe( + ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID, + ); + expect(connectRes.error?.details?.reason).toBe("device-signature"); await new Promise((resolve) => ws.once("close", () => resolve())); }); @@ -613,6 +623,58 @@ describe("gateway server auth/connect", () => { await new Promise((resolve) => ws.once("close", () => resolve())); }); + test("returns nonce-required detail code when nonce is blank", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token, + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: "c-blank-nonce", + token, + device: { ...device, nonce: " " }, + }); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain("device nonce required"); + expect(connectRes.error?.details?.code).toBe( + ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED, + ); + expect(connectRes.error?.details?.reason).toBe("device-nonce-missing"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test("returns nonce-mismatch detail code when nonce does not match challenge", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token, + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: "c-wrong-nonce", + token, + device: { ...device, nonce: `${nonce}-stale` }, + }); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain("device nonce mismatch"); + expect(connectRes.error?.details?.code).toBe( + ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH, + ); + expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + test("invalid connect params surface in response and close reason", async () => { const ws = await openWs(port); const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 7c1b449ff4d..cdd0a68d74c 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -48,6 +48,7 @@ import { checkBrowserOrigin } from "../../origin-check.js"; import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; import { ConnectErrorDetailCodes, + resolveDeviceAuthConnectErrorDetailCode, resolveAuthConnectErrorDetailCode, } from "../../protocol/connect-error-details.js"; import { @@ -630,7 +631,7 @@ export function attachGatewayWsMessageHandler(params: { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message, { details: { - code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID, + code: resolveDeviceAuthConnectErrorDetailCode(reason), reason, }, }),