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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bfea94b032
commit
bf7559e118
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user