Files
auth/README.md
T
lixen 0dc8189147
Test and Fuzz / Build, Test, and Fuzz (push) Canceled after 0s
v0.5.1 pin scram wire format and edge tests
2026-10-02 15:16:39 -04:00

223 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# auth
Small, independent authentication components for Go 1.26+: Argon2id password
hashing, Argon2-SCRAM, HS256/RS256 JWTs, and opaque token validation.
## Argon2-SCRAM
This is a **package-specific JSON challenge/response protocol using Argon2id and
HMAC-SHA256**, not standard SASL SCRAM-SHA-256. Both peers must implement this
protocol. It does not implement SASLprep. Use authenticated TLS (HTTPS/WSS for
HTTP/WebSocket transports), including server certificate and hostname
verification. SCRAM does not encrypt subsequent application traffic.
Provision directly; do not create a PHC hash just to convert it:
```go
cred, err := auth.NewCredential(username, password)
if err != nil {
return err
}
// Persist cred.Export() in protected storage, then load with ImportCredential.
if err := server.AddCredential(cred); err != nil {
return err
}
```
For a service, construct `server` using
`NewScramServerWithDecoyKey(decoyKey)` and handle its error. Provision and persist
at least 32 random bytes for this key, separately from credentials. Reuse it across
restarts and all replicas sharing the credential database. `NewScramServer()`
remains available for single-instance use, but its random key changes unknown-user
salts on every restart, which can reveal account existence to repeated probes.
Always call `server.Stop()` at shutdown.
The exchange is:
1. Client: `NewScramClient(username, password)` then `StartAuthentication()`.
2. Server: `ProcessClientFirstMessage(first.Username, first.ClientNonce)`.
3. Client: `ProcessServerFirstMessage(challenge)`.
4. Server: `ProcessClientFinalMessage(proof.FullNonce, proof.ClientProof)`.
5. Client: `VerifyServerFinalMessage(final)` before accepting login or any token
delivered with it.
Clients reject costs below 64 MiB and 3 iterations by default, preventing a
hostile server from requesting a cheaply guessable password proof. For a trusted
deployment with a different profile, pass `WithMinArgonCost(iterations, memoryKiB)`
to `NewScramClient`. Choose this policy locally; never copy it from a challenge.
Invalid minimum configuration is rejected by `StartAuthentication`.
### Channel binding
Pass `WithChannelBinding(data)` to both `ProcessServerFirstMessage` (client) and
`ProcessClientFinalMessage` (server) to commit the proof to the transport. Both
peers must derive identical bytes, e.g. SHA-256 of the server's leaf certificate
DER (RFC 5929 style, stable across pooled connections) or an RFC 9266 TLS
exporter (per connection). A relay that terminates TLS with any other
certificate, even one your CA trusts, then fails the proof, so it can neither
complete the login nor obtain a token issued with it. Bound and unbound peers
never agree, so there is no silent downgrade; an empty or oversized (>64 bytes)
binding returns `ErrSCRAMChannelBinding`. A mismatch is reported as
`ErrInvalidCredentials`, like a wrong password. When later requests travel on
new connections (HTTP), the client must also pin the bound certificate on them.
Nil options are ignored; a repeated `WithChannelBinding` keeps the last one.
v0.5.0 added the variadic `...ExchangeOption` parameter to both methods. Calls
compile unchanged; interfaces or method values declaring the old signatures must
be updated.
Check every error and abort the exchange on failure. Only a successful **server
final** authenticates a user; use its `Username`, never a separate identity from
an untrusted request. Bind the exchange to its connection/request context in the
application. See [example_test.go](example_test.go) for a complete exchange.
`ScramServer` is safe for concurrent use. Each `ScramClient` belongs to one
exchange and must not be shared concurrently. Starting again discards previous
state. Challenges and final messages are single-use; errors consume client state.
`Reset` clears transient keys while retaining the public username/password fields
for retry. Secret clearing is best effort; Go strings and runtime copies cannot
be reliably erased.
All credentials in a server must share Argon2 parameters and salt length;
`AddCredential` returns `ErrSCRAMCredentialProfile` otherwise. Load the full
credential set before accepting traffic and use identical profiles on replicas.
Unknown users receive stable decoy challenges and fail at the proof step with
`ErrInvalidCredentials`, as wrong passwords do. This reduces enumeration signals;
it is not a guarantee against statistical timing analysis. Application responses,
registration, logging, and rate limits must not disclose account existence.
`AddCredential` validates and copies its input; check the returned error.
Replacement and `RemoveCredential(username)` invalidate pending handshakes for
that user. They do not revoke already issued JWTs or application sessions.
`Stop` discards pending handshakes and rejects further authentication.
## Password hashing and migration
`HashPassword(password, opts...)` returns an Argon2id PHC record.
`VerifyPassword(password, phc)` verifies it. New passwords must contain 8–1024
**bytes**; applications should apply their own password policy. Verification and
explicit `DeriveCredential` allow older shorter passwords. Defaults are 64 MiB,
3 iterations, 4 lanes, a random 16-byte salt, and a 32-byte digest. Do not use the
low-cost test parameters in production.
`WithTime`, `WithMemory` (KiB), and `WithThreads` configure hashing or
`NewCredential`; zero options retain defaults. All KDF entry points reject
invalid or excessive parameters before allocation. Existing short PHC salts of
8–15 bytes remain verifiable but cannot be migrated to SCRAM; reset/re-enroll
those credentials with fresh salts.
`MigrateFromPHC(username, password, phc)` verifies the password and derives a
SCRAM credential, normally reusing the single KDF result. A non-32-byte PHC
digest requires a second derivation. After migration, remove the old PHC record
unless it is still needed by a separate authentication path: its digest is the
salted password from which both SCRAM keys can be derived.
`ValidatePHCHashFormat` checks syntax, encoding, and structural parameter limits
without running the KDF. A syntactically valid record may still exceed execution
limits. PHC and SCRAM base64 fields must be canonical, with no CR/LF.
## JWTs
Create one manager per key and service policy, then reuse it concurrently:
```go
manager, err := auth.NewJWT(secret, // at least 32 cryptographically random bytes
auth.WithIssuer("lixen-auth"),
auth.WithAudience([]string{"vi-fighter"}),
auth.WithTokenLifetime(15*time.Minute),
auth.WithLeeway(0))
if err != nil {
return err
}
token, err := manager.GenerateToken(userID, map[string]any{"session_id": sessionID})
if err != nil {
return err
}
subject, claims, err := manager.ValidateToken(token)
// Check err, then authorize subject and the application claims for this action.
```
Generation uses `sub`, `exp`, `iat`, `nbf`, and configured `iss`/`aud`. Application
claims remain nested under `extra` and cannot overwrite registered claims.
Validation requires expiration and a nonempty subject, validates `nbf` and `iat`
when present, pins the signing algorithm, and enforces configured issuer and
**any matching audience**. No identity/claims are returned on failure.
Use `errors.Is` with package errors. `ErrTokenMissingClaim` covers missing or
mismatched issuer/audience; `ErrTokenNotYetValid` also covers future `iat`.
For logwisp and vi-fighter, use distinct audiences and verify them at each
service; use separate keys where trust differs. With HS256, every verifier can
mint tokens. For separate signing and verification trust, use
`NewJWTRSA(privateKey)` and `NewJWTVerifier(publicKey)` with the same claim policy.
RSA keys must be valid and at least 2048 bits. PEM constructors accept PKCS#1 or
PKCS#8 private keys and PKCS#1 or PKIX public keys. Constructors copy keys and
audiences; create a new manager for rotation and synchronize its replacement in
the application. Options are for construction only.
The default lifetime is 24 hours and the existing clock leeway is **5 minutes**,
including after expiration. Explicitly set `WithLeeway(0)` or a small justified
skew for new services. Expiry does not revoke an otherwise valid JWT on logout:
check a persisted session or a revocation policy when immediate revocation is
required. Do not place passwords, credential keys, or secrets in JWT claims;
JWT payloads are readable by the holder.
The unscoped one-off helpers `GenerateHS256Token` and `ValidateHS256Token` were
removed in v0.5.0; their only consumer (chess) now uses a scoped manager. Migrate
by constructing one `NewJWT` manager with an explicit issuer, audience, lifetime,
and leeway, then calling `GenerateToken`/`ValidateToken`. Application claims keep
the same nested `extra` JSON shape. Tokens minted by the removed helpers carry no
issuer/audience and are rejected by a scoped manager, so rotate sessions (users
sign in again) when switching.
## Opaque tokens and HTTP
`ParseBearerToken(header)` parses the case-insensitive Bearer scheme and RFC
6750 token alphabet. It rejects whitespace inside tokens, misplaced padding,
and oversized input. It only extracts a token; always validate it afterward.
`SimpleTokenValidator` is a concurrent, in-memory allowlist keyed by SHA256(token).
Its zero value works. Generate high-entropy opaque tokens with `crypto/rand.Text`,
then use `AddToken`, `ValidateToken`, and `RemoveToken`. Empty and oversized tokens
are never accepted. There is no TTL or persistence; use JWTs or application
session storage when needed. `AddToken` ignores invalid lengths for API stability.
## Resource limits
| Input/resource | Limit |
| --- | --- |
| Password | 1024 bytes at every KDF entry |
| SCRAM username | 1–256 UTF-8 bytes; no control characters, comma, or equals |
| Client nonce / combined nonce | 256 / 512 printable ASCII bytes; no comma |
| SCRAM channel binding | 1–64 bytes |
| SCRAM salt / key / proof | 16–64 / 32 / 32 decoded bytes |
| PHC record / salt / digest | 256 encoded / 8–64 / 16–64 decoded bytes |
| Argon2 execution | memory ≤256 MiB, iterations ≤16, lanes ≤16, memory ≥8×lanes KiB |
| SCRAM client minimum | 64 MiB and 3 iterations by default; trusted local policy can override |
| Argon2 combined work | memory×iterations ≤786432 KiB-passes (4×default) |
| Pending server handshakes | 4096, with a 30-second timeout |
| JWT / opaque token | 16 KiB |
These are per-operation ceilings, not aggregate memory/CPU quotas. Set transport
body/frame limits **before decoding**, use connection/request deadlines, rate
limit by peer/account, and bound concurrent KDF calls for your available RAM.
The server proof path only hashes/HMACs; registration, migration, password checks,
and SCRAM clients run Argon2. Apply limits to all of those routes. Username and
password normalization is application policy and must be identical at both ends.
## Verification
```sh
go vet ./...
go test -race -count=1 ./...
go test -run '^$' -bench . -benchmem
# Run each target separately; keep worker count bounded for KDF fuzzing.
for target in FuzzParsePHC FuzzVerifyPassword FuzzImportCredential FuzzValidateToken FuzzParseBearerToken FuzzScramServerMessages FuzzScramClientMessages; do
GOMAXPROCS=2 go test -run '^$' -fuzz "^${target}$" -fuzztime=60s -parallel=2
done
```
CI runs race tests, vet, and short fuzz campaigns. Fuzz KDF paths use bounded test
costs; deterministic tests cover production limits without allocating at those
limits. See [the audit report](doc/security-audit.md) for findings and scope.