// 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 }