A review of v0.10.4-rc.4, and a look at the live network, found: - Alertmanager listening on every interface: from any node its API took silences, with no login. Bound to loopback on the monitoring host, and the monitoring README now installs it that way; Prometheus beside it is its only client, and one Alertmanager needs no cluster port. - Login and signup unbounded in memory. Each attempt runs argon2 with 64 MiB, and nothing limited how many ran at once; the per-address throttle kept one entry per address ever tried. Hashing now takes one of a few slots and waits for one otherwise. The throttle expires entries whose window has passed when it fills, and if it is still full it refuses new addresses: evicting live ones instead would let a flood of made-up addresses reset the throttle on the address actually being guessed. - Metric labels taken from the request. A refused request counted the model name it asked for, so any key holder could mint a series per request. Requests are counted under the catalog's name, or "unknown". - Two in code written this week. A SQLite backup made its snapshot with the default mode in the listable backup directory, a full copy of the store readable while the backup ran; it is now made in a private directory, and the unit sets UMask=0077. And archive links were checked by name only, so "a -> ." then "a/a/b -> ../.." passed and a file written through them landed above the install directory, as root. Archives now unpack through os.Root, and a link may only point at a file beside it — which every llama.cpp b10991 archive, CPU and CUDA, was checked to follow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
127 lines
4.3 KiB
Go
127 lines
4.3 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"
|
|
|
|
"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 := 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 := 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
|
|
}
|
|
|
|
// 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))
|
|
|
|
func idKey(password, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte {
|
|
argonSlots <- struct{}{}
|
|
defer func() { <-argonSlots }()
|
|
return argon2.IDKey(password, salt, time, memory, threads, keyLen)
|
|
}
|