fix(auth): hand off qr bootstrap to bounded device tokens

This commit is contained in:
Nimrod Gutman
2026-04-03 13:05:33 +03:00
committed by Peter Steinberger
parent c4597992ca
commit a9140abea6
14 changed files with 808 additions and 241 deletions

View File

@@ -96,6 +96,19 @@ export const HelloOkSchema = Type.Object(
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
deviceTokens: Type.Optional(
Type.Array(
Type.Object(
{
deviceToken: NonEmptyString,
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
),
),
},
{ additionalProperties: false },
),

View File

@@ -711,9 +711,12 @@ export function registerControlUiAndPairingSuite(): void {
restoreGatewayToken(prevToken);
});
test("auto-approves fresh node-only bootstrap pairing and revokes the token after connect", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
test("auto-approves fresh node bootstrap pairing from qr setup code", async () => {
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
@@ -729,12 +732,7 @@ export function registerControlUiAndPairingSuite(): void {
};
try {
const issued = await issueDeviceBootstrapToken({
profile: {
roles: ["node"],
scopes: [],
},
});
const issued = await issueDeviceBootstrapToken();
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const initial = await connectReq(wsBootstrap, {
skipDefaultAuth: true,
@@ -752,168 +750,84 @@ export function registerControlUiAndPairingSuite(): void {
deviceToken?: string;
role?: string;
scopes?: string[];
deviceTokens?: Array<{
deviceToken?: string;
role?: string;
scopes?: string[];
}>;
};
}
| undefined;
expect(initialPayload?.type).toBe("hello-ok");
const issuedDeviceToken = initialPayload?.auth?.deviceToken;
const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find(
(entry) => entry.role === "operator",
)?.deviceToken;
expect(issuedDeviceToken).toBeDefined();
expect(issuedOperatorToken).toBeDefined();
expect(initialPayload?.auth?.role).toBe("node");
expect(initialPayload?.auth?.scopes ?? []).toEqual([]);
expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe(
false,
);
expect(
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
).toEqual(
expect.arrayContaining([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]),
);
const afterBootstrap = await listDevicePairing();
expect(
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
).toEqual([]);
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(expect.arrayContaining(["node"]));
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.approvedScopes ?? []).toEqual(
expect.arrayContaining([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]),
);
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
if (!issuedDeviceToken) {
throw new Error("expected hello-ok auth.deviceToken for bootstrap onboarding");
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
if (!issuedDeviceToken || !issuedOperatorToken) {
throw new Error("expected hello-ok auth.deviceTokens for bootstrap onboarding");
}
wsBootstrap.close();
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const replay = await connectReq(wsReplay, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(replay.ok).toBe(false);
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
);
wsReplay.close();
const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const reconnect = await connectReq(wsReconnect, {
skipDefaultAuth: true,
deviceToken: issuedDeviceToken,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(reconnect.ok).toBe(true);
wsReconnect.close();
} finally {
await server.close();
restoreGatewayToken(prevToken);
}
});
test("keeps setup bootstrap tokens valid until operator approval completes", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
const { identityPath, identity, client } = await createOperatorIdentityFixture(
"openclaw-bootstrap-setup-profile-",
);
const nodeClient = {
...client,
id: "openclaw-android",
mode: "node",
};
const operatorClient = {
...client,
id: "openclaw-android",
mode: "ui",
};
const operatorScopes = ["operator.read", "operator.write", "operator.talk.secrets"];
try {
const issued = await issueDeviceBootstrapToken();
const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const nodeConnect = await connectReq(wsNode, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client: nodeClient,
deviceIdentityPath: identityPath,
});
expect(nodeConnect.ok).toBe(true);
wsNode.close();
const pairedAfterNode = await getPairedDevice(identity.deviceId);
expect(pairedAfterNode?.roles).toEqual(expect.arrayContaining(["node"]));
const wsOperatorPending = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const operatorPending = await connectReq(wsOperatorPending, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "operator",
scopes: operatorScopes,
client: operatorClient,
deviceIdentityPath: identityPath,
});
expect(operatorPending.ok).toBe(false);
expect((operatorPending.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
wsOperatorPending.close();
const pending = (await listDevicePairing()).pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pending).toHaveLength(1);
const pendingRequest = pending[0];
if (!pendingRequest) {
throw new Error("expected pending pairing request");
}
await approveDevicePairing(pendingRequest.requestId, {
callerScopes: pendingRequest.scopes ?? ["operator.admin"],
await new Promise<void>((resolve) => {
if (wsBootstrap.readyState === WebSocket.CLOSED) {
resolve();
return;
}
wsBootstrap.once("close", () => resolve());
wsBootstrap.close();
});
const wsNodeReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const nodeReconnect = await connectReq(wsNodeReconnect, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client: nodeClient,
deviceIdentityPath: identityPath,
});
expect(nodeReconnect.ok).toBe(true);
wsNodeReconnect.close();
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
role: "node",
scopes: [],
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const operatorApproved = await connectReq(wsOperatorApproved, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "operator",
scopes: operatorScopes,
client: operatorClient,
deviceIdentityPath: identityPath,
});
expect(operatorApproved.ok).toBe(true);
wsOperatorApproved.close();
const pairedAfterOperator = await getPairedDevice(identity.deviceId);
expect(pairedAfterOperator?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const replay = await connectReq(wsReplay, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "operator",
scopes: operatorScopes,
client: operatorClient,
deviceIdentityPath: identityPath,
});
expect(replay.ok).toBe(false);
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
);
wsReplay.close();
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedDeviceToken,
role: "node",
scopes: [],
}),
).resolves.toEqual({ ok: true });
} finally {
await server.close();
restoreGatewayToken(prevToken);

View File

@@ -3,10 +3,9 @@ import os from "node:os";
import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import {
getDeviceBootstrapTokenProfile,
redeemDeviceBootstrapTokenProfile,
restoreDeviceBootstrapToken,
getBoundDeviceBootstrapProfile,
revokeDeviceBootstrapToken,
restoreDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
import {
@@ -14,6 +13,7 @@ import {
normalizeDevicePublicKeyBase64Url,
} from "../../../infra/device-identity.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
ensureDeviceToken,
getPairedDevice,
@@ -34,6 +34,7 @@ import { upsertPresence } from "../../../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import {
isBrowserOperatorUiClient,
@@ -714,10 +715,8 @@ export function attachGatewayWsMessageHandler(params: {
rejectUnauthorized(authResult);
return;
}
const bootstrapProfile =
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
let bootstrapProfile: DeviceBootstrapProfile | null = null;
let shouldConsumeBootstrapTokenAfterHello = false;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
@@ -824,6 +823,21 @@ export function attachGatewayWsMessageHandler(params: {
allowedScopes: pairedScopes,
});
};
if (
bootstrapProfile === null &&
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice &&
bootstrapTokenCandidate
) {
bootstrapProfile = await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey,
});
}
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
locality: pairingLocality,
hasBrowserOriginHeader,
@@ -831,19 +845,26 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
// Bootstrap setup can silently pair the first fresh node connect. Keep the token alive
// until the issued profile is fully redeemed so follow-up operator connects can still
// present the same bootstrap token while approval is pending.
// QR bootstrap onboarding stays single-use, but only consume the bootstrap token
// after the hello-ok path succeeds so reconnects can recover from pre-hello failures.
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice;
!existingPairedDevice &&
bootstrapProfile !== null;
const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing
? bootstrapProfile
: null;
const bootstrapPairingRoles = bootstrapProfileForSilentApproval
? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles]))
: undefined;
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientPairingMetadata,
...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}),
silent:
reason === "scope-upgrade"
? false
@@ -868,10 +889,18 @@ export function attachGatewayWsMessageHandler(params: {
return replacementPending?.requestId;
};
if (pairing.request.silent === true) {
approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
approved = bootstrapProfileForSilentApproval
? await approveBootstrapDevicePairing(
pairing.request.requestId,
bootstrapProfileForSilentApproval,
)
: await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing) {
shouldConsumeBootstrapTokenAfterHello = true;
}
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
@@ -1021,6 +1050,47 @@ export function attachGatewayWsMessageHandler(params: {
const deviceToken = device
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
const bootstrapDeviceTokens: Array<{
deviceToken: string;
role: string;
scopes: string[];
issuedAtMs: number;
}> = [];
if (deviceToken) {
bootstrapDeviceTokens.push({
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
});
}
const bootstrapProfileForHello: DeviceBootstrapProfile | null = device
? bootstrapProfile
: null;
if (device && bootstrapProfileForHello !== null) {
for (const bootstrapRole of bootstrapProfileForHello.roles) {
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
continue;
}
const bootstrapRoleScopes =
bootstrapRole === "operator" ? bootstrapProfileForHello.scopes : [];
const extraToken = await ensureDeviceToken({
deviceId: device.id,
role: bootstrapRole,
scopes: bootstrapRoleScopes,
});
if (!extraToken) {
continue;
}
bootstrapDeviceTokens.push({
deviceToken: extraToken.token,
role: extraToken.role,
scopes: extraToken.scopes,
issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs,
});
}
}
if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({
cfg: loadConfig(),
@@ -1111,6 +1181,9 @@ export function attachGatewayWsMessageHandler(params: {
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
: {}),
}
: undefined,
policy: {
@@ -1188,32 +1261,20 @@ export function attachGatewayWsMessageHandler(params: {
let consumedBootstrapTokenRecord:
| Awaited<ReturnType<typeof revokeDeviceBootstrapToken>>["record"]
| undefined;
if (
authMethod === "bootstrap-token" &&
bootstrapProfile &&
bootstrapTokenCandidate &&
device
) {
if (shouldConsumeBootstrapTokenAfterHello && bootstrapTokenCandidate && device) {
try {
const redemption = await redeemDeviceBootstrapTokenProfile({
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
role,
scopes,
});
if (redemption.fullyRedeemed) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
consumedBootstrapTokenRecord = revoked.record;
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
);
}
consumedBootstrapTokenRecord = revoked.record;
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after bootstrap handoff device=${device.id}`,
);
}
} catch (err) {
logGateway.warn(
`bootstrap token redemption bookkeeping failed device=${device.id}: ${formatForLog(err)}`,
`bootstrap token consume failed after device-token handoff device=${device.id}: ${formatForLog(err)}`,
);
}
}

View File

@@ -5,6 +5,7 @@ import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
clearDeviceBootstrapTokens,
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
getBoundDeviceBootstrapProfile,
getDeviceBootstrapTokenProfile,
issueDeviceBootstrapToken,
redeemDeviceBootstrapTokenProfile,
@@ -12,6 +13,7 @@ import {
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "./device-bootstrap.js";
import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } from "./device-identity.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-");
@@ -68,7 +70,7 @@ describe("device bootstrap tokens", () => {
issuedAtMs: Date.now(),
profile: {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
},
});
});
@@ -103,7 +105,12 @@ describe("device bootstrap tokens", () => {
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
{
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
},
);
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
@@ -129,7 +136,12 @@ describe("device bootstrap tokens", () => {
await expect(
verifyBootstrapToken(baseDir, issued.token, {
role: "operator",
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
scopes: [
"operator.approvals",
"operator.read",
"operator.write",
"operator.talk.secrets",
],
}),
).resolves.toEqual({ ok: true });
await expect(
@@ -137,7 +149,12 @@ describe("device bootstrap tokens", () => {
baseDir,
token: issued.token,
role: "operator",
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
scopes: [
"operator.approvals",
"operator.read",
"operator.write",
"operator.talk.secrets",
],
}),
).resolves.toEqual({
recorded: true,
@@ -214,7 +231,12 @@ describe("device bootstrap tokens", () => {
issuedAtMs,
profile: {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
},
},
},
@@ -358,6 +380,37 @@ describe("device bootstrap tokens", () => {
expect(parsed[issued.token]?.token).toBe(issued.token);
});
it("accepts equivalent public key encodings after binding the bootstrap token", async () => {
const baseDir = await createTempDir();
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "device.json"));
const issued = await issueDeviceBootstrapToken({ baseDir });
const rawPublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
await expect(
verifyBootstrapToken(baseDir, issued.token, {
deviceId: identity.deviceId,
publicKey: identity.publicKeyPem,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyBootstrapToken(baseDir, issued.token, {
deviceId: identity.deviceId,
publicKey: rawPublicKey,
}),
).resolves.toEqual({ ok: true });
await expect(
getBoundDeviceBootstrapProfile({
token: issued.token,
deviceId: identity.deviceId,
publicKey: rawPublicKey,
baseDir,
}),
).resolves.toEqual({
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
});
});
it("rejects a second device identity after the first verification binds the token", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });

View File

@@ -6,6 +6,7 @@ import {
type DeviceBootstrapProfileInput,
} from "../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
import { normalizeDevicePublicKeyBase64Url } from "./device-identity.js";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
@@ -115,6 +116,17 @@ function bootstrapProfileSatisfiesProfile(params: {
return true;
}
function normalizeBootstrapPublicKey(publicKey: string): string {
const trimmed = publicKey.trim();
if (!trimmed) {
return "";
}
if (trimmed.includes("BEGIN") || /[+/=]/.test(trimmed)) {
return normalizeDevicePublicKeyBase64Url(trimmed) ?? trimmed;
}
return trimmed;
}
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
@@ -306,7 +318,7 @@ export async function verifyDeviceBootstrapToken(params: {
const [tokenKey, record] = found;
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
const role = params.role.trim();
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
@@ -326,7 +338,10 @@ export async function verifyDeviceBootstrapToken(params: {
}
const boundDeviceId = record.deviceId?.trim();
const boundPublicKey = record.publicKey?.trim();
const boundPublicKey =
typeof record.publicKey === "string"
? normalizeBootstrapPublicKey(record.publicKey)
: undefined;
if (boundDeviceId || boundPublicKey) {
if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) {
return { ok: false, reason: "bootstrap_token_invalid" };
@@ -353,3 +368,38 @@ export async function verifyDeviceBootstrapToken(params: {
return { ok: true };
});
}
export async function getBoundDeviceBootstrapProfile(params: {
token: string;
deviceId: string;
publicKey: string;
baseDir?: string;
}): Promise<DeviceBootstrapProfile | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const providedToken = params.token.trim();
if (!providedToken) {
return null;
}
const found = Object.entries(state).find(([, candidate]) =>
verifyPairingToken(providedToken, candidate.token),
);
if (!found) {
return null;
}
const [, record] = found;
const deviceId = params.deviceId.trim();
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
if (!deviceId || !publicKey) {
return null;
}
const recordPublicKey =
typeof record.publicKey === "string"
? normalizeBootstrapPublicKey(record.publicKey)
: undefined;
if (record.deviceId?.trim() !== deviceId || recordPublicKey !== publicKey) {
return null;
}
return resolvePersistedBootstrapProfile(record);
});
}

View File

@@ -2,8 +2,10 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
clearDevicePairing,
ensureDeviceToken,
@@ -96,23 +98,27 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
}
async function mutatePairedOperatorDevice(baseDir: string, mutate: (device: PairedDevice) => void) {
async function mutatePairedDevice(
baseDir: string,
deviceId: string,
mutate: (device: PairedDevice) => void,
) {
const { pairedPath } = resolvePairingPaths(baseDir, "devices");
const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
string,
PairedDevice
>;
const device = pairedByDeviceId["device-1"];
const device = pairedByDeviceId[deviceId];
expect(device).toBeDefined();
if (!device) {
throw new Error("expected paired operator device");
throw new Error(`expected paired device ${deviceId}`);
}
mutate(device);
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
}
async function clearPairedOperatorApprovalBaseline(baseDir: string) {
await mutatePairedOperatorDevice(baseDir, (device) => {
await mutatePairedDevice(baseDir, "device-1", (device) => {
delete device.approvedScopes;
delete device.scopes;
});
@@ -558,6 +564,68 @@ describe("device pairing tokens", () => {
).resolves.toEqual({ ok: true });
});
test("normalizes legacy node token scopes back to [] on re-approval", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedNodeDevice(baseDir);
await mutatePairedDevice(baseDir, "node-1", (device) => {
const nodeToken = device.tokens?.node;
expect(nodeToken).toBeDefined();
if (!nodeToken) {
throw new Error("expected paired node token");
}
nodeToken.scopes = ["operator.read"];
});
const repair = await requestDevicePairing(
{
deviceId: "node-1",
publicKey: "public-key-node-1",
role: "node",
},
baseDir,
);
await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir);
const paired = await getPairedDevice("node-1", baseDir);
expect(paired?.scopes).toEqual([]);
expect(paired?.approvedScopes).toEqual([]);
expect(paired?.tokens?.node?.scopes).toEqual([]);
});
test("bootstrap pairing seeds node and operator device tokens explicitly", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "bootstrap-device-1",
publicKey: "bootstrap-public-key-1",
role: "node",
roles: ["node", "operator"],
scopes: [],
silent: true,
},
baseDir,
);
await expect(
approveBootstrapDevicePairing(
request.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
baseDir,
),
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
const paired = await getPairedDevice("bootstrap-device-1", baseDir);
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.approvedScopes).toEqual(
expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes),
);
expect(paired?.tokens?.node?.scopes).toEqual([]);
expect(paired?.tokens?.operator?.scopes).toEqual(
expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes),
);
});
test("verifies token and rejects mismatches", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js";
import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js";
import {
createAsyncLock,
@@ -92,6 +93,7 @@ type DevicePairingStateFile = {
};
const PENDING_TTL_MS = 5 * 60 * 1000;
const OPERATOR_ROLE = "operator";
const OPERATOR_SCOPE_PREFIX = "operator.";
const withLock = createAsyncLock();
@@ -484,12 +486,14 @@ export async function approveDevicePairing(
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
options: { callerScopes?: readonly string[] },
options: { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] },
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
optionsOrBaseDir?:
| { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] }
| string,
maybeBaseDir?: string,
): Promise<ApproveDevicePairingResult> {
const options =
@@ -503,10 +507,14 @@ export async function approveDevicePairing(
if (!pending) {
return null;
}
const requestedRoles = mergeRoles(pending.roles, pending.role) ?? [];
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
scope.startsWith(OPERATOR_SCOPE_PREFIX),
const approvedScopesOverride = normalizeDeviceAuthScopes(
options?.approvedScopesOverride ? [...options.approvedScopesOverride] : undefined,
);
const requestedRoles = mergeRoles(pending.roles, pending.role) ?? [];
const requestedOperatorScopes = normalizeDeviceAuthScopes([
...(pending.scopes ?? []),
...approvedScopesOverride,
]).filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX));
if (requestedOperatorScopes.length > 0) {
if (!options?.callerScopes) {
return {
@@ -514,11 +522,11 @@ export async function approveDevicePairing(
missingScope: requestedOperatorScopes[0] ?? "callerScopes-required",
};
}
if (!requestedRoles.includes("operator")) {
if (!requestedRoles.includes(OPERATOR_ROLE)) {
return { status: "forbidden", missingScope: requestedOperatorScopes[0] };
}
const missingScope = resolveMissingRequestedScope({
role: "operator",
role: OPERATOR_ROLE,
requestedScopes: requestedOperatorScopes,
allowedScopes: options.callerScopes,
});
@@ -532,6 +540,7 @@ export async function approveDevicePairing(
const approvedScopes = mergeScopes(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
approvedScopesOverride,
);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of requestedRoles) {
@@ -578,6 +587,90 @@ export async function approveDevicePairing(
});
}
export async function approveBootstrapDevicePairing(
requestId: string,
bootstrapProfile: DeviceBootstrapProfile,
baseDir?: string,
): Promise<ApproveDevicePairingResult> {
// QR bootstrap handoff is an explicit trust path: it can seed the bounded
// node/operator baseline from the verified bootstrap profile without routing
// operator scope approval through the generic interactive approval checker.
const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
const approvedScopes = normalizeDeviceAuthScopes([...bootstrapProfile.scopes]);
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) {
return null;
}
const requestedRoles = resolveRequestedRoles(pending);
const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role));
if (missingRole) {
return { status: "forbidden", missingScope: missingRole };
}
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
scope.startsWith(OPERATOR_SCOPE_PREFIX),
);
const missingScope = resolveMissingRequestedScope({
role: OPERATOR_ROLE,
requestedScopes: requestedOperatorScopes,
allowedScopes: approvedScopes,
});
if (missingScope) {
return { status: "forbidden", missingScope };
}
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles(
existing?.roles,
existing?.role,
pending.roles,
pending.role,
approvedRoles,
);
const nextApprovedScopes = mergeScopes(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
approvedScopes,
);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of approvedRoles) {
const existingToken = tokens[roleForToken];
const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : [];
tokens[roleForToken] = buildDeviceAuthToken({
role: roleForToken,
scopes: tokenScopes,
existing: existingToken,
now,
...(existingToken ? { rotatedAtMs: now } : {}),
});
}
const device: PairedDevice = {
deviceId: pending.deviceId,
publicKey: pending.publicKey,
displayName: pending.displayName,
platform: pending.platform,
deviceFamily: pending.deviceFamily,
clientId: pending.clientId,
clientMode: pending.clientMode,
role: pending.role,
roles,
scopes: nextApprovedScopes,
approvedScopes: nextApprovedScopes,
remoteIp: pending.remoteIp,
tokens,
createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now,
};
delete state.pendingById[requestId];
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, baseDir);
return { status: "approved", requestId, device };
});
}
export async function rejectDevicePairing(
requestId: string,
baseDir?: string,

View File

@@ -92,7 +92,12 @@ describe("pairing setup code", () => {
expect.objectContaining({
profile: {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
},
}),
);

View File

@@ -12,7 +12,7 @@ export type DeviceBootstrapProfileInput = {
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
};
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {