diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a8e3af..2ceab215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -486,6 +486,24 @@ 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`. +- `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. +- `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. +- `String`, `Format` and `LogValue` on `session.Session` (and a redacting + `MarshalJSON`), `session.Authentication`, `basic.Authentication` and + `bearer.Authentication`. ### Changed @@ -755,6 +773,70 @@ 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). +- `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). +- `basic` parses credentials strictly (RFC 7617 §2): base64 alphabet with + canonical padding, user-id and password in UTF-8 without any control + 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 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 + 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). +- `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). +- 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). +- `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` 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); @@ -956,6 +1038,23 @@ 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)`). +- `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 `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 @@ -1623,3 +1722,83 @@ 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). +- `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). +- The Basic extractor forwarded control characters (NUL, CR LF, ANSI escape + 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 + 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`. +- 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 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` + 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. +- 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 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/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/LIMITATIONS.md b/LIMITATIONS.md index d9bae009..85147723 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -462,12 +462,57 @@ 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. +- **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). 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 + +- **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 - 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. +- 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 @@ -477,8 +522,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 @@ -487,18 +533,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 e5cc3eaa..3c13a207 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1997,3 +1997,255 @@ 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 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 } + + 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. + +### `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. + +### `basic`: credentials are parsed strictly; `Name()` is the authenticated subject + +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. + +| 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 | +| `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, + 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. **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 + 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 +(format 2), 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`, 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`) | +| 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: 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 + 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). + +### `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 open as valid sessions, copies of one session included | + +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 (`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 + `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 + +| | 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()` | +| Value returned by a failed `session.Authenticator.Authenticate` | its input (the decoded session) | `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/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/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 61ddfb1e..179c6154 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,84 @@ 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. +// 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 +// 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,9 +123,26 @@ 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" } +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 @@ -74,39 +153,46 @@ 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 { - // Loader-level errors (db down, unknown user, ...) collapse to a - // single ErrInvalidCredentials at the client boundary. 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) + // 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 user == nil { - return auth, fmt.Errorf("basic: user not found: %w", security.ErrInvalidCredentials) + if in.Username() == "" { + return security.Anonymous(), malformed("empty user-id") } - if !user.IsEnabled() || user.IsLocked() || user.IsExpired() || user.IsCredentialsExpired() { - return auth, fmt.Errorf("basic: account ineligible: %w", security.ErrInvalidCredentials) + user, loadErr := a.loader.LoadByUsername(ctx, in.Username()) + + stored, hasPassword := "", false + if loadErr == nil && user != nil { + stored = user.GetPasswordHash() + hasPassword = stored != "" } - ok, err = a.hasher.Verify(ctx, user.GetPasswordHash(), in.Password()) - if err != nil { - return auth, fmt.Errorf("basic: hash verify: %w (%w)", err, security.ErrInvalidCredentials) + hash := a.dummyHash + if hasPassword { + hash = stored } - if !ok { - return auth, fmt.Errorf("basic: password mismatch: %w", security.ErrInvalidCredentials) + 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) @@ -115,5 +201,107 @@ func (a *Authenticator) Authenticate(ctx context.Context, auth security.Authenti 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 (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 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) + } + + return nil +} + +// 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 + } + + defer release() + + match, err := a.hasher.Verify(ctx, hash, pw) + if err != nil { + return false, fmt.Errorf("basic: %w", err) + } + + 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 + } + + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("basic: no verification slot: %w (%w)", err, security.ErrUnavailable) + } + + 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. var _ security.Authenticator = (*Authenticator)(nil) diff --git a/basic/authenticator_test.go b/basic/authenticator_test.go index 3154ef81..2ca8a26b 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(basic.NewMockUserLoader(t), newHasher(t)) assert.Equal(t, "basic", a.AuthenticatorName()) } diff --git a/basic/doc.go b/basic/doc.go index 8e12baa3..41181157 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, 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) // - github.com/hyperscale-stack/security/password (for password hashing) diff --git a/basic/extractor.go b/basic/extractor.go index 07fac854..5675e3ad 100644 --- a/basic/extractor.go +++ b/basic/extractor.go @@ -10,23 +10,54 @@ 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, 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 +// "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), 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] +// refuses an empty user-id. type Extractor struct{} // NewExtractor returns the canonical zero-config Extractor. @@ -34,9 +65,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 +85,99 @@ 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, err + } + + 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 nil, fmt.Errorf("basic: base64 decode: %w (%w)", ErrBadFormat, security.ErrInvalidCredentials) + return "", "", malformed("credentials are not canonical base64") } - colon := strings.IndexByte(string(raw), ':') - if colon < 0 { - return nil, fmt.Errorf("basic: missing colon separator: %w (%w)", ErrBadFormat, security.ErrInvalidCredentials) + username, password, ok := strings.Cut(string(raw), ":") + if !ok { + return "", "", malformed("missing colon separator") } - return New(string(raw[:colon]), string(raw[colon+1:])), nil + 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 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, 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) +} + +// 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/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/limiter_test.go b/basic/limiter_test.go new file mode 100644 index 00000000..a55e4fa0 --- /dev/null +++ b/basic/limiter_test.go @@ -0,0 +1,441 @@ +// 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/mock" + "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 } + +// 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(t), h), h, limit, limit+5) +} + +func TestWithMaxConcurrentVerifications(t *testing.T) { + t.Parallel() + + h := newGateHasher() + a := basic.NewAuthenticator(unknownUsers(t), 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(t), 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() + alice := mockUser(t, account{subject: "alice", hash: "hash"}) + users := mockLoader(t, func(username string) (basic.PasswordUser, error) { + if username == "alice" { + return alice, 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(t), &ctxHasher{}) + + _, err := a.Authenticate(context.Background(), basic.New("nobody", "pw")) + + require.ErrorIs(t, err, security.ErrUnavailable) + assert.NotErrorIs(t, err, security.ErrInvalidCredentials) +} + +// 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 +} + +// newRehashingLoader returns a rehashingLoader whose lookups of "alice" +// return user. +func newRehashingLoader(t *testing.T, user basic.PasswordUser) rehashingLoader { + t.Helper() + + l := rehashingLoader{basic.NewMockUserLoader(t), basic.NewMockPasswordRehasher(t)} + l.MockUserLoader.EXPECT().LoadByUsername(mock.Anything, "alice").Return(user, nil).Once() + + return l +} + +// 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() + + alice := account{subject: "alice", hash: "old"} + + t.Run("outdated hash is re-hashed after a success", func(t *testing.T) { + t.Parallel() + + 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()) + }) + + t.Run("current hash is left alone", func(t *testing.T) { + t.Parallel() + + 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) + }) + + 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 := 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) + }) + } + + t.Run("a failed update does not fail the authentication", func(t *testing.T) { + t.Parallel() + + 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()) + }) + + t.Run("a loader without PasswordRehasher is never asked", func(t *testing.T) { + t.Parallel() + + 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")) + + 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 := 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()) +} + +// 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 := 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) + + 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()) +} + +// 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() + + h := &recordingHasher{} + a := basic.NewAuthenticator(unknownUsers(t), 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(t), &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/redaction_test.go b/basic/redaction_test.go new file mode 100644 index 00000000..c41e316e --- /dev/null +++ b/basic/redaction_test.go @@ -0,0 +1,81 @@ +// 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/mock" + "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() + + // 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} + 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!") + + 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/basic/strict_test.go b/basic/strict_test.go new file mode 100644 index 00000000..4b8643b8 --- /dev/null +++ b/basic/strict_test.go @@ -0,0 +1,346 @@ +// 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" + "testing" + "unicode" + "unicode/utf8" + + "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" +) + +// 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, 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() + + 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") + }) + } +} + +// 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() + + 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)}, + {"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 { + 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) + } +} + +// 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() + + 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"), + "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), ""), + } + + 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() + + l := basic.NewMockUserLoader(t) // no expectation: the loader never sees malformed credentials + 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.Empty(t, h.calls(), "nor does the hasher") + }) + } +} + +// 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) { + t.Parallel() + + 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))) + + require.NoError(t, err) + assert.True(t, out.IsAuthenticated()) +} + +// 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() + + // 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()) + 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, format +// or separator characters (Cc, Cf, Zl, Zp). +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")) + f.Add(basicHeader("ali\u200dce:p\u2028w")) + + 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, 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) + } + } + }) +} + +// 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/basic/timing_test.go b/basic/timing_test.go new file mode 100644 index 00000000..8b424c99 --- /dev/null +++ b/basic/timing_test.go @@ -0,0 +1,392 @@ +// 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/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 +// 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) +} + +// 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() + + return u +} + +// 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 + user *account // nil: the loader returns no user + loadErr error + password string + dummy bool // verified against the dummy hash +} + +// 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) + } + + 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"}, + } +} + +// 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(t), 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(t), 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 := 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")) + + 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(t), &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]basic.PasswordUser{ + "alice": mockUser(t, account{subject: "alice", hash: hash}), + "locked": mockUser(t, account{subject: "locked", hash: hash, locked: true}), + } + loader := mockLoader(t, 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)} + + 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) + + 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 := basic.NewMockUserLoader(t) // never called + + 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() + + 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")) + + require.ErrorIs(t, err, security.ErrInvalidCredentials) + require.ErrorIs(t, err, password.ErrMalformedHash) + assert.False(t, out.IsAuthenticated()) +} 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..69674acd --- /dev/null +++ b/bearer/redaction_test.go @@ -0,0 +1,130 @@ +// 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/mock" + "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()) + + 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) + + 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() + + 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) + require.Error(t, err) + assert.Equal(t, security.Anonymous(), out) + } + + 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) + 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/docs/security-considerations.md b/docs/security-considerations.md index c632ad6d..2cf2d011 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -7,26 +7,106 @@ 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 -`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. + +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 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 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 + +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 @@ -1072,24 +1152,95 @@ 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. + `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 opens as a valid session, it has no + session: a tossed cookie can deny a session (until it expires), never + 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 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. -- **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, 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 + 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 @@ -1458,7 +1609,14 @@ 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. +- [ ] 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. @@ -1535,8 +1693,13 @@ 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; 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/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/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/examples/session-web/login_test.go b/examples/session-web/login_test.go new file mode 100644 index 00000000..de446112 --- /dev/null +++ b/examples/session-web/login_test.go @@ -0,0 +1,218 @@ +// 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) +} + +// 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 c9e30c33..44f1d32d 100644 --- a/examples/session-web/main.go +++ b/examples/session-web/main.go @@ -6,15 +6,23 @@ // // 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 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: // // 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 +32,52 @@ 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 + 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 // 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 +96,101 @@ func newServer() (http.Handler, error) { a := &app{ manager: session.NewManager(codec), - hasher: hasher, - users: map[string]string{"alice": aliceHash}, + // 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() 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 without a subject is never 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 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.form.Get(r.Context(), carrier) + if err != nil { + if s, err = a.form.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, 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) - 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.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) - 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 } - // Same response for unknown user and wrong password (anti-enumeration). - http.Error(w, "invalid credentials", http.StatusUnauthorized) + 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) + + return + } + + http.Redirect(w, r, "/", http.StatusSeeOther) } // logout clears the session after checking the CSRF token. @@ -152,6 +214,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 +236,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 82cc6de1..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) { @@ -112,7 +68,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/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) 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..ebebdc5a --- /dev/null +++ b/password/bounds_test.go @@ -0,0 +1,397 @@ +// 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}, + {"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}, + {"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) +} 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/authenticator.go b/session/authenticator.go index d592071c..0a78a130 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), @@ -70,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/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") } 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/csrf.go b/session/csrf.go index 60411f2d..78dc8b4b 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,30 @@ 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, 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 diff --git a/session/doc.go b/session/doc.go index 2d94546d..ce8ed64f 100644 --- a/session/doc.go +++ b/session/doc.go @@ -3,12 +3,33 @@ // 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). +// +// 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 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. +// 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.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/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/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 new file mode 100644 index 00000000..17d12d98 --- /dev/null +++ b/session/hygiene_test.go @@ -0,0 +1,386 @@ +// 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() + + // 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) + + 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") + + // 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{ + "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") + + 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") +} + +// 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, +// 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/lifetime_test.go b/session/lifetime_test.go new file mode 100644 index 00000000..e04144e3 --- /dev/null +++ b/session/lifetime_test.go @@ -0,0 +1,295 @@ +// 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) + + 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) + + _, 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..c1d1e6fe 100644 --- a/session/manager.go +++ b/session/manager.go @@ -10,7 +10,9 @@ import ( "encoding/hex" "errors" "fmt" + "math" "net/http" + "strings" "time" "github.com/hyperscale-stack/security" @@ -24,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) @@ -41,14 +49,44 @@ type Manager struct { ttl time.Duration idleTimeout time.Duration 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 +// 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; 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: "/". @@ -72,13 +110,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,29 +136,141 @@ func WithClock(now func() time.Time) Option { } } -// NewManager builds a [Manager] sealing sessions with codec. +// 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. 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 !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). +// +// 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() @@ -141,11 +297,19 @@ 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 -// [Manager.Touch] when sliding expiry is desired. +// 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. 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() @@ -155,42 +319,142 @@ 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. 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 + } + + 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.Decode(found[0].Value) + s, err := m.codec.open(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) } - s.LastAccessed = now - span.SetAttributes(attribute.String("session.id_hash", hashID(s.ID))) + s.ExpiresAt = m.deadline(s) // the enforced deadline, earlier after a TTL decrease + + if err := m.validate(ctx, s); err != nil { + return nil, err + } 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 +471,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 +509,28 @@ 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) + // 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) + } + + s.ExpiresAt = deadline + + value, err := m.codec.seal(s, m.cookieName, m.purpose) if err != nil { return fmt.Errorf("session: encode: %w", err) } - // 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 + 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) @@ -269,8 +542,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/redaction_test.go b/session/redaction_test.go new file mode 100644 index 00000000..70c52291 --- /dev/null +++ b/session/redaction_test.go @@ -0,0 +1,191 @@ +// 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/mock" + "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) + + // 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) + + 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. 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 } + +// 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 6c8c1eee..64bf11c1 100644 --- a/session/session.go +++ b/session/session.go @@ -7,6 +7,8 @@ package session import ( "crypto/rand" "encoding/base64" + "encoding/json" + "math" "time" ) @@ -26,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 } @@ -62,7 +68,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 +94,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/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) +} 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) { 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) +}