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.
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.
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.
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 ¶
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 ¶
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 ¶
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.
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 )
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.