From d88b239d3c8a5683a1375affae008191e81d3923 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 10:29:13 +0000 Subject: [PATCH] feat: add device token auth and devices cli --- .../ClawdbotProtocol/GatewayModels.swift | 42 ++++ docs/cli/devices.md | 66 +++++ docs/cli/gateway.md | 5 +- docs/cli/index.md | 2 + docs/gateway/configuration.md | 5 +- docs/gateway/protocol.md | 17 ++ src/cli/devices-cli.ts | 225 ++++++++++++++++++ src/cli/gateway-cli/discover.ts | 9 +- src/cli/program/register.subclis.ts | 8 + src/gateway/auth.ts | 2 +- src/gateway/client.ts | 59 +++-- src/gateway/protocol/index.ts | 10 + src/gateway/protocol/schema/devices.ts | 17 ++ src/gateway/protocol/schema/frames.ts | 11 + .../protocol/schema/protocol-schemas.ts | 4 + src/gateway/protocol/schema/types.ts | 4 + src/gateway/server-methods-list.ts | 2 + src/gateway/server-methods.ts | 2 + src/gateway/server-methods/devices.ts | 89 ++++++- src/gateway/server.auth.test.ts | 77 +++++- src/gateway/server.health.test.ts | 4 +- src/gateway/server.nodes.allowlist.test.ts | 2 + .../server/ws-connection/message-handler.ts | 105 +++++--- src/infra/device-auth-store.ts | 104 ++++++++ src/infra/device-pairing.test.ts | 43 ++++ src/infra/device-pairing.ts | 202 ++++++++++++++++ src/media/input-files.ts | 10 +- 27 files changed, 1055 insertions(+), 71 deletions(-) create mode 100644 docs/cli/devices.md create mode 100644 src/cli/devices-cli.ts create mode 100644 src/infra/device-auth-store.ts create mode 100644 src/infra/device-pairing.test.ts diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index ea9b5a776..0ec8533ef 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -75,6 +75,7 @@ public struct HelloOk: Codable, Sendable { public let features: [String: AnyCodable] public let snapshot: Snapshot public let canvashosturl: String? + public let auth: [String: AnyCodable]? public let policy: [String: AnyCodable] public init( @@ -84,6 +85,7 @@ public struct HelloOk: Codable, Sendable { features: [String: AnyCodable], snapshot: Snapshot, canvashosturl: String?, + auth: [String: AnyCodable]?, policy: [String: AnyCodable] ) { self.type = type @@ -92,6 +94,7 @@ public struct HelloOk: Codable, Sendable { self.features = features self.snapshot = snapshot self.canvashosturl = canvashosturl + self.auth = auth self.policy = policy } private enum CodingKeys: String, CodingKey { @@ -101,6 +104,7 @@ public struct HelloOk: Codable, Sendable { case features case snapshot case canvashosturl = "canvasHostUrl" + case auth case policy } } @@ -1950,6 +1954,44 @@ public struct DevicePairRejectParams: Codable, Sendable { } } +public struct DeviceTokenRotateParams: Codable, Sendable { + public let deviceid: String + public let role: String + public let scopes: [String]? + + public init( + deviceid: String, + role: String, + scopes: [String]? + ) { + self.deviceid = deviceid + self.role = role + self.scopes = scopes + } + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + case scopes + } +} + +public struct DeviceTokenRevokeParams: Codable, Sendable { + public let deviceid: String + public let role: String + + public init( + deviceid: String, + role: String + ) { + self.deviceid = deviceid + self.role = role + } + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + } +} + public struct DevicePairRequestedEvent: Codable, Sendable { public let requestid: String public let deviceid: String diff --git a/docs/cli/devices.md b/docs/cli/devices.md new file mode 100644 index 000000000..910eec957 --- /dev/null +++ b/docs/cli/devices.md @@ -0,0 +1,66 @@ +--- +summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)" +read_when: + - You are approving device pairing requests + - You need to rotate or revoke device tokens +--- + +# `clawdbot devices` + +Manage device pairing requests and device-scoped tokens. + +## Commands + +### `clawdbot devices list` + +List pending pairing requests and paired devices. + +``` +clawdbot devices list +clawdbot devices list --json +``` + +### `clawdbot devices approve ` + +Approve a pending device pairing request. + +``` +clawdbot devices approve +``` + +### `clawdbot devices reject ` + +Reject a pending device pairing request. + +``` +clawdbot devices reject +``` + +### `clawdbot devices rotate --device --role [--scope ]` + +Rotate a device token for a specific role (optionally updating scopes). + +``` +clawdbot devices rotate --device --role operator --scope operator.read --scope operator.write +``` + +### `clawdbot devices revoke --device --role ` + +Revoke a device token for a specific role. + +``` +clawdbot devices revoke --device --role node +``` + +## Common options + +- `--url `: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured). +- `--token `: Gateway token (if required). +- `--password `: Gateway password (password auth). +- `--timeout `: RPC timeout. +- `--json`: JSON output (recommended for scripting). + +## Notes + +- Token rotation returns a new token (sensitive). Treat it like a secret. +- These commands require `operator.pairing` (or `operator.admin`) scope. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index e9e461761..25d44eee6 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -116,17 +116,18 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}' ## Discover gateways (Bonjour) -`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`). +`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`). - Multicast DNS-SD: `local.` - Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour)) -Only gateways with the **bridge enabled** will advertise the discovery beacon. +Only gateways with Bonjour discovery enabled (default) advertise the beacon. Wide-Area discovery records include (TXT): - `gatewayPort` (WebSocket port, usually `18789`) - `sshPort` (SSH port; defaults to `22` if not present) - `tailnetDns` (MagicDNS hostname, when available) +- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint) - `cliPath` (optional hint for remote installs) ### `gateway discover` diff --git a/docs/cli/index.md b/docs/cli/index.md index 47ae49c37..1a32d38d9 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -34,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do - [`models`](/cli/models) - [`memory`](/cli/memory) - [`nodes`](/cli/nodes) +- [`devices`](/cli/devices) - [`node`](/cli/node) - [`approvals`](/cli/approvals) - [`sandbox`](/cli/sandbox) @@ -188,6 +189,7 @@ clawdbot [--dev] [--profile ] runs run nodes + devices node start daemon diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 99f6183f0..b41c18fd1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2677,7 +2677,7 @@ Notes: Auth and Tailscale: - `gateway.auth.mode` sets the handshake requirements (`token` or `password`). -- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). +- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers @@ -2686,6 +2686,9 @@ Auth and Tailscale: `true`, Serve requests do not need a token/password; set `false` to require explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and auth mode is not `password`. +- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes. + These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them + instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`. - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 5b27b0879..ed6095c9c 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -59,6 +59,18 @@ Gateway → Client: } ``` +When a device token is issued, `hello-ok` also includes: + +```json +{ + "auth": { + "deviceToken": "…", + "role": "operator", + "scopes": ["operator.read", "operator.write"] + } +} +``` + ### Node example ```json @@ -141,6 +153,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token` must match or the socket is closed. +- After pairing, the Gateway issues a **device token** scoped to the connection + role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be + persisted by the client for future connects. +- Device tokens can be rotated/revoked via `device.token.rotate` and + `device.token.revoke` (requires `operator.pairing` scope). ## Device identity + pairing diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts new file mode 100644 index 000000000..e7290fe47 --- /dev/null +++ b/src/cli/devices-cli.ts @@ -0,0 +1,225 @@ +import type { Command } from "commander"; + +import { callGateway } from "../gateway/call.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { defaultRuntime } from "./runtime.js"; +import { withProgress } from "./progress.js"; + +type DevicesRpcOpts = { + url?: string; + token?: string; + password?: string; + timeout?: string; + json?: boolean; + device?: string; + role?: string; + scope?: string[]; +}; + +type DeviceTokenSummary = { + role: string; + scopes?: string[]; + revokedAtMs?: number; +}; + +type PendingDevice = { + requestId: string; + deviceId: string; + displayName?: string; + role?: string; + remoteIp?: string; + isRepair?: boolean; + ts?: number; +}; + +type PairedDevice = { + deviceId: string; + displayName?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + tokens?: DeviceTokenSummary[]; + createdAtMs?: number; + approvedAtMs?: number; +}; + +type DevicePairingList = { + pending?: PendingDevice[]; + paired?: PairedDevice[]; +}; + +function formatAge(msAgo: number) { + const s = Math.max(0, Math.floor(msAgo / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + const d = Math.floor(h / 24); + return `${d}d`; +} + +const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => + cmd + .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (password auth)") + .option("--timeout ", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000)) + .option("--json", "Output JSON", false); + +const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) => + withProgress( + { + label: `Devices ${method}`, + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + url: opts.url, + token: opts.token, + password: opts.password, + method, + params, + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }), + ); + +function parseDevicePairingList(value: unknown): DevicePairingList { + const obj = typeof value === "object" && value !== null ? (value as Record) : {}; + return { + pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [], + paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [], + }; +} + +function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) { + if (!tokens || tokens.length === 0) return "tokens: none"; + const parts = tokens + .map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`) + .sort((a, b) => a.localeCompare(b)); + return `tokens: ${parts.join(", ")}`; +} + +export function registerDevicesCli(program: Command) { + const devices = program.command("devices").description("Device pairing and auth tokens"); + + devicesCallOpts( + devices + .command("list") + .description("List pending and paired devices") + .action(async (opts: DevicesRpcOpts) => { + const result = await callGatewayCli("device.pair.list", opts, {}); + const list = parseDevicePairingList(result); + if (opts.json) { + defaultRuntime.log(JSON.stringify(list, null, 2)); + return; + } + if (list.pending?.length) { + defaultRuntime.log("Pending:"); + for (const req of list.pending) { + const name = req.displayName || req.deviceId; + const repair = req.isRepair ? " (repair)" : ""; + const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; + const age = typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : ""; + const role = req.role ? ` · role: ${req.role}` : ""; + defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`); + } + } + if (list.paired?.length) { + defaultRuntime.log("Paired:"); + for (const device of list.paired) { + const name = device.displayName || device.deviceId; + const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -"; + const scopes = device.scopes?.length ? `scopes: ${device.scopes.join(", ")}` : "scopes: -"; + const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; + const tokens = formatTokenSummary(device.tokens); + defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`); + } + } + if (!list.pending?.length && !list.paired?.length) { + defaultRuntime.log("No device pairing entries."); + } + }), + ); + + devicesCallOpts( + devices + .command("approve") + .description("Approve a pending device pairing request") + .argument("", "Pending request id") + .action(async (requestId: string, opts: DevicesRpcOpts) => { + const result = await callGatewayCli("device.pair.approve", opts, { requestId }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId; + defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`); + }), + ); + + devicesCallOpts( + devices + .command("reject") + .description("Reject a pending device pairing request") + .argument("", "Pending request id") + .action(async (requestId: string, opts: DevicesRpcOpts) => { + const result = await callGatewayCli("device.pair.reject", opts, { requestId }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + const deviceId = (result as { deviceId?: string })?.deviceId; + defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`); + }), + ); + + devicesCallOpts( + devices + .command("rotate") + .description("Rotate a device token for a role") + .requiredOption("--device ", "Device id") + .requiredOption("--role ", "Role name") + .option("--scope ", "Scopes to attach to the token (repeatable)") + .action(async (opts: DevicesRpcOpts) => { + const deviceId = String(opts.device ?? "").trim(); + const role = String(opts.role ?? "").trim(); + if (!deviceId || !role) { + defaultRuntime.error("--device and --role required"); + defaultRuntime.exit(1); + return; + } + const result = await callGatewayCli("device.token.rotate", opts, { + deviceId, + role, + scopes: Array.isArray(opts.scope) ? opts.scope : undefined, + }); + defaultRuntime.log(JSON.stringify(result, null, 2)); + }), + ); + + devicesCallOpts( + devices + .command("revoke") + .description("Revoke a device token for a role") + .requiredOption("--device ", "Device id") + .requiredOption("--role ", "Role name") + .action(async (opts: DevicesRpcOpts) => { + const deviceId = String(opts.device ?? "").trim(); + const role = String(opts.role ?? "").trim(); + if (!deviceId || !role) { + defaultRuntime.error("--device and --role required"); + defaultRuntime.exit(1); + return; + } + const result = await callGatewayCli("device.token.revoke", opts, { + deviceId, + role, + }); + defaultRuntime.log(JSON.stringify(result, null, 2)); + }), + ); +} diff --git a/src/cli/gateway-cli/discover.ts b/src/cli/gateway-cli/discover.ts index e0e4f2fa9..e624d5f40 100644 --- a/src/cli/gateway-cli/discover.ts +++ b/src/cli/gateway-cli/discover.ts @@ -64,7 +64,8 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean): const host = pickBeaconHost(beacon); const gatewayPort = pickGatewayPort(beacon); - const wsUrl = host ? `ws://${host}:${gatewayPort}` : null; + const scheme = beacon.gatewayTls ? "wss" : "ws"; + const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null; const lines = [`- ${title} ${domain}`]; @@ -81,6 +82,12 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean): if (wsUrl) { lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`); } + if (beacon.gatewayTls) { + const fingerprint = beacon.gatewayTlsFingerprintSha256 + ? `sha256 ${beacon.gatewayTlsFingerprintSha256}` + : "enabled"; + lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`); + } if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) { const ssh = `ssh -N -L 18789:127.0.0.1:18789 @${host} -p ${beacon.sshPort}`; lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 835e1b502..8b480b7f0 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -94,6 +94,14 @@ const entries: SubCliEntry[] = [ mod.registerNodesCli(program); }, }, + { + name: "devices", + description: "Device pairing + token management", + register: async (program) => { + const mod = await import("../devices-cli.js"); + mod.registerDevicesCli(program); + }, + }, { name: "node", description: "Node control", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 3c83fac20..d2d850a24 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -12,7 +12,7 @@ export type ResolvedGatewayAuth = { export type GatewayAuthResult = { ok: boolean; - method?: "none" | "token" | "password" | "tailscale"; + method?: "none" | "token" | "password" | "tailscale" | "device-token"; user?: string; reason?: string; }; diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 28b4805da..addd6e9fa 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -8,6 +8,7 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; +import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -146,36 +147,39 @@ export class GatewayClient { } private sendConnect() { + const role = this.opts.role ?? "operator"; + const storedToken = this.opts.deviceIdentity + ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token + : null; + const authToken = this.opts.token ?? storedToken ?? undefined; const auth = - this.opts.token || this.opts.password + authToken || this.opts.password ? { - token: this.opts.token, + token: authToken, password: this.opts.password, } : undefined; const signedAtMs = Date.now(); - const role = this.opts.role ?? "operator"; const scopes = this.opts.scopes ?? ["operator.admin"]; - const identity = this.opts.deviceIdentity; - if (!identity) { - throw new Error("gateway client requires a device identity"); - } - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, - role, - scopes, - signedAtMs, - token: this.opts.token ?? null, - }); - const signature = signDevicePayload(identity.privateKeyPem, payload); - const device = { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature, - signedAt: signedAtMs, - }; + const device = (() => { + if (!this.opts.deviceIdentity) return undefined; + const payload = buildDeviceAuthPayload({ + deviceId: this.opts.deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, + role, + scopes, + signedAtMs, + token: authToken ?? null, + }); + const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); + return { + id: this.opts.deviceIdentity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem), + signature, + signedAt: signedAtMs, + }; + })(); const params: ConnectParams = { minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, @@ -201,6 +205,15 @@ export class GatewayClient { void this.request("connect", params) .then((helloOk) => { + const authInfo = helloOk?.auth; + if (authInfo?.deviceToken && this.opts.deviceIdentity) { + storeDeviceAuthToken({ + deviceId: this.opts.deviceIdentity.deviceId, + role: authInfo.role ?? role, + token: authInfo.deviceToken, + scopes: authInfo.scopes ?? [], + }); + } this.backoffMs = 1000; this.tickIntervalMs = typeof helloOk.policy?.tickIntervalMs === "number" diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index d3c62064e..06847f2c5 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -62,6 +62,10 @@ import { DevicePairListParamsSchema, type DevicePairRejectParams, DevicePairRejectParamsSchema, + type DeviceTokenRevokeParams, + DeviceTokenRevokeParamsSchema, + type DeviceTokenRotateParams, + DeviceTokenRotateParamsSchema, type ExecApprovalsGetParams, ExecApprovalsGetParamsSchema, type ExecApprovalsNodeGetParams, @@ -270,6 +274,12 @@ export const validateDevicePairApproveParams = ajv.compile( DevicePairRejectParamsSchema, ); +export const validateDeviceTokenRotateParams = ajv.compile( + DeviceTokenRotateParamsSchema, +); +export const validateDeviceTokenRevokeParams = ajv.compile( + DeviceTokenRevokeParamsSchema, +); export const validateExecApprovalsGetParams = ajv.compile( ExecApprovalsGetParamsSchema, ); diff --git a/src/gateway/protocol/schema/devices.ts b/src/gateway/protocol/schema/devices.ts index 1a5fa3840..ec32f381b 100644 --- a/src/gateway/protocol/schema/devices.ts +++ b/src/gateway/protocol/schema/devices.ts @@ -14,6 +14,23 @@ export const DevicePairRejectParamsSchema = Type.Object( { additionalProperties: false }, ); +export const DeviceTokenRotateParamsSchema = Type.Object( + { + deviceId: NonEmptyString, + role: NonEmptyString, + scopes: Type.Optional(Type.Array(NonEmptyString)), + }, + { additionalProperties: false }, +); + +export const DeviceTokenRevokeParamsSchema = Type.Object( + { + deviceId: NonEmptyString, + role: NonEmptyString, + }, + { additionalProperties: false }, +); + export const DevicePairRequestedEventSchema = Type.Object( { requestId: NonEmptyString, diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 166e137a7..dfaac1c26 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -85,6 +85,17 @@ export const HelloOkSchema = Type.Object( ), snapshot: SnapshotSchema, canvasHostUrl: Type.Optional(NonEmptyString), + auth: Type.Optional( + Type.Object( + { + deviceToken: NonEmptyString, + role: NonEmptyString, + scopes: Type.Array(NonEmptyString), + issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false }, + ), + ), policy: Type.Object( { maxPayload: Type.Integer({ minimum: 1 }), diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 841d517bb..9a18af061 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -64,6 +64,8 @@ import { DevicePairRejectParamsSchema, DevicePairRequestedEventSchema, DevicePairResolvedEventSchema, + DeviceTokenRevokeParamsSchema, + DeviceTokenRotateParamsSchema, } from "./devices.js"; import { ConnectParamsSchema, @@ -206,6 +208,8 @@ export const ProtocolSchemas: Record = { DevicePairListParams: DevicePairListParamsSchema, DevicePairApproveParams: DevicePairApproveParamsSchema, DevicePairRejectParams: DevicePairRejectParamsSchema, + DeviceTokenRotateParams: DeviceTokenRotateParamsSchema, + DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema, DevicePairRequestedEvent: DevicePairRequestedEventSchema, DevicePairResolvedEvent: DevicePairResolvedEventSchema, ChatHistoryParams: ChatHistoryParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index ccb6dd1d9..01de1749f 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -60,6 +60,8 @@ import type { DevicePairApproveParamsSchema, DevicePairListParamsSchema, DevicePairRejectParamsSchema, + DeviceTokenRevokeParamsSchema, + DeviceTokenRotateParamsSchema, } from "./devices.js"; import type { ConnectParamsSchema, @@ -195,6 +197,8 @@ export type ExecApprovalResolveParams = Static; export type DevicePairApproveParams = Static; export type DevicePairRejectParams = Static; +export type DeviceTokenRotateParams = Static; +export type DeviceTokenRevokeParams = Static; export type ChatAbortParams = Static; export type ChatInjectParams = Static; export type ChatEvent = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 7f0c90907..30bd2d1a7 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -49,6 +49,8 @@ const BASE_METHODS = [ "device.pair.list", "device.pair.approve", "device.pair.reject", + "device.token.rotate", + "device.token.revoke", "node.rename", "node.list", "node.describe", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 7c801ced9..0759744d8 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -41,6 +41,8 @@ const PAIRING_METHODS = new Set([ "device.pair.list", "device.pair.approve", "device.pair.reject", + "device.token.rotate", + "device.token.revoke", "node.rename", ]); const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 972d63335..9029a1894 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -1,7 +1,11 @@ import { approveDevicePairing, listDevicePairing, + type DeviceAuthToken, rejectDevicePairing, + revokeDeviceToken, + rotateDeviceToken, + summarizeDeviceTokens, } from "../../infra/device-pairing.js"; import { ErrorCodes, @@ -10,9 +14,19 @@ import { validateDevicePairApproveParams, validateDevicePairListParams, validateDevicePairRejectParams, + validateDeviceTokenRevokeParams, + validateDeviceTokenRotateParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; +function redactPairedDevice(device: { tokens?: Record } & Record) { + const { tokens, ...rest } = device; + return { + ...rest, + tokens: summarizeDeviceTokens(tokens), + }; +} + export const deviceHandlers: GatewayRequestHandlers = { "device.pair.list": async ({ params, respond }) => { if (!validateDevicePairListParams(params)) { @@ -29,7 +43,14 @@ export const deviceHandlers: GatewayRequestHandlers = { return; } const list = await listDevicePairing(); - respond(true, list, undefined); + respond( + true, + { + pending: list.pending, + paired: list.paired.map((device) => redactPairedDevice(device)), + }, + undefined, + ); }, "device.pair.approve": async ({ params, respond, context }) => { if (!validateDevicePairApproveParams(params)) { @@ -58,10 +79,10 @@ export const deviceHandlers: GatewayRequestHandlers = { deviceId: approved.device.deviceId, decision: "approved", ts: Date.now(), - }, + }, { dropIfSlow: true }, ); - respond(true, approved, undefined); + respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined); }, "device.pair.reject": async ({ params, respond, context }) => { if (!validateDevicePairRejectParams(params)) { @@ -95,4 +116,66 @@ export const deviceHandlers: GatewayRequestHandlers = { ); respond(true, rejected, undefined); }, + "device.token.rotate": async ({ params, respond }) => { + if (!validateDeviceTokenRotateParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid device.token.rotate params: ${formatValidationErrors( + validateDeviceTokenRotateParams.errors, + )}`, + ), + ); + return; + } + const { deviceId, role, scopes } = params as { + deviceId: string; + role: string; + scopes?: string[]; + }; + const entry = await rotateDeviceToken({ deviceId, role, scopes }); + if (!entry) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); + return; + } + respond( + true, + { + deviceId, + role: entry.role, + token: entry.token, + scopes: entry.scopes, + rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs, + }, + undefined, + ); + }, + "device.token.revoke": async ({ params, respond }) => { + if (!validateDeviceTokenRevokeParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid device.token.revoke params: ${formatValidationErrors( + validateDeviceTokenRevokeParams.errors, + )}`, + ), + ); + return; + } + const { deviceId, role } = params as { deviceId: string; role: string }; + const entry = await revokeDeviceToken({ deviceId, role }); + if (!entry) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); + return; + } + respond( + true, + { deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() }, + undefined, + ); + }, }; diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 2ff302ec0..3092a88e3 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -73,7 +73,7 @@ describe("gateway server auth/connect", () => { }); test("rejects invalid token", async () => { - const { server, ws, prevToken } = await startServerWithClient("secret"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); const res = await connectReq(ws, { token: "wrong" }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("unauthorized"); @@ -100,6 +100,81 @@ describe("gateway server auth/connect", () => { await server.close(); }); + test("accepts device token auth for paired device", async () => { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import( + "../infra/device-pairing.js" + ); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + const res = await connectReq(ws, { token: "secret" }); + if (!res.ok) { + const list = await listDevicePairing(); + const pending = list.pending.at(0); + expect(pending?.requestId).toBeDefined(); + if (pending?.requestId) { + await approveDevicePairing(pending.requestId); + } + } + + const identity = loadOrCreateDeviceIdentity(); + const paired = await getPairedDevice(identity.deviceId); + const deviceToken = paired?.tokens?.operator?.token; + expect(deviceToken).toBeDefined(); + + ws.close(); + + const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws2.once("open", resolve)); + const res2 = await connectReq(ws2, { token: deviceToken }); + expect(res2.ok).toBe(true); + + 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 } = + await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + const res = await connectReq(ws, { token: "secret" }); + if (!res.ok) { + const list = await listDevicePairing(); + const pending = list.pending.at(0); + expect(pending?.requestId).toBeDefined(); + if (pending?.requestId) { + await approveDevicePairing(pending.requestId); + } + } + + const identity = loadOrCreateDeviceIdentity(); + const paired = await getPairedDevice(identity.deviceId); + const deviceToken = paired?.tokens?.operator?.token; + expect(deviceToken).toBeDefined(); + + await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" }); + + ws.close(); + + const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws2.once("open", resolve)); + const res2 = await connectReq(ws2, { token: deviceToken }); + expect(res2.ok).toBe(false); + + ws2.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("rejects invalid password", async () => { testState.gatewayAuth = { mode: "password", password: "secret" }; const port = await getFreePort(); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 82a8713cc..6ea099ba3 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { emitAgentEvent } from "../infra/agent-events.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { connectOk, @@ -223,8 +224,9 @@ describe("gateway server health/presence", () => { ); const presenceRes = await presenceP; + const identity = loadOrCreateDeviceIdentity(); const entries = presenceRes.payload as Array>; - const clientEntry = entries.find((e) => e.instanceId === "abc"); + const clientEntry = entries.find((e) => e.instanceId === identity.deviceId); expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT); expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.mode).toBe("ui"); diff --git a/src/gateway/server.nodes.allowlist.test.ts b/src/gateway/server.nodes.allowlist.test.ts index 9cfecaa27..02c46f444 100644 --- a/src/gateway/server.nodes.allowlist.test.ts +++ b/src/gateway/server.nodes.allowlist.test.ts @@ -58,6 +58,7 @@ describe("gateway node command allowlist", () => { role: "node", client: { id: GATEWAY_CLIENT_NAMES.NODE_HOST, + displayName: "node-empty", version: "1.0.0", platform: "ios", mode: GATEWAY_CLIENT_MODES.NODE, @@ -94,6 +95,7 @@ describe("gateway node command allowlist", () => { role: "node", client: { id: GATEWAY_CLIENT_NAMES.NODE_HOST, + displayName: "node-allowed", version: "1.0.0", platform: "ios", mode: GATEWAY_CLIENT_MODES.NODE, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 1544eefa9..485577bb3 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -9,9 +9,11 @@ import { } from "../../../infra/device-identity.js"; import { approveDevicePairing, + ensureDeviceToken, getPairedDevice, requestDevicePairing, updatePairedDeviceMetadata, + verifyDeviceToken, } from "../../../infra/device-pairing.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; @@ -218,42 +220,6 @@ export function attachGatewayWsMessageHandler(params: { return; } - const authResult = await authorizeGatewayConnect({ - auth: resolvedAuth, - connectAuth: connectParams.auth, - req: upgradeReq, - }); - if (!authResult.ok) { - setHandshakeState("failed"); - logWsControl.warn( - `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`, - ); - const authProvided = connectParams.auth?.token - ? "token" - : connectParams.auth?.password - ? "password" - : "none"; - setCloseCause("unauthorized", { - authMode: resolvedAuth.mode, - authProvided, - authReason: authResult.reason, - allowTailscale: resolvedAuth.allowTailscale, - client: connectParams.client.id, - clientDisplayName: connectParams.client.displayName, - mode: connectParams.client.mode, - version: connectParams.client.version, - }); - send({ - type: "res", - id: frame.id, - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"), - }); - close(1008, "unauthorized"); - return; - } - const authMethod = authResult.method ?? "none"; - const roleRaw = connectParams.role ?? "operator"; const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null; if (!role) { @@ -385,6 +351,55 @@ export function attachGatewayWsMessageHandler(params: { } } + const authResult = await authorizeGatewayConnect({ + auth: resolvedAuth, + connectAuth: connectParams.auth, + req: upgradeReq, + }); + let authOk = authResult.ok; + let authMethod = authResult.method ?? "none"; + if (!authOk && connectParams.auth?.token) { + const tokenCheck = await verifyDeviceToken({ + deviceId: device.id, + token: connectParams.auth.token, + role, + scopes, + }); + if (tokenCheck.ok) { + authOk = true; + authMethod = "device-token"; + } + } + if (!authOk) { + setHandshakeState("failed"); + logWsControl.warn( + `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`, + ); + const authProvided = connectParams.auth?.token + ? "token" + : connectParams.auth?.password + ? "password" + : "none"; + setCloseCause("unauthorized", { + authMode: resolvedAuth.mode, + authProvided, + authReason: authResult.reason, + allowTailscale: resolvedAuth.allowTailscale, + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"), + }); + close(1008, "unauthorized"); + return; + } + if (device && devicePublicKey) { const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ @@ -445,7 +460,11 @@ export function attachGatewayWsMessageHandler(params: { if (!ok) return; } else { const allowedRoles = new Set( - Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [], + Array.isArray(paired.roles) + ? paired.roles + : paired.role + ? [paired.role] + : [], ); if (allowedRoles.size === 0) { const ok = await requirePairing("role-upgrade", paired); @@ -482,6 +501,10 @@ export function attachGatewayWsMessageHandler(params: { } } + const deviceToken = device + ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) + : null; + if (role === "node") { const cfg = loadConfig(); const allowlist = resolveNodeCommandAllowlist(cfg, { @@ -552,6 +575,14 @@ export function attachGatewayWsMessageHandler(params: { features: { methods: gatewayMethods, events }, snapshot, canvasHostUrl, + auth: deviceToken + ? { + deviceToken: deviceToken.token, + role: deviceToken.role, + scopes: deviceToken.scopes, + issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + } + : undefined, policy: { maxPayload: MAX_PAYLOAD_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES, diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts new file mode 100644 index 000000000..59666524d --- /dev/null +++ b/src/infra/device-auth-store.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "../config/paths.js"; + +export type DeviceAuthEntry = { + token: string; + role: string; + scopes: string[]; + updatedAtMs: number; +}; + +type DeviceAuthStore = { + version: 1; + deviceId: string; + tokens: Record; +}; + +const DEVICE_AUTH_FILE = "device-auth.json"; + +function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE); +} + +function normalizeRole(role: string): string { + return role.trim(); +} + +function normalizeScopes(scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) return []; + const out = new Set(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) out.add(trimmed); + } + return [...out].sort(); +} + +function readStore(filePath: string): DeviceAuthStore | null { + try { + if (!fs.existsSync(filePath)) return null; + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as DeviceAuthStore; + if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") return null; + if (!parsed.tokens || typeof parsed.tokens !== "object") return null; + return parsed; + } catch { + return null; + } +} + +function writeStore(filePath: string, store: DeviceAuthStore): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +export function loadDeviceAuthToken(params: { + deviceId: string; + role: string; + env?: NodeJS.ProcessEnv; +}): DeviceAuthEntry | null { + const filePath = resolveDeviceAuthPath(params.env); + const store = readStore(filePath); + if (!store) return null; + if (store.deviceId !== params.deviceId) return null; + const role = normalizeRole(params.role); + const entry = store.tokens[role]; + if (!entry || typeof entry.token !== "string") return null; + return entry; +} + +export function storeDeviceAuthToken(params: { + deviceId: string; + role: string; + token: string; + scopes?: string[]; + env?: NodeJS.ProcessEnv; +}): DeviceAuthEntry { + const filePath = resolveDeviceAuthPath(params.env); + const existing = readStore(filePath); + const role = normalizeRole(params.role); + const next: DeviceAuthStore = { + version: 1, + deviceId: params.deviceId, + tokens: + existing && existing.deviceId === params.deviceId && existing.tokens + ? { ...existing.tokens } + : {}, + }; + const entry: DeviceAuthEntry = { + token: params.token, + role, + scopes: normalizeScopes(params.scopes), + updatedAtMs: Date.now(), + }; + next.tokens[role] = entry; + writeStore(filePath, next); + return entry; +} diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts new file mode 100644 index 000000000..faa2a1a44 --- /dev/null +++ b/src/infra/device-pairing.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + approveDevicePairing, + getPairedDevice, + requestDevicePairing, + rotateDeviceToken, +} from "./device-pairing.js"; + +describe("device pairing tokens", () => { + test("preserves existing token scopes when rotating without scopes", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "clawdbot-device-pairing-")); + const request = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.admin"], + }, + baseDir, + ); + await approveDevicePairing(request.request.requestId, baseDir); + + await rotateDeviceToken({ + deviceId: "device-1", + role: "operator", + scopes: ["operator.read"], + baseDir, + }); + let paired = await getPairedDevice("device-1", baseDir); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); + + await rotateDeviceToken({ + deviceId: "device-1", + role: "operator", + baseDir, + }); + paired = await getPairedDevice("device-1", baseDir); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); + }); +}); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 7c15a985e..c5594df34 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -20,6 +20,25 @@ export type DevicePairingPendingRequest = { ts: number; }; +export type DeviceAuthToken = { + token: string; + role: string; + scopes: string[]; + createdAtMs: number; + rotatedAtMs?: number; + revokedAtMs?: number; + lastUsedAtMs?: number; +}; + +export type DeviceAuthTokenSummary = { + role: string; + scopes: string[]; + createdAtMs: number; + rotatedAtMs?: number; + revokedAtMs?: number; + lastUsedAtMs?: number; +}; + export type PairedDevice = { deviceId: string; publicKey: string; @@ -31,6 +50,7 @@ export type PairedDevice = { roles?: string[]; scopes?: string[]; remoteIp?: string; + tokens?: Record; createdAtMs: number; approvedAtMs: number; }; @@ -136,6 +156,11 @@ function normalizeDeviceId(deviceId: string) { return deviceId.trim(); } +function normalizeRole(role: string | undefined): string | null { + const trimmed = role?.trim(); + return trimmed ? trimmed : null; +} + function mergeRoles(...items: Array): string[] | undefined { const roles = new Set(); for (const item of items) { @@ -167,6 +192,27 @@ function mergeScopes(...items: Array): string[] | undefine return [...scopes]; } +function normalizeScopes(scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) return []; + const out = new Set(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) out.add(trimmed); + } + return [...out].sort(); +} + +function scopesAllow(requested: string[], allowed: string[]): boolean { + if (requested.length === 0) return true; + if (allowed.length === 0) return false; + const allowedSet = new Set(allowed); + return requested.every((scope) => allowedSet.has(scope)); +} + +function newToken() { + return randomUUID().replaceAll("-", ""); +} + export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts); @@ -237,6 +283,22 @@ export async function approveDevicePairing( const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); const scopes = mergeScopes(existing?.scopes, pending.scopes); + const tokens = existing?.tokens ? { ...existing.tokens } : {}; + const roleForToken = normalizeRole(pending.role); + if (roleForToken) { + const nextScopes = normalizeScopes(pending.scopes); + const existingToken = tokens[roleForToken]; + const now = Date.now(); + tokens[roleForToken] = { + token: newToken(), + role: roleForToken, + scopes: nextScopes, + createdAtMs: existingToken?.createdAtMs ?? now, + rotatedAtMs: existingToken ? now : undefined, + revokedAtMs: undefined, + lastUsedAtMs: existingToken?.lastUsedAtMs, + }; + } const device: PairedDevice = { deviceId: pending.deviceId, publicKey: pending.publicKey, @@ -248,6 +310,7 @@ export async function approveDevicePairing( roles, scopes, remoteIp: pending.remoteIp, + tokens, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, }; @@ -296,3 +359,142 @@ export async function updatePairedDeviceMetadata( await persistState(state, baseDir); }); } + +export function summarizeDeviceTokens( + tokens: Record | undefined, +): DeviceAuthTokenSummary[] | undefined { + if (!tokens) return undefined; + const summaries = Object.values(tokens) + .map((token) => ({ + role: token.role, + scopes: token.scopes, + createdAtMs: token.createdAtMs, + rotatedAtMs: token.rotatedAtMs, + revokedAtMs: token.revokedAtMs, + lastUsedAtMs: token.lastUsedAtMs, + })) + .sort((a, b) => a.role.localeCompare(b.role)); + return summaries.length > 0 ? summaries : undefined; +} + +export async function verifyDeviceToken(params: { + deviceId: string; + token: string; + role: string; + scopes: string[]; + baseDir?: string; +}): Promise<{ ok: boolean; reason?: string }> { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + if (!device) return { ok: false, reason: "device-not-paired" }; + const role = normalizeRole(params.role); + if (!role) return { ok: false, reason: "role-missing" }; + const entry = device.tokens?.[role]; + if (!entry) return { ok: false, reason: "token-missing" }; + if (entry.revokedAtMs) return { ok: false, reason: "token-revoked" }; + if (entry.token !== params.token) return { ok: false, reason: "token-mismatch" }; + const requestedScopes = normalizeScopes(params.scopes); + if (!scopesAllow(requestedScopes, entry.scopes)) { + return { ok: false, reason: "scope-mismatch" }; + } + entry.lastUsedAtMs = Date.now(); + device.tokens = { ...(device.tokens ?? {}), [role]: entry }; + state.pairedByDeviceId[device.deviceId] = device; + await persistState(state, params.baseDir); + return { ok: true }; + }); +} + +export async function ensureDeviceToken(params: { + deviceId: string; + role: string; + scopes: string[]; + baseDir?: string; +}): Promise { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + if (!device) return null; + const role = normalizeRole(params.role); + if (!role) return null; + const requestedScopes = normalizeScopes(params.scopes); + const tokens = device.tokens ? { ...device.tokens } : {}; + const existing = tokens[role]; + if (existing && !existing.revokedAtMs) { + if (scopesAllow(requestedScopes, existing.scopes)) { + return existing; + } + } + const now = Date.now(); + const next: DeviceAuthToken = { + token: newToken(), + role, + scopes: requestedScopes, + createdAtMs: existing?.createdAtMs ?? now, + rotatedAtMs: existing ? now : undefined, + revokedAtMs: undefined, + lastUsedAtMs: existing?.lastUsedAtMs, + }; + tokens[role] = next; + device.tokens = tokens; + state.pairedByDeviceId[device.deviceId] = device; + await persistState(state, params.baseDir); + return next; + }); +} + +export async function rotateDeviceToken(params: { + deviceId: string; + role: string; + scopes?: string[]; + baseDir?: string; +}): Promise { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + if (!device) return null; + const role = normalizeRole(params.role); + if (!role) return null; + const tokens = device.tokens ? { ...device.tokens } : {}; + const existing = tokens[role]; + const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes); + const now = Date.now(); + const next: DeviceAuthToken = { + token: newToken(), + role, + scopes: requestedScopes, + createdAtMs: existing?.createdAtMs ?? now, + rotatedAtMs: now, + revokedAtMs: undefined, + lastUsedAtMs: existing?.lastUsedAtMs, + }; + tokens[role] = next; + device.tokens = tokens; + state.pairedByDeviceId[device.deviceId] = device; + await persistState(state, params.baseDir); + return next; + }); +} + +export async function revokeDeviceToken(params: { + deviceId: string; + role: string; + baseDir?: string; +}): Promise { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + if (!device) return null; + const role = normalizeRole(params.role); + if (!role) return null; + if (!device.tokens?.[role]) return null; + const tokens = { ...device.tokens }; + const entry = { ...tokens[role], revokedAtMs: Date.now() }; + tokens[role] = entry; + device.tokens = tokens; + state.pairedByDeviceId[device.deviceId] = device; + await persistState(state, params.baseDir); + return entry; + }); +} diff --git a/src/media/input-files.ts b/src/media/input-files.ts index adca969ad..0b131b93e 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -1,4 +1,5 @@ import { lookup } from "node:dns/promises"; +import { logWarn } from "../logger.js"; type CanvasModule = typeof import("@napi-rs/canvas"); type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); @@ -289,7 +290,14 @@ async function extractPdfContent(params: { return { text, images: [] }; } - const { createCanvas } = await loadCanvasModule(); + let canvasModule: CanvasModule; + try { + canvasModule = await loadCanvasModule(); + } catch (err) { + logWarn(`media: PDF image extraction skipped; ${String(err)}`); + return { text, images: [] }; + } + const { createCanvas } = canvasModule; const images: InputImageContent[] = []; for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) { const page = await pdf.getPage(pageNum);