From 2dced6b4a004fd03e11c14df66342b3c49fdf2b7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 30 Mar 2026 18:58:21 +0530 Subject: [PATCH] fix: allow setup-code bootstrap auth for operator pairing --- src/infra/device-bootstrap.test.ts | 45 ++++++++++++++++++-------- src/infra/device-bootstrap.ts | 38 +++++++++++++++------- src/pairing/setup-code.test.ts | 4 +-- src/plugin-sdk/device-bootstrap.ts | 1 - src/shared/device-bootstrap-profile.ts | 16 ++------- 5 files changed, 62 insertions(+), 42 deletions(-) diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 0f0ad503d35..a4c01502920 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -64,24 +64,29 @@ describe("device bootstrap tokens", () => { ts: Date.now(), issuedAtMs: Date.now(), profile: { - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk.secrets", "operator.write"], }, }); }); - it("verifies valid bootstrap tokens once and deletes them after success", async () => { + it("verifies valid bootstrap tokens without consuming them before expiry", 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, { + role: "operator", + scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + }), + ).resolves.toEqual({ ok: true }); await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ - ok: false, - reason: "bootstrap_token_invalid", + ok: true, }); - await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + expect(raw).toContain(issued.token); }); it("clears outstanding bootstrap tokens on demand", async () => { @@ -120,7 +125,7 @@ describe("device bootstrap tokens", () => { await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true }); }); - it("consumes bootstrap tokens by the persisted map key", async () => { + it("verifies bootstrap tokens by the persisted map key without deleting them", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); const issuedAtMs = Date.now(); @@ -134,8 +139,8 @@ describe("device bootstrap tokens", () => { ts: issuedAtMs, issuedAtMs, profile: { - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk.secrets", "operator.write"], }, }, }, @@ -147,7 +152,8 @@ describe("device bootstrap tokens", () => { await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); - await expect(fs.readFile(bootstrapPath, "utf8")).resolves.toBe("{}"); + const raw = await fs.readFile(bootstrapPath, "utf8"); + expect(raw).toContain(issued.token); }); it("keeps the token when required verification fields are blank", async () => { @@ -179,6 +185,18 @@ describe("device bootstrap tokens", () => { 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({ @@ -207,7 +225,7 @@ describe("device bootstrap tokens", () => { ).resolves.toEqual({ ok: true }); }); - it("accepts trimmed bootstrap tokens and still consumes them once", async () => { + it("accepts trimmed bootstrap tokens without consuming them", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -215,7 +233,8 @@ describe("device bootstrap tokens", () => { ok: true, }); - await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); + const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); + expect(raw).toContain(issued.token); }); it("rejects blank or unknown tokens", async () => { diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index d4307001314..846f23f5165 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -2,10 +2,10 @@ import path from "node:path"; import { normalizeDeviceBootstrapProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE, - sameDeviceBootstrapProfile, type DeviceBootstrapProfile, type DeviceBootstrapProfileInput, } from "../shared/device-bootstrap-profile.js"; +import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { resolvePairingPaths } from "./pairing-files.js"; import { createAsyncLock, @@ -60,6 +60,21 @@ function resolveIssuedBootstrapProfile(params: { return PAIRING_SETUP_BOOTSTRAP_PROFILE; } +function bootstrapProfileAllowsRequest(params: { + allowedProfile: DeviceBootstrapProfile; + requestedRole: string; + requestedScopes: readonly string[]; +}): boolean { + return ( + params.allowedProfile.roles.includes(params.requestedRole) && + roleScopesAllow({ + role: params.requestedRole, + requestedScopes: params.requestedScopes, + allowedScopes: params.allowedProfile.scopes, + }) + ); +} + async function loadState(baseDir?: string): Promise { const bootstrapPath = resolveBootstrapPath(baseDir); const rawState = (await readJsonFile(bootstrapPath)) ?? {}; @@ -174,7 +189,7 @@ export async function verifyDeviceBootstrapToken(params: { if (!found) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const [tokenKey, record] = found; + const [, record] = found; const deviceId = params.deviceId.trim(); const publicKey = params.publicKey.trim(); @@ -182,24 +197,23 @@ export async function verifyDeviceBootstrapToken(params: { if (!deviceId || !publicKey || !role) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const requestedProfile = normalizeDeviceBootstrapProfile({ - roles: [role], - scopes: params.scopes, - }); const allowedProfile = resolvePersistedBootstrapProfile(record); // Fail closed for unbound legacy setup codes and for any attempt to redeem - // the token outside the exact role/scope profile it was issued for. + // the token outside the issued role/scope allowlist. if ( allowedProfile.roles.length === 0 || - !sameDeviceBootstrapProfile(requestedProfile, allowedProfile) + !bootstrapProfileAllowsRequest({ + allowedProfile, + requestedRole: role, + requestedScopes: params.scopes, + }) ) { return { ok: false, reason: "bootstrap_token_invalid" }; } - // Bootstrap setup codes are single-use. Consume the record before returning - // success so the same token cannot be replayed to mutate a pending request. - delete state[tokenKey]; - await persistState(state, params.baseDir); + // Keep valid setup codes alive until they expire or are explicitly revoked. + // Approval happens after bootstrap verification, so consuming the token here + // makes post-approval reconnect impossible. return { ok: true }; }); } diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index f1bb9ff7cda..4f117a6912e 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -74,8 +74,8 @@ describe("pairing setup code", () => { expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith( expect.objectContaining({ profile: { - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk.secrets", "operator.write"], }, }), ); diff --git a/src/plugin-sdk/device-bootstrap.ts b/src/plugin-sdk/device-bootstrap.ts index 850a3fe93e7..12437af8be7 100644 --- a/src/plugin-sdk/device-bootstrap.ts +++ b/src/plugin-sdk/device-bootstrap.ts @@ -9,7 +9,6 @@ export { export { normalizeDeviceBootstrapProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE, - sameDeviceBootstrapProfile, type DeviceBootstrapProfile, type DeviceBootstrapProfileInput, } from "../shared/device-bootstrap-profile.js"; diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index be288560d5d..014734d2197 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -11,8 +11,8 @@ export type DeviceBootstrapProfileInput = { }; export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk.secrets", "operator.write"], }; function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] { @@ -37,15 +37,3 @@ export function normalizeDeviceBootstrapProfile( scopes: normalizeDeviceAuthScopes(input?.scopes ? [...input.scopes] : []), }; } - -export function sameDeviceBootstrapProfile( - left: DeviceBootstrapProfile, - right: DeviceBootstrapProfile, -): boolean { - return ( - left.roles.length === right.roles.length && - left.scopes.length === right.scopes.length && - left.roles.every((value, index) => value === right.roles[index]) && - left.scopes.every((value, index) => value === right.scopes[index]) - ); -}