Files
nexarch/archive/internal/wormstore/wormstore.go
T
sysops 8ff4e82d38 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
2026-08-30 14:19:23 +02:00

148 lines
5.3 KiB
Go

// 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
}