v0.10.1 e4c85c7

passkeys_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close