From b08d58c91718a8ce7da3065ec19f3cef4ea4cb87 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 3 Apr 2026 16:02:06 +0530 Subject: [PATCH] fix(gateway): track bootstrap profile redemption --- src/gateway/server.auth.control-ui.suite.ts | 12 +++ .../server/ws-connection/message-handler.ts | 60 +++---------- src/infra/device-bootstrap.test.ts | 37 ++++++++ src/infra/device-bootstrap.ts | 85 +++++++++++++++++++ 4 files changed, 148 insertions(+), 46 deletions(-) diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index feaff761b71..88b8ce82ed2 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -872,6 +872,18 @@ export function registerControlUiAndPairingSuite(): void { callerScopes: pendingRequest.scopes ?? ["operator.admin"], }); + const wsNodeReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const nodeReconnect = await connectReq(wsNodeReconnect, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: nodeClient, + deviceIdentityPath: identityPath, + }); + expect(nodeReconnect.ok).toBe(true); + wsNodeReconnect.close(); + const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const operatorApproved = await connectReq(wsOperatorApproved, { skipDefaultAuth: true, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index b3880df267e..7fc183ebcfb 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -4,6 +4,7 @@ import type { WebSocket } from "ws"; import { loadConfig } from "../../../config/config.js"; import { getDeviceBootstrapTokenProfile, + redeemDeviceBootstrapTokenProfile, revokeDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "../../../infra/device-bootstrap.js"; @@ -32,7 +33,6 @@ import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; -import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isBrowserOperatorUiClient, @@ -146,44 +146,6 @@ function resolvePinnedClientMetadata(params: { }; } -function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] { - if (role === "operator") { - return scopes.filter((scope) => scope.startsWith("operator.")); - } - return scopes.filter((scope) => !scope.startsWith("operator.")); -} - -function pairedDeviceSatisfiesBootstrapProfile( - pairedDevice: Awaited>, - bootstrapProfile: DeviceBootstrapProfile, -): boolean { - if (!pairedDevice) { - return false; - } - const approvedScopes = Array.isArray(pairedDevice.approvedScopes) - ? pairedDevice.approvedScopes - : Array.isArray(pairedDevice.scopes) - ? pairedDevice.scopes - : []; - for (const bootstrapRole of bootstrapProfile.roles) { - if (!hasEffectivePairedDeviceRole(pairedDevice, bootstrapRole)) { - return false; - } - const requestedScopes = resolveBootstrapProfileScopes(bootstrapRole, bootstrapProfile.scopes); - if ( - requestedScopes.length > 0 && - !roleScopesAllow({ - role: bootstrapRole, - requestedScopes, - allowedScopes: approvedScopes, - }) - ) { - return false; - } - } - return true; -} - export function attachGatewayWsMessageHandler(params: { socket: WebSocket; upgradeReq: IncomingMessage; @@ -1029,16 +991,22 @@ export function attachGatewayWsMessageHandler(params: { authMethod === "bootstrap-token" && bootstrapProfile && bootstrapTokenCandidate && - device && - pairedDeviceSatisfiesBootstrapProfile(await getPairedDevice(device.id), bootstrapProfile) + device ) { - const revoked = await revokeDeviceBootstrapToken({ + const redemption = await redeemDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate, + role, + scopes, }); - if (!revoked.removed) { - logGateway.warn( - `bootstrap token revoke skipped after profile redemption device=${device.id}`, - ); + if (redemption.fullyRedeemed) { + const revoked = await revokeDeviceBootstrapToken({ + token: bootstrapTokenCandidate, + }); + if (!revoked.removed) { + logGateway.warn( + `bootstrap token revoke skipped after profile redemption device=${device.id}`, + ); + } } } diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 2733560f866..98898fd9ac4 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -7,6 +7,7 @@ import { DEVICE_BOOTSTRAP_TOKEN_TTL_MS, getDeviceBootstrapTokenProfile, issueDeviceBootstrapToken, + redeemDeviceBootstrapTokenProfile, revokeDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "./device-bootstrap.js"; @@ -107,6 +108,42 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); }); + it("persists bootstrap redemption state across verification reloads", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); + await expect( + redeemDeviceBootstrapTokenProfile({ + baseDir, + token: issued.token, + role: "node", + scopes: [], + }), + ).resolves.toEqual({ + recorded: true, + fullyRedeemed: false, + }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + }), + ).resolves.toEqual({ ok: true }); + await expect( + redeemDeviceBootstrapTokenProfile({ + baseDir, + token: issued.token, + role: "operator", + scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + }), + ).resolves.toEqual({ + recorded: true, + fullyRedeemed: true, + }); + }); + it("clears outstanding bootstrap tokens on demand", async () => { const baseDir = await createTempDir(); const first = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index beb49df4a39..5a362aa2ef6 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -23,6 +23,7 @@ export type DeviceBootstrapTokenRecord = { deviceId?: string; publicKey?: string; profile?: DeviceBootstrapProfile; + redeemedProfile?: DeviceBootstrapProfile; roles?: string[]; scopes?: string[]; issuedAtMs: number; @@ -43,6 +44,12 @@ function resolvePersistedBootstrapProfile( return normalizeDeviceBootstrapProfile(record.profile ?? record); } +function resolvePersistedRedeemedProfile( + record: Partial, +): DeviceBootstrapProfile { + return normalizeDeviceBootstrapProfile(record.redeemedProfile); +} + function resolveIssuedBootstrapProfile(params: { profile?: DeviceBootstrapProfileInput; roles?: readonly string[]; @@ -75,6 +82,39 @@ function bootstrapProfileAllowsRequest(params: { ); } +function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] { + if (role === "operator") { + return scopes.filter((scope) => scope.startsWith("operator.")); + } + return scopes.filter((scope) => !scope.startsWith("operator.")); +} + +function bootstrapProfileSatisfiesProfile(params: { + actualProfile: DeviceBootstrapProfile; + requiredProfile: DeviceBootstrapProfile; +}): boolean { + for (const requiredRole of params.requiredProfile.roles) { + if (!params.actualProfile.roles.includes(requiredRole)) { + return false; + } + const requiredScopes = resolveBootstrapProfileScopes( + requiredRole, + params.requiredProfile.scopes, + ); + if ( + requiredScopes.length > 0 && + !bootstrapProfileAllowsRequest({ + allowedProfile: params.actualProfile, + requestedRole: requiredRole, + requestedScopes: requiredScopes, + }) + ) { + return false; + } + } + return true; +} + async function loadState(baseDir?: string): Promise { const bootstrapPath = resolveBootstrapPath(baseDir); const rawState = (await readJsonFile(bootstrapPath)) ?? {}; @@ -94,6 +134,7 @@ async function loadState(baseDir?: string): Promise { state[tokenKey] = { token, profile, + redeemedProfile: resolvePersistedRedeemedProfile(record), deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined, publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined, issuedAtMs, @@ -127,6 +168,7 @@ export async function issueDeviceBootstrapToken( token, ts: issuedAtMs, profile, + redeemedProfile: normalizeDeviceBootstrapProfile(undefined), issuedAtMs, }; await persistState(state, params.baseDir); @@ -186,6 +228,49 @@ export async function getDeviceBootstrapTokenProfile(params: { }); } +export async function redeemDeviceBootstrapTokenProfile(params: { + token: string; + role: string; + scopes: readonly string[]; + baseDir?: string; +}): Promise<{ recorded: boolean; fullyRedeemed: boolean }> { + return await withLock(async () => { + const providedToken = params.token.trim(); + if (!providedToken) { + return { recorded: false, fullyRedeemed: false }; + } + const state = await loadState(params.baseDir); + const found = Object.entries(state).find(([, candidate]) => + verifyPairingToken(providedToken, candidate.token), + ); + if (!found) { + return { recorded: false, fullyRedeemed: false }; + } + const [tokenKey, record] = found; + const issuedProfile = resolvePersistedBootstrapProfile(record); + const redeemedProfile = normalizeDeviceBootstrapProfile({ + roles: [...resolvePersistedRedeemedProfile(record).roles, params.role], + scopes: [ + ...resolvePersistedRedeemedProfile(record).scopes, + ...resolveBootstrapProfileScopes(params.role, params.scopes), + ], + }); + state[tokenKey] = { + ...record, + profile: issuedProfile, + redeemedProfile, + }; + await persistState(state, params.baseDir); + return { + recorded: true, + fullyRedeemed: bootstrapProfileSatisfiesProfile({ + actualProfile: redeemedProfile, + requiredProfile: issuedProfile, + }), + }; + }); +} + export async function verifyDeviceBootstrapToken(params: { token: string; deviceId: string;