hmn/src/auth/auth.go

170 lines
4.4 KiB
Go

package auth
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strconv"
"strings"
"git.handmade.network/hmn/hmn/src/oops"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/pbkdf2"
)
type HashAlgorithm string
const (
Django_PBKDF2SHA256 HashAlgorithm = "pbkdf2_sha256"
Argon2id HashAlgorithm = "argon2id"
)
const saltLength = 16
const keyLength = 64
type HashedPassword struct {
Algorithm HashAlgorithm
AlgoConfig string // arbitrary info describing the hash parameters (e.g. work factor)
// To make it easier to handle varying implementations and encodings,
// these fields will always store a form of the data that can be directly
// stored in the database (usually base64-encoded or whatever).
Salt string
Hash string
}
func ParsePasswordString(s string) (HashedPassword, error) {
pieces := strings.SplitN(s, "$", 4)
if len(pieces) < 4 {
return HashedPassword{}, oops.New(nil, "unrecognized password string format")
}
return HashedPassword{
Algorithm: HashAlgorithm(pieces[0]),
AlgoConfig: pieces[1],
Salt: pieces[2],
Hash: pieces[3],
}, nil
}
func (p HashedPassword) String() string {
return fmt.Sprintf("%s$%s$%s$%s", p.Algorithm, p.AlgoConfig, p.Salt, p.Hash)
}
type Argon2idConfig struct {
Time uint32
Memory uint32
Threads uint8
KeyLength uint32
}
func ParseArgon2idConfig(cfg string) (Argon2idConfig, error) {
parts := strings.Split(cfg, ",")
t64, err := strconv.ParseUint(parts[0][2:], 10, 32)
if err != nil {
return Argon2idConfig{}, oops.New(err, "failed to parse time in Argon2id config")
}
m64, err := strconv.ParseUint(parts[1][2:], 10, 32)
if err != nil {
return Argon2idConfig{}, oops.New(err, "failed to parse memory in Argon2id config")
}
p64, err := strconv.ParseUint(parts[2][2:], 10, 8)
if err != nil {
return Argon2idConfig{}, oops.New(err, "failed to parse threads in Argon2id config")
}
l64, err := strconv.ParseUint(parts[3][2:], 10, 32)
if err != nil {
return Argon2idConfig{}, oops.New(err, "failed to parse key length in Argon2id config")
}
return Argon2idConfig{
Time: uint32(t64),
Memory: uint32(m64),
Threads: uint8(p64),
KeyLength: uint32(l64),
}, nil
}
func (c Argon2idConfig) String() string {
return fmt.Sprintf("t=%v,m=%v,p=%v,l=%v", c.Time, c.Memory, c.Threads, c.KeyLength)
}
func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) {
switch hashedPassword.Algorithm {
case Argon2id:
cfg, err := ParseArgon2idConfig(hashedPassword.AlgoConfig)
if err != nil {
return false, err
}
salt, err := base64.StdEncoding.DecodeString(hashedPassword.Salt)
if err != nil {
return false, oops.New(err, "failed to decode salt")
}
newHash := argon2.IDKey([]byte(password), []byte(salt), cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength)
newHashEnc := base64.StdEncoding.EncodeToString(newHash)
return bytes.Equal([]byte(newHashEnc), []byte(hashedPassword.Hash)), nil
case Django_PBKDF2SHA256:
decoded, err := base64.StdEncoding.DecodeString(hashedPassword.Hash)
if err != nil {
return false, oops.New(nil, "failed to get key length of hashed password")
}
iterations, err := strconv.Atoi(hashedPassword.AlgoConfig)
if err != nil {
return false, oops.New(nil, "failed to get PBKDF2 iterations")
}
newHash := pbkdf2.Key(
[]byte(password),
[]byte(hashedPassword.Salt),
iterations,
len(decoded),
sha256.New,
)
newHashEncoded := base64.StdEncoding.EncodeToString(newHash)
return bytes.Equal([]byte(newHashEncoded), []byte(hashedPassword.Hash)), nil
default:
return false, oops.New(nil, "unrecognized password hash algorithm: %s", hashedPassword.Algorithm)
}
}
func HashPassword(password string) (HashedPassword, error) {
// Follows the OWASP recommendations as of March 2021.
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
salt := make([]byte, saltLength)
_, err := io.ReadFull(rand.Reader, salt)
if err != nil {
return HashedPassword{}, oops.New(err, "failed to generate salt")
}
saltEnc := base64.StdEncoding.EncodeToString(salt)
cfg := Argon2idConfig{
Time: 1,
Memory: 40 * 1024 * 1024,
Threads: 1,
KeyLength: keyLength,
}
key := argon2.IDKey([]byte(password), salt, cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength)
keyEnc := base64.StdEncoding.EncodeToString(key)
return HashedPassword{
Algorithm: Argon2id,
AlgoConfig: cfg.String(),
Salt: saltEnc,
Hash: keyEnc,
}, nil
}