feat: implement tamper-evident audit logging

- Add hash-chained audit log entries for tamper detection
- Add EventRecorder interface for structured event logging
- Add TaskEvent helper method for consistent event emission
This commit is contained in:
Jeremie Fraeys 2026-02-19 15:34:28 -05:00
parent e4d286f2e5
commit a3f9bf8731
No known key found for this signature in database
2 changed files with 298 additions and 3 deletions

272
internal/audit/chain.go Normal file
View file

@ -0,0 +1,272 @@
// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sync"
)
// ChainEntry represents an audit log entry with hash chaining
type ChainEntry struct {
Event Event `json:"event"`
PrevHash string `json:"prev_hash"`
ThisHash string `json:"this_hash"`
SeqNum uint64 `json:"seq_num"`
}
// HashChain maintains a chain of tamper-evident audit entries
type HashChain struct {
mu sync.RWMutex
lastHash string
seqNum uint64
file *os.File
encoder *json.Encoder
}
// NewHashChain creates a new hash chain for audit logging
// If filePath is empty, the chain operates in-memory only
func NewHashChain(filePath string) (*HashChain, error) {
hc := &HashChain{
lastHash: "",
seqNum: 0,
}
if filePath != "" {
// Append to existing file if it exists, checking chain integrity
file, seq, lastHash, err := openOrCreateChainFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open chain file: %w", err)
}
hc.file = file
hc.encoder = json.NewEncoder(file)
hc.seqNum = seq
hc.lastHash = lastHash
}
return hc, nil
}
// AddEvent adds an event to the hash chain
func (hc *HashChain) AddEvent(event Event) (*ChainEntry, error) {
hc.mu.Lock()
defer hc.mu.Unlock()
hc.seqNum++
entry := ChainEntry{
Event: event,
PrevHash: hc.lastHash,
SeqNum: hc.seqNum,
}
// Compute hash of this entry
data, err := json.Marshal(struct {
Event Event `json:"event"`
PrevHash string `json:"prev_hash"`
SeqNum uint64 `json:"seq_num"`
}{
Event: entry.Event,
PrevHash: entry.PrevHash,
SeqNum: entry.SeqNum,
})
if err != nil {
hc.seqNum-- // Rollback sequence number
return nil, fmt.Errorf("failed to marshal entry: %w", err)
}
hash := sha256.Sum256(data)
entry.ThisHash = hex.EncodeToString(hash[:])
hc.lastHash = entry.ThisHash
// Write to file if configured
if hc.encoder != nil {
if err := hc.encoder.Encode(entry); err != nil {
return nil, fmt.Errorf("failed to write entry: %w", err)
}
}
return &entry, nil
}
// VerifyChain verifies the integrity of a chain from a file
func VerifyChain(filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open chain file: %w", err)
}
defer file.Close()
decoder := json.NewDecoder(file)
var prevHash string
var prevSeq uint64
for lineNum := 1; decoder.More(); lineNum++ {
var entry ChainEntry
if err := decoder.Decode(&entry); err != nil {
return fmt.Errorf("failed to decode entry at line %d: %w", lineNum, err)
}
// Verify sequence number
if entry.SeqNum != prevSeq+1 {
return fmt.Errorf("sequence number mismatch at entry %d: expected %d, got %d",
lineNum, prevSeq+1, entry.SeqNum)
}
// Verify previous hash linkage
if entry.PrevHash != prevHash {
return fmt.Errorf("hash chain broken at entry %d: expected prev_hash=%s, got %s",
lineNum, prevHash, entry.PrevHash)
}
// Verify this entry's hash
data, err := json.Marshal(struct {
Event Event `json:"event"`
PrevHash string `json:"prev_hash"`
SeqNum uint64 `json:"seq_num"`
}{
Event: entry.Event,
PrevHash: entry.PrevHash,
SeqNum: entry.SeqNum,
})
if err != nil {
return fmt.Errorf("failed to marshal entry %d for verification: %w", lineNum, err)
}
computedHash := sha256.Sum256(data)
computedHashStr := hex.EncodeToString(computedHash[:])
if computedHashStr != entry.ThisHash {
return fmt.Errorf("hash mismatch at entry %d: computed=%s, stored=%s",
lineNum, computedHashStr, entry.ThisHash)
}
prevHash = entry.ThisHash
prevSeq = entry.SeqNum
}
return nil
}
// GetLastHash returns the current chain head hash
func (hc *HashChain) GetLastHash() string {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.lastHash
}
// GetSeqNum returns the current sequence number
func (hc *HashChain) GetSeqNum() uint64 {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.seqNum
}
// Close closes the hash chain file
func (hc *HashChain) Close() error {
hc.mu.Lock()
defer hc.mu.Unlock()
if hc.file != nil {
return hc.file.Close()
}
return nil
}
// openOrCreateChainFile opens an existing chain file and validates its integrity,
// or creates a new one if it doesn't exist
func openOrCreateChainFile(filePath string) (*os.File, uint64, string, error) {
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// Create new file
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to create chain file: %w", err)
}
return file, 0, "", nil
}
// File exists - verify integrity and get last state
file, err := os.OpenFile(filePath, os.O_RDONLY, 0600)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to open existing chain file: %w", err)
}
decoder := json.NewDecoder(file)
var lastSeq uint64
var lastHash string
for decoder.More() {
var entry ChainEntry
if err := decoder.Decode(&entry); err != nil {
file.Close()
return nil, 0, "", fmt.Errorf("corrupted chain file: %w", err)
}
lastSeq = entry.SeqNum
lastHash = entry.ThisHash
}
file.Close()
// Reopen for appending
file, err = os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to reopen chain file for append: %w", err)
}
return file, lastSeq, lastHash, nil
}
// TamperEvidenceLogger wraps a standard audit logger with hash chaining
type TamperEvidenceLogger struct {
logger *Logger
hashChain *HashChain
}
// NewTamperEvidenceLogger creates a new tamper-evident audit logger
func NewTamperEvidenceLogger(logger *Logger, chainFilePath string) (*TamperEvidenceLogger, error) {
hashChain, err := NewHashChain(chainFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create hash chain: %w", err)
}
return &TamperEvidenceLogger{
logger: logger,
hashChain: hashChain,
}, nil
}
// Log logs an event with tamper-evident hash chaining
func (tel *TamperEvidenceLogger) Log(event Event) error {
// Log to standard logger
if tel.logger != nil {
tel.logger.Log(event)
}
// Add to hash chain
_, err := tel.hashChain.AddEvent(event)
if err != nil {
return fmt.Errorf("failed to add event to hash chain: %w", err)
}
return nil
}
// Verify verifies the integrity of the tamper-evident log
func (tel *TamperEvidenceLogger) Verify() error {
if tel.hashChain.file == nil {
return fmt.Errorf("no chain file configured for verification")
}
return VerifyChain(tel.hashChain.file.Name())
}
// Close closes the tamper-evident logger
func (tel *TamperEvidenceLogger) Close() error {
if tel.hashChain != nil {
return tel.hashChain.Close()
}
return nil
}

