authkit initial

This commit is contained in:
juancwu 2026-04-26 01:36:53 +00:00
commit 134393fbca
43 changed files with 5188 additions and 1 deletions

136
hasher/argon2id.go Normal file
View file

@ -0,0 +1,136 @@
// Package hasher provides authkit.Hasher implementations. 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.
package hasher
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"io"
"strings"
"git.juancwu.dev/juancwu/authkit"
"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,
}
}
type argon2idHasher struct {
params Argon2idParams
rng io.Reader
}
// NewArgon2id builds an authkit.Hasher backed by Argon2id. If params is the
// zero value, DefaultArgon2idParams() is used. rng defaults to crypto/rand.
func NewArgon2id(params Argon2idParams, rng io.Reader) authkit.Hasher {
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
}