Vellum/internal/auth/password.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

106 lines
3.5 KiB
Go

// Package auth implements identity: password hashing, sessions, per-library
// grants, and the middleware that turns a cookie into a current user.
//
// Nothing outside this package may inspect a password or a raw session token.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. These are the OWASP-recommended baseline for
// interactive logins (memory cost is the dominant factor). They are encoded
// into every hash, so changing them here does not invalidate old passwords —
// existing hashes keep verifying with their own stored parameters.
const (
argonTime = 2
argonMemory = 64 * 1024 // KiB, i.e. 64 MiB
argonThreads = 4
argonKeyLen = 32
argonSaltLen = 16
)
var (
// ErrInvalidHash means a stored password hash is not in the PHC format
// this package writes. It indicates database corruption, not a bad login.
ErrInvalidHash = errors.New("auth: password hash is not in PHC format")
// ErrIncompatibleVersion means the hash was made by a newer argon2 version.
ErrIncompatibleVersion = errors.New("auth: incompatible argon2 version")
)
// HashPassword derives an argon2id hash and returns it as a PHC string:
//
// $argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>
//
// The parameters travel with the hash, so verification never needs them passed
// in and parameter changes are backward compatible.
func HashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("auth: read salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// VerifyPassword reports whether password matches the PHC-encoded hash.
// It always performs the full derivation, so a malformed hash and a wrong
// password cost the same — there is no timing oracle for "user exists".
func VerifyPassword(password, encoded string) (bool, error) {
salt, want, params, err := decodeHash(encoded)
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
type argonParams struct {
time uint32
memory uint32
threads uint8
}
func decodeHash(encoded string) (salt, hash []byte, p argonParams, err error) {
parts := strings.Split(encoded, "$")
// Leading empty field from the leading '$' gives 6 fields.
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
return nil, nil, p, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return nil, nil, p, ErrInvalidHash
}
if version != argon2.Version {
return nil, nil, p, ErrIncompatibleVersion
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.threads); err != nil {
return nil, nil, p, ErrInvalidHash
}
salt, err = base64.RawStdEncoding.Strict().DecodeString(parts[4])
if err != nil {
return nil, nil, p, ErrInvalidHash
}
hash, err = base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil {
return nil, nil, p, ErrInvalidHash
}
if len(hash) == 0 || len(salt) == 0 {
return nil, nil, p, ErrInvalidHash
}
return salt, hash, p, nil
}