// Package auth implements login, TOTP verification, and credential management. // // Security design: // - All credential comparisons use constant-time operations to resist timing // side-channels. crypto/subtle.ConstantTimeCompare is used wherever secrets // are compared. // - On any login failure the error returned to the caller is always generic // ("invalid credentials"), regardless of which step failed, to prevent // user enumeration. // - TOTP uses a ±1 time-step window (±30s) per RFC 6238 recommendation. // - PHC string format is used for password hashes, enabling transparent // parameter upgrades without re-migration. package auth import ( "crypto/hmac" "crypto/sha1" //nolint:gosec // SHA-1 is required by RFC 6238 for TOTP; not used for collision resistance. "crypto/subtle" "encoding/base32" encodingbase64 "encoding/base64" "encoding/binary" "errors" "fmt" "math" "strconv" "strings" "time" "golang.org/x/crypto/argon2" "git.wntrmute.dev/kyle/mcias/internal/crypto" ) // ErrInvalidCredentials is returned for any authentication failure. // It intentionally does not distinguish between wrong password, wrong TOTP, // or unknown user — to prevent information leakage to the caller. var ErrInvalidCredentials = errors.New("auth: invalid credentials") // ArgonParams holds Argon2id hashing parameters embedded in PHC strings. type ArgonParams struct { Time uint32 Memory uint32 // KiB Threads uint8 } // DefaultArgonParams returns OWASP-2023-compliant parameters. // Security: These meet the OWASP minimum (time=2, memory=64MiB) and provide // additional margin with time=3. func DefaultArgonParams() ArgonParams { return ArgonParams{ Time: 3, Memory: 64 * 1024, // 64 MiB in KiB Threads: 4, } } // HashPassword hashes a password using Argon2id and returns a PHC-format string. // A random 16-byte salt is generated via crypto/rand for each call. // // Security: Argon2id is selected per OWASP recommendation; it resists both // side-channel and GPU brute-force attacks. The random salt ensures each hash // is unique even for identical passwords. func HashPassword(password string, params ArgonParams) (string, error) { if password == "" { return "", errors.New("auth: password must not be empty") } // Generate a cryptographically-random 16-byte salt. salt, err := crypto.RandomBytes(16) if err != nil { return "", fmt.Errorf("auth: generate salt: %w", err) } hash := argon2.IDKey( []byte(password), salt, params.Time, params.Memory, params.Threads, 32, // 256-bit output ) // PHC format: $argon2id$v=19$m=,t=,p=

$$ saltB64 := encodingbase64.RawStdEncoding.EncodeToString(salt) hashB64 := encodingbase64.RawStdEncoding.EncodeToString(hash) phc := fmt.Sprintf( "$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", params.Memory, params.Time, params.Threads, saltB64, hashB64, ) return phc, nil } // VerifyPassword checks a plaintext password against a PHC-format Argon2id hash. // Returns true if the password matches. // // Security: Comparison uses crypto/subtle.ConstantTimeCompare after computing // the candidate hash with identical parameters and the stored salt. This // prevents timing attacks that could reveal whether a password is "closer" to // the correct value. func VerifyPassword(password, phcHash string) (bool, error) { params, salt, expectedHash, err := parsePHC(phcHash) if err != nil { return false, fmt.Errorf("auth: parse PHC hash: %w", err) } candidateHash := argon2.IDKey( []byte(password), salt, params.Time, params.Memory, params.Threads, uint32(len(expectedHash)), //nolint:gosec // G115: hash buffer length is always small and fits uint32 ) // Security: constant-time comparison prevents timing side-channels. if subtle.ConstantTimeCompare(candidateHash, expectedHash) != 1 { return false, nil } return true, nil } // parsePHC parses a PHC-format Argon2id hash string. // Expected format: $argon2id$v=19$m=,t=,p=

