v0.4.0 harden jwt, scram, credential handling, remove legacy adapters
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-26 20:42:42 -04:00
1 parent b28e30ee2e
commit 2bf8715e93
21 files changed
+1562 -687

No files matched your search

+186 -50
View File
@@ -1,66 +1,202 @@
# Auth Package
# auth
Modular authentication utilities for Go applications.
Small, independent authentication components for Go 1.26+: Argon2id password
hashing, Argon2-SCRAM, HS256/RS256 JWTs, and opaque token validation.
## Features
## Argon2-SCRAM
- **Password Hashing**: Standalone Argon2id hashing with PHC format.
- **JWT**: HS256/RS256 token management via a simple facade over `golang-jwt`.
- **SCRAM-SHA256**: Client/server implementation with Argon2id KDF.
- **HTTP Auth**: Helpers for parsing Basic and Bearer authentication headers.
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 or TLS channel binding. Use authenticated
TLS (HTTPS/WSS for HTTP/WebSocket transports), including server certificate and
hostname verification. SCRAM does not encrypt subsequent application traffic.
## Usage
Provision directly; do not create a PHC hash just to convert it:
```go
// Argon2 Password Hashing
hash, _ := auth.HashPassword("password123")
err := auth.VerifyPassword("password123", hash)
// JWT with HS256
jwtMgr, _ := auth.NewJWT([]byte("a-very-secure-32-byte-secret-key"))
token, _ := jwtMgr.GenerateToken("user123", map[string]any{"role": "admin"})
userID, claims, _ := jwtMgr.ValidateToken(token)
// SCRAM authentication
server := auth.NewScramServer()
defer server.Stop()
phcHash, _ := auth.HashPassword("password123")
cred, _ := auth.MigrateFromPHC("user", "password123", phcHash)
server.AddCredential(cred)
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
}
```
### SCRAM contract notes
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.
- Unknown usernames succeed at `ProcessClientFirstMessage` and fail at
`ProcessClientFinalMessage` with `ErrInvalidCredentials`. This is deliberate
user-enumeration protection. Do not log the first message as an auth success.
- Decoy Argon2 parameters mirror the most recently added credential. Provision
all credentials in a deployment with identical parameters, or the decoy shape
becomes a distinguisher.
- Passwords are bounded by `MaxPasswordLen` (1024 bytes) at every KDF entry
point.
The exchange is:
## Package Structure
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.
- `doc.go` - Overview and package documentation
- `argon2.go` - Standalone Argon2id password hashing
- `jwt.go` - JWT manager (HS256/RS256) wrapping `golang-jwt`
- `scram.go` - SCRAM-SHA256 client/server protocol
- `http.go` - HTTP Basic/Bearer header parsing
- `token.go` - Simple in-memory token validator
- `error.go` - Centralized error definitions
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`.
## Testing
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.
```bash
go test ./... -race -count=1
go test ./... -run '^$' -bench . -benchmem
`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.
# fuzz targets (run individually)
go test -run '^$' -fuzz FuzzParsePHC -fuzztime 60s
go test -run '^$' -fuzz FuzzVerifyPassword -fuzztime 60s
go test -run '^$' -fuzz FuzzImportCredential -fuzztime 60s
go test -run '^$' -fuzz FuzzValidateHS256Token -fuzztime 60s
go test -run '^$' -fuzz FuzzParseBasicAuth -fuzztime 30s
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 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.