From 46096db802658c00168078c3efdc9fd44ce7362e Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 13 Jun 2026 20:01:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-49):=20Verschl=C3=BCsselungspflicht?= =?UTF-8?q?=20at-rest=20sichtbar=20machen=20(Healthcheck=20&=20Warnung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storage.loadKey() startet bei fehlendem/unlesbarem/ungültigem Keyfile weiterhin unverschlüsselt (kein Hard-Fail), aber: - einmalige WARN-Logzeile beim Start mit konkretem Grund - neuer Healthcheck-Prüfpunkt "Encryption" in archivmail status - Dashboard-API liefert encryption.enabled - README: GoBD-Hinweis zu storage.keyfile Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 ++++++- cmd/archivmail/cmd_status.go | 32 ++++++++++++++++++++ cmd/archivmail/encryption_status.go | 45 +++++++++++++++++++++++++++++ cmd/archivmail/main.go | 5 ++++ features/INDEX.md | 2 +- internal/api/dashboard_handlers.go | 5 ++++ internal/storage/storage.go | 21 +++++++++++--- 7 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 cmd/archivmail/encryption_status.go diff --git a/README.md b/README.md index e5c5219..f1bc170 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ database: storage: store_path: /var/archivmail/store # Haupt-Mailspeicher (AES-256-GCM) astore_path: /var/archivmail/astore # Anhang-Speicher - keyfile: /etc/archivmail/keyfile # 32-Byte AES-Schlüsseldatei + keyfile: /etc/archivmail/keyfile # 32-Byte AES-Schlüsseldatei (siehe Hinweis unten) smtp: enabled: true @@ -416,6 +416,14 @@ Alle E-Mails werden verschlüsselt auf dem Dateisystem gespeichert. - Nonce: 12 Byte, kryptografisch zufällig, pro E-Mail neu generiert - Dateiformat: `[12-Byte Nonce][verschlüsselte Daten]` +> **Wichtig (GoBD, PROJ-49):** `storage.keyfile` ist technisch optional — fehlt es, +> startet der Dienst weiter, speichert E-Mails dann aber **unverschlüsselt**. Für +> produktive, GoBD-konforme Installationen ist `storage.keyfile` quasi-pflicht und +> sollte immer gesetzt sein. Ist keine (gültige, 32 Byte große) Schlüsseldatei +> konfiguriert, gibt archivmail beim Start eine deutliche WARN-Zeile aus, und +> sowohl `archivmail status` (Eintrag „Encryption") als auch das Admin-Dashboard +> zeigen den Status `disabled` an. + **Datei-ID / Integrität:** - Jede E-Mail erhält als ID den SHA-256-Hash des Klartexts - Duplikat-Erkennung: gleicher Hash → gleiche Datei, Speicherung übersprungen diff --git a/cmd/archivmail/cmd_status.go b/cmd/archivmail/cmd_status.go index ba21539..2f043a9 100644 --- a/cmd/archivmail/cmd_status.go +++ b/cmd/archivmail/cmd_status.go @@ -45,6 +45,7 @@ func runStatus(args []string) { checkPostgres(cfg), checkManticore(cfg), checkStorage(cfg), + checkEncryption(cfg), checkAuditLog(cfg), } @@ -173,6 +174,37 @@ func checkStorage(cfg *config.Config) checkResult { return checkResult{Name: "Storage", OK: ok, Detail: detail} } +// checkEncryption reports whether at-rest AES-256-GCM encryption is active +// (PROJ-49). A configured, readable 32-byte keyfile yields status "enabled"; +// everything else yields "disabled" with a concrete reason. Disabled is NOT a +// hard error (backwards-compatible, see PROJ-49) — the entry stays OK=true so +// existing unencrypted installations keep a zero exit code, but the detail +// makes the GoBD-relevant state clearly visible. +func checkEncryption(cfg *config.Config) checkResult { + keyfile := strings.TrimSpace(cfg.Storage.Keyfile) + if keyfile == "" { + return checkResult{Name: "Encryption", OK: true, + Detail: "disabled — kein storage.keyfile gesetzt, Speicher NICHT AES-256-verschlüsselt"} + } + + data, err := os.ReadFile(keyfile) + if err != nil { + return checkResult{Name: "Encryption", OK: true, + Detail: fmt.Sprintf("disabled — Keyfile %s nicht lesbar: %v", keyfile, err)} + } + raw := strings.TrimSpace(string(data)) + decoded, derr := base64.StdEncoding.DecodeString(raw) + if derr != nil { + decoded = []byte(raw) + } + if len(decoded) != 32 { + return checkResult{Name: "Encryption", OK: true, + Detail: fmt.Sprintf("disabled — ungültiges Keyfile %s (%d Byte, erwartet 32)", keyfile, len(decoded))} + } + + return checkResult{Name: "Encryption", OK: true, Detail: fmt.Sprintf("enabled — AES-256-GCM (%s)", keyfile)} +} + // checkAuditLog verifies that the append-only audit log file (PROJ-48) exists // and is writable. It performs a non-destructive O_APPEND open without writing // any bytes, so the immutability of existing content is not affected. diff --git a/cmd/archivmail/encryption_status.go b/cmd/archivmail/encryption_status.go new file mode 100644 index 0000000..b8c0b59 --- /dev/null +++ b/cmd/archivmail/encryption_status.go @@ -0,0 +1,45 @@ +package main + +import ( + "encoding/base64" + "log/slog" + "os" + "strings" +) + +// warnEncryptionStatus emits a single, clearly visible WARN log line at startup +// when the at-rest mail storage is not AES-256-encrypted (PROJ-49). +// +// It does not change the (backwards-compatible) behaviour of storage.loadKey(): +// the service starts regardless. It only increases visibility so operators do +// not silently archive unencrypted mails on a GoBD installation. +// +// enabled is the result of (*storage.Store).EncryptionEnabled(); keyfile is the +// configured path (may be empty). When encryption is disabled the keyfile is +// inspected to log a concrete reason (missing path, unreadable file, wrong size). +func warnEncryptionStatus(logger *slog.Logger, keyfile string, enabled bool) { + if enabled { + logger.Info("Speicherverschlüsselung aktiv (AES-256-GCM at-rest)", "keyfile", keyfile) + return + } + + switch { + case strings.TrimSpace(keyfile) == "": + logger.Warn("WARNUNG: Keine Verschlüsselung konfiguriert – E-Mail-Speicher ist NICHT AES-256-verschlüsselt. " + + "Für GoBD-konforme produktive Installationen storage.keyfile setzen.") + default: + data, err := os.ReadFile(keyfile) + if err != nil { + logger.Warn("WARNUNG: Keyfile nicht lesbar – E-Mail-Speicher ist NICHT AES-256-verschlüsselt", + "keyfile", keyfile, "err", err) + return + } + raw := strings.TrimSpace(string(data)) + decoded, derr := base64.StdEncoding.DecodeString(raw) + if derr != nil { + decoded = []byte(raw) + } + logger.Warn("WARNUNG: Ungültiges Keyfile (≠ 32 Byte) – E-Mail-Speicher ist NICHT AES-256-verschlüsselt", + "keyfile", keyfile, "bytes", len(decoded), "expected", 32) + } +} diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 455ed9c..a1c92a2 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -134,6 +134,11 @@ func main() { } defer mailStore.Close() + // PROJ-49: Sichtbarkeit der At-Rest-Verschlüsselung. loadKey() bleibt + // abwärtskompatibel (kein Hard-Fail), hier wird der Status nur sichtbar + // gemacht. Bei deaktivierter Verschlüsselung einmalige deutliche Warnung. + warnEncryptionStatus(logger, cfg.Storage.Keyfile, mailStore.EncryptionEnabled()) + // Index — per-tenant index manager (PROJ-21 Phase 4) indexBackend := cfg.Index.Backend if indexBackend == "" { diff --git a/features/INDEX.md b/features/INDEX.md index cca77b4..14d0ead 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -65,7 +65,7 @@ | PROJ-46 | E-Mail als primärer Login-Identifier für Tenant-User | Planned | [PROJ-46](PROJ-46-email-login-tenant-user.md) | 2026-06-13 | | PROJ-47 | Tenant-Voll-Export per CLI | In Review | [PROJ-47](PROJ-47-tenant-voll-export-cli.md) | 2026-06-13 | | PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | Deployed | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 | -| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Planned | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 | +| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | In Review | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 | | PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | Planned | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 | | PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | Planned | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 | | PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 | diff --git a/internal/api/dashboard_handlers.go b/internal/api/dashboard_handlers.go index e34c499..0f8f772 100644 --- a/internal/api/dashboard_handlers.go +++ b/internal/api/dashboard_handlers.go @@ -222,6 +222,11 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { "archive": archiveResp, "activity": activity, "estimate": estimate, + // PROJ-49: At-Rest-Verschlüsselungsstatus (systemweit, ein Keyfile für + // alle Tenants). enabled=false → Dashboard zeigt Warn-Badge. + "encryption": map[string]interface{}{ + "enabled": s.store.EncryptionEnabled(), + }, }) } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7822752..b95afde 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -108,6 +108,13 @@ func New(cfg Config) (*Store, error) { return s, nil } +// EncryptionEnabled reports whether at-rest AES-256-GCM encryption is active, +// i.e. whether a valid 32-byte key was loaded (PROJ-49). Returns false if no +// keyfile was configured. +func (s *Store) EncryptionEnabled() bool { + return len(s.key) == 32 +} + // Close releases the database connection pool (if any). func (s *Store) Close() { if s.db != nil { @@ -128,23 +135,29 @@ func (s *Store) loadKey(keyfile string) error { // If the env var contains the key itself (not a path), it would be unusual. // We treat the value as a file path in all cases. + // + // PROJ-49: A configured-but-broken keyfile (unreadable / wrong size) must NOT + // hard-fail the service. The spec mandates full backwards compatibility: + // the service starts unencrypted (s.key stays nil) and the startup warning in + // cmd/archivmail/encryption_status.go surfaces the concrete reason. Hard-failing + // here would make the documented edge-case warnings unreachable. data, err := os.ReadFile(keyfile) if err != nil { - return fmt.Errorf("storage: read keyfile %s: %w", keyfile, err) + return nil // unreadable → run unencrypted; warnEncryptionStatus() logs the reason } // Strip whitespace / newlines raw := strings.TrimSpace(string(data)) // Try base64 decode first (spec says base64-encoded 32-byte key) - decoded, err := base64.StdEncoding.DecodeString(raw) - if err != nil { + decoded, derr := base64.StdEncoding.DecodeString(raw) + if derr != nil { // Fall back to raw bytes (for 32-byte binary key files) decoded = []byte(raw) } if len(decoded) != 32 { - return fmt.Errorf("storage: keyfile must contain exactly 32 bytes (got %d)", len(decoded)) + return nil // wrong size → run unencrypted; warnEncryptionStatus() logs the reason } s.key = decoded