Files
teleport/lib/auth/webauthncli/fido2.go
T
Nic Klaassen b0c1c3e8da feat: early feedback for successful security key taps (#21691)
This commit adds a user message ("Detected security key tap") that is
printed at the earliest opportunity after `tsh` succesfully detects a
security key tap.

This solves a problem users have when there is significant latency in
their connection to their teleport proxy, where there may be zero
visible feedback to the user for upwards of 8 seconds after they tap
their security key.
This often causes users to think that the tap was not detected or
failed, and they attempt to tap again.
This can cause the user to accidentally paste a "yubisneeze" into their
terminal, which is not great from a security perspective.

As an API choice here I added a `TouchAcknowledger` returned by they
`PromptTouch` of the `LoginPrompt` interface.
It may seem like a bit of a weird API, but I did it this way so that all
callers of `PromptTouch` are forced to explicitly acknowledge the touch
or explicitly ignore that return value, they cannot simply forget to ack.

This applies to WebAuthn and Passwordless logins from `tsh`, no changes
are made to Connect.
2023-02-13 23:23:38 +00:00

993 lines
29 KiB
Go

//go:build libfido2
// +build libfido2
// Copyright 2022 Gravitational, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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"
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
)
// 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")
errNoRegisteredCredentials = errors.New("device lacks registered credentials")
errNoUV = errors.New("device lacks PIN or user verification capabilities")
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)
// Cancel mirrors libfido2.Device.Cancel.
Cancel() 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 *wanlib.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", passwordless, uv)
// 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[wanlib.AppIDExtension]; ok {
appID = fmt.Sprint(val)
}
// mu guards the variables below it.
var mu sync.Mutex
var assertionResp *libfido2.Assertion
var usedAppID bool
pathToRPID := &sync.Map{} // map[string]string
filter := func(dev FIDODevice, info *deviceInfo) error {
switch {
case info.u2f && (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
case passwordless: // Nothing else to check
return nil
}
// TODO(codingllama): Kill discoverRPID? It makes behavioral assumptions
// that caused problems before.
// Does the device have a suitable credential?
const pin = ""
actualRPID, err := discoverRPID(dev, info, pin, rpID, appID, allowedCreds)
if err != nil {
return errNoRegisteredCredentials
}
pathToRPID.Store(info.path, actualRPID)
return nil
}
user := opts.User
deviceCallback := func(dev FIDODevice, info *deviceInfo, pin string) error {
actualRPID := rpID
if val, ok := pathToRPID.Load(info.path); ok {
actualRPID = val.(string)
}
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 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 discoverRPID(dev FIDODevice, info *deviceInfo, pin, rpID, appID string, allowedCreds [][]byte) (string, error) {
// The actual hash is not necessary here.
const cdh = "00000000000000000000000000000000"
opts := &libfido2.AssertionOpts{
UP: libfido2.False,
}
for _, id := range []string{rpID, appID} {
if id == "" {
continue
}
switch _, err := dev.Assertion(id, []byte(cdh), allowedCreds, pin, opts); {
// Yubikey4 returns ErrUserPresenceRequired if the credential exists,
// despite the UP=false opts above.
case err == nil, errors.Is(err, libfido2.ErrUserPresenceRequired):
return id, nil
case errors.Is(err, libfido2.ErrNoCredentials):
// Device not registered for RPID=id, keep trying.
default:
log.WithError(err).Debugf("FIDO2: Device %v: attempt RPID = %v", info.path, id)
}
}
return "", libfido2.ErrNoCredentials
}
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 *wanlib.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,
Icon: cc.Response.User.Icon,
}
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.u2f && (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 deviceWithInfo struct {
FIDODevice
info *deviceInfo
}
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 {
// About to select, prompt user.
ackTouch, err := prompt.PromptTouch()
if err != nil {
return trace.Wrap(err)
}
// List/select devices.
cb := withPINHandler(withRetries(deviceCallback))
dev, requiresPIN, err := findAndSelectDevice(ctx, filter, cb)
if err != nil {
return trace.Wrap(err)
}
if err := ackTouch(); err != nil {
return trace.Wrap(err)
}
if !requiresPIN {
return nil
}
// Selected device requires PIN, let's use the prompt and run the callback
// again.
pin, err := prompt.PromptPIN()
switch {
case err != nil:
return trace.Wrap(err)
case pin == "":
return libfido2.ErrPinRequired
}
// Prompt a second touch after reading the PIN.
ackTouch, err = prompt.PromptTouch()
if err != nil {
return trace.Wrap(err)
}
// Run the callback again with the informed PIN.
// selectDevice is used since it correctly deals with cancellation.
cb = withoutPINHandler(withRetries(deviceCallback))
_, err = selectDevice(ctx, pin, dev, cb)
if err != nil {
return trace.Wrap(err)
}
return trace.Wrap(ackTouch())
}
// 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 withInteractiveError(filter deviceFilterFunc, cb pinAwareCallbackFunc) pinAwareCallbackFunc {
return func(dev FIDODevice, info *deviceInfo, pin string) (bool, error) {
filterErr := filter(dev, info)
if filterErr == nil {
return cb(dev, info, pin)
}
// U2F devices tend to cause problems with the waitForTouch strategy below,
// so we filter them silently, as we used to do with all devices in previous
// versions.
if info.u2f {
log.Warnf("FIDO2: Device %v: U2F device filtered due to lack of capabilities", info.path)
return false, &nonInteractiveError{filterErr}
}
// Device got filtered out, let's see if the user chooses it and provide a
// nice error message.
switch waitErr := waitForTouch(dev); {
case errors.Is(waitErr, libfido2.ErrKeepaliveCancel):
// Device not chosen.
return false, &nonInteractiveError{filterErr}
case errors.Is(waitErr, libfido2.ErrNoCredentials):
// Device chosen, error is useful in this case.
default:
log.Warnf("FIDO2: Device %v: unexpected wait error: %q", info.path, waitErr)
}
return false, trace.Wrap(filterErr)
}
}
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
}
func findAndSelectDevice(ctx context.Context, filter deviceFilterFunc, deviceCallback pinAwareCallbackFunc) (dev *deviceWithInfo, requiresPIN bool, err error) {
innerCtx, innerCancel := context.WithCancel(ctx)
// innerCancel handled below.
type devicesResp struct {
devs []*deviceWithInfo
err error
}
// devicesC transport newly-found devices.
// Closed on exit by the device poll goroutine below.
devicesC := make(chan devicesResp)
// Poll for new devices until the user selects one (via touch).
// Runs until innerCtx is closed.
go func() {
defer close(devicesC)
// knownPaths is retained between findDevices calls so only "new" devices
// are returned.
knownPaths := make(map[string]struct{})
ticker := time.NewTicker(FIDO2PollInterval)
defer ticker.Stop()
for {
devs, err := findDevices(knownPaths)
devicesC <- devicesResp{
devs: devs,
err: err,
}
select {
case <-innerCtx.Done():
return
case <-ticker.C:
}
}
}()
type selectResp struct {
dev *deviceWithInfo
requiresPIN bool
err error
}
selectC := make(chan selectResp)
selectGoroutines := 0
defer func() {
innerCancel() // Cancel all goroutines
// Collect select goroutines.
for selectGoroutines > 0 {
<-selectC
selectGoroutines--
}
// Empty devicesC, if blocked.
for {
if _, open := <-devicesC; !open {
break
}
}
}()
cb := withInteractiveError(filter, deviceCallback)
for {
select {
// New devices found.
case resp := <-devicesC:
if resp.err != nil {
return nil, false, trace.Wrap(resp.err)
}
for _, dev := range resp.devs {
dev := dev
selectGoroutines++
go func() {
requiresPIN, err := selectDevice(innerCtx, "" /* pin */, dev, cb)
selectC <- selectResp{
dev: dev,
requiresPIN: requiresPIN,
err: err,
}
}()
}
// User selected device.
case resp := <-selectC:
selectGoroutines--
if errors.Is(resp.err, &nonInteractiveError{}) {
continue
}
return resp.dev, resp.requiresPIN, trace.Wrap(resp.err)
// Timed out.
case <-ctx.Done():
return nil, false, trace.Wrap(ctx.Err())
}
}
}
func findDevices(knownPaths map[string]struct{}) ([]*deviceWithInfo, error) {
locs, err := fidoDeviceLocations()
if err != nil {
return nil, trace.Wrap(err, "device locations")
}
var devs []*deviceWithInfo
for _, loc := range locs {
path := loc.Path
if _, ok := knownPaths[path]; ok {
continue
}
knownPaths[path] = struct{}{}
dev, err := fidoNewDevice(path)
if err != nil {
return nil, trace.Wrap(err, "device %v: open", path)
}
var info *libfido2.DeviceInfo
var u2f bool
const infoAttempts = 3
for i := 0; i < infoAttempts; i++ {
info, err = dev.Info()
switch {
case errors.Is(err, libfido2.ErrNotFIDO2):
u2f = true
case errors.Is(err, libfido2.ErrTX):
// Happens occasionally, let's retry.
fallthrough
case err != nil:
// Unexpected error, retry anyway.
// Note that U2F devices fail in a variety of different ways.
time.Sleep(100 * time.Millisecond)
continue
}
break // err == nil
}
if !u2f && info == nil {
log.Warnf("FIDO2: Device %v: max info attempts reached, treating as U2F", path)
u2f = true
}
log.Debugf("FIDO2: Info for device %v: %#v", path, info)
devs = append(devs, &deviceWithInfo{
FIDODevice: dev,
info: makeDevInfo(path, info, u2f),
})
}
if l := len(devs); l > 0 {
log.Debugf("FIDO2: Found %v new devices", l)
}
return devs, nil
}
func selectDevice(
ctx context.Context,
pin string, dev *deviceWithInfo, cb pinAwareCallbackFunc,
) (requiresPIN bool, err error) {
// Spin a goroutine to run the callback so we can deal with context
// cancellation.
done := make(chan struct{})
go func() {
requiresPIN, err = cb(dev, dev.info, pin)
close(done)
}()
select {
case <-done:
log.Debugf("FIDO2: device %v: selected with err=%v", dev.info.path, err)
case <-ctx.Done():
log.Debugf("FIDO2: device %v: requesting cancel", dev.info.path)
if err := dev.Cancel(); err != nil {
log.Debugf("FIDO2: device %v: cancel errored: %v", dev.info.path, err)
}
// Give the device a grace period to cancel/cleanup, but do not wait
// forever.
timer := time.NewTimer(500 * time.Millisecond)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
log.Warnf("FIDO2: " +
"Timed out waiting for device cancels. " +
"It's possible some devices are left blinking.")
}
return false, trace.Wrap(ctx.Err())
}
// Returns variables captured by goroutine.
return
}
// 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
u2f 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, u2f bool) *deviceInfo {
di := &deviceInfo{
path: path,
u2f: u2f,
}
// U2F devices don't respond to dev.Info().
if u2f {
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
}