Files
mpc/pkg/audit/event.go
T
zeekay 82bd565201 mpc: deployment-mode flag + audit dispatcher for fund-private clusters
Adds the topology contract that lets a fund run mpcd as its own private
cluster — no Lux primary network registration, no shared M-Chain audit
ledger — using the same code path and the same operator surface.

Mode flag at cmd/mpcd/main.go:
  --network=primary|private              # registration target
  --audit-store=mchain|local-worm|...    # audit log destination
  --registrar-url, --registrar-auth      # primary-mode announcement
  --audit-worm-path, --audit-mchain-url  # backend selectors

pkg/audit:
  - Append-only Merkle hash chain. Tampering with any historical event
    invalidates every subsequent hash. Canonical bytes use fixed-order
    concatenation (json.Marshal map ordering is non-deterministic).
  - WORMDispatcher: NDJSON append, replay-on-start, fsync per Append.
  - MChainDispatcher: batched POST of (batchHash, fromSeq, toSeq) to a
    configurable anchor RPC.
  - compositeDispatcher: fan-out to multiple legs with per-leg sealing.
  - S3 Glacier Vault / Azure Immutable / GCS Object Lock dispatchers
    fail closed — wired into the flag surface but gated behind the
    cloud-audit build tag, no silent fallback.

pkg/identity/bootstrap.go: identity creation + (primary-mode only)
registrar announcement. Same on-disk format as the previous
loadOrGenerateIdentity (which is now removed); private mode is silent.

deployments/k8s-private: fund-only Kustomize overlay. Standalone
namespace, NetworkPolicy that blocks egress to anything outside the
fund's VPC, hardware-WORM PVC for the audit log, KMS-driven secret
template.

deployments/k8s-primary: env-var patch overlay on the existing lux-mpc
base. composite(local-worm,mchain) by default.

deployments/terraform/{aws,gcp,azure}: VPC + audit storage modules.
terraform validate passes; EKS/GKE/AKS + HSM modules are stubbed and
not yet plan-tested.

deployments/onprem/README.md: WORM storage + PKCS#11 HSM checklist.

Tests (14 total, all passing):
  pkg/audit:           6/6  (chain, WORM replay, MChain batching, composite,
                             cloud stubs fail loud, double-seal rejected)
  pkg/identity:        4/4  (private no-call, primary call, primary empty
                             URL no-op, primary registrar 503 surfaces)
  internal/privatemode: 4/4 (no registrar traffic, restart, tamper-detect,
                             cloud stubs fail loud)

Companion runbook in lux/papers/lux-private-mpc-deployment/
(LaTeX, 5 pages, tectonic-clean).
2025-12-28 16:20:40 -08:00

135 lines
4.4 KiB
Go

// Package audit provides an append-only audit log with a Merkle hash chain.
//
// Every Event carries: a sequence number, the SHA-256 of the previous event,
// and the SHA-256 of the canonical bytes of (PrevHash || SerializedPayload).
// Tampering with any historical event invalidates every subsequent hash, so
// verification reduces to recomputing the chain.
//
// The Dispatcher interface lets the same chain be persisted to different
// targets — the local WORM file, the Lux M-Chain anchor RPC, S3 Glacier
// Vault Lock, Azure Immutable Blob Storage, or GCS Object Lock. Mode
// selection happens once at startup via NewDispatcher; the rest of the
// daemon only sees the interface.
package audit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// EventKind enumerates the audit event types emitted by mpcd.
//
// Keep this list closed: adding a kind is a wire-protocol change and must
// be coordinated with verifiers. New kinds go at the end of the constant
// block to preserve any external numeric ordering.
type EventKind string
const (
KindKeygen EventKind = "keygen"
KindSign EventKind = "sign"
KindReshare EventKind = "reshare"
KindBackup EventKind = "backup"
KindBootstrap EventKind = "bootstrap"
KindPeerJoin EventKind = "peer_join"
KindPeerLeave EventKind = "peer_leave"
KindPolicyApply EventKind = "policy_apply"
)
// Event is one entry in the audit log.
//
// The Hash field is the SHA-256 of canonical(PrevHash || Seq || NodeID ||
// Kind || OrgID || WalletID || Timestamp || Payload). It is computed by
// Seal and never set by callers; ComputeHash will reject an Event with a
// pre-populated Hash to prevent forgery.
type Event struct {
Seq uint64 `json:"seq"`
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
Timestamp time.Time `json:"ts"`
NodeID string `json:"node_id"`
Kind EventKind `json:"kind"`
OrgID string `json:"org_id,omitempty"`
WalletID string `json:"wallet_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// canonicalBytes returns the bytes that go into the SHA-256 for Hash.
// Order is fixed: prev || seq(8 BE) || node || kind || org || wallet ||
// unix-nano(8 BE) || payload. We intentionally do NOT use json.Marshal
// here because Go's map iteration ordering would defeat reproducibility.
func (e *Event) canonicalBytes() []byte {
prev, _ := hex.DecodeString(e.PrevHash)
buf := make([]byte, 0, 256+len(e.Payload))
buf = append(buf, prev...)
buf = appendUint64BE(buf, e.Seq)
buf = append(buf, []byte(e.NodeID)...)
buf = append(buf, byte(0)) // separator — NodeID may be empty
buf = append(buf, []byte(e.Kind)...)
buf = append(buf, byte(0))
buf = append(buf, []byte(e.OrgID)...)
buf = append(buf, byte(0))
buf = append(buf, []byte(e.WalletID)...)
buf = append(buf, byte(0))
buf = appendUint64BE(buf, uint64(e.Timestamp.UnixNano()))
buf = append(buf, e.Payload...)
return buf
}
// ComputeHash returns the canonical SHA-256 hex of e. e.Hash must be empty.
func (e *Event) ComputeHash() (string, error) {
if e.Hash != "" {
return "", fmt.Errorf("audit: event already sealed (Hash=%q)", e.Hash)
}
sum := sha256.Sum256(e.canonicalBytes())
return hex.EncodeToString(sum[:]), nil
}
// Seal sets Seq, PrevHash, Timestamp (if zero) and Hash on e.
// Mutates e and returns it.
func (e *Event) Seal(seq uint64, prevHash string) (*Event, error) {
e.Seq = seq
e.PrevHash = prevHash
if e.Timestamp.IsZero() {
e.Timestamp = time.Now().UTC()
}
h, err := e.ComputeHash()
if err != nil {
return nil, err
}
e.Hash = h
return e, nil
}
// VerifyChain walks events in order and confirms each Hash chains to its
// PrevHash. Returns the index of the first inconsistent event, or -1 if
// the whole slice is intact.
func VerifyChain(events []*Event) (int, error) {
prev := ""
for i, ev := range events {
if ev.PrevHash != prev {
return i, fmt.Errorf("audit: event %d prev=%q expected %q", i, ev.PrevHash, prev)
}
want := ev.Hash
ev.Hash = "" // recompute requires empty
got, err := ev.ComputeHash()
ev.Hash = want
if err != nil {
return i, err
}
if got != want {
return i, fmt.Errorf("audit: event %d hash=%q recomputed %q", i, want, got)
}
prev = want
}
return -1, nil
}
func appendUint64BE(b []byte, v uint64) []byte {
return append(b,
byte(v>>56), byte(v>>48), byte(v>>40), byte(v>>32),
byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}