From cbb552ca981574beedbc8f38144e64b69d9af474 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 01:30:30 +0200 Subject: [PATCH 01/17] fix(password): bound stored hash parameters and refuse truncated passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hashers trusted the parameters encoded in a stored hash, so whoever could write or import a hash controlled the cost of every verification against it: an argon2id hash with t=0, p=0, a missing parameter or an empty tag panicked inside golang.org/x/crypto, m=4294967295 made one verification try to allocate 4 TiB, a 1-byte tag accepted about one random password in 256, and a stored bcrypt cost of 31 asked for days of CPU. Verify now decodes and checks the stored hash before any hashing: RFC 9106 §3.1 ranges (t >= 1, p >= 1, m >= 8p), each of m, t, p exactly once and nothing else, version 19, salt and tag of 16 to 64 bytes, an encoded length bounded before parsing (ErrMalformedHash), and inclusive operator limits on the cost (ErrCostLimitExceeded): argon2id 256 MiB, 10 passes, 16 lanes by default (WithArgon2idLimits), bcrypt cost 14 (WithBCryptMaxCost), both raised to the hasher's own parameters. The constructors apply the same bounds and panic on static misconfiguration; NewBCryptHasher no longer clamps an out-of-range cost silently. bcrypt Verify compared only the first 72 bytes of a longer password while Hash refused it; NIST SP 800-63B-4 §3.1.1.2 requires the entire password to be verified, so both now return ErrPasswordTooLong. Empty passwords are refused by Hash (ErrEmptyPassword) and never accepted by Verify (RFC 8265 §4.1). NeedsRehash also flags a shorter salt and every hash Verify refuses. The hashers still apply no Unicode normalization: the package documentation shows an NFC decorator (SP 800-63B-4 SHOULD). Documentation fixed: the default bcrypt cost is 10, the argon2id defaults are the OWASP minimum rather than RFC 9106 §4's recommendation, and ErrMismatch is never returned by the shipped hashers. Audit: CORE-06, CORE-I7. --- CHANGELOG.md | 34 +++ LIMITATIONS.md | 14 ++ MIGRATION.md | 59 +++++ docs/security-considerations.md | 44 +++- password/argon2id.go | 312 +++++++++++++++++++------ password/bcrypt.go | 123 ++++++++-- password/bcrypt_test.go | 17 -- password/bounds_test.go | 393 ++++++++++++++++++++++++++++++++ password/doc.go | 48 +++- password/errors.go | 43 +++- password/hasher.go | 29 ++- password/refusals_test.go | 185 +++++++++++++++ 12 files changed, 1157 insertions(+), 144 deletions(-) create mode 100644 password/bounds_test.go create mode 100644 password/refusals_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a8e3af..40c36a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -486,6 +486,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `interaction_required`, `account_selection_required`, `consent_required`) — are answered with a bare error page instead of a redirect (RFC 9700 §4.11.2). Without it, every registered client is trusted, as before. +- `password`: limits on the cost of stored hashes — `Argon2idLimits`, + `DefaultArgon2idLimits` (256 MiB, 10 passes, 16 lanes), + `WithArgon2idLimits`, `Argon2idHasher.Limits`; `WithBCryptMaxCost`, + `DefaultBCryptMaxCost` (14), `BCryptHasher.MaxCost` — and the sentinels + `ErrCostLimitExceeded`, `ErrPasswordTooLong` and `ErrEmptyPassword`. ### Changed @@ -755,6 +760,16 @@ legacy packages (`authentication/`, `authorization/`, the in-tree unless `WithOutboundPolicy` allows them, and follows redirects within the JWK Set URL's origin only; RFC 9068 access tokens need a non-empty audience from the `IssuerResolver`. +- `password`: `NewArgon2idHasher` panics on a memory cost below 8×lanes or a + key or salt length outside 16–64 bytes, and `NewBCryptHasher` on a cost + outside 4–31 (it used to clamp it silently); both take options + (`WithArgon2idLimits`, `WithBCryptMaxCost`). `Verify` refuses, before any + hashing, a stored hash outside the bounds (`ErrMalformedHash`) or above the + limits (`ErrCostLimitExceeded`); bcrypt refuses passwords longer than 72 + bytes (`ErrPasswordTooLong`) instead of verifying their first 72 bytes; + `Hash` refuses an empty password (`ErrEmptyPassword`) and `Verify` never + accepts one; `NeedsRehash` also flags a shorter salt and every hash + `Verify` refuses. See [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -956,6 +971,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree and deduplicated fetches, and `docs/security-considerations.md` said JWK Sets were fetched through `outbound`: none of it held (see Security). - `now == exp` was accepted by the JWT verifier (RFC 7519 §4.1.4). +- `password` documentation: the default bcrypt cost is 10 + (`bcrypt.DefaultCost`), not 12; `DefaultArgon2idParams` is the OWASP + Password Storage Cheat Sheet minimum, not RFC 9106 §4's recommendation; + `ErrMismatch` is never returned by the shipped hashers (a mismatch is + `(false, nil)`). - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It @@ -1623,3 +1643,17 @@ legacy packages (`authentication/`, `authorization/`, the in-tree while the grants refused them there (OIDC Core §16.15). And without an `IntrospectionPolicy`, any confidential client — every resource server — could read the claims of any client's refresh token (RFC 6749 §1.5). +- `password` hashers trusted the parameters of the stored hash: an argon2id + hash with `t=0`, `p=0`, a missing parameter or an empty tag panicked + inside `golang.org/x/crypto` (fatal in a gRPC server without a recovery + interceptor), `m=4294967295` made a single verification try to allocate + 4 TiB, a 1-byte tag accepted about one random password in 256, and a + stored bcrypt cost of 31 asked for days of CPU. Whoever can write or + import a hash — a migration, a compromised store — controlled that cost. + `Verify` now checks the stored hash before any hashing: RFC 9106 §3.1 + ranges, each parameter exactly once, salt and tag of 16–64 bytes, and + operator limits on the cost. +- bcrypt `Verify` accepted any password whose first 72 bytes matched, + contrary to NIST SP 800-63B-4 §3.1.1.2 ("SHALL verify the entire submitted + password"), while `Hash` refused such passwords; it now refuses them. + Empty passwords are no longer hashed nor accepted (RFC 8265 §4.1). diff --git a/LIMITATIONS.md b/LIMITATIONS.md index d9bae009..a629ee22 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -462,6 +462,20 @@ PKCE. Not supported, and refused where a provider could select them: expects the user-id and password in Unicode Normalization Form C; `basic` checks them as received. +## Passwords + +- **No Unicode normalization** — the hashers hash a password's bytes as + given, so a password typed in NFD on one device and NFC on another does + not verify. NIST SP 800-63B-4 §3.1.1.2 says verifiers SHOULD apply NFC: + wrap the hasher in the decorator shown in the `password` package + documentation (it needs `golang.org/x/text`, which the module does not + depend on). +- **bcrypt: 72 bytes** — longer passwords are refused (`ErrPasswordTooLong`), + which caps passwords of multi-byte characters below the 64 characters + SP 800-63B-4 asks verifiers to permit. Hashes imported from an + implementation that truncated silently need the migration wrapper of + MIGRATION.md. Prefer Argon2id. + ## Sessions - The session module is stateless: the whole session lives in an encrypted diff --git a/MIGRATION.md b/MIGRATION.md index e5cc3eaa..12eb33de 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1997,3 +1997,62 @@ To migrate: `AllowImplicit` there, and the server would serve the flow to plain OAuth 2.0 requests and advertise it in its RFC 8414 metadata while the discovery document does not — the disagreement RFC 9068 §4 forbids. + +### `password`: stored hashes are bounded; truncated and empty passwords are refused + +The hashers trusted the parameters encoded in a stored hash. Whoever could +write or import a hash controlled the cost of every verification against it +— up to a panic inside `golang.org/x/crypto`, a 4 TiB allocation or days of +CPU — and could plant an argon2id hash with a 1-byte tag that accepted about +one random password in 256. bcrypt `Verify` also accepted any password whose +first 72 bytes matched, and empty passwords hashed and verified. `Verify` now +checks the stored hash before any hashing: + +| | Before | After | +| --- | --- | --- | +| argon2id `t=0`, `p=0`, `m < 8p`, missing or repeated parameter, unknown parameter, empty tag | panic or accepted | `ErrMalformedHash` (RFC 9106 §3.1) | +| argon2id salt or tag outside 16–64 bytes | accepted | `ErrMalformedHash` (library policy) | +| argon2id `m`, `t`, `p` above the limits (256 MiB, 10, 16 by default) | honored | `ErrCostLimitExceeded`, before any hashing | +| bcrypt stored cost above the limit (14 by default) | honored up to 31 | `ErrCostLimitExceeded`, before any hashing | +| bcrypt password longer than 72 bytes | `Hash`: error; `Verify`: first 72 bytes compared | `ErrPasswordTooLong` from both | +| empty password | hashed and verified | `Hash`: `ErrEmptyPassword`; `Verify`: `(false, nil)` | +| `NewBCryptHasher(cost)` outside 4–31 | clamped silently | panic | +| `NewArgon2idHasher` with `m < 8p`, key or salt length outside 16–64 bytes | accepted | panic | +| `NeedsRehash` | memory, passes, lanes, key length | also the salt length, and `true` for every hash `Verify` refuses | + +The limits are inclusive and never refuse the hasher's own parameters. They +accept the hashes of every mainstream implementation's default profile +(OWASP, RFC 9106 §4's second option, libsodium interactive and moderate, +PHP, Spring Security, Keycloak). + +To migrate: + +1. Hashes made with 1 GiB or more of memory (RFC 9106 §4's first option, + libsodium's sensitive profile) or with a bcrypt cost above 14: raise the + limits — `password.WithArgon2idLimits(password.Argon2idLimits{MaxMemoryKiB: + 2 << 20})`, `password.WithBCryptMaxCost(15)` — or configure the hasher + with those parameters. Watch for `ErrCostLimitExceeded` after the upgrade. +2. bcrypt hashes imported from an implementation that silently truncated + passwords to 72 bytes — older `golang.org/x/crypto` releases (this + library's v0 stack used one), PHP's `password_hash`, Node's `bcrypt` — + no longer verify a password longer than 72 bytes. If such accounts exist, + wrap the hasher for them and move them to Argon2id at their next login: + ```go + type truncating struct{ password.Hasher } + + func (t truncating) Verify(ctx context.Context, hash, pw string) (bool, error) { + if len(pw) > 72 { + pw = pw[:72] // what the truncating system hashed + } + return t.Hasher.Verify(ctx, hash, pw) + } + ``` + then, on success, hash the full password with an `Argon2idHasher` and + store it. +3. Accounts with an empty password can no longer sign in: reset them. +4. Static configuration errors now panic at start-up: pass a bcrypt cost in + 4–31 (0 selects 10; costs below 10 are for tests) and argon2id parameters + within the documented bounds. +5. Unicode: the hashers still hash bytes as given. To apply NFC (NIST + SP 800-63B-4 §3.1.1.2), use the decorator of the `password` package + documentation at enrollment and at login. diff --git a/docs/security-considerations.md b/docs/security-considerations.md index c632ad6d..4508f569 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -7,18 +7,46 @@ ships, the attacks it defends against, and the choices left to the operator. Two `password.Hasher` implementations are shipped: -- **bcrypt** — `NewBCryptHasher(cost)`. Constant-time comparison is provided - by `golang.org/x/crypto/bcrypt`. - **Argon2id** — `NewArgon2idHasher(params)`. The default profile - (`DefaultArgon2idParams`) follows RFC 9106 §4 / OWASP 2024: memory 19 MiB, - time 2, parallelism 1, 32-byte key, 16-byte salt. + (`DefaultArgon2idParams`) is the minimum of the OWASP Password Storage + Cheat Sheet: memory 19 MiB, time 2, parallelism 1, 32-byte key, 16-byte + salt. RFC 9106 §4 recommends stronger options (2 GiB, t=1, p=4; or 64 MiB, + t=3, p=4 when memory is scarce): raise the parameters towards them when the + hardware allows. +- **bcrypt** — `NewBCryptHasher(cost)`, default cost 10 (`bcrypt.DefaultCost`, + the OWASP minimum; lower costs are for tests). bcrypt processes at most 72 + bytes: a longer password is refused by `Hash` and `Verify` + (`ErrPasswordTooLong`) rather than verified on its first 72 bytes — NIST + SP 800-63B-4 §3.1.1.2 requires the entire password to be verified. That + caps passwords of multi-byte characters below the 64 characters + SP 800-63B-4 asks verifiers to permit: prefer Argon2id. + +**Stored hashes are untrusted input.** `Verify` decodes the parameters of the +stored hash and checks them before any hashing: argon2id version 19, each of +`m`, `t`, `p` exactly once, `t ≥ 1`, `p ≥ 1`, `m ≥ 8p` (RFC 9106 §3.1), salt +and tag of 16 to 64 bytes (library policy: RFC 9106 sets no salt minimum, +§4 calls 128-bit salts and tags sufficient) → `ErrMalformedHash`; a cost +above the hasher's limits → `ErrCostLimitExceeded` (argon2id: 256 MiB, 10 +passes and 16 lanes by default, `WithArgon2idLimits`; bcrypt: cost 14, +`WithBCryptMaxCost`; both inclusive and raised to the hasher's own +parameters). A hash imported from an untrusted source or written by an +attacker can neither crash the process, exhaust its memory or CPU, nor +accept random passwords. Empty passwords are never hashed nor accepted +(RFC 8265 §4.1). `NeedsRehash` lets a login flow transparently upgrade a stored hash when the operator raises the cost factor. Call it after a successful `Verify` and re-hash if it returns true. -A plain mismatch returns `(false, nil)` — only malformed input or context -cancellation produces an error. Never store or log the cleartext password. +A plain mismatch returns `(false, nil)` — only malformed or out-of-bounds +input, a password too long for the algorithm or context cancellation +produces an error. Never store or log the cleartext password. + +**Unicode.** The hashers hash the password's bytes as given, so they verify +the hashes of systems that never normalized. NIST SP 800-63B-4 §3.1.1.2 says +verifiers SHOULD apply NFC to Unicode passwords (RFC 8265 §4.2 too): wrap +the hasher in the decorator of the `password` package documentation, at +enrollment and at login. ## Account enumeration @@ -1458,7 +1486,9 @@ span catalog. - [ ] Build with Go 1.27.2 or later, the latest patch release, and run `govulncheck` on your application. -- [ ] Pick a password hasher and review its cost against current hardware. +- [ ] Pick a password hasher and review its cost against current hardware; + raise its limits (`WithArgon2idLimits`, `WithBCryptMaxCost`) before + importing hashes costlier than the defaults. - [ ] Rotate JWT signing keys; expose them through a JWKS endpoint (`oidc/provider` serves one). - [ ] Keep the JWT allowlist asymmetric unless you truly need HMAC. diff --git a/password/argon2id.go b/password/argon2id.go index 5e43a366..fcded28d 100644 --- a/password/argon2id.go +++ b/password/argon2id.go @@ -9,7 +9,6 @@ import ( "crypto/rand" "crypto/subtle" "encoding/base64" - "errors" "fmt" "strconv" "strings" @@ -17,29 +16,51 @@ import ( "golang.org/x/crypto/argon2" ) +// Bounds of the argon2id salt and tag (output) lengths, in bytes, for the +// hasher's own parameters and for every stored hash. They are library +// policy: RFC 9106 §3.1 only requires a tag of at least 4 bytes, and §4 +// calls 128-bit salts and tags sufficient. A shorter tag lets random +// passwords verify (a 1-byte tag accepts about one in 256), a longer one +// buys nothing. +const ( + minArgon2idSaltLen = 16 + maxArgon2idSaltLen = 64 + minArgon2idKeyLen = 16 + maxArgon2idKeyLen = 64 + // maxArgon2idHashLen bounds the encoded hash: the longest valid one — + // maximal parameters, 64-byte salt and tag — is 220 bytes. + maxArgon2idHashLen = 256 +) + // Argon2idParams configures Argon2id. Values follow the PHC string format // (memory in KiB, time in iterations, parallelism in threads). // -// The default profile (see [DefaultArgon2idParams]) is RFC 9106 §4 / OWASP -// 2024: memory=19 MiB, time=2, parallelism=1, key length 32 bytes, salt -// length 16 bytes. This profile aims at ~50 ms on a contemporary x86 core. +// The default profile (see [DefaultArgon2idParams]) is the minimum of the +// OWASP Password Storage Cheat Sheet: memory=19 MiB, time=2, +// parallelism=1, key length 32 bytes, salt length 16 bytes (~40 ms on a +// contemporary core). RFC 9106 §4 recommends stronger options — 2 GiB, t=1, +// p=4, or 64 MiB, t=3, p=4 when memory is scarce: raise the parameters +// towards them when the hardware allows (and the stored hashes follow +// through NeedsRehash). type Argon2idParams struct { // MemoryKiB is the memory cost in kibibytes. Higher values strengthen // the hash against GPU/ASIC attacks but slow login down proportionally. + // It MUST be at least 8×Parallelism (RFC 9106 §3.1). MemoryKiB uint32 - // Time is the iteration count. + // Time is the iteration count (at least 1). Time uint32 - // Parallelism is the lane count. + // Parallelism is the lane count (at least 1). Parallelism uint8 - // KeyLen is the output length in bytes. + // KeyLen is the output (tag) length in bytes: 16 to 64. KeyLen uint32 - // SaltLen is the salt length in bytes. The salt is generated with - // crypto/rand on every Hash call. + // SaltLen is the salt length in bytes: 16 to 64. The salt is generated + // with crypto/rand on every Hash call. SaltLen uint32 } -// DefaultArgon2idParams returns the OWASP 2024 / RFC 9106 §4 profile. -// Operators free to harden it for their threat model via NewArgon2idHasher. +// DefaultArgon2idParams returns the OWASP Password Storage Cheat Sheet +// minimum profile (19 MiB, t=2, p=1, 32-byte key, 16-byte salt). Operators +// free to harden it for their threat model via NewArgon2idHasher. func DefaultArgon2idParams() Argon2idParams { return Argon2idParams{ MemoryKiB: 19 * 1024, // 19 MiB @@ -50,16 +71,75 @@ func DefaultArgon2idParams() Argon2idParams { } } +// Argon2idLimits bounds the cost Verify accepts from a stored hash. It is +// checked before any hashing: an attacker able to write or import a hash +// could otherwise make each verification allocate gigabytes or run for +// hours. A stored hash above a limit fails with [ErrCostLimitExceeded]. +// The limits are inclusive. +type Argon2idLimits struct { + // MaxMemoryKiB bounds the stored memory cost. Default: 256 MiB. + MaxMemoryKiB uint32 + // MaxTime bounds the stored iteration count. Default: 10. + MaxTime uint32 + // MaxParallelism bounds the stored lane count. Default: 16. + MaxParallelism uint8 +} + +// DefaultArgon2idLimits returns the default limits: 256 MiB, 10 passes and +// 16 lanes. They accept the OWASP profiles, RFC 9106 §4's second option +// (64 MiB, t=3, p=4), libsodium's interactive and moderate profiles and the +// PHP, Spring Security and Keycloak defaults; hashes made with 1 GiB or +// more (RFC 9106 §4's first option, libsodium's sensitive profile) need +// [WithArgon2idLimits]. +func DefaultArgon2idLimits() Argon2idLimits { + return Argon2idLimits{MaxMemoryKiB: 256 * 1024, MaxTime: 10, MaxParallelism: 16} +} + +// withDefaults replaces the zero fields of l with the default limits. +func (l Argon2idLimits) withDefaults() Argon2idLimits { + d := DefaultArgon2idLimits() + + if l.MaxMemoryKiB == 0 { + l.MaxMemoryKiB = d.MaxMemoryKiB + } + + if l.MaxTime == 0 { + l.MaxTime = d.MaxTime + } + + if l.MaxParallelism == 0 { + l.MaxParallelism = d.MaxParallelism + } + + return l +} + +// Argon2idOption configures an [Argon2idHasher]. +type Argon2idOption func(*Argon2idHasher) + +// WithArgon2idLimits sets the limits Verify applies to stored hashes. A zero +// field keeps its default value, and every limit is raised to the hasher's +// own parameter: a hasher always verifies the hashes it produces. +func WithArgon2idLimits(l Argon2idLimits) Argon2idOption { + return func(h *Argon2idHasher) { h.limits = l } +} + // Argon2idHasher implements [Hasher] using Argon2id from // golang.org/x/crypto/argon2. type Argon2idHasher struct { params Argon2idParams + limits Argon2idLimits } // NewArgon2idHasher returns a [Hasher] configured with params. Zero-valued // fields are replaced with [DefaultArgon2idParams] equivalents to keep the // hasher usable from `&Argon2idHasher{}` without surprising silent zeroes. -func NewArgon2idHasher(params Argon2idParams) *Argon2idHasher { +// The limits default to [DefaultArgon2idLimits] (see [WithArgon2idLimits]). +// +// NewArgon2idHasher panics on parameters outside their bounds — a memory +// cost below 8×Parallelism (RFC 9106 §3.1), a key or salt length outside +// 16–64 bytes: they are static configuration, refused at start-up. +func NewArgon2idHasher(params Argon2idParams, opts ...Argon2idOption) *Argon2idHasher { d := DefaultArgon2idParams() if params.MemoryKiB == 0 { @@ -82,24 +162,67 @@ func NewArgon2idHasher(params Argon2idParams) *Argon2idHasher { params.SaltLen = d.SaltLen } - return &Argon2idHasher{params: params} + if err := checkArgon2idParams(params); err != nil { + panic("password: NewArgon2idHasher: " + err.Error()) + } + + h := &Argon2idHasher{params: params} + + for _, o := range opts { + o(h) + } + + h.limits = h.limits.withDefaults() + h.limits.MaxMemoryKiB = max(h.limits.MaxMemoryKiB, params.MemoryKiB) + h.limits.MaxTime = max(h.limits.MaxTime, params.Time) + h.limits.MaxParallelism = max(h.limits.MaxParallelism, params.Parallelism) + + return h +} + +// checkArgon2idParams checks the bounds shared by the hasher's parameters +// and the stored hashes: RFC 9106 §3.1 (t ≥ 1, p ≥ 1, m ≥ 8p) and the salt +// and key lengths. +func checkArgon2idParams(p Argon2idParams) error { + switch { + case p.Time < 1: + return fmt.Errorf("time %d below 1 (RFC 9106 §3.1)", p.Time) + case p.Parallelism < 1: + return fmt.Errorf("parallelism %d below 1 (RFC 9106 §3.1)", p.Parallelism) + case uint64(p.MemoryKiB) < 8*uint64(p.Parallelism): + return fmt.Errorf("memory %d KiB below 8×parallelism (RFC 9106 §3.1)", p.MemoryKiB) + case p.KeyLen < minArgon2idKeyLen || p.KeyLen > maxArgon2idKeyLen: + return fmt.Errorf("key length %d outside %d–%d bytes", p.KeyLen, minArgon2idKeyLen, maxArgon2idKeyLen) + case p.SaltLen < minArgon2idSaltLen || p.SaltLen > maxArgon2idSaltLen: + return fmt.Errorf("salt length %d outside %d–%d bytes", p.SaltLen, minArgon2idSaltLen, maxArgon2idSaltLen) + } + + return nil } // Params returns the hasher's effective parameters. Useful in tests and for // observability. func (h *Argon2idHasher) Params() Argon2idParams { return h.params } +// Limits returns the hasher's effective limits on stored hashes. +func (h *Argon2idHasher) Limits() Argon2idLimits { return h.limits } + // Hash implements [Hasher]. The output follows the PHC string format: // // $argon2id$v=19$m=,t=,p=$$ // // The format is interoperable with libsodium, OpenSSH, and most modern -// argon2id implementations. +// argon2id implementations. An empty password is refused +// ([ErrEmptyPassword]). func (h *Argon2idHasher) Hash(ctx context.Context, password string) (string, error) { if err := ctx.Err(); err != nil { return "", fmt.Errorf("password: context canceled: %w", err) } + if password == "" { + return "", ErrEmptyPassword + } + salt := make([]byte, h.params.SaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("password: read salt: %w", err) @@ -113,32 +236,35 @@ func (h *Argon2idHasher) Hash(ctx context.Context, password string) (string, err return encodeArgon2idPHC(h.params, salt, key), nil } -// Verify implements [Hasher]. It returns (false, nil) on plain mismatch and -// an error only when the hash is malformed or the algorithm prefix differs. +// Verify implements [Hasher]. The stored hash is decoded and checked — +// RFC 9106 §3.1 bounds, salt and tag lengths, then the limits — before any +// hashing. It returns (false, nil) on a plain mismatch and for an empty +// password. func (h *Argon2idHasher) Verify(ctx context.Context, encodedHash, password string) (bool, error) { if err := ctx.Err(); err != nil { return false, fmt.Errorf("password: context canceled: %w", err) } - p, salt, expected, err := decodeArgon2idPHC(encodedHash) + p, salt, expected, err := h.decode(encodedHash) if err != nil { return false, err } - got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Parallelism, p.KeyLen) - - if subtle.ConstantTimeCompare(expected, got) == 1 { - return true, nil + if password == "" { + return false, nil } - return false, nil + got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Parallelism, p.KeyLen) + + return subtle.ConstantTimeCompare(expected, got) == 1, nil } -// NeedsRehash implements [Hasher]: true when the algorithm is not argon2id -// or when any stored parameter is strictly weaker than the current -// configuration. +// NeedsRehash implements [Hasher]: true when the stored hash cannot be +// verified (another algorithm, malformed, above the limits) or when any of +// its parameters — memory, passes, lanes, tag or salt length — is weaker +// than the current configuration. func (h *Argon2idHasher) NeedsRehash(encodedHash string) bool { - p, _, _, err := decodeArgon2idPHC(encodedHash) + p, _, _, err := h.decode(encodedHash) if err != nil { return true } @@ -146,7 +272,22 @@ func (h *Argon2idHasher) NeedsRehash(encodedHash string) bool { return p.MemoryKiB < h.params.MemoryKiB || p.Time < h.params.Time || p.Parallelism < h.params.Parallelism || - p.KeyLen < h.params.KeyLen + p.KeyLen < h.params.KeyLen || + p.SaltLen < h.params.SaltLen +} + +// decode parses encodedHash and applies the bounds and the limits. +func (h *Argon2idHasher) decode(encodedHash string) (Argon2idParams, []byte, []byte, error) { + p, salt, key, err := decodeArgon2idPHC(encodedHash) + if err != nil { + return Argon2idParams{}, nil, nil, err + } + + if p.MemoryKiB > h.limits.MaxMemoryKiB || p.Time > h.limits.MaxTime || p.Parallelism > h.limits.MaxParallelism { + return Argon2idParams{}, nil, nil, fmt.Errorf("%w: m=%d,t=%d,p=%d", ErrCostLimitExceeded, p.MemoryKiB, p.Time, p.Parallelism) + } + + return p, salt, key, nil } // encodeArgon2idPHC formats the parameters, salt and key in the PHC string @@ -173,55 +314,35 @@ func encodeArgon2idPHC(p Argon2idParams, salt, key []byte) string { return b.String() } -// decodeArgon2idPHC parses a PHC-formatted argon2id hash. Strict on prefix -// and field shape; tolerant of base64 padding to interop with implementations -// that emit RawStdEncoding output. +// decodeArgon2idPHC parses a PHC-formatted argon2id hash and checks its +// bounds: version 19, each of m, t and p exactly once (in any order) and +// nothing else, RFC 9106 §3.1 ranges, salt and tag of 16 to 64 bytes. The +// algorithm is checked first so that cross-algorithm inputs (bcrypt, +// scrypt) get a clear ErrUnsupportedAlgorithm. func decodeArgon2idPHC(s string) (Argon2idParams, []byte, []byte, error) { - parts := strings.Split(s, "$") - // Expected layout: ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, key] - // Algorithm check first so cross-algorithm inputs (bcrypt, scrypt) get a - // clear ErrUnsupportedAlgorithm even when their layout has fewer fields. - if len(parts) < 2 { + rest, ok := strings.CutPrefix(s, "$") + if !ok { return Argon2idParams{}, nil, nil, ErrMalformedHash } - if parts[1] != "argon2id" { + if alg, _, _ := strings.Cut(rest, "$"); alg != "argon2id" { return Argon2idParams{}, nil, nil, ErrUnsupportedAlgorithm } - if len(parts) != 6 { + // Bounded before it is split: a hostile hash costs no allocation. + if len(s) > maxArgon2idHashLen { return Argon2idParams{}, nil, nil, ErrMalformedHash } - if !strings.HasPrefix(parts[2], "v=") { - return Argon2idParams{}, nil, nil, ErrMalformedHash - } - - version, err := strconv.Atoi(parts[2][2:]) - if err != nil || version != argon2.Version { + // Expected layout: ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, key] + parts := strings.Split(s, "$") + if len(parts) != 6 || parts[2] != "v=19" { return Argon2idParams{}, nil, nil, ErrMalformedHash } - var ( - mem, tim uint64 - par uint64 - ) - - for _, kv := range strings.Split(parts[3], ",") { - switch { - case strings.HasPrefix(kv, "m="): - mem, err = strconv.ParseUint(kv[2:], 10, 32) - case strings.HasPrefix(kv, "t="): - tim, err = strconv.ParseUint(kv[2:], 10, 32) - case strings.HasPrefix(kv, "p="): - par, err = strconv.ParseUint(kv[2:], 10, 8) - default: - err = errors.New("unknown key") - } - - if err != nil { - return Argon2idParams{}, nil, nil, ErrMalformedHash - } + p, err := parseArgon2idParams(parts[3]) + if err != nil { + return Argon2idParams{}, nil, nil, err } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) @@ -234,15 +355,58 @@ func decodeArgon2idPHC(s string) (Argon2idParams, []byte, []byte, error) { return Argon2idParams{}, nil, nil, ErrMalformedHash } - // len() on a decoded base64 string is bounded by the input length - // (a few hundred bytes at most for a sane hash). The uint32 conversion - // cannot overflow in practice — gosec's static analyser cannot prove - // that, hence the explicit annotation. - return Argon2idParams{ - MemoryKiB: uint32(mem), - Time: uint32(tim), - Parallelism: uint8(par), - KeyLen: uint32(len(key)), //nolint:gosec // bounded by base64 of <= 64-byte key - SaltLen: uint32(len(salt)), //nolint:gosec // bounded by base64 of <= 64-byte salt - }, salt, key, nil + // Both lengths are bounded by the 256-byte encoding checked above. + p.KeyLen = uint32(len(key)) //nolint:gosec // G115: at most 192 bytes + p.SaltLen = uint32(len(salt)) //nolint:gosec // G115: at most 192 bytes + + if checkArgon2idParams(p) != nil { + return Argon2idParams{}, nil, nil, ErrMalformedHash + } + + return p, salt, key, nil +} + +// parseArgon2idParams parses the "m=…,t=…,p=…" field: each parameter +// exactly once, in any order, unsigned decimal, nothing else. +func parseArgon2idParams(field string) (Argon2idParams, error) { + var ( + p Argon2idParams + seen [3]bool + ) + + for kv := range strings.SplitSeq(field, ",") { + name, value, _ := strings.Cut(kv, "=") + + switch name { + case "m": + n, err := strconv.ParseUint(value, 10, 32) + if err != nil || seen[0] { + return Argon2idParams{}, ErrMalformedHash + } + + seen[0], p.MemoryKiB = true, uint32(n) + case "t": + n, err := strconv.ParseUint(value, 10, 32) + if err != nil || seen[1] { + return Argon2idParams{}, ErrMalformedHash + } + + seen[1], p.Time = true, uint32(n) + case "p": + n, err := strconv.ParseUint(value, 10, 8) + if err != nil || seen[2] { + return Argon2idParams{}, ErrMalformedHash + } + + seen[2], p.Parallelism = true, uint8(n) + default: + return Argon2idParams{}, ErrMalformedHash + } + } + + if !seen[0] || !seen[1] || !seen[2] { + return Argon2idParams{}, ErrMalformedHash + } + + return p, nil } diff --git a/password/bcrypt.go b/password/bcrypt.go index 25093d5f..d7e72e33 100644 --- a/password/bcrypt.go +++ b/password/bcrypt.go @@ -13,40 +13,91 @@ import ( "golang.org/x/crypto/bcrypt" ) +// DefaultBCryptMaxCost is the default limit on the cost of a stored bcrypt +// hash (see [WithBCryptMaxCost]): cost 14 runs about one second per +// verification on a contemporary core, 16 times cost 10. Each extra unit +// doubles the time — cost 31 would run for days. +const DefaultBCryptMaxCost = 14 + +// maxBCryptPasswordLen is the longest password bcrypt processes: longer +// ones would be silently truncated. +const maxBCryptPasswordLen = 72 + +// BCryptOption configures a [BCryptHasher]. +type BCryptOption func(*BCryptHasher) + +// WithBCryptMaxCost bounds the cost of the stored hashes Verify accepts: +// the cost is read before any hashing, and a hash above the limit fails with +// [ErrCostLimitExceeded]. The limit is inclusive and raised to the hasher's +// own cost, so a hasher always verifies the hashes it produces. It panics +// outside [bcrypt.MinCost, bcrypt.MaxCost]. +func WithBCryptMaxCost(cost int) BCryptOption { + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + panic(fmt.Sprintf("password: WithBCryptMaxCost: cost %d outside [%d, %d]", cost, bcrypt.MinCost, bcrypt.MaxCost)) + } + + return func(h *BCryptHasher) { h.maxCost = cost } +} + // BCryptHasher implements [Hasher] on top of golang.org/x/crypto/bcrypt. // It is the most widely deployed password hash and a good default for -// projects that do not need argon2id-level memory hardness. +// projects that do not need argon2id-level memory hardness. bcrypt +// processes at most 72 bytes — as few as 18 characters of 4-byte UTF-8 — +// while NIST SP 800-63B-4 §3.1.1.2 asks verifiers to permit passwords of at +// least 64 characters: prefer [Argon2idHasher] for new deployments. type BCryptHasher struct { - cost int + cost int + maxCost int } // NewBCryptHasher returns a [Hasher] backed by bcrypt at the given cost. -// Cost values below [bcrypt.MinCost] are clamped to bcrypt.MinCost; values -// above [bcrypt.MaxCost] are clamped to bcrypt.MaxCost. Passing 0 yields -// [bcrypt.DefaultCost] (12 as of bcrypt v0.x). -func NewBCryptHasher(cost int) *BCryptHasher { - switch { - case cost == 0: +// Passing 0 selects [bcrypt.DefaultCost] (10, the OWASP minimum); costs +// below 10 are for tests only. A cost outside [bcrypt.MinCost (4), +// bcrypt.MaxCost (31)] panics — it is static configuration, refused at +// start-up. Stored hashes are accepted up to [DefaultBCryptMaxCost] (or +// the hasher's own cost if higher) unless [WithBCryptMaxCost] says +// otherwise. +func NewBCryptHasher(cost int, opts ...BCryptOption) *BCryptHasher { + if cost == 0 { cost = bcrypt.DefaultCost - case cost < bcrypt.MinCost: - cost = bcrypt.MinCost - case cost > bcrypt.MaxCost: - cost = bcrypt.MaxCost } - return &BCryptHasher{cost: cost} + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + panic(fmt.Sprintf("password: NewBCryptHasher: cost %d outside [%d, %d]", cost, bcrypt.MinCost, bcrypt.MaxCost)) + } + + h := &BCryptHasher{cost: cost, maxCost: DefaultBCryptMaxCost} + + for _, o := range opts { + o(h) + } + + h.maxCost = max(h.maxCost, cost) + + return h } // Cost returns the configured bcrypt cost. Useful in tests and for // observability. func (h *BCryptHasher) Cost() int { return h.cost } -// Hash implements [Hasher]. +// MaxCost returns the highest stored cost Verify accepts. +func (h *BCryptHasher) MaxCost() int { return h.maxCost } + +// Hash implements [Hasher]. It refuses an empty password +// ([ErrEmptyPassword]) and one longer than 72 bytes ([ErrPasswordTooLong]). func (h *BCryptHasher) Hash(ctx context.Context, password string) (string, error) { if err := ctx.Err(); err != nil { return "", fmt.Errorf("password: context canceled: %w", err) } + switch { + case password == "": + return "", ErrEmptyPassword + case len(password) > maxBCryptPasswordLen: + return "", fmt.Errorf("password: bcrypt: %w", ErrPasswordTooLong) + } + out, err := bcrypt.GenerateFromPassword([]byte(password), h.cost) if err != nil { return "", fmt.Errorf("password: bcrypt hash: %w", err) @@ -55,14 +106,24 @@ func (h *BCryptHasher) Hash(ctx context.Context, password string) (string, error return string(out), nil } -// Verify implements [Hasher]. A plain mismatch returns (false, nil). +// Verify implements [Hasher]. The stored hash's algorithm and cost are +// checked before any hashing; a password longer than 72 bytes is refused +// ([ErrPasswordTooLong]) instead of being compared on its first 72 bytes. A +// plain mismatch, and an empty password, return (false, nil). func (h *BCryptHasher) Verify(ctx context.Context, encodedHash, password string) (bool, error) { if err := ctx.Err(); err != nil { return false, fmt.Errorf("password: context canceled: %w", err) } - if !looksLikeBCrypt(encodedHash) { - return false, ErrUnsupportedAlgorithm + if err := h.check(encodedHash); err != nil { + return false, err + } + + switch { + case len(password) > maxBCryptPasswordLen: + return false, fmt.Errorf("password: bcrypt: %w", ErrPasswordTooLong) + case password == "": + return false, nil } err := bcrypt.CompareHashAndPassword([]byte(encodedHash), []byte(password)) @@ -74,24 +135,40 @@ func (h *BCryptHasher) Verify(ctx context.Context, encodedHash, password string) return false, nil } - return false, fmt.Errorf("password: bcrypt compare: %w", err) + return false, fmt.Errorf("password: bcrypt: %w: %w", ErrMalformedHash, err) } // NeedsRehash implements [Hasher]: it returns true when the stored cost is // strictly below the hasher's configured cost (the case after the operator -// raised the security baseline) or when the encoded hash is not a bcrypt -// blob at all (e.g. migration from another algorithm). +// raised the security baseline), or when Verify refuses the hash (another +// algorithm, malformed, above the cost limit). func (h *BCryptHasher) NeedsRehash(encodedHash string) bool { - if !looksLikeBCrypt(encodedHash) { + if h.check(encodedHash) != nil { return true } + cost, _ := bcrypt.Cost([]byte(encodedHash)) // check parsed it already + + return cost < h.cost +} + +// check verifies that encodedHash is a bcrypt hash whose cost is within the +// limit, without hashing. +func (h *BCryptHasher) check(encodedHash string) error { + if !looksLikeBCrypt(encodedHash) { + return ErrUnsupportedAlgorithm + } + cost, err := bcrypt.Cost([]byte(encodedHash)) if err != nil { - return true + return fmt.Errorf("password: bcrypt: %w: %w", ErrMalformedHash, err) } - return cost < h.cost + if cost > h.maxCost { + return fmt.Errorf("%w: bcrypt cost %d above %d", ErrCostLimitExceeded, cost, h.maxCost) + } + + return nil } // looksLikeBCrypt is a cheap discriminator: every bcrypt blob starts with diff --git a/password/bcrypt_test.go b/password/bcrypt_test.go index 3b73b45b..0826dd8e 100644 --- a/password/bcrypt_test.go +++ b/password/bcrypt_test.go @@ -65,23 +65,6 @@ func TestBCryptNeedsRehash(t *testing.T) { assert.True(t, lo.NeedsRehash("garbage")) } -func TestBCryptCostClamps(t *testing.T) { - t.Parallel() - - cases := []struct { - give, want int - }{ - {0, 10}, // bcrypt.DefaultCost (x/crypto/bcrypt) - {3, 4}, // clamp to MinCost - {50, 31}, // clamp to MaxCost - {7, 7}, - } - for _, c := range cases { - got := password.NewBCryptHasher(c.give).Cost() - assert.Equal(t, c.want, got, "input %d", c.give) - } -} - func TestBCryptContextCancellation(t *testing.T) { t.Parallel() diff --git a/password/bounds_test.go b/password/bounds_test.go new file mode 100644 index 00000000..7fced9da --- /dev/null +++ b/password/bounds_test.go @@ -0,0 +1,393 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package password_test + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "runtime" + "strings" + "testing" + + "github.com/hyperscale-stack/security/password" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +// phc encodes an argon2id hash with the given parameter field, salt and tag, +// the way a foreign system or a tampered store row could. +func phc(params string, salt, tag []byte) string { + enc := base64.RawStdEncoding + + return "$argon2id$v=19$" + params + "$" + enc.EncodeToString(salt) + "$" + enc.EncodeToString(tag) +} + +// realPHC hashes pw with x/crypto directly — the hash any argon2id +// implementation would store for these parameters. +func realPHC(pw string, m, t uint32, p uint8, saltLen, keyLen int) string { + salt := bytes.Repeat([]byte{0x5a}, saltLen) + tag := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(keyLen)) //nolint:gosec // test sizes + + return phc(fmt.Sprintf("m=%d,t=%d,p=%d", m, t, p), salt, tag) +} + +// verifyNoPanic calls Verify and turns a panic into a test failure: before +// the bounds, a stored t=0, p=0 or empty tag panicked inside x/crypto. +func verifyNoPanic(t *testing.T, h password.Hasher, hash, pw string) (ok bool, err error) { + t.Helper() + + defer func() { + if r := recover(); r != nil { + t.Errorf("Verify(%.60q) panicked: %v", hash, r) + } + }() + + return h.Verify(context.Background(), hash, pw) +} + +// tinyLimits keeps every accepted stored hash cheap to verify. +func tinyLimitsHasher() *password.Argon2idHasher { + return password.NewArgon2idHasher( + password.Argon2idParams{MemoryKiB: 8, Time: 1, Parallelism: 1, KeyLen: 16, SaltLen: 16}, + password.WithArgon2idLimits(password.Argon2idLimits{MaxMemoryKiB: 64, MaxTime: 2, MaxParallelism: 2}), + ) +} + +// Audit CORE-06 (probe TestZZAuditArgon2idStoredHashBounds): stored +// parameters were parsed without bounds — t=0, p=0 and a missing t panicked +// in x/crypto, an empty tag dereferenced nil, a 1-byte tag accepted about +// one random password in 256 and absurd costs were honored. RFC 9106 §3.1: +// t ≥ 1, p ≥ 1, m ≥ 8p; library policy: salt and tag of 16 to 64 bytes; +// operator limits on the cost. Every refusal happens before any hashing. +func TestArgon2idVerifyRefusesOutOfBoundsHashes(t *testing.T) { + t.Parallel() + + salt := bytes.Repeat([]byte{1}, 16) + tag := bytes.Repeat([]byte{2}, 32) + + cases := []struct { + name string + hash string + want error + }{ + {"t=0", phc("m=64,t=0,p=1", salt, tag), password.ErrMalformedHash}, + {"p=0", phc("m=64,t=1,p=0", salt, tag), password.ErrMalformedHash}, + {"m below 8p", phc("m=15,t=1,p=2", salt, tag), password.ErrMalformedHash}, + {"t and p missing", phc("m=64", salt, tag), password.ErrMalformedHash}, + {"p missing", phc("m=64,t=1", salt, tag), password.ErrMalformedHash}, + {"duplicate m", phc("m=64,t=1,p=1,m=8", salt, tag), password.ErrMalformedHash}, + {"duplicate t", phc("m=64,t=1,t=2,p=1", salt, tag), password.ErrMalformedHash}, + {"unknown parameter", phc("m=64,t=1,p=1,keyid=a", salt, tag), password.ErrMalformedHash}, + {"empty parameter", phc("m=64,,t=1,p=1", salt, tag), password.ErrMalformedHash}, + {"signed value", phc("m=+64,t=1,p=1", salt, tag), password.ErrMalformedHash}, + {"empty tag", phc("m=64,t=1,p=1", salt, nil), password.ErrMalformedHash}, + {"1-byte tag", phc("m=8,t=1,p=1", salt, []byte{0x42}), password.ErrMalformedHash}, + {"15-byte tag", phc("m=64,t=1,p=1", salt, tag[:15]), password.ErrMalformedHash}, + {"65-byte tag", phc("m=64,t=1,p=1", salt, bytes.Repeat([]byte{2}, 65)), password.ErrMalformedHash}, + {"empty salt", phc("m=64,t=1,p=1", nil, tag), password.ErrMalformedHash}, + {"15-byte salt", phc("m=64,t=1,p=1", salt[:15], tag), password.ErrMalformedHash}, + {"65-byte salt", phc("m=64,t=1,p=1", bytes.Repeat([]byte{1}, 65), tag), password.ErrMalformedHash}, + {"version 16", strings.Replace(phc("m=64,t=1,p=1", salt, tag), "v=19", "v=16", 1), password.ErrMalformedHash}, + {"no version", strings.Replace(phc("m=64,t=1,p=1", salt, tag), "$v=19", "", 1), password.ErrMalformedHash}, + {"extra field", phc("m=64,t=1,p=1", salt, tag) + "$x", password.ErrMalformedHash}, + {"too long", phc("m=64,t=1,p=1", salt, tag) + strings.Repeat("A", 200), password.ErrMalformedHash}, + {"memory above the limit", phc("m=65,t=1,p=1", salt, tag), password.ErrCostLimitExceeded}, + {"time above the limit", phc("m=64,t=3,p=1", salt, tag), password.ErrCostLimitExceeded}, + {"parallelism above the limit", phc("m=64,t=1,p=3", salt, tag), password.ErrCostLimitExceeded}, + {"maximal memory", phc("m=4294967295,t=1,p=1", salt, tag), password.ErrCostLimitExceeded}, + {"maximal time and lanes", phc("m=4294967295,t=4294967295,p=255", salt, tag), password.ErrCostLimitExceeded}, + } + + h := tinyLimitsHasher() + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + ok, err := verifyNoPanic(t, h, c.hash, "whatever") + + require.ErrorIs(t, err, c.want) + assert.False(t, ok) + assert.True(t, h.NeedsRehash(c.hash), "a hash Verify refuses always needs a rehash") + }) + } +} + +// The probe's 1-byte stored tag accepted 7 of 2000 random passwords. +func TestArgon2idVerifyNeverAcceptsAShortTag(t *testing.T) { + t.Parallel() + + h := tinyLimitsHasher() + hash := phc("m=8,t=1,p=1", bytes.Repeat([]byte{1}, 16), []byte{0x42}) + + for i := range 600 { + ok, _ := verifyNoPanic(t, h, hash, fmt.Sprintf("guess-%d", i)) + require.False(t, ok, "guess-%d accepted", i) + } +} + +// The limits are inclusive and default to 256 MiB, 10 passes and 16 lanes: +// libsodium's MODERATE profile (256 MiB, t=3) sits exactly on the memory +// limit. NeedsRehash tells an accepted hash (false: stronger than the +// configuration) from a refused one (true) without hashing. +func TestArgon2idDefaultLimitsAreInclusive(t *testing.T) { + t.Parallel() + + h := password.NewArgon2idHasher(password.Argon2idParams{}) + salt := bytes.Repeat([]byte{1}, 16) + tag := bytes.Repeat([]byte{2}, 32) + + assert.Equal(t, password.Argon2idLimits{MaxMemoryKiB: 256 * 1024, MaxTime: 10, MaxParallelism: 16}, password.DefaultArgon2idLimits()) + assert.Equal(t, password.DefaultArgon2idLimits(), h.Limits()) + + for params, refused := range map[string]bool{ + "m=262144,t=2,p=1": false, + "m=262145,t=2,p=1": true, + "m=19456,t=10,p=1": false, + "m=19456,t=11,p=1": true, + "m=19456,t=2,p=16": false, + "m=19456,t=2,p=17": true, + "m=65536,t=3,p=2": false, // infinityauth's profile + "m=262144,t=3,p=4": false, // libsodium MODERATE-like + "m=1048576,t=4,p=1": true, // libsodium SENSITIVE needs WithArgon2idLimits + } { + assert.Equal(t, refused, h.NeedsRehash(phc(params, salt, tag)), params) + } + + _, err := verifyNoPanic(t, h, phc("m=262145,t=2,p=1", salt, tag), "x") + require.ErrorIs(t, err, password.ErrCostLimitExceeded) +} + +// A hasher always verifies its own hashes: the limits are raised to its +// parameters, whatever the option says. +func TestArgon2idLimitsNeverRefuseTheHashersOwnHashes(t *testing.T) { + t.Parallel() + + h := password.NewArgon2idHasher( + password.Argon2idParams{MemoryKiB: 128, Time: 3, Parallelism: 4, KeyLen: 16, SaltLen: 16}, + password.WithArgon2idLimits(password.Argon2idLimits{MaxMemoryKiB: 64, MaxTime: 1, MaxParallelism: 1}), + ) + + assert.Equal(t, password.Argon2idLimits{MaxMemoryKiB: 128, MaxTime: 3, MaxParallelism: 4}, h.Limits()) + + hash, err := h.Hash(context.Background(), "pw") + require.NoError(t, err) + + ok, err := verifyNoPanic(t, h, hash, "pw") + require.NoError(t, err) + assert.True(t, ok) + + // A zero limit takes its default value. + partial := password.NewArgon2idHasher(password.Argon2idParams{}, + password.WithArgon2idLimits(password.Argon2idLimits{MaxMemoryKiB: 1 << 20})) + assert.Equal(t, password.Argon2idLimits{MaxMemoryKiB: 1 << 20, MaxTime: 10, MaxParallelism: 16}, partial.Limits()) +} + +// Hashes of other argon2id implementations: every mainstream encoder emits +// m,t,p in that order; another order is accepted as long as each parameter +// appears exactly once. +func TestArgon2idVerifyAcceptsForeignHashes(t *testing.T) { + t.Parallel() + + h := password.NewArgon2idHasher(password.Argon2idParams{}, + password.WithArgon2idLimits(password.Argon2idLimits{MaxMemoryKiB: 1024})) + + canonical := realPHC("correct horse", 1024, 3, 2, 16, 32) + reordered := strings.Replace(canonical, "m=1024,t=3,p=2", "t=3,p=2,m=1024", 1) + longSalt := realPHC("correct horse", 64, 1, 1, 64, 64) + + for name, hash := range map[string]string{"canonical": canonical, "reordered": reordered, "64-byte salt and tag": longSalt} { + ok, err := verifyNoPanic(t, h, hash, "correct horse") + require.NoError(t, err, name) + assert.True(t, ok, name) + + ok, err = verifyNoPanic(t, h, hash, "wrong horse") + require.NoError(t, err, name) + assert.False(t, ok, name) + } +} + +// Audit §9 surviving mutant: the tag comparison reduced to its first byte. +func TestArgon2idVerifyComparesTheWholeTag(t *testing.T) { + t.Parallel() + + h := tinyLimitsHasher() + hash := realPHC("pw", 8, 1, 1, 16, 32) + + parts := strings.Split(hash, "$") + tag, err := base64.RawStdEncoding.DecodeString(parts[5]) + require.NoError(t, err) + + tag[len(tag)-1] ^= 0x01 + parts[5] = base64.RawStdEncoding.EncodeToString(tag) + + ok, err := verifyNoPanic(t, h, strings.Join(parts, "$"), "pw") + require.NoError(t, err) + assert.False(t, ok, "a tag differing in its last byte must not verify") +} + +// Audit §9 surviving mutant (NeedsRehash ignoring memory) and CORE-I7 +// (NeedsRehash ignoring the salt length). +func TestArgon2idNeedsRehashOnEachWeakerParameter(t *testing.T) { + t.Parallel() + + current := password.NewArgon2idHasher(password.Argon2idParams{MemoryKiB: 64, Time: 2, Parallelism: 2, KeyLen: 32, SaltLen: 32}) + + for name, c := range map[string]struct { + hash string + needs bool + }{ + "same parameters": {realPHC("x", 64, 2, 2, 32, 32), false}, + "stronger": {realPHC("x", 128, 3, 4, 64, 64), false}, + "less memory only": {realPHC("x", 32, 2, 2, 32, 32), true}, + "fewer passes only": {realPHC("x", 64, 1, 2, 32, 32), true}, + "fewer lanes only": {realPHC("x", 64, 2, 1, 32, 32), true}, + "shorter tag only": {realPHC("x", 64, 2, 2, 32, 16), true}, + "shorter salt only": {realPHC("x", 64, 2, 2, 16, 32), true}, + } { + assert.Equal(t, c.needs, current.NeedsRehash(c.hash), name) + } +} + +// Audit CORE-06 (probe TestZZAuditArgon2idWeakConfigAccepted): the +// constructor accepted KeyLen=1 and SaltLen=1. +func TestNewArgon2idHasherRefusesOutOfRangeParameters(t *testing.T) { + t.Parallel() + + base := func(mod func(*password.Argon2idParams)) password.Argon2idParams { + p := password.Argon2idParams{MemoryKiB: 64, Time: 1, Parallelism: 1, KeyLen: 32, SaltLen: 16} + mod(&p) + + return p + } + + for name, p := range map[string]password.Argon2idParams{ + "1-byte tag": base(func(p *password.Argon2idParams) { p.KeyLen = 1 }), + "15-byte tag": base(func(p *password.Argon2idParams) { p.KeyLen = 15 }), + "65-byte tag": base(func(p *password.Argon2idParams) { p.KeyLen = 65 }), + "1-byte salt": base(func(p *password.Argon2idParams) { p.SaltLen = 1 }), + "15-byte salt": base(func(p *password.Argon2idParams) { p.SaltLen = 15 }), + "65-byte salt": base(func(p *password.Argon2idParams) { p.SaltLen = 65 }), + "m below 8p": base(func(p *password.Argon2idParams) { p.MemoryKiB, p.Parallelism = 15, 2 }), + "probe settings": {MemoryKiB: 8, Time: 1, KeyLen: 1, SaltLen: 1}, + } { + assert.Panics(t, func() { password.NewArgon2idHasher(p) }, name) + } + + for name, p := range map[string]password.Argon2idParams{ + "bounds": base(func(p *password.Argon2idParams) { p.KeyLen, p.SaltLen = 16, 64 }), + "upper": base(func(p *password.Argon2idParams) { p.KeyLen, p.SaltLen = 64, 16 }), + "m = 8p": base(func(p *password.Argon2idParams) { p.MemoryKiB, p.Parallelism = 16, 2 }), + "default": {}, + } { + assert.NotPanics(t, func() { password.NewArgon2idHasher(p) }, name) + } +} + +// Audit CORE-06 (probe TestZZAuditBCrypt): the cost of a stored bcrypt hash +// was honored up to 31 (cost 31 ≈ 34 h of CPU per attempt). The stored cost +// is read before any hashing and bounded by an inclusive limit (default 14, +// raised to the hasher's own cost). +func TestBCryptVerifyRefusesStoredCostAboveTheLimit(t *testing.T) { + t.Parallel() + + h := password.NewBCryptHasher(4, password.WithBCryptMaxCost(5)) + assert.Equal(t, 5, h.MaxCost()) + + atLimit, err := bcrypt.GenerateFromPassword([]byte("pw"), 5) + require.NoError(t, err) + + ok, err := h.Verify(context.Background(), string(atLimit), "pw") + require.NoError(t, err) + assert.True(t, ok, "the limit is inclusive") + + above, err := bcrypt.GenerateFromPassword([]byte("pw"), 6) + require.NoError(t, err) + + ok, err = h.Verify(context.Background(), string(above), "pw") + require.ErrorIs(t, err, password.ErrCostLimitExceeded) + assert.False(t, ok) + assert.True(t, h.NeedsRehash(string(above))) + + cost31 := strings.Replace(string(above), "$06$", "$31$", 1) + + ok, err = h.Verify(context.Background(), cost31, "pw") + require.ErrorIs(t, err, password.ErrCostLimitExceeded, "refused before hashing: cost 31 would run for hours") + assert.False(t, ok) +} + +func TestBCryptDefaultMaxCostIsInclusive(t *testing.T) { + t.Parallel() + + h := password.NewBCryptHasher(4) + assert.Equal(t, password.DefaultBCryptMaxCost, h.MaxCost()) + assert.Equal(t, 14, password.DefaultBCryptMaxCost) + + cost4, err := bcrypt.GenerateFromPassword([]byte("pw"), 4) + require.NoError(t, err) + + // NeedsRehash parses the cost without hashing: false = accepted and at + // least as strong as the configuration, true = refused. + assert.False(t, h.NeedsRehash(strings.Replace(string(cost4), "$04$", "$14$", 1))) + assert.True(t, h.NeedsRehash(strings.Replace(string(cost4), "$04$", "$15$", 1))) + + // The limit is raised to the hasher's own cost. + assert.Equal(t, 16, password.NewBCryptHasher(16).MaxCost()) + assert.Equal(t, 12, password.NewBCryptHasher(12, password.WithBCryptMaxCost(5)).MaxCost()) +} + +func TestBCryptConstructorBounds(t *testing.T) { + t.Parallel() + + assert.Equal(t, 10, password.NewBCryptHasher(0).Cost(), "0 selects bcrypt.DefaultCost, which is 10") + assert.Equal(t, 4, password.NewBCryptHasher(4).Cost()) + assert.Equal(t, 31, password.NewBCryptHasher(31).Cost()) + + for _, cost := range []int{-1, 1, 3, 32, 50} { + assert.Panics(t, func() { password.NewBCryptHasher(cost) }, "cost %d", cost) + } + + for _, cost := range []int{-1, 0, 3, 32} { + assert.Panics(t, func() { password.WithBCryptMaxCost(cost) }, "max cost %d", cost) + } +} + +// A malformed bcrypt hash is reported as ErrMalformedHash. +func TestBCryptVerifyReportsMalformedHashes(t *testing.T) { + t.Parallel() + + h := password.NewBCryptHasher(4) + + for _, hash := range []string{"$2a$", "$2a$04$tooshort", "$2a$xx$" + strings.Repeat("a", 53)} { + ok, err := h.Verify(context.Background(), hash, "pw") + require.ErrorIs(t, err, password.ErrMalformedHash, hash) + assert.False(t, ok) + assert.True(t, h.NeedsRehash(hash)) + } +} + +// The encoded hash is bounded before it is split and decoded: a hostile +// multi-megabyte "hash" costs nothing. (Not parallel: it reads the +// process-wide allocation counter.) +func TestArgon2idBoundsTheEncodedHashBeforeParsing(t *testing.T) { + h := tinyLimitsHasher() + huge := "$argon2id$v=19$" + strings.Repeat("$", 4<<20) + + var before, after runtime.MemStats + + runtime.ReadMemStats(&before) + + _, err := h.Verify(context.Background(), huge, "pw") + + runtime.ReadMemStats(&after) + + require.ErrorIs(t, err, password.ErrMalformedHash) + assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(1<<20), "the hash must be refused before being split") + assert.True(t, h.NeedsRehash(huge)) +} diff --git a/password/doc.go b/password/doc.go index 68720e13..3eecc10b 100644 --- a/password/doc.go +++ b/password/doc.go @@ -14,7 +14,8 @@ // Two implementations are shipped: // // - BCryptHasher — bcrypt via golang.org/x/crypto/bcrypt, default -// cost is bcrypt.DefaultCost. +// cost 10 (bcrypt.DefaultCost, the OWASP minimum), passwords of at most +// 72 bytes. // - Argon2idHasher — Argon2id via golang.org/x/crypto/argon2, with // parameters encoded into the hash so downstream // consumers can decode and verify without @@ -22,6 +23,51 @@ // // Both implementations are safe for concurrent use and never log secrets. // +// # Stored hashes are untrusted input +// +// Verify decodes the parameters of the stored hash before any hashing and +// refuses what would be unsafe to compute: RFC 9106 §3.1 violations, argon2id +// salts and tags outside 16–64 bytes, missing or repeated parameters +// (ErrMalformedHash), and costs above the hasher's limits +// (ErrCostLimitExceeded, see WithArgon2idLimits and WithBCryptMaxCost). +// Whoever can write or import a hash can therefore neither crash the +// process, exhaust its memory or CPU, nor plant a hash that accepts random +// passwords. Empty passwords are never hashed nor accepted (RFC 8265 §4.1), +// and bcrypt refuses passwords longer than 72 bytes instead of verifying a +// truncated prefix (NIST SP 800-63B-4 §3.1.1.2). +// +// # Unicode normalization +// +// The hashers hash the password's bytes as given: they apply no Unicode +// normalization, so they verify the hashes of systems that never normalized +// and do not depend on golang.org/x/text. NIST SP 800-63B-4 §3.1.1.2 says +// verifiers SHOULD apply NFC to passwords containing Unicode characters +// (RFC 8265 §4.2, OpaqueString, does as well). An application that wants it +// wraps its hasher in a decorator applied both at enrollment and at login, +// for instance with golang.org/x/text/unicode/norm: +// +// type nfcHasher struct{ password.Hasher } +// +// func (h nfcHasher) Hash(ctx context.Context, pw string) (string, error) { +// return h.Hasher.Hash(ctx, norm.NFC.String(pw)) +// } +// +// func (h nfcHasher) Verify(ctx context.Context, hash, pw string) (bool, error) { +// nfc := norm.NFC.String(pw) +// +// ok, err := h.Hasher.Verify(ctx, hash, nfc) +// if ok || err != nil || nfc == pw { +// return ok, err +// } +// +// // A password enrolled before the decorator: verify the raw input, +// // then re-hash it (Hash normalizes) and store the new hash. +// return h.Hasher.Verify(ctx, hash, pw) +// } +// +// The fallback costs one more verification for a password that is not in +// NFC; remove it once every account has been re-hashed. +// // Allowed dependencies: // - golang.org/x/crypto // - stdlib only diff --git a/password/errors.go b/password/errors.go index 18230528..55359d21 100644 --- a/password/errors.go +++ b/password/errors.go @@ -8,21 +8,42 @@ import "errors" // Sentinel errors. var ( - // ErrMismatch is returned by Verify when the password does not match the - // hash. Callers SHOULD NOT distinguish ErrMismatch from "user not found" - // in user-facing messages to avoid account-enumeration leaks; the typed - // error is only here so application code can branch on it for metrics - // or rate-limiting. + // ErrMismatch is offered to application code that needs an error value + // for a wrong password (metrics, rate limiting): the shipped hashers + // never return it — Verify reports a mismatch as (false, nil). Do not + // tell it apart from "user not found" in user-facing messages: that + // would leak which accounts exist. ErrMismatch = errors.New("password: mismatch") - // ErrUnsupportedAlgorithm is returned by Verify / NeedsRehash when the - // encoded hash uses an algorithm the hasher does not know how to parse. - // It typically signals a mistake in the application's storage layer - // (mixing bcrypt and argon2id without an algorithm-aware dispatcher). + // ErrUnsupportedAlgorithm is returned by Verify when the encoded hash + // uses an algorithm the hasher does not know how to parse. It typically + // signals a mistake in the application's storage layer (mixing bcrypt + // and argon2id without an algorithm-aware dispatcher). ErrUnsupportedAlgorithm = errors.New("password: unsupported algorithm") // ErrMalformedHash is returned by Verify when the encoded hash exists - // for the right algorithm but cannot be decoded (truncated, corrupted, - // wrong number of fields, …). It is typically a storage-corruption bug. + // for the right algorithm but cannot be decoded or is out of bounds + // (truncated, corrupted, wrong number of fields, a parameter missing, + // repeated or out of its range, a salt or tag of the wrong size…). It + // typically is a storage-corruption bug, or a hash imported from a + // system with unsafe parameters. ErrMalformedHash = errors.New("password: malformed hash") + + // ErrCostLimitExceeded is returned by Verify, before any hashing, when + // the cost a stored hash asks for exceeds the hasher's limits + // (WithArgon2idLimits, WithBCryptMaxCost): honoring it would let + // whoever can write or import hashes exhaust the server's memory or CPU. + // Raise the limits when you legitimately import costlier hashes. + ErrCostLimitExceeded = errors.New("password: stored hash cost exceeds the configured limit") + + // ErrPasswordTooLong is returned by the bcrypt hasher's Hash and Verify + // for a password longer than 72 bytes, the most bcrypt processes: NIST + // SP 800-63B-4 §3.1.1.2 requires the entire password to be verified, + // never a truncated prefix. + ErrPasswordTooLong = errors.New("password: password longer than 72 bytes") + + // ErrEmptyPassword is returned by Hash for an empty password: "A + // password MUST NOT be zero bytes in length" (RFC 8265 §4.1). Verify + // never accepts an empty password either. + ErrEmptyPassword = errors.New("password: empty password") ) diff --git a/password/hasher.go b/password/hasher.go index c2cd94a5..80f4785d 100644 --- a/password/hasher.go +++ b/password/hasher.go @@ -14,23 +14,30 @@ import "context" // Hasher implementations MUST be safe for concurrent use. // // Two implementations are shipped: NewBCryptHasher and NewArgon2idHasher. +// Both hash the password's bytes as given: they apply no Unicode +// normalization (see the package documentation for a decorator that does). type Hasher interface { // Hash returns a self-describing encoded hash of password. The ctx - // allows cancellation of slow KDF iterations; bcrypt is bounded by its - // cost factor (low ms), Argon2id by its time/memory parameters (tens - // of ms). Hash MUST NOT log or otherwise emit the cleartext password. + // allows cancellation of slow KDF iterations. Hash refuses an empty + // password (ErrEmptyPassword) and a password the algorithm cannot + // process whole (bcrypt: ErrPasswordTooLong). Hash MUST NOT log or + // otherwise emit the cleartext password. Hash(ctx context.Context, password string) (string, error) // Verify reports whether password matches encodedHash. A plain mismatch - // returns (false, nil); errors are reserved for malformed input - // (ErrMalformedHash), unknown algorithms (ErrUnsupportedAlgorithm), or - // context cancellation. Verify uses constant-time comparison on its - // final step to avoid timing attacks. + // returns (false, nil), and so does an empty password. Errors are + // reserved for a stored hash that is malformed or out of bounds + // (ErrMalformedHash), of another algorithm (ErrUnsupportedAlgorithm) or + // costlier than the hasher's limits (ErrCostLimitExceeded), a password + // the algorithm cannot process whole (ErrPasswordTooLong), and context + // cancellation. Every such check runs before any hashing, and the final + // comparison is constant-time. Verify(ctx context.Context, encodedHash, password string) (bool, error) - // NeedsRehash reports whether encodedHash uses parameters weaker than - // the hasher's current configuration. Callers SHOULD invoke it after a - // successful Verify so that login flows can transparently upgrade - // stored hashes when the operator bumps cost factors. + // NeedsRehash reports whether encodedHash should be replaced: it uses + // parameters weaker than the hasher's current configuration, or Verify + // refuses it. Callers SHOULD invoke it after a successful Verify so that + // login flows can transparently upgrade stored hashes when the operator + // bumps cost factors. NeedsRehash(encodedHash string) bool } diff --git a/password/refusals_test.go b/password/refusals_test.go new file mode 100644 index 00000000..698b1812 --- /dev/null +++ b/password/refusals_test.go @@ -0,0 +1,185 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package password_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/hyperscale-stack/security/password" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +// Audit CORE-I7 (probe TestZZAuditBCrypt): Hash refused a password longer +// than 72 bytes while Verify silently compared its first 72 bytes — +// Verify(pw72+suffix) succeeded. NIST SP 800-63B-4 §3.1.1.2: verifiers SHALL +// verify the entire submitted password and not truncate it. +func TestBCryptRefusesPasswordsLongerThan72Bytes(t *testing.T) { + t.Parallel() + + h := password.NewBCryptHasher(4) + pw72 := strings.Repeat("A", 72) + + hash, err := h.Hash(context.Background(), pw72) + require.NoError(t, err, "72 bytes is the longest password bcrypt processes whole") + + ok, err := h.Verify(context.Background(), hash, pw72) + require.NoError(t, err) + assert.True(t, ok) + + ok, err = h.Verify(context.Background(), hash, pw72+"-anything-appended") + require.ErrorIs(t, err, password.ErrPasswordTooLong) + assert.False(t, ok) + + _, err = h.Hash(context.Background(), pw72+"x") + require.ErrorIs(t, err, password.ErrPasswordTooLong) + + // 18 four-byte UTF-8 characters fill the 72 bytes. + emoji := strings.Repeat("😀", 19) + _, err = h.Verify(context.Background(), hash, emoji) + require.ErrorIs(t, err, password.ErrPasswordTooLong) +} + +// Audit CORE-I7: empty passwords hashed and verified. RFC 8265 §4.1: "A +// password MUST NOT be zero bytes in length". +func TestHashRefusesAnEmptyPassword(t *testing.T) { + t.Parallel() + + for name, h := range map[string]password.Hasher{ + "bcrypt": password.NewBCryptHasher(4), + "argon2id": password.NewArgon2idHasher(fast2idParams()), + } { + _, err := h.Hash(context.Background(), "") + require.ErrorIs(t, err, password.ErrEmptyPassword, name) + } +} + +// A stored hash of the empty password — made by another system — never +// authenticates an empty password. +func TestVerifyNeverAcceptsAnEmptyPassword(t *testing.T) { + t.Parallel() + + bcryptEmpty, err := bcrypt.GenerateFromPassword(nil, 4) + require.NoError(t, err) + + cases := map[string]struct { + h password.Hasher + hash string + }{ + "bcrypt": {password.NewBCryptHasher(4), string(bcryptEmpty)}, + "argon2id": {tinyLimitsHasher(), realPHC("", 8, 1, 1, 16, 32)}, + } + + for name, c := range cases { + ok, err := c.h.Verify(context.Background(), c.hash, "") + require.NoError(t, err, name) + assert.False(t, ok, name) + } +} + +// Positive observation of the audit, pinned: NUL bytes are data, not +// terminators. +func TestNULIsData(t *testing.T) { + t.Parallel() + + for name, h := range map[string]password.Hasher{ + "bcrypt": password.NewBCryptHasher(4), + "argon2id": password.NewArgon2idHasher(fast2idParams()), + } { + hash, err := h.Hash(context.Background(), "a\x00b") + require.NoError(t, err, name) + + ok, err := h.Verify(context.Background(), hash, "a") + require.NoError(t, err, name) + assert.False(t, ok, name) + + ok, err = h.Verify(context.Background(), hash, "a\x00b") + require.NoError(t, err, name) + assert.True(t, ok, name) + } +} + +// The sentinels are distinct: application code branches on them. +func TestSentinelsAreDistinct(t *testing.T) { + t.Parallel() + + all := []error{ + password.ErrMismatch, password.ErrUnsupportedAlgorithm, password.ErrMalformedHash, + password.ErrCostLimitExceeded, password.ErrPasswordTooLong, password.ErrEmptyPassword, + } + + for i, a := range all { + for j, b := range all { + assert.Equal(t, i == j, errors.Is(a, b), "%v vs %v", a, b) + } + } +} + +// FuzzArgon2idDecode: no stored hash makes Verify or NeedsRehash panic, and +// Verify only ever reports the documented errors. The limits keep every +// accepted hash cheap. +func FuzzArgon2idDecode(f *testing.F) { + h := tinyLimitsHasher() + + f.Add(realPHC("pw", 8, 1, 1, 16, 32)) + f.Add(phc("m=64,t=0,p=1", bytes.Repeat([]byte{1}, 16), bytes.Repeat([]byte{2}, 32))) + f.Add("$argon2id$v=19$m=4294967295,t=4294967295,p=255$MDEyMzQ1Njc4OWFiY2RlZg$AAAAAAAAAAAAAAAAAAAAAA") + f.Add("$argon2id$v=19$m=64$$") + f.Add("$2a$04$abc") + f.Add("") + + f.Fuzz(func(t *testing.T, hash string) { + _ = h.NeedsRehash(hash) + + _, err := h.Verify(context.Background(), hash, "pw") + if err != nil && !errors.Is(err, password.ErrMalformedHash) && + !errors.Is(err, password.ErrUnsupportedAlgorithm) && !errors.Is(err, password.ErrCostLimitExceeded) { + t.Fatalf("Verify(%q) returned an undocumented error: %v", hash, err) + } + }) +} + +// Verify honors a canceled context before any work, for both hashers. +func TestVerifyHonorsACanceledContext(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + bcryptHash, err := bcrypt.GenerateFromPassword([]byte("pw"), 4) + require.NoError(t, err) + + for name, c := range map[string]struct { + h password.Hasher + hash string + }{ + "bcrypt": {password.NewBCryptHasher(4), string(bcryptHash)}, + "argon2id": {tinyLimitsHasher(), realPHC("pw", 8, 1, 1, 16, 32)}, + } { + ok, err := c.h.Verify(ctx, c.hash, "pw") + require.ErrorIs(t, err, context.Canceled, name) + assert.False(t, ok, name) + } +} + +// A bcrypt hash whose cost parses but whose salt is not bcrypt base64 is +// malformed, not a mismatch. +func TestBCryptVerifyReportsACorruptedSalt(t *testing.T) { + t.Parallel() + + good, err := bcrypt.GenerateFromPassword([]byte("pw"), 4) + require.NoError(t, err) + + corrupted := string(good[:7]) + strings.Repeat("!", len(good)-7) + + ok, err := password.NewBCryptHasher(4).Verify(context.Background(), corrupted, "pw") + require.ErrorIs(t, err, password.ErrMalformedHash) + assert.False(t, ok) +} From 8a5d636891fc40748299e312b48a1a8f20e13c2f Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:01:07 +0200 Subject: [PATCH 02/17] fix(basic): verify every attempt against a password hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit basic.Authenticator returned before verifying the password for unknown, disabled, locked and expired accounts — about 250 times faster than a known account with a wrong password at bcrypt cost 10 — so the response time told which accounts exist, contrary to its documentation (CWE-208). Every attempt now runs exactly one password verification: unknown users, loader failures and accounts without a password hash are verified against a dummy hash that the configured hasher makes at construction, and the lifecycle flags are read only after the verification. Every refusal is decided after that work; a failed Authenticate returns security.Anonymous() instead of its credential-bearing input. A loader implementing the new PasswordRehasher gets a stored hash that NeedsRehash upgraded after a successful login, so stored hashes converge on the hasher's parameters and keep costing what the dummy costs. Since any username now costs a full hash, an unbounded number of concurrent attempts could exhaust memory (19 MiB per default Argon2id verification). At most runtime.GOMAXPROCS(0) verifications run at once by default (WithMaxConcurrentVerifications, UnlimitedVerifications); an attempt that gets no slot before its context ends, or whose verification the context ended, fails with security.ErrUnavailable (503) whatever the account, so overload reveals nothing either. NewAuthenticator panics on a nil loader or hasher and when the hasher cannot make the dummy hash. The documentation now states the guarantees and their residuals (stored hashes at other parameters, the loader's own latency) and the operator's part: build the Authenticator once, bound request times, rate-limit failed attempts (NIST SP 800-63B-4 §3.2.2). Audit: CORE-03, CORE-12. --- CHANGELOG.md | 24 ++ LIMITATIONS.md | 12 + MIGRATION.md | 37 +++ basic/authenticator.go | 230 ++++++++++++++--- basic/authenticator_test.go | 14 +- basic/limiter_test.go | 428 ++++++++++++++++++++++++++++++++ basic/loader.go | 27 +- basic/timing_test.go | 332 +++++++++++++++++++++++++ docs/security-considerations.md | 51 +++- 9 files changed, 1107 insertions(+), 48 deletions(-) create mode 100644 basic/limiter_test.go create mode 100644 basic/timing_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c36a3a..d41af053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -491,6 +491,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `WithArgon2idLimits`, `Argon2idHasher.Limits`; `WithBCryptMaxCost`, `DefaultBCryptMaxCost` (14), `BCryptHasher.MaxCost` — and the sentinels `ErrCostLimitExceeded`, `ErrPasswordTooLong` and `ErrEmptyPassword`. +- `basic.WithMaxConcurrentVerifications(n)` and `basic.UnlimitedVerifications`: + the cap on concurrent password verifications (`runtime.GOMAXPROCS(0)` by + default), and `basic.PasswordRehasher`, an optional `UserLoader` + capability through which the `Authenticator` upgrades a stored hash that + `NeedsRehash` after a successful authentication. ### Changed @@ -770,6 +775,14 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `Hash` refuses an empty password (`ErrEmptyPassword`) and `Verify` never accepts one; `NeedsRehash` also flags a shorter salt and every hash `Verify` refuses. See [MIGRATION.md](MIGRATION.md). +- `basic.NewAuthenticator` panics on a nil loader or hasher and hashes a + random password once (the dummy hash unknown accounts are verified + against): build the `Authenticator` once, not per request. Every attempt + runs exactly one password verification, at most `GOMAXPROCS` + verifications run at once by default (an attempt that gets no slot before + its context ends fails with `ErrUnavailable`, 503), and a failed + `Authenticate` returns `security.Anonymous()` instead of its input. See + [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1657,3 +1670,14 @@ legacy packages (`authentication/`, `authorization/`, the in-tree contrary to NIST SP 800-63B-4 §3.1.1.2 ("SHALL verify the entire submitted password"), while `Hash` refused such passwords; it now refuses them. Empty passwords are no longer hashed nor accepted (RFC 8265 §4.1). +- `basic.Authenticator` answered unknown, disabled, locked and expired + accounts before verifying the password — about 250× faster than a known + account with a wrong password at bcrypt cost 10 — so the response time + told which accounts exist, contrary to its documentation. Every attempt + now costs one password verification: unknown accounts, loader failures + and accounts without a password are verified against a dummy hash made by + the configured hasher, and the account state is read after the + verification. Since any username now costs a full hash, the number of + concurrent verifications is capped by default (`runtime.GOMAXPROCS(0)`): + a flood of Basic attempts can no longer exhaust memory (19 MiB per + default Argon2id verification). diff --git a/LIMITATIONS.md b/LIMITATIONS.md index a629ee22..1cf3242c 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -462,6 +462,18 @@ PKCE. Not supported, and refused where a provider could select them: expects the user-id and password in Unicode Normalization Form C; `basic` checks them as received. +## HTTP Basic + +- **No rate limiter or account lockout is shipped** — NIST SP 800-63B-4 + §3.2.2 requires verifiers to limit failed attempts: do it at the edge or + in an `Authenticator` wrapper. The `Authenticator` only caps the number + of concurrent verifications (`runtime.GOMAXPROCS(0)` by default). +- **Timing equalization residuals** — every attempt costs one verification, + but a stored hash made with other parameters than the hasher's costs + another time until it is re-hashed (`PasswordRehasher`), and the + `UserLoader`'s own latency (a cache hit for a known user) is outside the + library's control. + ## Passwords - **No Unicode normalization** — the hashers hash a password's bytes as diff --git a/MIGRATION.md b/MIGRATION.md index 12eb33de..a20cc0e2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2056,3 +2056,40 @@ To migrate: 5. Unicode: the hashers still hash bytes as given. To apply NFC (NIST SP 800-63B-4 §3.1.1.2), use the decorator of the `password` package documentation at enrollment and at login. + +### `basic`: every attempt costs one verification; concurrent verifications are capped + +`basic.Authenticator` returned before verifying the password for unknown, +disabled, locked and expired accounts, so the response time revealed which +accounts exist. Every attempt now runs exactly one password verification — +against the user's hash, or against a dummy hash the hasher made at +construction — and the account state is read after it. Because any username +now costs a full hash, the concurrent verifications are capped by default. + +| | Before | After | +| --- | --- | --- | +| Unknown user, loader failure, account without a password hash | refused at once | verified against the dummy hash, then refused | +| Disabled, locked, expired account | refused before the password check | password verified, then refused | +| `NewAuthenticator(nil, …)`, `NewAuthenticator(…, nil)` | panic on the first request | panic at construction | +| `NewAuthenticator` | free | hashes one random password (the dummy) — panics if the hasher cannot hash | +| Concurrent verifications | unbounded | `runtime.GOMAXPROCS(0)` by default; `WithMaxConcurrentVerifications(n)`, `UnlimitedVerifications` | +| No slot before the request context ends; a verification ended by the context | `ErrInvalidCredentials` (401) | `ErrUnavailable` (503) | +| Value returned by a failed `Authenticate` | its input (password included) | `security.Anonymous()` | +| Stored hash that `NeedsRehash` | left as is | re-hashed after a successful login when the loader implements `PasswordRehasher` | + +To migrate: + +1. Build the `Authenticator` once at start-up: each construction hashes a + password. +2. Configure the hasher with the parameters of your stored hashes, and + implement `PasswordRehasher` on your loader so that hashes made with + other parameters (older settings, imports) are upgraded: the timing + equalization holds for hashes at the hasher's current parameters. +3. Bound the request time (`http.TimeoutHandler`, a deadline middleware): + an attempt waits for a verification slot until its context ends, and + `net/http` request contexts have no deadline. Keep the default cap for + bcrypt and Argon2id; pass `UnlimitedVerifications` only for a hasher that + is not CPU-bound. +4. A loader should return `(nil, nil)` or an error wrapping + `security.ErrInvalidCredentials` for an unknown user, and an error + wrapping `security.ErrUnavailable` when its store is down. diff --git a/basic/authenticator.go b/basic/authenticator.go index 61ddfb1e..6879b146 100644 --- a/basic/authenticator.go +++ b/basic/authenticator.go @@ -6,12 +6,23 @@ package basic import ( "context" + "crypto/rand" + "errors" "fmt" + "runtime" "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/password" ) +// UnlimitedVerifications removes the cap on concurrent password +// verifications: WithMaxConcurrentVerifications(UnlimitedVerifications). +// Use it only with a hasher whose Verify is neither CPU- nor memory-bound +// (one calling a remote HSM, say): bcrypt and Argon2id gain nothing from +// running more verifications than there are cores — only latency and +// memory. +const UnlimitedVerifications = -1 + // AuthorityResolver maps a [PasswordUser] to the authorities (roles, scopes, // claims) attached to the resulting [security.Authentication]. The default // resolver returns nil; applications that ship role-based authorization @@ -22,33 +33,79 @@ import ( type AuthorityResolver func(PasswordUser) []string // Authenticator implements [security.Authenticator] for the HTTP Basic -// scheme. It loads the user via a [UserLoader], runs lifecycle checks, -// verifies the password with a [password.Hasher], then returns the +// scheme. It loads the user via a [UserLoader], verifies the password with a +// [password.Hasher], checks the account's lifecycle flags, then returns the // authenticated [Authentication]. // -// Errors are always wrapped in [security.ErrInvalidCredentials] to avoid -// account-enumeration via response-time / response-code analysis — but a -// [UserLoader] error wrapping [security.ErrUnavailable] keeps it, and -// [security.ErrorClass] ranks the outage first: a user store that is down -// is answered 503 / Unavailable for every username, not refused. Detailed -// causes remain reachable through errors.As / errors.Is for server-side -// telemetry only — do NOT mirror them in the client response. +// Every attempt runs exactly one password verification, whatever the +// account: an unknown user, a loader failure or an account without a +// password hash is verified against a dummy hash the hasher made at +// construction, and the lifecycle flags (disabled, locked, expired) are +// read only after the verification. The response time therefore does not +// tell which accounts exist or are locked (CWE-208), and every refusal +// wraps [security.ErrInvalidCredentials] — the detailed cause stays in the +// error chain for server-side telemetry only: do NOT mirror it in the +// client response. A [UserLoader] error wrapping [security.ErrUnavailable] +// keeps it, and [security.ErrorClass] ranks the outage first: a user store +// that is down is answered 503 / Unavailable for every username, not +// refused. +// +// The equalization holds for stored hashes made with the hasher's current +// algorithm and parameters: configure the hasher with the parameters of +// your stored hashes, and let a [PasswordRehasher] loader upgrade the ones +// that differ — a hash at other parameters costs another time to verify +// than the dummy. +// +// Since every attempt costs a hash, at most runtime.GOMAXPROCS(0) +// verifications run at once by default ([WithMaxConcurrentVerifications]): +// further attempts wait for a slot, so a flood of attempts cannot exhaust +// memory (an Argon2id verification with the default parameters allocates +// 19 MiB). Rate-limit failed attempts per account and per client in front +// of it (NIST SP 800-63B-4 §3.2.2). type Authenticator struct { loader UserLoader hasher password.Hasher authResolv AuthorityResolver + dummyHash string + maxVerify int + slots chan struct{} // nil: unlimited } // NewAuthenticator returns an Authenticator using the supplied loader and // hasher. Authorities default to nil (use [WithAuthorityResolver] to // populate them from the user record). +// +// It hashes a random password once with hasher — the dummy hash that +// unknown accounts are verified against — so construction costs one hash: +// build the Authenticator once, not per request. It panics on a nil loader +// or hasher, and when hasher cannot hash: an Authenticator without its dummy +// hash would answer unknown accounts faster than known ones. func NewAuthenticator(loader UserLoader, hasher password.Hasher, opts ...Option) *Authenticator { - a := &Authenticator{loader: loader, hasher: hasher} + if loader == nil { + panic("basic: NewAuthenticator: nil UserLoader") + } + + if hasher == nil { + panic("basic: NewAuthenticator: nil password.Hasher") + } + + a := &Authenticator{loader: loader, hasher: hasher, maxVerify: runtime.GOMAXPROCS(0)} for _, o := range opts { o(a) } + if a.maxVerify != UnlimitedVerifications { + a.slots = make(chan struct{}, a.maxVerify) + } + + dummy, err := hasher.Hash(context.Background(), rand.Text()) + if err != nil { + panic("basic: NewAuthenticator: the hasher cannot make the dummy hash: " + err.Error()) + } + + a.dummyHash = dummy + return a } @@ -61,6 +118,23 @@ func WithAuthorityResolver(r AuthorityResolver) Option { return func(a *Authenticator) { a.authResolv = r } } +// WithMaxConcurrentVerifications runs at most n password verifications at +// once — the dummy ones included. Default: runtime.GOMAXPROCS(0). +// [UnlimitedVerifications] removes the cap. An attempt waits for a slot +// until its context ends, then fails with an error wrapping +// [security.ErrUnavailable] (HTTP 503 / Unavailable): an overloaded +// verifier is not a verdict on the credentials. net/http request contexts +// carry no deadline — a queued attempt waits as long as its client stays +// connected — so bound the request time (http.TimeoutHandler, a deadline +// middleware). It panics unless n ≥ 1 or n is UnlimitedVerifications. +func WithMaxConcurrentVerifications(n int) Option { + if n < 1 && n != UnlimitedVerifications { + panic(fmt.Sprintf("basic: WithMaxConcurrentVerifications: %d is neither ≥ 1 nor UnlimitedVerifications", n)) + } + + return func(a *Authenticator) { a.maxVerify = n } +} + // AuthenticatorName implements [security.NamedAuthenticator] so the core // Manager can attribute spans to "basic". func (a *Authenticator) AuthenticatorName() string { return "basic" } @@ -74,45 +148,143 @@ func (a *Authenticator) Supports(auth security.Authentication) bool { return ok } -// Authenticate implements [security.Authenticator]. +// Authenticate implements [security.Authenticator]. On failure it returns +// [security.Anonymous]: the presented credentials never flow back. func (a *Authenticator) Authenticate(ctx context.Context, auth security.Authentication) (security.Authentication, error) { in, ok := auth.(Authentication) if !ok { - return auth, security.ErrUnsupportedCredential + return security.Anonymous(), security.ErrUnsupportedCredential } - user, err := a.loader.LoadByUsername(ctx, in.Username()) - if err != nil { + user, loadErr := a.loader.LoadByUsername(ctx, in.Username()) + + stored, hasPassword := "", false + if loadErr == nil && user != nil { + stored = user.GetPasswordHash() + hasPassword = stored != "" + } + + hash := a.dummyHash + if hasPassword { + hash = stored + } + + match, verifyErr := a.verify(ctx, hash, in.Password()) + + if err := refusal(loadErr, user, hasPassword, match, verifyErr); err != nil { + return security.Anonymous(), err + } + + a.rehash(ctx, user, stored, in.Password()) + + var authorities []string + if a.authResolv != nil { + authorities = a.authResolv(user) + } + + return in.WithAuthenticated(user, authorities), nil +} + +// refusal returns the reason to refuse the attempt, or nil. It runs after +// the verification, so every refusal costs the same work. An unavailable +// verifier comes first: it is not a verdict on the account, and answering +// it differently for unknown accounts would reveal them under load. +func refusal(loadErr error, user PasswordUser, hasPassword, match bool, verifyErr error) error { + switch { + case verifyErr != nil && unavailable(verifyErr): + return fmt.Errorf("basic: verify password: %w (%w)", verifyErr, security.ErrUnavailable) + case loadErr != nil: // Loader-level errors (db down, unknown user, ...) collapse to a - // single ErrInvalidCredentials at the client boundary. The original + // single ErrInvalidCredentials at the client boundary (an + // ErrUnavailable the loader wrapped still answers 503). The original // error stays in the chain for observability; the typed username // does not (it can be a password typed in the wrong field). - return auth, fmt.Errorf("basic: load user: %w (%w)", err, security.ErrInvalidCredentials) + return fmt.Errorf("basic: load user: %w (%w)", loadErr, security.ErrInvalidCredentials) + case user == nil: + return fmt.Errorf("basic: user not found: %w", security.ErrInvalidCredentials) + case !hasPassword: + return fmt.Errorf("basic: account without a password hash: %w", security.ErrInvalidCredentials) + case verifyErr != nil: + return fmt.Errorf("basic: verify password: %w (%w)", verifyErr, security.ErrInvalidCredentials) + case !match: + return fmt.Errorf("basic: password mismatch: %w", security.ErrInvalidCredentials) + case !user.IsEnabled() || user.IsLocked() || user.IsExpired() || user.IsCredentialsExpired(): + return fmt.Errorf("basic: account ineligible: %w", security.ErrInvalidCredentials) } - if user == nil { - return auth, fmt.Errorf("basic: user not found: %w", security.ErrInvalidCredentials) - } + return nil +} - if !user.IsEnabled() || user.IsLocked() || user.IsExpired() || user.IsCredentialsExpired() { - return auth, fmt.Errorf("basic: account ineligible: %w", security.ErrInvalidCredentials) +// unavailable reports whether a verification error means the credentials +// were not checked: no slot before the context ended, or the context ended +// before the hashing. +func unavailable(err error) bool { + return errors.Is(err, security.ErrUnavailable) || + errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) +} + +// verify runs one password verification inside a slot. +func (a *Authenticator) verify(ctx context.Context, hash, pw string) (bool, error) { + release, err := a.acquire(ctx) + if err != nil { + return false, err } - ok, err = a.hasher.Verify(ctx, user.GetPasswordHash(), in.Password()) + defer release() + + match, err := a.hasher.Verify(ctx, hash, pw) if err != nil { - return auth, fmt.Errorf("basic: hash verify: %w (%w)", err, security.ErrInvalidCredentials) + return false, fmt.Errorf("basic: %w", err) } - if !ok { - return auth, fmt.Errorf("basic: password mismatch: %w", security.ErrInvalidCredentials) + return match, nil +} + +// acquire waits for a verification slot until ctx ends. A context that has +// already ended never gets a slot (select would pick a free slot or the +// done context at random). +func (a *Authenticator) acquire(ctx context.Context) (func(), error) { + if a.slots == nil { + return func() {}, nil } - var authorities []string - if a.authResolv != nil { - authorities = a.authResolv(user) + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("basic: no verification slot: %w (%w)", err, security.ErrUnavailable) } - return in.WithAuthenticated(user, authorities), nil + select { + case a.slots <- struct{}{}: + return func() { <-a.slots }, nil + case <-ctx.Done(): + return nil, fmt.Errorf("basic: waiting for a verification slot: %w (%w)", ctx.Err(), security.ErrUnavailable) + } +} + +// rehash re-hashes the password of a successfully authenticated user whose +// stored hash NeedsRehash, when the loader can store it. A failure leaves +// the stored hash as it is — the next login tries again — and never fails +// the authentication. It runs only after a success, so it reveals nothing +// about other accounts. +func (a *Authenticator) rehash(ctx context.Context, user PasswordUser, stored, pw string) { + rehasher, ok := a.loader.(PasswordRehasher) + if !ok || !a.hasher.NeedsRehash(stored) { + return + } + + release, err := a.acquire(ctx) + if err != nil { + return + } + + defer release() + + hash, err := a.hasher.Hash(ctx, pw) + if err != nil { + return + } + + _ = rehasher.UpdatePasswordHash(ctx, user, hash) } // Compile-time interface check. diff --git a/basic/authenticator_test.go b/basic/authenticator_test.go index 3154ef81..1d5e9f95 100644 --- a/basic/authenticator_test.go +++ b/basic/authenticator_test.go @@ -19,12 +19,12 @@ import ( // stubUser is a [basic.PasswordUser] driven by per-field flags so individual // tests can dial in the exact lifecycle scenario they need. type stubUser struct { - subject string - passwordHash string - enabled bool - locked bool - expired bool - credentialsExpired bool + subject string + passwordHash string + enabled bool + locked bool + expired bool + credentialsExpired bool } func (u *stubUser) Subject() string { return u.subject } @@ -184,6 +184,6 @@ func TestAuthenticatorRejectsForeignAuthentication(t *testing.T) { func TestAuthenticatorName(t *testing.T) { t.Parallel() - a := basic.NewAuthenticator(nil, nil) + a := basic.NewAuthenticator(&stubLoader{}, newHasher(t)) assert.Equal(t, "basic", a.AuthenticatorName()) } diff --git a/basic/limiter_test.go b/basic/limiter_test.go new file mode 100644 index 00000000..a28e4eaa --- /dev/null +++ b/basic/limiter_test.go @@ -0,0 +1,428 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// gateHasher blocks every Verify until release is closed and tracks how +// many run at once. +type gateHasher struct { + release chan struct{} + running atomic.Int32 + max atomic.Int32 + verifies atomic.Int32 +} + +func newGateHasher() *gateHasher { return &gateHasher{release: make(chan struct{})} } + +func (h *gateHasher) Hash(context.Context, string) (string, error) { return "dummy", nil } + +func (h *gateHasher) Verify(ctx context.Context, _, _ string) (bool, error) { + h.verifies.Add(1) + + n := h.running.Add(1) + defer h.running.Add(-1) + + for { + m := h.max.Load() + if n <= m || h.max.CompareAndSwap(m, n) { + break + } + } + + select { + case <-h.release: + return false, nil + case <-ctx.Done(): + return false, ctx.Err() + } +} + +func (h *gateHasher) NeedsRehash(string) bool { return false } + +func unknownUsers() basic.UserLoader { + return funcLoader(func(string) (basic.PasswordUser, error) { return nil, nil }) +} + +// runAttempts starts n concurrent attempts and returns a function waiting +// for them — failing the test, instead of hanging it, when they do not end. +func runAttempts(t *testing.T, a *basic.Authenticator, n int) func() { + t.Helper() + + var wg sync.WaitGroup + + for i := range n { + wg.Add(1) + + go func() { + defer wg.Done() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + _, _ = a.Authenticate(ctx, basic.New(fmt.Sprintf("user-%d", i), "pw")) + }() + } + + return func() { + t.Helper() + + done := make(chan struct{}) + + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("attempts did not end: a verification slot leaked") + } + } +} + +// assertConcurrencyCap starts more attempts than limit and checks that +// exactly limit verifications run at once, then that every attempt ends. +func assertConcurrencyCap(t *testing.T, a *basic.Authenticator, h *gateHasher, limit, attempts int) { + t.Helper() + + wait := runAttempts(t, a, attempts) + + require.Eventually(t, func() bool { return h.running.Load() >= int32(limit) }, 5*time.Second, time.Millisecond) //nolint:gosec // small test values + time.Sleep(20 * time.Millisecond) // give extra attempts a chance to overshoot + assert.Equal(t, int32(limit), h.max.Load(), "verifications running at once") //nolint:gosec // small test values + + close(h.release) + wait() + + assert.Equal(t, int32(attempts), h.verifies.Load(), "every attempt was verified once") //nolint:gosec // small test values +} + +// Audit CORE-12 (probe TestZZAuditArgon2idConcurrentMemory: 16 concurrent +// default argon2id verifications allocated 304 MiB) and review finding 1: +// once every attempt costs a hash (CORE-03), an unbounded number of +// concurrent attempts — with any username — exhausts memory. By default an +// Authenticator runs at most runtime.GOMAXPROCS(0) verifications at once: +// CPU-bound hashing gains nothing from more. +func TestAuthenticatorCapsConcurrentVerificationsByDefault(t *testing.T) { + t.Parallel() + + h := newGateHasher() + limit := runtime.GOMAXPROCS(0) + + assertConcurrencyCap(t, basic.NewAuthenticator(unknownUsers(), h), h, limit, limit+5) +} + +func TestWithMaxConcurrentVerifications(t *testing.T) { + t.Parallel() + + h := newGateHasher() + a := basic.NewAuthenticator(unknownUsers(), h, basic.WithMaxConcurrentVerifications(3)) + + assertConcurrencyCap(t, a, h, 3, 12) +} + +// UnlimitedVerifications removes the cap (for a hasher that is not CPU-bound, +// such as one calling a remote HSM). +func TestUnlimitedVerifications(t *testing.T) { + t.Parallel() + + h := newGateHasher() + limit := runtime.GOMAXPROCS(0) + 8 + a := basic.NewAuthenticator(unknownUsers(), h, basic.WithMaxConcurrentVerifications(basic.UnlimitedVerifications)) + + assertConcurrencyCap(t, a, h, limit, limit) +} + +func TestWithMaxConcurrentVerificationsPanics(t *testing.T) { + t.Parallel() + + for _, n := range []int{0, -2, -100} { + assert.Panics(t, func() { basic.WithMaxConcurrentVerifications(n) }, "n=%d", n) + } +} + +// An attempt waiting for a slot gives up when its context ends: the +// credentials were not checked, so the answer is ErrUnavailable (503), never +// ErrInvalidCredentials — and the same for an unknown user, or an overload +// would tell which accounts exist. +func TestAuthenticatorVerificationQueueHonorsTheContext(t *testing.T) { + t.Parallel() + + h := newGateHasher() + users := funcLoader(func(username string) (basic.PasswordUser, error) { + if username == "alice" { + return &stubUser{subject: "alice", passwordHash: "hash", enabled: true}, nil + } + + return nil, security.ErrInvalidCredentials + }) + a := basic.NewAuthenticator(users, h, basic.WithMaxConcurrentVerifications(1)) + + wait := runAttempts(t, a, 1) // holds the only slot + require.Eventually(t, func() bool { return h.running.Load() == 1 }, 5*time.Second, time.Millisecond) + + for _, username := range []string{"alice", "nobody"} { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + + type result struct { + out security.Authentication + err error + } + + done := make(chan result, 1) + + go func() { + out, err := a.Authenticate(ctx, basic.New(username, "pw")) + done <- result{out, err} + }() + + var r result + + select { + case r = <-done: + case <-time.After(5 * time.Second): + t.Fatalf("%s: the attempt kept waiting after its context ended", username) + } + + cancel() + + out, err := r.out, r.err + + require.ErrorIs(t, err, security.ErrUnavailable, username) + assert.NotErrorIs(t, err, security.ErrInvalidCredentials, username) + assert.Equal(t, security.ErrorClassUnavailable, security.ErrorClass(err), username) + assert.False(t, out.IsAuthenticated()) + } + + assert.Equal(t, int32(1), h.verifies.Load(), "the queued attempts never reached Verify") + + close(h.release) + wait() +} + +// ctxHasher returns a context error, as a hasher does when the request's +// context ended between the slot and the hashing. +type ctxHasher struct{ recordingHasher } + +func (h *ctxHasher) Verify(context.Context, string, string) (bool, error) { + return false, fmt.Errorf("password: context canceled: %w", context.DeadlineExceeded) +} + +func TestAuthenticatorMapsAContextEndedVerificationToUnavailable(t *testing.T) { + t.Parallel() + + a := basic.NewAuthenticator(unknownUsers(), &ctxHasher{}) + + _, err := a.Authenticate(context.Background(), basic.New("nobody", "pw")) + + require.ErrorIs(t, err, security.ErrUnavailable) + assert.NotErrorIs(t, err, security.ErrInvalidCredentials) +} + +// rehashLoader is a UserLoader that also stores new password hashes. +type rehashLoader struct { + user *stubUser + mu sync.Mutex + updates []string + err error +} + +func (l *rehashLoader) LoadByUsername(context.Context, string) (basic.PasswordUser, error) { + return l.user, nil +} + +func (l *rehashLoader) UpdatePasswordHash(_ context.Context, user basic.PasswordUser, hash string) error { + l.mu.Lock() + defer l.mu.Unlock() + + if user.Subject() != l.user.subject { + return errors.New("wrong user") + } + + l.updates = append(l.updates, hash) + + return l.err +} + +func (l *rehashLoader) updated() []string { + l.mu.Lock() + defer l.mu.Unlock() + + return append([]string(nil), l.updates...) +} + +// Review finding 2: the dummy hash costs what a hash at the hasher's current +// parameters costs; stored hashes made with other parameters keep a timing +// difference until they are re-hashed. A loader implementing +// PasswordRehasher gets the stored hash upgraded after a successful +// authentication whose hash NeedsRehash. +func TestAuthenticatorRehashesOutdatedHashes(t *testing.T) { + t.Parallel() + + newUser := func() *stubUser { return &stubUser{subject: "alice", passwordHash: "old", enabled: true} } + + t.Run("outdated hash is re-hashed after a success", func(t *testing.T) { + t.Parallel() + + l := &rehashLoader{user: newUser()} + h := &recordingHasher{match: "old", rehash: true} + a := basic.NewAuthenticator(l, h) + + out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) + assert.Equal(t, []string{"dummy-2"}, l.updated(), "the new hash comes from the hasher") + }) + + t.Run("current hash is left alone", func(t *testing.T) { + t.Parallel() + + l := &rehashLoader{user: newUser()} + a := basic.NewAuthenticator(l, &recordingHasher{match: "old"}) + + _, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.NoError(t, err) + assert.Empty(t, l.updated()) + }) + + for name, mod := range map[string]func(*stubUser){ + "wrong password": func(u *stubUser) { u.passwordHash = "other" }, + "locked account": func(u *stubUser) { u.locked = true }, + } { + t.Run("no rehash on a failure: "+name, func(t *testing.T) { + t.Parallel() + + u := newUser() + mod(u) + l := &rehashLoader{user: u} + a := basic.NewAuthenticator(l, &recordingHasher{match: "old", rehash: true}) + + _, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.Empty(t, l.updated()) + }) + } + + t.Run("a failed update does not fail the authentication", func(t *testing.T) { + t.Parallel() + + l := &rehashLoader{user: newUser(), err: errors.New("store read-only")} + a := basic.NewAuthenticator(l, &recordingHasher{match: "old", rehash: true}) + + out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) + assert.Len(t, l.updated(), 1) + }) + + t.Run("a loader without PasswordRehasher is never asked", func(t *testing.T) { + t.Parallel() + + a := basic.NewAuthenticator(&stubLoader{user: newUser()}, &recordingHasher{match: "old", rehash: true}) + + out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) + }) +} + +// failingRehashHasher verifies, asks for a rehash, but cannot hash again. +type failingRehashHasher struct{ recordingHasher } + +func (h *failingRehashHasher) Hash(ctx context.Context, pw string) (string, error) { + if h.hashes > 0 { + return "", errors.New("hsm offline") + } + + return h.recordingHasher.Hash(ctx, pw) +} + +func TestAuthenticatorIgnoresAFailedRehash(t *testing.T) { + t.Parallel() + + l := &rehashLoader{user: &stubUser{subject: "alice", passwordHash: "old", enabled: true}} + a := basic.NewAuthenticator(l, &failingRehashHasher{recordingHasher{match: "old", rehash: true}}) + + out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) + assert.Empty(t, l.updated()) +} + +// A context that ended during the verification gets no slot for the +// rehash: the stored hash stays as it is and the authentication succeeds. +func TestAuthenticatorSkipsTheRehashWhenTheContextEnded(t *testing.T) { + t.Parallel() + + l := &rehashLoader{user: &stubUser{subject: "alice", passwordHash: "old", enabled: true}} + h := &recordingHasher{match: "old", rehash: true, delay: 40 * time.Millisecond} // Verify ignores ctx + a := basic.NewAuthenticator(l, h) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + out, err := a.Authenticate(ctx, basic.New("alice", "right")) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) + assert.Empty(t, l.updated()) +} + +// A canceled request never starts a verification. +func TestAuthenticatorDoesNotVerifyForACanceledRequest(t *testing.T) { + t.Parallel() + + h := &recordingHasher{} + a := basic.NewAuthenticator(unknownUsers(), h) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := a.Authenticate(ctx, basic.New("nobody", "right")) + + require.ErrorIs(t, err, security.ErrUnavailable) + assert.Empty(t, h.calls()) +} + +// Every attempt gives its slot back: with one slot, sequential attempts never +// wait. +func TestAuthenticatorReleasesItsSlots(t *testing.T) { + t.Parallel() + + a := basic.NewAuthenticator(unknownUsers(), &recordingHasher{}, basic.WithMaxConcurrentVerifications(1)) + + for i := range 5 { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + + _, err := a.Authenticate(ctx, basic.New("nobody", "right")) + + cancel() + + require.ErrorIs(t, err, security.ErrInvalidCredentials, "attempt %d", i) + require.NotErrorIs(t, err, security.ErrUnavailable, "attempt %d waited for a slot", i) + } +} diff --git a/basic/loader.go b/basic/loader.go index 82237824..53c53d8b 100644 --- a/basic/loader.go +++ b/basic/loader.go @@ -43,13 +43,26 @@ type PasswordUser interface { // in the application layer; this module ships no implementation to keep // itself storage-agnostic. // -// On unknown user, implementations SHOULD return an error wrapping -// [security.ErrInvalidCredentials] to prevent account enumeration via -// response-time / response-code differences. A store that is temporarily -// unavailable SHOULD be reported with an error wrapping -// [security.ErrUnavailable]: the [Authenticator] then answers 503 / -// Unavailable (retryable) instead of refusing the credentials. Any other -// error is a refusal ([security.ErrInvalidCredentials], 401). +// For an unknown user, return (nil, nil) or an error wrapping +// [security.ErrInvalidCredentials]: the [Authenticator] then verifies the +// password against its dummy hash, so the response does not reveal whether +// the account exists. A store that is temporarily unavailable SHOULD be +// reported with an error wrapping [security.ErrUnavailable]: the +// [Authenticator] then answers 503 / Unavailable (retryable) instead of +// refusing the credentials. Any other error is a refusal +// ([security.ErrInvalidCredentials], 401). type UserLoader interface { LoadByUsername(ctx context.Context, username string) (PasswordUser, error) } + +// PasswordRehasher is implemented by a [UserLoader] that can store a new +// password hash for a user. After a successful authentication whose stored +// hash NeedsRehash — the hasher's parameters were raised, or the hash was +// imported with other parameters — the [Authenticator] hashes the password +// again and calls UpdatePasswordHash with the new hash. Keeping every +// stored hash at the hasher's current parameters keeps the response time of +// known accounts equal to the dummy verification of unknown ones. An error +// is ignored: the authentication succeeds and the next login retries. +type PasswordRehasher interface { + UpdatePasswordHash(ctx context.Context, user PasswordUser, hash string) error +} diff --git a/basic/timing_test.go b/basic/timing_test.go new file mode 100644 index 00000000..3e9ee233 --- /dev/null +++ b/basic/timing_test.go @@ -0,0 +1,332 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "context" + "errors" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/basic" + "github.com/hyperscale-stack/security/password" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingHasher is a password.Hasher fake: Hash returns a fresh +// "dummy-N" value; Verify waits `delay`, records the hash it was given and +// accepts the password "right" against the hash `match` — or every +// password against every hash when `always` is set. +type recordingHasher struct { + mu sync.Mutex + hashes int + verified []string + match string + always bool + delay time.Duration + rehash bool + hashErr error +} + +func (h *recordingHasher) Hash(context.Context, string) (string, error) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.hashErr != nil { + return "", h.hashErr + } + + h.hashes++ + + return "dummy-" + string(rune('0'+h.hashes)), nil +} + +func (h *recordingHasher) Verify(_ context.Context, hash, pw string) (bool, error) { + time.Sleep(h.delay) + + h.mu.Lock() + defer h.mu.Unlock() + + h.verified = append(h.verified, hash) + + return h.always || (h.match != "" && hash == h.match && pw == "right"), nil +} + +func (h *recordingHasher) NeedsRehash(string) bool { return h.rehash } + +func (h *recordingHasher) calls() []string { + h.mu.Lock() + defer h.mu.Unlock() + + return slices.Clone(h.verified) +} + +// funcLoader is a basic.UserLoader driven by a function. +type funcLoader func(username string) (basic.PasswordUser, error) + +func (f funcLoader) LoadByUsername(_ context.Context, username string) (basic.PasswordUser, error) { + return f(username) +} + +// attempts lists every outcome an attacker could tell apart by timing. +func attempts(real string) []struct { + name string + loader funcLoader + password string + dummy bool // verified against the dummy hash +} { + user := func(mod func(*stubUser)) funcLoader { + return func(string) (basic.PasswordUser, error) { + u := &stubUser{subject: "alice", passwordHash: real, enabled: true} + mod(u) + + return u, nil + } + } + + return []struct { + name string + loader funcLoader + password string + dummy bool + }{ + {"unknown user (nil, nil)", func(string) (basic.PasswordUser, error) { return nil, nil }, "right", true}, + {"unknown user (ErrInvalidCredentials)", func(string) (basic.PasswordUser, error) { + return nil, security.ErrInvalidCredentials + }, "right", true}, + {"loader failure", func(string) (basic.PasswordUser, error) { return nil, errors.New("store down") }, "right", true}, + {"account without a password hash", user(func(u *stubUser) { u.passwordHash = "" }), "right", true}, + {"disabled", user(func(u *stubUser) { u.enabled = false }), "right", false}, + {"locked", user(func(u *stubUser) { u.locked = true }), "right", false}, + {"expired", user(func(u *stubUser) { u.expired = true }), "right", false}, + {"credentials expired", user(func(u *stubUser) { u.credentialsExpired = true }), "right", false}, + {"wrong password", user(func(*stubUser) {}), "wrong", false}, + } +} + +// Audit CORE-03 (probe TestZZAuditBasicTimingOracle): unknown, disabled, +// locked and expired accounts returned before the password check — 267 µs +// against 62.5 ms for a known user with bcrypt cost 10 — so the response +// time told which accounts exist. Every attempt now runs exactly one +// password verification: against the user's hash, or against a dummy hash +// the configured hasher made at construction; the account state is read +// after it. +func TestAuthenticatorVerifiesExactlyOncePerAttempt(t *testing.T) { + t.Parallel() + + for _, c := range attempts("real-hash") { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + h := &recordingHasher{match: "real-hash"} + a := basic.NewAuthenticator(c.loader, h) + + out, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) + + require.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.False(t, out.IsAuthenticated()) + assert.Equal(t, security.Anonymous(), out, "a failure never hands the credentials back") + + want := "real-hash" + if c.dummy { + want = "dummy-1" // the hash the hasher produced at construction + } + + assert.Equal(t, []string{want}, h.calls()) + }) + } +} + +// Review finding 7: a hasher that accepts every password (buggy, custom, +// or the dummy matching by chance) never authenticates a refused account: +// the refusals are checked after the verification, not instead of it. +func TestAuthenticatorRefusalsHoldWhateverTheHasherAnswers(t *testing.T) { + t.Parallel() + + for _, c := range attempts("real-hash") { + if c.password == "wrong" { + continue // the only refusal the hasher decides + } + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + h := &recordingHasher{always: true} + a := basic.NewAuthenticator(c.loader, h) + + out, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) + + require.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.False(t, out.IsAuthenticated()) + assert.Len(t, h.calls(), 1) + }) + } +} + +// The account state is read after the password: a locked account answers +// the same with the right and the wrong password. +func TestAuthenticatorChecksTheAccountStateAfterThePassword(t *testing.T) { + t.Parallel() + + h := &recordingHasher{match: "real-hash"} + locked := &stubUser{subject: "alice", passwordHash: "real-hash", enabled: true, locked: true} + a := basic.NewAuthenticator(&stubLoader{user: locked}, h) + + _, rightErr := a.Authenticate(context.Background(), basic.New("alice", "right")) + _, wrongErr := a.Authenticate(context.Background(), basic.New("alice", "wrong")) + + require.ErrorIs(t, rightErr, security.ErrInvalidCredentials) + require.ErrorIs(t, wrongErr, security.ErrInvalidCredentials) + assert.Equal(t, []string{"real-hash", "real-hash"}, h.calls()) +} + +// Robust timing check: a Verify that takes at least `delay` makes every +// refused attempt take at least `delay` — a lower bound guaranteed by the +// sleep, so the test cannot flake. Before the fix, unknown and ineligible +// accounts answered in microseconds. +func TestAuthenticatorEveryAttemptTakesAVerification(t *testing.T) { + t.Parallel() + + const delay = 25 * time.Millisecond + + for _, c := range attempts("real-hash") { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + a := basic.NewAuthenticator(c.loader, &recordingHasher{match: "real-hash", delay: delay}) + + start := time.Now() + _, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) + elapsed := time.Since(start) + + require.Error(t, err) + assert.GreaterOrEqual(t, elapsed, delay, "the attempt skipped the password verification") + }) + } +} + +// With the real hashers, unknown and known accounts answer in comparable +// time. To stay robust on a loaded machine, the accounts are interleaved in +// every round (drift — warm-up, GC debt, CPU frequency — affects them all +// alike) and the fastest of nine runs is compared: noise only ever adds +// time. Before the fix the ratio was about 0.0001; the bound is 0.5. +func TestAuthenticatorResponseTimeDoesNotRevealAccounts(t *testing.T) { + for _, c := range []struct { + name string + h password.Hasher + }{ + {"bcrypt", password.NewBCryptHasher(6)}, + {"argon2id", password.NewArgon2idHasher(password.Argon2idParams{MemoryKiB: 4 * 1024, Time: 1, Parallelism: 1})}, + } { + hash, err := c.h.Hash(context.Background(), "right") + require.NoError(t, err) + + users := map[string]*stubUser{ + "alice": {subject: "alice", passwordHash: hash, enabled: true}, + "locked": {subject: "locked", passwordHash: hash, enabled: true, locked: true}, + } + loader := funcLoader(func(username string) (basic.PasswordUser, error) { + if u, ok := users[username]; ok { + return u, nil + } + + return nil, security.ErrInvalidCredentials + }) + + a := basic.NewAuthenticator(loader, c.h) + fastest := map[string]time.Duration{} + + for range 9 { + for _, username := range []string{"alice", "nobody", "locked"} { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + start := time.Now() + _, err := a.Authenticate(ctx, basic.New(username, "wrong")) + d := time.Since(start) + + cancel() + require.NotErrorIs(t, err, security.ErrUnavailable) + + if fastest[username] == 0 || d < fastest[username] { + fastest[username] = d + } + } + } + + for _, other := range []string{"nobody", "locked"} { + ratio := float64(fastest[other]) / float64(fastest["alice"]) + assert.GreaterOrEqual(t, ratio, 0.5, "%s: %s answers %.3f× the time of a known account", c.name, other, ratio) + } + } +} + +// The dummy hash is made by the configured hasher, with its current +// parameters: it costs what a stored hash at those parameters costs. +func TestNewAuthenticatorMakesTheDummyWithTheConfiguredHasher(t *testing.T) { + t.Parallel() + + params := password.Argon2idParams{MemoryKiB: 1024, Time: 3, Parallelism: 2, KeyLen: 32, SaltLen: 16} + rec := &recordingWrapper{Hasher: password.NewArgon2idHasher(params)} + + a := basic.NewAuthenticator(funcLoader(func(string) (basic.PasswordUser, error) { return nil, nil }), rec) + + _, err := a.Authenticate(context.Background(), basic.New("nobody", "pw")) + require.ErrorIs(t, err, security.ErrInvalidCredentials) + + require.Len(t, rec.verified, 1) + assert.True(t, strings.HasPrefix(rec.verified[0], "$argon2id$v=19$m=1024,t=3,p=2$"), rec.verified[0]) +} + +// recordingWrapper records the hashes a real hasher verifies. +type recordingWrapper struct { + password.Hasher + + mu sync.Mutex + verified []string +} + +func (w *recordingWrapper) Verify(ctx context.Context, hash, pw string) (bool, error) { + w.mu.Lock() + w.verified = append(w.verified, hash) + w.mu.Unlock() + + return w.Hasher.Verify(ctx, hash, pw) //nolint:wrapcheck // test fake +} + +func TestNewAuthenticatorPanics(t *testing.T) { + t.Parallel() + + loader := funcLoader(func(string) (basic.PasswordUser, error) { return nil, nil }) + + assert.PanicsWithValue(t, "basic: NewAuthenticator: nil UserLoader", func() { + basic.NewAuthenticator(nil, &recordingHasher{}) + }) + assert.PanicsWithValue(t, "basic: NewAuthenticator: nil password.Hasher", func() { + basic.NewAuthenticator(loader, nil) + }) + assert.Panics(t, func() { + basic.NewAuthenticator(loader, &recordingHasher{hashErr: errors.New("hsm offline")}) + }, "an Authenticator without its dummy hash would reopen the timing oracle") +} + +// A stored hash the hasher cannot verify (malformed, another algorithm, +// above the cost limit) refuses the attempt with ErrInvalidCredentials, the +// cause kept for the server's logs. +func TestAuthenticatorRefusesAnUnverifiableStoredHash(t *testing.T) { + t.Parallel() + + user := &stubUser{subject: "alice", passwordHash: "$2a$broken", enabled: true} + a := basic.NewAuthenticator(&stubLoader{user: user}, password.NewBCryptHasher(4)) + + out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) + + require.ErrorIs(t, err, security.ErrInvalidCredentials) + require.ErrorIs(t, err, password.ErrMalformedHash) + assert.False(t, out.IsAuthenticated()) +} diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 4508f569..78738dba 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -50,11 +50,47 @@ enrollment and at login. ## Account enumeration -`basic.Authenticator` collapses every failure — unknown user, loader error, -disabled/locked/expired account, password mismatch — into a single -`security.ErrInvalidCredentials` at the client boundary. The detailed cause -stays in the wrapped error chain for server-side telemetry only. Do not -mirror the detailed cause in the HTTP/gRPC response. +`basic.Authenticator` answers every refused attempt — unknown user, loader +error, account without a password, disabled/locked/expired account, password +mismatch — with `security.ErrInvalidCredentials` at the client boundary, and +spends the same work on each: exactly one password verification, against +the user's hash or against a dummy hash the configured hasher made at +construction; the account state is read only after the verification. The +detailed cause stays in the wrapped error chain for server-side telemetry +only. Do not mirror it in the HTTP/gRPC response. + +What remains measurable, and how to close it: + +- **Stored hashes at other parameters** cost another time than the dummy: + configure the hasher with the parameters of your stored hashes and + implement `basic.PasswordRehasher` on your loader, which re-hashes a + stored hash that `NeedsRehash` after a successful login. +- **The loader's own latency** (a cache hit for known users, a slower miss) + is the application's: make unknown and known lookups cost the same. +- A stored hash `Verify` refuses (malformed, above the limits) fails without + hashing — an operator data problem, not reachable by a client. +- Under overload an attempt that gets no verification slot answers 503 + (`ErrUnavailable`) whatever the account, so overload reveals nothing. + +### Online guessing and hashing cost + +Basic authentication runs a full password verification on every request, +and since any username now costs one, a flood of attempts costs the server +CPU and memory (19 MiB per default Argon2id verification). The +`Authenticator` therefore runs at most `runtime.GOMAXPROCS(0)` verifications +at once by default (`WithMaxConcurrentVerifications`); further attempts wait +for a slot until their request context ends, then answer 503. Hashing is +CPU-bound: more parallel verifications than cores add latency and memory, +not throughput. Bound the request time (`http.TimeoutHandler`): `net/http` +request contexts carry no deadline. + +The library ships no rate limiter: limit failed attempts per account and per +client at the edge or in an `Authenticator` wrapper (NIST SP 800-63B-4 +§3.1.1.2, §3.2.2 — verifiers SHALL rate-limit failed attempts), and prefer +exchanging Basic credentials once for a session or a token over sending them +on every request. Form logins and the OAuth2 `password` grant's +`ResourceOwnerVerifier` can reuse `basic.Authenticator` to get the same +protections. ## JWT @@ -1489,6 +1525,11 @@ span catalog. - [ ] Pick a password hasher and review its cost against current hardware; raise its limits (`WithArgon2idLimits`, `WithBCryptMaxCost`) before importing hashes costlier than the defaults. +- [ ] HTTP Basic: build each `basic.Authenticator` once, configure its hasher + with the parameters of the stored hashes (and implement + `basic.PasswordRehasher`), bound the request time + (`http.TimeoutHandler`), and rate-limit failed attempts per account + and per client in front of it. - [ ] Rotate JWT signing keys; expose them through a JWKS endpoint (`oidc/provider` serves one). - [ ] Keep the JWT allowlist asymmetric unless you truly need HMAC. From 9412be8390a30d8b53737393e9a13e887a3beab5 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:06:06 +0200 Subject: [PATCH 03/17] fix(basic): parse Basic credentials strictly and name the subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Basic extractor forwarded control characters (NUL, CR LF, ANSI escape sequences) and invalid UTF-8 in the user-id to the UserLoader — a log injection vector into the application — and encoding/base64 silently skipped CR LF inside the credentials. RFC 7617 §2: "The user-id and password MUST NOT contain any control characters". The extractor now checks the base64 alphabet before decoding, decodes with canonical padding (RFC 4648 §4, §3.5), refuses invalid UTF-8 and every control character (C0, DEL and C1, RFC 8264 §9.12) and bounds the credentials (4096 base64 characters, 3072 bytes decoded), and accepts several spaces after the scheme (RFC 9110 §11.4); "Basic" without credentials is malformed. Refusals wrap ErrBadFormat and ErrInvalidCredentials and never quote the input. basic.Authenticator applies the same checks before any lookup — a form login or an OAuth2 password grant building the value with basic.New gets them too — and refuses an empty user-id (RFC 8265 §3.1), which the extractor still lets through for custom authenticators taking API keys as `-u :KEY`. Malformed input costs no verification: it is a property of the input, not of an account. Authentication.Name() returned the user-id as typed ("ALICE" for the subject "alice", or a password typed in the username field) although Name must be safe to log: it now returns the authenticated subject, and "basic" before authentication. Audit: CORE-11. --- CHANGELOG.md | 17 ++ LIMITATIONS.md | 3 + MIGRATION.md | 29 ++++ basic/authentication.go | 29 +++- basic/authentication_test.go | 12 +- basic/authenticator.go | 28 +++- basic/doc.go | 7 + basic/extractor.go | 125 +++++++++++--- basic/strict_test.go | 288 ++++++++++++++++++++++++++++++++ docs/security-considerations.md | 7 + 10 files changed, 505 insertions(+), 40 deletions(-) create mode 100644 basic/strict_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d41af053..a3caa4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -783,6 +783,17 @@ legacy packages (`authentication/`, `authorization/`, the in-tree its context ends fails with `ErrUnavailable`, 503), and a failed `Authenticate` returns `security.Anonymous()` instead of its input. See [MIGRATION.md](MIGRATION.md). +- `basic` parses credentials strictly (RFC 7617 §2): base64 alphabet with + canonical padding, user-id and password in UTF-8 without any control + character, at most 3072 bytes, several spaces allowed after the scheme, + and `Basic` without credentials is malformed (it used to be ignored). + `basic.Authenticator` applies the same checks — a form login building the + value with `basic.New` included — and also refuses an empty user-id + (`ErrBadFormat`, 401) before any lookup; the `Extractor` still lets an + empty user-id through for custom authenticators (`-u :KEY`). + `Authentication.Name()` returns the authenticated user's subject, and + `"basic"` before authentication (it returned the typed user-id). See + [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1681,3 +1692,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree concurrent verifications is capped by default (`runtime.GOMAXPROCS(0)`): a flood of Basic attempts can no longer exhaust memory (19 MiB per default Argon2id verification). +- The Basic extractor forwarded control characters (NUL, CR LF, ANSI escape + sequences) and invalid UTF-8 in the user-id to the `UserLoader` — a log + injection vector into the application's lookups and logs — and its + lenient base64 decoding skipped CR LF inside the credentials. Malformed + credentials are now refused before any lookup, by the extractor and by + the `Authenticator` alike. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 1cf3242c..4e729c52 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -473,6 +473,9 @@ PKCE. Not supported, and refused where a provider could select them: another time until it is re-hashed (`PasswordRehasher`), and the `UserLoader`'s own latency (a cache hit for a known user) is outside the library's control. +- **UTF-8 only** — Basic credentials must be UTF-8 (the challenge advertises + `charset="UTF-8"`, RFC 7617 §2.1); clients sending ISO-8859-1 non-ASCII + credentials are refused. User-ids and passwords are not normalized. ## Passwords diff --git a/MIGRATION.md b/MIGRATION.md index a20cc0e2..0a851bbe 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2093,3 +2093,32 @@ To migrate: 4. A loader should return `(nil, nil)` or an error wrapping `security.ErrInvalidCredentials` for an unknown user, and an error wrapping `security.ErrUnavailable` when its store is down. + +### `basic`: credentials are parsed strictly; `Name()` is the authenticated subject + +The Basic extractor forwarded control characters and invalid UTF-8 to the +`UserLoader` (log injection), skipped CR LF inside the base64 credentials, +and `Authentication.Name()` returned the user-id as typed — `ALICE` for the +subject `alice`, or a password typed in the username field — although +`Name` must be safe to log. + +| Request / value | Before | After | +| --- | --- | --- | +| Control character (C0, DEL, C1) in the user-id or password | forwarded to the loader | 401, `ErrBadFormat`, the loader is not called | +| Invalid UTF-8 (e.g. ISO-8859-1 credentials) | forwarded | 401 (the Basic challenge advertises `charset="UTF-8"`) | +| CR LF or spaces inside the base64, unpadded or non-canonical base64 | decoded | 401 | +| user-pass longer than 3072 bytes | forwarded | 401 | +| `Authorization: Basic` without credentials | ignored (next extractor) | 401 | +| `Basic` followed by several spaces | 401 | accepted (RFC 9110 §11.4 `1*SP`) | +| Empty user-id (`-u :KEY`) | forwarded | `Extractor`: accepted; `basic.Authenticator`: 401 before any lookup | +| `basic.New(...)` from a form login, malformed | looked up | refused by the `Authenticator` like a malformed header | +| `Authentication.Name()` | typed user-id | before authentication `"basic"`, after it the user's subject | + +To migrate: + +1. Clients must send UTF-8 credentials (RFC 7617 §2.1) without control + characters. +2. Read the typed user-id with `Username()`, the authenticated identity with + `Name()` or `Principal().Subject()`; log `Name()`, never `Username()`. +3. A custom authenticator consuming `basic.Authentication` with an empty + user-id (API keys) keeps working: only `basic.Authenticator` refuses it. diff --git a/basic/authentication.go b/basic/authentication.go index f92313de..75df5520 100644 --- a/basic/authentication.go +++ b/basic/authentication.go @@ -23,14 +23,18 @@ type Authentication struct { } // New constructs an unauthenticated Authentication from a username/password -// pair. Reserved for [Extractor] implementations; application code should -// build authentications via the Engine pipeline instead. +// pair: the [Extractor] builds it from an Authorization header, and a form +// login (or an OAuth2 password grant) may build it from its own fields and +// hand it to the [Authenticator], which checks the pair again before any +// lookup. func New(username, password string) Authentication { return Authentication{username: username, password: password} } -// Username returns the username extracted from the request. It MAY differ -// from the resolved [PasswordUser]'s subject (e.g. login by email). +// Username returns the user-id as typed by the client. It MAY differ from +// the resolved [PasswordUser]'s subject (e.g. login by email) and may even +// be a password typed in the wrong field: log [Authentication.Name], never +// Username. func (a Authentication) Username() string { return a.username } // Password returns the cleartext password. Once an [Authenticator] has @@ -84,9 +88,20 @@ func (a Authentication) Authorities() []string { return slices.Clone(a.authoriti // IsAuthenticated implements [security.Authentication]. func (a Authentication) IsAuthenticated() bool { return a.authed } -// Name implements [security.Authentication]. Returns the username, which is -// the user-facing identifier for HTTP Basic flows. -func (a Authentication) Name() string { return a.username } +// Name implements [security.Authentication]. Returns the authenticated +// user's subject, or "basic" before authentication: the typed user-id is +// not safe to log (see [Authentication.Username]). +func (a Authentication) Name() string { + if a.user != nil { + return a.user.Subject() + } + + return schemeLabel +} + +// schemeLabel names the scheme in Name before authentication, like the +// bearer and session schemes do. +const schemeLabel = "basic" // User returns the resolved [PasswordUser], or nil when the value is still // pre-authentication. diff --git a/basic/authentication_test.go b/basic/authentication_test.go index 19d8605c..80f9a4c0 100644 --- a/basic/authentication_test.go +++ b/basic/authentication_test.go @@ -15,11 +15,11 @@ import ( // fakeUser is a minimal basic.PasswordUser for the authentication tests. type fakeUser struct{ sub string } -func (u fakeUser) Subject() string { return u.sub } -func (u fakeUser) GetPasswordHash() string { return "" } -func (u fakeUser) IsEnabled() bool { return true } -func (u fakeUser) IsLocked() bool { return false } -func (u fakeUser) IsExpired() bool { return false } +func (u fakeUser) Subject() string { return u.sub } +func (u fakeUser) GetPasswordHash() string { return "" } +func (u fakeUser) IsEnabled() bool { return true } +func (u fakeUser) IsLocked() bool { return false } +func (u fakeUser) IsExpired() bool { return false } func (u fakeUser) IsCredentialsExpired() bool { return false } func TestAuthenticationPreAuth(t *testing.T) { @@ -29,7 +29,7 @@ func TestAuthenticationPreAuth(t *testing.T) { assert.Equal(t, "alice", auth.Username()) assert.Equal(t, "s3cr3t", auth.Password()) - assert.Equal(t, "alice", auth.Name()) + assert.Equal(t, "basic", auth.Name(), "before authentication Name is the scheme label") assert.False(t, auth.IsAuthenticated()) assert.Nil(t, auth.Authorities()) assert.Nil(t, auth.User()) diff --git a/basic/authenticator.go b/basic/authenticator.go index 6879b146..20ab7241 100644 --- a/basic/authenticator.go +++ b/basic/authenticator.go @@ -37,11 +37,16 @@ type AuthorityResolver func(PasswordUser) []string // [password.Hasher], checks the account's lifecycle flags, then returns the // authenticated [Authentication]. // -// Every attempt runs exactly one password verification, whatever the -// account: an unknown user, a loader failure or an account without a -// password hash is verified against a dummy hash the hasher made at -// construction, and the lifecycle flags (disabled, locked, expired) are -// read only after the verification. The response time therefore does not +// Credentials that are malformed — control characters, invalid UTF-8, more +// than 3072 bytes, an empty user-id — are refused with [ErrBadFormat] +// before any lookup: a form login building the [Authentication] with [New] +// gets the same checks as the [Extractor]. +// +// Every attempt with well-formed credentials runs exactly one password +// verification, whatever the account: an unknown user, a loader failure or +// an account without a password hash is verified against a dummy hash the +// hasher made at construction, and the lifecycle flags (disabled, locked, +// expired) are read only after the verification. The response time therefore does not // tell which accounts exist or are locked (CWE-208), and every refusal // wraps [security.ErrInvalidCredentials] — the detailed cause stays in the // error chain for server-side telemetry only: do NOT mirror it in the @@ -137,7 +142,7 @@ func WithMaxConcurrentVerifications(n int) Option { // AuthenticatorName implements [security.NamedAuthenticator] so the core // Manager can attribute spans to "basic". -func (a *Authenticator) AuthenticatorName() string { return "basic" } +func (a *Authenticator) AuthenticatorName() string { return schemeLabel } // Supports reports whether auth is a [basic.Authentication]. Returns false // for everything else, which lets the [security.Manager] delegate to the @@ -156,6 +161,17 @@ func (a *Authenticator) Authenticate(ctx context.Context, auth security.Authenti return security.Anonymous(), security.ErrUnsupportedCredential } + // The Extractor's rules again — a form login builds the Authentication + // from its own fields — plus a non-empty user-id (RFC 8265 §3.1): a + // property of the input, refused before any lookup or verification. + if err := checkUserPass(in.Username(), in.Password()); err != nil { + return security.Anonymous(), err + } + + if in.Username() == "" { + return security.Anonymous(), malformed("empty user-id") + } + user, loadErr := a.loader.LoadByUsername(ctx, in.Username()) stored, hasPassword := "", false diff --git a/basic/doc.go b/basic/doc.go index 8e12baa3..ecdbf879 100644 --- a/basic/doc.go +++ b/basic/doc.go @@ -8,6 +8,13 @@ // Carrier, and an Authenticator that consumes a UserLoader + a Hasher to // validate the username/password pair against a backing store. // +// The Extractor parses RFC 7617 credentials strictly (canonical base64, UTF-8 +// without control characters, bounded length). The Authenticator checks the +// same rules — form logins can build the value with New — then spends exactly +// one password verification on every attempt, unknown accounts included, so +// that the response time reveals nothing; at most runtime.GOMAXPROCS(0) +// verifications run at once. +// // Allowed dependencies: // - github.com/hyperscale-stack/security (core) // - github.com/hyperscale-stack/security/password (for password hashing) diff --git a/basic/extractor.go b/basic/extractor.go index 07fac854..f229b46c 100644 --- a/basic/extractor.go +++ b/basic/extractor.go @@ -10,23 +10,47 @@ import ( "errors" "fmt" "strings" + "unicode" + "unicode/utf8" "github.com/hyperscale-stack/security" ) // ErrBadFormat is returned by the [Extractor] when the Authorization header -// carries a Basic scheme but the payload cannot be decoded (invalid base64, -// missing colon, ...). It is wrapped around [security.ErrInvalidCredentials] -// so error mappers route it to 401 — and to prevent oracle attacks that -// distinguish "malformed" from "wrong". +// carries a Basic scheme but the credentials are malformed (not base64, +// missing colon, control characters, not UTF-8, too long, ...), and by the +// [Authenticator] for a user-id or password it refuses before any lookup. +// It is wrapped around [security.ErrInvalidCredentials] so error mappers +// route it to 401 — and to prevent oracle attacks that distinguish +// "malformed" from "wrong". var ErrBadFormat = errors.New("basic: malformed credentials") const scheme = "Basic" +// Bounds of the credentials. maxCredentialsLength bounds the base64 +// credentials of the Authorization header; maxUserPassLength the decoded +// "user-id:password" (what 4096 base64 characters decode to). RFC 7617 sets +// no limit; these are far above any real password and keep hostile input +// away from the loader and the hasher. +const ( + maxCredentialsLength = 4096 + maxUserPassLength = 3072 +) + // Extractor implements [security.Extractor] for the HTTP Basic scheme // (RFC 7617). It reads the Authorization header from the Carrier and parses -// the base64-encoded "username:password" payload. The scheme prefix is -// matched case-insensitively per RFC 7235 §2.1. +// the base64-encoded "user-id:password" payload. The scheme is matched +// case-insensitively and may be followed by several spaces (RFC 9110 §11.4). +// +// It is strict: the credentials must use the base64 alphabet with canonical +// padding (RFC 4648 §4, §3.5), the user-id and password must be valid UTF-8 +// without any control character (RFC 7617 §2 "MUST NOT contain any control +// characters"; C1 controls included, RFC 8264 §9.12), and the decoded +// user-pass is at most 3072 bytes. Malformed credentials are refused before +// they reach any [UserLoader] — they are never logged nor looked up. An +// empty user-id or password is syntactically valid and let through (custom +// authenticators accept API keys sent as `-u :KEY`); the [Authenticator] +// refuses an empty user-id. type Extractor struct{} // NewExtractor returns the canonical zero-config Extractor. @@ -34,9 +58,9 @@ func NewExtractor() Extractor { return Extractor{} } // Extract implements [security.Extractor]. Returns (nil, nil) when no Basic // credentials are present (next extractor gets a chance); a non-nil error -// for credentials that are present but malformed, and one wrapping -// [security.ErrInvalidRequest] when the Authorization field is repeated -// (RFC 9110 §5.3). +// wrapping [ErrBadFormat] for credentials that are present but malformed, +// and one wrapping [security.ErrInvalidRequest] when the Authorization field +// is repeated (RFC 9110 §5.3). func (Extractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { header, err := security.SingleValue(c, "Authorization") if err != nil { @@ -54,27 +78,86 @@ func (Extractor) Extract(_ context.Context, c security.Carrier) (security.Authen return nil, nil } - raw, err := base64.StdEncoding.DecodeString(payload) + username, password, err := decodeCredentials(payload) if err != nil { - return nil, fmt.Errorf("basic: base64 decode: %w (%w)", ErrBadFormat, security.ErrInvalidCredentials) + return nil, err } - colon := strings.IndexByte(string(raw), ':') - if colon < 0 { - return nil, fmt.Errorf("basic: missing colon separator: %w (%w)", ErrBadFormat, security.ErrInvalidCredentials) + return New(username, password), nil +} + +// decodeCredentials decodes the token68 credentials of RFC 7617 §2 and +// checks the user-id and the password. +func decodeCredentials(payload string) (string, string, error) { + if payload == "" || len(payload) > maxCredentialsLength || strings.IndexFunc(payload, notBase64) >= 0 { + // Checked before decoding: encoding/base64 silently skips CR and LF. + return "", "", malformed("credentials are not base64") + } + + raw, err := base64.StdEncoding.Strict().DecodeString(payload) + if err != nil { + return "", "", malformed("credentials are not canonical base64") } - return New(string(raw[:colon]), string(raw[colon+1:])), nil + username, password, ok := strings.Cut(string(raw), ":") + if !ok { + return "", "", malformed("missing colon separator") + } + + if err := checkUserPass(username, password); err != nil { + return "", "", err + } + + return username, password, nil } -// extractValue is the case-insensitive scheme-stripper. Duplicated locally to -// avoid a transport-shaped dependency on httpsec (which would create a cycle -// once httpsec composes basic.Extractor). +// checkUserPass applies the rules the [Extractor] and the [Authenticator] +// share: UTF-8 without any control character (RFC 7617 §2, RFC 8264 §9.12) +// and a bounded length. The reason never quotes the input. +func checkUserPass(username, password string) error { + switch { + case len(username)+1+len(password) > maxUserPassLength: + return malformed("credentials longer than 3072 bytes") + case !utf8.ValidString(username) || !utf8.ValidString(password): + return malformed("credentials are not UTF-8") + case strings.IndexFunc(username, unicode.IsControl) >= 0 || strings.IndexFunc(password, unicode.IsControl) >= 0: + return malformed("control character in the credentials") + } + + return nil +} + +// malformed wraps ErrBadFormat and security.ErrInvalidCredentials. +func malformed(reason string) error { + return fmt.Errorf("basic: %s: %w (%w)", reason, ErrBadFormat, security.ErrInvalidCredentials) +} + +// notBase64 reports whether r is outside the base64 alphabet of RFC 4648 §4 +// (padding included). +func notBase64(r rune) bool { + switch { + case 'A' <= r && r <= 'Z', 'a' <= r && r <= 'z', '0' <= r && r <= '9', r == '+', r == '/', r == '=': + return false + } + + return true +} + +// extractValue is the case-insensitive scheme-stripper: the scheme, then +// one or more spaces, then the credentials ("" when the scheme stands +// alone). A header whose first word merely starts with the scheme +// ("BasicAuth") is another scheme. Duplicated locally to avoid a +// transport-shaped dependency on httpsec (which would create a cycle once +// httpsec composes basic.Extractor). func extractValue(scheme, header string) (string, bool) { - prefix := scheme + " " - if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + if len(header) < len(scheme) || !strings.EqualFold(header[:len(scheme)], scheme) { + return "", false + } + + rest := header[len(scheme):] + if rest != "" && rest[0] != ' ' { return "", false } - return header[len(prefix):], true + return strings.TrimLeft(rest, " "), true } diff --git a/basic/strict_test.go b/basic/strict_test.go new file mode 100644 index 00000000..b0c09412 --- /dev/null +++ b/basic/strict_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "context" + "encoding/base64" + "runtime" + "strings" + "sync/atomic" + "testing" + "unicode" + "unicode/utf8" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// basicHeader encodes user-pass the way RFC 7617 §2 does. +func basicHeader(userPass string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(userPass)) +} + +// Audit CORE-11 (probe TestZZAuditBasicControlChars): the extractor forwarded +// NUL, CR LF, ESC sequences and invalid UTF-8 to the UserLoader — log +// injection into the application — and decoded base64 with embedded CR LF. +// RFC 7617 §2: "The user-id and password MUST NOT contain any control +// characters"; §2.1 and RFC 8264 §4.2.3/§4.3.3/§9.12 (UTF-8, no Controls). +func TestExtractorRefusesMalformedCredentials(t *testing.T) { + t.Parallel() + + enc := base64.StdEncoding.EncodeToString([]byte("alice:alice-secret")) + + cases := map[string]string{ + "NUL in the user-id": basicHeader("al\x00ice:pw"), + "CR LF in the user-id": basicHeader("alice\r\nX-Injected: 1:pw"), + "ESC sequence in the user-id": basicHeader("\x1b[31mred\x1b[0m:pw"), + "DEL in the password": basicHeader("alice:p\x7fw"), + "C1 control (NEL) in the password": basicHeader("alice:p\u0085w"), + "invalid UTF-8 in the user-id": basicHeader("\xff\xfe:pw"), + "invalid UTF-8 in the password": basicHeader("alice:\xc3("), + "CR LF inside the base64": "Basic " + enc[:4] + "\r\n" + enc[4:], + "space inside the base64": "Basic " + enc[:4] + " " + enc[4:], + "unpadded base64": "Basic " + strings.TrimRight(base64.StdEncoding.EncodeToString([]byte("alice:pw")), "="), + "non-canonical padding bits": "Basic YTpiYx==", // lenient decoding: "a:bc" + "base64url alphabet": "Basic " + base64.URLEncoding.EncodeToString([]byte("alice:p?>?w")), + "credentials over 4096 bytes": "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:"+strings.Repeat("p", 3200))), + "scheme without credentials": "Basic", + "empty credentials": "Basic ", + "missing colon": basicHeader("alicepassword"), + } + + for name, header := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + c := newCarrier() + c.Set("Authorization", header) + + got, err := basic.NewExtractor().Extract(context.Background(), c) + + require.ErrorIs(t, err, basic.ErrBadFormat) + require.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.Nil(t, got) + assert.NotContains(t, err.Error(), "alice", "the error never echoes the credentials") + }) + } +} + +func TestExtractorAcceptsRFC7617Credentials(t *testing.T) { + t.Parallel() + + cases := []struct { + name, header, user, password string + }{ + {"RFC 7617 §2 example", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", "Aladdin", "open sesame"}, + {"UTF-8 user-id and password", basicHeader("józef:hasło żółć"), "józef", "hasło żółć"}, + {"colons, spaces and emoji in the password", basicHeader("alice:p a:s😀s"), "alice", "p a:s😀s"}, + {"empty password", basicHeader("alice:"), "alice", ""}, + {"empty user-id (API key as password)", basicHeader(":KEY-123"), "", "KEY-123"}, + {"several spaces after the scheme", "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw")), "bob", "pw"}, + {"scheme in another case", "bASIC " + base64.StdEncoding.EncodeToString([]byte("bob:pw")), "bob", "pw"}, + {"3072-byte user-pass", basicHeader("u:" + strings.Repeat("p", 3070)), "u", strings.Repeat("p", 3070)}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + carrier := newCarrier() + carrier.Set("Authorization", c.header) + + got, err := basic.NewExtractor().Extract(context.Background(), carrier) + require.NoError(t, err) + require.NotNil(t, got) + + ba, ok := got.(basic.Authentication) + require.True(t, ok) + assert.Equal(t, c.user, ba.Username()) + assert.Equal(t, c.password, ba.Password()) + }) + } +} + +// A header of another scheme, even one starting with "Basic", is left to +// the other extractors. +func TestExtractorLeavesOtherSchemes(t *testing.T) { + t.Parallel() + + for _, header := range []string{"Bearer abc", "BasicAuth abc", "Basicx", "Digest username=\"a\""} { + c := newCarrier() + c.Set("Authorization", header) + + got, err := basic.NewExtractor().Extract(context.Background(), c) + require.NoError(t, err, header) + assert.Nil(t, got, header) + } +} + +// countingLoader counts the lookups it serves. +type countingLoader struct { + calls atomic.Int32 + user *stubUser +} + +func (l *countingLoader) LoadByUsername(context.Context, string) (basic.PasswordUser, error) { + l.calls.Add(1) + + return l.user, nil +} + +// Review finding 3: form logins (and the OAuth2 password grant) build the +// Authentication with basic.New from their own fields, bypassing the +// Extractor. The Authenticator applies the same rules before any lookup — +// and, the PasswordUser flow needing an account, refuses an empty user-id +// (RFC 8265 §3.1; the Extractor lets it through for custom authenticators +// such as API keys sent as `-u :KEY`). Malformed input is refused without a +// verification: an input property, not an account property. +func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { + t.Parallel() + + for name, in := range map[string]basic.Authentication{ + "NUL in the user-id": basic.New("al\x00ice", "pw"), + "CR LF in the user-id": basic.New("a\r\nb", "pw"), + "invalid UTF-8 user-id": basic.New("\xff", "pw"), + "control in the password": basic.New("alice", "p\x1bw"), + "invalid UTF-8 password": basic.New("alice", "\xc3("), + "empty user-id": basic.New("", "pw"), + "user-pass over 3072 bytes": basic.New("alice", strings.Repeat("p", 3067)), + "user-pass over 3072 (user)": basic.New(strings.Repeat("u", 3072), ""), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + l := &countingLoader{user: &stubUser{subject: "alice", passwordHash: "h", enabled: true}} + h := &recordingHasher{always: true} + a := basic.NewAuthenticator(l, h) + + out, err := a.Authenticate(context.Background(), in) + + require.ErrorIs(t, err, basic.ErrBadFormat) + require.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.False(t, out.IsAuthenticated()) + assert.Zero(t, l.calls.Load(), "the loader never sees malformed credentials") + assert.Empty(t, h.calls(), "nor does the hasher") + }) + } +} + +// The longest accepted user-pass is 3072 bytes, what 4096 base64 characters +// decode to. +func TestAuthenticatorAcceptsTheLongestUserPass(t *testing.T) { + t.Parallel() + + l := &countingLoader{user: &stubUser{subject: "alice", passwordHash: "h", enabled: true}} + a := basic.NewAuthenticator(l, &recordingHasher{always: true}) + + out, err := a.Authenticate(context.Background(), basic.New("alice", strings.Repeat("p", 3066))) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) +} + +// caseInsensitiveLoader finds users whatever the case of the typed user-id — +// the usual e-mail lookup. +type caseInsensitiveLoader struct{ user *stubUser } + +func (l caseInsensitiveLoader) LoadByUsername(_ context.Context, username string) (basic.PasswordUser, error) { + if strings.EqualFold(username, l.user.subject) { + return l.user, nil + } + + return nil, nil +} + +// Audit CORE-11 (probe TestZZAuditBasicNameIsTypedUsername): Name() returned +// the typed user-id ("ALICE") instead of the authenticated subject +// ("alice"). Before authentication Name() is the scheme label: the typed +// user-id may be a password typed in the wrong field and Name MUST be safe +// to log. +func TestAuthenticationNameIsTheSubjectOnceAuthenticated(t *testing.T) { + t.Parallel() + + user := &stubUser{subject: "alice", passwordHash: "h", enabled: true} + a := basic.NewAuthenticator(caseInsensitiveLoader{user: user}, &recordingHasher{always: true}) + + in := basic.New("ALICE", "pw") + assert.Equal(t, "basic", in.Name()) + assert.Equal(t, "ALICE", in.Username(), "Username keeps the typed user-id") + + out, err := a.Authenticate(context.Background(), in) + require.NoError(t, err) + + assert.Equal(t, "alice", out.Name()) + assert.Equal(t, "alice", out.Principal().Subject()) +} + +// FuzzExtractor: whatever the header, the extractor never returns both a +// value and an error, never an authenticated value, and only accepts Basic +// credentials whose user-id and password are UTF-8 without control +// characters. +func FuzzExtractor(f *testing.F) { + f.Add("Basic YWxpY2U6c2VjcmV0") + f.Add("basic Og==") + f.Add("Basic ") + f.Add("Basic YR==") + f.Add("Bearer abc") + f.Add(basicHeader("al\x00ice:pw")) + + e := basic.NewExtractor() + + f.Fuzz(func(t *testing.T, header string) { + c := newCarrier() + c.Set("Authorization", header) + + got, err := e.Extract(context.Background(), c) + if got != nil && err != nil { + t.Fatalf("both a value and an error for %q", header) + } + + if got == nil { + return + } + + if got.IsAuthenticated() { + t.Fatalf("authenticated value for %q", header) + } + + if !strings.EqualFold(header[:min(5, len(header))], "basic") { + t.Fatalf("accepted a non-Basic header %q", header) + } + + ba, ok := got.(basic.Authentication) + if !ok { + t.Fatalf("unexpected type %T", got) + } + + for _, s := range []string{ba.Username(), ba.Password()} { + if !utf8.ValidString(s) || strings.IndexFunc(s, unicode.IsControl) >= 0 { + t.Fatalf("accepted %q from %q", s, header) + } + } + }) +} + +// The credentials are bounded before they are decoded: a hostile +// multi-megabyte header costs no allocation. (Not parallel: it reads the +// process-wide allocation counter.) +func TestExtractorBoundsTheCredentialsBeforeDecoding(t *testing.T) { + c := newCarrier() + c.Set("Authorization", "Basic "+strings.Repeat("QUFB", 1<<20)) // 4 MiB of valid base64 + + var before, after runtime.MemStats + + runtime.ReadMemStats(&before) + + got, err := basic.NewExtractor().Extract(context.Background(), c) + + runtime.ReadMemStats(&after) + + require.ErrorIs(t, err, basic.ErrBadFormat) + assert.Nil(t, got) + assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(64<<10), "the credentials must be refused before being decoded") +} diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 78738dba..866cddb9 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -72,6 +72,13 @@ What remains measurable, and how to close it: - Under overload an attempt that gets no verification slot answers 503 (`ErrUnavailable`) whatever the account, so overload reveals nothing. +Malformed credentials — control characters, invalid UTF-8, more than 3072 +bytes, an empty user-id — are refused before the loader runs, without a +verification: a property of the input, not of an account. The extractor and +the `Authenticator` apply the same rules, so a form login that builds the +value with `basic.New` is checked too, and a control character never reaches +the application's lookups and logs (log injection). + ### Online guessing and hashing cost Basic authentication runs a full password verification on every request, From 28ed02cfaf8bb5b4dfd1d26934c26eccb416badb Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:17:02 +0200 Subject: [PATCH 04/17] fix(session): require strong keys, bind cookies and enforce the lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.NewCodec accepted an empty or low-entropy key and derived the AES key with a bare SHA-256: an unset environment variable let anyone forge a cookie for any subject, and a passphrase key could be brute-forced offline. Keys must now be at least 32 random bytes (a single-byte buffer never filled is refused too) and are expanded with HKDF-SHA256 under a format label (RFC 5869). Cookies carried no AEAD associated data, so a cookie minted for "shop_session" opened as "admin_session", or for another purpose sharing the keys. Cookie format v2 writes a version byte in clear and binds every cookie to version, cookie name and an optional purpose (WithPurpose) with length-prefixed associated data (NIST SP 800-38D); Codec.Encode/Decode are no longer exported, so only the Manager seals, always bound. The unversioned format is refused: every existing session cookie is invalidated once (users sign in again, documented). Rotate moved the expiry to now + TTL, so periodic rotations kept a session alive forever, and only the expiry carried by the cookie was checked. A session now expires at the earlier of its ExpiresAt and CreatedAt + the reading Manager's TTL, on every read; Rotate keeps the creation time and deadline, and writes (Login, Touch, Rotate) never extend it — Touch refuses a session past its deadline instead of writing it with Max-Age 1, and a nil session instead of panicking. The optional WithValidator hook revokes stateless sessions server-side; a validator error wrapping security.ErrUnavailable surfaces as 503 through the Extractor instead of a silent logout. Numbers in Values decoded as float64 (1234567890123456789 came back as ...768): they now decode as json.Number, read with Session.GetInt64. The saml-sp example asserted float64/int64 on its SessionNotOnOrAfter value and would have silently stopped enforcing it: it now reads it with GetInt64 and ends the session when the value is present but unreadable. Audit: CORE-04, CORE-08, CORE-09, CORE-14. --- CHANGELOG.md | 33 ++ LIMITATIONS.md | 8 +- MIGRATION.md | 46 +++ docs/security-considerations.md | 41 ++- examples/saml-sp/main.go | 15 +- examples/saml-sp/session_end_test.go | 46 +++ session/authentication_internal_test.go | 2 + session/codec.go | 193 ++++++++--- session/codec_format_test.go | 14 +- session/codec_test.go | 56 +--- session/doc.go | 19 +- session/example_test.go | 12 +- session/export_test.go | 16 + session/extractor.go | 19 +- session/format_test.go | 426 ++++++++++++++++++++++++ session/lifetime_test.go | 294 ++++++++++++++++ session/manager.go | 166 +++++++-- session/session.go | 44 ++- session/validator_test.go | 139 ++++++++ session/values_test.go | 145 ++++++++ 20 files changed, 1588 insertions(+), 146 deletions(-) create mode 100644 examples/saml-sp/session_end_test.go create mode 100644 session/export_test.go create mode 100644 session/format_test.go create mode 100644 session/lifetime_test.go create mode 100644 session/validator_test.go create mode 100644 session/values_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a3caa4b2..a5d62c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -496,6 +496,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree default), and `basic.PasswordRehasher`, an optional `UserLoader` capability through which the `Authenticator` upgrades a stored hash that `NeedsRehash` after a successful authentication. +- `session`: `MinKeySize`, `MaxCookieSize`, `WithPurpose` (binds the cookies + of a Manager to a purpose), `WithValidator` (server-side revocation hook) + and `Session.GetInt64`. ### Changed @@ -794,6 +797,19 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `Authentication.Name()` returns the authenticated user's subject, and `"basic"` before authentication (it returned the typed user-id). See [MIGRATION.md](MIGRATION.md). +- `session` cookie format v2 (breaking: **every existing session cookie is + invalidated once** — users sign in again): keys of at least 32 random + bytes (`NewCodec` refuses shorter keys and single-byte buffers), expanded + with HKDF-SHA256; AES-256-GCM associated data binding the cookie to the + format version, its name and the Manager's purpose; `Codec.Encode` and + `Codec.Decode` are no longer exported (only the `Manager` seals and opens + cookies). A session expires at the earlier of its `ExpiresAt` and + `CreatedAt + TTL` of the reading Manager; `Rotate` keeps the creation time + and deadline; `Touch` refuses a nil session (`ErrNoSession`) and a session + past its deadline (`ErrTokenExpired`). Numbers in `Values` decode as + `json.Number`. The session `Extractor` reports a validator error wrapping + `security.ErrUnavailable` instead of finding no session. See + [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1698,3 +1714,20 @@ legacy packages (`authentication/`, `authorization/`, the in-tree lenient base64 decoding skipped CR LF inside the credentials. Malformed credentials are now refused before any lookup, by the extractor and by the `Authenticator` alike. +- `session.NewCodec` accepted an empty or low-entropy key and derived the + AES key with a bare SHA-256: an unset environment variable let anyone mint + a cookie for any subject, and a passphrase key could be brute-forced + offline from one cookie. Keys must now be at least 32 random bytes. +- Session cookies carried no AEAD associated data: a cookie minted by one + manager (`shop_session`) opened under another cookie name + (`admin_session`) or for another purpose sharing the keys. Cookies are now + bound to the format version, their name and an optional purpose. +- `Manager.Rotate` moved the session's expiry to now + TTL, so periodic + rotations kept a session alive forever, and only the expiry written in the + cookie was checked — lowering the TTL did not affect issued cookies. The + absolute lifetime `CreatedAt + TTL` is now enforced on every read and + never extended. `session.WithValidator` makes server-side revocation + possible (a stolen copy after logout). +- Session values decoded numbers as float64: 1234567890123456789 came back + as 1234567890123456768, mapping a session to another account. Numbers now + decode as `json.Number`. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 4e729c52..1efc6fa2 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -494,9 +494,11 @@ PKCE. Not supported, and refused where a provider could select them: ## Sessions - The session module is stateless: the whole session lives in an encrypted - cookie, there is no server-side session store. This covers the common - case without server state, but a session cannot be revoked server-side - before its cookie expires. A server-side store (Redis/SQL) is not shipped. + cookie, there is no server-side session store. A session can be revoked + before its cookie expires only through `session.WithValidator` checking a + store of the application's (no Redis/SQL store is shipped); otherwise a + copy of the cookie stays valid until its deadline, `Logout` deleting the + browser's copy only. - The session cookie is read through `httpsec.Carrier` (its `CookiesNamed` accessor). The `grpcsec` and `connectrpcsec` carriers expose no cookies: a cookie session does not authenticate gRPC or ConnectRPC diff --git a/MIGRATION.md b/MIGRATION.md index 0a851bbe..f56b846e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2122,3 +2122,49 @@ To migrate: `Name()` or `Principal().Subject()`; log `Name()`, never `Username()`. 3. A custom authenticator consuming `basic.Authentication` with an empty user-id (API keys) keeps working: only `basic.Authenticator` refuses it. + +### `session`: strong keys, cookies bound to their name and purpose, an enforced absolute lifetime + +`session.NewCodec` accepted any key — an empty one let anyone forge a +session — and cookies were not bound to their name or purpose. `Rotate` +extended a session's lifetime indefinitely, a TTL lowered after issuance did +not apply, and numbers in `Values` lost precision. The cookie format changes +(v2), so **every existing session cookie is refused once: users sign in +again** after the upgrade. + +| | Before | After | +| --- | --- | --- | +| `NewCodec` keys | any length, empty included | at least 32 random bytes each (`MinKeySize`); shorter keys and single-byte buffers → `ErrInvalidKeys` | +| AES key | SHA-256(key) | HKDF-SHA256(key, label) | +| Cookie value | `base64url(nonce ‖ ciphertext)` | `base64url(0x02 ‖ nonce ‖ ciphertext)`, AEAD-bound to version, cookie name and purpose (see the `Codec` documentation) | +| Cookies issued before the upgrade | — | refused (`ErrNoSession` wrapping `ErrDecode`) | +| `Codec.Encode` / `Codec.Decode` | exported | removed: only the `Manager` seals and opens cookies | +| Same keys, another cookie name or `WithPurpose` | cookie accepted | refused | +| Session deadline | the `ExpiresAt` in the cookie | the earlier of it and `CreatedAt + TTL` of the reading `Manager` | +| `Rotate` | `ExpiresAt` = now + TTL | creation time and deadline kept; new ID and CSRF token | +| `Touch` on a session at or past its deadline / on nil | cookie written (Max-Age 1) / panic | `ErrTokenExpired` / `ErrNoSession`, nothing written | +| Numbers in `Values` after a round trip | `float64` (precision lost above 2^53) | `json.Number` (`Session.GetInt64`) | +| Server-side revocation | impossible | `WithValidator` | + +To migrate: + +1. Generate each session key from a cryptographic source — `openssl rand + -base64 32`, decoded — and load it from your secret store; never a + passphrase. To rotate later, put the new key first and keep the previous + one until the longest TTL has elapsed. +2. Deploy all instances at once, or accept that users are signed out while + old and new instances run side by side: a v1 instance cannot read v2 + cookies and the other way round. +3. Several applications or paths sharing keys and a cookie name: give each + its own `WithPurpose`. +4. Call `Login` at sign-in (new session, new lifetime); `Rotate` within a + session (step-up) keeps the deadline. Expect `Touch` to fail with + `ErrTokenExpired` at the end of a session's life, and not to write. +5. Search your code for type assertions on session values: `.(float64)` on a + decoded number now fails — and fails open if the code treats an + unreadable value as absent. Read integers with `s.GetInt64(key)` and + treat a present but unreadable value as a refusal. +6. To revoke sessions server-side, register `session.WithValidator` (a + deny-list written at logout, or a per-user "valid since" time); wrap + `security.ErrUnavailable` when its store is down (503, nobody is signed + out). diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 866cddb9..2c06bd11 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1143,9 +1143,32 @@ session store to compromise or scale. - **Confidentiality + integrity** — the cookie payload is sealed with AES-256-GCM (AEAD): tampering fails decryption, it is not merely detected. +- **Keys** — `NewCodec` requires keys of at least 32 bytes from a + cryptographic source (`openssl rand -base64 32`, `crypto/rand`) and + refuses shorter keys and buffers that were never filled; it cannot tell a + random key from a 32-character passphrase, so never use one. Each key is + expanded with HKDF-SHA256 under a label of its own (RFC 5869), so a secret + reused elsewhere never yields the same AES key. +- **Binding** — the AEAD associated data binds every cookie to the format + version, the cookie name and an optional purpose (`WithPurpose`): a cookie + minted for another cookie name, application or path that shares the keys + never opens (NIST SP 800-38D). - **Key rotation** — the `Codec` accepts an ordered key list. New cookies are sealed with the first key; decryption is attempted against every key, - so a key can be retired gracefully. + so a key can be retired gracefully, and `Touch`/`Rotate` re-seal a cookie + with the active key. +- **Absolute lifetime** — a session expires at the earlier of the + `ExpiresAt` its cookie carries and `CreatedAt + TTL` of the reading + `Manager`: lowering the TTL applies to issued cookies at once, raising it + extends none, and neither `Rotate` nor `Touch` ever extends a session. + The idle timeout slides only when `Touch` rewrites the cookie. +- **Revocation** — the cookie is stateless, so `Logout` only deletes the + browser's copy. To revoke server-side — a stolen copy after logout, every + session of a user after a password change — register `WithValidator` + against a store of yours (a deny-list of session-ID fingerprints kept until + the session's deadline, or a "valid since" time per user); cache its + lookups. A validator error wrapping `security.ErrUnavailable` answers 503, + so an outage of that store never signs everyone out. - **Cookie attributes** — defaults are conservative: `Secure=true`, `HttpOnly=true`, `SameSite=Lax`. Disable `Secure` only for local plain-HTTP development. @@ -1153,9 +1176,14 @@ session store to compromise or scale. `Cookie` header field, through `httpsec.Carrier.CookiesNamed`, never from the URL nor from a header named like the cookie: a link carrying a session value cannot log a browser into another account. -- **Session fixation** — `Manager.Rotate` mints a fresh session ID; call it - immediately after a privilege change (login). The ID never appears raw in - a span — only a SHA-256 fingerprint. +- **Session fixation** — call `Manager.Login` at sign-in: it mints a fresh + session (new ID, new CSRF token, new lifetime) and replaces the previous + one. `Manager.Rotate` mints a new ID and CSRF token within the same + session — after a step-up — and keeps its creation time and deadline. The + ID never appears raw in a span — only a SHA-256 fingerprint. +- **Values** — numbers come back as `json.Number` (read integers with + `Session.GetInt64`): a 64-bit ID never loses precision through a float64. + Treat a value that is present but unreadable as a refusal, not as absent. - **CSRF** — the synchronizer-token helper (`CSRFToken` / `VerifyCSRF`) compares tokens in constant time. The session cookie being `HttpOnly` keeps the token out of reach of XSS. @@ -1613,8 +1641,9 @@ span catalog. `Config.ClaimsSupported`, and the claim names end-users choose kept apart from them. - [ ] Serve over HTTPS so `Secure` cookies and bearer tokens are protected. -- [ ] Supply at least two session keys so rotation is possible without - invalidating live sessions. +- [ ] Generate session keys of 32 bytes or more from a cryptographic source + and keep them in a secret store; keep the previous key after the new + one while you rotate, so rotation does not sign everyone out. - [ ] SAML: import IdP metadata through a trusted channel, or fetch it from the IdP's https metadata URL with a `MetadataSource` pinned to its entity ID; never take a metadata URL from a request. Widen the diff --git a/examples/saml-sp/main.go b/examples/saml-sp/main.go index 623fde8d..eb16bb66 100644 --- a/examples/saml-sp/main.go +++ b/examples/saml-sp/main.go @@ -346,15 +346,18 @@ func (a *app) home(w http.ResponseWriter, r *http.Request) { } // samlSessionEnded honors the IdP's SessionNotOnOrAfter (Core §2.7.2): the -// local session must not outlive it. +// local session must not outlive it. Session numbers come back from the +// cookie as json.Number (GetInt64 reads them); a value that is present but +// unreadable ends the session — failing open would keep a session the IdP +// has closed. func (a *app) samlSessionEnded(s *session.Session) bool { - end, ok := s.Values["saml_session_end"].(int64) - if !ok { - f, isFloat := s.Values["saml_session_end"].(float64) // JSON round trip - end, ok = int64(f), isFloat + if _, present := s.Values["saml_session_end"]; !present { + return false } - return ok && !a.now().Before(time.Unix(end, 0)) + end, ok := s.GetInt64("saml_session_end") + + return !ok || !a.now().Before(time.Unix(end, 0)) } // logout ends the local session after checking the CSRF token. diff --git a/examples/saml-sp/session_end_test.go b/examples/saml-sp/session_end_test.go new file mode 100644 index 00000000..9fb445e3 --- /dev/null +++ b/examples/saml-sp/session_end_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" +) + +// The IdP session end survives the cookie round trip as a json.Number, and a +// value that is present but unreadable ends the local session (fail closed): +// before, an unexpected type silently kept the session alive. +func TestSAMLSessionEndedFailsClosed(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + a := &app{now: func() time.Time { return now }} + + for name, c := range map[string]struct { + value any + set bool + ended bool + }{ + "no IdP session end": {set: false, ended: false}, + "future, as set at login": {value: now.Add(time.Minute).Unix(), set: true, ended: false}, + "future, after a round trip": {value: json.Number("1792000000"), set: true, ended: false}, + "past, after a round trip": {value: json.Number("1700000000"), set: true, ended: true}, + "exactly now": {value: now.Unix(), set: true, ended: true}, + "unreadable string": {value: "tomorrow", set: true, ended: true}, + "unreadable float": {value: 1.792e9, set: true, ended: true}, + "out-of-range number": {value: json.Number("99999999999999999999"), set: true, ended: true}, + } { + s := &session.Session{Values: map[string]any{}} + if c.set { + s.Values["saml_session_end"] = c.value + } + + assert.Equal(t, c.ended, a.samlSessionEnded(s), name) + } +} diff --git a/session/authentication_internal_test.go b/session/authentication_internal_test.go index 1dede9aa..741f5508 100644 --- a/session/authentication_internal_test.go +++ b/session/authentication_internal_test.go @@ -7,6 +7,7 @@ package session import ( "testing" + "github.com/hyperscale-stack/security" "github.com/stretchr/testify/assert" ) @@ -25,6 +26,7 @@ func TestAuthenticationValue(t *testing.T) { assert.False(t, pending.IsAuthenticated()) assert.Nil(t, pending.Credentials(), "a session is never exposed as a credential") assert.Equal(t, schemeName, pending.Name()) + assert.Equal(t, security.AnonymousPrincipal, pending.Principal()) assert.Nil(t, pending.Authorities()) // Post-authentication. diff --git a/session/codec.go b/session/codec.go index df61d003..fc391fd5 100644 --- a/session/codec.go +++ b/session/codec.go @@ -5,52 +5,103 @@ package session import ( + "bytes" "crypto/aes" "crypto/cipher" + "crypto/hkdf" "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/binary" "encoding/json" "errors" "fmt" "io" + "time" ) -// Codec encrypts and authenticates a [Session] into an opaque cookie value -// and back. It uses AES-256-GCM: GCM is an AEAD construction, so a single -// pass provides BOTH confidentiality and integrity — no separate HMAC is -// needed (a tampered ciphertext fails the GCM tag check on Open). +// MinKeySize is the minimum size of a session key, in bytes: 32 bytes from +// a cryptographic random source, never a passphrase. +const MinKeySize = 32 + +// cookieFormat is the version of the cookie format, written in clear as the +// first byte of every cookie and bound into the AEAD associated data. The +// unversioned format before it — SHA-256(key), no associated data — is +// version 1 and no longer opens. +const cookieFormat byte = 2 + +// keyLabel is the HKDF info that derives the AES-256-GCM key of a cookie +// key, so that a secret reused elsewhere never yields the same AES key. +const keyLabel = "github.com/hyperscale-stack/security/session v2 AES-256-GCM" + +// Codec seals a [Session] into an opaque cookie value and opens it back. It +// holds the AES-256-GCM ciphers of the session keys; only the [Manager] +// seals and opens cookies, always bound to its cookie name and purpose. +// +// Cookie format v2: +// +// value = base64url(0x02 ‖ nonce(12) ‖ AES-256-GCM_k(nonce, payload, AD)) +// k = HKDF-SHA256(key, salt = ∅, info = "github.com/hyperscale-stack/security/session v2 AES-256-GCM") +// AD = 0x02 ‖ uint16be(len(name)) ‖ name ‖ uint16be(len(purpose)) ‖ purpose +// +// GCM is an AEAD construction: a single pass provides confidentiality and +// integrity, and the associated data binds the cookie to the format version, +// the cookie name and the purpose ([WithPurpose]) — a cookie sealed for +// another name or purpose fails the tag check (NIST SP 800-38D). // // Codec supports key rotation. The first key is the ACTIVE key, used to -// encrypt; every key is tried on decrypt, so an operator can prepend a new -// key and keep decoding cookies sealed with the previous one. Each input -// key is run through SHA-256 so keys of any length yield a valid 32-byte -// AES-256 key. +// seal; every key is tried on open, so an operator can prepend a new key +// and keep opening cookies sealed with the previous one. +// +// Nonces are random (96 bits): NIST SP 800-38D §8.3 caps the number of +// seals under one key at 2^32. Every Login, Touch and Rotate is one seal: +// rotate the active key before that volume (about 50 days at 1 000 cookie +// writes per second), and do not Touch on every request. type Codec struct { aeads []cipher.AEAD } -// ErrInvalidKeys is returned by [NewCodec] when no key is supplied. -var ErrInvalidKeys = errors.New("session: at least one encryption key is required") +// ErrInvalidKeys is returned by [NewCodec] when no key is supplied or a key +// is not a random key of at least [MinKeySize] bytes. +var ErrInvalidKeys = errors.New("session: at least one key of at least 32 random bytes is required") -// ErrDecode is returned by [Codec.Decode] when the cookie value cannot be -// authenticated with any configured key (tampering, expired key, garbage). +// ErrDecode is returned (wrapped in [ErrNoSession]) when a cookie value +// cannot be opened with any configured key for the Manager's cookie name and +// purpose (tampering, retired key, another cookie, previous format, +// garbage). var ErrDecode = errors.New("session: cookie could not be decoded") -// NewCodec builds a [Codec] from one or more raw key bytes. keys[0] is the -// active encryption key; the rest are decrypt-only (rotation). At least one -// key is mandatory. +// NewCodec builds a [Codec] from one or more keys. keys[0] seals new +// cookies; every key opens them (rotation). Each key MUST be at least +// [MinKeySize] bytes drawn from a cryptographic random source — generate one +// with `openssl rand -base64 32` and decode it, or crypto/rand — and is kept +// in a secret store, never in code or logs. A missing or shorter key, or one +// that repeats a single byte (a buffer never filled), fails with +// [ErrInvalidKeys]; randomness cannot otherwise be checked. The error names +// the key's position and size, never its bytes. Keys are expanded with +// HKDF-SHA256 (RFC 5869). func NewCodec(keys ...[]byte) (*Codec, error) { if len(keys) == 0 { - return nil, ErrInvalidKeys + return nil, fmt.Errorf("%w: no key", ErrInvalidKeys) } aeads := make([]cipher.AEAD, 0, len(keys)) - for _, k := range keys { - derived := sha256.Sum256(k) + for i, k := range keys { + if len(k) < MinKeySize { + return nil, fmt.Errorf("%w: key %d is %d bytes", ErrInvalidKeys, i, len(k)) + } + + if bytes.Count(k, k[:1]) == len(k) { + return nil, fmt.Errorf("%w: key %d repeats a single byte", ErrInvalidKeys, i) + } - block, err := aes.NewCipher(derived[:]) + derived, err := hkdf.Key(sha256.New, k, nil, keyLabel, 32) + if err != nil { + return nil, fmt.Errorf("session: derive key: %w", err) + } + + block, err := aes.NewCipher(derived) if err != nil { return nil, fmt.Errorf("session: build cipher: %w", err) } @@ -77,10 +128,38 @@ func (c *Codec) String() string { return fmt.Sprintf("session.Codec{Keys: %d}", // redact that one. func (c *Codec) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, c.String()) } -// Encode serializes s to JSON and seals it with the active key. The output -// is base64url(nonce || ciphertext||tag), safe for a cookie value. -func (c *Codec) Encode(s *Session) (string, error) { - plaintext, err := json.Marshal(s) +// payload is the sealed form of a Session: short field names keep the +// cookie small, and the redacting MarshalJSON of Session never applies. +type payload struct { + ID string `json:"id"` + Values map[string]any `json:"v,omitempty"` + CSRFToken string `json:"csrf"` + CreatedAt time.Time `json:"c"` + LastAccessed time.Time `json:"a"` + ExpiresAt time.Time `json:"e"` +} + +// associatedData binds a cookie to the format version, the cookie name and +// the purpose; the length prefixes make it unambiguous. The Manager bounds +// both lengths (cookie names are tokens of at most 256 bytes, purposes at +// most 255 bytes). +func associatedData(name, purpose string) []byte { + ad := make([]byte, 0, 5+len(name)+len(purpose)) + ad = append(ad, cookieFormat) + ad = binary.BigEndian.AppendUint16(ad, uint16(len(name))) //nolint:gosec // G115: bounded by the Manager + ad = append(ad, name...) + ad = binary.BigEndian.AppendUint16(ad, uint16(len(purpose))) //nolint:gosec // G115: bounded by the Manager + + return append(ad, purpose...) +} + +// seal serializes s and seals it with the active key for the cookie name +// and purpose. +func (c *Codec) seal(s *Session, name, purpose string) (string, error) { + plaintext, err := json.Marshal(payload{ + ID: s.ID, Values: s.Values, CSRFToken: s.CSRFToken, + CreatedAt: s.CreatedAt, LastAccessed: s.LastAccessed, ExpiresAt: s.ExpiresAt, + }) if err != nil { return "", fmt.Errorf("session: marshal: %w", err) } @@ -92,42 +171,66 @@ func (c *Codec) Encode(s *Session) (string, error) { return "", fmt.Errorf("session: read nonce: %w", err) } - sealed := active.Seal(nonce, nonce, plaintext, nil) + out := make([]byte, 0, 1+len(nonce)+len(plaintext)+active.Overhead()) + out = append(out, cookieFormat) + out = append(out, nonce...) + out = active.Seal(out, nonce, plaintext, associatedData(name, purpose)) - return base64.RawURLEncoding.EncodeToString(sealed), nil + return base64.RawURLEncoding.EncodeToString(out), nil } -// Decode reverses [Codec.Encode]. It tries every configured key so that a -// cookie sealed before a key rotation still opens. Any failure (bad -// base64, wrong key, tampered ciphertext) collapses to [ErrDecode] — the -// caller MUST NOT distinguish the causes (it would be a padding-oracle- -// style information leak). -func (c *Codec) Decode(value string) (*Session, error) { +// open reverses seal for the cookie name and purpose, trying every key so +// that a cookie sealed before a key rotation still opens. Any failure (too +// long, bad base64, another version, wrong key or binding, tampered +// ciphertext, bad payload) collapses to ErrDecode — the caller MUST NOT +// distinguish the causes (a padding-oracle-style information leak). +func (c *Codec) open(value, name, purpose string) (*Session, error) { + if len(value) > MaxCookieSize { + return nil, ErrDecode + } + raw, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return nil, ErrDecode } - for _, aead := range c.aeads { - ns := aead.NonceSize() - if len(raw) < ns { - continue - } + // Every key is AES-256-GCM: same nonce size and overhead. + ns := c.aeads[0].NonceSize() + if len(raw) < 1+ns+c.aeads[0].Overhead() || raw[0] != cookieFormat { + return nil, ErrDecode + } - nonce, ciphertext := raw[:ns], raw[ns:] + nonce, ciphertext, ad := raw[1:1+ns], raw[1+ns:], associatedData(name, purpose) - plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + for _, aead := range c.aeads { + plaintext, err := aead.Open(nil, nonce, ciphertext, ad) if err != nil { - continue // wrong key or tampered — try the next key - } - - var s Session - if err := json.Unmarshal(plaintext, &s); err != nil { - return nil, ErrDecode + continue // wrong key, binding or tampered — try the next key } - return &s, nil + return decodePayload(plaintext) } return nil, ErrDecode } + +// decodePayload decodes a payload. Numbers decode as json.Number: integers +// beyond ±2^53 keep their precision (RFC 8259 §6). +func decodePayload(plaintext []byte) (*Session, error) { + dec := json.NewDecoder(bytes.NewReader(plaintext)) + dec.UseNumber() + + var p payload + if err := dec.Decode(&p); err != nil { + return nil, ErrDecode + } + + if p.Values == nil { + p.Values = map[string]any{} + } + + return &Session{ + ID: p.ID, Values: p.Values, CSRFToken: p.CSRFToken, + CreatedAt: p.CreatedAt, LastAccessed: p.LastAccessed, ExpiresAt: p.ExpiresAt, + }, nil +} diff --git a/session/codec_format_test.go b/session/codec_format_test.go index a66fe182..1b4b2e78 100644 --- a/session/codec_format_test.go +++ b/session/codec_format_test.go @@ -5,7 +5,8 @@ package session_test import ( - "bytes" + "crypto/hkdf" + "crypto/rand" "crypto/sha256" "encoding/binary" "fmt" @@ -29,10 +30,15 @@ import ( func TestCodecRedactsItsKeys(t *testing.T) { t.Parallel() - key := bytes.Repeat([]byte{0x5a}, 32) - derived := sha256.Sum256(key) // the AES-256 key of the active key + key, previous := make([]byte, 32), make([]byte, 32) + _, _ = rand.Read(key) + _, _ = rand.Read(previous) - codec, err := session.NewCodec(key, []byte("previous-key")) + // The AES-256 key of the active key (cookie format v2). + derived, err := hkdf.Key(sha256.New, key, nil, "github.com/hyperscale-stack/security/session v2 AES-256-GCM", 32) + require.NoError(t, err) + + codec, err := session.NewCodec(key, previous) require.NoError(t, err) // The first word of the key schedule, as fmt prints it on either byte order. diff --git a/session/codec_test.go b/session/codec_test.go index 3471e5b0..d9ad4952 100644 --- a/session/codec_test.go +++ b/session/codec_test.go @@ -5,6 +5,7 @@ package session_test import ( + "strings" "testing" "time" @@ -32,24 +33,26 @@ func TestCodecRoundTrip(t *testing.T) { codec, err := session.NewCodec(testKey) require.NoError(t, err) - encoded, err := codec.Encode(sampleSession()) + encoded, err := codec.SealForTest(sampleSession(), "session", "") require.NoError(t, err) assert.NotEmpty(t, encoded) - got, err := codec.Decode(encoded) + got, err := codec.OpenForTest(encoded, "session", "") require.NoError(t, err) assert.Equal(t, "sid-1", got.ID) assert.Equal(t, "alice", got.GetString("sub")) assert.Equal(t, "csrf-token-value", got.CSRFToken) + assert.True(t, got.CreatedAt.Equal(sampleSession().CreatedAt)) + assert.True(t, got.ExpiresAt.Equal(sampleSession().ExpiresAt)) } -func TestCodecEncodeIsRandomised(t *testing.T) { +func TestCodecSealIsRandomised(t *testing.T) { t.Parallel() codec, _ := session.NewCodec(testKey) - a, _ := codec.Encode(sampleSession()) - b, _ := codec.Encode(sampleSession()) + a, _ := codec.SealForTest(sampleSession(), "session", "") + b, _ := codec.SealForTest(sampleSession(), "session", "") assert.NotEqual(t, a, b, "a fresh GCM nonce per call must change the ciphertext") } @@ -57,12 +60,12 @@ func TestCodecRejectsTamperedValue(t *testing.T) { t.Parallel() codec, _ := session.NewCodec(testKey) - encoded, _ := codec.Encode(sampleSession()) + encoded, _ := codec.SealForTest(sampleSession(), "session", "") // Flip the last byte — the GCM tag check must fail. tampered := encoded[:len(encoded)-1] + flipChar(encoded[len(encoded)-1]) - _, err := codec.Decode(tampered) + _, err := codec.OpenForTest(tampered, "session", "") assert.ErrorIs(t, err, session.ErrDecode) } @@ -71,45 +74,12 @@ func TestCodecRejectsGarbage(t *testing.T) { codec, _ := session.NewCodec(testKey) - for _, bad := range []string{"", "!!!not base64!!!", "c2hvcnQ"} { - _, err := codec.Decode(bad) - assert.ErrorIs(t, err, session.ErrDecode, "input %q", bad) + for _, bad := range []string{"", "!!!not base64!!!", "c2hvcnQ", "Ag", strings.Repeat("A", 4097)} { + _, err := codec.OpenForTest(bad, "session", "") + assert.ErrorIs(t, err, session.ErrDecode, "input %.20q", bad) } } -func TestCodecKeyRotation(t *testing.T) { - t.Parallel() - - oldKey := []byte("old-key-old-key-old-key-old-key!") - newKey := []byte("new-key-new-key-new-key-new-key!") - - // A cookie sealed by the old codec... - oldCodec, _ := session.NewCodec(oldKey) - sealed, err := oldCodec.Encode(sampleSession()) - require.NoError(t, err) - - // ...still decodes after rotation when the old key is kept as a - // decrypt-only key (new key first = active for encryption). - rotated, err := session.NewCodec(newKey, oldKey) - require.NoError(t, err) - - got, err := rotated.Decode(sealed) - require.NoError(t, err) - assert.Equal(t, "sid-1", got.ID) - - // A codec that dropped the old key can no longer read the cookie. - newOnly, _ := session.NewCodec(newKey) - _, err = newOnly.Decode(sealed) - assert.ErrorIs(t, err, session.ErrDecode) -} - -func TestNewCodecRequiresAKey(t *testing.T) { - t.Parallel() - - _, err := session.NewCodec() - assert.ErrorIs(t, err, session.ErrInvalidKeys) -} - func flipChar(b byte) string { if b == 'A' { return "B" diff --git a/session/doc.go b/session/doc.go index 2d94546d..77afef38 100644 --- a/session/doc.go +++ b/session/doc.go @@ -3,12 +3,21 @@ // license that can be found in the LICENSE file. // Package session provides cookie-based session management for browser apps: -// signed/encrypted cookie store, session ID rotation after login (defense -// against session fixation), logout, CSRF helper. +// a stateless encrypted cookie store, session ID rotation (defense against +// session fixation), logout, a CSRF helper and an optional server-side +// revocation hook. // -// Defaults are secure: Secure=true, HttpOnly=true, SameSite=Lax. The cookie -// store uses AES-GCM with HMAC and supports key rotation (multi-key reader, -// single active writer). +// Defaults are secure: Secure=true, HttpOnly=true, SameSite=Lax. The whole +// session is sealed into the cookie with AES-256-GCM, an AEAD (no separate +// HMAC): keys of at least 32 random bytes expanded with HKDF-SHA256, the +// cookie bound to the format version, its name and an optional purpose, +// key rotation with a multi-key reader and a single active writer (see +// Codec for the cookie format). +// +// A session lives at most its TTL from its creation: the Manager enforces +// CreatedAt + TTL on every read, and neither Rotate nor Touch extends it. +// Numbers in Values come back as json.Number (Session.GetInt64), never as a +// float64 that loses the precision of a 64-bit ID. // // Allowed dependencies: // - github.com/hyperscale-stack/security (core) diff --git a/session/example_test.go b/session/example_test.go index b32726fb..9ab45213 100644 --- a/session/example_test.go +++ b/session/example_test.go @@ -6,6 +6,7 @@ package session_test import ( "context" + "crypto/rand" "fmt" "github.com/hyperscale-stack/security/session" @@ -15,7 +16,16 @@ import ( // encrypted cookie, Get replays it, Rotate changes the ID after a // privilege change, and Logout clears it. func Example() { - codec, err := session.NewCodec([]byte("a-32-byte-or-longer-secret-key!!")) + // A session key is 32 bytes or more from a cryptographic source — in + // production, read it from your secret store (e.g. a base64 value made + // with `openssl rand -base64 32`), never a passphrase. Keep the previous + // key after the new one to rotate without signing everyone out. + key := make([]byte, session.MinKeySize) + if _, err := rand.Read(key); err != nil { + panic(err) + } + + codec, err := session.NewCodec(key) if err != nil { panic(err) } diff --git a/session/export_test.go b/session/export_test.go new file mode 100644 index 00000000..f9818377 --- /dev/null +++ b/session/export_test.go @@ -0,0 +1,16 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session + +// SealForTest and OpenForTest expose the codec to the black-box tests: only +// the Manager seals and opens cookies, always bound to its cookie name and +// purpose. +func (c *Codec) SealForTest(s *Session, name, purpose string) (string, error) { + return c.seal(s, name, purpose) +} + +func (c *Codec) OpenForTest(value, name, purpose string) (*Session, error) { + return c.open(value, name, purpose) +} diff --git a/session/extractor.go b/session/extractor.go index 1c63f516..bca1d25e 100644 --- a/session/extractor.go +++ b/session/extractor.go @@ -6,6 +6,8 @@ package session import ( "context" + "errors" + "fmt" "github.com/hyperscale-stack/security" ) @@ -22,15 +24,22 @@ type Extractor struct { func NewExtractor(mgr *Manager) Extractor { return Extractor{mgr: mgr} } // Extract implements [security.Extractor]. Returns (nil, nil) when the -// request carries no decodable session cookie, so the engine moves on to -// the next extractor / anonymous flow. +// request carries no usable session cookie — absent, undecodable, expired or +// refused by the Manager's validator — so the engine moves on to the next +// extractor / anonymous flow. A session that could not be checked (an error +// wrapping [security.ErrUnavailable] from the validator) is an error: the +// transports answer 503 rather than treating the user as signed out. func (e Extractor) Extract(ctx context.Context, c security.Carrier) (security.Authentication, error) { s, err := e.mgr.Get(ctx, c) - if err != nil { + + switch { + case err == nil: + return newPending(s), nil + case errors.Is(err, security.ErrUnavailable): + return nil, fmt.Errorf("session: %w", err) + default: // ErrNoSession and expiry both mean "no usable session here" — // the engine treats a nil result as "extractor did not apply". return nil, nil //nolint:nilerr // absent/expired session is not an extraction error } - - return newPending(s), nil } diff --git a/session/format_test.go b/session/format_test.go new file mode 100644 index 00000000..7467350e --- /dev/null +++ b/session/format_test.go @@ -0,0 +1,426 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "runtime" + "strings" + "testing" + "time" + + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// randomKey returns a fresh 32-byte key from crypto/rand. +func randomKey(t *testing.T) []byte { + t.Helper() + + k := make([]byte, 32) + _, err := rand.Read(k) + require.NoError(t, err) + + return k +} + +// v2Cookie seals plaintext the way the documented cookie format v2 does, +// independently of the package: AES-256-GCM under +// HKDF-SHA256(key, salt=∅, info=label), associated data +// version ‖ len16(name) ‖ name ‖ len16(purpose) ‖ purpose, value +// base64url(version ‖ nonce ‖ ciphertext‖tag). +func v2Cookie(t *testing.T, key []byte, version byte, adVersion byte, name, purpose string, plaintext []byte) string { + t.Helper() + + derived, err := hkdf.Key(sha256.New, key, nil, "github.com/hyperscale-stack/security/session v2 AES-256-GCM", 32) + require.NoError(t, err) + + block, err := aes.NewCipher(derived) + require.NoError(t, err) + + gcm, err := cipher.NewGCM(block) + require.NoError(t, err) + + ad := []byte{adVersion} + ad = binary.BigEndian.AppendUint16(ad, uint16(len(name))) //nolint:gosec // test sizes + ad = append(ad, name...) + ad = binary.BigEndian.AppendUint16(ad, uint16(len(purpose))) //nolint:gosec // test sizes + ad = append(ad, purpose...) + + nonce := make([]byte, gcm.NonceSize()) + _, err = rand.Read(nonce) + require.NoError(t, err) + + out := append([]byte{version}, nonce...) + out = gcm.Seal(out, nonce, plaintext, ad) + + return base64.RawURLEncoding.EncodeToString(out) +} + +// samplePayload is a v2 payload for sub alice, valid for an hour at now. +func samplePayload(t *testing.T, now time.Time) []byte { + t.Helper() + + b, err := json.Marshal(map[string]any{ + "id": "sid-1", "v": map[string]any{"sub": "alice"}, "csrf": "csrf-1", + "c": now, "a": now, "e": now.Add(time.Hour), + }) + require.NoError(t, err) + + return b +} + +// managerWithKeys builds a Manager on a fixed clock with the given keys. +func managerWithKeys(t *testing.T, now *time.Time, keys [][]byte, opts ...session.Option) *session.Manager { + t.Helper() + + codec, err := session.NewCodec(keys...) + require.NoError(t, err) + + return session.NewManager(codec, append([]session.Option{session.WithClock(func() time.Time { return *now })}, opts...)...) +} + +// getWithCookie reads the session a browser presenting name=value carries. +func getWithCookie(mgr *session.Manager, name, value string) (*session.Session, error) { + c := newCarrier() + c.cookies[name] = value + + return mgr.Get(context.Background(), c) +} + +// Audit CORE-04 (probe TestZZAuditSessionEmptyKeyForgery): NewCodec accepted +// an empty key — an unset environment variable — and derived the AES key +// with SHA-256, so anyone could mint a cookie for any subject; a passphrase +// ("changeme") could be brute-forced from one cookie. Decision 10: keys of +// at least 32 random bytes; a key that repeats one byte (a buffer never +// filled) is refused too. +func TestNewCodecRefusesShortKeys(t *testing.T) { + t.Parallel() + + for name, keys := range map[string][][]byte{ + "no key": nil, + "nil key": {nil}, + "empty key": {{}}, + "31 bytes": {bytes.Repeat([]byte{7, 8}, 16)[:31]}, + "passphrase": {[]byte("changeme")}, + "valid then short": {randomKey(t), []byte("previous-key")}, + "32 zero bytes": {make([]byte, 32)}, + "64 times the same byte": {bytes.Repeat([]byte{0x5a}, 64)}, + } { + codec, err := session.NewCodec(keys...) + + require.ErrorIs(t, err, session.ErrInvalidKeys, name) + assert.Nil(t, codec, name) + assert.NotContains(t, err.Error(), "changeme", "the error never quotes a key") + } + + assert.Equal(t, 32, session.MinKeySize) +} + +func TestNewCodecAcceptsLongRandomKeys(t *testing.T) { + t.Parallel() + + long := make([]byte, 64) + _, err := rand.Read(long) + require.NoError(t, err) + + for _, keys := range [][][]byte{{randomKey(t)}, {long}, {randomKey(t), randomKey(t)}} { + codec, err := session.NewCodec(keys...) + require.NoError(t, err) + assert.NotNil(t, codec) + } +} + +// The documented cookie format v2 is pinned both ways: a cookie built from +// the documentation opens, and a cookie the Manager writes decodes with the +// documentation. +func TestCookieFormatV2(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + mgr := managerWithKeys(t, &now, [][]byte{key}) + name := mgr.CookieName() + + s, err := getWithCookie(mgr, name, v2Cookie(t, key, 2, 2, name, "", samplePayload(t, now))) + require.NoError(t, err) + assert.Equal(t, "alice", s.GetString("sub")) + assert.Equal(t, "sid-1", s.ID) + assert.Equal(t, "csrf-1", s.CSRFToken) + + // The other way round: open a Manager cookie with the documented recipe. + c := newCarrier() + _, err = mgr.Login(context.Background(), c, principal{sub: "bob"}) + require.NoError(t, err) + + raw, err := base64.RawURLEncoding.DecodeString(c.replay().cookies[name]) + require.NoError(t, err) + require.Equal(t, byte(2), raw[0], "version byte in clear") + + derived, err := hkdf.Key(sha256.New, key, nil, "github.com/hyperscale-stack/security/session v2 AES-256-GCM", 32) + require.NoError(t, err) + + block, err := aes.NewCipher(derived) + require.NoError(t, err) + + gcm, err := cipher.NewGCM(block) + require.NoError(t, err) + + ad := []byte{2} + ad = binary.BigEndian.AppendUint16(ad, uint16(len(name))) //nolint:gosec // test sizes + ad = append(ad, name...) + ad = binary.BigEndian.AppendUint16(ad, 0) + + plaintext, err := gcm.Open(nil, raw[1:13], raw[13:], ad) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(plaintext, &payload)) + assert.ElementsMatch(t, []string{"id", "v", "csrf", "c", "a", "e"}, keys(payload)) + assert.Equal(t, map[string]any{"sub": "bob"}, payload["v"]) +} + +func keys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + + return out +} + +// Audit CORE-09 (probe TestZZAuditSessionNoAADBinding): without associated +// data, a cookie minted by the manager "shop_session" opened under +// "admin_session", and a value sealed by the same Codec for another purpose +// opened as a session. Decision 10: AD = version ‖ cookie name ‖ purpose. +func TestManagerBindsCookiesToTheirName(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Now() + shop := managerWithKeys(t, &now, [][]byte{key}, session.WithCookieName("shop_session")) + admin := managerWithKeys(t, &now, [][]byte{key}, session.WithCookieName("admin_session")) + + c := newCarrier() + _, err := shop.Login(context.Background(), c, principal{sub: "bob"}) + require.NoError(t, err) + + value := c.replay().cookies["shop_session"] + + _, err = getWithCookie(admin, "admin_session", value) + require.ErrorIs(t, err, session.ErrNoSession) + require.ErrorIs(t, err, session.ErrDecode) + + s, err := getWithCookie(shop, "shop_session", value) + require.NoError(t, err, "control: the right manager opens it") + assert.Equal(t, "bob", s.GetString("sub")) +} + +func TestManagerBindsCookiesToTheirPurpose(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Now() + + login := func(mgr *session.Manager) string { + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "bob"}) + require.NoError(t, err) + + return c.replay().cookies[mgr.CookieName()] + } + + appA := managerWithKeys(t, &now, [][]byte{key}, session.WithPurpose("app-a")) + appB := managerWithKeys(t, &now, [][]byte{key}, session.WithPurpose("app-b")) + noPurpose := managerWithKeys(t, &now, [][]byte{key}) + + value := login(appA) + + for name, mgr := range map[string]*session.Manager{"other purpose": appB, "no purpose": noPurpose} { + _, err := getWithCookie(mgr, mgr.CookieName(), value) + require.ErrorIs(t, err, session.ErrNoSession, name) + } + + _, err := getWithCookie(appA, appA.CookieName(), value) + require.NoError(t, err, "control: the same purpose opens it") + + _, err = getWithCookie(appA, appA.CookieName(), login(noPurpose)) + require.ErrorIs(t, err, session.ErrNoSession, "and the other way round") +} + +// The associated data is length-prefixed: shifting a byte from the cookie +// name to the purpose changes it. +func TestAssociatedDataIsUnambiguous(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Now() + a := managerWithKeys(t, &now, [][]byte{key}, session.WithCookieName("session"), session.WithPurpose("x")) + b := managerWithKeys(t, &now, [][]byte{key}, session.WithCookieName("sessionx")) + + c := newCarrier() + _, err := a.Login(context.Background(), c, principal{sub: "bob"}) + require.NoError(t, err) + + _, err = getWithCookie(b, "sessionx", c.replay().cookies["session"]) + require.ErrorIs(t, err, session.ErrNoSession) + + c = newCarrier() + _, err = b.Login(context.Background(), c, principal{sub: "bob"}) + require.NoError(t, err) + + _, err = getWithCookie(a, "session", c.replay().cookies["sessionx"]) + require.ErrorIs(t, err, session.ErrNoSession) +} + +func TestWithPurposeRefusesLongPurposes(t *testing.T) { + t.Parallel() + + assert.Panics(t, func() { session.WithPurpose(string(bytes.Repeat([]byte("p"), 256))) }) + assert.NotPanics(t, func() { session.WithPurpose(string(bytes.Repeat([]byte("p"), 255))) }) +} + +// Decision 10: the cookies of the previous format — AES-256-GCM under +// SHA-256(key), no version, no associated data — no longer open: every +// user signs in once again. +func TestManagerRefusesV1Cookies(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Now() + mgr := managerWithKeys(t, &now, [][]byte{key}) + + sum := sha256.Sum256(key) + block, err := aes.NewCipher(sum[:]) + require.NoError(t, err) + + gcm, err := cipher.NewGCM(block) + require.NoError(t, err) + + plaintext, err := json.Marshal(map[string]any{ + "ID": "sid", "Values": map[string]any{"sub": "alice"}, "CSRFToken": "x", + "CreatedAt": now, "LastAccessed": now, "ExpiresAt": now.Add(time.Hour), + }) + require.NoError(t, err) + + for range 32 { // the first byte of a v1 cookie is a random nonce byte: try many + nonce := make([]byte, gcm.NonceSize()) + _, err = rand.Read(nonce) + require.NoError(t, err) + + v1 := base64.RawURLEncoding.EncodeToString(gcm.Seal(nonce, nonce, plaintext, nil)) + + _, err = getWithCookie(mgr, mgr.CookieName(), v1) + require.ErrorIs(t, err, session.ErrNoSession) + } +} + +func TestManagerRefusesOtherFormatVersions(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Now() + mgr := managerWithKeys(t, &now, [][]byte{key}) + payload := samplePayload(t, now) + cookie := mgr.CookieName() + + for name, value := range map[string]string{ + "version 3 everywhere": v2Cookie(t, key, 3, 3, cookie, "", payload), + "version 2 bound to version 3": v2Cookie(t, key, 2, 3, cookie, "", payload), + "version 3 bound to version 2": v2Cookie(t, key, 3, 2, cookie, "", payload), + } { + _, err := getWithCookie(mgr, cookie, value) + require.ErrorIs(t, err, session.ErrNoSession, name) + } + + _, err := getWithCookie(mgr, cookie, v2Cookie(t, key, 2, 2, cookie, "", payload)) + require.NoError(t, err, "control") +} + +// Audit §9 surviving mutant: the codec ignored JSON errors. +func TestManagerRefusesAnUndecodablePayload(t *testing.T) { + t.Parallel() + + key := randomKey(t) + now := time.Now() + mgr := managerWithKeys(t, &now, [][]byte{key}) + + for _, plaintext := range []string{"not json", `{"id":`, `[]`, `{"v":"not an object"}`} { + _, err := getWithCookie(mgr, mgr.CookieName(), v2Cookie(t, key, 2, 2, mgr.CookieName(), "", []byte(plaintext))) + require.ErrorIs(t, err, session.ErrNoSession, plaintext) + require.ErrorIs(t, err, session.ErrDecode, plaintext) + } +} + +// Review nit 11: key rotation in the v2 format. A cookie sealed with the old +// key opens while the old key is kept after the new one, is refused once it +// is dropped, and is re-sealed with the new key by Touch and Rotate. +func TestKeyRotation(t *testing.T) { + t.Parallel() + + oldKey, newKey := randomKey(t), randomKey(t) + now := time.Now() + before := managerWithKeys(t, &now, [][]byte{oldKey}) + during := managerWithKeys(t, &now, [][]byte{newKey, oldKey}) + after := managerWithKeys(t, &now, [][]byte{newKey}) + + c := newCarrier() + _, err := before.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + old := c.replay() + + _, err = after.Get(context.Background(), old) + require.ErrorIs(t, err, session.ErrNoSession, "dropped key") + + s, err := during.Get(context.Background(), old) + require.NoError(t, err) + + touched := newCarrier() + require.NoError(t, during.Touch(context.Background(), touched, s)) + + got, err := after.Get(context.Background(), touched.replay()) + require.NoError(t, err, "Touch re-sealed the cookie with the active key") + assert.Equal(t, "alice", got.GetString("sub")) + + rotating := newCarrier() + rotating.cookies = old.cookies + + _, err = during.Rotate(context.Background(), rotating) + require.NoError(t, err) + + _, err = after.Get(context.Background(), rotating.replay()) + require.NoError(t, err, "Rotate re-sealed the cookie with the active key") +} + +// The cookie value is bounded before it is decoded: a hostile megabyte of +// base64 costs no allocation. (Not parallel: it reads the process-wide +// allocation counter.) +func TestManagerBoundsTheCookieBeforeDecoding(t *testing.T) { + now := time.Now() + mgr := managerWithKeys(t, &now, [][]byte{testKey}) + huge := strings.Repeat("AAAA", 1<<18) // 1 MiB of valid base64url + + var before, after runtime.MemStats + + runtime.ReadMemStats(&before) + + _, err := getWithCookie(mgr, mgr.CookieName(), huge) + + runtime.ReadMemStats(&after) + + require.ErrorIs(t, err, session.ErrNoSession) + assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(64<<10), "the cookie must be refused before being decoded") +} diff --git a/session/lifetime_test.go b/session/lifetime_test.go new file mode 100644 index 00000000..e1af721d --- /dev/null +++ b/session/lifetime_test.go @@ -0,0 +1,294 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var epoch = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + +// clockedManager returns a Manager on the clock *now with the given TTL. +func clockedManager(t *testing.T, now *time.Time, opts ...session.Option) *session.Manager { + t.Helper() + + return managerWithKeys(t, now, [][]byte{testKey}, opts...) +} + +// lastCookie parses the last Set-Cookie staged on c. +func lastCookie(t *testing.T, c *mapCarrier) *http.Cookie { + t.Helper() + + resp := http.Response{Header: http.Header{"Set-Cookie": {c.lastSetCookie()}}} + cookies := resp.Cookies() + require.Len(t, cookies, 1) + + return cookies[0] +} + +// Audit CORE-08 (probe TestZZAuditSessionRotateExtendsAbsoluteExpiry): with a +// 1 h TTL and a Rotate every 55 minutes, a session created at 00:00 was still +// alive at 04:35 — Rotate moved ExpiresAt to now+TTL. Decision 10: the +// absolute lifetime CreatedAt+TTL is never extended by Rotate. +func TestRotateNeverExtendsTheAbsoluteLifetime(t *testing.T) { + t.Parallel() + + now := epoch + mgr := clockedManager(t, &now, session.WithTTL(time.Hour)) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + browser := c.replay() + + now = now.Add(55 * time.Minute) + + rotated, err := mgr.Rotate(context.Background(), browser) + require.NoError(t, err) + assert.True(t, rotated.CreatedAt.Equal(epoch)) + assert.True(t, rotated.ExpiresAt.Equal(epoch.Add(time.Hour)), "ExpiresAt stays CreatedAt+TTL: %s", rotated.ExpiresAt) + assert.Equal(t, 300, lastCookie(t, browser).MaxAge, "the cookie lives until the deadline only") + + now = now.Add(5*time.Minute + time.Second) // 01:00:01 + + _, err = mgr.Rotate(context.Background(), browser.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired) + + _, err = mgr.Get(context.Background(), browser.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired) +} + +// Audit CORE-08 (probe TestZZAuditSessionNoRevocation): only the ExpiresAt +// carried in the cookie was checked — a cookie issued with a 30-day TTL kept +// working for 30 days on a manager reconfigured with 15 minutes. +func TestManagerEnforcesItsTTLOnOlderCookies(t *testing.T) { + t.Parallel() + + now := epoch + long := clockedManager(t, &now, session.WithTTL(30*24*time.Hour)) + short := clockedManager(t, &now, session.WithTTL(15*time.Minute)) + + c := newCarrier() + _, err := long.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(14 * time.Minute) + + _, err = short.Get(context.Background(), c.replay()) + require.NoError(t, err) + + now = epoch.Add(16 * time.Minute) + + _, err = short.Get(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired) + + _, err = long.Get(context.Background(), c.replay()) + require.NoError(t, err, "control: the 30-day manager still accepts it") +} + +// Raising the TTL never extends an issued session: the cookie's own +// ExpiresAt still applies. +func TestRaisingTheTTLDoesNotExtendIssuedSessions(t *testing.T) { + t.Parallel() + + now := epoch + short := clockedManager(t, &now, session.WithTTL(time.Hour)) + long := clockedManager(t, &now, session.WithTTL(48*time.Hour)) + + c := newCarrier() + _, err := short.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(2 * time.Hour) + + _, err = long.Get(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired) +} + +// Boundaries: a session is valid at exactly its deadline and expired one +// nanosecond later, whichever of CreatedAt+TTL and ExpiresAt comes first. +func TestManagerDeadlineBoundaries(t *testing.T) { + t.Parallel() + + for name, ttls := range map[string][2]time.Duration{ + "TTL of the reading manager first": {2 * time.Hour, time.Hour}, + "cookie ExpiresAt first": {time.Hour, 2 * time.Hour}, + } { + now := epoch + writer := clockedManager(t, &now, session.WithTTL(ttls[0])) + reader := clockedManager(t, &now, session.WithTTL(ttls[1])) + + c := newCarrier() + _, err := writer.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(time.Hour) + + _, err = reader.Get(context.Background(), c.replay()) + require.NoError(t, err, "%s: valid at exactly the deadline", name) + + now = epoch.Add(time.Hour + time.Nanosecond) + + _, err = reader.Get(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired, name) + } +} + +// Review nit 13: the cookie lifetime comes from the deadline, never from an +// ExpiresAt the application moved — and Max-Age is rounded up (in Go, +// MaxAge 0 means "no attribute"). +func TestTouchWritesTheDeadline(t *testing.T) { + t.Parallel() + + now := epoch + mgr := clockedManager(t, &now, session.WithTTL(time.Hour)) + + s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice"}) + require.NoError(t, err) + + s.ExpiresAt = epoch.Add(365 * 24 * time.Hour) // the application tries to extend it + + now = epoch.Add(59*time.Minute + 58*time.Second + 500*time.Millisecond) + + c := newCarrier() + require.NoError(t, mgr.Touch(context.Background(), c, s)) + + cookie := lastCookie(t, c) + assert.Equal(t, 2, cookie.MaxAge, "1.5 s left, rounded up") + assert.True(t, cookie.Expires.Equal(epoch.Add(time.Hour)), "Expires is the deadline: %s", cookie.Expires) + + got, err := mgr.Get(context.Background(), c.replay()) + require.NoError(t, err) + assert.True(t, got.ExpiresAt.Equal(epoch.Add(time.Hour)), "the sealed ExpiresAt is the deadline: %s", got.ExpiresAt) +} + +// Touch never writes a session past its deadline — it used to write it with +// Max-Age 1 — and refuses a nil session instead of panicking. +func TestTouchRefusesAnExpiredOrMissingSession(t *testing.T) { + t.Parallel() + + now := epoch + mgr := clockedManager(t, &now, session.WithTTL(time.Hour)) + + s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(time.Hour) + + c := newCarrier() + require.ErrorIs(t, mgr.Touch(context.Background(), c, s), security.ErrTokenExpired, "nothing left to write at the deadline") + assert.Empty(t, c.setCookies) + + require.ErrorIs(t, mgr.Touch(context.Background(), c, nil), session.ErrNoSession) + assert.Empty(t, c.setCookies) +} + +// Pinned documented behaviour: Get does not slide the idle window, Touch does. +func TestOnlyTouchSlidesTheIdleWindow(t *testing.T) { + t.Parallel() + + now := epoch + mgr := clockedManager(t, &now, session.WithIdleTimeout(10*time.Minute)) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + browser := c.replay() + + now = epoch.Add(6 * time.Minute) + + s, err := mgr.Get(context.Background(), browser) + require.NoError(t, err) + + now = epoch.Add(12 * time.Minute) + + _, err = mgr.Get(context.Background(), browser) + require.ErrorIs(t, err, security.ErrTokenExpired, "Get alone does not slide the window") + + now = epoch.Add(6 * time.Minute) + + touched := newCarrier() + require.NoError(t, mgr.Touch(context.Background(), touched, s)) + + now = epoch.Add(12 * time.Minute) + + _, err = mgr.Get(context.Background(), touched.replay()) + require.NoError(t, err, "Touch slid it") +} + +// At exactly its deadline a session is still read, but nothing is left to +// rotate into. +func TestRotateAtTheDeadline(t *testing.T) { + t.Parallel() + + now := epoch + mgr := clockedManager(t, &now, session.WithTTL(time.Hour)) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(time.Hour) + + _, err = mgr.Rotate(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired) +} + +// Session.IsExpired checks the ExpiresAt the session carries (the Manager +// also enforces CreatedAt + TTL). +func TestSessionIsExpired(t *testing.T) { + t.Parallel() + + s := &session.Session{ExpiresAt: epoch} + + assert.False(t, s.IsExpired(epoch)) + assert.True(t, s.IsExpired(epoch.Add(time.Nanosecond))) +} + +// The cookie name is length-prefixed in the associated data: absurdly long +// names are a configuration error. +func TestNewManagerRefusesAnOverlongCookieName(t *testing.T) { + t.Parallel() + + codec, err := session.NewCodec(testKey) + require.NoError(t, err) + + assert.Panics(t, func() { session.NewManager(codec, session.WithCookieName(strings.Repeat("n", 257))) }) + assert.NotPanics(t, func() { session.NewManager(codec, session.WithCookieName(strings.Repeat("n", 256))) }) +} + +// A session issued under a shorter TTL keeps its own deadline through a +// Rotate on a Manager whose TTL was raised since. +func TestRotateKeepsTheShorterDeadlineAfterATTLIncrease(t *testing.T) { + t.Parallel() + + now := epoch + short := clockedManager(t, &now, session.WithTTL(time.Hour)) + long := clockedManager(t, &now, session.WithTTL(48*time.Hour)) + + c := newCarrier() + _, err := short.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(30 * time.Minute) + + browser := c.replay() + + rotated, err := long.Rotate(context.Background(), browser) + require.NoError(t, err) + assert.True(t, rotated.ExpiresAt.Equal(epoch.Add(time.Hour)), "deadline kept: %s", rotated.ExpiresAt) + assert.Equal(t, 1800, lastCookie(t, browser).MaxAge) +} diff --git a/session/manager.go b/session/manager.go index 86feee2e..3f0ab3df 100644 --- a/session/manager.go +++ b/session/manager.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "errors" "fmt" + "math" "net/http" "time" @@ -41,8 +42,22 @@ type Manager struct { ttl time.Duration idleTimeout time.Duration clock func() time.Time + purpose string + validator func(ctx context.Context, s *Session) error } +// MaxCookieSize bounds a session cookie: the name and value of a cookie +// together may not exceed 4096 octets, or user agents ignore it +// (draft-ietf-httpbis-rfc6265bis §5.6). +const MaxCookieSize = 4096 + +// maxPurposeLength and maxCookieNameLength bound what the AEAD associated +// data length-prefixes. +const ( + maxPurposeLength = 255 + maxCookieNameLength = 256 +) + // Option configures a [Manager]. type Option func(*Manager) @@ -72,13 +87,19 @@ func WithSameSite(mode http.SameSite) Option { return func(m *Manager) { m.sameSite = mode } } -// WithTTL overrides the absolute session lifetime. Default: 24h. +// WithTTL overrides the absolute session lifetime: a session expires at +// CreatedAt + TTL, whatever happens in between (Rotate and Touch never +// extend it), and a cookie issued under a longer TTL expires at +// CreatedAt + the current TTL. Raising the TTL does not extend issued +// sessions. Default: 24h. func WithTTL(ttl time.Duration) Option { return func(m *Manager) { m.ttl = ttl } } -// WithIdleTimeout enables an idle-timeout: a session untouched for longer -// than d is treated as expired. Default: 0 (disabled). +// WithIdleTimeout enables an idle-timeout: a session whose cookie was not +// written (Login, Touch, Rotate) for longer than d is treated as expired — +// Get alone does not slide the window: call [Manager.Touch] on the requests +// that count as activity. Default: 0 (disabled). func WithIdleTimeout(d time.Duration) Option { return func(m *Manager) { m.idleTimeout = d } } @@ -92,6 +113,40 @@ func WithClock(now func() time.Time) Option { } } +// WithPurpose binds the Manager's cookies to purpose — e.g. the application +// or realm name — in the AEAD associated data: a cookie sealed by another +// Manager that shares the keys and the cookie name (another application, +// another path) never opens here, nor does one of this Manager open there. +// Default: "". It panics on a purpose longer than 255 bytes. +func WithPurpose(purpose string) Option { + if len(purpose) > maxPurposeLength { + panic(fmt.Sprintf("session: WithPurpose: purpose of %d bytes, at most %d", len(purpose), maxPurposeLength)) + } + + return func(m *Manager) { m.purpose = purpose } +} + +// WithValidator registers a server-side check run by [Manager.Get] — and so +// by the [Extractor] and [Manager.Rotate] — on every decoded, unexpired +// session: the way to revoke stateless sessions before they expire. Record, +// say, a fingerprint of the session ID in a deny-list at logout (keep it +// until the session's deadline) or the time of a user's last password +// change, and refuse the sessions that match. The validator runs on every +// request: cache its lookups. +// +// An error refuses the session: Get returns [ErrNoSession] wrapping it, and +// the Extractor finds no session. An error wrapping [security.ErrUnavailable] +// — the revocation store is down — is returned as is and propagated by the +// Extractor, so the transports answer 503 instead of signing everyone out. +// It panics on a nil validator. +func WithValidator(validate func(ctx context.Context, s *Session) error) Option { + if validate == nil { + panic("session: WithValidator: nil validator") + } + + return func(m *Manager) { m.validator = validate } +} + // NewManager builds a [Manager] sealing sessions with codec. func NewManager(codec *Codec, opts ...Option) *Manager { m := &Manager{ @@ -109,6 +164,10 @@ func NewManager(codec *Codec, opts ...Option) *Manager { o(m) } + if len(m.cookieName) > maxCookieNameLength { + panic(fmt.Sprintf("session: NewManager: cookie name of %d bytes, at most %d", len(m.cookieName), maxCookieNameLength)) + } + return m } @@ -141,10 +200,14 @@ func (m *Manager) Login(ctx context.Context, c security.Carrier, principal secur // Get decodes and validates the session carried by the request's cookie, // read through the carrier's [CookieReader] accessor — never from a header -// field or the URL. It returns [ErrNoSession] when the cookie is absent / -// undecodable or the carrier exposes no cookies, and a wrapped expiry error -// when the session is past its absolute or idle deadline. On success it -// refreshes LastAccessed but does NOT rewrite the cookie — call +// field or the URL. It returns [ErrNoSession] when the cookie is absent or +// undecodable (wrapping [ErrDecode]) or the carrier exposes no cookies, and +// an error wrapping [security.ErrTokenExpired] when the session is past its +// deadline — the earlier of its ExpiresAt and CreatedAt + the Manager's TTL +// — or its idle timeout. A validator ([WithValidator]) then decides; an +// error wrapping [security.ErrUnavailable] means the session could not be +// checked: answer 503, do not sign the user out. On success Get refreshes +// LastAccessed in memory but does NOT rewrite the cookie — call // [Manager.Touch] when sliding expiry is desired. func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) { _, span := otel.Tracer(tracerName).Start(ctx, "session.Manager.Get") @@ -160,37 +223,80 @@ func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) return nil, ErrNoSession } - s, err := m.codec.Decode(found[0].Value) + s, err := m.codec.open(found[0].Value, m.cookieName, m.purpose) if err != nil { - return nil, ErrNoSession + return nil, fmt.Errorf("%w: %w", ErrNoSession, err) } now := m.clock() - if s.IsExpired(now) || s.IdleExpired(now, m.idleTimeout) { + if now.After(m.deadline(s)) || s.IdleExpired(now, m.idleTimeout) { return nil, fmt.Errorf("session: %w", security.ErrTokenExpired) } + if err := m.validate(ctx, s); err != nil { + return nil, err + } + s.LastAccessed = now span.SetAttributes(attribute.String("session.id_hash", hashID(s.ID))) return s, nil } +// deadline is the instant s expires: the earlier of the ExpiresAt it +// carries and CreatedAt + the Manager's TTL (decision: the absolute +// lifetime is enforced on every read, whatever the cookie says). +func (m *Manager) deadline(s *Session) time.Time { + if limit := s.CreatedAt.Add(m.ttl); limit.Before(s.ExpiresAt) { + return limit + } + + return s.ExpiresAt +} + +// validate runs the validator, if any. +func (m *Manager) validate(ctx context.Context, s *Session) error { + if m.validator == nil { + return nil + } + + err := m.validator(ctx, s) + + switch { + case err == nil: + return nil + case errors.Is(err, security.ErrUnavailable): + return fmt.Errorf("session: validator: %w", err) + default: + return fmt.Errorf("%w: refused by the validator: %w", ErrNoSession, err) + } +} + // Touch re-writes the cookie with a refreshed LastAccessed, implementing // sliding-window idle expiry. Call it after a successful Get when the -// idle-timeout should reset on activity. +// idle-timeout should reset on activity. It never extends the absolute +// lifetime: the cookie expires at the session's deadline, and a session at +// or past it is not written ([security.ErrTokenExpired]). A nil session +// fails with [ErrNoSession]. func (m *Manager) Touch(ctx context.Context, c security.Carrier, s *Session) error { _, span := otel.Tracer(tracerName).Start(ctx, "session.Manager.Touch") defer span.End() + if s == nil { + return ErrNoSession + } + s.LastAccessed = m.clock() return m.writeCookie(c, s) } -// Rotate issues a new session ID for the current session while preserving -// its Values — the canonical defense against session fixation, to be -// called right after a privilege change (login, step-up auth). +// Rotate issues a new session ID and CSRF token for the current session +// while preserving its Values — the defense against session fixation after +// a privilege change within a session (step-up authentication). It never +// extends the absolute lifetime: the rotated session keeps the creation +// time and deadline of the current one. To start a new lifetime — at +// sign-in — call [Manager.Login], which mints a fresh session. func (m *Manager) Rotate(ctx context.Context, c security.Carrier) (*Session, error) { _, span := otel.Tracer(tracerName).Start(ctx, "session.Manager.Rotate") defer span.End() @@ -207,6 +313,7 @@ func (m *Manager) Rotate(ctx context.Context, c security.Carrier) (*Session, err rotated.Values = current.Values rotated.CreatedAt = current.CreatedAt + rotated.ExpiresAt = m.deadline(current) if err := m.writeCookie(c, rotated); err != nil { return nil, err @@ -244,20 +351,24 @@ func (m *Manager) Logout(ctx context.Context, c security.Carrier) { // tests). func (m *Manager) CookieName() string { return m.cookieName } -// writeCookie encodes s and stages a Set-Cookie header on the carrier. +// writeCookie seals s and stages a Set-Cookie header on the carrier. The +// cookie — and the ExpiresAt it carries — lasts until the session's +// deadline; a session at or past it is not written. func (m *Manager) writeCookie(c security.Carrier, s *Session) error { - value, err := m.codec.Encode(s) - if err != nil { - return fmt.Errorf("session: encode: %w", err) + // Derived from the injected clock, not time.Now, so tests driving a + // fixed clock observe a coherent cookie lifetime. + now := m.clock() + + deadline := m.deadline(s) + if !now.Before(deadline) { + return fmt.Errorf("session: %w", security.ErrTokenExpired) } - // MaxAge is derived from the injected clock, not time.Now, so tests - // driving a fixed clock observe a coherent cookie lifetime. It is - // floored at 1s — a zero/negative MaxAge would tell the browser to - // delete the cookie, which is Logout's job, not Login's. - maxAge := int(s.ExpiresAt.Sub(m.clock()).Seconds()) - if maxAge < 1 { - maxAge = 1 + s.ExpiresAt = deadline + + value, err := m.codec.seal(s, m.cookieName, m.purpose) + if err != nil { + return fmt.Errorf("session: encode: %w", err) } //nolint:gosec // G124: Secure/HttpOnly/SameSite come from the Manager config (secure-by-default: Secure, HttpOnly, SameSiteLax) @@ -269,8 +380,9 @@ func (m *Manager) writeCookie(c security.Carrier, s *Session) error { Secure: m.secure, HttpOnly: m.httpOnly, SameSite: m.sameSite, - Expires: s.ExpiresAt, - MaxAge: maxAge, + Expires: deadline, + // Rounded up: in net/http a MaxAge of 0 means "no attribute". + MaxAge: int(math.Ceil(deadline.Sub(now).Seconds())), } c.Add("Set-Cookie", cookie.String()) diff --git a/session/session.go b/session/session.go index 6c8c1eee..a810949c 100644 --- a/session/session.go +++ b/session/session.go @@ -7,6 +7,8 @@ package session import ( "crypto/rand" "encoding/base64" + "encoding/json" + "math" "time" ) @@ -62,7 +64,9 @@ func newSession(now time.Time, ttl time.Duration) (*Session, error) { }, nil } -// IsExpired reports whether the session has passed its absolute expiry. +// IsExpired reports whether the session has passed the ExpiresAt it +// carries. The [Manager] also enforces CreatedAt + its TTL, whichever comes +// first: rely on [Manager.Get], not on IsExpired alone. func (s *Session) IsExpired(now time.Time) bool { return now.After(s.ExpiresAt) } @@ -86,6 +90,44 @@ func (s *Session) GetString(key string) string { return v } +// GetInt64 returns the integer stored under key and whether there is one +// that fits an int64: an int of any size set in this request, or the +// json.Number it becomes once the cookie round-trips (numbers decode as +// json.Number, so 64-bit IDs keep their precision — never assert float64 on +// a decoded value). A float, a string or an out-of-range number reports +// false: treat a value that is present but unreadable as a refusal, not as +// absent. +func (s *Session) GetInt64(key string) (int64, bool) { + switch v := s.Values[key].(type) { + case json.Number: + n, err := v.Int64() + + return n, err == nil + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint: + return int64(v), v <= math.MaxInt64 //nolint:gosec // G115: checked + case uint64: + return int64(v), v <= math.MaxInt64 //nolint:gosec // G115: checked + } + + return 0, false +} + // randomToken returns n cryptographically-random bytes, base64url-encoded // without padding. func randomToken(n int) (string, error) { diff --git a/session/validator_test.go b/session/validator_test.go new file mode 100644 index 00000000..88f979bc --- /dev/null +++ b/session/validator_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errRevoked = errors.New("session revoked") + +// denyList is a server-side revocation list keyed by session ID. +type denyList struct { + mu sync.Mutex + revoked map[string]bool + down bool + seen []*session.Session +} + +func (d *denyList) check(_ context.Context, s *session.Session) error { + d.mu.Lock() + defer d.mu.Unlock() + + d.seen = append(d.seen, s) + + switch { + case d.down: + return fmt.Errorf("deny-list store: %w", security.ErrUnavailable) + case d.revoked[s.ID]: + return errRevoked + } + + return nil +} + +// Review decision Q4 (audit CORE-08, "logout is client-side only"): a +// stateless session could not be revoked before its cookie expired; a stolen +// copy outlived Logout. WithValidator lets the application check every +// decoded session against its own store (a deny-list written at logout, +// "sessions created before T" after a password change). +func TestWithValidatorRevokesSessions(t *testing.T) { + t.Parallel() + + list := &denyList{revoked: map[string]bool{}} + mgr := newManager(t, session.WithValidator(list.check)) + + c := newCarrier() + s, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + stolen := c.replay() + + got, err := mgr.Get(context.Background(), stolen) + require.NoError(t, err, "not revoked yet") + assert.Equal(t, "alice", got.GetString("sub")) + + list.revoked[s.ID] = true + + _, err = mgr.Get(context.Background(), stolen) + require.ErrorIs(t, err, session.ErrNoSession) + require.ErrorIs(t, err, errRevoked, "the cause stays in the chain") + + _, err = mgr.Rotate(context.Background(), stolen) + require.ErrorIs(t, err, errRevoked, "a revoked session cannot be rotated into a fresh one") + + auth, err := session.NewExtractor(mgr).Extract(context.Background(), stolen) + require.NoError(t, err) + assert.Nil(t, auth, "a revoked session authenticates nobody") + + require.Len(t, list.seen, 4) + assert.Equal(t, s.ID, list.seen[0].ID, "the validator sees the decoded session") + assert.True(t, list.seen[0].CreatedAt.Equal(s.CreatedAt)) +} + +// Review finding 8: an outage of the revocation store must not log every +// user out silently: the error wraps security.ErrUnavailable and the +// transports answer 503. +func TestWithValidatorUnavailableIsNotALogout(t *testing.T) { + t.Parallel() + + list := &denyList{down: true} + mgr := newManager(t, session.WithValidator(list.check)) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + _, err = mgr.Get(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrUnavailable) + assert.NotErrorIs(t, err, session.ErrNoSession) + + auth, err := session.NewExtractor(mgr).Extract(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrUnavailable, "the extractor reports the outage instead of 'no session'") + assert.Nil(t, auth) + + engine := security.NewEngine(security.NewManager(session.NewAuthenticator(stubLoader{})), session.NewExtractor(mgr)) + + _, out, err := engine.Process(context.Background(), c.replay()) + require.Error(t, err) + assert.Equal(t, security.ErrorClassUnavailable, security.ErrorClass(err), "HTTP 503 / gRPC Unavailable") + assert.False(t, out.IsAuthenticated()) +} + +// The validator runs after the expiry checks: an expired session never +// reaches it. +func TestWithValidatorRunsOnLiveSessionsOnly(t *testing.T) { + t.Parallel() + + now := epoch + list := &denyList{revoked: map[string]bool{}} + mgr := clockedManager(t, &now, session.WithTTL(time.Hour), session.WithValidator(list.check)) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + now = epoch.Add(2 * time.Hour) + + _, err = mgr.Get(context.Background(), c.replay()) + require.ErrorIs(t, err, security.ErrTokenExpired) + assert.Empty(t, list.seen) +} + +func TestWithValidatorRefusesNil(t *testing.T) { + t.Parallel() + + assert.Panics(t, func() { session.WithValidator(nil) }) +} diff --git a/session/values_test.go b/session/values_test.go new file mode 100644 index 00000000..04acd32d --- /dev/null +++ b/session/values_test.go @@ -0,0 +1,145 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "encoding/json" + "math" + "testing" + + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// roundTrip writes s with Touch and reads it back from the cookie. +func roundTrip(t *testing.T, mgr *session.Manager, s *session.Session) *session.Session { + t.Helper() + + c := newCarrier() + require.NoError(t, mgr.Touch(context.Background(), c, s)) + + got, err := mgr.Get(context.Background(), c.replay()) + require.NoError(t, err) + + return got +} + +// Audit CORE-14 (probe TestZZAuditSessionNumericValues): session values were +// decoded into float64, so 1234567890123456789 came back as +// 1234567890123456768 — a 64-bit user ID mapped to another account. Decision +// 10: numbers are decoded without precision loss (json.Number). +func TestSessionValuesKeepIntegerPrecision(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice"}) + require.NoError(t, err) + + s.Values["uid"] = int64(1234567890123456789) + s.Values["max"] = int64(math.MaxInt64) + s.Values["min"] = int64(math.MinInt64) + s.Values["ratio"] = 1.5 + s.Values["nested"] = map[string]any{"ids": []any{int64(9007199254740993)}} + + got := roundTrip(t, mgr, s) + + assert.Equal(t, json.Number("1234567890123456789"), got.Values["uid"]) + assert.Equal(t, json.Number("1.5"), got.Values["ratio"]) + + for key, want := range map[string]int64{"uid": 1234567890123456789, "max": math.MaxInt64, "min": math.MinInt64} { + n, ok := got.GetInt64(key) + require.True(t, ok, key) + assert.Equal(t, want, n, key) + } + + nested, ok := got.Values["nested"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{json.Number("9007199254740993")}, nested["ids"], "2^53+1 survives at any depth") + + again := roundTrip(t, mgr, got) + n, ok := again.GetInt64("uid") + require.True(t, ok) + assert.Equal(t, int64(1234567890123456789), n, "a json.Number is written back verbatim") +} + +func TestGetInt64(t *testing.T) { + t.Parallel() + + s := &session.Session{Values: map[string]any{ + "int": 42, + "int8": int8(-1), + "int16": int16(-2), + "int32": int32(8), + "int64": int64(-7), + "uint": uint(11), + "uint8": uint8(3), + "uint16": uint16(4), + "uint32": uint32(9), + "uint64": uint64(10), + "hugeU": uint(math.MaxUint64), + "hugeU64": uint64(math.MaxUint64), + "number": json.Number("123"), + "float": json.Number("1.5"), + "overflow": json.Number("9223372036854775808"), + "string": "123", + "float64": 2.0, + }} + + for key, want := range map[string]int64{ + "int": 42, "int8": -1, "int16": -2, "int32": 8, "int64": -7, + "uint": 11, "uint8": 3, "uint16": 4, "uint32": 9, "uint64": 10, "number": 123, + } { + got, ok := s.GetInt64(key) + assert.True(t, ok, key) + assert.Equal(t, want, got, key) + } + + for _, key := range []string{"absent", "hugeU", "hugeU64", "float", "overflow", "string", "float64"} { + _, ok := s.GetInt64(key) + assert.False(t, ok, key) + } + + var empty session.Session + + _, ok := empty.GetInt64("x") + assert.False(t, ok) +} + +// A decoded session always has a Values map the application can write to. +func TestDecodedSessionHasValues(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, nil) + require.NoError(t, err) + + got, err := mgr.Get(context.Background(), c.replay()) + require.NoError(t, err) + require.NotNil(t, got.Values) + + got.Values["k"] = "v" // no panic +} + +// A value encoding/json cannot serialize (a channel, a function) fails the +// write: nothing is staged. +func TestTouchReportsAnUnserializableValue(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice"}) + require.NoError(t, err) + + s.Values["bad"] = make(chan int) + + c := newCarrier() + require.Error(t, mgr.Touch(context.Background(), c, s)) + assert.Empty(t, c.setCookies) +} From 9fc2b98bb6731a2d904789d05526d32e0c70e4b2 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:24:43 +0200 Subject: [PATCH 05/17] fix(session): validate cookie attributes and default to a __Host- cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session cookie had no prefix, so a sibling host could plant a cookie of the same name that shadowed the user's own (cookie tossing, login CSRF), and misconfigurations produced cookies browsers silently refuse: a __Host- name with Domain and Path was emitted, an invalid name produced an empty Set-Cookie while Login reported success, an oversized session was written and dropped. The default cookie name is now __Host-session — Secure, Path=/, no Domain (draft-ietf-httpbis-rfc6265bis §4.1.3.2) — falling back to __Secure-session with a Domain or another Path and to session with WithSecure(false), whatever the order of the options. NewManager panics on a configuration browsers would refuse or that cannot work: a nil codec, a name that is not an RFC 6265 token, a relative, invalid or over-long path or domain, __Host-/__Secure- rules broken by the attributes (prefixes matched case-insensitively, §5.4), SameSite=None without Secure, a TTL that is not positive, a negative idle timeout. Login, Touch and Rotate refuse a cookie whose name and value exceed 4096 octets (§5.6) with ErrCookieTooLarge and write nothing. When a request carries several cookies of the session's name, only those that open and are live count: two valid sessions make the request ambiguous and it has none — a tossed cookie can deny a session, never swap it — and a validator that cannot check one of them ends the search with 503. Documentation: the store is AES-256-GCM (an AEAD), not "AES-GCM with HMAC"; the CSRF token is rendered into pages, so HttpOnly does not keep it from an XSS; VerifyCSRF must be called on every state-changing request (sign-in included) behind net/http's CrossOriginProtection; one key seals at most 2^32 cookies (NIST SP 800-38D §8.3). The opt-in SAML browser suites assert the new cookie name (make e2e-saml: 16/16 scenarios pass). Audit: CORE-I8. --- CHANGELOG.md | 24 ++ LIMITATIONS.md | 7 + MIGRATION.md | 33 +++ docs/security-considerations.md | 45 +++- e2e/saml/be1_local_logout.tales | 2 +- e2e/saml/br1_cross_site_login.tales | 2 +- examples/session-web/main_test.go | 2 +- session/csrf.go | 19 +- session/doc.go | 10 + session/hygiene_test.go | 347 ++++++++++++++++++++++++++++ session/manager.go | 188 +++++++++++++-- session/testing_helpers_test.go | 13 +- 12 files changed, 656 insertions(+), 36 deletions(-) create mode 100644 session/hygiene_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d62c0a..1bc46708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -499,6 +499,8 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - `session`: `MinKeySize`, `MaxCookieSize`, `WithPurpose` (binds the cookies of a Manager to a purpose), `WithValidator` (server-side revocation hook) and `Session.GetInt64`. +- `session.ErrCookieTooLarge`: a session cookie that would exceed 4096 + bytes is refused instead of being silently dropped by the browser. ### Changed @@ -810,6 +812,14 @@ legacy packages (`authentication/`, `authorization/`, the in-tree `json.Number`. The session `Extractor` reports a validator error wrapping `security.ErrUnavailable` instead of finding no session. See [MIGRATION.md](MIGRATION.md). +- The session cookie is named `__Host-session` by default (`__Secure-session` + with a `Domain` or another `Path`, `session` with `WithSecure(false)`), and + `session.NewManager` panics on a configuration browsers would refuse or + that cannot work: a nil codec, an invalid cookie name, path or domain, a + `__Host-`/`__Secure-` name whose rules the attributes break, `SameSite=None` + without `Secure`, a TTL that is not positive, a negative idle timeout. A + request carrying more than one valid session cookie of the session's name + has no session. See [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1016,6 +1026,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree Password Storage Cheat Sheet minimum, not RFC 9106 §4's recommendation; `ErrMismatch` is never returned by the shipped hashers (a mismatch is `(false, nil)`). +- `session` documentation: the store is AES-256-GCM (an AEAD), not "AES-GCM + with HMAC"; the CSRF token is rendered into pages, so `HttpOnly` does not + keep it out of reach of an XSS; `VerifyCSRF` must be called on every + state-changing request (sign-in included) — guidance added, with the + 2^32-seal limit of a session key. - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It @@ -1731,3 +1746,12 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - Session values decoded numbers as float64: 1234567890123456789 came back as 1234567890123456768, mapping a session to another account. Numbers now decode as `json.Number`. +- Session cookie hygiene: the session cookie had no `__Host-` prefix, so a + sibling host could plant a cookie of the same name that shadowed the + user's own (cookie tossing, login CSRF); a `__Host-` name with `Domain` + and `Path` was emitted although browsers reject it; an oversized session + was emitted and silently dropped by browsers; an invalid cookie name + produced an empty `Set-Cookie` while `Login` reported success. The + default name is now `__Host-session`, the configuration is checked at + construction, a request with two valid session cookies has none, and an + oversized cookie is an error. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 1efc6fa2..c4309bbd 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -499,6 +499,13 @@ PKCE. Not supported, and refused where a provider could select them: store of the application's (no Redis/SQL store is shipped); otherwise a copy of the cookie stays valid until its deadline, `Logout` deleting the browser's copy only. +- With a cookie name that is not `__Host-` (a `Domain`, another `Path`, + plain HTTP), a sibling host can plant a cookie of the session's name: the + request then carries two valid sessions and has none until that cookie + expires (denial, never a swap). The default `__Host-session` prevents it. +- One active session key seals at most 2^32 cookies (random GCM nonces, + NIST SP 800-38D §8.3): the library does not count seals across + instances — rotate the key by volume. - The session cookie is read through `httpsec.Carrier` (its `CookiesNamed` accessor). The `grpcsec` and `connectrpcsec` carriers expose no cookies: a cookie session does not authenticate gRPC or ConnectRPC diff --git a/MIGRATION.md b/MIGRATION.md index f56b846e..1d98936c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2168,3 +2168,36 @@ To migrate: deny-list written at logout, or a per-user "valid since" time); wrap `security.ErrUnavailable` when its store is down (503, nobody is signed out). + +### `session`: the cookie is `__Host-session` by default and its configuration is checked + +The session cookie had no prefix, so a sibling host could plant a cookie of +the same name that shadowed the user's own; misconfigurations (a `__Host-` +name with a `Domain`, an invalid name, an oversized cookie) produced cookies +that browsers silently refused. + +| | Before | After | +| --- | --- | --- | +| Default cookie name | `session` | `__Host-session`; `__Secure-session` with a `Domain` or a `Path` other than `/`; `session` with `WithSecure(false)` | +| Invalid cookie name, relative or invalid path, invalid domain | empty or truncated `Set-Cookie`, `Login` succeeds | `NewManager` panics | +| `__Host-` name with `Domain`, another `Path` or without `Secure`; `__Secure-` name without `Secure` (any case) | emitted, refused by browsers | `NewManager` panics | +| `SameSite=None` without `Secure` | emitted, dropped by browsers | `NewManager` panics | +| `WithTTL(d ≤ 0)`, `WithIdleTimeout(d < 0)`, nil codec | accepted | `NewManager` panics | +| Cookie name + value over 4096 bytes | emitted, dropped by browsers | `ErrCookieTooLarge`, nothing written | +| Several cookies of the session's name | the first one | the only valid one; none when two or more are valid sessions | + +To migrate: + +1. Nothing to do for the default configuration: the new name takes effect + with the cookie format change (users sign in again anyway). Update + whatever reads the cookie by name (tests, proxies, monitoring) to + `__Host-session`. +2. Sharing a session across subdomains (`WithDomain`) or under a path now + gets `__Secure-session`; plain-HTTP development (`WithSecure(false)`) + gets `session`. An explicit name keeps working if its prefix rules hold. +3. Keep `Values` small: a write that would exceed 4096 bytes now returns + `ErrCookieTooLarge`. +4. Enforce CSRF on every state-changing request — see the `VerifyCSRF` + documentation and the session-web example (sign-in form protected + through a session opened for the form, `net/http`'s + `CrossOriginProtection` in front of the handlers). diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 2c06bd11..62a6a1b3 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1170,8 +1170,22 @@ session store to compromise or scale. lookups. A validator error wrapping `security.ErrUnavailable` answers 503, so an outage of that store never signs everyone out. - **Cookie attributes** — defaults are conservative: `Secure=true`, - `HttpOnly=true`, `SameSite=Lax`. Disable `Secure` only for local plain-HTTP - development. + `HttpOnly=true`, `SameSite=Lax`, and the name `__Host-session`: a + `__Host-` cookie is Secure, has `Path=/` and no `Domain`, so a sibling + host cannot plant a cookie that shadows it (cookie tossing, + draft-ietf-httpbis-rfc6265bis §4.1.3.2). When the configuration cannot + satisfy that prefix the default name falls back to `__Secure-session` + (a `Domain` or another `Path`) or `session` (`WithSecure(false)`, local + plain-HTTP development only). `NewManager` refuses at start-up a + configuration browsers would refuse: an invalid name, path or domain, a + `__Host-`/`__Secure-` name whose rules the attributes break (prefixes + matched case-insensitively, like browsers do), `SameSite=None` without + `Secure`. +- **Shadowing cookies** — when a request carries several cookies of the + session's name and more than one is a valid session, it has no session: + a tossed cookie can deny a session (until it expires), never swap it. + Expired or undecodable ones do not count. With the default `__Host-` name + no other host can toss one at all. - **Cookie only** — the `Manager` reads the session from the request's `Cookie` header field, through `httpsec.Carrier.CookiesNamed`, never from the URL nor from a header named like the cookie: a link carrying a session @@ -1185,10 +1199,23 @@ session store to compromise or scale. `Session.GetInt64`): a 64-bit ID never loses precision through a float64. Treat a value that is present but unreadable as a refusal, not as absent. - **CSRF** — the synchronizer-token helper (`CSRFToken` / `VerifyCSRF`) - compares tokens in constant time. The session cookie being `HttpOnly` - keeps the token out of reach of XSS. -- **Size** — the whole session is JSON-encoded into the cookie; browsers - cap a cookie near 4 KiB. Keep `Values` small. + compares tokens in constant time, but it is a check, not a policy: call + `VerifyCSRF` on every state-changing request a cookie session + authenticates — the sign-in form included, through a session opened for + the form (`Login` with a nil principal), against login CSRF — never change + state on GET, and put `net/http`'s `CrossOriginProtection` (Sec-Fetch-Site + and Origin checks) in front of the handlers. `SameSite=Lax` does not stop + same-site requests from a sibling subdomain. The cookie is `HttpOnly`, but + the token is rendered into the page: an XSS reads it — no CSRF defense + survives XSS. +- **Size** — the whole session is JSON-encoded into the cookie, and browsers + ignore a cookie whose name and value exceed 4096 bytes (rfc6265bis §5.6): + `Login`, `Touch` and `Rotate` refuse it with `ErrCookieTooLarge` instead + of losing the session silently. Keep `Values` small. +- **Key usage** — nonces are random (96 bits), and NIST SP 800-38D §8.3 + limits one key to 2^32 seals: every `Login`, `Touch` and `Rotate` is one. + Rotate the active key well before that volume (about 50 days at 1 000 + cookie writes per second), and do not `Touch` on every request. ## OpenID Connect client @@ -1643,7 +1670,11 @@ span catalog. - [ ] Serve over HTTPS so `Secure` cookies and bearer tokens are protected. - [ ] Generate session keys of 32 bytes or more from a cryptographic source and keep them in a secret store; keep the previous key after the new - one while you rotate, so rotation does not sign everyone out. + one while you rotate, so rotation does not sign everyone out; rotate + the active key before it seals 2^32 cookies. +- [ ] Sessions: keep the default `__Host-session` cookie name; call + `VerifyCSRF` on every state-changing request (sign-in form included) + behind `net/http`'s `CrossOriginProtection`. - [ ] SAML: import IdP metadata through a trusted channel, or fetch it from the IdP's https metadata URL with a `MetadataSource` pinned to its entity ID; never take a metadata URL from a request. Widen the diff --git a/e2e/saml/be1_local_logout.tales b/e2e/saml/be1_local_logout.tales index 7bcbe57b..da30695a 100644 --- a/e2e/saml/be1_local_logout.tales +++ b/e2e/saml/be1_local_logout.tales @@ -97,7 +97,7 @@ scenario "BE1 local logout is not Single Logout" { credential_posts = 1 logout = { set_cookies = { - session = { + "__Host-session" = { cleared = true } } diff --git a/e2e/saml/br1_cross_site_login.tales b/e2e/saml/br1_cross_site_login.tales index eaa8b63c..47a36c6e 100644 --- a/e2e/saml/br1_cross_site_login.tales +++ b/e2e/saml/br1_cross_site_login.tales @@ -144,7 +144,7 @@ scenario "BR1 cross-site POST to the ACS carries the binding cookie" { path = "/" sec_fetch_site = "cross-site" cookies = { - session = true + "__Host-session" = true "__Host-acs-lax" = true "__Host-acs-strict" = not_exists() } diff --git a/examples/session-web/main_test.go b/examples/session-web/main_test.go index 82cc6de1..72d5de12 100644 --- a/examples/session-web/main_test.go +++ b/examples/session-web/main_test.go @@ -112,7 +112,7 @@ func TestSessionWebTamperedCookieIsDropped(t *testing.T) { req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) require.NoError(t, err) - req.Header.Set("Cookie", "session="+strings.Repeat("A", 80)) + req.Header.Set("Cookie", "__Host-session="+strings.Repeat("A", 80)) // the default session cookie name client := srv.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { diff --git a/session/csrf.go b/session/csrf.go index 60411f2d..122c608a 100644 --- a/session/csrf.go +++ b/session/csrf.go @@ -8,9 +8,10 @@ import "crypto/subtle" // CSRFToken returns the per-session CSRF token. The application embeds it // into rendered forms (a hidden field) or a tag so the browser can -// echo it back on state-changing requests. The token lives inside the -// encrypted, HttpOnly session cookie, so it is never directly readable by -// page JavaScript — only the server, which decrypts the cookie, knows it. +// echo it back on state-changing requests. Its canonical copy lives in the +// encrypted, HttpOnly session cookie, which page scripts cannot read — but +// the token itself is rendered into the page, where a script injected into +// it (XSS) can read it: no CSRF defense survives XSS. func CSRFToken(s *Session) string { if s == nil { return "" @@ -21,7 +22,17 @@ func CSRFToken(s *Session) string { // VerifyCSRF reports whether presented matches the session's CSRF token. // The comparison is constant-time to avoid leaking the token through -// response-timing analysis. +// response-timing analysis. An empty token never verifies. +// +// VerifyCSRF is a check, not a policy: nothing enforces it for you. Call it +// on every state-changing request (POST, PUT, PATCH, DELETE) that a cookie +// session authenticates — the sign-in form included, through a session +// opened for the form before sign-in (Manager.Login with a nil principal), +// against login CSRF — never change state on GET, and add a cross-origin +// check in front of the handlers (net/http's CrossOriginProtection, which +// refuses cross-origin unsafe requests from their Sec-Fetch-Site and Origin +// headers). SameSite=Lax keeps the cookie off cross-site POSTs in current +// browsers but not off same-site requests from a sibling subdomain. // // This is the synchronizer-token pattern: the server holds the canonical // token in the (encrypted) session and checks the value the client echoed diff --git a/session/doc.go b/session/doc.go index 77afef38..a3cf6780 100644 --- a/session/doc.go +++ b/session/doc.go @@ -14,6 +14,16 @@ // key rotation with a multi-key reader and a single active writer (see // Codec for the cookie format). // +// The cookie is named __Host-session by default — Secure, Path=/ and no +// Domain, so no other host can plant a cookie that shadows it — and the +// Manager refuses at construction a cookie configuration a browser would +// refuse; a cookie that would exceed 4096 bytes is refused with +// ErrCookieTooLarge rather than silently dropped by the browser. +// +// CSRF: VerifyCSRF checks a synchronizer token; the application must call it +// on every state-changing request (the sign-in form included) and should +// put net/http's CrossOriginProtection in front of its handlers. +// // A session lives at most its TTL from its creation: the Manager enforces // CreatedAt + TTL on every read, and neither Rotate nor Touch extends it. // Numbers in Values come back as json.Number (Session.GetInt64), never as a diff --git a/session/hygiene_test.go b/session/hygiene_test.go new file mode 100644 index 00000000..11968e9d --- /dev/null +++ b/session/hygiene_test.go @@ -0,0 +1,347 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I8 and decision Q1: the default cookie name is __Host-session +// — Secure, Path=/, no Domain, so a sibling host cannot plant a cookie +// that shadows it (draft-ietf-httpbis-rfc6265bis §4.1.3.2) — and falls back +// to __Secure-session or session when the configuration cannot satisfy the +// prefix. It is computed after every option, whatever their order. +func TestDefaultCookieName(t *testing.T) { + t.Parallel() + + codec, err := session.NewCodec(testKey) + require.NoError(t, err) + + for want, opts := range map[string][][]session.Option{ + "__Host-session": {nil, {session.WithPath("/")}, {session.WithSecure(true)}}, + "__Secure-session": { + {session.WithDomain("example.com")}, + {session.WithPath("/app")}, + {session.WithDomain("example.com"), session.WithPath("/app")}, + }, + "session": { + {session.WithSecure(false)}, + {session.WithSecure(false), session.WithDomain("example.com")}, + }, + } { + for _, o := range opts { + assert.Equal(t, want, session.NewManager(codec, o...).CookieName(), "%d options", len(o)) + + reversed := make([]session.Option, len(o)) + for i := range o { + reversed[i] = o[len(o)-1-i] + } + + assert.Equal(t, want, session.NewManager(codec, reversed...).CookieName(), "options reversed") + } + } + + c := newCarrier() + _, err = session.NewManager(codec).Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + cookie := lastCookie(t, c) + assert.Equal(t, "__Host-session", cookie.Name) + assert.Equal(t, "/", cookie.Path) + assert.Empty(t, cookie.Domain) + assert.True(t, cookie.Secure) + assert.True(t, cookie.HttpOnly) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +// Audit CORE-I8 (probe TestZZAuditSessionCookieAttributes): a __Host- name +// with Domain and Path was emitted although browsers reject it, and an +// invalid name produced an empty Set-Cookie while Login reported success. +// The configuration is now checked at construction (prefixes matched +// case-insensitively, like user agents do — rfc6265bis §5.4). +func TestNewManagerRefusesInvalidCookieConfiguration(t *testing.T) { + t.Parallel() + + codec, err := session.NewCodec(testKey) + require.NoError(t, err) + + cases := map[string]struct { + codec *session.Codec + opts []session.Option + msg string + }{ + "nil codec": {nil, nil, "nil Codec"}, + "name with a space": {codec, []session.Option{session.WithCookieName("my session")}, "cookie name"}, + "empty name": {codec, []session.Option{session.WithCookieName("")}, "cookie name"}, + "__Host- with a Domain": {codec, []session.Option{session.WithCookieName("__Host-sid"), session.WithDomain("example.com")}, "__Host-"}, + "__Host- with another Path": {codec, []session.Option{session.WithCookieName("__Host-sid"), session.WithPath("/app")}, "__Host-"}, + "__Host- without Secure": {codec, []session.Option{session.WithCookieName("__host-sid"), session.WithSecure(false)}, "__Host-"}, + "__Secure- without Secure": {codec, []session.Option{session.WithCookieName("__SECURE-sid"), session.WithSecure(false)}, "__Secure-"}, + "relative path": {codec, []session.Option{session.WithPath("app")}, "path"}, + "path with a semicolon": {codec, []session.Option{session.WithPath("/a;b")}, "path"}, + "over-long path": {codec, []session.Option{session.WithPath("/" + strings.Repeat("p", 1024))}, "path"}, + "invalid domain": {codec, []session.Option{session.WithDomain("exa mple.com")}, "domain"}, + "SameSite=None without Secure": {codec, []session.Option{session.WithSameSite(http.SameSiteNoneMode), session.WithSecure(false)}, "SameSite=None"}, + "zero TTL": {codec, []session.Option{session.WithTTL(0)}, "TTL"}, + "negative TTL": {codec, []session.Option{session.WithTTL(-time.Hour)}, "TTL"}, + "negative idle timeout": {codec, []session.Option{session.WithIdleTimeout(-time.Minute)}, "idle"}, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + defer func() { + r := recover() + require.NotNil(t, r, "NewManager must panic") + assert.Contains(t, r, c.msg) + }() + + session.NewManager(c.codec, c.opts...) + }) + } + + for name, opts := range map[string][]session.Option{ + "explicit __Host-": {session.WithCookieName("__Host-sid")}, + "explicit __Secure- with Domain": {session.WithCookieName("__Secure-sid"), session.WithDomain("example.com")}, + "plain name over plain HTTP": {session.WithCookieName("sid"), session.WithSecure(false)}, + "SameSite=None with Secure": {session.WithSameSite(http.SameSiteNoneMode)}, + "strict": {session.WithSameSite(http.SameSiteStrictMode)}, + } { + assert.NotPanics(t, func() { session.NewManager(codec, opts...) }, name) + } +} + +// Audit CORE-I8: an oversized session (8 500 bytes) was emitted silently and +// dropped by browsers (name + value over 4096 octets, rfc6265bis §5.6). The +// write now fails with ErrCookieTooLarge and nothing is staged. +func TestManagerRefusesOversizedCookies(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice"}) + require.NoError(t, err) + + fits := func(blob int) error { + s.Values["blob"] = strings.Repeat("x", blob) + + c := newCarrier() + err := mgr.Touch(context.Background(), c, s) + + if err != nil { + assert.Empty(t, c.setCookies, "nothing staged on failure") + + return err + } + + cookie := lastCookie(t, c) + assert.LessOrEqual(t, len(cookie.Name)+len(cookie.Value), session.MaxCookieSize) + + return nil + } + + require.ErrorIs(t, fits(6000), session.ErrCookieTooLarge) + + // Find the boundary: the largest blob that fits gives a cookie of at + // most 4096 octets, one more byte does not fit. + lo, hi := 0, 6000 + for lo < hi { + mid := (lo + hi + 1) / 2 + if fits(mid) == nil { + lo = mid + } else { + hi = mid - 1 + } + } + + require.NoError(t, fits(lo)) + require.ErrorIs(t, fits(lo+3), session.ErrCookieTooLarge, "3 more bytes of plaintext always grow the base64 value") + + // The limit is inclusive: some blob gives exactly 4096 octets of name + // and value, and that cookie is written. + exact := false + + for blob := lo - 8; blob <= lo; blob++ { + s.Values["blob"] = strings.Repeat("x", blob) + + c := newCarrier() + if mgr.Touch(context.Background(), c, s) == nil { + cookie := lastCookie(t, c) + exact = exact || len(cookie.Name)+len(cookie.Value) == session.MaxCookieSize + } + } + + assert.True(t, exact, "a cookie of exactly 4096 octets is accepted") + + _, err = mgr.Login(context.Background(), newCarrier(), principal{sub: strings.Repeat("s", 5000)}) + require.ErrorIs(t, err, session.ErrCookieTooLarge, "Login too") +} + +// Decision Q1 and review nit 12: several cookies with the session's name — a +// cookie tossed by a sibling host or a more specific path, or a stale one — +// make the request ambiguous when more than one is a valid session: the +// request has no session (fail closed: a tossed cookie can deny a session, +// never swap it). Invalid or expired ones do not count. +func TestManagerRefusesAmbiguousSessionCookies(t *testing.T) { + t.Parallel() + + now := epoch + mgr := clockedManager(t, &now, session.WithTTL(time.Hour)) + name := mgr.CookieName() + + valid := func(sub string) string { + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: sub}) + require.NoError(t, err) + + return c.replay().cookies[name] + } + + victim, attacker := valid("victim"), valid("attacker") + + now = epoch.Add(-2 * time.Hour) + stale := valid("stale") // expired at epoch-1h + now = epoch + + request := func(values ...string) (*session.Session, error) { + c := newCarrier() + c.cookies[name] = values[0] + c.more = map[string][]string{name: values[1:]} + + return mgr.Get(context.Background(), c) + } + + _, err := request(attacker, victim) + require.ErrorIs(t, err, session.ErrNoSession, "two valid sessions: ambiguous") + + _, err = request(victim, attacker) + require.ErrorIs(t, err, session.ErrNoSession, "in either order") + + for name, values := range map[string][]string{ + "garbage first": {"garbage", victim}, + "garbage last": {victim, "garbage"}, + "expired first": {stale, victim}, + "another key's first": {v2Cookie(t, randomKey(t), 2, 2, name, "", samplePayload(t, now)), victim}, + } { + s, err := request(values...) + require.NoError(t, err, name) + assert.Equal(t, "victim", s.GetString("sub"), name) + } + + _, err = request(stale) + require.ErrorIs(t, err, security.ErrTokenExpired, "a single expired session still reports its expiry") + + _, err = request("") + require.ErrorIs(t, err, session.ErrNoSession, "an empty cookie is no session") + + _, err = request("garbage", "garbage", "garbage", "garbage", victim) + require.ErrorIs(t, err, session.ErrNoSession, "more than four cookies of one name are not inspected") +} + +// Audit §9 surviving mutant: the deletion cookie's Max-Age was never +// checked (the test passed because the value was empty). It must carry +// Max-Age=0 and the same Path, Domain and attributes as the session cookie, +// or the browser keeps the session cookie (and rejects a __Host- deletion). +func TestLogoutDeletesTheCookie(t *testing.T) { + t.Parallel() + + codec, err := session.NewCodec(testKey) + require.NoError(t, err) + + for _, mgr := range []*session.Manager{ + session.NewManager(codec), + session.NewManager(codec, session.WithDomain("example.com"), session.WithPath("/app"), session.WithSameSite(http.SameSiteStrictMode)), + } { + login := newCarrier() + _, err := mgr.Login(context.Background(), login, principal{sub: "alice"}) + require.NoError(t, err) + + issued := lastCookie(t, login) + + logout := login.replay() + mgr.Logout(context.Background(), logout) + + require.Len(t, logout.setCookies, 1) + assert.Contains(t, logout.setCookies[0], "Max-Age=0") + + deletion := lastCookie(t, logout) + assert.Equal(t, issued.Name, deletion.Name) + assert.Empty(t, deletion.Value) + assert.Negative(t, deletion.MaxAge) + assert.Equal(t, issued.Path, deletion.Path) + assert.Equal(t, issued.Domain, deletion.Domain) + assert.Equal(t, issued.Secure, deletion.Secure) + assert.Equal(t, issued.HttpOnly, deletion.HttpOnly) + assert.Equal(t, issued.SameSite, deletion.SameSite) + } +} + +// Audit §9 surviving mutants: VerifyCSRF reduced to the first byte, and its +// empty-token guard removed. +func TestVerifyCSRFComparesTheWholeToken(t *testing.T) { + t.Parallel() + + s := &session.Session{CSRFToken: "abcdefghijklmnop"} + + assert.True(t, session.VerifyCSRF(s, "abcdefghijklmnop")) + assert.False(t, session.VerifyCSRF(s, "abcdefghijklmnoq"), "last character differs") + assert.False(t, session.VerifyCSRF(s, "abcdefghijklmno"), "prefix") + assert.False(t, session.VerifyCSRF(s, "a"), "first character only") +} + +func TestVerifyCSRFRefusesEmptyTokens(t *testing.T) { + t.Parallel() + + assert.False(t, session.VerifyCSRF(&session.Session{}, ""), "no token minted, none presented") + assert.False(t, session.VerifyCSRF(&session.Session{}, "x")) + assert.False(t, session.VerifyCSRF(&session.Session{CSRFToken: "x"}, "")) +} + +// A validator that cannot check one of the cookies ends the search: the +// request might be ambiguous, so it is answered as unavailable (503), not +// with the other session. +func TestAmbiguityCheckStopsAtAnUnavailableValidator(t *testing.T) { + t.Parallel() + + var down string + + mgr := newManager(t, session.WithValidator(func(_ context.Context, s *session.Session) error { + if s.ID == down { + return security.ErrUnavailable + } + + return nil + })) + name := mgr.CookieName() + + login := func(sub string) (string, string) { + c := newCarrier() + s, err := mgr.Login(context.Background(), c, principal{sub: sub}) + require.NoError(t, err) + + return s.ID, c.replay().cookies[name] + } + + unchecked, first := login("alice") + _, second := login("bob") + down = unchecked + + c := newCarrier() + c.cookies[name] = first + c.more = map[string][]string{name: {second}} + + _, err := mgr.Get(context.Background(), c) + require.ErrorIs(t, err, security.ErrUnavailable) +} diff --git a/session/manager.go b/session/manager.go index 3f0ab3df..576e31c5 100644 --- a/session/manager.go +++ b/session/manager.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "net/http" + "strings" "time" "github.com/hyperscale-stack/security" @@ -25,6 +26,12 @@ const tracerName = "github.com/hyperscale-stack/security/session" // session cookie (or one that fails to decode). var ErrNoSession = errors.New("session: no session on request") +// ErrCookieTooLarge is returned by [Manager.Login], [Manager.Touch] and +// [Manager.Rotate] when the session cookie would exceed [MaxCookieSize]: +// user agents ignore such a cookie, so the session would be lost silently. +// Nothing is written. Keep Values small. +var ErrCookieTooLarge = errors.New("session: cookie larger than 4096 bytes") + // Manager owns the session lifecycle on top of a cookie. It writes the // cookie through a [security.Carrier] and reads it through the carrier's // [CookieReader] accessor, so it works behind the HTTP adapter (httpsec) @@ -44,6 +51,7 @@ type Manager struct { clock func() time.Time purpose string validator func(ctx context.Context, s *Session) error + nameSet bool } // MaxCookieSize bounds a session cookie: the name and value of a cookie @@ -52,18 +60,33 @@ type Manager struct { const MaxCookieSize = 4096 // maxPurposeLength and maxCookieNameLength bound what the AEAD associated -// data length-prefixes. +// data length-prefixes; maxAttributeLength is the longest Path or Domain a +// user agent keeps (rfc6265bis §5.6); maxSessionCookies bounds the cookies +// of the session's name one request may make the Manager open. const ( maxPurposeLength = 255 maxCookieNameLength = 256 + maxAttributeLength = 1024 + maxSessionCookies = 4 ) // Option configures a [Manager]. type Option func(*Manager) -// WithCookieName overrides the cookie name. Default: "session". +// WithCookieName overrides the cookie name. Default: "__Host-session" when +// the cookie is Secure with Path=/ and no Domain — a sibling host cannot +// then plant a cookie that shadows it (draft-ietf-httpbis-rfc6265bis +// §4.1.3.2) — else "__Secure-session" when it is Secure, else "session". +// An explicit name must be an RFC 6265 token of at most 256 bytes whose +// prefix rules the configuration satisfies: "__Host-" requires Secure, +// Path=/ and no Domain, "__Secure-" requires Secure (prefixes are matched +// case-insensitively, like user agents do). One cookie name per Manager per +// host: __Host- cookies ignore the port. func WithCookieName(name string) Option { - return func(m *Manager) { m.cookieName = name } + return func(m *Manager) { + m.cookieName = name + m.nameSet = true + } } // WithPath overrides the cookie Path attribute. Default: "/". @@ -147,30 +170,99 @@ func WithValidator(validate func(ctx context.Context, s *Session) error) Option return func(m *Manager) { m.validator = validate } } -// NewManager builds a [Manager] sealing sessions with codec. +// NewManager builds a [Manager] sealing sessions with codec. It panics on a +// configuration a user agent would refuse or that cannot work — a nil +// codec, an invalid cookie name, path or domain, a cookie prefix whose rules +// the attributes break, SameSite=None without Secure (user agents drop it), +// a TTL that is not positive, a negative idle timeout: it is static +// configuration, refused at start-up rather than as silently lost sessions. func NewManager(codec *Codec, opts ...Option) *Manager { m := &Manager{ - codec: codec, - cookieName: schemeName, - path: "/", - secure: true, - httpOnly: true, - sameSite: http.SameSiteLaxMode, - ttl: 24 * time.Hour, - clock: time.Now, + codec: codec, + path: "/", + secure: true, + httpOnly: true, + sameSite: http.SameSiteLaxMode, + ttl: 24 * time.Hour, + clock: time.Now, } for _, o := range opts { o(m) } - if len(m.cookieName) > maxCookieNameLength { - panic(fmt.Sprintf("session: NewManager: cookie name of %d bytes, at most %d", len(m.cookieName), maxCookieNameLength)) + if !m.nameSet { + m.cookieName = m.defaultCookieName() + } + + if err := m.check(); err != nil { + panic("session: NewManager: " + err.Error()) } return m } +// defaultCookieName is the strongest prefixed name the configuration +// allows. +func (m *Manager) defaultCookieName() string { + switch { + case m.secure && m.path == "/" && m.domain == "": + return "__Host-" + schemeName + case m.secure: + return "__Secure-" + schemeName + default: + return schemeName + } +} + +// check refuses a configuration that cannot work. +func (m *Manager) check() error { + switch { + case m.codec == nil: + return errors.New("nil Codec") + case m.ttl <= 0: + return fmt.Errorf("TTL %s: it must be positive", m.ttl) + case m.idleTimeout < 0: + return fmt.Errorf("idle timeout %s: it must not be negative", m.idleTimeout) + } + + return m.checkCookie() +} + +// checkCookie refuses cookie attributes a user agent would refuse or drop. +func (m *Manager) checkCookie() error { + name := strings.ToLower(m.cookieName) + + switch { + case probe(m.cookieName, "", "").Valid() != nil: + return fmt.Errorf("invalid cookie name %q: an RFC 6265 token is required", m.cookieName) + case len(m.cookieName) > maxCookieNameLength: + return fmt.Errorf("cookie name of %d bytes, at most %d", len(m.cookieName), maxCookieNameLength) + case !strings.HasPrefix(m.path, "/") || len(m.path) > maxAttributeLength || + probe("n", m.path, "").Valid() != nil: + return fmt.Errorf("invalid cookie path %q: an absolute path of at most %d bytes is required", m.path, maxAttributeLength) + case m.domain != "" && (len(m.domain) > maxAttributeLength || probe("n", "", m.domain).Valid() != nil): + return fmt.Errorf("invalid cookie domain %q", m.domain) + case strings.HasPrefix(name, "__host-") && (!m.secure || m.path != "/" || m.domain != ""): + return fmt.Errorf("a __Host- cookie (%q) requires Secure, Path=/ and no Domain", m.cookieName) + case strings.HasPrefix(name, "__secure-") && !m.secure: + return fmt.Errorf("a __Secure- cookie (%q) requires Secure", m.cookieName) + case m.sameSite == http.SameSiteNoneMode && !m.secure: + return errors.New("SameSite=None requires Secure: user agents drop the cookie otherwise") + } + + return nil +} + +// probe is a cookie whose name, path or domain http.Cookie.Valid checks +// (RFC 6265 §4.1.1 token, path and domain syntax). +func probe(name, path, domain string) *http.Cookie { + return &http.Cookie{ + Name: name, Value: "v", Path: path, Domain: domain, + Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, + } +} + // Login mints a fresh authenticated session for principal, stores its // subject under the "sub" value, and writes the session cookie via the // carrier. Any prior session is replaced (a fresh ID defeats fixation). @@ -218,12 +310,67 @@ func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) return nil, fmt.Errorf("%w: the carrier exposes no cookies", ErrNoSession) } - found := cookies.CookiesNamed(m.cookieName) - if len(found) == 0 || found[0].Value == "" { + s, err := m.pick(ctx, cookies.CookiesNamed(m.cookieName)) + if err != nil { + return nil, err + } + + s.LastAccessed = m.clock() + span.SetAttributes(attribute.String("session.id_hash", hashID(s.ID))) + + return s, nil +} + +// pick returns the one valid session among the cookies of the session's +// name. Several cookies of one name come from a cookie tossed by a sibling +// host or a more specific path, or from a stale configuration: when more +// than one is a valid session the request is ambiguous and has none — a +// tossed cookie can deny a session, never swap it. Cookies that do not open +// or are expired do not count. An unavailable validator ends the search. +func (m *Manager) pick(ctx context.Context, found []*http.Cookie) (*Session, error) { + if len(found) == 0 { + return nil, ErrNoSession + } + + if len(found) > maxSessionCookies { + return nil, fmt.Errorf("%w: %d cookies of the session's name", ErrNoSession, len(found)) + } + + var ( + chosen *Session + firstErr error + ) + + for _, cookie := range found { + s, err := m.load(ctx, cookie.Value) + + switch { + case err == nil && chosen != nil: + return nil, fmt.Errorf("%w: several valid session cookies", ErrNoSession) + case err == nil: + chosen = s + case errors.Is(err, security.ErrUnavailable): + return nil, err + case firstErr == nil: + firstErr = err + } + } + + if chosen == nil { + return nil, firstErr + } + + return chosen, nil +} + +// load opens one cookie value and checks the session's deadline, idle +// timeout and validator. +func (m *Manager) load(ctx context.Context, value string) (*Session, error) { + if value == "" { return nil, ErrNoSession } - s, err := m.codec.open(found[0].Value, m.cookieName, m.purpose) + s, err := m.codec.open(value, m.cookieName, m.purpose) if err != nil { return nil, fmt.Errorf("%w: %w", ErrNoSession, err) } @@ -237,9 +384,6 @@ func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) return nil, err } - s.LastAccessed = now - span.SetAttributes(attribute.String("session.id_hash", hashID(s.ID))) - return s, nil } @@ -371,6 +515,10 @@ func (m *Manager) writeCookie(c security.Carrier, s *Session) error { return fmt.Errorf("session: encode: %w", err) } + if size := len(m.cookieName) + len(value); size > MaxCookieSize { + return fmt.Errorf("%w: %d bytes", ErrCookieTooLarge, size) + } + //nolint:gosec // G124: Secure/HttpOnly/SameSite come from the Manager config (secure-by-default: Secure, HttpOnly, SameSiteLax) cookie := &http.Cookie{ Name: m.cookieName, diff --git a/session/testing_helpers_test.go b/session/testing_helpers_test.go index f232de7a..ef6a495d 100644 --- a/session/testing_helpers_test.go +++ b/session/testing_helpers_test.go @@ -19,6 +19,9 @@ type mapCarrier struct { headers map[string]string // cookies are the request cookies. cookies map[string]string + // more are further request cookies sharing a name with one of cookies + // (a tossed or stale cookie), sent after it. + more map[string][]string // setCookies are the Set-Cookie headers the Manager staged via Add. setCookies []string } @@ -41,11 +44,17 @@ func (c *mapCarrier) Values(key string) []string { // CookiesNamed implements session.CookieReader. func (c *mapCarrier) CookiesNamed(name string) []*http.Cookie { + var out []*http.Cookie + if v, ok := c.cookies[name]; ok { - return []*http.Cookie{{Name: name, Value: v}} + out = append(out, &http.Cookie{Name: name, Value: v}) } - return nil + for _, v := range c.more[name] { + out = append(out, &http.Cookie{Name: name, Value: v}) + } + + return out } func (c *mapCarrier) Set(key, value string) { From e22e3b8aa727d2d192db02dc1570e1e6e3817f3d Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:30:52 +0200 Subject: [PATCH 06/17] fix(basic,bearer,session): keep credentials out of fmt, slog and JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported fields of a session.Session revealed its CSRF token and raw ID through %+v and encoding/json — log/slog's JSON handler encodes nested values with encoding/json — and basic and bearer authentications printed the password, the bearer token and, once authenticated, the application's user record (its password hash included). session.Session now implements String, Format (every verb), LogValue and a redacting MarshalJSON: the ID as its SHA-256 fingerprint (the session.id_hash span attribute), the keys of its values but not the values (tokens, personal data), the times, and never the CSRF token. The cookie is sealed from an internal payload type, so the redaction applies to printing and encoding only. session.Authentication, basic.Authentication and bearer.Authentication print and log their name — the subject, or the scheme label — their state and authorities only; their JSON was already {} (unexported fields), now pinned by tests. A verified bearer.Authentication kept its token for the rest of the request, contrary to the Token() documentation: WithAuthenticated now drops it, so every TokenVerifier building its result with bearer.New(token).WithAuthenticated — jwtsec.BearerVerifier included — returns a token-free value, and a failed bearer Authenticate returns security.Anonymous() instead of its input. Also fixes a flaky test of the previous commit: the cookie-size boundary depends on the timestamps' RFC 3339 length, so it now runs on a fixed clock. Audit: CORE-15. --- CHANGELOG.md | 17 +++++ LIMITATIONS.md | 28 +++---- MIGRATION.md | 20 +++++ basic/format.go | 41 ++++++++++ basic/redaction_test.go | 75 ++++++++++++++++++ bearer/authentication.go | 17 ++--- bearer/authenticator.go | 9 ++- bearer/format.go | 38 +++++++++ bearer/redaction_test.go | 123 +++++++++++++++++++++++++++++ bearer/verifier.go | 3 + session/format.go | 122 +++++++++++++++++++++++++++++ session/hygiene_test.go | 5 +- session/redaction_test.go | 157 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 627 insertions(+), 28 deletions(-) create mode 100644 basic/format.go create mode 100644 basic/redaction_test.go create mode 100644 bearer/format.go create mode 100644 bearer/redaction_test.go create mode 100644 session/format.go create mode 100644 session/redaction_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc46708..296c413e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -501,6 +501,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree and `Session.GetInt64`. - `session.ErrCookieTooLarge`: a session cookie that would exceed 4096 bytes is refused instead of being silently dropped by the browser. +- `String`, `Format` and `LogValue` on `session.Session` (and a redacting + `MarshalJSON`), `session.Authentication`, `basic.Authentication` and + `bearer.Authentication`. ### Changed @@ -820,6 +823,12 @@ legacy packages (`authentication/`, `authorization/`, the in-tree without `Secure`, a TTL that is not positive, a negative idle timeout. A request carrying more than one valid session cookie of the session's name has no session. See [MIGRATION.md](MIGRATION.md). +- `bearer.Authentication.WithAuthenticated` drops the token: `Token()` is + `""` once authenticated (read the request's `Authorization` field to relay + a token), and a failed `bearer.Authenticator.Authenticate` returns + `security.Anonymous()`. `json.Marshal` of a `session.Session` writes its + ID fingerprint, the keys of its values and its times, never the CSRF + token, the ID or the values. See [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); @@ -1755,3 +1764,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree default name is now `__Host-session`, the configuration is checked at construction, a request with two valid session cookies has none, and an oversized cookie is an error. +- Credentials printed and serialized: the exported fields of a + `session.Session` revealed its CSRF token (and its raw ID) through `%+v` + and `encoding/json` — `slog`'s JSON handler included; `basic` and `bearer` + authentications printed the password, the bearer token and, once + authenticated, the application's user record (password hash included); + and a verified `bearer.Authentication` kept its token for the rest of the + request. They now print, log and encode without secrets, and the bearer + token is dropped once verified. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index c4309bbd..632ea7e4 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -515,8 +515,9 @@ PKCE. Not supported, and refused where a provider could select them: - **What `fmt` cannot redact** — the types that hold secrets (`oidcclient.Config` and `*Client`, `oauth2.DefaultClient`, the `jwtsec` - keys, signers and key sets, `session.Codec`…) print redacted with every - `fmt` verb and in `slog` output — as do the `grpcsec` and + keys, signers and key sets, `session.Codec` and `session.Session`, the + `basic`, `bearer` and `session` authentications…) print redacted with + every `fmt` verb and in `slog` output — as do the `grpcsec` and `connectrpcsec` carriers, which print metadata key counts only — but fmt prints some values without calling any of their methods: `%p` of a value that is not a pointer, a @@ -525,18 +526,17 @@ PKCE. Not supported, and refused where a provider could select them: Log these types directly or through exported fields, with `%v`. - **JSON** — `slog`'s JSON handler calls `LogValue` for attribute values only: a value nested in another one (a struct field, a map) is encoded - with `encoding/json`. `oidcclient.Config`, `AuthRequest` and `Callback` - encode without their secrets, but `oidcclient.Identity` and `Tokens` - encode their tokens — deliberately, so that applications can persist them - — and `UserInfo` its claims: log these as attributes of their own. Other - reflection-based encoders (`encoding/gob`, `encoding/xml`, YAML) write - every exported field, an `AuthRequest`'s binding secret included. -- **Credentials and tokens in flight are not redacted yet** — `basic` and - `bearer` authentications print the password (before authentication) and - the bearer token they carry, and the `oauth2` records - (`AuthorizationCode`, `AccessToken`, `RefreshToken`, the `GrantResponse` - holding them) the raw code and tokens they carry during issuance: never - log them. + with `encoding/json`. `oidcclient.Config`, `AuthRequest` and `Callback`, + and `session.Session`, encode without their secrets, but + `oidcclient.Identity` and `Tokens` encode their tokens — deliberately, so + that applications can persist them — and `UserInfo` its claims: log these + as attributes of their own. Other reflection-based encoders + (`encoding/gob`, `encoding/xml`, YAML) write every exported field, an + `AuthRequest`'s binding secret included. +- **Credentials and tokens in flight are not redacted yet** — the `oauth2` + records (`AuthorizationCode`, `AccessToken`, `RefreshToken`, the + `GrantResponse` holding them) print the raw code and tokens they carry + during issuance: never log them. ## Tooling diff --git a/MIGRATION.md b/MIGRATION.md index 1d98936c..d85b73f4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2201,3 +2201,23 @@ To migrate: documentation and the session-web example (sign-in form protected through a session opened for the form, `net/http`'s `CrossOriginProtection` in front of the handlers). + +### `session`, `basic`, `bearer`: credentials stay out of `fmt`, `slog` and JSON; the bearer token is dropped once verified + +| | Before | After | +| --- | --- | --- | +| `fmt` / `slog` of a `session.Session` | every field, CSRF token and raw ID included | `session.Session{ID: , Values: [keys], CSRFToken: REDACTED, …}` with every verb | +| `json.Marshal` of a `session.Session` | every field | `{"IDHash", "Values": [keys], "CSRFToken": "REDACTED", "CreatedAt", "LastAccessed", "ExpiresAt"}` | +| `fmt` of a `basic.Authentication` | typed user-id, password, user record | name (subject, or `basic`), state, authorities | +| `fmt` of a `bearer.Authentication` | the token | name, state, authorities; `Token: REDACTED` | +| `bearer.Authentication` after `WithAuthenticated` | keeps the token (`Token()`) | `Token()` is `""` | +| Value returned by a failed `bearer.Authenticator.Authenticate` | its input (the token) | `security.Anonymous()` | + +To migrate: + +1. Code that persisted a `session.Session` with `encoding/json` must use a + type of its own (the session lives sealed in its cookie anyway). +2. Code that read `Token()` from an authenticated `bearer.Authentication` to + relay the token must read the request's `Authorization` field. A custom + `TokenVerifier` should build its result with + `bearer.New(token).WithAuthenticated(...)`, which drops the token. diff --git a/basic/format.go b/basic/format.go new file mode 100644 index 00000000..57316c77 --- /dev/null +++ b/basic/format.go @@ -0,0 +1,41 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic + +import ( + "fmt" + "io" + "log/slog" +) + +// String implements fmt.Stringer with the name — the authenticated subject, +// or "basic" — the state and the authorities: never the typed user-id (it +// may be a password typed in the wrong field), the password, nor the +// resolved PasswordUser (the application's user record, its password hash +// included). +func (a Authentication) String() string { + credentials := `""` + if a.password != "" { + credentials = "REDACTED" + } + + return fmt.Sprintf("basic.Authentication{Name: %q, Authenticated: %t, Authorities: %q, Credentials: %s}", + a.Name(), a.authed, a.authorities, credentials) +} + +// Format implements fmt.Formatter like String, for every verb, %#v +// included: without it, fmt prints the unexported fields — the password and +// the user record. fmt handles %p of a non-pointer value before any method: +// no type can redact that one. +func (a Authentication) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, a.String()) } + +// LogValue implements slog.LogValuer like String. +func (a Authentication) LogValue() slog.Value { + return slog.GroupValue( + slog.String("name", a.Name()), + slog.Bool("authenticated", a.authed), + slog.Any("authorities", a.authorities), + ) +} diff --git a/basic/redaction_test.go b/basic/redaction_test.go new file mode 100644 index 00000000..b47c4f70 --- /dev/null +++ b/basic/redaction_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package basic_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "testing" + + "github.com/hyperscale-stack/security/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// renderings prints v every way an application could log it. +func renderings(t *testing.T, v any) []string { + t.Helper() + + var out []string + + nested := struct{ Field any }{v} + + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d", "%t", "%o", "%b", "%e", "%U", "%c"} { + out = append(out, fmt.Sprintf(verb, v), fmt.Sprintf(verb, nested)) + } + + var text, js bytes.Buffer + + slog.New(slog.NewTextHandler(&text, nil)).Info("m", "v", v, "nested", nested) + slog.New(slog.NewJSONHandler(&js, nil)).Info("m", "v", v, "nested", nested) + + out = append(out, text.String(), js.String()) + + for _, x := range []any{v, nested} { + b, err := json.Marshal(x) + require.NoError(t, err) + + out = append(out, string(b)) + } + + return out +} + +// Audit CORE-15 (probe TestZZAuditBasicPrintsPassword): a basic +// Authentication printed `{username:alice password:S3cr3t! …}`, and once +// authenticated its PasswordUser — the application's user record, password +// hash included. It now prints its Name (the subject, or "basic"), its +// state and authorities, never the typed user-id, the password or the user. +func TestAuthenticationRedactsCredentials(t *testing.T) { + t.Parallel() + + user := &stubUser{subject: "alice", passwordHash: "$2a$04$HASH-OF-THE-USER", enabled: true} + a := basic.NewAuthenticator(&stubLoader{user: user}, &recordingHasher{always: true}) + + pending := basic.New("Summer2026!-typed-in-the-user-field", "S3cr3t!") + + authed, err := a.Authenticate(context.Background(), basic.New("alice", "S3cr3t!")) + require.NoError(t, err) + + for _, v := range []any{pending, &pending, authed} { + for _, out := range renderings(t, v) { + assert.NotContains(t, out, "S3cr3t!") + assert.NotContains(t, out, "Summer2026") + assert.NotContains(t, out, "HASH-OF-THE-USER") + } + } + + assert.Equal(t, `basic.Authentication{Name: "basic", Authenticated: false, Authorities: [], Credentials: REDACTED}`, fmt.Sprint(pending)) + assert.Equal(t, `basic.Authentication{Name: "alice", Authenticated: true, Authorities: [], Credentials: ""}`, fmt.Sprint(authed)) +} diff --git a/bearer/authentication.go b/bearer/authentication.go index d0d041db..4e908eb8 100644 --- a/bearer/authentication.go +++ b/bearer/authentication.go @@ -28,15 +28,16 @@ func New(token string) Authentication { return Authentication{token: token} } -// Token returns the raw bearer token. Once a [TokenVerifier] has produced an -// authenticated value, the token can be redacted by calling -// [Authentication.WithAuthenticated] with a verifier that builds a fresh -// value from scratch. +// Token returns the raw bearer token of an unauthenticated value — the one +// the [Extractor] read — and "" once it is authenticated: +// [Authentication.WithAuthenticated] drops it. To relay the token to another +// service, read the request's Authorization field instead. func (a Authentication) Token() string { return a.token } // WithAuthenticated returns a new Authentication marked as validated, with -// the provided principal, authorities and display name. The token is -// preserved so adapters that issue refresh challenges can still inspect it. +// the provided principal, authorities and display name. It drops the token: +// a verified value never carries the credential it was verified from, so it +// can flow through the request context, logs and panics without it. func (a Authentication) WithAuthenticated(p security.Principal, authorities []string, name string) Authentication { cp := authorities if authorities != nil { @@ -49,7 +50,6 @@ func (a Authentication) WithAuthenticated(p security.Principal, authorities []st } return Authentication{ - token: a.token, principal: p, authorities: cp, authed: true, @@ -67,8 +67,7 @@ func (a Authentication) Principal() security.Principal { } // Credentials implements [security.Authentication]. Returns the token before -// authentication, nil after (the verifier is expected to redact via a fresh -// WithAuthenticated call). +// authentication, nil after (WithAuthenticated drops it). func (a Authentication) Credentials() any { if a.authed { return nil diff --git a/bearer/authenticator.go b/bearer/authenticator.go index 03e982d6..ca1f901a 100644 --- a/bearer/authenticator.go +++ b/bearer/authenticator.go @@ -42,20 +42,21 @@ func (a *Authenticator) Supports(auth security.Authentication) bool { // Authenticate implements [security.Authenticator]. The returned // authentication is whatever the verifier produced; on verifier error the // error is propagated as-is (the verifier is expected to wrap one of the -// security sentinels for the error mapper to route). +// security sentinels for the error mapper to route). On failure it returns +// [security.Anonymous]: the token never flows back to the caller. func (a *Authenticator) Authenticate(ctx context.Context, auth security.Authentication) (security.Authentication, error) { in, ok := auth.(Authentication) if !ok { - return auth, security.ErrUnsupportedCredential + return security.Anonymous(), security.ErrUnsupportedCredential } out, err := a.verifier.Verify(ctx, in.Token()) if err != nil { - return auth, fmt.Errorf("bearer: verify token: %w", err) + return security.Anonymous(), fmt.Errorf("bearer: verify token: %w", err) } if out == nil { - return auth, fmt.Errorf("bearer: verifier returned nil authentication: %w", security.ErrInvalidCredentials) + return security.Anonymous(), fmt.Errorf("bearer: verifier returned nil authentication: %w", security.ErrInvalidCredentials) } return out, nil diff --git a/bearer/format.go b/bearer/format.go new file mode 100644 index 00000000..f178ccc4 --- /dev/null +++ b/bearer/format.go @@ -0,0 +1,38 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package bearer + +import ( + "fmt" + "io" + "log/slog" +) + +// String implements fmt.Stringer with the name, the state and the +// authorities: the bearer token is never printed. +func (a Authentication) String() string { + token := `""` + if a.token != "" { + token = "REDACTED" + } + + return fmt.Sprintf("bearer.Authentication{Name: %q, Authenticated: %t, Authorities: %q, Token: %s}", + a.Name(), a.authed, a.authorities, token) +} + +// Format implements fmt.Formatter like String, for every verb, %#v +// included: without it, fmt prints the unexported fields — the token among +// them. fmt handles %p of a non-pointer value before any method: no type can +// redact that one. +func (a Authentication) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, a.String()) } + +// LogValue implements slog.LogValuer like String. +func (a Authentication) LogValue() slog.Value { + return slog.GroupValue( + slog.String("name", a.Name()), + slog.Bool("authenticated", a.authed), + slog.Any("authorities", a.authorities), + ) +} diff --git a/bearer/redaction_test.go b/bearer/redaction_test.go new file mode 100644 index 00000000..c0743d3d --- /dev/null +++ b/bearer/redaction_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package bearer_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/bearer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// renderings prints v every way an application could log it. +func renderings(t *testing.T, v any) []string { + t.Helper() + + var out []string + + nested := struct{ Field any }{v} + + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d", "%t", "%o", "%b", "%e", "%U", "%c"} { + out = append(out, fmt.Sprintf(verb, v), fmt.Sprintf(verb, nested)) + } + + var text, js bytes.Buffer + + slog.New(slog.NewTextHandler(&text, nil)).Info("m", "v", v, "nested", nested) + slog.New(slog.NewJSONHandler(&js, nil)).Info("m", "v", v, "nested", nested) + + out = append(out, text.String(), js.String()) + + for _, x := range []any{v, nested} { + b, err := json.Marshal(x) + require.NoError(t, err) + + out = append(out, string(b)) + } + + return out +} + +const secretToken = "eyJhbGciOiJFZERTQSJ9.SECRET-PAYLOAD.SIG" + +// Audit CORE-15: a bearer Authentication printed the bearer token, and kept +// it after authentication although Token()'s documentation promised a +// redaction. WithAuthenticated now drops the token, and the value prints and +// logs without it. +func TestAuthenticationRedactsTheToken(t *testing.T) { + t.Parallel() + + pending := bearer.New(secretToken) + authed := pending.WithAuthenticated(principal{sub: "alice"}, []string{"scope:read"}, "alice") + + for _, v := range []any{pending, &pending, authed} { + for _, out := range renderings(t, v) { + assert.NotContains(t, out, "SECRET-PAYLOAD") + } + } + + assert.Equal(t, `bearer.Authentication{Name: "bearer", Authenticated: false, Authorities: [], Token: REDACTED}`, fmt.Sprint(pending)) + assert.Equal(t, `bearer.Authentication{Name: "alice", Authenticated: true, Authorities: ["scope:read"], Token: ""}`, fmt.Sprint(authed)) +} + +func TestWithAuthenticatedDropsTheToken(t *testing.T) { + t.Parallel() + + authed := bearer.New(secretToken).WithAuthenticated(principal{sub: "alice"}, nil, "") + + assert.Empty(t, authed.Token(), "the token never outlives its verification") + assert.Nil(t, authed.Credentials()) + + a := bearer.NewAuthenticator(bearer.VerifierFunc(func(_ context.Context, token string) (security.Authentication, error) { + return bearer.New(token).WithAuthenticated(principal{sub: "alice"}, nil, ""), nil + })) + + out, err := a.Authenticate(context.Background(), bearer.New(secretToken)) + require.NoError(t, err) + + ba, ok := out.(bearer.Authentication) + require.True(t, ok) + assert.Empty(t, ba.Token()) +} + +// A failed Authenticate returns security.Anonymous(), never its input — the +// token. +func TestAuthenticatorFailureReturnsAnonymous(t *testing.T) { + t.Parallel() + + a := bearer.NewAuthenticator(bearer.VerifierFunc(func(context.Context, string) (security.Authentication, error) { + return nil, security.ErrTokenExpired + })) + + for _, in := range []security.Authentication{bearer.New(secretToken), security.Anonymous()} { + out, err := a.Authenticate(context.Background(), in) + require.Error(t, err) + assert.Equal(t, security.Anonymous(), out) + } + + nilVerifier := bearer.NewAuthenticator(bearer.VerifierFunc(func(context.Context, string) (security.Authentication, error) { + return nil, nil + })) + + out, err := nilVerifier.Authenticate(context.Background(), bearer.New(secretToken)) + require.ErrorIs(t, err, security.ErrInvalidCredentials) + assert.Equal(t, security.Anonymous(), out) +} + +// Name falls back to the principal's subject, then to the scheme label. +func TestAuthenticationNameFallbacks(t *testing.T) { + t.Parallel() + + assert.Equal(t, "bob", bearer.New("t").WithAuthenticated(principal{sub: "bob"}, nil, "").Name()) + assert.Equal(t, "", bearer.New("t").WithAuthenticated(principal{sub: ""}, nil, "").Name()) + assert.Equal(t, "bearer", bearer.New("t").Name()) +} diff --git a/bearer/verifier.go b/bearer/verifier.go index c523a354..0a5fb45f 100644 --- a/bearer/verifier.go +++ b/bearer/verifier.go @@ -31,6 +31,9 @@ import ( // or auth service temporarily down — wraps [security.ErrUnavailable] // (503 / Unavailable, retryable), never a credential sentinel; // - an error wrapping no sentinel is an internal error (500 / Internal). +// +// The returned Authentication MUST NOT keep the raw token: build it with +// bearer.New(token).WithAuthenticated(...), which drops it. type TokenVerifier interface { Verify(ctx context.Context, token string) (security.Authentication, error) } diff --git a/session/format.go b/session/format.go new file mode 100644 index 00000000..ea667365 --- /dev/null +++ b/session/format.go @@ -0,0 +1,122 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "maps" + "slices" + "time" +) + +// redacted stands for a secret wherever a session is printed, logged or +// encoded. +const redacted = "REDACTED" + +// redact renders a secret as REDACTED, or as "" when it is empty. +func redact(secret string) string { + if secret == "" { + return `""` + } + + return redacted +} + +// fingerprint renders a session ID as its SHA-256 fingerprint (the +// session.id_hash span attribute), or "" when it is empty: the raw ID is a +// credential. +func fingerprint(id string) string { + if id == "" { + return `""` + } + + return hashID(id) +} + +// valueKeys returns the sorted keys of the session values: the values +// themselves are application data — tokens, personal data — and are never +// printed. +func valueKeys(values map[string]any) []string { + return slices.Sorted(maps.Keys(values)) +} + +// String implements fmt.Stringer: the ID as its fingerprint, the keys of the +// values, the times — never the CSRF token, the raw ID or the values. The +// cookie carries everything: the redaction applies to printing only. +func (s Session) String() string { + return fmt.Sprintf("session.Session{ID: %s, Values: %s, CSRFToken: %s, CreatedAt: %s, LastAccessed: %s, ExpiresAt: %s}", + fingerprint(s.ID), valueKeys(s.Values), redact(s.CSRFToken), + s.CreatedAt.Format(time.RFC3339), s.LastAccessed.Format(time.RFC3339), s.ExpiresAt.Format(time.RFC3339)) +} + +// Format implements fmt.Formatter like String, for every verb, %#v +// included: without it, fmt prints the exported fields — the CSRF token +// among them. fmt handles %p of a non-pointer value before any method: no +// type can redact that one. +func (s Session) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, s.String()) } + +// LogValue implements slog.LogValuer like String. +func (s Session) LogValue() slog.Value { + return slog.GroupValue( + slog.String("id_hash", fingerprint(s.ID)), + slog.Any("values", valueKeys(s.Values)), + slog.Time("created_at", s.CreatedAt), + slog.Time("last_accessed", s.LastAccessed), + slog.Time("expires_at", s.ExpiresAt), + ) +} + +// sessionJSON is the encoding/json form of a Session. +type sessionJSON struct { + IDHash string `json:"IDHash,omitempty"` + Values []string `json:"Values"` + CSRFToken string `json:"CSRFToken,omitempty"` + CreatedAt time.Time `json:"CreatedAt"` + LastAccessed time.Time `json:"LastAccessed"` + ExpiresAt time.Time `json:"ExpiresAt"` +} + +// MarshalJSON implements json.Marshaler like String: log/slog's JSON handler +// encodes a Session nested in another value with encoding/json, which would +// otherwise write the CSRF token and the values. A Session is persisted +// sealed in its cookie by the Manager, never with encoding/json; an +// application that stores sessions itself uses its own type. +func (s Session) MarshalJSON() ([]byte, error) { + out := sessionJSON{ + Values: valueKeys(s.Values), CreatedAt: s.CreatedAt, LastAccessed: s.LastAccessed, ExpiresAt: s.ExpiresAt, + } + + if s.ID != "" { + out.IDHash = hashID(s.ID) + } + + if s.CSRFToken != "" { + out.CSRFToken = redacted + } + + return json.Marshal(out) //nolint:wrapcheck // strings and times only: never fails +} + +// String implements fmt.Stringer with the name, the state and the +// authorities: never the session it holds nor the application's principal. +func (a Authentication) String() string { + return fmt.Sprintf("session.Authentication{Name: %q, Authenticated: %t, Authorities: %q}", a.Name(), a.authed, a.authorities) +} + +// Format implements fmt.Formatter like String, for every verb: fmt would +// otherwise print the principal's fields (an application's user record). +func (a Authentication) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, a.String()) } + +// LogValue implements slog.LogValuer like String. +func (a Authentication) LogValue() slog.Value { + return slog.GroupValue( + slog.String("name", a.Name()), + slog.Bool("authenticated", a.authed), + slog.Any("authorities", a.authorities), + ) +} diff --git a/session/hygiene_test.go b/session/hygiene_test.go index 11968e9d..b7d6a156 100644 --- a/session/hygiene_test.go +++ b/session/hygiene_test.go @@ -129,7 +129,10 @@ func TestNewManagerRefusesInvalidCookieConfiguration(t *testing.T) { func TestManagerRefusesOversizedCookies(t *testing.T) { t.Parallel() - mgr := newManager(t) + // A fixed clock: the cookie size depends on the timestamps it carries, + // whose RFC 3339 length varies with the clock (trailing zeros). + now := epoch + mgr := clockedManager(t, &now) s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice"}) require.NoError(t, err) diff --git a/session/redaction_test.go b/session/redaction_test.go new file mode 100644 index 00000000..603b8fb8 --- /dev/null +++ b/session/redaction_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "testing" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// renderings prints v every way an application could log it: every fmt +// verb, alone and nested in an exported struct field, through slog's text +// and JSON handlers (top level and nested), and with encoding/json. +func renderings(t *testing.T, v any) []string { + t.Helper() + + var out []string + + nested := struct{ Field any }{v} + + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d", "%t", "%o", "%b", "%e", "%U", "%c"} { + out = append(out, fmt.Sprintf(verb, v), fmt.Sprintf(verb, nested)) + } + + var text, js bytes.Buffer + + slog.New(slog.NewTextHandler(&text, nil)).Info("m", "v", v, "nested", nested) + slog.New(slog.NewJSONHandler(&js, nil)).Info("m", "v", v, "nested", nested) + + out = append(out, text.String(), js.String()) + + for _, x := range []any{v, nested} { + b, err := json.Marshal(x) + require.NoError(t, err) + + out = append(out, string(b)) + } + + return out +} + +// Audit CORE-15 (probe TestZZAuditSessionPrinting): the exported fields of a +// Session revealed its CSRF token (and its raw ID, a credential the module +// never puts on a span) through %+v and encoding/json. A Session now +// prints, logs and encodes its ID fingerprint, the keys of its values (not +// the values: tokens, personal data) and its times — never the token. +func TestSessionRedactsItself(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + s, err := mgr.Login(context.Background(), newCarrier(), principal{sub: "alice@example.com"}) + require.NoError(t, err) + + s.Values["access_token"] = "eyJhbGciOiJ-SECRET-TOKEN" + + for _, v := range []any{*s, s} { + for _, out := range renderings(t, v) { + assert.NotContains(t, out, s.CSRFToken) + assert.NotContains(t, out, s.ID) + assert.NotContains(t, out, "SECRET-TOKEN") + assert.NotContains(t, out, "alice@example.com") + } + } + + str := fmt.Sprint(*s) + assert.Contains(t, str, "session.Session{ID: ") + assert.Contains(t, str, "Values: [access_token sub]") + assert.Contains(t, str, "CSRFToken: REDACTED") + + b, err := json.Marshal(s) + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(b, &decoded)) + assert.Equal(t, "REDACTED", decoded["CSRFToken"]) + assert.Equal(t, []any{"access_token", "sub"}, decoded["Values"]) + assert.NotEmpty(t, decoded["IDHash"]) + assert.NotContains(t, decoded, "ID") + + // The cookie itself still carries everything: the redaction applies to + // printing and encoding/json only. + c := newCarrier() + require.NoError(t, mgr.Touch(context.Background(), c, s)) + + got, err := mgr.Get(context.Background(), c.replay()) + require.NoError(t, err) + assert.Equal(t, s.CSRFToken, got.CSRFToken) + assert.Equal(t, "eyJhbGciOiJ-SECRET-TOKEN", got.GetString("access_token")) +} + +func TestEmptySessionPrints(t *testing.T) { + t.Parallel() + + var s session.Session + + assert.Equal(t, `session.Session{ID: "", Values: [], CSRFToken: "", CreatedAt: 0001-01-01T00:00:00Z, LastAccessed: 0001-01-01T00:00:00Z, ExpiresAt: 0001-01-01T00:00:00Z}`, fmt.Sprint(s)) +} + +// The session Authentication holds the Session and the application's +// principal: it prints neither the CSRF token nor the principal's fields. +func TestSessionAuthenticationRedactsItself(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + c := newCarrier() + s, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + engine := security.NewEngine(security.NewManager(session.NewAuthenticator(recordLoader{})), session.NewExtractor(mgr)) + + _, auth, err := engine.Process(context.Background(), c.replay()) + require.NoError(t, err) + + pending, err := session.NewExtractor(mgr).Extract(context.Background(), c.replay()) + require.NoError(t, err) + + for _, v := range []any{auth, pending} { + for _, out := range renderings(t, v) { + assert.NotContains(t, out, s.CSRFToken) + assert.NotContains(t, out, s.ID) + assert.NotContains(t, out, "HASH-OF-THE-USER", "the principal's fields are never printed") + } + } + + assert.Equal(t, `session.Authentication{Name: "alice", Authenticated: true, Authorities: ["ROLE_USER"]}`, fmt.Sprint(auth)) + assert.Equal(t, `session.Authentication{Name: "session", Authenticated: false, Authorities: []}`, fmt.Sprint(pending)) +} + +// userRecord is an application principal holding a secret field, as a user +// record loaded from a store does. +type userRecord struct { + sub string + passwordHash string +} + +func (u userRecord) Subject() string { return u.sub } + +// recordLoader resolves the session's subject into a userRecord. +type recordLoader struct{} + +func (recordLoader) Load(_ context.Context, values map[string]any) (security.Principal, []string, error) { + sub, _ := values["sub"].(string) + + return userRecord{sub: sub, passwordHash: "$2a$10$HASH-OF-THE-USER"}, []string{"ROLE_USER"}, nil +} From efec4f5ebfdd3bd1aa46f56c0ce91193e8674247 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:34:44 +0200 Subject: [PATCH 07/17] fix(session): copies of one session do not make a request ambiguous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request carrying several cookies of the session's name is refused when they hold different valid sessions (a tossed cookie). Two copies of the same session — the same cookie set once with and once without a Domain, say — are one session, not an ambiguity: refusing them signed the user out. Only valid sessions with different IDs make the request ambiguous. Audit: CORE-I8. --- session/hygiene_test.go | 9 +++++---- session/manager.go | 13 +++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/session/hygiene_test.go b/session/hygiene_test.go index b7d6a156..053e7a54 100644 --- a/session/hygiene_test.go +++ b/session/hygiene_test.go @@ -233,10 +233,11 @@ func TestManagerRefusesAmbiguousSessionCookies(t *testing.T) { require.ErrorIs(t, err, session.ErrNoSession, "in either order") for name, values := range map[string][]string{ - "garbage first": {"garbage", victim}, - "garbage last": {victim, "garbage"}, - "expired first": {stale, victim}, - "another key's first": {v2Cookie(t, randomKey(t), 2, 2, name, "", samplePayload(t, now)), victim}, + "the same session twice": {victim, victim}, + "garbage first": {"garbage", victim}, + "garbage last": {victim, "garbage"}, + "expired first": {stale, victim}, + "another key's first": {v2Cookie(t, randomKey(t), 2, 2, name, "", samplePayload(t, now)), victim}, } { s, err := request(values...) require.NoError(t, err, name) diff --git a/session/manager.go b/session/manager.go index 576e31c5..94da6864 100644 --- a/session/manager.go +++ b/session/manager.go @@ -323,10 +323,11 @@ func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) // pick returns the one valid session among the cookies of the session's // name. Several cookies of one name come from a cookie tossed by a sibling -// host or a more specific path, or from a stale configuration: when more -// than one is a valid session the request is ambiguous and has none — a -// tossed cookie can deny a session, never swap it. Cookies that do not open -// or are expired do not count. An unavailable validator ends the search. +// host or a more specific path, or from a stale configuration: when they +// hold different valid sessions the request is ambiguous and has none — a +// tossed cookie can deny a session, never swap it. Copies of the same +// session (same ID), cookies that do not open and expired ones do not make +// it ambiguous. An unavailable validator ends the search. func (m *Manager) pick(ctx context.Context, found []*http.Cookie) (*Session, error) { if len(found) == 0 { return nil, ErrNoSession @@ -345,9 +346,9 @@ func (m *Manager) pick(ctx context.Context, found []*http.Cookie) (*Session, err s, err := m.load(ctx, cookie.Value) switch { - case err == nil && chosen != nil: + case err == nil && chosen != nil && s.ID != chosen.ID: return nil, fmt.Errorf("%w: several valid session cookies", ErrNoSession) - case err == nil: + case err == nil && chosen == nil: chosen = s case errors.Is(err, security.ErrUnavailable): return nil, err From 5b4263b22aaf39967b1a4ccc99e848a578bec5fd Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:51:23 +0200 Subject: [PATCH 08/17] fix(examples): stop handing out demo tokens and protect the session-web sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bearer-jwt example served an unauthenticated /token endpoint that minted an access token for anyone who asked, and the bearer-jwt, grpc-bearer and connectrpc-bearer examples printed a bearer token, valid one hour, to their logs. The demos now mint their token in-process, valid 15 minutes, write it to a file only the current user can read (os.CreateTemp), log the file's path and remove the file when they stop. bearer-jwt's newServer returns its minter, so its tests mint tokens directly — with the expired-token and wrong-scope challenge rows the transport change left for this one. The session-web sign-in form carried no CSRF token, so another site could sign a victim into the attacker's account (login CSRF), and it verified the password of known users only — 262 us against 66 ms at bcrypt cost 10 — so the response time told which accounts exist. The form now opens a session for itself and carries its CSRF token, which the sign-in checks; net/http's CrossOriginProtection refuses cross-origin posts; the credentials go through basic.Authenticator (one verification per attempt, malformed input refused); a session opened for the form is not signed in. The basic-http example challenged with Bearer although it accepts Basic credentials only; it now sends Basic realm="basic-http", charset="UTF-8". The HTTP servers of basic-http, bearer-jwt, connectrpc-bearer and session-web, and the README quick start, now bound every stage with read, write and idle timeouts (they had none, or a header timeout only). Audit: CORE-I9, CORE-03. --- CHANGELOG.md | 19 ++ README.md | 16 +- examples/basic-http/hygiene_test.go | 54 +++++ examples/basic-http/main.go | 27 ++- examples/bearer-jwt/challenge_test.go | 27 +-- .../bearer-jwt/credentials_location_test.go | 14 +- examples/bearer-jwt/main.go | 161 ++++++++++----- examples/bearer-jwt/main_test.go | 21 +- examples/bearer-jwt/token_test.go | 75 +++++++ examples/connectrpc-bearer/main.go | 94 +++++++-- examples/connectrpc-bearer/token_test.go | 58 ++++++ examples/doc.go | 8 +- examples/grpc-bearer/main.go | 69 ++++++- examples/grpc-bearer/token_test.go | 43 ++++ examples/session-web/login_test.go | 189 ++++++++++++++++++ examples/session-web/main.go | 145 ++++++++++---- examples/session-web/main_test.go | 78 ++------ examples/session-web/session_location_test.go | 22 +- 18 files changed, 891 insertions(+), 229 deletions(-) create mode 100644 examples/basic-http/hygiene_test.go create mode 100644 examples/bearer-jwt/token_test.go create mode 100644 examples/connectrpc-bearer/token_test.go create mode 100644 examples/grpc-bearer/token_test.go create mode 100644 examples/session-web/login_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 296c413e..3e9052ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1040,6 +1040,13 @@ legacy packages (`authentication/`, `authorization/`, the in-tree keep it out of reach of an XSS; `VerifyCSRF` must be called on every state-changing request (sign-in included) — guidance added, with the 2^32-seal limit of a session key. +- The `basic-http` example answered with a `Bearer` challenge although it + accepts Basic credentials only; it now challenges with + `Basic realm="basic-http", charset="UTF-8"`. The HTTP servers of the + `basic-http`, `bearer-jwt`, `connectrpc-bearer` and `session-web` examples + and of the README quick start set read, write and idle timeouts (they had + none, or a header timeout only, and slow clients could hold connections + open). - The SAML Redis store answered `ErrTransactionExists` only when a second save differed from the stored transaction: an identical one succeeded. It @@ -1772,3 +1779,15 @@ legacy packages (`authentication/`, `authorization/`, the in-tree and a verified `bearer.Authentication` kept its token for the rest of the request. They now print, log and encode without secrets, and the bearer token is dropped once verified. +- The `bearer-jwt` example served an unauthenticated `/token` endpoint that + minted an access token for anyone who asked, and the `bearer-jwt`, + `grpc-bearer` and `connectrpc-bearer` examples printed a bearer token, + valid one hour, to their logs. The demos now mint their token in-process, + valid 15 minutes, write it to a file only the current user can read, log + the file's path and remove the file when they stop. +- The `session-web` example's sign-in form carried no CSRF token, so another + site could sign a victim into the attacker's account (login CSRF), and it + verified the password of known users only, so its response time told + which accounts exist. The form now carries the token of a session opened + for it, cross-origin posts are refused (`http.CrossOriginProtection`) and + the credentials go through `basic.Authenticator`. diff --git a/README.md b/README.md index fd1db64a..fd85e7b5 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ go get github.com/hyperscale-stack/security/http # and any other module you ne package main import ( + "log" "net/http" + "time" "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/basic" @@ -79,8 +81,18 @@ func main() { w.Write([]byte("hello " + auth.Name())) }) - // The challenge advertises the scheme the extractors accept (RFC 7617). - http.ListenAndServe(":8080", httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"))(mux)) + srv := &http.Server{ + Addr: ":8080", + // The challenge advertises the scheme the extractors accept (RFC 7617). + Handler: httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"))(mux), + // Bound every stage so slow clients cannot hold connections open. + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Fatal(srv.ListenAndServe()) } ``` diff --git a/examples/basic-http/hygiene_test.go b/examples/basic-http/hygiene_test.go new file mode 100644 index 00000000..3378e7bc --- /dev/null +++ b/examples/basic-http/hygiene_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I9: the Basic demo answered with a Bearer challenge, and its +// server had no timeouts. +func TestBasicHTTPChallengesWithBasic(t *testing.T) { + t.Parallel() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + for name, creds := range map[string][2]string{"no credentials": {}, "wrong password": {"alice", "nope"}} { + req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + require.NoError(t, err) + + if creds[0] != "" { + req.SetBasicAuth(creds[0], creds[1]) + } + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, name) + assert.Equal(t, []string{`Basic realm="basic-http", charset="UTF-8"`}, resp.Header.Values("WWW-Authenticate"), name) + } +} + +func TestNewHTTPServerBoundsSlowClients(t *testing.T) { + t.Parallel() + + srv := newHTTPServer(":0", http.NotFoundHandler()) + + assert.Positive(t, srv.ReadHeaderTimeout) + assert.Positive(t, srv.ReadTimeout) + assert.Positive(t, srv.WriteTimeout) + assert.Positive(t, srv.IdleTimeout) + assert.Positive(t, srv.MaxHeaderBytes) +} diff --git a/examples/basic-http/main.go b/examples/basic-http/main.go index f1344211..55fb3131 100644 --- a/examples/basic-http/main.go +++ b/examples/basic-http/main.go @@ -27,6 +27,9 @@ // Probe — wrong password -> 401: // // curl -i -u alice:nope http://localhost:8080/ +// +// Every 401 carries the challenge of the scheme the server accepts, +// `WWW-Authenticate: Basic realm="basic-http", charset="UTF-8"` (RFC 7617). package main import ( @@ -35,6 +38,7 @@ import ( "html" "log" "net/http" + "time" "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/basic" @@ -137,7 +141,26 @@ func newServer() (http.Handler, error) { adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) mux.Handle("/admin", httpsec.Authorize(adm, security.Role("ADMIN"))(admin)) - return httpsec.Middleware(engine)(mux), nil + // The challenge names the scheme the extractors accept: Basic, with the + // realm RFC 7617 §2 requires (the default challenge is Bearer). + return httpsec.Middleware(engine, httpsec.WithChallengeScheme("Basic"), httpsec.WithRealm(realm))(mux), nil +} + +// realm is the protection space of the demo (RFC 7617 §2). +const realm = "basic-http" + +// newHTTPServer bounds the time a client may take at every stage, so that +// slow clients cannot hold connections open. +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 64 << 10, + } } func main() { @@ -148,5 +171,5 @@ func main() { addr := ":8080" log.Printf("basic-http: listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, handler)) //nolint:gosec // demo server, no timeouts needed + log.Fatal(newHTTPServer(addr, handler).ListenAndServe()) } diff --git a/examples/bearer-jwt/challenge_test.go b/examples/bearer-jwt/challenge_test.go index 510a5cc5..805f0105 100644 --- a/examples/bearer-jwt/challenge_test.go +++ b/examples/bearer-jwt/challenge_test.go @@ -8,7 +8,6 @@ import ( "context" "crypto/ed25519" "crypto/rand" - "encoding/json" "net/http" "net/http/httptest" "testing" @@ -54,26 +53,25 @@ func foreignToken(t *testing.T) string { // Audit CORE-10 (probe TestZZAuditBearerChallengeErrorCodes): a bad signature // or a malformed token got a bare "Bearer" challenge. RFC 6750 §3: the // challenge carries a realm; RFC 9068 §4: every failed check of a JWT -// access token carries error="invalid_token". (The expired-token and -// wrong-scope rows need the example's signing key: they come with the -// token-minting hook of a later change.) +// access token carries error="invalid_token"; §3.1: a valid token without +// the scope gets error="insufficient_scope". func TestBearerJWTChallenges(t *testing.T) { t.Parallel() - handler, err := newServer() + handler, mint, err := newServer() require.NoError(t, err) srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - resp, err := srv.Client().Post(srv.URL+"/token", "", nil) + valid, err := mint("resource:read", time.Now()) require.NoError(t, err) - t.Cleanup(func() { _ = resp.Body.Close() }) - var minted struct { - AccessToken string `json:"access_token"` - } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted)) + expired, err := mint("resource:read", time.Now().Add(-time.Hour)) + require.NoError(t, err) + + wrongScope, err := mint("other:read", time.Now()) + require.NoError(t, err) cases := []struct { name string @@ -84,9 +82,12 @@ func TestBearerJWTChallenges(t *testing.T) { {"no token", nil, http.StatusUnauthorized, []string{bareChallenge}}, {"malformed token", []string{"Bearer not-a-jwt"}, http.StatusUnauthorized, []string{invalidTokenChallenge}}, {"untrusted signature", []string{"Bearer " + foreignToken(t)}, http.StatusUnauthorized, []string{invalidTokenChallenge}}, - {"repeated Authorization", []string{"Bearer " + minted.AccessToken, "Bearer not-a-jwt"}, http.StatusBadRequest, + {"expired token", []string{"Bearer " + expired}, http.StatusUnauthorized, []string{invalidTokenChallenge}}, + {"valid token without the scope", []string{"Bearer " + wrongScope}, http.StatusForbidden, + []string{`Bearer realm="api", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token."`}}, + {"repeated Authorization", []string{"Bearer " + valid, "Bearer not-a-jwt"}, http.StatusBadRequest, []string{`Bearer realm="api", error="invalid_request", error_description="The request is malformed."`}}, - {"valid token", []string{"Bearer " + minted.AccessToken}, http.StatusOK, nil}, + {"valid token", []string{"Bearer " + valid}, http.StatusOK, nil}, } for _, c := range cases { diff --git a/examples/bearer-jwt/credentials_location_test.go b/examples/bearer-jwt/credentials_location_test.go index a1686c53..c2ede0ec 100644 --- a/examples/bearer-jwt/credentials_location_test.go +++ b/examples/bearer-jwt/credentials_location_test.go @@ -5,11 +5,11 @@ package main import ( - "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,22 +21,16 @@ import ( func TestBearerJWTReadsTheTokenFromTheAuthorizationHeaderOnly(t *testing.T) { t.Parallel() - handler, err := newServer() + handler, mint, err := newServer() require.NoError(t, err) srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - resp, err := srv.Client().Post(srv.URL+"/token", "", nil) + token, err := mint("resource:read", time.Now()) require.NoError(t, err) - t.Cleanup(func() { _ = resp.Body.Close() }) - var minted struct { - AccessToken string `json:"access_token"` - } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted)) - - bearer := "Bearer " + minted.AccessToken + bearer := "Bearer " + token inQuery, err := http.NewRequest(http.MethodGet, srv.URL+"/?Authorization="+url.QueryEscape(bearer), nil) require.NoError(t, err) diff --git a/examples/bearer-jwt/main.go b/examples/bearer-jwt/main.go index 2a54476f..82701cbd 100644 --- a/examples/bearer-jwt/main.go +++ b/examples/bearer-jwt/main.go @@ -4,17 +4,21 @@ // Command bearer-jwt is a runnable JWT bearer-token demo. // -// It plays both roles in one process: a tiny issuer that mints EdDSA-signed -// JWTs, and a resource server that validates the Bearer token on every -// request and gates one route on an OAuth2 scope. +// It plays both roles in one process: a tiny issuer that mints an +// EdDSA-signed RFC 9068 access token at start-up, and a resource server +// that validates the Bearer token on every request and gates one route on +// an OAuth2 scope. A real issuer authenticates its clients before minting +// tokens: the demo has no public token endpoint, it writes its token — +// valid 15 minutes — to a file only the current user can read, logs the +// file's path (never the token) and removes the file when it stops. // // Run: // // go run ./bearer-jwt // -// Probe — mint a token: +// Probe — read the token from the file the demo logged: // -// TOKEN=$(curl -s -X POST http://localhost:8081/token | sed 's/.*"access_token":"//;s/".*//') +// TOKEN=$(cat /path/from/the/log) // // Probe — call the protected route: // @@ -30,13 +34,17 @@ package main import ( + "context" "crypto/ed25519" "crypto/rand" - "encoding/json" + "errors" "fmt" "html" "log" "net/http" + "os" + "os/signal" + "syscall" "time" "github.com/hyperscale-stack/security" @@ -54,13 +62,22 @@ const ( clientID = "demo-client" ) -// newServer builds the demo handler. The signer mints tokens, the verifier -// validates them; in a real deployment those live in separate processes and -// the resource server fetches the issuer's public keys over JWKS. -func newServer() (http.Handler, error) { +// tokenLifetime is the lifetime of the demo tokens: short, a leaked one +// expires soon. +const tokenLifetime = 15 * time.Minute + +// minter signs a demo access token carrying scope, issued at issuedAt. +type minter func(scope string, issuedAt time.Time) (string, error) + +// newServer builds the demo handler and the minter sharing its signing key. +// The signer mints tokens, the verifier validates them; in a real deployment +// those live in separate processes and the resource server fetches the +// issuer's public keys over JWKS. It is separate from main so the +// end-to-end test drives the same wiring. +func newServer() (http.Handler, minter, error) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { - return nil, fmt.Errorf("generate key: %w", err) + return nil, nil, fmt.Errorf("generate key: %w", err) } signer, err := jwtsec.NewOAuth2AccessTokenSigner(jwtsec.PrivateKey{ @@ -69,7 +86,7 @@ func newServer() (http.Handler, error) { Key: priv, }) if err != nil { - return nil, fmt.Errorf("signer: %w", err) + return nil, nil, fmt.Errorf("signer: %w", err) } jwks := jwtsec.NewStaticJWKS([]jwtsec.PublicKey{{ @@ -91,30 +108,6 @@ func newServer() (http.Handler, error) { mux := http.NewServeMux() - // /token mints a demo token. A real issuer would authenticate the - // caller and derive the subject + scopes from the grant. - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - now := time.Now() - - token, err := signer.SignAccessToken(r.Context(), oauth2token.AccessTokenClaims{ - Issuer: issuer, - Subject: "demo-user", - Audience: audience, - ClientID: clientID, - IssuedAt: now, - ExpiresAt: now.Add(time.Hour), - Scope: "resource:read", - }) - if err != nil { - http.Error(w, "mint failed", http.StatusInternalServerError) - - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"access_token": token}) - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { auth, _ := security.FromContext(r.Context()) @@ -132,23 +125,99 @@ func newServer() (http.Handler, error) { adm := security.NewAffirmativeDecisionManager(voter.HasScope("resource:read")) mux.Handle("/reports", httpsec.Authorize(adm, security.Scope("resource:read"))(reports)) - // The /token route is public; everything else requires a valid token. - protected := httpsec.Middleware(engine)(mux) + mint := func(scope string, issuedAt time.Time) (string, error) { + token, err := signer.SignAccessToken(context.Background(), oauth2token.AccessTokenClaims{ + Issuer: issuer, + Subject: "demo-user", + Audience: audience, + ClientID: clientID, + IssuedAt: issuedAt, + ExpiresAt: issuedAt.Add(tokenLifetime), + Scope: scope, + }) + if err != nil { + return "", fmt.Errorf("mint token: %w", err) + } - root := http.NewServeMux() - root.Handle("/token", mux) - root.Handle("/", protected) + return token, nil + } - return root, nil + // Every route requires a valid token. + return httpsec.Middleware(engine)(mux), mint, nil +} + +// writeDemoToken writes token to a new file in dir (the system's temporary +// directory when dir is "") that only the current user can read, and +// returns its path: the demo never prints a credential to its logs. +func writeDemoToken(dir, token string) (string, error) { + f, err := os.CreateTemp(dir, "bearer-jwt-*.token") // mode 0600 + if err != nil { + return "", fmt.Errorf("create token file: %w", err) + } + + if _, err := f.WriteString(token); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + + return "", fmt.Errorf("write token file: %w", err) + } + + if err := f.Close(); err != nil { + return "", fmt.Errorf("close token file: %w", err) + } + + return f.Name(), nil +} + +// newHTTPServer bounds the time a client may take at every stage, so that +// slow clients cannot hold connections open. +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 64 << 10, + } } func main() { - handler, err := newServer() + handler, mint, err := newServer() + if err != nil { + log.Fatalf("bearer-jwt: %v", err) + } + + token, err := mint("resource:read", time.Now()) if err != nil { log.Fatalf("bearer-jwt: %v", err) } - addr := ":8081" - log.Printf("bearer-jwt: listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, handler)) //nolint:gosec // demo server, no timeouts needed + path, err := writeDemoToken("", token) + if err != nil { + log.Fatalf("bearer-jwt: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + srv := newHTTPServer(":8081", handler) + + go func() { + <-ctx.Done() + + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = srv.Shutdown(shutdown) + }() + + log.Printf("bearer-jwt: listening on %s; demo token (valid %s) in %s", srv.Addr, tokenLifetime, path) + + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Printf("bearer-jwt: %v", err) + } + + _ = os.Remove(path) } diff --git a/examples/bearer-jwt/main_test.go b/examples/bearer-jwt/main_test.go index f998c006..b60101c7 100644 --- a/examples/bearer-jwt/main_test.go +++ b/examples/bearer-jwt/main_test.go @@ -5,10 +5,10 @@ package main import ( - "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,23 +17,16 @@ import ( func TestBearerJWTExample(t *testing.T) { t.Parallel() - handler, err := newServer() + handler, mint, err := newServer() require.NoError(t, err) srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - // Mint a token via the public /token endpoint. - resp, err := srv.Client().Post(srv.URL+"/token", "", nil) + // Mint a token the way the demo does at start-up. + token, err := mint("resource:read", time.Now()) require.NoError(t, err) - t.Cleanup(func() { _ = resp.Body.Close() }) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var minted struct { - AccessToken string `json:"access_token"` - } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted)) - require.NotEmpty(t, minted.AccessToken) + require.NotEmpty(t, token) get := func(t *testing.T, path, token string) int { t.Helper() @@ -54,12 +47,12 @@ func TestBearerJWTExample(t *testing.T) { t.Run("valid token reaches the protected route", func(t *testing.T) { t.Parallel() - assert.Equal(t, http.StatusOK, get(t, "/", minted.AccessToken)) + assert.Equal(t, http.StatusOK, get(t, "/", token)) }) t.Run("valid token carries the resource:read scope", func(t *testing.T) { t.Parallel() - assert.Equal(t, http.StatusOK, get(t, "/reports", minted.AccessToken)) + assert.Equal(t, http.StatusOK, get(t, "/reports", token)) }) t.Run("missing token is rejected", func(t *testing.T) { diff --git a/examples/bearer-jwt/token_test.go b/examples/bearer-jwt/token_test.go new file mode 100644 index 00000000..0e9c29d0 --- /dev/null +++ b/examples/bearer-jwt/token_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I9: the demo exposed an unauthenticated /token endpoint that +// minted access tokens for anyone. Tokens are now minted in-process and the +// demo writes its token to a file only the current user can read. +func TestBearerJWTHasNoPublicTokenEndpoint(t *testing.T) { + t.Parallel() + + handler, _, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + resp, err := srv.Client().Post(srv.URL+"/token", "", nil) + require.NoError(t, err) + _ = resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +// The demo never prints its token: it writes it to a 0600 file and logs the +// path. +func TestWriteDemoToken(t *testing.T) { + t.Parallel() + + path, err := writeDemoToken(t.TempDir(), "secret-token") + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + content, err := os.ReadFile(filepath.Clean(path)) + require.NoError(t, err) + assert.Equal(t, "secret-token", string(content)) + + _, err = writeDemoToken(filepath.Join(t.TempDir(), "missing"), "x") + require.Error(t, err) +} + +func TestNewHTTPServerBoundsSlowClients(t *testing.T) { + t.Parallel() + + srv := newHTTPServer(":0", http.NotFoundHandler()) + + assert.Positive(t, srv.ReadHeaderTimeout) + assert.Positive(t, srv.ReadTimeout) + assert.Positive(t, srv.WriteTimeout) + assert.Positive(t, srv.IdleTimeout) + assert.Positive(t, srv.MaxHeaderBytes) +} + +// Demo tokens are short-lived. +func TestDemoTokensAreShortLived(t *testing.T) { + t.Parallel() + + assert.LessOrEqual(t, tokenLifetime, 15*time.Minute) +} diff --git a/examples/connectrpc-bearer/main.go b/examples/connectrpc-bearer/main.go index b76c86f1..869eb6e1 100644 --- a/examples/connectrpc-bearer/main.go +++ b/examples/connectrpc-bearer/main.go @@ -6,16 +6,18 @@ // // It exposes the gRPC-style health service behind two ConnectRPC interceptors: // one authenticates every RPC against a JWT, the other authorizes it against -// an OAuth2 scope. The process also mints a demo token at start-up. +// an OAuth2 scope. The process also mints a demo token — valid 15 minutes — +// at start-up, writes it to a file only the current user can read, logs the +// file's path (never the token) and removes the file when it stops. // // Run: // // go run ./connectrpc-bearer // -// The server logs a ready-to-use token. Probe it with curl over the Connect -// protocol: +// Probe it with curl over the Connect protocol, reading the token from the +// file the server logged: // -// curl -H "Authorization: Bearer " \ +// curl -H "Authorization: Bearer $(cat /path/from/the/log)" \ // -H "Content-Type: application/json" \ // -d '{}' http://localhost:9091/grpc.health.v1.Health/Check // @@ -28,10 +30,14 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "errors" "fmt" "log" "net" "net/http" + "os" + "os/signal" + "syscall" "time" "connectrpc.com/connect" @@ -53,6 +59,10 @@ const ( addr = ":9091" ) +// tokenLifetime is the lifetime of the demo tokens: short, a leaked one +// expires soon. +const tokenLifetime = 15 * time.Minute + // minter signs a demo JWT carrying the requested scope. type minter func(scope string) (string, error) @@ -113,7 +123,7 @@ func newServer() (http.Handler, minter, error) { Audience: audience, ClientID: clientID, IssuedAt: now, - ExpiresAt: now.Add(time.Hour), + ExpiresAt: now.Add(tokenLifetime), Scope: grant, }) if err != nil { @@ -126,6 +136,42 @@ func newServer() (http.Handler, minter, error) { return mux, mint, nil } +// writeDemoToken writes token to a new file in dir (the system's temporary +// directory when dir is "") that only the current user can read, and +// returns its path: the demo never prints a credential to its logs. +func writeDemoToken(dir, token string) (string, error) { + f, err := os.CreateTemp(dir, "connectrpc-bearer-*.token") // mode 0600 + if err != nil { + return "", fmt.Errorf("create token file: %w", err) + } + + if _, err := f.WriteString(token); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + + return "", fmt.Errorf("write token file: %w", err) + } + + if err := f.Close(); err != nil { + return "", fmt.Errorf("close token file: %w", err) + } + + return f.Name(), nil +} + +// newHTTPServer bounds the time a client may take at every stage, so that +// slow clients cannot hold connections open. +func newHTTPServer(handler http.Handler) *http.Server { + return &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 64 << 10, + } +} + func main() { handler, mint, err := newServer() if err != nil { @@ -137,19 +183,39 @@ func main() { log.Fatalf("connectrpc-bearer: %v", err) } + path, err := writeDemoToken("", token) + if err != nil { + log.Fatalf("connectrpc-bearer: %v", err) + } + + defer func() { _ = os.Remove(path) }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + var lc net.ListenConfig - lis, err := lc.Listen(context.Background(), "tcp", addr) //nolint:gosec // G102: demo server, binding to all interfaces is intentional + lis, err := lc.Listen(ctx, "tcp", addr) //nolint:gosec // G102: demo server, binding to all interfaces is intentional if err != nil { - log.Fatalf("connectrpc-bearer: listen: %v", err) - } + log.Printf("connectrpc-bearer: listen: %v", err) - srv := &http.Server{ - Handler: handler, - ReadHeaderTimeout: 5 * time.Second, + return } - log.Printf("connectrpc-bearer: listening on %s", addr) - log.Printf("connectrpc-bearer: demo token: %s", token) - log.Fatal(srv.Serve(lis)) + srv := newHTTPServer(handler) + + go func() { + <-ctx.Done() + + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = srv.Shutdown(shutdown) + }() + + log.Printf("connectrpc-bearer: listening on %s; demo token (valid %s) in %s", addr, tokenLifetime, path) + + if err := srv.Serve(lis); !errors.Is(err, http.ErrServerClosed) { + log.Printf("connectrpc-bearer: %v", err) + } } diff --git a/examples/connectrpc-bearer/token_test.go b/examples/connectrpc-bearer/token_test.go new file mode 100644 index 00000000..bd2e3349 --- /dev/null +++ b/examples/connectrpc-bearer/token_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I9: the demo printed a valid bearer token, good for an hour, to +// its logs. It now writes a short-lived token to a file only the current +// user can read and logs the path. +func TestWriteDemoToken(t *testing.T) { + t.Parallel() + + path, err := writeDemoToken(t.TempDir(), "secret-token") + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + content, err := os.ReadFile(filepath.Clean(path)) + require.NoError(t, err) + assert.Equal(t, "secret-token", string(content)) + + _, err = writeDemoToken(filepath.Join(t.TempDir(), "missing"), "x") + require.Error(t, err) +} + +// Demo tokens are short-lived. +func TestDemoTokensAreShortLived(t *testing.T) { + t.Parallel() + + assert.LessOrEqual(t, tokenLifetime, 15*time.Minute) +} + +// Audit CORE-I9: only ReadHeaderTimeout was set — a client could hold a +// connection open by sending its body or reading the response slowly. +func TestNewHTTPServerBoundsSlowClients(t *testing.T) { + t.Parallel() + + srv := newHTTPServer(http.NotFoundHandler()) + + assert.Positive(t, srv.ReadHeaderTimeout) + assert.Positive(t, srv.ReadTimeout) + assert.Positive(t, srv.WriteTimeout) + assert.Positive(t, srv.IdleTimeout) + assert.Positive(t, srv.MaxHeaderBytes) +} diff --git a/examples/doc.go b/examples/doc.go index 3eed01f5..84bcbbea 100644 --- a/examples/doc.go +++ b/examples/doc.go @@ -13,10 +13,10 @@ // Available examples: // // - basic-http — HTTP Basic authentication + role-based authorization. -// - bearer-jwt — JWT issuance and Bearer-token validation, scope gating. -// - grpc-bearer — gRPC unary interceptors authenticating a Bearer JWT. -// - connectrpc-bearer — ConnectRPC interceptors authenticating a Bearer JWT. -// - session-web — cookie-session login form with a CSRF-protected logout. +// - bearer-jwt — JWT Bearer-token validation and scope gating, with an in-process demo issuer. +// - grpc-bearer — gRPC unary and stream interceptors authenticating and authorizing a Bearer JWT. +// - connectrpc-bearer — ConnectRPC interceptors authenticating and authorizing a Bearer JWT. +// - session-web — cookie-session login with CSRF-protected sign-in and logout forms. // - oauth2 — OAuth2 authorization server + Bearer resource server. // - oidc-client — OpenID Connect sign-in through external providers. // - oidc-provider — OpenID Provider with its own login and consent pages. diff --git a/examples/grpc-bearer/main.go b/examples/grpc-bearer/main.go index aeebaa34..6981e1e4 100644 --- a/examples/grpc-bearer/main.go +++ b/examples/grpc-bearer/main.go @@ -7,16 +7,18 @@ // It exposes the standard gRPC health service behind two interceptor chains // — unary and streaming: one authenticates every RPC against a JWT, the // other authorizes it against an OAuth2 scope. The process also mints a -// demo token at start-up. +// demo token — valid 15 minutes — at start-up, writes it to a file only the +// current user can read, logs the file's path (never the token) and removes +// the file when it stops. // // Run: // // go run ./grpc-bearer // -// The server logs a ready-to-use token. Probe it with grpcurl: +// Probe it with grpcurl, reading the token from the file the server logged: // // grpcurl -plaintext \ -// -H "authorization: Bearer " \ +// -H "authorization: Bearer $(cat /path/from/the/log)" \ // localhost:9090 grpc.health.v1.Health/Check // // Without the token the call fails with codes.Unauthenticated; with a token @@ -30,7 +32,10 @@ import ( "fmt" "log" "net" + "os" + "os/signal" "slices" + "syscall" "time" "github.com/hyperscale-stack/security" @@ -52,6 +57,10 @@ const ( scope = "health:read" ) +// tokenLifetime is the lifetime of the demo tokens: short, a leaked one +// expires soon. +const tokenLifetime = 15 * time.Minute + // minter signs a demo JWT carrying the requested scope. type minter func(scope string) (string, error) @@ -110,7 +119,7 @@ func newServer() (*grpc.Server, minter, error) { Audience: audience, ClientID: clientID, IssuedAt: now, - ExpiresAt: now.Add(time.Hour), + ExpiresAt: now.Add(tokenLifetime), Scope: grant, }) if err != nil { @@ -123,6 +132,29 @@ func newServer() (*grpc.Server, minter, error) { return srv, mint, nil } +// writeDemoToken writes token to a new file in dir (the system's temporary +// directory when dir is "") that only the current user can read, and +// returns its path: the demo never prints a credential to its logs. +func writeDemoToken(dir, token string) (string, error) { + f, err := os.CreateTemp(dir, "grpc-bearer-*.token") // mode 0600 + if err != nil { + return "", fmt.Errorf("create token file: %w", err) + } + + if _, err := f.WriteString(token); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + + return "", fmt.Errorf("write token file: %w", err) + } + + if err := f.Close(); err != nil { + return "", fmt.Errorf("close token file: %w", err) + } + + return f.Name(), nil +} + func main() { srv, mint, err := newServer() if err != nil { @@ -134,16 +166,35 @@ func main() { log.Fatalf("grpc-bearer: %v", err) } + path, err := writeDemoToken("", token) + if err != nil { + log.Fatalf("grpc-bearer: %v", err) + } + + defer func() { _ = os.Remove(path) }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + addr := ":9090" var lc net.ListenConfig - lis, err := lc.Listen(context.Background(), "tcp", addr) //nolint:gosec // G102: demo server, binding to all interfaces is intentional + lis, err := lc.Listen(ctx, "tcp", addr) //nolint:gosec // G102: demo server, binding to all interfaces is intentional if err != nil { - log.Fatalf("grpc-bearer: listen: %v", err) + log.Printf("grpc-bearer: listen: %v", err) + + return } - log.Printf("grpc-bearer: listening on %s", addr) - log.Printf("grpc-bearer: demo token: %s", token) - log.Fatal(srv.Serve(lis)) + go func() { + <-ctx.Done() + srv.GracefulStop() + }() + + log.Printf("grpc-bearer: listening on %s; demo token (valid %s) in %s", addr, tokenLifetime, path) + + if err := srv.Serve(lis); err != nil { + log.Printf("grpc-bearer: %v", err) + } } diff --git a/examples/grpc-bearer/token_test.go b/examples/grpc-bearer/token_test.go new file mode 100644 index 00000000..c4ddb5ff --- /dev/null +++ b/examples/grpc-bearer/token_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Audit CORE-I9: the demo printed a valid bearer token, good for an hour, to +// its logs. It now writes a short-lived token to a file only the current +// user can read and logs the path. +func TestWriteDemoToken(t *testing.T) { + t.Parallel() + + path, err := writeDemoToken(t.TempDir(), "secret-token") + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + content, err := os.ReadFile(filepath.Clean(path)) + require.NoError(t, err) + assert.Equal(t, "secret-token", string(content)) + + _, err = writeDemoToken(filepath.Join(t.TempDir(), "missing"), "x") + require.Error(t, err) +} + +// Demo tokens are short-lived. +func TestDemoTokensAreShortLived(t *testing.T) { + t.Parallel() + + assert.LessOrEqual(t, tokenLifetime, 15*time.Minute) +} diff --git a/examples/session-web/login_test.go b/examples/session-web/login_test.go new file mode 100644 index 00000000..24bd94c8 --- /dev/null +++ b/examples/session-web/login_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// browser is a test client with a cookie jar that does not follow +// redirects. +type browser struct { + t *testing.T + srv *httptest.Server + client *http.Client +} + +func newBrowser(t *testing.T) *browser { + t.Helper() + + handler, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + + client := srv.Client() + client.Jar = jar + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + return &browser{t: t, srv: srv, client: client} +} + +// do sends a request with the given headers and returns the status and body. +func (b *browser) do(method, path string, form url.Values, headers map[string]string) (int, string) { + b.t.Helper() + + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + + req, err := http.NewRequest(method, b.srv.URL+path, body) + require.NoError(b.t, err) + + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := b.client.Do(req) + require.NoError(b.t, err) + + defer func() { _ = resp.Body.Close() }() + + out, err := io.ReadAll(resp.Body) + require.NoError(b.t, err) + + return resp.StatusCode, string(out) +} + +// loginToken opens the sign-in form and returns its CSRF token. +func (b *browser) loginToken() string { + b.t.Helper() + + status, body := b.do(http.MethodGet, "/login", nil, nil) + require.Equal(b.t, http.StatusOK, status) + + match := csrfRE.FindStringSubmatch(body) + require.Len(b.t, match, 2, "the sign-in form must carry a CSRF token") + + return match[1] +} + +func (b *browser) signIn(username, password, token string, headers map[string]string) int { + b.t.Helper() + + status, _ := b.do(http.MethodPost, "/login", url.Values{"username": {username}, "password": {password}, "csrf_token": {token}}, headers) + + return status +} + +// Audit CORE-I9: the session-web sign-in form had no CSRF token, so another +// site could sign the victim into the attacker's account (login CSRF). The +// form now carries the token of a session opened for it, and the sign-in +// refuses a request without that session or token. +func TestSessionWebSignInRequiresTheFormToken(t *testing.T) { + t.Parallel() + + b := newBrowser(t) + + assert.Equal(t, http.StatusForbidden, b.signIn("alice", "alice-secret", "", nil), "no session, no token") + + token := b.loginToken() + + assert.Equal(t, http.StatusForbidden, b.signIn("alice", "alice-secret", "", nil), "no token") + assert.Equal(t, http.StatusForbidden, b.signIn("alice", "alice-secret", "forged", nil), "forged token") + + status, _ := b.do(http.MethodGet, "/", nil, nil) + assert.Equal(t, http.StatusSeeOther, status, "the session opened for the form is not a signed-in session") + + assert.Equal(t, http.StatusSeeOther, b.signIn("alice", "alice-secret", token, nil)) + + status, body := b.do(http.MethodGet, "/", nil, nil) + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, body, "Welcome alice") +} + +// net/http's CrossOriginProtection refuses cross-origin unsafe requests from +// their Sec-Fetch-Site and Origin headers, whatever the token. +func TestSessionWebRefusesCrossOriginPosts(t *testing.T) { + t.Parallel() + + b := newBrowser(t) + token := b.loginToken() + + assert.Equal(t, http.StatusForbidden, b.signIn("alice", "alice-secret", token, map[string]string{"Sec-Fetch-Site": "cross-site"})) + assert.Equal(t, http.StatusForbidden, b.signIn("alice", "alice-secret", token, map[string]string{"Origin": "https://evil.example"})) + assert.Equal(t, http.StatusSeeOther, b.signIn("alice", "alice-secret", token, map[string]string{"Sec-Fetch-Site": "same-origin"})) +} + +// Audit CORE-03: the example's login checked the password only for known +// users — 262 µs against 66 ms. It now authenticates through +// basic.Authenticator, which spends one verification on every attempt. Coarse +// bound (fastest of 7 interleaved runs, ratio ≥ 0.5; ≈ 0.004 before). +func TestSessionWebSignInTimeDoesNotRevealAccounts(t *testing.T) { + b := newBrowser(t) + token := b.loginToken() + fastest := map[string]time.Duration{} + + for range 7 { + for _, username := range []string{"alice", "nobody"} { + start := time.Now() + status := b.signIn(username, "wrong", token, nil) + d := time.Since(start) + + require.Equal(t, http.StatusUnauthorized, status, username) + + if fastest[username] == 0 || d < fastest[username] { + fastest[username] = d + } + } + } + + ratio := float64(fastest["nobody"]) / float64(fastest["alice"]) + assert.GreaterOrEqual(t, ratio, 0.5, "an unknown user answers %.3f× the time of a known one", ratio) +} + +// The sign-in applies the Basic credential rules: a control character never +// reaches the user lookup. +func TestSessionWebSignInRefusesMalformedCredentials(t *testing.T) { + t.Parallel() + + b := newBrowser(t) + token := b.loginToken() + + for _, username := range []string{"al\x00ice", "alice\r\nX-Injected: 1", "\xff\xfe", ""} { + assert.Equal(t, http.StatusUnauthorized, b.signIn(username, "alice-secret", token, nil), "%q", username) + } +} + +func TestNewHTTPServerBoundsSlowClients(t *testing.T) { + t.Parallel() + + srv := newHTTPServer(":0", http.NotFoundHandler()) + + assert.Positive(t, srv.ReadHeaderTimeout) + assert.Positive(t, srv.ReadTimeout) + assert.Positive(t, srv.WriteTimeout) + assert.Positive(t, srv.IdleTimeout) + assert.Positive(t, srv.MaxHeaderBytes) +} diff --git a/examples/session-web/main.go b/examples/session-web/main.go index c9e30c33..8994d031 100644 --- a/examples/session-web/main.go +++ b/examples/session-web/main.go @@ -6,15 +6,20 @@ // // It is a tiny login-form application: a successful login mints an // encrypted session cookie, the home page reads it, and logout clears it. -// The logout form is protected by a CSRF synchronizer token. +// Both forms are protected against CSRF: the sign-in form by the token of a +// session opened for it (against login CSRF), the logout form by the +// session's token; net/http's CrossOriginProtection also refuses +// cross-origin form posts. The credentials are checked by a +// basic.Authenticator, which spends one password verification on every +// attempt, so the response time does not tell which accounts exist. // // Run: // // go run ./session-web // // Then open http://localhost:8082 in a browser and log in with -// alice / alice-secret. The session cookie is AES-256-GCM sealed; tampering -// with it simply drops the session. +// alice / alice-secret. The session cookie, __Host-session, is AES-256-GCM +// sealed; tampering with it simply drops the session. package main import ( @@ -24,28 +29,51 @@ import ( "html" "log" "net/http" + "time" + "github.com/hyperscale-stack/security/basic" httpsec "github.com/hyperscale-stack/security/http" "github.com/hyperscale-stack/security/password" "github.com/hyperscale-stack/security/session" ) -// principal is the minimal [security.Principal] stored on login. -type principal struct{ subject string } +// user is an in-memory [basic.PasswordUser]. A real application would back +// this with a database row. +type user struct{ subject, hash string } -func (p principal) Subject() string { return p.subject } +func (u user) Subject() string { return u.subject } +func (u user) GetPasswordHash() string { return u.hash } +func (u user) IsEnabled() bool { return true } +func (u user) IsLocked() bool { return false } +func (u user) IsExpired() bool { return false } +func (u user) IsCredentialsExpired() bool { return false } + +// users is an in-memory [basic.UserLoader]: an unknown user is (nil, nil), +// and the Authenticator verifies the password against its dummy hash. +type users map[string]user + +// demoUser is the demo account (password alice-secret). +const demoUser = "alice" + +func (u users) LoadByUsername(_ context.Context, username string) (basic.PasswordUser, error) { + known, ok := u[username] + if !ok { + return nil, nil + } + + return known, nil +} // app holds the demo dependencies. type app struct { manager *session.Manager - hasher password.Hasher - users map[string]string // username -> bcrypt hash + login *basic.Authenticator } // newServer builds the demo handler. It is separate from main so the // end-to-end test can drive the exact same wiring. func newServer() (http.Handler, error) { - key := make([]byte, 32) // AES-256 + key := make([]byte, session.MinKeySize) // in production: from your secret store if _, err := rand.Read(key); err != nil { return nil, fmt.Errorf("generate codec key: %w", err) } @@ -64,71 +92,92 @@ func newServer() (http.Handler, error) { a := &app{ manager: session.NewManager(codec), - hasher: hasher, - users: map[string]string{"alice": aliceHash}, + login: basic.NewAuthenticator(users{demoUser: {subject: demoUser, hash: aliceHash}}, hasher), } mux := http.NewServeMux() mux.HandleFunc("GET /", a.home) mux.HandleFunc("GET /login", a.loginForm) - mux.HandleFunc("POST /login", a.login) + mux.HandleFunc("POST /login", a.signIn) mux.HandleFunc("POST /logout", a.logout) - return mux, nil + // Refuses cross-origin unsafe requests (Sec-Fetch-Site, Origin): a + // second line of defense against CSRF next to the synchronizer tokens. + return http.NewCrossOriginProtection().Handler(mux), nil } -// home renders the protected page, or redirects to the login form when no -// valid session cookie is present. +// home renders the protected page, or redirects to the login form when the +// request carries no signed-in session. func (a *app) home(w http.ResponseWriter, r *http.Request) { s, err := a.manager.Get(r.Context(), httpsec.NewCarrier(w, r)) - if err != nil { + if err != nil || s.GetString("sub") == "" { // a session opened for the form is not signed in http.Redirect(w, r, "/login", http.StatusSeeOther) return } - sub, _ := s.Values["sub"].(string) - - //nolint:gosec // G705: both interpolated values are HTML-escaped above + //nolint:gosec // G705: both interpolated values are HTML-escaped fmt.Fprintf(w, `

Welcome %s

-
`, html.EscapeString(sub), html.EscapeString(session.CSRFToken(s))) +`, html.EscapeString(s.GetString("sub")), html.EscapeString(session.CSRFToken(s))) } -// loginForm renders the login form. -func (a *app) loginForm(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `

Sign in

+// loginForm renders the login form with the CSRF token of the browser's +// session, opening one for the form when there is none: without it another +// site could post its own credentials and sign the victim into the +// attacker's account (login CSRF). +func (a *app) loginForm(w http.ResponseWriter, r *http.Request) { + carrier := httpsec.NewCarrier(w, r) + + s, err := a.manager.Get(r.Context(), carrier) + if err != nil { + if s, err = a.manager.Login(r.Context(), carrier, nil); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + + return + } + } + + //nolint:gosec // G705: the interpolated token is HTML-escaped + fmt.Fprintf(w, `

Sign in

+ -
`) +`, html.EscapeString(session.CSRFToken(s))) } -// login verifies the credentials and mints a session on success. -func (a *app) login(w http.ResponseWriter, r *http.Request) { - username := r.FormValue("username") - hash, ok := a.users[username] +// signIn checks the form's CSRF token, verifies the credentials and mints a +// fresh session — new ID, new CSRF token, new lifetime — on success. +func (a *app) signIn(w http.ResponseWriter, r *http.Request) { + carrier := httpsec.NewCarrier(w, r) - if ok { - match, err := a.hasher.Verify(r.Context(), hash, r.FormValue("password")) - if err == nil && match { - if _, err := a.manager.Login(r.Context(), httpsec.NewCarrier(w, r), principal{subject: username}); err != nil { - http.Error(w, "session error", http.StatusInternalServerError) + form, err := a.manager.Get(r.Context(), carrier) + if err != nil || !session.VerifyCSRF(form, r.FormValue("csrf_token")) { + http.Error(w, "bad CSRF token: reload the sign-in form", http.StatusForbidden) - return - } + return + } - http.Redirect(w, r, "/", http.StatusSeeOther) + auth, err := a.login.Authenticate(r.Context(), basic.New(r.FormValue("username"), r.FormValue("password"))) + if err != nil { + // Same response for unknown user, wrong password and malformed + // input (anti-enumeration). + http.Error(w, "invalid credentials", http.StatusUnauthorized) - return - } + return + } + + if _, err := a.manager.Login(r.Context(), carrier, auth.Principal()); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + + return } - // Same response for unknown user and wrong password (anti-enumeration). - http.Error(w, "invalid credentials", http.StatusUnauthorized) + http.Redirect(w, r, "/", http.StatusSeeOther) } // logout clears the session after checking the CSRF token. @@ -152,6 +201,20 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) } +// newHTTPServer bounds the time a client may take at every stage, so that +// slow clients cannot hold connections open. +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 64 << 10, + } +} + func main() { handler, err := newServer() if err != nil { @@ -160,5 +223,5 @@ func main() { addr := ":8082" log.Printf("session-web: listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, handler)) //nolint:gosec // demo server, no timeouts needed + log.Fatal(newHTTPServer(addr, handler).ListenAndServe()) } diff --git a/examples/session-web/main_test.go b/examples/session-web/main_test.go index 72d5de12..088724f4 100644 --- a/examples/session-web/main_test.go +++ b/examples/session-web/main_test.go @@ -5,9 +5,7 @@ package main import ( - "io" "net/http" - "net/http/cookiejar" "net/http/httptest" "net/url" "regexp" @@ -23,82 +21,40 @@ var csrfRE = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) func TestSessionWebExample(t *testing.T) { t.Parallel() - handler, err := newServer() - require.NoError(t, err) - - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - - jar, err := cookiejar.New(nil) - require.NoError(t, err) - - client := srv.Client() - client.Jar = jar - client.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse // inspect redirects instead of following them - } - - get := func(t *testing.T, path string) *http.Response { - t.Helper() - - resp, err := client.Get(srv.URL + path) - require.NoError(t, err) - - return resp - } - - postForm := func(t *testing.T, path string, form url.Values) *http.Response { - t.Helper() - - resp, err := client.PostForm(srv.URL+path, form) - require.NoError(t, err) - - return resp - } + b := newBrowser(t) // 1. The home page redirects to /login when no session cookie is set. - resp := get(t, "/") - assert.Equal(t, http.StatusSeeOther, resp.StatusCode) - assert.Equal(t, "/login", resp.Header.Get("Location")) - _ = resp.Body.Close() + status, _ := b.do(http.MethodGet, "/", nil, nil) + assert.Equal(t, http.StatusSeeOther, status) // 2. Wrong password is rejected. - resp = postForm(t, "/login", url.Values{"username": {"alice"}, "password": {"wrong"}}) - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - _ = resp.Body.Close() + token := b.loginToken() + assert.Equal(t, http.StatusUnauthorized, b.signIn("alice", "wrong", token, nil)) // 3. Correct credentials mint a session and redirect home. - resp = postForm(t, "/login", url.Values{"username": {"alice"}, "password": {"alice-secret"}}) - assert.Equal(t, http.StatusSeeOther, resp.StatusCode) - assert.Equal(t, "/", resp.Header.Get("Location")) - _ = resp.Body.Close() + assert.Equal(t, http.StatusSeeOther, b.signIn("alice", "alice-secret", token, nil)) // 4. The home page now renders the authenticated view. - resp = get(t, "/") - require.Equal(t, http.StatusOK, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - _ = resp.Body.Close() - assert.Contains(t, string(body), "Welcome alice") + status, body := b.do(http.MethodGet, "/", nil, nil) + require.Equal(t, http.StatusOK, status) + assert.Contains(t, body, "Welcome alice") - match := csrfRE.FindStringSubmatch(string(body)) + match := csrfRE.FindStringSubmatch(body) require.Len(t, match, 2, "home page must embed a CSRF token") csrf := match[1] + assert.NotEqual(t, token, csrf, "signing in minted a fresh session and token") // 5. Logout without the CSRF token is forbidden. - resp = postForm(t, "/logout", url.Values{"csrf_token": {"forged"}}) - assert.Equal(t, http.StatusForbidden, resp.StatusCode) - _ = resp.Body.Close() + status, _ = b.do(http.MethodPost, "/logout", url.Values{"csrf_token": {"forged"}}, nil) + assert.Equal(t, http.StatusForbidden, status) // 6. Logout with the CSRF token clears the session. - resp = postForm(t, "/logout", url.Values{"csrf_token": {csrf}}) - assert.Equal(t, http.StatusSeeOther, resp.StatusCode) - _ = resp.Body.Close() + status, _ = b.do(http.MethodPost, "/logout", url.Values{"csrf_token": {csrf}}, nil) + assert.Equal(t, http.StatusSeeOther, status) // 7. The home page redirects to /login again. - resp = get(t, "/") - assert.Equal(t, http.StatusSeeOther, resp.StatusCode) - _ = resp.Body.Close() + status, _ = b.do(http.MethodGet, "/", nil, nil) + assert.Equal(t, http.StatusSeeOther, status) } func TestSessionWebTamperedCookieIsDropped(t *testing.T) { diff --git a/examples/session-web/session_location_test.go b/examples/session-web/session_location_test.go index 7407f20a..c062417c 100644 --- a/examples/session-web/session_location_test.go +++ b/examples/session-web/session_location_test.go @@ -6,7 +6,6 @@ package main import ( "net/http" - "net/http/httptest" "net/url" "testing" @@ -20,24 +19,21 @@ import ( func TestSessionWebReadsTheSessionFromItsCookieOnly(t *testing.T) { t.Parallel() - handler, err := newServer() - require.NoError(t, err) - - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) + b := newBrowser(t) + require.Equal(t, http.StatusSeeOther, b.signIn("alice", "alice-secret", b.loginToken(), nil)) - client := srv.Client() - client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - - resp, err := client.PostForm(srv.URL+"/login", url.Values{"username": {"alice"}, "password": {"alice-secret"}}) + srvURL, err := url.Parse(b.srv.URL) require.NoError(t, err) - _ = resp.Body.Close() - require.Equal(t, http.StatusSeeOther, resp.StatusCode) - cookies := resp.Cookies() + cookies := b.client.Jar.Cookies(srvURL) require.Len(t, cookies, 1) cookie := cookies[0] + srv := b.srv + // A client without the browser's cookie jar (srv.Client() would return + // the same client). + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + inQuery, err := http.NewRequest(http.MethodGet, srv.URL+"/?"+cookie.Name+"="+url.QueryEscape(cookie.Value), nil) require.NoError(t, err) From 37b0aec4e69e18ee6d540c6b86f076f167f40369 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 02:52:44 +0200 Subject: [PATCH 09/17] docs: record the session, password and basic invariants for contributors CLAUDE.md now states the invariants the previous changes rely on: every basic.Authenticator attempt spends exactly one password verification and reads the account state after it, password hashers check a stored hash before hashing, sessions are format-v2 sealed cookies (keys of at least 32 random bytes, bound to their name and purpose, default __Host-session, absolute lifetime never extended), and the credential-bearing types print without their secrets. The documentation of ambiguous session cookies now matches the behaviour: a request has no session when its cookies hold two different valid sessions; copies of one session do not count. The MIGRATION sections name the previous code and the legacy v0 packages rather than version numbers that read like releases. Audit: CORE-03, CORE-04, CORE-08, CORE-09, CORE-I8, CORE-15. --- CHANGELOG.md | 2 +- CLAUDE.md | 16 ++++++++++++++++ MIGRATION.md | 17 +++++++++-------- docs/security-considerations.md | 9 +++++---- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9052ae..ff8013cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1769,7 +1769,7 @@ legacy packages (`authentication/`, `authorization/`, the in-tree was emitted and silently dropped by browsers; an invalid cookie name produced an empty `Set-Cookie` while `Login` reported success. The default name is now `__Host-session`, the configuration is checked at - construction, a request with two valid session cookies has none, and an + construction, a request carrying two different valid sessions has none, and an oversized cookie is an error. - Credentials printed and serialized: the exported fields of a `session.Session` revealed its CSRF token (and its raw ID) through `%+v` diff --git a/CLAUDE.md b/CLAUDE.md index 796ef7a7..9928a02c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,22 @@ Conventions baked into the core: scope `github.com/hyperscale-stack/security`; each instrumented module (`httpsec`, `grpcsec`, `connectrpcsec`, `jwtsec`, `session`, `samlsp`) uses its own. See `docs/observability.md` for the span catalog. +- **Credential checks spend the same work.** `basic.Authenticator` runs + exactly one `hasher.Verify` per attempt — against a dummy hash made by the + configured hasher for unknown users, loader failures and accounts without + a password — reads the account state after it, and caps concurrent + verifications (`WithMaxConcurrentVerifications`, `ErrUnavailable` when the + caller gives up). `password` hashers check a stored hash's parameters + (RFC 9106 §3.1 ranges, operator limits) before any hashing. +- **Sessions** are sealed cookies: format v2 (AES-256-GCM under an HKDF key + from ≥ 32 random bytes, associated data = version ‖ cookie name ‖ + purpose), default name `__Host-session`, absolute lifetime + `CreatedAt + TTL` never extended. `NewManager` panics on a configuration + browsers would refuse. +- **Credentials never print**: the `basic`, `bearer` and `session` + authentications and `session.Session` print and log without their secrets + (`String`, `Format` for every verb, `LogValue`); `session.Session` also has + a redacting `MarshalJSON`. ## OAuth2 server (`oauth2/`) diff --git a/MIGRATION.md b/MIGRATION.md index d85b73f4..0f2b0e1e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2034,9 +2034,10 @@ To migrate: with those parameters. Watch for `ErrCostLimitExceeded` after the upgrade. 2. bcrypt hashes imported from an implementation that silently truncated passwords to 72 bytes — older `golang.org/x/crypto` releases (this - library's v0 stack used one), PHP's `password_hash`, Node's `bcrypt` — - no longer verify a password longer than 72 bytes. If such accounts exist, - wrap the hasher for them and move them to Argon2id at their next login: + library's legacy v0 packages used one), PHP's `password_hash`, Node's + `bcrypt` — no longer verify a password longer than 72 bytes. If such + accounts exist, wrap the hasher for them and move them to Argon2id at + their next login: ```go type truncating struct{ password.Hasher } @@ -2129,8 +2130,8 @@ To migrate: session — and cookies were not bound to their name or purpose. `Rotate` extended a session's lifetime indefinitely, a TTL lowered after issuance did not apply, and numbers in `Values` lost precision. The cookie format changes -(v2), so **every existing session cookie is refused once: users sign in -again** after the upgrade. +(format 2), so **every existing session cookie is refused once: users sign +in again** after the upgrade. | | Before | After | | --- | --- | --- | @@ -2153,8 +2154,8 @@ To migrate: passphrase. To rotate later, put the new key first and keep the previous one until the longest TTL has elapsed. 2. Deploy all instances at once, or accept that users are signed out while - old and new instances run side by side: a v1 instance cannot read v2 - cookies and the other way round. + old and new instances run side by side: an instance on the previous code + cannot read the new cookies, and the other way round. 3. Several applications or paths sharing keys and a cookie name: give each its own `WithPurpose`. 4. Call `Login` at sign-in (new session, new lifetime); `Rotate` within a @@ -2184,7 +2185,7 @@ that browsers silently refused. | `SameSite=None` without `Secure` | emitted, dropped by browsers | `NewManager` panics | | `WithTTL(d ≤ 0)`, `WithIdleTimeout(d < 0)`, nil codec | accepted | `NewManager` panics | | Cookie name + value over 4096 bytes | emitted, dropped by browsers | `ErrCookieTooLarge`, nothing written | -| Several cookies of the session's name | the first one | the only valid one; none when two or more are valid sessions | +| Several cookies of the session's name | the first one | the only valid session; none when they hold two or more different valid sessions | To migrate: diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 62a6a1b3..c82c04c7 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1182,10 +1182,11 @@ session store to compromise or scale. matched case-insensitively, like browsers do), `SameSite=None` without `Secure`. - **Shadowing cookies** — when a request carries several cookies of the - session's name and more than one is a valid session, it has no session: - a tossed cookie can deny a session (until it expires), never swap it. - Expired or undecodable ones do not count. With the default `__Host-` name - no other host can toss one at all. + session's name and they hold more than one valid session, it has no + session: a tossed cookie can deny a session (until it expires), never + swap it. Expired or undecodable ones do not count, nor do copies of the + same session. With the default `__Host-` name no other host can toss one + at all. - **Cookie only** — the `Manager` reads the session from the request's `Cookie` header field, through `httpsec.Carrier.CookiesNamed`, never from the URL nor from a header named like the cookie: a link carrying a session From d0671edbadb64ce6d1c14d9428c9fc83fe2834fe Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 03:13:05 +0200 Subject: [PATCH 10/17] test(basic,bearer,session): use the generated mockery mocks for interface doubles The tests added by the previous changes now use the mockery mocks for the interfaces of the package under test: basic.UserLoader, PasswordUser and PasswordRehasher (a rehashing loader embeds the two loader mocks), bearer.TokenVerifier and session.PrincipalLoader. The expectations make the tests stricter: an attempt looks its user up exactly once, malformed credentials never reach the loader, and a stored hash is only rewritten when one is expected. Hand-written doubles remain where no generated mock can stand in: password.Hasher in the basic tests and security.Principal in the session tests (mockery generates in-package mocks, so another package's mocks exist only in that package's test binary), and the user records of the redaction tests, whose secrets must sit in fields fmt would print. Audit: CORE-03, CORE-11, CORE-12, CORE-15. --- basic/authenticator_test.go | 2 +- basic/limiter_test.go | 102 +++++++++++-------------- basic/redaction_test.go | 8 +- basic/strict_test.go | 39 +++------- basic/timing_test.go | 148 +++++++++++++++++++++++++----------- bearer/redaction_test.go | 25 +++--- session/redaction_test.go | 26 ++++--- 7 files changed, 195 insertions(+), 155 deletions(-) diff --git a/basic/authenticator_test.go b/basic/authenticator_test.go index 1d5e9f95..2ca8a26b 100644 --- a/basic/authenticator_test.go +++ b/basic/authenticator_test.go @@ -184,6 +184,6 @@ func TestAuthenticatorRejectsForeignAuthentication(t *testing.T) { func TestAuthenticatorName(t *testing.T) { t.Parallel() - a := basic.NewAuthenticator(&stubLoader{}, newHasher(t)) + a := basic.NewAuthenticator(basic.NewMockUserLoader(t), newHasher(t)) assert.Equal(t, "basic", a.AuthenticatorName()) } diff --git a/basic/limiter_test.go b/basic/limiter_test.go index a28e4eaa..8f56046e 100644 --- a/basic/limiter_test.go +++ b/basic/limiter_test.go @@ -17,6 +17,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/basic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -56,10 +57,6 @@ func (h *gateHasher) Verify(ctx context.Context, _, _ string) (bool, error) { func (h *gateHasher) NeedsRehash(string) bool { return false } -func unknownUsers() basic.UserLoader { - return funcLoader(func(string) (basic.PasswordUser, error) { return nil, nil }) -} - // runAttempts starts n concurrent attempts and returns a function waiting // for them — failing the test, instead of hanging it, when they do not end. func runAttempts(t *testing.T, a *basic.Authenticator, n int) func() { @@ -127,14 +124,14 @@ func TestAuthenticatorCapsConcurrentVerificationsByDefault(t *testing.T) { h := newGateHasher() limit := runtime.GOMAXPROCS(0) - assertConcurrencyCap(t, basic.NewAuthenticator(unknownUsers(), h), h, limit, limit+5) + assertConcurrencyCap(t, basic.NewAuthenticator(unknownUsers(t), h), h, limit, limit+5) } func TestWithMaxConcurrentVerifications(t *testing.T) { t.Parallel() h := newGateHasher() - a := basic.NewAuthenticator(unknownUsers(), h, basic.WithMaxConcurrentVerifications(3)) + a := basic.NewAuthenticator(unknownUsers(t), h, basic.WithMaxConcurrentVerifications(3)) assertConcurrencyCap(t, a, h, 3, 12) } @@ -146,7 +143,7 @@ func TestUnlimitedVerifications(t *testing.T) { h := newGateHasher() limit := runtime.GOMAXPROCS(0) + 8 - a := basic.NewAuthenticator(unknownUsers(), h, basic.WithMaxConcurrentVerifications(basic.UnlimitedVerifications)) + a := basic.NewAuthenticator(unknownUsers(t), h, basic.WithMaxConcurrentVerifications(basic.UnlimitedVerifications)) assertConcurrencyCap(t, a, h, limit, limit) } @@ -167,9 +164,10 @@ func TestAuthenticatorVerificationQueueHonorsTheContext(t *testing.T) { t.Parallel() h := newGateHasher() - users := funcLoader(func(username string) (basic.PasswordUser, error) { + alice := mockUser(t, account{subject: "alice", hash: "hash"}) + users := mockLoader(t, func(username string) (basic.PasswordUser, error) { if username == "alice" { - return &stubUser{subject: "alice", passwordHash: "hash", enabled: true}, nil + return alice, nil } return nil, security.ErrInvalidCredentials @@ -229,7 +227,7 @@ func (h *ctxHasher) Verify(context.Context, string, string) (bool, error) { func TestAuthenticatorMapsAContextEndedVerificationToUnavailable(t *testing.T) { t.Parallel() - a := basic.NewAuthenticator(unknownUsers(), &ctxHasher{}) + a := basic.NewAuthenticator(unknownUsers(t), &ctxHasher{}) _, err := a.Authenticate(context.Background(), basic.New("nobody", "pw")) @@ -237,36 +235,23 @@ func TestAuthenticatorMapsAContextEndedVerificationToUnavailable(t *testing.T) { assert.NotErrorIs(t, err, security.ErrInvalidCredentials) } -// rehashLoader is a UserLoader that also stores new password hashes. -type rehashLoader struct { - user *stubUser - mu sync.Mutex - updates []string - err error -} - -func (l *rehashLoader) LoadByUsername(context.Context, string) (basic.PasswordUser, error) { - return l.user, nil +// rehashingLoader is a UserLoader that also stores new password hashes +// (PasswordRehasher), made of the two generated mocks. Without an +// UpdatePasswordHash expectation, any call fails the test. +type rehashingLoader struct { + *basic.MockUserLoader + *basic.MockPasswordRehasher } -func (l *rehashLoader) UpdatePasswordHash(_ context.Context, user basic.PasswordUser, hash string) error { - l.mu.Lock() - defer l.mu.Unlock() - - if user.Subject() != l.user.subject { - return errors.New("wrong user") - } - - l.updates = append(l.updates, hash) - - return l.err -} +// newRehashingLoader returns a rehashingLoader whose lookups of "alice" +// return user. +func newRehashingLoader(t *testing.T, user basic.PasswordUser) rehashingLoader { + t.Helper() -func (l *rehashLoader) updated() []string { - l.mu.Lock() - defer l.mu.Unlock() + l := rehashingLoader{basic.NewMockUserLoader(t), basic.NewMockPasswordRehasher(t)} + l.MockUserLoader.EXPECT().LoadByUsername(mock.Anything, "alice").Return(user, nil).Once() - return append([]string(nil), l.updates...) + return l } // Review finding 2: the dummy hash costs what a hash at the hasher's current @@ -277,70 +262,71 @@ func (l *rehashLoader) updated() []string { func TestAuthenticatorRehashesOutdatedHashes(t *testing.T) { t.Parallel() - newUser := func() *stubUser { return &stubUser{subject: "alice", passwordHash: "old", enabled: true} } + alice := account{subject: "alice", hash: "old"} t.Run("outdated hash is re-hashed after a success", func(t *testing.T) { t.Parallel() - l := &rehashLoader{user: newUser()} - h := &recordingHasher{match: "old", rehash: true} - a := basic.NewAuthenticator(l, h) + user := mockUser(t, alice) + l := newRehashingLoader(t, user) + l.MockPasswordRehasher.EXPECT().UpdatePasswordHash(mock.Anything, user, "dummy-2").Return(nil).Once() // the new hash comes from the hasher + a := basic.NewAuthenticator(l, &recordingHasher{match: "old", rehash: true}) out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) require.NoError(t, err) assert.True(t, out.IsAuthenticated()) - assert.Equal(t, []string{"dummy-2"}, l.updated(), "the new hash comes from the hasher") }) t.Run("current hash is left alone", func(t *testing.T) { t.Parallel() - l := &rehashLoader{user: newUser()} + l := newRehashingLoader(t, mockUser(t, alice)) // no UpdatePasswordHash expected a := basic.NewAuthenticator(l, &recordingHasher{match: "old"}) _, err := a.Authenticate(context.Background(), basic.New("alice", "right")) require.NoError(t, err) - assert.Empty(t, l.updated()) }) - for name, mod := range map[string]func(*stubUser){ - "wrong password": func(u *stubUser) { u.passwordHash = "other" }, - "locked account": func(u *stubUser) { u.locked = true }, + for name, mod := range map[string]func(*account){ + "wrong password": func(a *account) { a.hash = "other" }, + "locked account": func(a *account) { a.locked = true }, } { t.Run("no rehash on a failure: "+name, func(t *testing.T) { t.Parallel() - u := newUser() - mod(u) - l := &rehashLoader{user: u} + u := alice + mod(&u) + l := newRehashingLoader(t, mockUser(t, u)) // no UpdatePasswordHash expected a := basic.NewAuthenticator(l, &recordingHasher{match: "old", rehash: true}) _, err := a.Authenticate(context.Background(), basic.New("alice", "right")) require.ErrorIs(t, err, security.ErrInvalidCredentials) - assert.Empty(t, l.updated()) }) } t.Run("a failed update does not fail the authentication", func(t *testing.T) { t.Parallel() - l := &rehashLoader{user: newUser(), err: errors.New("store read-only")} + user := mockUser(t, alice) + l := newRehashingLoader(t, user) + l.MockPasswordRehasher.EXPECT().UpdatePasswordHash(mock.Anything, user, mock.Anything).Return(errors.New("store read-only")).Once() a := basic.NewAuthenticator(l, &recordingHasher{match: "old", rehash: true}) out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) require.NoError(t, err) assert.True(t, out.IsAuthenticated()) - assert.Len(t, l.updated(), 1) }) t.Run("a loader without PasswordRehasher is never asked", func(t *testing.T) { t.Parallel() - a := basic.NewAuthenticator(&stubLoader{user: newUser()}, &recordingHasher{match: "old", rehash: true}) + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "alice").Return(mockUser(t, alice), nil).Once() + a := basic.NewAuthenticator(l, &recordingHasher{match: "old", rehash: true}) out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) @@ -363,14 +349,13 @@ func (h *failingRehashHasher) Hash(ctx context.Context, pw string) (string, erro func TestAuthenticatorIgnoresAFailedRehash(t *testing.T) { t.Parallel() - l := &rehashLoader{user: &stubUser{subject: "alice", passwordHash: "old", enabled: true}} + l := newRehashingLoader(t, mockUser(t, account{subject: "alice", hash: "old"})) // no UpdatePasswordHash expected a := basic.NewAuthenticator(l, &failingRehashHasher{recordingHasher{match: "old", rehash: true}}) out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) require.NoError(t, err) assert.True(t, out.IsAuthenticated()) - assert.Empty(t, l.updated()) } // A context that ended during the verification gets no slot for the @@ -378,7 +363,7 @@ func TestAuthenticatorIgnoresAFailedRehash(t *testing.T) { func TestAuthenticatorSkipsTheRehashWhenTheContextEnded(t *testing.T) { t.Parallel() - l := &rehashLoader{user: &stubUser{subject: "alice", passwordHash: "old", enabled: true}} + l := newRehashingLoader(t, mockUser(t, account{subject: "alice", hash: "old"})) // no UpdatePasswordHash expected h := &recordingHasher{match: "old", rehash: true, delay: 40 * time.Millisecond} // Verify ignores ctx a := basic.NewAuthenticator(l, h) @@ -389,7 +374,6 @@ func TestAuthenticatorSkipsTheRehashWhenTheContextEnded(t *testing.T) { require.NoError(t, err) assert.True(t, out.IsAuthenticated()) - assert.Empty(t, l.updated()) } // A canceled request never starts a verification. @@ -397,7 +381,7 @@ func TestAuthenticatorDoesNotVerifyForACanceledRequest(t *testing.T) { t.Parallel() h := &recordingHasher{} - a := basic.NewAuthenticator(unknownUsers(), h) + a := basic.NewAuthenticator(unknownUsers(t), h) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -413,7 +397,7 @@ func TestAuthenticatorDoesNotVerifyForACanceledRequest(t *testing.T) { func TestAuthenticatorReleasesItsSlots(t *testing.T) { t.Parallel() - a := basic.NewAuthenticator(unknownUsers(), &recordingHasher{}, basic.WithMaxConcurrentVerifications(1)) + a := basic.NewAuthenticator(unknownUsers(t), &recordingHasher{}, basic.WithMaxConcurrentVerifications(1)) for i := range 5 { ctx, cancel := context.WithTimeout(context.Background(), time.Second) diff --git a/basic/redaction_test.go b/basic/redaction_test.go index b47c4f70..c41e316e 100644 --- a/basic/redaction_test.go +++ b/basic/redaction_test.go @@ -14,6 +14,7 @@ import ( "github.com/hyperscale-stack/security/basic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -54,8 +55,13 @@ func renderings(t *testing.T, v any) []string { func TestAuthenticationRedactsCredentials(t *testing.T) { t.Parallel() + // The user record is a hand-written stubUser, not a generated mock: the + // test needs the hash in a field fmt would print if the Authentication + // leaked its user — a mock keeps its return values behind pointers. user := &stubUser{subject: "alice", passwordHash: "$2a$04$HASH-OF-THE-USER", enabled: true} - a := basic.NewAuthenticator(&stubLoader{user: user}, &recordingHasher{always: true}) + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "alice").Return(user, nil).Once() + a := basic.NewAuthenticator(l, &recordingHasher{always: true}) pending := basic.New("Summer2026!-typed-in-the-user-field", "S3cr3t!") diff --git a/basic/strict_test.go b/basic/strict_test.go index b0c09412..f9d37829 100644 --- a/basic/strict_test.go +++ b/basic/strict_test.go @@ -9,7 +9,6 @@ import ( "encoding/base64" "runtime" "strings" - "sync/atomic" "testing" "unicode" "unicode/utf8" @@ -17,6 +16,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/basic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -121,18 +121,6 @@ func TestExtractorLeavesOtherSchemes(t *testing.T) { } } -// countingLoader counts the lookups it serves. -type countingLoader struct { - calls atomic.Int32 - user *stubUser -} - -func (l *countingLoader) LoadByUsername(context.Context, string) (basic.PasswordUser, error) { - l.calls.Add(1) - - return l.user, nil -} - // Review finding 3: form logins (and the OAuth2 password grant) build the // Authentication with basic.New from their own fields, bypassing the // Extractor. The Authenticator applies the same rules before any lookup — @@ -156,7 +144,7 @@ func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - l := &countingLoader{user: &stubUser{subject: "alice", passwordHash: "h", enabled: true}} + l := basic.NewMockUserLoader(t) // no expectation: the loader never sees malformed credentials h := &recordingHasher{always: true} a := basic.NewAuthenticator(l, h) @@ -165,7 +153,6 @@ func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { require.ErrorIs(t, err, basic.ErrBadFormat) require.ErrorIs(t, err, security.ErrInvalidCredentials) assert.False(t, out.IsAuthenticated()) - assert.Zero(t, l.calls.Load(), "the loader never sees malformed credentials") assert.Empty(t, h.calls(), "nor does the hasher") }) } @@ -176,7 +163,8 @@ func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { func TestAuthenticatorAcceptsTheLongestUserPass(t *testing.T) { t.Parallel() - l := &countingLoader{user: &stubUser{subject: "alice", passwordHash: "h", enabled: true}} + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "alice").Return(mockUser(t, account{subject: "alice", hash: "h"}), nil).Once() a := basic.NewAuthenticator(l, &recordingHasher{always: true}) out, err := a.Authenticate(context.Background(), basic.New("alice", strings.Repeat("p", 3066))) @@ -185,18 +173,6 @@ func TestAuthenticatorAcceptsTheLongestUserPass(t *testing.T) { assert.True(t, out.IsAuthenticated()) } -// caseInsensitiveLoader finds users whatever the case of the typed user-id — -// the usual e-mail lookup. -type caseInsensitiveLoader struct{ user *stubUser } - -func (l caseInsensitiveLoader) LoadByUsername(_ context.Context, username string) (basic.PasswordUser, error) { - if strings.EqualFold(username, l.user.subject) { - return l.user, nil - } - - return nil, nil -} - // Audit CORE-11 (probe TestZZAuditBasicNameIsTypedUsername): Name() returned // the typed user-id ("ALICE") instead of the authenticated subject // ("alice"). Before authentication Name() is the scheme label: the typed @@ -205,8 +181,11 @@ func (l caseInsensitiveLoader) LoadByUsername(_ context.Context, username string func TestAuthenticationNameIsTheSubjectOnceAuthenticated(t *testing.T) { t.Parallel() - user := &stubUser{subject: "alice", passwordHash: "h", enabled: true} - a := basic.NewAuthenticator(caseInsensitiveLoader{user: user}, &recordingHasher{always: true}) + // The loader finds users whatever the case of the typed user-id — the + // usual e-mail lookup. + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "ALICE").Return(mockUser(t, account{subject: "alice", hash: "h"}), nil).Once() + a := basic.NewAuthenticator(l, &recordingHasher{always: true}) in := basic.New("ALICE", "pw") assert.Equal(t, "basic", in.Name()) diff --git a/basic/timing_test.go b/basic/timing_test.go index 3e9ee233..8b424c99 100644 --- a/basic/timing_test.go +++ b/basic/timing_test.go @@ -17,9 +17,15 @@ import ( "github.com/hyperscale-stack/security/basic" "github.com/hyperscale-stack/security/password" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +// The password.Hasher doubles of these tests (recordingHasher, gateHasher and +// their variants, recordingWrapper) are hand-written: mockery generates +// in-package mocks, so password's MockHasher exists only in the password +// package's own test binary, not in this one. + // recordingHasher is a password.Hasher fake: Hash returns a fresh // "dummy-N" value; Verify waits `delay`, records the hash it was given and // accepts the password "right" against the hash `match` — or every @@ -68,46 +74,95 @@ func (h *recordingHasher) calls() []string { return slices.Clone(h.verified) } -// funcLoader is a basic.UserLoader driven by a function. -type funcLoader func(username string) (basic.PasswordUser, error) +// account is the user record behind a generated PasswordUser mock. +type account struct { + subject, hash string + disabled, locked, expired, credentialsExpired bool +} + +// mockUser returns a generated PasswordUser mock answering with a: the +// Authenticator may read each field any number of times. +func mockUser(t *testing.T, a account) *basic.MockPasswordUser { + t.Helper() + + u := basic.NewMockPasswordUser(t) + u.EXPECT().Subject().Return(a.subject).Maybe() + u.EXPECT().GetPasswordHash().Return(a.hash).Maybe() + u.EXPECT().IsEnabled().Return(!a.disabled).Maybe() + u.EXPECT().IsLocked().Return(a.locked).Maybe() + u.EXPECT().IsExpired().Return(a.expired).Maybe() + u.EXPECT().IsCredentialsExpired().Return(a.credentialsExpired).Maybe() -func (f funcLoader) LoadByUsername(_ context.Context, username string) (basic.PasswordUser, error) { - return f(username) + return u } -// attempts lists every outcome an attacker could tell apart by timing. -func attempts(real string) []struct { +// mockLoader returns a generated UserLoader mock answering every lookup with +// load, from any goroutine, any number of times. +func mockLoader(t *testing.T, load func(username string) (basic.PasswordUser, error)) *basic.MockUserLoader { + t.Helper() + + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, username string) (basic.PasswordUser, error) { return load(username) }). + Maybe() + + return l +} + +// unknownUsers returns a generated UserLoader mock that knows nobody. +func unknownUsers(t *testing.T) *basic.MockUserLoader { + t.Helper() + + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, mock.Anything).Return(nil, nil).Maybe() + + return l +} + +// attempt is one outcome an attacker could tell apart by timing. +type attempt struct { name string - loader funcLoader + user *account // nil: the loader returns no user + loadErr error password string dummy bool // verified against the dummy hash -} { - user := func(mod func(*stubUser)) funcLoader { - return func(string) (basic.PasswordUser, error) { - u := &stubUser{subject: "alice", passwordHash: real, enabled: true} - mod(u) +} - return u, nil - } +// loader returns a generated UserLoader mock expecting the attempt's single +// lookup of "alice". +func (c attempt) loader(t *testing.T) *basic.MockUserLoader { + t.Helper() + + var user basic.PasswordUser + if c.user != nil { + user = mockUser(t, *c.user) } - return []struct { - name string - loader funcLoader - password string - dummy bool - }{ - {"unknown user (nil, nil)", func(string) (basic.PasswordUser, error) { return nil, nil }, "right", true}, - {"unknown user (ErrInvalidCredentials)", func(string) (basic.PasswordUser, error) { - return nil, security.ErrInvalidCredentials - }, "right", true}, - {"loader failure", func(string) (basic.PasswordUser, error) { return nil, errors.New("store down") }, "right", true}, - {"account without a password hash", user(func(u *stubUser) { u.passwordHash = "" }), "right", true}, - {"disabled", user(func(u *stubUser) { u.enabled = false }), "right", false}, - {"locked", user(func(u *stubUser) { u.locked = true }), "right", false}, - {"expired", user(func(u *stubUser) { u.expired = true }), "right", false}, - {"credentials expired", user(func(u *stubUser) { u.credentialsExpired = true }), "right", false}, - {"wrong password", user(func(*stubUser) {}), "wrong", false}, + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "alice").Return(user, c.loadErr).Once() + + return l +} + +// attempts lists every outcome an attacker could tell apart by timing. +func attempts(real string) []attempt { + user := func(mod func(*account)) *account { + a := &account{subject: "alice", hash: real} + mod(a) + + return a + } + + return []attempt{ + {name: "unknown user (nil, nil)", password: "right", dummy: true}, + {name: "unknown user (ErrInvalidCredentials)", loadErr: security.ErrInvalidCredentials, password: "right", dummy: true}, + {name: "loader failure", loadErr: errors.New("store down"), password: "right", dummy: true}, + {name: "account without a password hash", user: user(func(a *account) { a.hash = "" }), password: "right", dummy: true}, + {name: "disabled", user: user(func(a *account) { a.disabled = true }), password: "right"}, + {name: "locked", user: user(func(a *account) { a.locked = true }), password: "right"}, + {name: "expired", user: user(func(a *account) { a.expired = true }), password: "right"}, + {name: "credentials expired", user: user(func(a *account) { a.credentialsExpired = true }), password: "right"}, + {name: "wrong password", user: user(func(*account) {}), password: "wrong"}, } } @@ -126,7 +181,7 @@ func TestAuthenticatorVerifiesExactlyOncePerAttempt(t *testing.T) { t.Parallel() h := &recordingHasher{match: "real-hash"} - a := basic.NewAuthenticator(c.loader, h) + a := basic.NewAuthenticator(c.loader(t), h) out, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) @@ -159,7 +214,7 @@ func TestAuthenticatorRefusalsHoldWhateverTheHasherAnswers(t *testing.T) { t.Parallel() h := &recordingHasher{always: true} - a := basic.NewAuthenticator(c.loader, h) + a := basic.NewAuthenticator(c.loader(t), h) out, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) @@ -176,8 +231,10 @@ func TestAuthenticatorChecksTheAccountStateAfterThePassword(t *testing.T) { t.Parallel() h := &recordingHasher{match: "real-hash"} - locked := &stubUser{subject: "alice", passwordHash: "real-hash", enabled: true, locked: true} - a := basic.NewAuthenticator(&stubLoader{user: locked}, h) + locked := mockUser(t, account{subject: "alice", hash: "real-hash", locked: true}) + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "alice").Return(locked, nil).Twice() + a := basic.NewAuthenticator(l, h) _, rightErr := a.Authenticate(context.Background(), basic.New("alice", "right")) _, wrongErr := a.Authenticate(context.Background(), basic.New("alice", "wrong")) @@ -200,7 +257,7 @@ func TestAuthenticatorEveryAttemptTakesAVerification(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() - a := basic.NewAuthenticator(c.loader, &recordingHasher{match: "real-hash", delay: delay}) + a := basic.NewAuthenticator(c.loader(t), &recordingHasher{match: "real-hash", delay: delay}) start := time.Now() _, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) @@ -228,11 +285,11 @@ func TestAuthenticatorResponseTimeDoesNotRevealAccounts(t *testing.T) { hash, err := c.h.Hash(context.Background(), "right") require.NoError(t, err) - users := map[string]*stubUser{ - "alice": {subject: "alice", passwordHash: hash, enabled: true}, - "locked": {subject: "locked", passwordHash: hash, enabled: true, locked: true}, + users := map[string]basic.PasswordUser{ + "alice": mockUser(t, account{subject: "alice", hash: hash}), + "locked": mockUser(t, account{subject: "locked", hash: hash, locked: true}), } - loader := funcLoader(func(username string) (basic.PasswordUser, error) { + loader := mockLoader(t, func(username string) (basic.PasswordUser, error) { if u, ok := users[username]; ok { return u, nil } @@ -274,7 +331,9 @@ func TestNewAuthenticatorMakesTheDummyWithTheConfiguredHasher(t *testing.T) { params := password.Argon2idParams{MemoryKiB: 1024, Time: 3, Parallelism: 2, KeyLen: 32, SaltLen: 16} rec := &recordingWrapper{Hasher: password.NewArgon2idHasher(params)} - a := basic.NewAuthenticator(funcLoader(func(string) (basic.PasswordUser, error) { return nil, nil }), rec) + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "nobody").Return(nil, nil).Once() + a := basic.NewAuthenticator(l, rec) _, err := a.Authenticate(context.Background(), basic.New("nobody", "pw")) require.ErrorIs(t, err, security.ErrInvalidCredentials) @@ -302,7 +361,7 @@ func (w *recordingWrapper) Verify(ctx context.Context, hash, pw string) (bool, e func TestNewAuthenticatorPanics(t *testing.T) { t.Parallel() - loader := funcLoader(func(string) (basic.PasswordUser, error) { return nil, nil }) + loader := basic.NewMockUserLoader(t) // never called assert.PanicsWithValue(t, "basic: NewAuthenticator: nil UserLoader", func() { basic.NewAuthenticator(nil, &recordingHasher{}) @@ -321,8 +380,9 @@ func TestNewAuthenticatorPanics(t *testing.T) { func TestAuthenticatorRefusesAnUnverifiableStoredHash(t *testing.T) { t.Parallel() - user := &stubUser{subject: "alice", passwordHash: "$2a$broken", enabled: true} - a := basic.NewAuthenticator(&stubLoader{user: user}, password.NewBCryptHasher(4)) + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "alice").Return(mockUser(t, account{subject: "alice", hash: "$2a$broken"}), nil).Once() + a := basic.NewAuthenticator(l, password.NewBCryptHasher(4)) out, err := a.Authenticate(context.Background(), basic.New("alice", "right")) diff --git a/bearer/redaction_test.go b/bearer/redaction_test.go index c0743d3d..69674acd 100644 --- a/bearer/redaction_test.go +++ b/bearer/redaction_test.go @@ -15,6 +15,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/bearer" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -77,9 +78,13 @@ func TestWithAuthenticatedDropsTheToken(t *testing.T) { assert.Empty(t, authed.Token(), "the token never outlives its verification") assert.Nil(t, authed.Credentials()) - a := bearer.NewAuthenticator(bearer.VerifierFunc(func(_ context.Context, token string) (security.Authentication, error) { - return bearer.New(token).WithAuthenticated(principal{sub: "alice"}, nil, ""), nil - })) + v := bearer.NewMockTokenVerifier(t) + v.EXPECT().Verify(mock.Anything, secretToken). + RunAndReturn(func(_ context.Context, token string) (security.Authentication, error) { + return bearer.New(token).WithAuthenticated(principal{sub: "alice"}, nil, ""), nil + }).Once() + + a := bearer.NewAuthenticator(v) out, err := a.Authenticate(context.Background(), bearer.New(secretToken)) require.NoError(t, err) @@ -94,9 +99,10 @@ func TestWithAuthenticatedDropsTheToken(t *testing.T) { func TestAuthenticatorFailureReturnsAnonymous(t *testing.T) { t.Parallel() - a := bearer.NewAuthenticator(bearer.VerifierFunc(func(context.Context, string) (security.Authentication, error) { - return nil, security.ErrTokenExpired - })) + v := bearer.NewMockTokenVerifier(t) + v.EXPECT().Verify(mock.Anything, secretToken).Return(nil, security.ErrTokenExpired).Once() // Anonymous is not supported: never verified + + a := bearer.NewAuthenticator(v) for _, in := range []security.Authentication{bearer.New(secretToken), security.Anonymous()} { out, err := a.Authenticate(context.Background(), in) @@ -104,9 +110,10 @@ func TestAuthenticatorFailureReturnsAnonymous(t *testing.T) { assert.Equal(t, security.Anonymous(), out) } - nilVerifier := bearer.NewAuthenticator(bearer.VerifierFunc(func(context.Context, string) (security.Authentication, error) { - return nil, nil - })) + nilResult := bearer.NewMockTokenVerifier(t) + nilResult.EXPECT().Verify(mock.Anything, secretToken).Return(nil, nil).Once() + + nilVerifier := bearer.NewAuthenticator(nilResult) out, err := nilVerifier.Authenticate(context.Background(), bearer.New(secretToken)) require.ErrorIs(t, err, security.ErrInvalidCredentials) diff --git a/session/redaction_test.go b/session/redaction_test.go index 603b8fb8..d46546a4 100644 --- a/session/redaction_test.go +++ b/session/redaction_test.go @@ -15,6 +15,7 @@ import ( "github.com/hyperscale-stack/security" "github.com/hyperscale-stack/security/session" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -118,7 +119,16 @@ func TestSessionAuthenticationRedactsItself(t *testing.T) { s, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) require.NoError(t, err) - engine := security.NewEngine(security.NewManager(session.NewAuthenticator(recordLoader{})), session.NewExtractor(mgr)) + // The loader resolves the session's subject into a userRecord. + loader := session.NewMockPrincipalLoader(t) + loader.EXPECT().Load(mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, values map[string]any) (security.Principal, []string, error) { + sub, _ := values["sub"].(string) + + return userRecord{sub: sub, passwordHash: "$2a$10$HASH-OF-THE-USER"}, []string{"ROLE_USER"}, nil + }).Once() + + engine := security.NewEngine(security.NewManager(session.NewAuthenticator(loader)), session.NewExtractor(mgr)) _, auth, err := engine.Process(context.Background(), c.replay()) require.NoError(t, err) @@ -139,19 +149,13 @@ func TestSessionAuthenticationRedactsItself(t *testing.T) { } // userRecord is an application principal holding a secret field, as a user -// record loaded from a store does. +// record loaded from a store does. It is hand-written: security.Principal +// belongs to the core package, whose generated mocks exist only in the +// core's own test binary, and the test needs the secret in a field fmt +// would print. type userRecord struct { sub string passwordHash string } func (u userRecord) Subject() string { return u.sub } - -// recordLoader resolves the session's subject into a userRecord. -type recordLoader struct{} - -func (recordLoader) Load(_ context.Context, values map[string]any) (security.Principal, []string, error) { - sub, _ := values["sub"].(string) - - return userRecord{sub: sub, passwordHash: "$2a$10$HASH-OF-THE-USER"}, []string{"ROLE_USER"}, nil -} From 5f5e43681f05556a0022ece8160cb4a29929a1b9 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 07:31:08 +0200 Subject: [PATCH 11/17] fix(session): refuse copies of one session like any two valid sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request whose cookies of the session's name open as two or more valid sessions has no session again, even when they carry the same session ID. Copies of one session can differ — a copy planted before a sign-in that skipped Rotate carries the attacker's values under the victim's ID — and nothing tells which one the browser meant, so accepting same-ID copies let a planted copy win. This reverts the relaxation of the previous "copies of one session" change. Stale duplicates, left by a change of Domain or Path while the cookie keeps its name, now make every request of the browser sessionless until the old cookie expires: the Manager.Get documentation, MIGRATION and the security considerations tell how to clear each old variant once with Max-Age=0. Audit: CORE-I8. --- CHANGELOG.md | 5 ++-- MIGRATION.md | 10 +++++++- docs/security-considerations.md | 14 +++++++---- session/hygiene_test.go | 41 +++++++++++++++++++++++++++++---- session/manager.go | 26 +++++++++++++-------- 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8013cf..839a8590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1769,8 +1769,9 @@ legacy packages (`authentication/`, `authorization/`, the in-tree was emitted and silently dropped by browsers; an invalid cookie name produced an empty `Set-Cookie` while `Login` reported success. The default name is now `__Host-session`, the configuration is checked at - construction, a request carrying two different valid sessions has none, and an - oversized cookie is an error. + construction, a request whose cookies open as two valid sessions — + copies of one session included — has none, and an oversized cookie is an + error. - Credentials printed and serialized: the exported fields of a `session.Session` revealed its CSRF token (and its raw ID) through `%+v` and `encoding/json` — `slog`'s JSON handler included; `basic` and `bearer` diff --git a/MIGRATION.md b/MIGRATION.md index 0f2b0e1e..fce0aa33 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2185,7 +2185,7 @@ that browsers silently refused. | `SameSite=None` without `Secure` | emitted, dropped by browsers | `NewManager` panics | | `WithTTL(d ≤ 0)`, `WithIdleTimeout(d < 0)`, nil codec | accepted | `NewManager` panics | | Cookie name + value over 4096 bytes | emitted, dropped by browsers | `ErrCookieTooLarge`, nothing written | -| Several cookies of the session's name | the first one | the only valid session; none when they hold two or more different valid sessions | +| Several cookies of the session's name | the first one | the only valid one; none when two or more open as valid sessions, copies of one session included | To migrate: @@ -2202,6 +2202,14 @@ To migrate: documentation and the session-web example (sign-in form protected through a session opened for the form, `net/http`'s `CrossOriginProtection` in front of the handlers). +5. After changing `WithDomain` or `WithPath` while the cookie keeps its + name (an explicit name, or `__Secure-session` from one `Domain` or + `Path` to another), browsers keep the cookie of the earlier `Domain` or + `Path` next to the new one: two valid sessions, so no session, until the + old cookie expires. Clear each old variant once, at sign-in for + instance, with a `Set-Cookie` of the same name, the old `Domain` and + `Path` and `Max-Age=0` — in Go, `http.Cookie{Name: name, Domain: + oldDomain, Path: oldPath, MaxAge: -1, Secure: true}`. ### `session`, `basic`, `bearer`: credentials stay out of `fmt`, `slog` and JSON; the bearer token is dropped once verified diff --git a/docs/security-considerations.md b/docs/security-considerations.md index c82c04c7..ad738457 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1182,11 +1182,17 @@ session store to compromise or scale. matched case-insensitively, like browsers do), `SameSite=None` without `Secure`. - **Shadowing cookies** — when a request carries several cookies of the - session's name and they hold more than one valid session, it has no + session's name and more than one opens as a valid session, it has no session: a tossed cookie can deny a session (until it expires), never - swap it. Expired or undecodable ones do not count, nor do copies of the - same session. With the default `__Host-` name no other host can toss one - at all. + swap it. Copies of one session count as several — they can differ, as a + copy planted before a sign-in without `Rotate` does — while expired or + undecodable ones do not count. With the default `__Host-` name no other + host can toss one at all. Stale duplicates have the same effect: after + changing `WithDomain` or `WithPath` while the cookie keeps its name, + browsers keep the cookie of the earlier `Domain` or `Path` next to the + new one, so clear each old variant once with `Max-Age=0` — a + `Set-Cookie` of the same name with the old `Domain` and `Path` (in Go, + `http.Cookie{…, MaxAge: -1}`). - **Cookie only** — the `Manager` reads the session from the request's `Cookie` header field, through `httpsec.Carrier.CookiesNamed`, never from the URL nor from a header named like the cookie: a link carrying a session diff --git a/session/hygiene_test.go b/session/hygiene_test.go index 053e7a54..2b51426c 100644 --- a/session/hygiene_test.go +++ b/session/hygiene_test.go @@ -232,12 +232,21 @@ func TestManagerRefusesAmbiguousSessionCookies(t *testing.T) { _, err = request(victim, attacker) require.ErrorIs(t, err, session.ErrNoSession, "in either order") + // Decision Q1, literal rule: copies of one session are ambiguous too. + // They need not be equal — a copy planted before a sign-in without + // Rotate carries the attacker's values under the victim's ID — and the + // Manager cannot tell which one the browser meant. + _, err = request(victim, victim) + require.ErrorIs(t, err, session.ErrNoSession, "two copies of one session: ambiguous") + + _, err = request(victim, resealed(t, mgr, victim)) + require.ErrorIs(t, err, session.ErrNoSession, "the same ID with other values: ambiguous") + for name, values := range map[string][]string{ - "the same session twice": {victim, victim}, - "garbage first": {"garbage", victim}, - "garbage last": {victim, "garbage"}, - "expired first": {stale, victim}, - "another key's first": {v2Cookie(t, randomKey(t), 2, 2, name, "", samplePayload(t, now)), victim}, + "garbage first": {"garbage", victim}, + "garbage last": {victim, "garbage"}, + "expired first": {stale, victim}, + "another key's first": {v2Cookie(t, randomKey(t), 2, 2, name, "", samplePayload(t, now)), victim}, } { s, err := request(values...) require.NoError(t, err, name) @@ -254,6 +263,28 @@ func TestManagerRefusesAmbiguousSessionCookies(t *testing.T) { require.ErrorIs(t, err, session.ErrNoSession, "more than four cookies of one name are not inspected") } +// resealed returns a cookie of the session sealed in value — same ID — with +// another value written into it. +func resealed(t *testing.T, mgr *session.Manager, value string) string { + t.Helper() + + in := newCarrier() + in.cookies[mgr.CookieName()] = value + + s, err := mgr.Get(context.Background(), in) + require.NoError(t, err) + + s.Values["role"] = "admin" + + out := newCarrier() + require.NoError(t, mgr.Touch(context.Background(), out, s)) + + copied := out.replay().cookies[mgr.CookieName()] + require.NotEqual(t, value, copied) + + return copied +} + // Audit §9 surviving mutant: the deletion cookie's Max-Age was never // checked (the test passed because the value was empty). It must carry // Max-Age=0 and the same Path, Domain and attributes as the session cookie, diff --git a/session/manager.go b/session/manager.go index 94da6864..f5235111 100644 --- a/session/manager.go +++ b/session/manager.go @@ -298,9 +298,13 @@ func (m *Manager) Login(ctx context.Context, c security.Carrier, principal secur // deadline — the earlier of its ExpiresAt and CreatedAt + the Manager's TTL // — or its idle timeout. A validator ([WithValidator]) then decides; an // error wrapping [security.ErrUnavailable] means the session could not be -// checked: answer 503, do not sign the user out. On success Get refreshes -// LastAccessed in memory but does NOT rewrite the cookie — call -// [Manager.Touch] when sliding expiry is desired. +// checked: answer 503, do not sign the user out. When several cookies of +// the session's name open as valid sessions — copies of one session +// included — the request is ambiguous and Get returns [ErrNoSession]: after +// changing [WithDomain] or [WithPath] while the cookie keeps its name, +// clear each cookie of the earlier Domain or Path once with Max-Age=0. On +// success Get refreshes LastAccessed in memory but does NOT rewrite the +// cookie — call [Manager.Touch] when sliding expiry is desired. func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) { _, span := otel.Tracer(tracerName).Start(ctx, "session.Manager.Get") defer span.End() @@ -323,11 +327,13 @@ func (m *Manager) Get(ctx context.Context, c security.Carrier) (*Session, error) // pick returns the one valid session among the cookies of the session's // name. Several cookies of one name come from a cookie tossed by a sibling -// host or a more specific path, or from a stale configuration: when they -// hold different valid sessions the request is ambiguous and has none — a -// tossed cookie can deny a session, never swap it. Copies of the same -// session (same ID), cookies that do not open and expired ones do not make -// it ambiguous. An unavailable validator ends the search. +// host or a more specific path, or from a stale configuration: when more +// than one is a valid session the request is ambiguous and has none — a +// tossed cookie can deny a session, never swap it. Copies of one session +// count as several: they can differ (a copy planted before a sign-in +// without Rotate), and nothing tells which one the browser meant. Cookies +// that do not open or are expired do not count. An unavailable validator +// ends the search. func (m *Manager) pick(ctx context.Context, found []*http.Cookie) (*Session, error) { if len(found) == 0 { return nil, ErrNoSession @@ -346,9 +352,9 @@ func (m *Manager) pick(ctx context.Context, found []*http.Cookie) (*Session, err s, err := m.load(ctx, cookie.Value) switch { - case err == nil && chosen != nil && s.ID != chosen.ID: + case err == nil && chosen != nil: return nil, fmt.Errorf("%w: several valid session cookies", ErrNoSession) - case err == nil && chosen == nil: + case err == nil: chosen = s case errors.Is(err, security.ErrUnavailable): return nil, err From 85773c34b6a851642bf0245c678333104e23aa31 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 08:21:59 +0200 Subject: [PATCH 12/17] fix(session,examples): keep the sign-in form's session on a dedicated Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSRF guidance told applications to protect their sign-in form with the token of a session opened with Login(ctx, c, nil) — on the Manager the Engine reads. That session has no subject: the Engine extracted it on every request, the PrincipalLoader refused it as its documentation asks, and every page of the browser, the sign-in form included, answered 401 for the cookie's lifetime. The obvious workaround, a loader mapping a session without subject to an anonymous or guest principal, made the Authenticator return an authenticated value that voter.Authenticated grants. The VerifyCSRF, Login and PrincipalLoader documentation, the package documentation, MIGRATION and the security considerations now open the form's session on a dedicated Manager — its own cookie name and purpose, a short TTL — that no Extractor reads, and say that a PrincipalLoader must refuse a session without a subject. The session-web example does so (cookie __Host-signin, cleared at sign-in), and a test runs the trap and the pattern through a session Engine. Audit: CORE-I8, CORE-I9. --- CHANGELOG.md | 5 +- MIGRATION.md | 10 ++-- docs/security-considerations.md | 23 ++++++--- examples/session-web/login_test.go | 29 ++++++++++++ examples/session-web/main.go | 49 ++++++++++++------- session/authenticator.go | 6 +++ session/csrf.go | 19 ++++++-- session/doc.go | 6 ++- session/manager.go | 5 ++ session/signin_test.go | 76 ++++++++++++++++++++++++++++++ 10 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 session/signin_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 839a8590..38a3a9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1790,5 +1790,6 @@ legacy packages (`authentication/`, `authorization/`, the in-tree site could sign a victim into the attacker's account (login CSRF), and it verified the password of known users only, so its response time told which accounts exist. The form now carries the token of a session opened - for it, cross-origin posts are refused (`http.CrossOriginProtection`) and - the credentials go through `basic.Authenticator`. + for it on a `Manager` of its own (cookie `__Host-signin`), cross-origin + posts are refused (`http.CrossOriginProtection`) and the credentials go + through `basic.Authenticator`. diff --git a/MIGRATION.md b/MIGRATION.md index fce0aa33..e38cd99b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2199,9 +2199,13 @@ To migrate: 3. Keep `Values` small: a write that would exceed 4096 bytes now returns `ErrCookieTooLarge`. 4. Enforce CSRF on every state-changing request — see the `VerifyCSRF` - documentation and the session-web example (sign-in form protected - through a session opened for the form, `net/http`'s - `CrossOriginProtection` in front of the handlers). + documentation and the session-web example (`net/http`'s + `CrossOriginProtection` in front of the handlers). Protect the sign-in + form with the token of a session opened for it on a dedicated `Manager` + (its own cookie name and purpose, e.g. `__Host-signin`), never on the + one behind the Engine: there, the `PrincipalLoader` would refuse the + subject-less session on every request (401). A `PrincipalLoader` must + refuse a session without a subject, never map it to a principal. 5. After changing `WithDomain` or `WithPath` while the cookie keeps its name (an explicit name, or `__Secure-session` from one `Domain` or `Path` to another), browsers keep the cookie of the earlier `Domain` or diff --git a/docs/security-considerations.md b/docs/security-considerations.md index ad738457..a163aa6d 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -1208,13 +1208,22 @@ session store to compromise or scale. - **CSRF** — the synchronizer-token helper (`CSRFToken` / `VerifyCSRF`) compares tokens in constant time, but it is a check, not a policy: call `VerifyCSRF` on every state-changing request a cookie session - authenticates — the sign-in form included, through a session opened for - the form (`Login` with a nil principal), against login CSRF — never change - state on GET, and put `net/http`'s `CrossOriginProtection` (Sec-Fetch-Site - and Origin checks) in front of the handlers. `SameSite=Lax` does not stop - same-site requests from a sibling subdomain. The cookie is `HttpOnly`, but - the token is rendered into the page: an XSS reads it — no CSRF defense - survives XSS. + authenticates, never change state on GET, and put `net/http`'s + `CrossOriginProtection` (Sec-Fetch-Site and Origin checks) in front of the + handlers. `SameSite=Lax` does not stop same-site requests from a sibling + subdomain. The cookie is `HttpOnly`, but the token is rendered into the + page: an XSS reads it — no CSRF defense survives XSS. +- **Sign-in form** — protect it too, against login CSRF, with the token of a + session opened for the form (`Login` with a nil principal) on a + **dedicated `Manager`**: its own cookie name and purpose, a short TTL + (`WithCookieName("__Host-signin")`, `WithPurpose("sign-in form")`, + `WithTTL(15*time.Minute)`), read by no `Extractor`. On the `Manager` + behind the Engine, the Engine would extract that subject-less session on + every request and the `PrincipalLoader` refuse it: every page, the + sign-in form included, would answer 401. Do not work around that by + mapping a session without a subject to a principal — anonymous or guest: + the `Authenticator` would return an **authenticated** value, which + `voter.Authenticated` grants. The session-web example shows the pattern. - **Size** — the whole session is JSON-encoded into the cookie, and browsers ignore a cookie whose name and value exceed 4096 bytes (rfc6265bis §5.6): `Login`, `Touch` and `Rotate` refuse it with `ErrCookieTooLarge` instead diff --git a/examples/session-web/login_test.go b/examples/session-web/login_test.go index 24bd94c8..de446112 100644 --- a/examples/session-web/login_test.go +++ b/examples/session-web/login_test.go @@ -187,3 +187,32 @@ func TestNewHTTPServerBoundsSlowClients(t *testing.T) { assert.Positive(t, srv.IdleTimeout) assert.Positive(t, srv.MaxHeaderBytes) } + +// Review finding 1: the sign-in form's session lives on a Manager of its own +// (cookie __Host-signin, purpose "sign-in form", 15 minutes), never on the +// session cookie: behind a session Engine, a session without a subject on +// __Host-session is refused by the PrincipalLoader on every request, the +// sign-in form included. The form's session is cleared at sign-in. +func TestSessionWebSignInFormHasItsOwnSession(t *testing.T) { + t.Parallel() + + b := newBrowser(t) + token := b.loginToken() + + u, err := url.Parse(b.srv.URL) + require.NoError(t, err) + + names := func() []string { + var out []string + for _, c := range b.client.Jar.Cookies(u) { + out = append(out, c.Name) + } + + return out + } + + assert.Equal(t, []string{"__Host-signin"}, names(), "the form's session only") + + require.Equal(t, http.StatusSeeOther, b.signIn("alice", "alice-secret", token, nil)) + assert.Equal(t, []string{"__Host-session"}, names(), "the signed-in session only") +} diff --git a/examples/session-web/main.go b/examples/session-web/main.go index 8994d031..44f1d32d 100644 --- a/examples/session-web/main.go +++ b/examples/session-web/main.go @@ -7,11 +7,14 @@ // It is a tiny login-form application: a successful login mints an // encrypted session cookie, the home page reads it, and logout clears it. // Both forms are protected against CSRF: the sign-in form by the token of a -// session opened for it (against login CSRF), the logout form by the -// session's token; net/http's CrossOriginProtection also refuses -// cross-origin form posts. The credentials are checked by a -// basic.Authenticator, which spends one password verification on every -// attempt, so the response time does not tell which accounts exist. +// session opened for it on a Manager of its own (cookie __Host-signin, +// against login CSRF), the logout form by the session's token; net/http's +// CrossOriginProtection also refuses cross-origin form posts. The form's +// session never shares the session cookie: behind a session Engine, a +// session without a subject would be refused on every request. The +// credentials are checked by a basic.Authenticator, which spends one +// password verification on every attempt, so the response time does not +// tell which accounts exist. // // Run: // @@ -66,8 +69,9 @@ func (u users) LoadByUsername(_ context.Context, username string) (basic.Passwor // app holds the demo dependencies. type app struct { - manager *session.Manager - login *basic.Authenticator + manager *session.Manager // signed-in sessions + form *session.Manager // the sign-in form's session (its CSRF token) + login *basic.Authenticator // password verification } // newServer builds the demo handler. It is separate from main so the @@ -92,7 +96,13 @@ func newServer() (http.Handler, error) { a := &app{ manager: session.NewManager(codec), - login: basic.NewAuthenticator(users{demoUser: {subject: demoUser, hash: aliceHash}}, hasher), + // Its own cookie name and purpose: the sign-in form's session is + // never read as a signed-in session. + form: session.NewManager(codec, + session.WithCookieName("__Host-signin"), + session.WithPurpose("sign-in form"), + session.WithTTL(15*time.Minute)), + login: basic.NewAuthenticator(users{demoUser: {subject: demoUser, hash: aliceHash}}, hasher), } mux := http.NewServeMux() @@ -110,7 +120,7 @@ func newServer() (http.Handler, error) { // request carries no signed-in session. func (a *app) home(w http.ResponseWriter, r *http.Request) { s, err := a.manager.Get(r.Context(), httpsec.NewCarrier(w, r)) - if err != nil || s.GetString("sub") == "" { // a session opened for the form is not signed in + if err != nil || s.GetString("sub") == "" { // a session without a subject is never signed in http.Redirect(w, r, "/login", http.StatusSeeOther) return @@ -124,16 +134,16 @@ func (a *app) home(w http.ResponseWriter, r *http.Request) { `, html.EscapeString(s.GetString("sub")), html.EscapeString(session.CSRFToken(s))) } -// loginForm renders the login form with the CSRF token of the browser's -// session, opening one for the form when there is none: without it another -// site could post its own credentials and sign the victim into the -// attacker's account (login CSRF). +// loginForm renders the login form with the CSRF token of the form's +// session, opening one when there is none: without it another site could +// post its own credentials and sign the victim into the attacker's account +// (login CSRF). func (a *app) loginForm(w http.ResponseWriter, r *http.Request) { carrier := httpsec.NewCarrier(w, r) - s, err := a.manager.Get(r.Context(), carrier) + s, err := a.form.Get(r.Context(), carrier) if err != nil { - if s, err = a.manager.Login(r.Context(), carrier, nil); err != nil { + if s, err = a.form.Login(r.Context(), carrier, nil); err != nil { http.Error(w, "session error", http.StatusInternalServerError) return @@ -150,12 +160,13 @@ func (a *app) loginForm(w http.ResponseWriter, r *http.Request) { `, html.EscapeString(session.CSRFToken(s))) } -// signIn checks the form's CSRF token, verifies the credentials and mints a -// fresh session — new ID, new CSRF token, new lifetime — on success. +// signIn checks the form's CSRF token, verifies the credentials and, on +// success, clears the form's session and mints a fresh session — new ID, +// new CSRF token, new lifetime. func (a *app) signIn(w http.ResponseWriter, r *http.Request) { carrier := httpsec.NewCarrier(w, r) - form, err := a.manager.Get(r.Context(), carrier) + form, err := a.form.Get(r.Context(), carrier) if err != nil || !session.VerifyCSRF(form, r.FormValue("csrf_token")) { http.Error(w, "bad CSRF token: reload the sign-in form", http.StatusForbidden) @@ -171,6 +182,8 @@ func (a *app) signIn(w http.ResponseWriter, r *http.Request) { return } + a.form.Logout(r.Context(), carrier) + if _, err := a.manager.Login(r.Context(), carrier, auth.Principal()); err != nil { http.Error(w, "session error", http.StatusInternalServerError) diff --git a/session/authenticator.go b/session/authenticator.go index d592071c..7cf7b6a7 100644 --- a/session/authenticator.go +++ b/session/authenticator.go @@ -29,6 +29,12 @@ type PrincipalLoader interface { // scopes one security.ScopePrefix + scope authority each // ("scope:read"): that is what voter.HasRole and voter.HasScope match. // + // A session without a subject — opened with Manager.Login(ctx, c, nil) + // — MUST be refused with security.ErrInvalidCredentials, never mapped + // to a principal (anonymous or guest): the Authenticator would return + // an authenticated value that voter.Authenticated grants. Keep such + // sessions (a sign-in form's) on a Manager no Extractor reads. + // // Returning an error fails authentication, and its sentinel tells the // transports how to answer: wrap security.ErrInvalidCredentials for a // session that must be refused (unknown or disabled user: 401), diff --git a/session/csrf.go b/session/csrf.go index 122c608a..78dc8b4b 100644 --- a/session/csrf.go +++ b/session/csrf.go @@ -26,14 +26,27 @@ func CSRFToken(s *Session) string { // // VerifyCSRF is a check, not a policy: nothing enforces it for you. Call it // on every state-changing request (POST, PUT, PATCH, DELETE) that a cookie -// session authenticates — the sign-in form included, through a session -// opened for the form before sign-in (Manager.Login with a nil principal), -// against login CSRF — never change state on GET, and add a cross-origin +// session authenticates, never change state on GET, and add a cross-origin // check in front of the handlers (net/http's CrossOriginProtection, which // refuses cross-origin unsafe requests from their Sec-Fetch-Site and Origin // headers). SameSite=Lax keeps the cookie off cross-site POSTs in current // browsers but not off same-site requests from a sibling subdomain. // +// The sign-in form needs a token too, against login CSRF, before there is a +// signed-in session: open a session for the form on a dedicated Manager — +// its own cookie name and purpose, a short TTL — that no [Extractor] reads, +// with Login(ctx, c, nil) when the form is rendered, and check the posted +// token against it: +// +// form := session.NewManager(codec, +// session.WithCookieName("__Host-signin"), +// session.WithPurpose("sign-in form"), +// session.WithTTL(15*time.Minute)) +// +// Never open it on the Manager an Extractor reads: the Engine would extract +// the subject-less session on every request and the [PrincipalLoader] +// refuse it — every page, the sign-in form included, would answer 401. +// // This is the synchronizer-token pattern: the server holds the canonical // token in the (encrypted) session and checks the value the client echoed // back in, e.g., the "X-CSRF-Token" header or a form field. Unlike the diff --git a/session/doc.go b/session/doc.go index a3cf6780..ce8ed64f 100644 --- a/session/doc.go +++ b/session/doc.go @@ -21,8 +21,10 @@ // ErrCookieTooLarge rather than silently dropped by the browser. // // CSRF: VerifyCSRF checks a synchronizer token; the application must call it -// on every state-changing request (the sign-in form included) and should -// put net/http's CrossOriginProtection in front of its handlers. +// on every state-changing request and should put net/http's +// CrossOriginProtection in front of its handlers. The sign-in form takes +// its token from a session on a dedicated Manager (its own cookie name and +// purpose) that no Extractor reads — see VerifyCSRF. // // A session lives at most its TTL from its creation: the Manager enforces // CreatedAt + TTL on every read, and neither Rotate nor Touch extends it. diff --git a/session/manager.go b/session/manager.go index f5235111..ecb0e072 100644 --- a/session/manager.go +++ b/session/manager.go @@ -266,6 +266,11 @@ func probe(name, path, domain string) *http.Cookie { // Login mints a fresh authenticated session for principal, stores its // subject under the "sub" value, and writes the session cookie via the // carrier. Any prior session is replaced (a fresh ID defeats fixation). +// +// A nil principal opens a session without a subject — no "sub" value, not +// signed in — such as the one a sign-in form takes its CSRF token from: +// open it on a dedicated Manager that no [Extractor] reads (see +// [VerifyCSRF]), never on the Manager behind the Engine. func (m *Manager) Login(ctx context.Context, c security.Carrier, principal security.Principal) (*Session, error) { _, span := otel.Tracer(tracerName).Start(ctx, "session.Manager.Login") defer span.End() diff --git a/session/signin_test.go b/session/signin_test.go new file mode 100644 index 00000000..25392069 --- /dev/null +++ b/session/signin_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "testing" + "time" + + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Review finding 1: a sign-in form protected by a session opened with +// Login(ctx, c, nil) on the Manager the Engine reads is extracted on every +// request and refused by the PrincipalLoader (it has no subject): every +// page of the browser answers 401, the sign-in form included. Opened on a +// dedicated Manager — its own cookie name and purpose, a short TTL — the +// form's session never reaches the Engine. +func TestSignInFormSessionOnADedicatedManager(t *testing.T) { + t.Parallel() + + codec, err := session.NewCodec(testKey) + require.NoError(t, err) + + sessions := session.NewManager(codec) + form := session.NewManager(codec, + session.WithCookieName("__Host-signin"), + session.WithPurpose("sign-in form"), + session.WithTTL(15*time.Minute)) + + // The PrincipalLoader refuses a session without a subject, as its + // documentation requires; it is reached by the trap only. + loader := session.NewMockPrincipalLoader(t) + loader.EXPECT().Load(mock.Anything, mock.Anything).Return(nil, nil, security.ErrInvalidCredentials).Once() + + engine := engineFor(sessions, loader) + + // The trap: the form's session opened on the Manager the Engine reads. + trap := newCarrier() + _, err = sessions.Login(context.Background(), trap, nil) + require.NoError(t, err) + + _, _, err = engine.Process(context.Background(), trap.replay()) + require.ErrorIs(t, err, security.ErrInvalidCredentials, "every request of the browser is refused") + + // The pattern: the form's session on its own Manager. + c := newCarrier() + s, err := form.Login(context.Background(), c, nil) + require.NoError(t, err) + + browser := c.replay() + require.Contains(t, browser.cookies, "__Host-signin") + require.NotContains(t, browser.cookies, sessions.CookieName()) + + _, auth, err := engine.Process(context.Background(), browser) + require.NoError(t, err, "the Engine does not see the form's session") + assert.False(t, auth.IsAuthenticated()) + + got, err := form.Get(context.Background(), browser) + require.NoError(t, err) + assert.True(t, session.VerifyCSRF(got, s.CSRFToken)) + + // Not even under the session cookie's name: the cookie name and the + // purpose are bound into the cookie. + renamed := newCarrier() + renamed.cookies[sessions.CookieName()] = browser.cookies["__Host-signin"] + + _, err = sessions.Get(context.Background(), renamed) + require.ErrorIs(t, err, session.ErrNoSession) +} From 4c477367685a0fc60697e1677fa5772e7ba82ae2 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 08:22:14 +0200 Subject: [PATCH 13/17] fix(session): report the enforced deadline and fail to the anonymous authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get returned the ExpiresAt carried by the cookie, later than the deadline the Manager enforces once its TTL was lowered; it now reports the enforced deadline, the earlier of that ExpiresAt and CreatedAt + TTL. The Session field documentation said the CSRF token is never exposed to JavaScript — it is rendered into pages, where an XSS reads it — and that LastAccessed is refreshed on every load, while the idle timeout keys off the value written by Login, Touch and Rotate. Like the basic and bearer authenticators, a failed session.Authenticator.Authenticate returns security.Anonymous() rather than its input, the pending value holding the decoded session. Tests pin the four-cookie bound of a request (four cookies of the session's name are inspected, five are not), and FuzzManagerGet sends two arbitrary cookies of the session's name: Get never panics, opens only sealed cookies, and two copies of one cookie never make a session. Audit: CORE-08, CORE-I8, CORE-15. --- CHANGELOG.md | 9 +++--- MIGRATION.md | 3 +- session/authenticator.go | 8 +++-- session/fuzz_test.go | 61 +++++++++++++++++++++++++++++++++++++++ session/hygiene_test.go | 4 +++ session/lifetime_test.go | 3 +- session/manager.go | 2 ++ session/redaction_test.go | 30 +++++++++++++++++++ session/session.go | 16 ++++++---- 9 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 session/fuzz_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a3a9c9..9769d46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -825,10 +825,11 @@ legacy packages (`authentication/`, `authorization/`, the in-tree has no session. See [MIGRATION.md](MIGRATION.md). - `bearer.Authentication.WithAuthenticated` drops the token: `Token()` is `""` once authenticated (read the request's `Authorization` field to relay - a token), and a failed `bearer.Authenticator.Authenticate` returns - `security.Anonymous()`. `json.Marshal` of a `session.Session` writes its - ID fingerprint, the keys of its values and its times, never the CSRF - token, the ID or the values. See [MIGRATION.md](MIGRATION.md). + a token), and a failed `bearer.Authenticator.Authenticate` or + `session.Authenticator.Authenticate` returns `security.Anonymous()`. + `json.Marshal` of a `session.Session` writes its ID fingerprint, the keys + of its values and its times, never the CSRF token, the ID or the values. + See [MIGRATION.md](MIGRATION.md). - **SAML Service Provider (breaking, security audit).** `samlsp.New` requires `Config.TransactionKeys` (1 to 4 keys of at least 32 bytes); diff --git a/MIGRATION.md b/MIGRATION.md index e38cd99b..639a3064 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2141,7 +2141,7 @@ in again** after the upgrade. | Cookies issued before the upgrade | — | refused (`ErrNoSession` wrapping `ErrDecode`) | | `Codec.Encode` / `Codec.Decode` | exported | removed: only the `Manager` seals and opens cookies | | Same keys, another cookie name or `WithPurpose` | cookie accepted | refused | -| Session deadline | the `ExpiresAt` in the cookie | the earlier of it and `CreatedAt + TTL` of the reading `Manager` | +| Session deadline | the `ExpiresAt` in the cookie | the earlier of it and `CreatedAt + TTL` of the reading `Manager`, which `Get` reports in `ExpiresAt` | | `Rotate` | `ExpiresAt` = now + TTL | creation time and deadline kept; new ID and CSRF token | | `Touch` on a session at or past its deadline / on nil | cookie written (Max-Age 1) / panic | `ErrTokenExpired` / `ErrNoSession`, nothing written | | Numbers in `Values` after a round trip | `float64` (precision lost above 2^53) | `json.Number` (`Session.GetInt64`) | @@ -2225,6 +2225,7 @@ To migrate: | `fmt` of a `bearer.Authentication` | the token | name, state, authorities; `Token: REDACTED` | | `bearer.Authentication` after `WithAuthenticated` | keeps the token (`Token()`) | `Token()` is `""` | | Value returned by a failed `bearer.Authenticator.Authenticate` | its input (the token) | `security.Anonymous()` | +| Value returned by a failed `session.Authenticator.Authenticate` | its input (the decoded session) | `security.Anonymous()` | To migrate: diff --git a/session/authenticator.go b/session/authenticator.go index 7cf7b6a7..0a78a130 100644 --- a/session/authenticator.go +++ b/session/authenticator.go @@ -76,16 +76,18 @@ func (a *Authenticator) Supports(auth security.Authentication) bool { func (a *Authenticator) Authenticate(ctx context.Context, auth security.Authentication) (security.Authentication, error) { in, ok := auth.(Authentication) if !ok { - return auth, security.ErrUnsupportedCredential + return security.Anonymous(), security.ErrUnsupportedCredential } + // A failure returns the anonymous authentication, never its input: the + // pending value holds the decoded session. principal, authorities, err := a.loader.Load(ctx, in.session.Values) if err != nil { - return auth, fmt.Errorf("session: load principal: %w", err) + return security.Anonymous(), fmt.Errorf("session: load principal: %w", err) } if principal == nil { - return auth, fmt.Errorf("session: loader returned nil principal: %w", security.ErrInvalidCredentials) + return security.Anonymous(), fmt.Errorf("session: loader returned nil principal: %w", security.ErrInvalidCredentials) } return in.withAuthenticated(principal, authorities), nil diff --git a/session/fuzz_test.go b/session/fuzz_test.go new file mode 100644 index 00000000..2fed1cf4 --- /dev/null +++ b/session/fuzz_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package session_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/security/session" +) + +// FuzzManagerGet: whatever two cookies of the session's name a client sends, +// Get never panics, only a cookie the key sealed opens, and two copies of +// one cookie never make a session. +func FuzzManagerGet(f *testing.F) { + codec, err := session.NewCodec(testKey) + if err != nil { + f.Fatal(err) + } + + mgr := session.NewManager(codec) + name := mgr.CookieName() + + login := newCarrier() + if _, err := mgr.Login(context.Background(), login, nil); err != nil { + f.Fatal(err) + } + + valid := login.replay().cookies[name] + + f.Add(valid, "") + f.Add("", valid) + f.Add(valid, valid) + f.Add(valid, "garbage") + f.Add("AgAAAA", "Ag") + + f.Fuzz(func(t *testing.T, a, b string) { + c := newCarrier() + c.cookies[name] = a + c.more = map[string][]string{name: {b}} + + s, err := mgr.Get(context.Background(), c) + if err != nil { + return + } + + // Every fuzz worker seals its own seed cookie with the same key, + // so only structural properties are checked: an opened session + // was sealed (an ID, a CSRF token, a deadline), and two cookies + // never both count. + if s == nil || s.ID == "" || s.CSRFToken == "" || s.ExpiresAt.IsZero() { + t.Fatalf("opened a session that was never sealed: %q %q", a, b) + } + + if a != "" && a == b { + t.Fatalf("two copies of one cookie made a session: %q", a) + } + }) +} diff --git a/session/hygiene_test.go b/session/hygiene_test.go index 2b51426c..17d12d98 100644 --- a/session/hygiene_test.go +++ b/session/hygiene_test.go @@ -259,6 +259,10 @@ func TestManagerRefusesAmbiguousSessionCookies(t *testing.T) { _, err = request("") require.ErrorIs(t, err, session.ErrNoSession, "an empty cookie is no session") + s, err := request("garbage", "garbage", "garbage", victim) + require.NoError(t, err, "four cookies of one name are inspected") + assert.Equal(t, "victim", s.GetString("sub")) + _, err = request("garbage", "garbage", "garbage", "garbage", victim) require.ErrorIs(t, err, session.ErrNoSession, "more than four cookies of one name are not inspected") } diff --git a/session/lifetime_test.go b/session/lifetime_test.go index e1af721d..e04144e3 100644 --- a/session/lifetime_test.go +++ b/session/lifetime_test.go @@ -86,8 +86,9 @@ func TestManagerEnforcesItsTTLOnOlderCookies(t *testing.T) { now = epoch.Add(14 * time.Minute) - _, err = short.Get(context.Background(), c.replay()) + s, err := short.Get(context.Background(), c.replay()) require.NoError(t, err) + assert.True(t, s.ExpiresAt.Equal(epoch.Add(15*time.Minute)), "Get reports the enforced deadline: %s", s.ExpiresAt) now = epoch.Add(16 * time.Minute) diff --git a/session/manager.go b/session/manager.go index ecb0e072..c1d1e6fe 100644 --- a/session/manager.go +++ b/session/manager.go @@ -392,6 +392,8 @@ func (m *Manager) load(ctx context.Context, value string) (*Session, error) { return nil, fmt.Errorf("session: %w", security.ErrTokenExpired) } + s.ExpiresAt = m.deadline(s) // the enforced deadline, earlier after a TTL decrease + if err := m.validate(ctx, s); err != nil { return nil, err } diff --git a/session/redaction_test.go b/session/redaction_test.go index d46546a4..70c52291 100644 --- a/session/redaction_test.go +++ b/session/redaction_test.go @@ -159,3 +159,33 @@ type userRecord struct { } func (u userRecord) Subject() string { return u.sub } + +// Review nit 8: like the basic and bearer authenticators, a failed +// Authenticate returns security.Anonymous(), never its input — the pending +// Authentication holding the decoded session. +func TestSessionAuthenticatorFailureReturnsAnonymous(t *testing.T) { + t.Parallel() + + mgr := newManager(t) + + c := newCarrier() + _, err := mgr.Login(context.Background(), c, principal{sub: "alice"}) + require.NoError(t, err) + + pending, err := session.NewExtractor(mgr).Extract(context.Background(), c.replay()) + require.NoError(t, err) + require.NotNil(t, pending) + + refusing := session.NewMockPrincipalLoader(t) + refusing.EXPECT().Load(mock.Anything, mock.Anything).Return(nil, nil, security.ErrInvalidCredentials).Once() + + nilPrincipal := session.NewMockPrincipalLoader(t) + nilPrincipal.EXPECT().Load(mock.Anything, mock.Anything).Return(nil, nil, nil).Once() + + for name, loader := range map[string]session.PrincipalLoader{"refused session": refusing, "nil principal": nilPrincipal} { + out, err := session.NewAuthenticator(loader).Authenticate(context.Background(), pending) + + require.ErrorIs(t, err, security.ErrInvalidCredentials, name) + assert.Equal(t, security.Anonymous(), out, name) + } +} diff --git a/session/session.go b/session/session.go index a810949c..64bf11c1 100644 --- a/session/session.go +++ b/session/session.go @@ -28,16 +28,20 @@ type Session struct { // Keep it small: the whole map is JSON-encoded into the cookie, and // browsers cap a cookie at ~4 KiB. Values map[string]any - // CSRFToken is a random token minted with the session. It is never - // exposed to JavaScript (the cookie is HttpOnly); the application - // echoes it into forms / a meta tag and the csrf helpers verify it. + // CSRFToken is a random token minted with the session and sealed in + // its (HttpOnly) cookie. The application renders it into forms or a + // meta tag — where a page script, an XSS, can read it — and + // [VerifyCSRF] checks the value the browser echoes back. CSRFToken string // CreatedAt is the session creation time. CreatedAt time.Time - // LastAccessed is refreshed on every successful load; idle-timeout - // enforcement keys off it. + // LastAccessed is the time the cookie was last written (Login, Touch, + // Rotate): the idle timeout keys off that written value. Get updates + // it in memory only. LastAccessed time.Time - // ExpiresAt is the absolute expiry time. + // ExpiresAt is the absolute expiry time. Get reports the deadline the + // Manager enforces: the earlier of the cookie's ExpiresAt and + // CreatedAt + the Manager's TTL. ExpiresAt time.Time } From 3fe835186164c98f39c3029c18c7b5137c072047 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 08:22:14 +0200 Subject: [PATCH 14/17] test(basic): an unavailable hasher answers 503 for every account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hasher that cannot verify — a remote HSM or pepper service down — wraps security.ErrUnavailable, and the Authenticator answers it 503 before any account check. Nothing pinned it: answered 401 for unknown accounts and 503 for known ones, the status code would tell which accounts exist during the outage. Every attempt outcome now runs against such a hasher and must be classified unavailable. Audit: CORE-03, CORE-12. --- basic/limiter_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/basic/limiter_test.go b/basic/limiter_test.go index 8f56046e..a55e4fa0 100644 --- a/basic/limiter_test.go +++ b/basic/limiter_test.go @@ -376,6 +376,35 @@ func TestAuthenticatorSkipsTheRehashWhenTheContextEnded(t *testing.T) { assert.True(t, out.IsAuthenticated()) } +// downHasher cannot verify: its Verify wraps security.ErrUnavailable, as a +// hasher calling a remote HSM or pepper service does while it is down. +type downHasher struct{ recordingHasher } + +func (*downHasher) Verify(context.Context, string, string) (bool, error) { + return false, fmt.Errorf("hsm: %w", security.ErrUnavailable) +} + +// Review finding 2: a hasher that cannot verify is answered 503 for every +// account. Answered 401 for unknown accounts and 503 for known ones, the +// status code would tell which accounts exist during the outage. +func TestAuthenticatorAnswersAnUnavailableHasherAlikeForEveryAccount(t *testing.T) { + t.Parallel() + + for _, c := range attempts("real-hash") { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + a := basic.NewAuthenticator(c.loader(t), &downHasher{}) + + out, err := a.Authenticate(context.Background(), basic.New("alice", c.password)) + + require.ErrorIs(t, err, security.ErrUnavailable) + assert.Equal(t, security.ErrorClassUnavailable, security.ErrorClass(err)) + assert.False(t, out.IsAuthenticated()) + }) + } +} + // A canceled request never starts a verification. func TestAuthenticatorDoesNotVerifyForACanceledRequest(t *testing.T) { t.Parallel() From 2bd373602ac0ffd0b8773031a922faea6b63d6a3 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 08:22:14 +0200 Subject: [PATCH 15/17] test(password): pin the parsing of the argon2id lane count Rows for a repeated p, p=256 and p=257 (which a wider parse would truncate to 1 lane) and a negative p: each is refused as a malformed hash, as the documentation promises for every parameter. Audit: CORE-06. --- password/bounds_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/password/bounds_test.go b/password/bounds_test.go index 7fced9da..ebebdc5a 100644 --- a/password/bounds_test.go +++ b/password/bounds_test.go @@ -83,6 +83,10 @@ func TestArgon2idVerifyRefusesOutOfBoundsHashes(t *testing.T) { {"p missing", phc("m=64,t=1", salt, tag), password.ErrMalformedHash}, {"duplicate m", phc("m=64,t=1,p=1,m=8", salt, tag), password.ErrMalformedHash}, {"duplicate t", phc("m=64,t=1,t=2,p=1", salt, tag), password.ErrMalformedHash}, + {"duplicate p", phc("m=64,t=1,p=1,p=1", salt, tag), password.ErrMalformedHash}, + {"p=256 overflows the lane count", phc("m=64,t=1,p=256", salt, tag), password.ErrMalformedHash}, + {"p=257 is not p=1", phc("m=64,t=1,p=257", salt, tag), password.ErrMalformedHash}, + {"negative p", phc("m=64,t=1,p=-1", salt, tag), password.ErrMalformedHash}, {"unknown parameter", phc("m=64,t=1,p=1,keyid=a", salt, tag), password.ErrMalformedHash}, {"empty parameter", phc("m=64,,t=1,p=1", salt, tag), password.ErrMalformedHash}, {"signed value", phc("m=+64,t=1,p=1", salt, tag), password.ErrMalformedHash}, From 37eda172f434b9394426c73e5962521a7a6de18b Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 08:36:37 +0200 Subject: [PATCH 16/17] fix(basic)!: refuse format and separator characters in Basic credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Basic rules refused control characters only: invisible format characters — zero-width joiner and space, soft hyphen, bidirectional overrides and isolates, byte order mark, tags (Unicode Cf) — and the line and paragraph separators (Zl, Zp) still reached the UserLoader, so a user-id displaying as another one (with U+202E or U+200B inside) reached the application's lookups and logs. RFC 7617 §2.1 points to RFC 8265's OpaqueString and UsernameCasePreserved profiles, whose FreeformClass (RFC 8264) disallows the separators and the default-ignorable code points, the joiners outside a virama context included. The Extractor and the Authenticator (form logins) now refuse these characters in the user-id and in the password, with the same error as a control character. Accented letters, emoji without a joiner, variation selectors and no-break spaces are still accepted. Breaking: an account whose password contains such a character — an emoji sequence joined with U+200D, a pasted soft hyphen or byte order mark — can no longer sign in with Basic. MIGRATION, LIMITATIONS and the security considerations give the remedy: reset the password, and refuse these characters when a password is set. Audit: CORE-11. --- CHANGELOG.md | 20 +++++--- LIMITATIONS.md | 5 ++ MIGRATION.md | 22 +++++--- basic/authenticator.go | 8 +-- basic/doc.go | 10 ++-- basic/extractor.go | 34 ++++++++++--- basic/strict_test.go | 89 +++++++++++++++++++++++++++++++-- docs/security-considerations.md | 17 +++++-- 8 files changed, 166 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9769d46e..49a7698c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -793,8 +793,13 @@ legacy packages (`authentication/`, `authorization/`, the in-tree [MIGRATION.md](MIGRATION.md). - `basic` parses credentials strictly (RFC 7617 §2): base64 alphabet with canonical padding, user-id and password in UTF-8 without any control - character, at most 3072 bytes, several spaces allowed after the scheme, - and `Basic` without credentials is malformed (it used to be ignored). + character, format character (Unicode Cf: zero-width joiner, soft hyphen, + bidirectional overrides, byte order mark…) or line or paragraph separator + (after RFC 8264's FreeformClass, the base of RFC 8265's OpaqueString), at + most 3072 bytes, several spaces allowed after the scheme, and `Basic` + without credentials is malformed (it used to be ignored). **Breaking:** an + account whose password contains such a character can no longer sign in + with Basic — reset its password. `basic.Authenticator` applies the same checks — a form login building the value with `basic.New` included — and also refuses an empty user-id (`ErrBadFormat`, 401) before any lookup; the `Extractor` still lets an @@ -1741,11 +1746,12 @@ legacy packages (`authentication/`, `authorization/`, the in-tree a flood of Basic attempts can no longer exhaust memory (19 MiB per default Argon2id verification). - The Basic extractor forwarded control characters (NUL, CR LF, ANSI escape - sequences) and invalid UTF-8 in the user-id to the `UserLoader` — a log - injection vector into the application's lookups and logs — and its - lenient base64 decoding skipped CR LF inside the credentials. Malformed - credentials are now refused before any lookup, by the extractor and by - the `Authenticator` alike. + sequences), invisible format characters (zero-width joiner and space, + right-to-left override) and invalid UTF-8 in the user-id to the + `UserLoader` — a log injection and spoofing vector into the application's + lookups and logs — and its lenient base64 decoding skipped CR LF inside + the credentials. Malformed credentials are now refused before any lookup, + by the extractor and by the `Authenticator` alike. - `session.NewCodec` accepted an empty or low-entropy key and derived the AES key with a bare SHA-256: an unset environment variable let anyone mint a cookie for any subject, and a passphrase key could be brute-forced diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 632ea7e4..d3812484 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -476,6 +476,11 @@ PKCE. Not supported, and refused where a provider could select them: - **UTF-8 only** — Basic credentials must be UTF-8 (the challenge advertises `charset="UTF-8"`, RFC 7617 §2.1); clients sending ISO-8859-1 non-ASCII credentials are refused. User-ids and passwords are not normalized. +- **Format and separator characters refused** — a password containing a + Unicode format character (an emoji sequence joined with U+200D, a soft + hyphen, a byte order mark) or a line or paragraph separator no longer + signs in with Basic: reset it, and refuse these characters when passwords + are set (RFC 8265 OpaqueString). ## Passwords diff --git a/MIGRATION.md b/MIGRATION.md index 639a3064..8dae3045 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2097,8 +2097,9 @@ To migrate: ### `basic`: credentials are parsed strictly; `Name()` is the authenticated subject -The Basic extractor forwarded control characters and invalid UTF-8 to the -`UserLoader` (log injection), skipped CR LF inside the base64 credentials, +The Basic extractor forwarded control characters, invisible format +characters and invalid UTF-8 to the `UserLoader` (log injection, a user-id +that displays as another one), skipped CR LF inside the base64 credentials, and `Authentication.Name()` returned the user-id as typed — `ALICE` for the subject `alice`, or a password typed in the username field — although `Name` must be safe to log. @@ -2106,6 +2107,7 @@ subject `alice`, or a password typed in the username field — although | Request / value | Before | After | | --- | --- | --- | | Control character (C0, DEL, C1) in the user-id or password | forwarded to the loader | 401, `ErrBadFormat`, the loader is not called | +| Format character (Unicode Cf: U+200D zero-width joiner, U+200B, U+00AD soft hyphen, U+202E and the other bidirectional controls, U+FEFF, tags) or line/paragraph separator (U+2028, U+2029) in the user-id or password | forwarded | 401, `ErrBadFormat`, the same error as a control character | | Invalid UTF-8 (e.g. ISO-8859-1 credentials) | forwarded | 401 (the Basic challenge advertises `charset="UTF-8"`) | | CR LF or spaces inside the base64, unpadded or non-canonical base64 | decoded | 401 | | user-pass longer than 3072 bytes | forwarded | 401 | @@ -2117,11 +2119,19 @@ subject `alice`, or a password typed in the username field — although To migrate: -1. Clients must send UTF-8 credentials (RFC 7617 §2.1) without control - characters. -2. Read the typed user-id with `Username()`, the authenticated identity with +1. Clients must send UTF-8 credentials (RFC 7617 §2.1) without control, + format or separator characters. Accented letters, emoji without a + joiner, variation selectors and no-break spaces are still accepted. +2. **An account whose password contains a format or separator character — + an emoji sequence joined with U+200D, a soft hyphen pasted from a + document, a byte order mark — can no longer sign in with Basic** (form + logins through `basic.Authenticator` included): reset its password. When + a password is set, refuse these characters (RFC 8265's OpaqueString + disallows them, but for the zero-width joiners after a virama), so that + no new account gets locked out. +3. Read the typed user-id with `Username()`, the authenticated identity with `Name()` or `Principal().Subject()`; log `Name()`, never `Username()`. -3. A custom authenticator consuming `basic.Authentication` with an empty +4. A custom authenticator consuming `basic.Authentication` with an empty user-id (API keys) keeps working: only `basic.Authenticator` refuses it. ### `session`: strong keys, cookies bound to their name and purpose, an enforced absolute lifetime diff --git a/basic/authenticator.go b/basic/authenticator.go index 20ab7241..179c6154 100644 --- a/basic/authenticator.go +++ b/basic/authenticator.go @@ -37,10 +37,10 @@ type AuthorityResolver func(PasswordUser) []string // [password.Hasher], checks the account's lifecycle flags, then returns the // authenticated [Authentication]. // -// Credentials that are malformed — control characters, invalid UTF-8, more -// than 3072 bytes, an empty user-id — are refused with [ErrBadFormat] -// before any lookup: a form login building the [Authentication] with [New] -// gets the same checks as the [Extractor]. +// Credentials that are malformed — control, format (Cf) or separator (Zl, +// Zp) characters, invalid UTF-8, more than 3072 bytes, an empty user-id — +// are refused with [ErrBadFormat] before any lookup: a form login building +// the [Authentication] with [New] gets the same checks as the [Extractor]. // // Every attempt with well-formed credentials runs exactly one password // verification, whatever the account: an unknown user, a loader failure or diff --git a/basic/doc.go b/basic/doc.go index ecdbf879..41181157 100644 --- a/basic/doc.go +++ b/basic/doc.go @@ -9,11 +9,11 @@ // validate the username/password pair against a backing store. // // The Extractor parses RFC 7617 credentials strictly (canonical base64, UTF-8 -// without control characters, bounded length). The Authenticator checks the -// same rules — form logins can build the value with New — then spends exactly -// one password verification on every attempt, unknown accounts included, so -// that the response time reveals nothing; at most runtime.GOMAXPROCS(0) -// verifications run at once. +// without control, format or separator characters, bounded length). The +// Authenticator checks the same rules — form logins can build the value with +// New — then spends exactly one password verification on every attempt, +// unknown accounts included, so that the response time reveals nothing; at +// most runtime.GOMAXPROCS(0) verifications run at once. // // Allowed dependencies: // - github.com/hyperscale-stack/security (core) diff --git a/basic/extractor.go b/basic/extractor.go index f229b46c..5675e3ad 100644 --- a/basic/extractor.go +++ b/basic/extractor.go @@ -18,7 +18,8 @@ import ( // ErrBadFormat is returned by the [Extractor] when the Authorization header // carries a Basic scheme but the credentials are malformed (not base64, -// missing colon, control characters, not UTF-8, too long, ...), and by the +// missing colon, control, format or separator characters, not UTF-8, too +// long, ...), and by the // [Authenticator] for a user-id or password it refuses before any lookup. // It is wrapped around [security.ErrInvalidCredentials] so error mappers // route it to 401 — and to prevent oracle attacks that distinguish @@ -45,8 +46,14 @@ const ( // It is strict: the credentials must use the base64 alphabet with canonical // padding (RFC 4648 §4, §3.5), the user-id and password must be valid UTF-8 // without any control character (RFC 7617 §2 "MUST NOT contain any control -// characters"; C1 controls included, RFC 8264 §9.12), and the decoded -// user-pass is at most 3072 bytes. Malformed credentials are refused before +// characters"; C1 controls included, RFC 8264 §9.12), format character +// (Unicode Cf: zero-width joiner and space, soft hyphen, bidirectional +// overrides, byte order mark, tags) or line or paragraph separator (Zl, Zp) +// — after RFC 8264's FreeformClass, the base of the OpaqueString and +// UsernameCasePreserved profiles of RFC 8265 that RFC 7617 §2.1 points to +// — and the decoded user-pass is at most 3072 bytes. +// Accented letters, emoji without a joiner and other ordinary non-ASCII +// characters are accepted. Malformed credentials are refused before // they reach any [UserLoader] — they are never logged nor looked up. An // empty user-id or password is syntactically valid and let through (custom // authenticators accept API keys sent as `-u :KEY`); the [Authenticator] @@ -112,21 +119,34 @@ func decodeCredentials(payload string) (string, string, error) { } // checkUserPass applies the rules the [Extractor] and the [Authenticator] -// share: UTF-8 without any control character (RFC 7617 §2, RFC 8264 §9.12) -// and a bounded length. The reason never quotes the input. +// share: UTF-8 without any disallowed character and a bounded length. The +// reason never quotes the input. func checkUserPass(username, password string) error { switch { case len(username)+1+len(password) > maxUserPassLength: return malformed("credentials longer than 3072 bytes") case !utf8.ValidString(username) || !utf8.ValidString(password): return malformed("credentials are not UTF-8") - case strings.IndexFunc(username, unicode.IsControl) >= 0 || strings.IndexFunc(password, unicode.IsControl) >= 0: - return malformed("control character in the credentials") + case strings.IndexFunc(username, disallowed) >= 0 || strings.IndexFunc(password, disallowed) >= 0: + return malformed("disallowed character in the credentials") } return nil } +// disallowed reports whether r may not appear in a user-id or a password: a +// control character (C0, DEL, C1: RFC 7617 §2, RFC 8264 §9.12), a format +// character (Cf) or a line or paragraph separator (Zl, Zp). RFC 8264's +// FreeformClass disallows the separators and the default-ignorable code +// points (§9.13), and admits the zero-width joiner and non-joiner only after +// a virama (CONTEXTJ): every format character is refused here, those two +// included. They are invisible or reorder the text around them — a user-id +// that displays as another one would reach logs and lookups — and are +// refused alike, with one error. +func disallowed(r rune) bool { + return unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) +} + // malformed wraps ErrBadFormat and security.ErrInvalidCredentials. func malformed(reason string) error { return fmt.Errorf("basic: %s: %w (%w)", reason, ErrBadFormat, security.ErrInvalidCredentials) diff --git a/basic/strict_test.go b/basic/strict_test.go index f9d37829..4b8643b8 100644 --- a/basic/strict_test.go +++ b/basic/strict_test.go @@ -54,6 +54,11 @@ func TestExtractorRefusesMalformedCredentials(t *testing.T) { "missing colon": basicHeader("alicepassword"), } + for name, r := range invisibleOrSeparator { + cases[name+" in the user-id"] = basicHeader("ali" + r + "ce:pw") + cases[name+" in the password"] = basicHeader("alice:p" + r + "w") + } + for name, header := range cases { t.Run(name, func(t *testing.T) { t.Parallel() @@ -71,6 +76,48 @@ func TestExtractorRefusesMalformedCredentials(t *testing.T) { } } +// invisibleOrSeparator lists format characters (Unicode Cf) and line and +// paragraph separators (Zl, Zp): RFC 8264's FreeformClass — the base of the +// OpaqueString and UsernameCasePreserved profiles RFC 7617 §2.1 points to — +// disallows them. They are invisible or reorder the text around them, so a +// user-id that displays as another one would reach logs and lookups. +var invisibleOrSeparator = map[string]string{ + "ZERO WIDTH JOINER U+200D": "\u200d", + "ZERO WIDTH SPACE U+200B": "\u200b", + "BYTE ORDER MARK U+FEFF": "\ufeff", + "SOFT HYPHEN U+00AD": "\u00ad", + "RIGHT-TO-LEFT OVERRIDE U+202E": "\u202e", + "LEFT-TO-RIGHT ISOLATE U+2066": "\u2066", + "TAG LATIN CAPITAL LETTER A": "\U000E0041", + "LINE SEPARATOR U+2028 (Zl)": "\u2028", + "PARAGRAPH SEPARATOR U+2029 (Zp)": "\u2029", +} + +// Decision on review nit 6: a format character or a separator is refused +// like a control character — the same error, so the refusals cannot be +// told apart, even in the server's logs. +func TestExtractorRefusesInvisibleCharactersLikeControlCharacters(t *testing.T) { + t.Parallel() + + extract := func(userPass string) error { + c := newCarrier() + c.Set("Authorization", basicHeader(userPass)) + + _, err := basic.NewExtractor().Extract(context.Background(), c) + + return err + } + + control := extract("al\x00ice:pw") + require.Error(t, control) + + for name, r := range invisibleOrSeparator { + err := extract("al" + r + "ice:pw") + require.Error(t, err, name) + assert.Equal(t, control.Error(), err.Error(), name) + } +} + func TestExtractorAcceptsRFC7617Credentials(t *testing.T) { t.Parallel() @@ -85,6 +132,10 @@ func TestExtractorAcceptsRFC7617Credentials(t *testing.T) { {"several spaces after the scheme", "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw")), "bob", "pw"}, {"scheme in another case", "bASIC " + base64.StdEncoding.EncodeToString([]byte("bob:pw")), "bob", "pw"}, {"3072-byte user-pass", basicHeader("u:" + strings.Repeat("p", 3070)), "u", strings.Repeat("p", 3070)}, + {"accented password", basicHeader("zoë:pässwörd"), "zoë", "pässwörd"}, + {"emoji without a joiner", basicHeader("alice:🔑🚀 ok"), "alice", "🔑🚀 ok"}, + {"emoji with a variation selector (Mn)", basicHeader("alice:I \u2764\ufe0f Go"), "alice", "I \u2764\ufe0f Go"}, + {"CJK and no-break space (Zs)", basicHeader("用户:密码\u00a0一"), "用户", "密码\u00a0一"}, } for _, c := range cases { @@ -131,7 +182,7 @@ func TestExtractorLeavesOtherSchemes(t *testing.T) { func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { t.Parallel() - for name, in := range map[string]basic.Authentication{ + cases := map[string]basic.Authentication{ "NUL in the user-id": basic.New("al\x00ice", "pw"), "CR LF in the user-id": basic.New("a\r\nb", "pw"), "invalid UTF-8 user-id": basic.New("\xff", "pw"), @@ -140,7 +191,14 @@ func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { "empty user-id": basic.New("", "pw"), "user-pass over 3072 bytes": basic.New("alice", strings.Repeat("p", 3067)), "user-pass over 3072 (user)": basic.New(strings.Repeat("u", 3072), ""), - } { + } + + for name, r := range invisibleOrSeparator { + cases[name+" in the user-id"] = basic.New("ali"+r+"ce", "pw") + cases[name+" in the password"] = basic.New("alice", "p"+r+"w") + } + + for name, in := range cases { t.Run(name, func(t *testing.T) { t.Parallel() @@ -158,6 +216,24 @@ func TestAuthenticatorRefusesMalformedCredentialsBeforeTheLoader(t *testing.T) { } } +// Ordinary non-ASCII credentials still sign in through the Authenticator +// (form logins): accented letters, emoji without a joiner, a variation +// selector, CJK, a no-break space. +func TestAuthenticatorAcceptsNonASCIICredentials(t *testing.T) { + t.Parallel() + + for _, pw := range []string{"pässwörd", "🔑🚀 ok", "I \u2764\ufe0f Go", "密码\u00a0一"} { + l := basic.NewMockUserLoader(t) + l.EXPECT().LoadByUsername(mock.Anything, "zoë").Return(mockUser(t, account{subject: "zoë", hash: "h"}), nil).Once() + h := &recordingHasher{always: true} + + out, err := basic.NewAuthenticator(l, h).Authenticate(context.Background(), basic.New("zoë", pw)) + + require.NoError(t, err, pw) + assert.True(t, out.IsAuthenticated(), pw) + } +} + // The longest accepted user-pass is 3072 bytes, what 4096 base64 characters // decode to. func TestAuthenticatorAcceptsTheLongestUserPass(t *testing.T) { @@ -200,8 +276,8 @@ func TestAuthenticationNameIsTheSubjectOnceAuthenticated(t *testing.T) { // FuzzExtractor: whatever the header, the extractor never returns both a // value and an error, never an authenticated value, and only accepts Basic -// credentials whose user-id and password are UTF-8 without control -// characters. +// credentials whose user-id and password are UTF-8 without control, format +// or separator characters (Cc, Cf, Zl, Zp). func FuzzExtractor(f *testing.F) { f.Add("Basic YWxpY2U6c2VjcmV0") f.Add("basic Og==") @@ -209,6 +285,7 @@ func FuzzExtractor(f *testing.F) { f.Add("Basic YR==") f.Add("Bearer abc") f.Add(basicHeader("al\x00ice:pw")) + f.Add(basicHeader("ali\u200dce:p\u2028w")) e := basic.NewExtractor() @@ -239,7 +316,9 @@ func FuzzExtractor(f *testing.F) { } for _, s := range []string{ba.Username(), ba.Password()} { - if !utf8.ValidString(s) || strings.IndexFunc(s, unicode.IsControl) >= 0 { + if !utf8.ValidString(s) || strings.IndexFunc(s, func(r rune) bool { + return unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) + }) >= 0 { t.Fatalf("accepted %q from %q", s, header) } } diff --git a/docs/security-considerations.md b/docs/security-considerations.md index a163aa6d..713bfd3f 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -72,12 +72,19 @@ What remains measurable, and how to close it: - Under overload an attempt that gets no verification slot answers 503 (`ErrUnavailable`) whatever the account, so overload reveals nothing. -Malformed credentials — control characters, invalid UTF-8, more than 3072 +Malformed credentials — control characters, format characters (Unicode Cf: +zero-width joiner and space, soft hyphen, bidirectional overrides, byte +order mark), line and paragraph separators, invalid UTF-8, more than 3072 bytes, an empty user-id — are refused before the loader runs, without a -verification: a property of the input, not of an account. The extractor and -the `Authenticator` apply the same rules, so a form login that builds the -value with `basic.New` is checked too, and a control character never reaches -the application's lookups and logs (log injection). +verification and with one error: a property of the input, not of an +account. The extractor and the `Authenticator` apply the same rules, so a +form login that builds the value with `basic.New` is checked too, and such +a character never reaches the application's lookups and logs (log +injection, a user-id that displays as another one). RFC 8264's +FreeformClass, the base of RFC 8265's OpaqueString profile for passwords, +disallows them too (but for the zero-width joiner and non-joiner after a +virama, which this library refuses as well): refuse them when a password is +set, or the account cannot sign in with Basic (reset its password). ### Online guessing and hashing cost From 611137edac19401799cf8f14682963aa211ecc25 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Tue, 13 Oct 2026 08:55:50 +0200 Subject: [PATCH 17/17] test(session,basic): pin foreign authentication results, name user-ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.Authenticator.Authenticate returns security.Anonymous() when it does not support the authentication it gets, but no test told that return value from its input: the test passed security.Anonymous() itself. It now passes a value of another type and asserts the anonymous result, with the generated PrincipalLoader mock (never called) in place of the hand-written loader. The breaking note on format and separator characters named passwords only: a user-id holding one — a Persian or Indic name written with ZERO WIDTH NON-JOINER U+200C or ZERO WIDTH JOINER U+200D — is refused too. CHANGELOG, MIGRATION, LIMITATIONS and the security considerations now say so, with its remedy: rename the account or give it an alias. Audit: CORE-11, CORE-15. --- CHANGELOG.md | 6 ++++-- LIMITATIONS.md | 4 +++- MIGRATION.md | 6 +++++- docs/security-considerations.md | 6 ++++-- session/authenticator_test.go | 8 ++++++-- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a7698c..2ceab215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -798,8 +798,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree (after RFC 8264's FreeformClass, the base of RFC 8265's OpaqueString), at most 3072 bytes, several spaces allowed after the scheme, and `Basic` without credentials is malformed (it used to be ignored). **Breaking:** an - account whose password contains such a character can no longer sign in - with Basic — reset its password. + account whose password or user-id contains such a character can no longer + sign in with Basic — reset the password; rename the account or give it an + alias (a Persian or Indic name written with U+200C or U+200D, for + instance). `basic.Authenticator` applies the same checks — a form login building the value with `basic.New` included — and also refuses an empty user-id (`ErrBadFormat`, 401) before any lookup; the `Extractor` still lets an diff --git a/LIMITATIONS.md b/LIMITATIONS.md index d3812484..85147723 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -480,7 +480,9 @@ PKCE. Not supported, and refused where a provider could select them: Unicode format character (an emoji sequence joined with U+200D, a soft hyphen, a byte order mark) or a line or paragraph separator no longer signs in with Basic: reset it, and refuse these characters when passwords - are set (RFC 8265 OpaqueString). + are set (RFC 8265 OpaqueString). The same goes for a user-id — a Persian + or Indic name written with U+200C or U+200D, for instance: rename the + account or give it an alias without them. ## Passwords diff --git a/MIGRATION.md b/MIGRATION.md index 8dae3045..3c13a207 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2128,7 +2128,11 @@ To migrate: logins through `basic.Authenticator` included): reset its password. When a password is set, refuse these characters (RFC 8265's OpaqueString disallows them, but for the zero-width joiners after a virama), so that - no new account gets locked out. + no new account gets locked out. **The same holds for a user-id** — a + Persian or Indic name written with ZERO WIDTH NON-JOINER U+200C or + ZERO WIDTH JOINER U+200D, for instance: rename the account or give it an + alias without these characters, and refuse them when a user-id is + chosen. 3. Read the typed user-id with `Username()`, the authenticated identity with `Name()` or `Principal().Subject()`; log `Name()`, never `Username()`. 4. A custom authenticator consuming `basic.Authentication` with an empty diff --git a/docs/security-considerations.md b/docs/security-considerations.md index 713bfd3f..2cf2d011 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -83,8 +83,10 @@ a character never reaches the application's lookups and logs (log injection, a user-id that displays as another one). RFC 8264's FreeformClass, the base of RFC 8265's OpaqueString profile for passwords, disallows them too (but for the zero-width joiner and non-joiner after a -virama, which this library refuses as well): refuse them when a password is -set, or the account cannot sign in with Basic (reset its password). +virama, which this library refuses as well): refuse them when a password or +a user-id is chosen, or the account cannot sign in with Basic (reset the +password; rename the account or give it an alias — a Persian or Indic name +written with U+200C or U+200D, for instance). ### Online guessing and hashing cost diff --git a/session/authenticator_test.go b/session/authenticator_test.go index c01e0432..3a96d141 100644 --- a/session/authenticator_test.go +++ b/session/authenticator_test.go @@ -122,9 +122,13 @@ func TestNewAuthenticatorPanicsOnNilLoader(t *testing.T) { func TestSessionAuthenticatorRejectsForeignAuthentication(t *testing.T) { t.Parallel() - a := session.NewAuthenticator(stubLoader{}) + a := session.NewAuthenticator(session.NewMockPrincipalLoader(t)) // never called + + // A value of another scheme: a type of its own around the anonymous one. + foreign := struct{ security.Authentication }{security.Anonymous()} - _, err := a.Authenticate(context.Background(), security.Anonymous()) + out, err := a.Authenticate(context.Background(), foreign) require.Error(t, err) assert.ErrorIs(t, err, security.ErrUnsupportedCredential) + assert.Equal(t, security.Anonymous(), out, "a failure returns the anonymous authentication, never its input") }