diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md index 55ec09499..09dd026c2 100644 --- a/docs/refactor/clawnet.md +++ b/docs/refactor/clawnet.md @@ -291,7 +291,7 @@ Same `deviceId` across roles → single “Instance” row: # Execution checklist (ship order) - [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local. - [x] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries. -- [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests. +- [x] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests. - [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting. - [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation. - [ ] **Discovery + allowlist:** WS discovery TXT includes TLS fingerprint + role hints; node commands filtered by server allowlist. diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 181608230..d36c6c825 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -152,6 +152,85 @@ describe("gateway server auth/connect", () => { } }); + test("requires pairing for scope upgrades", 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 { approveDevicePairing, getPairedDevice, listDevicePairing } = + await import("../infra/device-pairing.js"); + const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } = + await import("../utils/message-channel.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + const identityDir = await mkdtemp(join(tmpdir(), "clawdbot-device-scope-")); + const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const client = { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, + }; + const buildDevice = (scopes: string[]) => { + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: client.id, + clientMode: client.mode, + role: "operator", + scopes, + signedAtMs, + token: "secret", + }); + return { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + }; + const initial = await connectReq(ws, { + token: "secret", + scopes: ["operator.read"], + client, + device: buildDevice(["operator.read"]), + }); + if (!initial.ok) { + const list = await listDevicePairing(); + const pending = list.pending.at(0); + expect(pending?.requestId).toBeDefined(); + if (pending?.requestId) { + await approveDevicePairing(pending.requestId); + } + } + + let paired = await getPairedDevice(identity.deviceId); + expect(paired?.scopes).toContain("operator.read"); + + ws.close(); + + const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws2.once("open", resolve)); + const res = await connectReq(ws2, { + token: "secret", + scopes: ["operator.admin"], + client, + device: buildDevice(["operator.admin"]), + }); + expect(res.ok).toBe(true); + paired = await getPairedDevice(identity.deviceId); + expect(paired?.scopes).toContain("operator.admin"); + + ws2.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("rejects revoked device token", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } =