v0.36.0 1fb2421

webauthn_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package webauthn_test
4
5import (
6	"encoding/json"
7	"errors"
8	"maps"
9	"testing"
10	"time"
11
12	"github.com/descope/virtualwebauthn"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite/internal/adapters/webauthn"
17	"bnema.dev/ermite/internal/domain"
18)
19
20var userHandle = []byte("0123456789abcdef0123456789abcdef")
21
22// site is one public URL and the relying party a browser would present for it.
23type site struct {
24	url string
25	rp  virtualwebauthn.RelyingParty
26}
27
28var sites = map[string]site{
29	"localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}},
30	"https":     {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}},
31}
32
33// device is a virtual authenticator with one credential and the passkey the
34// server stored for it.
35type device struct {
36	auth  virtualwebauthn.Authenticator
37	cred  virtualwebauthn.Credential
38	store map[string]domain.Passkey // by credential id
39}
40
41func (d *device) lookup(id []byte) (domain.Passkey, error) {
42	p, ok := d.store[string(id)]
43	if !ok {
44		return domain.Passkey{}, domain.ErrNotFound
45	}
46	return p, nil
47}
48
49// register runs a registration ceremony against v with a new credential.
50func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device {
51	t.Helper()
52	opts.UserHandle = userHandle
53	d := &device{
54		auth:  virtualwebauthn.NewAuthenticatorWithOptions(opts),
55		cred:  virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
56		store: map[string]domain.Passkey{},
57	}
58	options, ceremony, err := v.BeginRegistration(userHandle, nil)
59	require.NoError(t, err)
60	parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
61	require.NoError(t, err)
62	response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed)
63
64	p, err := v.FinishRegistration(userHandle, ceremony, []byte(response))
65	require.NoError(t, err)
66	assert.Equal(t, d.cred.ID, p.ID)
67	assert.NotEmpty(t, p.PublicKey)
68	p.Name = "test"
69	d.store[string(p.ID)] = p
70	d.auth.AddCredential(d.cred)
71	return d
72}
73
74// assertion starts a login and returns the ceremony and the signed response.
75func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) {
76	t.Helper()
77	raw, ceremony, err := v.BeginLogin()
78	require.NoError(t, err)
79	options, err = virtualwebauthn.ParseAssertionOptions(string(raw))
80	require.NoError(t, err)
81	return ceremony, options
82}
83
84func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte {
85	return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options))
86}
87
88func TestRegisterThenLogin(t *testing.T) {
89	t.Parallel()
90	for name, s := range sites {
91		t.Run(name, func(t *testing.T) {
92			t.Parallel()
93			v, err := webauthn.New(s.url, "owner")
94			require.NoError(t, err)
95			d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true})
96			d.cred.Counter = 5
97
98			ceremony, options := d.assertion(t, v, s)
99			got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
100			require.NoError(t, err)
101			assert.Equal(t, d.cred.ID, got.ID)
102			assert.Equal(t, "test", got.Name, "the stored fields are kept")
103			assert.Equal(t, uint32(5), got.SignCount, "the new sign count")
104			assert.True(t, got.BackupEligible)
105			assert.False(t, got.BackupState)
106			assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey)
107
108			// The next login advances the counter again.
109			d.store[string(got.ID)] = got
110			d.cred.Counter = 6
111			ceremony, options = d.assertion(t, v, s)
112			got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
113			require.NoError(t, err)
114			assert.Equal(t, uint32(6), got.SignCount)
115		})
116	}
117}
118
119func TestRegistrationReportsBackupFlags(t *testing.T) {
120	t.Parallel()
121	s := sites["https"]
122	v, err := webauthn.New(s.url, "owner")
123	require.NoError(t, err)
124	d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true,
125		Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}})
126	p := d.store[string(d.cred.ID)]
127	assert.True(t, p.BackupEligible)
128	assert.True(t, p.BackupState)
129	assert.Equal(t, []string{"internal", "hybrid"}, p.Transports)
130
131	// A login reports the backup state the authenticator has now.
132	d.auth.Options.BackupState = false
133	ceremony, options := d.assertion(t, v, s)
134	got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
135	require.NoError(t, err)
136	assert.False(t, got.BackupState)
137}
138
139func TestOptions(t *testing.T) {
140	t.Parallel()
141	s := sites["https"]
142	v, err := webauthn.New(s.url, "owner")
143	require.NoError(t, err)
144
145	existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}}
146	raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing})
147	require.NoError(t, err)
148	var creation struct {
149		PublicKey struct {
150			RP                     struct{ ID, Name string }
151			User                   struct{ ID, Name string }
152			Challenge              string
153			AuthenticatorSelection struct {
154				ResidentKey        string
155				RequireResidentKey bool
156				UserVerification   string
157			}
158			Attestation        string
159			Timeout            int
160			ExcludeCredentials []struct{ ID string }
161		}
162	}
163	require.NoError(t, json.Unmarshal(raw, &creation))
164	pk := creation.PublicKey
165	assert.Equal(t, "git.example.test", pk.RP.ID)
166	assert.Equal(t, "owner", pk.User.Name)
167	assert.NotEmpty(t, pk.Challenge)
168	assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey)
169	assert.True(t, pk.AuthenticatorSelection.RequireResidentKey)
170	assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification)
171	assert.Equal(t, "none", pk.Attestation)
172	assert.Equal(t, 120000, pk.Timeout)
173	require.Len(t, pk.ExcludeCredentials, 1)
174	parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw))
175	require.NoError(t, err)
176	require.Len(t, parsed.ExcludeCredentials, 1)
177
178	raw, _, err = v.BeginLogin()
179	require.NoError(t, err)
180	var assertion struct {
181		PublicKey struct {
182			RPID             string
183			Challenge        string
184			UserVerification string
185			Timeout          int
186			AllowCredentials []struct{ ID string }
187		}
188	}
189	require.NoError(t, json.Unmarshal(raw, &assertion))
190	assert.Equal(t, "git.example.test", assertion.PublicKey.RPID)
191	assert.NotEmpty(t, assertion.PublicKey.Challenge)
192	assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification")
193	assert.Equal(t, 120000, assertion.PublicKey.Timeout)
194	assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential")
195}
196
197func TestLoginRefused(t *testing.T) {
198	t.Parallel()
199	s := sites["localhost"]
200	v, err := webauthn.New(s.url, "owner")
201	require.NoError(t, err)
202
203	tests := []struct {
204		name string
205		// run returns the error of FinishLogin.
206		run func(t *testing.T, d *device) error
207	}{
208		{"wrong origin", func(t *testing.T, d *device) error {
209			ceremony, options := d.assertion(t, v, s)
210			other := s
211			other.rp.Origin = "http://localhost:9999"
212			return finish(v, ceremony, d.respond(other, options), d)
213		}},
214		{"wrong RP ID", func(t *testing.T, d *device) error {
215			ceremony, options := d.assertion(t, v, s)
216			other := s
217			other.rp.ID = "evil.example.test"
218			return finish(v, ceremony, d.respond(other, options), d)
219		}},
220		{"tampered challenge", func(t *testing.T, d *device) error {
221			ceremony, options := d.assertion(t, v, s)
222			options.Challenge[0] ^= 0xff
223			return finish(v, ceremony, d.respond(s, options), d)
224		}},
225		{"challenge of another ceremony", func(t *testing.T, d *device) error {
226			ceremony, _ := d.assertion(t, v, s)
227			_, other := d.assertion(t, v, s)
228			return finish(v, ceremony, d.respond(s, other), d)
229		}},
230		{"expired ceremony", func(t *testing.T, d *device) error {
231			ceremony, options := d.assertion(t, v, s)
232			return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d)
233		}},
234		{"garbage ceremony", func(t *testing.T, d *device) error {
235			_, options := d.assertion(t, v, s)
236			return finish(v, []byte("not json"), d.respond(s, options), d)
237		}},
238		{"garbage response", func(t *testing.T, d *device) error {
239			ceremony, _ := d.assertion(t, v, s)
240			return finish(v, ceremony, []byte("{}"), d)
241		}},
242		{"user not verified", func(t *testing.T, d *device) error {
243			d.auth.Options.UserNotVerified = true
244			ceremony, options := d.assertion(t, v, s)
245			return finish(v, ceremony, d.respond(s, options), d)
246		}},
247		{"user not present", func(t *testing.T, d *device) error {
248			d.auth.Options.UserNotPresent = true
249			ceremony, options := d.assertion(t, v, s)
250			return finish(v, ceremony, d.respond(s, options), d)
251		}},
252		{"unknown credential", func(t *testing.T, d *device) error {
253			ceremony, options := d.assertion(t, v, s)
254			delete(d.store, string(d.cred.ID))
255			return finish(v, ceremony, d.respond(s, options), d)
256		}},
257		{"other user handle", func(t *testing.T, d *device) error {
258			d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------")
259			ceremony, options := d.assertion(t, v, s)
260			return finish(v, ceremony, d.respond(s, options), d)
261		}},
262		{"no user handle", func(t *testing.T, d *device) error {
263			d.auth.Options.UserHandle = nil
264			ceremony, options := d.assertion(t, v, s)
265			return finish(v, ceremony, d.respond(s, options), d)
266		}},
267		{"registration ceremony used for a login", func(t *testing.T, d *device) error {
268			_, ceremony, err := v.BeginRegistration(userHandle, nil)
269			require.NoError(t, err)
270			_, options := d.assertion(t, v, s)
271			return finish(v, ceremony, d.respond(s, options), d)
272		}},
273	}
274	for _, tc := range tests {
275		t.Run(tc.name, func(t *testing.T) {
276			t.Parallel()
277			d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
278			opts := d.auth.Options
279			stored := maps.Clone(d.store)
280			err := tc.run(t, d)
281			require.Error(t, err)
282			assert.ErrorIs(t, err, domain.ErrUnauthenticated)
283
284			// Control: the refusal was for the reason of the case. With the device
285			// and the store as they were, a fresh assertion still succeeds, so the
286			// case did not fail by something that breaks every login.
287			d.auth.Options = opts
288			d.store = stored
289			ceremony, options := d.assertion(t, v, s)
290			got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
291			require.NoError(t, err, "a fresh, untouched assertion must succeed")
292			assert.Equal(t, d.cred.ID, got.ID)
293		})
294	}
295}
296
297func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error {
298	_, err := v.FinishLogin(userHandle, ceremony, response, d.lookup)
299	return err
300}
301
302// setExpires rewrites the expiry of a ceremony the way a late finish would see
303// it, since the library checks it against the wall clock.
304func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte {
305	t.Helper()
306	var m map[string]any
307	require.NoError(t, json.Unmarshal(ceremony, &m))
308	require.Contains(t, m, "expires")
309	m["expires"] = at
310	out, err := json.Marshal(m)
311	require.NoError(t, err)
312	return out
313}
314
315func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) {
316	t.Parallel()
317	s := sites["localhost"]
318	v, err := webauthn.New(s.url, "owner")
319	require.NoError(t, err)
320	d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
321	ceremony, options := d.assertion(t, v, s)
322	boom := errors.New("disk on fire")
323	_, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) {
324		return domain.Passkey{}, boom
325	})
326	require.ErrorIs(t, err, boom)
327	assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
328}
329
330// A counter that does not advance is not refused here: the verifier reports the
331// stored counter (the library keeps it on a clone warning) and the use case,
332// which orders the logins under its lock, refuses it. TestPasskeyClonedAuthenticator
333// in package app checks the refusal end to end.
334func TestCounterThatDoesNotAdvanceKeepsTheStoredOne(t *testing.T) {
335	t.Parallel()
336	s := sites["localhost"]
337	v, err := webauthn.New(s.url, "owner")
338	require.NoError(t, err)
339	d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
340
341	d.cred.Counter = 1
342	ceremony, options := d.assertion(t, v, s)
343	got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
344	require.NoError(t, err)
345	assert.Equal(t, uint32(1), got.SignCount)
346	d.store[string(got.ID)] = got
347
348	// The counter did not advance: a copy of the key may be in use. The answer
349	// is valid, and carries the stored counter, not a lower or equal new one.
350	ceremony, options = d.assertion(t, v, s)
351	again, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
352	require.NoError(t, err)
353	assert.Equal(t, uint32(1), again.SignCount, "the stored counter, unchanged")
354
355	// A response with a lower counter keeps the stored one too.
356	d.cred.Counter = 0
357	ceremony, options = d.assertion(t, v, s)
358	lower, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
359	require.NoError(t, err)
360	assert.Equal(t, uint32(1), lower.SignCount)
361}
362
363// Synced passkeys always report 0: that is not a clone.
364func TestZeroCounterIsNotAClone(t *testing.T) {
365	t.Parallel()
366	s := sites["localhost"]
367	v, err := webauthn.New(s.url, "owner")
368	require.NoError(t, err)
369	d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true})
370	for range 2 {
371		ceremony, options := d.assertion(t, v, s)
372		got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
373		require.NoError(t, err)
374		assert.Zero(t, got.SignCount)
375	}
376}
377
378func TestRegistrationRefused(t *testing.T) {
379	t.Parallel()
380	s := sites["https"]
381	v, err := webauthn.New(s.url, "owner")
382	require.NoError(t, err)
383
384	tests := []struct {
385		name string
386		auth virtualwebauthn.AuthenticatorOptions
387		rp   func(rp *virtualwebauthn.RelyingParty)
388		// ceremony replaces the ceremony data when set.
389		ceremony func(t *testing.T, ceremony []byte) []byte
390		handle   []byte
391	}{
392		{name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }},
393		{name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }},
394		{name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}},
395		{name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }},
396		{name: "other user", handle: []byte("somebody-else-------------------")},
397	}
398	for _, tc := range tests {
399		t.Run(tc.name, func(t *testing.T) {
400			t.Parallel()
401			rp := s.rp
402			if tc.rp != nil {
403				tc.rp(&rp)
404			}
405			auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth)
406			cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
407			options, ceremony, err := v.BeginRegistration(userHandle, nil)
408			require.NoError(t, err)
409			parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
410			require.NoError(t, err)
411			response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed)
412			if tc.ceremony != nil {
413				ceremony = tc.ceremony(t, ceremony)
414			}
415			handle := userHandle
416			if tc.handle != nil {
417				handle = tc.handle
418			}
419			_, err = v.FinishRegistration(handle, ceremony, []byte(response))
420			require.Error(t, err)
421			assert.ErrorIs(t, err, domain.ErrUnauthenticated)
422		})
423	}
424
425	t.Run("garbage", func(t *testing.T) {
426		t.Parallel()
427		_, ceremony, err := v.BeginRegistration(userHandle, nil)
428		require.NoError(t, err)
429		_, err = v.FinishRegistration(userHandle, ceremony, []byte("{"))
430		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
431		_, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}"))
432		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
433	})
434}
435
436func TestNew(t *testing.T) {
437	t.Parallel()
438	for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} {
439		_, err := webauthn.New(ok, "owner")
440		assert.NoError(t, err, ok)
441	}
442	for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} {
443		_, err := webauthn.New(bad, "owner")
444		assert.Error(t, err, bad)
445	}
446}

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 4

Tags 45

↑↓ moveenter openesc close