218 lines
11 KiB
Markdown
218 lines
11 KiB
Markdown
# 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.
|
||
|
||
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.
|