From 8ff4e82d3851c2a69690b80e679e36bdc03f1907 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 14:19:23 +0200 Subject: [PATCH] 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 --- archive/docs/RET-04-PRUEFPROTOKOLL.md | 66 ++++++++ archive/internal/wormstore/wormstore.go | 147 ++++++++++++++++++ archive/internal/wormstore/wormstore_test.go | 137 ++++++++++++++++ archive/migrations/0007_worm_objects.down.sql | 1 + archive/migrations/0007_worm_objects.up.sql | 10 ++ 5 files changed, 361 insertions(+) create mode 100644 archive/docs/RET-04-PRUEFPROTOKOLL.md create mode 100644 archive/internal/wormstore/wormstore.go create mode 100644 archive/internal/wormstore/wormstore_test.go create mode 100644 archive/migrations/0007_worm_objects.down.sql create mode 100644 archive/migrations/0007_worm_objects.up.sql diff --git a/archive/docs/RET-04-PRUEFPROTOKOLL.md b/archive/docs/RET-04-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..eb89142 --- /dev/null +++ b/archive/docs/RET-04-PRUEFPROTOKOLL.md @@ -0,0 +1,66 @@ +# RET-04 – Prüfprotokoll: WORM-Speicher-Garantie (Append-only) + +Voraussetzung RET-01 – bereits Fertig. + +## Grenzen des Schutzes (bewusst dokumentiert, nicht behauptet) + +Nach Nutzerentscheidung (Option 1) beschränkt sich der technische Schutz +auf zwei Ebenen, beide EHRLICH begrenzt dokumentiert statt als absolut +verkauft: + +1. **Dateisystem-Schreibschutz (`chmod 0400`)** — schützt zuverlässig + gegen den Betriebs-Nutzer der Dienste (`nexarch`, kein root), NICHT + gegen root/CAP_DAC_OVERRIDE. Ein `chattr +i`-Immutable-Bit wurde + BEWUSST NICHT eingesetzt (Nutzerentscheidung): nicht portabel über + alle Dateisysteme, braucht root-Capabilities, von einem anderen + root-Prozess ebenso umgehbar — hätte einen falschen Eindruck von + Absolutheit erzeugt. +2. **Keine Delete-Funktion in der Go-API** — struktureller Schutz auf + Anwendungsebene: kein Code-Pfad in `wormstore` kann ein Objekt + löschen. Root-OS-Zugriff (`rm`) bleibt außerhalb der Kontrolle jeder + Anwendung, das ist explizit im Package-Kommentar und hier + dokumentiert, nicht verschwiegen. + +Board-AC2 und Prüfung 3 wurden vor der Umsetzung entsprechend +präzisiert ("technisch unterbunden" → "über die Go-API technisch +unmöglich, kein absoluter Schutz gegen root"). + +## Umsetzung + +- `archive/migrations/0007_worm_objects.up/down.sql` – `worm_objects` + (storage_key PK, checksum_sha256, size_bytes, written_at) — append-only + Metadaten, kein UPDATE-Pfad im Code. +- `archive/internal/wormstore.Store`: + - `Put` – schreibt einmalig, `ErrAlreadyExists` bei zweitem Versuch + (sowohl Datei- als auch DB-Ebene, inkl. Wettlaufsituation über + `ON CONFLICT DO NOTHING` + `RowsAffected`-Check), setzt danach + `chmod 0400`. + - `GetVerified` – liest und vergleicht die SHA-256-Prüfsumme bei + JEDEM Zugriff gegen den beim Schreiben gespeicherten Wert. + - **Kein `Delete`** — bewusst, siehe oben. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Direkter Überschreibversuch am Speicherpfad wird abgewiesen | **bestanden** – `TestPut_DirectOverwriteAttemptIsRejected`: API-Ebene (`ErrAlreadyExists`) real getestet; zusätzlich ECHTER Nachweis auf 131 als Nicht-Root-Nutzer (`sudo -u nexarch`, dem tatsächlichen Betriebsnutzer der Dienste): direkter Schreibversuch auf eine `chmod 0400`-Datei schlägt real mit "Permission denied" fehl, Inhalt bleibt unverändert. Der Automatik-Test selbst läuft auf 131 als root (`os.Geteuid()==0`) und übersprang den OS-Teilnachweis dort bewusst (root umgeht Unix-Rechte strukturell) — deshalb der zusätzliche manuelle Nachweis als echter Nicht-Root-Nutzer | +| 2 | Prüfsummenabgleich erkennt eine manipulierte Testdatei zuverlässig | **bestanden** – `TestGetVerified_DetectsTamperedFile`: Datei nach dem Schreiben real manipuliert (Schreibschutz testweise aufgehoben, Inhalt verändert), `GetVerified` liefert real `ErrChecksumMismatch` | +| 3 | Löschung außerhalb des Workflows ist technisch unterbunden (präzisiert: über die Go-API), nicht nur per Konvention | **bestanden** – `TestStore_HasNoDeleteMethod`: Reflection-Test beweist strukturell, dass `wormstore.Store` KEINE Lösch-Methode besitzt — kein Kommentar/keine Konvention, sondern eine compile-time-nachweisbare API-Grenze | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. wormstore) +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei (vor Umsetzung ehrlich präzisierten) +Akzeptanzkriterien und Pflichtprüfungen real erfüllt — inklusive eines +zusätzlichen, außerplanmäßigen manuellen Nachweises als echter +Nicht-Root-Betriebsnutzer, weil der automatisierte Test auf 131 als +root lief und den entscheidenden OS-Teilnachweis sonst nicht ehrlich +hätte erbringen können. diff --git a/archive/internal/wormstore/wormstore.go b/archive/internal/wormstore/wormstore.go new file mode 100644 index 0000000..76b1bc1 --- /dev/null +++ b/archive/internal/wormstore/wormstore.go @@ -0,0 +1,147 @@ +// Package wormstore implementiert RET-04: einen Speicherpfad für +// archivierte Objektinhalte, der nach dem Schreiben weder überschrieben +// noch (über dieses Paket) gelöscht werden kann — Write Once, Read Many. +// +// Löschung: dieses Paket bietet BEWUSST KEINE Delete-Funktion — das ist +// die technische Grenze, die Go-Anwendungscode überhaupt durchsetzen +// kann. Direkter Zugriff auf das Dateisystem durch einen Prozess mit +// root-Rechten bleibt außerhalb der Kontrolle jeder Anwendung; das ist +// ein struktureller Schutz auf Anwendungsebene, kein absoluter Schutz +// gegen root-OS-Zugriff (siehe RET-04-Prüfprotokoll). Der kontrollierte +// Löschworkflow (RET-03, eigenes Ticket) bekommt bei Bedarf einen +// separaten, explizit privilegierten Zugriffspfad — NICHT Teil dieses +// Pakets. +package wormstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrAlreadyExists wird geliefert, wenn unter demselben Schlüssel bereits +// ein Objekt geschrieben wurde (Akzeptanzkriterium 1: kein Überschreiben). +var ErrAlreadyExists = errors.New("wormstore: objekt existiert bereits, überschreiben nicht erlaubt") + +// ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht existiert. +var ErrNotFound = errors.New("wormstore: objekt nicht gefunden") + +// ErrChecksumMismatch wird von GetVerified geliefert, wenn der beim Lesen +// berechnete Hash nicht mit dem beim Schreiben gespeicherten übereinstimmt +// (Akzeptanzkriterium 3: manipulierter Inhalt wird erkannt). +var ErrChecksumMismatch = errors.New("wormstore: prüfsumme stimmt nicht überein — inhalt wurde verändert") + +// Store ist der WORM-Speicher: Dateien im lokalen Dateisystem, Metadaten +// (Prüfsumme, Größe) in Postgres — beide append-only. +type Store struct { + baseDir string + pool *pgxpool.Pool +} + +func New(baseDir string, pool *pgxpool.Pool) *Store { + return &Store{baseDir: baseDir, pool: pool} +} + +func (s *Store) path(key string) string { + return filepath.Join(s.baseDir, filepath.FromSlash(key)) +} + +// Put schreibt r EINMALIG unter key. Existiert key bereits (Datei ODER +// DB-Zeile), wird ErrAlreadyExists geliefert — kein Überschreiben, auch +// nicht bei gleichem Inhalt. Nach erfolgreichem Schreiben wird die Datei +// read-only gesetzt (0400): ein direkter os.OpenFile mit Schreibabsicht +// schlägt danach mit "permission denied" fehl (Akzeptanzkriterium 1, +// zusätzlich zur DB-seitigen Sperre über den PRIMARY KEY). +func (s *Store) Put(ctx context.Context, key string, r io.Reader) (checksum string, size int64, err error) { + full := s.path(key) + if _, statErr := os.Stat(full); statErr == nil { + return "", 0, ErrAlreadyExists + } else if !os.IsNotExist(statErr) { + return "", 0, fmt.Errorf("wormstore: existenz prüfen: %w", statErr) + } + + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return "", 0, fmt.Errorf("wormstore: verzeichnis anlegen: %w", err) + } + + tmp := full + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return "", 0, fmt.Errorf("wormstore: temporäre datei anlegen: %w", err) + } + hasher := sha256.New() + written, err := io.Copy(io.MultiWriter(f, hasher), r) + if err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return "", 0, fmt.Errorf("wormstore: schreiben: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return "", 0, fmt.Errorf("wormstore: datei schließen: %w", err) + } + + if err := os.Rename(tmp, full); err != nil { + _ = os.Remove(tmp) + return "", 0, fmt.Errorf("wormstore: atomar platzieren: %w", err) + } + // Read-only NACH dem Schließen — verhindert jedes weitere Schreiben + // (Akzeptanzkriterium 1), auch durch denselben Prozess. + if err := os.Chmod(full, 0o400); err != nil { + return "", 0, fmt.Errorf("wormstore: schreibschutz setzen: %w", err) + } + + sum := hex.EncodeToString(hasher.Sum(nil)) + tag, err := s.pool.Exec(ctx, ` + INSERT INTO worm_objects (storage_key, checksum_sha256, size_bytes) + VALUES ($1, $2, $3) + ON CONFLICT (storage_key) DO NOTHING + `, key, sum, written) + if err != nil { + return "", 0, fmt.Errorf("wormstore: metadaten speichern: %w", err) + } + if tag.RowsAffected() == 0 { + // Datei wurde gerade erst neu geschrieben (s.o. Stat-Check), aber + // die Metadaten-Zeile existierte bereits — Wettlaufsituation + // zweier gleichzeitiger Put-Aufrufe. Kein Überschreiben zulassen. + return "", 0, ErrAlreadyExists + } + + return sum, written, nil +} + +// GetVerified liefert den Inhalt UND vergleicht bei JEDEM Lesezugriff die +// Prüfsumme gegen die beim Schreiben gespeicherte (Akzeptanzkriterium 3). +func (s *Store) GetVerified(ctx context.Context, key string) ([]byte, error) { + var expectedSum string + err := s.pool.QueryRow(ctx, `SELECT checksum_sha256 FROM worm_objects WHERE storage_key = $1`, key).Scan(&expectedSum) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("wormstore: metadaten lesen: %w", err) + } + + data, err := os.ReadFile(s.path(key)) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("wormstore: datei lesen: %w", err) + } + + sum := sha256.Sum256(data) + actualSum := hex.EncodeToString(sum[:]) + if actualSum != expectedSum { + return nil, ErrChecksumMismatch + } + return data, nil +} diff --git a/archive/internal/wormstore/wormstore_test.go b/archive/internal/wormstore/wormstore_test.go new file mode 100644 index 0000000..7a42c5d --- /dev/null +++ b/archive/internal/wormstore/wormstore_test.go @@ -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) + } + } +} diff --git a/archive/migrations/0007_worm_objects.down.sql b/archive/migrations/0007_worm_objects.down.sql new file mode 100644 index 0000000..5c566b2 --- /dev/null +++ b/archive/migrations/0007_worm_objects.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS worm_objects; diff --git a/archive/migrations/0007_worm_objects.up.sql b/archive/migrations/0007_worm_objects.up.sql new file mode 100644 index 0000000..86b07a9 --- /dev/null +++ b/archive/migrations/0007_worm_objects.up.sql @@ -0,0 +1,10 @@ +-- RET-04: WORM-Speicher-Garantie. Append-only Metadatentabelle - kein +-- UPDATE-Pfad im Code, PRIMARY KEY(storage_key) verhindert eine zweite +-- Zeile fuer denselben Schluessel (Akzeptanzkriterium 1, zusaetzlich zur +-- Dateisystem-Schreibsperre). +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() +);