// 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$$ // 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$$ 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 }