-
Notifications
You must be signed in to change notification settings - Fork 1.7k
/
fido2.go
990 lines (866 loc) · 28.9 KB
/
fido2.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
//go:build libfido2
// +build libfido2
/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package webauthncli
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/protocol/webauthncose"
"github.com/gravitational/trace"
"github.com/keys-pub/go-libfido2"
log "github.com/sirupsen/logrus"
"github.com/gravitational/teleport/api/client/proto"
wanpb "github.com/gravitational/teleport/api/types/webauthn"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
const (
// Max wait time for closing devices, before "abandoning" the device
// goroutine.
fido2DeviceMaxWait = 100 * time.Millisecond
// Timeout for blocking operations.
// Functions fail with FIDO_ERR_RX on timeout.
fido2DeviceTimeout = 30 * time.Second
// Operation retry interval.
// Keep it less frequent than 2Hz / 0.5s.
fido2RetryInterval = 500 * time.Millisecond
)
// User-friendly device filter errors.
var (
errHasExcludedCredential = errors.New("device already holds a registered credential")
errNoPasswordless = errors.New("device not registered for passwordless")
errNoPlatform = errors.New("device cannot fulfill platform attachment requirement")
errNoRK = errors.New("device lacks resident key capabilities")
errNoUV = errors.New("device lacks PIN or user verification capabilities necessary to support passwordless")
errPasswordlessU2F = errors.New("U2F devices cannot do passwordless")
)
// FIDODevice abstracts *libfido2.Device for testing.
type FIDODevice interface {
// Info mirrors libfido2.Device.Info.
Info() (*libfido2.DeviceInfo, error)
// IsFIDO2 mirrors libfido2.Device.IsFIDO2.
IsFIDO2() (bool, error)
// Cancel mirrors libfido2.Device.Cancel.
Cancel() error
// Close mirrors libfido2.Device.Close.
Close() error
// SetTimeout mirrors libfido2.Device.SetTimeout.
SetTimeout(d time.Duration) error
// MakeCredential mirrors libfido2.Device.MakeCredential.
MakeCredential(
clientDataHash []byte,
rp libfido2.RelyingParty,
user libfido2.User,
typ libfido2.CredentialType,
pin string,
opts *libfido2.MakeCredentialOpts) (*libfido2.Attestation, error)
// Assertion mirrors libfido2.Device.Assertion.
Assertion(
rpID string,
clientDataHash []byte,
credentialIDs [][]byte,
pin string,
opts *libfido2.AssertionOpts) ([]*libfido2.Assertion, error)
}
// fidoDeviceLocations and fidoNewDevice are used to allow testing.
var (
fidoDeviceLocations = libfido2.DeviceLocations
fidoNewDevice = func(path string) (FIDODevice, error) {
return libfido2.NewDevice(path)
}
)
// isLibfido2Enabled returns true if libfido2 is available in the current build.
func isLibfido2Enabled() bool {
val, ok := os.LookupEnv("TELEPORT_FIDO2")
// Default to enabled, otherwise obey the env variable.
return !ok || val == "1"
}
// fido2Login implements FIDO2Login.
func fido2Login(
ctx context.Context,
origin string, assertion *wantypes.CredentialAssertion, prompt LoginPrompt, opts *LoginOpts,
) (*proto.MFAAuthenticateResponse, string, error) {
switch {
case origin == "":
return nil, "", trace.BadParameter("origin required")
case prompt == nil:
return nil, "", trace.BadParameter("prompt required")
}
if err := assertion.Validate(); err != nil {
return nil, "", trace.Wrap(err)
}
if opts == nil {
opts = &LoginOpts{}
}
allowedCreds := assertion.Response.GetAllowedCredentialIDs()
uv := assertion.Response.UserVerification == protocol.VerificationRequired
// Presence of any allowed credential is interpreted as the user identity
// being partially established, aka non-passwordless.
passwordless := len(allowedCreds) == 0
log.Debugf("FIDO2: assertion: passwordless=%v, uv=%v, %v allowed credentials", passwordless, uv, len(allowedCreds))
// Prepare challenge data for the device.
ccdJSON, err := json.Marshal(&CollectedClientData{
Type: string(protocol.AssertCeremony),
Challenge: base64.RawURLEncoding.EncodeToString(assertion.Response.Challenge),
Origin: origin,
})
if err != nil {
return nil, "", trace.Wrap(err)
}
ccdHash := sha256.Sum256(ccdJSON)
rpID := assertion.Response.RelyingPartyID
var appID string
if val, ok := assertion.Response.Extensions[wantypes.AppIDExtension]; ok {
appID = fmt.Sprint(val)
}
// mu guards the variables below it.
var mu sync.Mutex
var assertionResp *libfido2.Assertion
var usedAppID bool
filter := func(dev FIDODevice, info *deviceInfo) error {
switch {
case !info.fido2 && (uv || passwordless):
return errPasswordlessU2F
case passwordless && (!info.uvCapable() || !info.rk):
return errNoPasswordless
case uv && !info.uvCapable():
// Unlikely that we would ask for UV without passwordless, but let's check
// just in case.
// If left unchecked this causes libfido2.ErrUnsupportedOption.
return errNoUV
default:
return nil
}
}
user := opts.User
deviceCallback := func(dev FIDODevice, info *deviceInfo, pin string) error {
actualRPID := rpID
if usesAppID(dev, info, ccdHash[:], allowedCreds, rpID, appID) {
log.Debugf("FIDO2: Device %v registered for AppID (%q) instead of RPID", info.path, appID)
actualRPID = appID
}
opts := &libfido2.AssertionOpts{
UP: libfido2.True,
}
// Note that "uv" fails for PIN-capable devices with an empty PIN.
// This is handled by runOnFIDO2Devices.
if uv {
opts.UV = libfido2.True
}
assertions, err := dev.Assertion(actualRPID, ccdHash[:], allowedCreds, pin, opts)
if errors.Is(err, libfido2.ErrUnsupportedOption) && uv && pin != "" {
// Try again if we are getting "unsupported option" and the PIN is set.
// Happens inconsistently in some authenticator series (YubiKey 5).
// We are relying on the fact that, because the PIN is set, the
// authenticator will set the UV bit regardless of it being requested.
log.Debugf("FIDO2: Device %v: retrying assertion without UV", info.path)
opts.UV = libfido2.Default
assertions, err = dev.Assertion(actualRPID, ccdHash[:], allowedCreds, pin, opts)
}
if errors.Is(err, libfido2.ErrNoCredentials) {
err = ErrUsingNonRegisteredDevice // "Upgrade" error message.
}
if err != nil {
return trace.Wrap(err)
}
log.Debugf("FIDO2: Got %v assertions", len(assertions))
// Find assertion for target user, or show the prompt.
assertion, err := pickAssertion(assertions, prompt, user, passwordless)
if err != nil {
return trace.Wrap(err)
}
log.Debugf(
"FIDO2: Authenticated: credential ID (b64) = %v, user ID (hex) = %x, user name = %q",
base64.RawURLEncoding.EncodeToString(assertion.CredentialID), assertion.User.ID, assertion.User.Name)
// Use the first successful assertion.
// In practice it is very unlikely we'd hit this twice.
mu.Lock()
if assertionResp == nil {
assertionResp = assertion
usedAppID = actualRPID != rpID
}
mu.Unlock()
return nil
}
if err := runOnFIDO2Devices(ctx, prompt, filter, deviceCallback); err != nil {
return nil, "", trace.Wrap(err)
}
var rawAuthData []byte
if err := cbor.Unmarshal(assertionResp.AuthDataCBOR, &rawAuthData); err != nil {
return nil, "", trace.Wrap(err)
}
// Trust the assertion user if present, otherwise say nothing.
actualUser := assertionResp.User.Name
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Webauthn{
Webauthn: &wanpb.CredentialAssertionResponse{
Type: string(protocol.PublicKeyCredentialType),
RawId: assertionResp.CredentialID,
Response: &wanpb.AuthenticatorAssertionResponse{
ClientDataJson: ccdJSON,
AuthenticatorData: rawAuthData,
Signature: assertionResp.Sig,
UserHandle: assertionResp.User.ID,
},
Extensions: &wanpb.AuthenticationExtensionsClientOutputs{
AppId: usedAppID,
},
},
},
}, actualUser, nil
}
func usesAppID(dev FIDODevice, info *deviceInfo, ccdHash []byte, allowedCreds [][]byte, rpID, appID string) bool {
if appID == "" {
return false
}
opts := &libfido2.AssertionOpts{
UP: libfido2.False,
}
isRegistered := func(id string) bool {
const pin = "" // Not necessary here.
_, err := dev.Assertion(id, ccdHash, allowedCreds, pin, opts)
return err == nil || (!info.fido2 && errors.Is(err, libfido2.ErrUserPresenceRequired))
}
return isRegistered(appID) && !isRegistered(rpID)
}
func pickAssertion(
assertions []*libfido2.Assertion, prompt LoginPrompt, user string, passwordless bool,
) (*libfido2.Assertion, error) {
switch l := len(assertions); {
// Shouldn't happen, but let's be safe and handle it anyway.
case l == 0:
return nil, errors.New("authenticator returned empty assertions")
// MFA or single account.
// Note that authenticators don't return the user name, display name or icon
// for a single account per RP.
// See the authenticatorGetAssertion response, user member (0x04):
// https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorgetassertion-response-structure
case !passwordless, l == 1:
return assertions[0], nil
// Explicit user required. First occurrence wins.
case user != "":
for _, assertion := range assertions {
if assertion.User.Name == user {
return assertion, nil
}
}
return nil, fmt.Errorf("no credentials for user %q", user)
}
// Prepare credentials and show picker.
creds := make([]*CredentialInfo, len(assertions))
credToAssertion := make(map[*CredentialInfo]*libfido2.Assertion)
for i, assertion := range assertions {
cred := &CredentialInfo{
ID: assertion.CredentialID,
User: UserInfo{
UserHandle: assertion.User.ID,
Name: assertion.User.Name,
},
}
credToAssertion[cred] = assertion
creds[i] = cred
}
chosen, err := prompt.PromptCredential(creds)
if err != nil {
return nil, trace.Wrap(err)
}
assertion, ok := credToAssertion[chosen]
if !ok {
return nil, fmt.Errorf("prompt returned invalid credential: %#v", chosen)
}
return assertion, nil
}
// fido2Register implements FIDO2Register.
func fido2Register(
ctx context.Context,
origin string, cc *wantypes.CredentialCreation, prompt RegisterPrompt,
) (*proto.MFARegisterResponse, error) {
switch {
case origin == "":
return nil, trace.BadParameter("origin required")
case prompt == nil:
return nil, trace.BadParameter("prompt required")
}
if err := cc.Validate(); err != nil {
return nil, trace.Wrap(err)
}
rrk, err := cc.RequireResidentKey()
if err != nil {
return nil, trace.Wrap(err)
}
log.Debugf("FIDO2: registration: resident key=%v", rrk)
// Can we create ES256 keys?
// TODO(codingllama): Consider supporting other algorithms and respecting
// param order in the credential.
ok := false
for _, p := range cc.Response.Parameters {
if p.Type == protocol.PublicKeyCredentialType && p.Algorithm == webauthncose.AlgES256 {
ok = true
break
}
}
if !ok {
return nil, trace.BadParameter("ES256 not allowed by credential parameters")
}
// Prepare challenge data for the device.
ccdJSON, err := json.Marshal(&CollectedClientData{
Type: string(protocol.CreateCeremony),
Challenge: base64.RawURLEncoding.EncodeToString(cc.Response.Challenge),
Origin: origin,
})
if err != nil {
return nil, trace.Wrap(err)
}
ccdHash := sha256.Sum256(ccdJSON)
rp := libfido2.RelyingParty{
ID: cc.Response.RelyingParty.ID,
Name: cc.Response.RelyingParty.Name,
}
user := libfido2.User{
ID: cc.Response.User.ID,
Name: cc.Response.User.Name,
DisplayName: cc.Response.User.DisplayName,
}
plat := cc.Response.AuthenticatorSelection.AuthenticatorAttachment == protocol.Platform
uv := cc.Response.AuthenticatorSelection.UserVerification == protocol.VerificationRequired
excludeList := make([][]byte, len(cc.Response.CredentialExcludeList))
for i := range cc.Response.CredentialExcludeList {
excludeList[i] = cc.Response.CredentialExcludeList[i].CredentialID
}
// mu guards attestation from goroutines.
var mu sync.Mutex
var attestation *libfido2.Attestation
filter := func(dev FIDODevice, info *deviceInfo) error {
switch {
case !info.fido2 && (rrk || uv):
return errPasswordlessU2F
case plat && !info.plat:
return errNoPlatform
case rrk && !info.rk:
return errNoRK
case uv && !info.uvCapable():
return errNoUV
case len(excludeList) == 0:
return nil
}
// Does the device hold an excluded credential?
const pin = "" // not required to filter
switch _, err := dev.Assertion(rp.ID, ccdHash[:], excludeList, pin, &libfido2.AssertionOpts{
UP: libfido2.False,
}); {
case errors.Is(err, libfido2.ErrNoCredentials):
return nil
case errors.Is(err, libfido2.ErrUserPresenceRequired):
// Yubikey4 does this when the credential exists.
return errHasExcludedCredential
case err != nil:
// Swallow unexpected errors: a double registration is better than
// aborting the ceremony.
log.Debugf(
"FIDO2: Device %v: excluded credential assertion failed, letting device through: err=%q",
info.path, err)
return nil
default:
log.Debugf("FIDO2: Device %v: filtered due to presence of excluded credential", info.path)
return errHasExcludedCredential
}
}
deviceCallback := func(d FIDODevice, info *deviceInfo, pin string) error {
// TODO(codingllama): We may need to setup a PIN if rrk=true.
// Do that as a response to specific MakeCredential failures.
opts := &libfido2.MakeCredentialOpts{}
if rrk {
opts.RK = libfido2.True
}
// Only set the "uv" bit if the authenticator supports built-in
// verification. PIN-enabled devices don't claim to support "uv", but they
// are capable of UV assertions.
// See
// https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#getinfo-uv.
if uv && info.uv {
opts.UV = libfido2.True
}
resp, err := d.MakeCredential(ccdHash[:], rp, user, libfido2.ES256, pin, opts)
if err != nil {
return trace.Wrap(err)
}
// Use the first successful attestation.
// In practice it is very unlikely we'd hit this twice.
mu.Lock()
if attestation == nil {
attestation = resp
}
mu.Unlock()
return nil
}
if err := runOnFIDO2Devices(ctx, prompt, filter, deviceCallback); err != nil {
return nil, trace.Wrap(err)
}
var rawAuthData []byte
if err := cbor.Unmarshal(attestation.AuthData, &rawAuthData); err != nil {
return nil, trace.Wrap(err)
}
format, attStatement, err := makeAttStatement(attestation)
if err != nil {
return nil, trace.Wrap(err)
}
attObj := &protocol.AttestationObject{
RawAuthData: rawAuthData,
Format: format,
AttStatement: attStatement,
}
attestationCBOR, err := cbor.Marshal(attObj)
if err != nil {
return nil, trace.Wrap(err)
}
return &proto.MFARegisterResponse{
Response: &proto.MFARegisterResponse_Webauthn{
Webauthn: &wanpb.CredentialCreationResponse{
Type: string(protocol.PublicKeyCredentialType),
RawId: attestation.CredentialID,
Response: &wanpb.AuthenticatorAttestationResponse{
ClientDataJson: ccdJSON,
AttestationObject: attestationCBOR,
},
},
},
}, nil
}
func makeAttStatement(attestation *libfido2.Attestation) (string, map[string]interface{}, error) {
const fidoU2F = "fido-u2f"
const none = "none"
const packed = "packed"
// See https://www.w3.org/TR/webauthn-2/#sctn-defined-attestation-formats.
// The formats handled below are what we expect from the keys libfido2
// interacts with.
format := attestation.Format
switch format {
case fidoU2F, packed: // OK, continue below
case none:
return format, nil, nil
default:
log.Debugf(`FIDO2: Unsupported attestation format %q, using "none"`, format)
return none, nil, nil
}
sig := attestation.Sig
if len(sig) == 0 {
return "", nil, trace.BadParameter("attestation %q without signature", format)
}
cert := attestation.Cert
if len(cert) == 0 {
return "", nil, trace.BadParameter("attestation %q without certificate", format)
}
m := map[string]interface{}{
"sig": sig,
"x5c": []interface{}{cert},
}
if format == packed {
m["alg"] = int64(attestation.CredentialType)
}
return format, m, nil
}
type (
deviceFilterFunc func(dev FIDODevice, info *deviceInfo) error
deviceCallbackFunc func(dev FIDODevice, info *deviceInfo, pin string) error
pinAwareCallbackFunc func(dev FIDODevice, info *deviceInfo, pin string) (requiresPIN bool, err error)
)
// runPrompt defines the prompt operations necessary for runOnFIDO2Devices.
// (RegisterPrompt happens to match the minimal interface required.)
type runPrompt RegisterPrompt
func runOnFIDO2Devices(
ctx context.Context,
prompt runPrompt,
filter deviceFilterFunc,
deviceCallback deviceCallbackFunc,
) error {
locs, err := fidoDeviceLocations()
if err != nil {
return trace.Wrap(err, "device locations")
}
if len(locs) == 0 {
return trace.Wrap(errors.New("no security keys found"))
}
devices, devicesC, err := startDevices(locs, filter, deviceCallback, prompt)
if err != nil {
return trace.Wrap(err)
}
var receiveCount int
defer func() {
// Cancel all in-flight requests, if any.
devices.cancelAll(nil /* except */)
// Give the devices some time to tidy up, but don't wait forever.
maxWait := time.NewTimer(fido2DeviceMaxWait)
defer maxWait.Stop()
for receiveCount < devices.len() {
select {
case <-devicesC:
receiveCount++
case <-maxWait.C:
log.Debugf("FIDO2: Abandoning device goroutines after %s", fido2DeviceMaxWait)
return
}
}
log.Debug("FIDO2: Device goroutines exited cleanly")
}()
// First "interactive" response wins.
for receiveCount < devices.len() {
select {
case err := <-devicesC:
receiveCount++
// Keep going on cancels or non-interactive errors.
if errors.Is(err, libfido2.ErrKeepaliveCancel) || errors.Is(err, &nonInteractiveError{}) {
log.Debugf("FIDO2: Got cancel or non-interactive device error: %v", err)
continue
}
return trace.Wrap(err)
case <-ctx.Done():
return trace.Wrap(ctx.Err())
}
}
return trace.Wrap(errors.New("all MFA devices failed"))
}
func startDevices(
locs []*libfido2.DeviceLocation,
filter deviceFilterFunc,
deviceCallback deviceCallbackFunc,
prompt runPrompt,
) (devices *openedDevices, devicesC <-chan error, err error) {
fidoDevs := make([]FIDODevice, 0, len(locs))
openDevs := make([]*openedDevice, 0, len(locs))
// closeAll should only be used until the devices are handed over.
// Do not defer-call it.
closeAll := func() {
for i, dev := range fidoDevs {
path := openDevs[i].path
err := dev.Close()
log.Debugf("FIDO2: Close device %v, err=%v", path, err)
}
}
// Open all devices in one go.
// This ensures cancels propagate to the complete list.
for _, loc := range locs {
path := loc.Path
dev, err := fidoNewDevice(path)
if err != nil {
closeAll()
return nil, nil, trace.Wrap(err, "device open")
}
fidoDevs = append(fidoDevs, dev)
openDevs = append(openDevs, &openedDevice{
path: path,
dev: dev,
})
}
// Prompt touch, it's about to begin.
ackTouch, err := prompt.PromptTouch()
if err != nil {
closeAll()
return nil, nil, trace.Wrap(err)
}
//nolint:ineffassign // closeAll not meant to be used from here onwards.
closeAll = nil
errC := make(chan error, len(fidoDevs))
devices = &openedDevices{
devices: openDevs,
}
// Fire device handling goroutines.
// From this point onwards devices are owned by their respective goroutines,
// only cancels are supposed to happen outside of them.
for i, dev := range fidoDevs {
path := openDevs[i].path
dev := dev
go func() {
errC <- handleDevice(path, dev, filter, deviceCallback, devices.cancelAll, ackTouch, prompt)
}()
}
return devices, errC, nil
}
type openedDevice struct {
path string
// dev is the opened device.
// Only cancels may be issued outside of the handleDevice goroutine.
dev interface{ Cancel() error }
// Keep tabs on canceled devices to avoid multiple cancels.
canceled bool
}
type openedDevices struct {
// mu guards device changes and cancelAll().
// Note that the size of the devices slice doesn't change after it's assigned,
// only the `canceled` device field changes.
mu sync.Mutex
devices []*openedDevice
}
func (l *openedDevices) len() int {
// Safe to read without locking, the size of the slice doesn't change after
// assigned.
return len(l.devices)
}
// cancelAll cancels all devices but `except`.
func (l *openedDevices) cancelAll(except FIDODevice) {
l.mu.Lock()
defer l.mu.Unlock()
for _, d := range l.devices {
if d.dev == except || d.canceled {
continue
}
d.canceled = true
// Note that U2F devices fail Cancel with "invalid argument".
err := d.dev.Cancel()
log.Debugf("FIDO2: Cancel device %v, err=%v", d.path, err)
}
}
// handleDevice handles all device interactions, apart from external cancels.
func handleDevice(
path string,
dev FIDODevice,
filter deviceFilterFunc, deviceCallback deviceCallbackFunc,
cancelAll func(except FIDODevice),
firstTouchAck func() error,
pinPrompt runPrompt,
) error {
// handleDevice owns the device, thus it has the privilege to shut it down.
defer func() {
err := dev.Close()
log.Debugf("FIDO2: Close device %v, err=%v", path, err)
}()
if err := dev.SetTimeout(fido2DeviceTimeout); err != nil {
return trace.Wrap(&nonInteractiveError{err})
}
// Gather device information.
var info *libfido2.DeviceInfo
isFIDO2, err := dev.IsFIDO2()
if err != nil {
return trace.Wrap(&nonInteractiveError{err: err})
}
if isFIDO2 {
info, err = devInfo(path, dev)
if err != nil {
return trace.Wrap(&nonInteractiveError{err: err})
}
log.Debugf("FIDO2: Device %v: info %#v", path, info)
} else {
log.Debugf("FIDO2: Device %v: not a FIDO2 device", path)
}
di := makeDevInfo(path, info, isFIDO2)
// Apply initial filters, waiting for confirmation if the filter fails before
// relaying the error.
if err := filter(dev, di); err != nil {
log.Debugf("FIDO2: Device %v filtered, err=%v", path, err)
// If the device is chosen then treat the error as interactive.
if waitErr := waitForTouch(dev); errors.Is(waitErr, libfido2.ErrNoCredentials) {
cancelAll(dev)
} else {
err = &nonInteractiveError{err: err}
}
return trace.Wrap(err)
}
// Run the callback.
cb := withPINHandler(withRetries(deviceCallback))
requiresPIN, err := cb(dev, di, "" /* pin */)
log.Debugf("FIDO2: Device %v: callback returned, requiresPIN=%v, err=%v", path, requiresPIN, err)
if err != nil {
return trace.Wrap(err)
}
if err := firstTouchAck(); err != nil {
return trace.Wrap(err)
}
// Cancel other devices only on success. This avoids multiple cancel attempts
// as non-chosen devices return FIDO_ERR_KEEPALIVE_CANCEL.
cancelAll(dev)
if !requiresPIN {
return nil
}
// Ask for PIN, prompt for next touch.
pin, err := pinPrompt.PromptPIN()
switch {
case err != nil:
return trace.Wrap(err)
case pin == "":
return libfido2.ErrPinRequired
}
ackTouch, err := pinPrompt.PromptTouch()
if err != nil {
return trace.Wrap(err)
}
cb = withoutPINHandler(withRetries(deviceCallback))
if _, err := cb(dev, di, pin); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(ackTouch())
}
func devInfo(path string, dev FIDODevice) (*libfido2.DeviceInfo, error) {
const infoAttempts = 3
var lastErr error
for i := 0; i < infoAttempts; i++ {
info, err := dev.Info()
if err == nil {
return info, nil
}
lastErr = err
log.Debugf("FIDO2: Device %v: Info failed, retrying after %s: %v", path, fido2RetryInterval, err)
time.Sleep(fido2RetryInterval)
}
return nil, trace.Wrap(lastErr)
}
// withRetries wraps callback with retries and error handling for commonly seen
// errors.
func withRetries(callback deviceCallbackFunc) deviceCallbackFunc {
return func(dev FIDODevice, info *deviceInfo, pin string) error {
const maxRetries = 3
var err error
for i := 0; i < maxRetries; i++ {
err = callback(dev, info, pin)
if err == nil {
return nil
}
// Handle errors mapped by go-libfido2.
// ErrOperationDenied happens when fingerprint reading fails (UV=false).
if errors.Is(err, libfido2.ErrOperationDenied) {
fmt.Println("Gesture validation failed, make sure you use a registered fingerprint")
log.Debug("FIDO2: Retrying libfido2 error 'operation denied'")
continue
}
// Handle generic libfido2.Error instances.
var fidoErr libfido2.Error
if !errors.As(err, &fidoErr) {
return err
}
// See https://github.com/Yubico/libfido2/blob/main/src/fido/err.h#L32.
switch fidoErr.Code {
case 60: // FIDO_ERR_UV_BLOCKED, 0x3c
const msg = "" +
"The user verification function in your security key is blocked. " +
"This is likely due to too many failed authentication attempts. " +
"Consult your manufacturer documentation for how to unblock your security key. " +
"Alternatively, you may unblock your device by using it in the Web UI."
return trace.Wrap(err, msg)
case 63: // FIDO_ERR_UV_INVALID, 0x3f
log.Debug("FIDO2: Retrying libfido2 error 63")
continue
default: // Unexpected code.
return err
}
}
return fmt.Errorf("max retry attempts reached: %w", err)
}
}
func withPINHandler(cb deviceCallbackFunc) pinAwareCallbackFunc {
return func(dev FIDODevice, info *deviceInfo, pin string) (requiresPIN bool, err error) {
// Attempt to select a device by running "deviceCallback" on it.
// For most scenarios this works, saving a touch.
err = cb(dev, info, pin)
switch {
case errors.Is(err, libfido2.ErrPinRequired):
// Continued below.
case errors.Is(err, libfido2.ErrUnsupportedOption) && pin == "" && !info.uv && info.clientPinSet:
// The failing option is likely to be "UV", so we handle this the same as
// ErrPinRequired: see if the user selects this device, ask for the PIN and
// try again.
// Continued below.
default:
return
}
// ErrPinRequired means we can't use "deviceCallback" as the selection
// mechanism. Let's run a different operation to ask for a touch.
requiresPIN = true
err = waitForTouch(dev)
if errors.Is(err, libfido2.ErrNoCredentials) {
err = nil // OK, selected successfully
}
return
}
}
func withoutPINHandler(cb deviceCallbackFunc) pinAwareCallbackFunc {
return func(dev FIDODevice, info *deviceInfo, pin string) (bool, error) {
return false, cb(dev, info, pin)
}
}
// nonInteractiveError tags device errors that happen before user interaction.
// These are are usually ignored in the context of selecting devices.
type nonInteractiveError struct {
err error
}
func (e *nonInteractiveError) Error() string {
return e.err.Error()
}
func (e *nonInteractiveError) Is(err error) bool {
_, ok := err.(*nonInteractiveError)
return ok
}
func waitForTouch(dev FIDODevice) error {
// TODO(codingllama): What we really want here is fido_dev_get_touch_begin.
const rpID = "7f364cc0-958c-4177-b3ea-b2d8d7f15d4a" // arbitrary, unlikely to collide with a real RP
const cdh = "00000000000000000000000000000000" // "random", size 32
_, err := dev.Assertion(rpID, []byte(cdh), nil /* credentials */, "", &libfido2.AssertionOpts{
UP: libfido2.True,
})
return err
}
// deviceInfo contains an aggregate of a device's information and capabilities.
// Various fields match options under
// https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorGetInfo.
type deviceInfo struct {
path string
fido2 bool
plat bool
rk bool
clientPinCapable, clientPinSet bool
uv bool
bioEnroll bool
}
// uvCapable returns true for both "uv" and pin-configured devices.
func (di *deviceInfo) uvCapable() bool {
return di.uv || di.clientPinSet
}
func makeDevInfo(path string, info *libfido2.DeviceInfo, fido2 bool) *deviceInfo {
di := &deviceInfo{
path: path,
fido2: fido2,
}
// U2F devices don't respond to dev.Info().
if !fido2 {
return di
}
for _, opt := range info.Options {
// See
// https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorGetInfo.
switch opt.Name {
case "plat":
di.plat = opt.Value == libfido2.True
case "rk":
di.rk = opt.Value == libfido2.True
case "clientPin":
di.clientPinCapable = true
di.clientPinSet = opt.Value == libfido2.True
case "uv":
di.uv = opt.Value == libfido2.True
case "bioEnroll":
di.bioEnroll = opt.Value == libfido2.True
}
}
return di
}