Files
volumen/internal/users/totp.go
T

145 lines
4.5 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
}