Files

94 lines
2.4 KiB
Go
Raw Permalink Normal View History

2026-05-24 08:37:27 +00:00
package internals
// TOTP implementation per RFC 6238 (TOTP) and RFC 4226 (HOTP).
// No external dependencies — uses only crypto/hmac, crypto/sha1, encoding/base32.
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"net/url"
"strings"
"time"
)
const (
totpDigits = 6
totpStep = 30 // seconds per time window
totpWindow = 1 // ±1 step tolerance for clock drift (~30 s either side)
)
// newTOTPSecret generates a random 160-bit (20-byte) base32 secret.
// Compatible with Google Authenticator, Aegis, Authy, etc.
func newTOTPSecret() string {
b := make([]byte, 20)
rand.Read(b) //nolint:errcheck
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}
// decodeTOTPSecret decodes a base32 secret; case-insensitive, padding optional.
func decodeTOTPSecret(secret string) ([]byte, error) {
s := strings.ToUpper(strings.TrimSpace(secret))
if pad := len(s) % 8; pad != 0 {
s += strings.Repeat("=", 8-pad)
}
return base32.StdEncoding.DecodeString(s)
}
// hotpAt computes one HOTP value for the given key and counter (RFC 4226 §5).
func hotpAt(key []byte, counter uint64) string {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf) //nolint:errcheck
h := mac.Sum(nil)
// Dynamic truncation
offset := h[len(h)-1] & 0x0f
code := (uint32(h[offset])&0x7f)<<24 |
uint32(h[offset+1])<<16 |
uint32(h[offset+2])<<8 |
uint32(h[offset+3])
code %= uint32(math.Pow10(totpDigits))
return fmt.Sprintf("%0*d", totpDigits, code)
}
// validateTOTP returns true if code matches the current window ± totpWindow steps.
func validateTOTP(secret, code string) bool {
if len(code) != totpDigits {
return false
}
for _, c := range code {
if c < '0' || c > '9' {
return false
}
}
key, err := decodeTOTPSecret(secret)
if err != nil {
return false
}
step := uint64(time.Now().Unix()) / totpStep
for d := -totpWindow; d <= totpWindow; d++ {
if hotpAt(key, uint64(int64(step)+int64(d))) == code {
return true
}
}
return false
}
// totpOtpauthURL returns the otpauth:// URI used to provision authenticator apps.
func totpOtpauthURL(secret, username, issuer string) string {
return fmt.Sprintf(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
url.PathEscape(issuer),
url.PathEscape(username),
secret,
url.QueryEscape(issuer),
totpDigits,
totpStep,
)
}