Files
ntfywui/internal/security/totp.go
2026-01-12 13:51:52 +01:00

64 lines
1.4 KiB
Go

package security
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"strings"
"time"
)
// GenerateTOTPSecret returns a base32 secret without padding.
func GenerateTOTPSecret() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", err
}
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
return enc.EncodeToString(b), nil
}
// VerifyTOTP verifies a 6-digit token with ±1 step skew (30s step).
func VerifyTOTP(secretBase32, code string, now time.Time) bool {
code = strings.ReplaceAll(code, " ", "")
if len(code) != 6 {
return false
}
sec, err := decodeBase32NoPad(secretBase32)
if err != nil {
return false
}
t := now.Unix() / 30
for _, drift := range []int64{-1, 0, 1} {
if hotp(sec, uint64(t+drift)) == code {
return true
}
}
return false
}
func hotp(secret []byte, counter uint64) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, secret)
mac.Write(buf[:])
sum := mac.Sum(nil)
off := sum[len(sum)-1] & 0x0f
bin := (int(sum[off])&0x7f)<<24 |
(int(sum[off+1])&0xff)<<16 |
(int(sum[off+2])&0xff)<<8 |
(int(sum[off+3]) & 0xff)
otp := bin % 1000000
return fmt.Sprintf("%06d", otp)
}
func decodeBase32NoPad(s string) ([]byte, error) {
s = strings.ToUpper(strings.ReplaceAll(s, " ", ""))
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
return enc.DecodeString(s)
}