diff --git a/archive/internal/backupcrypto/envelope_test.go b/archive/internal/backupcrypto/envelope_test.go new file mode 100644 index 00000000..10365f5b --- /dev/null +++ b/archive/internal/backupcrypto/envelope_test.go @@ -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") + } +} diff --git a/docs/OPS-13-PRUEFPROTOKOLL.md b/docs/OPS-13-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..1fd1f861 --- /dev/null +++ b/docs/OPS-13-PRUEFPROTOKOLL.md @@ -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. diff --git a/internal/kek/aad_test.go b/internal/kek/aad_test.go new file mode 100644 index 00000000..7c7f0906 --- /dev/null +++ b/internal/kek/aad_test.go @@ -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") + } +} diff --git a/mail/internal/crypto/envelope_test.go b/mail/internal/crypto/envelope_test.go new file mode 100644 index 00000000..9c92ad71 --- /dev/null +++ b/mail/internal/crypto/envelope_test.go @@ -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) + } +}