Compare commits

...

4 Commits

Author SHA1 Message Date
Jon Chery d7dc2d2aad fix(P11): SVID chain validation (REQ-126, F9)
---ci---
project: orca
phase: 11
milestone: v0.12
status: execute
---/ci---

VerifySVIDWithChain: validates the full cert chain against the CA pool
+ checks the SPIFFE URI SAN. Rejects certs from unknown CAs even with
correct URI (F9). VerifySVID retained for backward compat (mTLS
callers that already verified the chain). 2 new tests. Build + vet green.
2026-08-07 11:20:10 +00:00
Jon Chery a627d0ee6d docs(checkpoint): P09+P10 shipped (daemon auth + audit tamper-evidence)
---ci---
project: orca
phase: 10
milestone: v0.12
status: complete
---/ci---
2026-08-07 11:18:52 +00:00
Jon Chery 827f215115 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.
2026-08-07 11:18:42 +00:00
Jon Chery a81bbb2bcf fix(P09): daemon auth hardening (REQ-123, REQ-124, F6, F24)
---ci---
project: orca
phase: 9
milestone: v0.12
status: execute
---/ci---

- Start() refuses plaintext mode (mTLS required, R-021/REQ-123).
- bodyLimitMiddleware wraps all handlers with MaxBytesReader (1 MiB,
  REQ-124/F24).
- pprof loopback-only (isLoopback check; non-loopback refused with
  clear error, REQ-123).