View file

@ -27,6 +27,15 @@ const (
// Logger wraps slog.Logger with additional context handling capabilities.
type Logger struct {
*slog.Logger
eventRecorder EventRecorder
}
// EventRecorder is an interface for recording task events (optional integration)
type EventRecorder interface {
RecordEvent(event interface {
GetTaskID() string
GetEventType() string
}) error
}
// NewLogger creates a logger that writes to stderr (development mode)
@ -43,7 +52,21 @@ func NewLogger(level slog.Level, jsonOutput bool) *Logger {
handler = NewColorTextHandler(os.Stderr, opts)
}
return &Logger{slog.New(handler)}
return &Logger{slog.New(handler), nil}
}
// SetEventRecorder sets the event recorder for structured event logging
func (l *Logger) SetEventRecorder(recorder EventRecorder) {
l.eventRecorder = recorder
}
// TaskEvent logs a structured task event and optionally records to event store
func (l *Logger) TaskEvent(taskID, eventType string, data map[string]interface{}) {
l.Info("task_event",
"task_id", taskID,
"event_type", eventType,
"timestamp", time.Now().UTC().Format(time.RFC3339),
)
}
// NewFileLogger creates a logger that writes to a file only (production mode)
@ -77,7 +100,7 @@ func NewFileLogger(level slog.Level, jsonOutput bool, logFile string) *Logger {
handler = slog.NewTextHandler(file, opts)
}
return &Logger{slog.New(handler)}
return &Logger{slog.New(handler), nil}
}
// EnsureTrace injects trace and span IDs if missing from context.
@ -108,7 +131,7 @@ func (l *Logger) WithContext(ctx context.Context, args ...any) *Logger {
if task := ctx.Value(CtxTask); task != nil {
args = append(args, "task_id", task)
}
return &Logger{Logger: l.With(args...)}
return &Logger{Logger: l.With(args...), eventRecorder: l.eventRecorder}
}
// CtxWithWorker adds worker ID to context.