diff --git a/.env.production.example b/.env.production.example index d9cab1cae..383a9e0b9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -67,6 +67,17 @@ API_TOKEN_HMAC_KEY="" # DEPLOY_WEBHOOK_SECRET="" +# ----------------------------------------------------------------------------- +# TLS leaf pin monitor -- alarm when the pinned leaf certificate rotates +# ----------------------------------------------------------------------------- +# The native client SPKI-pins the served TLS leaf. Set this to the +# known-good leaf pin(s) so the monitor can alarm on a rotation. The value +# is base64(sha256(DER subjectPublicKeyInfo)); comma-separate to hold both +# the current and the next pin during a dual-pin renewal window. See +# docs/ops/tls-cert-pin.md for extraction + the re-pin runbook. +# TLS_LEAF_SPKI_PINS="" + + # ----------------------------------------------------------------------------- # Off-host backups -- all-or-none # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 72fd5ebb4..5cc35e198 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,13 @@ services: # container (the `environment:` block is a whitelist). WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}" WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}" + # TLS leaf SPKI-change alarm baseline (optional). The native client + # pins the served TLS leaf; this is the known-good pin set the monitor + # alarms against on a rotation. Comma-separated for the dual-pin + # renewal window. Listed here so an operator value in .env reaches the + # container (the `environment:` block is a whitelist). See + # docs/ops/tls-cert-pin.md. + TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}" depends_on: db: condition: service_healthy diff --git a/docs/ops/tls-cert-pin.md b/docs/ops/tls-cert-pin.md new file mode 100644 index 000000000..bc2588e58 --- /dev/null +++ b/docs/ops/tls-cert-pin.md @@ -0,0 +1,127 @@ +# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook + +The native client SPKI-pins the server's TLS **leaf** certificate. This is a +deliberate hardening choice on the client side, but it couples the client to +the exact leaf keypair the server serves: when that leaf rotates, a client +that was only ever shipped the old pin refuses to connect. This page explains +the coupling, the server-side alarm that watches for a rotation, and the +operator / release-owner steps when it fires. + +## The coupling + +A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the +certificate's public key, not of the whole certificate. The native client +carries a set of these pins and rejects any TLS connection whose leaf public +key is not in the set. + +The app host's certificate is issued by Google Trust Services (GTS) and +auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**, +so the SPKI pin changes on every renewal. Intermediate and root rolls have +the same effect on the leaf chain. Because the client pins the leaf, every +such change is a hard outage for any client that hasn't been shipped the new +pin — and there is otherwise no server-side signal that a client is pinning +the served leaf at all. + +## The alarm + +A scheduled job (`src/lib/jobs/tls-pin-monitor.ts`) probes the served leaf +every 6 hours and fails loudly when it changes: + +- It derives the public host from `APP_URL` (falling back to + `NEXT_PUBLIC_APP_URL`) and opens a raw TLS socket to it — observing exactly + the certificate a pinned client would, regardless of how the reverse proxy + terminates TLS. +- It reads the served leaf, computes its SPKI pin + (`base64(sha256(DER subjectPublicKeyInfo))`), and compares it against the + operator's known-good set in `TLS_LEAF_SPKI_PINS`. +- On a served pin that is **not** in the known set it emits a + `tls.pin.leaf_changed` wide-event annotation (old set + served pin), writes + a `system.tls.pin_changed` audit-log row, and fans out a high-priority + `SYSTEM_ALERT` to every admin user through the notification dispatcher + (Telegram / ntfy / Web Push / APNs, whichever the operator has wired). + +A transient TLS or network failure during the probe is annotated +(`tls.pin.probe_failed`) and never alarms — the alarm fires only on a +successful probe returning a pin genuinely outside the known set. + +### Baseline source: an env var, not auto-learning + +`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is +an env var on purpose, **not** a persisted "last-seen" row: + +- The pinned client only trusts the pins it was shipped, so the operator must + derive the client's pin set from a single explicit source of truth. The env + var **is** that source — the same value goes into the client's pin set and + into `TLS_LEAF_SPKI_PINS`. +- A persisted last-seen baseline would silently adopt the first rotated pin + and suppress the very alarm the pinned client needs. +- When the env var is unset, the monitor fails **loud, not open**: it logs + the served pin (so you can seed the baseline) and warns "not configured", + but never auto-adopts what it observed. + +## Setting the baseline + +Extract the current leaf SPKI pin from the served certificate: + +```sh +openssl s_client -connect "$APP_HOST:443" -servername "$APP_HOST" /dev/null \ + | openssl x509 -noout -pubkey \ + | openssl pkey -pubin -outform der 2>/dev/null \ + | openssl dgst -sha256 -binary \ + | openssl base64 +``` + +`$APP_HOST` is the host in your configured `APP_URL` (the host the client +connects to). The output is the `base64(sha256(DER subjectPublicKeyInfo))` +pin. Set it in the deployment env: + +``` +TLS_LEAF_SPKI_PINS="" +``` + +The variable is on the compose `environment:` whitelist and the +`scripts/env-manifest.json` optional groups, so `pnpm check-env` will report +whether it is set. It is optional: without it the monitor still runs and logs +the served pin, but cannot alarm on a change. + +## When the alarm fires — re-pin procedure + +When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit +row, the served leaf has rotated. The pinned client will stop connecting once +its shipped pin no longer matches a served leaf. Act inside the certificate's +remaining validity, with margin for app review and client roll-out — target +**≥ 11 days before the old pin's certificate expires** (the alert body and +audit row both carry the new leaf's `validTo`). + +1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It + should equal the `servedPin` in the alert. +2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old + one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the + comma-separated pair, e.g. + `TLS_LEAF_SPKI_PINS=","`. Holding both pins means clients + on the old build keep working through the cutover and the server-side + alarm goes quiet because the served pin is now in the known set. Re-deploy + the server so the new env value takes effect. +3. **Ship the client build** carrying the dual-pin set to TestFlight (and + onward to release). Give it long enough to roll out to the installed base + before the next renewal. +4. **Retire the old pin** once the old leaf is no longer served and the + dual-pinned client build has reached the installed base: drop the old pin + from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the + current leaf pin. + +Treat the dual-pin window as standing practice: always ship the **next** pin +before the current leaf rotates, so a renewal never catches a single-pinned +client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the +upcoming leaf ahead of the renewal turns every rotation into a no-op. + +## Environment assumption + +The monitor speaks TLS directly to the public host from `APP_URL` / +`NEXT_PUBLIC_APP_URL`, which is correct regardless of the reverse-proxy +topology — it observes the same leaf a real client does. The one assumption +is that the host the client connects to is the host in that env var. If the +client is pinned against a different hostname (e.g. a vanity domain that +fronts the configured `APP_URL`), point the monitor at that host by setting +`APP_URL` to it, or extend the job to probe the additional host. Plain-HTTP +self-hosts have no leaf to pin and the monitor no-ops there by design. diff --git a/messages/de.json b/messages/de.json index 04aa733f6..86fcb9031 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4583,7 +4583,9 @@ "reminderCheckOverdueTitle": "Einnahme überfällig: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.", "offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen", - "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer." + "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer.", + "tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}", + "tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md." }, "user": { "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." diff --git a/messages/en.json b/messages/en.json index 3858e3844..5d4da4ca3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4583,7 +4583,9 @@ "reminderCheckOverdueTitle": "Dose overdue: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.", "offlineGeoUnavailableTitle": "Offline geolocation databases not loaded", - "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty." + "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty.", + "tlsPinChangedTitle": "TLS leaf certificate changed: {host}", + "tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md." }, "user": { "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active." diff --git a/messages/es.json b/messages/es.json index e525013dd..81c68c037 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4583,7 +4583,9 @@ "reminderCheckOverdueTitle": "Dosis atrasada: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.", "offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas", - "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía." + "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía.", + "tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}", + "tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md." }, "user": { "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." diff --git a/messages/fr.json b/messages/fr.json index 84f5cf2ea..db919b66c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4583,7 +4583,9 @@ "reminderCheckOverdueTitle": "Prise en retard : {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.", "offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées", - "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide." + "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide.", + "tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}", + "tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md." }, "user": { "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives." diff --git a/messages/it.json b/messages/it.json index e2f2462a7..e50b0f5f8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4583,7 +4583,9 @@ "reminderCheckOverdueTitle": "Dose in ritardo: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.", "offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati", - "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota." + "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota.", + "tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}", + "tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md." }, "user": { "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." diff --git a/messages/pl.json b/messages/pl.json index 63a80ff5a..647ee2e35 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4583,7 +4583,9 @@ "reminderCheckOverdueTitle": "Dawka opóźniona: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.", "offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane", - "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta." + "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta.", + "tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}", + "tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md." }, "user": { "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 1bc7b0c8e..0f2710c4e 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -90,6 +90,17 @@ } ] }, + { + "name": "TLS leaf pin monitor", + "description": "Baseline for the TLS leaf SPKI-change alarm. The native client pins the served leaf certificate; without this the monitor logs the served pin but cannot alarm on a rotation. See docs/ops/tls-cert-pin.md.", + "required": false, + "variables": [ + { + "name": "TLS_LEAF_SPKI_PINS", + "purpose": "Comma-separated known-good leaf SPKI pins, base64(sha256(DER subjectPublicKeyInfo)) — the same value the native client pins. Hold both the current and the next pin during a dual-pin renewal window." + } + ] + }, { "name": "Off-host backups", "description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.", diff --git a/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts new file mode 100644 index 000000000..4c5a44a0e --- /dev/null +++ b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts @@ -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/, + ); + }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index b674568ba..92b46ad36 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -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[]) { }); } +async function handleTlsPinMonitor(jobs: Job[]) { + 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[], ) { @@ -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( + 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 diff --git a/src/lib/jobs/tls-pin-monitor.ts b/src/lib/jobs/tls-pin-monitor.ts new file mode 100644 index 000000000..095fe8334 --- /dev/null +++ b/src/lib/jobs/tls-pin-monitor.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/src/lib/tls/__tests__/leaf-spki.test.ts b/src/lib/tls/__tests__/leaf-spki.test.ts new file mode 100644 index 000000000..e10852fc8 --- /dev/null +++ b/src/lib/tls/__tests__/leaf-spki.test.ts @@ -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(); + }); +}); diff --git a/src/lib/tls/leaf-spki.ts b/src/lib/tls/leaf-spki.ts new file mode 100644 index 000000000..895f07161 --- /dev/null +++ b/src/lib/tls/leaf-spki.ts @@ -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(); + 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 { + return new Promise((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); + }); +}