carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "path/filepath"
9 "testing"
10 "time"
11)
12
13// testFlow is the flow hash the store tests' sign-in challenges belong to.
14var testFlow = hashToken("a-test-flow-token-of-some-length-000000")
15
16func newSignInChallenge(t *testing.T, s *Store, now time.Time, flowHash string) (link, code string) {
17 t.Helper()
18 link, code, err := s.CreateEmailChallenge(EmailChallenge{
19 Purpose: ChallengeSignIn, Address: "bob@example.com", Next: "/auth/cli", FlowHash: flowHash,
20 ExpiresAt: now.Add(ConfirmationTTL),
21 }, now)
22 if err != nil {
23 t.Fatal(err)
24 }
25 return link, code
26}
27
28// live is the flow's newest live sign-in challenge, failing the test when
29// there is none.
30func live(t *testing.T, s *Store, flowHash string, now time.Time) *EmailChallenge {
31 t.Helper()
32 c, ok := s.LiveEmailChallenge(ChallengeSignIn, flowHash, now)
33 if !ok {
34 t.Fatal("the flow has no live sign-in challenge")
35 }
36 return c
37}
38
39// wrongCode returns a six-digit code that is not code.
40func wrongCode(code string) string {
41 if code == "000000" {
42 return "000001"
43 }
44 return "000000"
45}
46
47func TestEmailChallengeLinkPeekDoesNotConsume(t *testing.T) {
48 s := openTestStore(t)
49 now := time.Now()
50 link, _ := newSignInChallenge(t, s, now, testFlow)
51 for i := 0; i < 2; i++ {
52 c, ok := s.PeekEmailLink(link, now)
53 if !ok || c.Address != "bob@example.com" || c.Next != "/auth/cli" || c.Purpose != ChallengeSignIn || c.FlowHash != testFlow {
54 t.Fatalf("peek %d = %+v, %v", i, c, ok)
55 }
56 }
57 if c, ok := s.TakeEmailLink(link, now); !ok || c.Address != "bob@example.com" || c.Next != "/auth/cli" {
58 t.Fatalf("take = %+v, %v", c, ok)
59 }
60 if _, ok := s.TakeEmailLink(link, now); ok {
61 t.Fatal("a link was taken twice")
62 }
63 if _, ok := s.PeekEmailLink(link, now); ok {
64 t.Fatal("a taken link still peeks")
65 }
66}
67
68func TestEmailChallengeLiveByFlow(t *testing.T) {
69 s := openTestStore(t)
70 now := time.Now()
71 _, code := newSignInChallenge(t, s, now, testFlow)
72 for i := 0; i < 2; i++ {
73 c := live(t, s, testFlow, now)
74 if c.Address != "bob@example.com" || c.Next != "/auth/cli" || c.Purpose != ChallengeSignIn {
75 t.Fatalf("live read %d = %+v", i, c)
76 }
77 }
78 for _, tc := range []struct {
79 why, purpose, flow string
80 at time.Time
81 }{
82 {"expired", ChallengeSignIn, testFlow, now.Add(ConfirmationTTL + time.Minute)},
83 {"empty flow", ChallengeSignIn, "", now},
84 {"another flow", ChallengeSignIn, hashToken("other"), now},
85 {"another purpose", ChallengeStepUp, testFlow, now},
86 } {
87 if _, ok := s.LiveEmailChallenge(tc.purpose, tc.flow, tc.at); ok {
88 t.Errorf("%s: a challenge was found", tc.why)
89 }
90 }
91 if _, res := s.TakeEmailCode(live(t, s, testFlow, now), code, now); res != CodeOK {
92 t.Fatalf("reading spent the challenge: %v", res)
93 }
94 if _, ok := s.LiveEmailChallenge(ChallengeSignIn, testFlow, now); ok {
95 t.Fatal("a taken challenge is still live")
96 }
97 // A step-up is found by its session's hash.
98 if _, _, err := s.CreateEmailChallenge(EmailChallenge{
99 Purpose: ChallengeStepUp, Address: "bob@example.com", SessionHash: "sh", PendingHash: "ph",
100 }, now); err != nil {
101 t.Fatal(err)
102 }
103 if c, ok := s.LiveEmailChallenge(ChallengeStepUp, "sh", now); !ok || c.PendingHash != "ph" {
104 t.Fatalf("step-up by session = %+v, %v", c, ok)
105 }
106}
107
108func TestEmailChallengeCode(t *testing.T) {
109 s := openTestStore(t)
110 now := time.Now()
111
112 link, code := newSignInChallenge(t, s, now, testFlow)
113 c := live(t, s, testFlow, now)
114 if got, res := s.TakeEmailCode(c, code, now); res != CodeOK || got == nil || got.Address != "bob@example.com" {
115 t.Fatalf("right code = %+v, %v", got, res)
116 }
117 if _, ok := s.TakeEmailLink(link, now); ok {
118 t.Fatal("the link outlived a successful code")
119 }
120 if _, res := s.TakeEmailCode(c, code, now); res != CodeGone {
121 t.Fatalf("a code was taken twice: %v", res)
122 }
123 if _, res := s.TakeEmailCode(nil, code, now); res != CodeGone {
124 t.Fatalf("no challenge = %v, want CodeGone", res)
125 }
126
127 link, code = newSignInChallenge(t, s, now, testFlow)
128 c = live(t, s, testFlow, now)
129 bad := wrongCode(code)
130 for i := 1; i <= 4; i++ {
131 if _, res := s.TakeEmailCode(c, bad, now); res != CodeWrong {
132 t.Fatalf("wrong code %d = %v, want CodeWrong", i, res)
133 }
134 }
135 if _, res := s.TakeEmailCode(c, bad, now); res != CodeBurned {
136 t.Fatalf("5th wrong code = %v, want CodeBurned", res)
137 }
138 if _, res := s.TakeEmailCode(c, code, now); res != CodeGone {
139 t.Fatalf("right code after burn = %v, want CodeGone", res)
140 }
141 if _, ok := s.TakeEmailLink(link, now); ok {
142 t.Fatal("the link outlived a burned challenge")
143 }
144
145 _, code = newSignInChallenge(t, s, now, testFlow)
146 c = live(t, s, testFlow, now)
147 other := *c
148 other.Purpose = ChallengeStepUp
149 if _, res := s.TakeEmailCode(&other, code, now); res != CodeGone {
150 t.Fatalf("wrong purpose = %v, want CodeGone", res)
151 }
152 if _, res := s.TakeEmailCode(c, code, now); res != CodeGone {
153 t.Fatalf("a wrong-purpose attempt left the challenge standing: %v", res)
154 }
155}
156
157func countChallenges(t *testing.T, s *Store) int {
158 t.Helper()
159 var n int
160 if err := s.db.QueryRow(`SELECT COUNT(*) FROM email_challenges`).Scan(&n); err != nil {
161 t.Fatal(err)
162 }
163 return n
164}
165
166func TestEmailChallengeExpiry(t *testing.T) {
167 s := openTestStore(t)
168 now := time.Now()
169 late := now.Add(ConfirmationTTL + time.Second)
170
171 link, code := newSignInChallenge(t, s, now, testFlow)
172 c := live(t, s, testFlow, now)
173 if _, ok := s.PeekEmailLink(link, late); ok {
174 t.Fatal("an expired link peeks")
175 }
176 if _, ok := s.TakeEmailLink(link, late); ok {
177 t.Fatal("an expired link was taken")
178 }
179 if n := countChallenges(t, s); n != 0 {
180 t.Fatalf("expired link attempt left %d rows", n)
181 }
182 if _, res := s.TakeEmailCode(c, code, late); res != CodeGone {
183 t.Fatalf("code after expiry = %v", res)
184 }
185
186 link, code = newSignInChallenge(t, s, now, testFlow)
187 c = live(t, s, testFlow, now)
188 if _, res := s.TakeEmailCode(c, code, late); res != CodeGone {
189 t.Fatalf("expired code = %v, want CodeGone", res)
190 }
191 if n := countChallenges(t, s); n != 0 {
192 t.Fatalf("expired code attempt left %d rows", n)
193 }
194 if _, ok := s.TakeEmailLink(link, now); ok {
195 t.Fatal("the link survived an expired code attempt")
196 }
197}
198
199func TestEmailChallengeCodeKeyIsProcessLocal(t *testing.T) {
200 path := filepath.Join(t.TempDir(), "console.db")
201 s, err := Open(path)
202 if err != nil {
203 t.Fatal(err)
204 }
205 now := time.Now()
206 link, code := newSignInChallenge(t, s, now, testFlow)
207
208 // The stored MAC is exactly HMAC-SHA256 under this process's key over the
209 // challenge's binding (its link hash) and the code, behind the epoch.
210 var mac string
211 if err := s.db.QueryRow(`SELECT code_mac FROM email_challenges WHERE link_hash = ?`, hashToken(link)).Scan(&mac); err != nil {
212 t.Fatal(err)
213 }
214 macUnder := func(key []byte) string {
215 m := hmac.New(sha256.New, key)
216 m.Write([]byte(hashToken(link) + ":" + code))
217 return s.codeEpoch + ":" + hex.EncodeToString(m.Sum(nil))
218 }
219 if want := macUnder(s.codeKey); mac != want {
220 t.Fatalf("code_mac = %q, want HMAC under the process key %q", mac, want)
221 }
222 otherKey := append([]byte(nil), s.codeKey...)
223 otherKey[0] ^= 0xff
224 if mac == macUnder(otherKey) {
225 t.Fatal("a different key gives the same code_mac: the key does not bind it")
226 }
227 plain := sha256.Sum256([]byte(hashToken(link) + ":" + code))
228 if mac == s.codeEpoch+":"+hex.EncodeToString(plain[:]) {
229 t.Fatal("code_mac is a plain hash of the binding and code, recoverable from a database read")
230 }
231 if err := s.Close(); err != nil {
232 t.Fatal(err)
233 }
234
235 s2, err := Open(path)
236 if err != nil {
237 t.Fatal(err)
238 }
239 defer s2.Close()
240 // The link is 32 random bytes hashed in the database, so it survives.
241 if c, ok := s2.PeekEmailLink(link, now); !ok || c.Address != "bob@example.com" {
242 t.Fatalf("link after restart = %+v, %v", c, ok)
243 }
244 // The code's key died with the first process: the code reads as used up.
245 if _, res := s2.TakeEmailCode(live(t, s2, testFlow, now), code, now); res != CodeGone {
246 t.Fatalf("code after restart = %v, want CodeGone (used up)", res)
247 }
248 if _, ok := s2.TakeEmailLink(link, now); !ok {
249 t.Fatal("the link did not survive a restart")
250 }
251}
252
253// A new challenge replaces the flow's earlier one, link and code alike, and
254// leaves other flows alone.
255func TestEmailChallengeReplace(t *testing.T) {
256 s := openTestStore(t)
257 now := time.Now()
258 linkA, codeA := newSignInChallenge(t, s, now, testFlow)
259 cA := live(t, s, testFlow, now)
260 otherFlow := hashToken("another-browser")
261 linkOther, _ := newSignInChallenge(t, s, now, otherFlow)
262 linkB, _ := newSignInChallenge(t, s, now, testFlow)
263 if _, ok := s.PeekEmailLink(linkA, now); ok {
264 t.Fatal("the replaced link still works")
265 }
266 if _, res := s.TakeEmailCode(cA, codeA, now); res != CodeGone {
267 t.Fatalf("the replaced code = %v, want CodeGone", res)
268 }
269 if n := countChallenges(t, s); n != 2 {
270 t.Fatalf("%d challenges after a replace, want 2 (this flow's newest and the other flow's)", n)
271 }
272 if _, ok := s.PeekEmailLink(linkOther, now); !ok {
273 t.Fatal("a replace in one flow ended another flow's challenge")
274 }
275 // B's own link signs in, once.
276 if c, ok := s.TakeEmailLink(linkB, now); !ok || c.Address != "bob@example.com" {
277 t.Fatalf("the new link = %+v, %v", c, ok)
278 }
279 if _, ok := s.LiveEmailChallenge(ChallengeSignIn, testFlow, now); ok {
280 t.Fatal("B's challenge is still live after its link was used")
281 }
282}
283
284// A refused request's challenge is recorded only when the flow has no live
285// one, and never displaces the email already sent.
286func TestEmailChallengeHold(t *testing.T) {
287 s := openTestStore(t)
288 now := time.Now()
289 link, code := newSignInChallenge(t, s, now, testFlow)
290 held := EmailChallenge{Purpose: ChallengeSignIn, Address: "carol@example.com", FlowHash: testFlow}
291 if err := s.HoldEmailChallenge(held, now); err != nil {
292 t.Fatal(err)
293 }
294 if n := countChallenges(t, s); n != 1 {
295 t.Fatalf("%d challenges after a hold beside a live one, want 1", n)
296 }
297 if c := live(t, s, testFlow, now); c.Address != "bob@example.com" {
298 t.Fatalf("the hold displaced the sent challenge: %+v", c)
299 }
300 if _, ok := s.PeekEmailLink(link, now); !ok {
301 t.Fatal("the hold ended the sent link")
302 }
303 if _, res := s.TakeEmailCode(live(t, s, testFlow, now), code, now); res != CodeOK {
304 t.Fatalf("the sent code after a hold = %v", res)
305 }
306 // A flow with none gets an unsent challenge for the refused address.
307 if err := s.HoldEmailChallenge(held, now); err != nil {
308 t.Fatal(err)
309 }
310 c := live(t, s, testFlow, now)
311 if c.Address != "carol@example.com" {
312 t.Fatalf("held challenge = %+v", c)
313 }
314 if _, res := s.TakeEmailCode(c, "000000", now); res == CodeGone {
315 t.Fatal("a code against the held challenge found nothing, unlike a sent one")
316 }
317 // An expired one counts as none.
318 if err := s.HoldEmailChallenge(held, now.Add(ConfirmationTTL+time.Second)); err != nil {
319 t.Fatal(err)
320 }
321 if n := countChallenges(t, s); n != 1 {
322 t.Fatalf("%d challenges after holding past an expired one, want 1", n)
323 }
324}
325
326// A successful link or code ends every other challenge of the same flow.
327func TestEmailChallengeTakeEndsFlow(t *testing.T) {
328 s := openTestStore(t)
329 now := time.Now()
330 for _, by := range []string{"link", "code"} {
331 t.Run(by, func(t *testing.T) {
332 link, code := newSignInChallenge(t, s, now, testFlow)
333 // A second row in the same flow, as an older build might leave.
334 if _, err := s.db.Exec(`INSERT INTO email_challenges (id_hash, link_hash, code_mac, purpose, address, flow_hash, created_at, expires_at)
335VALUES (?, ?, 'x', ?, 'old@example.com', ?, ?, ?)`, hashToken(by+"-row"), hashToken(by+"-link"), ChallengeSignIn, testFlow, now.Unix()-1, now.Add(time.Minute).Unix()); err != nil {
336 t.Fatal(err)
337 }
338 if by == "link" {
339 if _, ok := s.TakeEmailLink(link, now); !ok {
340 t.Fatal("link refused")
341 }
342 } else if _, res := s.TakeEmailCode(live(t, s, testFlow, now), code, now); res != CodeOK {
343 t.Fatalf("code = %v", res)
344 }
345 if n := countChallenges(t, s); n != 0 {
346 t.Fatalf("%d of the flow's challenges outlived a sign-in", n)
347 }
348 })
349 }
350}
351
352func TestEmailChallengePurgedWithExpired(t *testing.T) {
353 s := openTestStore(t)
354 now := time.Now()
355 newSignInChallenge(t, s, now, testFlow)
356 if err := s.PurgeExpired(now); err != nil {
357 t.Fatal(err)
358 }
359 if n := countChallenges(t, s); n != 1 {
360 t.Fatalf("PurgeExpired removed a live challenge (%d left)", n)
361 }
362 if err := s.PurgeExpired(now.Add(ConfirmationTTL + time.Second)); err != nil {
363 t.Fatal(err)
364 }
365 if n := countChallenges(t, s); n != 0 {
366 t.Fatalf("PurgeExpired left %d expired challenges", n)
367 }
368}
369
370func TestSessionMethod(t *testing.T) {
371 s := openTestStore(t)
372 now := time.Now()
373
374 raw, err := s.CreateSession("bob@example.com", "tok", now)
375 if err != nil {
376 t.Fatal(err)
377 }
378 if a, m, ok := s.SessionInfo(raw, now); !ok || a != "bob@example.com" || m != MethodKeymail {
379 t.Fatalf("CreateSession info = %q %q %v", a, m, ok)
380 }
381 raw, err = s.CreateSessionMethod("bob@example.com", "", MethodEmail, now)
382 if err != nil {
383 t.Fatal(err)
384 }
385 if a, m, ok := s.SessionInfo(raw, now); !ok || a != "bob@example.com" || m != MethodEmail {
386 t.Fatalf("email info = %q %q %v", a, m, ok)
387 }
388 if _, _, ok := s.SessionInfo(raw, now.Add(SessionTTL+time.Second)); ok {
389 t.Fatal("an expired session resolved")
390 }
391 if _, _, ok := s.SessionInfo("nope", now); ok {
392 t.Fatal("an unknown session resolved")
393 }
394}
395
396func TestSessionMethodPreMigrationRowReadsKeymail(t *testing.T) {
397 path := filepath.Join(t.TempDir(), "console.db")
398 old, err := sql.Open("sqlite", path)
399 if err != nil {
400 t.Fatal(err)
401 }
402 now := time.Now()
403 raw := "an-old-session-token"
404 for _, q := range []string{
405 `CREATE TABLE sessions (
406 token_hash TEXT PRIMARY KEY,
407 address TEXT NOT NULL,
408 keymail_token TEXT NOT NULL DEFAULT '',
409 created_at INTEGER NOT NULL,
410 expires_at INTEGER NOT NULL
411)`,
412 } {
413 if _, err := old.Exec(q); err != nil {
414 t.Fatal(err)
415 }
416 }
417 if _, err := old.Exec(`INSERT INTO sessions (token_hash, address, keymail_token, created_at, expires_at) VALUES (?, ?, '', ?, ?)`,
418 hashToken(raw), "old@example.com", now.Unix(), now.Add(time.Hour).Unix()); err != nil {
419 t.Fatal(err)
420 }
421 if err := old.Close(); err != nil {
422 t.Fatal(err)
423 }
424
425 s, err := Open(path)
426 if err != nil {
427 t.Fatal(err)
428 }
429 defer s.Close()
430 if a, m, ok := s.SessionInfo(raw, now); !ok || a != "old@example.com" || m != MethodKeymail {
431 t.Fatalf("pre-migration session = %q %q %v, want keymail", a, m, ok)
432 }
433}
434