255 lines
8.6 KiB
Go
255 lines
8.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// testParams runs argon2id at a cost that is still real but does not make the
|
|
// suite crawl. Every property under test — salting, verification, the encoded
|
|
// format — is independent of the cost, and DefaultPasswordParams is asserted
|
|
// separately in TestDefaultPasswordParamsMeetOWASP.
|
|
var testParams = PasswordParams{Memory: 8 * 1024, Time: 1, Threads: 2, SaltLength: 16, KeyLength: 32}
|
|
|
|
const goodPassword = "correct-horse-battery-staple"
|
|
|
|
// 1. Hash generation produces a well-formed, self-describing argon2id record.
|
|
func TestHashPasswordProducesArgon2idRecord(t *testing.T) {
|
|
hash, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("HashPasswordWithParams: %v", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(hash, "$argon2id$") {
|
|
t.Fatalf("hash is not argon2id: %q", firstField(hash))
|
|
}
|
|
// bcrypt would be "$2a$"/"$2b$"; argon2i and argon2d are different KDFs.
|
|
// The decision was argon2id specifically, so assert it rather than merely
|
|
// "some hash was produced".
|
|
if parts := strings.Split(hash, "$"); len(parts) != 6 {
|
|
t.Fatalf("hash has %d fields, want 6 (PHC format)", len(parts))
|
|
}
|
|
|
|
got, salt, key, err := DecodePasswordHash(hash)
|
|
if err != nil {
|
|
t.Fatalf("DecodePasswordHash: %v", err)
|
|
}
|
|
if got.Memory != testParams.Memory || got.Time != testParams.Time || got.Threads != testParams.Threads {
|
|
t.Errorf("decoded params = m=%d,t=%d,p=%d, want m=%d,t=%d,p=%d",
|
|
got.Memory, got.Time, got.Threads, testParams.Memory, testParams.Time, testParams.Threads)
|
|
}
|
|
if len(salt) != int(testParams.SaltLength) {
|
|
t.Errorf("salt is %d bytes, want %d", len(salt), testParams.SaltLength)
|
|
}
|
|
if len(key) != int(testParams.KeyLength) {
|
|
t.Errorf("key is %d bytes, want %d", len(key), testParams.KeyLength)
|
|
}
|
|
// The whole point of the format: the hash must not contain the password.
|
|
if strings.Contains(hash, goodPassword) {
|
|
t.Error("the encoded hash contains the plaintext password")
|
|
}
|
|
}
|
|
|
|
// 2. The correct password verifies.
|
|
func TestVerifyPasswordAcceptsTheCorrectPassword(t *testing.T) {
|
|
hash, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
ok, err := VerifyPassword(hash, goodPassword)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("the correct password did not verify")
|
|
}
|
|
}
|
|
|
|
// 3. An incorrect password is rejected — including the near misses that a
|
|
// sloppy comparison would let through.
|
|
func TestVerifyPasswordRejectsIncorrectPasswords(t *testing.T) {
|
|
hash, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
|
|
wrong := map[string]string{
|
|
"different": "incorrect-horse-battery-staple",
|
|
"empty": "",
|
|
"prefix": goodPassword[:len(goodPassword)-1],
|
|
"suffix appended": goodPassword + "x",
|
|
"case flipped": strings.ToUpper(goodPassword),
|
|
"whitespace": " " + goodPassword,
|
|
}
|
|
for name, candidate := range wrong {
|
|
t.Run(name, func(t *testing.T) {
|
|
ok, err := VerifyPassword(hash, candidate)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword returned an error for a wrong password: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("a wrong password verified")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 4. Different passwords produce different hashes — and so does the SAME
|
|
// password hashed twice, which is the stronger property and the one that
|
|
// actually depends on the salt being random.
|
|
func TestHashPasswordIsSaltedPerCall(t *testing.T) {
|
|
a, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash a: %v", err)
|
|
}
|
|
b, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash b: %v", err)
|
|
}
|
|
if a == b {
|
|
t.Fatal("hashing the same password twice produced identical hashes; the salt is not random")
|
|
}
|
|
// Both must still verify: a per-call salt is only useful if it travels
|
|
// with the hash.
|
|
for i, h := range []string{a, b} {
|
|
ok, err := VerifyPassword(h, goodPassword)
|
|
if err != nil || !ok {
|
|
t.Fatalf("hash %d did not verify its own password (ok=%v err=%v)", i, ok, err)
|
|
}
|
|
}
|
|
|
|
c, err := HashPasswordWithParams("a-completely-different-password", testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash c: %v", err)
|
|
}
|
|
if c == a {
|
|
t.Error("different passwords produced identical hashes")
|
|
}
|
|
// And a hash must not verify a password it was not made from.
|
|
if ok, _ := VerifyPassword(c, goodPassword); ok {
|
|
t.Error("a hash verified a password it was not derived from")
|
|
}
|
|
}
|
|
|
|
// 5a. Empty and out-of-policy passwords are refused at hashing time.
|
|
func TestHashPasswordRejectsInvalidPasswords(t *testing.T) {
|
|
cases := map[string]struct {
|
|
password string
|
|
want error
|
|
}{
|
|
"empty": {"", ErrEmptyPassword},
|
|
"too short": {strings.Repeat("a", MinPasswordLength-1), ErrPasswordTooShort},
|
|
"too long": {strings.Repeat("a", MaxPasswordLength+1), ErrPasswordTooLong},
|
|
}
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
hash, err := HashPasswordWithParams(tc.password, testParams)
|
|
if err != tc.want {
|
|
t.Fatalf("error = %v, want %v", err, tc.want)
|
|
}
|
|
if hash != "" {
|
|
t.Error("a hash was returned alongside the error")
|
|
}
|
|
// The rejection must not quote the input back.
|
|
if tc.password != "" && err != nil && strings.Contains(err.Error(), tc.password) {
|
|
t.Error("the error message contains the password")
|
|
}
|
|
})
|
|
}
|
|
|
|
// The boundary itself is allowed: the rule is "at least MinPasswordLength".
|
|
if _, err := HashPasswordWithParams(strings.Repeat("a", MinPasswordLength), testParams); err != nil {
|
|
t.Errorf("a password of exactly the minimum length was rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// 5b. A malformed *stored* hash is an error, not a silent "wrong password".
|
|
// The distinction matters: one is a 401, the other is a 500.
|
|
func TestVerifyPasswordRejectsMalformedHashes(t *testing.T) {
|
|
valid, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
fields := strings.Split(valid, "$")
|
|
|
|
bad := map[string]string{
|
|
"empty": "",
|
|
"not a phc string": "not-a-hash",
|
|
"bcrypt": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
"argon2i": strings.Replace(valid, "argon2id", "argon2i", 1),
|
|
"too few fields": strings.Join(fields[:5], "$"),
|
|
"unparseable params": strings.Replace(valid, fields[3], "m=x,t=y,p=z", 1),
|
|
"zero memory": strings.Replace(valid, fields[3], "m=0,t=1,p=2", 1),
|
|
"bad version": strings.Replace(valid, fields[2], "v=notanumber", 1),
|
|
"salt not base64": strings.Replace(valid, fields[4], "!!!!not base64!!!!", 1),
|
|
}
|
|
for name, encoded := range bad {
|
|
t.Run(name, func(t *testing.T) {
|
|
ok, err := VerifyPassword(encoded, goodPassword)
|
|
if err == nil {
|
|
t.Fatal("a malformed hash verified without an error")
|
|
}
|
|
if ok {
|
|
t.Error("a malformed hash reported a successful verification")
|
|
}
|
|
})
|
|
}
|
|
|
|
// A future argon2 version is reported as its own error, because it is an
|
|
// upgrade problem rather than corruption.
|
|
future := strings.Replace(valid, fields[2], "v=99", 1)
|
|
if _, err := VerifyPassword(future, goodPassword); err != ErrIncompatibleVersion {
|
|
t.Errorf("error for a future version = %v, want ErrIncompatibleVersion", err)
|
|
}
|
|
}
|
|
|
|
// The defaults are a security decision, so they are asserted rather than
|
|
// assumed: OWASP's argon2id recommendation is m=65536 (64 MiB), t=3, p=4.
|
|
func TestDefaultPasswordParamsMeetOWASP(t *testing.T) {
|
|
p := DefaultPasswordParams
|
|
if p.Memory < 64*1024 {
|
|
t.Errorf("Memory = %d KiB, want at least 65536", p.Memory)
|
|
}
|
|
if p.Time < 3 {
|
|
t.Errorf("Time = %d, want at least 3", p.Time)
|
|
}
|
|
if p.Threads < 1 {
|
|
t.Errorf("Threads = %d, want at least 1", p.Threads)
|
|
}
|
|
if p.SaltLength < 16 {
|
|
t.Errorf("SaltLength = %d, want at least 16", p.SaltLength)
|
|
}
|
|
if p.KeyLength < 32 {
|
|
t.Errorf("KeyLength = %d, want at least 32", p.KeyLength)
|
|
}
|
|
}
|
|
|
|
func TestNeedsRehash(t *testing.T) {
|
|
weak, err := HashPasswordWithParams(goodPassword, testParams)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
if !NeedsRehash(weak, DefaultPasswordParams) {
|
|
t.Error("a hash below the default cost was not flagged for rehashing")
|
|
}
|
|
strong, err := HashPasswordWithParams(goodPassword, DefaultPasswordParams)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
if NeedsRehash(strong, DefaultPasswordParams) {
|
|
t.Error("a hash at the default cost was flagged for rehashing")
|
|
}
|
|
if !NeedsRehash("not-a-hash", DefaultPasswordParams) {
|
|
t.Error("an unreadable hash should be flagged for rehashing")
|
|
}
|
|
}
|
|
|
|
// firstField is used only to report a failure without dumping a whole hash.
|
|
func firstField(hash string) string {
|
|
parts := strings.SplitN(hash, "$", 3)
|
|
if len(parts) < 2 {
|
|
return hash
|
|
}
|
|
return "$" + parts[1] + "$"
|
|
}
|