94 lines
2.4 KiB
Go
94 lines
2.4 KiB
Go
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,
|
|
)
|
|
}
|