Merge branch 'feature/aud-02-unveraenderliches-protokoll-append-only' into feature/qa-05-abnahme-compliance-pruefung-core
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAppendOnlyTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
||||||
|
actor TEXT NOT NULL CHECK (actor <> ''),
|
||||||
|
action TEXT NOT NULL CHECK (action <> ''),
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
CREATE OR REPLACE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events;
|
||||||
|
CREATE TRIGGER audit_events_no_update
|
||||||
|
BEFORE UPDATE ON audit_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||||
|
DROP TRIGGER IF EXISTS audit_events_no_delete ON audit_events;
|
||||||
|
CREATE TRIGGER audit_events_no_delete
|
||||||
|
BEFORE DELETE ON audit_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewLog(pool), pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: direkter UPDATE/DELETE-Versuch wird von
|
||||||
|
// der Datenbank abgewiesen.
|
||||||
|
func TestAppendOnly_RejectsUpdateAndDelete(t *testing.T) {
|
||||||
|
log, pool, cleanup := setupAppendOnlyTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Append-only bedeutet: dieser Testeintrag kann NIE wieder geloescht
|
||||||
|
// werden, auch nicht vom Test selbst. Eindeutiger Tenant-Slug pro Lauf,
|
||||||
|
// damit wiederholte Testlaeufe sich nicht gegenseitig die Zaehlung
|
||||||
|
// verfaelschen.
|
||||||
|
tenantSlug := fmt.Sprintf("test_appendonly_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
if err := log.Record(ctx, Event{
|
||||||
|
TenantSlug: tenantSlug,
|
||||||
|
Actor: "alice",
|
||||||
|
Action: "test.event",
|
||||||
|
Target: "x",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := pool.Exec(ctx, `UPDATE audit_events SET actor = 'mallory' WHERE tenant_slug = $1`, tenantSlug)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei UPDATE auf audit_events, habe nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug = $1`, tenantSlug)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei DELETE auf audit_events, habe nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := log.CountByTenant(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("eintrag haette trotz fehlgeschlagener update/delete-versuche erhalten bleiben muessen, count=%d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ package audit
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -48,8 +50,13 @@ func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Seit AUD-02 ist audit_events append-only — Zeilen koennen nie wieder
|
||||||
|
// geloescht werden (auch nicht vom Test-Cleanup). Eindeutiger Slug pro
|
||||||
|
// Lauf, damit wiederholte Testlaeufe die Zaehlung nicht verfaelschen.
|
||||||
|
tenantSlug := "test_acme_" + fmt.Sprint(time.Now().UnixNano())
|
||||||
|
|
||||||
err := log.Record(ctx, Event{
|
err := log.Record(ctx, Event{
|
||||||
TenantSlug: "test_acme",
|
TenantSlug: tenantSlug,
|
||||||
Actor: "alice@example.com",
|
Actor: "alice@example.com",
|
||||||
Action: "iam.login_failed",
|
Action: "iam.login_failed",
|
||||||
Target: "user:alice@example.com",
|
Target: "user:alice@example.com",
|
||||||
@@ -59,7 +66,7 @@ func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
|||||||
t.Fatalf("record: %v", err)
|
t.Fatalf("record: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := log.CountByTenant(ctx, "test_acme")
|
count, err := log.CountByTenant(ctx, tenantSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("count: %v", err)
|
t.Fatalf("count: %v", err)
|
||||||
}
|
}
|
||||||
@@ -69,8 +76,8 @@ func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
|||||||
|
|
||||||
var actor, action, target string
|
var actor, action, target string
|
||||||
if err := pool.QueryRow(ctx, `
|
if err := pool.QueryRow(ctx, `
|
||||||
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
|
SELECT actor, action, target FROM audit_events WHERE tenant_slug = $1
|
||||||
`).Scan(&actor, &action, &target); err != nil {
|
`, tenantSlug).Scan(&actor, &action, &target); err != nil {
|
||||||
t.Fatalf("eintrag lesen: %v", err)
|
t.Fatalf("eintrag lesen: %v", err)
|
||||||
}
|
}
|
||||||
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
|
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrConfirmationNotFound = errors.New("audit: bestaetigungsvorgang nicht gefunden")
|
||||||
|
ErrAlreadyDecided = errors.New("audit: bestaetigungsvorgang wurde bereits entschieden")
|
||||||
|
ErrSameActor = errors.New("audit: bestaetigung muss von einer anderen person als der anfordernden erfolgen")
|
||||||
|
ErrInvalidCode = errors.New("audit: bestaetigungscode ungueltig")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfirmationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending ConfirmationStatus = "pending"
|
||||||
|
StatusConfirmed ConfirmationStatus = "confirmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FourEyes implementiert das Vier-Augen-Prinzip fuer sicherheitskritische
|
||||||
|
// Entscheidungen (Akzeptanzkriterium 2) nach dem archivdms-Vorbild:
|
||||||
|
// FOR-UPDATE-Lock gegen Race-Bedingungen bei paralleler Bestaetigung,
|
||||||
|
// Timing-safe Vergleich des Bestaetigungscodes (Akzeptanzkriterium 3).
|
||||||
|
type FourEyes struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFourEyes(pool *pgxpool.Pool) *FourEyes {
|
||||||
|
return &FourEyes{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request legt einen neuen, zu bestaetigenden Vorgang an (z.B. Loeschbestaetigung,
|
||||||
|
// Rechtevergabe) und liefert einen einmaligen Klartext-Code, der ausserhalb
|
||||||
|
// dieses Systems (z.B. per E-Mail) an eine ZWEITE Person uebermittelt wird —
|
||||||
|
// niemals der anfordernden Person selbst.
|
||||||
|
func (f *FourEyes) Request(ctx context.Context, action, target, requestedBy string) (id, code string, err error) {
|
||||||
|
code, err = generateCode()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("bestaetigungscode erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
hash := hashCode(code)
|
||||||
|
|
||||||
|
err = f.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO security_confirmations (action, target, requested_by, code_hash, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'pending')
|
||||||
|
RETURNING id
|
||||||
|
`, action, target, requestedBy, hash).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("bestaetigungsvorgang anlegen: %w", err)
|
||||||
|
}
|
||||||
|
return id, code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm bestaetigt einen Vorgang. confirmedBy MUSS sich von der
|
||||||
|
// anfordernden Person unterscheiden (echtes Vier-Augen-Prinzip). Der Zugriff
|
||||||
|
// auf die Zeile erfolgt mit FOR UPDATE, damit zwei gleichzeitige
|
||||||
|
// Bestaetigungsversuche serialisiert werden und niemals beide durchgehen
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
func (f *FourEyes) Confirm(ctx context.Context, id, confirmedBy, code string) error {
|
||||||
|
tx, err := f.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var requestedBy, status string
|
||||||
|
var codeHash []byte
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT requested_by, status, code_hash FROM security_confirmations
|
||||||
|
WHERE id = $1 FOR UPDATE
|
||||||
|
`, id).Scan(&requestedBy, &status, &codeHash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrConfirmationNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("bestaetigungsvorgang lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != string(StatusPending) {
|
||||||
|
return ErrAlreadyDecided
|
||||||
|
}
|
||||||
|
if confirmedBy == requestedBy {
|
||||||
|
return ErrSameActor
|
||||||
|
}
|
||||||
|
if !timingSafeEqual(hashCode(code), codeHash) {
|
||||||
|
return ErrInvalidCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE security_confirmations
|
||||||
|
SET status = 'confirmed', confirmed_by = $2, confirmed_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`, id, confirmedBy); err != nil {
|
||||||
|
return fmt.Errorf("bestaetigung speichern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateCode() (string, error) {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashCode(code string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(code))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFourEyesTest(t *testing.T) (*FourEyes, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS security_confirmations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
requested_by TEXT NOT NULL,
|
||||||
|
code_hash BYTEA NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected')),
|
||||||
|
confirmed_by TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
confirmed_at TIMESTAMPTZ
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM security_confirmations WHERE action LIKE 'test.%'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewFourEyes(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFourEyes_RequestAndConfirm(t *testing.T) {
|
||||||
|
fe, cleanup := setupFourEyesTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fe.Confirm(ctx, id, "bob@example.com", code); err != nil {
|
||||||
|
t.Fatalf("confirm: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFourEyes_RejectsSameActor(t *testing.T) {
|
||||||
|
fe, cleanup := setupFourEyesTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fe.Confirm(ctx, id, "alice@example.com", code); !errors.Is(err, ErrSameActor) {
|
||||||
|
t.Fatalf("erwartet ErrSameActor, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFourEyes_RejectsWrongCode(t *testing.T) {
|
||||||
|
fe, cleanup := setupFourEyesTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, _, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fe.Confirm(ctx, id, "bob@example.com", "falscher-code"); !errors.Is(err, ErrInvalidCode) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCode, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: FOR-UPDATE-Lock unter parallelen
|
||||||
|
// Anfragen race-frei — von zwei gleichzeitigen Bestaetigungsversuchen fuer
|
||||||
|
// denselben Vorgang darf genau einer durchgehen.
|
||||||
|
func TestFourEyes_ConcurrentConfirmIsRaceFree(t *testing.T) {
|
||||||
|
fe, cleanup := setupFourEyesTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]error, 2)
|
||||||
|
confirmers := []string{"bob@example.com", "carol@example.com"}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i] = fe.Confirm(ctx, id, confirmers[i], code)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
for _, err := range results {
|
||||||
|
if err == nil {
|
||||||
|
successCount++
|
||||||
|
} else if !errors.Is(err, ErrAlreadyDecided) {
|
||||||
|
t.Fatalf("unerwarteter fehler: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successCount != 1 {
|
||||||
|
t.Fatalf("erwartet genau eine erfolgreiche bestaetigung, habe %d", successCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import "crypto/subtle"
|
||||||
|
|
||||||
|
// timingSafeEqual ist die projektweite Referenzimplementierung fuer
|
||||||
|
// Timing-safe-Vergleiche sicherheitsrelevanter Geheimnisse (Bestaetigungs-
|
||||||
|
// codes hier, spaeter Freigabelinks in Archive CMP-06 — siehe IAM-02-Ticket-
|
||||||
|
// Konvention). subtle.ConstantTimeCompare vergleicht in konstanter Zeit
|
||||||
|
// bezogen auf die Laenge von a, unabhaengig vom Inhalt.
|
||||||
|
func timingSafeEqual(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare(a, b) == 1
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TRIGGER IF EXISTS audit_events_no_delete ON audit_events;
|
||||||
|
DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events;
|
||||||
|
DROP FUNCTION IF EXISTS audit_events_prevent_mutation();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Audit-Log technisch gegen Aenderung/Loeschung absichern (AUD-02, siehe
|
||||||
|
-- core-kanban/tickets/AUD-02.md). Ein Trigger statt nur GRANT/REVOKE, damit
|
||||||
|
-- der Schutz unabhaengig davon greift, mit welcher Rolle verbunden wird
|
||||||
|
-- (Akzeptanzkriterium 1: "auf Datenbankebene technisch unterbunden").
|
||||||
|
CREATE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER audit_events_no_update
|
||||||
|
BEFORE UPDATE ON audit_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER audit_events_no_delete
|
||||||
|
BEFORE DELETE ON audit_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS security_confirmations;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Vier-Augen-Prinzip fuer sicherheitskritische Entscheidungen (AUD-02
|
||||||
|
-- Akzeptanzkriterium 2), Vorbild: archivdms FOR-UPDATE-Lock + Timing-safe
|
||||||
|
-- Vergleich. code_hash speichert NIEMALS den Bestaetigungscode im Klartext.
|
||||||
|
CREATE TABLE security_confirmations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
requested_by TEXT NOT NULL,
|
||||||
|
code_hash BYTEA NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected')),
|
||||||
|
confirmed_by TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
confirmed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user