fix: allow setup-code bootstrap auth for operator pairing

This commit is contained in:
Ayaan Zaidi 2026-03-30 18:58:21 +05:30
parent e0281849c0
commit 2dced6b4a0
No known key found for this signature in database
5 changed files with 62 additions and 42 deletions

View File

@ -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 () => {

View File

@ -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<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(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 };
});
}

View File

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

View File

@ -9,7 +9,6 @@ export {
export {
normalizeDeviceBootstrapProfile,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
sameDeviceBootstrapProfile,
type DeviceBootstrapProfile,
type DeviceBootstrapProfileInput,
} from "../shared/device-bootstrap-profile.js";

View File

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