- attempt-number-semantics: name the real bucket column
(consecutiveFailuresByKind) and drop a drifted line citation
- backup-restore: show the smoke-test response inside the standard
{ data, error } envelope
- deploy: the manual migration step now uses the bundled Prisma CLI
via node (the image strips pnpm); WHOOP vars are already on the
shipped compose whitelist
- encryption-key-rotation: point at the encrypted-column registry
instead of a five-model list several releases behind
- env-check: the CI gate shipped (env-check.yml); the partial-APNs
example now shows the synthetic all-or-none row and the non-zero exit
- password-reset: the admin API reset route exists; only the
self-service route does not
- tls-cert-pin: stop asserting client-repo pin-set details that cannot
be audited from this repository
- drop v141-followup-issues.md: a v1.4.1 punch list whose items are
fixed, removed, or superseded; keeping it under docs/ops invites
re-fixing shipped work
7.1 KiB
TLS SPKI pinning — coupling, alarm, and re-pin runbook
The native client SPKI-pins the server's TLS chain at the CA level: it ships a small pin set and rejects a connection whose validated chain contains no pinned key. The leaf is deliberately not pinned, so routine leaf renewals are a client no-op. The pin set itself and its enforcement rules live in the client repository, not here. The outage risk is a chain change: the served chain stops terminating in the pinned CA keys — the server moves to a different CA, or the CA rotates its intermediate / root keys. This page explains the coupling, the server-side alarm that watches the served leaf, and the operator / release-owner steps when it fires.
The coupling
A SPKI pin is base64(sha256(DER subjectPublicKeyInfo)) — a hash of a
certificate's public key, not of the whole certificate. The native client
carries a set of these pins in its build-time config and rejects any TLS
connection whose validated chain contains no pinned key; the exact pin-set
rules are the client repository's contract. Self-hosted instances that ship
their own chain sit outside the pinned-host list and validate against
system trust.
The app host's certificate auto-renews roughly every 90 days and each renewal mints a new leaf keypair. Because the client pins the CA keys rather than the leaf, those renewals pass without a client build. What DOES break every pinned client is a chain that no longer terminates in a pinned key — and there is no server-side signal that clients are pinning the served chain at all. The alarm below exists to catch exactly that early: it watches the served leaf, the most frequently rotating element, as the canary for any chain movement.
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 toNEXT_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 inTLS_LEAF_SPKI_PINS. - On a served pin that is not in the known set it emits a
tls.pin.leaf_changedwide-event annotation (old set + served pin), writes asystem.tls.pin_changedaudit-log row, and fans out a high-prioritySYSTEM_ALERTto 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 of served
leaf pins. It is the operator's explicit record of what the server is
expected to serve — distinct from the client's CA-level pin set, which lives
in the client build config. The baseline is an env var on purpose, not a
persisted "last-seen" row:
- An explicit baseline forces the operator to acknowledge every leaf rotation and check the new chain against the client's pinned CA keys before the alarm goes quiet.
- 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:
openssl s_client -connect "$APP_HOST:443" -servername "$APP_HOST" </dev/null 2>/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="<current-leaf-pin>"
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
When you see a tls.pin.leaf_changed alert / system.tls.pin_changed audit
row, the served leaf has rotated. First determine which of two cases you are
in:
- Routine renewal, same chain. Inspect the new chain (the
opensslpipeline above, or the alert payload). If the issuing intermediate and root are unchanged, the client's CA-level pins still match and no client is at risk. UpdateTLS_LEAF_SPKI_PINSto the new served-leaf pin and re-deploy so the alarm goes quiet. No client build. - Chain change. The new leaf chains through a different intermediate /
root (CA switch, or the CA rotated its keys). Every pinned client will
stop connecting once the old chain is no longer served — run the re-pin
procedure below inside the old chain's remaining validity, with margin
for app review and client roll-out (the alert body and audit row carry
the new leaf's
validTo).
Re-pin procedure (chain change)
- Extract the new chain's CA pins (intermediate + root) with the
opensslpipeline / the client repo's SPKI-extraction script. - Dual-pin. Add the new CA pins to the client's pin set alongside
the old ones (do not replace them yet). Clients on the old build keep
working as long as the old chain is still served; the dual-pinned build
works against both chains. Update
TLS_LEAF_SPKI_PINSto the currently served leaf pin(s) so the server-side alarm reflects reality. - 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 old chain disappears.
- Retire the old pins once the old chain is no longer served and the dual-pinned client build has reached the installed base.
CA-level pins move rarely — intermediates and roots carry multi-year lifetimes and rotations are announced well ahead. Track the pinned certificates' expiry dates and pre-stage the successor pins before the cutover, so a chain change never catches a single-chain client.
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.