authcost

package
v1.0.227 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package authcost is the admission governor for the ONE deliberately expensive operation on Culvert's per-request authentication path: the bcrypt comparison that validates a presented proxy credential against the local account. It is a self-contained stdlib-only leaf per ADR-0002.

Why this is its own engine

bcrypt is expensive ON PURPOSE — that is the whole point of a password hash. At the cost factor Culvert stores (`bcrypt.DefaultCost`) one comparison measures **~80 ms of exclusive CPU** on the reference 4-core box. On a gateway that authenticates on EVERY request, that constant is not a property of the login form; it is a property of the data path, and it is reachable by anybody who can send the proxy a TCP connection.

The pre-fix path had no bound on it of any kind:

  • The wrong-username branch runs a comparison against a fixed dummy hash (RISK-008 — so a wrong username is not distinguishable from a wrong password by timing). That branch is reached BEFORE the result cache and never populates it, so a flood of DISTINCT usernames is a guaranteed cache miss every time.
  • The three front-door limiters that would otherwise cap the arrival rate — the per-IP connection limiter, the request rate limiter and the IP filter — all ship DISABLED by default (`-rate-limit 0`, connlimit disabled, no filter configured). In the shipped posture nothing at all stood in front of this.
  • Nothing capped CONCURRENCY, so N simultaneous requests put N goroutines into bcrypt at once and the scheduler shared every core between them.

Measured on the pre-fix tree (4 cores, `zz` probe reproduced as the defect gates in this package and in the root `auth_cost_chaos_test.go`):

one wrong-username attempt          79.6 ms of CPU
one cached successful auth           1.5 µs
amplification                       51,631x
rate that saturates all four cores  66 req/s  (~13 KB/s on the wire)
degradation to other CPU work       15.6x, at 64 attacker connections

So ~13 KB/s of traffic from one unauthenticated source consumes 100% of a four-core gateway, and everything else the appliance must do per request — TLS handshakes, DPI scanning, policy evaluation, relay copying — is competing for what is left. That is a remotely triggerable denial of service against the data plane, in the DEFAULT configuration, requiring no credentials and no knowledge of the deployment.

The policy

Two bounds, both fail-closed, and one fairness rule.

  1. A GLOBAL CEILING on concurrent verifications, sized as a fraction of GOMAXPROCS (see DefaultMaxConcurrent). Credential verification can therefore never consume more than roughly half the machine, whatever the arrival rate and however many distinct sources it comes from. This is the bound that holds against a DISTRIBUTED flood, where no per-client rule can help.

  2. A PER-CLIENT CEILING (DefaultMaxPerClient = 1) on how many of those slots one client key may occupy AT ONCE. Without it a single source could hold every slot and the global ceiling would bound the CPU while still denying every other user — the fault would be contained and the outage would not.

    A client already at its cap WAITS for its own earlier verification to finish; it is NOT refused on the spot. Getting that wrong is a self-inflicted outage and it was the first shape of this engine: a single workstation opening its ordinary parallel connection pool, all of whose cached results expired together, had five of six VALID credentials denied with no attacker present. Waiting costs the fairness rule nothing — the cap still bounds how many slots one source holds at any instant — and only changes what happens to the excess: serialised behind its own predecessor rather than rejected.

    The wait is bounded like every other, so a burst deeper than roughly DefaultMaxWait / (cost of one verification) — about a dozen concurrent requests from ONE client at the measured ~80 ms — still ends in a refusal. A browser pool is six to eight, so it fits with room to spare; a client genuinely needing more concurrent credential checks than that is not a shape this gateway should absorb silently.

  3. A BOUNDED WAIT (DefaultMaxWait) with a BOUNDED QUEUE (maxWaiters). A legitimate burst — a fleet of clients whose cached results expired together, a password rotation — is absorbed rather than refused, because graceful degradation is the house preference. The queue is capped because an unbounded one would convert a CPU-exhaustion vector into a goroutine-and-memory one: an attacker who can park an unlimited number of waiters for free has simply been handed a different resource to exhaust. Trading one exhaustion for another is not a fix, so both are bounded explicitly.

Refusal DENIES the request. That is the fail-closed direction and it is the safe one: the cost of a spurious refusal is a 407 the client retries, and the cost of admitting is the outage above. It is never silent — every refusal is counted by reason, and the root health plane (`auth_cost_health.go`) turns those counters into a contract row, a rate-limited log line and an alert.

What this deliberately does NOT do

It does not cache, memoise or otherwise decide anything about the CREDENTIAL. It knows nothing about usernames, passwords or verdicts, and it must not: the admission decision is taken by the caller BEFORE the username is compared, precisely so that being over budget cannot leak whether a username exists. A gate whose behaviour depended on the credential would reintroduce the username-enumeration oracle RISK-008 closed. See the call site in store.go.

Index

Constants

