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:
parent
e4d286f2e5
commit
a3f9bf8731
2 changed files with 298 additions and 3 deletions
272
internal/audit/chain.go
Normal file
272
internal/audit/chain.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue