diff --git a/CHANGELOG.md b/CHANGELOG.md index af5e7bd091d..97d7825c734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(auth): prevent bootstrap pairing scope changes [AI]. (#80976) Thanks @pgondhi987. - Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987. - Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987. - fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987. diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 81af3007a27..4d478cecc36 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -97,6 +97,54 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.publicKey).toBe("public-key-123"); }); + it("rejects changing the requested profile while a bound use is pending", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: { + roles: ["operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], + }, + }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.read"], + }), + ).resolves.toEqual({ ok: true }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.write", "operator.approvals"], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.read"], + }), + ).resolves.toEqual({ ok: true }); + + await expect( + redeemDeviceBootstrapTokenProfile({ + baseDir, + token: issued.token, + role: "operator", + scopes: ["operator.read"], + }), + ).resolves.toEqual({ + recorded: true, + fullyRedeemed: false, + }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.write", "operator.approvals"], + }), + ).resolves.toEqual({ ok: true }); + }); + it("loads the issued bootstrap profile for a valid token", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 54cf81ed7b1..5fa5a136f63 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -23,6 +23,7 @@ export type DeviceBootstrapTokenRecord = { publicKey?: string; profile?: DeviceBootstrapProfile; redeemedProfile?: DeviceBootstrapProfile; + pendingProfile?: DeviceBootstrapProfile; roles?: string[]; scopes?: string[]; issuedAtMs: number; @@ -67,6 +68,35 @@ function resolvePersistedRedeemedProfile( return normalizeDeviceBootstrapProfile(record.redeemedProfile); } +function resolvePersistedPendingProfile( + record: Partial, +): DeviceBootstrapProfile | null { + return record.pendingProfile ? normalizeDeviceBootstrapProfile(record.pendingProfile) : null; +} + +function resolveRequestedBootstrapProfile(params: { + role: string; + scopes: readonly string[]; +}): DeviceBootstrapProfile { + return normalizeDeviceBootstrapProfile({ + roles: [params.role], + scopes: resolveBootstrapProfileScopesForRole(params.role, params.scopes), + }); +} + +function sameBootstrapProfile( + left: DeviceBootstrapProfile, + right: DeviceBootstrapProfile, +): boolean { + if (left.roles.length !== right.roles.length || left.scopes.length !== right.scopes.length) { + return false; + } + return ( + left.roles.every((role, index) => role === right.roles[index]) && + left.scopes.every((scope, index) => scope === right.scopes[index]) + ); +} + function resolveIssuedBootstrapProfile(params: { profile?: DeviceBootstrapProfileInput; roles?: readonly string[]; @@ -173,10 +203,12 @@ async function loadState(baseDir?: string): Promise { typeof record.token === "string" && record.token.trim().length > 0 ? record.token : tokenKey; const issuedAtMs = typeof record.issuedAtMs === "number" ? record.issuedAtMs : 0; const profile = resolvePersistedBootstrapProfile(record); + const pendingProfile = resolvePersistedPendingProfile(record); state[tokenKey] = { token, profile, redeemedProfile: resolvePersistedRedeemedProfile(record), + ...(pendingProfile ? { pendingProfile } : {}), deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined, publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined, issuedAtMs, @@ -304,6 +336,7 @@ export async function redeemDeviceBootstrapTokenProfile(params: { } const [tokenKey, record] = found; const issuedProfile = resolvePersistedBootstrapProfile(record); + const pendingProfile = resolvePersistedPendingProfile(record); const redeemedProfile = normalizeDeviceBootstrapProfile({ roles: [...resolvePersistedRedeemedProfile(record).roles, params.role], scopes: [ @@ -311,11 +344,25 @@ export async function redeemDeviceBootstrapTokenProfile(params: { ...resolveBootstrapProfileScopesForRole(params.role, params.scopes), ], }); - state[tokenKey] = { + const nextPendingProfile = + pendingProfile && + !bootstrapProfileSatisfiesProfile({ + actualProfile: redeemedProfile, + requiredProfile: pendingProfile, + }) + ? pendingProfile + : undefined; + const nextRecord: DeviceBootstrapTokenRecord = { ...record, profile: issuedProfile, redeemedProfile, }; + if (nextPendingProfile) { + nextRecord.pendingProfile = nextPendingProfile; + } else { + delete nextRecord.pendingProfile; + } + state[tokenKey] = nextRecord; await persistState(state, params.baseDir); return { recorded: true, @@ -368,6 +415,10 @@ export async function verifyDeviceBootstrapToken(params: { ) { return { ok: false, reason: "bootstrap_token_invalid" }; } + const requestedProfile = resolveRequestedBootstrapProfile({ + role, + scopes: params.scopes, + }); const boundDeviceId = record.deviceId?.trim(); const boundPublicKey = @@ -378,9 +429,14 @@ export async function verifyDeviceBootstrapToken(params: { if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) { return { ok: false, reason: "bootstrap_token_invalid" }; } + const pendingProfile = resolvePersistedPendingProfile(record); + if (pendingProfile && !sameBootstrapProfile(pendingProfile, requestedProfile)) { + return { ok: false, reason: "bootstrap_token_invalid" }; + } state[tokenKey] = { ...record, profile: allowedProfile, + pendingProfile: pendingProfile ?? requestedProfile, deviceId, publicKey, lastUsedAtMs: Date.now(), @@ -392,6 +448,7 @@ export async function verifyDeviceBootstrapToken(params: { state[tokenKey] = { ...record, profile: allowedProfile, + pendingProfile: requestedProfile, deviceId, publicKey, lastUsedAtMs: Date.now(), diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 9e160442e03..d0019406098 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -590,7 +590,7 @@ describe("device pairing tokens", () => { const issued = await issueDeviceBootstrapToken({ baseDir, roles: ["operator"], - scopes: ["operator.read"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }); await expect( @@ -620,11 +620,15 @@ describe("device pairing tokens", () => { deviceId: "device-1", publicKey: "public-key-1", role: "operator", - scopes: ["operator.admin"], + scopes: ["operator.write", "operator.approvals"], baseDir, }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + const pending = await listDevicePairing(baseDir); + expect(pending.pending).toHaveLength(1); + expect(pending.pending[0]?.scopes).toEqual(["operator.read"]); + await approveDevicePairing( first.request.requestId, { callerScopes: ["operator.read"] },