feat: unify device auth + pairing

This commit is contained in:
Peter Steinberger
2026-01-19 02:31:18 +00:00
parent 47d1f23d55
commit 73e9e787b4
30 changed files with 2041 additions and 20 deletions

View File

@@ -13,7 +13,6 @@ import {
maxAsk,
minSecurity,
recordAllowlistUse,
requestExecApprovalViaSocket,
resolveCommandResolution,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
@@ -526,20 +525,21 @@ export function createExecTool(
let approvedByAsk = false;
if (requiresAsk) {
const decisionResult = (await callGatewayTool("exec.approval.request", {}, {
command: params.command,
cwd: workdir,
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000,
})) as { decision?: string } | null;
const decision =
(await requestExecApprovalViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request: {
command: params.command,
cwd: workdir,
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
},
})) ?? null;
decisionResult && typeof decisionResult === "object"
? decisionResult.decision ?? null
: null;
if (decision === "deny") {
throw new Error("exec denied: user denied");
@@ -550,14 +550,12 @@ export function createExecTool(
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
throw new Error(
"exec denied: approval required (companion app approval UI not available)",
"exec denied: approval required (approval UI not available)",
);
}
approvedByAsk = true;
} else {
throw new Error(
"exec denied: approval required (companion app approval UI not available)",
);
throw new Error("exec denied: approval required (approval UI not available)");
}
}
if (decision === "allow-once") {

View File

@@ -7,6 +7,7 @@ import {
resolveStateDir,
} from "../config/config.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -186,6 +187,9 @@ export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promis
clientVersion: opts.clientVersion ?? "dev",
platform: opts.platform,
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
deviceIdentity: loadOrCreateDeviceIdentity(),
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
onHelloOk: async () => {

View File

@@ -2,12 +2,15 @@ import { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js";
import type { DeviceIdentity } from "../infra/device-identity.js";
import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import {
type ConnectParams,
type EventFrame,
@@ -35,6 +38,9 @@ export type GatewayClientOptions = {
clientVersion?: string;
platform?: string;
mode?: GatewayClientMode;
role?: string;
scopes?: string[];
deviceIdentity?: DeviceIdentity;
minProtocol?: number;
maxProtocol?: number;
onEvent?: (evt: EventFrame) => void;
@@ -110,6 +116,28 @@ export class GatewayClient {
password: this.opts.password,
}
: undefined;
const signedAtMs = Date.now();
const role = this.opts.role ?? "operator";
const scopes = this.opts.scopes ?? ["operator.admin"];
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: this.opts.token ?? 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,
@@ -123,6 +151,9 @@ export class GatewayClient {
},
caps: [],
auth,
role,
scopes,
device,
};
void this.request<HelloOk>("connect", params)

View File

@@ -0,0 +1,24 @@
export type DeviceAuthPayloadParams = {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
};
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
return [
"v1",
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
].join("|");
}

View File

@@ -0,0 +1,74 @@
import { randomUUID } from "node:crypto";
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
export type ExecApprovalRequestPayload = {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
export type ExecApprovalRecord = {
id: string;
request: ExecApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
resolvedAtMs?: number;
decision?: ExecApprovalDecision;
resolvedBy?: string | null;
};
type PendingEntry = {
record: ExecApprovalRecord;
resolve: (decision: ExecApprovalDecision) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export class ExecApprovalManager {
private pending = new Map<string, PendingEntry>();
create(request: ExecApprovalRequestPayload, timeoutMs: number): ExecApprovalRecord {
const now = Date.now();
const id = randomUUID();
const record: ExecApprovalRecord = {
id,
request,
createdAtMs: now,
expiresAtMs: now + timeoutMs,
};
return record;
}
async waitForDecision(record: ExecApprovalRecord, timeoutMs: number): Promise<ExecApprovalDecision> {
return await new Promise<ExecApprovalDecision>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(record.id);
resolve("deny");
}, timeoutMs);
this.pending.set(record.id, { record, resolve, reject, timer });
});
}
resolve(recordId: string, decision: ExecApprovalDecision, resolvedBy?: string | null): boolean {
const pending = this.pending.get(recordId);
if (!pending) return false;
clearTimeout(pending.timer);
pending.record.resolvedAtMs = Date.now();
pending.record.decision = decision;
pending.record.resolvedBy = resolvedBy ?? null;
this.pending.delete(recordId);
pending.resolve(decision);
return true;
}
getSnapshot(recordId: string): ExecApprovalRecord | null {
const entry = this.pending.get(recordId);
return entry?.record ?? null;
}
}

View File

@@ -56,6 +56,12 @@ import {
CronStatusParamsSchema,
type CronUpdateParams,
CronUpdateParamsSchema,
type DevicePairApproveParams,
DevicePairApproveParamsSchema,
type DevicePairListParams,
DevicePairListParamsSchema,
type DevicePairRejectParams,
DevicePairRejectParamsSchema,
type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
@@ -65,6 +71,10 @@ import {
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
type ExecApprovalRequestParams,
ExecApprovalRequestParamsSchema,
type ExecApprovalResolveParams,
ExecApprovalResolveParamsSchema,
ErrorCodes,
type ErrorShape,
ErrorShapeSchema,
@@ -239,12 +249,27 @@ export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdate
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
export const validateDevicePairListParams = ajv.compile<DevicePairListParams>(
DevicePairListParamsSchema,
);
export const validateDevicePairApproveParams = ajv.compile<DevicePairApproveParams>(
DevicePairApproveParamsSchema,
);
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
DevicePairRejectParamsSchema,
);
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
ExecApprovalsGetParamsSchema,
);
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema,
);
export const validateExecApprovalRequestParams = ajv.compile<ExecApprovalRequestParams>(
ExecApprovalRequestParamsSchema,
);
export const validateExecApprovalResolveParams = ajv.compile<ExecApprovalResolveParams>(
ExecApprovalResolveParamsSchema,
);
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
@@ -364,6 +389,9 @@ export type {
NodePairRequestParams,
NodePairListParams,
NodePairApproveParams,
DevicePairListParams,
DevicePairApproveParams,
DevicePairRejectParams,
ConfigGetParams,
ConfigSetParams,
ConfigApplyParams,

View File

@@ -5,6 +5,7 @@ export * from "./schema/config.js";
export * from "./schema/cron.js";
export * from "./schema/error-codes.js";
export * from "./schema/exec-approvals.js";
export * from "./schema/devices.js";
export * from "./schema/frames.js";
export * from "./schema/logs-chat.js";
export * from "./schema/nodes.js";

View File

@@ -0,0 +1,44 @@
import { Type } from "@sinclair/typebox";
import { NonEmptyString } from "./primitives.js";
export const DevicePairListParamsSchema = Type.Object({}, { additionalProperties: false });
export const DevicePairApproveParamsSchema = Type.Object(
{ requestId: NonEmptyString },
{ additionalProperties: false },
);
export const DevicePairRejectParamsSchema = Type.Object(
{ requestId: NonEmptyString },
{ additionalProperties: false },
);
export const DevicePairRequestedEventSchema = Type.Object(
{
requestId: NonEmptyString,
deviceId: NonEmptyString,
publicKey: NonEmptyString,
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
clientId: Type.Optional(NonEmptyString),
clientMode: Type.Optional(NonEmptyString),
role: Type.Optional(NonEmptyString),
scopes: Type.Optional(Type.Array(NonEmptyString)),
remoteIp: Type.Optional(NonEmptyString),
silent: Type.Optional(Type.Boolean()),
isRepair: Type.Optional(Type.Boolean()),
ts: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const DevicePairResolvedEventSchema = Type.Object(
{
requestId: NonEmptyString,
deviceId: NonEmptyString,
decision: NonEmptyString,
ts: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);

View File

@@ -2,6 +2,7 @@ import type { ErrorShape } from "./types.js";
export const ErrorCodes = {
NOT_LINKED: "NOT_LINKED",
NOT_PAIRED: "NOT_PAIRED",
AGENT_TIMEOUT: "AGENT_TIMEOUT",
INVALID_REQUEST: "INVALID_REQUEST",
UNAVAILABLE: "UNAVAILABLE",

View File

@@ -86,3 +86,26 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
},
{ additionalProperties: false },
);
export const ExecApprovalRequestParamsSchema = Type.Object(
{
command: NonEmptyString,
cwd: Type.Optional(Type.String()),
host: Type.Optional(Type.String()),
security: Type.Optional(Type.String()),
ask: Type.Optional(Type.String()),
agentId: Type.Optional(Type.String()),
resolvedPath: Type.Optional(Type.String()),
sessionKey: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
export const ExecApprovalResolveParamsSchema = Type.Object(
{
id: NonEmptyString,
decision: NonEmptyString,
},
{ additionalProperties: false },
);

View File

@@ -35,6 +35,19 @@ export const ConnectParamsSchema = Type.Object(
{ additionalProperties: false },
),
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
role: Type.Optional(NonEmptyString),
scopes: Type.Optional(Type.Array(NonEmptyString)),
device: Type.Optional(
Type.Object(
{
id: NonEmptyString,
publicKey: NonEmptyString,
signature: NonEmptyString,
signedAt: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
),
auth: Type.Optional(
Type.Object(
{

View File

@@ -53,7 +53,16 @@ import {
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
} from "./exec-approvals.js";
import {
DevicePairApproveParamsSchema,
DevicePairListParamsSchema,
DevicePairRejectParamsSchema,
DevicePairRequestedEventSchema,
DevicePairResolvedEventSchema,
} from "./devices.js";
import {
ConnectParamsSchema,
ErrorShapeSchema,
@@ -182,6 +191,13 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
DevicePairListParams: DevicePairListParamsSchema,
DevicePairApproveParams: DevicePairApproveParamsSchema,
DevicePairRejectParams: DevicePairRejectParamsSchema,
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,
ChatAbortParams: ChatAbortParamsSchema,

View File

@@ -51,7 +51,14 @@ import type {
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
} from "./exec-approvals.js";
import type {
DevicePairApproveParamsSchema,
DevicePairListParamsSchema,
DevicePairRejectParamsSchema,
} from "./devices.js";
import type {
ConnectParamsSchema,
ErrorShapeSchema,
@@ -175,6 +182,11 @@ export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
export type ExecApprovalRequestParams = Static<typeof ExecApprovalRequestParamsSchema>;
export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsSchema>;
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;

View File

@@ -17,6 +17,8 @@ const BASE_METHODS = [
"exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"exec.approval.request",
"exec.approval.resolve",
"wizard.start",
"wizard.next",
"wizard.cancel",
@@ -43,6 +45,9 @@ const BASE_METHODS = [
"node.pair.approve",
"node.pair.reject",
"node.pair.verify",
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
"node.rename",
"node.list",
"node.describe",
@@ -82,5 +87,9 @@ export const GATEWAY_EVENTS = [
"cron",
"node.pair.requested",
"node.pair.resolved",
"device.pair.requested",
"device.pair.resolved",
"voicewake.changed",
"exec.approval.requested",
"exec.approval.resolved",
];

View File

@@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
import { cronHandlers } from "./server-methods/cron.js";
import { deviceHandlers } from "./server-methods/devices.js";
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
import { healthHandlers } from "./server-methods/health.js";
import { logsHandlers } from "./server-methods/logs.js";
@@ -23,6 +24,43 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js";
import { webHandlers } from "./server-methods/web.js";
import { wizardHandlers } from "./server-methods/wizard.js";
const ADMIN_SCOPE = "operator.admin";
const APPROVALS_SCOPE = "operator.approvals";
const PAIRING_SCOPE = "operator.pairing";
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
const PAIRING_METHODS = new Set([
"node.pair.request",
"node.pair.list",
"node.pair.approve",
"node.pair.reject",
"node.pair.verify",
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
]);
const ADMIN_METHOD_PREFIXES = ["exec.approvals."];
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
if (!client?.connect) return null;
const role = client.connect.role ?? "operator";
const scopes = client.connect.scopes ?? [];
if (role !== "operator") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}
if (scopes.includes(ADMIN_SCOPE)) return null;
if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE)) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals");
}
if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing");
}
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
}
return null;
}
export const coreGatewayHandlers: GatewayRequestHandlers = {
...connectHandlers,
...logsHandlers,
@@ -31,6 +69,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...channelsHandlers,
...chatHandlers,
...cronHandlers,
...deviceHandlers,
...execApprovalsHandlers,
...webHandlers,
...modelsHandlers,
@@ -52,6 +91,11 @@ export async function handleGatewayRequest(
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
): Promise<void> {
const { req, respond, client, isWebchatConnect, context } = opts;
const authError = authorizeGatewayMethod(req.method, client);
if (authError) {
respond(false, undefined, authError);
return;
}
const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
if (!handler) {
respond(

View File

@@ -0,0 +1,98 @@
import {
approveDevicePairing,
listDevicePairing,
rejectDevicePairing,
} from "../../infra/device-pairing.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateDevicePairApproveParams,
validateDevicePairListParams,
validateDevicePairRejectParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond }) => {
if (!validateDevicePairListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.list params: ${formatValidationErrors(
validateDevicePairListParams.errors,
)}`,
),
);
return;
}
const list = await listDevicePairing();
respond(true, list, undefined);
},
"device.pair.approve": async ({ params, respond, context }) => {
if (!validateDevicePairApproveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.approve params: ${formatValidationErrors(
validateDevicePairApproveParams.errors,
)}`,
),
);
return;
}
const { requestId } = params as { requestId: string };
const approved = await approveDevicePairing(requestId);
if (!approved) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.broadcast(
"device.pair.resolved",
{
requestId,
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now(),
},
{ dropIfSlow: true },
);
respond(true, approved, undefined);
},
"device.pair.reject": async ({ params, respond, context }) => {
if (!validateDevicePairRejectParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.pair.reject params: ${formatValidationErrors(
validateDevicePairRejectParams.errors,
)}`,
),
);
return;
}
const { requestId } = params as { requestId: string };
const rejected = await rejectDevicePairing(requestId);
if (!rejected) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.broadcast(
"device.pair.resolved",
{
requestId,
deviceId: rejected.deviceId,
decision: "rejected",
ts: Date.now(),
},
{ dropIfSlow: true },
);
respond(true, rejected, undefined);
},
};

View File

@@ -0,0 +1,105 @@
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateExecApprovalRequestParams,
validateExecApprovalResolveParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
): GatewayRequestHandlers {
return {
"exec.approval.request": async ({ params, respond, context }) => {
if (!validateExecApprovalRequestParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.request params: ${formatValidationErrors(
validateExecApprovalRequestParams.errors,
)}`,
),
);
return;
}
const p = params as {
command: string;
cwd?: string;
host?: string;
security?: string;
ask?: string;
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
timeoutMs?: number;
};
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
const request = {
command: p.command,
cwd: p.cwd ?? null,
host: p.host ?? null,
security: p.security ?? null,
ask: p.ask ?? null,
agentId: p.agentId ?? null,
resolvedPath: p.resolvedPath ?? null,
sessionKey: p.sessionKey ?? null,
};
const record = manager.create(request, timeoutMs);
context.broadcast(
"exec.approval.requested",
{
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
{ dropIfSlow: true },
);
const decision = await manager.waitForDecision(record, timeoutMs);
respond(true, {
id: record.id,
decision,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
}, undefined);
},
"exec.approval.resolve": async ({ params, respond, client, context }) => {
if (!validateExecApprovalResolveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.resolve params: ${formatValidationErrors(
validateExecApprovalResolveParams.errors,
)}`,
),
);
return;
}
const p = params as { id: string; decision: string };
const decision = p.decision as ExecApprovalDecision;
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
return;
}
context.broadcast(
"exec.approval.resolved",
{ id: p.id, decision, resolvedBy, ts: Date.now() },
{ dropIfSlow: true },
);
respond(true, { ok: true }, undefined);
},
};
}

View File

@@ -37,6 +37,8 @@ import {
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createChannelManager } from "./server-channels.js";
import { createAgentEventHandler } from "./server-chat.js";
@@ -351,6 +353,9 @@ export async function startGatewayServer(
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
const execApprovalManager = new ExecApprovalManager();
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
attachGatewayWsHandlers({
wss,
clients,
@@ -364,7 +369,10 @@ export async function startGatewayServer(
logGateway: log,
logHealth,
logWsControl,
extraHandlers: pluginRegistry.gatewayHandlers,
extraHandlers: {
...pluginRegistry.gatewayHandlers,
...execApprovalHandlers,
},
broadcast,
context: {
deps,

View File

@@ -2,12 +2,24 @@ import type { IncomingMessage } from "node:http";
import os from "node:os";
import type { WebSocket } from "ws";
import {
deriveDeviceIdFromPublicKey,
normalizeDevicePublicKeyBase64Url,
verifyDeviceSignature,
} from "../../../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
updatePairedDeviceMetadata,
} from "../../../infra/device-pairing.js";
import { upsertPresence } from "../../../infra/system-presence.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js";
import {
type ConnectParams,
@@ -38,6 +50,8 @@ import type { GatewayWsClient } from "../ws-types.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
export function attachGatewayWsMessageHandler(params: {
socket: WebSocket;
upgradeReq: IncomingMessage;
@@ -236,6 +250,163 @@ export function attachGatewayWsMessageHandler(params: {
}
const authMethod = authResult.method ?? "none";
const role = connectParams.role ?? "operator";
const scopes = Array.isArray(connectParams.scopes)
? connectParams.scopes
: role === "operator"
? ["operator.admin"]
: [];
connectParams.role = role;
connectParams.scopes = scopes;
const device = connectParams.device;
let devicePublicKey: string | null = null;
if (device) {
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
if (!derivedId || derivedId !== device.id) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-id-mismatch",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device identity mismatch"),
});
close(1008, "device identity mismatch");
return;
}
const signedAt = device.signedAt;
if (
typeof signedAt !== "number" ||
Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS
) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-signature-stale",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature expired"),
});
close(1008, "device signature expired");
return;
}
const payload = buildDeviceAuthPayload({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? null,
});
if (!verifyDeviceSignature(device.publicKey, payload, device.signature)) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-signature",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature invalid"),
});
close(1008, "device signature invalid");
return;
}
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
if (!devicePublicKey) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-public-key",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device public key invalid"),
});
close(1008, "device public key invalid");
return;
}
}
if (device && devicePublicKey) {
const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
displayName: connectParams.client.displayName,
platform: connectParams.client.platform,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: remoteAddr,
silent: isLoopbackAddress(remoteAddr) && authMethod !== "none",
});
const context = buildRequestContext();
if (pairing.request.silent === true) {
const approved = await approveDevicePairing(pairing.request.requestId);
if (approved) {
context.broadcast(
"device.pair.resolved",
{
requestId: pairing.request.requestId,
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now(),
},
{ dropIfSlow: true },
);
}
} else if (pairing.created) {
context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
}
if (pairing.request.silent !== true) {
setHandshakeState("failed");
setCloseCause("pairing-required", {
deviceId: device.id,
requestId: pairing.request.requestId,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
details: { requestId: pairing.request.requestId },
}),
});
close(1008, "pairing required");
return;
}
} else {
await updatePairedDeviceMetadata(device.id, {
displayName: connectParams.client.displayName,
platform: connectParams.client.platform,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: remoteAddr,
});
}
}
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId;

View File

@@ -0,0 +1,183 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export type DeviceIdentity = {
deviceId: string;
publicKeyPem: string;
privateKeyPem: string;
};
type StoredIdentity = {
version: 1;
deviceId: string;
publicKeyPem: string;
privateKeyPem: string;
createdAtMs: number;
};
const DEFAULT_DIR = path.join(os.homedir(), ".clawdbot", "identity");
const DEFAULT_FILE = path.join(DEFAULT_DIR, "device.json");
function ensureDir(filePath: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
function base64UrlEncode(buf: Buffer): string {
return buf
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/g, "");
}
function base64UrlDecode(input: string): Buffer {
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, "base64");
}
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
const key = crypto.createPublicKey(publicKeyPem);
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
if (
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
) {
return spki.subarray(ED25519_SPKI_PREFIX.length);
}
return spki;
}
function fingerprintPublicKey(publicKeyPem: string): string {
const raw = derivePublicKeyRaw(publicKeyPem);
return crypto.createHash("sha256").update(raw).digest("hex");
}
function generateIdentity(): DeviceIdentity {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
const deviceId = fingerprintPublicKey(publicKeyPem);
return { deviceId, publicKeyPem, privateKeyPem };
}
export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): DeviceIdentity {
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKeyPem === "string" &&
typeof parsed.privateKeyPem === "string"
) {
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
if (derivedId && derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
return {
deviceId: derivedId,
publicKeyPem: parsed.publicKeyPem,
privateKeyPem: parsed.privateKeyPem,
};
}
return {
deviceId: parsed.deviceId,
publicKeyPem: parsed.publicKeyPem,
privateKeyPem: parsed.privateKeyPem,
};
}
}
} catch {
// fall through to regenerate
}
const identity = generateIdentity();
ensureDir(filePath);
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKeyPem: identity.publicKeyPem,
privateKeyPem: identity.privateKeyPem,
createdAtMs: Date.now(),
};
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
return identity;
}
export function signDevicePayload(privateKeyPem: string, payload: string): string {
const key = crypto.createPrivateKey(privateKeyPem);
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
return base64UrlEncode(sig);
}
export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | null {
try {
if (publicKey.includes("BEGIN")) {
return base64UrlEncode(derivePublicKeyRaw(publicKey));
}
const raw = base64UrlDecode(publicKey);
return base64UrlEncode(raw);
} catch {
return null;
}
}
export function deriveDeviceIdFromPublicKey(publicKey: string): string | null {
try {
const raw = publicKey.includes("BEGIN")
? derivePublicKeyRaw(publicKey)
: base64UrlDecode(publicKey);
return crypto.createHash("sha256").update(raw).digest("hex");
} catch {
return null;
}
}
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
}
export function verifyDeviceSignature(
publicKey: string,
payload: string,
signatureBase64Url: string,
): boolean {
try {
const key = publicKey.includes("BEGIN")
? crypto.createPublicKey(publicKey)
: crypto.createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
type: "spki",
format: "der",
});
const sig = (() => {
try {
return base64UrlDecode(signatureBase64Url);
} catch {
return Buffer.from(signatureBase64Url, "base64");
}
})();
return crypto.verify(null, Buffer.from(payload, "utf8"), key, sig);
} catch {
return false;
}
}

256
src/infra/device-pairing.ts Normal file
View File

@@ -0,0 +1,256 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export type DevicePairingPendingRequest = {
requestId: string;
deviceId: string;
publicKey: string;
displayName?: string;
platform?: string;
clientId?: string;
clientMode?: string;
role?: string;
scopes?: string[];
remoteIp?: string;
silent?: boolean;
isRepair?: boolean;
ts: number;
};
export type PairedDevice = {
deviceId: string;
publicKey: string;
displayName?: string;
platform?: string;
clientId?: string;
clientMode?: string;
role?: string;
scopes?: string[];
remoteIp?: string;
createdAtMs: number;
approvedAtMs: number;
};
export type DevicePairingList = {
pending: DevicePairingPendingRequest[];
paired: PairedDevice[];
};
type DevicePairingStateFile = {
pendingById: Record<string, DevicePairingPendingRequest>;
pairedByDeviceId: Record<string, PairedDevice>;
};
const PENDING_TTL_MS = 5 * 60 * 1000;
function resolvePaths(baseDir?: string) {
const root = baseDir ?? resolveStateDir();
const dir = path.join(root, "devices");
return {
dir,
pendingPath: path.join(dir, "pending.json"),
pairedPath: path.join(dir, "paired.json"),
};
}
async function readJSON<T>(filePath: string): Promise<T | null> {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function writeJSONAtomic(filePath: string, value: unknown) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = `${filePath}.${randomUUID()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
try {
await fs.chmod(tmp, 0o600);
} catch {
// best-effort
}
await fs.rename(tmp, filePath);
try {
await fs.chmod(filePath, 0o600);
} catch {
// best-effort
}
}
function pruneExpiredPending(
pendingById: Record<string, DevicePairingPendingRequest>,
nowMs: number,
) {
for (const [id, req] of Object.entries(pendingById)) {
if (nowMs - req.ts > PENDING_TTL_MS) {
delete pendingById[id];
}
}
}
let lock: Promise<void> = Promise.resolve();
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await prev;
try {
return await fn();
} finally {
release?.();
}
}
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
const { pendingPath, pairedPath } = resolvePaths(baseDir);
const [pending, paired] = await Promise.all([
readJSON<Record<string, DevicePairingPendingRequest>>(pendingPath),
readJSON<Record<string, PairedDevice>>(pairedPath),
]);
const state: DevicePairingStateFile = {
pendingById: pending ?? {},
pairedByDeviceId: paired ?? {},
};
pruneExpiredPending(state.pendingById, Date.now());
return state;
}
async function persistState(state: DevicePairingStateFile, baseDir?: string) {
const { pendingPath, pairedPath } = resolvePaths(baseDir);
await Promise.all([
writeJSONAtomic(pendingPath, state.pendingById),
writeJSONAtomic(pairedPath, state.pairedByDeviceId),
]);
}
function normalizeDeviceId(deviceId: string) {
return deviceId.trim();
}
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);
const paired = Object.values(state.pairedByDeviceId).sort(
(a, b) => b.approvedAtMs - a.approvedAtMs,
);
return { pending, paired };
}
export async function getPairedDevice(
deviceId: string,
baseDir?: string,
): Promise<PairedDevice | null> {
const state = await loadState(baseDir);
return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null;
}
export async function requestDevicePairing(
req: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
baseDir?: string,
): Promise<{
status: "pending";
request: DevicePairingPendingRequest;
created: boolean;
}> {
return await withLock(async () => {
const state = await loadState(baseDir);
const deviceId = normalizeDeviceId(req.deviceId);
if (!deviceId) {
throw new Error("deviceId required");
}
const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId);
if (existing) {
return { status: "pending", request: existing, created: false };
}
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
const request: DevicePairingPendingRequest = {
requestId: randomUUID(),
deviceId,
publicKey: req.publicKey,
displayName: req.displayName,
platform: req.platform,
clientId: req.clientId,
clientMode: req.clientMode,
role: req.role,
scopes: req.scopes,
remoteIp: req.remoteIp,
silent: req.silent,
isRepair,
ts: Date.now(),
};
state.pendingById[request.requestId] = request;
await persistState(state, baseDir);
return { status: "pending", request, created: true };
});
}
export async function approveDevicePairing(
requestId: string,
baseDir?: string,
): Promise<{ requestId: string; device: PairedDevice } | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) return null;
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const device: PairedDevice = {
deviceId: pending.deviceId,
publicKey: pending.publicKey,
displayName: pending.displayName,
platform: pending.platform,
clientId: pending.clientId,
clientMode: pending.clientMode,
role: pending.role,
scopes: pending.scopes,
remoteIp: pending.remoteIp,
createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now,
};
delete state.pendingById[requestId];
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, baseDir);
return { requestId, device };
});
}
export async function rejectDevicePairing(
requestId: string,
baseDir?: string,
): Promise<{ requestId: string; deviceId: string } | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) return null;
delete state.pendingById[requestId];
await persistState(state, baseDir);
return { requestId, deviceId: pending.deviceId };
});
}
export async function updatePairedDeviceMetadata(
deviceId: string,
patch: Partial<Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs">>,
baseDir?: string,
): Promise<void> {
return await withLock(async () => {
const state = await loadState(baseDir);
const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)];
if (!existing) return;
state.pairedByDeviceId[deviceId] = {
...existing,
...patch,
deviceId: existing.deviceId,
createdAtMs: existing.createdAtMs,
approvedAtMs: existing.approvedAtMs,
};
await persistState(state, baseDir);
});
}