1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "bytes"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "slices"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "testing"
18 "time"
19
20 "github.com/bnema/zerowrap"
21 "github.com/stretchr/testify/assert"
22 "github.com/stretchr/testify/mock"
23 "github.com/stretchr/testify/require"
24
25 "bnema.dev/ermite/internal/domain"
26 "bnema.dev/ermite/internal/ports/mocks"
27 "bnema.dev/ermite/internal/usecase"
28)
29
30var testHandle = []byte("0123456789abcdef0123456789abcdef")
31
32type passkeyFixture struct {
33 *sessionFixture
34 store *mocks.PasskeyStore
35 verifier *mocks.PasskeyVerifier
36 passkeys *usecase.Passkeys
37
38 mu sync.Mutex
39 stored map[string]domain.Passkey // by credential id
40 ceremon atomic.Int64 // makes the ceremony data of each begin unique
41}
42
43func newPasskeyFixture(t *testing.T) *passkeyFixture {
44 t.Helper()
45 f := &passkeyFixture{
46 sessionFixture: newSessionFixture(t),
47 store: mocks.NewPasskeyStore(t),
48 verifier: mocks.NewPasskeyVerifier(t),
49 stored: map[string]domain.Passkey{},
50 }
51 f.store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil).Maybe()
52 f.store.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Passkey, error) {
53 f.mu.Lock()
54 defer f.mu.Unlock()
55 var out []domain.Passkey
56 for _, p := range f.stored {
57 out = append(out, p)
58 }
59 slices.SortFunc(out, func(a, b domain.Passkey) int { return bytes.Compare(a.ID, b.ID) })
60 return out, nil
61 }).Maybe()
62 f.store.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) (domain.Passkey, error) {
63 f.mu.Lock()
64 defer f.mu.Unlock()
65 if p, ok := f.stored[string(id)]; ok {
66 return p, nil
67 }
68 return domain.Passkey{}, domain.ErrNotFound
69 }).Maybe()
70 f.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
71 f.mu.Lock()
72 defer f.mu.Unlock()
73 if _, ok := f.stored[string(p.ID)]; ok {
74 return domain.ErrAlreadyExists
75 }
76 f.stored[string(p.ID)] = p
77 return nil
78 }).Maybe()
79 f.store.EXPECT().Update(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
80 f.mu.Lock()
81 defer f.mu.Unlock()
82 if _, ok := f.stored[string(p.ID)]; !ok {
83 return domain.ErrNotFound
84 }
85 f.stored[string(p.ID)] = p
86 return nil
87 }).Maybe()
88 f.store.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) error {
89 f.mu.Lock()
90 defer f.mu.Unlock()
91 if _, ok := f.stored[string(id)]; !ok {
92 return domain.ErrNotFound
93 }
94 delete(f.stored, string(id))
95 return nil
96 }).Maybe()
97
98 f.verifier.EXPECT().BeginRegistration(mock.Anything, mock.Anything).RunAndReturn(func([]byte, []domain.Passkey) ([]byte, []byte, error) {
99 return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("register-%d", f.ceremon.Add(1))), nil
100 }).Maybe()
101 f.verifier.EXPECT().BeginLogin().RunAndReturn(func() ([]byte, []byte, error) {
102 return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("login-%d", f.ceremon.Add(1))), nil
103 }).Maybe()
104
105 f.passkeys = usecase.NewPasskeys(f.store, f.verifier, f.sessions, f.clock)
106 return f
107}
108
109// add puts a passkey in the store directly.
110func (f *passkeyFixture) add(id string) domain.Passkey {
111 p := domain.Passkey{ID: []byte(id), Name: id, PublicKey: []byte("k"), CreatedAt: *f.at.Load()}
112 f.mu.Lock()
113 f.stored[id] = p
114 f.mu.Unlock()
115 return p
116}
117
118func (f *passkeyFixture) count() int {
119 f.mu.Lock()
120 defer f.mu.Unlock()
121 return len(f.stored)
122}
123
124// acceptRegistration makes the verifier accept any registration as the passkey.
125func (f *passkeyFixture) acceptRegistration(id string) {
126 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(func(_, ceremony, _ []byte) (domain.Passkey, error) {
127 if !bytes.HasPrefix(ceremony, []byte("register-")) {
128 return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
129 }
130 return domain.Passkey{ID: []byte(id), PublicKey: []byte("pub"), AttestationType: "none", Transports: []string{"internal"}, BackupEligible: true}, nil
131 }).Maybe()
132}
133
134// acceptLogin makes the verifier accept a login with the stored passkey `id`.
135// first is the counter of the first sign-in; like a real authenticator, each
136// later one reports a higher counter. 0 means a synced passkey: always 0.
137func (f *passkeyFixture) acceptLogin(id string, first uint32) {
138 var next atomic.Uint32
139 next.Store(first)
140 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
141 func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
142 if !bytes.HasPrefix(ceremony, []byte("login-")) {
143 return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
144 }
145 p, err := lookup([]byte(id))
146 if err != nil {
147 return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)
148 }
149 if first != 0 {
150 p.SignCount = next.Add(1) - 1
151 }
152 p.BackupState = true
153 return p, nil
154 }).Maybe()
155}
156
157func ownerAndOthers() map[string]struct {
158 p domain.Principal
159 err error
160} {
161 partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
162 return map[string]struct {
163 p domain.Principal
164 err error
165 }{
166 "root": {domain.Root(), nil},
167 "owner session": {domain.Owner(), nil},
168 "every scope": {allScopeToken(), nil},
169 "scoped token": {partial, domain.ErrForbidden},
170 "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated},
171 }
172}
173
174func TestPasskeysAreOwnerOnly(t *testing.T) {
175 t.Parallel()
176 ctx := context.Background()
177 for name, tc := range ownerAndOthers() {
178 t.Run(name, func(t *testing.T) {
179 t.Parallel()
180 f := newPasskeyFixture(t)
181 f.add("a")
182 f.acceptRegistration("new")
183 check := func(what string, err error) {
184 t.Helper()
185 if tc.err == nil {
186 assert.NoError(t, err, what)
187 } else {
188 assert.ErrorIs(t, err, tc.err, what)
189 }
190 }
191 _, _, err := f.passkeys.BeginRegistration(ctx, tc.p, "")
192 check("begin registration", err)
193 _, err = f.passkeys.List(ctx, tc.p)
194 check("list", err)
195
196 // A ceremony begun by the owner cannot be finished by anyone else.
197 id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
198 require.NoError(t, err)
199 err = f.passkeys.FinishRegistration(ctx, tc.p, id, []byte("{}"))
200 check("finish registration", err)
201 if tc.err != nil {
202 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte("{}"))
203 assert.NoError(t, err, "a refused caller does not burn the ceremony")
204 }
205
206 err = f.passkeys.Remove(ctx, tc.p, []byte("a"))
207 check("remove", err)
208 if tc.err != nil {
209 assert.Equal(t, 2, f.count(), "nothing removed")
210 }
211 })
212 }
213}
214
215func TestBeginLoginIsAnonymous(t *testing.T) {
216 t.Parallel()
217 f := newPasskeyFixture(t)
218 id, options, err := f.passkeys.BeginLogin(context.Background(), "192.0.2.1")
219 require.NoError(t, err)
220 assert.Len(t, id, 43, "256 bits")
221 assert.JSONEq(t, `{"publicKey":{}}`, string(options))
222}
223
224func TestRegistration(t *testing.T) {
225 t.Parallel()
226 ctx := context.Background()
227 f := newPasskeyFixture(t)
228 f.acceptRegistration("cred-1")
229
230 id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "Pixel 9")
231 require.NoError(t, err)
232 assert.JSONEq(t, `{"publicKey":{}}`, string(options))
233
234 require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`)))
235 list, err := f.passkeys.List(ctx, domain.Owner())
236 require.NoError(t, err)
237 require.Len(t, list, 1)
238 got := list[0]
239 assert.Equal(t, "Pixel 9", got.Name, "the name given at begin")
240 assert.Equal(t, *f.at.Load(), got.CreatedAt)
241 assert.True(t, got.LastUsedAt.IsZero())
242 assert.Equal(t, []byte("cred-1"), got.ID)
243
244 // The ceremony is single use.
245 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))
246 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
247}
248
249// An empty name is the default one, and each ceremony keeps the name it began
250// with, whatever else is pending.
251func TestRegistrationNames(t *testing.T) {
252 t.Parallel()
253 ctx := context.Background()
254 f := newPasskeyFixture(t)
255 // The credential id is the ceremony data, so that two ceremonies finish as
256 // two different passkeys.
257 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(
258 func(_, ceremony, _ []byte) (domain.Passkey, error) {
259 return domain.Passkey{ID: bytes.Clone(ceremony), PublicKey: []byte("pub")}, nil
260 })
261 unnamed, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
262 require.NoError(t, err)
263 named, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "Second")
264 require.NoError(t, err)
265 // Finished in the other order.
266 require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), named, []byte(`{}`)))
267 require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), unnamed, []byte(`{}`)))
268
269 names := map[string]string{}
270 list, err := f.passkeys.List(ctx, domain.Owner())
271 require.NoError(t, err)
272 for _, p := range list {
273 names[string(p.ID)] = p.Name
274 }
275 assert.ElementsMatch(t, []string{"Second", domain.DefaultPasskeyName}, []string{names["register-2"], names["register-1"]})
276 assert.Equal(t, "Second", names["register-2"])
277 assert.Equal(t, domain.DefaultPasskeyName, names["register-1"])
278}
279
280// What FinishRegistration refuses, and what is left in the store each time.
281func TestRegistrationRefusals(t *testing.T) {
282 t.Parallel()
283 tests := []struct {
284 name string
285 existing []string // passkeys already stored
286 verifier func(f *passkeyFixture)
287 wantErr error
288 wantKept int // passkeys in the store afterwards
289 }{
290 {
291 name: "duplicate credential",
292 existing: []string{"cred-1"},
293 verifier: func(f *passkeyFixture) { f.acceptRegistration("cred-1") },
294 wantErr: domain.ErrAlreadyExists, wantKept: 1,
295 },
296 {
297 name: "refused by the verifier",
298 verifier: func(f *passkeyFixture) {
299 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
300 Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))
301 },
302 wantErr: domain.ErrUnauthenticated, wantKept: 0,
303 },
304 {
305 name: "credential id too long",
306 verifier: func(f *passkeyFixture) {
307 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
308 Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)
309 },
310 wantErr: domain.ErrUnauthenticated, wantKept: 0,
311 },
312 {
313 name: "empty credential id",
314 verifier: func(f *passkeyFixture) {
315 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
316 Return(domain.Passkey{PublicKey: []byte("k")}, nil)
317 },
318 wantErr: domain.ErrUnauthenticated, wantKept: 0,
319 },
320 }
321 for _, tc := range tests {
322 t.Run(tc.name, func(t *testing.T) {
323 t.Parallel()
324 ctx := context.Background()
325 f := newPasskeyFixture(t)
326 for _, id := range tc.existing {
327 f.add(id)
328 }
329 tc.verifier(f)
330 id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "x")
331 require.NoError(t, err)
332 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))
333 assert.ErrorIs(t, err, tc.wantErr)
334 assert.Equal(t, tc.wantKept, f.count())
335 })
336 }
337}
338
339// The verifier is told about the passkeys to exclude.
340func TestBeginRegistrationExcludesExisting(t *testing.T) {
341 t.Parallel()
342 f := newPasskeyFixture(t)
343 f.add("a")
344 f.add("b")
345 f.verifier.ExpectedCalls = nil // replace the permissive default
346 f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {
347 return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"
348 })).Return([]byte(`{}`), []byte("register-x"), nil).Once()
349 _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner(), "")
350 require.NoError(t, err)
351}
352
353func TestMaxPasskeys(t *testing.T) {
354 t.Parallel()
355 ctx := context.Background()
356 f := newPasskeyFixture(t)
357 f.acceptRegistration("one-more")
358 for i := range domain.MaxPasskeys - 1 {
359 f.add(fmt.Sprintf("p%02d", i))
360 }
361 // A ceremony begun with room for one more.
362 id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
363 require.NoError(t, err)
364 f.add("last")
365 assert.Equal(t, domain.MaxPasskeys, f.count())
366
367 // Full: no new ceremony, and the one in flight is refused at the end.
368 _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
369 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
370 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))
371 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
372 assert.Equal(t, domain.MaxPasskeys, f.count())
373
374 // Removing one makes room again.
375 require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))
376 _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
377 assert.NoError(t, err)
378}
379
380func TestCeremonyExpiry(t *testing.T) {
381 t.Parallel()
382 ctx := context.Background()
383 f := newPasskeyFixture(t)
384 f.acceptRegistration("cred-1")
385 f.add("login-cred")
386 f.acceptLogin("login-cred", 1)
387
388 // One second short of the TTL still works.
389 reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
390 require.NoError(t, err)
391 login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
392 require.NoError(t, err)
393 f.advance(domain.PasskeyCeremonyTTL - time.Second)
394 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, []byte(`{}`))
395 assert.NoError(t, err)
396 _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
397 assert.NoError(t, err)
398
399 // At the TTL it is gone, and it stays gone.
400 reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
401 require.NoError(t, err)
402 login, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
403 require.NoError(t, err)
404 f.advance(domain.PasskeyCeremonyTTL)
405 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, []byte(`{}`))
406 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
407 _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
408 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
409}
410
411// Expired ceremonies are swept on begin, so they stop counting.
412func TestExpiredRegistrationsDoNotCount(t *testing.T) {
413 t.Parallel()
414 ctx := context.Background()
415 f := newPasskeyFixture(t)
416 for range usecase.MaxPendingPasskeyRegistrations {
417 _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
418 require.NoError(t, err)
419 }
420 _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
421 require.ErrorIs(t, err, domain.ErrResourceExhausted)
422 f.advance(domain.PasskeyCeremonyTTL)
423 _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
424 assert.NoError(t, err)
425}
426
427func TestPendingRegistrationsAreBounded(t *testing.T) {
428 t.Parallel()
429 ctx := context.Background()
430 f := newPasskeyFixture(t)
431 f.acceptRegistration("cred-1")
432 var ids []string
433 for range usecase.MaxPendingPasskeyRegistrations {
434 id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
435 require.NoError(t, err)
436 ids = append(ids, id)
437 }
438 assert.Len(t, ids, 16)
439 _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
440 require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")
441
442 // Pending logins do not use the registrations' room, and a finished
443 // registration frees one.
444 _, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
445 require.NoError(t, err)
446 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], []byte(`{}`))
447 require.NoError(t, err)
448 _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
449 assert.NoError(t, err)
450}
451
452// loginIDs begins n logins for the client, a millisecond apart so that the order
453// of creation is the order of time too.
454func (f *passkeyFixture) loginIDs(t *testing.T, client string, n int) []string {
455 t.Helper()
456 var ids []string
457 for range n {
458 id, _, err := f.passkeys.BeginLogin(context.Background(), client)
459 require.NoError(t, err, "a login is never refused")
460 ids = append(ids, id)
461 f.advance(time.Millisecond)
462 }
463 return ids
464}
465
466// finishes tells whether the ceremony still opens a session.
467func (f *passkeyFixture) finishes(id string) bool {
468 _, _, err := f.passkeys.FinishLogin(context.Background(), id, []byte(`{}`))
469 return err == nil
470}
471
472// A client keeps at most 8 logins waiting: the 9th costs it its own oldest.
473func TestPendingLoginsPerClientCap(t *testing.T) {
474 t.Parallel()
475 f := newPasskeyFixture(t)
476 f.add("cred-1")
477 f.acceptLogin("cred-1", 1)
478
479 other := f.loginIDs(t, "198.51.100.7", 1)[0]
480 ids := f.loginIDs(t, "192.0.2.1", usecase.MaxPendingPasskeyLoginsPerClient)
481 assert.Equal(t, 8, usecase.MaxPendingPasskeyLoginsPerClient)
482 newest := f.loginIDs(t, "192.0.2.1", 1)[0]
483
484 assert.False(t, f.finishes(ids[0]), "the 9th evicted this client's oldest")
485 assert.True(t, f.finishes(ids[1]), "the next oldest is still there")
486 assert.True(t, f.finishes(newest), "the newest works")
487 assert.True(t, f.finishes(other), "another client's ceremony is untouched")
488}
489
490// One address flooding can never evict another address's ceremony.
491func TestLoginFloodDoesNotEvictAnotherClient(t *testing.T) {
492 t.Parallel()
493 f := newPasskeyFixture(t)
494 f.add("cred-1")
495 f.acceptLogin("cred-1", 1)
496
497 owner := f.loginIDs(t, "198.51.100.7", 1)[0]
498 f.loginIDs(t, "192.0.2.1", 2*usecase.MaxPendingPasskeyLogins)
499 assert.True(t, f.finishes(owner), "the owner's ceremony from another address still works")
500}
501
502// Over the global cap, the client with the most waiting loses its oldest; a
503// client with one ceremony is never the victim.
504func TestPendingLoginsGlobalOverflow(t *testing.T) {
505 t.Parallel()
506 f := newPasskeyFixture(t)
507 f.add("cred-1")
508 f.acceptLogin("cred-1", 1)
509
510 owner := f.loginIDs(t, "198.51.100.7", 1)[0]
511 perClient := usecase.MaxPendingPasskeyLoginsPerClient
512 var first, second []string
513 n := 0
514 for total := 1; total+perClient <= usecase.MaxPendingPasskeyLogins; total += perClient {
515 ids := f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)
516 if n == 0 {
517 first = ids
518 }
519 if n == 1 {
520 second = ids
521 }
522 n++
523 }
524 // 1 + 31*8 = 249 waiting; top up to the cap with a client below the cap.
525 small := f.loginIDs(t, "10.1.0.1", usecase.MaxPendingPasskeyLogins-1-n*perClient)
526 require.Len(t, small, 7)
527
528 // The 257th comes from a client that has none: nobody is refused, the
529 // oldest of a client with 8 waiting goes (the one that waited longest).
530 fresh := f.loginIDs(t, "203.0.113.9", 1)[0]
531 assert.False(t, f.finishes(first[0]), "the oldest of the fullest, oldest client was evicted")
532 assert.True(t, f.finishes(first[1]))
533 assert.True(t, f.finishes(second[0]), "other full clients keep theirs")
534 assert.True(t, f.finishes(owner), "a client with one ceremony is never the victim")
535 assert.True(t, f.finishes(small[0]), "nor one below the others")
536 assert.True(t, f.finishes(fresh), "the new login works")
537}
538
539// Logins never push out a registration, and registrations never push out a login.
540func TestCeremonyKindsAreSeparate(t *testing.T) {
541 t.Parallel()
542 ctx := context.Background()
543 f := newPasskeyFixture(t)
544 f.add("cred-1")
545 f.acceptLogin("cred-1", 1)
546 f.acceptRegistration("cred-2")
547
548 reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
549 require.NoError(t, err)
550 login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
551 require.NoError(t, err)
552
553 // A registration ceremony does not finish a login, nor the reverse; the
554 // attempt burns it.
555 _, _, err = f.passkeys.FinishLogin(ctx, reg, []byte(`{}`))
556 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
557 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), login, []byte(`{}`))
558 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
559 _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
560 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "burned by the mismatch")
561 assert.Equal(t, 1, f.count(), "no passkey was added")
562}
563
564func TestUnknownCeremony(t *testing.T) {
565 t.Parallel()
566 ctx := context.Background()
567 f := newPasskeyFixture(t)
568 _, _, err := f.passkeys.FinishLogin(ctx, "", []byte(`{}`))
569 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
570 _, _, err = f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`))
571 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
572 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "nope", []byte(`{}`))
573 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
574}
575
576// A passkey login gives a session that Verify accepts, and records the use.
577func TestLoginIssuesASession(t *testing.T) {
578 t.Parallel()
579 ctx := context.Background()
580 f := newPasskeyFixture(t)
581 f.add("cred-1")
582 f.acceptLogin("cred-1", 9)
583
584 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
585 require.NoError(t, err)
586 f.advance(10 * time.Second)
587 token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
588 require.NoError(t, err)
589 assert.Equal(t, f.at.Load().Add(usecase.SessionTTL).Truncate(time.Second), exp)
590
591 p, err := f.sessions.Verify(ctx, token)
592 require.NoError(t, err)
593 assert.Equal(t, "owner", p.Label())
594 assert.True(t, p.HasOwnerRights())
595
596 list, err := f.passkeys.List(ctx, domain.Owner())
597 require.NoError(t, err)
598 require.Len(t, list, 1)
599 assert.Equal(t, uint32(9), list[0].SignCount)
600 assert.True(t, list[0].BackupState)
601 assert.Equal(t, *f.at.Load(), list[0].LastUsedAt)
602
603 // The ceremony is single use.
604 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
605 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
606}
607
608// logCtx is a context whose logger writes JSON lines into the buffer.
609func logCtx(t *testing.T) (context.Context, *syncLog) {
610 t.Helper()
611 buf := &syncLog{}
612 log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: buf})
613 return zerowrap.WithCtx(context.Background(), log), buf
614}
615
616type syncLog struct {
617 mu sync.Mutex
618 b bytes.Buffer
619}
620
621func (l *syncLog) Write(p []byte) (int, error) {
622 l.mu.Lock()
623 defer l.mu.Unlock()
624 return l.b.Write(p)
625}
626
627func (l *syncLog) String() string {
628 l.mu.Lock()
629 defer l.mu.Unlock()
630 return l.b.String()
631}
632
633// logEntries parses the JSON lines of the buffer.
634func logEntries(t *testing.T, logs *syncLog) []map[string]any {
635 t.Helper()
636 var out []map[string]any
637 for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
638 if line == "" {
639 continue
640 }
641 var m map[string]any
642 require.NoError(t, json.Unmarshal([]byte(line), &m), line)
643 out = append(out, m)
644 }
645 return out
646}
647
648// The reason of a refusal is logged at info, never answered, and holds no
649// secret: neither the response bytes nor a key.
650func TestRefusalsAreLogged(t *testing.T) {
651 t.Parallel()
652
653 t.Run("sign-in", func(t *testing.T) {
654 t.Parallel()
655 ctx, logs := logCtx(t)
656 f := newPasskeyFixture(t)
657 f.add("cred-1")
658 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
659 Return(domain.Passkey{}, fmt.Errorf("%w: passkey login: origin mismatch", domain.ErrUnauthenticated))
660 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
661 require.NoError(t, err)
662 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{"secret":"response-bytes"}`))
663 require.ErrorIs(t, err, domain.ErrUnauthenticated)
664 assert.NotContains(t, err.Error(), "origin mismatch", "the caller learns nothing")
665 entries := logEntries(t, logs)
666 require.Len(t, entries, 1)
667 assert.Equal(t, "info", entries[0]["level"])
668 assert.Contains(t, entries[0]["error"], "origin mismatch")
669 assert.NotContains(t, logs.String(), "response-bytes")
670 })
671
672 t.Run("sign-in with an unknown ceremony", func(t *testing.T) {
673 t.Parallel()
674 ctx, logs := logCtx(t)
675 f := newPasskeyFixture(t)
676 _, _, err := f.passkeys.FinishLogin(ctx, "nope", []byte(`{"secret":"response-bytes"}`))
677 require.ErrorIs(t, err, domain.ErrUnauthenticated)
678 entries := logEntries(t, logs)
679 require.Len(t, entries, 1)
680 assert.Equal(t, "info", entries[0]["level"])
681 assert.NotContains(t, logs.String(), "response-bytes")
682 })
683
684 t.Run("registration", func(t *testing.T) {
685 t.Parallel()
686 ctx, logs := logCtx(t)
687 f := newPasskeyFixture(t)
688 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
689 Return(domain.Passkey{}, fmt.Errorf("%w: passkey registration: user verification missing", domain.ErrUnauthenticated))
690 id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "x")
691 require.NoError(t, err)
692 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{"secret":"response-bytes"}`))
693 require.ErrorIs(t, err, domain.ErrUnauthenticated)
694 entries := logEntries(t, logs)
695 require.Len(t, entries, 1)
696 assert.Equal(t, "info", entries[0]["level"])
697 assert.Contains(t, entries[0]["error"], "user verification missing")
698 assert.NotContains(t, logs.String(), "response-bytes")
699
700 // An unknown ceremony is logged too.
701 err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "unknown", []byte(`{}`))
702 require.ErrorIs(t, err, domain.ErrUnauthenticated)
703 entries = logEntries(t, logs)
704 require.Len(t, entries, 2)
705 assert.Equal(t, "info", entries[1]["level"])
706 })
707}
708
709// A counter that does not advance on a passkey that has one is refused, and the
710// warning names the passkey by its short reference. The library hands back the
711// stored counter when the received one is not higher, which is what the verifier
712// returns here.
713func TestCloneIsRefusedAndNamed(t *testing.T) {
714 t.Parallel()
715 ctx, logs := logCtx(t)
716 f := newPasskeyFixture(t)
717 p := f.add("cred-1")
718 p.SignCount = 5
719 f.mu.Lock()
720 f.stored["cred-1"] = p
721 f.mu.Unlock()
722 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
723 func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
724 return lookup([]byte("cred-1")) // SignCount stays 5
725 })
726 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
727 require.NoError(t, err)
728 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
729 require.ErrorIs(t, err, domain.ErrUnauthenticated)
730 assert.Empty(t, token, "no session")
731 assert.Equal(t, 1, f.count())
732 f.mu.Lock()
733 assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded")
734 f.mu.Unlock()
735
736 sum := sha256.Sum256([]byte("cred-1"))
737 ref := hex.EncodeToString(sum[:])[:8]
738 entries := logEntries(t, logs)
739 require.Len(t, entries, 1, "one log line for the refusal")
740 assert.Equal(t, "warn", entries[0]["level"])
741 assert.Equal(t, ref, entries[0]["passkey"])
742 assert.NotContains(t, logs.String(), "cred-1", "the credential id itself is never logged")
743}
744
745// Two logins verified against the same stored counter cannot both
746// succeed. The verifier saw counter 5; by the time the second one writes, the
747// first has stored 6.
748func TestLoginRefusesACounterThatDidNotAdvance(t *testing.T) {
749 t.Parallel()
750 ctx := context.Background()
751
752 t.Run("another login moved the counter meanwhile", func(t *testing.T) {
753 t.Parallel()
754 f := newPasskeyFixture(t)
755 stored := f.add("cred-1")
756 stored.SignCount = 5
757 f.mu.Lock()
758 f.stored["cred-1"] = stored
759 f.mu.Unlock()
760 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
761 func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
762 p, err := lookup([]byte("cred-1"))
763 require.NoError(t, err)
764 p.SignCount = 6 // what the response reports, against the 5 it was shown
765 // A concurrent login stores 6 before this one gets the lock.
766 f.mu.Lock()
767 f.stored["cred-1"] = p
768 f.mu.Unlock()
769 return p, nil
770 })
771 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
772 require.NoError(t, err)
773 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
774 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
775 assert.Empty(t, token, "no session")
776 f.mu.Lock()
777 assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded")
778 f.mu.Unlock()
779 })
780
781 t.Run("a counter that advances is accepted", func(t *testing.T) {
782 t.Parallel()
783 f := newPasskeyFixture(t)
784 p := f.add("cred-1")
785 p.SignCount = 5
786 f.mu.Lock()
787 f.stored["cred-1"] = p
788 f.mu.Unlock()
789 f.acceptLogin("cred-1", 6)
790 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
791 require.NoError(t, err)
792 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
793 assert.NoError(t, err)
794 })
795
796 t.Run("a synced passkey reports 0 and is exempt", func(t *testing.T) {
797 t.Parallel()
798 f := newPasskeyFixture(t)
799 f.add("cred-1") // stored counter 0
800 f.acceptLogin("cred-1", 0)
801 for range 2 {
802 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
803 require.NoError(t, err)
804 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
805 assert.NoError(t, err)
806 }
807 })
808
809}
810
811func TestLoginRefusals(t *testing.T) {
812 t.Parallel()
813 ctx := context.Background()
814
815 t.Run("unknown passkey", func(t *testing.T) {
816 t.Parallel()
817 f := newPasskeyFixture(t)
818 f.acceptLogin("ghost", 1)
819 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
820 require.NoError(t, err)
821 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
822 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
823 })
824
825 t.Run("verifier refuses", func(t *testing.T) {
826 t.Parallel()
827 f := newPasskeyFixture(t)
828 f.add("cred-1")
829 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
830 Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))
831 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
832 require.NoError(t, err)
833 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
834 require.ErrorIs(t, err, domain.ErrUnauthenticated)
835 assert.NotContains(t, err.Error(), "bad signature", "the reason is for logs, not for the caller")
836 })
837
838 t.Run("store failure fails closed and is not an authentication error", func(t *testing.T) {
839 t.Parallel()
840 f := newPasskeyFixture(t)
841 boom := errors.New("disk on fire")
842 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
843 Return(domain.Passkey{}, boom)
844 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
845 require.NoError(t, err)
846 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
847 require.ErrorIs(t, err, boom)
848 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
849 assert.Empty(t, token)
850 })
851
852 t.Run("update failure gives no session", func(t *testing.T) {
853 t.Parallel()
854 f := newPasskeyFixture(t)
855 store := mocks.NewPasskeyStore(t)
856 boom := errors.New("disk on fire")
857 store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)
858 store.EXPECT().Get(mock.Anything, []byte("cred-1")).Return(domain.Passkey{ID: []byte("cred-1")}, nil)
859 store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)
860 verifier := mocks.NewPasskeyVerifier(t)
861 verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)
862 verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
863 Return(domain.Passkey{ID: []byte("cred-1")}, nil)
864 pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)
865 id, _, err := pk.BeginLogin(ctx, "192.0.2.1")
866 require.NoError(t, err)
867 token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
868 require.ErrorIs(t, err, boom)
869 assert.Empty(t, token)
870 })
871
872 t.Run("removed while verifying", func(t *testing.T) {
873 t.Parallel()
874 f := newPasskeyFixture(t)
875 f.add("cred-1")
876 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
877 func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
878 p, err := lookup([]byte("cred-1"))
879 require.NoError(t, err)
880 f.mu.Lock()
881 delete(f.stored, "cred-1") // an owner removes it meanwhile
882 f.mu.Unlock()
883 return p, nil
884 })
885 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
886 require.NoError(t, err)
887 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
888 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
889 assert.Empty(t, token)
890 })
891}
892
893// Removing a passkey signs every browser out, the passkey's own sessions
894// included.
895func TestRemoveBumpsTheEpoch(t *testing.T) {
896 t.Parallel()
897 ctx := context.Background()
898 f := newPasskeyFixture(t)
899 f.add("cred-1")
900 f.add("cred-2")
901 f.acceptLogin("cred-1", 1)
902
903 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
904 require.NoError(t, err)
905 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
906 require.NoError(t, err)
907 _, err = f.sessions.Verify(ctx, token)
908 require.NoError(t, err)
909 linkToken := func() string {
910 tok, _, err := f.sessions.Login(ctx, f.nonce(t))
911 require.NoError(t, err)
912 return tok
913 }()
914
915 require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("cred-2")))
916 assert.Equal(t, 1, f.count())
917 for name, tok := range map[string]string{"passkey session": token, "link session": linkToken} {
918 _, err = f.sessions.Verify(ctx, tok)
919 assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
920 }
921
922 // A new login works with the epoch it finds now.
923 id, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
924 require.NoError(t, err)
925 token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
926 require.NoError(t, err)
927 _, err = f.sessions.Verify(ctx, token)
928 assert.NoError(t, err)
929}
930
931func TestRemoveUnknownKeepsTheSessions(t *testing.T) {
932 t.Parallel()
933 ctx := context.Background()
934 f := newPasskeyFixture(t)
935 f.add("cred-1")
936 tok, _, err := f.sessions.Login(ctx, f.nonce(t))
937 require.NoError(t, err)
938
939 err = f.passkeys.Remove(ctx, domain.Owner(), []byte("ghost"))
940 require.ErrorIs(t, err, domain.ErrNotFound)
941 _, err = f.sessions.Verify(ctx, tok)
942 assert.NoError(t, err, "nothing was removed, nobody is signed out")
943}
944
945func TestRemoveReportsASignOutFailure(t *testing.T) {
946 t.Parallel()
947 ctx := context.Background()
948 boom := errors.New("disk on fire")
949 sessionStore := mocks.NewSessionStore(t)
950 sessionStore.EXPECT().Key(mock.Anything).Return(testHandle, nil)
951 sessionStore.EXPECT().BumpEpoch(mock.Anything).Return(0, boom)
952 clock := mocks.NewClock(t)
953 clock.EXPECT().Now().Return(time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)).Maybe()
954 sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example")
955 require.NoError(t, err)
956
957 store := mocks.NewPasskeyStore(t)
958 store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(nil)
959 pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), sessions, clock)
960 err = pk.Remove(ctx, domain.Owner(), []byte("cred-1"))
961 require.ErrorIs(t, err, boom)
962 assert.Contains(t, err.Error(), "signing out the web sessions failed")
963}
964
965// The use cases are safe for concurrent callers (run with -race).
966func TestPasskeysConcurrent(t *testing.T) {
967 t.Parallel()
968 ctx := context.Background()
969 f := newPasskeyFixture(t)
970 f.add("cred-1")
971 f.acceptLogin("cred-1", 0) // synced: counters racing in order would be refused, rightly
972 var wg sync.WaitGroup
973 for range 32 {
974 wg.Add(1)
975 go func() {
976 defer wg.Done()
977 id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
978 if assert.NoError(t, err) {
979 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
980 assert.NoError(t, err)
981 }
982 _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
983 }()
984 }
985 wg.Wait()
986}
987
988// TS-001: a name that breaks the rules is refused before any ceremony starts,
989// and does not use up one of the pending registrations.
990func TestBeginRegistrationChecksTheName(t *testing.T) {
991 t.Parallel()
992 ctx := context.Background()
993 f := newPasskeyFixture(t)
994 for _, name := range []string{"evil \u202e name", "a\x00b", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "two\nlines", "\xff"} {
995 _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), name)
996 assert.ErrorIs(t, err, domain.ErrInvalidText, "%q", name)
997 }
998 // None of those counted as a pending registration.
999 for range usecase.MaxPendingPasskeyRegistrations {
1000 _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "ok")
1001 require.NoError(t, err)
1002 }
1003 _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
1004 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1005}
1006
1007// A client at its cap begins a login while the store is full. Its own eviction
1008// makes the room the new login needs, so exactly one ceremony is dropped, the
1009// client's own oldest: the global rule must not take a second one, from anyone.
1010func TestFullClientOnAFullStoreLosesOnlyItsOwnOldest(t *testing.T) {
1011 t.Parallel()
1012 f := newPasskeyFixture(t)
1013 f.add("cred-1")
1014 f.acceptLogin("cred-1", 1)
1015 perClient := usecase.MaxPendingPasskeyLoginsPerClient
1016
1017 // Seven clients with one login each, the flooder with a full cap, then full
1018 // clients and a last partial one until the store holds exactly its cap.
1019 small := make([]string, 0, 7)
1020 for i := range 7 {
1021 small = append(small, f.loginIDs(t, fmt.Sprintf("10.9.0.%d", i), 1)[0])
1022 }
1023 flooder := f.loginIDs(t, "192.0.2.1", perClient)
1024 total := len(small) + len(flooder)
1025 var others []string
1026 for n := 0; total+perClient <= usecase.MaxPendingPasskeyLogins; n++ {
1027 others = append(others, f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)...)
1028 total += perClient
1029 }
1030 if rest := usecase.MaxPendingPasskeyLogins - total; rest > 0 {
1031 others = append(others, f.loginIDs(t, "10.8.0.1", rest)...)
1032 total += rest
1033 }
1034 require.Equal(t, usecase.MaxPendingPasskeyLogins, total, "the store is full")
1035
1036 newest := f.loginIDs(t, "192.0.2.1", 1)[0]
1037
1038 assert.False(t, f.finishes(flooder[0]), "the flooder lost its own oldest")
1039 for _, id := range append(append(flooder[1:], small...), others...) {
1040 assert.True(t, f.finishes(id), "nobody else lost a ceremony")
1041 }
1042 assert.True(t, f.finishes(newest), "the new login works")
1043}
1044
1045// A failure of the store while removing a passkey is returned as it is, and
1046// nobody is signed out for it.
1047func TestRemoveReturnsAStoreError(t *testing.T) {
1048 t.Parallel()
1049 ctx := context.Background()
1050 f := newPasskeyFixture(t)
1051 boom := errors.New("disk on fire")
1052 store := mocks.NewPasskeyStore(t)
1053 store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(boom)
1054 // The session store is a strict mock: a BumpEpoch would fail the test.
1055 pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), f.sessions, f.clock)
1056 tok, _, err := f.sessions.Login(ctx, f.nonce(t))
1057 require.NoError(t, err)
1058
1059 err = pk.Remove(ctx, domain.Owner(), []byte("cred-1"))
1060 require.ErrorIs(t, err, boom)
1061 assert.NotErrorIs(t, err, domain.ErrNotFound)
1062 _, err = f.sessions.Verify(ctx, tok)
1063 assert.NoError(t, err, "the epoch did not move: the sessions are still valid")
1064}