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
This commit is contained in:
sysops
2026-08-30 23:58:44 +02:00
parent ee98efb51e
commit cb5a9da701
6 changed files with 567 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
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)
}