refactor: centralize bootstrap profile handling

This commit is contained in:
Peter Steinberger
2026-03-23 00:14:10 -07:00
parent 43557668d2
commit 6686f1cb2c
5 changed files with 129 additions and 46 deletions

View File

@@ -52,14 +52,21 @@ describe("device bootstrap tokens", () => {
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
const parsed = JSON.parse(raw) as Record<
string,
{ token: string; ts: number; issuedAtMs: number }
{
token: string;
ts: number;
issuedAtMs: number;
profile: { roles: string[]; scopes: string[] };
}
>;
expect(parsed[issued.token]).toMatchObject({
token: issued.token,
ts: Date.now(),
issuedAtMs: Date.now(),
roles: ["node"],
scopes: [],
profile: {
roles: ["node"],
scopes: [],
},
});
});
@@ -126,8 +133,10 @@ describe("device bootstrap tokens", () => {
token: issued.token,
ts: issuedAtMs,
issuedAtMs,
roles: ["node"],
scopes: [],
profile: {
roles: ["node"],
scopes: [],
},
},
},
null,
@@ -174,6 +183,18 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: {
roles: [" operator ", "operator"],
scopes: ["operator.read", " operator.read "],
},
});
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
const parsed = JSON.parse(raw) as Record<
string,
{ profile: { roles: string[]; scopes: string[] } }
>;
expect(parsed[issued.token]?.profile).toEqual({
roles: ["operator"],
scopes: ["operator.read"],
});

View File

@@ -1,5 +1,11 @@
import path from "node:path";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import {
normalizeDeviceBootstrapProfile,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
sameDeviceBootstrapProfile,
type DeviceBootstrapProfile,
type DeviceBootstrapProfileInput,
} from "../shared/device-bootstrap-profile.js";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
@@ -16,6 +22,7 @@ export type DeviceBootstrapTokenRecord = {
ts: number;
deviceId?: string;
publicKey?: string;
profile?: DeviceBootstrapProfile;
roles?: string[];
scopes?: string[];
issuedAtMs: number;
@@ -26,31 +33,33 @@ type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
const withLock = createAsyncLock();
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
if (!Array.isArray(roles)) {
return [];
}
const out = new Set<string>();
for (const role of roles) {
const trimmed = role.trim();
if (trimmed) {
out.add(trimmed);
}
}
return [...out].toSorted();
}
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) {
return false;
}
return left.every((value, index) => value === right[index]);
}
function resolveBootstrapPath(baseDir?: string): string {
return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
}
function resolvePersistedBootstrapProfile(
record: Partial<DeviceBootstrapTokenRecord>,
): DeviceBootstrapProfile {
return normalizeDeviceBootstrapProfile(record.profile ?? record);
}
function resolveIssuedBootstrapProfile(params: {
profile?: DeviceBootstrapProfileInput;
roles?: readonly string[];
scopes?: readonly string[];
}): DeviceBootstrapProfile {
if (params.profile) {
return normalizeDeviceBootstrapProfile(params.profile);
}
if (params.roles || params.scopes) {
return normalizeDeviceBootstrapProfile({
roles: params.roles,
scopes: params.scopes,
});
}
return PAIRING_SETUP_BOOTSTRAP_PROFILE;
}
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
@@ -66,11 +75,15 @@ async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
const token =
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);
state[tokenKey] = {
...record,
token,
profile,
deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined,
publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined,
issuedAtMs,
ts: typeof record.ts === "number" ? record.ts : issuedAtMs,
lastUsedAtMs: typeof record.lastUsedAtMs === "number" ? record.lastUsedAtMs : undefined,
};
}
pruneExpiredPending(state, Date.now(), DEVICE_BOOTSTRAP_TOKEN_TTL_MS);
@@ -85,6 +98,7 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
export async function issueDeviceBootstrapToken(
params: {
baseDir?: string;
profile?: DeviceBootstrapProfileInput;
roles?: readonly string[];
scopes?: readonly string[];
} = {},
@@ -93,13 +107,11 @@ export async function issueDeviceBootstrapToken(
const state = await loadState(params.baseDir);
const token = generatePairingToken();
const issuedAtMs = Date.now();
const roles = normalizeBootstrapRoles(params.roles ?? ["node"]);
const scopes = normalizeDeviceAuthScopes(params.scopes ? [...params.scopes] : []);
const profile = resolveIssuedBootstrapProfile(params);
state[token] = {
token,
ts: issuedAtMs,
roles,
scopes,
profile,
issuedAtMs,
};
await persistState(state, params.baseDir);
@@ -170,16 +182,16 @@ export async function verifyDeviceBootstrapToken(params: {
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const requestedRoles = normalizeBootstrapRoles([role]);
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
const allowedRoles = normalizeBootstrapRoles(record.roles);
const allowedScopes = normalizeDeviceAuthScopes(record.scopes);
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.
if (
allowedRoles.length === 0 ||
!sameStringSet(requestedRoles, allowedRoles) ||
!sameStringSet(requestedScopes, allowedScopes)
allowedProfile.roles.length === 0 ||
!sameDeviceBootstrapProfile(requestedProfile, allowedProfile)
) {
return { ok: false, reason: "bootstrap_token_invalid" };
}

View File

@@ -56,8 +56,10 @@ describe("pairing setup code", () => {
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith(
expect.objectContaining({
roles: ["node"],
scopes: [],
profile: {
roles: ["node"],
scopes: [],
},
}),
);
if (params.url) {

View File

@@ -13,6 +13,7 @@ import {
pickMatchingExternalInterfaceAddress,
safeNetworkInterfaces,
} from "../infra/network-interfaces.js";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
@@ -22,9 +23,6 @@ export type PairingSetupPayload = {
bootstrapToken: string;
};
const PAIRING_SETUP_BOOTSTRAP_ROLES = ["node"] as const;
const PAIRING_SETUP_BOOTSTRAP_SCOPES: string[] = [];
export type PairingSetupCommandResult = {
code: number | null;
stdout: string;
@@ -387,8 +385,7 @@ export async function resolvePairingSetupFromConfig(
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
roles: PAIRING_SETUP_BOOTSTRAP_ROLES,
scopes: PAIRING_SETUP_BOOTSTRAP_SCOPES,
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
})
).token,
},

View File

@@ -0,0 +1,51 @@
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js";
export type DeviceBootstrapProfile = {
roles: string[];
scopes: string[];
};
export type DeviceBootstrapProfileInput = {
roles?: readonly string[];
scopes?: readonly string[];
};
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node"],
scopes: [],
};
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
if (!Array.isArray(roles)) {
return [];
}
const out = new Set<string>();
for (const role of roles) {
const normalized = normalizeDeviceAuthRole(role);
if (normalized) {
out.add(normalized);
}
}
return [...out].toSorted();
}
export function normalizeDeviceBootstrapProfile(
input: DeviceBootstrapProfileInput | undefined,
): DeviceBootstrapProfile {
return {
roles: normalizeBootstrapRoles(input?.roles),
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])
);
}