Skip to content

Commit cb9374a

Browse files
authored
Gateway: improve device-auth v2 migration diagnostics (#28305)
* Gateway: add device-auth detail code resolver * Gateway: emit specific device-auth detail codes * Gateway tests: cover nonce and signature detail codes * Docs: add gateway device-auth migration diagnostics * Docs: add device-auth v2 troubleshooting signatures
1 parent 22ad752 commit cb9374a

File tree

5 files changed

+132
-2
lines changed

5 files changed

+132
-2
lines changed

docs/gateway/protocol.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,28 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
215215
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
216216
is enabled for break-glass use.
217217
- All connections must sign the server-provided `connect.challenge` nonce.
218+
219+
### Device auth migration diagnostics
220+
221+
For legacy clients that still use pre-challenge signing behavior, `connect` now returns
222+
`DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`.
223+
224+
Common migration failures:
225+
226+
| Message | details.code | details.reason | Meaning |
227+
| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- |
228+
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
229+
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
230+
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
231+
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
232+
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
233+
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
234+
235+
Migration target:
236+
237+
- Always wait for `connect.challenge`.
238+
- Sign the v2 payload that includes the server nonce.
239+
- Send the same nonce in `connect.params.device.nonce`.
218240
- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
219241
in addition to device/client/role/scopes/token/nonce fields.
220242
- Legacy `v2` signatures remain accepted for compatibility, but paired-device

docs/gateway/troubleshooting.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,27 @@ Look for:
8080
Common signatures:
8181

8282
- `device identity required` → non-secure context or missing device auth.
83+
- `device nonce required` / `device nonce mismatch` → client is not completing the
84+
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
85+
- `device signature invalid` / `device signature expired` → client signed the wrong
86+
payload (or stale timestamp) for the current handshake.
8387
- `unauthorized` / reconnect loop → token/password mismatch.
8488
- `gateway connect failed:` → wrong host/port/url target.
8589

90+
Device auth v2 migration check:
91+
92+
```bash
93+
openclaw --version
94+
openclaw doctor
95+
openclaw gateway status
96+
```
97+
98+
If logs show nonce/signature errors, update the connecting client and verify it:
99+
100+
1. waits for `connect.challenge`
101+
2. signs the challenge-bound payload
102+
3. sends `connect.params.device.nonce` with the same challenge nonce
103+
86104
Related:
87105

88106
- [/web/control-ui](/web/control-ui)

src/gateway/protocol/connect-error-details.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export const ConnectErrorDetailCodes = {
1616
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
1717
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
1818
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",
19+
DEVICE_AUTH_DEVICE_ID_MISMATCH: "DEVICE_AUTH_DEVICE_ID_MISMATCH",
20+
DEVICE_AUTH_SIGNATURE_EXPIRED: "DEVICE_AUTH_SIGNATURE_EXPIRED",
21+
DEVICE_AUTH_NONCE_REQUIRED: "DEVICE_AUTH_NONCE_REQUIRED",
22+
DEVICE_AUTH_NONCE_MISMATCH: "DEVICE_AUTH_NONCE_MISMATCH",
23+
DEVICE_AUTH_SIGNATURE_INVALID: "DEVICE_AUTH_SIGNATURE_INVALID",
24+
DEVICE_AUTH_PUBLIC_KEY_INVALID: "DEVICE_AUTH_PUBLIC_KEY_INVALID",
1925
PAIRING_REQUIRED: "PAIRING_REQUIRED",
2026
} as const;
2127

@@ -57,6 +63,27 @@ export function resolveAuthConnectErrorDetailCode(
5763
}
5864
}
5965

