Skip to content

Commit 129a10b

Browse files
committed
Add userpassword package
1 parent 40be7bd commit 129a10b

File tree

2 files changed

+124
-0
lines changed

2 files changed

+124
-0
lines changed

coderd/userpassword/userpassword.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package userpassword
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/sha256"
6+
"crypto/subtle"
7+
"encoding/base64"
8+
"fmt"
9+
"strconv"
10+
"strings"
11+
12+
"golang.org/x/crypto/pbkdf2"
13+
"golang.org/x/xerrors"
14+
)
15+
16+
const (
17+
// This is the length of our output hash.
18+
// bcrypt has a hash size of 59, so we rounded up to a power of 8.
19+
hashLength = 64
20+
// The scheme to include in our hashed password.
21+
hashScheme = "pbkdf2-sha256"
22+
)
23+
24+
// Compare checks the equality of passwords from a hashed pbkdf2 string.
25+
// This uses pbkdf2 to ensure FIPS 140-2 compliance. See:
26+
// https://csrc.nist.gov/csrc/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf
27+
func Compare(hashed string, password string) (bool, error) {
28+
if len(hashed) < hashLength {
29+
return false, xerrors.Errorf("hash too short: %d", len(hashed))
30+
}
31+
parts := strings.SplitN(hashed, "$", 5)
32+
if len(parts) != 5 {
33+
return false, xerrors.Errorf("hash has too many parts: %d", len(parts))
34+
}
35+
if len(parts[0]) != 0 {
36+
return false, xerrors.Errorf("hash prefix is invalid")
37+
}
38+
if string(parts[1]) != hashScheme {
39+
return false, xerrors.Errorf("hash isn't %q scheme: %q", hashScheme, parts[1])
40+
}
41+
iter, err := strconv.Atoi(string(parts[2]))
42+
if err != nil {
43+
return false, xerrors.Errorf("parse iter from hash: %w", err)
44+
}
45+
salt, err := base64.RawStdEncoding.DecodeString(string(parts[3]))
46+
if err != nil {
47+
return false, xerrors.Errorf("decode salt: %w", err)
48+
}
49+
50+
if subtle.ConstantTimeCompare([]byte(hashWithSaltAndIter(password, salt, iter)), []byte(hashed)) != 1 {
51+
return false, nil
52+
}
53+
return true, nil
54+
}
55+
56+
// Hash generates a hash using pbkdf2.
57+
// See the Compare() comment for rationale.
58+
func Hash(password string) (string, error) {
59+
// bcrypt uses a salt size of 16 bytes.
60+
salt := make([]byte, 16)
61+
_, err := rand.Read(salt)
62+
if err != nil {
63+
return "", xerrors.Errorf("read random bytes for salt: %w", err)
64+
}
65+
// The default hash iteration is 1024 for speed.
66+
// As this is increased, the password is hashed more.
67+
return hashWithSaltAndIter(password, salt, 1024), nil
68+
}
69+
70+
// Produces a string representation of the hash.
71+
func hashWithSaltAndIter(password string, salt []byte, iter int) string {
72+
hash := pbkdf2.Key([]byte(password), salt, iter, hashLength, sha256.New)
73+
hash = []byte(base64.RawStdEncoding.EncodeToString(hash))
74+
salt = []byte(base64.RawStdEncoding.EncodeToString(salt))
75+
// This format is similar to bcrypt. See:
76+
// https://en.wikipedia.org/wiki/Bcrypt#Description
77+
return fmt.Sprintf("$%s$%d$%s$%s", hashScheme, iter, salt, hash)
78+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package userpassword_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/coder/coder/coderd/userpassword"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestUserPassword(t *testing.T) {
11+
t.Run("Legacy", func(t *testing.T) {
12+
// Ensures legacy v1 passwords function for v2.
13+
// This has is manually generated using a print statement from v1 code.
14+
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
15+
require.NoError(t, err)
16+
require.True(t, equal)
17+
})
18+
19+
t.Run("Same", func(t *testing.T) {
20+
hash, err := userpassword.Hash("password")
21+
require.NoError(t, err)
22+
equal, err := userpassword.Compare(hash, "password")
23+
require.NoError(t, err)
24+
require.True(t, equal)
25+
})
26+
27+
t.Run("Different", func(t *testing.T) {
28+
hash, err := userpassword.Hash("password")
29+
require.NoError(t, err)
30+
equal, err := userpassword.Compare(hash, "notpassword")
31+
require.NoError(t, err)
32+
require.False(t, equal)
33+
})
34+
35+
t.Run("Invalid", func(t *testing.T) {
36+
equal, err := userpassword.Compare("invalidhash", "password")
37+
require.False(t, equal)
38+
require.Error(t, err)
39+
})
40+
41+
t.Run("InvalidParts", func(t *testing.T) {
42+
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
43+
require.False(t, equal)
44+
require.Error(t, err)
45+
})
46+
}

0 commit comments

Comments
 (0)