111 lines
3.1 KiB
Go
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
|
|
}
|