231 lines
8.9 KiB
Go
231 lines
8.9 KiB
Go
// Package auth is the authentication foundation: password hashing, session
|
|
// tokens, and the server-side session lifecycle.
|
|
//
|
|
// It deliberately knows nothing about HTTP. There is no handler, no cookie and
|
|
// no middleware here — those arrive in a later phase and will be written in
|
|
// terms of this package, not inside it. What lives here is the part that must
|
|
// be correct regardless of transport: how a password becomes a hash, how a
|
|
// session token is generated and stored, and when a session stops being valid.
|
|
//
|
|
// Two rules hold throughout, and every function below is written to keep them:
|
|
//
|
|
// - A raw session token exists in exactly two places: the response that
|
|
// created it, and the client's cookie. The database holds SHA-256 of it.
|
|
// - Neither a password nor a token nor a password hash is ever returned in an
|
|
// error, formatted into a string, or logged. Nothing in this package logs.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// Password policy errors. They describe the rule that was broken and never
|
|
// echo the password back.
|
|
var (
|
|
ErrEmptyPassword = errors.New("auth: password is empty")
|
|
ErrPasswordTooShort = errors.New("auth: password is shorter than the minimum length")
|
|
ErrPasswordTooLong = errors.New("auth: password is longer than the maximum length")
|
|
|
|
// ErrInvalidHash means the stored value is not a hash this package wrote:
|
|
// wrong prefix, wrong field count, or unparseable parameters.
|
|
ErrInvalidHash = errors.New("auth: password hash is malformed")
|
|
|
|
// ErrIncompatibleVersion means the hash was produced by a future argon2
|
|
// version this binary cannot verify. Distinguished from ErrInvalidHash
|
|
// because it is an upgrade problem, not corruption.
|
|
ErrIncompatibleVersion = errors.New("auth: password hash uses an unsupported argon2 version")
|
|
)
|
|
|
|
const (
|
|
// MinPasswordLength is measured in bytes, not runes. A byte floor is the
|
|
// honest one: it is what the KDF consumes, and counting runes would let a
|
|
// short ASCII password through by way of a generous rune count.
|
|
MinPasswordLength = 12
|
|
|
|
// MaxPasswordLength caps the input. Argon2 has no internal length limit —
|
|
// unlike bcrypt, it does not silently truncate — so the only reason for a
|
|
// ceiling is to stop an unbounded body from being hashed at 64 MiB of
|
|
// memory per attempt. 1 KiB is far above any real passphrase.
|
|
MaxPasswordLength = 1024
|
|
)
|
|
|
|
// PasswordParams are the argon2id cost parameters.
|
|
//
|
|
// They are stored inside every hash this package writes, so a future increase
|
|
// does not invalidate existing hashes: verification reads the parameters out of
|
|
// the stored string rather than assuming today's defaults.
|
|
type PasswordParams struct {
|
|
// Memory is the KiB of memory the KDF fills. This is the parameter that
|
|
// makes GPU and ASIC attacks expensive, and the one worth raising first.
|
|
Memory uint32
|
|
// Time is the number of passes over that memory.
|
|
Time uint32
|
|
// Threads is the parallelism (argon2's `p`).
|
|
Threads uint8
|
|
// SaltLength and KeyLength are in bytes.
|
|
SaltLength uint32
|
|
KeyLength uint32
|
|
}
|
|
|
|
// DefaultPasswordParams follows the OWASP Password Storage Cheat Sheet's
|
|
// argon2id recommendation: 64 MiB of memory, 3 iterations, 4 lanes (m=65536,
|
|
// t=3, p=4). A 16-byte salt and a 32-byte key are the RFC 9106 defaults.
|
|
//
|
|
// This costs roughly a tenth of a second per login on developer hardware,
|
|
// which is the point: it is a cost an attacker pays per guess.
|
|
var DefaultPasswordParams = PasswordParams{
|
|
Memory: 64 * 1024,
|
|
Time: 3,
|
|
Threads: 4,
|
|
SaltLength: 16,
|
|
KeyLength: 32,
|
|
}
|
|
|
|
// HashPassword hashes a plaintext password with the default parameters.
|
|
//
|
|
// The returned string is a complete, self-describing PHC record — algorithm,
|
|
// version, parameters, salt and digest — and is what belongs in
|
|
// users.password_hash. It is safe to store and unsafe to log.
|
|
func HashPassword(plain string) (string, error) {
|
|
return HashPasswordWithParams(plain, DefaultPasswordParams)
|
|
}
|
|
|
|
// HashPasswordWithParams is HashPassword with explicit cost parameters. Tests
|
|
// use it to run at a cost that does not dominate the test suite; production
|
|
// code should call HashPassword.
|
|
func HashPasswordWithParams(plain string, p PasswordParams) (string, error) {
|
|
if err := ValidatePassword(plain); err != nil {
|
|
return "", err
|
|
}
|
|
if p.SaltLength == 0 || p.KeyLength == 0 || p.Memory == 0 || p.Time == 0 || p.Threads == 0 {
|
|
return "", fmt.Errorf("auth: argon2id parameters must all be non-zero")
|
|
}
|
|
|
|
salt := make([]byte, p.SaltLength)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
// crypto/rand failing is not recoverable and must never fall back to a
|
|
// weaker source: a predictable salt defeats the whole construction.
|
|
return "", fmt.Errorf("auth: read salt: %w", err)
|
|
}
|
|
|
|
key := argon2.IDKey([]byte(plain), salt, p.Time, p.Memory, p.Threads, p.KeyLength)
|
|
|
|
// The PHC string format, as produced by the reference implementation:
|
|
// $argon2id$v=19$m=65536,t=3,p=4$<b64 salt>$<b64 key>
|
|
// Standard base64 without padding, which is what the format specifies.
|
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, p.Memory, p.Time, p.Threads,
|
|
base64.RawStdEncoding.EncodeToString(salt),
|
|
base64.RawStdEncoding.EncodeToString(key),
|
|
), nil
|
|
}
|
|
|
|
// VerifyPassword reports whether plain is the password behind encoded.
|
|
//
|
|
// A false return with a nil error is the ordinary "wrong password" answer. A
|
|
// non-nil error means the *stored hash* could not be read, which is an
|
|
// operational problem rather than a failed login, and callers should tell the
|
|
// two apart: the first is a 401, the second is a 500.
|
|
//
|
|
// The digest comparison is constant-time. The length and parameter checks
|
|
// before it are not, and do not need to be: they depend only on the stored
|
|
// hash, never on the supplied password.
|
|
func VerifyPassword(encoded, plain string) (bool, error) {
|
|
p, salt, want, err := DecodePasswordHash(encoded)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// No policy check on `plain` here. A password that predates a tightened
|
|
// minimum length must still be able to log in; the policy applies when a
|
|
// password is set, which is where ValidatePassword is called.
|
|
if len(plain) > MaxPasswordLength {
|
|
return false, nil
|
|
}
|
|
|
|
got := argon2.IDKey([]byte(plain), salt, p.Time, p.Memory, p.Threads, p.KeyLength)
|
|
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
|
}
|
|
|
|
// ValidatePassword applies the policy for setting a new password.
|
|
func ValidatePassword(plain string) error {
|
|
switch {
|
|
case len(plain) == 0:
|
|
return ErrEmptyPassword
|
|
case len(plain) < MinPasswordLength:
|
|
return ErrPasswordTooShort
|
|
case len(plain) > MaxPasswordLength:
|
|
return ErrPasswordTooLong
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DecodePasswordHash parses a PHC argon2id record back into its parts.
|
|
//
|
|
// Exported so that a future re-hash-on-login path can ask whether a stored hash
|
|
// was written with weaker parameters than today's default and upgrade it. It
|
|
// returns the salt and digest, never the password.
|
|
func DecodePasswordHash(encoded string) (p PasswordParams, salt, key []byte, err error) {
|
|
// $argon2id$v=19$m=65536,t=3,p=4$<salt>$<key> splits into six fields, the
|
|
// first of which is empty because the string starts with the separator.
|
|
parts := strings.Split(encoded, "$")
|
|
if len(parts) != 6 || parts[0] != "" {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
if parts[1] != "argon2id" {
|
|
// bcrypt, argon2i and argon2d all land here. This package writes and
|
|
// reads argon2id and nothing else; a different algorithm is a
|
|
// migration decision, not something to guess at during a login.
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
|
|
var version int
|
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
if version != argon2.Version {
|
|
return p, nil, nil, ErrIncompatibleVersion
|
|
}
|
|
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Time, &p.Threads); err != nil {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
if p.Memory == 0 || p.Time == 0 || p.Threads == 0 {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
|
|
if salt, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
if key, err = base64.RawStdEncoding.DecodeString(parts[5]); err != nil {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
if len(salt) == 0 || len(key) == 0 {
|
|
return p, nil, nil, ErrInvalidHash
|
|
}
|
|
|
|
p.SaltLength = uint32(len(salt))
|
|
p.KeyLength = uint32(len(key))
|
|
return p, salt, key, nil
|
|
}
|
|
|
|
// NeedsRehash reports whether a stored hash was written with parameters weaker
|
|
// than want, so a successful login can transparently upgrade it.
|
|
//
|
|
// Unused in Phase 3B — there is no login yet — and exported now because the
|
|
// judgement belongs beside the format that encodes the parameters.
|
|
func NeedsRehash(encoded string, want PasswordParams) bool {
|
|
p, _, _, err := DecodePasswordHash(encoded)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return p.Memory < want.Memory || p.Time < want.Time ||
|
|
p.KeyLength < want.KeyLength || p.SaltLength < want.SaltLength
|
|
}
|