feat(ops): alarm on a TLS leaf SPKI change for pinned native clients

The native client SPKI-pins the server's TLS leaf certificate. When the
leaf auto-renews (GTS re-issues it roughly every 90 days) the leaf keypair
— and so the SPKI pin — changes, and a client shipped only the old pin
refuses to connect on the next renewal: a silent outage. Nothing
server-side signalled that a client pins the served leaf.

Add a pg-boss monitor that probes the served leaf every 6 hours, computes
its iOS SPKI pin (base64(sha256(DER subjectPublicKeyInfo)) via a raw TLS
socket + X509Certificate), and compares it against the operator's
known-good set in TLS_LEAF_SPKI_PINS (comma-separated for the dual-pin
renewal window). On a served pin outside the set it fails loud: a
tls.pin.leaf_changed wide-event annotation, a system.tls.pin_changed audit
row, and a high-priority SYSTEM_ALERT fanned out to admins through the
existing dispatcher idiom. Transient TLS/network failures annotate
tls.pin.probe_failed and never alarm.

The baseline is an env var, not a persisted last-seen row — a self-learning
baseline would silently adopt the first rotation and suppress the alarm the
pinned client needs; an unset baseline logs the served pin and warns rather
than auto-adopting. The runbook documents extraction, the re-pin + dual-pin
procedure (≥11 days before expiry, ship to TestFlight), and the assumption
that the probed host is the host the client connects to.

Wires the queue into the worker (allQueues + cron + worker), adds the
optional env to the manifest, compose whitelist, and .env example, and
covers the SPKI computation + change detection with a fixture-cert unit
test plus a queue-registration guard.

(cherry picked from commit cdb88cd77b88aec71ad2f21caf2b15c185dfc01e)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 08:10:13 +02:00
1 parent a199fac20e
commit 540abebf09
15 files changed
+839 -6

No files matched your search

