AUD-02: unveraenderliches-protokoll-append-only

Append-only per Trigger (nicht nur GRANT/REVOKE): audit_events_prevent_mutation()
wirft bei jedem UPDATE/DELETE auf audit_events eine Exception, unabhaengig
von der verbindenden Rolle (Akzeptanzkriterium 1).

internal/audit/four_eyes.go: Vier-Augen-Prinzip fuer sicherheitskritische
Entscheidungen (Loeschbestaetigung, Rechtevergabe), 1:1 nach archivdms-
Vorbild. Request erzeugt einen Klartext-Code (wird ausserhalb des Systems an
eine ZWEITE Person uebermittelt) und speichert nur dessen SHA-256-Hash.
Confirm sperrt die Zeile mit FOR UPDATE (Akzeptanzkriterium 2 — serialisiert
zwei gleichzeitige Bestaetigungsversuche, verhindert doppelte Ausfuehrung),
weist eine Bestaetigung durch dieselbe Person wie die anfordernde ab
(ErrSameActor, echtes Vier-Augen-Prinzip statt nur Code-Pruefung), und
vergleicht den Code timing-safe (Akzeptanzkriterium 3).

internal/audit/timingsafe.go: timingSafeEqual als projektweite Referenz-
implementierung (subtle.ConstantTimeCompare) fuer sicherheitsrelevante
Vergleiche — andere Module (z.B. Archive CMP-06 Freigabelinks) uebernehmen
dasselbe Muster laut IAM-02-Konvention.

Nebenbei behoben: AUD-01s eigener Test nutzte einen festen Tenant-Slug mit
DELETE-basiertem Cleanup — seit dem neuen Append-only-Trigger schlaegt dieses
Cleanup lautlos fehl, wodurch Zeilen sich ueber Testlaeufe hinweg summierten
und die Zaehl-Assertion brach. Auf eindeutigen Slug pro Lauf umgestellt
(direkte, notwendige Folge dieser Kachel, keine Umgestaltung von AUD-01
selbst).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Direkter UPDATE/DELETE-Versuch von der Datenbank abgewiesen —
   TestAppendOnly_RejectsUpdateAndDelete: beide Operationen scheitern,
   Eintrag bleibt unveraendert erhalten. PASS.
2. Vier-Augen-Prinzip mit FOR-UPDATE-Lock race-frei unter parallelen
   Anfragen — TestFourEyes_ConcurrentConfirmIsRaceFree: zwei gleichzeitige
   Bestaetigungsversuche fuer denselben Vorgang, genau einer erfolgreich,
   der andere ErrAlreadyDecided. PASS.
3. Timing-safe Vergleich per Laufzeitmessung stichprobenartig verifiziert —
   TestTimingSafeEqual_NoEarlyExitTiming: Mismatch am Anfang (603µs) vs. am
   Ende (574µs) ueber 20000 Iterationen, kein Hinweis auf Short-Circuit-
   Vergleich (Ratio innerhalb Faktor 3 Toleranz). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-08-27 19:57:51 +02:00
co-authored by Claude Sonnet 5
parent b12d53f469
commit 1bfb2efd94
10 changed files with 468 additions and 4 deletions
+62
View File
@@ -0,0 +1,62 @@
package audit
import (
"testing"
"time"
)
func TestTimingSafeEqual_Correctness(t *testing.T) {
a := hashCode("geheimnis-a")
b := hashCode("geheimnis-a")
c := hashCode("geheimnis-b")
if !timingSafeEqual(a, b) {
t.Fatal("identische hashes sollten gleich sein")
}
if timingSafeEqual(a, c) {
t.Fatal("unterschiedliche hashes sollten ungleich sein")
}
if timingSafeEqual(a, []byte("kuerzer")) {
t.Fatal("unterschiedliche laenge sollte ungleich sein")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Timing-safe Vergleich stichprobenartig
// per Laufzeitmessung verifiziert — ein Mismatch am Anfang darf nicht
// messbar schneller sein als ein Mismatch am Ende (klassisches Merkmal
// eines NICHT timing-safen Vergleichs wie bytes.Equal mit Short-Circuit).
func TestTimingSafeEqual_NoEarlyExitTiming(t *testing.T) {
reference := hashCode("referenzwert-fuer-timing-test")
mismatchAtStart := make([]byte, len(reference))
copy(mismatchAtStart, reference)
mismatchAtStart[0] ^= 0xFF
mismatchAtEnd := make([]byte, len(reference))
copy(mismatchAtEnd, reference)
mismatchAtEnd[len(mismatchAtEnd)-1] ^= 0xFF
const iterations = 20000
startDur := measure(iterations, func() { timingSafeEqual(reference, mismatchAtStart) })
endDur := measure(iterations, func() { timingSafeEqual(reference, mismatchAtEnd) })
t.Logf("mismatch am anfang: %s, mismatch am ende: %s (%d iterationen)", startDur, endDur, iterations)
ratio := float64(startDur) / float64(endDur)
// Grosszuegige Toleranz (Faktor 3), da es ein Stichprobentest auf einer
// geteilten Testmaschine ist, kein isolierter Benchmark — es geht darum,
// eine grobe Short-Circuit-Implementierung zuverlaessig aufzudecken
// (die haette typischerweise eine Groessenordnung Unterschied), nicht um
// kryptographisch praezise Constant-Time-Beweise.
if ratio > 3.0 || ratio < 1.0/3.0 {
t.Fatalf("timing-unterschied zu gross (verdacht auf short-circuit-vergleich): ratio=%.2f", ratio)
}
}
func measure(iterations int, fn func()) time.Duration {
start := time.Now()
for i := 0; i < iterations; i++ {
fn()
}
return time.Since(start)
}