128 lines
4.4 KiB
Go
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
|
|
}
|