// 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$$$". 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 }