mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user