- mail/internal/crypto: Envelope-Encryption (AES-256-GCM), DEK pro Objekt, HTTPKEKProvider bezieht Tenant-KEK ueber Core API-12 - bewaehrtes Muster aus DMS FDN-09, Neuimplementierung (Mail kann DMS nicht importieren) - mail/internal/encstorage: verbindet ARC-01 (storage.Service) mit ARC-02 (crypto.Service) OHNE eines der beiden zu aendern (kein Diff an mail/internal/storage/) - Put verschluesselt vor dem Schreiben, GetDecrypted nutzt ARC-01s Pruefsummenverifikation mit - 3 Tests real bestanden: Rohspeicher ohne Schluessel unlesbar, falscher Mandantenschluessel abgelehnt (ErrDecryptFailed), Performance (50x64KiB-Objekte in 910us/Objekt) - zusaetzlich echter End-zu-Ende-Beweis gegen den laufenden nexarch-kek-api.service (API-12): vollstaendiger Put->GetDecrypted- Roundtrip ueber echten HTTP-KEK-Bezug, nicht-existenter Tenant real abgelehnt (404) - offener Punkt ehrlich vermerkt: internal/crypto/internal/encstorage fehlen noch in QA-01s Pflichttest-Gate-Pfadmustern Pruefungen siehe mail/docs/ARC-02-PRUEFPROTOKOLL.md
139 lines
5.0 KiB
Go
139 lines
5.0 KiB
Go
package encstorage
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
|
||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||
)
|
||
|
||
type fakeUsageReporter struct{}
|
||
|
||
func (fakeUsageReporter) Report(_ context.Context, _, _ string, _ int64) error { return nil }
|
||
|
||
// fakeKEKProvider liefert einen festen, mandantenspezifischen KEK —
|
||
// simuliert Core API-12, ohne den echten Dienst für Unit-Tests zu
|
||
// brauchen. "richtiger-tenant" hat einen bekannten KEK, jeder andere
|
||
// Tenant-Slug liefert einen ANDEREN (falschen) KEK — genau der Fall,
|
||
// den Pflichtprüfung 2 testet.
|
||
type fakeKEKProvider struct {
|
||
keksByTenant map[string][]byte
|
||
}
|
||
|
||
func (f fakeKEKProvider) TenantKEK(_ context.Context, tenantSlug string) ([]byte, error) {
|
||
if kek, ok := f.keksByTenant[tenantSlug]; ok {
|
||
return kek, nil
|
||
}
|
||
// Unbekannter Tenant bekommt einen ANDEREN zufälligen KEK (nicht
|
||
// abgelehnt auf Core-Seite in diesem Fake, aber garantiert falsch —
|
||
// entspricht dem realen Fall, dass ein falscher Tenant-Slug einen
|
||
// anderen KEK liefert und die Entschlüsselung deshalb scheitert).
|
||
return bytes.Repeat([]byte{0xFF}, crypto.KEKSize), nil
|
||
}
|
||
|
||
func newTestServiceWithDir(t *testing.T) (*Service, string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
storageSvc := storage.NewService(storage.NewLocalDriver(dir), fakeUsageReporter{}, "acme")
|
||
kekProvider := fakeKEKProvider{keksByTenant: map[string][]byte{
|
||
"richtiger-tenant": bytes.Repeat([]byte{0x42}, crypto.KEKSize),
|
||
}}
|
||
cryptoSvc := crypto.NewService(kekProvider)
|
||
return NewService(storageSvc, cryptoSvc), dir
|
||
}
|
||
|
||
// TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent ist die
|
||
// geforderte Pflichtprüfung 1: Zugriff auf Rohspeicher ohne Schlüssel
|
||
// liefert keine lesbaren Inhalte.
|
||
func TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent(t *testing.T) {
|
||
svc, dir := newTestServiceWithDir(t)
|
||
ctx := context.Background()
|
||
key := storage.ObjectKey("msg-enc-1", 0)
|
||
plaintext := "STRENG GEHEIMER E-MAIL-INHALT, NICHT IM KLARTEXT AUF DER PLATTE"
|
||
|
||
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(plaintext), "text/plain"); err != nil {
|
||
t.Fatalf("put: %v", err)
|
||
}
|
||
|
||
// Direkter Dateisystemzugriff, UMGEHT den Service/die Entschlüsselung
|
||
// vollständig — simuliert "Rohspeicher ohne Schlüssel".
|
||
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(key)))
|
||
if err != nil {
|
||
t.Fatalf("rohdatei lesen: %v", err)
|
||
}
|
||
if bytes.Contains(raw, []byte(plaintext)) {
|
||
t.Fatalf("klartext ist unverschlüsselt im rohspeicher auffindbar: %q enthält %q", raw, plaintext)
|
||
}
|
||
if bytes.Contains(raw, []byte("STRENG GEHEIMER")) || bytes.Contains(raw, []byte("GEHEIM")) {
|
||
t.Fatal("erkennbare klartext-fragmente im rohspeicher gefunden")
|
||
}
|
||
}
|
||
|
||
// TestGetDecrypted_WrongTenantKeyDeniesDecryption ist die geforderte
|
||
// Pflichtprüfung 2: falscher Mandantenschlüssel verweigert
|
||
// Entschlüsselung.
|
||
func TestGetDecrypted_WrongTenantKeyDeniesDecryption(t *testing.T) {
|
||
svc, _ := newTestServiceWithDir(t)
|
||
ctx := context.Background()
|
||
key := storage.ObjectKey("msg-enc-2", 0)
|
||
plaintext := "inhalt fuer richtiger-tenant"
|
||
|
||
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(plaintext), "text/plain"); err != nil {
|
||
t.Fatalf("put: %v", err)
|
||
}
|
||
|
||
// Korrekter Tenant kann entschlüsseln.
|
||
got, err := svc.GetDecrypted(ctx, "richtiger-tenant", key)
|
||
if err != nil {
|
||
t.Fatalf("getdecrypted (richtiger tenant): %v", err)
|
||
}
|
||
if string(got) != plaintext {
|
||
t.Fatalf("falscher inhalt: %q", got)
|
||
}
|
||
|
||
// FALSCHER Mandant (anderer Tenant-Slug -> anderer KEK) muss
|
||
// abgelehnt werden.
|
||
_, err = svc.GetDecrypted(ctx, "falscher-tenant", key)
|
||
if !errors.Is(err, crypto.ErrDecryptFailed) {
|
||
t.Fatalf("erwartet ErrDecryptFailed bei falschem mandantenschlüssel, habe: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestPut_AcceptableEncryptionOverhead ist die geforderte
|
||
// Pflichtprüfung 3: Performance-Test bestätigt akzeptablen Overhead
|
||
// durch Verschlüsselung.
|
||
func TestPut_AcceptableEncryptionOverhead(t *testing.T) {
|
||
svc, _ := newTestServiceWithDir(t)
|
||
ctx := context.Background()
|
||
|
||
// ~64 KiB Nutzlast, realistische Anhanggröße.
|
||
payload := strings.Repeat("x", 64*1024)
|
||
|
||
const count = 50
|
||
start := time.Now()
|
||
for i := 0; i < count; i++ {
|
||
key := storage.ObjectKey("msg-perf", i)
|
||
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(payload), "application/octet-stream"); err != nil {
|
||
t.Fatalf("put #%d: %v", i, err)
|
||
}
|
||
}
|
||
elapsed := time.Since(start)
|
||
avgPerObject := elapsed / count
|
||
|
||
// Großzügige Grenze (50ms je 64-KiB-Objekt inkl. AES-GCM,
|
||
// Prüfsumme, Sidecar-Schreiben, Rücklese-Verifikation aus ARC-01) —
|
||
// Ziel: Nachweis, dass Verschlüsselung keinen unverhältnismäßigen
|
||
// Overhead verursacht, nicht ein knallhartes SLA.
|
||
if avgPerObject > 50*time.Millisecond {
|
||
t.Fatalf("erwartet akzeptablen verschlüsselungs-overhead (<50ms/64KiB-objekt), habe %v/objekt (gesamt %v für %d objekte)", avgPerObject, elapsed, count)
|
||
}
|
||
t.Logf("Verschlüsselungs-Performance: %d×64KiB-Objekte in %v (%v/Objekt)", count, elapsed, avgPerObject)
|
||
}
|