first commit
This commit is contained in:
230
go-api/internal/auth/password.go
Normal file
230
go-api/internal/auth/password.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user