View Source
const (
	// DefaultMaxPerClient is how many concurrent verifications one client key
	// may have in flight. One, because a single client that genuinely needs a
	// second concurrent credential check while the first is still running is
	// either a flood or a misconfiguration, and in both cases making it wait
	// is correct. Raising this directly widens the share of the global ceiling
	// a single source can occupy.
	DefaultMaxPerClient = 1

	// DefaultMaxWait bounds how long a caller will wait for a slot. It is
	// sized against the queue: with the default ceiling and queue depth the
	// deepest legitimate wait is (maxWaiters/maxConcurrent) x ~80 ms, so one
	// second covers a completely full queue with headroom, while still being
	// far below any client's proxy timeout.
	DefaultMaxWait = 1 * time.Second
)

Variables

This section is empty.

Functions

func DefaultMaxConcurrent

func DefaultMaxConcurrent() int

DefaultMaxConcurrent is the global ceiling on concurrent credential verifications: half of GOMAXPROCS, floored at one.

Half, not all, because the gateway's REAL work — TLS handshakes, scanning, policy evaluation, relaying — has to keep running while somebody is authenticating. A ceiling equal to GOMAXPROCS bounds the fault in the sense that it stops being unbounded, but still permits credential verification to occupy every core, which is the outage this engine exists to prevent.

Floored at one rather than two so the guarantee holds on a single-core appliance, where two concurrent bcrypts is already 100% of the machine.

Sizing sanity for the other direction: at ~80 ms per verification a ceiling of K sustains K/0.08 verifications per second — 25/s on a four-core box. Successful results are cached for `authCacheTTL` (5 minutes), so a deployment's steady-state UNCACHED rate is (active users / 300 s): even 500 users behind one gateway is ~1.7/s, better than an order of magnitude below the ceiling. The ceiling bites under attack, not under load.

Types

type Gate

type Gate struct {
	// contains filtered or unexported fields
}

Gate is the admission governor. Construct with New; the zero value is not usable. All methods are safe for concurrent use.

func New

func New(maxConcurrent, maxPerClient int, maxWait time.Duration) *Gate

New builds a Gate. Non-positive arguments fall back to the defaults, so a caller can pass a partially-specified configuration without ever constructing a gate that admits everything (maxConcurrent <= 0) or nothing.

func (*Gate) Admit

func (g *Gate) Admit(client string) (result Refusal, queued bool)

Admit asks permission to run one credential verification on behalf of client.

On Admitted the caller holds a slot and MUST call Release(client) exactly once, on every path out including panics — use `defer`. On any refusal the caller holds nothing and MUST NOT call Release.

queued reports whether the admission had to wait for a slot. It is meaningful only when the refusal is Admitted, and it is returned rather than derived from a counter because the caller uses it to decide RECOVERY: an admission that had to queue is not evidence that capacity has returned.

client is an eviction-fairness key, not an identity: it is never used for lookup, authentication or authorization, and the empty string is a valid key (all callers that cannot resolve a peer share one bucket, which can only throttle each other). See authStateClientKey in the root package for the derivation and why it goes through realClientIP.

func (*Gate) Release

func (g *Gate) Release(client string)

Release returns the slot held by an Admitted caller.

func (*Gate) Saturated

func (g *Gate) Saturated() bool

Saturated reports whether every verification slot is currently occupied.

func (*Gate) Stats

func (g *Gate) Stats() Stats

Stats returns a snapshot for the reporting surfaces.

type Refusal

type Refusal uint8

Refusal classifies why a verification was not admitted. The set is small, closed and stable because it reaches a metric label: an unbounded reason string on a per-request path is the WK-12/RS-5 cardinality defect.

const (
	// Admitted is the zero value: the caller holds a slot and MUST Release it.
	Admitted Refusal = iota
	// RefusedPerClient — this client key already holds its maximum number of
	// concurrent verifications.
	RefusedPerClient
	// RefusedQueueFull — every slot is busy and the bounded wait queue is full.
	RefusedQueueFull
	// RefusedTimeout — the caller waited its full budget without a slot
	// becoming free.
	RefusedTimeout
)

func (Refusal) String

func (r Refusal) String() string

String renders the refusal as the stable metric-label / log token.

type Stats

type Stats struct {
	// MaxConcurrent / MaxPerClient are the effective bounds, reported so an
	// operator reading a saturation alert can see what it saturated against.
	MaxConcurrent int
	MaxPerClient  int

	// InFlight and Queued are instantaneous.
	InFlight int
	Queued   int

	// Admitted counts verifications that were allowed to run bcrypt.
	Admitted uint64

	// Refused* count fail-closed denials by reason. Their sum is the blast
	// radius of the governor: requests that were denied WITHOUT their
	// credential ever being checked.
	RefusedPerClient uint64
	RefusedQueueFull uint64
	RefusedTimeout   uint64

	// Waited counts admissions that had to queue for a slot. It is the leading
	// indicator: a climbing Waited with no refusals means the ceiling is being
	// approached but is still absorbing, which is exactly when an operator
	// wants to look rather than after users start seeing 407s.
	Waited uint64

	// PeakInFlight / PeakQueued are high-water marks since startup, so an
	// incident that has already drained is still visible on a contract row
	// that only ever sees "right now".
	PeakInFlight int
	PeakQueued   int
}

Stats is the reporting snapshot consumed by the root health plane.

func (Stats) Refusals

func (s Stats) Refusals() uint64

Refusals is the total number of fail-closed denials across all reasons.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL