v0.3.1 minor refactor, tests changed to standard library
This commit is contained in:
+382
-147
@@ -1,192 +1,427 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func TestPasswordHashing(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
func TestHashPasswordEncoding(t *testing.T) {
|
||||
const pw = "testPassword123"
|
||||
hash, err := HashPassword(pw)
|
||||
noErr(t, err, "HashPassword")
|
||||
|
||||
// Test hashing with default parameters
|
||||
hash, err := HashPassword(password)
|
||||
require.NoError(t, err, "Failed to hash password")
|
||||
parts := strings.Split(hash, "$")
|
||||
eq(t, len(parts), 6, "field count")
|
||||
eq(t, parts[0], "", "leading field")
|
||||
eq(t, parts[1], "argon2id", "algorithm")
|
||||
eq(t, parts[2], fmt.Sprintf("v=%d", argon2.Version), "version")
|
||||
eq(t, parts[3], fmt.Sprintf("m=%d,t=%d,p=%d", DefaultArgonMemory, DefaultArgonTime, DefaultArgonThreads), "parameters")
|
||||
|
||||
// Verify PHC format
|
||||
assert.True(t, strings.HasPrefix(hash, "$argon2id$"),
|
||||
"Hash should have argon2id prefix, got: %s", hash)
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
noErr(t, err, "salt decode")
|
||||
eq(t, len(salt), DefaultArgonSaltLen, "salt length")
|
||||
|
||||
// Test verification with correct password
|
||||
err = VerifyPassword(password, hash)
|
||||
assert.NoError(t, err, "Failed to verify correct password")
|
||||
digest, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
noErr(t, err, "digest decode")
|
||||
eq(t, len(digest), DefaultArgonKeyLen, "digest length")
|
||||
|
||||
// Test verification with incorrect password
|
||||
err = VerifyPassword("wrongPassword", hash)
|
||||
assert.Error(t, err, "Verification should fail for incorrect password")
|
||||
assert.Equal(t, ErrInvalidCredentials, err)
|
||||
// the encoded digest must be reproducible from the encoded material
|
||||
want := argon2.IDKey([]byte(pw), salt, DefaultArgonTime, DefaultArgonMemory, DefaultArgonThreads, DefaultArgonKeyLen)
|
||||
eqBytes(t, digest, want, "digest")
|
||||
|
||||
// Test weak password
|
||||
_, err = HashPassword("weak")
|
||||
assert.Equal(t, ErrWeakPassword, err, "Should reject weak password")
|
||||
isTrue(t, len(hash) <= MaxPHCHashLen, "default encoding within MaxPHCHashLen")
|
||||
noErr(t, ValidatePHCHashFormat(hash), "self-validation")
|
||||
}
|
||||
|
||||
// Test with custom options
|
||||
hash, err = HashPassword(password,
|
||||
WithTime(5),
|
||||
WithMemory(128*1024),
|
||||
WithThreads(8))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = VerifyPassword(password, hash)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test malformed PHC hash
|
||||
err = VerifyPassword(password, "$invalid$format")
|
||||
assert.Error(t, err, "Should reject malformed hash")
|
||||
|
||||
// Test corrupted salt
|
||||
corruptedHash := strings.Replace(hash, "$argon2id$", "$argon2id$", 1)
|
||||
parts := strings.Split(corruptedHash, "$")
|
||||
if len(parts) == 6 {
|
||||
parts[4] = "invalid!base64"
|
||||
corruptedHash = strings.Join(parts, "$")
|
||||
err = VerifyPassword(password, corruptedHash)
|
||||
assert.Error(t, err, "Should reject corrupted salt")
|
||||
func TestHashPasswordSaltUniqueness(t *testing.T) {
|
||||
seen := make(map[string]struct{}, 64)
|
||||
for range 64 {
|
||||
h, err := HashPassword("testPassword123", cheapArgon...)
|
||||
noErr(t, err, "HashPassword")
|
||||
salt := strings.Split(h, "$")[4]
|
||||
if _, dup := seen[salt]; dup {
|
||||
t.Fatalf("duplicate salt: %s", salt)
|
||||
}
|
||||
seen[salt] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPasswordAfterValidation(t *testing.T) {
|
||||
// Empty password should be rejected by length check
|
||||
func TestVerifyPassword(t *testing.T) {
|
||||
const pw = "testPassword123"
|
||||
hash, err := HashPassword(pw, cheapArgon...)
|
||||
noErr(t, err, "HashPassword")
|
||||
|
||||
noErr(t, VerifyPassword(pw, hash), "correct password")
|
||||
noErr(t, VerifyPassword(pw, hash), "repeat verification")
|
||||
|
||||
for _, wrong := range []string{"wrongPassword", "", pw + "\x00", pw + " ", strings.ToUpper(pw)} {
|
||||
errIs(t, VerifyPassword(wrong, hash), ErrInvalidCredentials, fmt.Sprintf("password %q", wrong))
|
||||
}
|
||||
|
||||
// verification has no minimum length: legacy hashes must stay verifiable
|
||||
weak := phcFor("short", []byte("0123456789abcdef"), DefaultArgonKeyLen)
|
||||
noErr(t, VerifyPassword("short", weak), "sub-minimum password verifies")
|
||||
}
|
||||
|
||||
func TestPasswordLengthBounds(t *testing.T) {
|
||||
_, err := HashPassword("")
|
||||
assert.Equal(t, ErrWeakPassword, err)
|
||||
errIs(t, err, ErrWeakPassword, "empty")
|
||||
_, err = HashPassword("1234567")
|
||||
errIs(t, err, ErrWeakPassword, "seven bytes")
|
||||
|
||||
// 8-character password should pass
|
||||
hash, err := HashPassword("12345678")
|
||||
require.NoError(t, err)
|
||||
h8, err := HashPassword("12345678", cheapArgon...)
|
||||
noErr(t, err, "eight bytes")
|
||||
noErr(t, VerifyPassword("12345678", h8), "verify eight bytes")
|
||||
|
||||
err = VerifyPassword("12345678", hash)
|
||||
assert.NoError(t, err)
|
||||
// the minimum is measured in bytes, not runes
|
||||
const multibyte = "ünïcödé"
|
||||
eq(t, len([]rune(multibyte)), 7, "rune count")
|
||||
eq(t, len(multibyte), 11, "byte count")
|
||||
hm, err := HashPassword(multibyte, cheapArgon...)
|
||||
noErr(t, err, "multibyte password")
|
||||
noErr(t, VerifyPassword(multibyte, hm), "verify multibyte")
|
||||
|
||||
maxPw := strings.Repeat("a", MaxPasswordLen)
|
||||
hMax, err := HashPassword(maxPw, cheapArgon...)
|
||||
noErr(t, err, "maximum length")
|
||||
noErr(t, VerifyPassword(maxPw, hMax), "verify maximum length")
|
||||
|
||||
_, err = HashPassword(maxPw+"a", cheapArgon...)
|
||||
errIs(t, err, ErrPasswordTooLong, "over maximum")
|
||||
// rejected before the KDF runs
|
||||
errIs(t, VerifyPassword(maxPw+"a", hMax), ErrPasswordTooLong, "verify over maximum")
|
||||
}
|
||||
|
||||
func TestConcurrentPasswordOperations(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
hash, err := HashPassword(password)
|
||||
require.NoError(t, err)
|
||||
func TestHashPasswordOptions(t *testing.T) {
|
||||
const pw = "testPassword123"
|
||||
|
||||
// Test concurrent verification
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := VerifyPassword(password, hash)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
h, err := HashPassword(pw, WithTime(2), WithMemory(16*1024), WithThreads(2))
|
||||
noErr(t, err, "custom parameters")
|
||||
eq(t, strings.Split(h, "$")[3], "m=16384,t=2,p=2", "encoded parameters")
|
||||
noErr(t, VerifyPassword(pw, h), "verify custom parameters")
|
||||
|
||||
// zero values are discarded by the option guards
|
||||
h, err = HashPassword(pw, WithMemory(testArgonMemory), WithTime(0), WithThreads(0))
|
||||
noErr(t, err, "zero-valued options")
|
||||
eq(t, strings.Split(h, "$")[3],
|
||||
fmt.Sprintf("m=%d,t=%d,p=%d", testArgonMemory, DefaultArgonTime, DefaultArgonThreads),
|
||||
"defaults retained")
|
||||
|
||||
// the last option wins
|
||||
h, err = HashPassword(pw, WithMemory(64*1024), WithMemory(testArgonMemory), WithTime(1), WithThreads(1))
|
||||
noErr(t, err, "repeated option")
|
||||
eq(t, strings.Split(h, "$")[3], fmt.Sprintf("m=%d,t=1,p=1", testArgonMemory), "last option applied")
|
||||
}
|
||||
|
||||
func TestVerifyPasswordNonStandardDigestLength(t *testing.T) {
|
||||
// Records produced elsewhere may encode digests other than 32 bytes;
|
||||
// verification must derive at the encoded length.
|
||||
const pw = "testPassword123"
|
||||
salt := []byte("0123456789abcdef0123")
|
||||
|
||||
for _, keyLen := range []uint32{16, 20, 32, MaxArgonKeyLen} {
|
||||
ctx := fmt.Sprintf("keyLen=%d", keyLen)
|
||||
h := phcFor(pw, salt, keyLen)
|
||||
noErr(t, ValidatePHCHashFormat(h), "format "+ctx)
|
||||
noErr(t, VerifyPassword(pw, h), "verify "+ctx)
|
||||
errIs(t, VerifyPassword("wrongPassword", h), ErrInvalidCredentials, "wrong password "+ctx)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestPHCMigration(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
username := "migrationUser"
|
||||
|
||||
// Generate PHC hash
|
||||
phcHash, err := HashPassword(password)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Migrate to SCRAM credential
|
||||
cred, err := MigrateFromPHC(username, password, phcHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, username, cred.Username)
|
||||
assert.NotNil(t, cred.StoredKey)
|
||||
assert.NotNil(t, cred.ServerKey)
|
||||
|
||||
// Test with wrong password
|
||||
_, err = MigrateFromPHC(username, "wrongPassword", phcHash)
|
||||
assert.Equal(t, ErrInvalidCredentials, err)
|
||||
|
||||
// Test with invalid PHC format
|
||||
_, err = MigrateFromPHC(username, password, "$invalid$format")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidatePHCHashFormat(t *testing.T) {
|
||||
// Generate valid hash for testing
|
||||
validHash, err := HashPassword("testPassword123")
|
||||
require.NoError(t, err)
|
||||
ver := fmt.Sprintf("v=%d", argon2.Version)
|
||||
const params = "m=65536,t=3,p=4"
|
||||
okSalt, okDigest := rawB64(16), rawB64(32)
|
||||
|
||||
// Test valid hash
|
||||
err = ValidatePHCHashFormat(validHash)
|
||||
assert.NoError(t, err, "Valid hash should pass validation")
|
||||
build := func(alg, version, prm, salt, digest string) string {
|
||||
return "$" + alg + "$" + version + "$" + prm + "$" + salt + "$" + digest
|
||||
}
|
||||
std := func(prm, salt, digest string) string { return build("argon2id", ver, prm, salt, digest) }
|
||||
|
||||
// Test malformed formats
|
||||
testCases := []struct {
|
||||
name string
|
||||
hash string
|
||||
wantErr error
|
||||
generated, err := HashPassword("testPassword123", cheapArgon...)
|
||||
noErr(t, err, "HashPassword")
|
||||
|
||||
// largest structurally valid record: proves MaxPHCHashLen never binds
|
||||
maxRecord := std("m=4194304,t=1000,p=255", rawB64(MaxArgonSaltLen), rawB64(MaxArgonKeyLen))
|
||||
isTrue(t, len(maxRecord) <= MaxPHCHashLen, "maximum record within length cap")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hash string
|
||||
want error
|
||||
}{
|
||||
{"generated", generated, nil},
|
||||
{"minimal", std(params, okSalt, okDigest), nil},
|
||||
{"maximum record", maxRecord, nil},
|
||||
|
||||
{"empty", "", ErrPHCInvalidFormat},
|
||||
{"not PHC format", "plaintext", ErrPHCInvalidFormat},
|
||||
{"wrong prefix", "argon2id$v=19$m=65536,t=3,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"wrong algorithm", "$bcrypt$v=19$m=65536,t=3,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"missing version", "$argon2id$$m=65536,t=3,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"wrong version", "$argon2id$v=1$m=65536,t=3,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"missing params", "$argon2id$v=19$$salt$hash", ErrPHCInvalidFormat},
|
||||
{"invalid params format", "$argon2id$v=19$invalid$salt$hash", ErrPHCInvalidFormat},
|
||||
{"zero time", "$argon2id$v=19$m=65536,t=0,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"zero memory", "$argon2id$v=19$m=0,t=3,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"zero threads", "$argon2id$v=19$m=65536,t=3,p=0$salt$hash", ErrPHCInvalidFormat},
|
||||
{"excessive memory", "$argon2id$v=19$m=5000000,t=3,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"excessive time", "$argon2id$v=19$m=65536,t=2000,p=4$salt$hash", ErrPHCInvalidFormat},
|
||||
{"invalid salt encoding", "$argon2id$v=19$m=65536,t=3,p=4$!!!invalid!!!$hash", ErrPHCInvalidSalt},
|
||||
{"invalid hash encoding", "$argon2id$v=19$m=65536,t=3,p=4$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("salt12345678")) + "$!!!invalid!!!", ErrPHCInvalidHash},
|
||||
{"short salt", "$argon2id$v=19$m=65536,t=3,p=4$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("short")) + "$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("hash1234567890123456")), ErrPHCInvalidSalt},
|
||||
{"short hash", "$argon2id$v=19$m=65536,t=3,p=4$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("salt12345678")) + "$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("short")), ErrPHCInvalidHash},
|
||||
{"too few parts", "$argon2id$v=19$m=65536,t=3,p=4", ErrPHCInvalidFormat},
|
||||
{"too many parts", "$argon2id$v=19$m=65536,t=3,p=4$salt$hash$extra", ErrPHCInvalidFormat},
|
||||
{"oversized salt", "$argon2id$v=19$m=65536,t=3,p=4$" +
|
||||
base64.RawStdEncoding.EncodeToString(make([]byte, 128)) + "$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("hash1234567890123456")), ErrPHCInvalidSalt},
|
||||
{"oversized hash", "$argon2id$v=19$m=65536,t=3,p=4$" +
|
||||
base64.RawStdEncoding.EncodeToString([]byte("salt12345678")) + "$" +
|
||||
base64.RawStdEncoding.EncodeToString(make([]byte, 128)), ErrPHCInvalidHash},
|
||||
{"oversized input", "$argon2id$v=19$m=65536,t=3,p=4$" +
|
||||
strings.Repeat("A", 512) + "$hash", ErrPHCInvalidFormat},
|
||||
{"plaintext", "plaintext", ErrPHCInvalidFormat},
|
||||
{"missing leading separator", "argon2id$" + ver + "$" + params + "$" + okSalt + "$" + okDigest, ErrPHCInvalidFormat},
|
||||
{"leading garbage", "x" + std(params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"leading space", " " + std(params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"too few fields", "$argon2id$" + ver + "$" + params, ErrPHCInvalidFormat},
|
||||
{"too many fields", std(params, okSalt, okDigest) + "$extra", ErrPHCInvalidFormat},
|
||||
{"over length cap", strings.Repeat("A", MaxPHCHashLen+1), ErrPHCInvalidFormat},
|
||||
|
||||
{"algorithm bcrypt", build("bcrypt", ver, params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"algorithm argon2i", build("argon2i", ver, params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"algorithm argon2d", build("argon2d", ver, params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"algorithm case", build("ARGON2ID", ver, params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
|
||||
{"version empty", build("argon2id", "", params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"version malformed", build("argon2id", "version=19", params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"version leading zero", build("argon2id", "v=019", params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"version negative", build("argon2id", "v=-19", params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"version too low", build("argon2id", "v=18", params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"version too high", build("argon2id", "v=20", params, okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
|
||||
{"parameters empty", std("", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"parameters reordered", std("t=3,m=65536,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"parameters spaced", std("m=65536, t=3, p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"parameters trailing", std("m=65536,t=3,p=4,x=1", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"parameters negative", std("m=-1,t=3,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"parameters overflow", std("m=99999999999999999999,t=3,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"zero time", std("m=65536,t=0,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"zero memory", std("m=0,t=3,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"zero threads", std("m=65536,t=3,p=0", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"memory at cap", std("m=4194304,t=3,p=4", okSalt, okDigest), nil},
|
||||
{"memory over cap", std("m=4194305,t=3,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"time at cap", std("m=65536,t=1000,p=4", okSalt, okDigest), nil},
|
||||
{"time over cap", std("m=65536,t=1001,p=4", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
{"threads at cap", std("m=65536,t=3,p=255", okSalt, okDigest), nil},
|
||||
{"threads overflow", std("m=65536,t=3,p=256", okSalt, okDigest), ErrPHCInvalidFormat},
|
||||
|
||||
{"salt not base64", std(params, "!!!invalid!!!", okDigest), ErrPHCInvalidSalt},
|
||||
{"salt padded", std(params, base64.StdEncoding.EncodeToString(make([]byte, 10)), okDigest), ErrPHCInvalidSalt},
|
||||
{"salt url alphabet", std(params, "abc-def_ghijklmn", okDigest), ErrPHCInvalidSalt},
|
||||
{"salt empty", std(params, "", okDigest), ErrPHCInvalidSalt},
|
||||
{"salt too short", std(params, rawB64(7), okDigest), ErrPHCInvalidSalt},
|
||||
{"salt minimum", std(params, rawB64(8), okDigest), nil},
|
||||
{"salt at cap", std(params, rawB64(MaxArgonSaltLen), okDigest), nil},
|
||||
{"salt over cap", std(params, rawB64(MaxArgonSaltLen+1), okDigest), ErrPHCInvalidSalt},
|
||||
|
||||
{"digest not base64", std(params, okSalt, "!!!invalid!!!"), ErrPHCInvalidHash},
|
||||
{"digest empty", std(params, okSalt, ""), ErrPHCInvalidHash},
|
||||
{"digest too short", std(params, okSalt, rawB64(15)), ErrPHCInvalidHash},
|
||||
{"digest minimum", std(params, okSalt, rawB64(16)), nil},
|
||||
{"digest at cap", std(params, okSalt, rawB64(MaxArgonKeyLen)), nil},
|
||||
{"digest over cap", std(params, okSalt, rawB64(MaxArgonKeyLen+1)), ErrPHCInvalidHash},
|
||||
|
||||
// encoding/base64 discards CR and LF, so PHC records are not
|
||||
// byte-canonical: never compare them as strings.
|
||||
{"digest with newline", std(params, okSalt, okDigest[:20]+"\n"+okDigest[20:]), nil},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidatePHCHashFormat(tc.hash)
|
||||
assert.ErrorIs(t, err, tc.wantErr, "Test case: %s", tc.name)
|
||||
if tc.want == nil {
|
||||
noErr(t, err, tc.name)
|
||||
return
|
||||
}
|
||||
errIs(t, err, tc.want, tc.name)
|
||||
})
|
||||
}
|
||||
|
||||
// Test that validation doesn't require password
|
||||
err = ValidatePHCHashFormat(validHash)
|
||||
assert.NoError(t, err, "Should validate format without password")
|
||||
|
||||
// Verify that a validated hash can still be used for verification
|
||||
err = ValidatePHCHashFormat(validHash)
|
||||
require.NoError(t, err)
|
||||
err = VerifyPassword("testPassword123", validHash)
|
||||
assert.NoError(t, err, "Validated hash should still work for password verification")
|
||||
}
|
||||
|
||||
func TestVerifyPassword_MalformedParamsNoPanic(t *testing.T) {
|
||||
func TestVerifyPasswordMalformedNoPanic(t *testing.T) {
|
||||
for _, h := range []string{
|
||||
"$argon2id$v=19$m=65536,t=0,p=4$c2FsdHNhbHRzYWx0MTI$aGFzaGhhc2hoYXNoaGFzaA",
|
||||
"$argon2id$v=19$m=65536,t=3,p=0$c2FsdHNhbHRzYWx0MTI$aGFzaGhhc2hoYXNoaGFzaA",
|
||||
"$argon2id$v=19$garbage$c2FsdHNhbHRzYWx0MTI$aGFzaGhhc2hoYXNoaGFzaA",
|
||||
"", "$", "$$$$$", "$argon2id$", strings.Repeat("$", 1000),
|
||||
"$argon2id$v=19$m=65536,t=0,p=4$" + rawB64(16) + "$" + rawB64(32),
|
||||
"$argon2id$v=19$m=65536,t=3,p=0$" + rawB64(16) + "$" + rawB64(32),
|
||||
"$argon2id$v=19$garbage$" + rawB64(16) + "$" + rawB64(32),
|
||||
"$argon2id$v=19$m=65536,t=3,p=4$$",
|
||||
"$argon2id$v=19$m=99999999999999999999,t=3,p=4$" + rawB64(16) + "$" + rawB64(32),
|
||||
} {
|
||||
assert.Error(t, VerifyPassword("whatever", h))
|
||||
hasErr(t, VerifyPassword("whatever", h), fmt.Sprintf("hash %q", h))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFromPHC(t *testing.T) {
|
||||
const user, pw = "migrationUser", "testPassword123"
|
||||
phcHash, err := HashPassword(pw, cheapArgon...)
|
||||
noErr(t, err, "HashPassword")
|
||||
|
||||
cred, err := MigrateFromPHC(user, pw, phcHash)
|
||||
noErr(t, err, "MigrateFromPHC")
|
||||
eq(t, cred.Username, user, "username")
|
||||
eq(t, cred.ArgonTime, uint32(testArgonTime), "time")
|
||||
eq(t, cred.ArgonMemory, uint32(testArgonMemory), "memory")
|
||||
eq(t, cred.ArgonThreads, uint8(testArgonThreads), "threads")
|
||||
eq(t, len(cred.StoredKey), sha256.Size, "stored key length")
|
||||
eq(t, len(cred.ServerKey), sha256.Size, "server key length")
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(strings.Split(phcHash, "$")[4])
|
||||
noErr(t, err, "salt decode")
|
||||
eqBytes(t, cred.Salt, salt, "salt carried over")
|
||||
|
||||
// migration must agree with direct derivation over the same material
|
||||
direct, err := DeriveCredential(user, pw, cred.Salt, cred.ArgonTime, cred.ArgonMemory, cred.ArgonThreads)
|
||||
noErr(t, err, "DeriveCredential")
|
||||
eqBytes(t, cred.StoredKey, direct.StoredKey, "stored key")
|
||||
eqBytes(t, cred.ServerKey, direct.ServerKey, "server key")
|
||||
|
||||
// keys are derived from the salted password, not copies of it
|
||||
salted, err := base64.RawStdEncoding.DecodeString(strings.Split(phcHash, "$")[5])
|
||||
noErr(t, err, "digest decode")
|
||||
want := sha256.Sum256(computeHMAC(salted, []byte("Client Key")))
|
||||
eqBytes(t, cred.StoredKey, want[:], "stored key derivation")
|
||||
eqBytes(t, cred.ServerKey, computeHMAC(salted, []byte("Server Key")), "server key derivation")
|
||||
if bytes.Equal(cred.StoredKey, salted) || bytes.Equal(cred.ServerKey, salted) {
|
||||
t.Fatal("credential exposes the salted password")
|
||||
}
|
||||
|
||||
_, err = MigrateFromPHC(user, "wrongPassword", phcHash)
|
||||
errIs(t, err, ErrInvalidCredentials, "wrong password")
|
||||
_, err = MigrateFromPHC(user, pw, "$invalid$format")
|
||||
errIs(t, err, ErrPHCInvalidFormat, "malformed record")
|
||||
_, err = MigrateFromPHC(user, strings.Repeat("a", MaxPasswordLen+1), phcHash)
|
||||
errIs(t, err, ErrPasswordTooLong, "oversized password")
|
||||
}
|
||||
|
||||
func TestMigrateFromPHCShortSalt(t *testing.T) {
|
||||
// parsePHC accepts 8..64 byte salts; SCRAM requires >= 16. The 32-byte
|
||||
// digest branch skips that check, the fallback branch enforces it.
|
||||
// See the ‼️ note on MigrateFromPHC.
|
||||
const pw = "testPassword123"
|
||||
salt := []byte("12345678")
|
||||
|
||||
cred, err := MigrateFromPHC("u", pw, phcFor(pw, salt, DefaultArgonKeyLen))
|
||||
noErr(t, err, "32-byte digest branch")
|
||||
eq(t, len(cred.Salt), 8, "short salt retained")
|
||||
|
||||
// the credential it produced cannot survive an export/import cycle
|
||||
_, err = ImportCredential(cred.Export())
|
||||
errIs(t, err, ErrSCRAMSaltTooShort, "re-import")
|
||||
|
||||
_, err = MigrateFromPHC("u", pw, phcFor(pw, salt, 20))
|
||||
errIs(t, err, ErrSCRAMSaltTooShort, "fallback branch")
|
||||
}
|
||||
|
||||
func TestConcurrentPasswordOperations(t *testing.T) {
|
||||
const pw = "testPassword123"
|
||||
hash, err := HashPassword(pw, cheapArgon...)
|
||||
noErr(t, err, "HashPassword")
|
||||
|
||||
const n = 16
|
||||
errs := make(chan error, 2*n)
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := VerifyPassword(pw, hash); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
local := fmt.Sprintf("password-%04d", i)
|
||||
h, err := HashPassword(local, cheapArgon...)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if err := VerifyPassword(local, h); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Errorf("concurrent operation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParsePHC(f *testing.F) {
|
||||
h, err := HashPassword("testPassword123", cheapArgon...)
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
f.Add(h)
|
||||
f.Add("")
|
||||
f.Add("$argon2id$v=19$m=65536,t=3,p=4$" + rawB64(16) + "$" + rawB64(32))
|
||||
f.Add("$argon2id$v=19$m=0,t=0,p=0$$")
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
r, err := parsePHC(s)
|
||||
if err != nil {
|
||||
if r != nil {
|
||||
t.Fatalf("result returned alongside error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.time == 0 || r.memory == 0 || r.threads == 0 {
|
||||
t.Fatalf("zero parameter accepted: m=%d t=%d p=%d", r.memory, r.time, r.threads)
|
||||
}
|
||||
if r.memory > 4*1024*1024 || r.time > 1000 {
|
||||
t.Fatalf("cost bound exceeded: m=%d t=%d", r.memory, r.time)
|
||||
}
|
||||
if len(r.salt) < 8 || len(r.salt) > MaxArgonSaltLen {
|
||||
t.Fatalf("salt length %d accepted", len(r.salt))
|
||||
}
|
||||
if len(r.expectedHash) < 16 || len(r.expectedHash) > MaxArgonKeyLen {
|
||||
t.Fatalf("digest length %d accepted", len(r.expectedHash))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzVerifyPassword(f *testing.F) {
|
||||
h, err := HashPassword("testPassword123", cheapArgon...)
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
f.Add("testPassword123", h)
|
||||
f.Add("", "")
|
||||
f.Add("x", "$argon2id$v=19$m=8192,t=1,p=1$"+rawB64(16)+"$"+rawB64(32))
|
||||
|
||||
f.Fuzz(func(t *testing.T, password, hash string) {
|
||||
// parsePHC admits m up to 4 GiB from untrusted input; clamp before
|
||||
// letting the fuzzer choose the KDF cost.
|
||||
if r, err := parsePHC(hash); err == nil &&
|
||||
(r.memory > 64*1024 || r.time > 4 || r.threads > 8) {
|
||||
return
|
||||
}
|
||||
_ = VerifyPassword(password, hash)
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkHashPasswordDefault(b *testing.B) {
|
||||
for b.Loop() {
|
||||
if _, err := HashPassword("testPassword123"); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkVerifyPasswordDefault(b *testing.B) {
|
||||
hash, err := HashPassword("testPassword123")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for b.Loop() {
|
||||
if err := VerifyPassword("testPassword123", hash); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordCostCeiling(t *testing.T) {
|
||||
const pw = "testPassword123"
|
||||
// well-formed per PHC, refused before the KDF runs
|
||||
over := fmt.Sprintf("$argon2id$v=%d$m=%d,t=1,p=1$%s$%s",
|
||||
argon2.Version, MaxVerifyArgonMemory+1, rawB64(16), rawB64(32))
|
||||
noErr(t, ValidatePHCHashFormat(over), "format validation is unaffected")
|
||||
errIs(t, VerifyPassword(pw, over), ErrPHCCostTooHigh, "memory over ceiling")
|
||||
|
||||
overTime := fmt.Sprintf("$argon2id$v=%d$m=8192,t=%d,p=1$%s$%s",
|
||||
argon2.Version, MaxVerifyArgonTime+1, rawB64(16), rawB64(32))
|
||||
errIs(t, VerifyPassword(pw, overTime), ErrPHCCostTooHigh, "time over ceiling")
|
||||
_, err := MigrateFromPHC("u", pw, over)
|
||||
errIs(t, err, ErrPHCCostTooHigh, "migration honors the ceiling")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user