OPS-13: Prüfprotokoll und neue AAD-Tests (F-21)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s

Neue Tests fuer Archive/Mail/Core-kek (Pruefung 1/2 je Stelle,
identisches Muster wie die bereits vorhandenen DMS-Tests). Alle
Pruefungen real gegen Postgres auf Host 204 durchgefuehrt (siehe
Pruefprotokoll), inkl. vollstaendiger Regression von dms/qa02,
internal/kek, internal/ldapsync, internal/webhook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-26 22:47:08 +02:00
1 parent 0ea129e71c
commit 47b85b6522
4 files changed
+245

No files matched your search

@@ -0,0 +1,70 @@
package backupcrypto
import (
"bytes"
"errors"
"testing"
)
// TestWrapDEK_AADBindsContext ist F-21 Pruefung 1: ein Austausch zwischen
// zwei Kontexten (Mandanten) faellt beim Entschluesseln auf.
func TestWrapDEK_AADBindsContext(t *testing.T) {
kek := bytes.Repeat([]byte{0x01}, KEKSize)
dek, err := GenerateDEK()
if err != nil {
t.Fatalf("generatedek: %v", err)
}
wrapped, err := WrapDEK(kek, dek, []byte("tenant-a"))
if err != nil {
t.Fatalf("wrapdek: %v", err)
}
if _, err := UnwrapDEK(kek, wrapped, []byte("tenant-b")); !errors.Is(err, ErrDecryptFailed) {
t.Fatal("unwrapdek mit falschem aad haette fehlschlagen muessen")
}
if _, err := UnwrapDEK(kek, wrapped, []byte("tenant-a")); err != nil {
t.Fatalf("unwrapdek mit korrektem aad: %v", err)
}
}
// TestUnwrapDEK_LegacyWithoutAADStillReadable ist F-21 Pruefung 2:
// Ruecklaufkompatibilitaet fuer vor dieser Aenderung ohne AAD versiegelte
// Datensaetze.
func TestUnwrapDEK_LegacyWithoutAADStillReadable(t *testing.T) {
kek := bytes.Repeat([]byte{0x02}, KEKSize)
dek, err := GenerateDEK()
if err != nil {
t.Fatalf("generatedek: %v", err)
}
legacyWrapped, err := WrapDEK(kek, dek, nil)
if err != nil {
t.Fatalf("wrapdek (legacy): %v", err)
}
unwrapped, err := UnwrapDEK(kek, legacyWrapped, []byte("tenant-a"))
if err != nil {
t.Fatalf("legacy-datensatz muss lesbar bleiben: %v", err)
}
if !bytes.Equal(unwrapped, dek) {
t.Fatal("legacy-datensatz lieferte falschen dek")
}
}
// TestWrapUnwrapDEK_RoundTrip ist Pruefung 3: bestehendes
// Verschluesselungsverhalten bleibt unveraendert (keine Regression).
func TestWrapUnwrapDEK_RoundTrip(t *testing.T) {
kek := bytes.Repeat([]byte{0x03}, KEKSize)
dek, err := GenerateDEK()
if err != nil {
t.Fatalf("generatedek: %v", err)
}
wrapped, err := WrapDEK(kek, dek, []byte("tenant-a"))
if err != nil {
t.Fatalf("wrapdek: %v", err)
}
unwrapped, err := UnwrapDEK(kek, wrapped, []byte("tenant-a"))
if err != nil {
t.Fatalf("unwrapdek: %v", err)
}
if !bytes.Equal(unwrapped, dek) {
t.Fatal("entpackter dek stimmt nicht mit original ueberein")
}
}
+50
View File
@@ -0,0 +1,50 @@
# OPS-13 – Prüfprotokoll: Additional Authenticated Data bei AES-GCM (F-21)
Datum: 2026-09-26. Testhost: 192.168.1.204, Wegwerf-Postgres-Rollen/-Datenbanken (nach jedem Lauf entfernt).
## Umgesetzte Änderung
Alle vier in QA-15/F-21 genannten Stellen binden jetzt ein Additional Authenticated Data (AAD) an ihre AES-256-GCM-Chiffretexte:
| Ort | AAD-Quelle |
|---|---|
| `dms/internal/crypto/envelope.go` (`WrapDEK`/`UnwrapDEK`/`EncryptStream`/`DecryptStream`) | Tenant-Slug (`Service.Seal`/`Open`/`NewDataKey`/`UnwrapDataKey` — Signatur unverändert, ~30 externe Aufrufstellen nicht angefasst) |
| `archive/internal/backupcrypto/envelope.go` (`WrapDEK`/`UnwrapDEK`, plus direkte `seal`/`open`-Aufrufe in `file.go`) | Tenant-Slug (`RepoPassword`/`EncryptFile`/`DecryptFile` — bereits vorhandener Parameter) |
| `mail/internal/crypto/envelope.go` (`WrapDEK`/`UnwrapDEK`/`EncryptStream`/`DecryptStream`, plus `kekversions.go`) | Tenant-Slug (`Service.Seal`/`Open`/`OpenAtVersion`, ARC-08-KEK-Versionsverwaltung) |
| `internal/kek/master.go` (`wrap`/`unwrap`, `EncryptWithTenantKEK`/`DecryptWithTenantKEK`) und `internal/kek/store.go` (Tenant-KEK-Verpackung durch den Master-Key) | Tenant-ID (`Store.CreateForTenant`/`GetDecrypted`/`RotateTenantKEK`/`RotateMasterKey`); für die drei realen externen Aufrufer von `EncryptWithTenantKEK`/`DecryptWithTenantKEK`: `internal/ldapsync` (Tenant-ID + Zweck-Bezeichner), `internal/webhook` (Ziel-URL des Abonnements), `cmd/account-api` modulekeys (fester Zweck-Bezeichner, siehe „Bekannte Grenze“ unten) |
Rückwärtskompatibilität: jedes `open`/`unwrap` versucht zuerst mit dem neuen AAD, fällt bei Fehlschlag auf kein-AAD zurück. Vor dieser Änderung versiegelte Datensätze bleiben ohne Neuverschlüsselung lesbar.
## Akzeptanzkriterien
1. **Alle vier Stellen binden AAD an ihren Chiffretext** — erfüllt, siehe Tabelle oben.
2. **Simulierter Schlüssel-/Chiffretext-Austausch zwischen zwei Kontexten wird erkannt** — erfüllt, siehe Prüfung 1 unten für alle vier Stellen.
3. **Bereits verschlüsselte Datensätze bleiben lesbar** — erfüllt, siehe Prüfung 2 unten.
## Prüfungen (real durchgeführt, nicht nur behauptet)
### DMS (`dms/internal/crypto`)
- Prüfung 1 (`TestWrapDEK_AADBindsContext`): DEK mit AAD `"tenant-a"` verpackt, Entpacken mit AAD `"tenant-b"` liefert `ErrDecryptFailed`. **Bestanden.**
- Prüfung 2 (`TestUnwrapDEK_LegacyWithoutAADStillReadable`): mit `WrapDEK(kek, dek, nil)` versiegelter Alt-Datensatz bleibt mit `UnwrapDEK(kek, wrapped, []byte("tenant-a"))` lesbar. **Bestanden.**
- Prüfung 3 (Regression): komplette bestehende `internal/crypto`-Testsuite (9 Tests) grün, `internal/qa02`-End-zu-Ende-Suite (6 Tests, echter Postgres, u. a. `TestKeyManagement_RewrapKeepsContentReadableWithNewKEKOnly` — der reale `RewrapDEK`-Produktionspfad) grün.
### Archive (`archive/internal/backupcrypto`)
- Prüfung 1 (`TestWrapDEK_AADBindsContext`): identisches Muster wie DMS. **Bestanden.**
- Prüfung 2 (`TestUnwrapDEK_LegacyWithoutAADStillReadable`): **Bestanden.**
- Prüfung 3 (Regression): 3 Tests grün (Paket hatte zuvor keine eigene Testdatei — neu angelegt, keine Alttests zu brechen).
### Mail (`mail/internal/crypto`)
- Prüfung 1/2: **Bestanden** (neue `envelope_test.go`, gleiches Muster).
- Prüfung 3 (Regression, echter Postgres): `TestKEKVersionStore_RecordIfNewDetectsRotationOnly`, `TestKEKVersionStore_RevokeBlocksOnlyThatVersion`, `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation`, `TestRotation_CompromisedOldKeyCanBeRevoked` — alle 4 grün (ARC-08-KEK-Versionsverwaltung nutzt dieselben Primitive, keine Regression durch die AAD-Umstellung in `kekversions.go`).
### Core (`internal/kek`, `internal/ldapsync`, `internal/webhook`, `cmd/account-api`)
- Prüfung 1/2 (`TestEncryptWithTenantKEK_AADBindsContext`, `TestDecryptWithTenantKEK_LegacyWithoutAADStillReadable`): **Bestanden.**
- Prüfung 3 (Regression, echter Postgres): `internal/kek` komplette Suite (17 Tests, inkl. `TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext`, `TestRotateTenantKEK_OnlyAffectsThatTenant` — beide nutzen die geänderten `wrap`/`unwrap`-Aufrufe in `store.go` real) grün. `internal/ldapsync` (8 Tests, inkl. `TestConfigStore_WrongTenantKEK_CannotDecrypt`) grün. `internal/webhook` (7 Tests, inkl. `TestSubscribe_SecretIsEncryptedAtRest`, `TestProcessDue_RetriesWithBackoffThenMarksFailed` — durchläuft den geänderten Decrypt-Pfad mehrfach mit unterschiedlichen Ziel-URLs) grün. `cmd/account-api` Bestandstest grün.
## Bekannte, dokumentierte Grenze
Diese Kachel bindet AAD an den **verfügbaren** Kontext (Tenant-Slug/-ID, Ziel-URL, fester Zweck-Bezeichner) — nicht an eine Datensatz-ID. Ein Austausch zwischen zwei Datensätzen **desselben** Mandanten/Kontexts (z. B. zwei Dokument-Revisionen desselben Tenants) bleibt dadurch unerkannt, da beide dasselbe AAD tragen würden. Eine vollständige Bindung an den einzelnen Datensatz würde eine Datensatz-ID an rund 30 Aufrufstellen in DMS/Mail durchreichen (Service.Seal/Open) bzw. das generische `SecretCipher`-Interface in `internal/moduletrust` ändern (betrifft mehrere Module, die dasselbe Interface für Modul-/Sitzungs-Signaturschlüssel nutzen) — beides bewusst außerhalb der "kleinsten Lösung" dieser Kachel. F-21 selbst stuft das Risiko als moderat ein, weil bereits Schreibzugriff auf die Datenbank vorausgesetzt ist; diese Kachel schließt die praktisch bedeutsamste Variante (Kontext-/Mandanten-Verwechslung), nicht die theoretisch verbleibende Variante (Verwechslung innerhalb desselben Kontexts).
## Zusammenfassung
Alle Akzeptanzkriterien und Prüfungen bestanden. Keine Regression in den vier betroffenen Modulen (DMS, Archive, Mail, Core) und ihren jeweils sieben echten Aufrufern. Kein Merge, kein Deploy.
+51
View File
@@ -0,0 +1,51 @@
package kek
import (
"bytes"
"errors"
"testing"
)
// TestEncryptWithTenantKEK_AADBindsContext ist F-21 Pruefung 1: ein
// Austausch zwischen zwei Kontexten faellt beim Entschluesseln auf.
func TestEncryptWithTenantKEK_AADBindsContext(t *testing.T) {
key, err := generateRandomKey()
if err != nil {
t.Fatalf("generaterandomkey: %v", err)
}
ciphertext, err := EncryptWithTenantKEK(key, []byte("geheimnis"), []byte("context-a"))
if err != nil {
t.Fatalf("encryptwithtenantkek: %v", err)
}
if _, err := DecryptWithTenantKEK(key, ciphertext, []byte("context-b")); !errors.Is(err, ErrUnwrapFailed) {
t.Fatal("decryptwithtenantkek mit falschem aad haette fehlschlagen muessen")
}
plain, err := DecryptWithTenantKEK(key, ciphertext, []byte("context-a"))
if err != nil {
t.Fatalf("decryptwithtenantkek mit korrektem aad: %v", err)
}
if !bytes.Equal(plain, []byte("geheimnis")) {
t.Fatal("entschluesselter wert stimmt nicht")
}
}
// TestDecryptWithTenantKEK_LegacyWithoutAADStillReadable ist F-21
// Pruefung 2: Ruecklaufkompatibilitaet fuer vor dieser Aenderung ohne AAD
// versiegelte Geheimnisse.
func TestDecryptWithTenantKEK_LegacyWithoutAADStillReadable(t *testing.T) {
key, err := generateRandomKey()
if err != nil {
t.Fatalf("generaterandomkey: %v", err)
}
legacyCiphertext, err := EncryptWithTenantKEK(key, []byte("altes geheimnis"), nil)
if err != nil {
t.Fatalf("encryptwithtenantkek (legacy): %v", err)
}
plain, err := DecryptWithTenantKEK(key, legacyCiphertext, []byte("context-a"))
if err != nil {
t.Fatalf("legacy-geheimnis muss lesbar bleiben: %v", err)
}
if !bytes.Equal(plain, []byte("altes geheimnis")) {
t.Fatal("legacy-geheimnis lieferte falschen wert")
}
}
+74
View File
@@ -0,0 +1,74 @@
package crypto
import (
"bytes"
"errors"
"testing"
)
// TestWrapDEK_AADBindsContext ist F-21 Pruefung 1: ein Austausch zwischen
// zwei Kontexten (Mandanten) faellt beim Entschluesseln auf.
func TestWrapDEK_AADBindsContext(t *testing.T) {
kek := bytes.Repeat([]byte{0x01}, KEKSize)
dek, err := GenerateDEK()
if err != nil {
t.Fatalf("generatedek: %v", err)
}
wrapped, err := WrapDEK(kek, dek, []byte("tenant-a"))
if err != nil {
t.Fatalf("wrapdek: %v", err)
}
if _, err := UnwrapDEK(kek, wrapped, []byte("tenant-b")); !errors.Is(err, ErrDecryptFailed) {
t.Fatal("unwrapdek mit falschem aad haette fehlschlagen muessen")
}
if _, err := UnwrapDEK(kek, wrapped, []byte("tenant-a")); err != nil {
t.Fatalf("unwrapdek mit korrektem aad: %v", err)
}
}
// TestUnwrapDEK_LegacyWithoutAADStillReadable ist F-21 Pruefung 2:
// Ruecklaufkompatibilitaet fuer vor dieser Aenderung ohne AAD versiegelte
// Datensaetze.
func TestUnwrapDEK_LegacyWithoutAADStillReadable(t *testing.T) {
kek := bytes.Repeat([]byte{0x02}, KEKSize)
dek, err := GenerateDEK()
if err != nil {
t.Fatalf("generatedek: %v", err)
}
legacyWrapped, err := WrapDEK(kek, dek, nil)
if err != nil {
t.Fatalf("wrapdek (legacy): %v", err)
}
unwrapped, err := UnwrapDEK(kek, legacyWrapped, []byte("tenant-a"))
if err != nil {
t.Fatalf("legacy-datensatz muss lesbar bleiben: %v", err)
}
if !bytes.Equal(unwrapped, dek) {
t.Fatal("legacy-datensatz lieferte falschen dek")
}
}
// TestEncryptDecryptStream_RoundTrip ist Pruefung 3: bestehendes
// Verschluesselungsverhalten bleibt unveraendert (keine Regression).
func TestEncryptDecryptStream_RoundTrip(t *testing.T) {
dek, err := GenerateDEK()
if err != nil {
t.Fatalf("generatedek: %v", err)
}
plaintext := []byte("vertraulicher mail-anhang")
ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext), []byte("tenant-a"))
if err != nil {
t.Fatalf("encryptstream: %v", err)
}
decrypted, err := DecryptStream(dek, ciphertext, []byte("tenant-a"))
if err != nil {
t.Fatalf("decryptstream: %v", err)
}
got := make([]byte, len(plaintext))
if _, err := decrypted.Read(got); err != nil {
t.Fatalf("klartext lesen: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("entschluesselter klartext = %q, want %q", got, plaintext)
}
}