From bf7559e11864f85c7cad74c4b252db3d4b5fa4c0 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 29 Aug 2026 22:13:49 +0200 Subject: [PATCH] FDN-09: verschluesselung at rest & schluesselverwaltung Envelope-Encryption: DEK (32 Byte) frisch je Objekt, AES-256-GCM fuer Objektinhalt, DEK selbst mit Tenant-KEK verpackt (WrapDEK/UnwrapDEK). KEK kommt ausschliesslich ueber HTTPKEKProvider von Core API-10 (internal/kek.Handler.TenantKEKHandler-Vertrag, Service-Credential- authentifiziert), wird nie persistiert - jeder Seal/Open-Aufruf bezieht ihn frisch. RewrapDEK fuer KEK-Rotation ohne Neuverschluesselung der Objekte (nur der DEK-Wrapper wird neu verpackt, Chiffretext bleibt unveraendert). Auf 192.168.1.131 verifiziert: Round-Trip Encrypt/Decrypt, manipulierter Chiffretext UND falscher Schluessel werden beide ueber GCM-Auth-Tag abgelehnt, KEK-Rotation real getestet (RewrapDEK, altes Objekt danach mit neuem KEK weiterhin lesbar, alter KEK funktioniert nicht mehr). Befund dokumentiert: Cores internal/kek.Handler (Gegenstelle fuer den KEK-Bezug) ist noch in keinem cmd/*/main.go verdrahtet, dieselbe Fehlerklasse wie FDN-03/QA-05/AUD-06. HTTPKEKProvider daher gegen den dokumentierten Vertrag getestet, nicht gegen eine laufende Core-Instanz. Siehe dms/docs/FDN-09-PRUEFPROTOKOLL.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ --- dms/docs/FDN-09-PRUEFPROTOKOLL.md | 57 ++++++++ dms/internal/crypto/envelope.go | 140 ++++++++++++++++++ dms/internal/crypto/envelope_test.go | 187 ++++++++++++++++++++++++ dms/internal/crypto/kekprovider.go | 80 ++++++++++ dms/internal/crypto/kekprovider_test.go | 65 ++++++++ dms/internal/crypto/service.go | 65 ++++++++ dms/internal/crypto/service_test.go | 77 ++++++++++ 7 files changed, 671 insertions(+) create mode 100644 dms/docs/FDN-09-PRUEFPROTOKOLL.md create mode 100644 dms/internal/crypto/envelope.go create mode 100644 dms/internal/crypto/envelope_test.go create mode 100644 dms/internal/crypto/kekprovider.go create mode 100644 dms/internal/crypto/kekprovider_test.go create mode 100644 dms/internal/crypto/service.go create mode 100644 dms/internal/crypto/service_test.go diff --git a/dms/docs/FDN-09-PRUEFPROTOKOLL.md b/dms/docs/FDN-09-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..498889d --- /dev/null +++ b/dms/docs/FDN-09-PRUEFPROTOKOLL.md @@ -0,0 +1,57 @@ +# FDN-09 – Prüfprotokoll: Verschlüsselung at rest & Schlüsselverwaltung + +Welle 2. Voraussetzung: FDN-01 (Status "Fertig"), Core API-10 (Status +"Fertig"). + +## Umsetzung + +`internal/crypto`: + +- `GenerateDEK` — 32-Byte-Zufallsschlüssel je Objekt (Akzeptanzkriterium 1). +- `EncryptStream`/`DecryptStream` — AES-256-GCM auf dem Objektinhalt. +- `WrapDEK`/`UnwrapDEK` — Envelope-Verpackung des DEK mit dem Tenant-KEK. +- `RewrapDEK` — verpackt einen DEK-Wrapper von altem auf neuen KEK um, + ohne DEK oder Objekt-Chiffretext anzufassen (Akzeptanzkriterium 3). +- `HTTPKEKProvider` — bezieht den Tenant-KEK über Cores + `internal/kek.Handler.TenantKEKHandler` (API-10), Service-Credential- + authentifiziert (Akzeptanzkriterium 2: KEK kommt ausschließlich von Core, + wird hier nie persistiert — jeder `Seal`/`Open`-Aufruf bezieht ihn frisch). +- `Service` — verbindet `KEKProvider` mit den Envelope-Operationen + (`Seal`/`Open`), das ist die einzige öffentliche Schnittstelle, die + FDN-03/DOC-01 nutzen sollen. + +## Wichtiger Befund: Core-Endpunkt noch nicht live verdrahtet + +Dieselbe Fehlerklasse wie in FDN-03 (dort: `internal/resync.Handler`): +`internal/kek.Handler` (inkl. `TenantKEKHandler`) ist in Core vollständig +implementiert und eigenständig getestet, aber **in keinem `cmd/*/main.go` +registriert** (per `grep` bestätigt, Stand 2026-08-29). `HTTPKEKProvider` +ist daher gegen den **dokumentierten Vertrag** getestet (exakte +Feldnamen/Header/Query-Parameter aus `internal/kek/handler.go` gelesen), +nicht gegen eine echte laufende Core-Instanz. Empfehlung wie schon bei +FDN-03: Core-Board-Folgeticket analog `AUD-06`/dem FDN-03-Befund, das +sowohl den Resync- als auch den KEK-Endpunkt in einen laufenden Dienst +verdrahtet. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Round-Trip Encrypt/Decrypt liefert identischen Klartext | **bestanden** — `TestEncryptDecryptStream_RoundTrip`, `TestService_SealOpen_RoundTrip` | +| 2 | Manipulierter Chiffretext wird bei Decrypt erkannt und abgelehnt (GCM-Auth-Tag) | **bestanden** — `TestDecryptStream_RejectsTamperedCiphertext` (letztes Byte gekippt) UND `TestDecryptStream_WrongKeyRejected` (falscher Schlüssel, zweite mögliche Fehlerursache) | +| 3 | KEK-Rotation getestet, alte Objekte weiterhin lesbar | **bestanden** — `TestRewrapDEK_RotationKeepsObjectReadable`: Objekt vor Rotation verschlüsselt, `RewrapDEK` von altem auf neuen KEK, Chiffretext bleibt UNVERÄNDERT, alter KEK kann neuen Wrapper nicht mehr entpacken (Rotation wirksam), neuer KEK + neuer Wrapper entschlüsseln das unveränderte, alte Objekt korrekt | + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 5/5 Pakete ok, 0 Fehlschläge (10 neue crypto-Tests) +``` + +## Gesamtergebnis + +**Bestanden**, mit derselben dokumentierten Core-seitigen Abhängigkeit wie +FDN-03 (Abschnitt "Wichtiger Befund"). Alle drei Akzeptanzkriterien und +alle drei Pflichtprüfungen im Rahmen des DMS-seitigen Scopes erfüllt. diff --git a/dms/internal/crypto/envelope.go b/dms/internal/crypto/envelope.go new file mode 100644 index 0000000..ed21d2d --- /dev/null +++ b/dms/internal/crypto/envelope.go @@ -0,0 +1,140 @@ +// Package crypto implementiert FDN-09: Envelope-Encryption fuer Objekte +// at rest. Jedes Objekt bekommt einen eigenen, zufaelligen +// Datenverschluesselungsschluessel (DEK, Akzeptanzkriterium 1), der mit +// dem Tenant-Hauptschluessel (KEK) verpackt wird — der KEK selbst kommt +// AUSSCHLIESSLICH von Core API-10 (Akzeptanzkriterium 2), wird hier nie +// persistiert, nur fluechtig fuer eine Wrap-/Unwrap-Operation gehalten. +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "fmt" + "io" +) + +// DEKSize/KEKSize sind die geforderten Laengen fuer AES-256-GCM. +const ( + DEKSize = 32 + KEKSize = 32 +) + +// ErrDecryptFailed wird geliefert, wenn ein Chiffretext nicht entschluesselt +// werden kann — falscher Schluessel ODER manipulierte Daten (Pruefung 2: +// GCM-Auth-Tag erkennt Manipulation zuverlaessig, AEAD unterscheidet die +// beiden Ursachen bewusst nicht, um keine Seitenkanal-Information ueber +// "welcher Teil" falsch war preiszugeben). +var ErrDecryptFailed = errors.New("crypto: entschluesselung fehlgeschlagen (falscher schluessel oder manipulierte daten)") + +// GenerateDEK erzeugt einen neuen, zufaelligen Datenverschluesselungs- +// schluessel — fuer JEDES Objekt neu, nie wiederverwendet +// (Akzeptanzkriterium 1). +func GenerateDEK() ([]byte, error) { + dek := make([]byte, DEKSize) + if _, err := rand.Read(dek); err != nil { + return nil, fmt.Errorf("crypto: dek erzeugen: %w", err) + } + return dek, nil +} + +func seal(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: gcm erstellen: %w", err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("crypto: nonce erzeugen: %w", err) + } + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +func open(key, sealed []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: gcm erstellen: %w", err) + } + if len(sealed) < gcm.NonceSize() { + 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 + } + return plaintext, nil +} + +// WrapDEK verpackt einen DEK mit dem Tenant-KEK (Envelope-Verfahren). +func WrapDEK(kek, dek []byte) ([]byte, error) { + wrapped, err := seal(kek, dek) + if err != nil { + return nil, fmt.Errorf("crypto: dek verpacken: %w", err) + } + return wrapped, nil +} + +// UnwrapDEK entpackt einen zuvor mit WrapDEK verpackten DEK. +func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) { + return open(kek, wrappedDEK) +} + +// 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) + if err != nil { + return nil, fmt.Errorf("crypto: dek mit altem kek entpacken: %w", err) + } + rewrapped, err := WrapDEK(newKEK, dek) + if err != nil { + return nil, fmt.Errorf("crypto: dek mit neuem kek verpacken: %w", err) + } + return rewrapped, nil +} + +// EncryptStream verschluesselt den gesamten Inhalt von r mit dek +// (AES-256-GCM) und liefert den Chiffretext als Stream. Liest r vollstaendig +// in den Speicher (dasselbe Muster wie internal/storage.S3Driver.Put aus +// 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) { + plaintext, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("crypto: klartext lesen: %w", err) + } + ciphertext, err := seal(dek, plaintext) + if err != nil { + return nil, fmt.Errorf("crypto: verschluesseln: %w", err) + } + return bytes.NewReader(ciphertext), nil +} + +// 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) { + ciphertext, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("crypto: chiffretext lesen: %w", err) + } + plaintext, err := open(dek, ciphertext) + if err != nil { + return nil, err + } + return bytes.NewReader(plaintext), nil +} diff --git a/dms/internal/crypto/envelope_test.go b/dms/internal/crypto/envelope_test.go new file mode 100644 index 0000000..b1dc764 --- /dev/null +++ b/dms/internal/crypto/envelope_test.go @@ -0,0 +1,187 @@ +package crypto + +import ( + "bytes" + "errors" + "io" + "testing" +) + +// TestGenerateDEK_NeverReused ist Akzeptanzkriterium 1: DEK wird pro +// Objekt neu erzeugt, nie wiederverwendet. +func TestGenerateDEK_NeverReused(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 50; i++ { + dek, err := GenerateDEK() + if err != nil { + t.Fatalf("generatedek: %v", err) + } + if len(dek) != DEKSize { + t.Fatalf("dek-laenge = %d, want %d", len(dek), DEKSize) + } + key := string(dek) + if seen[key] { + t.Fatal("generatedek lieferte denselben schluessel zweimal") + } + seen[key] = true + } +} + +// TestEncryptDecryptStream_RoundTrip ist Pruefung 1: Round-Trip liefert +// identischen Klartext. +func TestEncryptDecryptStream_RoundTrip(t *testing.T) { + dek, err := GenerateDEK() + if err != nil { + t.Fatalf("generatedek: %v", err) + } + plaintext := []byte("Rechnung 2026-0001 — vertraulicher Inhalt") + + ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext)) + if err != nil { + t.Fatalf("encryptstream: %v", err) + } + ciphertextBytes, err := io.ReadAll(ciphertext) + if err != nil { + t.Fatalf("chiffretext lesen: %v", err) + } + if bytes.Equal(ciphertextBytes, plaintext) { + t.Fatal("chiffretext ist identisch zum klartext - keine verschluesselung stattgefunden") + } + + decrypted, err := DecryptStream(dek, bytes.NewReader(ciphertextBytes)) + if err != nil { + t.Fatalf("decryptstream: %v", err) + } + got, err := io.ReadAll(decrypted) + if err != nil { + t.Fatalf("klartext lesen: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("entschluesselter klartext = %q, want %q", got, plaintext) + } +} + +// TestDecryptStream_RejectsTamperedCiphertext ist Pruefung 2: manipulierter +// Chiffretext wird bei Decrypt erkannt und abgelehnt (GCM-Auth-Tag). +func TestDecryptStream_RejectsTamperedCiphertext(t *testing.T) { + dek, err := GenerateDEK() + if err != nil { + t.Fatalf("generatedek: %v", err) + } + ciphertext, err := EncryptStream(dek, bytes.NewReader([]byte("geheimer inhalt"))) + if err != nil { + t.Fatalf("encryptstream: %v", err) + } + tampered, err := io.ReadAll(ciphertext) + if err != nil { + t.Fatalf("chiffretext lesen: %v", err) + } + // 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) { + t.Fatalf("manipulierter chiffretext: erwartet ErrDecryptFailed, habe %v", err) + } +} + +// TestDecryptStream_WrongKeyRejected prueft den zweiten Fall, in dem +// Entschluesselung scheitern muss: falscher Schluessel statt Manipulation. +func TestDecryptStream_WrongKeyRejected(t *testing.T) { + dek1, _ := GenerateDEK() + dek2, _ := GenerateDEK() + ciphertext, err := EncryptStream(dek1, bytes.NewReader([]byte("inhalt"))) + if err != nil { + t.Fatalf("encryptstream: %v", err) + } + ciphertextBytes, _ := io.ReadAll(ciphertext) + + if _, err := DecryptStream(dek2, bytes.NewReader(ciphertextBytes)); !errors.Is(err, ErrDecryptFailed) { + t.Fatalf("falscher schluessel: erwartet ErrDecryptFailed, habe %v", err) + } +} + +// TestWrapUnwrapDEK_RoundTrip prueft das Envelope-Verpacken des DEK selbst. +func TestWrapUnwrapDEK_RoundTrip(t *testing.T) { + kek := make([]byte, KEKSize) + for i := range kek { + kek[i] = byte(i) + } + dek, err := GenerateDEK() + if err != nil { + t.Fatalf("generatedek: %v", err) + } + + wrapped, err := WrapDEK(kek, dek) + if err != nil { + t.Fatalf("wrapdek: %v", err) + } + if bytes.Equal(wrapped, dek) { + t.Fatal("wrapdek lieferte den unveraenderten dek") + } + + unwrapped, err := UnwrapDEK(kek, wrapped) + if err != nil { + t.Fatalf("unwrapdek: %v", err) + } + if !bytes.Equal(unwrapped, dek) { + t.Fatal("entpackter dek stimmt nicht mit original ueberein") + } +} + +// TestRewrapDEK_RotationKeepsObjectReadable ist Pruefung 3: KEK-Rotation +// getestet, alte Objekte weiterhin lesbar — OHNE dass der Chiffretext des +// Objekts selbst jemals angefasst wird (Akzeptanzkriterium 3). +func TestRewrapDEK_RotationKeepsObjectReadable(t *testing.T) { + oldKEK := bytes.Repeat([]byte{0x01}, KEKSize) + newKEK := bytes.Repeat([]byte{0x02}, KEKSize) + + dek, err := GenerateDEK() + if err != nil { + t.Fatalf("generatedek: %v", err) + } + plaintext := []byte("altes objekt, vor der rotation verschluesselt") + ciphertext, err := EncryptStream(dek, bytes.NewReader(plaintext)) + if err != nil { + t.Fatalf("encryptstream: %v", err) + } + ciphertextBytes, _ := io.ReadAll(ciphertext) + + wrappedOld, err := WrapDEK(oldKEK, dek) + 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) + if err != nil { + t.Fatalf("rewrapdek: %v", err) + } + if bytes.Equal(wrappedNew, wrappedOld) { + t.Fatal("rewrapdek lieferte denselben wrapper wie vor der rotation") + } + + // Der alte KEK kann den NEUEN Wrapper nicht mehr entpacken (Rotation + // war wirksam). + if _, err := UnwrapDEK(oldKEK, wrappedNew); !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) + if err != nil { + t.Fatalf("unwrapdek (neu) nach rotation: %v", err) + } + decrypted, err := DecryptStream(dekAfterRotation, bytes.NewReader(ciphertextBytes)) + if err != nil { + t.Fatalf("decryptstream nach rotation: %v", err) + } + got, err := io.ReadAll(decrypted) + if err != nil { + t.Fatalf("klartext lesen: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("altes objekt nach rotation = %q, want %q", got, plaintext) + } +} diff --git a/dms/internal/crypto/kekprovider.go b/dms/internal/crypto/kekprovider.go new file mode 100644 index 0000000..0dfcd52 --- /dev/null +++ b/dms/internal/crypto/kekprovider.go @@ -0,0 +1,80 @@ +package crypto + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// KEKProvider liefert den aktuellen, entschluesselten Tenant-KEK +// (Akzeptanzkriterium 2: KEK kommt ausschliesslich von Core API-10). +// Schmale Schnittstelle, damit Tests einen Fake statt eines echten +// HTTP-Aufrufs einsetzen koennen. +type KEKProvider interface { + TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) +} + +// tenantKEKResponse entspricht Core internal/kek.tenantKEKResponse +// (JSON-Vertrag: tenant_kek_base64) — dieselbe Struktur, hier gespiegelt, +// da DMS Cores internal/-Pakete nicht importieren kann. +type tenantKEKResponse struct { + TenantKEKBase64 string `json:"tenant_kek_base64"` +} + +// HTTPKEKProvider bezieht den Tenant-KEK ueber Cores KEK-Handler +// (internal/kek.Handler.TenantKEKHandler, API-10), authentifiziert ueber +// dasselbe Service-Credential-Verfahren wie jeder andere Modul-Core-Aufruf +// (API-02) — identisches Muster wie internal/storage.HTTPUsageReporter aus +// FDN-03. +type HTTPKEKProvider struct { + endpointURL string + clientID string + clientSecret string + httpClient *http.Client +} + +func NewHTTPKEKProvider(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPKEKProvider { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &HTTPKEKProvider{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient} +} + +func (p *HTTPKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) { + u, err := url.Parse(p.endpointURL) + if err != nil { + return nil, fmt.Errorf("crypto: kek-endpunkt-url ungueltig: %w", err) + } + q := u.Query() + q.Set("tenant", tenantSlug) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("crypto: kek-anfrage aufbauen: %w", err) + } + req.Header.Set("X-Nexarch-Client-Id", p.clientID) + req.Header.Set("X-Nexarch-Client-Secret", p.clientSecret) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("crypto: kek-anfrage senden: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crypto: kek-bezug von core abgelehnt: status %d", resp.StatusCode) + } + + var body tenantKEKResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("crypto: kek-antwort dekodieren: %w", err) + } + kek, err := base64.StdEncoding.DecodeString(body.TenantKEKBase64) + if err != nil { + return nil, fmt.Errorf("crypto: kek base64-dekodieren: %w", err) + } + return kek, nil +} diff --git a/dms/internal/crypto/kekprovider_test.go b/dms/internal/crypto/kekprovider_test.go new file mode 100644 index 0000000..8ac6f8a --- /dev/null +++ b/dms/internal/crypto/kekprovider_test.go @@ -0,0 +1,65 @@ +package crypto + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestHTTPKEKProvider_SendsCorrectContractToCore ist der Nachweis, dass +// HTTPKEKProvider exakt den Vertrag von Core internal/kek.Handler. +// TenantKEKHandler bedient (Service-Credential-Header, tenant-Query-Param, +// JSON-Feldname) — echte Vernetzung gegen einen laufenden Core-Prozess ist +// nicht Teil dieses Tests (siehe FDN-09-Pruefprotokoll: derselbe +// "nicht verdrahtet"-Befund wie bei internal/resync in FDN-03), daher hier +// gegen einen httptest-Server geprueft, der denselben Vertrag nachbildet. +func TestHTTPKEKProvider_SendsCorrectContractToCore(t *testing.T) { + wantKEK := make([]byte, KEKSize) + for i := range wantKEK { + wantKEK[i] = byte(i) + } + + var gotClientID, gotClientSecret, gotTenant string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClientID = r.Header.Get("X-Nexarch-Client-Id") + gotClientSecret = r.Header.Get("X-Nexarch-Client-Secret") + gotTenant = r.URL.Query().Get("tenant") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tenantKEKResponse{TenantKEKBase64: base64.StdEncoding.EncodeToString(wantKEK)}) + })) + defer srv.Close() + + provider := NewHTTPKEKProvider(srv.URL, "dms-service-client", "dms-service-secret", nil) + got, err := provider.TenantKEK(context.Background(), "acme") + if err != nil { + t.Fatalf("tenantkek: %v", err) + } + + if gotClientID != "dms-service-client" || gotClientSecret != "dms-service-secret" { + t.Fatalf("service-credential-header falsch: id=%q secret=%q", gotClientID, gotClientSecret) + } + if gotTenant != "acme" { + t.Fatalf("tenant-query-parameter = %q, want %q", gotTenant, "acme") + } + if len(got) != KEKSize || got[0] != wantKEK[0] { + t.Fatalf("erhaltener kek stimmt nicht mit erwartetem ueberein") + } +} + +// TestHTTPKEKProvider_RejectsNonOKStatus prueft, dass ein von Core +// abgelehnter Aufruf (z.B. Modul nicht fuer diesen Mandanten aktiv, +// ErrForbidden auf Core-Seite) als Fehler zurueckgegeben wird. +func TestHTTPKEKProvider_RejectsNonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden) + })) + defer srv.Close() + + provider := NewHTTPKEKProvider(srv.URL, "x", "y", nil) + if _, err := provider.TenantKEK(context.Background(), "fremder-tenant"); err == nil { + t.Fatal("erwartet fehler bei abgelehntem kek-bezug, habe nil") + } +} diff --git a/dms/internal/crypto/service.go b/dms/internal/crypto/service.go new file mode 100644 index 0000000..8ee965c --- /dev/null +++ b/dms/internal/crypto/service.go @@ -0,0 +1,65 @@ +package crypto + +import ( + "context" + "fmt" + "io" +) + +// Envelope ist das Ergebnis einer Seal-Operation: der Chiffretext-Stream +// plus der mit dem Tenant-KEK verpackte DEK, der zusammen mit dem Objekt +// persistiert werden muss (z.B. in file_revisions, FDN-02 — die konkrete +// Spalte ist nicht Bestandteil dieser Kachel, siehe "Nicht Bestandteil"). +type Envelope struct { + Ciphertext io.Reader + WrappedDEK []byte +} + +// Service verbindet KEKProvider mit den Envelope-Operationen — die +// Storage-Abstraktion (FDN-03) ruft ausschliesslich Service auf, nie die +// Einzelfunktionen aus envelope.go direkt. +type Service struct { + kek KEKProvider +} + +func NewService(kek KEKProvider) *Service { + return &Service{kek: kek} +} + +// Seal erzeugt einen neuen DEK (Akzeptanzkriterium 1), verschluesselt +// plaintext damit und verpackt den DEK mit dem aktuellen Tenant-KEK +// (Akzeptanzkriterium 2 — der KEK wird bei JEDEM Aufruf frisch von Core +// bezogen, nie zwischengespeichert). +func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Reader) (*Envelope, error) { + dek, err := GenerateDEK() + if err != nil { + return nil, err + } + ciphertext, err := EncryptStream(dek, plaintext) + if err != nil { + return nil, err + } + kek, err := s.kek.TenantKEK(ctx, tenantSlug) + if err != nil { + return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err) + } + wrappedDEK, err := WrapDEK(kek, dek) + if err != nil { + return nil, err + } + return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK}, nil +} + +// Open entpackt den DEK mit dem aktuellen Tenant-KEK und entschluesselt +// ciphertext damit. +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("crypto: tenant-kek beziehen: %w", err) + } + dek, err := UnwrapDEK(kek, wrappedDEK) + if err != nil { + return nil, err + } + return DecryptStream(dek, ciphertext) +} diff --git a/dms/internal/crypto/service_test.go b/dms/internal/crypto/service_test.go new file mode 100644 index 0000000..36a0194 --- /dev/null +++ b/dms/internal/crypto/service_test.go @@ -0,0 +1,77 @@ +package crypto + +import ( + "bytes" + "context" + "io" + "testing" +) + +// fakeKEKProvider liefert einen fest hinterlegten Tenant-KEK, ohne echten +// HTTP-Aufruf — fuer Tests, die Service isoliert vom Core-Kontrakt pruefen +// wollen (der Kontrakt selbst ist in kekprovider_test.go geprueft). +type fakeKEKProvider struct { + kek []byte + calls int +} + +func (f *fakeKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) { + f.calls++ + return f.kek, nil +} + +// TestService_SealOpen_RoundTrip ist der End-to-End-Nachweis fuer +// Pruefung 1 ueber Service statt die Einzelfunktionen. +func TestService_SealOpen_RoundTrip(t *testing.T) { + kekProvider := &fakeKEKProvider{kek: bytes.Repeat([]byte{0x07}, KEKSize)} + svc := NewService(kekProvider) + ctx := context.Background() + plaintext := []byte("vertraulicher dokumentinhalt") + + env, err := svc.Seal(ctx, "acme", bytes.NewReader(plaintext)) + if err != nil { + t.Fatalf("seal: %v", err) + } + ciphertextBytes, err := io.ReadAll(env.Ciphertext) + if err != nil { + t.Fatalf("chiffretext lesen: %v", err) + } + if bytes.Equal(ciphertextBytes, plaintext) { + t.Fatal("chiffretext ist identisch zum klartext") + } + if len(env.WrappedDEK) == 0 { + t.Fatal("erwartet nicht-leeren wrappeddek") + } + + decrypted, err := svc.Open(ctx, "acme", env.WrappedDEK, bytes.NewReader(ciphertextBytes)) + if err != nil { + t.Fatalf("open: %v", err) + } + got, err := io.ReadAll(decrypted) + if err != nil { + t.Fatalf("klartext lesen: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("entschluesselter klartext = %q, want %q", got, plaintext) + } +} + +// TestService_Seal_NeverPersistsKEK ist Akzeptanzkriterium 2 (struktureller +// Nachweis): Service haelt den KEK nicht ueber einen Aufruf hinaus fest — +// jeder Seal/Open-Aufruf bezieht ihn frisch ueber KEKProvider. +func TestService_Seal_NeverPersistsKEK(t *testing.T) { + kekProvider := &fakeKEKProvider{kek: bytes.Repeat([]byte{0x09}, KEKSize)} + svc := NewService(kekProvider) + ctx := context.Background() + + if _, err := svc.Seal(ctx, "acme", bytes.NewReader([]byte("a"))); err != nil { + t.Fatalf("seal 1: %v", err) + } + if _, err := svc.Seal(ctx, "acme", bytes.NewReader([]byte("b"))); err != nil { + t.Fatalf("seal 2: %v", err) + } + + if kekProvider.calls != 2 { + t.Fatalf("erwartet 2 kek-abrufe (einer je Seal-Aufruf, kein Caching), habe %d", kekProvider.calls) + } +}