mirror of https://github.com/openclaw/openclaw.git
fix: bind bootstrap setup codes to node profile
This commit is contained in:
parent
4580d585ff
commit
a600c72ed7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
expect(text).toContain("- Security: single-use bootstrap token");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue