feat: add device token auth and devices cli
This commit is contained in:
@@ -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
|
||||
|
||||
66
docs/cli/devices.md
Normal file
66
docs/cli/devices.md
Normal file
@@ -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 <requestId>`
|
||||
|
||||
Approve a pending device pairing request.
|
||||
|
||||
```
|
||||
clawdbot devices approve <requestId>
|
||||
```
|
||||
|
||||
### `clawdbot devices reject <requestId>`
|
||||
|
||||
Reject a pending device pairing request.
|
||||
|
||||
```
|
||||
clawdbot devices reject <requestId>
|
||||
```
|
||||
|
||||
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
|
||||
|
||||
Rotate a device token for a specific role (optionally updating scopes).
|
||||
|
||||
```
|
||||
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
|
||||
```
|
||||
|
||||
### `clawdbot devices revoke --device <id> --role <role>`
|
||||
|
||||
Revoke a device token for a specific role.
|
||||
|
||||
```
|
||||
clawdbot devices revoke --device <deviceId> --role node
|
||||
```
|
||||
|
||||
## Common options
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
|
||||
- `--token <token>`: Gateway token (if required).
|
||||
- `--password <password>`: Gateway password (password auth).
|
||||
- `--timeout <ms>`: 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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <name>] <command>
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
devices
|
||||
node
|
||||
start
|
||||
daemon
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
225
src/cli/devices-cli.ts
Normal file
225
src/cli/devices-cli.ts
Normal file
@@ -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 <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (password auth)")
|
||||
.option("--timeout <ms>", "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<string, unknown>) : {};
|
||||
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("<requestId>", "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("<requestId>", "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 <id>", "Device id")
|
||||
.requiredOption("--role <role>", "Role name")
|
||||
.option("--scope <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 <id>", "Device id")
|
||||
.requiredOption("--role <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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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 <user>@${host} -p ${beacon.sshPort}`;
|
||||
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<HelloOk>("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"
|
||||
|
||||
@@ -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<DevicePairApprovePara
|
||||
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
|
||||
DevicePairRejectParamsSchema,
|
||||
);
|
||||
export const validateDeviceTokenRotateParams = ajv.compile<DeviceTokenRotateParams>(
|
||||
DeviceTokenRotateParamsSchema,
|
||||
);
|
||||
export const validateDeviceTokenRevokeParams = ajv.compile<DeviceTokenRevokeParams>(
|
||||
DeviceTokenRevokeParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
||||
ExecApprovalsGetParamsSchema,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
DevicePairRejectParamsSchema,
|
||||
DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEventSchema,
|
||||
DeviceTokenRevokeParamsSchema,
|
||||
DeviceTokenRotateParamsSchema,
|
||||
} from "./devices.js";
|
||||
import {
|
||||
ConnectParamsSchema,
|
||||
@@ -206,6 +208,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
DevicePairListParams: DevicePairListParamsSchema,
|
||||
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||
DeviceTokenRotateParams: DeviceTokenRotateParamsSchema,
|
||||
DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema,
|
||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
|
||||
@@ -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<typeof ExecApprovalResolveParamsS
|
||||
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
|
||||
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
|
||||
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
|
||||
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
|
||||
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."];
|
||||
|
||||
@@ -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<string, DeviceAuthToken> } & Record<string, unknown>) {
|
||||
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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<void>((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<void>((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();
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
104
src/infra/device-auth-store.ts
Normal file
104
src/infra/device-auth-store.ts
Normal file
@@ -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<string, DeviceAuthEntry>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
43
src/infra/device-pairing.test.ts
Normal file
43
src/infra/device-pairing.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, DeviceAuthToken>;
|
||||
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 | string[] | undefined>): string[] | undefined {
|
||||
const roles = new Set<string>();
|
||||
for (const item of items) {
|
||||
@@ -167,6 +192,27 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||
if (!Array.isArray(scopes)) return [];
|
||||
const out = new Set<string>();
|
||||
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<DevicePairingList> {
|
||||
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<string, DeviceAuthToken> | 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<DeviceAuthToken | null> {
|
||||
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<DeviceAuthToken | null> {
|
||||
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<DeviceAuthToken | null> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user