fix(gateway): track bootstrap profile redemption

This commit is contained in:
Ayaan Zaidi 2026-04-03 16:02:06 +05:30 committed by Peter Steinberger
parent 0891253012
commit b08d58c917
4 changed files with 148 additions and 46 deletions

View File

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

View File

@ -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<ReturnType<typeof getPairedDevice>>,
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}`,
);
}
}
}

View File

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

View File

@ -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<DeviceBootstrapTokenRecord>,
): 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<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
@ -94,6 +134,7 @@ async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
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;