124 lines
3.9 KiB
Go
124 lines
3.9 KiB
Go
// Package totp implements TOTP (RFC 6238, built on HOTP RFC 4226) —
|
|||
|
|
// hand-rolled on stdlib crypto/hmac + crypto/sha1 + encoding/base32, no
|
||
|
|
// third-party OTP library. Correctness is checked against RFC 6238's own
|
||
|
|
// published test vectors (Appendix B) in the test suite, not just "it
|
||
|
|
// produces a 6-digit number."
|
||
|
|
package totp
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha1"
|
||
|
|
"encoding/base32"
|
||
|
|
"fmt"
|
||
|
|
"math"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
period = 30 // seconds per RFC 6238's recommended default
|
||
|
|
digits = 6
|
||
|
|
)
|
||
|
|
|
||
|
|
// GenerateSecret creates a new random 20-byte (160-bit) secret, base32
|
||
|
|
// encoded — the standard size real authenticator apps (Google Authenticator,
|
||
|
|
// Authy, etc.) expect.
|
||
|
|
func GenerateSecret() (string, error) {
|
||
|
|
b := make([]byte, 20)
|
||
|
|
if _, err := rand.Read(b); err != nil {
|
||
|
|
return "", fmt.Errorf("generating TOTP secret: %w", err)
|
||
|
|
}
|
||
|
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate computes the TOTP code for secret at the given time — exported
|
||
|
|
// primarily so the test suite can check RFC 6238's published vectors, which
|
||
|
|
// specify exact codes for exact timestamps.
|
||
|
|
func Generate(secret string, at time.Time) (string, error) {
|
||
|
|
key, err := decodeSecret(secret)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
counter := uint64(at.Unix() / period)
|
||
|
|
return hotp(key, counter), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate checks code against the current time step and, per common TOTP
|
||
|
|
// practice, the one step before and after (±30s) to tolerate minor clock
|
||
|
|
// drift between server and authenticator app.
|
||
|
|
func Validate(secret, code string) (bool, error) {
|
||
|
|
key, err := decodeSecret(secret)
|
||
|
|
if err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
code = strings.TrimSpace(code)
|
||
|
|
now := time.Now().UTC()
|
||
|
|
counter := uint64(now.Unix() / period)
|
||
|
|
|
||
|
|
for _, skew := range []int64{0, -1, 1} {
|
||
|
|
c := hotp(key, uint64(int64(counter)+skew))
|
||
|
|
if c == code {
|
||
|
|
return true, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// hotp implements RFC 4226 HOTP — the counter-based primitive TOTP wraps.
|
||
|
|
func hotp(key []byte, counter uint64) string {
|
||
|
|
msg := make([]byte, 8)
|
||
|
|
for i := 7; i >= 0; i-- {
|
||
|
|
msg[i] = byte(counter & 0xff)
|
||
|
|
counter >>= 8
|
||
|
|
}
|
||
|
|
|
||
|
|
mac := hmac.New(sha1.New, key)
|
||
|
|
mac.Write(msg)
|
||
|
|
sum := mac.Sum(nil)
|
||
|
|
|
||
|
|
offset := sum[len(sum)-1] & 0x0f
|
||
|
|
binCode := (uint32(sum[offset])&0x7f)<<24 |
|
||
|
|
(uint32(sum[offset+1])&0xff)<<16 |
|
||
|
|
(uint32(sum[offset+2])&0xff)<<8 |
|
||
|
|
(uint32(sum[offset+3]) & 0xff)
|
||
|
|
|
||
|
|
mod := uint32(math.Pow10(digits))
|
||
|
|
return fmt.Sprintf("%0*d", digits, binCode%mod)
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeSecret(secret string) ([]byte, error) {
|
||
|
|
secret = strings.ToUpper(strings.TrimSpace(secret))
|
||
|
|
secret = strings.ReplaceAll(secret, " ", "")
|
||
|
|
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("decoding TOTP secret: %w", err)
|
||
|
|
}
|
||
|
|
return key, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ProvisioningURI builds the otpauth:// URI real authenticator apps use to
|
||
|
|
// set up an account — either scanned as a QR code (QR rendering itself is
|
||
|
|
// deliberately not implemented here, see package doc note below) or
|
||
|
|
// manually entered, since every mainstream authenticator app supports
|
||
|
|
// typing in the secret directly as a fallback to scanning.
|
||
|
|
//
|
||
|
|
// Note: this package does not generate a QR code image. Real QR encoding
|
||
|
|
// (Reed-Solomon error correction, matrix placement) is a substantial
|
||
|
|
// sub-project of its own with little shared surface with TOTP itself —
|
||
|
|
// deferred rather than half-implemented. The webmail MFA setup page
|
||
|
|
// displays this URI as both a copyable string and (optionally, via a
|
||
|
|
// client-side QR library the frontend can add later) a scannable code.
|
||
|
|
func ProvisioningURI(secret, accountEmail, issuer string) string {
|
||
|
|
v := url.Values{}
|
||
|
|
v.Set("secret", secret)
|
||
|
|
v.Set("issuer", issuer)
|
||
|
|
v.Set("algorithm", "SHA1")
|
||
|
|
v.Set("digits", strconv.Itoa(digits))
|
||
|
|
v.Set("period", strconv.Itoa(period))
|
||
|
|
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountEmail)
|
||
|
|
return fmt.Sprintf("otpauth://totp/%s?%s", label, v.Encode())
|
||
|
|
}
|