diff --git a/archive/internal/backupcrypto/envelope.go b/archive/internal/backupcrypto/envelope.go index e578ed03..aebed735 100644 --- a/archive/internal/backupcrypto/envelope.go +++ b/archive/internal/backupcrypto/envelope.go @@ -32,7 +32,12 @@ func GenerateDEK() ([]byte, error) { return dek, nil } -func seal(key, plaintext []byte) ([]byte, error) { +// seal/open binden aad (F-21) an den Chiffretext, damit ein Austausch +// verpackter Werte zwischen zwei Kontexten beim Entschluesseln auffaellt +// statt still zu funktionieren. open versucht bei gesetztem aad zuerst mit +// aad, dann (Ruecklaufkompatibilitaet fuer vor dieser Aenderung versiegelte +// Daten ohne AAD) ohne aad. +func seal(key, plaintext, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("backupcrypto: aes-cipher erstellen: %w", err) @@ -45,10 +50,10 @@ func seal(key, plaintext []byte) ([]byte, error) { if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("backupcrypto: nonce erzeugen: %w", err) } - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nonce, nonce, plaintext, aad), nil } -func open(key, sealed []byte) ([]byte, error) { +func open(key, sealed, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("backupcrypto: aes-cipher erstellen: %w", err) @@ -61,21 +66,25 @@ func open(key, sealed []byte) ([]byte, error) { return nil, ErrDecryptFailed } nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, ErrDecryptFailed + if plaintext, err := gcm.Open(nil, nonce, ciphertext, aad); err == nil { + return plaintext, nil } - return plaintext, nil + if len(aad) > 0 { + if plaintext, err := gcm.Open(nil, nonce, ciphertext, nil); err == nil { + return plaintext, nil + } + } + return nil, ErrDecryptFailed } -func WrapDEK(kek, dek []byte) ([]byte, error) { - wrapped, err := seal(kek, dek) +func WrapDEK(kek, dek, aad []byte) ([]byte, error) { + wrapped, err := seal(kek, dek, aad) if err != nil { return nil, fmt.Errorf("backupcrypto: dek verpacken: %w", err) } return wrapped, nil } -func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) { - return open(kek, wrappedDEK) +func UnwrapDEK(kek, wrappedDEK, aad []byte) ([]byte, error) { + return open(kek, wrappedDEK, aad) } diff --git a/archive/internal/backupcrypto/file.go b/archive/internal/backupcrypto/file.go index 9ee88719..3eff6dbe 100644 --- a/archive/internal/backupcrypto/file.go +++ b/archive/internal/backupcrypto/file.go @@ -34,7 +34,7 @@ func EncryptFile(ctx context.Context, provider kekclient.Provider, tenantSlug, s if err != nil { return err } - ciphertext, err := seal(dek, plaintext) + ciphertext, err := seal(dek, plaintext, []byte(tenantSlug)) if err != nil { return fmt.Errorf("backupcrypto: %q verschluesseln: %w", srcPath, err) } @@ -42,7 +42,7 @@ func EncryptFile(ctx context.Context, provider kekclient.Provider, tenantSlug, s if err != nil { return fmt.Errorf("backupcrypto: tenant-kek fuer %q beziehen: %w", tenantSlug, err) } - wrappedDEK, err := WrapDEK(tenantKEK, dek) + wrappedDEK, err := WrapDEK(tenantKEK, dek, []byte(tenantSlug)) if err != nil { return fmt.Errorf("backupcrypto: dek fuer %q verpacken: %w", tenantSlug, err) } @@ -88,11 +88,11 @@ func DecryptFile(ctx context.Context, provider kekclient.Provider, tenantSlug, s if err != nil { return fmt.Errorf("backupcrypto: tenant-kek fuer %q beziehen: %w", tenantSlug, err) } - dek, err := UnwrapDEK(tenantKEK, wrappedDEK) + dek, err := UnwrapDEK(tenantKEK, wrappedDEK, []byte(tenantSlug)) if err != nil { return fmt.Errorf("backupcrypto: dek entpacken (falscher tenant-kek?): %w", err) } - plaintext, err := open(dek, ciphertext) + plaintext, err := open(dek, ciphertext, []byte(tenantSlug)) if err != nil { return fmt.Errorf("backupcrypto: %q entschluesseln: %w", srcPath, err) } diff --git a/archive/internal/backupcrypto/reposecret.go b/archive/internal/backupcrypto/reposecret.go index 3f9b1672..b4eba098 100644 --- a/archive/internal/backupcrypto/reposecret.go +++ b/archive/internal/backupcrypto/reposecret.go @@ -34,7 +34,7 @@ func RepoPassword(ctx context.Context, provider kekclient.Provider, tenantSlug, if err != nil { return "", fmt.Errorf("backupcrypto: tenant-kek fuer %q beziehen: %w", tenantSlug, err) } - dek, err := UnwrapDEK(tenantKEK, wrappedDEK) + dek, err := UnwrapDEK(tenantKEK, wrappedDEK, []byte(tenantSlug)) if err != nil { return "", fmt.Errorf("backupcrypto: repo-dek entpacken (falscher tenant-kek?): %w", err) } @@ -52,7 +52,7 @@ func RepoPassword(ctx context.Context, provider kekclient.Provider, tenantSlug, if err != nil { return "", fmt.Errorf("backupcrypto: tenant-kek fuer %q beziehen: %w", tenantSlug, err) } - wrappedDEK, err := WrapDEK(tenantKEK, dek) + wrappedDEK, err := WrapDEK(tenantKEK, dek, []byte(tenantSlug)) if err != nil { return "", fmt.Errorf("backupcrypto: repo-dek fuer %q verpacken: %w", tenantSlug, err) } diff --git a/cmd/account-api/modulekeys.go b/cmd/account-api/modulekeys.go index 01042e51..2bbf6add 100644 --- a/cmd/account-api/modulekeys.go +++ b/cmd/account-api/modulekeys.go @@ -83,12 +83,20 @@ func parseModuleKeyConfig(getenv func(string) string) (moduleKeyConfig, error) { type kekCipher struct{ key kek.MasterKey } +// AAD (F-21) ist ein fester Zweck-Bezeichner statt einer Datensatz-ID: das +// SecretCipher-Interface (internal/moduletrust) reicht keine Kennung des +// einzelnen Schluessels durch, eine Erweiterung wuerde in alle Module +// hineinwirken, die dasselbe Interface fuer Modul-/Sitzungs-Signaturschluessel +// verwenden (ausserhalb dieser Kachel). Bindet den Chiffretext zumindest an +// seinen Verwendungszweck. +const moduleSigningKeyAAD = "module-signing-key" + func (c kekCipher) Encrypt(plaintext []byte) ([]byte, error) { - return kek.EncryptWithTenantKEK(c.key, plaintext) + return kek.EncryptWithTenantKEK(c.key, plaintext, []byte(moduleSigningKeyAAD)) } func (c kekCipher) Decrypt(ciphertext []byte) ([]byte, error) { - return kek.DecryptWithTenantKEK(c.key, ciphertext) + return kek.DecryptWithTenantKEK(c.key, ciphertext, []byte(moduleSigningKeyAAD)) } func newModuleKeyManager(ctx context.Context, pool *pgxpool.Pool, getenv func(string) string, tokenTTL time.Duration) (*moduletrust.KeyManager, error) { diff --git a/dms/internal/crypto/envelope.go b/dms/internal/crypto/envelope.go index ed21d2d0..da447854 100644 --- a/dms/internal/crypto/envelope.go +++ b/dms/internal/crypto/envelope.go @@ -40,7 +40,12 @@ func GenerateDEK() ([]byte, error) { return dek, nil } -func seal(key, plaintext []byte) ([]byte, error) { +// seal/open binden aad (F-21) an den Chiffretext, damit ein Austausch +// verpackter Werte zwischen zwei Kontexten (z.B. Mandanten) beim +// Entschluesseln auffaellt statt still zu funktionieren. open versucht bei +// gesetztem aad zuerst mit aad, dann (Ruecklaufkompatibilitaet fuer vor +// dieser Aenderung versiegelte Daten ohne AAD) ohne aad. +func seal(key, plaintext, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err) @@ -53,10 +58,10 @@ func seal(key, plaintext []byte) ([]byte, error) { if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("crypto: nonce erzeugen: %w", err) } - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nonce, nonce, plaintext, aad), nil } -func open(key, sealed []byte) ([]byte, error) { +func open(key, sealed, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err) @@ -69,16 +74,21 @@ func open(key, sealed []byte) ([]byte, error) { return nil, ErrDecryptFailed } nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, ErrDecryptFailed + if plaintext, err := gcm.Open(nil, nonce, ciphertext, aad); err == nil { + return plaintext, nil } - return plaintext, nil + if len(aad) > 0 { + if plaintext, err := gcm.Open(nil, nonce, ciphertext, nil); err == nil { + return plaintext, nil + } + } + return nil, ErrDecryptFailed } -// WrapDEK verpackt einen DEK mit dem Tenant-KEK (Envelope-Verfahren). -func WrapDEK(kek, dek []byte) ([]byte, error) { - wrapped, err := seal(kek, dek) +// WrapDEK verpackt einen DEK mit dem Tenant-KEK (Envelope-Verfahren). aad +// bindet den Wrapper an seinen Kontext (F-21). +func WrapDEK(kek, dek, aad []byte) ([]byte, error) { + wrapped, err := seal(kek, dek, aad) if err != nil { return nil, fmt.Errorf("crypto: dek verpacken: %w", err) } @@ -86,20 +96,20 @@ func WrapDEK(kek, dek []byte) ([]byte, error) { } // UnwrapDEK entpackt einen zuvor mit WrapDEK verpackten DEK. -func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) { - return open(kek, wrappedDEK) +func UnwrapDEK(kek, wrappedDEK, aad []byte) ([]byte, error) { + return open(kek, wrappedDEK, aad) } // RewrapDEK verpackt einen bereits vorhandenen DEK-Wrapper von oldKEK auf // newKEK um, OHNE den DEK selbst oder den Objekt-Chiffretext zu veraendern // (Akzeptanzkriterium 3: KEK-Rotation ohne Neuverschluesselung aller // Objekte — nur der DEK-Wrapper wird neu verpackt). -func RewrapDEK(oldKEK, newKEK, wrappedDEK []byte) ([]byte, error) { - dek, err := UnwrapDEK(oldKEK, wrappedDEK) +func RewrapDEK(oldKEK, newKEK, wrappedDEK, aad []byte) ([]byte, error) { + dek, err := UnwrapDEK(oldKEK, wrappedDEK, aad) if err != nil { return nil, fmt.Errorf("crypto: dek mit altem kek entpacken: %w", err) } - rewrapped, err := WrapDEK(newKEK, dek) + rewrapped, err := WrapDEK(newKEK, dek, aad) if err != nil { return nil, fmt.Errorf("crypto: dek mit neuem kek verpacken: %w", err) } @@ -112,12 +122,12 @@ func RewrapDEK(oldKEK, newKEK, wrappedDEK []byte) ([]byte, error) { // FDN-03, das S3-PutObject ebenfalls vollstaendig puffert) — fuer sehr // grosse Dateien waere ein segmentiertes AEAD-Verfahren noetig, das ist // bewusst nicht Teil der "kleinsten Loesung" dieser Kachel. -func EncryptStream(dek []byte, r io.Reader) (io.Reader, error) { +func EncryptStream(dek []byte, r io.Reader, aad []byte) (io.Reader, error) { plaintext, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("crypto: klartext lesen: %w", err) } - ciphertext, err := seal(dek, plaintext) + ciphertext, err := seal(dek, plaintext, aad) if err != nil { return nil, fmt.Errorf("crypto: verschluesseln: %w", err) } @@ -127,12 +137,12 @@ func EncryptStream(dek []byte, r io.Reader) (io.Reader, error) { // DecryptStream entschluesselt einen zuvor mit EncryptStream erzeugten // Chiffretext-Stream. Liefert ErrDecryptFailed bei manipuliertem Chiffretext // (Pruefung 2, GCM-Auth-Tag). -func DecryptStream(dek []byte, r io.Reader) (io.Reader, error) { +func DecryptStream(dek []byte, r io.Reader, aad []byte) (io.Reader, error) { ciphertext, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("crypto: chiffretext lesen: %w", err) } - plaintext, err := open(dek, ciphertext) + plaintext, err := open(dek, ciphertext, aad) if err != nil { return nil, err } diff --git a/dms/internal/crypto/envelope_test.go b/dms/internal/crypto/envelope_test.go index b1dc764c..682061d1 100644 --- a/dms/internal/crypto/envelope_test.go +++ b/dms/internal/crypto/envelope_test.go @@ -36,7 +36,7 @@ func TestEncryptDecryptStream_RoundTrip(t *testing.T) { } plaintext := []byte("Rechnung 2026-0001 — vertraulicher Inhalt") - ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext)) + ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext), nil) if err != nil { t.Fatalf("encryptstream: %v", err) } @@ -48,7 +48,7 @@ func TestEncryptDecryptStream_RoundTrip(t *testing.T) { t.Fatal("chiffretext ist identisch zum klartext - keine verschluesselung stattgefunden") } - decrypted, err := DecryptStream(dek, bytes.NewReader(ciphertextBytes)) + decrypted, err := DecryptStream(dek, bytes.NewReader(ciphertextBytes), nil) if err != nil { t.Fatalf("decryptstream: %v", err) } @@ -68,7 +68,7 @@ func TestDecryptStream_RejectsTamperedCiphertext(t *testing.T) { if err != nil { t.Fatalf("generatedek: %v", err) } - ciphertext, err := EncryptStream(dek, bytes.NewReader([]byte("geheimer inhalt"))) + ciphertext, err := EncryptStream(dek, bytes.NewReader([]byte("geheimer inhalt")), nil) if err != nil { t.Fatalf("encryptstream: %v", err) } @@ -79,7 +79,7 @@ func TestDecryptStream_RejectsTamperedCiphertext(t *testing.T) { // Ein Byte in der Mitte des Chiffretexts kippen (nach dem Nonce-Praefix). tampered[len(tampered)-1] ^= 0xFF - if _, err := DecryptStream(dek, bytes.NewReader(tampered)); !errors.Is(err, ErrDecryptFailed) { + if _, err := DecryptStream(dek, bytes.NewReader(tampered), nil); !errors.Is(err, ErrDecryptFailed) { t.Fatalf("manipulierter chiffretext: erwartet ErrDecryptFailed, habe %v", err) } } @@ -89,13 +89,13 @@ func TestDecryptStream_RejectsTamperedCiphertext(t *testing.T) { func TestDecryptStream_WrongKeyRejected(t *testing.T) { dek1, _ := GenerateDEK() dek2, _ := GenerateDEK() - ciphertext, err := EncryptStream(dek1, bytes.NewReader([]byte("inhalt"))) + ciphertext, err := EncryptStream(dek1, bytes.NewReader([]byte("inhalt")), nil) if err != nil { t.Fatalf("encryptstream: %v", err) } ciphertextBytes, _ := io.ReadAll(ciphertext) - if _, err := DecryptStream(dek2, bytes.NewReader(ciphertextBytes)); !errors.Is(err, ErrDecryptFailed) { + if _, err := DecryptStream(dek2, bytes.NewReader(ciphertextBytes), nil); !errors.Is(err, ErrDecryptFailed) { t.Fatalf("falscher schluessel: erwartet ErrDecryptFailed, habe %v", err) } } @@ -111,7 +111,7 @@ func TestWrapUnwrapDEK_RoundTrip(t *testing.T) { t.Fatalf("generatedek: %v", err) } - wrapped, err := WrapDEK(kek, dek) + wrapped, err := WrapDEK(kek, dek, nil) if err != nil { t.Fatalf("wrapdek: %v", err) } @@ -119,7 +119,7 @@ func TestWrapUnwrapDEK_RoundTrip(t *testing.T) { t.Fatal("wrapdek lieferte den unveraenderten dek") } - unwrapped, err := UnwrapDEK(kek, wrapped) + unwrapped, err := UnwrapDEK(kek, wrapped, nil) if err != nil { t.Fatalf("unwrapdek: %v", err) } @@ -140,20 +140,20 @@ func TestRewrapDEK_RotationKeepsObjectReadable(t *testing.T) { t.Fatalf("generatedek: %v", err) } plaintext := []byte("altes objekt, vor der rotation verschluesselt") - ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext)) + ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext), nil) if err != nil { t.Fatalf("encryptstream: %v", err) } ciphertextBytes, _ := io.ReadAll(ciphertext) - wrappedOld, err := WrapDEK(oldKEK, dek) + wrappedOld, err := WrapDEK(oldKEK, dek, nil) if err != nil { t.Fatalf("wrapdek (alt): %v", err) } // Rotation: NUR der DEK-Wrapper wird neu verpackt, der Chiffretext // (ciphertextBytes) wird nicht angefasst. - wrappedNew, err := RewrapDEK(oldKEK, newKEK, wrappedOld) + wrappedNew, err := RewrapDEK(oldKEK, newKEK, wrappedOld, nil) if err != nil { t.Fatalf("rewrapdek: %v", err) } @@ -163,17 +163,17 @@ func TestRewrapDEK_RotationKeepsObjectReadable(t *testing.T) { // Der alte KEK kann den NEUEN Wrapper nicht mehr entpacken (Rotation // war wirksam). - if _, err := UnwrapDEK(oldKEK, wrappedNew); !errors.Is(err, ErrDecryptFailed) { + if _, err := UnwrapDEK(oldKEK, wrappedNew, nil); !errors.Is(err, ErrDecryptFailed) { t.Fatal("alter kek konnte den nach der rotation neu verpackten dek weiterhin entpacken") } // Mit dem NEUEN KEK und dem neuen Wrapper ist das unveraenderte, alte // Objekt weiterhin vollstaendig lesbar. - dekAfterRotation, err := UnwrapDEK(newKEK, wrappedNew) + dekAfterRotation, err := UnwrapDEK(newKEK, wrappedNew, nil) if err != nil { t.Fatalf("unwrapdek (neu) nach rotation: %v", err) } - decrypted, err := DecryptStream(dekAfterRotation, bytes.NewReader(ciphertextBytes)) + decrypted, err := DecryptStream(dekAfterRotation, bytes.NewReader(ciphertextBytes), nil) if err != nil { t.Fatalf("decryptstream nach rotation: %v", err) } @@ -185,3 +185,46 @@ func TestRewrapDEK_RotationKeepsObjectReadable(t *testing.T) { t.Fatalf("altes objekt nach rotation = %q, want %q", got, plaintext) } } + +// TestWrapDEK_AADBindsContext ist F-21 Pruefung 1: ein mit AAD "tenant-a" +// verpackter DEK laesst sich NICHT mit AAD "tenant-b" entpacken (Austausch +// zwischen zwei Kontexten faellt auf), obwohl derselbe KEK verwendet wird. +func TestWrapDEK_AADBindsContext(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) + } + if _, err := UnwrapDEK(kek, wrapped, []byte("tenant-b")); !errors.Is(err, ErrDecryptFailed) { + t.Fatal("unwrapdek mit falschem aad haette fehlschlagen muessen (kontext-austausch nicht erkannt)") + } + 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: ein VOR +// dieser Aenderung ohne AAD versiegelter Datensatz bleibt mit dem neuen, +// AAD-bindenden UnwrapDEK weiterhin lesbar (Ruecklaufkompatibilitaet). +func TestUnwrapDEK_LegacyWithoutAADStillReadable(t *testing.T) { + kek := bytes.Repeat([]byte{0x04}, KEKSize) + dek, err := GenerateDEK() + if err != nil { + t.Fatalf("generatedek: %v", err) + } + legacyWrapped, err := WrapDEK(kek, dek, nil) // Alt-Datensatz: kein AAD. + if err != nil { + t.Fatalf("wrapdek (legacy): %v", err) + } + unwrapped, err := UnwrapDEK(kek, legacyWrapped, []byte("tenant-a")) + if err != nil { + t.Fatalf("legacy-datensatz ohne aad muss trotz neuem aad-parameter lesbar bleiben: %v", err) + } + if !bytes.Equal(unwrapped, dek) { + t.Fatal("legacy-datensatz lieferte falschen dek") + } +} diff --git a/dms/internal/crypto/service.go b/dms/internal/crypto/service.go index fb1584dc..514a6c9e 100644 --- a/dms/internal/crypto/service.go +++ b/dms/internal/crypto/service.go @@ -38,7 +38,8 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read if err != nil { return nil, err } - ciphertext, err := EncryptStream(dek, plaintext) + aad := []byte(tenantSlug) + ciphertext, err := EncryptStream(dek, plaintext, aad) if err != nil { return nil, err } @@ -46,7 +47,7 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read if err != nil { return nil, fmt.Errorf("%w: %w", ErrKEKUnavailable, err) } - wrappedDEK, err := WrapDEK(kek, dek) + wrappedDEK, err := WrapDEK(kek, dek, aad) if err != nil { return nil, err } @@ -54,17 +55,25 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read } // Open entpackt den DEK mit dem aktuellen Tenant-KEK und entschluesselt -// ciphertext damit. +// ciphertext damit. Das AAD bindet Wrapper und Chiffretext an tenantSlug +// (F-21): ein Austausch zwischen zwei Mandanten faellt beim Entschluesseln +// auf, weil der falsche Mandanten-KEK ohnehin nicht passt UND das AAD nicht +// uebereinstimmt. Ein Austausch zwischen zwei Datensaetzen DESSELBEN +// Mandanten bleibt unerkannt, da beide dasselbe AAD (den Mandanten-Slug) +// tragen wuerden — eine vollstaendige Bindung an den einzelnen Datensatz +// braeuchte eine Datensatz-ID an jeder der rund 30 Aufrufstellen, das ist +// bewusst nicht Teil dieser Kachel (siehe Pruefprotokoll). func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) { kek, err := s.kek.TenantKEK(ctx, tenantSlug) if err != nil { return nil, fmt.Errorf("%w: %w", ErrKEKUnavailable, err) } - dek, err := UnwrapDEK(kek, wrappedDEK) + aad := []byte(tenantSlug) + dek, err := UnwrapDEK(kek, wrappedDEK, aad) if err != nil { return nil, err } - return DecryptStream(dek, ciphertext) + return DecryptStream(dek, ciphertext, aad) } // NewDataKey erzeugt einen DEK und verpackt ihn mit dem aktuellen Tenant-KEK. @@ -79,7 +88,7 @@ func (s *Service) NewDataKey(ctx context.Context, tenantSlug string) (dek, wrapp if err != nil { return nil, nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err) } - wrapped, err = WrapDEK(kek, dek) + wrapped, err = WrapDEK(kek, dek, []byte(tenantSlug)) if err != nil { return nil, nil, err } @@ -92,5 +101,5 @@ func (s *Service) UnwrapDataKey(ctx context.Context, tenantSlug string, wrapped if err != nil { return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err) } - return UnwrapDEK(kek, wrapped) + return UnwrapDEK(kek, wrapped, []byte(tenantSlug)) } diff --git a/dms/internal/qa02/qa02_test.go b/dms/internal/qa02/qa02_test.go index 29711b5e..4c4c12e1 100644 --- a/dms/internal/qa02/qa02_test.go +++ b/dms/internal/qa02/qa02_test.go @@ -362,7 +362,7 @@ func TestKeyManagement_RewrapKeepsContentReadableWithNewKEKOnly(t *testing.T) { _ = s.e.Pool.QueryRow(ctx, `SELECT storage_key, wrapped_dek FROM file_revisions WHERE id = $1`, revID).Scan(&key, &wrapped) newKEK := bytes.Repeat([]byte{0x77}, dmscrypto.KEKSize) - rewrapped, err := dmscrypto.RewrapDEK(s.kek.kek, newKEK, wrapped) + rewrapped, err := dmscrypto.RewrapDEK(s.kek.kek, newKEK, wrapped, []byte(testenv.TenantSlug)) if err != nil { t.Fatalf("rewrap: %v", err) } diff --git a/internal/kek/master.go b/internal/kek/master.go index 5af8082a..3d7528ab 100644 --- a/internal/kek/master.go +++ b/internal/kek/master.go @@ -68,8 +68,11 @@ func generateRandomKey() ([]byte, error) { // wrap verschluesselt plaintext mit key via AES-256-GCM. Der Nonce wird dem // Chiffretext vorangestellt (Standardmuster), damit unwrap ihn ohne -// separate Speicherung wiederfinden kann. -func wrap(key, plaintext []byte) ([]byte, error) { +// separate Speicherung wiederfinden kann. aad (F-21) bindet den +// Chiffretext an seinen Kontext, damit ein Austausch zwischen zwei +// verschiedenen Geheimnissen beim Entschluesseln auffaellt statt still zu +// funktionieren. +func wrap(key, plaintext, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("aes-cipher erstellen: %w", err) @@ -82,7 +85,7 @@ func wrap(key, plaintext []byte) ([]byte, error) { if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("nonce erzeugen: %w", err) } - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nonce, nonce, plaintext, aad), nil } // ErrUnwrapFailed wird geliefert, wenn ein verpacktes Geheimnis nicht mit @@ -97,8 +100,8 @@ var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schlue // Aufrufer ausserhalb dieses Pakets (z.B. internal/ldapsync) MUESSEN // ausschliesslich diese Funktion (bzw. DecryptWithTenantKEK) fuer schuetzens- // werte Tenant-Geheimnisse verwenden statt eigener Krypto-Logik. -func EncryptWithTenantKEK(tenantKEK, plaintext []byte) ([]byte, error) { - return wrap(tenantKEK, plaintext) +func EncryptWithTenantKEK(tenantKEK, plaintext, aad []byte) ([]byte, error) { + return wrap(tenantKEK, plaintext, aad) } // DecryptWithTenantKEK entschluesselt einen mit EncryptWithTenantKEK @@ -106,11 +109,13 @@ func EncryptWithTenantKEK(tenantKEK, plaintext []byte) ([]byte, error) { // Tenant-KEK nicht zu diesem Chiffretext passt — insbesondere wenn der KEK // eines ANDEREN Mandanten verwendet wird (kein Cross-Tenant-Zugriff, // IAM-19 Akzeptanzkriterium 1 / Pruefung 3). -func DecryptWithTenantKEK(tenantKEK, ciphertext []byte) ([]byte, error) { - return unwrap(tenantKEK, ciphertext) +func DecryptWithTenantKEK(tenantKEK, ciphertext, aad []byte) ([]byte, error) { + return unwrap(tenantKEK, ciphertext, aad) } -func unwrap(key, wrapped []byte) ([]byte, error) { +// unwrap versucht bei gesetztem aad zuerst mit aad, dann (Ruecklaufkompatibilitaet +// fuer vor dieser Aenderung versiegelte Daten ohne AAD) ohne aad. +func unwrap(key, wrapped, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("aes-cipher erstellen: %w", err) @@ -123,9 +128,13 @@ func unwrap(key, wrapped []byte) ([]byte, error) { return nil, ErrUnwrapFailed } nonce, ciphertext := wrapped[:gcm.NonceSize()], wrapped[gcm.NonceSize():] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, ErrUnwrapFailed + if plaintext, err := gcm.Open(nil, nonce, ciphertext, aad); err == nil { + return plaintext, nil } - return plaintext, nil + if len(aad) > 0 { + if plaintext, err := gcm.Open(nil, nonce, ciphertext, nil); err == nil { + return plaintext, nil + } + } + return nil, ErrUnwrapFailed } diff --git a/internal/kek/store.go b/internal/kek/store.go index 1244e444..3b7e47a3 100644 --- a/internal/kek/store.go +++ b/internal/kek/store.go @@ -61,7 +61,7 @@ func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey if err != nil { return nil, err } - wrapped, err := wrap(masterKey, plainKEK) + wrapped, err := wrap(masterKey, plainKEK, []byte(tenantID)) if err != nil { return nil, fmt.Errorf("tenant-kek verpacken: %w", err) } @@ -91,7 +91,7 @@ func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey Mas } return nil, fmt.Errorf("tenant-kek lesen: %w", err) } - plainKEK, err := unwrap(masterKey, wrapped) + plainKEK, err := unwrap(masterKey, wrapped, []byte(tenantID)) if err != nil { return nil, err } @@ -133,7 +133,7 @@ func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey if err != nil { return nil, err } - wrapped, err := wrap(masterKey, newPlainKEK) + wrapped, err := wrap(masterKey, newPlainKEK, []byte(tenantID)) if err != nil { return nil, fmt.Errorf("neuen tenant-kek verpacken: %w", err) } @@ -189,12 +189,12 @@ func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) ( } for _, r := range all { - plainKEK, err := unwrap(oldKey, r.wrapped) + plainKEK, err := unwrap(oldKey, r.wrapped, []byte(r.tenantID)) if err != nil { failedTenantIDs = append(failedTenantIDs, r.tenantID) continue } - rewrapped, err := wrap(newKey, plainKEK) + rewrapped, err := wrap(newKey, plainKEK, []byte(r.tenantID)) if err != nil { failedTenantIDs = append(failedTenantIDs, r.tenantID) continue diff --git a/internal/ldapsync/config_store.go b/internal/ldapsync/config_store.go index af7a48cd..04e93efc 100644 --- a/internal/ldapsync/config_store.go +++ b/internal/ldapsync/config_store.go @@ -60,7 +60,7 @@ func (s *ConfigStore) Save(ctx context.Context, cfg BindConfig) error { return fmt.Errorf("tenant-kek fuer ldap-bind-konfiguration lesen: %w", err) } - ciphertext, err := kek.EncryptWithTenantKEK(tenantKEK, []byte(cfg.Password)) + ciphertext, err := kek.EncryptWithTenantKEK(tenantKEK, []byte(cfg.Password), []byte(s.tenantID+":ldapsync-bind-password")) if err != nil { return fmt.Errorf("ldap-bind-passwort verschluesseln: %w", err) } @@ -110,7 +110,7 @@ func (s *ConfigStore) Get(ctx context.Context) (BindConfig, error) { return BindConfig{}, fmt.Errorf("tenant-kek fuer ldap-bind-konfiguration lesen: %w", err) } - plainPassword, err := kek.DecryptWithTenantKEK(tenantKEK, ciphertext) + plainPassword, err := kek.DecryptWithTenantKEK(tenantKEK, ciphertext, []byte(s.tenantID+":ldapsync-bind-password")) if err != nil { return BindConfig{}, fmt.Errorf("ldap-bind-passwort entschluesseln: %w", err) } diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go index 435c1b0e..67437ff2 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/webhook/dispatcher.go @@ -114,7 +114,7 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) { // faelligen Zustellungen in diesem Batch nicht verhindern — dieselbe // Isolationsregel wie fuer Zustellfehler (siehe attemptOne). for _, r := range raw { - secret, err := kek.DecryptWithTenantKEK(d.secretKey, r.ciphertext) + secret, err := kek.DecryptWithTenantKEK(d.secretKey, r.ciphertext, []byte(r.del.TargetURL)) if err != nil { _, _ = tx.Exec(ctx, ` UPDATE webhook_deliveries SET status = $2, last_error = $3 diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 43866e4a..8f4648f3 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -77,7 +77,10 @@ func (s *Store) Subscribe(ctx context.Context, policy TargetPolicy, eventType, t if err := policy.ValidateTargetURL(ctx, targetURL); err != nil { return Subscription{}, err } - ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret)) + // AAD (F-21) bindet das verschluesselte Geheimnis an seine Ziel-URL: + // ein Austausch des Chiffretexts zwischen zwei Abonnements mit + // unterschiedlicher Ziel-URL faellt beim Entschluesseln auf. + ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret), []byte(targetURL)) if err != nil { return Subscription{}, fmt.Errorf("geheimnis verschluesseln: %w", err) } diff --git a/mail/internal/crypto/envelope.go b/mail/internal/crypto/envelope.go index 03ae6395..1f6dbb95 100644 --- a/mail/internal/crypto/envelope.go +++ b/mail/internal/crypto/envelope.go @@ -38,7 +38,12 @@ func GenerateDEK() ([]byte, error) { return dek, nil } -func seal(key, plaintext []byte) ([]byte, error) { +// seal/open binden aad (F-21) an den Chiffretext, damit ein Austausch +// verpackter Werte zwischen zwei Kontexten beim Entschluesseln auffaellt +// statt still zu funktionieren. open versucht bei gesetztem aad zuerst mit +// aad, dann (Ruecklaufkompatibilitaet fuer vor dieser Aenderung versiegelte +// Daten ohne AAD) ohne aad. +func seal(key, plaintext, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err) @@ -51,10 +56,10 @@ func seal(key, plaintext []byte) ([]byte, error) { if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("crypto: nonce erzeugen: %w", err) } - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nonce, nonce, plaintext, aad), nil } -func open(key, sealed []byte) ([]byte, error) { +func open(key, sealed, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err) @@ -67,23 +72,27 @@ func open(key, sealed []byte) ([]byte, error) { return nil, ErrDecryptFailed } nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, ErrDecryptFailed + if plaintext, err := gcm.Open(nil, nonce, ciphertext, aad); err == nil { + return plaintext, nil } - return plaintext, nil + if len(aad) > 0 { + if plaintext, err := gcm.Open(nil, nonce, ciphertext, nil); err == nil { + return plaintext, nil + } + } + return nil, ErrDecryptFailed } -func WrapDEK(kek, dek []byte) ([]byte, error) { - wrapped, err := seal(kek, dek) +func WrapDEK(kek, dek, aad []byte) ([]byte, error) { + wrapped, err := seal(kek, dek, aad) if err != nil { return nil, fmt.Errorf("crypto: dek verpacken: %w", err) } return wrapped, nil } -func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) { - return open(kek, wrappedDEK) +func UnwrapDEK(kek, wrappedDEK, aad []byte) ([]byte, error) { + return open(kek, wrappedDEK, aad) } // EncryptStream verschlüsselt den gesamten Inhalt von r mit dek @@ -91,12 +100,12 @@ func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) { // wie mail/internal/storage.S3Driver.Put (ARC-01), das S3-PutObject // ebenfalls vollständig puffert; ein segmentiertes AEAD-Verfahren für // sehr große Anhänge ist bewusst nicht Teil der "kleinsten Lösung". -func EncryptStream(dek []byte, r io.Reader) (io.Reader, error) { +func EncryptStream(dek []byte, r io.Reader, aad []byte) (io.Reader, error) { plaintext, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("crypto: klartext lesen: %w", err) } - ciphertext, err := seal(dek, plaintext) + ciphertext, err := seal(dek, plaintext, aad) if err != nil { return nil, fmt.Errorf("crypto: verschlüsseln: %w", err) } @@ -105,12 +114,12 @@ func EncryptStream(dek []byte, r io.Reader) (io.Reader, error) { // DecryptStream entschlüsselt einen zuvor mit EncryptStream erzeugten // Chiffretext-Stream. -func DecryptStream(dek []byte, r io.Reader) (io.Reader, error) { +func DecryptStream(dek []byte, r io.Reader, aad []byte) (io.Reader, error) { ciphertext, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("crypto: chiffretext lesen: %w", err) } - plaintext, err := open(dek, ciphertext) + plaintext, err := open(dek, ciphertext, aad) if err != nil { return nil, err } diff --git a/mail/internal/crypto/kekversions.go b/mail/internal/crypto/kekversions.go index a414eb2c..a15d78de 100644 --- a/mail/internal/crypto/kekversions.go +++ b/mail/internal/crypto/kekversions.go @@ -82,7 +82,7 @@ func (s *KEKVersionStore) RecordIfNew(ctx context.Context, tenantSlug string, pl return 0, fmt.Errorf("crypto: letzte kek-version lesen: %w", err) } - latestPlain, err := open(s.localWrapKey, latestWrapped) + latestPlain, err := open(s.localWrapKey, latestWrapped, []byte(tenantSlug)) if err != nil { return 0, fmt.Errorf("crypto: zwischengespeicherten kek entpacken: %w", err) } @@ -93,7 +93,7 @@ func (s *KEKVersionStore) RecordIfNew(ctx context.Context, tenantSlug string, pl } func (s *KEKVersionStore) insertVersion(ctx context.Context, tenantSlug string, version int, plainKEK []byte) (int, error) { - wrapped, err := seal(s.localWrapKey, plainKEK) + wrapped, err := seal(s.localWrapKey, plainKEK, []byte(tenantSlug)) if err != nil { return 0, fmt.Errorf("crypto: kek für zwischenspeicherung verpacken: %w", err) } @@ -124,7 +124,7 @@ func (s *KEKVersionStore) Get(ctx context.Context, tenantSlug string, version in if revoked { return nil, ErrKEKVersionRevoked } - return open(s.localWrapKey, wrapped) + return open(s.localWrapKey, wrapped, []byte(tenantSlug)) } // Revoke sperrt eine Tenant-KEK-Version gezielt (Pflichtprüfung 2): diff --git a/mail/internal/crypto/service.go b/mail/internal/crypto/service.go index c9884f35..22ee0b13 100644 --- a/mail/internal/crypto/service.go +++ b/mail/internal/crypto/service.go @@ -49,7 +49,8 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read if err != nil { return nil, err } - ciphertext, err := EncryptStream(dek, plaintext) + aad := []byte(tenantSlug) + ciphertext, err := EncryptStream(dek, plaintext, aad) if err != nil { return nil, err } @@ -57,7 +58,7 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read if err != nil { return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err) } - wrappedDEK, err := WrapDEK(kek, dek) + wrappedDEK, err := WrapDEK(kek, dek, aad) if err != nil { return nil, err } @@ -85,11 +86,12 @@ func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte if err != nil { return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err) } - dek, err := UnwrapDEK(kek, wrappedDEK) + aad := []byte(tenantSlug) + dek, err := UnwrapDEK(kek, wrappedDEK, aad) if err != nil { return nil, err } - return DecryptStream(dek, ciphertext) + return DecryptStream(dek, ciphertext, aad) } // OpenAtVersion entpackt den DEK mit der beim Seal aufgezeichneten @@ -106,9 +108,10 @@ func (s *Service) OpenAtVersion(ctx context.Context, tenantSlug string, kekVersi if err != nil { return nil, fmt.Errorf("crypto: historischen tenant-kek beziehen: %w", err) } - dek, err := UnwrapDEK(kek, wrappedDEK) + aad := []byte(tenantSlug) + dek, err := UnwrapDEK(kek, wrappedDEK, aad) if err != nil { return nil, err } - return DecryptStream(dek, ciphertext) + return DecryptStream(dek, ciphertext, aad) }