fix(pairing): recover malformed pairing state files

This commit is contained in:
Peter Steinberger
2026-04-28 11:37:07 +01:00
parent aa1834a3ff
commit 205d8d4994
5 changed files with 89 additions and 9 deletions

View File

@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
@@ -161,6 +161,42 @@ describe("device pairing tokens", () => {
expect(second.request.requestId).toBe(first.request.requestId);
});
test("recovers when pairing state files were written as arrays", async () => {
const baseDir = await makeDevicePairingDir();
const paths = resolvePairingPaths(baseDir, "devices");
await mkdir(paths.dir, { recursive: true });
await writeFile(paths.pendingPath, "[]", "utf8");
await writeFile(paths.pairedPath, "[]", "utf8");
const pending = await requestDevicePairing(
{
deviceId: "device-array-state",
publicKey: "public-key-array-state",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
const approved = await approveDevicePairing(
pending.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
expect(approved).toEqual(
expect.objectContaining({
status: "approved",
device: expect.objectContaining({ deviceId: "device-array-state" }),
}),
);
expect(Array.isArray(JSON.parse(await readFile(paths.pendingPath, "utf8")))).toBe(false);
expect(JSON.parse(await readFile(paths.pairedPath, "utf8"))).toEqual(
expect.objectContaining({
"device-array-state": expect.objectContaining({ deviceId: "device-array-state" }),
}),
);
});
test("re-requesting with identical params preserves the original ts to prevent queue-jumping", async () => {
// Regression: refreshPendingDevicePairingRequest must not bump ts to Date.now().
// An attacker who reconnects with the same key/role/scopes could otherwise

View File

@@ -14,6 +14,7 @@ import {
pruneExpiredPending,
readDurableJsonFile,
reconcilePendingPairingRequests,
coercePairingStateRecord,
resolvePairingPaths,
writeJsonAtomic,
} from "./pairing-files.js";
@@ -152,12 +153,12 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");
const [pending, paired] = await Promise.all([
readDurableJsonFile<Record<string, DevicePairingPendingRequest>>(pendingPath),
readDurableJsonFile<Record<string, PairedDevice>>(pairedPath),
readDurableJsonFile<unknown>(pendingPath),
readDurableJsonFile<unknown>(pairedPath),
]);
const state: DevicePairingStateFile = {
pendingById: pending ?? {},
pairedByDeviceId: paired ?? {},
pendingById: coercePairingStateRecord<DevicePairingPendingRequest>(pending),
pairedByDeviceId: coercePairingStateRecord<PairedDevice>(paired),
};
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
return state;

View File

@@ -131,6 +131,41 @@ describe("node pairing tokens", () => {
});
});
test("recovers when pairing state files were written as arrays", async () => {
await withNodePairingDir(async (baseDir) => {
const paths = resolvePairingPaths(baseDir, "nodes");
await fs.mkdir(paths.dir, { recursive: true });
await fs.writeFile(paths.pendingPath, "[]", "utf8");
await fs.writeFile(paths.pairedPath, "[]", "utf8");
const pending = await requestNodePairing(
{
nodeId: "node-array-state",
platform: "darwin",
commands: ["system.run"],
},
baseDir,
);
const approved = await approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
expect(approved).toEqual(
expect.objectContaining({
node: expect.objectContaining({ nodeId: "node-array-state" }),
}),
);
expect(Array.isArray(JSON.parse(await fs.readFile(paths.pendingPath, "utf8")))).toBe(false);
expect(JSON.parse(await fs.readFile(paths.pairedPath, "utf8"))).toEqual(
expect.objectContaining({
"node-array-state": expect.objectContaining({ nodeId: "node-array-state" }),
}),
);
});
});
test("generates base64url node tokens and rejects mismatches", async () => {
await withNodePairingDir(async (baseDir) => {
const token = await setupPairedNode(baseDir);

View File

@@ -7,6 +7,7 @@ import {
pruneExpiredPending,
readDurableJsonFile,
reconcilePendingPairingRequests,
coercePairingStateRecord,
resolvePairingPaths,
writeJsonAtomic,
} from "./pairing-files.js";
@@ -136,12 +137,12 @@ type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairing
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
const [pending, paired] = await Promise.all([
readDurableJsonFile<Record<string, NodePairingPendingRequest>>(pendingPath),
readDurableJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
readDurableJsonFile<unknown>(pendingPath),
readDurableJsonFile<unknown>(pairedPath),
]);
const state: NodePairingStateFile = {
pendingById: pending ?? {},
pairedByNodeId: paired ?? {},
pendingById: coercePairingStateRecord<NodePairingPendingRequest>(pending),
pairedByNodeId: coercePairingStateRecord<NodePairingPairedNode>(paired),
};
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
return state;

View File

@@ -18,6 +18,13 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string)
};
}
export function coercePairingStateRecord<T>(value: unknown): Record<string, T> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, T>;
}
export function pruneExpiredPending<T extends { ts: number }>(
pendingById: Record<string, T>,
nowMs: number,