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