refactor the password hashing / password autoupgrade system

This commit is contained in:
Shivaram Lingamneni
2018-08-05 22:51:39 -04:00
parent 6260869068
commit dfb0a57040
18 changed files with 277 additions and 380 deletions
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2018 Shivaram Lingamneni
// released under the MIT license
package passwd
import "golang.org/x/crypto/bcrypt"
import "golang.org/x/crypto/sha3"
const (
MinCost = bcrypt.MinCost
DefaultCost = 12 // ballpark: 250 msec on a modern Intel CPU
)
// implements Dropbox's strategy of applying an initial pass of a "normal"
// (i.e., fast) cryptographically secure hash with 512 bits of output before
// applying bcrypt. This allows the use of, e.g., Diceware/XKCD-style passphrases
// that may be longer than the 80-character bcrypt limit.
// https://blogs.dropbox.com/tech/2016/09/how-dropbox-securely-stores-your-passwords/
// we are only using this for user-generated passwords, as opposed to the server
// and operator passwords that are hashed by `oragono genpasswd` and then
// hard-coded by the server admins into the config file, to avoid breaking
// backwards compatibility (since we can't upgrade the config file on the fly
// the way we can with the database).
func GenerateFromPassword(password []byte, cost int) (result []byte, err error) {
sum := sha3.Sum512(password)
return bcrypt.GenerateFromPassword(sum[:], cost)
}
func CompareHashAndPassword(hashedPassword, password []byte) error {
sum := sha3.Sum512(password)
return bcrypt.CompareHashAndPassword(hashedPassword, sum[:])
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright (c) 2018 Shivaram Lingamneni
// released under the MIT license
package passwd
import (
"testing"
)
func TestBasic(t *testing.T) {
hash, err := GenerateFromPassword([]byte("this is my passphrase"), DefaultCost)
if err != nil || len(hash) != 60 {
t.Errorf("bad password hash output: error %s, output %s, len %d", err, hash, len(hash))
}
if CompareHashAndPassword(hash, []byte("this is my passphrase")) != nil {
t.Errorf("hash comparison failed unexpectedly")
}
if CompareHashAndPassword(hash, []byte("this is not my passphrase")) == nil {
t.Errorf("hash comparison succeeded unexpectedly")
}
}
func TestLongPassphrases(t *testing.T) {
longPassphrase := make([]byte, 168)
for i := range longPassphrase {
longPassphrase[i] = 'a'
}
hash, err := GenerateFromPassword(longPassphrase, DefaultCost)
if err != nil {
t.Errorf("bad password hash output: error %s", err)
}
if CompareHashAndPassword(hash, longPassphrase) != nil {
t.Errorf("hash comparison failed unexpectedly")
}
// change a byte of the passphrase beyond the normal 80-character
// bcrypt truncation boundary:
longPassphrase[150] = 'b'
if CompareHashAndPassword(hash, longPassphrase) == nil {
t.Errorf("hash comparison succeeded unexpectedly")
}
}
// this could be useful for tuning the cost parameter on specific hardware
func BenchmarkComparisons(b *testing.B) {
pass := []byte("passphrase for benchmarking")
hash, err := GenerateFromPassword(pass, DefaultCost)
if err != nil {
b.Errorf("bad output")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
CompareHashAndPassword(hash, pass)
}
}
-66
View File
@@ -1,66 +0,0 @@
// Copyright (c) 2016 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package passwd
import (
"crypto/rand"
"golang.org/x/crypto/bcrypt"
)
const (
// newSaltLen is how many bytes long newly-generated salts are.
newSaltLen = 30
// defaultPasswordCost is the bcrypt cost we use for passwords.
defaultPasswordCost = 14
)
// NewSalt returns a salt for crypto uses.
func NewSalt() ([]byte, error) {
salt := make([]byte, newSaltLen)
_, err := rand.Read(salt)
if err != nil {
var emptySalt []byte
return emptySalt, err
}
return salt, nil
}
// SaltedManager supports the hashing and comparing of passwords with the given salt.
type SaltedManager struct {
salt []byte
}
// NewSaltedManager returns a new SaltedManager with the given salt.
func NewSaltedManager(salt []byte) SaltedManager {
return SaltedManager{
salt: salt,
}
}
// assemblePassword returns an assembled slice of bytes for the given password details.
func (sm *SaltedManager) assemblePassword(specialSalt []byte, password string) []byte {
var assembledPasswordBytes []byte
assembledPasswordBytes = append(assembledPasswordBytes, sm.salt...)
assembledPasswordBytes = append(assembledPasswordBytes, '-')
assembledPasswordBytes = append(assembledPasswordBytes, specialSalt...)
assembledPasswordBytes = append(assembledPasswordBytes, '-')
assembledPasswordBytes = append(assembledPasswordBytes, []byte(password)...)
return assembledPasswordBytes
}
// GenerateFromPassword encrypts the given password.
func (sm *SaltedManager) GenerateFromPassword(specialSalt []byte, password string) ([]byte, error) {
assembledPasswordBytes := sm.assemblePassword(specialSalt, password)
return bcrypt.GenerateFromPassword(assembledPasswordBytes, defaultPasswordCost)
}
// CompareHashAndPassword compares a hashed password with its possible plaintext equivalent.
// Returns nil on success, or an error on failure.
func (sm *SaltedManager) CompareHashAndPassword(hashedPassword []byte, specialSalt []byte, password string) error {
assembledPasswordBytes := sm.assemblePassword(specialSalt, password)
return bcrypt.CompareHashAndPassword(hashedPassword, assembledPasswordBytes)
}
-86
View File
@@ -1,86 +0,0 @@
// Copyright (c) 2016 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package passwd
import (
"encoding/base64"
"testing"
)
type SaltedPasswordTest struct {
ManagerSalt string
Salt string
Hash string
Password string
}
var SaltedPasswords = []SaltedPasswordTest{
{
ManagerSalt: "3TPITDVf/NGb4OlCyV1uZNW1H7zy3BFos+Dsu7dj",
Salt: "b6oVqshJUfcm1zWEtqwKqUVylqLONAZfqt17ns+Y",
Hash: "JDJhJDE0JFYuT28xOFFNZldaaTI1UWpzNENMeHVKdm5vS1lkL2tFL1lFVkQ2a0loUEY2Vzk3UTZSVDVP",
Password: "test",
},
{
ManagerSalt: "iNGeNEfuPihM8kYDZ/C6qAJ0JERKeKkUYp6wYDU0",
Salt: "U7TA6k6VLSLHfdjSsQH0vc3Jqq6cUezJNyd0DC9c",
Hash: "JDJhJDE0JEguY2Rva3VOTVRrNm1VeGdXWjAwamViMGNvV0xYZFdHcTZjenFCRWE3Ymt2N1JiSFJDZlYy",
Password: "test2",
},
{
ManagerSalt: "ghKJaaSNTjuFmgLRqrgY4FGfx8wXEGOBE02PZvbv",
Salt: "NO/mtrMhGjX1FGDGdpGrDJIi4jxsb0aFa7ybId7r",
Hash: "JDJhJDE0JEI0M055Z2NDcjNUanB5ZEJ5MzUybi5FT3o4Y1MyNXp2c1NDVS9hS0hOcUxSRDZTWmUxTnN5",
Password: "supermono",
},
}
func TestSaltedPassword(t *testing.T) {
// check newly-generated password
managerSalt, err := NewSalt()
if err != nil {
t.Error("Could not generate manager salt")
}
salt, err := NewSalt()
if err != nil {
t.Error("Could not generate salt")
}
manager := NewSaltedManager(managerSalt)
passHash, err := manager.GenerateFromPassword(salt, "this is a test password")
if err != nil {
t.Error("Could not generate from password")
}
if manager.CompareHashAndPassword(passHash, salt, "this is a test password") != nil {
t.Error("Generated password does not match")
}
// check our stored passwords
for i, info := range SaltedPasswords {
// decode strings to bytes
managerSalt, err = base64.StdEncoding.DecodeString(info.ManagerSalt)
if err != nil {
t.Errorf("Could not decode manager salt for test %d", i)
}
salt, err := base64.StdEncoding.DecodeString(info.Salt)
if err != nil {
t.Errorf("Could not decode salt for test %d", i)
}
hash, err := base64.StdEncoding.DecodeString(info.Hash)
if err != nil {
t.Errorf("Could not decode hash for test %d", i)
}
// make sure our test values are still correct
manager := NewSaltedManager(managerSalt)
if manager.CompareHashAndPassword(hash, salt, info.Password) != nil {
t.Errorf("Password does not match for [%s]", info.Password)
}
}
}
-53
View File
@@ -1,53 +0,0 @@
// Copyright (c) 2012-2014 Jeremy Latt
// released under the MIT license
package passwd
import (
"encoding/base64"
"errors"
"golang.org/x/crypto/bcrypt"
)
var (
// ErrEmptyPassword means that an empty password was given.
ErrEmptyPassword = errors.New("empty password")
)
// GenerateEncodedPasswordBytes returns an encrypted password, returning the bytes directly.
func GenerateEncodedPasswordBytes(passwd string) (encoded []byte, err error) {
if passwd == "" {
err = ErrEmptyPassword
return
}
encoded, err = bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.MinCost)
return
}
// GenerateEncodedPassword returns an encrypted password, encoded into a string with base64.
func GenerateEncodedPassword(passwd string) (encoded string, err error) {
bcrypted, err := GenerateEncodedPasswordBytes(passwd)
encoded = base64.StdEncoding.EncodeToString(bcrypted)
return
}
// DecodePasswordHash takes a base64-encoded password hash and returns the appropriate bytes.
func DecodePasswordHash(encoded string) (decoded []byte, err error) {
if encoded == "" {
err = ErrEmptyPassword
return
}
decoded, err = base64.StdEncoding.DecodeString(encoded)
return
}
// ComparePassword compares a given password with the given hash.
func ComparePassword(hash, password []byte) error {
return bcrypt.CompareHashAndPassword(hash, password)
}
// ComparePasswordString compares a given password string with the given hash.
func ComparePasswordString(hash []byte, password string) error {
return ComparePassword(hash, []byte(password))
}
-54
View File
@@ -1,54 +0,0 @@
// Copyright (c) 2016 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package passwd
import (
"testing"
)
var UnsaltedPasswords = map[string]string{
"test1": "JDJhJDA0JFFwZ1V0RWZTMFVaMkFrdlRrTG9FZk9FNEZWbWkvVEhsdGFnSXlIUC5wVmpYTkNERFJPNlcu",
"test2": "JDJhJDA0JHpQTGNqczlIanc3V2NFQ3JEOVlTM09aNkRTbGRsQzRyNmt3Q01aSUs2Y2xyWURVODZ1V0px",
"supernomo": "JDJhJDA0JHdJekhnQmk1VXQ4WUphL0pIL0tXQWVKVXJ6dXcvRDJ3WFljWW9XOGhzNllIbW1DRlFkL1VL",
}
func TestUnsaltedPassword(t *testing.T) {
for password, hash := range UnsaltedPasswords {
generatedHash, err := GenerateEncodedPassword(password)
if err != nil {
t.Errorf("Could not hash password for [%s]: %s", password, err.Error())
}
hashBytes, err := DecodePasswordHash(hash)
if err != nil {
t.Errorf("Could not decode hash for [%s]: %s", password, err.Error())
}
generatedHashBytes, err := DecodePasswordHash(generatedHash)
if err != nil {
t.Errorf("Could not decode generated hash for [%s]: %s", password, err.Error())
}
passwordBytes := []byte(password)
if ComparePassword(hashBytes, passwordBytes) != nil {
t.Errorf("Stored hash for [%s] did not match", password)
}
if ComparePassword(generatedHashBytes, passwordBytes) != nil {
t.Errorf("Generated hash for [%s] did not match", password)
}
}
}
func TestUnsaltedPasswordFailures(t *testing.T) {
_, err := GenerateEncodedPassword("")
if err != ErrEmptyPassword {
t.Error("Generating empty password did not fail as expected!")
}
_, err = DecodePasswordHash("")
if err != ErrEmptyPassword {
t.Error("Decoding empty password hash did not fail as expected!")
}
}