feat: add device token auth and devices cli

This commit is contained in:
Peter Steinberger
2026-01-20 10:29:13 +00:00
parent 1c02de1309
commit d88b239d3c
27 changed files with 1055 additions and 71 deletions

View File

@@ -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;
};

View File

@@ -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"

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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 }),

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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",

View File

@@ -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."];

View File

@@ -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,
);
},
};

View File

@@ -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();

View File

@@ -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");

View File

@@ -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,

View File

@@ -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,