// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package users import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base32" "encoding/hex" "errors" "strings" "time" "sourcedock.dev/petrbalvin/volumen/internal/tomlfile" "sourcedock.dev/petrbalvin/volumen/internal/totp" ) // ErrTotpNotEnabled is returned when a second-factor action addresses an // account that never finished enrolment. var ErrTotpNotEnabled = errors.New("two-factor authentication is not enabled for that account") // GenerateTotpSecret returns a fresh authenticator secret, base32 // without padding, the shape the otpauth URI and every application use. func GenerateTotpSecret() string { buf := make([]byte, 20) rand.Read(buf) return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf) } // GenerateRecoveryCodes returns count one-time codes, grouped for // reading, and their SHA-256 digests for storage. The codes are shown // once and survive only as hashes. func GenerateRecoveryCodes(count int) (codes, hashes []string) { for range count { buf := make([]byte, 10) rand.Read(buf) code := hex.EncodeToString(buf) codes = append(codes, code[:5]+"-"+code[5:10]+"-"+code[10:15]+"-"+code[15:20]) hashes = append(hashes, RecoveryHash(codes[len(codes)-1])) } return codes, hashes } // RecoveryHash is the stored form of a recovery code. func RecoveryHash(code string) string { sum := sha256.Sum256([]byte(normaliseCode(code))) return hex.EncodeToString(sum[:]) } // EnableTotp turns the second factor on in one write: the verified // secret, a zero replay floor and the hashed recovery codes. func (u *Users) EnableTotp(username, secret string, recoveryHashes []string) (*User, error) { return u.mutate(username, func(user *User) { user.TotpSecret = secret user.TotpStep = 0 user.Recovery = append([]string(nil), recoveryHashes...) }) } // ReplaceRecovery swaps the recovery codes and nothing else: the // secret and the replay floor stay, the old codes stop working. func (u *Users) ReplaceRecovery(username string, recoveryHashes []string) (*User, error) { return u.mutate(username, func(user *User) { user.Recovery = append([]string(nil), recoveryHashes...) }) } // ClearTotp turns the second factor off: the secret, the replay floor // and every remaining recovery code go together, so nothing of the old // factor survives to answer a future prompt. func (u *Users) ClearTotp(username string) (*User, error) { return u.mutate(username, func(user *User) { user.TotpSecret = "" user.TotpStep = 0 user.Recovery = nil }) } // VerifyTotp checks a code against the account's secret and, on // success, advances the replay floor in the same locked write. A wrong // code changes nothing, and a code already used is refused. func (u *Users) VerifyTotp(username, code string, now time.Time) bool { user := u.Find(username) if user == nil || user.TotpSecret == "" { return false } secret, err := decodeTotpSecret(user.TotpSecret) if err != nil { return false } ok, step := totp.Validate(secret, code, now, user.TotpStep) if !ok { return false } if _, err := u.mutate(username, func(user *User) { user.TotpStep = step }); err != nil { return false } return true } // ConsumeRecovery spends one recovery code. The code is matched against // the stored hashes in constant time and removed in the same locked // write, so a code works exactly once. func (u *Users) ConsumeRecovery(username, code string) bool { want := RecoveryHash(code) user := u.Find(username) if user == nil || len(user.Recovery) == 0 { return false } index := -1 for i, have := range user.Recovery { if subtle.ConstantTimeCompare([]byte(have), []byte(want)) == 1 { index = i break } } if index < 0 { return false } _, err := u.mutate(username, func(user *User) { user.Recovery = append(user.Recovery[:index], user.Recovery[index+1:]...) }) return err == nil } func decodeTotpSecret(encoded string) ([]byte, error) { return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded)) } // normaliseCode strips the shapes a human types around a recovery code. func normaliseCode(code string) string { return strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(code)) } // readTotpRecovery converts the stored TOML array back to strings. func readTotpRecovery(entry map[string]any) []string { out := tomlfile.Strings(entry["recovery"]) if len(out) == 0 { return nil } return out }