fix(2fa): accept display forms of the TOTP secret and never panic on a bad one (#7113)

gotp decodes the stored secret with strict base32 and panics when that fails,
so a 2FA secret saved through the settings API in lower case, grouped by
spaces, or not base32 at all turned every login and every attempt to turn 2FA
off into an HTTP 500. An empty secret had the opposite problem: the login
check accepted the code of an empty HMAC key, which anyone can compute.

VerifyWithSkew now normalizes the secret and verifies nothing for an empty or
non-base32 one, so secrets already stored in a display form work again.
Turning 2FA on through the settings update stores the normalized secret and
refuses one no authenticator could match.
This commit is contained in:
Aleksey Fedorov
2026-11-13 09:13:13 +10:00
committed by GitHub
parent 8438981ef1
commit 7367ff75cd
5 changed files with 128 additions and 3 deletions
+19 -1
View File
@@ -1,6 +1,8 @@
package totp
import (
"encoding/base32"
"strings"
"time"
"github.com/xlzd/gotp"
@@ -10,9 +12,25 @@ import (
// Standard TOTP clock-drift tolerance, see MHSanaei/3x-ui#6535.
const SkewWindows = 1
// NormalizeSecret turns a base32 key as authenticator apps show it (lower case,
// grouped by spaces, padded) into the bare upper-case form gotp decodes.
func NormalizeSecret(secret string) string {
return strings.TrimRight(strings.ToUpper(strings.Join(strings.Fields(secret), "")), "=")
}
// ValidSecret reports whether secret is a non-empty base32 key once normalized.
func ValidSecret(secret string) bool {
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(NormalizeSecret(secret))
return err == nil && len(key) > 0
}
// VerifyWithSkew accepts the code for the current step plus/minus SkewWindows.
// An empty or non-base32 secret verifies nothing (gotp panics on the latter).
func VerifyWithSkew(secret, code string, now time.Time) bool {
totp := gotp.NewDefaultTOTP(secret)
if !ValidSecret(secret) {
return false
}
totp := gotp.NewDefaultTOTP(NormalizeSecret(secret))
for i := -SkewWindows; i <= SkewWindows; i++ {
if totp.AtTime(now.Add(time.Duration(i*30)*time.Second)) == code {
return true
+27
View File
@@ -32,3 +32,30 @@ func TestVerifyWithSkew(t *testing.T) {
t.Fatal("wrong code should not verify")
}
}
func TestVerifyWithSkewSecretForms(t *testing.T) {
const canonical = "JBSWY3DPEHPK3PXP"
now := time.Unix((time.Now().Unix()/30)*30+15, 0).UTC()
code := gotp.NewDefaultTOTP(canonical).AtTime(now)
cases := []struct {
name string
secret string
want bool
}{
{"canonical", canonical, true},
{"lower case", "jbswy3dpehpk3pxp", true},
{"grouped and padded", " jbsw y3dp ehpk 3pxp== ", true},
{"empty", "", false},
{"blank", " ", false},
{"not base32", "totp-secret", false},
{"base32 digits out of range", "JBSWY3DPEHPK3PX1", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := VerifyWithSkew(c.secret, code, now); got != c.want {
t.Fatalf("VerifyWithSkew(%q) = %v, want %v", c.secret, got, c.want)
}
})
}
}