fix(P10): audit log tamper-evidence (REQ-125, F2)

---ci---
project: orca
phase: 10
milestone: v0.12
status: execute
---/ci---

Migration 0008: add prev_hash + entry_hash columns + append-only
triggers (UPDATE/DELETE blocked with ABORT).
audit_repo.go: Append computes hash chain (sha256(prev_hash ||
timestamp || actor || action || resource || result || error ||
metadata)). VerifyChain recomputes from first entry, detects
tampering.
2 new tests: VerifyChain (5-entry chain verifies), TamperDetection
(UPDATE + DELETE blocked by trigger). All store tests pass.
This commit is contained in:
Jon Chery
2026-08-07 11:18:42 +00:00
parent a81bbb2bcf
commit 827f215115
4 changed files with 168 additions and 15 deletions
+95 -13
View File
@@ -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