$$ func parsePHC(phc string) (ArgonParams, []byte, []byte, error) { parts := strings.Split(phc, "$") // Expected: ["", "argon2id", "v=19", "m=M,t=T,p=P", "salt", "hash"] if len(parts) != 6 { return ArgonParams{}, nil, nil, fmt.Errorf("auth: invalid PHC format: %d parts", len(parts)) } if parts[1] != "argon2id" { return ArgonParams{}, nil, nil, fmt.Errorf("auth: unsupported algorithm %q", parts[1]) } var params ArgonParams for _, kv := range strings.Split(parts[3], ",") { eq := strings.IndexByte(kv, '=') if eq < 0 { return ArgonParams{}, nil, nil, fmt.Errorf("auth: invalid PHC param %q", kv) } k, v := kv[:eq], kv[eq+1:] n, err := strconv.ParseUint(v, 10, 32) if err != nil { return ArgonParams{}, nil, nil, fmt.Errorf("auth: parse PHC param %q: %w", kv, err) } switch k { case "m": params.Memory = uint32(n) case "t": params.Time = uint32(n) case "p": params.Threads = uint8(n) //nolint:gosec // G115: thread count is validated to be <= 255 by config } } salt, err := encodingbase64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return ArgonParams{}, nil, nil, fmt.Errorf("auth: decode salt: %w", err) } hash, err := encodingbase64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return ArgonParams{}, nil, nil, fmt.Errorf("auth: decode hash: %w", err) } return params, salt, hash, nil } // ValidateTOTP checks a 6-digit TOTP code against a raw TOTP secret (bytes). // A ±1 time-step window (±30s) is allowed to accommodate clock skew. // // Security: // - Comparison uses crypto/subtle.ConstantTimeCompare to resist timing attacks. // - Only RFC 6238-compliant HOTP (HMAC-SHA1) is implemented; no custom crypto. // - A ±1 window is the RFC 6238 recommendation; wider windows increase // exposure to code interception between generation and submission. func ValidateTOTP(secret []byte, code string) (bool, error) { if len(code) != 6 { return false, nil } now := time.Now().Unix() step := int64(30) // RFC 6238 default time step in seconds for _, counter := range []int64{ now/step - 1, now / step, now/step + 1, } { expected, err := hotp(secret, uint64(counter)) //nolint:gosec // G115: counter is Unix time / step, always non-negative if err != nil { return false, fmt.Errorf("auth: compute TOTP: %w", err) } // Security: constant-time comparison to prevent timing attack. if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 { return true, nil } } return false, nil } // hotp computes an HMAC-SHA1-based OTP for a given counter value. // Implements RFC 4226 §5, which is the base algorithm for RFC 6238 TOTP. // // Security: SHA-1 is used as required by RFC 4226/6238. It is used here in // an HMAC construction for OTP purposes — not for collision-resistant hashing. // The HMAC-SHA1 construction is still cryptographically sound for this use case. func hotp(key []byte, counter uint64) (string, error) { counterBytes := make([]byte, 8) binary.BigEndian.PutUint64(counterBytes, counter) mac := hmac.New(sha1.New, key) if _, err := mac.Write(counterBytes); err != nil { return "", fmt.Errorf("auth: HMAC-SHA1 write: %w", err) } h := mac.Sum(nil) // Dynamic truncation per RFC 4226 §5.3. offset := h[len(h)-1] & 0x0F binCode := (int(h[offset]&0x7F)<<24 | int(h[offset+1])<<16 | int(h[offset+2])<<8 | int(h[offset+3])) % int(math.Pow10(6)) return fmt.Sprintf("%06d", binCode), nil } // DecodeTOTPSecret decodes a base32-encoded TOTP secret string to raw bytes. // TOTP authenticator apps present secrets in base32 for display; this function // converts them to the raw byte form stored (encrypted) in the database. func DecodeTOTPSecret(base32Secret string) ([]byte, error) { normalised := strings.ToUpper(strings.ReplaceAll(base32Secret, " ", "")) decoded, err := base32.StdEncoding.DecodeString(normalised) if err != nil { decoded, err = base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalised) if err != nil { return nil, fmt.Errorf("auth: decode base32 TOTP secret: %w", err) } } return decoded, nil } // GenerateTOTPSecret generates a random 20-byte TOTP shared secret and returns // both the raw bytes and their base32 representation for display to the user. func GenerateTOTPSecret() (rawBytes []byte, base32Encoded string, err error) { rawBytes, err = crypto.RandomBytes(20) if err != nil { return nil, "", fmt.Errorf("auth: generate TOTP secret: %w", err) } base32Encoded = base32.StdEncoding.EncodeToString(rawBytes) return rawBytes, base32Encoded, nil }