From d780643015920d226bc6144154a8a9b8ed063ec3 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 12:38:40 -0400 Subject: [PATCH] M1: hash-chained session audit log Co-Authored-By: Claude Opus 4.8 --- internal/store/audit.go | 141 +++++++++++++++++++++++++++++++++++ internal/store/audit_test.go | 77 +++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 internal/store/audit.go create mode 100644 internal/store/audit_test.go diff --git a/internal/store/audit.go b/internal/store/audit.go new file mode 100644 index 0000000..d0cc3e5 --- /dev/null +++ b/internal/store/audit.go @@ -0,0 +1,141 @@ +package store + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" +) + +const genesisHash = "0000000000000000000000000000000000000000000000000000000000000000" + +// BucketTotal is per-(class, scrubbed-title) accumulated time in a session. +type BucketTotal struct { + Class string `json:"class"` + Title string `json:"title"` // scrubbed + Seconds int64 `json:"seconds"` +} + +// SessionSummary is one permanent, hash-chained record of a finished session. +type SessionSummary struct { + Seq int `json:"seq"` + PrevHash string `json:"prev_hash"` + SessionID string `json:"session_id"` + NextAction string `json:"next_action"` + SuccessCond string `json:"success_condition"` + Outcome string `json:"outcome"` // "completed" | "expired" + StartedUnix int64 `json:"started_unix"` + EndedUnix int64 `json:"ended_unix"` + SwitchCount int `json:"switch_count"` + Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds + Hash string `json:"hash"` +} + +// computeHash returns SHA-256(prevHash || canonicalJSON(s with Hash zeroed)). +// encoding/json emits struct fields in declaration order, so this is stable. +func computeHash(s SessionSummary) (string, error) { + s.Hash = "" + canonical, err := json.Marshal(s) + if err != nil { + return "", err + } + h := sha256.New() + h.Write([]byte(s.PrevHash)) + h.Write(canonical) + return hex.EncodeToString(h.Sum(nil)), nil +} + +func readSummaries(path string) ([]SessionSummary, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + var out []SessionSummary + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var s SessionSummary + if err := json.Unmarshal([]byte(line), &s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, sc.Err() +} + +// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns +// the next contiguous Seq, computes its Hash, and appends one JSON line with an +// fsync. +func AppendSession(path string, s SessionSummary) error { + existing, err := readSummaries(path) + if err != nil { + return err + } + if len(existing) == 0 { + s.Seq = 1 + s.PrevHash = genesisHash + } else { + last := existing[len(existing)-1] + s.Seq = last.Seq + 1 + s.PrevHash = last.Hash + } + hash, err := computeHash(s) + if err != nil { + return err + } + s.Hash = hash + + line, err := json.Marshal(s) + if err != nil { + return err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(append(line, '\n')); err != nil { + return err + } + return f.Sync() +} + +// VerifyChain re-walks every line, recomputing hashes and confirming each +// prev_hash links to the prior hash and Seq is contiguous from 1. It returns an +// error naming the first broken line (1-based); nil if intact or empty. +func VerifyChain(path string) error { + summaries, err := readSummaries(path) + if err != nil { + return err + } + prev := genesisHash + for i, s := range summaries { + lineNo := i + 1 + if s.Seq != lineNo { + return fmt.Errorf("audit chain broken at line %d: seq %d, want %d", lineNo, s.Seq, lineNo) + } + if s.PrevHash != prev { + return fmt.Errorf("audit chain broken at line %d: prev_hash mismatch", lineNo) + } + want, err := computeHash(s) + if err != nil { + return err + } + if want != s.Hash { + return fmt.Errorf("audit chain broken at line %d: hash mismatch", lineNo) + } + prev = s.Hash + } + return nil +} diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go new file mode 100644 index 0000000..a3a08c5 --- /dev/null +++ b/internal/store/audit_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func auditFixture(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "audit.jsonl") +} + +func sampleSummary(id string) SessionSummary { + return SessionSummary{ + SessionID: id, + NextAction: "write plan", + SuccessCond: "plan committed", + Outcome: "completed", + StartedUnix: 1000, + EndedUnix: 2000, + SwitchCount: 3, + Buckets: []BucketTotal{ + {Class: "code", Title: "antidrift", Seconds: 540}, + {Class: "firefox", Title: "docs", Seconds: 120}, + }, + } +} + +func TestAppendSessionChainsAndVerifies(t *testing.T) { + path := auditFixture(t) + for i := 0; i < 3; i++ { + if err := AppendSession(path, sampleSummary("session-"+string(rune('a'+i)))); err != nil { + t.Fatalf("append %d: %v", i, err) + } + } + if err := VerifyChain(path); err != nil { + t.Fatalf("chain should verify: %v", err) + } +} + +func TestGenesisPrevHashIsZeros(t *testing.T) { + path := auditFixture(t) + if err := AppendSession(path, sampleSummary("session-a")); err != nil { + t.Fatalf("append: %v", err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), strings.Repeat("0", 64)) { + t.Fatalf("genesis prev_hash should be 64 zeros, got: %s", data) + } +} + +func TestVerifyEmptyChain(t *testing.T) { + if err := VerifyChain(auditFixture(t)); err != nil { + t.Fatalf("empty/missing chain should verify, got %v", err) + } +} + +func TestTamperDetected(t *testing.T) { + path := auditFixture(t) + _ = AppendSession(path, sampleSummary("session-a")) + _ = AppendSession(path, sampleSummary("session-b")) + _ = AppendSession(path, sampleSummary("session-c")) + + // Corrupt the middle line's switch_count. + data, _ := os.ReadFile(path) + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + lines[1] = strings.Replace(lines[1], `"switch_count":3`, `"switch_count":99`, 1) + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatalf("rewrite: %v", err) + } + err := VerifyChain(path) + if err == nil || !strings.Contains(err.Error(), "line 2") { + t.Fatalf("tamper on line 2 should be reported, got %v", err) + } +}