@@ -0,0 +1,53 @@
/**
* v1.12.2 ios-coord — TLS-pin-monitor queue registration guard.
*
* Same source-text-grep approach as the other queue-wiring guards: assert
* the queue is imported, registered in `allQueues`, scheduled, and wired to
* a `boss.work` handler — without booting pg-boss + Prisma. An unregistered
* queue silently never drains (the recurring v1.4.37 dead-queue bug), which
* would leave the pinned-leaf-rotation alarm dark.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
const workerSource = readFileSync(REMINDER_WORKER_PATH, "utf8");
describe("reminder-worker — tls-pin-monitor wiring", () => {
it("imports the queue symbols from the tls-pin-monitor module", () => {
expect(workerSource).toMatch(
/from\s*["']@\/lib\/jobs\/tls-pin-monitor["']/,
);
expect(workerSource).toMatch(/\bTLS_PIN_MONITOR_QUEUE\b/);
expect(workerSource).toMatch(/\bTLS_PIN_MONITOR_CRON\b/);
expect(workerSource).toMatch(/\brunTlsPinMonitor\b/);
});
it("registers the tls-pin-monitor queue in the allQueues loop", () => {
const allQueuesMatch = workerSource.match(
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
);
expect(allQueuesMatch).not.toBeNull();
expect(allQueuesMatch![1]).toMatch(/\bTLS_PIN_MONITOR_QUEUE\b/);
});
it("schedules the tls-pin-monitor cron", () => {
expect(workerSource).toMatch(
/\[TLS_PIN_MONITOR_QUEUE,\s*TLS_PIN_MONITOR_CRON\]/,
);
});
it("registers a boss.work handler for the queue", () => {
expect(workerSource).toMatch(
/boss\.work[\s\S]{0,200}TLS_PIN_MONITOR_QUEUE[\s\S]{0,200}handleTlsPinMonitor/,
);
});
it("runs the monitor pass inside the handler", () => {
expect(workerSource).toMatch(
/handleTlsPinMonitor[\s\S]{0,400}runTlsPinMonitor/,
);
});
});
+44
View File
@@ -70,6 +70,11 @@ import {
GEO_BACKFILL_QUEUE,
GEO_BACKFILL_CRON,
} from "@/lib/jobs/geo-backfill";
import {
runTlsPinMonitor,
TLS_PIN_MONITOR_QUEUE,
TLS_PIN_MONITOR_CRON,
} from "@/lib/jobs/tls-pin-monitor";
import {
PR_DETECTION_QUEUE,
PR_DETECTION_CONCURRENCY,
@@ -489,6 +494,10 @@ interface MoodReminderPayload {
triggeredAt: string;
}
interface TlsPinMonitorPayload {
triggeredAt: string;
}
// Re-export timezone utilities under local names for backward compatibility
const getUserTodayBounds = getUserTodayBoundsUtil;
@@ -1886,6 +1895,24 @@ async function handleGeoBackfill(jobs: Job<GeoBackfillPayload>[]) {
});
}
async function handleTlsPinMonitor(jobs: Job<TlsPinMonitorPayload>[]) {
void jobs;
await withBackgroundEvent("job.tls_pin_monitor", async (evt) => {
const p = getWorkerPrisma();
try {
const summary = await runTlsPinMonitor(p);
evt.addMeta("tls_pin_monitor_outcome", summary.outcome);
evt.addMeta("tls_pin_monitor_host", summary.host);
evt.addMeta("tls_pin_monitor_known_count", summary.knownPinCount);
} catch (err) {
// runTlsPinMonitor swallows probe failures internally; anything that
// escapes is unexpected. Log and move on so a one-off failure does not
// poison the queue and block the next tick.
evt.addWarning(`tls-pin-monitor failed: ${err}`);
}
});
}
async function handlePrDetection(
jobs: Job<PrDetectionPayload | { userId?: undefined }>[],
) {
@@ -2327,6 +2354,12 @@ export async function startReminderWorker() {
HOST_METRIC_QUEUE,
FEEDBACK_AGGREGATOR_QUEUE,
GEO_BACKFILL_QUEUE,
// v1.12.2 ios-coord — TLS leaf SPKI-change monitor. The iOS client
// pins the served leaf certificate; this queue probes it every 6 h and
// alarms when the served SPKI leaves the operator's known-good set. The
// queue MUST be registered here or pg-boss never provisions it and the
// schedule below silently no-ops (the v1.4.37 dead-queue class).
TLS_PIN_MONITOR_QUEUE,
PR_DETECTION_QUEUE,
MEDICATION_INVENTORY_EXPIRE_QUEUE,
// v1.4.46 — hourly auto-skip pass for stale unmarked intakes.
@@ -2507,6 +2540,10 @@ export async function startReminderWorker() {
// long tail of audit rows that landed with the offline MMDB
// missing or the online provider unreachable.
[GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON],
// v1.12.2 ios-coord — every-6-hour TLS leaf SPKI probe (:07 off the
// hourly sync crons). Surfaces a pinned-leaf rotation well inside the
// ≥11-day re-pin window the iOS release owner needs.
[TLS_PIN_MONITOR_QUEUE, TLS_PIN_MONITOR_CRON],
// Fallback rescan every 30 minutes — protects against ingest paths
// that ship measurements without enqueueing a per-user job. The
// cron payload deliberately omits a `userId` so the handler iterates
@@ -2738,6 +2775,13 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleGeoBackfill,
);
// v1.12.2 ios-coord — TLS leaf SPKI-change monitor. Single-flight: one
// short outbound TLS handshake per tick, no benefit to overlapping ticks.
await boss.work<TlsPinMonitorPayload>(
TLS_PIN_MONITOR_QUEUE,
{ localConcurrency: 1 },
handleTlsPinMonitor,
);
// v0.5.4 ios-coord — single-flight worker. localConcurrency=1 keeps
// two reminder ticks from interleaving against the same user row;
// the dedup ledger would still save us, but skipping the race here
+225
View File
@@ -0,0 +1,225 @@
/**
* TLS leaf SPKI-change monitor.
*
* The native iOS client SPKI-pins the server's TLS LEAF certificate. When
* the leaf auto-renews (e.g. Google Trust Services re-issues it) the leaf
* keypair — and so the SPKI pin — changes, and a client shipped only the
* old pin will refuse to connect on the next renewal: a silent outage.
*
* This job probes the served leaf on a schedule, computes the iOS SPKI pin
* (`base64(sha256(DER subjectPublicKeyInfo))`), and compares it against the
* operator's known-good set in `TLS_LEAF_SPKI_PINS` (comma-separated to
* cover the dual-pin renewal window). When the served pin leaves that set,
* it fires LOUD:
*
* 1. a `tls.pin.leaf_changed` wide-event annotation (old set + served pin),
* 2. a `system.tls.pin_changed` audit-log row, and
* 3. a high-priority `SYSTEM_ALERT` to every ADMIN user, reusing the same
* admin-fan-out idiom as the deploy webhook + integration-status alarm.
*
* The runbook at `docs/ops/tls-cert-pin.md` documents the operator / iOS
* release-owner response (re-extract, dual-pin, ship to TestFlight) and how
* to set the baseline.
*
* BASELINE SOURCE — env var, deliberately, NOT a persisted last-seen row.
* A persisted auto-baseline would silently adopt the first rotated pin and
* suppress the very alarm the pinned iOS client needs; the env baseline is
* the single source of truth the operator also derives the shipped iOS pin
* set from, and an unset baseline fails LOUD ("not configured"), never by
* auto-adopting whatever it observed.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import {
fetchLeafSpki,
parseKnownPins,
isPinKnown,
resolveAppTlsTarget,
type LeafProbeResult,
} from "@/lib/tls/leaf-spki";
/**
* pg-boss queue name + cron. Every 6 hours at :07 (offset off the :00 / :05
* / :08 hourly sync crons so the probe doesn't pile onto a busy boss poll).
* GTS leaves renew roughly every ~90 days; a 6-hour cadence surfaces a
* change well inside the ≥11-day re-pin window the runbook targets while
* costing one short outbound TLS handshake per tick.
*/
export const TLS_PIN_MONITOR_QUEUE = "tls-pin-monitor";
export const TLS_PIN_MONITOR_CRON = "7 */6 * * *";
export interface TlsPinMonitorSummary {
/** "ok" served pin is in the known set; "changed" it is not; "skipped" no target / no baseline; "probe_failed" the TLS probe threw. */
outcome: "ok" | "changed" | "skipped" | "probe_failed";
host: string | null;
servedPin: string | null;
knownPinCount: number;
validTo: string | null;
}
/**
* Fan out a high-priority SYSTEM_ALERT to every ADMIN user. Mirrors the
* deploy-webhook + integration-status admin-alert idiom: the dispatcher
* silently no-ops on a user with no configured channel, so this is safe
* even before any operator notification channel is wired.
*/
async function alertAdminsOfPinChange(
prisma: PrismaClient,
host: string,
probe: LeafProbeResult,
knownPins: string[],
): Promise<void> {
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
"No admin user configured to alert about TLS leaf SPKI change",
);
return;
}
for (const admin of admins) {
await dispatchLocalisedNotification({
userId: admin.id,
titleKey: "notifications.admin.tlsPinChangedTitle",
messageKey: "notifications.admin.tlsPinChangedBody",
params: {
host,
servedPin: probe.pin,
validTo: probe.validTo,
},
metadata: {
source: "tls-pin-monitor",
host,
servedPin: probe.pin,
knownPins: knownPins.join(","),
validTo: probe.validTo,
fingerprint256: probe.fingerprint256,
},
});
}
}
/**
* One monitor pass. Resolves the target host from the app URL, probes the
* served leaf, compares its SPKI pin against the baseline set, and on a
* change emits the wide-event annotation + audit row + admin alert.
*
* Never throws on a probe / TLS error — a transient handshake failure
* annotates `tls.pin.probe_failed` and returns; the alarm only fires on a
* confirmed pin that is genuinely absent from the known set.
*/
export async function runTlsPinMonitor(
prisma: PrismaClient,
): Promise<TlsPinMonitorSummary> {
const evt = getEvent();
const target = resolveAppTlsTarget();
if (!target) {
// No HTTPS app URL configured (plain-HTTP LAN/VPN self-host) — there is
// no leaf to pin, so the monitor is a no-op.
evt?.addMeta("tls_pin_outcome", "skipped_no_https_target");
return {
outcome: "skipped",
host: null,
servedPin: null,
knownPinCount: 0,
validTo: null,
};
}
const knownPins = parseKnownPins(process.env.TLS_LEAF_SPKI_PINS);
let probe: LeafProbeResult;
try {
probe = await fetchLeafSpki(target.host, target.port);
} catch (err) {
// Transient TLS / network failure — surface but do not alarm. A real
// pin change is confirmed only by a successful probe returning a pin
// outside the known set.
evt?.setAction({ name: "tls.pin.probe_failed" });
evt?.addMeta("tls_pin_outcome", "probe_failed");
evt?.addMeta("tls_pin_host", target.host);
evt?.addWarning(
`tls-pin-monitor probe failed for ${target.host}:${target.port}: ${err instanceof Error ? err.message : String(err)}`,
);
return {
outcome: "probe_failed",
host: target.host,
servedPin: null,
knownPinCount: knownPins.length,
validTo: null,
};
}
if (knownPins.length === 0) {
// Baseline not configured. Fail LOUD (no silent auto-adopt): record the
// served pin so the operator can seed TLS_LEAF_SPKI_PINS, but treat as a
// no-op alarm-wise — there is nothing to compare against yet.
evt?.addMeta("tls_pin_outcome", "skipped_no_baseline");
evt?.addMeta("tls_pin_host", target.host);
evt?.addMeta("tls_pin_served", probe.pin);
evt?.addWarning(
`tls-pin-monitor: TLS_LEAF_SPKI_PINS is not configured; served leaf pin for ${target.host} is ${probe.pin} (set the env baseline to enable the alarm)`,
);
return {
outcome: "skipped",
host: target.host,
servedPin: probe.pin,
knownPinCount: 0,
validTo: probe.validTo,
};
}
if (isPinKnown(probe.pin, knownPins)) {
evt?.addMeta("tls_pin_outcome", "ok");
evt?.addMeta("tls_pin_host", target.host);
return {
outcome: "ok",
host: target.host,
servedPin: probe.pin,
knownPinCount: knownPins.length,
validTo: probe.validTo,
};
}
// ── Alarm: the served leaf SPKI is not in the pinned set ──
evt?.setAction({ name: "tls.pin.leaf_changed" });
evt?.addMeta("tls_pin_outcome", "changed");
evt?.addMeta("tls_pin_host", target.host);
evt?.addMeta("tls_pin_served", probe.pin);
evt?.addMeta("tls_pin_known", knownPins.join(","));
evt?.addMeta("tls_pin_valid_to", probe.validTo);
await auditLog("system.tls.pin_changed", {
details: {
host: target.host,
port: target.port,
servedPin: probe.pin,
knownPins,
validTo: probe.validTo,
fingerprint256: probe.fingerprint256,
},
});
try {
await alertAdminsOfPinChange(prisma, target.host, probe, knownPins);
} catch (err) {
evt?.addWarning(
`tls-pin-monitor admin alert failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
return {
outcome: "changed",
host: target.host,
servedPin: probe.pin,
knownPinCount: knownPins.length,
validTo: probe.validTo,
};
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Unit coverage for the TLS leaf SPKI pin helpers.
*
* The SPKI computation is pinned against a static self-signed fixture
* certificate (RSA-2048, generated once with `openssl req -x509`). Its
* known pin is `base64(sha256(DER subjectPublicKeyInfo))` — the iOS pin
* convention. If the computation regresses (e.g. someone swaps in the raw
* EC point / RSA key from `getPeerCertificate().pubkey`, which is NOT the
* SPKI), this fixture catches it: the served-vs-pinned compare is the whole
* alarm, so the hash must match Apple's byte-for-byte.
*/
import { describe, expect, it } from "vitest";
import { createHash, X509Certificate } from "node:crypto";
import {
spkiPinFromCertificate,
parseKnownPins,
isPinKnown,
resolveAppTlsTarget,
} from "@/lib/tls/leaf-spki";
// Self-signed RSA-2048 leaf, CN=test.healthlog.example. Static fixture —
// its SPKI pin is deterministic and recorded below.
const FIXTURE_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIDIzCCAgugAwIBAgIUQuLHUZ5FLA5N96Qc6686X5uyh70wDQYJKoZIhvcNAQEL
BQAwITEfMB0GA1UEAwwWdGVzdC5oZWFsdGhsb2cuZXhhbXBsZTAeFw0yNjA2MDUw
NTUzMjFaFw0zNjA2MDIwNTUzMjFaMCExHzAdBgNVBAMMFnRlc3QuaGVhbHRobG9n
LmV4YW1wbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDhJFlWVLZC
rb/dVQltFOt1i1QWMRYLSRNXb5KL2XlAxDY3iTmalAwyss/Uh9PDXuavevtOhu/L
GNFoPdE2Y40WzLaJtmM+OagXzaZ2ZOEvhZjBn4yDI4y+6WuruHJ1KCi4777PGFFl
UiBKlGtcI1NyJbcGilrPV7a8YnAUYzbv7feTlVxg3td4d0fhzPCC7vgBORiaM8c7
xDvDkwF+38/Nmx2td6qjqtI+c4+HN4XF1jiVV227DU2hD/Oe056oL46qrj9W1psY
h5G8xSH2eWGOKlhoD3hnSDs8CwxZsEBaq3ky2k+JXARcbpxXKHj2FkNfcdnKDaTh
y7EGhHJSy17tAgMBAAGjUzBRMB0GA1UdDgQWBBTNRvThVPTH/b8p+bqILqP7ZPws
IjAfBgNVHSMEGDAWgBTNRvThVPTH/b8p+bqILqP7ZPwsIjAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQDVHga3RM+ACgDMdNtZ8R+kFo9/A3/LMkHF
0S2kI7ZqwDiZC7OhSac4sbFRR69fWwJfG6ORuYEWXExxnQpKOzEu91n8PURGC5CA
lRtPm3Oqoat7W6rtJuef9xzFXWsgsFPfVzWqCpYR727ketFeS2uo2Onm7tl3uZMk
C02Ev7XglT2r7qmyuY2R+BPmCqOK8YG5w9001KzyYHNTVlr5a9+6R22vLNh6mj/y
bPFU8Ozb1ze27ORT1CoRPhDfrU3+UzOvd1/ENfSFhyZ3SFI9BGV0ezt/hCUXsyLA
5vU8y43XR8vAQdGOF43har8K3S4Ijjy1ruaKjWFkCC0rjStJAt/f
-----END CERTIFICATE-----`;
const FIXTURE_PIN = "/W80wXNcE/ANQgZGEQK4grEcWul1vITYjPZ4HUvL090=";
describe("spkiPinFromCertificate", () => {
it("computes the iOS pin from a PEM fixture", () => {
expect(spkiPinFromCertificate(FIXTURE_CERT_PEM)).toBe(FIXTURE_PIN);
});
it("computes the same pin from an X509Certificate instance", () => {
const x509 = new X509Certificate(FIXTURE_CERT_PEM);
expect(spkiPinFromCertificate(x509)).toBe(FIXTURE_PIN);
});
it("computes the same pin from the DER bytes", () => {
const x509 = new X509Certificate(FIXTURE_CERT_PEM);
expect(spkiPinFromCertificate(x509.raw)).toBe(FIXTURE_PIN);
});
it("matches base64(sha256(DER subjectPublicKeyInfo)) — the iOS convention", () => {
// Cross-check the pin against an independent computation off the DER
// SPKI so the test pins the *meaning*, not just the recorded constant.
const x509 = new X509Certificate(FIXTURE_CERT_PEM);
const spkiDer = x509.publicKey.export({ type: "spki", format: "der" });
const expected = createHash("sha256").update(spkiDer).digest("base64");
expect(spkiPinFromCertificate(FIXTURE_CERT_PEM)).toBe(expected);
});
it("is base64 and 44 chars (32-byte SHA-256 digest)", () => {
const pin = spkiPinFromCertificate(FIXTURE_CERT_PEM);
expect(pin).toMatch(/^[A-Za-z0-9+/]{43}=$/);
});
});
describe("parseKnownPins", () => {
it("returns an empty set for unset / empty baseline", () => {
expect(parseKnownPins(undefined)).toEqual([]);
expect(parseKnownPins(null)).toEqual([]);
expect(parseKnownPins("")).toEqual([]);
expect(parseKnownPins(" ")).toEqual([]);
});
it("parses a single pin", () => {
expect(parseKnownPins(FIXTURE_PIN)).toEqual([FIXTURE_PIN]);
});
it("parses a comma-separated dual-pin window and trims whitespace", () => {
const next = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKK=";
expect(parseKnownPins(` ${FIXTURE_PIN} , ${next} `)).toEqual([
FIXTURE_PIN,
next,
]);
});
it("deduplicates repeated pins", () => {
expect(parseKnownPins(`${FIXTURE_PIN},${FIXTURE_PIN}`)).toEqual([
FIXTURE_PIN,
]);
});
});
describe("isPinKnown — change detection", () => {
const known = parseKnownPins(FIXTURE_PIN);
it("treats the served pin as known when it is in the set (no alarm)", () => {
expect(isPinKnown(FIXTURE_PIN, known)).toBe(true);
});
it("treats a rotated leaf pin as unknown (the alarm condition)", () => {
const rotated = "ZZZZYYYYXXXXWWWWVVVVUUUUTTTTSSSSRRRRQQQQPPP=";
expect(isPinKnown(rotated, known)).toBe(false);
});
it("treats any served pin as unknown when the baseline is empty", () => {
expect(isPinKnown(FIXTURE_PIN, [])).toBe(false);
});
it("matches against any pin in a dual-pin window", () => {
const next = "ZZZZYYYYXXXXWWWWVVVVUUUUTTTTSSSSRRRRQQQQPPP=";
const dual = parseKnownPins(`${FIXTURE_PIN},${next}`);
expect(isPinKnown(FIXTURE_PIN, dual)).toBe(true);
expect(isPinKnown(next, dual)).toBe(true);
});
});
describe("resolveAppTlsTarget", () => {
it("prefers APP_URL over NEXT_PUBLIC_APP_URL", () => {
expect(
resolveAppTlsTarget("https://app.example.com", "https://other.example.com"),
).toEqual({ host: "app.example.com", port: 443 });
});
it("falls back to NEXT_PUBLIC_APP_URL when APP_URL is unset", () => {
expect(resolveAppTlsTarget(undefined, "https://pub.example.com")).toEqual({
host: "pub.example.com",
port: 443,
});
});
it("honours an explicit https port", () => {
expect(resolveAppTlsTarget("https://app.example.com:8443", undefined)).toEqual(
{ host: "app.example.com", port: 8443 },
);
});
it("skips a plain-HTTP origin (no leaf to pin)", () => {
expect(resolveAppTlsTarget("http://lan.local", undefined)).toBeNull();
});
it("skips a non-https candidate but uses the next https one", () => {
expect(
resolveAppTlsTarget("http://lan.local", "https://pub.example.com"),
).toEqual({ host: "pub.example.com", port: 443 });
});
it("returns null when nothing is configured", () => {
expect(resolveAppTlsTarget(undefined, undefined)).toBeNull();
expect(resolveAppTlsTarget("not a url", "also bad")).toBeNull();
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* TLS leaf SubjectPublicKeyInfo (SPKI) pin helpers.
*
* The native iOS client SPKI-pins the server's TLS **leaf** certificate.
* A pin is `base64(sha256(DER subjectPublicKeyInfo))` — the same value
* Apple's `NSPinnedDomains` / a manual `SecTrust` SPKI check computes, and
* the same value the HTTP `Public-Key-Pins` header used historically.
*
* When the leaf certificate auto-renews (e.g. Google Trust Services
* re-issues it), the leaf keypair — and therefore the SPKI hash — changes.
* A pinned client that has not been shipped the new pin will then refuse
* to connect: a silent outage on the next renewal. There is no server-side
* signal today that any client pins the served leaf, so this module lets a
* scheduled job fail loudly the moment the served SPKI leaves the operator's
* known-good set, giving the iOS release owner time to re-pin and ship a
* TestFlight build before the old pin is gone.
*
* CORRECTNESS NOTE — why `X509Certificate.publicKey.export({spki})`, not
* `tls.getPeerCertificate(true).pubkey`:
*
* `getPeerCertificate(detailed).pubkey` is NOT the DER `subjectPublicKeyInfo`.
* For an EC leaf it is only the raw ~65-byte EC point; for RSA it is the
* bare `RSAPublicKey` SEQUENCE. Hashing it yields a value that does NOT
* match the iOS pin. We therefore parse `cert.raw` (the leaf DER) through
* `crypto.X509Certificate` and export the public key as `spki`/`der`, which
* is exactly the `subjectPublicKeyInfo` Apple hashes.
*/
import { connect as tlsConnect, type PeerCertificate } from "node:tls";
import { createHash, X509Certificate } from "node:crypto";
/**
* Compute the iOS SPKI pin for a leaf certificate supplied as DER bytes,
* a `crypto.X509Certificate`, or a PEM string. Returns
* `base64(sha256(DER subjectPublicKeyInfo))`.
*/
export function spkiPinFromCertificate(
cert: Buffer | X509Certificate | string,
): string {
const x509 =
cert instanceof X509Certificate ? cert : new X509Certificate(cert);
// `spki` + `der` => the DER subjectPublicKeyInfo, the exact byte sequence
// an iOS SPKI pin hashes. Do NOT substitute the raw EC point / RSA key.
const spkiDer = x509.publicKey.export({ type: "spki", format: "der" });
return createHash("sha256").update(spkiDer).digest("base64");
}
/**
* Parse the operator's known-good pin set from the env baseline.
*
* `TLS_LEAF_SPKI_PINS` is comma-separated so the operator can hold BOTH
* the current leaf pin AND the next leaf pin during the dual-pin window
* that precedes a renewal — exactly mirroring the pin set shipped to the
* iOS app. An empty / unset value yields an empty set; the caller treats
* that as "baseline not configured" (a loud no-op, never a silent
* auto-adopt).
*/
export function parseKnownPins(raw: string | undefined | null): string[] {
if (!raw) return [];
const seen = new Set<string>();
for (const part of raw.split(",")) {
const pin = part.trim();
if (pin.length > 0) seen.add(pin);
}
return [...seen];
}
/**
* Whether the served pin is in the operator's known-good set. A served pin
* that is NOT in the set is the alarm condition — the leaf the iOS app
* pins has rotated underneath the shipped pin set.
*/
export function isPinKnown(servedPin: string, knownPins: string[]): boolean {
return knownPins.includes(servedPin);
}
/**
* Derive the public host (and port) the iOS client connects to from the
* app URL env. Prefers `APP_URL`, then `NEXT_PUBLIC_APP_URL` — the same
* precedence the passkey origin resolver uses. Returns `null` when no
* usable HTTPS URL is configured (plain-HTTP self-hosts have no leaf to
* pin, so the monitor is a no-op there).
*/
export function resolveAppTlsTarget(
appUrl: string | undefined = process.env.APP_URL,
publicAppUrl: string | undefined = process.env.NEXT_PUBLIC_APP_URL,
): { host: string; port: number } | null {
for (const candidate of [appUrl, publicAppUrl]) {
const trimmed = candidate?.trim();
if (!trimmed) continue;
let url: URL;
try {
url = new URL(trimmed);
} catch {
continue;
}
// The iOS pin only matters for the TLS leaf; a plain-HTTP origin has
// no certificate to pin, so we skip it rather than probing :80.
if (url.protocol !== "https:") continue;
const port = url.port ? Number(url.port) : 443;
if (!Number.isFinite(port) || port <= 0) continue;
return { host: url.hostname, port };
}
return null;
}
export interface LeafProbeResult {
/** The iOS SPKI pin of the served leaf certificate. */
pin: string;
/** Leaf certificate validity end, ISO 8601, for the re-pin deadline. */
validTo: string;
/** SHA-256 fingerprint of the whole leaf cert, for cross-referencing. */
fingerprint256: string;
}
/**
* Open a raw TLS socket to `host:port`, read the served LEAF certificate,
* and compute its iOS SPKI pin. Works regardless of how the reverse proxy
* terminates TLS — we observe exactly the certificate a pinned client
* would, because we speak TLS to the same public endpoint it does.
*
* `rejectUnauthorized: false` is deliberate: we are inspecting the served
* leaf, not authenticating it. An expired / self-signed / chain-broken
* cert is itself signal the operator wants surfaced, not a reason to abort
* the probe before we can read the pin. The connection is read-only and
* torn down the instant the certificate is in hand.
*/
export function fetchLeafSpki(
host: string,
port = 443,
timeoutMs = 10_000,
): Promise<LeafProbeResult> {
return new Promise<LeafProbeResult>((resolve, reject) => {
const socket = tlsConnect(
{
host,
port,
servername: host,
rejectUnauthorized: false,
// No ALPN / no app data — we only need the handshake's leaf cert.
},
() => {
try {
const cert: PeerCertificate = socket.getPeerCertificate(true);
if (!cert || !cert.raw) {
cleanup();
reject(new Error("no peer certificate served"));
return;
}
const x509 = new X509Certificate(cert.raw);
const result: LeafProbeResult = {
pin: spkiPinFromCertificate(x509),
validTo: new Date(x509.validTo).toISOString(),
fingerprint256: x509.fingerprint256,
};
cleanup();
resolve(result);
} catch (err) {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
}
},
);
const timer = setTimeout(() => {
cleanup();
reject(new Error(`TLS probe to ${host}:${port} timed out`));
}, timeoutMs);
function cleanup() {
clearTimeout(timer);
socket.removeListener("error", onError);
socket.destroy();
}
function onError(err: Error) {
cleanup();
reject(err);
}
socket.once("error", onError);
});
}