Go · 4557 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package totp
4
5 import (
6 "strings"
7 "testing"
8 "time"
9 )
10
11 func TestGenerateAndVerify_RoundTrip(t *testing.T) {
12 t.Parallel()
13 secret, err := GenerateSecret()
14 if err != nil {
15 t.Fatalf("GenerateSecret: %v", err)
16 }
17 now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
18 code, err := Generate(secret, now)
19 if err != nil {
20 t.Fatalf("Generate: %v", err)
21 }
22 step, err := Verify(secret, code, now)
23 if err != nil {
24 t.Fatalf("Verify: %v", err)
25 }
26 if step != Step(now) {
27 t.Fatalf("step = %d, want %d", step, Step(now))
28 }
29 }
30
31 func TestVerify_AcceptsSkew(t *testing.T) {
32 t.Parallel()
33 secret, _ := GenerateSecret()
34 t0 := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
35 codePrev, _ := Generate(secret, t0.Add(-30*time.Second))
36 codeNext, _ := Generate(secret, t0.Add(30*time.Second))
37 if _, err := Verify(secret, codePrev, t0); err != nil {
38 t.Fatalf("prev step rejected: %v", err)
39 }
40 if _, err := Verify(secret, codeNext, t0); err != nil {
41 t.Fatalf("next step rejected: %v", err)
42 }
43 }
44
45 func TestVerify_RejectsTooFarOff(t *testing.T) {
46 t.Parallel()
47 secret, _ := GenerateSecret()
48 t0 := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
49 old, _ := Generate(secret, t0.Add(-90*time.Second)) // ~3 steps back
50 if _, err := Verify(secret, old, t0); err == nil {
51 t.Fatal("expected rejection of code 3 steps behind")
52 }
53 }
54
55 func TestVerify_RejectsMalformed(t *testing.T) {
56 t.Parallel()
57 secret, _ := GenerateSecret()
58 now := time.Now()
59 cases := []string{"", "12345", "1234567", "abcdef", "12345!"}
60 for _, c := range cases {
61 if _, err := Verify(secret, c, now); err == nil {
62 t.Errorf("expected rejection for %q", c)
63 }
64 }
65 }
66
67 func TestOtpauthURI_Shape(t *testing.T) {
68 t.Parallel()
69 secret := []byte("0123456789abcdef0123") // 20 bytes
70 u := OtpauthURI("shithub", "alice", secret)
71 if !strings.HasPrefix(u, "otpauth://totp/shithub:alice?") {
72 t.Fatalf("URI shape wrong: %s", u)
73 }
74 for _, want := range []string{"secret=", "issuer=shithub", "algorithm=SHA1", "digits=6", "period=30"} {
75 if !strings.Contains(u, want) {
76 t.Errorf("URI missing %q: %s", want, u)
77 }
78 }
79 }
80
81 func TestEncodeBase32_NoPadding(t *testing.T) {
82 t.Parallel()
83 enc := EncodeBase32(make([]byte, 20))
84 if strings.Contains(enc, "=") {
85 t.Fatalf("base32 contains padding: %s", enc)
86 }
87 }
88
89 // ----- recovery codes -----
90
91 func TestRecoveryCodes_ShapeAndUniqueness(t *testing.T) {
92 t.Parallel()
93 codes, hashes, err := GenerateRecoveryCodes()
94 if err != nil {
95 t.Fatalf("GenerateRecoveryCodes: %v", err)
96 }
97 if len(codes) != RecoveryCodeCount || len(hashes) != RecoveryCodeCount {
98 t.Fatalf("count mismatch")
99 }
100 seen := map[string]bool{}
101 for _, c := range codes {
102 if !LooksLikeRecoveryCode(c) {
103 t.Errorf("LooksLikeRecoveryCode rejected its own output: %q", c)
104 }
105 if !strings.Contains(c, "-") {
106 t.Errorf("missing dashes: %q", c)
107 }
108 if seen[c] {
109 t.Errorf("duplicate code: %q", c)
110 }
111 seen[c] = true
112 }
113 }
114
115 func TestRecoveryCodes_HashRoundTrip(t *testing.T) {
116 t.Parallel()
117 codes, hashes, _ := GenerateRecoveryCodes()
118 for i, c := range codes {
119 // Hashing the printed form (with dashes) and the normalized form
120 // must yield the same hash.
121 if !EqualHash(HashRecoveryCode(c), hashes[i]) {
122 t.Errorf("code %q hash mismatch (printed)", c)
123 }
124 if !EqualHash(HashRecoveryCode(NormalizeRecoveryCode(c)), hashes[i]) {
125 t.Errorf("code %q hash mismatch (normalized)", c)
126 }
127 // Lowercase + space variant should also match.
128 messy := " " + strings.ToLower(c) + " "
129 if !EqualHash(HashRecoveryCode(messy), hashes[i]) {
130 t.Errorf("code %q hash mismatch (lowercase + spaces)", c)
131 }
132 }
133 }
134
135 func TestLooksLikeRecoveryCode(t *testing.T) {
136 t.Parallel()
137 yes := []string{"ABCD-EFGH-JKMN", "abcd-efgh-jkmn", "A2C4-EF7H-JKM3"}
138 for _, s := range yes {
139 if !LooksLikeRecoveryCode(s) {
140 t.Errorf("expected accept: %q", s)
141 }
142 }
143 no := []string{"123456", "ABC-DEF", "ABCDEFGHJKMN!", ""}
144 for _, s := range no {
145 if LooksLikeRecoveryCode(s) {
146 t.Errorf("expected reject: %q", s)
147 }
148 }
149 }
150
151 func TestQRSVG_Renders(t *testing.T) {
152 t.Parallel()
153 secret, _ := GenerateSecret()
154 uri := OtpauthURI("shithub", "alice", secret)
155 svg, err := QRSVG(uri)
156 if err != nil {
157 t.Fatalf("QRSVG: %v", err)
158 }
159 s := string(svg)
160 for _, want := range []string{`<svg`, `</svg>`, `viewBox`, `<rect`} {
161 if !strings.Contains(s, want) {
162 t.Errorf("missing %q in svg", want)
163 }
164 }
165 // Defense-in-depth: secret must NOT appear in the SVG.
166 if strings.Contains(s, EncodeBase32(secret)) {
167 t.Fatal("secret leaked into rendered SVG")
168 }
169 }
170