Device pairing: bind setup codes to node approvals

This commit is contained in:
Vincent Koc 2026-03-14 20:04:15 -07:00
parent db20141993
commit c71fb8cda0
4 changed files with 90 additions and 2 deletions

View File

@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
const payload: SetupPayload = {
url: urlResult.url,
bootstrapToken: (await issueDeviceBootstrapToken()).token,
bootstrapToken: (
await issueDeviceBootstrapToken({
role: "node",
scopes: [],
})
).token,
};
if (action === "qr") {

View File

@ -43,6 +43,22 @@ describe("device bootstrap tokens", () => {
});
});
it("persists an intended role and scopes when requested", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
const parsed = JSON.parse(raw) as Record<string, { roles?: string[]; scopes?: string[] }>;
expect(parsed[issued.token]).toMatchObject({
roles: ["node"],
scopes: [],
});
});
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
@ -201,4 +217,44 @@ describe("device bootstrap tokens", () => {
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("rejects a role that does not match the issued pairing profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator",
scopes: [],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("rejects scopes that do not match the issued pairing profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
role: "node",
scopes: [],
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "node",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
});

View File

@ -1,4 +1,5 @@
import path from "node:path";
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
@ -63,16 +64,24 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
export async function issueDeviceBootstrapToken(
params: {
baseDir?: string;
role?: string;
scopes?: readonly string[];
} = {},
): Promise<{ token: string; expiresAtMs: number }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const token = generatePairingToken();
const issuedAtMs = Date.now();
const role = params.role?.trim();
const scopes = normalizeDeviceAuthScopes(
Array.isArray(params.scopes) ? [...params.scopes] : undefined,
);
state[token] = {
token,
ts: issuedAtMs,
issuedAtMs,
...(role ? { roles: [normalizeDeviceAuthRole(role)] } : {}),
...(scopes.length > 0 || Array.isArray(params.scopes) ? { scopes } : {}),
};
await persistState(state, params.baseDir);
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
@ -102,10 +111,26 @@ export async function verifyDeviceBootstrapToken(params: {
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
const role = params.role.trim();
const role = normalizeDeviceAuthRole(params.role);
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const allowedRoles = Array.isArray(entry.roles)
? entry.roles.map((value) => normalizeDeviceAuthRole(String(value))).filter(Boolean)
: [];
if (allowedRoles.length > 0 && !allowedRoles.includes(role)) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
if (Array.isArray(entry.scopes)) {
const allowedScopes = normalizeDeviceAuthScopes(entry.scopes);
if (
allowedScopes.length !== requestedScopes.length ||
allowedScopes.some((value, index) => value !== requestedScopes[index])
) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
}
// Bootstrap setup codes are single-use. Consume the record before returning
// success so the same token cannot be replayed to mutate a pending request.

View File

@ -400,6 +400,8 @@ export async function resolvePairingSetupFromConfig(
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
role: "node",
scopes: [],
})
).token,
},