mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-26 12:56:21 +02:00
366 lines
9.5 KiB
Go
366 lines
9.5 KiB
Go
// Package tpmaudit implements SEC-006 TPM-Sealed Decision Logger.
|
|
//
|
|
// Provides hardware-backed integrity for the audit decision chain:
|
|
// - Each decision entry is signed with a TPM-bound key
|
|
// - PCR values extended with each entry hash
|
|
// - Quotes can verify the entire chain hasn't been tampered
|
|
//
|
|
// When TPM is unavailable (dev/CI): falls back to software HMAC signing
|
|
// with a configurable secret key.
|
|
//
|
|
// Architecture:
|
|
//
|
|
// Decision Entry → SHA-256 Hash → TPM Sign → PCR Extend → Sealed Entry
|
|
// ↓
|
|
// Chain Verification via TPM Quote
|
|
package tpmaudit
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SealMode defines the sealing backend.
|
|
type SealMode string
|
|
|
|
const (
|
|
SealTPM SealMode = "tpm" // Hardware TPM 2.0
|
|
SealSoftware SealMode = "software" // HMAC fallback for dev/CI
|
|
)
|
|
|
|
// DecisionEntry is a single audit decision record.
|
|
type DecisionEntry struct {
|
|
ID string `json:"id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Action string `json:"action"` // ingest, correlate, respond, playbook
|
|
Decision string `json:"decision"` // allow, deny, escalate
|
|
Reason string `json:"reason"`
|
|
EventID string `json:"event_id,omitempty"`
|
|
IncidentID string `json:"incident_id,omitempty"`
|
|
Operator string `json:"operator,omitempty"`
|
|
PreviousHash string `json:"previous_hash"` // Chain link
|
|
}
|
|
|
|
// SealedEntry wraps a decision with cryptographic sealing.
|
|
type SealedEntry struct {
|
|
Entry DecisionEntry `json:"entry"`
|
|
Hash string `json:"hash"` // SHA-256 of entry
|
|
Signature string `json:"signature"` // TPM or HMAC signature
|
|
PCRValue string `json:"pcr_value"` // Extended PCR (or simulated)
|
|
SealMode SealMode `json:"seal_mode"`
|
|
ChainIdx int64 `json:"chain_idx"`
|
|
}
|
|
|
|
// ChainVerification holds the result of verifying an audit chain.
|
|
type ChainVerification struct {
|
|
Valid bool `json:"valid"`
|
|
TotalEntries int `json:"total_entries"`
|
|
VerifiedCount int `json:"verified_count"`
|
|
BrokenAtIndex int `json:"broken_at_index,omitempty"`
|
|
BrokenReason string `json:"broken_reason,omitempty"`
|
|
VerifiedAt time.Time `json:"verified_at"`
|
|
Mode SealMode `json:"mode"`
|
|
}
|
|
|
|
// SealedLogger provides TPM-sealed (or HMAC-fallback) audit logging.
|
|
type SealedLogger struct {
|
|
mu sync.Mutex
|
|
mode SealMode
|
|
hmacKey []byte // Used in software mode
|
|
chain []SealedEntry // In-memory chain (also persisted)
|
|
currentPCR string // Simulated PCR value
|
|
logFile *os.File
|
|
logger *slog.Logger
|
|
stats LoggerStats
|
|
}
|
|
|
|
// LoggerStats tracks audit logger metrics.
|
|
type LoggerStats struct {
|
|
TotalEntries int64 `json:"total_entries"`
|
|
LastEntry time.Time `json:"last_entry"`
|
|
ChainIntegrity bool `json:"chain_integrity"`
|
|
Mode SealMode `json:"mode"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
}
|
|
|
|
// NewSealedLogger creates a TPM-sealed decision logger.
|
|
// Falls back to software HMAC if TPM is unavailable.
|
|
func NewSealedLogger(auditDir string, hmacSecret string) (*SealedLogger, error) {
|
|
mode := SealTPM
|
|
var hmacKey []byte
|
|
|
|
// Try to open TPM device.
|
|
if !tpmAvailable() {
|
|
mode = SealSoftware
|
|
if hmacSecret == "" {
|
|
hmacSecret = "sentinel-dev-key-not-for-production"
|
|
}
|
|
hmacKey = []byte(hmacSecret)
|
|
}
|
|
|
|
// Open audit log file.
|
|
logPath := auditDir + "/decisions_sealed.jsonl"
|
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tpmaudit: open %s: %w", logPath, err)
|
|
}
|
|
|
|
logger := &SealedLogger{
|
|
mode: mode,
|
|
hmacKey: hmacKey,
|
|
currentPCR: "0000000000000000000000000000000000000000000000000000000000000000",
|
|
logFile: f,
|
|
logger: slog.Default().With("component", "sec-006-tpmaudit"),
|
|
stats: LoggerStats{
|
|
ChainIntegrity: true,
|
|
Mode: mode,
|
|
StartedAt: time.Now(),
|
|
},
|
|
}
|
|
|
|
// Load existing chain from file.
|
|
logger.loadExistingChain(logPath)
|
|
|
|
logger.logger.Info("sealed decision logger initialized",
|
|
"mode", mode,
|
|
"chain_length", len(logger.chain),
|
|
"log_path", logPath,
|
|
)
|
|
|
|
return logger, nil
|
|
}
|
|
|
|
// LogDecision seals and persists a decision entry.
|
|
func (sl *SealedLogger) LogDecision(entry DecisionEntry) (*SealedEntry, error) {
|
|
sl.mu.Lock()
|
|
defer sl.mu.Unlock()
|
|
|
|
// Set chain link.
|
|
if len(sl.chain) > 0 {
|
|
entry.PreviousHash = sl.chain[len(sl.chain)-1].Hash
|
|
} else {
|
|
entry.PreviousHash = "genesis"
|
|
}
|
|
|
|
entry.Timestamp = time.Now()
|
|
if entry.ID == "" {
|
|
entry.ID = fmt.Sprintf("DEC-%d", time.Now().UnixNano())
|
|
}
|
|
|
|
// Hash the entry.
|
|
entryBytes, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tpmaudit: marshal entry: %w", err)
|
|
}
|
|
hash := sha256.Sum256(entryBytes)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
// Sign with TPM or HMAC.
|
|
var signature string
|
|
switch sl.mode {
|
|
case SealTPM:
|
|
signature, err = sl.tpmSign(hash[:])
|
|
if err != nil {
|
|
// Fallback to software if TPM fails at runtime.
|
|
sl.logger.Warn("TPM sign failed, falling back to HMAC", "error", err)
|
|
signature = sl.hmacSign(hash[:])
|
|
sl.mode = SealSoftware
|
|
}
|
|
case SealSoftware:
|
|
signature = sl.hmacSign(hash[:])
|
|
}
|
|
|
|
// Extend PCR (simulated in software mode).
|
|
sl.extendPCR(hash[:])
|
|
|
|
sealed := SealedEntry{
|
|
Entry: entry,
|
|
Hash: hashHex,
|
|
Signature: signature,
|
|
PCRValue: sl.currentPCR,
|
|
SealMode: sl.mode,
|
|
ChainIdx: int64(len(sl.chain)),
|
|
}
|
|
|
|
// Persist to file.
|
|
line, _ := json.Marshal(sealed)
|
|
line = append(line, '\n')
|
|
if _, err := sl.logFile.Write(line); err != nil {
|
|
return nil, fmt.Errorf("tpmaudit: write log: %w", err)
|
|
}
|
|
|
|
sl.chain = append(sl.chain, sealed)
|
|
sl.stats.TotalEntries++
|
|
sl.stats.LastEntry = time.Now()
|
|
|
|
sl.logger.Info("decision sealed",
|
|
"id", entry.ID,
|
|
"action", entry.Action,
|
|
"decision", entry.Decision,
|
|
"chain_idx", sealed.ChainIdx,
|
|
"mode", sl.mode,
|
|
)
|
|
|
|
return &sealed, nil
|
|
}
|
|
|
|
// VerifyChain validates the entire decision chain integrity.
|
|
func (sl *SealedLogger) VerifyChain() ChainVerification {
|
|
sl.mu.Lock()
|
|
defer sl.mu.Unlock()
|
|
|
|
result := ChainVerification{
|
|
Valid: true,
|
|
TotalEntries: len(sl.chain),
|
|
VerifiedAt: time.Now(),
|
|
Mode: sl.mode,
|
|
}
|
|
|
|
for i, sealed := range sl.chain {
|
|
// Verify hash.
|
|
entryBytes, _ := json.Marshal(sealed.Entry)
|
|
hash := sha256.Sum256(entryBytes)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
if hashHex != sealed.Hash {
|
|
result.Valid = false
|
|
result.BrokenAtIndex = i
|
|
result.BrokenReason = fmt.Sprintf("hash mismatch at index %d", i)
|
|
sl.stats.ChainIntegrity = false
|
|
return result
|
|
}
|
|
|
|
// Verify chain link.
|
|
if i > 0 {
|
|
if sealed.Entry.PreviousHash != sl.chain[i-1].Hash {
|
|
result.Valid = false
|
|
result.BrokenAtIndex = i
|
|
result.BrokenReason = fmt.Sprintf("chain break at index %d: previous_hash mismatch", i)
|
|
sl.stats.ChainIntegrity = false
|
|
return result
|
|
}
|
|
} else {
|
|
if sealed.Entry.PreviousHash != "genesis" {
|
|
result.Valid = false
|
|
result.BrokenAtIndex = 0
|
|
result.BrokenReason = "genesis entry has wrong previous_hash"
|
|
sl.stats.ChainIntegrity = false
|
|
return result
|
|
}
|
|
}
|
|
|
|
// Verify signature.
|
|
if sl.mode == SealSoftware {
|
|
expectedSig := sl.hmacSign(hash[:])
|
|
if expectedSig != sealed.Signature {
|
|
result.Valid = false
|
|
result.BrokenAtIndex = i
|
|
result.BrokenReason = fmt.Sprintf("signature invalid at index %d", i)
|
|
sl.stats.ChainIntegrity = false
|
|
return result
|
|
}
|
|
}
|
|
|
|
result.VerifiedCount++
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ChainLength returns the current chain length.
|
|
func (sl *SealedLogger) ChainLength() int {
|
|
sl.mu.Lock()
|
|
defer sl.mu.Unlock()
|
|
return len(sl.chain)
|
|
}
|
|
|
|
// Stats returns logger metrics.
|
|
func (sl *SealedLogger) Stats() LoggerStats {
|
|
sl.mu.Lock()
|
|
defer sl.mu.Unlock()
|
|
return sl.stats
|
|
}
|
|
|
|
// Close flushes and closes the logger.
|
|
func (sl *SealedLogger) Close() error {
|
|
if sl.logFile != nil {
|
|
return sl.logFile.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Internal ---
|
|
|
|
func (sl *SealedLogger) hmacSign(data []byte) string {
|
|
mac := hmac.New(sha256.New, sl.hmacKey)
|
|
mac.Write(data)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (sl *SealedLogger) tpmSign(data []byte) (string, error) {
|
|
// TODO: Real TPM integration with github.com/google/go-tpm/tpm2.
|
|
// For now, return error to trigger fallback.
|
|
return "", fmt.Errorf("TPM not implemented — use software mode")
|
|
}
|
|
|
|
func (sl *SealedLogger) extendPCR(hash []byte) {
|
|
// Simulate PCR extend: new_pcr = SHA-256(old_pcr || hash).
|
|
oldPCR, _ := hex.DecodeString(sl.currentPCR)
|
|
combined := append(oldPCR, hash...)
|
|
newPCR := sha256.Sum256(combined)
|
|
sl.currentPCR = hex.EncodeToString(newPCR[:])
|
|
}
|
|
|
|
func (sl *SealedLogger) loadExistingChain(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || len(data) == 0 {
|
|
return
|
|
}
|
|
|
|
// Parse JSONL.
|
|
for _, line := range splitLines(data) {
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var sealed SealedEntry
|
|
if err := json.Unmarshal(line, &sealed); err == nil {
|
|
sl.chain = append(sl.chain, sealed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func splitLines(data []byte) [][]byte {
|
|
var lines [][]byte
|
|
start := 0
|
|
for i, b := range data {
|
|
if b == '\n' {
|
|
if i > start {
|
|
lines = append(lines, data[start:i])
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(data) {
|
|
lines = append(lines, data[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func tpmAvailable() bool {
|
|
// Check for TPM device.
|
|
// Linux: /dev/tpm0 or /dev/tpmrm0
|
|
// Windows: TBS (TPM Base Services)
|
|
for _, path := range []string{"/dev/tpm0", "/dev/tpmrm0"} {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|