summaryrefslogtreecommitdiff
path: root/internal/security
diff options
context:
space:
mode:
Diffstat (limited to 'internal/security')
-rw-r--r--internal/security/password.go60
-rw-r--r--internal/security/password_test.go24
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)
+ }
+ }
+}