mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge: integrate the v1.23 data export and encryption-status wave
This commit is contained in:
commit
268c5cef05
29 files changed
+2346
-125
No files matched your search
@@ -1349,6 +1349,35 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/export/encrypted:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Download the full backup as a passphrase-encrypted archive
|
||||
description: v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive
|
||||
(Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a
|
||||
`.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`,
|
||||
cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal
|
||||
session or Bearer. Shared `export:<userId>` rate bucket (10/h). The passphrase is never stored — a forgotten
|
||||
passphrase makes the archive unrecoverable.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EncryptedExportRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2
|
||||
params | salt | iv | tag | ciphertext).
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/login:
|
||||
post:
|
||||
tags:
|
||||
@@ -7826,6 +7855,17 @@ paths:
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
EncryptedExportRequest:
|
||||
type: object
|
||||
properties:
|
||||
passphrase:
|
||||
type: string
|
||||
minLength: 12
|
||||
maxLength: 1024
|
||||
description: User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no
|
||||
server-side recovery.
|
||||
required:
|
||||
- passphrase
|
||||
MfaVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+45
-1
@@ -4073,7 +4073,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Voll-Backup",
|
||||
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload."
|
||||
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.",
|
||||
"encryptToggle": "Mit einer Passphrase verschlüsseln",
|
||||
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
|
||||
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
|
||||
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
|
||||
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
@@ -5625,6 +5630,45 @@
|
||||
"module-availability": {
|
||||
"title": "Module",
|
||||
"subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Verschlüsselung",
|
||||
"subtitle": "Verschlüsselungsabdeckung und Schlüsselrotations-Status für ruhende Daten.",
|
||||
"loading": "Verschlüsselungsstatus wird geladen...",
|
||||
"loadError": "Verschlüsselungsstatus konnte nicht geladen werden.",
|
||||
"coverageTitle": "Abdeckung",
|
||||
"coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen. Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.",
|
||||
"activeKeyId": "Aktive Schlüssel-ID",
|
||||
"configuredKeys": "Konfigurierte Schlüssel",
|
||||
"totalRows": "Verschlüsselte Zeilen",
|
||||
"staleRows": "Zeilen nicht auf dem aktiven Schlüssel",
|
||||
"safeToDropLegacy": "Alter Schlüssel kann entfernt werden",
|
||||
"rotationIncomplete": "{count} Zeilen brauchen noch eine Rotation",
|
||||
"rotationTitle": "Schlüsselrotation",
|
||||
"rotationDescription": "Jede verschlüsselte Spalte auf den aktiven Schlüssel neu verschlüsseln. Das Kommandozeilen-Werkzeug bleibt der kanonische Weg; dieser Auslöser ist eine Bequemlichkeit.",
|
||||
"rotationState": "Status",
|
||||
"lastRun": "Letzter Lauf: {scanned} geprüft, {rotated} rotiert, {errors} Fehler.",
|
||||
"rotateNow": "Jetzt rotieren",
|
||||
"rotateConfirmTitle": "Den gesamten Datensatz neu verschlüsseln?",
|
||||
"rotateConfirmDescription": "Verschlüsselt jede verschlüsselte Spalte auf den aktiven Schlüssel neu. Es wird nie ein Schlüssel hinzugefügt oder entfernt, und ein erneuter Lauf ist sicher. Bei großen Konten kann das eine Weile dauern.",
|
||||
"cancel": "Abbrechen",
|
||||
"rotateConfirm": "Rotation starten",
|
||||
"rotateEnqueued": "Rotation gestartet.",
|
||||
"rotateFailed": "Rotation konnte nicht gestartet werden.",
|
||||
"stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.",
|
||||
"cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Abdeckung pro Spalte",
|
||||
"columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.",
|
||||
"colColumn": "Spalte",
|
||||
"colTotal": "Gesamt",
|
||||
"colActive": "Auf aktivem Schlüssel",
|
||||
"colStale": "Veraltet",
|
||||
"state": {
|
||||
"idle": "Bereit",
|
||||
"running": "Läuft",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Anbieter",
|
||||
|
||||
+45
-1
@@ -4073,7 +4073,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Full backup",
|
||||
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload."
|
||||
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.",
|
||||
"encryptToggle": "Encrypt with a passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
|
||||
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
|
||||
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
|
||||
"stepUpNeeded": "Confirm your second factor, then try again."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
@@ -5625,6 +5630,45 @@
|
||||
"module-availability": {
|
||||
"title": "Modules",
|
||||
"subtitle": "Turn modules on or off for every account on this server."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Encryption",
|
||||
"subtitle": "Encryption coverage and key-rotation status for data at rest.",
|
||||
"loading": "Loading encryption status...",
|
||||
"loadError": "Failed to load encryption status.",
|
||||
"coverageTitle": "Coverage",
|
||||
"coverageDescription": "How many encrypted rows sit under each key id. Only key ids and counts are shown — never key material.",
|
||||
"activeKeyId": "Active key id",
|
||||
"configuredKeys": "Configured keys",
|
||||
"totalRows": "Encrypted rows",
|
||||
"staleRows": "Rows not on the active key",
|
||||
"safeToDropLegacy": "Safe to drop the legacy key",
|
||||
"rotationIncomplete": "{count} rows still need rotation",
|
||||
"rotationTitle": "Key rotation",
|
||||
"rotationDescription": "Re-encrypt every encrypted column to the active key. The command-line tool stays the canonical path; this trigger is a convenience.",
|
||||
"rotationState": "State",
|
||||
"lastRun": "Last run: scanned {scanned}, rotated {rotated}, errors {errors}.",
|
||||
"rotateNow": "Rotate now",
|
||||
"rotateConfirmTitle": "Re-encrypt the whole record?",
|
||||
"rotateConfirmDescription": "Re-encrypts every encrypted column to the active key. It never adds or drops a key and is safe to re-run. This can take a while on large accounts.",
|
||||
"cancel": "Cancel",
|
||||
"rotateConfirm": "Start rotation",
|
||||
"rotateEnqueued": "Rotation started.",
|
||||
"rotateFailed": "Could not start the rotation.",
|
||||
"stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.",
|
||||
"cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Per-column coverage",
|
||||
"columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.",
|
||||
"colColumn": "Column",
|
||||
"colTotal": "Total",
|
||||
"colActive": "On active key",
|
||||
"colStale": "Stale",
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Carrier",
|
||||
|
||||
+45
-1
@@ -4073,7 +4073,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Copia completa",
|
||||
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador."
|
||||
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.",
|
||||
"encryptToggle": "Cifrar con una contraseña",
|
||||
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
|
||||
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
|
||||
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
@@ -5625,6 +5630,45 @@
|
||||
"module-availability": {
|
||||
"title": "Módulos",
|
||||
"subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Cifrado",
|
||||
"subtitle": "Cobertura de cifrado y estado de rotación de claves para los datos en reposo.",
|
||||
"loading": "Cargando el estado de cifrado...",
|
||||
"loadError": "No se pudo cargar el estado de cifrado.",
|
||||
"coverageTitle": "Cobertura",
|
||||
"coverageDescription": "Cuántas filas cifradas hay bajo cada id de clave. Solo se muestran los id de clave y los recuentos, nunca el material de la clave.",
|
||||
"activeKeyId": "Id de clave activa",
|
||||
"configuredKeys": "Claves configuradas",
|
||||
"totalRows": "Filas cifradas",
|
||||
"staleRows": "Filas que no usan la clave activa",
|
||||
"safeToDropLegacy": "Es seguro eliminar la clave antigua",
|
||||
"rotationIncomplete": "Aún quedan {count} filas por rotar",
|
||||
"rotationTitle": "Rotación de claves",
|
||||
"rotationDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. La herramienta de línea de comandos sigue siendo la vía canónica; este disparador es una comodidad.",
|
||||
"rotationState": "Estado",
|
||||
"lastRun": "Última ejecución: {scanned} revisadas, {rotated} rotadas, {errors} errores.",
|
||||
"rotateNow": "Rotar ahora",
|
||||
"rotateConfirmTitle": "¿Volver a cifrar todo el registro?",
|
||||
"rotateConfirmDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. Nunca añade ni elimina una clave y se puede repetir con seguridad. En cuentas grandes puede tardar un rato.",
|
||||
"cancel": "Cancelar",
|
||||
"rotateConfirm": "Iniciar rotación",
|
||||
"rotateEnqueued": "Rotación iniciada.",
|
||||
"rotateFailed": "No se pudo iniciar la rotación.",
|
||||
"stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.",
|
||||
"cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Cobertura por columna",
|
||||
"columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.",
|
||||
"colColumn": "Columna",
|
||||
"colTotal": "Total",
|
||||
"colActive": "En clave activa",
|
||||
"colStale": "Obsoletas",
|
||||
"state": {
|
||||
"idle": "Inactivo",
|
||||
"running": "En curso",
|
||||
"completed": "Completado",
|
||||
"failed": "Fallido"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Operador",
|
||||
|
||||
+45
-1
@@ -4073,7 +4073,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Sauvegarde complète",
|
||||
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur."
|
||||
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.",
|
||||
"encryptToggle": "Chiffrer avec une phrase secrète",
|
||||
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
|
||||
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
|
||||
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
|
||||
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
@@ -5625,6 +5630,45 @@
|
||||
"module-availability": {
|
||||
"title": "Modules",
|
||||
"subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Chiffrement",
|
||||
"subtitle": "Couverture du chiffrement et état de la rotation des clés pour les données au repos.",
|
||||
"loading": "Chargement de l'état du chiffrement...",
|
||||
"loadError": "Échec du chargement de l'état du chiffrement.",
|
||||
"coverageTitle": "Couverture",
|
||||
"coverageDescription": "Combien de lignes chiffrées relèvent de chaque id de clé. Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé.",
|
||||
"activeKeyId": "Id de clé active",
|
||||
"configuredKeys": "Clés configurées",
|
||||
"totalRows": "Lignes chiffrées",
|
||||
"staleRows": "Lignes hors de la clé active",
|
||||
"safeToDropLegacy": "La clé ancienne peut être retirée",
|
||||
"rotationIncomplete": "{count} lignes restent à faire tourner",
|
||||
"rotationTitle": "Rotation des clés",
|
||||
"rotationDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. L'outil en ligne de commande reste la voie canonique ; ce déclencheur est un confort.",
|
||||
"rotationState": "État",
|
||||
"lastRun": "Dernière exécution : {scanned} analysées, {rotated} tournées, {errors} erreurs.",
|
||||
"rotateNow": "Faire tourner maintenant",
|
||||
"rotateConfirmTitle": "Re-chiffrer tout le dossier ?",
|
||||
"rotateConfirmDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. Cela n'ajoute ni ne retire jamais de clé et peut être relancé sans risque. Cela peut prendre du temps sur les gros comptes.",
|
||||
"cancel": "Annuler",
|
||||
"rotateConfirm": "Démarrer la rotation",
|
||||
"rotateEnqueued": "Rotation démarrée.",
|
||||
"rotateFailed": "Impossible de démarrer la rotation.",
|
||||
"stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.",
|
||||
"cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Couverture par colonne",
|
||||
"columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.",
|
||||
"colColumn": "Colonne",
|
||||
"colTotal": "Total",
|
||||
"colActive": "Sur clé active",
|
||||
"colStale": "Obsolètes",
|
||||
"state": {
|
||||
"idle": "Au repos",
|
||||
"running": "En cours",
|
||||
"completed": "Terminé",
|
||||
"failed": "Échoué"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Opérateur",
|
||||
|
||||
+45
-1
@@ -4073,7 +4073,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Backup completo",
|
||||
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore."
|
||||
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.",
|
||||
"encryptToggle": "Cifra con una passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
|
||||
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
|
||||
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
|
||||
"stepUpNeeded": "Conferma il secondo fattore e riprova."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
@@ -5625,6 +5630,45 @@
|
||||
"module-availability": {
|
||||
"title": "Moduli",
|
||||
"subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Crittografia",
|
||||
"subtitle": "Copertura della crittografia e stato della rotazione delle chiavi per i dati a riposo.",
|
||||
"loading": "Caricamento dello stato della crittografia...",
|
||||
"loadError": "Impossibile caricare lo stato della crittografia.",
|
||||
"coverageTitle": "Copertura",
|
||||
"coverageDescription": "Quante righe cifrate stanno sotto ogni id di chiave. Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave.",
|
||||
"activeKeyId": "Id chiave attiva",
|
||||
"configuredKeys": "Chiavi configurate",
|
||||
"totalRows": "Righe cifrate",
|
||||
"staleRows": "Righe non sulla chiave attiva",
|
||||
"safeToDropLegacy": "È sicuro rimuovere la vecchia chiave",
|
||||
"rotationIncomplete": "Restano {count} righe da ruotare",
|
||||
"rotationTitle": "Rotazione delle chiavi",
|
||||
"rotationDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Lo strumento da riga di comando resta la via canonica; questo comando è una comodità.",
|
||||
"rotationState": "Stato",
|
||||
"lastRun": "Ultima esecuzione: {scanned} esaminate, {rotated} ruotate, {errors} errori.",
|
||||
"rotateNow": "Ruota ora",
|
||||
"rotateConfirmTitle": "Ricifrare l'intero archivio?",
|
||||
"rotateConfirmDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Non aggiunge né rimuove mai una chiave ed è sicuro rilanciarlo. Su account grandi può richiedere tempo.",
|
||||
"cancel": "Annulla",
|
||||
"rotateConfirm": "Avvia rotazione",
|
||||
"rotateEnqueued": "Rotazione avviata.",
|
||||
"rotateFailed": "Impossibile avviare la rotazione.",
|
||||
"stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.",
|
||||
"cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Copertura per colonna",
|
||||
"columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.",
|
||||
"colColumn": "Colonna",
|
||||
"colTotal": "Totale",
|
||||
"colActive": "Su chiave attiva",
|
||||
"colStale": "Obsolete",
|
||||
"state": {
|
||||
"idle": "Inattivo",
|
||||
"running": "In corso",
|
||||
"completed": "Completato",
|
||||
"failed": "Fallito"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Operatore",
|
||||
|
||||
+45
-1
@@ -4073,7 +4073,12 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Pełna kopia zapasowa",
|
||||
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora."
|
||||
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.",
|
||||
"encryptToggle": "Zaszyfruj hasłem-frazą",
|
||||
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
|
||||
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
|
||||
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
|
||||
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
@@ -5625,6 +5630,45 @@
|
||||
"module-availability": {
|
||||
"title": "Moduły",
|
||||
"subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Szyfrowanie",
|
||||
"subtitle": "Pokrycie szyfrowaniem i status rotacji kluczy dla danych w spoczynku.",
|
||||
"loading": "Ładowanie statusu szyfrowania...",
|
||||
"loadError": "Nie udało się wczytać statusu szyfrowania.",
|
||||
"coverageTitle": "Pokrycie",
|
||||
"coverageDescription": "Ile zaszyfrowanych wierszy przypada na każdy identyfikator klucza. Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza.",
|
||||
"activeKeyId": "Identyfikator aktywnego klucza",
|
||||
"configuredKeys": "Skonfigurowane klucze",
|
||||
"totalRows": "Zaszyfrowane wiersze",
|
||||
"staleRows": "Wiersze poza aktywnym kluczem",
|
||||
"safeToDropLegacy": "Można bezpiecznie usunąć stary klucz",
|
||||
"rotationIncomplete": "Pozostało {count} wierszy do rotacji",
|
||||
"rotationTitle": "Rotacja kluczy",
|
||||
"rotationDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Narzędzie wiersza poleceń pozostaje kanoniczną drogą; ten przycisk to udogodnienie.",
|
||||
"rotationState": "Status",
|
||||
"lastRun": "Ostatni przebieg: sprawdzono {scanned}, zrotowano {rotated}, błędy {errors}.",
|
||||
"rotateNow": "Rotuj teraz",
|
||||
"rotateConfirmTitle": "Ponownie zaszyfrować cały rekord?",
|
||||
"rotateConfirmDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Nigdy nie dodaje ani nie usuwa klucza i można go bezpiecznie uruchomić ponownie. Na dużych kontach może to chwilę potrwać.",
|
||||
"cancel": "Anuluj",
|
||||
"rotateConfirm": "Rozpocznij rotację",
|
||||
"rotateEnqueued": "Rotacja rozpoczęta.",
|
||||
"rotateFailed": "Nie udało się rozpocząć rotacji.",
|
||||
"stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.",
|
||||
"cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.",
|
||||
"columnsTitle": "Pokrycie wg kolumn",
|
||||
"columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.",
|
||||
"colColumn": "Kolumna",
|
||||
"colTotal": "Razem",
|
||||
"colActive": "Na aktywnym kluczu",
|
||||
"colStale": "Przestarzałe",
|
||||
"state": {
|
||||
"idle": "Bezczynny",
|
||||
"running": "W toku",
|
||||
"completed": "Zakończono",
|
||||
"failed": "Niepowodzenie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"carrier": "Operator",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* scripts/decrypt-export.ts <archive.hlx> [output.json]
|
||||
*
|
||||
* Standalone decryptor for a passphrase-encrypted HealthLog export (`HLX1`
|
||||
* wire format — see src/lib/export/passphrase-archive.ts). A user runs this
|
||||
* with their passphrase to recover the plaintext JSON backup WITHOUT the app:
|
||||
*
|
||||
* pnpm dlx tsx scripts/decrypt-export.ts healthlog-backup-....hlx
|
||||
*
|
||||
* The passphrase is read interactively with echo disabled, or from the
|
||||
* `EXPORT_PASSPHRASE` environment variable for non-interactive use. It is
|
||||
* never written to disk or logged. There is no server-side recovery: a wrong
|
||||
* or forgotten passphrase cannot be worked around — the archive is opaque.
|
||||
*
|
||||
* If no output path is given, the decrypted JSON is written next to the input
|
||||
* with a `.json` extension; pass `-` to stream to stdout instead.
|
||||
*
|
||||
* This script depends ONLY on the archive codec (Argon2id + node:crypto) — no
|
||||
* database, no Prisma, no app boot — so it stays runnable from a checkout with
|
||||
* just `pnpm install`.
|
||||
*/
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import { decryptArchive } from "@/lib/export/passphrase-archive";
|
||||
|
||||
function usage(): never {
|
||||
console.error(
|
||||
"Usage: pnpm dlx tsx scripts/decrypt-export.ts <archive.hlx> [output.json|-]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** Read a line from the TTY with local echo disabled. */
|
||||
async function promptPassphrase(): Promise<string> {
|
||||
const fromEnv = process.env.EXPORT_PASSPHRASE;
|
||||
if (fromEnv && fromEnv.length > 0) return fromEnv;
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(
|
||||
"No TTY available and EXPORT_PASSPHRASE is unset — cannot read passphrase.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
// Mute echo: intercept the output stream while the prompt is active.
|
||||
const out = process.stdout;
|
||||
let muted = false;
|
||||
const realWrite = out.write.bind(out);
|
||||
(out as unknown as { write: typeof out.write }).write = ((
|
||||
chunk: string | Uint8Array,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
if (muted) return true;
|
||||
// @ts-expect-error variadic passthrough
|
||||
return realWrite(chunk, ...rest);
|
||||
}) as typeof out.write;
|
||||
|
||||
process.stdout.write("Passphrase: ");
|
||||
muted = true;
|
||||
try {
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question("", resolve);
|
||||
});
|
||||
return answer;
|
||||
} finally {
|
||||
muted = false;
|
||||
(out as unknown as { write: typeof out.write }).write = realWrite;
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const input = process.argv[2];
|
||||
if (!input) usage();
|
||||
const output = process.argv[3] ?? input.replace(/\.hlx$/i, "") + ".json";
|
||||
|
||||
const buf = await readFile(input);
|
||||
const passphrase = await promptPassphrase();
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = await decryptArchive(buf, passphrase);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (output === "-") {
|
||||
process.stdout.write(plaintext);
|
||||
if (!plaintext.endsWith("\n")) process.stdout.write("\n");
|
||||
} else {
|
||||
await writeFile(output, plaintext, "utf8");
|
||||
console.error(`Decrypted -> ${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
|
||||
import { EncryptionSection } from "@/components/admin/encryption-section";
|
||||
import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section";
|
||||
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
|
||||
import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section";
|
||||
@@ -195,6 +196,15 @@ export function AdminSectionRenderer({
|
||||
<BackupsSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "encryption":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.encryption.title")}
|
||||
subtitle={t("admin.section.encryption.subtitle")}
|
||||
>
|
||||
<EncryptionSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "danger-zone":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/rotate
|
||||
*
|
||||
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
|
||||
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
|
||||
* column corpus to the configured ACTIVE key id. The CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
|
||||
* convenience that reuses the same column registry and is safe by construction:
|
||||
*
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
|
||||
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
|
||||
* redeploy act.
|
||||
* - IDEMPOTENT: rows already on the active key are skipped; the singleton key
|
||||
* coalesces duplicate triggers into one queued run.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
|
||||
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
|
||||
* high-blast-radius action. An admin without a second factor cannot use the
|
||||
* button and falls back to the CLI (this is intentional: step-up cannot be
|
||||
* softened, and the CLI is the documented canonical path).
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Cookie-only admin, then a fresh second factor. Both resolution paths are
|
||||
// cookie-only, so a Bearer token cannot reach either.
|
||||
await requireAdmin();
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
annotate({ action: { name: "admin.encryption.rotate.requested" } });
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return apiError("Background worker is not available", 503);
|
||||
}
|
||||
|
||||
const jobId = await boss.send(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ requestedByUserId: user.id, enqueuedAt: new Date().toISOString() },
|
||||
{
|
||||
// Coalesce duplicate triggers: at most one queued run at a time.
|
||||
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
},
|
||||
);
|
||||
|
||||
await auditLog("admin.encryption.rotate.requested", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { activeKeyId: getActiveKeyId(), enqueued: Boolean(jobId) },
|
||||
});
|
||||
|
||||
annotate({ meta: { rotate_enqueued: Boolean(jobId) } });
|
||||
|
||||
// A null jobId means the singleton coalesced this trigger onto an already-
|
||||
// queued run — still "accepted" from the operator's point of view.
|
||||
return apiSuccess({ accepted: true, alreadyQueued: !jobId });
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/status
|
||||
*
|
||||
* v1.23 — read-only admin view of encryption coverage + rotation progress.
|
||||
* Buckets every registered encrypted column's rows by key id (the same scan the
|
||||
* rotation tooling does), so an operator can SEE whether a rotation finished
|
||||
* before dropping a legacy key, instead of guessing.
|
||||
*
|
||||
* Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW
|
||||
* COUNTS — never key material. The configured-key set is surfaced as a count.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The
|
||||
* scan is on-demand with a short in-process cache so repeated panel renders
|
||||
* don't re-scan the corpus each time; the rotation run state is read from the
|
||||
* audit trail (DB-backed, so it is correct across the web/worker processes).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
type CorpusScan,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
|
||||
const SCAN_CACHE_TTL_MS = 15_000;
|
||||
let scanCache: { at: number; scan: CorpusScan } | null = null;
|
||||
|
||||
interface RotationState {
|
||||
state: "idle" | "running" | "completed" | "failed";
|
||||
lastRequestedAt: string | null;
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
}
|
||||
|
||||
async function readRotationState(): Promise<RotationState> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
action: {
|
||||
in: [
|
||||
"admin.encryption.rotate.requested",
|
||||
"admin.encryption.rotate.completed",
|
||||
"admin.encryption.rotate.failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { action: true, createdAt: true, details: true },
|
||||
});
|
||||
|
||||
const requested = rows.find(
|
||||
(r) => r.action === "admin.encryption.rotate.requested",
|
||||
);
|
||||
const finished = rows.find(
|
||||
(r) =>
|
||||
r.action === "admin.encryption.rotate.completed" ||
|
||||
r.action === "admin.encryption.rotate.failed",
|
||||
);
|
||||
|
||||
let lastResult: RotationState["lastResult"] = null;
|
||||
if (
|
||||
finished?.action === "admin.encryption.rotate.completed" &&
|
||||
finished.details
|
||||
) {
|
||||
try {
|
||||
const d = JSON.parse(finished.details) as {
|
||||
scanned?: number;
|
||||
rotated?: number;
|
||||
errors?: number;
|
||||
};
|
||||
lastResult = {
|
||||
scanned: d.scanned ?? 0,
|
||||
rotated: d.rotated ?? 0,
|
||||
errors: d.errors ?? 0,
|
||||
};
|
||||
} catch {
|
||||
lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
// "running" iff the most recent request is newer than the most recent
|
||||
// completion/failure.
|
||||
let state: RotationState["state"] = "idle";
|
||||
if (requested && (!finished || requested.createdAt > finished.createdAt)) {
|
||||
state = "running";
|
||||
} else if (finished?.action === "admin.encryption.rotate.completed") {
|
||||
state = "completed";
|
||||
} else if (finished?.action === "admin.encryption.rotate.failed") {
|
||||
state = "failed";
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
lastRequestedAt: requested?.createdAt.toISOString() ?? null,
|
||||
lastCompletedAt: finished?.createdAt.toISOString() ?? null,
|
||||
lastResult,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
|
||||
const now = Date.now();
|
||||
let scan: CorpusScan;
|
||||
if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) {
|
||||
scan = scanCache.scan;
|
||||
} else {
|
||||
scan = await scanCorpus(prisma as unknown as CorpusClient);
|
||||
scanCache = { at: now, scan };
|
||||
}
|
||||
|
||||
const rotation = await readRotationState();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
encryption_total_rows: scan.totalRows,
|
||||
encryption_stale_rows: scan.staleRows,
|
||||
encryption_rotation_complete: scan.rotationComplete,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
activeKeyId: scan.activeKeyId,
|
||||
// COUNT only — never the key material.
|
||||
configuredKeyCount: getConfiguredKeyIds().length,
|
||||
rotationComplete: scan.rotationComplete,
|
||||
totalRows: scan.totalRows,
|
||||
activeRows: scan.activeRows,
|
||||
staleRows: scan.staleRows,
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* v1.23 — POST /api/export/encrypted unit coverage.
|
||||
*
|
||||
* Mock-based. Pins the step-up gating contract (single-factor accounts export
|
||||
* without step-up; MFA accounts need a fresh second factor) and that the
|
||||
* passphrase produces a decryptable HLX1 archive — never a plaintext body.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
session: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
lookupIpAsn: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { POST } from "../route";
|
||||
import { decryptArchive } from "@/lib/export/passphrase-archive";
|
||||
|
||||
const PASSPHRASE = "a-strong-passphrase-123";
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/export/encrypted", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: Date.now() + 3600_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/export/encrypted", () => {
|
||||
it("returns a decryptable HLX1 archive for a single-factor account", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.subarray(0, 4).toString("ascii")).toBe("HLX1");
|
||||
const plaintext = await decryptArchive(buf, PASSPHRASE);
|
||||
const payload = JSON.parse(plaintext);
|
||||
expect(payload).toHaveProperty("schemaVersion");
|
||||
expect(payload).toHaveProperty("measurements");
|
||||
});
|
||||
|
||||
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
// No fresh mfaVerifiedAt on the session row.
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("allows an MFA account with a fresh second factor", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion");
|
||||
});
|
||||
|
||||
it("rejects a too-short passphrase with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: "short" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* POST /api/export/encrypted
|
||||
*
|
||||
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
|
||||
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
|
||||
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
|
||||
* the caller supplies in the request body. The binary archive is returned as
|
||||
* `application/octet-stream`.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Step-up: when the account has a second factor enrolled, this is gated by
|
||||
* `requireFreshMfa` — exporting the whole record is a sensitive action.
|
||||
* Single-factor accounts fall back to a normal authenticated session.
|
||||
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
|
||||
* body, passed straight into the KDF, and never `annotate()`d. The egress
|
||||
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
|
||||
* `passphrase=` query-string form as defence in depth.
|
||||
* - There is NO server-side recovery — the passphrase is not stored. A
|
||||
* forgotten passphrase means the archive is unrecoverable; the UI says so.
|
||||
*
|
||||
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
|
||||
* second factor for MFA accounts (Bearer can never satisfy step-up).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
|
||||
* plaintext export so the encrypted variant cannot be used to bypass the cap.
|
||||
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAuth,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import {
|
||||
encryptArchive,
|
||||
MIN_EXPORT_PASSPHRASE_LENGTH,
|
||||
} from "@/lib/export/passphrase-archive";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const encryptedExportSchema = z
|
||||
.object({
|
||||
passphrase: z
|
||||
.string()
|
||||
.min(MIN_EXPORT_PASSPHRASE_LENGTH)
|
||||
// A generous upper bound — a passphrase, not a file. Keeps the KDF input
|
||||
// bounded.
|
||||
.max(1024),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Resolve the session first (cookie or Bearer), then escalate to a fresh
|
||||
// second factor when the account has one enrolled. A single-factor account
|
||||
// cannot produce a fresh-MFA proof, so gating it unconditionally would lock
|
||||
// it out of its own encrypted export — the step-up applies to MFA users.
|
||||
const auth = await requireAuth();
|
||||
if (auth.user.totpConfirmedAt) {
|
||||
await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
}
|
||||
const user = auth.user;
|
||||
annotate({ action: { name: "user.export.encrypted" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
// The body is a single passphrase string — small and bounded. 8 KB is far
|
||||
// above any legitimate passphrase while rejecting a large body before parse.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = encryptedExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const { passphrase } = parsed.data;
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
|
||||
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
|
||||
|
||||
await auditLog("user.export.encrypted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { counts, format: "HLX1" },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
|
||||
// self-contained `.hlx` archive openable with the user's passphrase via
|
||||
// scripts/decrypt-export.ts.
|
||||
return new NextResponse(new Uint8Array(archive), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -35,125 +33,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, cycle] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
// v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the
|
||||
// user-initiated backup bundle so the round-trip via admin
|
||||
// restore does not resurrect deleted measurements.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
// Explicit `select` on the schedule columns — see the matching
|
||||
// comment in `medications/route.ts` for the rationale (the
|
||||
// integration testbed has an unmigrated `days_of_week` column on
|
||||
// the schema branch we ship alongside).
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows (mirrors the measurement
|
||||
// `deletedAt: null` filter above on this user-facing export).
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
// v1.15.0 — cycle tables (profile + observed spans + day-logs). The
|
||||
// shared helper carries `notesEncrypted` verbatim (never decrypted).
|
||||
buildCycleBackupSection(prisma, user.id),
|
||||
]);
|
||||
|
||||
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
|
||||
// `parseBackupPayload()` validator round-trips both blobs. Keep these
|
||||
// two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
|
||||
// canonical reference.
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
measurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
// v1.23 — decrypt the note for the human-readable backup; an admin
|
||||
// restore re-encrypts it on re-insert.
|
||||
notes: readNote(m.notesEncrypted, m.notes),
|
||||
})),
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
medication: e.medication.name,
|
||||
scheduledFor: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
cycleDayLogs: cycle.cycleDayLogs,
|
||||
};
|
||||
// v1.23 — the payload builder is shared with the passphrase-encrypted
|
||||
// export route so both emit the byte-for-byte same restore-compatible shape.
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
counts: {
|
||||
measurements: measurements.length,
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
moodEntries: moodEntries.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
},
|
||||
},
|
||||
details: { counts },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: measurements.length,
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeEvents.length,
|
||||
export_mood_count: moodEntries.length,
|
||||
export_cycle_count: cycle.cycles.length,
|
||||
export_cycle_day_log_count: cycle.cycleDayLogs.length,
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
Users,
|
||||
@@ -131,6 +132,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.backups.title",
|
||||
icon: Database,
|
||||
},
|
||||
// v1.23 — encryption coverage + key-rotation status / safe trigger.
|
||||
{
|
||||
slug: "encryption",
|
||||
titleKey: "admin.section.encryption.title",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
slug: "danger-zone",
|
||||
titleKey: "admin.section.danger-zone.title",
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<EncryptionSection>` — admin view of encryption coverage + key-rotation
|
||||
* status, with a guarded rotation trigger.
|
||||
*
|
||||
* Reads `GET /api/admin/encryption/status` (per-column rows-per-key-id, legacy
|
||||
* counts, overall rotation progress). NEVER surfaces key material — only key
|
||||
* ids (operator labels) and row counts. The rotation trigger POSTs to
|
||||
* `/api/admin/encryption/rotate`, which is step-up gated server-side; a 401
|
||||
* here means the admin needs a fresh second factor (or has none enrolled, in
|
||||
* which case the documented CLI is the path).
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, RotateCw, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
|
||||
interface ColumnScan {
|
||||
model: string;
|
||||
field: string;
|
||||
kind: "string" | "bytes";
|
||||
total: number;
|
||||
byKeyId: Record<string, number>;
|
||||
legacy: number;
|
||||
}
|
||||
|
||||
interface EncryptionStatus {
|
||||
activeKeyId: string;
|
||||
configuredKeyCount: number;
|
||||
rotationComplete: boolean;
|
||||
totalRows: number;
|
||||
activeRows: number;
|
||||
staleRows: number;
|
||||
columns: ColumnScan[];
|
||||
rotation: {
|
||||
state: "idle" | "running" | "completed" | "failed";
|
||||
lastRequestedAt: string | null;
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function EncryptionSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
queryFn: async (): Promise<EncryptionStatus> => {
|
||||
return apiGet<EncryptionStatus>("/api/admin/encryption/status", {
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const rotate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/admin/encryption/rotate", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
throw new Error(t("admin.section.encryption.stepUpNeeded"));
|
||||
}
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("admin.section.encryption.rotateEnqueued"));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.encryption.rotateFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
if (statusQuery.isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.section.encryption.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError || !statusQuery.data) {
|
||||
return (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("admin.section.encryption.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const s = statusQuery.data;
|
||||
const running = s.rotation.state === "running" || rotate.isPending;
|
||||
// Only the rows that share a coverage view need the per-column table; sort
|
||||
// stale-first so an operator sees what still needs rotating.
|
||||
const columns = [...s.columns].sort((a, b) => b.legacy - a.legacy);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
description={t("admin.section.encryption.coverageDescription")}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stat
|
||||
label={t("admin.section.encryption.activeKeyId")}
|
||||
value={s.activeKeyId}
|
||||
/>
|
||||
<Stat
|
||||
label={t("admin.section.encryption.configuredKeys")}
|
||||
value={String(s.configuredKeyCount)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("admin.section.encryption.totalRows")}
|
||||
value={s.totalRows.toLocaleString()}
|
||||
/>
|
||||
<Stat
|
||||
label={t("admin.section.encryption.staleRows")}
|
||||
value={s.staleRows.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{s.rotationComplete ? (
|
||||
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
{t("admin.section.encryption.safeToDropLegacy")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{t("admin.section.encryption.rotationIncomplete", {
|
||||
count: s.staleRows,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Rotation status + trigger ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.section.encryption.rotationTitle")}
|
||||
description={t("admin.section.encryption.rotationDescription")}
|
||||
/>
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<p>
|
||||
{t("admin.section.encryption.rotationState")}:{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{t(`admin.section.encryption.state.${s.rotation.state}`)}
|
||||
</span>
|
||||
</p>
|
||||
{s.rotation.lastResult && (
|
||||
<p>
|
||||
{t("admin.section.encryption.lastRun", {
|
||||
scanned: s.rotation.lastResult.scanned,
|
||||
rotated: s.rotation.lastResult.rotated,
|
||||
errors: s.rotation.lastResult.errors,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={running}
|
||||
data-testid="admin-encryption-rotate"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.encryption.rotateNow")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.encryption.rotateConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.encryption.rotateConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("admin.section.encryption.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => rotate.mutate()}>
|
||||
{t("admin.section.encryption.rotateConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.encryption.cliNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Per-column coverage table ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-3">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.columnsTitle")}
|
||||
description={t("admin.section.encryption.columnsDescription")}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-left">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.section.encryption.colColumn")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.section.encryption.colTotal")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.section.encryption.colActive")}
|
||||
</th>
|
||||
<th className="py-2 font-medium">
|
||||
{t("admin.section.encryption.colStale")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((c) => {
|
||||
const active = c.byKeyId[s.activeKeyId] ?? 0;
|
||||
const stale = c.total - active;
|
||||
return (
|
||||
<tr key={`${c.model}.${c.field}`} className="border-b">
|
||||
<td className="py-2 pr-3 font-mono text-xs">
|
||||
{c.model}.{c.field}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{c.total.toLocaleString()}</td>
|
||||
<td className="py-2 pr-3">{active.toLocaleString()}</td>
|
||||
<td className="py-2">
|
||||
{stale > 0 ? (
|
||||
<Badge variant="secondary">
|
||||
{stale.toLocaleString()}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-muted/40 rounded-lg px-3 py-2">
|
||||
<p className="text-muted-foreground text-xs">{label}</p>
|
||||
<p className="text-foreground font-mono text-sm font-medium">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"login-overview",
|
||||
"app-logs",
|
||||
"backups",
|
||||
// v1.23 — encryption coverage + key-rotation status / safe trigger. Sits
|
||||
// next to Backups (both are data-at-rest concerns) and just before the
|
||||
// danger zone.
|
||||
"encryption",
|
||||
"danger-zone",
|
||||
// v1.4.36 W4e — About / version / update check folded into Admin
|
||||
// Console. Lives at /admin/about; the legacy /settings/about route
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -412,20 +413,55 @@ function MoodCsvCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
|
||||
|
||||
function FullBackupCard() {
|
||||
const { t } = useTranslations();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// v1.23 — opt-in passphrase encryption. When on, the download POSTs to
|
||||
// /api/export/encrypted with the passphrase and saves an opaque `.hlx`
|
||||
// archive instead of plaintext JSON.
|
||||
const [encrypt, setEncrypt] = useState(false);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
|
||||
const passphraseTooShort =
|
||||
encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH;
|
||||
|
||||
async function handleDownload() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
await downloadFromUrl(
|
||||
"/api/export/full-backup",
|
||||
`healthlog-backup-${stamp}.json`,
|
||||
);
|
||||
if (encrypt) {
|
||||
const res = await apiFetchRaw("/api/export/encrypted", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ passphrase }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded"));
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = `healthlog-backup-${stamp}.hlx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
await downloadFromUrl(
|
||||
"/api/export/full-backup",
|
||||
`healthlog-backup-${stamp}.json`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
} finally {
|
||||
@@ -447,7 +483,7 @@ function FullBackupCard() {
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleDownload}
|
||||
disabled={busy}
|
||||
disabled={busy || passphraseTooShort}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
@@ -458,6 +494,44 @@ function FullBackupCard() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<label
|
||||
htmlFor="export-full-backup-encrypt"
|
||||
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
|
||||
>
|
||||
<Switch
|
||||
id="export-full-backup-encrypt"
|
||||
data-testid="export-full-backup-encrypt"
|
||||
checked={encrypt}
|
||||
onCheckedChange={(v) => {
|
||||
setEncrypt(v);
|
||||
setError(null);
|
||||
if (!v) setPassphrase("");
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settings.sections.export.cards.fullBackup.encryptToggle")}
|
||||
</span>
|
||||
</label>
|
||||
{encrypt && (
|
||||
<div className="space-y-2">
|
||||
<PasswordInput
|
||||
data-testid="export-full-backup-passphrase"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t(
|
||||
"settings.sections.export.cards.fullBackup.passphrasePlaceholder",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
{t("settings.sections.export.cards.fullBackup.noRecoveryWarning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
|
||||
@@ -182,6 +182,16 @@ export function getActiveKeyId(): string {
|
||||
return getActiveKey().id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured key ids (NOT the key material). Used by the admin
|
||||
* encryption-status view to report how many keys the operator has loaded and
|
||||
* by the rotation guard. Never expose the bytes — only the ids.
|
||||
*/
|
||||
export function getConfiguredKeyIds(): string[] {
|
||||
const { keys } = loadKeys();
|
||||
return [...keys.keys()];
|
||||
}
|
||||
|
||||
function encryptWithKey(plaintext: string, key: Buffer): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encrypt,
|
||||
extractKeyId,
|
||||
getActiveKeyId,
|
||||
getConfiguredKeyIds,
|
||||
_resetCryptoCacheForTests,
|
||||
} from "@/lib/crypto";
|
||||
import {
|
||||
scanCorpus,
|
||||
rotateCorpus,
|
||||
LEGACY_BUCKET,
|
||||
type CorpusClient,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_V2 =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function delegateKey(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake Prisma client covering every model in the registry, backed by
|
||||
* a per-model row store. Only the models we seed hold rows; the rest are empty.
|
||||
*/
|
||||
function makeClient(seed: Record<string, Row[]>): {
|
||||
client: CorpusClient;
|
||||
store: Record<string, Row[]>;
|
||||
} {
|
||||
const store: Record<string, Row[]> = {};
|
||||
const client: CorpusClient = {};
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
const key = delegateKey(col.model);
|
||||
if (!store[col.model]) store[col.model] = seed[col.model] ?? [];
|
||||
if (client[key]) continue;
|
||||
const model = col.model;
|
||||
client[key] = {
|
||||
findMany: async ({ select }) => {
|
||||
const fields = Object.keys(select);
|
||||
return store[model].map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) out[f] = row[f];
|
||||
return out;
|
||||
});
|
||||
},
|
||||
update: async ({ where, data }) => {
|
||||
const row = store[model].find((r) => r.id === where.id);
|
||||
if (row) Object.assign(row, data);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
return { client, store };
|
||||
}
|
||||
|
||||
function activeUnder(keyId: string, plaintext: string): string {
|
||||
// Encrypt while a given key id is active, then restore v2 as active.
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
|
||||
_resetCryptoCacheForTests();
|
||||
const ct = encrypt(plaintext);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
_resetCryptoCacheForTests();
|
||||
return ct;
|
||||
}
|
||||
|
||||
describe("encryption-corpus scan + rotate", () => {
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 });
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
it("buckets rows by key id (string + bytes columns)", async () => {
|
||||
const stringCt = activeUnder("v1", "token-under-v1");
|
||||
const stringActive = encrypt("token-under-v2");
|
||||
const bytesCt = Buffer.from(activeUnder("v1", "note-under-v1"), "utf8");
|
||||
|
||||
const { client } = makeClient({
|
||||
User: [
|
||||
{ id: "u1", codexAccessTokenEncrypted: stringCt },
|
||||
{ id: "u2", codexAccessTokenEncrypted: stringActive },
|
||||
],
|
||||
MoodEntry: [{ id: "m1", noteEncrypted: bytesCt }],
|
||||
});
|
||||
|
||||
const scan = await scanCorpus(client);
|
||||
expect(scan.activeKeyId).toBe("v2");
|
||||
|
||||
const userCol = scan.columns.find(
|
||||
(c) => c.model === "User" && c.field === "codexAccessTokenEncrypted",
|
||||
)!;
|
||||
expect(userCol.total).toBe(2);
|
||||
expect(userCol.byKeyId.v1).toBe(1);
|
||||
expect(userCol.byKeyId.v2).toBe(1);
|
||||
|
||||
const moodCol = scan.columns.find(
|
||||
(c) => c.model === "MoodEntry" && c.field === "noteEncrypted",
|
||||
)!;
|
||||
expect(moodCol.total).toBe(1);
|
||||
expect(moodCol.byKeyId.v1).toBe(1);
|
||||
|
||||
// 3 rows total, 1 already on the active key.
|
||||
expect(scan.totalRows).toBe(3);
|
||||
expect(scan.activeRows).toBe(1);
|
||||
expect(scan.staleRows).toBe(2);
|
||||
expect(scan.rotationComplete).toBe(false);
|
||||
});
|
||||
|
||||
it("rotates stale rows to the active key and is idempotent", async () => {
|
||||
const { client, store } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: activeUnder("v1", "x") }],
|
||||
MoodEntry: [
|
||||
{
|
||||
id: "m1",
|
||||
noteEncrypted: Buffer.from(activeUnder("v1", "y"), "utf8"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await rotateCorpus(client);
|
||||
expect(first.totalRotated).toBe(2);
|
||||
expect(first.totalErrors).toBe(0);
|
||||
|
||||
// ACTIVE-KEY-ONLY: every row now carries the active key id.
|
||||
expect(
|
||||
extractKeyId(store.User[0].codexAccessTokenEncrypted as string),
|
||||
).toBe(getActiveKeyId());
|
||||
expect(
|
||||
extractKeyId(
|
||||
Buffer.from(store.MoodEntry[0].noteEncrypted as Uint8Array).toString(
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
).toBe(getActiveKeyId());
|
||||
|
||||
// IDEMPOTENT: a second pass rotates nothing.
|
||||
const second = await rotateCorpus(client);
|
||||
expect(second.totalRotated).toBe(0);
|
||||
|
||||
const scan = await scanCorpus(client);
|
||||
expect(scan.rotationComplete).toBe(true);
|
||||
expect(scan.staleRows).toBe(0);
|
||||
});
|
||||
|
||||
it("never adds or drops a key during rotation", async () => {
|
||||
const before = getConfiguredKeyIds().sort();
|
||||
const { client } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: activeUnder("v1", "x") }],
|
||||
});
|
||||
await rotateCorpus(client);
|
||||
const after = getConfiguredKeyIds().sort();
|
||||
expect(after).toEqual(before);
|
||||
expect(after).toEqual(["v1", "v2"]);
|
||||
});
|
||||
|
||||
it("fails closed on a row under a no-longer-configured key (no data loss)", async () => {
|
||||
// A ciphertext whose key id ('v9') is not in ENCRYPTION_KEYS. Decrypt
|
||||
// throws -> counted as an error, the row is left untouched.
|
||||
const orphan = "v9." + Buffer.from("garbage").toString("base64");
|
||||
const { client, store } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: orphan }],
|
||||
});
|
||||
|
||||
const result = await rotateCorpus(client);
|
||||
expect(result.totalErrors).toBe(1);
|
||||
expect(result.totalRotated).toBe(0);
|
||||
// Row preserved exactly, not dropped or blanked.
|
||||
expect(store.User[0].codexAccessTokenEncrypted).toBe(orphan);
|
||||
});
|
||||
|
||||
it("treats legacy (unversioned) ciphertext as stale and buckets it", async () => {
|
||||
// Legacy bare-base64 row written by the old single-key path.
|
||||
process.env.ENCRYPTION_KEYS = "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "";
|
||||
process.env.ENCRYPTION_KEY = KEY_V1;
|
||||
_resetCryptoCacheForTests();
|
||||
const legacy = encrypt("legacy-secret").replace(/^v1\./, ""); // strip prefix
|
||||
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 });
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
_resetCryptoCacheForTests();
|
||||
|
||||
const { client } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: legacy }],
|
||||
});
|
||||
const scan = await scanCorpus(client);
|
||||
const col = scan.columns.find(
|
||||
(c) => c.model === "User" && c.field === "codexAccessTokenEncrypted",
|
||||
)!;
|
||||
expect(col.legacy).toBe(1);
|
||||
expect(col.byKeyId[LEGACY_BUCKET]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* v1.23 — read-only scan + in-place re-encrypt over the whole encrypted-column
|
||||
* corpus, driven by the canonical registry (`encrypted-columns.ts`).
|
||||
*
|
||||
* Two consumers:
|
||||
* - the admin encryption-status view (`GET /api/admin/encryption/status`)
|
||||
* calls `scanCorpus()` to bucket every encrypted column's rows by key id;
|
||||
* - the admin-triggered rotation pg-boss job (`encryption-key-rotate`) calls
|
||||
* `rotateCorpus()` to re-encrypt every row that is not already on the
|
||||
* active key.
|
||||
*
|
||||
* The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical
|
||||
* rotation path and stays independent (its own Prisma client). This module is
|
||||
* the in-app convenience that reuses the SAME registry, so the guard test keeps
|
||||
* both in lock-step.
|
||||
*
|
||||
* GUARANTEES (the security review must confirm these on the rotation path):
|
||||
* - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()`
|
||||
* always writes the configured active key id. There is no code path here
|
||||
* that selects any other write key.
|
||||
* - NEVER ADDS / DROPS A KEY. This module never reads or mutates
|
||||
* `ENCRYPTION_KEYS` / `ENCRYPTION_ACTIVE_KEY_ID`. The operator's env key map
|
||||
* is the only place keys live; a key drop stays a deliberate env + redeploy
|
||||
* act, never a button.
|
||||
* - IDEMPOTENT. `shouldRotate()` skips rows already on the active key, so a
|
||||
* second pass (or two racing workers) re-encrypts zero rows.
|
||||
* - FAIL-CLOSED. A row written under a key id that is no longer configured
|
||||
* throws on decrypt (counted as an error, the row is left untouched) rather
|
||||
* than being silently dropped or overwritten — exactly the property that
|
||||
* protects against dropping a legacy key too early.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
encryptedColumnKey,
|
||||
type EncryptedColumn,
|
||||
} from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */
|
||||
export const LEGACY_BUCKET = "legacy";
|
||||
|
||||
/** Minimal Prisma delegate shape this module needs. */
|
||||
interface ColumnDelegate {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The subset of the Prisma client we touch: one delegate per model. */
|
||||
export type CorpusClient = Record<string, ColumnDelegate>;
|
||||
|
||||
/** PascalCase model name -> camelCase Prisma delegate key. */
|
||||
function delegateKey(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
function getDelegate(client: CorpusClient, model: string): ColumnDelegate {
|
||||
const delegate = client[delegateKey(model)];
|
||||
if (!delegate) {
|
||||
throw new Error(`No Prisma delegate for model '${model}'`);
|
||||
}
|
||||
return delegate;
|
||||
}
|
||||
|
||||
/** Read a registry column's value as a ciphertext string (Bytes -> utf8). */
|
||||
function toCiphertext(
|
||||
value: unknown,
|
||||
kind: EncryptedColumn["kind"],
|
||||
): string | null {
|
||||
if (value == null) return null;
|
||||
if (kind === "bytes") {
|
||||
const buf = value as Uint8Array;
|
||||
if (buf.byteLength === 0) return null;
|
||||
return Buffer.from(buf).toString("utf8");
|
||||
}
|
||||
const s = value as string;
|
||||
return s.length === 0 ? null : s;
|
||||
}
|
||||
|
||||
/** Encode a re-encrypted ciphertext string back into the column's storage shape. */
|
||||
function fromCiphertext(
|
||||
value: string,
|
||||
kind: EncryptedColumn["kind"],
|
||||
): string | Uint8Array {
|
||||
if (kind !== "bytes") return value;
|
||||
const encoded = Buffer.from(value, "utf8");
|
||||
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
next.set(encoded);
|
||||
return next;
|
||||
}
|
||||
|
||||
function shouldRotate(ciphertext: string): boolean {
|
||||
return extractKeyId(ciphertext) !== getActiveKeyId();
|
||||
}
|
||||
|
||||
export interface ColumnScan {
|
||||
model: string;
|
||||
field: string;
|
||||
kind: EncryptedColumn["kind"];
|
||||
/** Non-null ciphertext rows. */
|
||||
total: number;
|
||||
/** Rows per key id; legacy/unversioned rows land under `LEGACY_BUCKET`. */
|
||||
byKeyId: Record<string, number>;
|
||||
/** Rows under the legacy/unversioned format (= `byKeyId[LEGACY_BUCKET]`). */
|
||||
legacy: number;
|
||||
}
|
||||
|
||||
/** Scan one encrypted column: bucket every non-null ciphertext by key id. */
|
||||
export async function scanColumn(
|
||||
client: CorpusClient,
|
||||
col: EncryptedColumn,
|
||||
): Promise<ColumnScan> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
const byKeyId: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null) continue;
|
||||
total += 1;
|
||||
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
|
||||
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
model: col.model,
|
||||
field: col.field,
|
||||
kind: col.kind,
|
||||
total,
|
||||
byKeyId,
|
||||
legacy: byKeyId[LEGACY_BUCKET] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CorpusScan {
|
||||
activeKeyId: string;
|
||||
columns: ColumnScan[];
|
||||
/** Total non-null ciphertext rows across the corpus. */
|
||||
totalRows: number;
|
||||
/** Rows already on the active key. */
|
||||
activeRows: number;
|
||||
/** Rows NOT on the active key (legacy + any non-active versioned). */
|
||||
staleRows: number;
|
||||
/**
|
||||
* True iff every column has zero rows that are not on the active key — the
|
||||
* single signal an operator needs before dropping a legacy key.
|
||||
*/
|
||||
rotationComplete: boolean;
|
||||
}
|
||||
|
||||
/** Scan the whole corpus. Read-only; never writes. */
|
||||
export async function scanCorpus(client: CorpusClient): Promise<CorpusScan> {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const columns: ColumnScan[] = [];
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
columns.push(await scanColumn(client, col));
|
||||
}
|
||||
let totalRows = 0;
|
||||
let activeRows = 0;
|
||||
for (const c of columns) {
|
||||
totalRows += c.total;
|
||||
activeRows += c.byKeyId[activeKeyId] ?? 0;
|
||||
}
|
||||
const staleRows = totalRows - activeRows;
|
||||
return {
|
||||
activeKeyId,
|
||||
columns,
|
||||
totalRows,
|
||||
activeRows,
|
||||
staleRows,
|
||||
rotationComplete: staleRows === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RotationResult {
|
||||
model: string;
|
||||
field: string;
|
||||
scanned: number;
|
||||
rotated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** Re-encrypt one column's stale rows to the active key. */
|
||||
export async function rotateColumn(
|
||||
client: CorpusClient,
|
||||
col: EncryptedColumn,
|
||||
): Promise<RotationResult> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
const result: RotationResult = {
|
||||
model: col.model,
|
||||
field: col.field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null || !shouldRotate(ciphertext)) continue;
|
||||
const id = row.id as string;
|
||||
try {
|
||||
// ACTIVE-KEY-ONLY: encrypt() always writes the active key id.
|
||||
const reencrypted = encrypt(decrypt(ciphertext));
|
||||
await delegate.update({
|
||||
where: { id },
|
||||
data: { [col.field]: fromCiphertext(reencrypted, col.kind) },
|
||||
});
|
||||
result.rotated += 1;
|
||||
} catch {
|
||||
// FAIL-CLOSED: a row under a no-longer-configured key throws on decrypt;
|
||||
// count it and leave the row untouched rather than dropping data.
|
||||
result.errors += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface CorpusRotation {
|
||||
activeKeyId: string;
|
||||
results: RotationResult[];
|
||||
totalScanned: number;
|
||||
totalRotated: number;
|
||||
totalErrors: number;
|
||||
}
|
||||
|
||||
/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */
|
||||
export async function rotateCorpus(
|
||||
client: CorpusClient,
|
||||
): Promise<CorpusRotation> {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const results: RotationResult[] = [];
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
results.push(await rotateColumn(client, col));
|
||||
}
|
||||
let totalScanned = 0;
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
for (const r of results) {
|
||||
totalScanned += r.scanned;
|
||||
totalRotated += r.rotated;
|
||||
totalErrors += r.errors;
|
||||
}
|
||||
return {
|
||||
activeKeyId,
|
||||
results,
|
||||
totalScanned,
|
||||
totalRotated,
|
||||
totalErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export { encryptedColumnKey };
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encryptArchive,
|
||||
decryptArchive,
|
||||
parseArchiveHeader,
|
||||
EXPORT_ARGON2_PARAMS,
|
||||
MIN_EXPORT_PASSPHRASE_LENGTH,
|
||||
} from "../passphrase-archive";
|
||||
|
||||
const PASSPHRASE = "correct horse battery staple";
|
||||
const PAYLOAD = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
measurements: [{ type: "WEIGHT", value: 81.2, unit: "kg" }],
|
||||
note: "Zugangsdaten 🔐 € ñ",
|
||||
});
|
||||
|
||||
describe("passphrase-encrypted export archive (HLX1)", () => {
|
||||
it("round-trips encrypt -> decrypt with the right passphrase", async () => {
|
||||
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
const out = await decryptArchive(archive, PASSPHRASE);
|
||||
expect(out).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it("emits the HLX1 magic + version header", async () => {
|
||||
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
expect(archive.subarray(0, 4).toString("ascii")).toBe("HLX1");
|
||||
expect(archive.readUInt8(4)).toBe(0x01); // version
|
||||
expect(archive.readUInt8(5)).toBe(0x01); // KDF = Argon2id
|
||||
});
|
||||
|
||||
it("carries the Argon2id KDF params + salt in the header", async () => {
|
||||
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
const { header, bodyOffset } = parseArchiveHeader(archive);
|
||||
expect(header.memoryCost).toBe(EXPORT_ARGON2_PARAMS.memoryCost);
|
||||
expect(header.timeCost).toBe(EXPORT_ARGON2_PARAMS.timeCost);
|
||||
expect(header.parallelism).toBe(EXPORT_ARGON2_PARAMS.parallelism);
|
||||
expect(header.salt.length).toBe(16);
|
||||
expect(bodyOffset).toBe(16 + 16 + 12 + 16); // header + salt + iv + tag
|
||||
});
|
||||
|
||||
it("uses a random salt + iv so two archives of the same input differ", async () => {
|
||||
const a = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
const b = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
expect(Buffer.compare(a, b)).not.toBe(0);
|
||||
// ...but both decrypt back to the same plaintext.
|
||||
expect(await decryptArchive(a, PASSPHRASE)).toBe(PAYLOAD);
|
||||
expect(await decryptArchive(b, PASSPHRASE)).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it("fails cleanly with a wrong passphrase (no plaintext leak)", async () => {
|
||||
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
await expect(
|
||||
decryptArchive(archive, "totally wrong passphrase"),
|
||||
).rejects.toThrow(/wrong passphrase or corrupt archive/i);
|
||||
});
|
||||
|
||||
it("fails when the ciphertext is tampered", async () => {
|
||||
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
|
||||
const tampered = Buffer.from(archive);
|
||||
tampered[tampered.length - 1] ^= 0xff; // flip a ciphertext bit
|
||||
await expect(decryptArchive(tampered, PASSPHRASE)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a passphrase shorter than the minimum on encrypt", async () => {
|
||||
await expect(encryptArchive(PAYLOAD, "short")).rejects.toThrow(
|
||||
new RegExp(`at least ${MIN_EXPORT_PASSPHRASE_LENGTH}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-HLX1 buffer", () => {
|
||||
expect(() => parseArchiveHeader(Buffer.from("not an archive"))).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a Buffer payload, not only a string", async () => {
|
||||
const buf = Buffer.from(PAYLOAD, "utf8");
|
||||
const archive = await encryptArchive(buf, PASSPHRASE);
|
||||
expect(await decryptArchive(archive, PASSPHRASE)).toBe(PAYLOAD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* v1.23 — shared builder for the user-scoped full-backup payload.
|
||||
*
|
||||
* Extracted so both the plaintext `GET /api/export/full-backup` route and the
|
||||
* passphrase-encrypted `POST /api/export/encrypted` route emit the byte-for-byte
|
||||
* same shape — the one that the pg-boss `data-backup` worker writes and that
|
||||
* `parseBackupPayload()` round-trips on admin restore. Keep this writer in sync
|
||||
* with the `data-backup` worker (`src/lib/jobs/reminder-worker.ts`).
|
||||
*
|
||||
* The decrypted notes are surfaced here (the backup is the human-readable
|
||||
* artefact); an admin restore re-encrypts them on re-insert.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
||||
|
||||
export interface FullBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
cycles: number;
|
||||
cycleDayLogs: number;
|
||||
}
|
||||
|
||||
export interface FullBackupResult {
|
||||
payload: Record<string, unknown>;
|
||||
counts: FullBackupCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical full-backup payload for `userId`. Soft-deleted rows are
|
||||
* excluded so a round-trip via admin restore never resurrects deleted records.
|
||||
*/
|
||||
export async function buildFullBackupPayload(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
): Promise<FullBackupResult> {
|
||||
const [measurements, medications, intakeEvents, moodEntries, cycle] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
buildCycleBackupSection(prisma, userId),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId,
|
||||
measurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
notes: readNote(m.notesEncrypted, m.notes),
|
||||
})),
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
medication: e.medication.name,
|
||||
scheduledFor: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
cycleDayLogs: cycle.cycleDayLogs,
|
||||
};
|
||||
|
||||
return {
|
||||
payload,
|
||||
counts: {
|
||||
measurements: measurements.length,
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
moodEntries: moodEntries.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* v1.23 — passphrase-encrypted export archive (`HLX1` wire format).
|
||||
*
|
||||
* A user can choose to download their export as an encrypted archive instead
|
||||
* of plaintext JSON. The passphrase the user supplies is run through Argon2id
|
||||
* to derive a 256-bit key, and the export bytes are sealed with AES-256-GCM.
|
||||
*
|
||||
* THREAT MODEL. The data is already plaintext-at-rest on the server (the
|
||||
* server computes insights / rollups / FHIR over it). What this closes is the
|
||||
* "plaintext health file lands in Downloads / iCloud / Google-Drive sync"
|
||||
* path: the file that leaves the server is opaque without the passphrase.
|
||||
* There is NO server-side recovery — the passphrase is never stored and never
|
||||
* logged, so a forgotten passphrase means the archive is unrecoverable. The
|
||||
* UI states this loudly.
|
||||
*
|
||||
* Wire format (binary), mirroring the off-host backup `HLBK` envelope so there
|
||||
* is one decryption mental model in the codebase + docs:
|
||||
*
|
||||
* magic(4) = "HLX1"
|
||||
* version(1) = 0x01
|
||||
* kdf(1) = 0x01 (Argon2id)
|
||||
* memoryCost(4) = uint32 BE (KiB; Argon2 `m`)
|
||||
* timeCost(4) = uint32 BE (Argon2 `t`)
|
||||
* parallelism(1)= uint8 (Argon2 `p`)
|
||||
* saltLen(1) = uint8 (= 16)
|
||||
* salt(saltLen)
|
||||
* iv(12) (AES-GCM nonce)
|
||||
* tag(16) (AES-GCM auth tag)
|
||||
* ciphertext(...)
|
||||
*
|
||||
* The Argon2 params travel in the header (self-describing) so a future cost
|
||||
* bump still decrypts every previously-written archive — the decryptor reads
|
||||
* `m/t/p` + `salt` from the file, never from a constant. The standalone
|
||||
* `scripts/decrypt-export.ts` decryptor depends ONLY on this module, so a user
|
||||
* can open the archive with `pnpm dlx tsx` and their passphrase, app or not.
|
||||
*
|
||||
* KDF defaults are the 2025 OWASP minimum for Argon2id (RFC 9106):
|
||||
* 19 MiB / t=2 / p=1, 32-byte output. Kept independent from the password
|
||||
* hashing params so tuning one never silently changes the other.
|
||||
*/
|
||||
import { hashRaw, type Algorithm } from "@node-rs/argon2";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
|
||||
const MAGIC = "HLX1";
|
||||
const VERSION = 0x01;
|
||||
const KDF_ARGON2ID = 0x01;
|
||||
// `Algorithm.Argon2id` is a const enum (= 2); reference the value directly so
|
||||
// the module compiles under `isolatedModules` without importing the enum.
|
||||
const ARGON2ID: Algorithm = 2 as Algorithm;
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
const SALT_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Argon2id KDF parameters for newly-written archives. OWASP-minimum for
|
||||
* 2025 (RFC 9106): 19 MiB memory, 2 passes, single lane, 32-byte key.
|
||||
* These are written into each archive header, so changing them never
|
||||
* orphans archives written under the old cost.
|
||||
*/
|
||||
export const EXPORT_ARGON2_PARAMS = {
|
||||
/** Argon2 `m` — memory cost in KiB (19 MiB). */
|
||||
memoryCost: 19456,
|
||||
/** Argon2 `t` — iterations. */
|
||||
timeCost: 2,
|
||||
/** Argon2 `p` — lanes. */
|
||||
parallelism: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Minimum passphrase length the encrypt path enforces. A short passphrase
|
||||
* defeats the KDF; 12 mirrors the account password floor.
|
||||
*/
|
||||
export const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
|
||||
|
||||
export interface ArchiveHeader {
|
||||
version: number;
|
||||
kdf: number;
|
||||
memoryCost: number;
|
||||
timeCost: number;
|
||||
parallelism: number;
|
||||
salt: Buffer;
|
||||
}
|
||||
|
||||
/** Derive the 256-bit AES key from a passphrase + the archive's KDF params. */
|
||||
async function deriveKey(
|
||||
passphrase: string,
|
||||
params: { memoryCost: number; timeCost: number; parallelism: number },
|
||||
salt: Buffer,
|
||||
): Promise<Buffer> {
|
||||
return hashRaw(passphrase, {
|
||||
algorithm: ARGON2ID,
|
||||
memoryCost: params.memoryCost,
|
||||
timeCost: params.timeCost,
|
||||
parallelism: params.parallelism,
|
||||
outputLen: KEY_LENGTH,
|
||||
salt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt `plaintext` into an `HLX1` archive sealed with `passphrase`.
|
||||
* Throws if the passphrase is shorter than `MIN_EXPORT_PASSPHRASE_LENGTH`.
|
||||
*/
|
||||
export async function encryptArchive(
|
||||
plaintext: string | Buffer,
|
||||
passphrase: string,
|
||||
): Promise<Buffer> {
|
||||
if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
|
||||
throw new Error(
|
||||
`Passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
const salt = randomBytes(SALT_LENGTH);
|
||||
const key = await deriveKey(passphrase, EXPORT_ARGON2_PARAMS, salt);
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const body =
|
||||
typeof plaintext === "string" ? Buffer.from(plaintext, "utf8") : plaintext;
|
||||
const ciphertext = Buffer.concat([cipher.update(body), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const header = Buffer.alloc(4 + 1 + 1 + 4 + 4 + 1 + 1);
|
||||
header.write(MAGIC, 0, "ascii");
|
||||
header.writeUInt8(VERSION, 4);
|
||||
header.writeUInt8(KDF_ARGON2ID, 5);
|
||||
header.writeUInt32BE(EXPORT_ARGON2_PARAMS.memoryCost, 6);
|
||||
header.writeUInt32BE(EXPORT_ARGON2_PARAMS.timeCost, 10);
|
||||
header.writeUInt8(EXPORT_ARGON2_PARAMS.parallelism, 14);
|
||||
header.writeUInt8(SALT_LENGTH, 15);
|
||||
|
||||
return Buffer.concat([header, salt, iv, tag, ciphertext]);
|
||||
}
|
||||
|
||||
/** Parse + validate the fixed `HLX1` header. Throws on any structural fault. */
|
||||
export function parseArchiveHeader(buf: Buffer): {
|
||||
header: ArchiveHeader;
|
||||
bodyOffset: number;
|
||||
} {
|
||||
if (buf.length < 16) {
|
||||
throw new Error("Not an HLX1 archive (too short)");
|
||||
}
|
||||
const magic = buf.subarray(0, 4).toString("ascii");
|
||||
if (!timingSafeEqual(Buffer.from(magic), Buffer.from(MAGIC))) {
|
||||
throw new Error("Not an HLX1 archive (bad magic)");
|
||||
}
|
||||
const version = buf.readUInt8(4);
|
||||
if (version !== VERSION) {
|
||||
throw new Error(`Unsupported HLX1 version ${version}`);
|
||||
}
|
||||
const kdf = buf.readUInt8(5);
|
||||
if (kdf !== KDF_ARGON2ID) {
|
||||
throw new Error(`Unsupported KDF id ${kdf}`);
|
||||
}
|
||||
const memoryCost = buf.readUInt32BE(6);
|
||||
const timeCost = buf.readUInt32BE(10);
|
||||
const parallelism = buf.readUInt8(14);
|
||||
const saltLen = buf.readUInt8(15);
|
||||
const saltStart = 16;
|
||||
const ivStart = saltStart + saltLen;
|
||||
const tagStart = ivStart + IV_LENGTH;
|
||||
const bodyOffset = tagStart + TAG_LENGTH;
|
||||
if (buf.length < bodyOffset) {
|
||||
throw new Error("Corrupt HLX1 archive (truncated header)");
|
||||
}
|
||||
if (memoryCost < 1 || timeCost < 1 || parallelism < 1 || saltLen < 8) {
|
||||
throw new Error("Corrupt HLX1 archive (implausible KDF params)");
|
||||
}
|
||||
return {
|
||||
header: {
|
||||
version,
|
||||
kdf,
|
||||
memoryCost,
|
||||
timeCost,
|
||||
parallelism,
|
||||
salt: buf.subarray(saltStart, ivStart),
|
||||
},
|
||||
bodyOffset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an `HLX1` archive with `passphrase`. A wrong passphrase fails the
|
||||
* GCM authentication tag and throws a clean, generic error (no oracle about
|
||||
* which part was wrong). The returned value is the original plaintext (UTF-8).
|
||||
*/
|
||||
export async function decryptArchive(
|
||||
buf: Buffer,
|
||||
passphrase: string,
|
||||
): Promise<string> {
|
||||
const { header, bodyOffset } = parseArchiveHeader(buf);
|
||||
const saltLen = header.salt.length;
|
||||
const ivStart = 16 + saltLen;
|
||||
const tagStart = ivStart + IV_LENGTH;
|
||||
const iv = buf.subarray(ivStart, tagStart);
|
||||
const tag = buf.subarray(tagStart, bodyOffset);
|
||||
const ciphertext = buf.subarray(bodyOffset);
|
||||
|
||||
const key = await deriveKey(
|
||||
passphrase,
|
||||
{
|
||||
memoryCost: header.memoryCost,
|
||||
timeCost: header.timeCost,
|
||||
parallelism: header.parallelism,
|
||||
},
|
||||
Buffer.from(header.salt),
|
||||
);
|
||||
|
||||
try {
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const out = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return out.toString("utf8");
|
||||
} catch {
|
||||
// GCM tag mismatch — wrong passphrase or a tampered/corrupt archive.
|
||||
// Deliberately opaque: never reveal which.
|
||||
throw new Error("Decryption failed — wrong passphrase or corrupt archive");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* v1.23 — admin-triggered encryption-key rotation, on pg-boss.
|
||||
*
|
||||
* Re-encrypts every registered encrypted column to the configured ACTIVE key
|
||||
* id, reusing the canonical column registry via `rotateCorpus()`. This is the
|
||||
* in-app convenience the admin panel triggers; the CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) remains the documented canonical path.
|
||||
* pg-boss is the right home because the standalone production image strips
|
||||
* `tsx`, so a button that ran the CLI inside the container would fail.
|
||||
*
|
||||
* SAFETY (security-review surface):
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: `rotateCorpus()` only ever
|
||||
* writes the active key and never touches the env key map. Dropping a key
|
||||
* stays an operator env + redeploy act.
|
||||
* - IDEMPOTENT: rows already on the active key are skipped, so re-running (or
|
||||
* the singleton coalescing a duplicate trigger) re-encrypts nothing.
|
||||
* - FAIL-CLOSED per row: a row under a no-longer-configured key is counted as
|
||||
* an error and left untouched, never dropped.
|
||||
*
|
||||
* On-demand only — no cron. The admin POST enqueues with a fixed singletonKey
|
||||
* so two concurrent triggers collapse into one run. Completion is recorded both
|
||||
* as a background wide-event and an `admin.encryption.rotate.completed` audit
|
||||
* entry that the status view reads to show the last run.
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
rotateCorpus,
|
||||
type CorpusClient,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
|
||||
|
||||
export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate";
|
||||
export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1;
|
||||
/** Fixed key so duplicate admin triggers coalesce into one queued run. */
|
||||
export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate";
|
||||
|
||||
export interface EncryptionKeyRotatePayload {
|
||||
/** The admin who triggered the run (for the audit trail). */
|
||||
requestedByUserId?: string;
|
||||
enqueuedAt?: string;
|
||||
}
|
||||
|
||||
export async function runEncryptionKeyRotation(): Promise<{
|
||||
activeKeyId: string;
|
||||
totalScanned: number;
|
||||
totalRotated: number;
|
||||
totalErrors: number;
|
||||
}> {
|
||||
const prisma = getWorkerPrisma();
|
||||
const out = await rotateCorpus(prisma as unknown as CorpusClient);
|
||||
return {
|
||||
activeKeyId: out.activeKeyId,
|
||||
totalScanned: out.totalScanned,
|
||||
totalRotated: out.totalRotated,
|
||||
totalErrors: out.totalErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleEncryptionKeyRotate(
|
||||
jobs: Job<EncryptionKeyRotatePayload>[],
|
||||
) {
|
||||
await withBackgroundEvent("job.encryption_key_rotate", async (evt) => {
|
||||
const requestedBy = jobs[0]?.data?.requestedByUserId ?? null;
|
||||
try {
|
||||
const result = await runEncryptionKeyRotation();
|
||||
evt.addMeta("rotate_active_key_id", result.activeKeyId);
|
||||
evt.addMeta("rotate_scanned", result.totalScanned);
|
||||
evt.addMeta("rotate_rotated", result.totalRotated);
|
||||
evt.addMeta("rotate_errors", result.totalErrors);
|
||||
await auditLog("admin.encryption.rotate.completed", {
|
||||
userId: requestedBy,
|
||||
details: {
|
||||
activeKeyId: result.activeKeyId,
|
||||
scanned: result.totalScanned,
|
||||
rotated: result.totalRotated,
|
||||
errors: result.totalErrors,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.addWarning(`encryption-key-rotate failed: ${err}`);
|
||||
await auditLog("admin.encryption.rotate.failed", {
|
||||
userId: requestedBy,
|
||||
details: { message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
// Re-throw so pg-boss records the failure (the run is idempotent, so a
|
||||
// retry is safe).
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -62,6 +62,12 @@ import {
|
||||
enqueueBootTimeNoteEncryptionBackfill,
|
||||
type NoteEncryptionBackfillPayload,
|
||||
} from "@/lib/jobs/note-encryption-backfill";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_CONCURRENCY,
|
||||
handleEncryptionKeyRotate,
|
||||
type EncryptionKeyRotatePayload,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { workerLog } from "./shared";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
@@ -228,6 +234,11 @@ const allQueues = [
|
||||
// plaintext note; without this entry pg-boss never provisions the queue and
|
||||
// both the boot enqueue and the cron silently no-op.
|
||||
NOTE_ENCRYPTION_BACKFILL_QUEUE,
|
||||
// v1.23 — admin-triggered encryption-key rotation. On-demand only (no
|
||||
// cron): the admin panel enqueues a singleton run that re-encrypts the
|
||||
// corpus to the active key. Without this entry pg-boss never provisions the
|
||||
// queue and the admin trigger silently no-ops.
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
@@ -490,6 +501,13 @@ export async function registerMaintenanceQueues(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.23 — admin-triggered encryption-key rotation (on-demand, singleton).
|
||||
await boss.work<EncryptionKeyRotatePayload>(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY },
|
||||
handleEncryptionKeyRotate,
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* OpenAPI route table — generic data export (passphrase-encrypted variant).
|
||||
*
|
||||
* The plaintext `GET /api/export/full-backup` + per-type CSV routes predate the
|
||||
* OpenAPI registry and are not iOS-contract surfaces; this module documents the
|
||||
* v1.23 passphrase-encrypted variant because it adds a request flag + a binary
|
||||
* response shape the iOS client may consume.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { stdResponses } from "./shared";
|
||||
|
||||
const encryptedExportRequest = z
|
||||
.object({
|
||||
passphrase: z.string().min(12).max(1024).meta({
|
||||
description:
|
||||
"User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no server-side recovery.",
|
||||
}),
|
||||
})
|
||||
.meta({ id: "EncryptedExportRequest" });
|
||||
|
||||
export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/export/encrypted": {
|
||||
post: {
|
||||
tags: ["Export"],
|
||||
summary: "Download the full backup as a passphrase-encrypted archive",
|
||||
description:
|
||||
"v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:<userId>` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: encryptedExportRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 params | salt | iv | tag | ciphertext).",
|
||||
content: {
|
||||
"application/octet-stream": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -34,6 +34,7 @@ import { consentPaths } from "./consent";
|
||||
import { cyclePaths } from "./cycle";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { devicePaths } from "./devices";
|
||||
import { exportPaths } from "./export";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
@@ -55,6 +56,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...cyclePaths,
|
||||
...metaPaths,
|
||||
...healthRecordPaths,
|
||||
...exportPaths,
|
||||
...authPaths,
|
||||
...syncPaths,
|
||||
...measurementPaths,
|
||||
|
||||
@@ -27,6 +27,8 @@ export const adminKeys = {
|
||||
adminModuleAvailability: () =>
|
||||
["admin", "settings", "module-availability"] as const,
|
||||
adminBackups: () => ["admin", "backups"] as const,
|
||||
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
|
||||
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
|
||||
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
|
||||
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
|
||||
adminFeedbackRoot: () => ["admin", "feedback"] as const,
|
||||
|
||||
Reference in new issue
Block a user