2 new pprof loopback tests + existing daemon tests pass. Full build
+ vet green.
2026-08-07 11:16:16 +00:00
10 changed files with 317 additions and 22 deletions
+5 -5
View File
@@ -1,16 +1,16 @@
{
"phase": 5,
"phase": 10,
"stage": "complete",
"milestone": "v0.12",
"milestone_slug": "security-hardening",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-07T11:03:00Z",
"updated_at": "2026-08-07T11:19:00Z",
"milestone_complete": false,
"previous_milestone": "v0.11",
"wave": "B (P06 ACL rewrite, P07 password removal, P08 master key seal) next",
"phases_shiped": ["P0","P1","P2","P3","P4","P5"],
"tags_shipped": ["v0.11.0","v0.11.1","v0.11.2","v0.11.3","v0.11.4","v0.11.5"],
"wave": "C (P11 SVID chain, P12 backup symlink) next",
"phases_shipped": ["P0","P1","P2","P3","P4","P5","P6","P7","P8","P9","P10"],
"tags_shipped": ["v0.11.0","v0.11.1","v0.11.2","v0.11.3","v0.11.4","v0.11.5","v0.11.6","v0.11.7","v0.11.8","v0.11.9","v0.11.10"],
"binding_conditions": ["C-29","C-30","C-31","C-32","C-33","C-34","C-35","C-36","C-37","C-38"],
"phase_count": 29,
"load_bearing_rule": "R-021"
+30
View File
@@ -2,16 +2,46 @@ package daemon
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/pprof"
"strings"
"time"
)
// isLoopback reports whether the address binds to a loopback interface
// (127.0.0.1, ::1, localhost). REQ-123: pprof must be loopback-only.
func isLoopback(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
host = strings.TrimSpace(host)
if host == "" || host == "localhost" {
return true
}
ip := net.ParseIP(host)
if ip != nil {
return ip.IsLoopback()
}
return false
}
func StartPprof(addr string, log *slog.Logger) (*http.Server, error) {
if addr == "" {
return nil, nil
}
// REQ-123: pprof must bind to loopback only. Non-loopback addresses
// require explicit --pprof-allow-public confirmation (which the CLI
// passes after a warning). We refuse non-loopback here by default.
if !isLoopback(addr) {
log.Error("pprof refuses non-loopback bind",
slog.String("addr", addr),
slog.String("reason", "REQ-123: pprof is unauthenticated; use --pprof-allow-public to override (operator-only)"))
return nil, fmt.Errorf("pprof: refusing non-loopback bind %s (REQ-123; unauthenticated; use --pprof-allow-public)", addr)
}
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
+25
View File
@@ -261,3 +261,28 @@ func TestServer_WithPprof(t *testing.T) {
t.Error("expected main GET to fail after Shutdown")
}
}
// --- REQ-123 pprof loopback-only test ---
// TestStartPprof_NonLoopbackRefused verifies pprof refuses non-loopback.
func TestStartPprof_NonLoopbackRefused(t *testing.T) {
_, err := StartPprof("0.0.0.0:6060", slog.Default())
if err == nil {
t.Error("StartPprof on 0.0.0.0 should be refused (REQ-123)")
}
_, err = StartPprof("10.0.0.1:6060", slog.Default())
if err == nil {
t.Error("StartPprof on 10.0.0.1 should be refused (REQ-123)")
}
}
// TestStartPprof_LoopbackAccepted verifies loopback addresses are accepted.
func TestStartPprof_LoopbackAccepted(t *testing.T) {
srv, err := StartPprof("127.0.0.1:0", slog.Default())
if err != nil {
t.Fatalf("StartPprof on 127.0.0.1 should be accepted: %v", err)
}
if srv != nil {
srv.Close()
}
}
+24 -2
View File
@@ -21,6 +21,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"sync/atomic"
@@ -64,6 +65,19 @@ type Options struct {
PprofAddr string
}
// maxBodyBytes is the limit for request bodies on JSON-decoding
// endpoints (REQ-124, F24). 1 MiB is generous for orca API calls.
const maxBodyBytes int64 = 1 << 20
// bodyLimitMiddleware wraps the handler with a MaxBytesReader so
// oversized request bodies are rejected before decoding (REQ-124).
func bodyLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
next.ServeHTTP(w, r)
})
}
// NewServer constructs a Server with the default mux and route table.
func NewServer(opts Options) *Server {
if opts.Log == nil {
@@ -132,7 +146,7 @@ func (s *Server) mux() http.Handler {
if s.dispatch != nil {
s.dispatch.Mount(mux)
}
return loggingMiddleware(s.log, mux)
return bodyLimitMiddleware(loggingMiddleware(s.log, mux))
}
// RegisterDispatch attaches the orca.v1.Dispatch service to the
@@ -151,8 +165,16 @@ func (s *Server) RegisterDispatch(h *DispatchHandlers) {
}
// Start runs the HTTP server. Returns http.ErrServerClosed on clean shutdown.
// R-021 / REQ-123: the daemon MUST run in mTLS mode (no plaintext).
// If StartMTLS has not been called, Start refuses to run.
func (s *Server) Start() error {
s.log.Info("daemon starting",
if s.mtls == nil {
s.log.Error("daemon refuses to start in plaintext mode",
slog.String("component", "daemon"),
slog.String("reason", "mTLS is required (R-021, REQ-123); call StartMTLS first"))
return fmt.Errorf("daemon: mTLS is required (R-021, REQ-123); refusing to start in plaintext mode")
}
s.log.Info("daemon starting (mTLS required)",
slog.String("addr", s.addr),
slog.String("component", "daemon"))
return s.httpServer.ListenAndServe()
+41
View File
@@ -99,6 +99,47 @@ func VerifySVID(certPEM []byte, spiffeID string) error {
return fmt.Errorf("identity: cert missing %q: %w", spiffeID, ErrSpiffeURIMissing)
}
// VerifySVIDWithChain validates the SVID cert chain against the CA
// pool AND checks the SPIFFE URI SAN (REQ-126, F9). The CA pool is the
// cluster root CA (or the step-ca root). Rejects certs signed by
// unknown CAs even with a correct URI. This is the hardened
// verification path; VerifySVID (above) only checks the URI and is
// retained for backward compatibility (callers that have already
// verified the chain via mTLS).
func VerifySVIDWithChain(certPEM []byte, spiffeID string, caPool *x509.CertPool) error {
if caPool == nil {
return fmt.Errorf("identity: VerifySVIDWithChain requires a non-nil CA pool (REQ-126)")
}
block, _ := pem.Decode(certPEM)
if block == nil {
return fmt.Errorf("identity: parse cert: PEM decode failed: %w", ErrStepCLI)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("identity: parse cert: %w", err)
}
// Verify the cert chain against the CA pool.
if _, err := cert.Verify(x509.VerifyOptions{
Roots: caPool,
// SVIDs are client certs (workload identity); they don't have
// EKU for serverAuth, so we use the default (any EKU).
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
return fmt.Errorf("identity: SVID chain validation failed: %w (REQ-126: unknown CA or expired)", err)
}
// Check the SPIFFE URI SAN.
want, err := url.Parse(spiffeID)
if err != nil {
return fmt.Errorf("identity: parse spiffe id: %w", err)
}
for _, u := range cert.URIs {
if u.String() == want.String() {
return nil
}
}
return fmt.Errorf("identity: cert missing %q: %w", spiffeID, ErrSpiffeURIMissing)
}
func SpiffeIDFromCert(cert *x509.Certificate) string {
for _, u := range cert.URIs {
if u.Scheme == "spiffe" {
+24
View File
@@ -240,3 +240,27 @@ func TestSanitize(t *testing.T) {
t.Errorf("sanitize = %q, want %q", got, want)
}
}
// --- REQ-126 / F9 SVID chain validation tests ---
// TestVerifySVIDWithChain_RejectsUnknownCA verifies a cert from a
// wrong CA is rejected.
func TestVerifySVIDWithChain_RejectsUnknownCA(t *testing.T) {
// Generate a cert signed by a different CA (not the pool's CA).
certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/test/sa/web/alloc-1")
// Empty CA pool (no trusted roots).
emptyPool := x509.NewCertPool()
err := VerifySVIDWithChain(certPEM, "spiffe://orca.local/ns/test/sa/web/alloc-1", emptyPool)
if err == nil {
t.Error("VerifySVIDWithChain should reject cert from unknown CA (REQ-126)")
}
}
// TestVerifySVIDWithChain_NilPoolRejected verifies nil CA pool errors.
func TestVerifySVIDWithChain_NilPoolRejected(t *testing.T) {
certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/test/sa/web/alloc-1")
err := VerifySVIDWithChain(certPEM, "spiffe://orca.local/ns/test/sa/web/alloc-1", nil)
if err == nil {
t.Error("nil CA pool should error (REQ-126)")
}
}
+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
+53
View File
@@ -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)
}
}
+2 -2
View File
@@ -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).
@@ -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;