diff --git a/internal/store/audit_repo.go b/internal/store/audit_repo.go index 63f4377..9e91b9b 100644 --- a/internal/store/audit_repo.go +++ b/internal/store/audit_repo.go @@ -2,7 +2,9 @@ package store import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "fmt" "time" @@ -27,6 +29,45 @@ func NewAuditRepo(db *sql.DB) *AuditRepo { return &AuditRepo{db: db} } +// computeEntryHash computes sha256(prev_hash || timestamp || actor || +// action || resource || result || error || metadata) for the hash +// chain (REQ-125, F2). The prev_hash is the entry_hash of the most +// recent prior entry (empty string for the first entry). +func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errMsg, metaJSON string) string { + h := sha256.New() + h.Write([]byte(prevHash)) + h.Write([]byte{0}) + h.Write([]byte(timestamp)) + h.Write([]byte{0}) + h.Write([]byte(actor)) + h.Write([]byte{0}) + h.Write([]byte(action)) + h.Write([]byte{0}) + h.Write([]byte(resource)) + h.Write([]byte{0}) + h.Write([]byte(result)) + h.Write([]byte{0}) + h.Write([]byte(errMsg)) + h.Write([]byte{0}) + h.Write([]byte(metaJSON)) + return hex.EncodeToString(h.Sum(nil)) +} + +// getLastEntryHash returns the entry_hash of the most recent audit_log +// entry, or "" if the table is empty. +func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) { + var prevHash string + err := r.db.QueryRowContext(ctx, + `SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get last entry hash: %w", err) + } + return prevHash, nil +} + func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error { if e.Timestamp.IsZero() { e.Timestamp = time.Now().UTC() @@ -35,24 +76,65 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error { e.Actor = "system" } metaJSON, _ := json.Marshal(e.Metadata) - if e.Error == "" { - _, err := r.db.ExecContext(ctx, - `INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`, - e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON)) - if err != nil { - return fmt.Errorf("insert audit: %w", err) - } - return nil - } - _, err := r.db.ExecContext(ctx, - `INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`, - e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON)) + tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano) + + // Compute the hash chain (REQ-125, F2). + prevHash, err := r.getLastEntryHash(ctx) if err != nil { - return fmt.Errorf("insert audit (with error): %w", err) + return fmt.Errorf("audit hash chain: %w", err) + } + entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON)) + + _, err = r.db.ExecContext(ctx, + `INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash) + if err != nil { + return fmt.Errorf("insert audit: %w", err) } return nil } +// VerifyChain recomputes the hash chain from the first entry and +// returns an error if any entry's entry_hash does not match. Used by +// `orca doctor audit` (REQ-125). +func (r *AuditRepo) VerifyChain(ctx context.Context) error { + rows, err := r.db.QueryContext(ctx, + `SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, ''), prev_hash, entry_hash FROM audit_log ORDER BY id ASC`) + if err != nil { + return fmt.Errorf("verify chain: query: %w", err) + } + defer rows.Close() + prevHash := "" + for rows.Next() { + var ( + id int64 + ts time.Time + actor string + action string + resource string + result string + errMsg string + metaJSON string + storedPrev string + storedHash string + ) + if err := rows.Scan(&id, &ts, &actor, &action, &resource, &result, &errMsg, &metaJSON, &storedPrev, &storedHash); err != nil { + return fmt.Errorf("verify chain: scan: %w", err) + } + // Verify the prev_hash link. + if storedPrev != prevHash { + return fmt.Errorf("verify chain: entry %d prev_hash mismatch (expected %q, got %q)", id, prevHash, storedPrev) + } + // Recompute the entry hash. + expected := computeEntryHash(prevHash, ts.UTC().Format(time.RFC3339Nano), actor, action, resource, result, errMsg, metaJSON) + if expected != storedHash { + return fmt.Errorf("verify chain: entry %d hash mismatch (entry may have been tampered)", id) + } + prevHash = storedHash + } + return rows.Err() +} + func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) { if limit <= 0 { limit = 100 diff --git a/internal/store/audit_repo_test.go b/internal/store/audit_repo_test.go index 2b0acac..56549c3 100644 --- a/internal/store/audit_repo_test.go +++ b/internal/store/audit_repo_test.go @@ -149,3 +149,56 @@ func TestAuditRepo_ListDefaultLimit(t *testing.T) { t.Errorf("List(-1): got %d, want 5", len(entries)) } } + +// --- REQ-125 / F2 audit tamper-evidence tests --- + +// TestAuditRepo_VerifyChain verifies the hash chain verifies after append. +func TestAuditRepo_VerifyChain(t *testing.T) { + repo, cleanup := openAuditTestDB(t) + defer cleanup() + ctx := context.Background() + for i := 0; i < 5; i++ { + if err := repo.Append(ctx, &AuditEntry{ + Action: "test.action", + Resource: "res", + Result: "success", + Actor: "user", + }); err != nil { + t.Fatalf("Append %d: %v", i, err) + } + } + if err := repo.VerifyChain(ctx); err != nil { + t.Errorf("VerifyChain: %v", err) + } +} + +// TestAuditRepo_TamperDetection verifies VerifyChain detects a modified +// entry. We use raw SQL to UPDATE (which the trigger should block). +func TestAuditRepo_TamperDetection(t *testing.T) { + repo, cleanup := openAuditTestDB(t) + defer cleanup() + ctx := context.Background() + if err := repo.Append(ctx, &AuditEntry{ + Action: "cert.issued", Resource: "node1", Result: "success", Actor: "system", + }); err != nil { + t.Fatalf("Append: %v", err) + } + // Verify chain is intact. + if err := repo.VerifyChain(ctx); err != nil { + t.Fatalf("VerifyChain before tamper: %v", err) + } + // Attempt UPDATE — the trigger should block it. + _, err := repo.db.ExecContext(ctx, `UPDATE audit_log SET actor='hacker' WHERE id=1`) + if err == nil { + t.Error("UPDATE should be blocked by append-only trigger (REQ-125)") + } + // Attempt DELETE — also blocked. + _, err = repo.db.ExecContext(ctx, `DELETE FROM audit_log WHERE id=1`) + if err == nil { + t.Error("DELETE should be blocked by append-only trigger (REQ-125)") + } + // Chain still verifies (nothing was modified). + if err := repo.VerifyChain(ctx); err != nil { + t.Errorf("VerifyChain after blocked tamper: %v", err) + } +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 6a2f5a9..9a75f96 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -19,8 +19,8 @@ func TestMigrationVersion(t *testing.T) { if err != nil { t.Fatalf("migration version: %v", err) } - if version != "0007_certs_serial_unique.sql" { - t.Errorf("MigrationVersion = %q, want 0007_certs_serial_unique.sql", version) + if version != "0008_audit_tamper_evidence.sql" { + t.Errorf("MigrationVersion = %q, want 0008_audit_tamper_evidence.sql", version) } // Empty the migrations table → should return ("", nil). diff --git a/internal/store/migrations/0008_audit_tamper_evidence.sql b/internal/store/migrations/0008_audit_tamper_evidence.sql new file mode 100644 index 0000000..9d5795a --- /dev/null +++ b/internal/store/migrations/0008_audit_tamper_evidence.sql @@ -0,0 +1,18 @@ +-- REQ-125 / F2: audit log tamper-evidence. +-- Add hash-chain columns + append-only trigger blocking UPDATE/DELETE. +ALTER TABLE audit_log ADD COLUMN prev_hash TEXT; +ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT ''; + +-- Append-only trigger: block UPDATE and DELETE on audit_log. +-- A tampered entry (UPDATE) or deleted entry (DELETE) is rejected. +CREATE TRIGGER IF NOT EXISTS audit_log_no_update +BEFORE UPDATE ON audit_log +BEGIN + SELECT RAISE(ABORT, 'audit_log is append-only (REQ-125)'); +END; + +CREATE TRIGGER IF NOT EXISTS audit_log_no_delete +BEFORE DELETE ON audit_log +BEGIN + SELECT RAISE(ABORT, 'audit_log is append-only (REQ-125)'); +END;