diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 1f684c840ae..0f0ad503d35 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -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"], }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index c91e7b6bec8..d4307001314 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -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; const withLock = createAsyncLock(); -function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] { - if (!Array.isArray(roles)) { - return []; - } - const out = new Set(); - 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, +): 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 { const bootstrapPath = resolveBootstrapPath(baseDir); const rawState = (await readJsonFile(bootstrapPath)) ?? {}; @@ -66,11 +75,15 @@ async function loadState(baseDir?: string): Promise { 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" }; } diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index f9f3c67b2f8..bebbf63bee4 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -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) { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 64c99a08498..fb22d3bcd0f 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -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, }, diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts new file mode 100644 index 00000000000..be288560d5d --- /dev/null +++ b/src/shared/device-bootstrap-profile.ts @@ -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(); + 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]) + ); +}