Merge remote-tracking branch 'origin/main' into feat/v140-fixes

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 20:06:16 +02:00
commit cd3c0b7de2
74 files changed
+4140 -508

No files matched your search

+5
View File
@@ -62,6 +62,11 @@ NODE_ENV="development"
# internet; the session cookie crosses the wire in cleartext.
# SESSION_COOKIE_SECURE="false"
# Where this server's environment is configured: compose, truenas, coolify,
# unraid or portainer. Only picks which instructions the admin's "Back up
# your encryption key" step shows first; unset means compose.
# HEALTHLOG_PLATFORM="compose"
# -----------------------------------------------------------------------------
# Required Secrets — generate with `openssl rand -hex 32`
+10
View File
@@ -257,6 +257,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# SESSION_COOKIE_SECURE="false"
# -----------------------------------------------------------------------------
# Platform hint for the encryption key backup step
# -----------------------------------------------------------------------------
# Where this server's environment lives: compose, truenas, coolify, unraid or
# portainer. The admin's "Back up your encryption key" step opens on the
# instructions for that platform. Unset means compose. Catalog templates set
# it for you.
# HEALTHLOG_PLATFORM="compose"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
+3
View File
@@ -132,6 +132,9 @@ x-healthlog-env: &healthlog_env
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
# compose | truenas | coolify | unraid | portainer. Only chooses which
# instructions the admin's "Back up your encryption key" step shows first.
HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
+544 -448
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -79,6 +79,14 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
## Catalog templates
A new required environment variable, or a change to the compose shape, needs a
matching pull request to the TrueNAS apps catalog in the same release cycle
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`). See
`docs/self-hosting/truenas.md` for what the catalog template carries.
## Verify the served version
The queued-deploy status is **not** the source of truth — the version
+41
View File
@@ -207,6 +207,13 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
the container unhealthy if Postgres is still
starting. Tail `docker compose logs db` to confirm Postgres came up
cleanly.
- **The login page says "Signing in cannot complete on this page".** The
server sets its session cookie with the `Secure` flag (the production
default) and the page was opened over plain `http://`, where the browser
would drop it. Set `SESSION_COOKIE_SECURE=false` for a server reached only
over HTTP on a private network, or open the `https://` address (behind a TLS
proxy, make sure it forwards `X-Forwarded-Proto: https`). The sign-in is
refused before any password, code or ticket is used.
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
`localhost`, and restart `app` after the change.
@@ -226,6 +233,40 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
`pull_policy: always` on the `app` service, and verify the running
build with `GET /api/version` (it returns `version` + `buildSha`).
## Back up your encryption key
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
Without it, the encrypted parts of the database and every in-database backup
cannot be read. Keep a copy outside the server, then confirm it under Admin,
Encryption: the step shows the key id and a fingerprint (the first 12 hex
characters of SHA-256 over the key) and can check a pasted copy without
storing it. Admins see a dashboard reminder until the active key is confirmed.
| Platform | Where the key is set |
| --------- | --------------------------------------------------------------- |
| Compose | `.env` next to `docker-compose.yml` |
| TrueNAS | Apps, HealthLog, Edit, Environment |
| Coolify | the application's Environment Variables |
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
| Portainer | the stack's Environment variables |
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
`portainer`) to open the step on your platform's instructions.
To compute the fingerprint of your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
```
**HealthLog refuses to serve: encryption key does not match this database.**
At start the app checks that its key opens the stored data. If it does not,
every API request answers `503` with `meta.errorCode`
`encryption.key_mismatch`, `/api/health` answers `503` with
`reason: "encryption_key_mismatch"`, and background jobs are not started.
Restore the original key, point `DATABASE_URL` at the matching database, or
start with an empty database; then restart.
## Backup and restore
Your database is the only stateful piece — back it up, and back up the
@@ -30,6 +30,12 @@
"label": "API token HMAC key",
"description": "Generate with: openssl rand -hex 32"
},
{
"name": "HEALTHLOG_PLATFORM",
"label": "Platform hint",
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
"default": "portainer"
},
{
"name": "SESSION_COOKIE_SECURE",
"label": "Session cookie Secure flag",
+87
View File
@@ -0,0 +1,87 @@
# HealthLog on TrueNAS SCALE
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
is stateless; everything you care about lives in the database dataset and in
one value you must keep yourself: the encryption key.
## What the app needs
| Setting | Value |
| ------------ | ---------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 3 × 30 s: migrations run at every boot |
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
| Volumes | none for the app; the database gets its own dataset |
## Environment
| Variable | Required | Notes |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
| `APP_URL` | yes | the same address |
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
and runs the background jobs.
## Plain HTTP on the LAN
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
for that. If it is left at its default, the browser drops the sign-in cookie
and the login page says so before you type a password: "This server only sets
its sign-in cookie over https://".
## Back up your encryption key
HealthLog encrypts tokens, notes, documents and in-database backups with
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
and reinstalled with a new key over the old database dataset, the data is
still there but cannot be read.
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
Environment) into your password manager.
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
Compare it with your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
```
or paste the copy into "Check my copy" (it is compared and dropped, never
stored or logged).
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
After a key rotation the step comes back for the new key.
## When the key does not match the database
At every start HealthLog checks that its key opens the data already in the
database. If it does not, it refuses to serve instead of failing on every
encrypted value: pages show "HealthLog cannot open its data", API requests
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
stays visible.
Fix it one of three ways, then restart the app:
1. Put the original `ENCRYPTION_KEY` back.
2. Point `DATABASE_URL` at the database that belongs to this key.
3. Start fresh with an empty database. The old data and its backups need the
old key.
## Updates
Pick the new tag, take a snapshot of the database dataset first, and update.
Migrations run on start; the first start of a new install applies all of them,
which is why the start period above is generous.
+8
View File
@@ -121,6 +121,14 @@ Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
and `APP_URL` at the public `https://` URL.
## Back up your encryption key
Copy `ENCRYPTION_KEY` from the template into your password manager, then
confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step
opens on the Unraid instructions). A reinstall with a new key over the old
database makes the data unreadable; HealthLog then refuses to serve and says
so. See [Getting started](getting-started.md#back-up-your-encryption-key).
## Updates
Pin a release tag (`ghcr.io/mbombeck/healthlog:vX.Y.Z`) and bump deliberately,
+1
View File
@@ -53,6 +53,7 @@
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
+63
View File
@@ -489,6 +489,13 @@
"action": "Bestätigen",
"failed": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
"nothingToConfirm": "Hier gibt es nichts zu bestätigen. Starte die Verbindung noch einmal in deinem Assistenten."
},
"insecureTransport": {
"title": "Die Anmeldung kann auf dieser Seite nicht abgeschlossen werden",
"body": "Dieser Server setzt sein Anmelde-Cookie nur über https://, aber diese Seite wurde über http:// geöffnet. Der Browser würde das Cookie verwerfen, deshalb ist die Anmeldung hier abgeschaltet.",
"fixLan": "Server im Heimnetz ohne TLS: Setze SESSION_COOKIE_SECURE=false in seinen Einstellungen und starte ihn neu.",
"fixHttps": "Server hinter einem HTTPS-Proxy: Öffne stattdessen die https://-Adresse und sorge dafür, dass der Proxy X-Forwarded-Proto: https weitergibt.",
"localhostNote": "Diese Seite läuft auf http://localhost, der Server setzt sein Anmelde-Cookie aber nur für https://. Die meisten Browser akzeptieren das auf localhost; wenn du nach der Anmeldung wieder hier landest, setze SESSION_COOKIE_SECURE=false."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Sichere deinen Verschlüsselungsschlüssel",
"body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.",
"action": "Zum Schritt"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "Dieser Teil der Seite konnte nicht angezeigt werden",
"description": "Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt."
},
"keyBackup": {
"title": "Sichere deinen Verschlüsselungsschlüssel",
"description": "Ohne eine Kopie dieses Schlüssels lassen sich deine verschlüsselten Daten und Sicherungen nie wieder lesen.",
"loading": "Status der Schlüsselsicherung wird geladen…",
"loadError": "Der Status der Schlüsselsicherung konnte nicht geladen werden.",
"badgeDue": "Offen",
"badgeDone": "Gesichert",
"keyId": "Aktive Schlüssel-ID",
"fingerprint": "Fingerabdruck",
"whatIsLost": "Bewahre eine Kopie des Schlüssels außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Wird der Server mit einem anderen Schlüssel neu installiert, nimmt HealthLog den Betrieb erst wieder auf, wenn der ursprüngliche Schlüssel zurück ist.",
"fingerprintHint": "Der Fingerabdruck sind die ersten 12 Zeichen des SHA-256-Hashes des Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, damit du ihn mit deiner Kopie vergleichen kannst.",
"checkLabel": "Meine Kopie prüfen",
"checkButton": "Prüfen",
"checkNote": "Der eingefügte Schlüssel wird mit dem aktiven Schlüssel verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
"checkInsecureNote": "Diese Seite läuft nicht über https://, also geht der eingefügte Schlüssel unverschlüsselt über das Netz, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
"checkMatches": "Deine Kopie passt zum aktiven Schlüssel.",
"checkNoMatch": "Deine Kopie passt nicht zum aktiven Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
"confirmButton": "Ich habe diesen Schlüssel gesichert",
"confirmed": "Schlüsselsicherung bestätigt.",
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
"stale": "Der Schlüssel des Servers hat sich geändert, seit diese Seite geladen wurde. Prüfe die neue Schlüssel-ID und den Fingerabdruck und bestätige dann erneut.",
"confirmedAt": "Bestätigt am {date}.",
"confirmedAtBy": "Bestätigt am {date} von {who}.",
"dueAgain": "Für Schlüssel {keyId} wurde eine Sicherung bestätigt, aber der Server nutzt jetzt einen anderen Schlüssel. Sichere den aktuellen.",
"tileLabel": "Sicherung des Verschlüsselungsschlüssels",
"tileDue": "Nicht bestätigt",
"tileDone": "Bestätigt",
"platform": {
"composeLabel": "Compose",
"composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "Bearbeite im Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
"portainerLabel": "Portainer",
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY aus seinen Umgebungsvariablen."
}
},
"keyMismatch": {
"title": "HealthLog kann seine Daten nicht öffnen",
"body": "Der Verschlüsselungsschlüssel, mit dem dieser Server gestartet wurde, ist nicht der, mit dem seine Datenbank geschrieben wurde. Zum Schutz deiner Daten liefert HealthLog nichts aus, bis das behoben ist.",
"fixTitle": "So behebst du es",
"fixRestore": "Trage den ursprünglichen ENCRYPTION_KEY (oder den Eintrag in ENCRYPTION_KEYS) wieder in die Servereinstellungen ein.",
"fixDatabase": "Oder lass DATABASE_URL auf die Datenbank zeigen, die zu diesem Schlüssel gehört.",
"fixFresh": "Oder starte neu mit einer leeren Datenbank. Die alten Daten und ihre Sicherungen lassen sich nur mit dem alten Schlüssel lesen.",
"restartNote": "Starte HealthLog nach der Änderung neu.",
"codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}."
}
},
"achievements": {
+63
View File
@@ -489,6 +489,13 @@
"action": "Confirm",
"failed": "That did not work. Please try again.",
"nothingToConfirm": "There is nothing to confirm here. Start the connection again from your assistant."
},
"insecureTransport": {
"title": "Signing in cannot complete on this page",
"body": "This server only sets its sign-in cookie over https://, but this page was opened over http://. The browser would drop the cookie, so signing in is switched off here.",
"fixLan": "Server on a home network without TLS: set SESSION_COOKIE_SECURE=false in its settings and restart it.",
"fixHttps": "Server behind an HTTPS proxy: open the https:// address instead, and make sure the proxy forwards X-Forwarded-Proto: https.",
"localhostNote": "This page is on http://localhost while the server sets its sign-in cookie for https:// only. Most browsers accept that on localhost; if signing in keeps returning here, set SESSION_COOKIE_SECURE=false."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Back up your encryption key",
"body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.",
"action": "Open the step"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "This part of the page could not be shown",
"description": "The rest of the page still works; try again or reload if it keeps failing."
},
"keyBackup": {
"title": "Back up your encryption key",
"description": "Without a copy of this key, your encrypted data and this server's backups cannot be read again.",
"loading": "Loading the key backup status…",
"loadError": "Could not load the key backup status.",
"badgeDue": "To do",
"badgeDone": "Backed up",
"keyId": "Active key id",
"fingerprint": "Fingerprint",
"whatIsLost": "Keep a copy of the key somewhere outside this server, for example in a password manager. If the server is reinstalled with a different key, HealthLog refuses to start serving until the original key is back.",
"fingerprintHint": "The fingerprint is the first 12 characters of the SHA-256 hash of the key. It identifies the key without revealing it, so you can compare it with your copy.",
"checkLabel": "Check my copy",
"checkButton": "Check",
"checkNote": "The key you paste is compared with the active key and then discarded. It is never stored or logged.",
"checkInsecureNote": "This page is not served over https://, so the key you paste crosses the network unencrypted, just like your password does. It is compared and discarded, never stored or logged.",
"checkMatches": "Your copy matches the active key.",
"checkNoMatch": "Your copy does not match the active key. Check that you copied the whole value.",
"checkFailed": "The check failed.",
"confirmButton": "I have backed up this key",
"confirmed": "Key backup confirmed.",
"confirmFailed": "Could not save the confirmation.",
"stale": "The server's key changed since this page loaded. Check the new key id and fingerprint, then confirm again.",
"confirmedAt": "Confirmed on {date}.",
"confirmedAtBy": "Confirmed on {date} by {who}.",
"dueAgain": "A backup was confirmed for key {keyId}, but the server now uses another key. Back up the current one.",
"tileLabel": "Encryption key backup",
"tileDue": "Not confirmed",
"tileDone": "Confirmed",
"platform": {
"composeLabel": "Compose",
"composeWhere": "The key is ENCRYPTION_KEY (or ENCRYPTION_KEYS) in the .env file next to docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Open Apps, select HealthLog, choose Edit and copy ENCRYPTION_KEY from the environment settings.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY from its Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY from the template, or from the .env of your Compose Manager stack.",
"portainerLabel": "Portainer",
"portainerWhere": "Open the HealthLog stack in Portainer and copy ENCRYPTION_KEY from its environment variables."
}
},
"keyMismatch": {
"title": "HealthLog cannot open its data",
"body": "The encryption key this server was started with is not the key its database was written with. To protect your data, HealthLog does not serve anything until this is fixed.",
"fixTitle": "How to fix it",
"fixRestore": "Put the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry) back into the server's settings.",
"fixDatabase": "Or point DATABASE_URL at the database that belongs to this key.",
"fixFresh": "Or start fresh with an empty database. The old data and its backups can only be read with the old key.",
"restartNote": "Restart HealthLog after the change.",
"codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}."
}
},
"achievements": {
+63
View File
@@ -489,6 +489,13 @@
"action": "Confirmar",
"failed": "No ha funcionado. Inténtalo de nuevo.",
"nothingToConfirm": "Aquí no hay nada que confirmar. Vuelve a iniciar la conexión desde tu asistente."
},
"insecureTransport": {
"title": "No se puede completar el inicio de sesión en esta página",
"body": "Este servidor solo establece su cookie de sesión por https://, pero esta página se abrió por http://. El navegador descartaría la cookie, así que el inicio de sesión está desactivado aquí.",
"fixLan": "Servidor en una red doméstica sin TLS: pon SESSION_COOKIE_SECURE=false en su configuración y reinícialo.",
"fixHttps": "Servidor detrás de un proxy HTTPS: abre la dirección https:// y asegúrate de que el proxy reenvía X-Forwarded-Proto: https.",
"localhostNote": "Esta página está en http://localhost y el servidor solo establece su cookie de sesión para https://. La mayoría de los navegadores lo aceptan en localhost; si vuelves aquí tras iniciar sesión, pon SESSION_COOKIE_SECURE=false."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Haz una copia de tu clave de cifrado",
"body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.",
"action": "Abrir el paso"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "Esta parte de la página no se pudo mostrar",
"description": "El resto de la página sigue funcionando; inténtalo de nuevo o recarga si el fallo continúa."
},
"keyBackup": {
"title": "Haz una copia de tu clave de cifrado",
"description": "Sin una copia de esta clave, tus datos cifrados y las copias de este servidor no se podrán volver a leer.",
"loading": "Cargando el estado de la copia de la clave…",
"loadError": "No se pudo cargar el estado de la copia de la clave.",
"badgeDue": "Pendiente",
"badgeDone": "Guardada",
"keyId": "ID de la clave activa",
"fingerprint": "Huella",
"whatIsLost": "Guarda una copia de la clave fuera de este servidor, por ejemplo en un gestor de contraseñas. Si el servidor se reinstala con otra clave, HealthLog no vuelve a funcionar hasta que la clave original esté de vuelta.",
"fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de la clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.",
"checkLabel": "Comprobar mi copia",
"checkButton": "Comprobar",
"checkNote": "La clave que pegues se compara con la clave activa y después se descarta. Nunca se guarda ni se registra.",
"checkInsecureNote": "Esta página no se sirve por https://, así que la clave que pegues viaja por la red sin cifrar, igual que tu contraseña. Se compara y se descarta, nunca se guarda ni se registra.",
"checkMatches": "Tu copia coincide con la clave activa.",
"checkNoMatch": "Tu copia no coincide con la clave activa. Comprueba que copiaste el valor completo.",
"checkFailed": "La comprobación ha fallado.",
"confirmButton": "He guardado esta clave",
"confirmed": "Copia de la clave confirmada.",
"confirmFailed": "No se pudo guardar la confirmación.",
"stale": "La clave del servidor cambió desde que se cargó esta página. Revisa el nuevo ID y la huella, y vuelve a confirmar.",
"confirmedAt": "Confirmada el {date}.",
"confirmedAtBy": "Confirmada el {date} por {who}.",
"dueAgain": "Se confirmó una copia de la clave {keyId}, pero el servidor usa ahora otra clave. Guarda una copia de la actual.",
"tileLabel": "Copia de la clave de cifrado",
"tileDue": "Sin confirmar",
"tileDone": "Confirmada",
"platform": {
"composeLabel": "Compose",
"composeWhere": "La clave es ENCRYPTION_KEY (o ENCRYPTION_KEYS) en el archivo .env junto a docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Abre Apps, selecciona HealthLog, elige Editar y copia ENCRYPTION_KEY de las variables de entorno.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY de sus Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY de la plantilla, o del .env de tu stack de Compose Manager.",
"portainerLabel": "Portainer",
"portainerWhere": "Abre el stack de HealthLog en Portainer y copia ENCRYPTION_KEY de sus variables de entorno."
}
},
"keyMismatch": {
"title": "HealthLog no puede abrir sus datos",
"body": "La clave de cifrado con la que se inició este servidor no es la clave con la que se escribió su base de datos. Para proteger tus datos, HealthLog no sirve nada hasta que se corrija.",
"fixTitle": "Cómo corregirlo",
"fixRestore": "Vuelve a poner la ENCRYPTION_KEY original (o la entrada de ENCRYPTION_KEYS) en la configuración del servidor.",
"fixDatabase": "O haz que DATABASE_URL apunte a la base de datos que corresponde a esta clave.",
"fixFresh": "O empieza de cero con una base de datos vacía. Los datos antiguos y sus copias solo se pueden leer con la clave antigua.",
"restartNote": "Reinicia HealthLog después del cambio.",
"codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}."
}
},
"achievements": {
+63
View File
@@ -489,6 +489,13 @@
"action": "Confirmer",
"failed": "Cela n'a pas fonctionné. Veuillez réessayer.",
"nothingToConfirm": "Il n'y a rien à confirmer ici. Relancez la connexion depuis votre assistant."
},
"insecureTransport": {
"title": "La connexion ne peut pas aboutir sur cette page",
"body": "Ce serveur ne pose son cookie de connexion qu'en https://, mais cette page a été ouverte en http://. Le navigateur rejetterait le cookie, la connexion est donc désactivée ici.",
"fixLan": "Serveur sur un réseau domestique sans TLS : mets SESSION_COOKIE_SECURE=false dans ses paramètres et redémarre-le.",
"fixHttps": "Serveur derrière un proxy HTTPS : ouvre plutôt l'adresse https:// et vérifie que le proxy transmet X-Forwarded-Proto: https.",
"localhostNote": "Cette page est sur http://localhost alors que le serveur ne pose son cookie de connexion qu'en https://. La plupart des navigateurs l'acceptent sur localhost ; si tu reviens ici après la connexion, mets SESSION_COOKIE_SECURE=false."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Sauvegarde ta clé de chiffrement",
"body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.",
"action": "Ouvrir l'étape"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "Cette partie de la page n’a pas pu s’afficher",
"description": "Le reste de la page fonctionne ; réessayez ou rechargez si le problème persiste."
},
"keyBackup": {
"title": "Sauvegarde ta clé de chiffrement",
"description": "Sans copie de cette clé, tes données chiffrées et les sauvegardes de ce serveur ne pourront plus être lues.",
"loading": "Chargement de l'état de la sauvegarde de la clé…",
"loadError": "Impossible de charger l'état de la sauvegarde de la clé.",
"badgeDue": "À faire",
"badgeDone": "Sauvegardée",
"keyId": "ID de la clé active",
"fingerprint": "Empreinte",
"whatIsLost": "Garde une copie de la clé en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Si le serveur est réinstallé avec une autre clé, HealthLog ne sert plus rien tant que la clé d'origine n'est pas revenue.",
"fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 de la clé. Elle identifie la clé sans la révéler, pour que tu puisses la comparer à ta copie.",
"checkLabel": "Vérifier ma copie",
"checkButton": "Vérifier",
"checkNote": "La clé collée est comparée à la clé active puis oubliée. Elle n'est jamais stockée ni journalisée.",
"checkInsecureNote": "Cette page n'est pas servie en https://, donc la clé collée traverse le réseau en clair, comme ton mot de passe. Elle est comparée puis oubliée, jamais stockée ni journalisée.",
"checkMatches": "Ta copie correspond à la clé active.",
"checkNoMatch": "Ta copie ne correspond pas à la clé active. Vérifie que tu as copié toute la valeur.",
"checkFailed": "La vérification a échoué.",
"confirmButton": "J'ai sauvegardé cette clé",
"confirmed": "Sauvegarde de la clé confirmée.",
"confirmFailed": "Impossible d'enregistrer la confirmation.",
"stale": "La clé du serveur a changé depuis le chargement de cette page. Vérifie le nouvel ID et l'empreinte, puis confirme à nouveau.",
"confirmedAt": "Confirmée le {date}.",
"confirmedAtBy": "Confirmée le {date} par {who}.",
"dueAgain": "Une sauvegarde a été confirmée pour la clé {keyId}, mais le serveur utilise maintenant une autre clé. Sauvegarde la clé actuelle.",
"tileLabel": "Sauvegarde de la clé de chiffrement",
"tileDue": "Non confirmée",
"tileDone": "Confirmée",
"platform": {
"composeLabel": "Compose",
"composeWhere": "La clé est ENCRYPTION_KEY (ou ENCRYPTION_KEYS) dans le fichier .env à côté de docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Ouvre Apps, sélectionne HealthLog, choisis Modifier et copie ENCRYPTION_KEY dans les variables d'environnement.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY depuis ses Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "Dans l'onglet Docker, modifie le conteneur HealthLog et copie ENCRYPTION_KEY depuis le modèle, ou depuis le .env de ta pile Compose Manager.",
"portainerLabel": "Portainer",
"portainerWhere": "Ouvre la pile HealthLog dans Portainer et copie ENCRYPTION_KEY depuis ses variables d'environnement."
}
},
"keyMismatch": {
"title": "HealthLog ne peut pas ouvrir ses données",
"body": "La clé de chiffrement avec laquelle ce serveur a démarré n'est pas celle avec laquelle sa base de données a été écrite. Pour protéger tes données, HealthLog ne sert rien tant que ce n'est pas corrigé.",
"fixTitle": "Comment corriger",
"fixRestore": "Remets la ENCRYPTION_KEY d'origine (ou l'entrée de ENCRYPTION_KEYS) dans les paramètres du serveur.",
"fixDatabase": "Ou fais pointer DATABASE_URL vers la base de données qui correspond à cette clé.",
"fixFresh": "Ou repars de zéro avec une base vide. Les anciennes données et leurs sauvegardes ne se lisent qu'avec l'ancienne clé.",
"restartNote": "Redémarre HealthLog après la modification.",
"codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}."
}
},
"achievements": {
+63
View File
@@ -489,6 +489,13 @@
"action": "Conferma",
"failed": "Non ha funzionato. Riprova.",
"nothingToConfirm": "Qui non c'è nulla da confermare. Avvia di nuovo la connessione dal tuo assistente."
},
"insecureTransport": {
"title": "L'accesso non può essere completato su questa pagina",
"body": "Questo server imposta il cookie di accesso solo tramite https://, ma questa pagina è stata aperta tramite http://. Il browser scarterebbe il cookie, quindi l'accesso qui è disattivato.",
"fixLan": "Server in una rete domestica senza TLS: imposta SESSION_COOKIE_SECURE=false nelle sue impostazioni e riavvialo.",
"fixHttps": "Server dietro un proxy HTTPS: apri invece l'indirizzo https:// e assicurati che il proxy inoltri X-Forwarded-Proto: https.",
"localhostNote": "Questa pagina è su http://localhost mentre il server imposta il cookie di accesso solo per https://. La maggior parte dei browser lo accetta su localhost; se dopo l'accesso torni qui, imposta SESSION_COOKIE_SECURE=false."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Fai un backup della tua chiave di cifratura",
"body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.",
"action": "Apri il passaggio"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "Questa parte della pagina non è stata mostrata",
"description": "Il resto della pagina funziona; riprova o ricarica se il problema continua."
},
"keyBackup": {
"title": "Fai un backup della tua chiave di cifratura",
"description": "Senza una copia di questa chiave, i tuoi dati cifrati e i backup di questo server non saranno più leggibili.",
"loading": "Caricamento dello stato del backup della chiave…",
"loadError": "Impossibile caricare lo stato del backup della chiave.",
"badgeDue": "Da fare",
"badgeDone": "Salvata",
"keyId": "ID della chiave attiva",
"fingerprint": "Impronta",
"whatIsLost": "Conserva una copia della chiave fuori da questo server, per esempio in un gestore di password. Se il server viene reinstallato con un'altra chiave, HealthLog non torna in servizio finché la chiave originale non è di nuovo al suo posto.",
"fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 della chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.",
"checkLabel": "Verifica la mia copia",
"checkButton": "Verifica",
"checkNote": "La chiave incollata viene confrontata con quella attiva e poi scartata. Non viene mai salvata né registrata.",
"checkInsecureNote": "Questa pagina non è servita tramite https://, quindi la chiave incollata attraversa la rete in chiaro, come la tua password. Viene confrontata e scartata, mai salvata né registrata.",
"checkMatches": "La tua copia corrisponde alla chiave attiva.",
"checkNoMatch": "La tua copia non corrisponde alla chiave attiva. Controlla di aver copiato l'intero valore.",
"checkFailed": "La verifica non è riuscita.",
"confirmButton": "Ho salvato questa chiave",
"confirmed": "Backup della chiave confermato.",
"confirmFailed": "Impossibile salvare la conferma.",
"stale": "La chiave del server è cambiata da quando questa pagina è stata caricata. Controlla il nuovo ID e l'impronta, poi conferma di nuovo.",
"confirmedAt": "Confermato il {date}.",
"confirmedAtBy": "Confermato il {date} da {who}.",
"dueAgain": "È stato confermato un backup per la chiave {keyId}, ma ora il server usa un'altra chiave. Salva quella attuale.",
"tileLabel": "Backup della chiave di cifratura",
"tileDue": "Non confermato",
"tileDone": "Confermato",
"platform": {
"composeLabel": "Compose",
"composeWhere": "La chiave è ENCRYPTION_KEY (o ENCRYPTION_KEYS) nel file .env accanto a docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Apri Apps, seleziona HealthLog, scegli Modifica e copia ENCRYPTION_KEY dalle variabili d'ambiente.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY dalle sue Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY dal modello, oppure dal .env del tuo stack di Compose Manager.",
"portainerLabel": "Portainer",
"portainerWhere": "Apri lo stack di HealthLog in Portainer e copia ENCRYPTION_KEY dalle sue variabili d'ambiente."
}
},
"keyMismatch": {
"title": "HealthLog non riesce ad aprire i suoi dati",
"body": "La chiave di cifratura con cui è stato avviato questo server non è quella con cui è stato scritto il suo database. Per proteggere i tuoi dati, HealthLog non serve nulla finché il problema non è risolto.",
"fixTitle": "Come risolvere",
"fixRestore": "Rimetti la ENCRYPTION_KEY originale (o la voce in ENCRYPTION_KEYS) nelle impostazioni del server.",
"fixDatabase": "Oppure fai puntare DATABASE_URL al database che appartiene a questa chiave.",
"fixFresh": "Oppure ricomincia con un database vuoto. I vecchi dati e i loro backup si leggono solo con la vecchia chiave.",
"restartNote": "Riavvia HealthLog dopo la modifica.",
"codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}."
}
},
"achievements": {
+63
View File
@@ -489,6 +489,13 @@
"action": "확인",
"failed": "확인하지 못했어요. 다시 시도해 주세요.",
"nothingToConfirm": "여기에서 확인할 것이 없어요. 어시스턴트에서 연결을 다시 시작해 주세요."
},
"insecureTransport": {
"title": "이 페이지에서는 로그인을 완료할 수 없습니다",
"body": "이 서버는 로그인 쿠키를 https://로만 설정하는데, 이 페이지는 http://로 열렸습니다. 브라우저가 쿠키를 버리므로 여기서는 로그인이 꺼져 있습니다.",
"fixLan": "TLS 없이 홈 네트워크에서 쓰는 서버: 설정에서 SESSION_COOKIE_SECURE=false로 지정하고 다시 시작하세요.",
"fixHttps": "HTTPS 프록시 뒤의 서버: 대신 https:// 주소를 열고, 프록시가 X-Forwarded-Proto: https를 전달하는지 확인하세요.",
"localhostNote": "이 페이지는 http://localhost에 있지만 서버는 로그인 쿠키를 https://에만 설정합니다. 대부분의 브라우저는 localhost에서 이를 허용합니다. 로그인 후 계속 여기로 돌아온다면 SESSION_COOKIE_SECURE=false로 지정하세요."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "암호화 키를 백업하세요",
"body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.",
"action": "단계 열기"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "페이지의 이 부분을 표시할 수 없습니다",
"description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요."
},
"keyBackup": {
"title": "암호화 키를 백업하세요",
"description": "이 키의 사본이 없으면 암호화된 데이터와 이 서버의 백업을 다시는 읽을 수 없습니다.",
"loading": "키 백업 상태를 불러오는 중…",
"loadError": "키 백업 상태를 불러오지 못했습니다.",
"badgeDue": "해야 함",
"badgeDone": "백업됨",
"keyId": "활성 키 ID",
"fingerprint": "지문",
"whatIsLost": "키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 서버가 다른 키로 다시 설치되면 HealthLog는 원래 키가 돌아올 때까지 서비스를 시작하지 않습니다.",
"fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.",
"checkLabel": "내 사본 확인",
"checkButton": "확인",
"checkNote": "붙여넣은 키는 활성 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.",
"checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.",
"checkMatches": "사본이 활성 키와 일치합니다.",
"checkNoMatch": "사본이 활성 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.",
"checkFailed": "확인하지 못했습니다.",
"confirmButton": "이 키를 백업했습니다",
"confirmed": "키 백업이 확인되었습니다.",
"confirmFailed": "확인을 저장하지 못했습니다.",
"stale": "이 페이지를 연 뒤 서버의 키가 바뀌었습니다. 새 키 ID와 지문을 확인한 뒤 다시 확인하세요.",
"confirmedAt": "{date}에 확인됨.",
"confirmedAtBy": "{date}에 {who}님이 확인함.",
"dueAgain": "키 {keyId}의 백업이 확인되었지만 서버는 이제 다른 키를 사용합니다. 현재 키를 백업하세요.",
"tileLabel": "암호화 키 백업",
"tileDue": "확인 안 됨",
"tileDone": "확인됨",
"platform": {
"composeLabel": "Compose",
"composeWhere": "키는 docker-compose.yml 옆 .env 파일의 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS)입니다.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누른 뒤 환경 설정에서 ENCRYPTION_KEY를 복사하세요.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY를 복사하세요.",
"unraidLabel": "Unraid",
"unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY를 복사하세요.",
"portainerLabel": "Portainer",
"portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY를 복사하세요."
}
},
"keyMismatch": {
"title": "HealthLog가 데이터를 열 수 없습니다",
"body": "이 서버를 시작한 암호화 키가 데이터베이스를 기록할 때 쓴 키와 다릅니다. 데이터를 보호하기 위해 이 문제가 해결될 때까지 HealthLog는 아무것도 제공하지 않습니다.",
"fixTitle": "해결 방법",
"fixRestore": "원래 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS 항목)를 서버 설정에 다시 넣으세요.",
"fixDatabase": "또는 DATABASE_URL이 이 키에 맞는 데이터베이스를 가리키게 하세요.",
"fixFresh": "또는 빈 데이터베이스로 새로 시작하세요. 이전 데이터와 백업은 이전 키로만 읽을 수 있습니다.",
"restartNote": "변경 후 HealthLog를 다시 시작하세요.",
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
}
},
"achievements": {
+63
View File
@@ -489,6 +489,13 @@
"action": "Potwierdź",
"failed": "Nie udało się. Spróbuj ponownie.",
"nothingToConfirm": "Nie ma tu nic do potwierdzenia. Rozpocznij połączenie ponownie w swoim asystencie."
},
"insecureTransport": {
"title": "Na tej stronie nie da się dokończyć logowania",
"body": "Ten serwer ustawia ciasteczko logowania tylko przez https://, a ta strona została otwarta przez http://. Przeglądarka odrzuciłaby ciasteczko, więc logowanie jest tu wyłączone.",
"fixLan": "Serwer w sieci domowej bez TLS: ustaw SESSION_COOKIE_SECURE=false w jego ustawieniach i uruchom go ponownie.",
"fixHttps": "Serwer za proxy HTTPS: otwórz zamiast tego adres https:// i upewnij się, że proxy przekazuje X-Forwarded-Proto: https.",
"localhostNote": "Ta strona działa na http://localhost, a serwer ustawia ciasteczko logowania tylko dla https://. Większość przeglądarek akceptuje to na localhost; jeśli po zalogowaniu wracasz tutaj, ustaw SESSION_COOKIE_SECURE=false."
}
},
"dashboard": {
@@ -637,6 +644,11 @@
"mood": "/5",
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Zrób kopię klucza szyfrowania",
"body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.",
"action": "Otwórz krok"
}
},
"measurements": {
@@ -7579,6 +7591,57 @@
"cardError": {
"title": "Nie udało się wyświetlić tej części strony",
"description": "Reszta strony działa; spróbuj ponownie lub odśwież, jeśli błąd się powtarza."
},
"keyBackup": {
"title": "Zrób kopię klucza szyfrowania",
"description": "Bez kopii tego klucza Twoich zaszyfrowanych danych i kopii zapasowych z tego serwera nie da się już odczytać.",
"loading": "Wczytywanie stanu kopii klucza…",
"loadError": "Nie udało się wczytać stanu kopii klucza.",
"badgeDue": "Do zrobienia",
"badgeDone": "Zapisany",
"keyId": "ID aktywnego klucza",
"fingerprint": "Odcisk",
"whatIsLost": "Przechowuj kopię klucza poza tym serwerem, na przykład w menedżerze haseł. Jeśli serwer zostanie przeinstalowany z innym kluczem, HealthLog nie wznowi działania, dopóki oryginalny klucz nie wróci.",
"fingerprintHint": "Odcisk to pierwsze 12 znaków skrótu SHA-256 klucza. Rozpoznaje klucz, nie ujawniając go, więc możesz porównać go ze swoją kopią.",
"checkLabel": "Sprawdź moją kopię",
"checkButton": "Sprawdź",
"checkNote": "Wklejony klucz jest porównywany z aktywnym kluczem, a potem odrzucany. Nigdy nie jest zapisywany ani logowany.",
"checkInsecureNote": "Ta strona nie jest serwowana przez https://, więc wklejony klucz przechodzi przez sieć bez szyfrowania, tak jak Twoje hasło. Jest porównywany i odrzucany, nigdy nie jest zapisywany ani logowany.",
"checkMatches": "Twoja kopia pasuje do aktywnego klucza.",
"checkNoMatch": "Twoja kopia nie pasuje do aktywnego klucza. Sprawdź, czy skopiowano całą wartość.",
"checkFailed": "Sprawdzenie nie powiodło się.",
"confirmButton": "Mam kopię tego klucza",
"confirmed": "Kopia klucza potwierdzona.",
"confirmFailed": "Nie udało się zapisać potwierdzenia.",
"stale": "Klucz serwera zmienił się od wczytania tej strony. Sprawdź nowe ID i odcisk, a potem potwierdź ponownie.",
"confirmedAt": "Potwierdzono {date}.",
"confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.",
"dueAgain": "Potwierdzono kopię klucza {keyId}, ale serwer używa teraz innego klucza. Zrób kopię obecnego.",
"tileLabel": "Kopia klucza szyfrowania",
"tileDue": "Niepotwierdzona",
"tileDone": "Potwierdzona",
"platform": {
"composeLabel": "Compose",
"composeWhere": "Klucz to ENCRYPTION_KEY (lub ENCRYPTION_KEYS) w pliku .env obok docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Otwórz Apps, wybierz HealthLog, kliknij Edytuj i skopiuj ENCRYPTION_KEY ze zmiennych środowiskowych.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY z jej Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.",
"portainerLabel": "Portainer",
"portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY z jego zmiennych środowiskowych."
}
},
"keyMismatch": {
"title": "HealthLog nie może otworzyć swoich danych",
"body": "Klucz szyfrowania, z którym uruchomiono ten serwer, nie jest kluczem, którym zapisano jego bazę danych. Aby chronić Twoje dane, HealthLog niczego nie udostępnia, dopóki to nie zostanie naprawione.",
"fixTitle": "Jak to naprawić",
"fixRestore": "Przywróć oryginalny ENCRYPTION_KEY (lub wpis w ENCRYPTION_KEYS) w ustawieniach serwera.",
"fixDatabase": "Albo ustaw DATABASE_URL na bazę danych, która należy do tego klucza.",
"fixFresh": "Albo zacznij od nowa z pustą bazą. Stare dane i ich kopie można odczytać tylko starym kluczem.",
"restartNote": "Po zmianie uruchom HealthLog ponownie.",
"codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}."
}
},
"achievements": {
@@ -0,0 +1,20 @@
-- The encryption key backup step and the boot check that the configured key
-- opens this database.
--
-- Four nullable columns on the instance settings row record which key the
-- operator confirmed a backup of (id and fingerprint, never the key). The
-- canary table holds one known plaintext per key id, sealed under that key; a
-- process whose key cannot open it refuses to serve. Both are written at
-- runtime, so the migration only creates the shapes.
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_at" TIMESTAMP(3);
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_key_id" TEXT;
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_fingerprint" TEXT;
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_by_user_id" TEXT;
CREATE TABLE IF NOT EXISTS "encryption_key_canaries" (
"key_id" TEXT NOT NULL,
"ciphertext" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encryption_key_canaries_pkey" PRIMARY KEY ("key_id")
);
+31
View File
@@ -4880,9 +4880,40 @@ model AppSettings {
documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes")
documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes")
// The operator's confirmation that the encryption key is backed up, bound to
// the key it was given for: the active key id and its fingerprint (the first
// twelve hex characters of SHA-256 over the raw key bytes). A rotation or a
// re-keyed install changes either one, and the step is due again. Never the
// key itself. Written by `POST /api/admin/encryption/key-backup/confirm`;
// consumer: `readKeyBackupStatus` (the admin step, the overview tile and the
// dashboard banner for admins).
encryptionKeyBackupConfirmedAt DateTime? @map("encryption_key_backup_confirmed_at")
encryptionKeyBackupConfirmedKeyId String? @map("encryption_key_backup_confirmed_key_id")
encryptionKeyBackupConfirmedFingerprint String? @map("encryption_key_backup_confirmed_fingerprint")
/// Plain id, not a relation: the instance row must not become user-scoped,
/// and a deleted admin leaves the confirmation standing. Consumer: the admin
/// step names who confirmed when the account still exists.
encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id")
@@map("app_settings")
}
/// One known plaintext per configured encryption key id, sealed under that
/// key. At boot every configured id is checked against its row: a row that no
/// longer opens means the key in the environment is not the key this database
/// was written with, and the process refuses to serve instead of answering
/// with random errors. A missing row is written (`ON CONFLICT DO NOTHING`).
/// Deliberately outside key rotation: a canary belongs to its key id, and
/// re-sealing it under the active key would make every id's check test the
/// active key. Written and read only by `src/lib/crypto/canary.ts`.
model EncryptionKeyCanary {
keyId String @id @map("key_id")
ciphertext String
createdAt DateTime @default(now()) @map("created_at")
@@map("encryption_key_canaries")
}
// ─── Audit Log ─────────────────────────────────────────────
model AuditLog {
+5
View File
@@ -322,6 +322,11 @@
"purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.",
"example": "false"
},
{
"name": "HEALTHLOG_PLATFORM",
"purpose": "Where this server's environment is configured (compose, truenas, coolify, unraid, portainer). Shown back to the admin: the \"Back up your encryption key\" step opens on that platform's instructions. Unset means compose.",
"example": "compose"
},
{
"name": "TRUST_PROXY_HOPS",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
@@ -114,3 +114,30 @@ describe("GET /api/auth/registration-status", () => {
expect(body.data.registrationEnabled).toBe(false);
});
});
describe("GET /api/auth/registration-status — sessionCookieSecure (#1097)", () => {
it("publishes whether the server issues Secure session cookies, on every branch", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
} as never);
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
let body = await (await callGet()).json();
expect(body.data.sessionCookieSecure).toBe(true);
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
body = await (await callGet()).json();
expect(body.data.sessionCookieSecure).toBe(false);
vi.mocked(prisma.appSettings.findUnique).mockRejectedValue(
new Error("db down"),
);
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
body = await (await callGet()).json();
expect(body.data).toEqual({
registrationEnabled: false,
sessionCookieSecure: true,
});
vi.unstubAllEnvs();
});
});
@@ -239,6 +239,18 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/encryption/key-backup/confirm": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup/verify": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
@@ -71,6 +71,11 @@ const MANIFEST_NAMES: string[] = (
* entry a lie rather than an inconvenience.
*/
const PUBLISHED_TO_USERS = [
{
name: "HEALTHLOG_PLATFORM",
shownBy:
"the admin's encryption key backup step, which opens on that platform",
},
{
name: "AUDIT_LOG_RETENTION_DAYS",
shownBy: "the shared-access activity window and the revoke dialog",
@@ -0,0 +1,73 @@
/**
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
*
* The admin states that the active encryption key is backed up. The body
* names the key the admin was shown; when that is no longer the active key
* (rotated, or the server restarted with another key since the page loaded)
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
* No step-up: confirming is a low-risk write that reveals nothing.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
confirmKeyBackup,
KEY_BACKUP_STALE_CODE,
} from "@/lib/crypto/key-backup";
export const dynamic = "force-dynamic";
const confirmSchema = z
.object({
keyId: z.string().min(1).max(32),
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = confirmSchema.safeParse(body.data);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const result = await confirmKeyBackup({
keyId: parsed.data.keyId,
fingerprint: parsed.data.fingerprint,
userId: user.id,
});
if (!result.ok) {
annotate({ meta: { key_backup_stale: true } });
return apiError(
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
409,
{
errorCode: KEY_BACKUP_STALE_CODE,
activeKeyId: result.activeKeyId,
fingerprint: result.fingerprint,
},
);
}
await auditLog("encryption.keyBackup.confirmed", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
keyId: result.status.activeKeyId,
fingerprint: result.status.fingerprint,
},
});
return apiSuccess(result.status);
});
@@ -0,0 +1,22 @@
/**
* GET /api/admin/encryption/key-backup
*
* Whether the "Back up your encryption key" step is due, for the admin step,
* the overview tile and the dashboard banner. A light read (one settings row),
* separate from `/status`, which walks the whole encrypted corpus. Key id and
* fingerprint only, never key material. Cookie-only admin.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
const status = await readKeyBackupStatus();
annotate({ meta: { key_backup_due: status.due } });
return apiSuccess(status);
});
@@ -0,0 +1,64 @@
/**
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
*
* "Check my copy": the admin pastes the backed-up key and learns whether it
* is the active key. The value is compared in memory (constant time on the
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
* the Zod issue list is not returned for this body (a validation failure is a
* plain 422), and the field name is on the observability denylist.
*
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
* per admin is there to keep the route from being anyone's busy loop. Over
* plain HTTP the value crosses the network in clear, as the admin's password
* does; the client says so before sending.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
export const dynamic = "force-dynamic";
const KEY_BACKUP_VERIFY_LIMIT = 5;
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
const verifySchema = z
.object({ encryptionKey: z.string().min(1).max(256) })
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
const limit = await checkRateLimit(
`key-backup-verify:${user.id}`,
KEY_BACKUP_VERIFY_LIMIT,
KEY_BACKUP_VERIFY_WINDOW_MS,
);
if (!limit.allowed) {
return apiError("Too many checks. Try again in a minute.", 429);
}
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = verifySchema.safeParse(body.data);
if (!parsed.success) {
// Deliberately not `returnAllZodIssues`: nothing derived from this body
// goes back over the wire.
return apiError("Paste the key as 64 hex characters.", 422);
}
const activeKeyId = getActiveKeyId();
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
annotate({ meta: { key_backup_verify_matches: matches } });
await auditLog("encryption.keyBackup.verified", {
userId: user.id,
details: { keyId: activeKeyId, matches },
});
return apiSuccess({ matches, keyId: activeKeyId });
});
@@ -17,6 +17,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
import { annotate } from "@/lib/logging/context";
import { getConfiguredKeyIds } from "@/lib/crypto";
import {
@@ -213,6 +214,7 @@ export const GET = apiHandler(async () => {
const rotation = await readRotationState();
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
const keyBackup = await readKeyBackupStatus();
annotate({
meta: {
@@ -233,6 +235,8 @@ export const GET = apiHandler(async () => {
columns: scan.columns,
rotation,
backups,
// The "Back up your encryption key" step, bound to the active key.
keyBackup,
// Every row rotated AND no backup still needs a retired key: the one
// state in which dropping an old key loses nothing. Copies that recorded
// no key ids count against it, because they may hold any key that
+8
View File
@@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
@@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// A browser page on plain http:// with Secure-only cookies cannot keep the
// session. Refuse before the password is verified (the account throttle
// never counts it) and before an MFA ticket is minted.
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
+6
View File
@@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa";
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const dynamic = "force-dynamic";
@@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// Before the ticket is loaded, an attempt reserved, or a one-time code
// spent: a sign-in whose cookie cannot stick must not consume either.
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = mfaVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
@@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const dynamic = "force-dynamic";
@@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// Before the ticket is loaded or an attempt reserved (see mfa/verify).
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
+19
View File
@@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { transportVerdict } from "@/lib/auth/client-transport";
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
import {
@@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
return loginError("oidc_disabled");
}
// The SSO button states the page's transport. A browser on plain http://
// with Secure-only cookies would come back from the provider and drop the
// session cookie, so it is stopped here instead of after the round trip.
if (!isNative) {
const clientProto = req.nextUrl.searchParams.get("client_proto");
const clientHost = req.nextUrl.searchParams.get("client_host") ?? "";
if (
(clientProto === "http" || clientProto === "https") &&
transportVerdict(shouldEmitSecureCookie(), {
protocol: clientProto,
host: clientHost,
}) === "blocked"
) {
annotate({ meta: { reason: "insecure_transport" } });
return loginError("insecure_transport");
}
}
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:login",
@@ -1,10 +1,11 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isOidcOnly } from "@/lib/auth/oidc";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const POST = apiHandler(async (request: Request) => {
// OIDC_ONLY must block passkey login too, not just password login.
@@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => {
);
}
// The body is optional (the web client sends its page transport; nothing
// else needs to send anything). A missing or unreadable body is no body, as
// before; only a stated plain-http page refuses, before a challenge exists.
if (request.headers.get("content-type")?.includes("application/json")) {
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
}
const { options, challengeId } = await createAuthenticationOptions();
annotate({ action: { name: "auth.passkey.login-options" } });
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
export const dynamic = "force-dynamic";
@@ -15,10 +16,16 @@ export const GET = apiHandler(async () => {
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
// Whether this server issues `Secure` session cookies. The login page
// compares it with its own scheme before any credential is sent.
sessionCookieSecure: shouldEmitSecureCookie(),
});
} catch {
// Fail closed on backend errors.
annotate({ action: { name: "auth.registration-status" } });
return apiSuccess({ registrationEnabled: false });
return apiSuccess({
registrationEnabled: false,
sessionCookieSecure: shouldEmitSecureCookie(),
});
}
});
@@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => {
expect(ready.body).toEqual({ status: "ok" });
});
});
describe("GET /api/health while the encryption key does not match", () => {
it("answers 503 with the reason, for everyone, even with every component up", async () => {
const { setKeyMismatchState } =
await import("@/lib/boot/key-mismatch-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
try {
const { response, body } = await health();
expect(response.status).toBe(503);
expect(body).toEqual({
status: "degraded",
reason: "encryption_key_mismatch",
});
} finally {
setKeyMismatchState(null);
}
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
});
+12 -1
View File
@@ -6,6 +6,10 @@ import { getSession } from "@/lib/auth/session";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
isKeyMismatch,
KEY_MISMATCH_HEALTH_REASON,
} from "@/lib/boot/key-mismatch-state";
export const dynamic = "force-dynamic";
@@ -22,7 +26,13 @@ export const GET = apiHandler(async () => {
const worker = getWorkerStatus();
const producerReady = getGlobalBoss() !== null;
// A process whose encryption key cannot open the database refuses every
// other route; the probe says so in a word every caller may see, because
// it is the one place a catalog install's status page reads.
const keyMismatch = isKeyMismatch();
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
const status =
!keyMismatch &&
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
@@ -40,6 +50,7 @@ export const GET = apiHandler(async () => {
return NextResponse.json(
{
status,
...(reason ? { reason } : {}),
timestamp: new Date().toISOString(),
database: dbOk ? "connected" : "disconnected",
worker: worker.running ? "running" : "stopped",
@@ -52,7 +63,7 @@ export const GET = apiHandler(async () => {
}
return NextResponse.json(
{ status },
{ status, ...(reason ? { reason } : {}) },
{ status: statusCode, headers: cacheHeaders },
);
});
+68 -15
View File
@@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
import {
InsecureTransportBanner,
useClientTransport,
} from "@/components/auth/insecure-transport-banner";
import { transportVerdict } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
import { loadDocument } from "@/lib/navigation/load-document";
export default function LoginPage() {
@@ -44,7 +52,8 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(() => {
const oidcError = searchParams.get("error");
if (!oidcError) return null;
// The SSO start refused a page on plain http://; the banner says why.
if (!oidcError || oidcError === "insecure_transport") return null;
const key: Record<string, string> = {
oidc_denied: "auth.oidc.errorDenied",
oidc_no_email: "auth.oidc.errorNoEmail",
@@ -71,22 +80,55 @@ export default function LoginPage() {
// standalone alert detached from the form.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const { data: registrationEnabled } = useQuery({
const { data: registrationStatus } = useQuery({
queryKey: queryKeys.authRegistrationStatus(),
queryFn: async () => {
try {
const data = await apiGet<{ registrationEnabled?: boolean }>(
"/api/auth/registration-status",
{ cache: "no-store" },
);
return Boolean(data?.registrationEnabled ?? true);
const data = await apiGet<{
registrationEnabled?: boolean;
sessionCookieSecure?: boolean;
}>("/api/auth/registration-status", { cache: "no-store" });
return {
registrationEnabled: Boolean(data?.registrationEnabled ?? true),
sessionCookieSecure: data?.sessionCookieSecure === true,
};
} catch {
// Fail open, exactly as the raw `if (!res.ok) return true` did.
return true;
// Fail open, exactly as the raw `if (!res.ok) return true` did; and
// no transport gate without the server's word on its cookies.
return { registrationEnabled: true, sessionCookieSecure: false };
}
},
staleTime: 60 * 1000,
});
const registrationEnabled = registrationStatus?.registrationEnabled;
// #1097 — a server that sets its sign-in cookie only over https:// cannot
// sign anyone in on a plain http:// page: the browser drops the cookie. Say
// so before a credential leaves the browser, and stop the sign-in controls.
// The server refuses the same requests (409) if they are sent anyway, and
// the SSO start sends the browser back here with `insecure_transport`.
const clientTransport = useClientTransport();
const [transportRefused, setTransportRefused] = useState(
() => searchParams.get("error") === "insecure_transport",
);
const verdict = transportRefused
? "blocked"
: transportVerdict(
registrationStatus?.sessionCookieSecure === true,
clientTransport,
);
const signInBlocked = verdict === "blocked";
function noteRefusal(err: unknown): boolean {
if (
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
) {
setTransportRefused(true);
return true;
}
return false;
}
const { data: oidcStatus } = useQuery({
queryKey: queryKeys.authOidcStatus(),
@@ -106,6 +148,10 @@ export default function LoginPage() {
function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
if (clientTransport) {
params.set("client_proto", clientTransport.protocol);
params.set("client_host", clientTransport.host);
}
loadDocument(`/api/auth/oidc/login?${params}`);
}
@@ -176,7 +222,7 @@ export default function LoginPage() {
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
}>("/api/auth/passkey/login-options", { clientTransport });
const credential = await startAuthentication({ optionsJSON: options });
@@ -189,6 +235,7 @@ export default function LoginPage() {
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
// Route rejections carry the envelope error verbatim; everything
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
if (err instanceof ApiError) {
@@ -220,7 +267,7 @@ export default function LoginPage() {
>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, clientTransport }),
});
if (meta?.mfaRequired && meta.mfaTicket) {
@@ -235,6 +282,7 @@ export default function LoginPage() {
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
setError(
err instanceof ApiError && err.message
? err.message
@@ -289,6 +337,9 @@ export default function LoginPage() {
useEffect(() => {
if (oidcStatus === undefined) return;
if (oidcStatus.only === true) return;
// Wait for the server's word on its cookies, and never arm the browser's
// credential prompt for a sign-in that cannot keep its cookie.
if (registrationStatus === undefined || signInBlocked) return;
if (autofillStarted.current) return;
autofillStarted.current = true;
let cancelled = false;
@@ -302,7 +353,7 @@ export default function LoginPage() {
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
}>("/api/auth/passkey/login-options", { clientTransport });
const credential = await startAuthentication({
optionsJSON: options,
@@ -327,7 +378,7 @@ export default function LoginPage() {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [oidcStatus]);
}, [oidcStatus, registrationStatus, signInBlocked]);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
@@ -380,12 +431,14 @@ export default function LoginPage() {
data-slot="login-actions"
className="mt-8 space-y-4"
>
<InsecureTransportBanner verdict={verdict} />
{oidcStatus?.enabled && (
<Button
onClick={handleOidcLogin}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
disabled={signInBlocked}
>
<Shield className="h-4 w-4" />
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
@@ -414,7 +467,7 @@ export default function LoginPage() {
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
disabled={loading || signInBlocked}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -491,7 +544,7 @@ export default function LoginPage() {
data-testid="login-password-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
disabled={loading || signInBlocked}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
+43
View File
@@ -0,0 +1,43 @@
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
/**
* What every page renders while the boot key check has found that the
* configured encryption key cannot open this database (see
* `src/lib/crypto/canary.ts`). A static server-rendered page with no client
* code and no data reads: the app behind it would only fail on every value.
* The API answers 503 with `encryption.key_mismatch` at the same time, and
* `/api/health` names the reason.
*/
export function KeyMismatchPage({ locale }: { locale: Locale }) {
const { t } = getServerTranslator(locale);
return (
<main className="bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10">
<div className="border-border bg-card w-full max-w-xl space-y-4 rounded-xl border p-4 md:p-6">
<h1 className="text-xl font-semibold">
{t("admin.keyMismatch.title")}
</h1>
<p className="text-sm">{t("admin.keyMismatch.body")}</p>
<div className="space-y-2">
<h2 className="text-base font-semibold">
{t("admin.keyMismatch.fixTitle")}
</h2>
<ol className="list-decimal space-y-2 ps-5 text-sm">
<li>{t("admin.keyMismatch.fixRestore")}</li>
<li>{t("admin.keyMismatch.fixDatabase")}</li>
<li>{t("admin.keyMismatch.fixFresh")}</li>
</ol>
</div>
<p className="text-muted-foreground text-sm">
{t("admin.keyMismatch.restartNote")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.keyMismatch.codes", {
code: "encryption.key_mismatch",
reason: "encryption_key_mismatch",
})}
</p>
</div>
</main>
);
}
+16 -8
View File
@@ -8,6 +8,8 @@ import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
variable: "--font-sans",
@@ -131,17 +133,23 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
{isKeyMismatch() ? (
// The boot key check refused this process: no app shell, no data
// reads, one page that names the fix.
<KeyMismatchPage locale={initialLocale} />
) : (
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
block mutations. Resolve it here (server component) and
thread the boolean into the client shell so the demo
banner can render without a client-side detection path. */}
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
)}
</body>
</html>
);
+2
View File
@@ -50,6 +50,7 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import { TodayHero } from "@/components/daily/today-hero";
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
import {
@@ -913,6 +914,7 @@ export default function DashboardPageClient({
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
<KeyBackupBanner isAdmin={user?.role === "ADMIN"} />
{/* Records other people share with you are reached from the account
switcher in the top bar and the sidebar, not from a card here. The
@@ -0,0 +1,98 @@
/**
* The key backup step and its dashboard banner.
*
* 1. the card shows the key id and fingerprint and never anything shaped
* like key material; the platform hint picks the open tab;
* 2. a due step offers the confirmation, a done step does not;
* 3. the banner renders only for an admin and only while the step is due;
* 4. the copy resolves in German too.
*
* Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red;
* render the confirm button unconditionally and case 2 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { KeyBackupCard } from "@/components/admin/key-backup-card";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/encryption",
}));
const DUE: KeyBackupStatus = {
due: true,
activeKeyId: "v1",
fingerprint: "a1b2c3d4e5f6",
confirmedAt: null,
confirmedKeyId: null,
confirmedFingerprint: null,
confirmedBy: null,
platformHint: "truenas",
};
const DONE: KeyBackupStatus = {
...DUE,
due: false,
confirmedAt: "2026-10-03T10:00:00.000Z",
confirmedKeyId: "v1",
confirmedFingerprint: "a1b2c3d4e5f6",
confirmedBy: { id: "u1", email: "admin@example.test" },
};
function render(
node: React.ReactNode,
status: KeyBackupStatus | null,
locale: "en" | "de" = "en",
): string {
const client = new QueryClient();
if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("KeyBackupCard", () => {
it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => {
const html = render(<KeyBackupCard />, DUE);
expect(html).toContain("a1b2c3d4e5f6");
expect(html).toContain(">v1<");
expect(html).toContain("Apps, select HealthLog");
expect(html).not.toMatch(/[0-9a-f]{64}/);
});
it("offers the confirmation only while the step is due", () => {
expect(render(<KeyBackupCard />, DUE)).toContain(
"I have backed up this key",
);
const done = render(<KeyBackupCard />, DONE);
expect(done).not.toContain("I have backed up this key");
expect(done).toContain("admin@example.test");
});
it("speaks German", () => {
expect(render(<KeyBackupCard />, DUE, "de")).toContain(
"Sichere deinen Verschlüsselungsschlüssel",
);
});
});
describe("KeyBackupBanner", () => {
it("renders for an admin while the step is due", () => {
expect(render(<KeyBackupBanner isAdmin />, DUE)).toContain(
'data-testid="key-backup-banner"',
);
});
it("renders nothing for a non-admin, or once the step is done", () => {
expect(render(<KeyBackupBanner isAdmin={false} />, DUE)).toBe("");
expect(render(<KeyBackupBanner isAdmin />, DONE)).toBe("");
});
});
+25 -20
View File
@@ -42,6 +42,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import { KeyBackupCard } from "./key-backup-card";
interface ColumnScan {
model: string;
@@ -130,27 +131,30 @@ export function EncryptionSection() {
// the fix carried across.
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
<div className="space-y-6">
<KeyBackupCard />
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
)}
</SettingsCard>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
</div>
);
}
@@ -162,6 +166,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
<KeyBackupCard />
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
+309
View File
@@ -0,0 +1,309 @@
"use client";
/**
* `<KeyBackupCard>` — the "Back up your encryption key" step on the admin
* Encryption page.
*
* Shows the active key id and its fingerprint (never the key), where the key
* lives on each platform, what is lost without it, a "Check my copy" field
* and the confirmation. The confirmation is bound to the key id and
* fingerprint shown here: if the server's key changed since the page loaded,
* the server answers 409 and the card reloads.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import {
useKeyBackupStatus,
type KeyBackupPlatform,
type KeyBackupStatus,
} from "./use-key-backup-status";
const PLATFORMS: KeyBackupPlatform[] = [
"compose",
"truenas",
"coolify",
"unraid",
"portainer",
];
/** Literal keys per platform, so the i18n coverage guard sees every one. */
function platformCopy(
t: (key: string) => string,
p: KeyBackupPlatform,
): { label: string; where: string } {
switch (p) {
case "truenas":
return {
label: t("admin.keyBackup.platform.truenasLabel"),
where: t("admin.keyBackup.platform.truenasWhere"),
};
case "coolify":
return {
label: t("admin.keyBackup.platform.coolifyLabel"),
where: t("admin.keyBackup.platform.coolifyWhere"),
};
case "unraid":
return {
label: t("admin.keyBackup.platform.unraidLabel"),
where: t("admin.keyBackup.platform.unraidWhere"),
};
case "portainer":
return {
label: t("admin.keyBackup.platform.portainerLabel"),
where: t("admin.keyBackup.platform.portainerWhere"),
};
case "compose":
return {
label: t("admin.keyBackup.platform.composeLabel"),
where: t("admin.keyBackup.platform.composeWhere"),
};
}
}
export function KeyBackupCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useKeyBackupStatus();
const [candidate, setCandidate] = useState("");
const [checkResult, setCheckResult] = useState<boolean | null>(null);
const invalidate = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.adminEncryptionKeyBackup(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.adminEncryptionStatus(),
}),
]);
const confirm = useMutation({
mutationFn: (s: KeyBackupStatus) =>
apiPost<KeyBackupStatus>(
"/api/admin/encryption/key-backup/confirm",
{ keyId: s.activeKeyId, fingerprint: s.fingerprint },
{ credentials: "include" },
),
onSuccess: async () => {
toast(t("admin.keyBackup.confirmed"));
await invalidate();
},
onError: async (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
toast.error(t("admin.keyBackup.stale"));
await invalidate();
return;
}
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
},
});
const verify = useMutation({
// Not `apiPost`: no idempotency key, so no cached response row exists
// for a request whose body was a key.
mutationFn: (encryptionKey: string) =>
apiFetch<{ matches: boolean }>(
"/api/admin/encryption/key-backup/verify",
{
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ encryptionKey }),
},
),
onSuccess: (data) => {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
setCheckResult(null);
toast.error(err.message || t("admin.keyBackup.checkFailed"));
},
});
const insecure =
typeof window !== "undefined" && window.isSecureContext === false;
if (!status.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.keyBackup.title")}
description={t("admin.keyBackup.description")}
/>
{status.isError ? (
<QueryErrorRow
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.keyBackup.loading")}
</div>
)}
</SettingsCard>
);
}
const s = status.data;
return (
<SettingsCard data-testid="key-backup-card">
<SettingsCardHeader
icon={KeyRound}
title={t("admin.keyBackup.title")}
description={t("admin.keyBackup.description")}
status={
s.due ? (
<Badge variant="outline" className="border-warning/40 text-warning">
{t("admin.keyBackup.badgeDue")}
</Badge>
) : (
<Badge variant="outline">{t("admin.keyBackup.badgeDone")}</Badge>
)
}
/>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">
{t("admin.keyBackup.keyId")}
</dt>
<dd className="font-mono">{s.activeKeyId}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("admin.keyBackup.fingerprint")}
</dt>
<dd className="font-mono" data-testid="key-backup-fingerprint">
{s.fingerprint}
</dd>
</div>
</dl>
<p className="text-sm">{t("admin.keyBackup.whatIsLost")}</p>
<Tabs defaultValue={s.platformHint}>
<TabsList className="flex-wrap">
{PLATFORMS.map((p) => (
<TabsTrigger key={p} value={p}>
{platformCopy(t, p).label}
</TabsTrigger>
))}
</TabsList>
{PLATFORMS.map((p) => (
<TabsContent key={p} value={p} className="text-sm">
{platformCopy(t, p).where}
</TabsContent>
))}
</Tabs>
<p className="text-muted-foreground text-sm">
{t("admin.keyBackup.fingerprintHint")}
</p>
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (candidate.trim()) verify.mutate(candidate);
}}
>
<Label htmlFor="key-backup-candidate">
{t("admin.keyBackup.checkLabel")}
</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="key-backup-candidate"
type="password"
autoComplete="off"
spellCheck={false}
value={candidate}
onChange={(e) => {
setCandidate(e.target.value);
setCheckResult(null);
}}
className="font-mono"
/>
<Button
type="submit"
variant="outline"
disabled={!candidate.trim() || verify.isPending}
>
{verify.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.keyBackup.checkButton")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{insecure
? t("admin.keyBackup.checkInsecureNote")
: t("admin.keyBackup.checkNote")}
</p>
{checkResult !== null && (
<p
role="status"
data-testid="key-backup-check-result"
className={
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
}
>
{checkResult
? t("admin.keyBackup.checkMatches")
: t("admin.keyBackup.checkNoMatch")}
</p>
)}
</form>
{!s.due && s.confirmedAt && (
<p className="text-muted-foreground text-sm">
{s.confirmedBy?.email
? t("admin.keyBackup.confirmedAtBy", {
date: fmt.dateTime(new Date(s.confirmedAt)),
who: s.confirmedBy.email,
})
: t("admin.keyBackup.confirmedAt", {
date: fmt.dateTime(new Date(s.confirmedAt)),
})}
</p>
)}
{s.due && s.confirmedKeyId && (
<p className="text-sm">
{t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })}
</p>
)}
{s.due && (
<SettingsCardActions>
<Button
onClick={() => confirm.mutate(s)}
disabled={confirm.isPending}
>
{confirm.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.keyBackup.confirmButton")}
</Button>
</SettingsCardActions>
)}
</SettingsCard>
);
}
+24 -1
View File
@@ -12,18 +12,28 @@
* don't re-fetch.
*/
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
import {
Clock,
Cog,
Database,
Globe,
KeyRound,
Loader2,
Server,
} from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
import { useKeyBackupStatus } from "./use-key-backup-status";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError, refetch } = useSystemStatus();
const { data: version } = usePublicVersion();
const { data: keyBackup } = useKeyBackupStatus();
return (
<SettingsCard
@@ -63,6 +73,19 @@ export function SystemStatusSummary() {
label={t("admin.overview.snapshotStarted")}
value={formatDateTime(status.startTime)}
/>
{/* v1.40.0 — the encryption key backup step, warning while due. */}
{keyBackup && (
<StatusItem
icon={KeyRound}
label={t("admin.keyBackup.tileLabel")}
value={
keyBackup.due
? t("admin.keyBackup.tileDue")
: t("admin.keyBackup.tileDone")
}
tone={keyBackup.due ? "warning" : "success"}
/>
)}
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
The field is undefined on legacy responses; the row only
@@ -0,0 +1,36 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export type KeyBackupPlatform =
"compose" | "truenas" | "coolify" | "unraid" | "portainer";
/** `GET /api/admin/encryption/key-backup`; key id and fingerprint only. */
export interface KeyBackupStatus {
due: boolean;
activeKeyId: string;
fingerprint: string;
confirmedAt: string | null;
confirmedKeyId: string | null;
confirmedFingerprint: string | null;
confirmedBy: { id: string; email: string | null } | null;
platformHint: KeyBackupPlatform;
}
/**
* The key backup step's state. `enabled` lets the dashboard banner ask only
* for an admin; the route itself is admin-only and cookie-only.
*/
export function useKeyBackupStatus(enabled = true) {
return useQuery({
queryKey: queryKeys.adminEncryptionKeyBackup(),
queryFn: () =>
apiGet<KeyBackupStatus>("/api/admin/encryption/key-backup", {
credentials: "include",
}),
enabled,
staleTime: 60_000,
});
}
@@ -0,0 +1,46 @@
/**
* The login page's transport notice (#1097): the blocked banner names both
* operator fixes, the localhost note is soft, and "ok" renders nothing.
* Mutation check: render the banner for `soft` and the second case goes red.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsecureTransportBanner } from "@/components/auth/insecure-transport-banner";
import type { TransportVerdict } from "@/lib/auth/client-transport";
function render(verdict: TransportVerdict, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<InsecureTransportBanner verdict={verdict} />
</I18nProvider>,
);
}
describe("InsecureTransportBanner", () => {
it("blocked: an alert naming SESSION_COOKIE_SECURE and https", () => {
const html = render("blocked");
expect(html).toContain('data-testid="insecure-transport-banner"');
expect(html).toContain('role="alert"');
expect(html).toContain("SESSION_COOKIE_SECURE=false");
expect(html).toContain("https://");
});
it("soft: a note on localhost, no alert", () => {
const html = render("soft");
expect(html).toContain('data-testid="insecure-transport-note"');
expect(html).not.toContain('role="alert"');
});
it("ok: nothing", () => {
expect(render("ok")).toBe("");
});
it("speaks German", () => {
expect(render("blocked", "de")).toContain("https://");
expect(render("blocked", "de")).not.toContain(
"This server only sets its sign-in cookie",
);
});
});
@@ -0,0 +1,75 @@
"use client";
/**
* Login-page notice for a page whose session cookie cannot stick (#1097).
*
* `blocked`: the server sets its sign-in cookie only over https:// and this
* page is plain http:// on a network address. The browser would drop the
* cookie, so the sign-in controls are disabled and the two operator fixes are
* named. `soft`: the same on localhost, where most browsers accept the cookie;
* the form stays usable.
*/
import { useSyncExternalStore } from "react";
import { TriangleAlert } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import type {
ClientTransport,
TransportVerdict,
} from "@/lib/auth/client-transport";
const noopSubscribe = () => () => {};
/**
* The page's transport, read after hydration only (the server render has no
* `location`), so the first client render matches the server HTML.
*/
export function useClientTransport(): ClientTransport | undefined {
const raw = useSyncExternalStore(
noopSubscribe,
() => `${window.location.protocol}//${window.location.host}`,
() => null,
);
if (!raw) return undefined;
const [scheme, host] = raw.split("//");
return { protocol: scheme === "https:" ? "https" : "http", host };
}
export function InsecureTransportBanner({
verdict,
}: {
verdict: TransportVerdict;
}) {
const { t } = useTranslations();
if (verdict === "ok") return null;
if (verdict === "soft") {
return (
<p
data-testid="insecure-transport-note"
className="text-muted-foreground rounded-lg border p-3 text-xs"
>
{t("auth.insecureTransport.localhostNote")}
</p>
);
}
return (
<div
role="alert"
data-testid="insecure-transport-banner"
className="border-warning/40 bg-warning/10 space-y-2 rounded-lg border p-3 text-sm"
>
<p className="flex items-start gap-2 font-semibold">
<TriangleAlert
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("auth.insecureTransport.title")}
</p>
<p>{t("auth.insecureTransport.body")}</p>
<ul className="list-disc space-y-1 ps-5">
<li>{t("auth.insecureTransport.fixLan")}</li>
<li>{t("auth.insecureTransport.fixHttps")}</li>
</ul>
</div>
);
}
+12 -3
View File
@@ -9,6 +9,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { currentClientTransport } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
import { describePasskeyError } from "@/lib/passkey-errors";
export type MfaMethod = "totp" | "recovery" | "webauthn";
@@ -61,13 +65,17 @@ export function MfaLoginStep({
method: useRecovery ? "recovery" : "totp",
code: code.trim(),
rememberDevice: useRecovery ? false : rememberDevice,
clientTransport: currentClientTransport(),
});
onSuccess();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
? t("auth.insecureTransport.title")
: err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
@@ -89,6 +97,7 @@ export function MfaLoginStep({
challengeId,
credential,
rememberDevice,
clientTransport: currentClientTransport(),
});
onSuccess();
} catch (err) {
@@ -0,0 +1,40 @@
"use client";
/**
* Dashboard banner for an admin while the encryption key backup step is due.
* Not dismissable by design: it goes away when the step is done, and comes
* back when the key changes. Renders nothing for anyone who is not an admin,
* and asks the server nothing for them either.
*/
import Link from "next/link";
import { KeyRound } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
const { data } = useKeyBackupStatus(isAdmin);
if (!isAdmin || !data?.due) return null;
return (
<div
role="status"
data-testid="key-backup-banner"
className="border-warning/40 bg-warning/10 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center"
>
<KeyRound className="text-warning h-5 w-5 shrink-0" aria-hidden="true" />
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold">
{t("dashboard.keyBackupBanner.title")}
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
</div>
<Link
href="/admin/encryption"
className="text-primary inline-flex min-h-11 items-center text-sm font-medium underline-offset-4 hover:underline"
>
{t("dashboard.keyBackupBanner.action")}
</Link>
</div>
);
}
+13
View File
@@ -15,6 +15,19 @@ export async function register() {
// A readiness summary must never break boot.
}
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
// neither the producer nor the worker (a wrong key must never write).
try {
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/key-check"),
import("@/lib/db"),
]);
if (await runBootKeyCheck(prisma)) return;
} catch (err) {
console.error("[instrumentation] Encryption key check crashed:", err);
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { setKeyMismatchState } from "@/lib/boot/key-mismatch-state";
const handler = vi.fn(async (_request: NextRequest) =>
Response.json({ data: "ran", error: null }),
);
const route = apiHandler(handler);
function call(path: string) {
return route(new NextRequest(`http://localhost${path}`));
}
afterEach(() => {
setKeyMismatchState(null);
handler.mockClear();
});
describe("apiHandler while the encryption key does not match", () => {
it("refuses every route with 503 encryption.key_mismatch before the handler runs", async () => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
const response = await call("/api/measurements");
expect(response.status).toBe(503);
const body = await response.json();
expect(body.data).toBeNull();
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
expect(response.headers.get("Retry-After")).toBe("300");
expect(handler).not.toHaveBeenCalled();
});
it("still serves /api/health and /api/version", async () => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
for (const path of ["/api/health", "/api/version"]) {
const response = await call(path);
expect(response.status).toBe(200);
}
expect(handler).toHaveBeenCalledTimes(2);
});
it("runs the handler when the key check passed", async () => {
const response = await call("/api/measurements");
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
});
+22 -1
View File
@@ -53,6 +53,11 @@ import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
import {
isKeyMismatch,
KEY_MISMATCH_ERROR_CODE,
KEY_MISMATCH_EXEMPT_PATHS,
} from "./boot/key-mismatch-state";
import {
capturedRateLimit,
rateLimitResponseHeaders,
@@ -258,7 +263,23 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
response = await handler(...args);
// The boot key check found that the configured encryption key
// cannot open this database. Refuse before auth, before the
// handler: nothing here may read or write encrypted data.
if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) {
annotate({ action: { name: "encryption.key_mismatch.refused" } });
response = NextResponse.json(
{
data: null,
error:
"The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.",
meta: { errorCode: KEY_MISMATCH_ERROR_CODE },
},
{ status: 503, headers: { "Retry-After": "300" } },
);
} else {
response = await handler(...args);
}
} catch (error) {
if (error instanceof AiUnavailableError) {
// An AI capability is unavailable for this record. One envelope
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { isLocalhostHost, transportVerdict } from "@/lib/auth/client-transport";
import {
cookieCannotStick,
INSECURE_TRANSPORT_CODE,
refuseWhenCookieCannotStick,
} from "@/lib/auth/transport-refusal";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("isLocalhostHost", () => {
it.each([
"localhost",
"localhost:3000",
"app.localhost:8080",
"127.0.0.1",
"127.0.0.1:3000",
"127.1.2.3",
"[::1]",
"[::1]:3000",
"LOCALHOST",
])("treats %s as loopback", (host) => {
expect(isLocalhostHost(host)).toBe(true);
});
it.each([
"192.168.1.20:3000",
"truenas.local:30180",
"healthlog.example.com",
"10.0.0.5",
"127.0.0.1.nip.io",
"localhost.example.com",
"[fe80::1]:3000",
])("treats %s as a network address", (host) => {
expect(isLocalhostHost(host)).toBe(false);
});
});
describe("transportVerdict", () => {
const http = (host: string) => ({ protocol: "http" as const, host });
it("blocks plain http on a network address when cookies are Secure", () => {
expect(transportVerdict(true, http("192.168.1.20:3000"))).toBe("blocked");
});
it("only notes plain http on localhost", () => {
expect(transportVerdict(true, http("localhost:3000"))).toBe("soft");
});
it("passes https, non-Secure cookies, and an unknown transport", () => {
expect(
transportVerdict(true, { protocol: "https", host: "nas.local" }),
).toBe("ok");
expect(transportVerdict(false, http("192.168.1.20:3000"))).toBe("ok");
expect(transportVerdict(true, undefined)).toBe("ok");
});
});
describe("refuseWhenCookieCannotStick", () => {
const body = (protocol: string, host = "192.168.1.20:3000") => ({
email: "a",
password: "b",
clientTransport: { protocol, host },
});
it("refuses http + Secure with 409 and the code", async () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
const response = refuseWhenCookieCannotStick(body("http"));
expect(response?.status).toBe(409);
const json = await response!.json();
expect(json.meta).toEqual({
errorCode: INSECURE_TRANSPORT_CODE,
sessionCookieSecure: true,
});
});
it("passes https, localhost, a body without the field, and SESSION_COOKIE_SECURE=false", () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
expect(refuseWhenCookieCannotStick(body("https"))).toBeNull();
expect(
refuseWhenCookieCannotStick(body("http", "localhost:3000")),
).toBeNull();
expect(
refuseWhenCookieCannotStick({ email: "a", password: "b" }),
).toBeNull();
expect(refuseWhenCookieCannotStick(null)).toBeNull();
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
expect(cookieCannotStick(body("http"))).toBe(false);
});
it("ignores a malformed field rather than refusing on it", () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
expect(
cookieCannotStick({ clientTransport: { protocol: "gopher", host: "x" } }),
).toBe(false);
expect(cookieCannotStick({ clientTransport: "http" })).toBe(false);
});
it("follows NODE_ENV when SESSION_COOKIE_SECURE is unset", () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "");
vi.stubEnv("NODE_ENV", "production");
expect(cookieCannotStick(body("http"))).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* What the browser knows and the server does not: whether this page was
* opened over https://. A `Secure` cookie only sticks on an https:// page
* (and, in most browsers, on http://localhost). The server knows whether it
* will issue `Secure` cookies (`shouldEmitSecureCookie`, published as
* `sessionCookieSecure` on `GET /api/auth/registration-status`); combining the
* two facts says, before any credential leaves the browser, whether a sign-in
* here can finish.
*
* Pure and dependency-free: imported by the login page and by the server
* refusal (`./transport-refusal.ts`) so both sides apply the same rule.
*/
export interface ClientTransport {
protocol: "http" | "https";
/** `location.host`: hostname plus an optional port. */
host: string;
}
/**
* Loopback names a browser treats as a secure context. A `Secure` cookie set
* on one of them usually sticks even over http://, so a sign-in there gets a
* soft note, never a refusal.
*/
export function isLocalhostHost(host: string): boolean {
let name = host.trim().toLowerCase();
if (name.startsWith("[")) {
// [::1] or [::1]:3000
name = name.slice(
1,
name.indexOf("]") === -1 ? undefined : name.indexOf("]"),
);
} else if (name.split(":").length === 2) {
name = name.split(":")[0];
}
if (name === "localhost" || name.endsWith(".localhost")) return true;
if (name === "::1") return true;
return /^127(?:\.\d{1,3}){3}$/.test(name);
}
/**
* - `blocked`: the server sets `Secure` cookies and this page is plain http on
* a non-loopback host; a sign-in cannot keep its cookie, so it must not start.
* - `soft`: the same on a loopback host; most browsers accept it, say so.
* - `ok`: nothing to say.
*/
export type TransportVerdict = "ok" | "soft" | "blocked";
export function transportVerdict(
sessionCookieSecure: boolean,
transport: ClientTransport | undefined,
): TransportVerdict {
if (!sessionCookieSecure || !transport || transport.protocol !== "http") {
return "ok";
}
return isLocalhostHost(transport.host) ? "soft" : "blocked";
}
/** The current page's transport, for the request bodies the server checks. */
export function currentClientTransport(): ClientTransport | undefined {
if (typeof window === "undefined") return undefined;
const protocol = window.location.protocol === "https:" ? "https" : "http";
return { protocol, host: window.location.host };
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Refuse a browser sign-in whose session cookie cannot stick, BEFORE it spends
* anything: before the password is verified (so the account throttle never
* counts it), before an MFA ticket is minted, before a ticket or a one-time
* code is consumed, before a passkey challenge is issued.
*
* The fact comes from the client (`clientTransport`), not from proxy headers:
* a TLS proxy that omits `X-Forwarded-Proto` would make a header heuristic
* refuse a correctly served install. A body without the field is never
* refused. The header heuristic in `secure-cookie.ts` stays as the operator
* log line for clients that do not send it.
*/
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { transportVerdict } from "@/lib/auth/client-transport";
import { clientTransportSchema } from "@/lib/validations/client-transport";
export const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
/** True when the body states a transport the session cookie cannot survive. */
export function cookieCannotStick(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const parsed = clientTransportSchema.safeParse(
(body as { clientTransport?: unknown }).clientTransport,
);
if (!parsed.success) return false;
return transportVerdict(shouldEmitSecureCookie(), parsed.data) === "blocked";
}
/** The 409 to return, or null when the sign-in may proceed. */
export function refuseWhenCookieCannotStick(body: unknown): Response | null {
if (!cookieCannotStick(body)) return null;
annotate({ action: { name: "auth.session.insecure_transport.refused" } });
return apiError(
"This server only sets its sign-in cookie over https://, but this page was opened over http://, so signing in cannot complete here. Open the https:// address, or set SESSION_COOKIE_SECURE=false for a server that is only reached over plain HTTP on a private network.",
409,
{ errorCode: INSECURE_TRANSPORT_CODE, sessionCookieSecure: true },
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Runs the encryption key check once per process at boot and records the
* verdict for the request path (`./key-mismatch-state.ts`).
*
* Returns true when the process must refuse: the caller then starts neither
* the queue producer nor the worker, because a process holding the wrong key
* must never write. The process keeps running so `/api/health` and the
* explanation page can say why; a crash loop would hide the reason in a log
* that the people most likely to hit this (a NAS app catalog install) never
* open.
*/
import {
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
} from "@/lib/crypto/canary";
import { setKeyMismatchState } from "./key-mismatch-state";
export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
const outcome = await checkEncryptionKeyCanaries(client);
if (outcome.state === "mismatch") {
setKeyMismatchState({
keyIds: outcome.keyIds,
detectedAt: new Date().toISOString(),
});
console.error(keyMismatchLogBlock(outcome.keyIds));
return true;
}
setKeyMismatchState(null);
if (outcome.state === "error") {
// The check could not run. The loaders and the database each have their
// own loud signal for that; serving stays on.
console.warn(
`[boot] Encryption key check skipped: ${outcome.message.slice(0, 300)}`,
);
} else {
if (outcome.inconclusive.length > 0) {
console.warn(
`[boot] Encryption key check inconclusive for key id(s) ${outcome.inconclusive.join(", ")}: ` +
"the only stored value found under that id did not open. Serving; " +
"no canary written; the check runs again at the next start.",
);
}
if (outcome.written.length > 0) {
console.info(
`[boot] Encryption key check: recorded key id(s) ${outcome.written.join(", ")}`,
);
}
}
return false;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Process-wide record of the boot key check's verdict.
*
* The check runs once in `instrumentation.ts`; the readers are `apiHandler`
* (every API route), `/api/health` and the root layout. Those live in
* different bundles of the same Node process, so module state is not shared
* between them and the verdict is pinned on `globalThis` under a registry
* symbol instead. This module imports nothing, so pulling it into the request
* path costs nothing.
*
* Only a positive mismatch is recorded. A check that could not run (database
* unreachable, keys not configured) leaves the process serving: those failures
* already have their own loud signals, and refusing on them would turn a
* transient database hiccup at boot into an outage.
*/
export interface KeyMismatchState {
/** Configured key ids whose canary did not open under the configured key. */
keyIds: string[];
/** When the check found it (ISO). */
detectedAt: string;
}
const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch");
type GlobalWithState = typeof globalThis & {
[STATE_SYMBOL]?: KeyMismatchState | null;
};
/** The paths that keep answering while the process refuses everything else. */
export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet<string> = new Set([
"/api/health",
"/api/version",
]);
/** Wire code for the refusal; catalogued in `src/lib/openapi/error-codes.ts`. */
export const KEY_MISMATCH_ERROR_CODE = "encryption.key_mismatch";
/** The `/api/health` reason while the process refuses. */
export const KEY_MISMATCH_HEALTH_REASON = "encryption_key_mismatch";
export function getKeyMismatchState(): KeyMismatchState | null {
return (globalThis as GlobalWithState)[STATE_SYMBOL] ?? null;
}
export function isKeyMismatch(): boolean {
return getKeyMismatchState() !== null;
}
export function setKeyMismatchState(state: KeyMismatchState | null): void {
(globalThis as GlobalWithState)[STATE_SYMBOL] = state;
}
+51
View File
@@ -22,6 +22,7 @@ import {
createHash,
hkdfSync,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { Buffer } from "node:buffer";
import { getEvent } from "@/lib/logging/context";
@@ -195,6 +196,56 @@ export function getConfiguredKeyIds(): string[] {
return [...keys.keys()];
}
/**
* A short, public fingerprint of one configured key: the first twelve hex
* characters of SHA-256 over the raw key bytes. Lets an operator tell which key
* a confirmation or a copy belongs to without the key ever leaving the process
* (48 bits of a hash of a 256-bit key; nothing to invert). Null for an id that
* is not configured.
*/
export function getKeyFingerprint(id: string): string | null {
const key = getKeyById(id);
return key ? fingerprintKeyBytes(key) : null;
}
/** The fingerprint rule itself, for a key that is already decoded. */
export function fingerprintKeyBytes(key: Buffer): string {
return createHash("sha256").update(key).digest("hex").slice(0, 12);
}
/**
* Whether `candidate` (as an operator would paste it: 64 hex characters or a
* base64 32-byte value) decodes to the configured key `id`. Constant-time on
* the decoded bytes. A value that does not decode is simply not a match; the
* decoder's error text is never surfaced, because it can quote the input's
* length.
*/
export function candidateMatchesKey(candidate: string, id: string): boolean {
const key = getKeyById(id);
if (!key) return false;
let decoded: Buffer;
try {
decoded = decodeKey(candidate.trim(), id);
} catch {
return false;
}
return decoded.length === key.length && timingSafeEqual(decoded, key);
}
/**
* Seal `plaintext` under a SPECIFIC configured key id rather than the active
* one. Exists for one caller, the boot key check (`src/lib/crypto/canary.ts`),
* which keeps one known value per key id; everything else writes with
* `encrypt()`. Throws for an id that is not configured.
*/
export function encryptUnderKeyId(plaintext: string, id: string): string {
const key = getKeyById(id);
if (!key) {
throw new Error(`Encryption key id '${id}' is not configured.`);
}
return `${id}.${encryptWithKey(plaintext, key)}`;
}
function encryptWithKey(plaintext: string, key: Buffer): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
+247
View File
@@ -0,0 +1,247 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetCryptoCacheForTests,
candidateMatchesKey,
encrypt,
encryptUnderKeyId,
fingerprintKeyBytes,
getKeyFingerprint,
} from "@/lib/crypto";
import {
canaryPlaintext,
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
} from "../canary";
const KEY_A =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
/** An in-memory stand-in for the two raw statements and the probe delegates. */
function fakeClient(opts: {
canaries?: Record<string, string>;
/** delegate → field → stored values (in row order). */
probe?: Record<string, Record<string, string[]>>;
}) {
const canaries = new Map(Object.entries(opts.canaries ?? {}));
const inserts: string[] = [];
const client = {
async $queryRaw() {
return [...canaries.entries()].map(([key_id, ciphertext]) => ({
key_id,
ciphertext,
}));
},
async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) {
const [keyId, ciphertext] = values as [string, string];
inserts.push(keyId);
if (!canaries.has(keyId)) canaries.set(keyId, ciphertext);
return 1;
},
} as unknown as CanaryClient & Record<string, unknown>;
for (const [delegate, fields] of Object.entries(opts.probe ?? {})) {
client[delegate] = {
async findMany(args: {
select: Record<string, boolean>;
take: number;
skip?: number;
}) {
const field = Object.keys(args.select)[0];
const skip = args.skip ?? 0;
return (fields[field] ?? [])
.slice(skip, skip + args.take)
.map((value) => ({ [field]: value }));
},
};
}
return { client, canaries, inserts };
}
describe("encryption key canary", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
});
it("writes a canary for a configured key with no row and no data", async () => {
const { client, canaries, inserts } = fakeClient({});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: ["v1"],
verified: [],
inconclusive: [],
});
expect(inserts).toEqual(["v1"]);
expect(canaries.get("v1")?.startsWith("v1.")).toBe(true);
});
it("verifies a canary written under the same key", async () => {
const { client, inserts } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v1"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: [],
verified: ["v1"],
inconclusive: [],
});
expect(inserts).toEqual([]);
});
it("reports a mismatch when the key changed under the same id", async () => {
const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
useKey(KEY_B);
const { client, inserts } = fakeClient({ canaries: { v1: sealedUnderA } });
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("reports a mismatch for a canary that opens to the wrong value", async () => {
// A row from another key id copied over: opens, but says the wrong thing.
const { client } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v2"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("mismatch");
});
it("refuses to seal a wrong key when existing data predates the canary", async () => {
const existing = [encrypt("a stored token"), encrypt("another token")];
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: {
user: {
codexAccessTokenEncrypted: [existing[0]],
codexRefreshTokenEncrypted: [existing[1]],
},
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("writes the canary when existing data opens under the key", async () => {
const { client, inserts } = fakeClient({
probe: {
user: { codexAccessTokenEncrypted: [encrypt("a stored token")] },
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
it("one damaged row among good ones does not take the server down", async () => {
// A row that claims key id v1 but does not open (damaged, or sealed by
// something else), found FIRST, then values that open.
const damaged = "v1." + Buffer.alloc(40, 7).toString("base64");
const { client, inserts } = fakeClient({
probe: {
user: {
codexAccessTokenEncrypted: [damaged],
codexRefreshTokenEncrypted: [encrypt("a stored token")],
},
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
it("several values in one column count, so a wrong key with one busy column still refuses", async () => {
const values = [encrypt("one"), encrypt("two"), encrypt("three")];
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: values } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("a single value that does not open is inconclusive: no mismatch, no canary", async () => {
const existing = encrypt("the only stored token");
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: [existing] } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: [],
verified: [],
inconclusive: ["v1"],
});
expect(inserts).toEqual([]);
});
it("reports an error, not a mismatch, when the table cannot be read", async () => {
const client = {
$queryRaw: async () => {
throw new Error('relation "encryption_key_canaries" does not exist');
},
$executeRaw: async () => 0,
} as unknown as CanaryClient;
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("error");
});
it("reports an error when no key is configured", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
const outcome = await checkEncryptionKeyCanaries(fakeClient({}).client);
expect(outcome.state).toBe("error");
});
it("names the key ids and never a key in the log block", () => {
const block = keyMismatchLogBlock(["v1"]);
expect(block).toContain("'v1'");
expect(block).toContain("encryption.key_mismatch");
expect(block).not.toContain(KEY_A);
});
});
describe("key fingerprint and copy check", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
});
it("is the first 12 hex of SHA-256 over the raw key bytes", () => {
expect(getKeyFingerprint("v1")).toBe(
fingerprintKeyBytes(Buffer.from(KEY_A, "hex")),
);
expect(getKeyFingerprint("v1")).toMatch(/^[0-9a-f]{12}$/);
expect(getKeyFingerprint("v1")).not.toBe(
fingerprintKeyBytes(Buffer.from(KEY_B, "hex")),
);
expect(getKeyFingerprint("nope")).toBeNull();
});
it("matches the configured key in hex, base64 and with whitespace", () => {
expect(candidateMatchesKey(KEY_A, "v1")).toBe(true);
expect(candidateMatchesKey(` ${KEY_A.toUpperCase()}\n`, "v1")).toBe(true);
expect(
candidateMatchesKey(Buffer.from(KEY_A, "hex").toString("base64"), "v1"),
).toBe(true);
});
it("does not match another key, garbage, or an unknown id", () => {
expect(candidateMatchesKey(KEY_B, "v1")).toBe(false);
expect(candidateMatchesKey("not a key", "v1")).toBe(false);
expect(candidateMatchesKey(KEY_A, "v9")).toBe(false);
});
});
@@ -60,11 +60,19 @@ const RELATION_ENVELOPE = new Set([
]);
/**
* Columns this scan derives that are deliberately NOT in the rotation
* registry. Empty today. An entry here is a written decision with a reason,
* never a way to quiet the guard.
* Columns that hold ciphertext and are deliberately NOT in the rotation
* registry. An entry here is a written decision with a reason, never a way to
* quiet the guard.
*
* `EncryptionKeyCanary.ciphertext` — one known value per key id, sealed under
* THAT key (`encryptUnderKeyId`, written by raw SQL in
* `src/lib/crypto/canary.ts`). Re-sealing it under the active key would make
* every id's boot check test the active key instead of its own. A retired
* key's row is never read again: only configured ids are checked.
*/
const NOT_ROTATED: ReadonlySet<string> = new Set<string>();
const NOT_ROTATED: ReadonlySet<string> = new Set<string>([
"EncryptionKeyCanary.ciphertext",
]);
/**
* Ciphertext written where the Prisma-call walk below cannot see it: by raw
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { isKeyBackupDue, resolvePlatformHint } from "../key-backup";
const confirmed = (keyId: string, fingerprint: string) => ({
encryptionKeyBackupConfirmedAt: new Date("2026-10-03T10:00:00Z"),
encryptionKeyBackupConfirmedKeyId: keyId,
encryptionKeyBackupConfirmedFingerprint: fingerprint,
});
describe("key backup step", () => {
it("is due without any confirmation", () => {
expect(isKeyBackupDue(null, "v1", "abcdefabcdef")).toBe(true);
expect(
isKeyBackupDue(
{
encryptionKeyBackupConfirmedAt: null,
encryptionKeyBackupConfirmedKeyId: null,
encryptionKeyBackupConfirmedFingerprint: null,
},
"v1",
"abcdefabcdef",
),
).toBe(true);
});
it("is done only for the exact key that was confirmed", () => {
expect(
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "abcdefabcdef"),
).toBe(false);
});
it("is due again after a rotation (new active id)", () => {
expect(
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v2", "abcdefabcdef"),
).toBe(true);
});
it("is due again after a re-keyed install (same id, other key)", () => {
expect(
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "123456123456"),
).toBe(true);
});
it("reads the platform hint and falls back to compose", () => {
expect(resolvePlatformHint("truenas")).toBe("truenas");
expect(resolvePlatformHint(" Unraid ")).toBe("unraid");
expect(resolvePlatformHint(undefined)).toBe("compose");
expect(resolvePlatformHint("kubernetes")).toBe("compose");
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* Boot check: does the configured encryption key open this database?
*
* Without it, a server started with a different `ENCRYPTION_KEY` than the one
* its database was written with (a reinstalled NAS app over an old dataset, a
* restored dump under a fresh key) signs people in normally, because sessions
* hash with `API_TOKEN_HMAC_KEY`, and then answers random 500s wherever a value
* has to be decrypted. This module turns that into one clear refusal.
*
* For every configured key id, `encryption_key_canaries` holds a known
* plaintext sealed under that key:
*
* - row present: it must decrypt to the expected value, else the key is not
* the one the row was written with;
* - row absent: before writing one, sample existing ciphertext under that
* key id from the registered string columns and try it. Without this
* probe, the first boot after an upgrade (or a re-keyed boot over a
* database that predates the canary) would seal the wrong key as the
* right one.
*
* The probe samples up to `PROBE_SAMPLE_LIMIT` values, one per column first
* (different columns are different writers, so one bad writer cannot speak
* for the whole database), then further values from columns that had any.
* Its verdict, and what each one does:
*
* - `opens`: at least one value opened. A wrong key opens nothing, so the
* key is right and any value that failed is a damaged or foreign row, not
* a key problem. The canary is written.
* - `fails`: two or more values were tried and none opened. That is what a
* wrong key looks like; the process refuses (mismatch).
* - `inconclusive`: exactly one value was found and it did not open. One
* row cannot tell a wrong key from one damaged row, and the two possible
* mistakes are not equal: declaring a mismatch takes a correctly keyed
* server down with 503 on every request, while serving keeps today's
* behaviour. So the process serves, but writes NO canary either (that
* would seal a key that may be wrong), logs a warning, and probes again
* at the next boot, by which time more data may decide it.
* - `none`: nothing found inside the time budget. The canary is written,
* which is exactly the behaviour before this check existed.
*
* Writes are `ON CONFLICT DO NOTHING`, so two processes racing is fine.
*
* Never logs or returns key material. The canary is written by raw SQL and is
* deliberately not in the rotation registry: it belongs to its key id.
*/
import {
decrypt,
encryptUnderKeyId,
extractKeyId,
getConfiguredKeyIds,
} from "@/lib/crypto";
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
export const CANARY_PREFIX = "healthlog-canary:";
export function canaryPlaintext(keyId: string): string {
return `${CANARY_PREFIX}${keyId}`;
}
/** How many existing values the probe tries at most per key id. */
export const PROBE_SAMPLE_LIMIT = 5;
/** How long the existing-data probe may take before the canary is written anyway. */
export const PROBE_BUDGET_MS = 10_000;
/** The slice of a Prisma client this module needs. */
export interface CanaryClient {
$queryRaw<T = unknown>(
query: TemplateStringsArray,
...values: unknown[]
): Promise<T>;
$executeRaw(
query: TemplateStringsArray,
...values: unknown[]
): Promise<number>;
}
type ProbeDelegate = {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean>;
take: number;
skip?: number;
}) => Promise<Array<Record<string, unknown>>>;
};
export type ProbeVerdict = "opens" | "fails" | "inconclusive" | "none";
export type KeyCheckOutcome =
| {
state: "ok";
/** Key ids whose canary this run wrote. */
written: string[];
/** Key ids whose canary existed and opened. */
verified: string[];
/**
* Key ids with no canary whose only existing value did not open: not a
* mismatch, no canary written, probed again at the next boot.
*/
inconclusive: string[];
}
| { state: "mismatch"; keyIds: string[] }
| { state: "error"; message: string };
function opensAs(ciphertext: string, keyId: string, expected?: string) {
try {
if (extractKeyId(ciphertext) !== keyId) return false;
const plain = decrypt(ciphertext);
return expected === undefined ? true : plain === expected;
} catch {
return false;
}
}
/**
* Sample existing values sealed under `keyId` and try them; see the module
* comment for what each verdict means and why.
*/
export async function probeExistingData(
client: unknown,
keyId: string,
deadline: number,
): Promise<ProbeVerdict> {
const delegates = client as Record<string, ProbeDelegate | undefined>;
const columns = ENCRYPTED_COLUMNS.filter(
(c) => c.kind === "string" && !c.codec && !c.codecField,
);
let tried = 0;
const columnsWithData: typeof columns = [];
const sample = async (
column: (typeof columns)[number],
skip: number,
take: number,
): Promise<string[]> => {
const delegate =
delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)];
if (!delegate || typeof delegate.findMany !== "function") return [];
try {
const rows = await delegate.findMany({
where: { [column.field]: { startsWith: `${keyId}.` } },
select: { [column.field]: true },
take,
...(skip > 0 ? { skip } : {}),
});
return rows
.map((row) => row[column.field])
.filter((v): v is string => typeof v === "string");
} catch {
return [];
}
};
// Pass 1: one value per column, so different writers are heard first.
for (const column of columns) {
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
const values = await sample(column, 0, 1);
if (values.length === 0) continue;
columnsWithData.push(column);
tried += 1;
if (opensAs(values[0], keyId)) return "opens";
}
// Pass 2: more values from the columns that had any, up to the limit.
for (const column of columnsWithData) {
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
const values = await sample(column, 1, PROBE_SAMPLE_LIMIT - tried);
for (const value of values) {
tried += 1;
if (opensAs(value, keyId)) return "opens";
}
}
if (tried >= 2) return "fails";
if (tried === 1) return "inconclusive";
return "none";
}
/**
* Run the check for every configured key id. Never throws: a failure to run
* the check at all is reported as `error`, and only a value that provably does
* not open is reported as `mismatch`.
*/
export async function checkEncryptionKeyCanaries(
client: CanaryClient,
options: { probeBudgetMs?: number } = {},
): Promise<KeyCheckOutcome> {
let keyIds: string[];
try {
keyIds = getConfiguredKeyIds();
} catch (err) {
return { state: "error", message: (err as Error).message };
}
let rows: Array<{ key_id: string; ciphertext: string }>;
try {
rows = await client.$queryRaw<
Array<{ key_id: string; ciphertext: string }>
>`SELECT key_id, ciphertext FROM encryption_key_canaries WHERE key_id = ANY(${keyIds})`;
} catch (err) {
return { state: "error", message: (err as Error).message };
}
const stored = new Map(rows.map((r) => [r.key_id, r.ciphertext]));
const mismatched: string[] = [];
const verified: string[] = [];
const missing: string[] = [];
for (const keyId of keyIds) {
const ciphertext = stored.get(keyId);
if (ciphertext === undefined) {
missing.push(keyId);
} else if (opensAs(ciphertext, keyId, canaryPlaintext(keyId))) {
verified.push(keyId);
} else {
mismatched.push(keyId);
}
}
const deadline = Date.now() + (options.probeBudgetMs ?? PROBE_BUDGET_MS);
const toWrite: string[] = [];
const inconclusive: string[] = [];
for (const keyId of missing) {
const probe = await probeExistingData(client, keyId, deadline);
if (probe === "fails") mismatched.push(keyId);
else if (probe === "inconclusive") inconclusive.push(keyId);
else toWrite.push(keyId);
}
if (mismatched.length > 0) {
// Nothing is written while any key fails: a process in this state must
// not leave anything behind sealed under a key that may be wrong.
return { state: "mismatch", keyIds: mismatched.sort() };
}
try {
for (const keyId of toWrite) {
const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId);
await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`;
}
} catch (err) {
return { state: "error", message: (err as Error).message };
}
return { state: "ok", written: toWrite, verified, inconclusive };
}
/**
* The one block an operator reads in the container log. English literal on
* purpose: logs are not translated. Names the key ids, never a key.
*/
export function keyMismatchLogBlock(keyIds: string[]): string {
const ids = keyIds.map((id) => `'${id}'`).join(", ");
return [
"============================================================",
" HealthLog refuses to serve: encryption key does not match",
"============================================================",
` The encryption key configured for key id ${ids} cannot open the`,
" data already stored in this database. Serving anyway would fail on",
" every encrypted value, so every API request answers 503 with",
" errorCode 'encryption.key_mismatch' and /api/health reports",
" reason 'encryption_key_mismatch'. Background jobs are not started.",
"",
" Fix it one of three ways, then restart:",
" 1. Restore the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry)",
" this database was written with.",
" 2. Point DATABASE_URL at the database that belongs to this key.",
" 3. Start fresh: an empty database with this key. Stored data and",
" in-database backups of the old database cannot be read without",
" the old key.",
"============================================================",
].join("\n");
}
+142
View File
@@ -0,0 +1,142 @@
/**
* The "Back up your encryption key" step: instance state and its rules.
*
* The confirmation lives on the `AppSettings` singleton, bound to the active
* key id and that key's fingerprint, so it is an instance fact (it survives
* the admin who gave it) and a rotation or a re-keyed install makes the step
* due again. Nothing here ever reads, returns or stores the key itself.
*/
import { prisma } from "@/lib/db";
import { envValue } from "@/lib/env";
import { getActiveKeyId, getKeyFingerprint } from "@/lib/crypto";
/** Where the operator sets the key; picks the tab the step opens on. */
export const KEY_BACKUP_PLATFORMS = [
"compose",
"truenas",
"coolify",
"unraid",
"portainer",
] as const;
export type KeyBackupPlatform = (typeof KEY_BACKUP_PLATFORMS)[number];
/** Wire code for a confirmation given for a key that is no longer active. */
export const KEY_BACKUP_STALE_CODE = "encryption.keyBackup.stale";
/**
* `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise.
* Catalog templates set it so the step opens on the right instructions.
*/
export function resolvePlatformHint(
raw: string | undefined = envValue("HEALTHLOG_PLATFORM"),
): KeyBackupPlatform {
const value = raw?.trim().toLowerCase();
return (KEY_BACKUP_PLATFORMS as readonly string[]).includes(value ?? "")
? (value as KeyBackupPlatform)
: "compose";
}
export interface KeyBackupStatus {
/** True until a confirmation exists for the active key id AND fingerprint. */
due: boolean;
activeKeyId: string;
/** Fingerprint of the active key (first 12 hex of SHA-256 over its bytes). */
fingerprint: string;
confirmedAt: string | null;
confirmedKeyId: string | null;
confirmedFingerprint: string | null;
/** The confirming admin, while that account exists. */
confirmedBy: { id: string; email: string | null } | null;
platformHint: KeyBackupPlatform;
}
interface StoredConfirmation {
encryptionKeyBackupConfirmedAt: Date | null;
encryptionKeyBackupConfirmedKeyId: string | null;
encryptionKeyBackupConfirmedFingerprint: string | null;
}
/** Pure rule: is the stored confirmation for this exact key? */
export function isKeyBackupDue(
stored: StoredConfirmation | null,
activeKeyId: string,
fingerprint: string,
): boolean {
return !(
stored?.encryptionKeyBackupConfirmedAt &&
stored.encryptionKeyBackupConfirmedKeyId === activeKeyId &&
stored.encryptionKeyBackupConfirmedFingerprint === fingerprint
);
}
function activeKey(): { activeKeyId: string; fingerprint: string } {
const activeKeyId = getActiveKeyId();
// The active id is always configured, so the fingerprint is never null.
return { activeKeyId, fingerprint: getKeyFingerprint(activeKeyId) ?? "" };
}
export async function readKeyBackupStatus(): Promise<KeyBackupStatus> {
const { activeKeyId, fingerprint } = activeKey();
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
encryptionKeyBackupConfirmedAt: true,
encryptionKeyBackupConfirmedKeyId: true,
encryptionKeyBackupConfirmedFingerprint: true,
encryptionKeyBackupConfirmedByUserId: true,
},
});
const confirmedBy = row?.encryptionKeyBackupConfirmedByUserId
? await prisma.user.findUnique({
where: { id: row.encryptionKeyBackupConfirmedByUserId },
select: { id: true, email: true },
})
: null;
return {
due: isKeyBackupDue(row, activeKeyId, fingerprint),
activeKeyId,
fingerprint,
confirmedAt: row?.encryptionKeyBackupConfirmedAt?.toISOString() ?? null,
confirmedKeyId: row?.encryptionKeyBackupConfirmedKeyId ?? null,
confirmedFingerprint: row?.encryptionKeyBackupConfirmedFingerprint ?? null,
confirmedBy: confirmedBy ?? null,
platformHint: resolvePlatformHint(),
};
}
export type ConfirmKeyBackupResult =
| { ok: true; status: KeyBackupStatus }
| { ok: false; activeKeyId: string; fingerprint: string };
/**
* Record the confirmation, but only for the key the admin was shown: a page
* opened before a rotation must not confirm the new key.
*/
export async function confirmKeyBackup(input: {
keyId: string;
fingerprint: string;
userId: string;
}): Promise<ConfirmKeyBackupResult> {
const { activeKeyId, fingerprint } = activeKey();
if (input.keyId !== activeKeyId || input.fingerprint !== fingerprint) {
return { ok: false, activeKeyId, fingerprint };
}
const now = new Date();
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
encryptionKeyBackupConfirmedAt: now,
encryptionKeyBackupConfirmedKeyId: activeKeyId,
encryptionKeyBackupConfirmedFingerprint: fingerprint,
encryptionKeyBackupConfirmedByUserId: input.userId,
},
update: {
encryptionKeyBackupConfirmedAt: now,
encryptionKeyBackupConfirmedKeyId: activeKeyId,
encryptionKeyBackupConfirmedFingerprint: fingerprint,
encryptionKeyBackupConfirmedByUserId: input.userId,
},
});
return { ok: true, status: await readKeyBackupStatus() };
}
+2
View File
@@ -299,6 +299,8 @@ export const WIPE_EXEMPT: Readonly<Record<string, string>> = {
*/
export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
AppSettings: "instance-wide configuration singleton",
EncryptionKeyCanary:
"one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account",
RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
HostMetric: "instance-wide host telemetry, not attributable to an account",
OffhostBackupKeyUse:
+5
View File
@@ -96,6 +96,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
// `client_secret` are already covered by `/token/i` + `/secret/i`; the
// verifier is the one secret-shaped key those patterns miss.
/verifier/i,
// v1.40.0 — the encryption key an admin pastes into "Check my copy"
// (`POST /api/admin/encryption/key-backup/verify`). The route never logs its
// body; this keeps it out of any excerpt a future change might take.
// Anchored so `encryptionKeyId` and friends stay readable.
/^encryption[_-]?key$/i,
];
/**
+2
View File
@@ -70,6 +70,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.reproof.required",
"auth.reproof.too_weak",
"auth.scope.insufficient",
"auth.session.insecure_transport",
"auth.stepup.required",
"auth.token.expired",
"auth.token.invalid",
@@ -172,6 +173,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"encounter.reminder-conflict",
"encounter.reminder-not-found",
],
encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"],
environment: [
"environment.invalid",
"environment.no_home",
+41 -2
View File
@@ -47,6 +47,7 @@ import {
errorEnvelope,
loginPasswordSchema,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
@@ -438,9 +439,24 @@ const registrationStatusResponse = z
.describe(
"Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token.",
),
sessionCookieSecure: z
.boolean()
.describe(
"Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this with its own scheme before any credential is sent.",
),
})
.meta({ id: "RegistrationStatusResponse" });
/**
* The browser sign-in refusal for a page whose session cookie cannot stick.
* Only ever returned to a caller that sent `clientTransport`.
*/
const insecureTransportResponse = {
description:
'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the iOS app, scripts) is never refused this way.',
content: { "application/json": { schema: errorEnvelope } },
};
const apiTokenInfo = z
.object({
id: z.string().describe("Row id; the handle for DELETE."),
@@ -701,6 +717,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: loginPasswordSchema } },
},
responses: {
"409": insecureTransportResponse,
"200": {
description:
"Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).",
@@ -733,6 +750,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: mfaVerifySchema } },
},
responses: {
"409": insecureTransportResponse,
"200": {
description:
"Second factor verified — session + optional bearer issued.",
@@ -1247,6 +1265,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
"409": insecureTransportResponse,
"200": {
description:
"Second factor verified — session + optional bearer issued.",
@@ -1613,8 +1632,15 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Begin a passkey sign-in (discoverable credentials)",
description:
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body, and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
requestBody: {
required: false,
content: {
"application/json": { schema: passkeyLoginOptionsSchema },
},
},
responses: {
"409": insecureTransportResponse,
"200": {
description: "Assertion options issued.",
content: {
@@ -2117,12 +2143,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.describe(
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
),
client_proto: z
.enum(["http", "https"])
.optional()
.describe(
"Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback `client_host` while the server issues `Secure` session cookies, the start redirects to `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped on return.",
),
client_host: z
.string()
.max(255)
.optional()
.describe(
"Browser leg only: `location.host` of that page, read together with `client_proto`.",
),
}),
},
responses: {
"302": {
description:
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
},
},
},
+12 -3
View File
@@ -25,15 +25,24 @@ const healthStatusEnum = z.enum(["ok", "degraded"]).meta({
"`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing.",
});
const healthPublicResponse = z.object({ status: healthStatusEnum }).meta({
id: "HealthPublicResponse",
const healthReason = z.enum(["encryption_key_mismatch"]).optional().meta({
id: "HealthReason",
description:
"What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.",
"Present only when the process refuses to serve for a reason a person has to fix. `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart.",
});
const healthPublicResponse = z
.object({ status: healthStatusEnum, reason: healthReason })
.meta({
id: "HealthPublicResponse",
description:
"What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.",
});
const healthAdminResponse = z
.object({
status: healthStatusEnum,
reason: healthReason,
timestamp: z.iso
.datetime({ offset: true })
.describe("When the probe ran. UTC (`Z`) — no user context is loaded."),
+3
View File
@@ -48,6 +48,9 @@ export const adminKeys = {
adminBackupRestores: () => ["admin", "backup-restores"] as const,
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
adminEncryptionKeyBackup: () =>
["admin", "encryption", "key-backup"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
adminFeedbackRoot: () => ["admin", "feedback"] as const,
+11
View File
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr";
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
import { clientTransportSchema } from "@/lib/validations/client-transport";
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
const PASSWORD_MIN_LENGTH = 12;
@@ -40,8 +41,18 @@ export const registerSchema = z.object({
export const loginPasswordSchema = z.object({
email: z.string().trim().min(1, "Email or username required"),
password: z.string().min(1),
clientTransport: clientTransportSchema.optional(),
});
/** `POST /api/auth/passkey/login-options`: an optional body, web client only. */
export const passkeyLoginOptionsSchema = z
.object({ clientTransport: clientTransportSchema.optional() })
.meta({
id: "PasskeyLoginOptionsRequest",
description:
"Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused before a challenge is issued. Any other caller may send no body at all.",
});
export const profileSchema = z.object({
email: z.email("Invalid email address").nullable().optional(),
heightCm: z.number().min(50).max(300).nullable().optional(),
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod/v4";
/**
* The optional `clientTransport` field the web client sends on the browser
* sign-in requests. Optional everywhere: a client that does not send it (the
* iOS app, scripts) is never refused because of it. It can only ever cause a
* refusal of its own request, so a forged value harms nobody but its sender.
*/
export const clientTransportSchema = z
.object({
protocol: z
.enum(["http", "https"])
.describe("The scheme of the page the request comes from."),
host: z
.string()
.max(255)
.describe("`location.host` of that page: hostname and optional port."),
})
.meta({
id: "ClientTransport",
description:
"Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` before any credential, ticket or code is checked or used, because the browser would drop the session cookie.",
});
+3
View File
@@ -4,6 +4,7 @@
* auth.ts`) describe the identical wire contract.
*/
import { z } from "zod/v4";
import { clientTransportSchema } from "@/lib/validations/client-transport";
/** A 6-digit TOTP code. */
export const totpCodeSchema = z
@@ -42,6 +43,7 @@ export const mfaVerifySchema = z
.describe(
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
),
clientTransport: clientTransportSchema.optional(),
})
.meta({
id: "MfaVerifyRequest",
@@ -111,6 +113,7 @@ export const mfaWebauthnLoginVerifySchema = z
.describe(
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
),
clientTransport: clientTransportSchema.optional(),
})
.meta({
id: "MfaWebauthnLoginVerifyRequest",
@@ -0,0 +1,327 @@
/**
* The boot encryption key check and the "Back up your encryption key" step,
* against real Postgres.
*
* - a first boot writes a canary; a second boot with the same key opens it;
* - a boot with another key under the same id refuses: the state is set,
* every API route answers 503 `encryption.key_mismatch`, `/api/health`
* names the reason, and nothing is written;
* - a database written before the canary existed is probed: data that does
* not open under the configured key refuses rather than sealing the wrong
* key as the right one;
* - the confirmation binds to the active key id and fingerprint, refuses a
* stale one with 409, and is due again after a re-keyed install;
* - "Check my copy" answers a boolean, stores nothing, and is rate limited.
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import {
_resetCryptoCacheForTests,
encrypt,
getKeyFingerprint,
} from "@/lib/crypto";
import { runBootKeyCheck } from "@/lib/boot/key-check";
import {
isKeyMismatch,
setKeyMismatchState,
} from "@/lib/boot/key-mismatch-state";
const KEY_A =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
async function seedAdmin(): Promise<string> {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "key-admin",
email: "key-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: admin.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return admin.id;
}
/** The GET handlers take no parameter; `apiHandler` still reads the request. */
type RouteFn = (request: NextRequest) => Promise<Response>;
const asRoute = (fn: unknown) => fn as RouteFn;
function post(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.$executeRaw`DELETE FROM encryption_key_canaries`;
await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'key-backup-verify:%'`;
cookieJar.clear();
headerJar.clear();
useKey(KEY_A);
setKeyMismatchState(null);
});
afterEach(() => {
setKeyMismatchState(null);
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
describe("boot encryption key check (real Postgres)", () => {
it("writes a canary on first boot and opens it on the next", async () => {
const prisma = getPrismaClient();
expect(await runBootKeyCheck(prisma)).toBe(false);
const rows = await prisma.encryptionKeyCanary.findMany();
expect(rows.map((r) => r.keyId)).toEqual(["v1"]);
expect(rows[0].ciphertext.startsWith("v1.")).toBe(true);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
});
it("refuses to serve when the key changed under the same id", async () => {
const prisma = getPrismaClient();
await runBootKeyCheck(prisma);
const before = await prisma.encryptionKeyCanary.findMany();
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
expect(errors.mock.calls[0]?.[0]).toContain("encryption.key_mismatch");
errors.mockRestore();
expect(isKeyMismatch()).toBe(true);
// Nothing re-sealed under the wrong key.
expect(await prisma.encryptionKeyCanary.findMany()).toEqual(before);
await seedAdmin();
const { GET: keyBackupGet } =
await import("@/app/api/admin/encryption/key-backup/route");
const refused = await asRoute(keyBackupGet)(
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
);
expect(refused.status).toBe(503);
const body = await refused.json();
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
const { GET: healthGet } = await import("@/app/api/health/route");
const health = await asRoute(healthGet)(
new NextRequest("http://localhost/api/health"),
);
expect(health.status).toBe(503);
const healthBody = await health.json();
expect(healthBody.status).toBe("degraded");
expect(healthBody.reason).toBe("encryption_key_mismatch");
// Restoring the original key clears it on the next boot.
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
});
it("refuses rather than sealing the wrong key over data that predates the canary", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "existing",
email: "existing@example.test",
codexAccessTokenEncrypted: encrypt("a stored token"),
codexRefreshTokenEncrypted: encrypt("another stored token"),
},
});
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
errors.mockRestore();
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
// With the right key the same data lets the canary be written.
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
});
describe("boot key check probe over existing data (real Postgres)", () => {
it("one damaged row among good ones still lets the right key through", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "damaged",
email: "damaged@example.test",
codexAccessTokenEncrypted:
"v1." + Buffer.alloc(40, 7).toString("base64"),
codexRefreshTokenEncrypted: encrypt("a good token"),
},
});
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
it("a single value that does not open neither refuses nor writes a canary", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "single",
email: "single@example.test",
codexAccessTokenEncrypted: encrypt("the only token"),
},
});
useKey(KEY_B);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(warn.mock.calls[0]?.[0]).toContain("inconclusive");
warn.mockRestore();
expect(isKeyMismatch()).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
});
});
describe("encryption key backup step (real Postgres)", () => {
it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => {
const prisma = getPrismaClient();
const adminId = await seedAdmin();
const { GET } = await import("@/app/api/admin/encryption/key-backup/route");
const { POST: confirm } =
await import("@/app/api/admin/encryption/key-backup/confirm/route");
const status = async () =>
(
await (
await asRoute(GET)(
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
)
).json()
).data;
const first = await status();
expect(first.due).toBe(true);
expect(first.activeKeyId).toBe("v1");
expect(first.fingerprint).toBe(getKeyFingerprint("v1"));
expect(first.platformHint).toBe("compose");
expect(JSON.stringify(first)).not.toContain(KEY_A);
const stale = await confirm(
post("/api/admin/encryption/key-backup/confirm", {
keyId: "v1",
fingerprint: "000000000000",
}),
);
expect(stale.status).toBe(409);
expect((await stale.json()).meta.errorCode).toBe(
"encryption.keyBackup.stale",
);
expect(
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
?.encryptionKeyBackupConfirmedAt ?? null,
).toBeNull();
const ok = await confirm(
post("/api/admin/encryption/key-backup/confirm", {
keyId: "v1",
fingerprint: first.fingerprint,
}),
);
expect(ok.status).toBe(200);
const confirmed = (await ok.json()).data;
expect(confirmed.due).toBe(false);
expect(confirmed.confirmedBy).toEqual({
id: adminId,
email: "key-admin@example.test",
});
const audit = await prisma.auditLog.findFirst({
where: { action: "encryption.keyBackup.confirmed" },
});
expect(audit).not.toBeNull();
vi.stubEnv("HEALTHLOG_PLATFORM", "truenas");
useKey(KEY_B);
const after = await status();
expect(after.due).toBe(true);
expect(after.confirmedKeyId).toBe("v1");
expect(after.platformHint).toBe("truenas");
});
it("checks a copy without storing it, and limits the checks", async () => {
const prisma = getPrismaClient();
await seedAdmin();
const { POST: verify } =
await import("@/app/api/admin/encryption/key-backup/verify/route");
const check = (encryptionKey: string) =>
verify(
post("/api/admin/encryption/key-backup/verify", { encryptionKey }),
);
const good = await check(`${KEY_A}\n`);
expect(good.status).toBe(200);
expect((await good.json()).data).toEqual({ matches: true, keyId: "v1" });
const bad = await check(KEY_B);
expect((await bad.json()).data.matches).toBe(false);
const invalid = await verify(
post("/api/admin/encryption/key-backup/verify", { encryptionKey: "" }),
);
expect(invalid.status).toBe(422);
expect(JSON.stringify(await invalid.json())).not.toContain("issues");
// Nothing of the candidate is stored anywhere a row could hold it.
const audits = await prisma.auditLog.findMany();
expect(JSON.stringify(audits)).not.toContain(KEY_A);
expect(JSON.stringify(audits)).not.toContain(KEY_B);
expect(
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
?.encryptionKeyBackupConfirmedAt ?? null,
).toBeNull();
await check(KEY_B);
await check(KEY_B);
const limited = await check(KEY_A);
expect(limited.status).toBe(429);
});
});
@@ -0,0 +1,221 @@
/**
* #1097 against real Postgres: a browser sign-in whose session cookie cannot
* stick (Secure-only cookies, page on plain http:// at a network address) is
* refused BEFORE it spends anything.
*
* - the password step mints no MFA ticket, creates no session and charges
* no account-throttle failure;
* - the MFA step leaves the ticket unclaimed and the TOTP code unspent, so
* the same ticket and code still complete over https;
* - the passkey start issues no challenge;
* - a request without `clientTransport` (the iOS app, scripts) is never
* refused; neither is localhost.
*/
import { NextRequest } from "next/server";
import * as OTPAuth from "otpauth";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-test-hmac-key-test-hmac-key-0123456789";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { hashPassword } = await import("@/lib/auth/password");
const { encrypt } = await import("@/lib/crypto");
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
const PASSWORD = "Correct horse battery staple!42";
const LAN_HTTP = { protocol: "http", host: "192.168.1.20:3000" };
const HTTPS = { protocol: "https", host: "healthlog.example.test" };
let secret: string;
function totpNow(): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret),
}).generate({ timestamp: Date.now() });
}
function post(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function login(clientTransport?: unknown, password = PASSWORD) {
const { POST } = await import("@/app/api/auth/login/route");
return POST(
post("/api/auth/login", {
email: "mfa@example.test",
password,
...(clientTransport ? { clientTransport } : {}),
}),
);
}
async function mfaVerify(
mfaTicket: string,
code: string,
clientTransport?: unknown,
) {
const { POST } = await import("@/app/api/auth/mfa/verify/route");
return POST(
post("/api/auth/mfa/verify", {
mfaTicket,
method: "totp",
code,
...(clientTransport ? { clientTransport } : {}),
}),
);
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.mfaChallenge.deleteMany();
await prisma.authChallenge.deleteMany();
await prisma.rateLimit.deleteMany();
cookieJar.clear();
headerJar.clear();
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
secret = generateTotpSecret();
await prisma.user.create({
data: {
id: "mfa-user",
username: "mfa-user",
email: "mfa@example.test",
passwordHash: await hashPassword(PASSWORD),
onboardingCompletedAt: new Date(),
totpSecretEncrypted: encrypt(secret),
totpConfirmedAt: new Date(),
},
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("sign-in over plain http with Secure-only cookies (real Postgres)", () => {
it("refuses the password step before a ticket, a session or a throttle charge", async () => {
const prisma = getPrismaClient();
// A wrong password too: the refusal must come before the check, so a
// wrong password cannot be told from a right one here either.
for (const password of [PASSWORD, "wrong password entirely"]) {
const res = await login(LAN_HTTP, password);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "auth.session.insecure_transport",
sessionCookieSecure: true,
});
}
expect(await prisma.mfaChallenge.count()).toBe(0);
expect(await prisma.session.count()).toBe(0);
expect(
await prisma.auditLog.count({
where: { action: { startsWith: "auth.login" } },
}),
).toBe(0);
expect(
await prisma.rateLimit.count({
where: { NOT: { key: { startsWith: "auth:login" } } },
}),
).toBe(0);
});
it("leaves the ticket and the code unspent at the MFA step", async () => {
const prisma = getPrismaClient();
const first = await login(HTTPS);
expect(first.status).toBe(200);
const ticket = (await first.json()).meta.mfaTicket as string;
expect(ticket).toBeTruthy();
const code = totpNow();
const refused = await mfaVerify(ticket, code, LAN_HTTP);
expect(refused.status).toBe(409);
expect((await refused.json()).meta.errorCode).toBe(
"auth.session.insecure_transport",
);
const challenge = await prisma.mfaChallenge.findFirstOrThrow();
expect(challenge.attempts).toBe(0);
expect(challenge.consumedAt).toBeNull();
const user = await prisma.user.findUniqueOrThrow({
where: { id: "mfa-user" },
select: { totpLastStep: true },
});
expect(user.totpLastStep).toBeNull();
// The same ticket and the same code still finish over https.
const done = await mfaVerify(ticket, code, HTTPS);
expect(done.status).toBe(200);
expect(await prisma.session.count()).toBe(1);
});
it("issues no passkey challenge for a page on plain http", async () => {
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/auth/passkey/login-options/route");
const refused = await POST(
post("/api/auth/passkey/login-options", { clientTransport: LAN_HTTP }),
);
expect(refused.status).toBe(409);
expect(await prisma.authChallenge.count()).toBe(0);
const ok = await POST(
new NextRequest("http://localhost/api/auth/passkey/login-options", {
method: "POST",
}),
);
expect(ok.status).toBe(200);
expect(await prisma.authChallenge.count()).toBe(1);
});
it("never refuses a caller that sends no transport, or localhost", async () => {
const withoutField = await login();
expect(withoutField.status).toBe(200);
expect((await withoutField.json()).meta.mfaRequired).toBe(true);
const local = await login({ protocol: "http", host: "localhost:3000" });
expect(local.status).toBe(200);
});
it("does not refuse when the server does not set Secure cookies", async () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
const res = await login(LAN_HTTP);
expect(res.status).toBe(200);
});
});
+3
View File
@@ -51,6 +51,9 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"coach_usage",
"data_backups",
"devices",
// Instance-wide, keyed by encryption key id: a canary left by one test
// would make the next test's boot check verify instead of write.
"encryption_key_canaries",
"host_metrics",
"idempotency_keys",
"integration_statuses",