Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 67 additions & 21 deletions middleware/basicauth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strconv"

"github.com/goceleris/celeris"
)
Expand Down Expand Up @@ -35,21 +36,28 @@ type Config struct {
// HashedUsers maps usernames to opaque hash strings. The format is
// determined by HashedUsersFunc — bcrypt's $2y$..., argon2id's $argon2..,
// scrypt, etc. HashedUsersFunc is REQUIRED whenever HashedUsers is
// non-empty; basicauth.New() panics otherwise. There is no built-in
// password-hash default because all general-purpose hashes (SHA-2,
// SHA-3, BLAKE2) are too fast to safely store credentials with.
// non-empty, with one exception: when every value carries the
// "pbkdf2-sha256$" tag of [HashPasswordPBKDF2] and parses within the
// window [VerifyPassword] accepts, VerifyPassword is wired in
// automatically. basicauth.New() panics otherwise — naming the entry
// when a tagged value is malformed or out of window. There is no
// fast-hash default because all general-purpose hashes (SHA-2, SHA-3,
// BLAKE2) are too fast to safely store credentials with.
HashedUsers map[string]string

// HashedUsersFunc receives the stored hash string and the plaintext
// candidate; returns true on match. Required when HashedUsers is set.
// Callers typically wrap bcrypt.CompareHashAndPassword or
// argon2.IDKey + subtle.ConstantTimeCompare.
// candidate; returns true on match. Required when HashedUsers is set
// (unless all hashes are pbkdf2-sha256, see HashedUsers). Callers
// typically pass [VerifyPassword] or wrap bcrypt.CompareHashAndPassword
// or argon2.IDKey + subtle.ConstantTimeCompare.
//
// IMPORTANT: The function MUST take constant time for any input,
// including empty or invalid hash strings. For bcrypt, this means
// pre-computing a dummy hash (via bcrypt.GenerateFromPassword) and
// comparing against it for unknown users, rather than letting
// bcrypt.CompareHashAndPassword fail instantly on an empty hash.
// [VerifyPassword] meets this: it performs one PBKDF2 derivation for
// every input, whatever format the stored hash is in.
HashedUsersFunc func(hash, password string) bool

// Realm is the authentication realm. Default: "Restricted".
Expand Down Expand Up @@ -105,21 +113,34 @@ func applyDefaults(cfg Config) Config {
}
if cfg.Validator == nil && cfg.ValidatorWithContext == nil && len(cfg.HashedUsers) > 0 {
if cfg.HashedUsersFunc == nil {
// SHA-256 is fast — adversaries can crack it on commodity GPUs
// at billions of guesses per second. The default has been
// removed so callers must wire bcrypt / scrypt / argon2 (or
// equivalent) explicitly. See package docs for an example.
panic("basicauth: HashedUsers requires HashedUsersFunc (use bcrypt or argon2; SHA-256 is not credential-grade)")
if !allPBKDF2(cfg.HashedUsers) {
// SHA-256 is fast — adversaries can crack it on commodity
// GPUs at billions of guesses per second. There is no
// fast-hash default: callers must wire VerifyPassword,
// bcrypt / scrypt / argon2 (or equivalent) explicitly.
// See package docs for the migration path.
panic("basicauth: HashedUsers requires HashedUsersFunc unless every hash is pbkdf2-sha256 " +
"(use HashPasswordPBKDF2 + VerifyPassword, bcrypt, or argon2; plain SHA-256 is not credential-grade)")
}
if u, bad := malformedPBKDF2Entry(cfg.HashedUsers); bad {
// A tagged value VerifyPassword cannot honour would 401
// that user on every request; fail at startup instead and
// say which entry.
panic("basicauth: HashedUsers entry for " + strconv.Quote(u) + " is not a valid pbkdf2-sha256 hash " +
"(want pbkdf2-sha256$<iter>$<salt-b64>$<hash-b64> with " +
strconv.Itoa(minPBKDF2Iterations) + " <= iter <= " + strconv.Itoa(maxPBKDF2Iterations) +
", salt of at least " + strconv.Itoa(minPBKDF2SaltLen) + " bytes, hash of exactly " +
strconv.Itoa(pbkdf2KeyLen) + " bytes)")
}
// Every hash is a slow, salted KDF inside the window we
// enforce, so a built-in verifier is safe here.
cfg.HashedUsersFunc = VerifyPassword
}
hashCopy := make(map[string]string, len(cfg.HashedUsers))
for u, h := range cfg.HashedUsers {
hashCopy[u] = h
}
var dummyHash string
for _, h := range hashCopy {
dummyHash = h
break
}
dummyHash := pickDummyHash(hashCopy)
verifyFn := cfg.HashedUsersFunc
cfg.Validator = func(user, pass string) bool {
h, ok := hashCopy[user]
Expand All @@ -133,6 +154,29 @@ func applyDefaults(cfg Config) Config {
return cfg
}

// pickDummyHash chooses the stored hash the auto-generated Validator
// verifies unknown usernames against. It is a real stored value so a
// caller-supplied verifier (bcrypt, argon2) pays its genuine cost on a
// miss. A pbkdf2-sha256 entry is preferred when the store is mixed —
// VerifyPassword costs the same for every format, but a custom verifier
// that dispatches on the tag may not — and ties break on username so the
// choice does not depend on map-iteration order. Returns "" for an empty
// map.
func pickDummyHash(hashes map[string]string) string {
var best, bestUser string
bestRank := -1
for u, h := range hashes {
rank := 0
if isPBKDF2Hash(h) {
rank = 1
}
if rank > bestRank || (rank == bestRank && u < bestUser) {
best, bestUser, bestRank = h, u, rank
}
}
return best
}

// hmacSHA256 computes HMAC-SHA256(key, data) and returns the 32-byte tag.
func hmacSHA256(key, data []byte) []byte {
mac := hmac.New(sha256.New, key)
Expand All @@ -142,11 +186,13 @@ func hmacSHA256(key, data []byte) []byte {

// HashPassword returns the hex-encoded SHA-256 hash of password.
//
// DEPRECATED: SHA-256 is not credential-grade — adversaries can crack it
// at billions of guesses per second on commodity GPUs. Use bcrypt or
// argon2 with [Config.HashedUsersFunc] instead. This helper is retained
// for backwards-compatibility but may be removed in a future major
// release.
// Deprecated: an unsalted, fast SHA-256 digest is not a credential-storage
// hash — identical passwords share a digest and it is brute-forceable at
// GPU speed (CodeQL go/weak-sensitive-data-hashing, celeris#503). Use
// [HashPasswordPBKDF2] to produce new hashes; [VerifyPassword] accepts both
// formats so existing stores can migrate one entry at a time. This helper's
// behaviour is frozen for backwards-compatibility and it may be removed in
// a future major release.
func HashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
Expand Down
55 changes: 51 additions & 4 deletions middleware/basicauth/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
// Exactly one credential source is required; [New] panics otherwise:
// - [Config].Users — plaintext map, auto-generates a constant-time HMAC validator.
// - [Config].HashedUsers + [Config].HashedUsersFunc — opaque hash strings with
// a caller-supplied compare function (bcrypt, argon2id, scrypt, etc.).
// HashedUsersFunc is mandatory when HashedUsers is set.
// a compare function: the built-in [VerifyPassword], or a caller-supplied
// one (bcrypt, argon2id, scrypt, etc.). HashedUsersFunc may be omitted
// only when every hash was produced by [HashPasswordPBKDF2].
// - [Config].Validator — arbitrary func(user, pass string) bool.
// - [Config].ValidatorWithContext — same, with request context access.
//
Expand All @@ -23,11 +24,57 @@
// },
// }))
//
// Hashed credentials without a third-party KDF dependency:
//
// // Generate once (e.g. `go run` a tiny tool) and paste the string into
// // config; every call yields a different salt.
// hash := basicauth.HashPasswordPBKDF2("secret")
// // -> pbkdf2-sha256$600000$<salt-b64>$<hash-b64>
//
// server.Use(basicauth.New(basicauth.Config{
// HashedUsers: map[string]string{"admin": hash},
// // HashedUsersFunc defaults to basicauth.VerifyPassword when every
// // hash is pbkdf2-sha256.
// }))
//
// Use [UsernameFromContext] to retrieve the authenticated username downstream.
// Set [Config].Skip or [Config].SkipPaths to bypass the middleware selectively.
//
// Note: [HashPassword] (SHA-256) is deprecated and credential-grade only with a
// modern KDF. Use bcrypt or argon2 via [Config].HashedUsersFunc instead.
// # Migrating from HashPassword (SHA-256)
//
// [HashPassword] is deprecated: it produces an unsalted, fast SHA-256 digest,
// which is not a credential-storage hash (identical passwords collide and
// the digest is brute-forceable at GPU speed). Nothing breaks for existing
// deployments — HashPassword's output is unchanged and any HashedUsersFunc
// you already supply keeps working — but new hashes should come from
// [HashPasswordPBKDF2] (PBKDF2-HMAC-SHA256, random 16-byte salt, 600,000
// iterations, 32-byte key; stdlib crypto/pbkdf2, no new dependencies).
//
// To migrate an existing HashedUsers store incrementally:
//
// 1. Set HashedUsersFunc to [VerifyPassword]. It detects the format of each
// stored hash — "pbkdf2-sha256$..." or a bare hex SHA-256 digest — and
// compares with crypto/subtle.ConstantTimeCompare either way, so mixed
// stores authenticate correctly. Every call costs one PBKDF2 derivation
// whatever the format (legacy entries and unknown users burn a
// default-cost one), so response time does not reveal which users have
// migrated. The one cost-related signal left is a non-default iteration
// count in a pbkdf2-sha256 entry, as with any tunable KDF; hashes from
// HashPasswordPBKDF2 all carry the default.
// 2. Re-hash each user with HashPasswordPBKDF2 (at the next password
// change, or in one sweep if you hold the plaintexts) and replace the
// stored value.
// 3. Once no legacy digests remain, drop the explicit HashedUsersFunc — the
// default kicks in for all-PBKDF2 stores. Mixed or legacy-only stores
// without a HashedUsersFunc still panic at [New], by design.
//
// Verification costs one PBKDF2 derivation per request (hundreds of
// milliseconds at 600k iterations) for every entry, legacy digests
// included; keep a session or token layer in front of hot endpoints rather
// than lowering the count. Stored pbkdf2-sha256 parameters are honoured
// within 600,000–10,000,000 iterations and a salt of at least 16 bytes; a
// value outside that window never verifies and, in an auto-wired store,
// makes [New] panic naming the entry.
//
// # Documentation
//
Expand Down
22 changes: 19 additions & 3 deletions middleware/basicauth/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,31 @@ func ExampleNew_validator() {
}

func ExampleNew_hashedUsers() {
// SHA-256 hashed passwords — avoids storing plaintext in source/config.
// PBKDF2-HMAC-SHA256 hashed passwords — avoids storing plaintext in
// source/config. In practice generate the strings once and paste them
// into config; HashedUsersFunc defaults to basicauth.VerifyPassword
// when every hash is pbkdf2-sha256.
_ = basicauth.New(basicauth.Config{
HashedUsers: map[string]string{
"admin": basicauth.HashPassword("secret"),
"user": basicauth.HashPassword("password"),
"admin": basicauth.HashPasswordPBKDF2("secret"),
"user": basicauth.HashPasswordPBKDF2("password"),
},
})
}

func ExampleVerifyPassword() {
// Migrating a store that still holds legacy HashPassword digests:
// VerifyPassword accepts both formats, so entries can be re-hashed one
// at a time.
_ = basicauth.New(basicauth.Config{
HashedUsers: map[string]string{
"admin": basicauth.HashPasswordPBKDF2("secret"),
"legacy": "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b", // sha256("secret")
},
HashedUsersFunc: basicauth.VerifyPassword,
})
}

func ExampleNew_contextValidator() {
// Context-aware validator for per-request auth decisions.
_ = basicauth.New(basicauth.Config{
Expand Down
Loading