Go · 4624 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package totp implements RFC 6238 TOTP (time-based one-time password)
4 // enrollment and verification. We use SHA-1 / 30-second period / 6 digits
5 // to maximize authenticator-app compatibility (every popular app speaks
6 // these defaults; SHA-256/512 cause real-world surprises).
7 //
8 // Counter anti-replay is enforced at the DB layer (BumpTOTPCounter only
9 // advances last_used_counter when the new step is strictly greater).
10 package totp
11
12 import (
13 "crypto/rand"
14 "encoding/base32"
15 "errors"
16 "fmt"
17 "net/url"
18 "strconv"
19 "strings"
20 "time"
21
22 "github.com/pquerna/otp"
23 "github.com/pquerna/otp/totp"
24 )
25
26 // SecretSize is the secret length in bytes. 20 is the RFC 4226/6238
27 // recommendation (160 bits — comfortably above brute-force reach).
28 const SecretSize = 20
29
30 // Period is the time-step in seconds.
31 const Period uint = 30
32
33 // Digits is the number of digits in the generated code.
34 const Digits = otp.DigitsSix
35
36 // SkewSteps is the number of windows ±0 we accept either side of `now`.
37 // A value of 1 means accept now-30s, now, now+30s — covers normal clock
38 // drift without expanding the replay window meaningfully.
39 const SkewSteps uint = 1
40
41 // GenerateSecret returns a fresh 20-byte secret.
42 func GenerateSecret() ([]byte, error) {
43 b := make([]byte, SecretSize)
44 if _, err := rand.Read(b); err != nil {
45 return nil, fmt.Errorf("totp: secret: %w", err)
46 }
47 return b, nil
48 }
49
50 // EncodeBase32 returns the Base32 encoding (no padding) authenticator
51 // apps display to the user. Used in the otpauth URI.
52 func EncodeBase32(secret []byte) string {
53 return strings.TrimRight(base32.StdEncoding.EncodeToString(secret), "=")
54 }
55
56 // OtpauthURI builds the canonical otpauth:// URI consumed by authenticator
57 // apps. issuer is the site name (e.g. "shithub"); accountName is the user's
58 // canonical identifier (we use "shithub:<username>"). The URI carries the
59 // secret in plaintext — never log it; redactor scrubs it as defense in depth.
60 func OtpauthURI(issuer, accountName string, secret []byte) string {
61 q := url.Values{}
62 q.Set("secret", EncodeBase32(secret))
63 q.Set("issuer", issuer)
64 q.Set("algorithm", "SHA1")
65 q.Set("digits", strconv.Itoa(int(Digits)))
66 q.Set("period", strconv.FormatUint(uint64(Period), 10))
67 u := url.URL{
68 Scheme: "otpauth",
69 Host: "totp",
70 Path: "/" + issuer + ":" + accountName,
71 RawQuery: q.Encode(),
72 }
73 return u.String()
74 }
75
76 // Generate returns the current 6-digit code for secret. Test affordance —
77 // not used at runtime by the server, only in tests.
78 func Generate(secret []byte, t time.Time) (string, error) {
79 code, err := totp.GenerateCodeCustom(EncodeBase32(secret), t, totp.ValidateOpts{
80 Period: Period,
81 Skew: SkewSteps,
82 Digits: Digits,
83 Algorithm: otp.AlgorithmSHA1,
84 })
85 if err != nil {
86 return "", fmt.Errorf("totp: generate: %w", err)
87 }
88 return code, nil
89 }
90
91 // Step returns the RFC 6238 step counter for time t.
92 func Step(t time.Time) int64 {
93 return t.Unix() / int64(Period)
94 }
95
96 // Verify checks code against secret with ±SkewSteps tolerance. On
97 // acceptance, returns the step counter that matched so the caller can
98 // reject codes whose counter is <= last_used_counter.
99 //
100 // Returns (step, nil) on accepted code; (0, err) otherwise. err is
101 // distinguishable via ErrInvalid for bad codes (vs. real errors).
102 func Verify(secret []byte, code string, t time.Time) (int64, error) {
103 if !looksLikeCode(code) {
104 return 0, ErrInvalid
105 }
106 b32 := EncodeBase32(secret)
107 // Walk the skew window ourselves so we know which step matched —
108 // pquerna/otp's ValidateCustom returns only a boolean.
109 for offset := -int64(SkewSteps); offset <= int64(SkewSteps); offset++ {
110 atTime := t.Add(time.Duration(offset) * time.Duration(Period) * time.Second)
111 want, err := totp.GenerateCodeCustom(b32, atTime, totp.ValidateOpts{
112 Period: Period,
113 Skew: 0,
114 Digits: Digits,
115 Algorithm: otp.AlgorithmSHA1,
116 })
117 if err != nil {
118 return 0, fmt.Errorf("totp: verify: %w", err)
119 }
120 if constantTimeEqual(want, code) {
121 return Step(atTime), nil
122 }
123 }
124 return 0, ErrInvalid
125 }
126
127 // ErrInvalid means the code doesn't match (or doesn't look like one).
128 // Distinct from a generation error so callers can branch on it.
129 var ErrInvalid = errors.New("totp: invalid code")
130
131 func looksLikeCode(s string) bool {
132 if len(s) != int(Digits) {
133 return false
134 }
135 for _, r := range s {
136 if r < '0' || r > '9' {
137 return false
138 }
139 }
140 return true
141 }
142
143 func constantTimeEqual(a, b string) bool {
144 if len(a) != len(b) {
145 return false
146 }
147 var v byte
148 for i := 0; i < len(a); i++ {
149 v |= a[i] ^ b[i]
150 }
151 return v == 0
152 }
153