Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/console/src/lib/mdm-chain-store.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ export function getExpectedAttestationKey(serial: string): string | null {
.get(serial) as { pubkey: string } | undefined;
return row?.pubkey ?? null;
}

/** The key AND when it was requested — used to make DeviceInformation enqueue
* idempotent (don't pile a second command for the same key onto NanoMDM's FIFO
* queue while the first is still pending). Null when none recorded. */
export function getExpectedAttestation(
serial: string,
): { pubkey: string; requestedAt: string } | null {
const row = consoleDb()
.prepare(`SELECT pubkey, requested_at FROM mdm_attestation_expected WHERE serial = ?`)
.get(serial) as { pubkey: string; requested_at: string } | undefined;
return row ? { pubkey: row.pubkey, requestedAt: row.requested_at } : null;
}
87 changes: 87 additions & 0 deletions packages/console/src/lib/mdm-coordinator-idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Idempotency of the DeviceInformation attestation enqueue.
//
// NanoMDM's per-device command queue is FIFO with no clear API. Re-enqueuing a
// DeviceInformation command for a key we already requested piles stale commands
// ahead of the real one AND hammers Apple's rate-limited DeviceAttestation into
// returning a CACHED (old-key) chain — which #178 then discards forever. So a
// repeat request for the SAME key must NOT enqueue again; it only re-pushes the
// command already queued. A key change must still enqueue immediately.
//
// The store is mocked (no better-sqlite3), and fetch is stubbed so we can assert
// exactly which NanoMDM endpoints are hit.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const store = { pending: null as { pubkey: string; requestedAt: string } | null };

vi.mock("./mdm-chain-store.server.ts", () => ({
getExpectedAttestation: () => store.pending,
getExpectedAttestationKey: () => store.pending?.pubkey ?? null,
putExpectedAttestationKey: (serial: string, pubkey: string, requestedAt: string) => {
store.pending = { pubkey, requestedAt };
},
getAttestationChain: () => null,
putAttestationChain: () => {},
}));

import { requestDeviceInformationAttestation } from "./mdm-coordinator.server.ts";

const SERIAL = "H2WHW38LQ6NV";
const UDID = "A1B2C3D4-1111-2222-3333-444455556666";
const KEY_A = Buffer.alloc(64, 1).toString("base64");
const KEY_B = Buffer.alloc(64, 2).toString("base64");

let calls: string[] = [];

beforeEach(() => {
store.pending = null;
calls = [];
process.env["COCORE_NANOMDM_URL"] = "https://nano.example";
process.env["COCORE_NANOMDM_API_KEY"] = "k";
vi.stubGlobal("fetch", async (url: string) => {
calls.push(String(url));
return { ok: true, status: 200, text: async () => "" } as Response;
});
});

afterEach(() => {
vi.unstubAllGlobals();
delete process.env["COCORE_NANOMDM_URL"];
delete process.env["COCORE_NANOMDM_API_KEY"];
});

const enqueued = () => calls.some((u) => u.includes("/v1/enqueue/"));
const pushed = () => calls.some((u) => u.includes("/v1/push/"));

describe("requestDeviceInformationAttestation idempotency", () => {
it("first request for a key enqueues + pushes", async () => {
await requestDeviceInformationAttestation(SERIAL, UDID, KEY_A);
expect(enqueued()).toBe(true);
expect(pushed()).toBe(true);
expect(store.pending?.pubkey).toBe(KEY_A);
});

it("a repeat request for the SAME key re-pushes but does NOT enqueue again", async () => {
store.pending = { pubkey: KEY_A, requestedAt: new Date().toISOString() };
const r = await requestDeviceInformationAttestation(SERIAL, UDID, KEY_A);
expect(enqueued()).toBe(false); // no duplicate command piled onto the queue
expect(pushed()).toBe(true); // but the queued command is nudged
expect(r.detail).toMatch(/already requested/i);
});

it("a KEY CHANGE enqueues immediately (bypasses dedup)", async () => {
store.pending = { pubkey: KEY_A, requestedAt: new Date().toISOString() };
await requestDeviceInformationAttestation(SERIAL, UDID, KEY_B);
expect(enqueued()).toBe(true);
expect(store.pending?.pubkey).toBe(KEY_B);
});

it("a STALE same-key request (past the dedup window) enqueues a fresh command", async () => {
store.pending = {
pubkey: KEY_A,
requestedAt: new Date(Date.now() - 7 * 60 * 60_000).toISOString(), // 7h ago > 6h window
};
await requestDeviceInformationAttestation(SERIAL, UDID, KEY_A);
expect(enqueued()).toBe(true);
});
});
45 changes: 45 additions & 0 deletions packages/console/src/lib/mdm-coordinator.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,22 @@ import { createHash, timingSafeEqual } from "node:crypto";
import { resolveBearerKey, type ResolvedKey } from "@/lib/api-keys.server.ts";
import {
getAttestationChain,
getExpectedAttestation,
getExpectedAttestationKey,
putAttestationChain,
putExpectedAttestationKey,
} from "@/lib/mdm-chain-store.server.ts";

/** How long a DeviceInformation attestation request for a given key stays
* "pending" before we'll enqueue a fresh command for that SAME key. NanoMDM's
* queue is FIFO with no clear API, so re-enqueuing the same key just piles stale
* commands ahead of the real one — and hammering Apple's rate-limited
* DeviceAttestation makes it return a CACHED (old-key) chain. Within this window
* a repeat request for the same key only RE-PUSHES the already-queued command
* instead of adding another. A KEY CHANGE always enqueues immediately (bypasses
* this). Comfortably under Apple's ~7-day attestation cadence. */
const ATTEST_REQUEST_DEDUP_MS = 6 * 60 * 60_000;

/** Apple's freshness-code OID — the leaf extension whose value equals the
* `DeviceAttestationNonce` we set (= sha256(pubkey)). Same OID the SDK/Rust/Py
* verifiers key on. */
Expand Down Expand Up @@ -861,6 +872,40 @@ export async function requestDeviceInformationAttestation(
const root = base.replace(/\/$/, "");
const authHeader = `Basic ${Buffer.from(`nanomdm:${apiKey}`).toString("base64")}`;
const enc = encodeURIComponent(target);

// Idempotency: if we already requested attestation for THIS exact key recently,
// don't append another DeviceInformation command to NanoMDM's FIFO queue —
// that's what let stale old-key commands drain ahead of the real one and made
// Apple's rate-limited attestation return a cached (old-key) chain, so the
// capture was discarded forever. Instead just RE-PUSH so the device processes
// the command already queued for this key. A key change falls through and
// enqueues immediately (below).
const pending = getExpectedAttestation(serial);
const sameKeyPending =
pending?.pubkey === publicKeyB64 &&
Date.now() - Date.parse(pending.requestedAt) < ATTEST_REQUEST_DEDUP_MS;
if (sameKeyPending) {
let pushed = false;
try {
const rp = await fetch(`${root}/v1/push/${enc}`, {
method: "GET",
headers: { authorization: authHeader },
});
pushed = rp.ok;
} catch {
/* push is best-effort — the queued command is still there */
}
return {
queued: true,
commandUuid: null,
status: "queued",
stubbed: false,
detail: pushed
? "attestation already requested for this key; re-pushed the queued command (no duplicate enqueue — avoids piling stale commands + Apple rate-limit)"
: "attestation already requested for this key; queued command awaits the device's next check-in",
};
}

const commandUuid = crypto.randomUUID().toUpperCase();
const enqueueBody = buildDeviceInformationAttestationCommand(
commandUuid,
Expand Down
Loading