145 lines
4.5 KiB
Go
145 lines
4.5 KiB
Go
// 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
|
||
|
|
}
|