diff options
Diffstat (limited to 'internal/security')
| -rw-r--r-- | internal/security/password.go | 60 | ||||
| -rw-r--r-- | internal/security/password_test.go | 24 |
2 files changed, 84 insertions, 0 deletions
diff --git a/internal/security/password.go b/internal/security/password.go new file mode 100644 index 0000000..5d805ca --- /dev/null +++ b/internal/security/password.go @@ -0,0 +1,60 @@ +package security + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "errors" + "unicode" +) + +const ( + PasswordSaltBytes = 16 + PasswordHashBytes = 32 + PasswordIterations = 600_000 +) + +var ErrWeakPassword = errors.New("password must be 12-128 characters and contain letters and numbers") + +func ValidatePassword(password string) error { + runes := []rune(password) + if len(runes) < 12 || len(runes) > 128 { + return ErrWeakPassword + } + var letter, number bool + for _, value := range runes { + letter = letter || unicode.IsLetter(value) + number = number || unicode.IsNumber(value) + } + if !letter || !number { + return ErrWeakPassword + } + return nil +} + +func HashPassword(password string) (hash, salt []byte, iterations int, err error) { + if err := ValidatePassword(password); err != nil { + return nil, nil, 0, err + } + salt = make([]byte, PasswordSaltBytes) + if _, err := rand.Read(salt); err != nil { + return nil, nil, 0, err + } + hash, err = pbkdf2.Key(sha256.New, password, salt, PasswordIterations, PasswordHashBytes) + if err != nil { + return nil, nil, 0, err + } + return hash, salt, PasswordIterations, nil +} + +func VerifyPassword(password string, expectedHash, salt []byte, iterations int) bool { + if len(expectedHash) != PasswordHashBytes || len(salt) != PasswordSaltBytes || iterations < 100_000 || iterations > 10_000_000 || len([]rune(password)) > 128 { + return false + } + actual, err := pbkdf2.Key(sha256.New, password, salt, iterations, len(expectedHash)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(actual, expectedHash) == 1 +} diff --git a/internal/security/password_test.go b/internal/security/password_test.go new file mode 100644 index 0000000..8c9b774 --- /dev/null +++ b/internal/security/password_test.go @@ -0,0 +1,24 @@ +package security + +import "testing" + +func TestPasswordHashRoundTrip(t *testing.T) { + hash, salt, iterations, err := HashPassword("correct-horse-42") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword("correct-horse-42", hash, salt, iterations) { + t.Fatal("correct password was rejected") + } + if VerifyPassword("wrong-password-42", hash, salt, iterations) { + t.Fatal("wrong password was accepted") + } +} + +func TestPasswordPolicy(t *testing.T) { + for _, password := range []string{"short1", "onlyletterslong", "123456789012345"} { + if err := ValidatePassword(password); err == nil { + t.Fatalf("password %q unexpectedly passed policy", password) + } + } +} |
