Go · 3697 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package totp
4
5 import (
6 "crypto/rand"
7 "crypto/sha256"
8 "crypto/subtle"
9 "encoding/base32"
10 "errors"
11 "strings"
12 )
13
14 // RecoveryCodeCount is the number of recovery codes generated per user.
15 const RecoveryCodeCount = 10
16
17 // recoveryAlphabet is RFC 4648 base32 minus 0/1/8/B/I/L/O/S so adjacent
18 // glyphs don't get mistyped. 24 characters is plenty of entropy for our
19 // per-code length (12 chars → ~57 bits).
20 const recoveryAlphabet = "ACDEFGHJKMNPQRTUVWXYZ234"
21
22 // RecoveryCodeGroups is the number of dash-separated groups in the
23 // rendered code (currently 3, of 4 chars each → 12 chars total).
24 const RecoveryCodeGroups = 3
25
26 // RecoveryCodeGroupSize is the length of each group.
27 const RecoveryCodeGroupSize = 4
28
29 // GenerateRecoveryCodes mints RecoveryCodeCount fresh codes. Returns the
30 // human-readable strings (XXXX-XXXX-XXXX) that are shown to the user
31 // once, and the matching sha256 hashes (stored in the DB).
32 func GenerateRecoveryCodes() ([]string, [][]byte, error) {
33 codes := make([]string, RecoveryCodeCount)
34 hashes := make([][]byte, RecoveryCodeCount)
35 for i := 0; i < RecoveryCodeCount; i++ {
36 c, err := mintCode()
37 if err != nil {
38 return nil, nil, err
39 }
40 codes[i] = c
41 h := sha256.Sum256([]byte(NormalizeRecoveryCode(c)))
42 hashes[i] = h[:]
43 }
44 return codes, hashes, nil
45 }
46
47 // HashRecoveryCode returns the sha256 hash of the normalized form of
48 // code, suitable for DB lookup.
49 func HashRecoveryCode(code string) []byte {
50 h := sha256.Sum256([]byte(NormalizeRecoveryCode(code)))
51 return h[:]
52 }
53
54 // NormalizeRecoveryCode strips dashes/whitespace and uppercases the
55 // result so codes typed with stray spaces or lowercase still match.
56 func NormalizeRecoveryCode(s string) string {
57 s = strings.ToUpper(s)
58 var b strings.Builder
59 b.Grow(len(s))
60 for _, r := range s {
61 if r == '-' || r == ' ' {
62 continue
63 }
64 b.WriteRune(r)
65 }
66 return b.String()
67 }
68
69 // LooksLikeRecoveryCode is a cheap predicate the login challenge handler
70 // uses to distinguish a recovery code from a TOTP code: alphanumeric and
71 // of the expected post-normalization length.
72 func LooksLikeRecoveryCode(s string) bool {
73 n := NormalizeRecoveryCode(s)
74 if len(n) != RecoveryCodeGroups*RecoveryCodeGroupSize {
75 return false
76 }
77 for _, r := range n {
78 isUpper := r >= 'A' && r <= 'Z'
79 isDigit := r >= '0' && r <= '9'
80 if !isUpper && !isDigit {
81 return false
82 }
83 }
84 return true
85 }
86
87 // EqualHash compares two recovery-code hashes in constant time. Used by
88 // callers that read a candidate hash from the DB and want to compare to
89 // a value they computed themselves.
90 func EqualHash(a, b []byte) bool {
91 return subtle.ConstantTimeCompare(a, b) == 1
92 }
93
94 // mintCode generates one fresh formatted recovery code.
95 func mintCode() (string, error) {
96 const total = RecoveryCodeGroups * RecoveryCodeGroupSize
97 out := make([]byte, total)
98 // Generate 5 raw bytes per 4 base32 chars (40 bits → 8 chars; we use
99 // our reduced alphabet so generate plenty and slice).
100 buf := make([]byte, total*2)
101 if _, err := rand.Read(buf); err != nil {
102 return "", err
103 }
104 for i := 0; i < total; i++ {
105 out[i] = recoveryAlphabet[int(buf[i])%len(recoveryAlphabet)]
106 }
107 // Insert dashes between groups.
108 var b strings.Builder
109 for g := 0; g < RecoveryCodeGroups; g++ {
110 if g > 0 {
111 b.WriteByte('-')
112 }
113 b.Write(out[g*RecoveryCodeGroupSize : (g+1)*RecoveryCodeGroupSize])
114 }
115 return b.String(), nil
116 }
117
118 // ErrRecoveryCodeInvalid is returned by callers when the typed code
119 // doesn't match any stored hash.
120 var ErrRecoveryCodeInvalid = errors.New("totp: recovery code invalid")
121
122 // noOpUseStdEncoding silences unused-import warnings during refactors.
123 var _ = base32.StdEncoding
124