Infra: fail closed without device scope baseline

This commit is contained in:
Vincent Koc
2026-03-12 01:41:13 -04:00
parent 276ee259ca
commit d8d8dc7421
2 changed files with 138 additions and 51 deletions

View File

@@ -69,6 +69,28 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
}
async function mutatePairedOperatorDevice(baseDir: 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"];
expect(device).toBeDefined();
if (!device) {
throw new Error("expected paired operator device");
}
mutate(device);
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
}
async function clearPairedOperatorApprovalBaseline(baseDir: string) {
await mutatePairedOperatorDevice(baseDir, (device) => {
delete device.approvedScopes;
delete device.scopes;
});
}
describe("device pairing tokens", () => {
test("reuses existing pending requests for the same device", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
@@ -250,6 +272,19 @@ describe("device pairing tokens", () => {
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
});
test("fails closed when the paired device approval baseline is missing during verification", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
await clearPairedOperatorApprovalBaseline(baseDir);
await expect(
verifyOperatorToken({
baseDir,
token,
scopes: ["operator.read"],
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
});
test("accepts operator.read/operator.write requests with an operator.admin token scope", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.admin"]);
@@ -268,6 +303,57 @@ describe("device pairing tokens", () => {
expect(writeOk.ok).toBe(true);
});
test("accepts custom operator scopes under an operator.admin approval baseline", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
const rotated = await rotateDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.talk.secrets"],
baseDir,
});
expect(rotated?.scopes).toEqual(["operator.talk.secrets"]);
await expect(
verifyOperatorToken({
baseDir,
token: requireToken(rotated?.token),
scopes: ["operator.talk.secrets"],
}),
).resolves.toEqual({ ok: true });
});
test("fails closed when the paired device approval baseline is missing during ensure", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
await clearPairedOperatorApprovalBaseline(baseDir);
await expect(
ensureDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toBeNull();
});
test("fails closed when the paired device approval baseline is missing during rotation", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
await clearPairedOperatorApprovalBaseline(baseDir);
await expect(
rotateDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toBeNull();
});
test("treats multibyte same-length token input as mismatch without throwing", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
const multibyteToken = "é".repeat(token.length);

View File

@@ -181,44 +181,6 @@ function mergePendingDevicePairingRequest(
};
}
function scopesAllow(requested: string[], allowed: string[]): boolean {
if (requested.length === 0) {
return true;
}
if (allowed.length === 0) {
return false;
}
const allowedSet = new Set(allowed);
return requested.every((scope) => allowedSet.has(scope));
}
const DEVICE_SCOPE_IMPLICATIONS: Readonly<Record<string, readonly string[]>> = {
"operator.admin": ["operator.read", "operator.write", "operator.approvals", "operator.pairing"],
"operator.write": ["operator.read"],
};
function expandScopeImplications(scopes: string[]): string[] {
const expanded = new Set(scopes);
const queue = [...scopes];
while (queue.length > 0) {
const scope = queue.pop();
if (!scope) {
continue;
}
for (const impliedScope of DEVICE_SCOPE_IMPLICATIONS[scope] ?? []) {
if (!expanded.has(impliedScope)) {
expanded.add(impliedScope);
queue.push(impliedScope);
}
}
}
return [...expanded];
}
function scopesAllowWithImplications(requested: string[], allowed: string[]): boolean {
return scopesAllow(expandScopeImplications(requested), expandScopeImplications(allowed));
}
function newToken() {
return generatePairingToken();
}
@@ -252,6 +214,29 @@ function buildDeviceAuthToken(params: {
};
}
function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null {
const baseline = device.approvedScopes ?? device.scopes;
if (!Array.isArray(baseline)) {
return null;
}
return normalizeDeviceAuthScopes(baseline);
}
function scopesWithinApprovedDeviceBaseline(params: {
role: string;
scopes: readonly string[];
approvedScopes: readonly string[] | null;
}): boolean {
if (!params.approvedScopes) {
return false;
}
return roleScopesAllow({
role: params.role,
requestedScopes: params.scopes,
allowedScopes: params.approvedScopes,
});
}
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
@@ -494,10 +479,14 @@ export async function verifyDeviceToken(params: {
if (!verifyPairingToken(params.token, entry.token)) {
return { ok: false, reason: "token-mismatch" };
}
const approvedScopes = normalizeDeviceAuthScopes(
device.approvedScopes ?? device.scopes ?? entry.scopes,
);
if (!scopesAllowWithImplications(entry.scopes, approvedScopes)) {
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (
!scopesWithinApprovedDeviceBaseline({
role,
scopes: entry.scopes,
approvedScopes,
})
) {
return { ok: false, reason: "scope-mismatch" };
}
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
@@ -531,14 +520,22 @@ export async function ensureDeviceToken(params: {
return null;
}
const { device, role, tokens, existing } = context;
const approvedScopes = normalizeDeviceAuthScopes(
device.approvedScopes ?? device.scopes ?? existing?.scopes,
);
if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) {
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (
!scopesWithinApprovedDeviceBaseline({
role,
scopes: requestedScopes,
approvedScopes,
})
) {
return null;
}
if (existing && !existing.revokedAtMs) {
const existingWithinApproved = scopesAllowWithImplications(existing.scopes, approvedScopes);
const existingWithinApproved = scopesWithinApprovedDeviceBaseline({
role,
scopes: existing.scopes,
approvedScopes,
});
if (
existingWithinApproved &&
roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })
@@ -605,10 +602,14 @@ export async function rotateDeviceToken(params: {
const requestedScopes = normalizeDeviceAuthScopes(
params.scopes ?? existing?.scopes ?? device.scopes,
);
const approvedScopes = normalizeDeviceAuthScopes(
device.approvedScopes ?? device.scopes ?? existing?.scopes,
);
if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) {
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (
!scopesWithinApprovedDeviceBaseline({
role,
scopes: requestedScopes,
approvedScopes,
})
) {
return null;
}
const now = Date.now();