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
}

78
hasher/argon2id_test.go Normal file
View file

@ -0,0 +1,78 @@
package hasher
import (
"strings"
"testing"
)
func TestArgon2idHashVerifyRoundtrip(t *testing.T) {
h := NewArgon2id(DefaultArgon2idParams(), nil)
encoded, err := h.Hash("hunter2hunter2")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if !strings.HasPrefix(encoded, "$argon2id$") {
t.Fatalf("encoded hash not in PHC form: %s", encoded)
}
ok, needsRehash, err := h.Verify("hunter2hunter2", encoded)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !ok {
t.Fatalf("Verify rejected the original password")
}
if needsRehash {
t.Fatalf("Verify with default params should not signal rehash")
}
}
func TestArgon2idVerifyWrongPassword(t *testing.T) {
h := NewArgon2id(DefaultArgon2idParams(), nil)
encoded, err := h.Hash("correct horse battery staple")
if err != nil {
t.Fatalf("Hash: %v", err)
}
ok, _, err := h.Verify("nope", encoded)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if ok {
t.Fatalf("Verify should reject wrong password")
}
}
func TestArgon2idNeedsRehashOnParamChange(t *testing.T) {
// Hash with light params...
light := Argon2idParams{Memory: 8 * 1024, Iterations: 1, Parallelism: 1, SaltLen: 16, KeyLen: 32}
encoded, err := NewArgon2id(light, nil).Hash("hello world")
if err != nil {
t.Fatalf("Hash: %v", err)
}
// ...verify with stronger params should still match but flag rehash.
heavier := DefaultArgon2idParams()
ok, needsRehash, err := NewArgon2id(heavier, nil).Verify("hello world", encoded)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !ok {
t.Fatalf("Verify rejected legitimate password across params")
}
if !needsRehash {
t.Fatalf("Verify should flag rehash when stored params differ from current")
}
}
func TestArgon2idRejectsMalformed(t *testing.T) {
h := NewArgon2id(DefaultArgon2idParams(), nil)
cases := []string{
"",
"not-a-phc",
"$argon2i$v=19$m=64,t=1,p=1$abc$def",
"$argon2id$v=99$m=64,t=1,p=1$YWJj$ZGVm",
}
for _, c := range cases {
if _, _, err := h.Verify("x", c); err == nil {
t.Fatalf("Verify should reject malformed encoding: %q", c)
}
}
}