From d3c672cc3f73084947dc7cc84dedaa5e5e900a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 13:32:29 +0100 Subject: [PATCH] feat(admin): replace the key backup step with a recovery kit The step confirmed one key, the active application key. A restore needs more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy written before a rotation needs the retired application key it was written under, and every session, token and share link hangs off API_TOKEN_HMAC_KEY. An operator could do everything the step asked and still be unable to read a single backup. The recovery kit lists every key a restore of this server needs, by id and fingerprint, never key material: - each id in ENCRYPTION_KEYS, plus any retired id a stored copy (innerKeyIds) or an off-host object inside the bucket's retention (OffhostBackupKeyUse) still lists, also once the server dropped it; - BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry, named by the id their objects record; - API_TOKEN_HMAC_KEY. Confirming records the set the admin was shown and refuses a stale one with 409 recovery.kit.stale. The kit is due again as soon as a key it lists is not in that set (a rotation, a new backup key, a re-keyed install); a key that merely drops out does not make it due. "Check my copy" now checks any key in the kit. Migration 0487 carries a confirmation given under the old step forward as a kit holding that key, so that part stays confirmed, and drops the four app_settings columns. It also creates the two tables the readiness evaluation uses (restore_drill_outcomes, recovery_readiness_notices). The routes move to /api/admin/recovery-kit; the reader of the keys the backups still need moves out of the encryption status route into src/lib/recovery/backup-key-needs.ts so the kit and the status view share one definition. --- e2e/today-overview.spec.ts | 2 +- messages/de.json | 123 +++-- messages/en.json | 123 +++-- messages/es.json | 123 +++-- messages/fr.json | 123 +++-- messages/it.json | 123 +++-- messages/ko.json | 123 +++-- messages/nl.json | 123 +++-- messages/pl.json | 123 +++-- messages/pt-BR.json | 123 +++-- .../0487_recovery_readiness/migration.sql | 101 ++++ prisma/schema.prisma | 79 ++- scripts/seed-demo.ts | 64 ++- src/__tests__/baselines/route-safe-json.json | 2 +- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 5 +- .../baselines/version-stamp-comments.json | 4 +- .../openapi-route-coverage-guard.test.ts | 25 +- .../encryption/key-backup/confirm/route.ts | 68 --- .../api/admin/encryption/key-backup/route.ts | 22 - .../encryption/key-backup/verify/route.ts | 64 --- src/app/api/admin/encryption/status/route.ts | 103 +--- .../api/admin/recovery-kit/confirm/route.ts | 60 ++ src/app/api/admin/recovery-kit/route.ts | 29 + .../api/admin/recovery-kit/verify/route.ts | 70 +++ src/app/page-client.tsx | 4 +- .../encryption-legacy-badge.test.tsx | 2 +- .../admin/__tests__/key-backup-card.test.tsx | 101 ---- .../__tests__/recovery-kit-card.test.tsx | 168 ++++++ src/components/admin/encryption-section.tsx | 20 +- src/components/admin/key-backup-card.tsx | 316 ----------- src/components/admin/recovery-kit-card.tsx | 519 ++++++++++++++++++ .../admin/system-status-summary.tsx | 25 +- src/components/admin/use-key-backup-status.ts | 36 -- src/components/admin/use-recovery-kit.ts | 30 + ...kup-banner.tsx => recovery-kit-banner.tsx} | 23 +- src/lib/crypto/__tests__/key-backup.test.ts | 50 -- src/lib/crypto/key-backup.ts | 142 ----- src/lib/data-wipe/wipe-plan.ts | 6 + src/lib/jobs/offhost-backup.ts | 24 +- src/lib/jobs/offhost-key.ts | 28 + src/lib/observability/redact-payload.ts | 10 +- src/lib/openapi/error-codes.ts | 3 +- src/lib/query-keys/admin.ts | 7 +- src/lib/recovery/__tests__/kit-rules.test.ts | 231 ++++++++ src/lib/recovery/backup-key-needs.ts | 99 ++++ src/lib/recovery/kit-rules.ts | 302 ++++++++++ src/lib/recovery/kit.ts | 173 ++++++ .../export-disaster-recovery.json | 8 - .../restore-disaster-recovery.json | 8 - .../backup-parity/restore-portable.json | 4 - .../backup-parity/restore-skips.json | 4 - .../backup-parity/restore-version-1.json | 8 - .../backup-parity/seeded-account.json | 4 - .../integration/encryption-key-check.test.ts | 130 +---- tests/integration/recovery-kit.test.ts | 359 ++++++++++++ 56 files changed, 3042 insertions(+), 1611 deletions(-) create mode 100644 prisma/migrations/0487_recovery_readiness/migration.sql delete mode 100644 src/app/api/admin/encryption/key-backup/confirm/route.ts delete mode 100644 src/app/api/admin/encryption/key-backup/route.ts delete mode 100644 src/app/api/admin/encryption/key-backup/verify/route.ts create mode 100644 src/app/api/admin/recovery-kit/confirm/route.ts create mode 100644 src/app/api/admin/recovery-kit/route.ts create mode 100644 src/app/api/admin/recovery-kit/verify/route.ts delete mode 100644 src/components/admin/__tests__/key-backup-card.test.tsx create mode 100644 src/components/admin/__tests__/recovery-kit-card.test.tsx delete mode 100644 src/components/admin/key-backup-card.tsx create mode 100644 src/components/admin/recovery-kit-card.tsx delete mode 100644 src/components/admin/use-key-backup-status.ts create mode 100644 src/components/admin/use-recovery-kit.ts rename src/components/dashboard/{key-backup-banner.tsx => recovery-kit-banner.tsx} (51%) delete mode 100644 src/lib/crypto/__tests__/key-backup.test.ts delete mode 100644 src/lib/crypto/key-backup.ts create mode 100644 src/lib/jobs/offhost-key.ts create mode 100644 src/lib/recovery/__tests__/kit-rules.test.ts create mode 100644 src/lib/recovery/backup-key-needs.ts create mode 100644 src/lib/recovery/kit-rules.ts create mode 100644 src/lib/recovery/kit.ts create mode 100644 tests/integration/recovery-kit.test.ts diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 1b7782963..d166d0ffc 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -255,7 +255,7 @@ test.describe("Today overview geometry", () => { const hero = await openDashboard(page, digest); const phone = viewport.width < 768; - // Late banners (the key-backup nudge) insert above the hero; let + // Late banners (the recovery kit nudge) insert above the hero; let // the page settle so the capture shows one stable layout. await page.waitForLoadState("networkidle"); mkdirSync(SHOTS, { recursive: true }); diff --git a/messages/de.json b/messages/de.json index cfe7abcc4..11ced69c4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -778,13 +778,13 @@ "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" - }, "quickAddSymptom": "Symptom erfassen", - "todayCardVisible": "Obere Karte anzeigen" + "todayCardVisible": "Obere Karte anzeigen", + "recoveryKitBanner": { + "title": "Wiederherstellungs-Kit sichern", + "body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.", + "action": "Kit öffnen" + } }, "measurements": { "title": "Messungen", @@ -8506,47 +8506,6 @@ "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. Der Schlüssel steht im Feld Encryption Key unter HealthLog Configuration; bei einer per YAML installierten App ist es ENCRYPTION_KEY in 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.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.", "gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)", "gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h." + }, + "recoveryKit": { + "title": "Wiederherstellungs-Kit", + "description": "Jeder Schlüssel, den eine Wiederherstellung dieses Servers braucht, mit Kennung und Fingerabdruck.", + "intro": "Bewahre von jedem Schlüssel unten eine Kopie außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Ohne sie lassen sich weder ein Datenbank-Dump noch eine Sicherungskopie wieder lesen, und HealthLog startet nicht, wenn ein benötigter Schlüssel fehlt.", + "loading": "Wiederherstellungs-Kit wird geladen…", + "loadError": "Das Wiederherstellungs-Kit konnte nicht geladen werden.", + "badgeDue": "Offen", + "badgeDone": "Gesichert", + "keyId": "Kennung", + "fingerprint": "Fingerabdruck", + "noFingerprint": "Nicht auf diesem Server", + "kind": { + "application": "Anwendungsschlüssel {id}", + "backup": "Schlüssel für externe Sicherungen", + "backupPrevious": "Früherer Schlüssel für externe Sicherungen", + "token": "Token-Schlüssel" + }, + "need": { + "active": "Verschlüsselt neue Daten.", + "configured": "Liegt auf diesem Server.", + "storedCopies": "Wird von {count} gespeicherten Kopien gebraucht.", + "offhostUntil": "Wird von externen Kopien bis {date} gebraucht.", + "offhostUnknown": "Wird von externen Kopien gebraucht, solange der Bucket sie aufbewahrt.", + "backupActive": "Versiegelt jede neue externe Kopie.", + "backupPrevious": "Öffnet externe Kopien, die vor dem letzten Schlüsselwechsel geschrieben wurden.", + "token": "Signiert Sitzungen, Tokens und Freigabelinks. Ohne ihn werden alle abgemeldet, Daten gehen aber nicht verloren." + }, + "source": "Gesetzt in {variable}.", + "status": { + "confirmed": "Gesichert", + "new": "Noch nicht gesichert", + "notConfigured": "Nicht auf diesem Server", + "unreadable": "Kein gültiger Schlüssel" + }, + "notConfiguredHint": "Eine Sicherung braucht diesen Schlüssel noch, aber dieser Server hat ihn nicht. Nur eine Kopie, die du anderswo aufbewahrst, kann diese Sicherungen lesen. Bestätige das Kit, sobald du diese Kopie hast.", + "unreadableHint": "Die Variable ist gesetzt, enthält aber keinen gültigen Schlüssel. Behebe das, bevor du dich auf die externen Kopien verlässt.", + "fingerprintHint": "Der Fingerabdruck besteht aus den ersten 12 Zeichen des SHA-256-Hashs eines Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, sodass du ihn mit deiner Kopie vergleichen kannst.", + "whereTitle": "Wo die Schlüssel gesetzt sind", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Die Schlüssel stehen in der .env-Datei neben docker-compose.yml: ENCRYPTION_KEY oder ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY und, mit externen Kopien, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Öffne Apps, wähle HealthLog und dann Edit. Die Schlüssel stehen unter HealthLog Configuration; bei einer per YAML installierten App sind es ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY in den Umgebungsvariablen.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Bearbeite auf dem Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_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, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Umgebungsvariablen." + }, + "restoreTitle": "Diesen Server wiederherstellen", + "restoreBody": "Installiere HealthLog mit denselben Schlüsseln, spiele den neuesten Datenbank-Dump in eine leere Datenbank ein und starte dann die App. Die Anleitung führt durch jeden Schritt.", + "restoreLink": "Anleitung zur Notfallwiederherstellung", + "checkLabel": "Meine Kopie prüfen", + "checkKeyLabel": "Zu prüfender Schlüssel", + "checkValueLabel": "Deine Kopie des Schlüssels", + "checkButton": "Prüfen", + "checkNote": "Der eingefügte Schlüssel wird mit dem auf diesem Server verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.", + "checkInsecureNote": "Diese Seite wird nicht über https:// ausgeliefert. Der eingefügte Schlüssel geht deshalb unverschlüsselt über das Netzwerk, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.", + "checkMatches": "Deine Kopie passt zu diesem Schlüssel.", + "checkNoMatch": "Deine Kopie passt nicht zu diesem Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.", + "checkFailed": "Die Prüfung ist fehlgeschlagen.", + "confirmButton": "Ich habe von jedem Schlüssel eine Kopie", + "confirmed": "Wiederherstellungs-Kit bestätigt.", + "confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.", + "stale": "Das Kit hat sich geändert, seit die Seite geladen wurde. Prüfe die Schlüssel, die es jetzt aufführt, und bestätige erneut.", + "confirmedAt": "Bestätigt am {date}.", + "confirmedAtBy": "Bestätigt am {date} von {who}.", + "dueAgain": "Das Kit wurde am {date} bestätigt, führt jetzt aber Schlüssel auf, die diese Bestätigung nicht abdeckt: {keys}." } }, "achievements": { diff --git a/messages/en.json b/messages/en.json index 9e7fa4317..24d7846e2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -778,13 +778,13 @@ "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" - }, "quickAddSymptom": "Log symptom", - "todayCardVisible": "Show the top card" + "todayCardVisible": "Show the top card", + "recoveryKitBanner": { + "title": "Save your recovery kit", + "body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.", + "action": "Open the kit" + } }, "measurements": { "title": "Measurements", @@ -8506,47 +8506,6 @@ "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 and choose Edit. The key is the Encryption Key field under HealthLog Configuration; in an app installed via YAML, it is ENCRYPTION_KEY in 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.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "The default level cannot be above the highest level.", "gapLabel": "Hours between card refreshes after a sync (Standard)", "gapHint": "Whole hours from 1 to 48. Leave empty to use AI_SYNC_REGEN_GAP_HOURS, else 6. In effect now: {hours} h." + }, + "recoveryKit": { + "title": "Recovery kit", + "description": "Every key a restore of this server needs, by id and fingerprint.", + "intro": "Keep a copy of each key below outside this server, for example in a password manager. Without them, neither a database dump nor a backup copy can be read again, and HealthLog refuses to start if a key it needs is missing.", + "loading": "Loading the recovery kit…", + "loadError": "Could not load the recovery kit.", + "badgeDue": "To do", + "badgeDone": "Saved", + "keyId": "Id", + "fingerprint": "Fingerprint", + "noFingerprint": "Not on this server", + "kind": { + "application": "Application key {id}", + "backup": "Off-host backup key", + "backupPrevious": "Previous off-host backup key", + "token": "Token key" + }, + "need": { + "active": "Encrypts new data.", + "configured": "Held by this server.", + "storedCopies": "Needed by {count} stored copies.", + "offhostUntil": "Needed by off-host copies until {date}.", + "offhostUnknown": "Needed by off-host copies for as long as the bucket keeps them.", + "backupActive": "Seals every new off-host copy.", + "backupPrevious": "Opens off-host copies written before the last key change.", + "token": "Signs sessions, tokens and share links. Without it everyone is signed out, but no data is lost." + }, + "source": "Set in {variable}.", + "status": { + "confirmed": "Saved", + "new": "Not saved yet", + "notConfigured": "Not on this server", + "unreadable": "Not a valid key" + }, + "notConfiguredHint": "A backup still needs this key and this server does not hold it. Only a copy you keep elsewhere can read those backups. Confirm the kit once you have that copy.", + "unreadableHint": "The variable is set but does not hold a valid key. Fix it before you rely on the off-host copies.", + "fingerprintHint": "A fingerprint is the first 12 characters of the SHA-256 hash of a key. It identifies the key without revealing it, so you can compare it with your copy.", + "whereTitle": "Where the keys are set", + "platform": { + "composeLabel": "Compose", + "composeWhere": "The keys are in the .env file next to docker-compose.yml: ENCRYPTION_KEY or ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY and, with off-host copies, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Open Apps, select HealthLog and choose Edit. The keys are under HealthLog Configuration; in an app installed via YAML, they are ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY in the environment settings.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from its Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_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, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from its environment variables." + }, + "restoreTitle": "Restoring this server", + "restoreBody": "Install HealthLog with the same keys, restore the newest database dump into an empty database, then start the app. The guide walks through each step.", + "restoreLink": "Disaster recovery guide", + "checkLabel": "Check my copy", + "checkKeyLabel": "Key to check", + "checkValueLabel": "Your copy of the key", + "checkButton": "Check", + "checkNote": "The key you paste is compared with the one this server holds 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 this key.", + "checkNoMatch": "Your copy does not match this key. Check that you copied the whole value.", + "checkFailed": "The check failed.", + "confirmButton": "I have a copy of every key", + "confirmed": "Recovery kit confirmed.", + "confirmFailed": "Could not save the confirmation.", + "stale": "The kit changed since this page loaded. Check the keys it lists now, then confirm again.", + "confirmedAt": "Confirmed on {date}.", + "confirmedAtBy": "Confirmed on {date} by {who}.", + "dueAgain": "The kit was confirmed on {date}, but it now lists keys that confirmation does not cover: {keys}." } }, "achievements": { diff --git a/messages/es.json b/messages/es.json index 55dd09b69..6f3591693 100644 --- a/messages/es.json +++ b/messages/es.json @@ -778,13 +778,13 @@ "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" - }, "quickAddSymptom": "Registrar síntoma", - "todayCardVisible": "Mostrar la tarjeta superior" + "todayCardVisible": "Mostrar la tarjeta superior", + "recoveryKitBanner": { + "title": "Guarda tu kit de recuperación", + "body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.", + "action": "Abrir el kit" + } }, "measurements": { "title": "Mediciones", @@ -8506,47 +8506,6 @@ "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 y elige Editar. La clave está en el campo Encryption Key de HealthLog Configuration; en una app instalada con YAML, es ENCRYPTION_KEY en 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.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "El nivel predeterminado no puede estar por encima del nivel más alto.", "gapLabel": "Horas entre actualizaciones de tarjetas tras una sincronización (Estándar)", "gapHint": "Horas enteras de 1 a 48. Déjalo vacío para usar AI_SYNC_REGEN_GAP_HOURS, si no 6. Vigente ahora: {hours} h." + }, + "recoveryKit": { + "title": "Kit de recuperación", + "description": "Cada clave que necesita una restauración de este servidor, con su ID y su huella.", + "intro": "Guarda una copia de cada clave de abajo fuera de este servidor, por ejemplo en un gestor de contraseñas. Sin ellas no se puede volver a leer ni un volcado de la base de datos ni una copia de seguridad, y HealthLog se niega a arrancar si falta una clave que necesita.", + "loading": "Cargando el kit de recuperación…", + "loadError": "No se pudo cargar el kit de recuperación.", + "badgeDue": "Pendiente", + "badgeDone": "Guardado", + "keyId": "ID", + "fingerprint": "Huella", + "noFingerprint": "No está en este servidor", + "kind": { + "application": "Clave de aplicación {id}", + "backup": "Clave de las copias fuera del host", + "backupPrevious": "Clave anterior de las copias fuera del host", + "token": "Clave de tokens" + }, + "need": { + "active": "Cifra los datos nuevos.", + "configured": "Está en este servidor.", + "storedCopies": "La necesitan {count} copias guardadas.", + "offhostUntil": "La necesitan las copias fuera del host hasta el {date}.", + "offhostUnknown": "La necesitan las copias fuera del host mientras el bucket las conserve.", + "backupActive": "Sella cada nueva copia fuera del host.", + "backupPrevious": "Abre las copias fuera del host escritas antes del último cambio de clave.", + "token": "Firma sesiones, tokens y enlaces para compartir. Sin ella se cierra la sesión de todo el mundo, pero no se pierden datos." + }, + "source": "Definida en {variable}.", + "status": { + "confirmed": "Guardada", + "new": "Aún sin guardar", + "notConfigured": "No está en este servidor", + "unreadable": "No es una clave válida" + }, + "notConfiguredHint": "Una copia de seguridad aún necesita esta clave y este servidor no la tiene. Solo una copia que guardes en otro lugar puede leer esas copias de seguridad. Confirma el kit cuando tengas esa copia.", + "unreadableHint": "La variable está definida pero no contiene una clave válida. Corrígela antes de confiar en las copias fuera del host.", + "fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de una clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.", + "whereTitle": "Dónde se definen las claves", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Las claves están en el archivo .env junto a docker-compose.yml: ENCRYPTION_KEY o ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY y, con copias fuera del host, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Abre Apps, selecciona HealthLog y elige Edit. Las claves están en HealthLog Configuration; en una app instalada con YAML, son ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY en los ajustes de entorno.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de sus Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_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, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de sus variables de entorno." + }, + "restoreTitle": "Restaurar este servidor", + "restoreBody": "Instala HealthLog con las mismas claves, restaura el volcado de la base de datos más reciente en una base de datos vacía y luego arranca la app. La guía explica cada paso.", + "restoreLink": "Guía de recuperación ante desastres", + "checkLabel": "Comprobar mi copia", + "checkKeyLabel": "Clave que comprobar", + "checkValueLabel": "Tu copia de la clave", + "checkButton": "Comprobar", + "checkNote": "La clave que pegues se compara con la que tiene este servidor 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 esta clave.", + "checkNoMatch": "Tu copia no coincide con esta clave. Comprueba que copiaste el valor completo.", + "checkFailed": "La comprobación ha fallado.", + "confirmButton": "Tengo una copia de cada clave", + "confirmed": "Kit de recuperación confirmado.", + "confirmFailed": "No se pudo guardar la confirmación.", + "stale": "El kit cambió desde que se cargó esta página. Revisa las claves que muestra ahora y vuelve a confirmar.", + "confirmedAt": "Confirmado el {date}.", + "confirmedAtBy": "Confirmado el {date} por {who}.", + "dueAgain": "El kit se confirmó el {date}, pero ahora muestra claves que esa confirmación no cubre: {keys}." } }, "achievements": { diff --git a/messages/fr.json b/messages/fr.json index d74a6043b..6fd4d7820 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -778,13 +778,13 @@ "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" - }, "quickAddSymptom": "Noter un symptôme", - "todayCardVisible": "Afficher la carte du haut" + "todayCardVisible": "Afficher la carte du haut", + "recoveryKitBanner": { + "title": "Sauvegarde ton kit de récupération", + "body": "Sans copies des clés utilisées par ce serveur, ses données ne peuvent pas être récupérées après une réinstallation. Ça prend quelques minutes.", + "action": "Ouvrir le kit" + } }, "measurements": { "title": "Mesures", @@ -8506,47 +8506,6 @@ "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 et choisis Modifier. La clé se trouve dans le champ Encryption Key sous HealthLog Configuration ; pour une app installée via YAML, c'est 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é.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "Le niveau par défaut ne peut pas dépasser le niveau le plus élevé.", "gapLabel": "Heures entre deux mises à jour des cartes après une synchronisation (Standard)", "gapHint": "Heures entières de 1 à 48. Laissez vide pour utiliser AI_SYNC_REGEN_GAP_HOURS, sinon 6. En vigueur : {hours} h." + }, + "recoveryKit": { + "title": "Kit de récupération", + "description": "Chaque clé nécessaire à la restauration de ce serveur, avec son ID et son empreinte.", + "intro": "Garde une copie de chaque clé ci-dessous en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Sans elles, ni un dump de la base de données ni une copie de sauvegarde ne peuvent être relus, et HealthLog refuse de démarrer s'il manque une clé dont il a besoin.", + "loading": "Chargement du kit de récupération…", + "loadError": "Impossible de charger le kit de récupération.", + "badgeDue": "À faire", + "badgeDone": "Sauvegardé", + "keyId": "ID", + "fingerprint": "Empreinte", + "noFingerprint": "Absente de ce serveur", + "kind": { + "application": "Clé d'application {id}", + "backup": "Clé des sauvegardes hors hôte", + "backupPrevious": "Ancienne clé des sauvegardes hors hôte", + "token": "Clé des jetons" + }, + "need": { + "active": "Chiffre les nouvelles données.", + "configured": "Présente sur ce serveur.", + "storedCopies": "Nécessaire à {count} copies stockées.", + "offhostUntil": "Nécessaire aux copies hors hôte jusqu'au {date}.", + "offhostUnknown": "Nécessaire aux copies hors hôte tant que le bucket les conserve.", + "backupActive": "Scelle chaque nouvelle copie hors hôte.", + "backupPrevious": "Ouvre les copies hors hôte écrites avant le dernier changement de clé.", + "token": "Signe les sessions, les jetons et les liens de partage. Sans elle, tout le monde est déconnecté, mais aucune donnée n'est perdue." + }, + "source": "Définie dans {variable}.", + "status": { + "confirmed": "Sauvegardée", + "new": "Pas encore sauvegardée", + "notConfigured": "Absente de ce serveur", + "unreadable": "Clé non valide" + }, + "notConfiguredHint": "Une sauvegarde a encore besoin de cette clé et ce serveur ne l'a pas. Seule une copie que tu conserves ailleurs peut lire ces sauvegardes. Confirme le kit une fois que tu as cette copie.", + "unreadableHint": "La variable est définie mais ne contient pas de clé valide. Corrige-la avant de te fier aux copies hors hôte.", + "fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 d'une clé. Elle identifie la clé sans la révéler, pour que tu puisses la comparer à ta copie.", + "whereTitle": "Où les clés sont définies", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Les clés se trouvent dans le fichier .env à côté de docker-compose.yml : ENCRYPTION_KEY ou ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY et, avec les copies hors hôte, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Ouvre Apps, sélectionne HealthLog et choisis Edit. Les clés se trouvent sous HealthLog Configuration ; pour une app installée via YAML, ce sont ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY dans les paramètres d'environnement.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis ses Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Dans l'onglet Docker, modifie le conteneur HealthLog et copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_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, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis ses variables d'environnement." + }, + "restoreTitle": "Restaurer ce serveur", + "restoreBody": "Installe HealthLog avec les mêmes clés, restaure le dump de la base de données le plus récent dans une base vide, puis démarre l'app. Le guide détaille chaque étape.", + "restoreLink": "Guide de reprise après sinistre", + "checkLabel": "Vérifier ma copie", + "checkKeyLabel": "Clé à vérifier", + "checkValueLabel": "Ta copie de la clé", + "checkButton": "Vérifier", + "checkNote": "La clé collée est comparée à celle que détient ce serveur 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 à cette clé.", + "checkNoMatch": "Ta copie ne correspond pas à cette clé. Vérifie que tu as copié toute la valeur.", + "checkFailed": "La vérification a échoué.", + "confirmButton": "J'ai une copie de chaque clé", + "confirmed": "Kit de récupération confirmé.", + "confirmFailed": "Impossible d'enregistrer la confirmation.", + "stale": "Le kit a changé depuis le chargement de cette page. Vérifie les clés qu'il liste maintenant, puis confirme à nouveau.", + "confirmedAt": "Confirmé le {date}.", + "confirmedAtBy": "Confirmé le {date} par {who}.", + "dueAgain": "Le kit a été confirmé le {date}, mais il liste maintenant des clés que cette confirmation ne couvre pas : {keys}." } }, "achievements": { diff --git a/messages/it.json b/messages/it.json index b1de9b105..ee227f3a3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -778,13 +778,13 @@ "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" - }, "quickAddSymptom": "Registra sintomo", - "todayCardVisible": "Mostra la scheda in alto" + "todayCardVisible": "Mostra la scheda in alto", + "recoveryKitBanner": { + "title": "Salva il tuo kit di recupero", + "body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.", + "action": "Apri il kit" + } }, "measurements": { "title": "Misurazioni", @@ -8506,47 +8506,6 @@ "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 e scegli Modifica. La chiave è nel campo Encryption Key sotto HealthLog Configuration; in un'app installata tramite YAML è ENCRYPTION_KEY nelle 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.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "Il livello predefinito non può superare il livello più alto.", "gapLabel": "Ore tra due aggiornamenti delle schede dopo una sincronizzazione (Standard)", "gapHint": "Ore intere da 1 a 48. Lascia vuoto per usare AI_SYNC_REGEN_GAP_HOURS, altrimenti 6. In vigore ora: {hours} h." + }, + "recoveryKit": { + "title": "Kit di recupero", + "description": "Ogni chiave necessaria per ripristinare questo server, con ID e impronta.", + "intro": "Conserva una copia di ciascuna chiave qui sotto fuori da questo server, per esempio in un gestore di password. Senza di esse non si possono più leggere né un dump del database né una copia di backup, e HealthLog si rifiuta di avviarsi se manca una chiave di cui ha bisogno.", + "loading": "Caricamento del kit di recupero…", + "loadError": "Impossibile caricare il kit di recupero.", + "badgeDue": "Da fare", + "badgeDone": "Salvato", + "keyId": "ID", + "fingerprint": "Impronta", + "noFingerprint": "Non presente su questo server", + "kind": { + "application": "Chiave dell'applicazione {id}", + "backup": "Chiave dei backup fuori host", + "backupPrevious": "Chiave precedente dei backup fuori host", + "token": "Chiave dei token" + }, + "need": { + "active": "Cifra i nuovi dati.", + "configured": "Presente su questo server.", + "storedCopies": "Serve a {count} copie archiviate.", + "offhostUntil": "Serve alle copie fuori host fino al {date}.", + "offhostUnknown": "Serve alle copie fuori host finché il bucket le conserva.", + "backupActive": "Sigilla ogni nuova copia fuori host.", + "backupPrevious": "Apre le copie fuori host scritte prima dell'ultimo cambio di chiave.", + "token": "Firma sessioni, token e link di condivisione. Senza di essa tutti vengono disconnessi, ma nessun dato va perso." + }, + "source": "Impostata in {variable}.", + "status": { + "confirmed": "Salvata", + "new": "Non ancora salvata", + "notConfigured": "Non presente su questo server", + "unreadable": "Chiave non valida" + }, + "notConfiguredHint": "Un backup ha ancora bisogno di questa chiave e questo server non ce l'ha. Solo una copia che conservi altrove può leggere quei backup. Conferma il kit quando hai quella copia.", + "unreadableHint": "La variabile è impostata ma non contiene una chiave valida. Correggila prima di fare affidamento sulle copie fuori host.", + "fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 di una chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.", + "whereTitle": "Dove sono impostate le chiavi", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Le chiavi sono nel file .env accanto a docker-compose.yml: ENCRYPTION_KEY o ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY e, con le copie fuori host, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Apri Apps, seleziona HealthLog e scegli Edit. Le chiavi sono sotto HealthLog Configuration; in un'app installata tramite YAML sono ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY nelle impostazioni dell'ambiente.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dalle sue Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_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, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dalle sue variabili d'ambiente." + }, + "restoreTitle": "Ripristinare questo server", + "restoreBody": "Installa HealthLog con le stesse chiavi, ripristina il dump del database più recente in un database vuoto, poi avvia l'app. La guida ti accompagna passo per passo.", + "restoreLink": "Guida al ripristino dopo un disastro", + "checkLabel": "Verifica la mia copia", + "checkKeyLabel": "Chiave da verificare", + "checkValueLabel": "La tua copia della chiave", + "checkButton": "Verifica", + "checkNote": "La chiave incollata viene confrontata con quella presente su questo server 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 a questa chiave.", + "checkNoMatch": "La tua copia non corrisponde a questa chiave. Controlla di aver copiato l'intero valore.", + "checkFailed": "La verifica non è riuscita.", + "confirmButton": "Ho una copia di ogni chiave", + "confirmed": "Kit di recupero confermato.", + "confirmFailed": "Impossibile salvare la conferma.", + "stale": "Il kit è cambiato da quando questa pagina è stata caricata. Controlla le chiavi che elenca ora, poi conferma di nuovo.", + "confirmedAt": "Confermato il {date}.", + "confirmedAtBy": "Confermato il {date} da {who}.", + "dueAgain": "Il kit è stato confermato il {date}, ma ora elenca chiavi che quella conferma non copre: {keys}." } }, "achievements": { diff --git a/messages/ko.json b/messages/ko.json index 3fbdc541b..26eafeeef 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -778,13 +778,13 @@ "bmi": "kg/m²" } }, - "keyBackupBanner": { - "title": "암호화 키를 백업하세요", - "body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.", - "action": "단계 열기" - }, "quickAddSymptom": "증상 기록", - "todayCardVisible": "상단 카드 표시" + "todayCardVisible": "상단 카드 표시", + "recoveryKitBanner": { + "title": "복구 키트를 보관하세요", + "body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.", + "action": "키트 열기" + } }, "measurements": { "title": "측정", @@ -8506,47 +8506,6 @@ "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을 누르세요. 키는 HealthLog Configuration 아래의 Encryption Key 필드에 있습니다. YAML로 설치한 앱이라면 환경 설정의 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는 아무것도 제공하지 않습니다.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "기본 단계는 최고 단계보다 높을 수 없어요.", "gapLabel": "동기화 후 카드 새로 고침 간격(시간, 표준 단계)", "gapHint": "1에서 48 사이의 정수 시간이에요. 비워 두면 AI_SYNC_REGEN_GAP_HOURS를, 없으면 6을 사용해요. 현재 적용 값: {hours}시간." + }, + "recoveryKit": { + "title": "복구 키트", + "description": "이 서버를 복원하는 데 필요한 모든 키를 ID와 지문으로 보여 줍니다.", + "intro": "아래 각 키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 키가 없으면 데이터베이스 덤프도 백업 사본도 다시 읽을 수 없으며, 필요한 키가 없으면 HealthLog는 시작하지 않습니다.", + "loading": "복구 키트를 불러오는 중…", + "loadError": "복구 키트를 불러오지 못했습니다.", + "badgeDue": "해야 함", + "badgeDone": "보관됨", + "keyId": "ID", + "fingerprint": "지문", + "noFingerprint": "이 서버에 없음", + "kind": { + "application": "애플리케이션 키 {id}", + "backup": "외부 보관 백업 키", + "backupPrevious": "이전 외부 보관 백업 키", + "token": "토큰 키" + }, + "need": { + "active": "새 데이터를 암호화합니다.", + "configured": "이 서버에 있습니다.", + "storedCopies": "저장된 사본 {count}개에 필요합니다.", + "offhostUntil": "{date}까지 외부 보관 사본에 필요합니다.", + "offhostUnknown": "버킷이 외부 보관 사본을 보관하는 동안 필요합니다.", + "backupActive": "새 외부 보관 사본을 모두 봉인합니다.", + "backupPrevious": "마지막 키 변경 전에 기록된 외부 보관 사본을 엽니다.", + "token": "세션, 토큰, 공유 링크에 서명합니다. 이 키가 없으면 모두 로그아웃되지만 데이터는 잃지 않습니다." + }, + "source": "{variable}에 설정됨.", + "status": { + "confirmed": "보관됨", + "new": "아직 보관 안 됨", + "notConfigured": "이 서버에 없음", + "unreadable": "유효한 키가 아님" + }, + "notConfiguredHint": "아직 이 키가 필요한 백업이 있지만 이 서버에는 키가 없습니다. 다른 곳에 보관한 사본만 그 백업을 읽을 수 있습니다. 그 사본을 확보한 뒤 키트를 확인하세요.", + "unreadableHint": "변수는 설정되어 있지만 유효한 키가 들어 있지 않습니다. 외부 보관 사본에 의존하기 전에 고치세요.", + "fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.", + "whereTitle": "키가 설정된 위치", + "platform": { + "composeLabel": "Compose", + "composeWhere": "키는 docker-compose.yml 옆 .env 파일에 있습니다: ENCRYPTION_KEY 또는 ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY, 그리고 외부 보관 사본을 쓰는 경우 BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누르세요. 키는 HealthLog Configuration 아래에 있습니다. YAML로 설치한 앱이라면 환경 설정의 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY입니다.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요.", + "unraidLabel": "Unraid", + "unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요.", + "portainerLabel": "Portainer", + "portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요." + }, + "restoreTitle": "이 서버 복원하기", + "restoreBody": "같은 키로 HealthLog를 설치하고, 가장 최근의 데이터베이스 덤프를 빈 데이터베이스에 복원한 다음 앱을 시작하세요. 가이드에서 단계별로 안내합니다.", + "restoreLink": "재해 복구 가이드", + "checkLabel": "내 사본 확인", + "checkKeyLabel": "확인할 키", + "checkValueLabel": "내가 보관한 키 사본", + "checkButton": "확인", + "checkNote": "붙여넣은 키는 이 서버의 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.", + "checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.", + "checkMatches": "사본이 이 키와 일치합니다.", + "checkNoMatch": "사본이 이 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.", + "checkFailed": "확인하지 못했습니다.", + "confirmButton": "모든 키의 사본을 보관했습니다", + "confirmed": "복구 키트가 확인되었습니다.", + "confirmFailed": "확인을 저장하지 못했습니다.", + "stale": "이 페이지를 연 뒤 키트가 바뀌었습니다. 지금 표시된 키를 확인한 뒤 다시 확인하세요.", + "confirmedAt": "{date}에 확인됨.", + "confirmedAtBy": "{date}에 {who}님이 확인함.", + "dueAgain": "키트는 {date}에 확인되었지만 지금은 그 확인에 포함되지 않은 키가 있습니다: {keys}." } }, "achievements": { diff --git a/messages/nl.json b/messages/nl.json index af2efc0a4..1bf30323a 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -778,13 +778,13 @@ "bmi": "kg/m²" } }, - "keyBackupBanner": { - "title": "Maak een back-up van je versleutelingssleutel", - "body": "Zonder een kopie van de sleutel kunnen je gegevens na een herinstallatie niet worden hersteld. Het kost een minuutje.", - "action": "Stap openen" - }, "quickAddSymptom": "Symptoom vastleggen", - "todayCardVisible": "Bovenste kaart tonen" + "todayCardVisible": "Bovenste kaart tonen", + "recoveryKitBanner": { + "title": "Bewaar je herstelkit", + "body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.", + "action": "Kit openen" + } }, "measurements": { "title": "Metingen", @@ -8506,47 +8506,6 @@ "title": "Dit deel van de pagina kon niet worden weergegeven", "description": "De rest van de pagina werkt nog; probeer het opnieuw of herlaad de pagina als het blijft mislukken." }, - "keyBackup": { - "title": "Maak een back-up van je versleutelingssleutel", - "description": "Zonder een kopie van deze sleutel kunnen je versleutelde gegevens en de back-ups van deze server niet meer worden gelezen.", - "loading": "Status van de sleutelback-up laden…", - "loadError": "De status van de sleutelback-up kon niet worden geladen.", - "badgeDue": "Te doen", - "badgeDone": "Back-up gemaakt", - "keyId": "Actieve sleutel-ID", - "fingerprint": "Vingerafdruk", - "whatIsLost": "Bewaar een kopie van de sleutel ergens buiten deze server, bijvoorbeeld in een wachtwoordmanager. Als de server opnieuw wordt geïnstalleerd met een andere sleutel, weigert HealthLog te starten tot de oorspronkelijke sleutel terug is.", - "fingerprintHint": "De vingerafdruk bestaat uit de eerste 12 tekens van de SHA-256-hash van de sleutel. Hij identificeert de sleutel zonder deze prijs te geven, zodat je hem met je kopie kunt vergelijken.", - "checkLabel": "Mijn kopie controleren", - "checkButton": "Controleren", - "checkNote": "De sleutel die je plakt, wordt vergeleken met de actieve sleutel en daarna weggegooid. Hij wordt nooit opgeslagen of gelogd.", - "checkInsecureNote": "Deze pagina wordt niet via https:// geserveerd, dus de sleutel die je plakt gaat onversleuteld over het netwerk, net als je wachtwoord. Hij wordt vergeleken en weggegooid, nooit opgeslagen of gelogd.", - "checkMatches": "Je kopie komt overeen met de actieve sleutel.", - "checkNoMatch": "Je kopie komt niet overeen met de actieve sleutel. Controleer of je de hele waarde hebt gekopieerd.", - "checkFailed": "De controle is mislukt.", - "confirmButton": "Ik heb een back-up van deze sleutel gemaakt", - "confirmed": "Sleutelback-up bevestigd.", - "confirmFailed": "De bevestiging kon niet worden opgeslagen.", - "stale": "De sleutel van de server is gewijzigd sinds deze pagina is geladen. Controleer de nieuwe sleutel-ID en vingerafdruk en bevestig daarna opnieuw.", - "confirmedAt": "Bevestigd op {date}.", - "confirmedAtBy": "Bevestigd op {date} door {who}.", - "dueAgain": "Er is een back-up bevestigd voor sleutel {keyId}, maar de server gebruikt nu een andere sleutel. Maak een back-up van de huidige sleutel.", - "tileLabel": "Back-up van versleutelingssleutel", - "tileDue": "Niet bevestigd", - "tileDone": "Bevestigd", - "platform": { - "composeLabel": "Compose", - "composeWhere": "De sleutel is ENCRYPTION_KEY (of ENCRYPTION_KEYS) in het .env-bestand naast docker-compose.yml.", - "truenasLabel": "TrueNAS", - "truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutel staat in het veld Encryption Key onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, is het ENCRYPTION_KEY in de omgevingsinstellingen.", - "coolifyLabel": "Coolify", - "coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY uit de Environment Variables.", - "unraidLabel": "Unraid", - "unraidWhere": "Bewerk op het tabblad Docker de HealthLog-container en kopieer ENCRYPTION_KEY uit de template, of uit het .env-bestand van je Compose Manager-stack.", - "portainerLabel": "Portainer", - "portainerWhere": "Open de HealthLog-stack in Portainer en kopieer ENCRYPTION_KEY uit de omgevingsvariabelen." - } - }, "keyMismatch": { "title": "HealthLog kan zijn gegevens niet openen", "body": "De versleutelingssleutel waarmee deze server is gestart, is niet de sleutel waarmee de database is geschreven. Om je gegevens te beschermen, levert HealthLog niets uit totdat dit is opgelost.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "Het standaardniveau kan niet hoger zijn dan het hoogste niveau.", "gapLabel": "Uren tussen het vernieuwen van kaarten na een synchronisatie (Standaard)", "gapHint": "Hele uren van 1 tot 48. Laat leeg om AI_SYNC_REGEN_GAP_HOURS te gebruiken, anders 6. Nu van kracht: {hours} u." + }, + "recoveryKit": { + "title": "Herstelkit", + "description": "Elke sleutel die nodig is om deze server terug te zetten, met ID en vingerafdruk.", + "intro": "Bewaar van elke sleutel hieronder een kopie buiten deze server, bijvoorbeeld in een wachtwoordmanager. Zonder die kopieën kunnen een databasedump en een back-upkopie niet meer worden gelezen, en HealthLog weigert te starten als een benodigde sleutel ontbreekt.", + "loading": "Herstelkit laden…", + "loadError": "De herstelkit kon niet worden geladen.", + "badgeDue": "Te doen", + "badgeDone": "Bewaard", + "keyId": "ID", + "fingerprint": "Vingerafdruk", + "noFingerprint": "Niet op deze server", + "kind": { + "application": "Applicatiesleutel {id}", + "backup": "Sleutel voor externe back-ups", + "backupPrevious": "Vorige sleutel voor externe back-ups", + "token": "Tokensleutel" + }, + "need": { + "active": "Versleutelt nieuwe gegevens.", + "configured": "Staat op deze server.", + "storedCopies": "Nodig voor {count} opgeslagen kopieën.", + "offhostUntil": "Nodig voor externe kopieën tot {date}.", + "offhostUnknown": "Nodig voor externe kopieën zolang de bucket ze bewaart.", + "backupActive": "Verzegelt elke nieuwe externe kopie.", + "backupPrevious": "Opent externe kopieën die vóór de laatste sleutelwissel zijn geschreven.", + "token": "Ondertekent sessies, tokens en deellinks. Zonder deze sleutel wordt iedereen afgemeld, maar gaan er geen gegevens verloren." + }, + "source": "Ingesteld in {variable}.", + "status": { + "confirmed": "Bewaard", + "new": "Nog niet bewaard", + "notConfigured": "Niet op deze server", + "unreadable": "Geen geldige sleutel" + }, + "notConfiguredHint": "Een back-up heeft deze sleutel nog nodig, maar deze server heeft hem niet. Alleen een kopie die je elders bewaart, kan die back-ups lezen. Bevestig de kit zodra je die kopie hebt.", + "unreadableHint": "De variabele is ingesteld, maar bevat geen geldige sleutel. Los dat op voordat je op de externe kopieën vertrouwt.", + "fingerprintHint": "De vingerafdruk bestaat uit de eerste 12 tekens van de SHA-256-hash van een sleutel. Hij identificeert de sleutel zonder deze prijs te geven, zodat je hem met je kopie kunt vergelijken.", + "whereTitle": "Waar de sleutels zijn ingesteld", + "platform": { + "composeLabel": "Compose", + "composeWhere": "De sleutels staan in het .env-bestand naast docker-compose.yml: ENCRYPTION_KEY of ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY en, bij externe kopieën, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutels staan onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, zijn het ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY in de omgevingsinstellingen.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Bewerk op het tabblad Docker de HealthLog-container en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de template, of uit het .env-bestand van je Compose Manager-stack.", + "portainerLabel": "Portainer", + "portainerWhere": "Open de HealthLog-stack in Portainer en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de omgevingsvariabelen." + }, + "restoreTitle": "Deze server terugzetten", + "restoreBody": "Installeer HealthLog met dezelfde sleutels, zet de nieuwste databasedump terug in een lege database en start dan de app. De handleiding loopt elke stap door.", + "restoreLink": "Handleiding voor noodherstel", + "checkLabel": "Mijn kopie controleren", + "checkKeyLabel": "Te controleren sleutel", + "checkValueLabel": "Jouw kopie van de sleutel", + "checkButton": "Controleren", + "checkNote": "De sleutel die je plakt, wordt vergeleken met die op deze server en daarna weggegooid. Hij wordt nooit opgeslagen of gelogd.", + "checkInsecureNote": "Deze pagina wordt niet via https:// geserveerd, dus de sleutel die je plakt gaat onversleuteld over het netwerk, net als je wachtwoord. Hij wordt vergeleken en weggegooid, nooit opgeslagen of gelogd.", + "checkMatches": "Je kopie komt overeen met deze sleutel.", + "checkNoMatch": "Je kopie komt niet overeen met deze sleutel. Controleer of je de hele waarde hebt gekopieerd.", + "checkFailed": "De controle is mislukt.", + "confirmButton": "Ik heb een kopie van elke sleutel", + "confirmed": "Herstelkit bevestigd.", + "confirmFailed": "De bevestiging kon niet worden opgeslagen.", + "stale": "De kit is gewijzigd sinds deze pagina is geladen. Controleer de sleutels die er nu in staan en bevestig daarna opnieuw.", + "confirmedAt": "Bevestigd op {date}.", + "confirmedAtBy": "Bevestigd op {date} door {who}.", + "dueAgain": "De kit is bevestigd op {date}, maar bevat nu sleutels die niet onder die bevestiging vallen: {keys}." } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index b0dec38cc..9f07e3f4f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -778,13 +778,13 @@ "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" - }, "quickAddSymptom": "Zapisz objaw", - "todayCardVisible": "Pokaż górną kartę" + "todayCardVisible": "Pokaż górną kartę", + "recoveryKitBanner": { + "title": "Zapisz zestaw odzyskiwania", + "body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.", + "action": "Otwórz zestaw" + } }, "measurements": { "title": "Pomiary", @@ -8506,47 +8506,6 @@ "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 i kliknij Edytuj. Klucz znajduje się w polu Encryption Key w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY w 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.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "Poziom domyślny nie może być wyższy niż najwyższy poziom.", "gapLabel": "Godziny między odświeżeniami kart po synchronizacji (Standardowy)", "gapHint": "Pełne godziny od 1 do 48. Zostaw puste, aby użyć AI_SYNC_REGEN_GAP_HOURS, w przeciwnym razie 6. Obecnie obowiązuje: {hours} h." + }, + "recoveryKit": { + "title": "Zestaw odzyskiwania", + "description": "Każdy klucz potrzebny do przywrócenia tego serwera, z ID i odciskiem.", + "intro": "Przechowuj kopię każdego z poniższych kluczy poza tym serwerem, na przykład w menedżerze haseł. Bez nich nie da się ponownie odczytać ani zrzutu bazy danych, ani kopii zapasowej, a HealthLog nie uruchomi się, jeśli brakuje potrzebnego mu klucza.", + "loading": "Wczytywanie zestawu odzyskiwania…", + "loadError": "Nie udało się wczytać zestawu odzyskiwania.", + "badgeDue": "Do zrobienia", + "badgeDone": "Zapisany", + "keyId": "ID", + "fingerprint": "Odcisk", + "noFingerprint": "Brak na tym serwerze", + "kind": { + "application": "Klucz aplikacji {id}", + "backup": "Klucz kopii poza hostem", + "backupPrevious": "Poprzedni klucz kopii poza hostem", + "token": "Klucz tokenów" + }, + "need": { + "active": "Szyfruje nowe dane.", + "configured": "Znajduje się na tym serwerze.", + "storedCopies": "Potrzebny zapisanym kopiom: {count}.", + "offhostUntil": "Potrzebny kopiom poza hostem do {date}.", + "offhostUnknown": "Potrzebny kopiom poza hostem, dopóki bucket je przechowuje.", + "backupActive": "Pieczętuje każdą nową kopię poza hostem.", + "backupPrevious": "Otwiera kopie poza hostem zapisane przed ostatnią zmianą klucza.", + "token": "Podpisuje sesje, tokeny i linki udostępniania. Bez niego wszyscy zostaną wylogowani, ale żadne dane nie przepadną." + }, + "source": "Ustawiony w {variable}.", + "status": { + "confirmed": "Zapisany", + "new": "Jeszcze niezapisany", + "notConfigured": "Brak na tym serwerze", + "unreadable": "Nieprawidłowy klucz" + }, + "notConfiguredHint": "Kopia zapasowa nadal potrzebuje tego klucza, a ten serwer go nie ma. Te kopie zapasowe może odczytać tylko kopia klucza, którą przechowujesz gdzie indziej. Potwierdź zestaw, gdy będziesz mieć tę kopię.", + "unreadableHint": "Zmienna jest ustawiona, ale nie zawiera prawidłowego klucza. Popraw ją, zanim zaczniesz polegać na kopiach poza hostem.", + "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ą.", + "whereTitle": "Gdzie ustawione są klucze", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Klucze znajdują się w pliku .env obok docker-compose.yml: ENCRYPTION_KEY lub ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY oraz, przy kopiach poza hostem, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Otwórz Apps, wybierz HealthLog i kliknij Edit. Klucze są w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY w ustawieniach środowiska.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z jej Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.", + "portainerLabel": "Portainer", + "portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z jego zmiennych środowiskowych." + }, + "restoreTitle": "Przywracanie tego serwera", + "restoreBody": "Zainstaluj HealthLog z tymi samymi kluczami, przywróć najnowszy zrzut bazy danych do pustej bazy, a potem uruchom aplikację. Przewodnik prowadzi przez każdy krok.", + "restoreLink": "Przewodnik odzyskiwania po awarii", + "checkLabel": "Sprawdź moją kopię", + "checkKeyLabel": "Klucz do sprawdzenia", + "checkValueLabel": "Twoja kopia klucza", + "checkButton": "Sprawdź", + "checkNote": "Wklejony klucz jest porównywany z kluczem na tym serwerze, 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 tego klucza.", + "checkNoMatch": "Twoja kopia nie pasuje do tego klucza. Sprawdź, czy skopiowano całą wartość.", + "checkFailed": "Sprawdzenie nie powiodło się.", + "confirmButton": "Mam kopię każdego klucza", + "confirmed": "Zestaw odzyskiwania potwierdzony.", + "confirmFailed": "Nie udało się zapisać potwierdzenia.", + "stale": "Zestaw zmienił się od wczytania tej strony. Sprawdź klucze, które teraz wymienia, a potem potwierdź ponownie.", + "confirmedAt": "Potwierdzono {date}.", + "confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.", + "dueAgain": "Zestaw potwierdzono {date}, ale teraz wymienia klucze, których to potwierdzenie nie obejmuje: {keys}." } }, "achievements": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 3137e637c..30697ec52 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -778,13 +778,13 @@ "bmi": "kg/m²" } }, - "keyBackupBanner": { - "title": "Faça backup da sua chave de criptografia", - "body": "Sem uma cópia da chave, seus dados não podem ser recuperados após uma reinstalação. Leva um minuto.", - "action": "Abrir a etapa" - }, "quickAddSymptom": "Registrar sintoma", - "todayCardVisible": "Mostrar o cartão principal" + "todayCardVisible": "Mostrar o cartão principal", + "recoveryKitBanner": { + "title": "Guarde seu kit de recuperação", + "body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.", + "action": "Abrir o kit" + } }, "measurements": { "title": "Medições", @@ -8506,47 +8506,6 @@ "title": "Esta parte da página não pôde ser exibida", "description": "O resto da página ainda funciona; tente novamente ou recarregue se continuar falhando." }, - "keyBackup": { - "title": "Faça backup da sua chave de criptografia", - "description": "Sem uma cópia desta chave, seus dados criptografados e os backups deste servidor não podem ser lidos novamente.", - "loading": "Carregando o status do backup da chave…", - "loadError": "Não foi possível carregar o status do backup da chave.", - "badgeDue": "A fazer", - "badgeDone": "Backup feito", - "keyId": "ID da chave ativa", - "fingerprint": "Impressão digital", - "whatIsLost": "Mantenha uma cópia da chave em algum lugar fora deste servidor, por exemplo em um gerenciador de senhas. Se o servidor for reinstalado com uma chave diferente, o HealthLog se recusa a começar a servir até a chave original voltar.", - "fingerprintHint": "A impressão digital são os primeiros 12 caracteres do hash SHA-256 da chave. Ela identifica a chave sem revelá-la, então você pode compará-la com sua cópia.", - "checkLabel": "Verificar minha cópia", - "checkButton": "Verificar", - "checkNote": "A chave que você cola é comparada com a chave ativa e depois descartada. Ela nunca é armazenada ou registrada.", - "checkInsecureNote": "Esta página não é servida por https://, então a chave que você cola atravessa a rede sem criptografia, assim como sua senha. Ela é comparada e descartada, nunca armazenada ou registrada.", - "checkMatches": "Sua cópia corresponde à chave ativa.", - "checkNoMatch": "Sua cópia não corresponde à chave ativa. Verifique se você copiou o valor inteiro.", - "checkFailed": "A verificação falhou.", - "confirmButton": "Eu fiz backup desta chave", - "confirmed": "Backup da chave confirmado.", - "confirmFailed": "Não foi possível salvar a confirmação.", - "stale": "A chave do servidor mudou desde que esta página carregou. Verifique o novo ID e a impressão digital e confirme novamente.", - "confirmedAt": "Confirmado em {date}.", - "confirmedAtBy": "Confirmado em {date} por {who}.", - "dueAgain": "Um backup foi confirmado para a chave {keyId}, mas o servidor agora usa outra. Faça backup da atual.", - "tileLabel": "Backup da chave de criptografia", - "tileDue": "Não confirmado", - "tileDone": "Confirmado", - "platform": { - "composeLabel": "Compose", - "composeWhere": "A chave é ENCRYPTION_KEY (ou ENCRYPTION_KEYS) no arquivo .env ao lado do docker-compose.yml.", - "truenasLabel": "TrueNAS", - "truenasWhere": "Abra Apps, selecione HealthLog e escolha Editar. A chave está no campo Encryption Key em HealthLog Configuration; em um app instalado via YAML, é ENCRYPTION_KEY nas configurações de ambiente.", - "coolifyLabel": "Coolify", - "coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY de suas Variáveis de Ambiente.", - "unraidLabel": "Unraid", - "unraidWhere": "Na aba Docker, edite o contêiner do HealthLog e copie ENCRYPTION_KEY do modelo, ou do .env da sua pilha do Compose Manager.", - "portainerLabel": "Portainer", - "portainerWhere": "Abra a stack do HealthLog no Portainer e copie ENCRYPTION_KEY de suas variáveis de ambiente." - } - }, "keyMismatch": { "title": "O HealthLog não consegue abrir seus dados", "body": "A chave de criptografia com que este servidor foi iniciado não é a chave com que seu banco de dados foi gravado. Para proteger seus dados, o HealthLog não serve nada até que isso seja corrigido.", @@ -8577,6 +8536,76 @@ "defaultAboveCeiling": "O nível padrão não pode estar acima do nível mais alto.", "gapLabel": "Horas entre atualizações de cartão após uma sincronização (Padrão)", "gapHint": "Horas inteiras de 1 a 48. Deixe em branco para usar AI_SYNC_REGEN_GAP_HOURS, senão 6. Em vigor agora: {hours} h." + }, + "recoveryKit": { + "title": "Kit de recuperação", + "description": "Todas as chaves de que uma restauração deste servidor precisa, com ID e impressão digital.", + "intro": "Mantenha uma cópia de cada chave abaixo fora deste servidor, por exemplo em um gerenciador de senhas. Sem elas, nem um dump do banco de dados nem uma cópia de backup podem ser lidos novamente, e o HealthLog se recusa a iniciar se faltar uma chave de que ele precisa.", + "loading": "Carregando o kit de recuperação…", + "loadError": "Não foi possível carregar o kit de recuperação.", + "badgeDue": "A fazer", + "badgeDone": "Guardado", + "keyId": "ID", + "fingerprint": "Impressão digital", + "noFingerprint": "Não está neste servidor", + "kind": { + "application": "Chave do aplicativo {id}", + "backup": "Chave de backup fora do host", + "backupPrevious": "Chave anterior de backup fora do host", + "token": "Chave de tokens" + }, + "need": { + "active": "Criptografa os dados novos.", + "configured": "Mantida por este servidor.", + "storedCopies": "Necessária para {count} cópias armazenadas.", + "offhostUntil": "Necessária para as cópias fora do host até {date}.", + "offhostUnknown": "Necessária para as cópias fora do host enquanto o bucket as mantiver.", + "backupActive": "Sela cada nova cópia fora do host.", + "backupPrevious": "Abre as cópias fora do host gravadas antes da última troca de chave.", + "token": "Assina sessões, tokens e links de compartilhamento. Sem ela, todos são desconectados, mas nenhum dado é perdido." + }, + "source": "Definida em {variable}.", + "status": { + "confirmed": "Guardada", + "new": "Ainda não guardada", + "notConfigured": "Não está neste servidor", + "unreadable": "Não é uma chave válida" + }, + "notConfiguredHint": "Um backup ainda precisa desta chave, e este servidor não a tem. Só uma cópia que você guarda em outro lugar consegue ler esses backups. Confirme o kit quando tiver essa cópia.", + "unreadableHint": "A variável está definida, mas não contém uma chave válida. Corrija isso antes de contar com as cópias fora do host.", + "fingerprintHint": "A impressão digital são os primeiros 12 caracteres do hash SHA-256 de uma chave. Ela identifica a chave sem revelá-la, então você pode compará-la com sua cópia.", + "whereTitle": "Onde as chaves são definidas", + "platform": { + "composeLabel": "Compose", + "composeWhere": "As chaves ficam no arquivo .env ao lado do docker-compose.yml: ENCRYPTION_KEY ou ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY e, com cópias fora do host, BACKUP_ENCRYPTION_KEY.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Abra Apps, selecione HealthLog e escolha Edit. As chaves ficam em HealthLog Configuration; em um app instalado via YAML, são ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY nas configurações de ambiente.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY de suas Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Na aba Docker, edite o contêiner do HealthLog e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY do modelo, ou do .env da sua pilha do Compose Manager.", + "portainerLabel": "Portainer", + "portainerWhere": "Abra a stack do HealthLog no Portainer e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY de suas variáveis de ambiente." + }, + "restoreTitle": "Restaurar este servidor", + "restoreBody": "Instale o HealthLog com as mesmas chaves, restaure o dump do banco de dados mais recente em um banco de dados vazio e depois inicie o app. O guia mostra cada etapa.", + "restoreLink": "Guia de recuperação de desastres", + "checkLabel": "Verificar minha cópia", + "checkKeyLabel": "Chave a verificar", + "checkValueLabel": "Sua cópia da chave", + "checkButton": "Verificar", + "checkNote": "A chave que você cola é comparada com a que este servidor mantém e depois descartada. Ela nunca é armazenada ou registrada.", + "checkInsecureNote": "Esta página não é servida por https://, então a chave que você cola atravessa a rede sem criptografia, assim como sua senha. Ela é comparada e descartada, nunca armazenada ou registrada.", + "checkMatches": "Sua cópia corresponde a esta chave.", + "checkNoMatch": "Sua cópia não corresponde a esta chave. Verifique se você copiou o valor inteiro.", + "checkFailed": "A verificação falhou.", + "confirmButton": "Tenho uma cópia de cada chave", + "confirmed": "Kit de recuperação confirmado.", + "confirmFailed": "Não foi possível salvar a confirmação.", + "stale": "O kit mudou desde que esta página carregou. Verifique as chaves que ele lista agora e confirme novamente.", + "confirmedAt": "Confirmado em {date}.", + "confirmedAtBy": "Confirmado em {date} por {who}.", + "dueAgain": "O kit foi confirmado em {date}, mas agora lista chaves que essa confirmação não cobre: {keys}." } }, "achievements": { diff --git a/prisma/migrations/0487_recovery_readiness/migration.sql b/prisma/migrations/0487_recovery_readiness/migration.sql new file mode 100644 index 000000000..3358296d5 --- /dev/null +++ b/prisma/migrations/0487_recovery_readiness/migration.sql @@ -0,0 +1,101 @@ +-- Recovery readiness: what the admins were last told, what the restore drill +-- last found, and the recovery kit confirmation. +-- +-- `recovery_readiness_notices` holds, per readiness signal, the state the +-- admins were last notified about and when. It is the whole memory of the +-- hourly evaluation: a state change notifies once, a failing signal is +-- reminded at most once a day, and a recovery is announced once. It also +-- remembers since when a signal has applied, so a dump switched on by an +-- upgrade is not called failing before its first run had a chance. +-- +-- `restore_drill_outcomes` keeps the monthly drill's last result for as long +-- as it is the last one. pg-boss forgets a failed run after seven days and +-- the admin failing-jobs card looks back 72 hours, so a failed monthly drill +-- was on screen three days in thirty. +-- +-- `recovery_kit_confirmations` replaces the four `encryption_key_backup_*` +-- columns on `app_settings`. Those confirmed one key, the active application +-- key; the kit confirms the set of every key a restore needs, by id and +-- fingerprint. A confirmation given under the old step is carried over as a +-- kit that holds that one key, so an operator who confirmed the active key +-- keeps that part confirmed and is asked only for what the old step never +-- covered. +-- +-- A backup file written by a release that still had the four columns carries +-- them in its instance settings; the restore writes an explicit list of +-- settings and ignores them. +-- +-- The whole file is one statement, so it applies whole or not at all, and +-- running it again changes nothing. The drop takes an ACCESS EXCLUSIVE lock +-- on `app_settings` for a catalogue update only; the wait is bounded. +-- +-- Reversibility: re-adding the four nullable columns restores the old shape; +-- the old step then reads as due until it is confirmed again. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + CREATE TABLE IF NOT EXISTS "recovery_readiness_notices" ( + "signal" TEXT NOT NULL, + "notified_state" TEXT, + "notified_at" TIMESTAMP(3), + "watched_since" TIMESTAMP(3), + CONSTRAINT "recovery_readiness_notices_pkey" PRIMARY KEY ("signal"), + CONSTRAINT "recovery_readiness_notices_state_check" + CHECK ("notified_state" IS NULL OR "notified_state" IN ('ok', 'attention', 'failing')) + ); + + CREATE TABLE IF NOT EXISTS "restore_drill_outcomes" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "last_run_at" TIMESTAMP(3) NOT NULL, + "last_success_at" TIMESTAMP(3), + "last_failure_at" TIMESTAMP(3), + "last_failure_reason" TEXT, + "accounts_checked" INTEGER NOT NULL DEFAULT 0, + "accounts_failed" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "restore_drill_outcomes_pkey" PRIMARY KEY ("id") + ); + + CREATE TABLE IF NOT EXISTS "recovery_kit_confirmations" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "confirmed_at" TIMESTAMP(3) NOT NULL, + "confirmed_by_user_id" TEXT, + "entries" JSONB NOT NULL, + CONSTRAINT "recovery_kit_confirmations_pkey" PRIMARY KEY ("id") + ); + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'app_settings' + AND column_name = 'encryption_key_backup_confirmed_at' + ) THEN + INSERT INTO "recovery_kit_confirmations" + ("id", "confirmed_at", "confirmed_by_user_id", "entries") + SELECT + 'singleton', + "encryption_key_backup_confirmed_at", + "encryption_key_backup_confirmed_by_user_id", + jsonb_build_array(jsonb_build_object( + 'kind', 'application', + 'id', "encryption_key_backup_confirmed_key_id", + 'fingerprint', "encryption_key_backup_confirmed_fingerprint" + )) + FROM "app_settings" + WHERE "id" = 'singleton' + AND "encryption_key_backup_confirmed_at" IS NOT NULL + AND "encryption_key_backup_confirmed_key_id" IS NOT NULL + AND "encryption_key_backup_confirmed_fingerprint" IS NOT NULL + ON CONFLICT ("id") DO NOTHING; + + ALTER TABLE "app_settings" + DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_at", + DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_key_id", + DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_fingerprint", + DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_by_user_id"; + END IF; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8397e0cb5..a8010479b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5469,21 +5469,6 @@ 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") - /// When this instance first stored a discovery finding under the seasonally /// adjusted correlation engine (v1.42). Stamped once, by the first /// `syncAcceptedPatterns` run of a discovery family, before it writes; null @@ -5906,6 +5891,70 @@ model OffhostBackupKeyUse { @@map("offhost_backup_key_use") } +/// The monthly restore drill's last result, one row for the instance. pg-boss +/// forgets a failed run after seven days and the failing-jobs card looks back +/// 72 hours, so the drill's verdict lives here for as long as it is the last +/// one. Written by `recordRestoreDrillOutcome` at the end of every drill that +/// had something to check; consumer: `readRecoveryReadiness` (the readiness +/// tile and the hourly evaluation). +model RestoreDrillOutcome { + id String @id @default("singleton") + /// When the last drill finished. Consumer: the tile's drill row. + lastRunAt DateTime @map("last_run_at") + /// When a drill last passed every object it checked. A failure stays the + /// verdict until a later success. Consumer: the readiness model. + lastSuccessAt DateTime? @map("last_success_at") + /// When a drill last failed. Consumer: the readiness model. + lastFailureAt DateTime? @map("last_failure_at") + /// One line naming why, already redacted. Consumer: the tile's drill row. + lastFailureReason String? @map("last_failure_reason") + /// Objects the last drill checked and how many of them failed. Consumer: + /// the tile's drill row. + accountsChecked Int @default(0) @map("accounts_checked") + accountsFailed Int @default(0) @map("accounts_failed") + + @@map("restore_drill_outcomes") +} + +/// Per readiness signal, the state the admins were last notified about. The +/// hourly evaluation compares the current state against it: a change to +/// attention or failing notifies once, a failing signal is reminded at most +/// once a day, and a return to ok is announced once. The update is a +/// compare-and-set on both columns, so two workers never send the same +/// notice. Written and read by `notifyReadinessChanges`. +model RecoveryReadinessNotice { + /// The signal's id (`database_dump`, `weekly_copy`, …). + signal String @id + /// `ok`, `attention` or `failing`; null until anything was announced. + notifiedState String? @map("notified_state") + notifiedAt DateTime? @map("notified_at") + /// Since when the evaluation has seen the signal apply (not `inactive`); + /// cleared when it stops applying. Consumer: the readiness model, which + /// judges a dump that has never been attempted against it. + watchedSince DateTime? @map("watched_since") + + @@map("recovery_readiness_notices") +} + +/// The recovery kit an admin confirmed: every key a restore needs, by kind, +/// id and fingerprint, never the key itself. The kit is due again as soon as +/// a key it needs is not in this set (a rotation, a new backup key). Written +/// by `POST /api/admin/recovery-kit/confirm`; consumer: `readRecoveryKit` +/// (the kit card, the dashboard banner for admins and the readiness model). +model RecoveryKitConfirmation { + id String @id @default("singleton") + confirmedAt DateTime @map("confirmed_at") + /// Plain id, not a relation: the instance row must not become user-scoped, + /// and a deleted admin leaves the confirmation standing. Consumer: the kit + /// card names who confirmed while the account still exists. + confirmedByUserId String? @map("confirmed_by_user_id") + /// `[{ kind, id, fingerprint }]`, validated on read + /// (`parseKitEntries`). Consumer: `assessRecoveryKit`. + entries Json + + @@map("recovery_kit_confirmations") +} + /// An account whose off-host copies have to leave the bucket: it was deleted, /// or its data was wiped. Written in the same transaction as the deletion, so /// the request cannot be lost between the two, and deleted once every object diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 44eddabb2..6974d57c3 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -49,8 +49,8 @@ * tomorrow morning * - An AI-configured Coach with five sample conversations spread from * today back over a few weeks - * - App settings (registration disabled, English locale, the key-backup - * step confirmed for the key the demo runs with) + * - App settings (registration disabled, English locale) and the recovery + * kit confirmed for the keys the demo runs with * - A multi-year history before the 90-day window (about four and a half * years) for the timeline and the day view: blood pressure in bursts with * real gaps, weekly weight, phone steps, watch resting heart rate / HRV / @@ -90,8 +90,14 @@ import { encrypt, encryptBytes, getActiveKeyId, + getConfiguredKeyIds, getKeyFingerprint, } from "../src/lib/crypto"; +import { + applicationKitKeys, + backupKitKeys, + tokenKitKeys, +} from "../src/lib/recovery/kit-rules"; import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns"; import { withBriefingGeneratedAt } from "../src/lib/insights/briefing-generated-at"; import { dailyStatsExternalId } from "../src/lib/measurements/apple-health-mapping"; @@ -3949,24 +3955,44 @@ export async function seed() { // ── App Settings ───────────────────────── console.log("Creating app settings..."); - // The key-backup step is confirmed for the key the demo runs with, the - // same fields `POST /api/admin/encryption/key-backup/confirm` writes (key - // id + its public fingerprint, never the key). The demo account is an - // admin, so an unconfirmed step put a "Back up your encryption key" - // banner above the dashboard for every visitor, about a key no visitor - // can reach. - const activeKeyId = getActiveKeyId(); await client.query( - `INSERT INTO app_settings (id, registration_enabled, default_locale, - encryption_key_backup_confirmed_at, encryption_key_backup_confirmed_key_id, - encryption_key_backup_confirmed_fingerprint, encryption_key_backup_confirmed_by_user_id) - VALUES ('singleton', false, 'en', NOW(), $1, $2, $3) - ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en', - encryption_key_backup_confirmed_at = EXCLUDED.encryption_key_backup_confirmed_at, - encryption_key_backup_confirmed_key_id = EXCLUDED.encryption_key_backup_confirmed_key_id, - encryption_key_backup_confirmed_fingerprint = EXCLUDED.encryption_key_backup_confirmed_fingerprint, - encryption_key_backup_confirmed_by_user_id = EXCLUDED.encryption_key_backup_confirmed_by_user_id`, - [activeKeyId, getKeyFingerprint(activeKeyId), userId], + `INSERT INTO app_settings (id, registration_enabled, default_locale) + VALUES ('singleton', false, 'en') + ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`, + ); + // The recovery kit is confirmed for the keys the demo runs with, the same + // entries `POST /api/admin/recovery-kit/confirm` writes (key ids and + // public fingerprints, never a key). The demo account is an admin, so an + // unconfirmed kit put a "Save your recovery kit" banner above the + // dashboard for every visitor, about keys no visitor can reach. The seed + // writes no backup copies, so no retired key is needed. + const kitEntries = [ + ...applicationKitKeys({ + activeKeyId: getActiveKeyId(), + configuredKeyIds: getConfiguredKeyIds(), + fingerprintOf: getKeyFingerprint, + needs: { + stored: [], + unrecorded: { copies: 0, oldestAt: null }, + offhost: [], + offhostExpirationDays: null, + retiredKeysStillNeeded: [], + }, + now: new Date(), + }), + ...backupKitKeys( + process.env.BACKUP_ENCRYPTION_KEY, + process.env.BACKUP_ENCRYPTION_PREVIOUS_KEYS, + ), + ...tokenKitKeys(process.env.API_TOKEN_HMAC_KEY), + ].map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint })); + await client.query( + `INSERT INTO recovery_kit_confirmations (id, confirmed_at, confirmed_by_user_id, entries) + VALUES ('singleton', NOW(), $1, $2::jsonb) + ON CONFLICT (id) DO UPDATE SET confirmed_at = EXCLUDED.confirmed_at, + confirmed_by_user_id = EXCLUDED.confirmed_by_user_id, + entries = EXCLUDED.entries`, + [userId, JSON.stringify(kitEntries)], ); // ── Audit Log (some login entries) ────── diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index ec52c3e59..1c9a79021 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -5,8 +5,8 @@ "entries": { "app/api/admin/ai-settings/route.ts": 1, "app/api/admin/drain-per-sample-cumulative/route.ts": 1, - "app/api/admin/encryption/key-backup/verify/route.ts": 1, "app/api/admin/notifications/reminder-check/route.ts": 1, + "app/api/admin/recovery-kit/verify/route.ts": 1, "app/api/admin/rollups/recompute/route.ts": 1, "app/api/admin/users/[id]/reset-password/route.ts": 1, "app/api/ai/test/route.ts": 1, diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index d57271904..6b1270bf8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 86, - "total": 96631, + "total": 96611, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -67,7 +67,7 @@ "lib/jobs/coach-nudge.ts": 1084, "lib/jobs/insight-pregenerate.ts": 1189, "lib/jobs/medication-intake-import.ts": 819, - "lib/jobs/offhost-backup.ts": 1147, + "lib/jobs/offhost-backup.ts": 1127, "lib/jobs/reaction-line.ts": 824, "lib/jobs/reminder/medication-reminder-check.ts": 816, "lib/managed-profiles/handover.ts": 986, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 3f0155aef..9d8b452d5 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 348, - "total": 112838, + "count": 347, + "total": 112600, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -59,7 +59,6 @@ "components/admin/glitchtip-section.tsx :: GlitchtipSection": 233, "components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232, "components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525, - "components/admin/key-backup-card.tsx :: KeyBackupCard": 238, "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, "components/admin/reminders-section.tsx :: RemindersSection": 309, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 81a01b793..9710d87dc 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2897, - "total": 9249, + "total": 9248, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2703,7 +2703,7 @@ "lib/queries/use-medication-compliance-summary.ts": 3, "lib/queries/use-medication-list-layout.ts": 3, "lib/queries/use-profile-claim.ts": 4, - "lib/query-keys/admin.ts": 13, + "lib/query-keys/admin.ts": 12, "lib/query-keys/ai-runs.ts": 1, "lib/query-keys/auth.ts": 9, "lib/query-keys/coach.ts": 13, diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 6a01790a2..8a8c45c70 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -242,18 +242,6 @@ const UNPUBLISHED: Readonly> = { 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"] }, @@ -276,6 +264,19 @@ const UNPUBLISHED: Readonly> = { "/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] }, + "/api/admin/recovery-kit": { kind: "adminConsole", methods: ["GET"] }, + "/api/admin/recovery-kit/confirm": { + kind: "adminConsole", + methods: ["POST"], + }, + "/api/admin/recovery-kit/verify": { + kind: "adminConsole", + methods: ["POST"], + }, + "/api/admin/recovery-readiness": { + kind: "adminConsole", + methods: ["GET"], + }, "/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] }, "/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] }, "/api/admin/settings/assistant-flags": { diff --git a/src/app/api/admin/encryption/key-backup/confirm/route.ts b/src/app/api/admin/encryption/key-backup/confirm/route.ts deleted file mode 100644 index 1a9f57724..000000000 --- a/src/app/api/admin/encryption/key-backup/confirm/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 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 } from "@/lib/api-response"; -import { parseJsonBody } from "@/lib/api/body"; -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 parsed = await parseJsonBody(request, confirmSchema, { - maxBytes: 1024, - }); - if (!parsed.ok) return 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); -}); diff --git a/src/app/api/admin/encryption/key-backup/route.ts b/src/app/api/admin/encryption/key-backup/route.ts deleted file mode 100644 index a41afa6bc..000000000 --- a/src/app/api/admin/encryption/key-backup/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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); -}); diff --git a/src/app/api/admin/encryption/key-backup/verify/route.ts b/src/app/api/admin/encryption/key-backup/verify/route.ts deleted file mode 100644 index dbbbb732b..000000000 --- a/src/app/api/admin/encryption/key-backup/verify/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * 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 }); -}); diff --git a/src/app/api/admin/encryption/status/route.ts b/src/app/api/admin/encryption/status/route.ts index 2ad234a82..1839f58b9 100644 --- a/src/app/api/admin/encryption/status/route.ts +++ b/src/app/api/admin/encryption/status/route.ts @@ -17,13 +17,9 @@ 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 { - offhostBackupConfigured, - probeOffhostLifecycle, -} from "@/lib/jobs/offhost-backup"; +import { readBackupKeyNeeds } from "@/lib/recovery/backup-key-needs"; import { scanCorpus, type CorpusClient, @@ -105,100 +101,6 @@ async function readRotationState(): Promise { }; } -/** - * Which keys the backups still need. - * - * Rotation re-encrypts every row in the database and re-seals every stored - * backup's envelope, but the content INSIDE a backup keeps the key it was - * written under: a restore writes it back verbatim. So "every row is on the - * active key" is not the same as "the old key can go". This answers the second - * question per key id, from what each copy recorded as it was written: the - * stored copies that need it and the oldest of them, and for the off-host - * bucket the last night an object needing it went in, plus how long the - * bucket's lifecycle rule keeps objects after that. - */ -export interface BackupKeyNeeds { - stored: Array<{ keyId: string; copies: number; oldestAt: string }>; - /** Copies written before v1.39.3, which recorded no key ids. */ - unrecorded: { copies: number; oldestAt: string | null }; - offhost: Array<{ - keyId: string; - firstWrittenAt: string; - lastWrittenAt: string; - /** When the last object needing this key expires; null when unknown. */ - neededUntil: string | null; - }>; - /** The bucket's expiry, when it has one the credential may read. */ - offhostExpirationDays: number | null; - /** Retired key ids some backup still needs (never the active one). */ - retiredKeysStillNeeded: string[]; -} - -async function readBackupKeyNeeds( - activeKeyId: string, - now: Date, -): Promise { - const [stored, unrecorded, offhostRows] = await Promise.all([ - prisma.$queryRaw< - Array<{ key_id: string; copies: number; oldest_at: Date }> - >` - SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at - FROM data_backups, unnest(inner_key_ids) AS k - WHERE inner_key_ids_recorded - GROUP BY k - ORDER BY k - `, - prisma.dataBackup.aggregate({ - where: { innerKeyIdsRecorded: false }, - _count: { _all: true }, - _min: { createdAt: true }, - }), - prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }), - ]); - const lifecycle = - offhostRows.length > 0 && offhostBackupConfigured() - ? await probeOffhostLifecycle() - : null; - const expirationDays = - lifecycle?.state === "configured" ? lifecycle.expirationDays : null; - - const offhost = offhostRows.map((row) => ({ - keyId: row.keyId, - firstWrittenAt: row.firstWrittenAt.toISOString(), - lastWrittenAt: row.lastWrittenAt.toISOString(), - neededUntil: - expirationDays === null - ? null - : new Date( - row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000, - ).toISOString(), - })); - - const needed = new Set(); - for (const row of stored) needed.add(row.key_id); - for (const row of offhost) { - if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) { - needed.add(row.keyId); - } - } - needed.delete(activeKeyId); - - return { - stored: stored.map((row) => ({ - keyId: row.key_id, - copies: row.copies, - oldestAt: row.oldest_at.toISOString(), - })), - unrecorded: { - copies: unrecorded._count._all, - oldestAt: unrecorded._min.createdAt?.toISOString() ?? null, - }, - offhost, - offhostExpirationDays: expirationDays, - retiredKeysStillNeeded: [...needed].sort(), - }; -} - export const GET = apiHandler(async () => { await requireAdmin(); annotate({ action: { name: "admin.encryption.status" } }); @@ -214,7 +116,6 @@ export const GET = apiHandler(async () => { const rotation = await readRotationState(); const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date()); - const keyBackup = await readKeyBackupStatus(); annotate({ meta: { @@ -235,8 +136,6 @@ 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 diff --git a/src/app/api/admin/recovery-kit/confirm/route.ts b/src/app/api/admin/recovery-kit/confirm/route.ts new file mode 100644 index 000000000..bcfa56a8e --- /dev/null +++ b/src/app/api/admin/recovery-kit/confirm/route.ts @@ -0,0 +1,60 @@ +/** + * POST /api/admin/recovery-kit/confirm { entries: [{ kind, id, fingerprint }] } + * + * The admin states that a copy of every key in the kit is kept outside the + * server. The body is the kit as the admin was shown it; when that is no + * longer the kit (a rotation, a new backup key, a restart with other keys + * since the page loaded) the answer is 409 `recovery.kit.stale` with the + * current kit, and nothing is recorded. No step-up: confirming is a low-risk + * write that reveals nothing. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { confirmRecoveryKit } from "@/lib/recovery/kit"; +import { + kitConfirmationSchema, + RECOVERY_KIT_STALE_CODE, +} from "@/lib/recovery/kit-rules"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.recoveryKit.confirm" } }); + + const parsed = await parseJsonBody(request, kitConfirmationSchema, { + maxBytes: 8 * 1024, + }); + if (!parsed.ok) return parsed.error; + + const result = await confirmRecoveryKit({ + shown: parsed.data.entries, + userId: user.id, + }); + if (!result.ok) { + annotate({ meta: { recovery_kit_stale: true } }); + return apiError( + "The recovery kit changed since this page was loaded. Reload, check the keys it lists now, and confirm again.", + 409, + { errorCode: RECOVERY_KIT_STALE_CODE }, + ); + } + + await auditLog("recovery.kit.confirmed", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + keys: result.kit.keys.map(({ kind, id, fingerprint }) => ({ + kind, + id, + fingerprint, + })), + }, + }); + return apiSuccess(result.kit); +}); diff --git a/src/app/api/admin/recovery-kit/route.ts b/src/app/api/admin/recovery-kit/route.ts new file mode 100644 index 000000000..e97175f84 --- /dev/null +++ b/src/app/api/admin/recovery-kit/route.ts @@ -0,0 +1,29 @@ +/** + * GET /api/admin/recovery-kit + * + * The recovery kit: every key a restore of this instance needs (the + * application keys, including retired ones a backup still lists, the off-host + * backup keys and the token key), each with its fingerprint and why it is + * needed, and whether the admin's confirmation covers it. Ids and + * fingerprints 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 { readRecoveryKit } from "@/lib/recovery/kit"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.recoveryKit.status" } }); + const kit = await readRecoveryKit(); + annotate({ + meta: { + recovery_kit_due: kit.due, + recovery_kit_keys: kit.keys.length, + recovery_kit_unconfirmed: kit.unconfirmed.length, + }, + }); + return apiSuccess(kit); +}); diff --git a/src/app/api/admin/recovery-kit/verify/route.ts b/src/app/api/admin/recovery-kit/verify/route.ts new file mode 100644 index 000000000..894858468 --- /dev/null +++ b/src/app/api/admin/recovery-kit/verify/route.ts @@ -0,0 +1,70 @@ +/** + * POST /api/admin/recovery-kit/verify { kind, id, keyCopy } + * + * "Check my copy": the admin pastes the copy of one key the kit lists and + * learns whether it is that key. The value is compared in memory (constant + * time on the compared 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 { verifyKitKey } from "@/lib/recovery/kit"; +import { KIT_KEY_KINDS } from "@/lib/recovery/kit-rules"; + +export const dynamic = "force-dynamic"; + +const KIT_VERIFY_LIMIT = 5; +const KIT_VERIFY_WINDOW_MS = 60 * 1000; + +const verifySchema = z + .object({ + kind: z.enum(KIT_KEY_KINDS), + id: z.string().min(1).max(64), + keyCopy: z.string().min(1).max(512), + }) + .strict(); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.recoveryKit.verify" } }); + + const limit = await checkRateLimit( + `recovery-kit-verify:${user.id}`, + KIT_VERIFY_LIMIT, + KIT_VERIFY_WINDOW_MS, + ); + if (!limit.allowed) { + return apiError("Too many checks. Try again in a minute.", 429); + } + + const body = await safeJson(request, { maxBytes: 2048 }); + 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 exactly as it is set.", 422); + } + + const { kind, id } = parsed.data; + const matches = verifyKitKey({ kind, id, value: parsed.data.keyCopy }); + annotate({ meta: { recovery_kit_verify_matches: matches } }); + await auditLog("recovery.kit.verified", { + userId: user.id, + details: { kind, keyId: id, matches }, + }); + return apiSuccess({ matches, kind, id }); +}); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 92c43a62e..b162629dc 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useDashboardChartReveal } from "@/components/dashboard/chart-reveal"; import { DashboardHeader } from "@/components/dashboard/dashboard-header"; import { EnvironmentChip } from "@/components/dashboard/environment-chip"; -import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner"; +import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner"; import { QuickEntrySheets, type QuickEntryDialog, @@ -117,7 +117,7 @@ export default function DashboardPageClient({ > - + {/* 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 diff --git a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx index 055ac5f63..2effd2e35 100644 --- a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx +++ b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx @@ -20,7 +20,7 @@ vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutate: vi.fn(), isPending: false }), })); -vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null })); +vi.mock("../recovery-kit-card", () => ({ RecoveryKitCard: () => null })); import { I18nProvider } from "@/lib/i18n/context"; import { ColumnCoverage, EncryptionSection } from "../encryption-section"; diff --git a/src/components/admin/__tests__/key-backup-card.test.tsx b/src/components/admin/__tests__/key-backup-card.test.tsx deleted file mode 100644 index 0ddc67a44..000000000 --- a/src/components/admin/__tests__/key-backup-card.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 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"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -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( - - {node} - , - ); -} - -describe("KeyBackupCard", () => { - it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => { - const html = render(, 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(, DUE)).toContain( - "I have backed up this key", - ); - const done = render(, DONE); - expect(done).not.toContain("I have backed up this key"); - expect(done).toContain("admin@example.test"); - }); - - it("speaks German", () => { - expect(render(, DUE, "de")).toContain( - "Sichere deinen Verschlüsselungsschlüssel", - ); - }); -}); - -describe("KeyBackupBanner", () => { - it("renders for an admin while the step is due", () => { - expect(render(, DUE)).toContain( - 'data-testid="key-backup-banner"', - ); - }); - - it("renders nothing for a non-admin, or once the step is done", () => { - expect(render(, DUE)).toBe(""); - expect(render(, DONE)).toBe(""); - }); -}); diff --git a/src/components/admin/__tests__/recovery-kit-card.test.tsx b/src/components/admin/__tests__/recovery-kit-card.test.tsx new file mode 100644 index 000000000..83722b8cb --- /dev/null +++ b/src/components/admin/__tests__/recovery-kit-card.test.tsx @@ -0,0 +1,168 @@ +/** + * The recovery kit card and its dashboard banner. + * + * 1. the card lists every key with its id and fingerprint and never + * anything shaped like key material; the platform hint picks the open + * tab; the restore guide is linked; + * 2. a retired key a backup still needs is listed with why, and a key this + * server does not hold says so; + * 3. a due kit offers the confirmation and names what the confirmation + * does not cover, a confirmed one does not; + * 4. the banner renders only for an admin and only while the kit is due; + * 5. the copy resolves in German too. + * + * Mutation check: drop the `isAdmin` guard in the banner and case 4 goes red; + * render the confirm button unconditionally and case 3 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 { + DISASTER_RECOVERY_GUIDE_URL, + RecoveryKitCard, +} from "@/components/admin/recovery-kit-card"; +import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner"; +import type { KitKey, RecoveryKit } from "@/components/admin/use-recovery-kit"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +primeLocales("de"); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/encryption", +})); + +const need = (over: Partial = {}): KitKey["need"] => ({ + active: false, + configured: true, + storedCopies: 0, + offhostUntil: null, + ...over, +}); + +const KEYS: KitKey[] = [ + { + kind: "application", + id: "v1", + fingerprint: null, + source: "ENCRYPTION_KEYS", + need: need({ configured: false, storedCopies: 2 }), + confirmed: false, + problem: "not_configured", + }, + { + kind: "application", + id: "v2", + fingerprint: "a1b2c3d4e5f6", + source: "ENCRYPTION_KEYS", + need: need({ active: true }), + confirmed: false, + problem: null, + }, + { + kind: "backup", + id: "0f0e0d0c0b0a", + fingerprint: "0f0e0d0c0b0a", + source: "BACKUP_ENCRYPTION_KEY", + need: need({ active: true }), + confirmed: true, + problem: null, + }, + { + kind: "token", + id: "API_TOKEN_HMAC_KEY", + fingerprint: "112233445566", + source: "API_TOKEN_HMAC_KEY", + need: need({ active: true }), + confirmed: true, + problem: null, + }, +]; + +const DUE: RecoveryKit = { + keys: KEYS, + due: true, + unconfirmed: [ + { kind: "application", id: "v1" }, + { kind: "application", id: "v2" }, + ], + confirmedAt: "2026-10-03T10:00:00.000Z", + confirmedBy: null, + platformHint: "truenas", +}; + +const DONE: RecoveryKit = { + ...DUE, + keys: KEYS.map((k) => ({ ...k, confirmed: true })), + due: false, + unconfirmed: [], + confirmedBy: { id: "u1", email: "admin@example.test" }, +}; + +function render( + node: React.ReactNode, + kit: RecoveryKit | null, + locale: "en" | "de" = "en", +): string { + const client = new QueryClient(); + if (kit) client.setQueryData(queryKeys.adminRecoveryKit(), kit); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("RecoveryKitCard", () => { + it("lists every key by id and fingerprint, opens the hinted platform, links the guide, and shows no key material", () => { + const html = render(, DUE); + for (const k of KEYS) { + expect(html).toContain( + `data-testid="recovery-kit-key-${k.kind}-${k.id}"`, + ); + } + expect(html).toContain("a1b2c3d4e5f6"); + expect(html).toContain("112233445566"); + expect(html).toContain("Apps, select HealthLog"); + expect(html).toContain(DISASTER_RECOVERY_GUIDE_URL); + expect(html).not.toMatch(/[0-9a-f]{64}/); + }); + + it("says why a retired key is listed and that this server does not hold it", () => { + const html = render(, DUE); + expect(html).toContain("Needed by 2 stored copies."); + expect(html).toContain("Not on this server"); + expect(html).toContain("Application key v1"); + }); + + it("offers the confirmation only while the kit is due, naming what is not covered", () => { + const due = render(, DUE); + expect(due).toContain("I have a copy of every key"); + expect(due).toContain("v1, v2"); + const done = render(, DONE); + expect(done).not.toContain("I have a copy of every key"); + expect(done).toContain("admin@example.test"); + }); + + it("speaks German", () => { + expect(render(, DUE, "de")).toContain( + "Wiederherstellungs-Kit", + ); + }); +}); + +describe("RecoveryKitBanner", () => { + it("renders for an admin while the kit is due", () => { + expect(render(, DUE)).toContain( + 'data-testid="recovery-kit-banner"', + ); + }); + + it("renders nothing for a non-admin, or once the kit is confirmed", () => { + expect(render(, DUE)).toBe(""); + expect(render(, DONE)).toBe(""); + }); +}); diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index bbea2f6f8..aa4441c08 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -50,8 +50,8 @@ import { throwIfReproofRequired, useRecentProof, } from "@/components/settings/security-section/use-recent-proof"; -import { KeyBackupCard } from "./key-backup-card"; -import { useKeyBackupStatus } from "./use-key-backup-status"; +import { RecoveryKitCard } from "./recovery-kit-card"; +import { useRecoveryKit } from "./use-recovery-kit"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { EmptyState } from "@/components/ui/empty-state"; @@ -101,7 +101,7 @@ export function EncryptionSection() { // Same query as the card's own (deduplicated by key): read here only to // know when the page can paint the cards below it in one step. - const keyBackup = useKeyBackupStatus(); + const recoveryKit = useRecoveryKit(); const statusQuery = useQuery({ queryKey: queryKeys.adminEncryptionStatus(), queryFn: async (): Promise => { @@ -158,14 +158,14 @@ export function EncryptionSection() { // downward the moment the status resolved. Same shape as // `coach-feedback-section.tsx`, which fixed this for itself and never had // the fix carried across. - // Until both reads land, only the key-backup card paints. It is the first + // Until both reads land, only the recovery kit card paints. It is the first // card and grows into its loaded shape with nothing under it yet; painting - // the coverage card below it while the backup card was still a one-line - // loader moved that card down the page when the backup status arrived. - if (statusQuery.isLoading || keyBackup.isLoading) { + // the coverage card below it while the kit was still a one-line loader + // moved that card down the page when the kit arrived. + if (statusQuery.isLoading || recoveryKit.isLoading) { return (
- +
); } @@ -173,7 +173,7 @@ export function EncryptionSection() { if (statusQuery.isError || !statusQuery.data) { return (
- + {recentProof.dialog} - + {/* ── Coverage summary ───────────────────────────────────────── */} ` — 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 { showErrorToast } from "@/lib/errors/error-toast"; -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(null); - - const invalidate = () => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.adminEncryptionKeyBackup(), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.adminEncryptionStatus(), - }), - ]); - - const confirm = useMutation({ - mutationFn: (s: KeyBackupStatus) => - apiPost( - "/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) { - showErrorToast(t("admin.keyBackup.stale"), { cause: err }); - await invalidate(); - return; - } - showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), { - cause: err, - }); - }, - }); - - 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); - showErrorToast(err.message || t("admin.keyBackup.checkFailed"), { - cause: err, - }); - }, - }); - - const insecure = - typeof window !== "undefined" && window.isSecureContext === false; - - if (!status.data) { - return ( - - - {status.isError ? ( - void status.refetch()} - /> - ) : ( -
- - {t("admin.keyBackup.loading")} -
- )} -
- ); - } - - const s = status.data; - return ( - - - {t("admin.keyBackup.badgeDue")} - - ) : ( - {t("admin.keyBackup.badgeDone")} - ) - } - /> - -
-
-
- {t("admin.keyBackup.keyId")} -
-
{s.activeKeyId}
-
-
-
- {t("admin.keyBackup.fingerprint")} -
-
- {s.fingerprint} -
-
-
- -

{t("admin.keyBackup.whatIsLost")}

- - - - {PLATFORMS.map((p) => ( - - {platformCopy(t, p).label} - - ))} - - {PLATFORMS.map((p) => ( - - {platformCopy(t, p).where} - - ))} - - -

- {t("admin.keyBackup.fingerprintHint")} -

- -
{ - e.preventDefault(); - if (candidate.trim()) verify.mutate(candidate); - }} - > - -
- { - setCandidate(e.target.value); - setCheckResult(null); - }} - className="font-mono" - /> - -
-

- {insecure - ? t("admin.keyBackup.checkInsecureNote") - : t("admin.keyBackup.checkNote")} -

- {checkResult !== null && ( -

- {checkResult - ? t("admin.keyBackup.checkMatches") - : t("admin.keyBackup.checkNoMatch")} -

- )} -
- - {!s.due && s.confirmedAt && ( -

- {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)), - })} -

- )} - {s.due && s.confirmedKeyId && ( -

- {t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })} -

- )} - - {s.due && ( - - - - )} -
- ); -} diff --git a/src/components/admin/recovery-kit-card.tsx b/src/components/admin/recovery-kit-card.tsx new file mode 100644 index 000000000..b08a9282f --- /dev/null +++ b/src/components/admin/recovery-kit-card.tsx @@ -0,0 +1,519 @@ +"use client"; + +/** + * `` — the recovery kit on the admin Encryption page. + * + * Lists every key a restore of this server needs: the application keys + * (including a retired one a backup still lists), the off-host backup keys and + * the token key. Each row shows the key's id and fingerprint (never the key), + * why the kit lists it and whether the confirmation covers it. Below them: + * where the keys are set on each platform, the short restore path with a link + * to the full guide, a "Check my copy" field and the confirmation. + * + * The confirmation sends the kit exactly as this card showed it. When the + * server's kit changed since the page loaded (a rotation, a new backup key), + * the server answers 409 and the card reloads. + */ + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { BookOpen, KeyRound, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { + useRecoveryKit, + type KitKey, + type KitPlatform, + type RecoveryKit, +} from "./use-recovery-kit"; + +const PLATFORMS: KitPlatform[] = [ + "compose", + "truenas", + "coolify", + "unraid", + "portainer", +]; + +/** The disaster recovery guide in the repository, the same on every host. */ +export const DISASTER_RECOVERY_GUIDE_URL = + "https://github.com/MBombeck/HealthLog/blob/main/docs/ops/disaster-recovery.md"; + +/** Literal keys per platform, so the i18n coverage guard sees every one. */ +function platformCopy( + t: (key: string) => string, + p: KitPlatform, +): { label: string; where: string } { + switch (p) { + case "truenas": + return { + label: t("admin.recoveryKit.platform.truenasLabel"), + where: t("admin.recoveryKit.platform.truenasWhere"), + }; + case "coolify": + return { + label: t("admin.recoveryKit.platform.coolifyLabel"), + where: t("admin.recoveryKit.platform.coolifyWhere"), + }; + case "unraid": + return { + label: t("admin.recoveryKit.platform.unraidLabel"), + where: t("admin.recoveryKit.platform.unraidWhere"), + }; + case "portainer": + return { + label: t("admin.recoveryKit.platform.portainerLabel"), + where: t("admin.recoveryKit.platform.portainerWhere"), + }; + case "compose": + return { + label: t("admin.recoveryKit.platform.composeLabel"), + where: t("admin.recoveryKit.platform.composeWhere"), + }; + } +} + +function keyLabel( + t: (key: string, params?: Record) => string, + key: KitKey, +): string { + switch (key.kind) { + case "application": + return t("admin.recoveryKit.kind.application", { id: key.id }); + case "backup": + return key.need.active + ? t("admin.recoveryKit.kind.backup") + : t("admin.recoveryKit.kind.backupPrevious"); + case "token": + return t("admin.recoveryKit.kind.token"); + } +} + +/** Why the kit lists a key, as short sentences. */ +function KeyNeeds({ k }: { k: KitKey }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const lines: string[] = []; + if (k.kind === "token") { + lines.push(t("admin.recoveryKit.need.token")); + } else if (k.kind === "backup") { + lines.push( + k.need.active + ? t("admin.recoveryKit.need.backupActive") + : t("admin.recoveryKit.need.backupPrevious"), + ); + } else { + if (k.need.active) lines.push(t("admin.recoveryKit.need.active")); + else if (k.need.configured) + lines.push(t("admin.recoveryKit.need.configured")); + if (k.need.storedCopies > 0) { + lines.push( + t("admin.recoveryKit.need.storedCopies", { + count: k.need.storedCopies, + }), + ); + } + if (k.need.offhostUntil === "unknown") { + lines.push(t("admin.recoveryKit.need.offhostUnknown")); + } else if (k.need.offhostUntil) { + lines.push( + t("admin.recoveryKit.need.offhostUntil", { + date: fmt.date(new Date(k.need.offhostUntil)), + }), + ); + } + } + lines.push(t("admin.recoveryKit.source", { variable: k.source })); + return

{lines.join(" ")}

; +} + +function KeyStatus({ k }: { k: KitKey }) { + const { t } = useTranslations(); + if (k.problem === "unreadable") { + return ( + + {t("admin.recoveryKit.status.unreadable")} + + ); + } + if (k.confirmed) { + return ( + {t("admin.recoveryKit.status.confirmed")} + ); + } + return ( + + {k.problem === "not_configured" + ? t("admin.recoveryKit.status.notConfigured") + : t("admin.recoveryKit.status.new")} + + ); +} + +/** One row per key: what it is, its id and fingerprint, why it is listed. */ +export function RecoveryKitKeyList({ kit }: { kit: RecoveryKit }) { + const { t } = useTranslations(); + return ( +
    + {kit.keys.map((k) => ( +
  • +
    +

    {keyLabel(t, k)}

    +
    + +
    +
    +
    +
    +
    + {t("admin.recoveryKit.keyId")} +
    +
    {k.id}
    +
    +
    +
    + {t("admin.recoveryKit.fingerprint")} +
    +
    + {k.fingerprint ?? t("admin.recoveryKit.noFingerprint")} +
    +
    +
    + + {k.problem === "not_configured" && k.kind !== "token" ? ( +

    + {t("admin.recoveryKit.notConfiguredHint")} +

    + ) : null} + {k.problem === "unreadable" ? ( +

    {t("admin.recoveryKit.unreadableHint")}

    + ) : null} +
  • + ))} +
+ ); +} + +function CheckMyCopy({ kit }: { kit: RecoveryKit }) { + const { t } = useTranslations(); + const checkable = kit.keys.filter((k) => k.fingerprint !== null); + const [selected, setSelected] = useState( + checkable[0] ? `${checkable[0].kind}:${checkable[0].id}` : "", + ); + const [candidate, setCandidate] = useState(""); + const [checkResult, setCheckResult] = useState(null); + + const verify = useMutation({ + // Not `apiPost`: no idempotency key, so no cached response row exists + // for a request whose body was a key. + mutationFn: (keyCopy: string) => { + const target = checkable.find((k) => `${k.kind}:${k.id}` === selected); + return apiFetch<{ matches: boolean }>("/api/admin/recovery-kit/verify", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: target?.kind, + id: target?.id, + keyCopy, + }), + }); + }, + onSuccess: (data) => { + setCheckResult(data.matches); + setCandidate(""); + }, + onError: (err: Error) => { + setCheckResult(null); + showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), { + cause: err, + }); + }, + }); + + if (checkable.length === 0) return null; + const insecure = + typeof window !== "undefined" && window.isSecureContext === false; + + return ( +
{ + e.preventDefault(); + if (candidate.trim() && selected) verify.mutate(candidate); + }} + > +

{t("admin.recoveryKit.checkLabel")}

+
+
+ + { + setSelected(e.target.value); + setCheckResult(null); + }} + > + {checkable.map((k) => ( + + ))} + +
+
+ + { + setCandidate(e.target.value); + setCheckResult(null); + }} + className="font-mono" + /> +
+ +
+

+ {insecure + ? t("admin.recoveryKit.checkInsecureNote") + : t("admin.recoveryKit.checkNote")} +

+ {checkResult !== null && ( +

+ {checkResult + ? t("admin.recoveryKit.checkMatches") + : t("admin.recoveryKit.checkNoMatch")} +

+ )} +
+ ); +} + +export function RecoveryKitCard() { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + const status = useRecoveryKit(); + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.adminRecoveryKit(), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.adminRecoveryReadiness(), + }), + ]); + + const confirm = useMutation({ + mutationFn: (kit: RecoveryKit) => + apiPost( + "/api/admin/recovery-kit/confirm", + { + entries: kit.keys.map(({ kind, id, fingerprint }) => ({ + kind, + id, + fingerprint, + })), + }, + { credentials: "include" }, + ), + onSuccess: async () => { + toast(t("admin.recoveryKit.confirmed")); + await invalidate(); + }, + onError: async (err: Error) => { + if (err instanceof ApiError && err.status === 409) { + showErrorToast(t("admin.recoveryKit.stale"), { cause: err }); + await invalidate(); + return; + } + showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), { + cause: err, + }); + }, + }); + + if (!status.data) { + return ( + + + {status.isError ? ( + void status.refetch()} + /> + ) : ( +
+ + {t("admin.recoveryKit.loading")} +
+ )} +
+ ); + } + + const kit = status.data; + const unconfirmedIds = kit.unconfirmed.map((k) => k.id).join(", "); + return ( + + + {t("admin.recoveryKit.badgeDue")} + + ) : ( + {t("admin.recoveryKit.badgeDone")} + ) + } + /> + +

{t("admin.recoveryKit.intro")}

+ + + +

+ {t("admin.recoveryKit.fingerprintHint")} +

+ +
+

+ {t("admin.recoveryKit.whereTitle")} +

+ + + {PLATFORMS.map((p) => ( + + {platformCopy(t, p).label} + + ))} + + {PLATFORMS.map((p) => ( + + {platformCopy(t, p).where} + + ))} + +
+ +
+

+ {t("admin.recoveryKit.restoreTitle")} +

+

{t("admin.recoveryKit.restoreBody")}

+ + + + +
+ + + + {!kit.due && kit.confirmedAt && ( +

+ {kit.confirmedBy?.email + ? t("admin.recoveryKit.confirmedAtBy", { + date: fmt.dateTime(new Date(kit.confirmedAt)), + who: kit.confirmedBy.email, + }) + : t("admin.recoveryKit.confirmedAt", { + date: fmt.dateTime(new Date(kit.confirmedAt)), + })} +

+ )} + {kit.due && kit.confirmedAt && ( +

+ {t("admin.recoveryKit.dueAgain", { + date: fmt.dateTime(new Date(kit.confirmedAt)), + keys: unconfirmedIds, + })} +

+ )} + + {kit.due && ( + + + + )} +
+ ); +} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 7c412113c..a02d64609 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -12,27 +12,17 @@ * don't re-fetch. */ -import { - Clock, - Cog, - Database, - Globe, - KeyRound, - Loader2, - Server, -} from "lucide-react"; +import { Clock, Cog, Database, Globe, 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 { geoLookupState, StatusItem, useSystemStatus } from "./_shared"; -import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); const { data: status, isError, error, refetch } = useSystemStatus(); - const { data: keyBackup } = useKeyBackupStatus(); return ( - {/* v1.40.0 — the encryption key backup step, warning while due. */} - {keyBackup && ( - - )} {/* v1.4.27 — surface the offline-geo state so the maintainer spots the missing GeoLite2 databases without crawling logs. Read from the admin status, not the public /api/version. */} diff --git a/src/components/admin/use-key-backup-status.ts b/src/components/admin/use-key-backup-status.ts deleted file mode 100644 index 6f4304924..000000000 --- a/src/components/admin/use-key-backup-status.ts +++ /dev/null @@ -1,36 +0,0 @@ -"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("/api/admin/encryption/key-backup", { - credentials: "include", - }), - enabled, - staleTime: 60_000, - }); -} diff --git a/src/components/admin/use-recovery-kit.ts b/src/components/admin/use-recovery-kit.ts new file mode 100644 index 000000000..534888758 --- /dev/null +++ b/src/components/admin/use-recovery-kit.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { RecoveryKit } from "@/lib/recovery/kit-rules"; + +export type { + KitKey, + KitKeyKind, + KitPlatform, + RecoveryKit, +} from "@/lib/recovery/kit-rules"; + +/** + * `GET /api/admin/recovery-kit`: key ids and fingerprints only. `enabled` lets + * the dashboard banner ask only for an admin; the route itself is admin-only + * and cookie-only. + */ +export function useRecoveryKit(enabled = true) { + return useQuery({ + queryKey: queryKeys.adminRecoveryKit(), + queryFn: () => + apiGet("/api/admin/recovery-kit", { + credentials: "include", + }), + enabled, + staleTime: 60_000, + }); +} diff --git a/src/components/dashboard/key-backup-banner.tsx b/src/components/dashboard/recovery-kit-banner.tsx similarity index 51% rename from src/components/dashboard/key-backup-banner.tsx rename to src/components/dashboard/recovery-kit-banner.tsx index 8b8901b2b..953d2e3fc 100644 --- a/src/components/dashboard/key-backup-banner.tsx +++ b/src/components/dashboard/recovery-kit-banner.tsx @@ -1,38 +1,39 @@ "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. + * Dashboard banner for an admin while the recovery kit is due: never + * confirmed, or listing a key the confirmation does not cover (a rotation, a + * new backup key). Not dismissable by design: it goes away when the kit is + * confirmed, and comes back when a 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"; +import { useRecoveryKit } from "@/components/admin/use-recovery-kit"; import { InlineAction } from "@/components/ui/inline-action"; -export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) { +export function RecoveryKitBanner({ isAdmin }: { isAdmin: boolean }) { const { t } = useTranslations(); - const { data } = useKeyBackupStatus(isAdmin); + const { data } = useRecoveryKit(isAdmin); if (!isAdmin || !data?.due) return null; return (
diff --git a/src/lib/crypto/__tests__/key-backup.test.ts b/src/lib/crypto/__tests__/key-backup.test.ts deleted file mode 100644 index e82929db8..000000000 --- a/src/lib/crypto/__tests__/key-backup.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -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"); - }); -}); diff --git a/src/lib/crypto/key-backup.ts b/src/lib/crypto/key-backup.ts deleted file mode 100644 index 6ea14555c..000000000 --- a/src/lib/crypto/key-backup.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * 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 { - 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 { - 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() }; -} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 92cead915..371c9c70b 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -353,6 +353,12 @@ export const INSTANCE_SCOPED: Readonly> = { HostMetric: "instance-wide host telemetry, not attributable to an account", OffhostBackupKeyUse: "per encryption key, when the nightly off-host run last wrote an object needing it; says nothing about any account", + RestoreDrillOutcome: + "the monthly restore drill's last verdict for the instance's off-host bucket; says nothing about any account", + RecoveryReadinessNotice: + "per backup signal, what the admins were last told; a fact about the instance's backups, not about any account", + RecoveryKitConfirmation: + "the instance's recovery kit confirmation (key ids and fingerprints); the confirming admin is a plain id, not a relation", OffhostPurgeRequest: "the deletions an account deletion or wipe asked of the off-host bucket; written by the wipe itself and removed once the bucket holds nothing of the account", CycleSymptomCategory: diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index cf6fbf1ba..4c802b4df 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -23,12 +23,7 @@ */ import { envOr } from "@/lib/env"; import { Buffer } from "node:buffer"; -import { - createCipheriv, - createDecipheriv, - createHash, - randomBytes, -} from "node:crypto"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { Readable, Transform } from "node:stream"; import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib"; import type { PrismaClient } from "@/generated/prisma/client"; @@ -44,6 +39,7 @@ import { import { annotate, getEvent } from "@/lib/logging/context"; import { envValue } from "@/lib/env"; import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue"; +import { decodeBackupKey, offhostKeyId } from "./offhost-key"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; @@ -74,17 +70,6 @@ export class OffhostBackupNotConfiguredError extends Error { } } -function decodeBackupKey(raw: string): Buffer { - if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, "hex"); - if (/^[A-Za-z0-9+/=]+$/.test(raw)) { - const buf = Buffer.from(raw, "base64"); - if (buf.length === 32) return buf; - } - throw new Error( - "BACKUP_ENCRYPTION_KEY must be 64 hex chars or 32-byte base64", - ); -} - /** * Whether this host has the five variables the nightly job needs. * @@ -200,11 +185,6 @@ function asKeyRing(key: Buffer | OffhostKeyRing): OffhostKeyRing { return Buffer.isBuffer(key) ? { active: key, previous: [] } : key; } -/** The id a version-4 object records for the key it was written under. */ -export function offhostKeyId(key: Buffer): string { - return createHash("sha256").update(key).digest("hex").slice(0, 12); -} - function offhostAad(objectKey: string): Buffer { return Buffer.from(`${OFFHOST_AAD_PREFIX}${objectKey}`, "utf8"); } diff --git a/src/lib/jobs/offhost-key.ts b/src/lib/jobs/offhost-key.ts new file mode 100644 index 000000000..3e28700de --- /dev/null +++ b/src/lib/jobs/offhost-key.ts @@ -0,0 +1,28 @@ +/** + * The off-host backup key as a value: how the operator writes it and the id + * an object records for it. Kept apart from the uploader so the recovery kit + * can name the keys without loading the backup machinery. + */ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; + +/** One off-host key as the operator writes it: 64 hex or 32-byte base64. */ +export function decodeBackupKey(raw: string): Buffer { + if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, "hex"); + if (/^[A-Za-z0-9+/=]+$/.test(raw)) { + const buf = Buffer.from(raw, "base64"); + if (buf.length === 32) return buf; + } + throw new Error( + "BACKUP_ENCRYPTION_KEY must be 64 hex chars or 32-byte base64", + ); +} + +/** + * The id a version-4 object records for the key it was written under: the + * first twelve hex characters of the key's SHA-256. The recovery kit uses the + * same value as the key's fingerprint, so a kit entry matches an object header. + */ +export function offhostKeyId(key: Buffer): string { + return createHash("sha256").update(key).digest("hex").slice(0, 12); +} diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index 848ff09e3..d47a0aa00 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -96,11 +96,13 @@ 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. + // v1.40.0 — the encryption key an admin pastes into "Check my copy", and the + // copy of any recovery kit key, sent as `keyCopy` + // (`POST /api/admin/recovery-kit/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, + /^key[_-]?copy$/i, ]; /** diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index eefaaf11d..58cc2786e 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -199,7 +199,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "encounter.reminder-conflict", "encounter.reminder-not-found", ], - encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"], + encryption: ["encryption.key_mismatch"], environment: [ "environment.backfill_pending", "environment.backfill_rate_limited", @@ -398,6 +398,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "profile_claim.taken", ], record_write: ["record_write.rate_limited"], + recovery: ["recovery.kit.stale"], "report-selection": [ "report-selection.body.invalid_json", "report-selection.body.invalid_shape", diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 62d6efd73..8a3e28cf4 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -55,9 +55,10 @@ export const adminKeys = { ["admin", "maintenance", "measurements"] as const, /** v1.43 — the pulse day-mean method, its dry run and apply step. */ adminPulseDayMean: () => ["admin", "maintenance", "pulse-day-mean"] as const, - /** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */ - adminEncryptionKeyBackup: () => - ["admin", "encryption", "key-backup"] as const, + /** The recovery kit (admin Encryption section + dashboard banner). */ + adminRecoveryKit: () => ["admin", "recovery-kit"] as const, + /** Every backup signal's verdict (admin overview + Backups). */ + adminRecoveryReadiness: () => ["admin", "recovery-readiness"] as const, adminCoachFeedback: () => ["admin", "coach-feedback"] as const, adminFeedback: (status: string) => ["admin", "feedback", status] as const, adminFeedbackRoot: () => ["admin", "feedback"] as const, diff --git a/src/lib/recovery/__tests__/kit-rules.test.ts b/src/lib/recovery/__tests__/kit-rules.test.ts new file mode 100644 index 000000000..f05e4b624 --- /dev/null +++ b/src/lib/recovery/__tests__/kit-rules.test.ts @@ -0,0 +1,231 @@ +/** + * The recovery kit rules: which keys the kit lists, what a confirmation + * covers, and when the kit is due again. + * + * Mutation check: compare fingerprints out of the covered test (id only) and + * the re-keyed-install case goes red; drop the backup needs from the + * application list and the retired-key case goes red. + */ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { offhostKeyId } from "@/lib/jobs/offhost-key"; +import type { BackupKeyNeeds } from "@/lib/recovery/backup-key-needs"; +import { + applicationKitKeys, + assessRecoveryKit, + backupKitKeys, + fingerprintText, + neededKeyIds, + parseKitEntries, + resolvePlatformHint, + sameKit, + tokenKitKeys, + type KitEntry, +} from "@/lib/recovery/kit-rules"; + +const NOW = new Date("2026-10-25T12:00:00Z"); +const DAY = 86_400_000; + +const NO_NEEDS: BackupKeyNeeds = { + stored: [], + unrecorded: { copies: 0, oldestAt: null }, + offhost: [], + offhostExpirationDays: null, + retiredKeysStillNeeded: [], +}; + +const FINGERPRINTS: Record = { + v1: "111111111111", + v2: "222222222222", +}; + +function appKeys(configured: string[], active: string, needs = NO_NEEDS) { + return applicationKitKeys({ + activeKeyId: active, + configuredKeyIds: configured, + fingerprintOf: (id) => (configured.includes(id) ? FINGERPRINTS[id] : null), + needs, + now: NOW, + }); +} + +const BACKUP_HEX = "ab".repeat(32); +const BACKUP_OLD_HEX = "cd".repeat(32); + +describe("which keys the kit lists", () => { + it("lists every configured application key, the active one marked", () => { + const keys = appKeys(["v1", "v2"], "v2"); + expect(keys.map((k) => [k.id, k.need.active, k.fingerprint])).toEqual([ + ["v1", false, "111111111111"], + ["v2", true, "222222222222"], + ]); + }); + + it("keeps a retired key a stored copy lists, even once the server dropped it", () => { + const needs: BackupKeyNeeds = { + ...NO_NEEDS, + stored: [{ keyId: "v1", copies: 3, oldestAt: NOW.toISOString() }], + }; + const [v1] = appKeys(["v2"], "v2", needs); + expect(v1).toMatchObject({ + id: "v1", + fingerprint: null, + problem: "not_configured", + need: { configured: false, storedCopies: 3 }, + }); + }); + + it("keeps an off-host key while objects inside the retention need it", () => { + const row = (neededUntil: string | null) => ({ + ...NO_NEEDS, + offhost: [ + { + keyId: "v1", + firstWrittenAt: NOW.toISOString(), + lastWrittenAt: NOW.toISOString(), + neededUntil, + }, + ], + }); + const until = new Date(NOW.getTime() + 10 * DAY).toISOString(); + expect(appKeys(["v2"], "v2", row(until))[0].need.offhostUntil).toBe(until); + expect(appKeys(["v2"], "v2", row(null))[0].need.offhostUntil).toBe( + "unknown", + ); + // Past the retention the objects are gone, so the key drops out. + const past = new Date(NOW.getTime() - DAY).toISOString(); + expect(appKeys(["v2"], "v2", row(past)).map((k) => k.id)).toEqual(["v2"]); + expect([...neededKeyIds(row(past), NOW)]).toEqual([]); + }); + + it("names the off-host keys by the id their objects record", () => { + const keys = backupKitKeys(BACKUP_HEX, ` ${BACKUP_OLD_HEX} ,`); + expect(keys.map((k) => [k.id, k.need.active, k.source])).toEqual([ + [ + offhostKeyId(Buffer.from(BACKUP_HEX, "hex")), + true, + "BACKUP_ENCRYPTION_KEY", + ], + [ + offhostKeyId(Buffer.from(BACKUP_OLD_HEX, "hex")), + false, + "BACKUP_ENCRYPTION_PREVIOUS_KEYS", + ], + ]); + expect(keys.every((k) => k.fingerprint === k.id)).toBe(true); + expect(backupKitKeys(undefined, undefined)).toEqual([]); + }); + + it("flags an off-host key that does not decode instead of hiding it", () => { + const [bad] = backupKitKeys("not-a-key", undefined); + expect(bad).toMatchObject({ fingerprint: null, problem: "unreadable" }); + }); + + it("fingerprints the token key as the text the operator set", () => { + const value = "x".repeat(40); + const [token] = tokenKitKeys(value); + expect(token.fingerprint).toBe( + createHash("sha256").update(value).digest("hex").slice(0, 12), + ); + expect(token.fingerprint).toBe(fingerprintText(value)); + expect(JSON.stringify(token)).not.toContain(value); + }); +}); + +describe("what a confirmation covers", () => { + const listed = [...appKeys(["v1"], "v1"), ...tokenKitKeys("t".repeat(40))]; + const entries = (keys: typeof listed): KitEntry[] => + keys.map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint })); + + it("is due without any confirmation, and done for the exact set", () => { + expect(assessRecoveryKit(listed, null).due).toBe(true); + expect(assessRecoveryKit(listed, entries(listed)).due).toBe(false); + }); + + it("keeps a confirmation of the active key only, from the old step, for that key", () => { + const legacy: KitEntry[] = [ + { kind: "application", id: "v1", fingerprint: "111111111111" }, + ]; + const verdict = assessRecoveryKit(listed, legacy); + expect(verdict.due).toBe(true); + expect(verdict.keys.find((k) => k.id === "v1")?.confirmed).toBe(true); + expect(verdict.unconfirmed.map((k) => k.id)).toEqual([ + "API_TOKEN_HMAC_KEY", + ]); + }); + + it("is due again after a rotation, a re-keyed install or a new backup key", () => { + const confirmed = entries(listed); + // Rotation: a new active id. + const rotated = [ + ...appKeys(["v1", "v2"], "v2"), + ...tokenKitKeys("t".repeat(40)), + ]; + expect( + assessRecoveryKit(rotated, confirmed).unconfirmed.map((k) => k.id), + ).toEqual(["v2"]); + // Same id, other key. + const rekeyed = listed.map((k) => + k.id === "v1" ? { ...k, fingerprint: "999999999999" } : k, + ); + expect(assessRecoveryKit(rekeyed, confirmed).due).toBe(true); + // A backup key appears. + const withBackup = [...listed, ...backupKitKeys(BACKUP_HEX, undefined)]; + expect(assessRecoveryKit(withBackup, confirmed).unconfirmed).toHaveLength( + 1, + ); + }); + + it("is not due when a key merely drops out of the kit", () => { + const wider = [ + ...appKeys(["v1", "v2"], "v2"), + ...tokenKitKeys("t".repeat(40)), + ]; + const narrower = [ + ...appKeys(["v2"], "v2"), + ...tokenKitKeys("t".repeat(40)), + ]; + expect(assessRecoveryKit(narrower, entries(wider)).due).toBe(false); + }); + + it("covers a key this server does not hold by its id", () => { + const needs: BackupKeyNeeds = { + ...NO_NEEDS, + stored: [{ keyId: "v0", copies: 1, oldestAt: NOW.toISOString() }], + }; + const kit = appKeys(["v1"], "v1", needs); + const confirmed = entries(kit); + expect(confirmed.find((e) => e.id === "v0")?.fingerprint).toBeNull(); + expect(assessRecoveryKit(kit, confirmed).due).toBe(false); + }); + + it("matches the shown kit against the current one as a set", () => { + const a = entries(listed); + expect(sameKit([...a].reverse(), a)).toBe(true); + expect(sameKit(a.slice(1), a)).toBe(false); + expect( + sameKit( + a.map((e) => + e.id === "v1" ? { ...e, fingerprint: "000000000000" } : e, + ), + a, + ), + ).toBe(false); + }); + + it("reads a stored confirmation it cannot parse as none", () => { + expect(parseKitEntries({ not: "a list" })).toEqual([]); + expect( + parseKitEntries([{ kind: "nope", id: "v1", fingerprint: null }]), + ).toEqual([]); + }); +}); + +describe("platform hint", () => { + it("opens on a known platform and falls back to compose", () => { + expect(resolvePlatformHint("TrueNAS")).toBe("truenas"); + expect(resolvePlatformHint("kubernetes")).toBe("compose"); + expect(resolvePlatformHint(undefined)).toBe("compose"); + }); +}); diff --git a/src/lib/recovery/backup-key-needs.ts b/src/lib/recovery/backup-key-needs.ts new file mode 100644 index 000000000..fa2940577 --- /dev/null +++ b/src/lib/recovery/backup-key-needs.ts @@ -0,0 +1,99 @@ +/** + * Which application keys the backups still need. + * + * Rotation re-encrypts every row in the database and re-seals every stored + * backup's envelope, but the content INSIDE a backup keeps the key it was + * written under: a restore writes it back verbatim. So "every row is on the + * active key" is not the same as "the old key can go". This answers the second + * question per key id, from what each copy recorded as it was written: the + * stored copies that need it and the oldest of them, and for the off-host + * bucket the last night an object needing it went in, plus how long the + * bucket's lifecycle rule keeps objects after that. + * + * Two readers ask it: the admin encryption view, which shows the table, and + * the recovery kit, which lists every key a restore needs. One definition, so + * the kit cannot drop a key the encryption view still calls needed. + */ +import { prisma } from "@/lib/db"; +import { + offhostBackupConfigured, + probeOffhostLifecycle, +} from "@/lib/jobs/offhost-backup"; +import { neededKeyIds } from "@/lib/recovery/kit-rules"; + +export interface BackupKeyNeeds { + stored: Array<{ keyId: string; copies: number; oldestAt: string }>; + /** Copies written before v1.39.3, which recorded no key ids. */ + unrecorded: { copies: number; oldestAt: string | null }; + offhost: Array<{ + keyId: string; + firstWrittenAt: string; + lastWrittenAt: string; + /** When the last object needing this key expires; null when unknown. */ + neededUntil: string | null; + }>; + /** The bucket's expiry, when it has one the credential may read. */ + offhostExpirationDays: number | null; + /** Retired key ids some backup still needs (never the active one). */ + retiredKeysStillNeeded: string[]; +} + +export async function readBackupKeyNeeds( + activeKeyId: string, + now: Date, +): Promise { + const [stored, unrecorded, offhostRows] = await Promise.all([ + prisma.$queryRaw< + Array<{ key_id: string; copies: number; oldest_at: Date }> + >` + SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at + FROM data_backups, unnest(inner_key_ids) AS k + WHERE inner_key_ids_recorded + GROUP BY k + ORDER BY k + `, + prisma.dataBackup.aggregate({ + where: { innerKeyIdsRecorded: false }, + _count: { _all: true }, + _min: { createdAt: true }, + }), + prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }), + ]); + const lifecycle = + offhostRows.length > 0 && offhostBackupConfigured() + ? await probeOffhostLifecycle() + : null; + const expirationDays = + lifecycle?.state === "configured" ? lifecycle.expirationDays : null; + + const offhost = offhostRows.map((row) => ({ + keyId: row.keyId, + firstWrittenAt: row.firstWrittenAt.toISOString(), + lastWrittenAt: row.lastWrittenAt.toISOString(), + neededUntil: + expirationDays === null + ? null + : new Date( + row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000, + ).toISOString(), + })); + + const needs: BackupKeyNeeds = { + stored: stored.map((row) => ({ + keyId: row.key_id, + copies: row.copies, + oldestAt: row.oldest_at.toISOString(), + })), + unrecorded: { + copies: unrecorded._count._all, + oldestAt: unrecorded._min.createdAt?.toISOString() ?? null, + }, + offhost, + offhostExpirationDays: expirationDays, + retiredKeysStillNeeded: [], + }; + const retired = neededKeyIds(needs, now); + retired.delete(activeKeyId); + needs.retiredKeysStillNeeded = [...retired].sort(); + return needs; +} diff --git a/src/lib/recovery/kit-rules.ts b/src/lib/recovery/kit-rules.ts new file mode 100644 index 000000000..e7edfd791 --- /dev/null +++ b/src/lib/recovery/kit-rules.ts @@ -0,0 +1,302 @@ +/** + * The recovery kit: every key a restore of this instance needs, by id and + * fingerprint, and the admin's confirmation that a copy of each is kept + * outside the server. + * + * It replaced a step that confirmed one key, the active application key. An + * operator could do everything that step asked and still be unable to read a + * single backup: the off-host copies are sealed under `BACKUP_ENCRYPTION_KEY`, + * a copy written before a rotation needs the retired application key it was + * written under, and every session, token and share link hangs off + * `API_TOKEN_HMAC_KEY`. The kit lists all of them: + * + * - `application`: every id in `ENCRYPTION_KEYS` this server holds, plus + * every retired id a stored copy or an off-host object inside the + * bucket's retention still lists (`backup-key-needs.ts`); + * - `backup`: `BACKUP_ENCRYPTION_KEY` and each key in + * `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when set; + * - `token`: `API_TOKEN_HMAC_KEY`. + * + * The confirmation records the set the admin was shown. The kit is due again + * as soon as a key it needs is not in that set: a rotation, a new backup key, + * a re-keyed install. A key that drops out of the kit (a retired key whose + * last copy expired) does not make it due, because nothing is missing. + * + * Fingerprints only, never key material: the first twelve hex characters of + * SHA-256 over the key, the same rule each key's own surface already uses (the + * application key's boot check, the off-host object header). The token key is + * hashed as the text the operator set, so `printf %s "$API_TOKEN_HMAC_KEY" | + * sha256sum` reproduces it. + */ +import { createHash } from "node:crypto"; +import { z } from "zod/v4"; + +import { envValue } from "@/lib/env"; +import { decodeBackupKey, offhostKeyId } from "@/lib/jobs/offhost-key"; +import type { BackupKeyNeeds } from "@/lib/recovery/backup-key-needs"; + +/** Where the operator sets the keys; picks the tab the kit opens on. */ +export const KIT_PLATFORMS = [ + "compose", + "truenas", + "coolify", + "unraid", + "portainer", +] as const; +export type KitPlatform = (typeof KIT_PLATFORMS)[number]; + +/** Wire code for a confirmation of a kit that has changed since it was shown. */ +export const RECOVERY_KIT_STALE_CODE = "recovery.kit.stale"; + +export const KIT_KEY_KINDS = ["application", "backup", "token"] as const; +export type KitKeyKind = (typeof KIT_KEY_KINDS)[number]; + +/** The variable each kind of key is set in. */ +export const KIT_KEY_SOURCES = { + application: "ENCRYPTION_KEYS", + backup: "BACKUP_ENCRYPTION_KEY", + backupPrevious: "BACKUP_ENCRYPTION_PREVIOUS_KEYS", + token: "API_TOKEN_HMAC_KEY", +} as const; +export type KitKeySource = + (typeof KIT_KEY_SOURCES)[keyof typeof KIT_KEY_SOURCES]; + +/** One key as a confirmation stores it. */ +export interface KitEntry { + kind: KitKeyKind; + id: string; + /** Null for a key this server does not hold but a copy still needs. */ + fingerprint: string | null; +} + +/** Why the kit lists a key. */ +export interface KitKeyNeed { + /** The key new data is written under. */ + active: boolean; + /** This server holds the key. */ + configured: boolean; + /** Stored copies in this database whose content needs it. */ + storedCopies: number; + /** + * Off-host objects need it until this instant; `"unknown"` when the bucket's + * retention cannot be read, so they may need it indefinitely. + */ + offhostUntil: string | null; +} + +/** One key the kit lists, with its verdict. */ +export interface KitKey extends KitEntry { + source: KitKeySource; + need: KitKeyNeed; + /** The confirmation covers this key. */ + confirmed: boolean; + /** + * `not_configured`: a copy needs it and this server does not hold it, so + * only a copy kept elsewhere can read those backups. `unreadable`: the + * variable is set but does not decode as a key. + */ + problem: "not_configured" | "unreadable" | null; +} + +export interface RecoveryKit { + keys: KitKey[]; + /** A key the kit lists is not covered by the confirmation. */ + due: boolean; + /** Keys the confirmation does not cover, by kind and id. */ + unconfirmed: Array>; + confirmedAt: string | null; + /** The confirming admin, while that account exists. */ + confirmedBy: { id: string; email: string | null } | null; + platformHint: KitPlatform; +} + +/** + * `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise. + * Catalog templates set it so the kit opens on the right instructions. + */ +export function resolvePlatformHint( + raw: string | undefined = envValue("HEALTHLOG_PLATFORM"), +): KitPlatform { + const value = raw?.trim().toLowerCase(); + return (KIT_PLATFORMS as readonly string[]).includes(value ?? "") + ? (value as KitPlatform) + : "compose"; +} + +/** The fingerprint rule for a key that is text rather than decoded bytes. */ +export function fingerprintText(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12); +} + +const kitEntrySchema = z.object({ + kind: z.enum(KIT_KEY_KINDS), + id: z.string().min(1).max(64), + fingerprint: z + .string() + .regex(/^[0-9a-f]{12}$/) + .nullable(), +}); + +/** The request body of a confirmation: the kit as the admin saw it. */ +export const kitConfirmationSchema = z + .object({ entries: z.array(kitEntrySchema).min(1).max(64) }) + .strict(); + +/** + * The stored entries, validated. A row that does not parse confirms nothing, + * which leaves the kit due rather than trusting a shape it cannot read. + */ +export function parseKitEntries(raw: unknown): KitEntry[] { + const parsed = z.array(kitEntrySchema).safeParse(raw); + return parsed.success ? parsed.data : []; +} + +const entryKey = (e: Pick) => `${e.kind}:${e.id}`; + +/** + * Pure rule: which listed keys a confirmation covers. + * + * A key this server holds is covered by an entry with the same kind, id and + * fingerprint, so a re-keyed install under the same id is due again. A key it + * does not hold has no fingerprint to compare, so an entry with its kind and id + * covers it: the admin said a copy of that id is kept. + */ +export function assessRecoveryKit( + listed: ReadonlyArray>, + confirmation: readonly KitEntry[] | null, +): { keys: KitKey[]; due: boolean; unconfirmed: KitEntry[] } { + const exact = new Set( + (confirmation ?? []).map((e) => `${entryKey(e)}:${e.fingerprint ?? ""}`), + ); + const byId = new Set((confirmation ?? []).map(entryKey)); + const keys = listed.map((key) => ({ + ...key, + confirmed: + key.fingerprint === null + ? byId.has(entryKey(key)) + : exact.has(`${entryKey(key)}:${key.fingerprint}`), + })); + const unconfirmed = keys + .filter((key) => !key.confirmed) + .map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint })); + return { keys, due: unconfirmed.length > 0, unconfirmed }; +} + +/** Pure rule: does what the admin was shown match what the kit lists now? */ +export function sameKit( + shown: readonly KitEntry[], + listed: readonly KitEntry[], +): boolean { + const canon = (entries: readonly KitEntry[]) => + [ + ...new Set(entries.map((e) => `${entryKey(e)}:${e.fingerprint ?? ""}`)), + ].sort(); + const a = canon(shown); + const b = canon(listed); + return a.length === b.length && a.every((value, i) => value === b[i]); +} + +/** + * Every key id a stored copy lists, and every key id an off-host object still + * inside the bucket's retention needs. An off-host key whose expiry cannot be + * read stays needed: without the lifecycle rule nothing proves the objects are + * gone. + */ +export function neededKeyIds(needs: BackupKeyNeeds, now: Date): Set { + const needed = new Set(); + for (const row of needs.stored) needed.add(row.keyId); + for (const row of needs.offhost) { + if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) { + needed.add(row.keyId); + } + } + return needed; +} + +/** + * Pure: the application keys the kit lists, from the ids this server holds + * and what the backups still need. + */ +export function applicationKitKeys(args: { + activeKeyId: string; + configuredKeyIds: readonly string[]; + fingerprintOf: (id: string) => string | null; + needs: BackupKeyNeeds; + now: Date; +}): Array> { + const needed = neededKeyIds(args.needs, args.now); + const ids = new Set([...args.configuredKeyIds, ...needed]); + return [...ids].sort().map((id) => { + const fingerprint = args.fingerprintOf(id); + const offhost = args.needs.offhost.find((row) => row.keyId === id); + return { + kind: "application" as const, + id, + fingerprint, + source: KIT_KEY_SOURCES.application, + need: { + active: id === args.activeKeyId, + configured: fingerprint !== null, + storedCopies: + args.needs.stored.find((row) => row.keyId === id)?.copies ?? 0, + offhostUntil: + offhost && needed.has(id) ? (offhost.neededUntil ?? "unknown") : null, + }, + problem: fingerprint === null ? ("not_configured" as const) : null, + }; + }); +} + +function noNeed(active: boolean): KitKeyNeed { + return { active, configured: true, storedCopies: 0, offhostUntil: null }; +} + +/** Pure: the off-host keys, from the two variables as the operator set them. */ +export function backupKitKeys( + activeRaw: string | undefined, + previousRaw: string | undefined, +): Array> { + const out: Array> = []; + const add = (raw: string, source: KitKeySource, active: boolean) => { + let fingerprint: string | null = null; + try { + fingerprint = offhostKeyId(decodeBackupKey(raw)); + } catch { + fingerprint = null; + } + out.push({ + kind: "backup", + // The fingerprint is the id off-host objects record for their key, so + // the operator matches a kit entry to an object header directly. A key + // that does not decode has neither; it is named by where it is set. + id: fingerprint ?? `${source}#${out.length + 1}`, + fingerprint, + source, + need: noNeed(active), + problem: fingerprint === null ? "unreadable" : null, + }); + }; + const active = activeRaw?.trim(); + if (active) add(active, KIT_KEY_SOURCES.backup, true); + for (const raw of (previousRaw ?? "").split(",")) { + if (raw.trim()) add(raw.trim(), KIT_KEY_SOURCES.backupPrevious, false); + } + return out; +} + +/** Pure: the token key, which the app cannot run without. */ +export function tokenKitKeys( + raw: string | undefined, +): Array> { + const value = raw ?? ""; + return [ + { + kind: "token", + id: KIT_KEY_SOURCES.token, + fingerprint: value ? fingerprintText(value) : null, + source: KIT_KEY_SOURCES.token, + need: { ...noNeed(true), configured: value.length > 0 }, + problem: value ? null : "not_configured", + }, + ]; +} diff --git a/src/lib/recovery/kit.ts b/src/lib/recovery/kit.ts new file mode 100644 index 000000000..194b4c898 --- /dev/null +++ b/src/lib/recovery/kit.ts @@ -0,0 +1,173 @@ +/** + * The recovery kit on this server: which keys it lists right now, what the + * stored confirmation covers, recording a confirmation, and "Check my copy". + * The rules themselves are pure and live in `kit-rules.ts`. + */ +import { createHash, timingSafeEqual } from "node:crypto"; + +import { prisma } from "@/lib/db"; +import { envValue } from "@/lib/env"; +import { + candidateMatchesKey, + getActiveKeyId, + getConfiguredKeyIds, + getKeyFingerprint, +} from "@/lib/crypto"; +import { decodeBackupKey, offhostKeyId } from "@/lib/jobs/offhost-key"; +import { readBackupKeyNeeds } from "@/lib/recovery/backup-key-needs"; +import { + applicationKitKeys, + assessRecoveryKit, + backupKitKeys, + KIT_KEY_SOURCES, + parseKitEntries, + resolvePlatformHint, + sameKit, + tokenKitKeys, + type KitEntry, + type KitKey, + type KitKeyKind, + type RecoveryKit, +} from "@/lib/recovery/kit-rules"; + +/** Every key the kit lists on this server right now, without a verdict. */ +export async function listKitKeys( + now: Date = new Date(), +): Promise>> { + const activeKeyId = getActiveKeyId(); + const needs = await readBackupKeyNeeds(activeKeyId, now); + return [ + ...applicationKitKeys({ + activeKeyId, + configuredKeyIds: getConfiguredKeyIds(), + fingerprintOf: getKeyFingerprint, + needs, + now, + }), + ...backupKitKeys( + envValue("BACKUP_ENCRYPTION_KEY"), + envValue("BACKUP_ENCRYPTION_PREVIOUS_KEYS"), + ), + ...tokenKitKeys(envValue("API_TOKEN_HMAC_KEY")), + ]; +} + +export async function readRecoveryKit( + now: Date = new Date(), +): Promise { + const [listed, row] = await Promise.all([ + listKitKeys(now), + prisma.recoveryKitConfirmation.findUnique({ + where: { id: "singleton" }, + }), + ]); + const verdict = assessRecoveryKit( + listed, + row ? parseKitEntries(row.entries) : null, + ); + const confirmedBy = row?.confirmedByUserId + ? await prisma.user.findUnique({ + where: { id: row.confirmedByUserId }, + select: { id: true, email: true }, + }) + : null; + return { + keys: verdict.keys, + due: verdict.due, + unconfirmed: verdict.unconfirmed.map(({ kind, id }) => ({ kind, id })), + confirmedAt: row?.confirmedAt.toISOString() ?? null, + confirmedBy: confirmedBy ?? null, + platformHint: resolvePlatformHint(), + }; +} + +export type ConfirmRecoveryKitResult = + { ok: true; kit: RecoveryKit } | { ok: false; kit: RecoveryKit }; + +/** + * Record the confirmation, but only for the kit the admin was shown: a page + * opened before a rotation or a new backup key must not confirm a key the + * admin never saw. + */ +export async function confirmRecoveryKit(input: { + shown: readonly KitEntry[]; + userId: string; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const listed = await listKitKeys(now); + const entries: KitEntry[] = listed.map(({ kind, id, fingerprint }) => ({ + kind, + id, + fingerprint, + })); + if (!sameKit(input.shown, entries)) { + return { ok: false, kit: await readRecoveryKit(now) }; + } + const data = { + confirmedAt: now, + confirmedByUserId: input.userId, + entries: entries.map(({ kind, id, fingerprint }) => ({ + kind, + id, + fingerprint, + })), + }; + await prisma.recoveryKitConfirmation.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ...data }, + update: data, + }); + return { ok: true, kit: await readRecoveryKit(now) }; +} + +/** + * "Check my copy": does `value` decode to the key the kit lists as + * `kind`/`id` on this server? Constant time on the compared bytes; a value + * that does not decode is simply not a match. + */ +export function verifyKitKey(input: { + kind: KitKeyKind; + id: string; + value: string; +}): boolean { + const value = input.value.trim(); + switch (input.kind) { + case "application": + return candidateMatchesKey(value, input.id); + case "backup": { + let candidate: Buffer; + try { + candidate = decodeBackupKey(value); + } catch { + return false; + } + const configured = [ + envValue("BACKUP_ENCRYPTION_KEY"), + ...(envValue("BACKUP_ENCRYPTION_PREVIOUS_KEYS") ?? "").split(","), + ] + .map((raw) => raw?.trim() ?? "") + .filter((raw) => raw.length > 0) + .flatMap((raw) => { + try { + return [decodeBackupKey(raw)]; + } catch { + return []; + } + }) + .find((key) => offhostKeyId(key) === input.id); + return ( + configured !== undefined && + configured.length === candidate.length && + timingSafeEqual(configured, candidate) + ); + } + case "token": { + const configured = envValue("API_TOKEN_HMAC_KEY"); + if (!configured || input.id !== KIT_KEY_SOURCES.token) return false; + // Digests, so the comparison is constant time whatever the lengths. + const digest = (s: string) => createHash("sha256").update(s).digest(); + return timingSafeEqual(digest(value), digest(configured)); + } + } +} diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index 5825e765a..f597f3954 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -54,10 +54,6 @@ }, "documentMaxFileBytes": 3, "documentQuotaBytes": "5", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", @@ -2504,10 +2500,6 @@ "aiUsageMaxLevel": "FRUGAL", "aiUsageDefaultLevel": "FRUGAL", "aiSyncRegenGapHours": 3, - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index ff5e45346..bc50cba10 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -2902,10 +2902,6 @@ }, "documentMaxFileBytes": 3, "documentQuotaBytes": "bigint:5", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", @@ -2970,10 +2966,6 @@ }, "documentMaxFileBytes": 3, "documentQuotaBytes": "5", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index b73ea56a6..b66d44a9a 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -2396,10 +2396,6 @@ }, "documentMaxFileBytes": 3, "documentQuotaBytes": "bigint:5", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 901ab0a0f..2d983c8ca 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -1004,10 +1004,6 @@ }, "documentMaxFileBytes": 10000000, "documentQuotaBytes": "bigint:500000000", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index bc26e8895..2d3534c66 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -1137,10 +1137,6 @@ }, "documentMaxFileBytes": 10000000, "documentQuotaBytes": "bigint:500000000", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", @@ -1203,10 +1199,6 @@ }, "documentMaxFileBytes": 10000000, "documentQuotaBytes": "500000000", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 1e9a3bc58..7b5206628 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -2515,10 +2515,6 @@ }, "documentMaxFileBytes": 3, "documentQuotaBytes": "bigint:5", - "encryptionKeyBackupConfirmedAt": "2026-06-30T12:34:56.789Z", - "encryptionKeyBackupConfirmedKeyId": "rt-AppSettings-encryptionKeyBackupConfirmedKeyId", - "encryptionKeyBackupConfirmedFingerprint": "rt-AppSettings-encryptionKeyBackupConfirmedFingerprint", - "encryptionKeyBackupConfirmedByUserId": "rt-AppSettings-encryptionKeyBackupConfirmedByUserId", "correlationSeasonalEngineSince": "2026-06-30T12:34:56.789Z", "pulseDayMeanMethod": "hour_mean_v1", "pulseDayMeanAppliedAt": "2026-06-30T12:34:56.789Z", diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index f7d1a8ea5..7e4700609 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -1,6 +1,5 @@ /** - * The boot encryption key check and the "Back up your encryption key" step, - * against real Postgres. + * The boot encryption key check, 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, @@ -9,9 +8,9 @@ * - 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. + * + * The recovery kit that replaced the key backup step is pinned in + * `recovery-kit.test.ts`. */ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -83,7 +82,6 @@ import { _resetCryptoCacheForTests, encrypt, encryptUnderKeyId, - getKeyFingerprint, } from "@/lib/crypto"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { runBootKeyCheck } from "@/lib/boot/key-check"; @@ -145,7 +143,6 @@ 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); @@ -185,10 +182,10 @@ describe("boot encryption key check (real Postgres)", () => { 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"), + const { GET: recoveryKitGet } = + await import("@/app/api/admin/recovery-kit/route"); + const refused = await asRoute(recoveryKitGet)( + new NextRequest("http://localhost/api/admin/recovery-kit"), ); expect(refused.status).toBe(503); const body = await refused.json(); @@ -526,10 +523,10 @@ describe("ENCRYPTION_KEY_CHECK (real Postgres)", () => { // Requests are served, through apiHandler and outside it. await seedAdmin(); - const { GET: keyBackupGet } = - await import("@/app/api/admin/encryption/key-backup/route"); - const served = await asRoute(keyBackupGet)( - new NextRequest("http://localhost/api/admin/encryption/key-backup"), + const { GET: recoveryKitGet } = + await import("@/app/api/admin/recovery-kit/route"); + const served = await asRoute(recoveryKitGet)( + new NextRequest("http://localhost/api/admin/recovery-kit"), ); expect(served.status).toBe(200); const { POST: mcp } = await import("@/app/mcp/route"); @@ -707,106 +704,3 @@ describe("routes outside apiHandler refuse while the key does not match", () => } }); }); - -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); - }); -}); diff --git a/tests/integration/recovery-kit.test.ts b/tests/integration/recovery-kit.test.ts new file mode 100644 index 000000000..d100f1d4e --- /dev/null +++ b/tests/integration/recovery-kit.test.ts @@ -0,0 +1,359 @@ +/** + * The recovery kit against real Postgres, through its routes. + * + * - the kit lists the application key, the token key and, when set, the + * off-host backup key, by id and fingerprint and never key material; + * - a confirmation of a kit the admin was not shown is refused with 409 + * and records nothing; the right one records the set and is audited; + * - a rotation makes the kit due again, and the retired key stays listed, + * named, for as long as a stored copy lists it, also once the server no + * longer holds it; when the last such copy is gone it drops out; + * - the readiness model names a needed key nobody confirmed by its id; + * - "Check my copy" answers a boolean for each kind of key, stores + * nothing, and is rate limited; + * - migration 0487 carries a confirmation given under the old one-key step + * forward as a kit holding that key. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +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, getKeyFingerprint } from "@/lib/crypto"; +import { offhostKeyId } from "@/lib/jobs/offhost-key"; +import type { RecoveryKit } from "@/lib/recovery/kit-rules"; + +const KEY_A = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_B = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; +const BACKUP_KEY = "ab".repeat(32); +const DAY = 86_400_000; + +function useKeys(keys: Record, active: string) { + vi.stubEnv("ENCRYPTION_KEY", ""); + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(keys)); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active); + _resetCryptoCacheForTests(); +} + +async function seedAdmin(createdAt = new Date()): Promise { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "kit-admin", + email: "kit-admin@example.test", + role: "ADMIN", + createdAt, + }, + }); + 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; +} + +type RouteFn = (request: NextRequest) => Promise; +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), + }); +} + +async function readKit(): Promise { + const { GET } = await import("@/app/api/admin/recovery-kit/route"); + const res = await asRoute(GET)( + new NextRequest("http://localhost/api/admin/recovery-kit"), + ); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +const shown = (kit: RecoveryKit) => ({ + entries: kit.keys.map(({ kind, id, fingerprint }) => ({ + kind, + id, + fingerprint, + })), +}); + +async function confirm(body: unknown): Promise { + const { POST } = await import("@/app/api/admin/recovery-kit/confirm/route"); + return POST(post("/api/admin/recovery-kit/confirm", body)); +} + +async function storedCopyListing(userId: string, keyIds: string[]) { + await getPrismaClient().dataBackup.create({ + data: { + userId, + type: "WEEKLY_AUTO", + data: "sealed", + innerKeyIds: keyIds, + innerKeyIdsRecorded: true, + }, + }); +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'recovery-kit-verify:%'`; + cookieJar.clear(); + headerJar.clear(); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", ""); + vi.stubEnv("BACKUP_ENCRYPTION_PREVIOUS_KEYS", ""); + vi.stubEnv("BACKUP_S3_ENDPOINT", ""); + useKeys({ v1: KEY_A }, "v1"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + _resetCryptoCacheForTests(); +}); + +describe("recovery kit (real Postgres)", () => { + it("lists every key by id and fingerprint, refuses a stale confirmation, and records the right one", async () => { + const prisma = getPrismaClient(); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", BACKUP_KEY); + vi.stubEnv("HEALTHLOG_PLATFORM", "truenas"); + const adminId = await seedAdmin(); + + const kit = await readKit(); + expect(kit.due).toBe(true); + expect(kit.platformHint).toBe("truenas"); + expect(kit.keys.map((k) => [k.kind, k.id, k.fingerprint])).toEqual([ + ["application", "v1", getKeyFingerprint("v1")], + [ + "backup", + offhostKeyId(Buffer.from(BACKUP_KEY, "hex")), + offhostKeyId(Buffer.from(BACKUP_KEY, "hex")), + ], + ["token", "API_TOKEN_HMAC_KEY", expect.stringMatching(/^[0-9a-f]{12}$/)], + ]); + const wire = JSON.stringify(kit); + expect(wire).not.toContain(KEY_A); + expect(wire).not.toContain(BACKUP_KEY); + expect(wire).not.toContain(process.env.API_TOKEN_HMAC_KEY ?? ""); + + const tampered = shown(kit); + tampered.entries[0] = { + ...tampered.entries[0], + fingerprint: "000000000000", + }; + const stale = await confirm(tampered); + expect(stale.status).toBe(409); + expect((await stale.json()).meta.errorCode).toBe("recovery.kit.stale"); + expect(await prisma.recoveryKitConfirmation.count()).toBe(0); + + const ok = await confirm(shown(kit)); + expect(ok.status).toBe(200); + const confirmed: RecoveryKit = (await ok.json()).data; + expect(confirmed.due).toBe(false); + expect(confirmed.confirmedBy).toEqual({ + id: adminId, + email: "kit-admin@example.test", + }); + const audit = await prisma.auditLog.findFirst({ + where: { action: "recovery.kit.confirmed" }, + }); + expect(audit).not.toBeNull(); + expect(JSON.stringify(audit)).not.toContain(KEY_A); + }); + + it("is due again after a rotation and keeps naming the retired key while a copy lists it", async () => { + const adminId = await seedAdmin(); + await storedCopyListing(adminId, ["v1"]); + expect((await confirm(shown(await readKit()))).status).toBe(200); + + // Rotate: v2 becomes active, v1 stays configured for the old data. + useKeys({ v1: KEY_A, v2: KEY_B }, "v2"); + const rotated = await readKit(); + expect(rotated.due).toBe(true); + expect(rotated.unconfirmed).toEqual([{ kind: "application", id: "v2" }]); + expect(rotated.keys.find((k) => k.id === "v1")).toMatchObject({ + confirmed: true, + need: { active: false, storedCopies: 1 }, + }); + expect((await confirm(shown(rotated))).status).toBe(200); + expect((await readKit()).due).toBe(false); + + // The operator drops v1 from the server while the copy still lists it: + // the kit keeps naming it, covered by the earlier confirmation of v1. + useKeys({ v2: KEY_B }, "v2"); + const dropped = await readKit(); + expect(dropped.keys.find((k) => k.id === "v1")).toMatchObject({ + fingerprint: null, + problem: "not_configured", + confirmed: true, + }); + expect(dropped.due).toBe(false); + + // Once no copy lists v1, it leaves the kit. + await getPrismaClient().dataBackup.deleteMany({}); + expect((await readKit()).keys.map((k) => k.id)).not.toContain("v1"); + }); + + it("names a needed key nobody confirmed in the readiness verdict", async () => { + const adminId = await seedAdmin(new Date(Date.now() - 30 * DAY)); + useKeys({ v1: KEY_A, v2: KEY_B }, "v2"); + await storedCopyListing(adminId, ["v1"]); + // A confirmation that covers only the new key and the token key. + const kit = await readKit(); + await getPrismaClient().recoveryKitConfirmation.create({ + data: { + confirmedAt: new Date(), + entries: shown(kit).entries.filter((e) => e.id !== "v1"), + }, + }); + + const { readRecoveryReadiness } = await import("@/lib/recovery/readiness"); + const readiness = await readRecoveryReadiness(); + expect( + readiness.signals.find((s) => s.id === "recovery_kit"), + ).toMatchObject({ + state: "attention", + reason: "out_of_date", + params: { keys: "v1", count: 1 }, + }); + + expect((await confirm(shown(kit))).status).toBe(200); + const after = await readRecoveryReadiness(); + expect(after.signals.find((s) => s.id === "recovery_kit")?.state).toBe( + "ok", + ); + }); + + it("checks a copy of each kind of key without storing it, and limits the checks", async () => { + const prisma = getPrismaClient(); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", BACKUP_KEY); + await seedAdmin(); + const { POST: verify } = + await import("@/app/api/admin/recovery-kit/verify/route"); + const check = (kind: string, id: string, keyCopy: string) => + verify(post("/api/admin/recovery-kit/verify", { kind, id, keyCopy })); + const backupId = offhostKeyId(Buffer.from(BACKUP_KEY, "hex")); + const token = process.env.API_TOKEN_HMAC_KEY ?? ""; + expect(token.length).toBeGreaterThan(0); + + const good = await check("application", "v1", `${KEY_A}\n`); + expect(good.status).toBe(200); + expect((await good.json()).data).toEqual({ + matches: true, + kind: "application", + id: "v1", + }); + expect( + (await (await check("backup", backupId, BACKUP_KEY)).json()).data.matches, + ).toBe(true); + expect( + (await (await check("token", "API_TOKEN_HMAC_KEY", token)).json()).data + .matches, + ).toBe(true); + expect( + (await (await check("application", "v1", KEY_B)).json()).data.matches, + ).toBe(false); + + const invalid = await verify( + post("/api/admin/recovery-kit/verify", { kind: "application", id: "v1" }), + ); + expect(invalid.status).toBe(422); + expect(JSON.stringify(await invalid.json())).not.toContain("issues"); + + const audits = JSON.stringify(await prisma.auditLog.findMany()); + for (const secret of [KEY_A, KEY_B, BACKUP_KEY, token]) { + expect(audits).not.toContain(secret); + } + expect(await prisma.recoveryKitConfirmation.count()).toBe(0); + + const limited = await check("application", "v1", KEY_A); + expect(limited.status).toBe(429); + }); +}); + +describe("migration 0487", () => { + it("carries a confirmation of the active key forward as a kit holding that key", async () => { + const prisma = getPrismaClient(); + const adminId = await seedAdmin(); + const fingerprint = getKeyFingerprint("v1"); + // The shape before 0487: the four columns on the settings row. + await prisma.$executeRawUnsafe(` + ALTER TABLE app_settings + ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_at TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_key_id TEXT, + ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_fingerprint TEXT, + ADD COLUMN IF NOT EXISTS encryption_key_backup_confirmed_by_user_id TEXT + `); + await prisma.$executeRaw` + INSERT INTO app_settings (id, encryption_key_backup_confirmed_at, + encryption_key_backup_confirmed_key_id, + encryption_key_backup_confirmed_fingerprint, + encryption_key_backup_confirmed_by_user_id) + VALUES ('singleton', NOW(), 'v1', ${fingerprint}, ${adminId}) + ON CONFLICT (id) DO UPDATE SET + encryption_key_backup_confirmed_at = NOW(), + encryption_key_backup_confirmed_key_id = 'v1', + encryption_key_backup_confirmed_fingerprint = ${fingerprint}, + encryption_key_backup_confirmed_by_user_id = ${adminId} + `; + + const sql = readFileSync( + join( + process.cwd(), + "prisma/migrations/0487_recovery_readiness/migration.sql", + ), + "utf8", + ); + await prisma.$executeRawUnsafe(sql); + // Running it again changes nothing. + await prisma.$executeRawUnsafe(sql); + + const columns = await prisma.$queryRaw>` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'app_settings' + AND column_name LIKE 'encryption_key_backup%' + `; + expect(columns).toEqual([]); + + const kit = await readKit(); + expect(kit.confirmedBy?.id).toBe(adminId); + expect(kit.keys.find((k) => k.id === "v1")?.confirmed).toBe(true); + // The old step never covered the token key, so the kit asks for it. + expect(kit.unconfirmed).toEqual([ + { kind: "token", id: "API_TOKEN_HMAC_KEY" }, + ]); + }); +});