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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
444 assert.Error(t, err, bad)
445 }
446}