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