// Package auth holds enkly's password hashing, token generation, and // session/rate-limiting primitives. // // Password hashing deliberately avoids golang.org/x/crypto/bcrypt: this // module has no third-party dependencies beyond lib/pq and gomarkdown // (see go.mod), and pulling in x/crypto just for bcrypt isn't worth the // extra supply-chain surface when the stdlib's crypto/hmac + crypto/sha256 // are enough to implement PBKDF2 (RFC 8018) directly — a well-defined, // widely reviewed construction, not a home-grown cipher. package authsupport import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "fmt" "strconv" "strings" ) // pbkdf2Iterations follows OWASP's 2023 password-storage cheat sheet // recommendation for PBKDF2-HMAC-SHA256 (>= 600,000 was later raised; // 210,000 is the still-current floor for SHA256 specifically). Bump this // if hashing becomes cheap enough to be a problem — old hashes keep // verifying fine since the iteration count travels with the hash. const pbkdf2Iterations = 210_000 const ( saltLen = 16 // bytes keyLen = 32 // bytes (SHA-256 output size) ) // pbkdf2 derives a keyLen-byte key from password+salt using HMAC-SHA256 // as the PRF, per RFC 8018 section 5.2. func pbkdf2(password, salt []byte, iterations, length int) []byte { prf := hmac.New(sha256.New, password) hLen := prf.Size() numBlocks := (length + hLen - 1) / hLen var derived []byte buf := make([]byte, 4) for block := 1; block <= numBlocks; block++ { buf[0] = byte(block >> 24) buf[1] = byte(block >> 16) buf[2] = byte(block >> 8) buf[3] = byte(block) prf.Reset() prf.Write(salt) prf.Write(buf) u := prf.Sum(nil) t := make([]byte, len(u)) copy(t, u) for i := 1; i < iterations; i++ { prf.Reset() prf.Write(u) u = prf.Sum(nil) for j := range t { t[j] ^= u[j] } } derived = append(derived, t...) } return derived[:length] } // HashPassword returns an encoded hash of the form // "pbkdf2-sha256$$$", suitable for // storing directly in users.password_hash. func HashPassword(password string) (string, error) { salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("auth: generate salt: %w", err) } hash := pbkdf2([]byte(password), salt, pbkdf2Iterations, keyLen) return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash), ), nil } // VerifyPassword checks a plaintext password against an encoded hash // produced by HashPassword, in constant time. func VerifyPassword(password, encoded string) bool { parts := strings.Split(encoded, "$") if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { return false } iterations, err := strconv.Atoi(parts[1]) if err != nil || iterations <= 0 { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[2]) if err != nil { return false } want, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil { return false } got := pbkdf2([]byte(password), salt, iterations, len(want)) return subtle.ConstantTimeCompare(got, want) == 1 }