11 KiB
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:
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:
- Client:
NewScramClient(username, password)thenStartAuthentication(). - Server:
ProcessClientFirstMessage(first.Username, first.ClientNonce). - Client:
ProcessServerFirstMessage(challenge). - Server:
ProcessClientFinalMessage(proof.FullNonce, proof.ClientProof). - 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 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:
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
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 for findings and scope.