Files
llmgw/internal/account/password.go
T
Ilker ManapandClaude Opus 5.5 44cd58005a Count sign-in attempts per address and client, guard the sign-in form with a token, ration both forms per address
A wrong password from anywhere counted against the address, so ten locked
its owner out. Attempts are now counted per address and client, with a
looser bound per address alone. The sign-in form carries a token matched
by a cookie (login CSRF). POST /login and /signup are rationed per client
address, a hash waits at most ten seconds for a turn, and server init
writes signup_open: false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:26:29 +02:00

145 lines
4.8 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"
"runtime"
"strings"
"time"
"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, err := idKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
if err != nil {
return "", err
}
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, err := idKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
if err != nil {
return false, err
}
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
}
// argonSlots bounds how many hashes run at once. Each takes 64 MiB, and the
// login and signup forms are on the client-facing port: without a bound, a
// few hundred parallel attempts at random addresses asked for gigabytes at
// once, and the per-address throttle does not see them — each address is new.
// Beyond the bound, attempts wait their turn instead.
var argonSlots = make(chan struct{}, max(2, runtime.NumCPU()/2))
// ErrBusy is a hash that waited its turn too long: the server is answering
// more sign-ins than it can hash, and says so rather than hold the request.
var ErrBusy = errors.New("too many sign-ins at once; try again in a moment")
// argonWait bounds the wait for a turn; a variable so a test can shorten it.
var argonWait = 10 * time.Second
func idKey(password, salt []byte, t, memory uint32, threads uint8, keyLen uint32) ([]byte, error) {
select {
case argonSlots <- struct{}{}:
case <-time.After(argonWait):
return nil, ErrBusy
}
defer func() { <-argonSlots }()
return argon2.IDKey(password, salt, t, memory, threads, keyLen), nil
}