Files
enkly-ai/pkg/authsupport/password.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

111 lines
3.1 KiB
Go

// 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$<iterations>$<base64 salt>$<base64 hash>", 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
}