M1: hash-chained session audit log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user