first commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// 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())
|
||||
}
|
||||
Reference in New Issue
Block a user