M1: hash-chained session audit log

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:38:40 -04:00
parent a31ce702fe
commit d780643015
2 changed files with 218 additions and 0 deletions
+141
View File
@@ -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
}