mirror of https://github.com/openclaw/openclaw.git
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
|
import {
|
|
clearDeviceBootstrapTokens,
|
|
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
|
|
getDeviceBootstrapTokenProfile,
|
|
issueDeviceBootstrapToken,
|
|
redeemDeviceBootstrapTokenProfile,
|
|
restoreDeviceBootstrapToken,
|
|
revokeDeviceBootstrapToken,
|
|
verifyDeviceBootstrapToken,
|
|
} from "./device-bootstrap.js";
|
|
|
|
const tempDirs = createTrackedTempDirs();
|
|
const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-");
|
|
|
|
function resolveBootstrapPath(baseDir: string): string {
|
|
return path.join(baseDir, "devices", "bootstrap.json");
|
|
}
|
|
|
|
async function verifyBootstrapToken(
|
|
baseDir: string,
|
|
token: string,
|
|
overrides: Partial<Parameters<typeof verifyDeviceBootstrapToken>[0]> = {},
|
|
) {
|
|
return await verifyDeviceBootstrapToken({
|
|
token,
|
|
deviceId: "device-123",
|
|
publicKey: "public-key-123",
|
|
role: "node",
|
|
scopes: [],
|
|
baseDir,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
await tempDirs.cleanup();
|
|
});
|
|
|
|
describe("device bootstrap tokens", () => {
|
|
it("issues bootstrap tokens and persists them with an expiry", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));
|
|
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
expect(issued.token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
expect(issued.expiresAtMs).toBe(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS);
|
|
|
|
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
|
const parsed = JSON.parse(raw) as Record<
|
|
string,
|
|
{
|
|
token: string;
|
|
ts: number;
|
|
issuedAtMs: number;
|
|
profile: { roles: string[]; scopes: string[] };
|
|
}
|
|
>;
|
|
expect(parsed[issued.token]).toMatchObject({
|
|
token: issued.token,
|
|
ts: Date.now(),
|
|
issuedAtMs: Date.now(),
|
|
profile: {
|
|
roles: ["node", "operator"],
|
|
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("verifies valid bootstrap tokens and binds them to the first device identity", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
|
|
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
|
const parsed = JSON.parse(raw) as Record<
|
|
string,
|
|
{
|
|
token: string;
|
|
deviceId?: string;
|
|
publicKey?: string;
|
|
}
|
|
>;
|
|
expect(parsed[issued.token]).toMatchObject({
|
|
token: issued.token,
|
|
deviceId: "device-123",
|
|
publicKey: "public-key-123",
|
|
});
|
|
});
|
|
|
|
it("loads the issued bootstrap profile for a valid token", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
|
|
{
|
|
roles: ["node", "operator"],
|
|
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
|
},
|
|
);
|
|
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
|
|
});
|
|
|
|
it("persists bootstrap redemption state across verification reloads", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
await expect(
|
|
redeemDeviceBootstrapTokenProfile({
|
|
baseDir,
|
|
token: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
}),
|
|
).resolves.toEqual({
|
|
recorded: true,
|
|
fullyRedeemed: false,
|
|
});
|
|
|
|
await expect(
|
|
verifyBootstrapToken(baseDir, issued.token, {
|
|
role: "operator",
|
|
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
|
}),
|
|
).resolves.toEqual({ ok: true });
|
|
await expect(
|
|
redeemDeviceBootstrapTokenProfile({
|
|
baseDir,
|
|
token: issued.token,
|
|
role: "operator",
|
|
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
|
}),
|
|
).resolves.toEqual({
|
|
recorded: true,
|
|
fullyRedeemed: true,
|
|
});
|
|
});
|
|
|
|
it("clears outstanding bootstrap tokens on demand", async () => {
|
|
const baseDir = await createTempDir();
|
|
const first = await issueDeviceBootstrapToken({ baseDir });
|
|
const second = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(clearDeviceBootstrapTokens({ baseDir })).resolves.toEqual({ removed: 2 });
|
|
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
|
|
|
await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
|
|
ok: false,
|
|
reason: "bootstrap_token_invalid",
|
|
});
|
|
|
|
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({
|
|
ok: false,
|
|
reason: "bootstrap_token_invalid",
|
|
});
|
|
});
|
|
|
|
it("restores a revoked bootstrap token record after send failure recovery", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
const revoked = await revokeDeviceBootstrapToken({ baseDir, token: issued.token });
|
|
expect(revoked.removed).toBe(true);
|
|
expect(revoked.record?.token).toBe(issued.token);
|
|
|
|
if (!revoked.record) {
|
|
throw new Error("expected revoked bootstrap token record");
|
|
}
|
|
await restoreDeviceBootstrapToken({ baseDir, record: revoked.record });
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it("revokes a specific bootstrap token", async () => {
|
|
const baseDir = await createTempDir();
|
|
const first = await issueDeviceBootstrapToken({ baseDir });
|
|
const second = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(
|
|
revokeDeviceBootstrapToken({ baseDir, token: first.token }),
|
|
).resolves.toMatchObject({
|
|
removed: true,
|
|
});
|
|
|
|
await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
|
|
ok: false,
|
|
reason: "bootstrap_token_invalid",
|
|
});
|
|
|
|
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it("verifies bootstrap tokens by the persisted map key and binds them", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
const issuedAtMs = Date.now();
|
|
const bootstrapPath = path.join(baseDir, "devices", "bootstrap.json");
|
|
await fs.writeFile(
|
|
bootstrapPath,
|
|
JSON.stringify(
|
|
{
|
|
"legacy-key": {
|
|
token: issued.token,
|
|
ts: issuedAtMs,
|
|
issuedAtMs,
|
|
profile: {
|
|
roles: ["node", "operator"],
|
|
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
|
|
const raw = await fs.readFile(bootstrapPath, "utf8");
|
|
const parsed = JSON.parse(raw) as Record<
|
|
string,
|
|
{ token: string; deviceId?: string; publicKey?: string }
|
|
>;
|
|
expect(parsed["legacy-key"]).toMatchObject({
|
|
token: issued.token,
|
|
deviceId: "device-123",
|
|
publicKey: "public-key-123",
|
|
});
|
|
});
|
|
|
|
it("keeps the token when required verification fields are blank", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(
|
|
verifyBootstrapToken(baseDir, issued.token, {
|
|
role: " ",
|
|
}),
|
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
|
|
|
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
|
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("allows operator scope subsets within the issued bootstrap profile", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(
|
|
verifyBootstrapToken(baseDir, issued.token, {
|
|
role: "operator",
|
|
scopes: ["operator.read"],
|
|
}),
|
|
).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it("supports explicitly bound bootstrap profiles", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({
|
|
baseDir,
|
|
profile: {
|
|
roles: [" operator ", "operator"],
|
|
scopes: ["operator.read", " operator.read "],
|
|
},
|
|
});
|
|
|
|
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
|
const parsed = JSON.parse(raw) as Record<
|
|
string,
|
|
{ profile: { roles: string[]; scopes: string[] } }
|
|
>;
|
|
expect(parsed[issued.token]?.profile).toEqual({
|
|
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 binds them", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({
|
|
ok: true,
|
|
});
|
|
|
|
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
|
const parsed = JSON.parse(raw) as Record<string, { deviceId?: string }>;
|
|
expect(parsed[issued.token]?.deviceId).toBe("device-123");
|
|
});
|
|
|
|
it("rejects blank or unknown tokens", async () => {
|
|
const baseDir = await createTempDir();
|
|
await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({
|
|
ok: false,
|
|
reason: "bootstrap_token_invalid",
|
|
});
|
|
|
|
await expect(
|
|
verifyDeviceBootstrapToken({
|
|
token: "missing-token",
|
|
deviceId: "device-123",
|
|
publicKey: "public-key-123",
|
|
role: "node",
|
|
scopes: [],
|
|
baseDir,
|
|
}),
|
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
|
});
|
|
|
|
it("repairs malformed persisted state when issuing a new token", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));
|
|
|
|
const baseDir = await createTempDir();
|
|
const bootstrapPath = resolveBootstrapPath(baseDir);
|
|
await fs.mkdir(path.dirname(bootstrapPath), { recursive: true });
|
|
await fs.writeFile(bootstrapPath, "[1,2,3]\n", "utf8");
|
|
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
const raw = await fs.readFile(bootstrapPath, "utf8");
|
|
const parsed = JSON.parse(raw) as Record<string, { token: string }>;
|
|
|
|
expect(Object.keys(parsed)).toEqual([issued.token]);
|
|
expect(parsed[issued.token]?.token).toBe(issued.token);
|
|
});
|
|
|
|
it("rejects a second device identity after the first verification binds the token", async () => {
|
|
const baseDir = await createTempDir();
|
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
|
|
|
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
|
await expect(
|
|
verifyBootstrapToken(baseDir, issued.token, {
|
|
deviceId: "device-456",
|
|
publicKey: "public-key-456",
|
|
}),
|
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
|
});
|
|
|
|
it("fails closed for unbound legacy records and prunes expired tokens", async () => {
|
|
vi.useFakeTimers();
|
|
const baseDir = await createTempDir();
|
|
const bootstrapPath = resolveBootstrapPath(baseDir);
|
|
await fs.mkdir(path.dirname(bootstrapPath), { recursive: true });
|
|
|
|
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));
|
|
await fs.writeFile(
|
|
bootstrapPath,
|
|
`${JSON.stringify(
|
|
{
|
|
legacyToken: {
|
|
token: "legacyToken",
|
|
issuedAtMs: Date.now(),
|
|
},
|
|
expiredToken: {
|
|
token: "expiredToken",
|
|
issuedAtMs: Date.now() - DEVICE_BOOTSTRAP_TOKEN_TTL_MS - 1,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({
|
|
ok: false,
|
|
reason: "bootstrap_token_invalid",
|
|
});
|
|
|
|
await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
|
|
ok: false,
|
|
reason: "bootstrap_token_invalid",
|
|
});
|
|
});
|
|
});
|