authkit/hasher/argon2id.go
juancwu d3c5367492 Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API
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>
2026-04-26 23:27:30 +00:00

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
}