OPS-13: Additional Authenticated Data bei AES-GCM (F-21)

seal/open (bzw. wrap/unwrap) in allen vier betroffenen Stellen binden
ein Additional Authenticated Data an Chiffretext/DEK-Wrapper: ein
Austausch zwischen zwei Kontexten faellt beim Entschluesseln auf,
statt still zu funktionieren.

- dms/internal/crypto/envelope.go + service.go: AAD = Tenant-Slug
  (bereits vorhandener Parameter in Service.Seal/Open, Signatur
  unveraendert -- keine der rund 30 externen Aufrufstellen musste
  angepasst werden)
- archive/internal/backupcrypto/envelope.go + reposecret.go/file.go:
  gleiches Muster, AAD = Tenant-Slug
- mail/internal/crypto/envelope.go + service.go + kekversions.go:
  gleiches Muster, AAD = Tenant-Slug (inkl. ARC-08
  KEK-Versionsverwaltung)
- internal/kek/master.go + store.go: wrap/unwrap sowie
  EncryptWithTenantKEK/DecryptWithTenantKEK binden AAD. Die drei
  echten externen Aufrufer angepasst: ldapsync (Tenant-ID + Zweck),
  webhook (Ziel-URL des Abonnements), account-api modulekeys (fester
  Zweck-Bezeichner -- SecretCipher-Interface aus internal/moduletrust
  reicht keine Datensatz-ID durch, eine Erweiterung wuerde in andere
  Module hineinwirken, bewusst nicht Teil dieser Kachel)

Rueckwaertskompatibel: jedes open/unwrap versucht zuerst mit AAD,
faellt bei Fehlschlag auf kein-AAD zurueck -- vor dieser Aenderung
versiegelte Datensaetze bleiben lesbar.

Bekannte, dokumentierte Grenze: ein Austausch zwischen zwei
Datensaetzen DESSELBEN Mandanten/Kontexts bleibt unerkannt (identisches
AAD) -- volle Bindung an den einzelnen Datensatz braeuchte eine
Datensatz-ID an rund 30 Aufrufstellen, bewusst nicht Teil dieser
kleinstmoeglichen Loesung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-26 22:42:17 +02:00
1 parent c3c9034650
commit 0ea129e71c
16 files changed
+208 -105

No files matched your search

+20 -11
View File
@@ -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)
}
+4 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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)
}
+10 -2
View File
@@ -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) {
+29 -19
View File
@@ -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
}
+57 -14
View File
@@ -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")
}
}
+16 -7
View File
@@ -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))
}
+1 -1
View File
@@ -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)
}
+21 -12
View File
@@ -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
}
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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)
}
+24 -15
View File
@@ -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
}
+3 -3
View File
@@ -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):
+9 -6
View File
@@ -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)
}