201 lines
6.0 KiB
Go
201 lines
6.0 KiB
Go
// Package auth provides the stdlib-only crypto primitives behind login:
|
|||
|
|
// PBKDF2-HMAC-SHA256 password hashing, RFC 6238 TOTP for MFA, and
|
||
|
|
// HMAC-signed session tokens. It has no knowledge of the database or HTTP —
|
||
|
|
// see internal/authsvc for the service that wires these into the app.
|
||
|
|
package auth
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha1"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/base32"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/binary"
|
||
|
|
"encoding/hex"
|
||
|
|
"fmt"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const pbkdf2Iterations = 210000
|
||
|
|
|
||
|
|
// HashPassword returns a self-describing hash string for storage:
|
||
|
|
// "pbkdf2$<iterations>$<saltHex>$<hashHex>".
|
||
|
|
func HashPassword(password string) (string, error) {
|
||
|
|
salt := make([]byte, 16)
|
||
|
|
if _, err := rand.Read(salt); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
hash := pbkdf2(password, salt, pbkdf2Iterations, 32)
|
||
|
|
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Iterations, hex.EncodeToString(salt), hex.EncodeToString(hash)), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// VerifyPassword checks password against a hash produced by HashPassword.
|
||
|
|
func VerifyPassword(password, encoded string) bool {
|
||
|
|
parts := strings.Split(encoded, "$")
|
||
|
|
if len(parts) != 4 || parts[0] != "pbkdf2" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
iterations, err := strconv.Atoi(parts[1])
|
||
|
|
if err != nil || iterations <= 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
salt, err := hex.DecodeString(parts[2])
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
want, err := hex.DecodeString(parts[3])
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
got := pbkdf2(password, salt, iterations, len(want))
|
||
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||
|
|
}
|
||
|
|
|
||
|
|
// pbkdf2 implements RFC 2898 PBKDF2-HMAC-SHA256.
|
||
|
|
func pbkdf2(password string, salt []byte, iterations, keyLen int) []byte {
|
||
|
|
prf := hmac.New(sha256.New, []byte(password))
|
||
|
|
hashLen := prf.Size()
|
||
|
|
numBlocks := (keyLen + hashLen - 1) / hashLen
|
||
|
|
dk := make([]byte, 0, numBlocks*hashLen)
|
||
|
|
var blockIndex [4]byte
|
||
|
|
for block := 1; block <= numBlocks; block++ {
|
||
|
|
prf.Reset()
|
||
|
|
prf.Write(salt)
|
||
|
|
binary.BigEndian.PutUint32(blockIndex[:], uint32(block))
|
||
|
|
prf.Write(blockIndex[:])
|
||
|
|
u := prf.Sum(nil)
|
||
|
|
t := append([]byte(nil), u...)
|
||
|
|
for i := 1; i < iterations; i++ {
|
||
|
|
prf.Reset()
|
||
|
|
prf.Write(u)
|
||
|
|
u = prf.Sum(nil)
|
||
|
|
for j := range t {
|
||
|
|
t[j] ^= u[j]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
dk = append(dk, t...)
|
||
|
|
}
|
||
|
|
return dk[:keyLen]
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- TOTP (RFC 6238) / HOTP (RFC 4226): HMAC-SHA1, 6 digits, 30s step. ---
|
||
|
|
// SHA1 is dictated by the TOTP standard that every authenticator app
|
||
|
|
// implements — it's not a general-purpose choice, just the protocol's.
|
||
|
|
|
||
|
|
const (
|
||
|
|
totpStep = 30
|
||
|
|
totpDigits = 6
|
||
|
|
)
|
||
|
|
|
||
|
|
var base32Enc = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||
|
|
|
||
|
|
// NewTOTPSecret generates a random 160-bit secret, base32-encoded for
|
||
|
|
// display/entry into an authenticator app.
|
||
|
|
func NewTOTPSecret() (string, error) {
|
||
|
|
raw := make([]byte, 20)
|
||
|
|
if _, err := rand.Read(raw); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return base32Enc.EncodeToString(raw), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// OTPAuthURI builds the otpauth:// URI most authenticator apps accept for
|
||
|
|
// manual key entry (no QR code — see the account page's hint text).
|
||
|
|
func OTPAuthURI(issuer, account, secretBase32 string) string {
|
||
|
|
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d",
|
||
|
|
issuer, account, secretBase32, issuer, totpDigits, totpStep)
|
||
|
|
}
|
||
|
|
|
||
|
|
func totpAt(secretBase32 string, t time.Time) (string, error) {
|
||
|
|
secret, err := base32Enc.DecodeString(strings.ToUpper(strings.TrimSpace(secretBase32)))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
counter := uint64(t.Unix() / totpStep)
|
||
|
|
return hotp(secret, counter, totpDigits), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ValidateTOTP checks code against the current 30s step, tolerating one
|
||
|
|
// step of clock drift either side.
|
||
|
|
func ValidateTOTP(secretBase32, code string) bool {
|
||
|
|
code = strings.TrimSpace(code)
|
||
|
|
if code == "" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
now := time.Now()
|
||
|
|
for _, skew := range [3]time.Duration{0, -totpStep * time.Second, totpStep * time.Second} {
|
||
|
|
want, err := totpAt(secretBase32, now.Add(skew))
|
||
|
|
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func hotp(secret []byte, counter uint64, digits int) string {
|
||
|
|
var buf [8]byte
|
||
|
|
binary.BigEndian.PutUint64(buf[:], counter)
|
||
|
|
mac := hmac.New(sha1.New, secret)
|
||
|
|
mac.Write(buf[:])
|
||
|
|
sum := mac.Sum(nil)
|
||
|
|
offset := sum[len(sum)-1] & 0x0f
|
||
|
|
code := (uint32(sum[offset])&0x7f)<<24 | uint32(sum[offset+1])<<16 | uint32(sum[offset+2])<<8 | uint32(sum[offset+3])
|
||
|
|
mod := uint32(1)
|
||
|
|
for i := 0; i < digits; i++ {
|
||
|
|
mod *= 10
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%0*d", digits, code%mod)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Session tokens: stateless, HMAC-signed "username|version|expiry". ---
|
||
|
|
// version lets a password change/reset invalidate every issued token at
|
||
|
|
// once, just by bumping the stored session_version.
|
||
|
|
|
||
|
|
func NewSessionToken(key []byte, username string, version int, ttl time.Duration) string {
|
||
|
|
expiry := time.Now().Add(ttl).Unix()
|
||
|
|
payload := fmt.Sprintf("%s|%d|%d", username, version, expiry)
|
||
|
|
mac := hmac.New(sha256.New, key)
|
||
|
|
mac.Write([]byte(payload))
|
||
|
|
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ParseSessionToken verifies the signature and expiry, returning the
|
||
|
|
// embedded username/version. The caller must still check these against the
|
||
|
|
// current stored auth record (see authsvc.Service.CheckSession).
|
||
|
|
func ParseSessionToken(key []byte, token string) (username string, version int, ok bool) {
|
||
|
|
payloadB64, sigB64, found := strings.Cut(token, ".")
|
||
|
|
if !found {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||
|
|
if err != nil {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
gotSig, err := base64.RawURLEncoding.DecodeString(sigB64)
|
||
|
|
if err != nil {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
mac := hmac.New(sha256.New, key)
|
||
|
|
mac.Write(payload)
|
||
|
|
if !hmac.Equal(gotSig, mac.Sum(nil)) {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
parts := strings.SplitN(string(payload), "|", 3)
|
||
|
|
if len(parts) != 3 {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
v, err := strconv.Atoi(parts[1])
|
||
|
|
if err != nil {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
expiry, err := strconv.ParseInt(parts[2], 10, 64)
|
||
|
|
if err != nil || time.Now().Unix() > expiry {
|
||
|
|
return "", 0, false
|
||
|
|
}
|
||
|
|
return parts[0], v, true
|
||
|
|
}
|