Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 bf7559e118 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-29 22:13:49 +02:00
7 changed files with 671 additions and 0 deletions
+57
View File
@@ -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.
+140
View File
@@ -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
}
+187
View File
@@ -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)
}
}
+80
View File
@@ -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
}
+65
View File
@@ -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")
}
}
+65
View File
@@ -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)
}
+77
View File
@@ -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)
}
}