first commit

This commit is contained in:
2026-08-25 06:37:14 +01:00
commit 5c3e72b40e
26 changed files with 6301 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
// 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
}