66+
export function resolveDeviceAuthConnectErrorDetailCode(
67+
reason: string | undefined,
68+
): ConnectErrorDetailCode {
69+
switch (reason) {
70+
case "device-id-mismatch":
71+
return ConnectErrorDetailCodes.DEVICE_AUTH_DEVICE_ID_MISMATCH;
72+
case "device-signature-stale":
73+
return ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_EXPIRED;
74+
case "device-nonce-missing":
75+
return ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED;
76+
case "device-nonce-mismatch":
77+
return ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH;
78+
case "device-signature":
79+
return ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID;
80+
case "device-public-key":
81+
return ConnectErrorDetailCodes.DEVICE_AUTH_PUBLIC_KEY_INVALID;
82+
default:
83+
return ConnectErrorDetailCodes.DEVICE_AUTH_INVALID;
84+
}
85+
}
86+
6087
export function readConnectErrorDetailCode(details: unknown): string | null {
6188
if (!details || typeof details !== "object" || Array.isArray(details)) {
6289
return null;

src/gateway/server.auth.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,13 @@ async function sendRawConnectReq(
253253
id?: string;
254254
ok?: boolean;
255255
payload?: Record<string, unknown> | null;
256-
error?: { message?: string };
256+
error?: {
257+
message?: string;
258+
details?: {
259+
code?: string;
260+
reason?: string;
261+
};
262+
};
257263
}>(ws, isConnectResMessage(params.id));
258264
}
259265

@@ -548,6 +554,10 @@ describe("gateway server auth/connect", () => {
548554
});
549555
expect(connectRes.ok).toBe(false);
550556
expect(connectRes.error?.message ?? "").toContain("device signature invalid");
557+
expect(connectRes.error?.details?.code).toBe(
558+
ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID,
559+
);
560+
expect(connectRes.error?.details?.reason).toBe("device-signature");
551561
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
552562
});
553563

@@ -613,6 +623,58 @@ describe("gateway server auth/connect", () => {
613623
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
614624
});
615625

626+
test("returns nonce-required detail code when nonce is blank", async () => {
627+
const ws = await openWs(port);
628+
const token = resolveGatewayTokenOrEnv();
629+
const nonce = await readConnectChallengeNonce(ws);
630+
const { device } = await createSignedDevice({
631+
token,
632+
scopes: ["operator.admin"],
633+
clientId: TEST_OPERATOR_CLIENT.id,
634+
clientMode: TEST_OPERATOR_CLIENT.mode,
635+
nonce,
636+
});
637+
638+
const connectRes = await sendRawConnectReq(ws, {
639+
id: "c-blank-nonce",
640+
token,
641+
device: { ...device, nonce: " " },
642+
});
643+
expect(connectRes.ok).toBe(false);
644+
expect(connectRes.error?.message ?? "").toContain("device nonce required");
645+
expect(connectRes.error?.details?.code).toBe(
646+
ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED,
647+
);
648+
expect(connectRes.error?.details?.reason).toBe("device-nonce-missing");
649+
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
650+
});
651+
652+
test("returns nonce-mismatch detail code when nonce does not match challenge", async () => {
653+
const ws = await openWs(port);
654+
const token = resolveGatewayTokenOrEnv();
655+
const nonce = await readConnectChallengeNonce(ws);
656+
const { device } = await createSignedDevice({
657+
token,
658+
scopes: ["operator.admin"],
659+
clientId: TEST_OPERATOR_CLIENT.id,
660+
clientMode: TEST_OPERATOR_CLIENT.mode,
661+
nonce,
662+
});
663+
664+
const connectRes = await sendRawConnectReq(ws, {
665+
id: "c-wrong-nonce",
666+
token,
667+
device: { ...device, nonce: `${nonce}-stale` },
668+
});
669+
expect(connectRes.ok).toBe(false);
670+
expect(connectRes.error?.message ?? "").toContain("device nonce mismatch");
671+
expect(connectRes.error?.details?.code).toBe(
672+
ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH,
673+
);
674+
expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch");
675+
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
676+
});
677+
616678
test("invalid connect params surface in response and close reason", async () => {
617679
const ws = await openWs(port);
618680
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { checkBrowserOrigin } from "../../origin-check.js";
4848
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
4949
import {
5050
ConnectErrorDetailCodes,
51+
resolveDeviceAuthConnectErrorDetailCode,
5152
resolveAuthConnectErrorDetailCode,
5253
} from "../../protocol/connect-error-details.js";
5354
import {
@@ -630,7 +631,7 @@ export function attachGatewayWsMessageHandler(params: {
630631
ok: false,
631632
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
632633
details: {
633-
code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID,
634+
code: resolveDeviceAuthConnectErrorDetailCode(reason),
634635
reason,
635636
},
636637
}),

0 commit comments

Comments
 (0)