From a3f9bf873115c286bf997792102550f0988fc6e8 Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Thu, 19 Feb 2026 15:34:28 -0500 Subject: [PATCH] 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 --- internal/audit/chain.go | 272 ++++++++++++++++++++++++++++++++++++ internal/logging/logging.go | 29 +++- 2 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 internal/audit/chain.go diff --git a/internal/audit/chain.go b/internal/audit/chain.go new file mode 100644 index 0000000..2ebeb56 --- /dev/null +++ b/internal/audit/chain.go @@ -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 +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index cb63c1b..b46bb5c 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -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.