feat(PROJ-49): Verschlüsselungspflicht at-rest sichtbar machen (Healthcheck & Warnung)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user