Files
zeekay cc986c328d lux/kms: orphan history at f2e793bf8 (was: kms: wire luxfi/mpc as REK backend)
Squashes all pre-rewrite history into a single root commit. Previous tree:
- 14506 commits including upstream Infisical fork history
- Multiple merge chains carrying pre-purge content

Force-rewrite per disk-space reclamation discipline. GHCR images
(1.9.9, 1.9.11) remain immutable; their git lineage is no longer
materialized.
2026-06-07 14:28:09 -07:00

219 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// nonce_ledger.go — anti-replay ledger for envelope nonces.
//
// The wall-clock freshness window (MaxClockSkew) caps how long a captured
// envelope is replayable, but within the window an attacker who observes
// one valid envelope can resubmit it verbatim. For OpSecretGet that
// produces audit-log churn the attacker controls; for OpSecretPut /
// OpSecretDelete it can be weaponised to obscure a parallel intrusion in
// audit noise or to cancel a legitimate in-flight delete. We close the
// window by ledgering (NodeID, Nonce) on the verifier side and rejecting
// any second sighting before the nonce ages out of the ledger.
//
// Design properties
//
// - Per-node nonce space. Two distinct signers may reuse the same
// nonce byte-for-byte without collision; the keying tuple is
// (NodeID, Nonce). This matches the wire reality: nonces are
// caller-fresh, not globally coordinated.
//
// - TTL = MaxClockSkew + 1m. Once an entry has aged past TTL, the
// clock-skew check would reject any envelope bearing the matching
// timestamp anyway, so reusing the nonce after TTL is safe and the
// ledger can GC the entry.
//
// - First-write-wins. SeenOrInsert returns true (duplicate) for every
// call after the first within TTL. The first caller wins; the second
// call sees `seen == true` and the verifier rejects.
//
// - Race-clean. sync.Map handles the lookup/insert atomically via
// LoadOrStore. No two goroutines can both observe `seen == false`
// for the same key.
//
// - GC budget. A background loop sweeps expired entries every
// gcInterval (default = TTL/4 = 90s for the default 6m TTL). Inline
// opportunistic GC is NOT performed in the hot path — the wall-clock
// check happens earlier and the ledger Insert path stays O(1).
//
// Production sizing (informational, no enforcement)
//
// 100 services × 60 envelopes/min × 6m TTL ≈ 36 000 live entries.
// sync.Map overhead per entry ≈ 80 bytes (key 36 + value 8 + Go map
// overhead). Working set ≈ 3 MB. Well within budget for an in-process
// ledger; no need to fall back to disk-backed storage at this scale.
package envelope
import (
"context"
"sync"
"time"
"github.com/luxfi/ids"
)
// NonceLedger is the contract the envelope verifier talks to. Production
// impls are typically the in-memory ledger; deployments that need
// cross-process replay defence (e.g. multi-replica kmsd behind a load
// balancer) supply a shared backend.
//
// The contract:
//
// - SeenOrInsert returns (true, nil) iff (node, nonce) is already
// recorded as live in the ledger. Returns (false, nil) and atomically
// records the entry on the first call within TTL.
//
// - SeenOrInsert MUST be safe for concurrent use. The verifier calls
// it on every request; a serial impl would bottleneck the surface.
//
// - SeenOrInsert MUST be deterministic per key: once a key returns
// (false, nil), every subsequent call within TTL returns (true, nil)
// until GC ages the entry out.
//
// Errors are reserved for transport failures in disk- or network-backed
// impls. The in-memory ledger never returns an error.
type NonceLedger interface {
SeenOrInsert(ctx context.Context, node ids.NodeID, nonce string, now time.Time) (bool, error)
}
// DefaultNonceLedgerTTL is the per-entry lifetime. Slightly larger than
// MaxClockSkew so an envelope arriving at the edge of the skew window
// still finds the prior copy in the ledger.
const DefaultNonceLedgerTTL = MaxClockSkew + time.Minute
// DefaultNonceLedgerGCInterval is the sweep cadence for the background
// GC goroutine. Smaller values reduce peak memory but burn CPU; the
// default trades 25% TTL granularity for low-cost sweeps.
const DefaultNonceLedgerGCInterval = DefaultNonceLedgerTTL / 4
// nonceKey is the composite map key. NodeID is 20 bytes; nonce string is
// caller-fresh (typically 16 bytes base64-encoded → 24 chars). Both are
// value types so the struct is map-key safe.
type nonceKey struct {
node ids.NodeID
nonce string
}
// MemoryNonceLedger is the canonical in-process NonceLedger. Backed by
// sync.Map for low-contention concurrent SeenOrInsert.
type MemoryNonceLedger struct {
ttl time.Duration
gcInterval time.Duration
entries sync.Map // map[nonceKey]time.Time — first-seen wall-clock
stopCh chan struct{}
stopMu sync.Mutex
}
// MemoryNonceLedgerConfig wires a MemoryNonceLedger. Zero values pick
// the package defaults (DefaultNonceLedgerTTL, DefaultNonceLedgerGCInterval).
type MemoryNonceLedgerConfig struct {
// TTL is the per-entry lifetime. 0 → DefaultNonceLedgerTTL.
TTL time.Duration
// GCInterval is the background sweep cadence. 0 → DefaultNonceLedgerGCInterval.
GCInterval time.Duration
}
// NewMemoryNonceLedger returns a fresh in-memory ledger and starts its
// background GC goroutine. Call Stop() at process shutdown to release
// the goroutine. Production callers typically wire one ledger per kmsd
// process and never Stop until the daemon shuts down.
func NewMemoryNonceLedger(cfg MemoryNonceLedgerConfig) *MemoryNonceLedger {
ttl := cfg.TTL
if ttl <= 0 {
ttl = DefaultNonceLedgerTTL
}
gc := cfg.GCInterval
if gc <= 0 {
gc = DefaultNonceLedgerGCInterval
}
l := &MemoryNonceLedger{
ttl: ttl,
gcInterval: gc,
stopCh: make(chan struct{}),
}
go l.gcLoop()
return l
}
// SeenOrInsert atomically checks and records (node, nonce). Returns true
// if the key was already present (replay), false if first sighting.
func (l *MemoryNonceLedger) SeenOrInsert(_ context.Context, node ids.NodeID, nonce string, now time.Time) (bool, error) {
k := nonceKey{node: node, nonce: nonce}
// LoadOrStore returns (existing, true) if the key was present, or
// (stored, false) on first insert. Two concurrent inserts race
// atomically — exactly one returns (_, false).
prev, loaded := l.entries.LoadOrStore(k, now)
if !loaded {
return false, nil
}
// A prior entry exists. If it has aged past TTL we treat the slot
// as expired — the GC just hasn't swept it yet — and overwrite with
// the new wall-clock. Concurrent overwrites collapse to last-write-
// wins; either outcome leaves a fresh entry, which is what the
// caller would have gotten from a clean ledger.
if prevTS, ok := prev.(time.Time); ok && now.Sub(prevTS) >= l.ttl {
l.entries.Store(k, now)
return false, nil
}
return true, nil
}
// Size reports the live entry count. Useful for tests + observability.
// Walks the entire map; not a hot-path operation.
func (l *MemoryNonceLedger) Size() int {
n := 0
l.entries.Range(func(_, _ any) bool {
n++
return true
})
return n
}
// Stop ends the background GC goroutine. Safe to call multiple times.
func (l *MemoryNonceLedger) Stop() {
l.stopMu.Lock()
defer l.stopMu.Unlock()
select {
case <-l.stopCh:
// already stopped
default:
close(l.stopCh)
}
}
// gcLoop sweeps expired entries every gcInterval. Runs until Stop() is
// called. Each sweep is O(n) over the entry map; at 36k entries this is
// microseconds — invisible to the request path.
func (l *MemoryNonceLedger) gcLoop() {
t := time.NewTicker(l.gcInterval)
defer t.Stop()
for {
select {
case <-l.stopCh:
return
case now := <-t.C:
l.gcOnce(now)
}
}
}
// gcOnce is one sweep. Exported via test hook only — production callers
// rely on the background goroutine.
func (l *MemoryNonceLedger) gcOnce(now time.Time) {
cutoff := now.Add(-l.ttl)
l.entries.Range(func(k, v any) bool {
if ts, ok := v.(time.Time); ok && ts.Before(cutoff) {
// CompareAndDelete protects against a concurrent
// SeenOrInsert that re-inserted a fresh entry at the
// same key after we read it: only delete the exact
// stale value.
l.entries.CompareAndDelete(k, ts)
}
return true
})
}