// Package totp implements time-based one-time passwords (RFC 6238) for
// two-factor login, with the settings every authenticator app supports:
// HMAC-SHA1, 6 digits, a 30-second step. It also makes the one-time
// recovery codes and the QR code that sets up an app.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"math/big"
"net/url"
"strings"
"time"
"rsc.io/qr"
)
const (
// Digits is the length of a code.
Digits = 6
// Period is the length of a time step.
Period = 30 * time.Second
// Skew is how many steps before and after the current one are
// accepted, for clocks that are a little off.
Skew = 1
// SecretBytes is the length of a secret (160 bits, as RFC 4226
// recommends).
SecretBytes = 20
// RecoveryCodes is how many recovery codes a user gets.
RecoveryCodes = 10
)
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
// NewSecret returns a new random secret, base32 encoded (the form
// authenticator apps take).
func NewSecret() string {
b := make([]byte, SecretBytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
func decode(secret string) ([]byte, error) {
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
k, err := b32.DecodeString(strings.TrimRight(s, "="))
if err != nil || len(k) == 0 {
return nil, errors.New("invalid TOTP secret")
}
return k, nil
}
// Step returns the time step t falls in.
func Step(t time.Time) int64 { return t.Unix() / int64(Period/time.Second) }
// Code returns the code for a step.
func Code(secret string, step int64) (string, error) {
k, err := decode(secret)
if err != nil {
return "", err
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(step))
m := hmac.New(sha1.New, k)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", Digits, v%1_000_000), nil
}
// Normalize removes the spaces and dashes people type in codes.
func Normalize(code string) string {
return strings.Map(func(r rune) rune {
if r == ' ' || r == '-' || r == '\t' {
return -1
}
return r
}, strings.TrimSpace(code))
}
// IsCode reports whether s looks like a TOTP code (as opposed to a
// recovery code).
func IsCode(s string) bool {
s = Normalize(s)
if len(s) != Digits {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// Verify checks a code against the steps around now and returns the step
// it matched. Steps up to and including last (the newest step already
// used) are refused, so a code works only once.
func Verify(secret, code string, now time.Time, last int64) (int64, bool) {
code = Normalize(code)
if !IsCode(code) {
return 0, false
}
cur := Step(now)
for d := -Skew; d <= Skew; d++ {
step := cur + int64(d)
if step <= last {
continue
}
want, err := Code(secret, step)
if err != nil {
return 0, false
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return step, true
}
}
return 0, false
}
// URI is the otpauth:// URI that authenticator apps read from the QR code.
func URI(issuer, account, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", fmt.Sprint(Digits))
q.Set("period", fmt.Sprint(int(Period/time.Second)))
return "otpauth://totp/" + label + "?" + q.Encode()
}
// QRDataURI renders text as a QR code: an SVG image as a data: URI, ready
// for an tag.
func QRDataURI(text string) (string, error) {
c, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
const quiet = 4 // the white border scanners need, in modules
n := c.Size + 2*quiet
var b strings.Builder
fmt.Fprintf(&b, ``)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())), nil
}
// recoveryAlphabet has no 0/o, 1/l/i, so codes are easy to read and type.
const recoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
// NewRecoveryCodes returns n random recovery codes like "k7dm2-xq9pw"
// (about 49 bits each).
func NewRecoveryCodes(n int) []string {
out := make([]string, n)
for i := range out {
var s strings.Builder
for j := range 10 {
if j == 5 {
s.WriteByte('-')
}
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet))))
if err != nil {
panic(err)
}
s.WriteByte(recoveryAlphabet[v.Int64()])
}
out[i] = s.String()
}
return out
}
// NormalizeRecovery puts a recovery code as typed into the form it is
// stored in (lower case, no spaces or dashes).
func NormalizeRecovery(code string) string {
return strings.ToLower(Normalize(code))
}