| 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 |