mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Infra: fail closed without device scope baseline
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user