fix(auth): prevent bootstrap pairing scope changes [AI] (#80976)

* fix: prevent bootstrap pairing scope changes before redemption

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-12 16:11:35 +05:30
committed by GitHub
parent abd2ba1fe0
commit 2d00bedc1e
4 changed files with 113 additions and 3 deletions

View File

@@ -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.

View File

@@ -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 });

View File

@@ -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<DeviceBootstrapTokenRecord>,
): 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<DeviceBootstrapStateFile> {
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(),

View File

@@ -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"] },