Drops the Dialect/Queries abstraction in favor of a single PostgreSQL 16+ implementation collapsed into the root authkit package, removes the public store interfaces, and reshapes the authorization model around seeded slugs (roles, permissions, abilities) with optional labels. Schema is now squashed into one migrations/0001_init.sql and applied automatically on authkit.New (opt-out via Config.SkipAutoMigrate). A schema verifier checks tables/columns/types/nullability on startup, tolerates extra columns, and falls back to default table names when a configured override is missing. Auth API: CreateUser + SetPassword replace Register; password is nullable. Email OTP (RequestEmailOTP/ConsumeEmailOTP) joins magic links and password reset, all with anti-enumeration silent-success defaults and a Config.RevealUnknownEmail opt-in. Service tokens drop owner columns and validate ability slugs against authkit_abilities at issue. Direct user permissions live alongside role-derived ones; queries return their UNION. Predicate API: HasRole/HasPermission/HasAbility leaves with AnyLogin/AllLogin/AnyServiceKey/AllServiceKey combinators. Validate runs at middleware construction, panicking on unknown slugs. Middleware collapses to RequireLogin (cookie + JWT), RequireGuest (configurable OnAuthenticated), and RequireServiceKey. UserIDFromCtx / UserFromCtx (lazy) / RefreshUserInCtx provide request-lifetime user caching. Cookie defaults flip to Secure=true and HttpOnly=true via *bool with BoolPtr opt-out. CLIs ship under cmd/perms, cmd/roles, cmd/abilities for seeding the authorization vocabulary; the library never seeds rows itself. Tests cover unit-level (slug validation + fuzz, opaque secrets, email normalization, extractors, predicates, OTP generator) and integration flows gated on AUTHKIT_TEST_DATABASE_URL (every Auth method, schema drift detection, migration idempotency, lazy user cache, all middleware paths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.7 KiB
Go
142 lines
4.7 KiB
Go
// Package hasher provides password-hashing primitives that satisfy the
|
|
// authkit.Hasher interface. The default implementation, Argon2id, encodes
|
|
// hashes in the standard PHC string format (https://github.com/P-H-C/phc-string-format)
|
|
// so callers can introspect parameters and migrate.
|
|
//
|
|
// This package intentionally does not import authkit — the Hasher interface
|
|
// is structurally satisfied. That keeps the dependency arrow one-way and
|
|
// lets test code in the authkit package itself import this package.
|
|
package hasher
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"git.juancwu.dev/juancwu/errx"
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// Argon2idParams configures the Argon2id KDF.
|
|
type Argon2idParams struct {
|
|
Memory uint32 // KB; OWASP 2024 baseline: 19 MiB minimum, 64 MiB recommended
|
|
Iterations uint32 // time cost
|
|
Parallelism uint8 // lanes
|
|
SaltLen uint32 // bytes
|
|
KeyLen uint32 // bytes
|
|
}
|
|
|
|
// DefaultArgon2idParams returns sensible defaults: 64 MiB memory, 3
|
|
// iterations, 2 lanes, 16-byte salt, 32-byte key. Tune up Memory/Iterations
|
|
// over time and rely on Verify's needsRehash signal to migrate stored hashes.
|
|
func DefaultArgon2idParams() Argon2idParams {
|
|
return Argon2idParams{
|
|
Memory: 64 * 1024,
|
|
Iterations: 3,
|
|
Parallelism: 2,
|
|
SaltLen: 16,
|
|
KeyLen: 32,
|
|
}
|
|
}
|
|
|
|
// Argon2idHasher implements password hashing via Argon2id. It satisfies
|
|
// authkit.Hasher through structural typing — pass *Argon2idHasher into
|
|
// authkit.Deps.Hasher.
|
|
type Argon2idHasher struct {
|
|
params Argon2idParams
|
|
rng io.Reader
|
|
}
|
|
|
|
// NewArgon2id builds an *Argon2idHasher. If params is the zero value,
|
|
// DefaultArgon2idParams() is used. rng defaults to crypto/rand.
|
|
func NewArgon2id(params Argon2idParams, rng io.Reader) *Argon2idHasher {
|
|
if params == (Argon2idParams{}) {
|
|
params = DefaultArgon2idParams()
|
|
}
|
|
if rng == nil {
|
|
rng = rand.Reader
|
|
}
|
|
return &Argon2idHasher{params: params, rng: rng}
|
|
}
|
|
|
|
func (h *Argon2idHasher) Hash(password string) (string, error) {
|
|
const op = "authkit.hasher.Argon2id.Hash"
|
|
if password == "" {
|
|
return "", errx.New(op, "password is empty")
|
|
}
|
|
salt := make([]byte, h.params.SaltLen)
|
|
if _, err := io.ReadFull(h.rng, salt); err != nil {
|
|
return "", errx.Wrap(op, err)
|
|
}
|
|
key := argon2.IDKey([]byte(password), salt,
|
|
h.params.Iterations, h.params.Memory, h.params.Parallelism, h.params.KeyLen)
|
|
return encodePHC(h.params, salt, key), nil
|
|
}
|
|
|
|
func (h *Argon2idHasher) Verify(password, encoded string) (bool, bool, error) {
|
|
const op = "authkit.hasher.Argon2id.Verify"
|
|
got, salt, key, err := decodePHC(encoded)
|
|
if err != nil {
|
|
return false, false, errx.Wrap(op, err)
|
|
}
|
|
want := argon2.IDKey([]byte(password), salt,
|
|
got.Iterations, got.Memory, got.Parallelism, uint32(len(key)))
|
|
if subtle.ConstantTimeCompare(want, key) != 1 {
|
|
return false, false, nil
|
|
}
|
|
needsRehash := got.Memory != h.params.Memory ||
|
|
got.Iterations != h.params.Iterations ||
|
|
got.Parallelism != h.params.Parallelism ||
|
|
uint32(len(salt)) != h.params.SaltLen ||
|
|
uint32(len(key)) != h.params.KeyLen
|
|
return true, needsRehash, nil
|
|
}
|
|
|
|
// PHC string format:
|
|
//
|
|
// $argon2id$v=19$m=<mem>,t=<iters>,p=<lanes>$<salt b64>$<key b64>
|
|
//
|
|
// base64 here is the unpadded standard alphabet, per the spec.
|
|
func encodePHC(p Argon2idParams, salt, key []byte) string {
|
|
enc := base64.RawStdEncoding
|
|
return fmt.Sprintf(
|
|
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, p.Memory, p.Iterations, p.Parallelism,
|
|
enc.EncodeToString(salt), enc.EncodeToString(key),
|
|
)
|
|
}
|
|
|
|
func decodePHC(s string) (Argon2idParams, []byte, []byte, error) {
|
|
parts := strings.Split(s, "$")
|
|
// Expect: ["", "argon2id", "v=19", "m=...,t=...,p=...", "<salt>", "<key>"]
|
|
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
|
|
return Argon2idParams{}, nil, nil, errors.New("hasher: not an argon2id phc string")
|
|
}
|
|
var version int
|
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
|
return Argon2idParams{}, nil, nil, fmt.Errorf("hasher: bad version segment: %w", err)
|
|
}
|
|
if version != argon2.Version {
|
|
return Argon2idParams{}, nil, nil, fmt.Errorf("hasher: unsupported argon2 version %d", version)
|
|
}
|
|
var p Argon2idParams
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Iterations, &p.Parallelism); err != nil {
|
|
return Argon2idParams{}, nil, nil, fmt.Errorf("hasher: bad params segment: %w", err)
|
|
}
|
|
enc := base64.RawStdEncoding
|
|
salt, err := enc.DecodeString(parts[4])
|
|
if err != nil {
|
|
return Argon2idParams{}, nil, nil, fmt.Errorf("hasher: bad salt: %w", err)
|
|
}
|
|
key, err := enc.DecodeString(parts[5])
|
|
if err != nil {
|
|
return Argon2idParams{}, nil, nil, fmt.Errorf("hasher: bad key: %w", err)
|
|
}
|
|
p.SaltLen = uint32(len(salt))
|
|
p.KeyLen = uint32(len(key))
|
|
return p, salt, key, nil
|
|
}
|