Files
nexarch/mail/internal/encstorage/encstorage_test.go
T
sysops cb5a9da701 ARC-02: verschluesselung-at-rest
- 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
2026-08-30 23:58:44 +02:00

139 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}