diff --git a/messages/de.json b/messages/de.json index 99c4d6007..7f5ecbdcd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5526,6 +5526,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", diff --git a/messages/en.json b/messages/en.json index c21dd63e9..ea832f414 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5526,6 +5526,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", diff --git a/messages/es.json b/messages/es.json index bed491e0e..2f8e83128 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5526,6 +5526,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", diff --git a/messages/fr.json b/messages/fr.json index e4f6a672f..cf4d07338 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5526,6 +5526,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", diff --git a/messages/it.json b/messages/it.json index 702f0f4ff..e4d227f29 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5526,6 +5526,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", diff --git a/messages/pl.json b/messages/pl.json index 9f5463383..f4190da54 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5526,6 +5526,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", diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 4e87add9e..37a97d675 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -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({ ); + case "encryption": + return ( + + + + ); case "danger-zone": return ( { + 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, + }); +}); diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 46e52c8d6..79833c234 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -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", diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx new file mode 100644 index 000000000..8456d347f --- /dev/null +++ b/src/components/admin/encryption-section.tsx @@ -0,0 +1,295 @@ +"use client"; + +/** + * `` — 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; + 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 => { + return apiGet("/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 ( +
+ + {t("admin.section.encryption.loading")} +
+ ); + } + + if (statusQuery.isError || !statusQuery.data) { + return ( +

+ {t("admin.section.encryption.loadError")} +

+ ); + } + + 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 ( +
+ {/* ── Coverage summary ───────────────────────────────────────── */} + + +
+ + + + +
+ {s.rotationComplete ? ( + + {t("admin.section.encryption.safeToDropLegacy")} + + ) : ( + + {t("admin.section.encryption.rotationIncomplete", { + count: s.staleRows, + })} + + )} +
+ + {/* ── Rotation status + trigger ──────────────────────────────── */} + + +
+

+ {t("admin.section.encryption.rotationState")}:{" "} + + {t(`admin.section.encryption.state.${s.rotation.state}`)} + +

+ {s.rotation.lastResult && ( +

+ {t("admin.section.encryption.lastRun", { + scanned: s.rotation.lastResult.scanned, + rotated: s.rotation.lastResult.rotated, + errors: s.rotation.lastResult.errors, + })} +

+ )} +
+ + + + + + + + + {t("admin.section.encryption.rotateConfirmTitle")} + + + {t("admin.section.encryption.rotateConfirmDescription")} + + + + + {t("admin.section.encryption.cancel")} + + rotate.mutate()}> + {t("admin.section.encryption.rotateConfirm")} + + + + + +

+ {t("admin.section.encryption.cliNote")} +

+
+ + {/* ── Per-column coverage table ──────────────────────────────── */} + + +
+ + + + + + + + + + + {columns.map((c) => { + const active = c.byKeyId[s.activeKeyId] ?? 0; + const stale = c.total - active; + return ( + + + + + + + ); + })} + +
+ {t("admin.section.encryption.colColumn")} + + {t("admin.section.encryption.colTotal")} + + {t("admin.section.encryption.colActive")} + + {t("admin.section.encryption.colStale")} +
+ {c.model}.{c.field} + {c.total.toLocaleString()}{active.toLocaleString()} + {stale > 0 ? ( + + {stale.toLocaleString()} + + ) : ( + 0 + )} +
+
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts index 45689aab4..901be36c9 100644 --- a/src/components/admin/section-slugs.ts +++ b/src/components/admin/section-slugs.ts @@ -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 diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index b2bba0cb4..3d67e2639 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -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); diff --git a/src/lib/crypto/__tests__/encryption-corpus.test.ts b/src/lib/crypto/__tests__/encryption-corpus.test.ts new file mode 100644 index 000000000..51216365c --- /dev/null +++ b/src/lib/crypto/__tests__/encryption-corpus.test.ts @@ -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): { + client: CorpusClient; + store: Record; +} { + const store: Record = {}; + 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 = {}; + 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); + }); +}); diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts new file mode 100644 index 000000000..f8574a30f --- /dev/null +++ b/src/lib/crypto/encryption-corpus.ts @@ -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; + }) => Promise>>; + update: (args: { + where: { id: string }; + data: Record; + }) => Promise; +} + +/** The subset of the Prisma client we touch: one delegate per model. */ +export type CorpusClient = Record; + +/** 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; + /** 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 { + const delegate = getDelegate(client, col.model); + const rows = await delegate.findMany({ + select: { id: true, [col.field]: true }, + }); + const byKeyId: Record = {}; + 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 { + 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 { + 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 { + 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 }; diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index d7472917e..d1caeac07 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -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,