fix: bind bootstrap setup codes to node profile

This commit is contained in:
Peter Steinberger
2026-03-22 23:55:10 -07:00
parent 4580d585ff
commit a600c72ed7
8 changed files with 117 additions and 9 deletions

View File

@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
- Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc.
- Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc.
- Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran.

View File

@@ -149,6 +149,10 @@ describe("device-pair /pair qr", () => {
const text = requireText(result);
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
roles: ["node"],
scopes: [],
});
expect(text).toContain("Scan this QR code with the OpenClaw iOS app:");
expect(text).toContain("![OpenClaw pairing QR](data:image/png;base64,ZmFrZXBuZw==)");
expect(text).toContain("- Security: single-use bootstrap token");

View File

@@ -43,6 +43,8 @@ function formatDurationMinutes(expiresAtMs: number): string {
}
const DEFAULT_GATEWAY_PORT = 18789;
const SETUP_CODE_ROLES = ["node"] as const;
const SETUP_CODE_SCOPES: string[] = [];
type DevicePairPluginConfig = {
publicUrl?: string;
@@ -515,7 +517,10 @@ function resolveQrReplyTarget(ctx: QrCommandContext): string {
}
async function issueSetupPayload(url: string): Promise<SetupPayload> {
const issuedBootstrap = await issueDeviceBootstrapToken();
const issuedBootstrap = await issueDeviceBootstrapToken({
roles: SETUP_CODE_ROLES,
scopes: SETUP_CODE_SCOPES,
});
return {
url,
bootstrapToken: issuedBootstrap.token,

View File

@@ -26,8 +26,8 @@ async function verifyBootstrapToken(
token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
role: "node",
scopes: [],
baseDir,
...overrides,
});
@@ -58,6 +58,8 @@ describe("device bootstrap tokens", () => {
token: issued.token,
ts: Date.now(),
issuedAtMs: Date.now(),
roles: ["node"],
scopes: [],
});
});
@@ -124,6 +126,8 @@ describe("device bootstrap tokens", () => {
token: issued.token,
ts: issuedAtMs,
issuedAtMs,
roles: ["node"],
scopes: [],
},
},
null,
@@ -151,6 +155,37 @@ describe("device bootstrap tokens", () => {
expect(raw).toContain(issued.token);
});
it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyBootstrapToken(baseDir, issued.token, {
role: "operator",
scopes: ["operator.admin"],
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
expect(raw).toContain(issued.token);
});
it("supports explicitly bound bootstrap profiles", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
roles: ["operator"],
scopes: ["operator.read"],
});
await expect(
verifyBootstrapToken(baseDir, issued.token, {
role: "operator",
scopes: ["operator.read"],
}),
).resolves.toEqual({ ok: true });
});
it("accepts trimmed bootstrap tokens and still consumes them once", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
@@ -176,8 +211,8 @@ describe("device bootstrap tokens", () => {
token: "missing-token",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
role: "node",
scopes: [],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
@@ -200,7 +235,7 @@ describe("device bootstrap tokens", () => {
expect(parsed[issued.token]?.token).toBe(issued.token);
});
it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => {
it("fails closed for unbound legacy records and prunes expired tokens", async () => {
vi.useFakeTimers();
const baseDir = await createTempDir();
const bootstrapPath = resolveBootstrapPath(baseDir);
@@ -226,7 +261,10 @@ describe("device bootstrap tokens", () => {
"utf8",
);
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
ok: false,

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
@@ -25,6 +26,27 @@ type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
const withLock = createAsyncLock();
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
if (!Array.isArray(roles)) {
return [];
}
const out = new Set<string>();
for (const role of roles) {
const trimmed = role.trim();
if (trimmed) {
out.add(trimmed);
}
}
return [...out].toSorted();
}
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) {
return false;
}
return left.every((value, index) => value === right[index]);
}
function resolveBootstrapPath(baseDir?: string): string {
return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
}
@@ -63,15 +85,21 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
export async function issueDeviceBootstrapToken(
params: {
baseDir?: string;
roles?: readonly 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 roles = normalizeBootstrapRoles(params.roles ?? ["node"]);
const scopes = normalizeDeviceAuthScopes(params.scopes ? [...params.scopes] : []);
state[token] = {
token,
ts: issuedAtMs,
roles,
scopes,
issuedAtMs,
};
await persistState(state, params.baseDir);
@@ -134,7 +162,7 @@ export async function verifyDeviceBootstrapToken(params: {
if (!found) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const [tokenKey] = found;
const [tokenKey, record] = found;
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
@@ -142,6 +170,19 @@ export async function verifyDeviceBootstrapToken(params: {
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const requestedRoles = normalizeBootstrapRoles([role]);
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
const allowedRoles = normalizeBootstrapRoles(record.roles);
const allowedScopes = normalizeDeviceAuthScopes(record.scopes);
// Fail closed for unbound legacy setup codes and for any attempt to redeem
// the token outside the exact role/scope profile it was issued for.
if (
allowedRoles.length === 0 ||
!sameStringSet(requestedRoles, allowedRoles) ||
!sameStringSet(requestedScopes, allowedScopes)
) {
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

@@ -196,7 +196,11 @@ describe("device pairing tokens", () => {
test("rejects bootstrap token replay before pending scope escalation can be approved", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const issued = await issueDeviceBootstrapToken({ baseDir });
const issued = await issueDeviceBootstrapToken({
baseDir,
roles: ["operator"],
scopes: ["operator.read"],
});
await expect(
verifyDeviceBootstrapToken({

View File

@@ -10,6 +10,7 @@ vi.mock("../infra/device-bootstrap.js", () => ({
let encodePairingSetupCode: typeof import("./setup-code.js").encodePairingSetupCode;
let resolvePairingSetupFromConfig: typeof import("./setup-code.js").resolvePairingSetupFromConfig;
let issueDeviceBootstrapTokenMock: typeof import("../infra/device-bootstrap.js").issueDeviceBootstrapToken;
describe("pairing setup code", () => {
type ResolvedSetup = Awaited<ReturnType<typeof resolvePairingSetupFromConfig>>;
@@ -53,6 +54,12 @@ describe("pairing setup code", () => {
}
expect(resolved.authLabel).toBe(params.authLabel);
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith(
expect.objectContaining({
roles: ["node"],
scopes: [],
}),
);
if (params.url) {
expect(resolved.payload.url).toBe(params.url);
}
@@ -78,6 +85,9 @@ describe("pairing setup code", () => {
beforeEach(async () => {
({ encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js"));
({ issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock } =
await import("../infra/device-bootstrap.js"));
vi.mocked(issueDeviceBootstrapTokenMock).mockClear();
});
afterEach(() => {

View File

@@ -22,6 +22,9 @@ export type PairingSetupPayload = {
bootstrapToken: string;
};
const PAIRING_SETUP_BOOTSTRAP_ROLES = ["node"] as const;
const PAIRING_SETUP_BOOTSTRAP_SCOPES: string[] = [];
export type PairingSetupCommandResult = {
code: number | null;
stdout: string;
@@ -384,6 +387,8 @@ export async function resolvePairingSetupFromConfig(
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
roles: PAIRING_SETUP_BOOTSTRAP_ROLES,
scopes: PAIRING_SETUP_BOOTSTRAP_SCOPES,
})
).token,
},