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
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:
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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user