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:
@@ -214,7 +214,7 @@ database:
|
|||||||
storage:
|
storage:
|
||||||
store_path: /var/archivmail/store # Haupt-Mailspeicher (AES-256-GCM)
|
store_path: /var/archivmail/store # Haupt-Mailspeicher (AES-256-GCM)
|
||||||
astore_path: /var/archivmail/astore # Anhang-Speicher
|
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:
|
smtp:
|
||||||
enabled: true
|
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
|
- Nonce: 12 Byte, kryptografisch zufällig, pro E-Mail neu generiert
|
||||||
- Dateiformat: `[12-Byte Nonce][verschlüsselte Daten]`
|
- 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:**
|
**Datei-ID / Integrität:**
|
||||||
- Jede E-Mail erhält als ID den SHA-256-Hash des Klartexts
|
- Jede E-Mail erhält als ID den SHA-256-Hash des Klartexts
|
||||||
- Duplikat-Erkennung: gleicher Hash → gleiche Datei, Speicherung übersprungen
|
- Duplikat-Erkennung: gleicher Hash → gleiche Datei, Speicherung übersprungen
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func runStatus(args []string) {
|
|||||||
checkPostgres(cfg),
|
checkPostgres(cfg),
|
||||||
checkManticore(cfg),
|
checkManticore(cfg),
|
||||||
checkStorage(cfg),
|
checkStorage(cfg),
|
||||||
|
checkEncryption(cfg),
|
||||||
checkAuditLog(cfg),
|
checkAuditLog(cfg),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +174,37 @@ func checkStorage(cfg *config.Config) checkResult {
|
|||||||
return checkResult{Name: "Storage", OK: ok, Detail: detail}
|
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
|
// 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
|
// and is writable. It performs a non-destructive O_APPEND open without writing
|
||||||
// any bytes, so the immutability of existing content is not affected.
|
// 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()
|
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)
|
// Index — per-tenant index manager (PROJ-21 Phase 4)
|
||||||
indexBackend := cfg.Index.Backend
|
indexBackend := cfg.Index.Backend
|
||||||
if indexBackend == "" {
|
if indexBackend == "" {
|
||||||
|
|||||||
+1
-1
@@ -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-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-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-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-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-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 |
|
| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 |
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
"archive": archiveResp,
|
"archive": archiveResp,
|
||||||
"activity": activity,
|
"activity": activity,
|
||||||
"estimate": estimate,
|
"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(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ func New(cfg Config) (*Store, error) {
|
|||||||
return s, nil
|
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).
|
// Close releases the database connection pool (if any).
|
||||||
func (s *Store) Close() {
|
func (s *Store) Close() {
|
||||||
if s.db != nil {
|
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.
|
// 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.
|
// 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)
|
data, err := os.ReadFile(keyfile)
|
||||||
if err != nil {
|
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
|
// Strip whitespace / newlines
|
||||||
raw := strings.TrimSpace(string(data))
|
raw := strings.TrimSpace(string(data))
|
||||||
|
|
||||||
// Try base64 decode first (spec says base64-encoded 32-byte key)
|
// Try base64 decode first (spec says base64-encoded 32-byte key)
|
||||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
decoded, derr := base64.StdEncoding.DecodeString(raw)
|
||||||
if err != nil {
|
if derr != nil {
|
||||||
// Fall back to raw bytes (for 32-byte binary key files)
|
// Fall back to raw bytes (for 32-byte binary key files)
|
||||||
decoded = []byte(raw)
|
decoded = []byte(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(decoded) != 32 {
|
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
|
s.key = decoded
|
||||||
|
|||||||
Reference in New Issue
Block a user