CI / test (push) Successful in 1m9s
Every key needed an operator with a shell. This adds the login system that made that necessary, which is the thing I argued against building until it was asked for — so it is built as if it will be attacked, because a public login form is. Signing up produces an account that can do nothing. Approval is what makes it work and stays on the CLI: having a shell on the control plane is what makes someone an administrator, and that is a smaller thing to get right than roles and an admin UI that mints credentials. The details that matter: Passwords are argon2id, where a slow hash is correct — the input is chosen by a human and has a dictionary, which is the opposite of an API key. Both kinds of authentication now live in this codebase and they are in separate packages so neither set of assumptions leaks into the other. Length is the only rule: composition rules push people towards Password1! and cost more entropy than they buy. Signup never says whether an address already has an account, and sign-in answers the same way for a wrong password and a missing account — verifying against a dummy hash when there is no user, so a stopwatch cannot enumerate accounts. Account state is checked after the password, not before, so guessing an address reveals nothing about it. Guessing is bounded per address rather than per IP: an attacker has many addresses to come from and one to guess against, and limiting by IP locks out an office behind one NAT. Sessions are server-side, so signing out and suspending take effect immediately rather than whenever a token expires; suspension is checked in the same query that resolves the session, because a second round trip is a check somebody eventually forgets. Every state-changing form carries a CSRF token verified against a hash in the session — a cookie authenticates the browser, not the page that asked. Key ownership is enforced in the UPDATE rather than by reading first, so a guessed key id from another account changes nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
3.7 KiB
Go
113 lines
3.7 KiB
Go
// Package account is the human half of authentication: people sign up, an
|
|
// operator approves them, and they issue their own API keys from a profile page
|
|
// (plan §7.9).
|
|
//
|
|
// This is deliberately a different mechanism from the one in internal/auth.
|
|
// That one authenticates machines holding 256-bit random keys; this one
|
|
// authenticates people holding passwords they chose. The two have opposite
|
|
// requirements — a fast hash is correct there and wrong here — and keeping them
|
|
// apart is what stops one set of assumptions leaking into the other.
|
|
package account
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// Argon2 parameters. OWASP's current floor for argon2id, which costs roughly
|
|
// 50ms per attempt on ordinary hardware — slow enough to make an offline
|
|
// dictionary attack expensive, fast enough that a login does not feel broken.
|
|
//
|
|
// They are stored with each hash, so raising them later leaves existing
|
|
// passwords working until their owners next sign in.
|
|
const (
|
|
argonTime = 3
|
|
argonMemory = 64 * 1024 // KiB
|
|
argonThreads = 4
|
|
argonKeyLen = 32
|
|
saltLen = 16
|
|
)
|
|
|
|
// MinPasswordLen is the only password rule.
|
|
//
|
|
// Length is the one requirement that reliably buys entropy. Composition rules —
|
|
// a digit, a symbol, a capital — push people towards Password1! and produce
|
|
// less entropy than they cost, so there are none.
|
|
const MinPasswordLen = 12
|
|
|
|
// ErrWeakPassword says a password is too short to accept.
|
|
var ErrWeakPassword = fmt.Errorf("a password must be at least %d characters", MinPasswordLen)
|
|
|
|
// ErrBadHash says a stored hash could not be parsed.
|
|
var ErrBadHash = errors.New("unreadable password hash")
|
|
|
|
// HashPassword derives a storable hash.
|
|
func HashPassword(password string) (string, error) {
|
|
if len([]rune(password)) < MinPasswordLen {
|
|
return "", ErrWeakPassword
|
|
}
|
|
salt := make([]byte, saltLen)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
|
|
|
b64 := base64.RawStdEncoding.EncodeToString
|
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(sum)), nil
|
|
}
|
|
|
|
// VerifyPassword reports whether a password matches a stored hash.
|
|
//
|
|
// The comparison is constant time. The parameters come from the hash rather
|
|
// than from the constants above, so a hash written under older settings still
|
|
// verifies.
|
|
func VerifyPassword(password, encoded string) (bool, error) {
|
|
parts := strings.Split(encoded, "$")
|
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
|
return false, ErrBadHash
|
|
}
|
|
|
|
var version int
|
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
|
|
return false, ErrBadHash
|
|
}
|
|
var memory, time uint32
|
|
var threads uint8
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
|
return false, ErrBadHash
|
|
}
|
|
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
|
if err != nil {
|
|
return false, ErrBadHash
|
|
}
|
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if err != nil {
|
|
return false, ErrBadHash
|
|
}
|
|
|
|
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
|
|
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
|
}
|
|
|
|
// dummyHash is verified against when no account matches, so a login attempt for
|
|
// an address that does not exist costs the same time as one that does.
|
|
//
|
|
// Without it, a stopwatch enumerates every account on the system.
|
|
var dummyHash string
|
|
|
|
func init() {
|
|
h, err := HashPassword(strings.Repeat("x", MinPasswordLen))
|
|
if err != nil {
|
|
panic("account: cannot build the dummy hash: " + err.Error())
|
|
}
|
|
dummyHash = h
|
|
}
|