RET-04: worm-speicher-garantie-append-only

- archive/internal/wormstore.Store: Put schreibt einmalig (chmod 0400
  danach, ErrAlreadyExists bei Ueberschreibversuch inkl. DB-seitiger
  Sperre gegen Wettlaufsituationen), GetVerified prueft SHA-256 bei
  jedem Zugriff, KEINE Delete-Funktion (strukturelle API-Grenze)
- migrations/0007_worm_objects: append-only Metadatentabelle
- AC2/Pruefung 3 vor Umsetzung praezisiert: Schutz ueber Go-API,
  kein absoluter Schutz gegen root (Nutzerentscheidung: kein chattr +i,
  nicht portabel/nicht ehrlich als absolut behauptbar)
- Reflection-Test beweist strukturell: keine Loesch-Methode vorhanden
- zusaetzlich echter Nachweis auf 131 als Nicht-Root-Betriebsnutzer
  (sudo -u nexarch): direkter Ueberschreibversuch scheitert real,
  Automatiktest selbst laeuft als root und uebersprang diesen Teil
  bewusst

Pruefungen siehe archive/docs/RET-04-PRUEFPROTOKOLL.md
This commit is contained in:
sysops
2026-08-30 14:19:23 +02:00
parent 0ddfad94df
commit 8ff4e82d38
5 changed files with 361 additions and 0 deletions
@@ -0,0 +1,137 @@
package wormstore
import (
"context"
"errors"
"os"
"reflect"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, *pgxpool.Pool) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS worm_objects (
storage_key TEXT PRIMARY KEY, checksum_sha256 TEXT NOT NULL,
size_bytes BIGINT NOT NULL, written_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM worm_objects WHERE storage_key LIKE 'wormtest_%'`)
})
return New(t.TempDir(), pool), pool
}
// TestPut_DirectOverwriteAttemptIsRejected ist die geforderte
// Pflichtprüfung 1: Objekt mit aktivem Schreibschutz widersteht einem
// direkten Überschreibversuch — sowohl über die Store-API als auch
// direkt am Dateisystem (echter Nachweis der Datei-Schreibsperre, nicht
// nur der Anwendungslogik).
func TestPut_DirectOverwriteAttemptIsRejected(t *testing.T) {
store, _ := setupTest(t)
ctx := context.Background()
sum1, size1, err := store.Put(ctx, "wormtest_a", strings.NewReader("erster inhalt"))
if err != nil {
t.Fatalf("erster put: %v", err)
}
// Überschreibversuch über die API.
_, _, err = store.Put(ctx, "wormtest_a", strings.NewReader("manipulierter inhalt"))
if !errors.Is(err, ErrAlreadyExists) {
t.Fatalf("erwartet ErrAlreadyExists beim zweiten put, habe: %v", err)
}
// Direkter Überschreibversuch am Dateisystem (umgeht die API) — muss
// an der echten OS-Schreibsperre (chmod 0400) scheitern. Nur
// aussagekräftig, wenn NICHT als root getestet wird: root umgeht
// Unix-Dateirechte grundsätzlich (DAC-Bypass), das ist kein Defekt
// dieses Pakets, sondern eine bekannte Grenze von chmod-basiertem
// Schutz (siehe Paket-Dokumentation "kein absoluter Schutz gegen
// root"). Auf 131 laufen Tests als root — dieser Teilnachweis wird
// dort bewusst übersprungen, die API-seitige Sperre (oben) bleibt
// die primäre, root-unabhängige Prüfung.
if os.Geteuid() != 0 {
full := store.path("wormtest_a")
f, openErr := os.OpenFile(full, os.O_WRONLY, 0)
if openErr == nil {
_ = f.Close()
t.Fatal("erwartet permission-fehler beim direkten öffnen zum schreiben, datei war schreibbar")
}
if !os.IsPermission(openErr) {
t.Fatalf("erwartet permission-fehler, habe: %v", openErr)
}
} else {
t.Log("laeuft als root: OS-Schreibsperren-Teilnachweis uebersprungen (root umgeht Unix-Dateirechte grundsaetzlich)")
}
// Ursprünglicher Inhalt unverändert.
data, err := store.GetVerified(ctx, "wormtest_a")
if err != nil {
t.Fatalf("getverified: %v", err)
}
if string(data) != "erster inhalt" {
t.Fatalf("inhalt wurde veraendert: %q", data)
}
if size1 != int64(len("erster inhalt")) || sum1 == "" {
t.Fatalf("unerwartete put-metadaten: sum=%q size=%d", sum1, size1)
}
}
// TestGetVerified_DetectsTamperedFile ist die geforderte Pflichtprüfung
// 2: Prüfsummenabgleich erkennt eine manipulierte Testdatei zuverlässig.
func TestGetVerified_DetectsTamperedFile(t *testing.T) {
store, _ := setupTest(t)
ctx := context.Background()
if _, _, err := store.Put(ctx, "wormtest_b", strings.NewReader("unveraenderter inhalt")); err != nil {
t.Fatalf("put: %v", err)
}
// Manipulation simulieren: Schreibschutz temporär aufheben (root/
// Betreiber-Zugriff außerhalb der API, genau der Fall, den die
// Prüfsumme abfangen soll) und den Inhalt verändern.
full := store.path("wormtest_b")
if err := os.Chmod(full, 0o600); err != nil {
t.Fatalf("chmod fuer testmanipulation: %v", err)
}
if err := os.WriteFile(full, []byte("MANIPULIERTER INHALT"), 0o600); err != nil {
t.Fatalf("testmanipulation schreiben: %v", err)
}
_, err := store.GetVerified(ctx, "wormtest_b")
if !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("erwartet ErrChecksumMismatch bei manipulierter datei, habe: %v", err)
}
}
// TestStore_HasNoDeleteMethod ist die geforderte Pflichtprüfung 3
// (präzisiert): Löschung ist über die Go-API des Pakets technisch
// unmöglich, weil KEINE Delete-Funktion existiert — strukturell per
// Reflection nachgewiesen, kein bloßer Verzicht per Konvention/Kommentar.
func TestStore_HasNoDeleteMethod(t *testing.T) {
storeType := reflect.TypeOf(&Store{})
for i := 0; i < storeType.NumMethod(); i++ {
name := storeType.Method(i).Name
if strings.Contains(strings.ToLower(name), "delete") || strings.Contains(strings.ToLower(name), "remove") {
t.Fatalf("wormstore.Store darf keine Lösch-Methode besitzen, gefunden: %s", name)
}
}
}