Files
msa-agent/internal/runtime/replay.go
T

128 lines
4.4 KiB
Go

// Package runtime is where a verified envelope becomes a local HTTP call, or is refused.
//
// The checks here are ordered, and the order is the security property: a request is only decrypted
// after its signature verifies, only routed after its policy decision, and only executed after its
// confirmation intent is shown to match the exact bytes about to be forwarded. Every refusal is
// classified, because the failure modes are indistinguishable from the outside — a node whose clock
// has drifted looks exactly like an attacker replaying a request — and an operator who cannot tell
// them apart will "fix" it by widening the policy.
package runtime
import (
"errors"
"sync"
"time"
)
// ReplayClass names why a request was refused, in terms an operator can act on.
type ReplayClass string
const (
// ReplayDuplicate means this request id was already executed.
ReplayDuplicate ReplayClass = "replay"
// ReplayExpired means the timestamp is outside the clock-skew window.
ReplayExpired ReplayClass = "expired"
)
var (
// ErrReplay is returned for a request id that has already been executed.
ErrReplay = errors.New("runtime: request id was already used")
// ErrExpired is returned for a timestamp outside the clock-skew window.
ErrExpired = errors.New("runtime: request timestamp is outside the allowed window")
)
// ReplayCache is a bounded set of request ids that have already run.
//
// Bounded, not growing: an attacker who can send traffic can also send distinct request ids, and a
// cache that grows to fit them is a memory exhaustion primitive. The bound is set from
// limits.replayCacheSize, which the policy asserts is larger than the relay mailbox depth, so an
// entry can never be evicted while a copy of that request is still queued.
//
// Eviction order is insertion order rather than LRU. LRU would let a flood of fresh ids push out
// the one id an attacker wants to reuse; FIFO eviction still admits that eventually, but only after
// the whole window has turned over, and the timestamp window expires it first in any case.
type ReplayCache struct {
mu sync.Mutex
entries map[string]time.Time
order []string
limit int
ttl time.Duration
}
// NewReplayCache builds a cache holding at most limit ids, each forgotten after ttl even if the
// cache is not yet full. The ttl is what makes the bound independent of traffic volume: ids older
// than the skew window can no longer be accepted anyway.
func NewReplayCache(limit int, ttl time.Duration) (*ReplayCache, error) {
if limit <= 0 {
return nil, errors.New("runtime: replay cache limit must be positive")
}
if ttl <= 0 {
return nil, errors.New("runtime: replay cache ttl must be positive")
}
return &ReplayCache{
entries: make(map[string]time.Time, limit),
order: make([]string, 0, limit),
limit: limit,
ttl: ttl,
}, nil
}
// Seen reports whether id has already been executed, and records it if not.
//
// The check and the insert are one call, not two. Splitting them invites a caller to check, decide,
// and then forget to record — which is a replay window that no test would name.
func (c *ReplayCache) Seen(id string, now time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.expire(now)
if _, ok := c.entries[id]; ok {
return true
}
if len(c.order) >= c.limit {
evicted := c.order[0]
c.order = c.order[1:]
delete(c.entries, evicted)
}
c.order = append(c.order, id)
c.entries[id] = now
return false
}
// Len reports how many ids are held. Used by the health surface and by tests.
func (c *ReplayCache) Len(now time.Time) int {
c.mu.Lock()
defer c.mu.Unlock()
c.expire(now)
return len(c.entries)
}
// expire drops entries older than the ttl. Called under the caller's lock, never exported.
func (c *ReplayCache) expire(now time.Time) {
cutoff := now.Add(-c.ttl)
for len(c.order) > 0 {
oldest := c.order[0]
stamp, ok := c.entries[oldest]
if !ok || stamp.After(cutoff) {
return
}
c.order = c.order[1:]
delete(c.entries, oldest)
}
}
// Freshness applies the timestamp window. It is separate from the cache so the two refusals stay
// distinguishable in the audit log: "expired" means set the clock, "replay" means investigate.
func Freshness(requestTS, now uint64, skewSeconds int) error {
if skewSeconds <= 0 {
return errors.New("runtime: clock skew window must be positive")
}
delta := int64(now) - int64(requestTS)
if delta < 0 {
delta = -delta
}
if delta > int64(skewSeconds) {
return ErrExpired
}
return nil
}