From 73728775366b8a139ee20e3dd71f5dd9361a7e0a Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Tue, 15 Mar 2022 17:23:29 -0300 Subject: [PATCH 01/10] Import github.com/keys-pub/go-libfido2 --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 0453c5b48392a..c335a0d83bc27 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/julienschmidt/httprouter v1.3.0 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 + github.com/keys-pub/go-libfido2 v1.5.3-0.20220306005615-8ab03fb1ec27 github.com/kr/pretty v0.3.0 github.com/kr/pty v1.1.8 github.com/kylelemons/godebug v1.1.0 diff --git a/go.sum b/go.sum index a122dbe19d79e..ff96c3f4c0fe7 100644 --- a/go.sum +++ b/go.sum @@ -602,6 +602,8 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALr github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19/go.mod h1:hY+WOq6m2FpbvyrI93sMaypsttvaIL5nhVR92dTMUcQ= +github.com/keys-pub/go-libfido2 v1.5.3-0.20220306005615-8ab03fb1ec27 h1:10nfvqVK4/KINnLT8bDICrRnfguTJ300dNGpW8D2bQo= +github.com/keys-pub/go-libfido2 v1.5.3-0.20220306005615-8ab03fb1ec27/go.mod h1:P0V19qHwJNY0htZwZDe9Ilvs/nokGhdFX7faKFyZ6+U= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.9.5 h1:U+CaK85mrNNb4k8BNOfgJtJ/gr6kswUCFj6miSzVC6M= From e9d72f4359770992393f3ba234667cae6c7deb4f Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Tue, 15 Mar 2022 17:31:29 -0300 Subject: [PATCH 02/10] Implement FIDO2 login --- lib/auth/webauthncli/fido2.go | 557 ++++++++++++++++++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 lib/auth/webauthncli/fido2.go diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go new file mode 100644 index 0000000000000..bb5deea744598 --- /dev/null +++ b/lib/auth/webauthncli/fido2.go @@ -0,0 +1,557 @@ +//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" + "sync" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/fxamacker/cbor/v2" + "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/trace" + "github.com/keys-pub/go-libfido2" + + wanpb "github.com/gravitational/teleport/api/types/webauthn" + wanlib "github.com/gravitational/teleport/lib/auth/webauthn" + log "github.com/sirupsen/logrus" +) + +// FIDO2PollInterval is the poll interval used to check for new FIDO2 devices. +var FIDO2PollInterval = 200 * time.Millisecond + +// 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 + + // Credentials mirrors libfido2.Device.Credentials. + Credentials(rpID string, pin string) ([]*libfido2.Credential, 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 +var fidoNewDevice = func(path string) (FIDODevice, error) { + return libfido2.NewDevice(path) +} + +// LoginPrompt is the user interface for FIDO2Login. +type LoginPrompt interface { + // PromptPIN prompts the user for their PIN. + PromptPIN() (string, error) + // PromptAdditionalTouch prompts the user for an additional security key + // touch. + // Additional touches may be required after PINs and during passwordless flows. + PromptAdditionalTouch() error +} + +// FIDO2Login signs an assertion using available CTAP1 or CTAP2 devices. +// It must be called with a context with timeout, otherwise it can run +// indefinitely. +// The informed user is used to disambiguate credentials in case of passwordless +// logins. +// It returns an MFAAuthenticateResponse and the credential user, if a resident +// credential is used. +func FIDO2Login( + ctx context.Context, + origin, user string, assertion *wanlib.CredentialAssertion, prompt LoginPrompt, +) (*proto.MFAAuthenticateResponse, string, error) { + switch { + case origin == "": + return nil, "", trace.BadParameter("origin required") + case assertion == nil: + return nil, "", trace.BadParameter("assertion required") + case prompt == nil: + return nil, "", trace.BadParameter("prompt required") + case len(assertion.Response.Challenge) == 0: + return nil, "", trace.BadParameter("assertion challenge required") + case assertion.Response.RelyingPartyID == "": + return nil, "", trace.BadParameter("assertion relying party ID required") + } + + allowedCreds := assertion.Response.GetAllowedCredentialIDs() + uv := assertion.Response.UserVerification == protocol.VerificationRequired + passwordless := len(allowedCreds) == 0 && 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 credentialID []byte + var userID []byte + var username string + var usedAppID bool + + if err := runOnFIDO2Devices( + ctx, prompt, passwordless, /* skipAdditionalPrompt */ + /* filter */ func(dev FIDODevice, info *deviceInfo) (bool, error) { + switch { + case uv && !info.uvCapable(): + return false, nil + case passwordless && !info.rk: + return false, nil + case len(allowedCreds) == 0: // Nothing else to check + return true, nil + } + + // Does the device have a suitable credential? + if _, err := dev.Assertion(rpID, ccdHash[:], allowedCreds, "" /* pin */, &libfido2.AssertionOpts{ + UP: libfido2.False, + }); err == nil { + return true, nil + } + + // Try again with the App ID, if present. + if appID == "" { + return false, nil + } + _, err = dev.Assertion(appID, ccdHash[:], allowedCreds, "" /* pin */, &libfido2.AssertionOpts{ + UP: libfido2.False, + }) + return err == nil, nil + }, + /* deviceCallback */ func(dev FIDODevice, info *deviceInfo, pin string) error { + var actualRPID string + var cID []byte + var uID []byte + var uName string + if passwordless { + cred, err := getPasswordlessCredentials(dev, pin, rpID, user) + if err != nil { + return trace.Wrap(err) + } + actualRPID = rpID + cID = cred.ID + uID = cred.User.ID + uName = cred.User.Name + } else { + // TODO(codingllama): Ideally we'd rely on fido_assert_id_ptr/_len. + var err error + actualRPID, cID, err = getMFACredentials(dev, pin, rpID, appID, allowedCreds) + if err != nil { + return trace.Wrap(err) + } + } + + if passwordless { + // Ask for another touch before the assertion, we used the first touch + // in the Credentials() call. + if err := prompt.PromptAdditionalTouch(); err != nil { + return trace.Wrap(err) + } + } + + opts := &libfido2.AssertionOpts{ + UP: libfido2.True, + } + if uv { + opts.UV = libfido2.True + } + resp, err := dev.Assertion(actualRPID, ccdHash[:], [][]byte{cID}, pin, opts) + if err != nil { + return trace.Wrap(err) + } + + // Use the first successful assertion. + // In practice it is very unlikely we'd hit this twice. + mu.Lock() + if assertionResp == nil { + assertionResp = resp + credentialID = cID + userID = uID + username = uName + usedAppID = actualRPID != rpID + } + mu.Unlock() + return nil + }); err != nil { + return nil, "", trace.Wrap(err) + } + + var rawAuthData []byte + if err := cbor.Unmarshal(assertionResp.AuthDataCBOR, &rawAuthData); err != nil { + return nil, "", trace.Wrap(err) + } + + return &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_Webauthn{ + Webauthn: &wanpb.CredentialAssertionResponse{ + Type: string(protocol.PublicKeyCredentialType), + RawId: credentialID, + Response: &wanpb.AuthenticatorAssertionResponse{ + ClientDataJson: ccdJSON, + AuthenticatorData: rawAuthData, + Signature: assertionResp.Sig, + UserHandle: userID, + }, + Extensions: &wanpb.AuthenticationExtensionsClientOutputs{ + AppId: usedAppID, + }, + }, + }, + }, username, nil +} + +func getPasswordlessCredentials(dev FIDODevice, pin, rpID, user string) (*libfido2.Credential, error) { + creds, err := dev.Credentials(rpID, pin) + if err != nil { + return nil, trace.Wrap(err) + } + + switch { + case len(creds) == 0: + return nil, libfido2.ErrNoCredentials + case len(creds) == 1 && user == "": // no need to disambiguate + cred := creds[0] + log.Debugf("FIDO2: Found resident credential for user %q", cred.User.Name) + return cred, nil + case len(creds) > 1 && user == "": // can't disambiguate + return nil, trace.BadParameter("too many credentials found, explicit user required") + } + + duplicateWarning := false + var res *libfido2.Credential + for _, cred := range creds { + if cred.User.Name == user { + // Print information about matched credential, useful for debugging. + // ykman prints user IDs in hex, hence the unusual encoding choice below. + cID := base64.RawURLEncoding.EncodeToString(cred.ID) + uID := cred.User.ID + log.Debugf("FIDO2: Found resident credential for user %v, credential ID (b64) = %v, user ID (hex) = %x", user, cID, uID) + if res == nil { + res = cred + continue // Don't break, we want to warn about duplicates. + } + if !duplicateWarning { + duplicateWarning = true + log.Warnf("Found multiple credentials for %q, using first match", user) + } + } + } + if res == nil { + return nil, trace.BadParameter("no credentials for user %q", user) + } + return res, nil +} + +func getMFACredentials(dev FIDODevice, pin, rpID, appID string, allowedCreds [][]byte) (string, []byte, error) { + // The actual hash is not necessary here. + const cdh = "00000000000000000000000000000000" + + opts := &libfido2.AssertionOpts{ + UP: libfido2.False, + } + actualRPID := rpID + var cID []byte + for _, cred := range allowedCreds { + _, err := dev.Assertion(rpID, []byte(cdh), [][]byte{cred}, pin, opts) + if err == nil { + cID = cred + break + } + + // Try again with the U2F appID, if present. + if appID != "" { + _, err = dev.Assertion(appID, []byte(cdh), [][]byte{cred}, pin, opts) + if err == nil { + actualRPID = appID + cID = cred + break + } + } + } + if len(cID) == 0 { + return "", nil, libfido2.ErrNoCredentials + } + + return actualRPID, cID, nil +} + +type deviceWithInfo struct { + FIDODevice + info *deviceInfo +} + +type deviceFilterFunc func(dev FIDODevice, info *deviceInfo) (ok bool, err error) +type deviceCallbackFunc func(dev FIDODevice, info *deviceInfo, pin string) error + +type runPrompt interface { + PromptPIN() (string, error) + PromptAdditionalTouch() error +} + +func runOnFIDO2Devices( + ctx context.Context, + prompt runPrompt, skipAdditionalPrompt bool, + filter deviceFilterFunc, + deviceCallback deviceCallbackFunc) error { + devices, err := findSuitableDevicesOrTimeout(ctx, filter) + if err != nil { + return trace.Wrap(err) + } + + dev, requiresPIN, err := selectDevice(ctx, devices, deviceCallback) + if err != nil || !requiresPIN { + return trace.Wrap(err) + } + + // Selected device requires PIN, let's use the prompt and run the callback + // again. + pin, err := prompt.PromptPIN() + if err != nil { + return trace.Wrap(err) + } + + // Ask for an additional touch after PIN. + // Works for most flows (except passwordless). + if !skipAdditionalPrompt { + if err := prompt.PromptAdditionalTouch(); err != nil { + return trace.Wrap(err) + } + } + + return trace.Wrap(deviceCallback(dev, dev.info, pin)) +} + +func findSuitableDevicesOrTimeout(ctx context.Context, filter deviceFilterFunc) ([]deviceWithInfo, error) { + ticker := time.NewTicker(FIDO2PollInterval) + defer ticker.Stop() + + knownPaths := make(map[string]struct{}) + for { + devices, err := findSuitableDevices(filter, knownPaths) + if err == nil { + return devices, nil + } + log.WithError(err).Debug("FIDO2: Selecting devices") + + select { + case <-ctx.Done(): + return nil, trace.Wrap(ctx.Err()) + case <-ticker.C: + } + } +} + +func findSuitableDevices(filter deviceFilterFunc, 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 + const infoAttempts = 3 + for i := 0; i < infoAttempts; i++ { + info, err = dev.Info() + switch { + case errors.Is(err, libfido2.ErrTX): + // Happens occasionally, give the device a short grace period and retry. + time.Sleep(1 * time.Millisecond) + continue + case err != nil: // unexpected error + return nil, trace.Wrap(err, "device %v: info", path) + } + break // err == nil + } + if info == nil { + return nil, trace.Wrap(libfido2.ErrTX, "device %v: max info attempts reached", path) + } + log.Debugf("FIDO2: Info for device %v: %#v", path, info) + + di := makeDevInfo(info) + switch ok, err := filter(dev, di); { + case err != nil: + return nil, trace.Wrap(err, "device %v: filter", path) + case !ok: + continue // Skip device. + } + devs = append(devs, deviceWithInfo{FIDODevice: dev, info: di}) + } + + if len(devs) == 0 { + return nil, errors.New("no suitable devices found") + } + + return devs, nil +} + +func selectDevice(ctx context.Context, devices []deviceWithInfo, deviceCallback deviceCallbackFunc) (deviceWithInfo, bool, error) { + // We don't know the PIN in the device selection step, so we are optimistic + // about the fact that there is no PIN. + const pin = "" + + callbackWrapper := 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. + if err = deviceCallback(dev, info, pin); !errors.Is(err, libfido2.ErrPinRequired) { + 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 + + // TODO(codingllama): What we really want here is fido_dev_get_touch_begin. + // Another option is to put the authenticator into U2F mode. + 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 */, pin, &libfido2.AssertionOpts{ + UP: libfido2.True, + }) + if errors.Is(err, libfido2.ErrNoCredentials) { + err = nil // OK, selected successfully + } + return + } + + // No need for goroutine shenanigans with a single device. + if len(devices) == 1 { + dev := devices[0] + requiresPIN, err := callbackWrapper(dev, dev.info, pin) + return dev, requiresPIN, trace.Wrap(err) + } + + type selectResp struct { + dev deviceWithInfo + requiresPIN bool + err error + } + + respC := make(chan selectResp) + numGoroutines := len(devices) + for _, dev := range devices { + dev := dev + go func() { + requiresPIN, err := callbackWrapper(dev, dev.info, pin) + respC <- selectResp{ + dev: dev, + requiresPIN: requiresPIN, + err: err, + } + }() + } + + // Stop on timeout or first interaction, whatever comes first wins and gets + // returned. + var resp selectResp + select { + case <-ctx.Done(): + resp.err = ctx.Err() + case resp = <-respC: + numGoroutines-- + } + + // Cancel ongoing operations and wait for goroutines to complete. + for _, dev := range devices { + if dev == resp.dev { + continue + } + if err := dev.Cancel(); err != nil { + log.WithError(err).Tracef("FIDO2: Device cancel") + } + } + for i := 0; i < numGoroutines; i++ { + cancelResp := <-respC + if err := cancelResp.err; err != nil && !errors.Is(err, libfido2.ErrKeepaliveCancel) { + log.WithError(err).Debugf("FIDO2: Device errored on cancel") + } + } + + return resp.dev, resp.requiresPIN, trace.Wrap(resp.err) +} + +// deviceInfo contains an aggregate of a device's informations 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 { + plat bool + rk bool + clientPinCapable, clientPinSet bool + uv bool +} + +// uvCapable returns true for both "uv" and pin-configured devices. +func (di *deviceInfo) uvCapable() bool { + return di.uv || di.clientPinSet +} + +func makeDevInfo(info *libfido2.DeviceInfo) *deviceInfo { + di := &deviceInfo{} + 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 + } + } + return di +} From 253158c97cedcc13a66cc2a720b61928f7dccc22 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Tue, 15 Mar 2022 18:24:44 -0300 Subject: [PATCH 03/10] Add login tests --- lib/auth/webauthncli/export_fido2_test.go | 23 + lib/auth/webauthncli/fido2_test.go | 886 ++++++++++++++++++++++ 2 files changed, 909 insertions(+) create mode 100644 lib/auth/webauthncli/export_fido2_test.go create mode 100644 lib/auth/webauthncli/fido2_test.go diff --git a/lib/auth/webauthncli/export_fido2_test.go b/lib/auth/webauthncli/export_fido2_test.go new file mode 100644 index 0000000000000..36b84e5ff42a3 --- /dev/null +++ b/lib/auth/webauthncli/export_fido2_test.go @@ -0,0 +1,23 @@ +//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 + +var ( + FIDODeviceLocations = &fidoDeviceLocations + FIDONewDevice = &fidoNewDevice +) diff --git a/lib/auth/webauthncli/fido2_test.go b/lib/auth/webauthncli/fido2_test.go new file mode 100644 index 0000000000000..fb2f4812fc93c --- /dev/null +++ b/lib/auth/webauthncli/fido2_test.go @@ -0,0 +1,886 @@ +//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_test + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/fxamacker/cbor/v2" + "github.com/google/go-cmp/cmp" + "github.com/gravitational/teleport/lib/auth/mocku2f" + "github.com/keys-pub/go-libfido2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + wanpb "github.com/gravitational/teleport/api/types/webauthn" + wanlib "github.com/gravitational/teleport/lib/auth/webauthn" + wancli "github.com/gravitational/teleport/lib/auth/webauthncli" +) + +var makeCredentialAuthDataRaw, makeCredentialAuthDataCBOR, makeCredentialSig []byte +var assertionAuthDataRaw, assertionAuthDataCBOR, assertionSig []byte + +func init() { + // Initialize arrays with random data, but use realistic sizes. + // YMMV. + makeCredentialAuthDataRaw = make([]byte, 37) + makeCredentialSig = make([]byte, 70) + assertionAuthDataRaw = make([]byte, 37) + assertionSig = make([]byte, 70) + for _, b := range [][]byte{ + makeCredentialAuthDataRaw, + makeCredentialSig, + assertionAuthDataRaw, + assertionSig, + } { + if _, err := rand.Read(b); err != nil { + panic(err) + } + } + + // Returned authData is CBOR-encoded, so let's do that. + pairs := []*[]byte{ + &makeCredentialAuthDataRaw, &makeCredentialAuthDataCBOR, + &assertionAuthDataRaw, &assertionAuthDataCBOR, + } + for i := 0; i < len(pairs); i += 2 { + dataRaw := pairs[i] + dataCBOR := pairs[i+1] + + res, err := cbor.Marshal(*dataRaw) + if err != nil { + panic(err) + } + *dataCBOR = res + } +} + +type noopPrompt struct{} + +func (p noopPrompt) PromptPIN() (string, error) { + return "", nil +} + +func (p noopPrompt) PromptAdditionalTouch() error { + return nil +} + +func TestFIDO2Login(t *testing.T) { + resetFIDO2AfterTests(t) + wancli.FIDO2PollInterval = 1 * time.Millisecond // run fast on tests + + const rpID = "example.com" + const appID = "https://example.com" + const origin = "https://example.com" + + authOpts := []libfido2.Option{ + {Name: "rk", Value: "true"}, + {Name: "up", Value: "true"}, + {Name: "plat", Value: "false"}, + {Name: "clientPin", Value: "false"}, // supported but unset + } + pinOpts := []libfido2.Option{ + {Name: "rk", Value: "true"}, + {Name: "up", Value: "true"}, + {Name: "plat", Value: "false"}, + {Name: "clientPin", Value: "true"}, // supported and configured + } + bioOpts := []libfido2.Option{ + {Name: "rk", Value: "true"}, + {Name: "up", Value: "true"}, + {Name: "uv", Value: "true"}, + {Name: "plat", Value: "false"}, + {Name: "alwaysUv", Value: "true"}, + {Name: "bioEnroll", Value: "true"}, // supported and configured + {Name: "clientPin", Value: "true"}, // supported and configured + } + + // User IDs and names for resident credentials / passwordless. + const llamaName = "llama" + const alpacaName = "alpaca" + var llamaID = make([]byte, 16) + var alpacaID = make([]byte, 16) + for _, b := range [][]byte{llamaID, alpacaID} { + _, err := rand.Read(b) + require.NoError(t, err, "Read failed") + } + + // auth1 is a FIDO2 authenticator without a PIN configured. + auth1 := mustNewFIDO2Device("/path1", "" /* pin */, &libfido2.DeviceInfo{ + Options: authOpts, + }) + // pin1 is a FIDO2 authenticator with a PIN. + pin1 := mustNewFIDO2Device("/pin1", "supersecretpinllama", &libfido2.DeviceInfo{ + Options: pinOpts, + }) + // pin2 is a FIDO2 authenticator with a PIN. + pin2 := mustNewFIDO2Device("/pin2", "supersecretpin2", &libfido2.DeviceInfo{ + Options: pinOpts, + }) + // pin3 is a FIDO2 authenticator with a PIN and resident credentials. + pin3 := mustNewFIDO2Device("/pin3", "supersecretpin3", &libfido2.DeviceInfo{ + Options: pinOpts, + }, &libfido2.Credential{ + User: libfido2.User{ + ID: alpacaID, + Name: alpacaName, + }, + }) + // bio1 is a biometric authenticator. + bio1 := mustNewFIDO2Device("/bio1", "supersecretBIOpin", &libfido2.DeviceInfo{ + Options: bioOpts, + }) + // bio2 is a biometric authenticator with configured resident credentials. + bio2 := mustNewFIDO2Device("/bio2", "supersecretBIO2pin", &libfido2.DeviceInfo{ + Options: bioOpts, + }, &libfido2.Credential{ + User: libfido2.User{ + ID: llamaID, + Name: llamaName, + }, + }, &libfido2.Credential{ + User: libfido2.User{ + ID: alpacaID, + Name: alpacaName, + }, + }) + // legacy1 is an authenticator registered using the U2F App ID. + legacy1 := mustNewFIDO2Device("/legacy1", "" /* pin */, &libfido2.DeviceInfo{Options: authOpts}) + legacy1.wantRPID = appID + + challenge, err := protocol.CreateChallenge() + require.NoError(t, err, "CreateChallenge failed") + + baseAssertion := &wanlib.CredentialAssertion{ + Response: protocol.PublicKeyCredentialRequestOptions{ + Challenge: challenge, + RelyingPartyID: rpID, + AllowedCredentials: []protocol.CredentialDescriptor{}, + UserVerification: protocol.VerificationDiscouraged, + Extensions: map[string]interface{}{}, + }, + } + + tests := []struct { + name string + timeout time.Duration + fido2 *fakeFIDO2 + setUP func() + user string + createAssertion func() *wanlib.CredentialAssertion + prompt wancli.LoginPrompt + // assertResponse and wantErr are mutually exclusive. + assertResponse func(t *testing.T, resp *wanpb.CredentialAssertionResponse) + wantErr string + }{ + { + name: "single device", + fido2: newFakeFIDO2(auth1), + setUP: func() { + go func() { + // Simulate delayed user press. + time.Sleep(100 * time.Millisecond) + auth1.setUP() + }() + }, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: auth1.credentialID()}, + } + return &cp + }, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, auth1.credentialID(), resp.RawId, "RawId mismatch") + }, + }, + { + name: "pin protected device", + fido2: newFakeFIDO2(pin1), + setUP: pin1.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: pin1.credentialID()}, + } + return &cp + }, + }, + { + name: "biometric device", + fido2: newFakeFIDO2(bio1), + setUP: bio1.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: bio1.credentialID()}, + } + return &cp + }, + }, + { + name: "legacy device (AppID)", + fido2: newFakeFIDO2(legacy1), + setUP: legacy1.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: legacy1.credentialID()}, + } + cp.Response.Extensions = protocol.AuthenticationExtensions{ + wanlib.AppIDExtension: appID, + } + return &cp + }, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.True(t, resp.Extensions.AppId, "AppID mismatch") + }, + }, + { + name: "multiple valid devices", + fido2: newFakeFIDO2( + auth1, + pin1, + bio1, + legacy1, + ), + setUP: bio1.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: auth1.credentialID()}, + {CredentialID: pin1.credentialID()}, + {CredentialID: bio1.credentialID()}, + {CredentialID: legacy1.credentialID()}, + } + cp.Response.Extensions = protocol.AuthenticationExtensions{ + wanlib.AppIDExtension: appID, + } + return &cp + }, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, bio1.credentialID(), resp.RawId, "RawId mismatch (want bio1)") + }, + }, + { + name: "multiple devices filtered", + fido2: newFakeFIDO2( + auth1, // allowed + pin1, // not allowed + bio1, + legacy1, // doesn't match RPID or AppID + ), + setUP: auth1.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: auth1.credentialID()}, + {CredentialID: bio1.credentialID()}, + {CredentialID: legacy1.credentialID()}, + } + cp.Response.Extensions = protocol.AuthenticationExtensions{ + wanlib.AppIDExtension: "https://badexample.com", + } + return &cp + }, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, auth1.credentialID(), resp.RawId, "RawId mismatch (want auth1)") + }, + }, + { + name: "multiple pin devices", + fido2: newFakeFIDO2( + auth1, + pin1, pin2, + bio1, + ), + setUP: pin2.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: auth1.credentialID()}, + {CredentialID: pin1.credentialID()}, + {CredentialID: pin2.credentialID()}, + {CredentialID: bio1.credentialID()}, + } + return &cp + }, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, pin2.credentialID(), resp.RawId, "RawId mismatch (want pin2)") + }, + }, + { + name: "NOK no devices plugged times out", + timeout: 10 * time.Millisecond, + fido2: newFakeFIDO2(), + setUP: func() {}, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: auth1.credentialID()}, + } + return &cp + }, + wantErr: context.DeadlineExceeded.Error(), + }, + { + name: "NOK no devices touched times out", + timeout: 10 * time.Millisecond, + fido2: newFakeFIDO2(auth1, pin1, bio1, legacy1), + setUP: func() {}, // no interaction + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{ + {CredentialID: auth1.credentialID()}, + {CredentialID: pin1.credentialID()}, + {CredentialID: bio1.credentialID()}, + } + return &cp + }, + wantErr: context.DeadlineExceeded.Error(), + }, + { + name: "passwordless pin", + fido2: newFakeFIDO2(pin3), + setUP: pin3.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = nil + cp.Response.UserVerification = protocol.VerificationRequired + return &cp + }, + prompt: pin3, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, pin3.credentials[0].ID, resp.RawId, "RawId mismatch (want %q resident credential)", alpacaName) + assert.Equal(t, alpacaID, resp.Response.UserHandle, "UserHandle mismatch (want %q)", alpacaName) + }, + }, + { + name: "passwordless biometric (llama)", + fido2: newFakeFIDO2(bio2), + setUP: bio2.setUP, + user: llamaName, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = nil + cp.Response.UserVerification = protocol.VerificationRequired + return &cp + }, + prompt: bio2, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, bio2.credentials[0].ID, resp.RawId, "RawId mismatch (want %q resident credential)", llamaName) + assert.Equal(t, llamaID, resp.Response.UserHandle, "UserHandle mismatch (want %q)", llamaName) + }, + }, + { + name: "passwordless biometric (alpaca)", + fido2: newFakeFIDO2(bio2), + setUP: bio2.setUP, + user: alpacaName, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = nil + cp.Response.UserVerification = protocol.VerificationRequired + return &cp + }, + prompt: bio2, + assertResponse: func(t *testing.T, resp *wanpb.CredentialAssertionResponse) { + assert.Equal(t, bio2.credentials[1].ID, resp.RawId, "RawId mismatch (want %q resident credential)", alpacaName) + assert.Equal(t, alpacaID, resp.Response.UserHandle, "UserHandle mismatch (want %q)", alpacaName) + }, + }, + { + name: "NOK passwordless no credentials", + fido2: newFakeFIDO2(bio1), + setUP: bio1.setUP, + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = nil + cp.Response.UserVerification = protocol.VerificationRequired + return &cp + }, + prompt: bio1, + wantErr: libfido2.ErrNoCredentials.Error(), + }, + { + name: "NOK passwordless ambiguous user", + fido2: newFakeFIDO2(bio2), + setUP: bio2.setUP, + user: "", // >1 resident credential, can't pick unambiguous username. + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = nil + cp.Response.UserVerification = protocol.VerificationRequired + return &cp + }, + prompt: bio2, + wantErr: "explicit user required", + }, + { + name: "NOK passwordless unknown user", + fido2: newFakeFIDO2(bio2), + setUP: bio2.setUP, + user: "camel", // unknown + createAssertion: func() *wanlib.CredentialAssertion { + cp := *baseAssertion + cp.Response.AllowedCredentials = nil + cp.Response.UserVerification = protocol.VerificationRequired + return &cp + }, + prompt: bio2, + wantErr: "no credentials for user", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.fido2.setCallbacks() + test.setUP() + + timeout := test.timeout + if timeout == 0 { + timeout = 1 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + prompt := test.prompt + if prompt == nil { + prompt = noopPrompt{} + } + + mfaResp, _, err := wancli.FIDO2Login(ctx, origin, test.user, test.createAssertion(), prompt) + switch { + case test.wantErr != "" && err == nil: + t.Fatalf("FIDO2Login returned err = nil, wantErr %q", test.wantErr) + case test.wantErr != "": + require.Contains(t, err.Error(), test.wantErr, "FIDO2Login returned err = %q, wantErr %q", err, test.wantErr) + return + default: + require.NoError(t, err, "FIDO2Login failed") + require.NotNil(t, mfaResp, "mfaResp nil") + } + + // Do a few baseline checks, tests can assert further. + got := mfaResp.GetWebauthn() + require.NotNil(t, got, "assertion response nil") + require.NotNil(t, got.Response, "authenticator response nil") + assert.NotNil(t, got.Response.ClientDataJson, "ClientDataJSON nil") + want := &wanpb.CredentialAssertionResponse{ + Type: string(protocol.PublicKeyCredentialType), + RawId: got.RawId, + Response: &wanpb.AuthenticatorAssertionResponse{ + ClientDataJson: got.Response.ClientDataJson, + AuthenticatorData: assertionAuthDataRaw, + Signature: assertionSig, + UserHandle: got.Response.UserHandle, + }, + Extensions: got.Extensions, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("FIDO2Login()/CredentialAssertionResponse mismatch (-want +got):\n%v", diff) + } + + if test.assertResponse != nil { + test.assertResponse(t, got) + } + }) + } +} + +func TestFIDO2Login_errors(t *testing.T) { + resetFIDO2AfterTests(t) + + // Make sure we won't call the real libfido2. + f2 := newFakeFIDO2() + f2.setCallbacks() + + const origin = "https://example.com" + const user = "" + okAssertion := &wanlib.CredentialAssertion{ + Response: protocol.PublicKeyCredentialRequestOptions{ + Challenge: make([]byte, 32), + RelyingPartyID: "example.com", + AllowedCredentials: []protocol.CredentialDescriptor{ + {Type: protocol.PublicKeyCredentialType, CredentialID: []byte{1, 2, 3, 4, 5}}, + }, + }, + } + var prompt noopPrompt + + nilChallengeAssertion := *okAssertion + nilChallengeAssertion.Response.Challenge = nil + + emptyRPIDAssertion := *okAssertion + emptyRPIDAssertion.Response.RelyingPartyID = "" + + tests := []struct { + name string + origin string + assertion *wanlib.CredentialAssertion + prompt wancli.LoginPrompt + wantErr string + }{ + { + name: "ok - timeout", // check that good params are good + origin: origin, + assertion: okAssertion, + prompt: prompt, + wantErr: context.DeadlineExceeded.Error(), + }, + { + name: "nil origin", + assertion: okAssertion, + prompt: prompt, + wantErr: "origin", + }, + { + name: "nil assertion", + origin: origin, + prompt: prompt, + wantErr: "assertion required", + }, + { + name: "assertion without challenge", + origin: origin, + assertion: &nilChallengeAssertion, + prompt: prompt, + wantErr: "challenge", + }, + { + name: "assertion without RPID", + origin: origin, + assertion: &emptyRPIDAssertion, + prompt: prompt, + wantErr: "relying party ID", + }, + { + name: "nil prompt", + origin: origin, + assertion: okAssertion, + wantErr: "prompt", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + _, _, err := wancli.FIDO2Login(ctx, test.origin, user, test.assertion, test.prompt) + require.Error(t, err, "FIDO2Login returned err = nil, want %q", test.wantErr) + assert.Contains(t, err.Error(), test.wantErr, "FIDO2Login returned err = %q, want %q", err, test.wantErr) + }) + } +} + +func resetFIDO2AfterTests(t *testing.T) { + pollInterval := wancli.FIDO2PollInterval + devLocations := wancli.FIDODeviceLocations + newDevice := wancli.FIDONewDevice + t.Cleanup(func() { + wancli.FIDO2PollInterval = pollInterval + wancli.FIDODeviceLocations = devLocations + wancli.FIDONewDevice = newDevice + }) +} + +type fakeFIDO2 struct { + locs []*libfido2.DeviceLocation + devices map[string]*fakeFIDO2Device +} + +func newFakeFIDO2(devs ...*fakeFIDO2Device) *fakeFIDO2 { + f := &fakeFIDO2{ + devices: make(map[string]*fakeFIDO2Device), + } + for _, dev := range devs { + if _, ok := f.devices[dev.path]; ok { + panic(fmt.Sprintf("Duplicate device path registered: %q", dev.path)) + } + f.locs = append(f.locs, &libfido2.DeviceLocation{ + Path: dev.path, + }) + f.devices[dev.path] = dev + } + return f +} + +func (f *fakeFIDO2) setCallbacks() { + *wancli.FIDODeviceLocations = f.newMeteredDeviceLocations() + *wancli.FIDONewDevice = f.NewDevice +} + +func (f *fakeFIDO2) newMeteredDeviceLocations() func() ([]*libfido2.DeviceLocation, error) { + i := 0 + return func() ([]*libfido2.DeviceLocation, error) { + // Delay showing devices for a while to exercise polling. + i++ + const minLoops = 2 + if i < minLoops { + return nil, nil + } + return f.locs, nil + } +} + +func (f *fakeFIDO2) NewDevice(path string) (wancli.FIDODevice, error) { + if dev, ok := f.devices[path]; ok { + return dev, nil + } + // go-libfido2 doesn't actually error here, but we do for simplicity. + return nil, errors.New("not found") +} + +type fakeFIDO2Device struct { + path string + info *libfido2.DeviceInfo + pin string + credentials []*libfido2.Credential + + // wantRPID may be set directly to enable RPID checks on Assertion. + wantRPID string + // format may be set directly to change the attestation format. + format string + + key *mocku2f.Key + pubKey []byte + + // mu and cond guard up and cancel. + mu sync.Mutex + cond *sync.Cond + up, cancel bool +} + +func mustNewFIDO2Device(path, pin string, info *libfido2.DeviceInfo, creds ...*libfido2.Credential) *fakeFIDO2Device { + dev, err := newFIDO2Device(path, pin, info, creds...) + if err != nil { + panic(err) + } + return dev +} + +func newFIDO2Device(path, pin string, info *libfido2.DeviceInfo, creds ...*libfido2.Credential) (*fakeFIDO2Device, error) { + key, err := mocku2f.Create() + if err != nil { + return nil, err + } + + pubKeyCBOR, err := wanlib.U2FKeyToCBOR(&key.PrivateKey.PublicKey) + if err != nil { + return nil, err + } + + for _, cred := range creds { + cred.ID = make([]byte, 16) // somewhat arbitrary + if _, err := rand.Read(cred.ID); err != nil { + return nil, err + } + cred.Type = libfido2.ES256 + } + + d := &fakeFIDO2Device{ + path: path, + pin: pin, + credentials: creds, + format: "packed", + info: info, + key: key, + pubKey: pubKeyCBOR, + } + d.cond = sync.NewCond(&d.mu) + return d, nil +} + +func (f *fakeFIDO2Device) PromptPIN() (string, error) { + return f.pin, nil +} + +func (f *fakeFIDO2Device) PromptAdditionalTouch() error { + f.setUP() + return nil +} + +func (f *fakeFIDO2Device) credentialID() []byte { + return f.key.KeyHandle +} + +func (f *fakeFIDO2Device) cert() []byte { + return f.key.Cert +} + +func (f *fakeFIDO2Device) Info() (*libfido2.DeviceInfo, error) { + return f.info, nil +} + +func (f *fakeFIDO2Device) setUP() { + f.mu.Lock() + f.up = true + f.mu.Unlock() + f.cond.Broadcast() +} + +func (f *fakeFIDO2Device) Cancel() error { + f.mu.Lock() + f.cancel = true + f.mu.Unlock() + f.cond.Broadcast() + return nil +} + +func (f *fakeFIDO2Device) Credentials(rpID string, pin string) ([]*libfido2.Credential, error) { + if f.pin != "" { + if err := f.validatePIN(pin); err != nil { + return nil, err + } + } + return f.credentials, nil +} + +func (f *fakeFIDO2Device) Assertion( + rpID string, + clientDataHash []byte, + credentialIDs [][]byte, + pin string, + opts *libfido2.AssertionOpts, +) (*libfido2.Assertion, error) { + switch { + case rpID == "": + return nil, errors.New("rp.ID required") + case f.wantRPID != "" && f.wantRPID != rpID: + return nil, libfido2.ErrNoCredentials + case len(clientDataHash) == 0: + return nil, errors.New("clientDataHash required") + case opts.UV == libfido2.False: // can only be empty or true + return nil, libfido2.ErrUnsupportedOption + } + + // Validate PIN only if present and UP is required. + // This is in line with how current YubiKeys behave. + privilegedAccess := f.isBio() + if pin != "" && opts.UP == libfido2.True { + if err := f.validatePIN(pin); err != nil { + return nil, err + } + privilegedAccess = true + } + + // Is our credential allowed? + foundCredential := false + for _, cred := range credentialIDs { + if bytes.Equal(cred, f.key.KeyHandle) { + foundCredential = true + break + } + + // Check resident credentials if we are properly authorized. + if !privilegedAccess { + continue + } + for _, resident := range f.credentials { + if bytes.Equal(cred, resident.ID) { + foundCredential = true + break + } + } + if foundCredential { + break + } + } + explicitCreds := len(credentialIDs) > 0 + if explicitCreds && !foundCredential { + return nil, libfido2.ErrNoCredentials + } + + if err := f.maybeLockUntilInteraction(opts.UP == libfido2.True); err != nil { + return nil, err + } + + // Pick a credential for the user? + switch { + case !explicitCreds && privilegedAccess && len(f.credentials) > 0: + // OK, at this point an authenticator picks a credential for the user. + case !explicitCreds: + return nil, libfido2.ErrNoCredentials + } + + return &libfido2.Assertion{ + AuthDataCBOR: assertionAuthDataCBOR, + Sig: assertionSig, + }, nil +} + +func (f *fakeFIDO2Device) validatePIN(pin string) error { + switch { + case f.isBio() && pin == "": // OK, biometric check supersedes PIN. + case f.pin != "" && pin == "": + return libfido2.ErrPinRequired + case f.pin != "" && f.pin != pin: + return libfido2.ErrPinInvalid + } + return nil +} + +func (f *fakeFIDO2Device) hasUV() bool { + for _, opt := range f.info.Options { + if opt.Name == "uv" { + return opt.Value == libfido2.True + } + } + return false +} + +func (f *fakeFIDO2Device) isBio() bool { + for _, opt := range f.info.Options { + if opt.Name == "bioEnroll" { + return opt.Value == libfido2.True + } + } + return false +} + +func (f *fakeFIDO2Device) maybeLockUntilInteraction(up bool) error { + if !up { + return nil // without UserPresence it doesn't lock. + } + + // Lock until we get a touch or a cancel. + f.mu.Lock() + for !f.up && !f.cancel { + f.cond.Wait() + } + + // Record/reset state. + isCancel := f.cancel + f.up = false + f.cancel = false + + if isCancel { + f.mu.Unlock() + return libfido2.ErrKeepaliveCancel + } + f.mu.Unlock() + + return nil +} From df63a4707008a78f574913b5d985d3350954c4af Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Tue, 15 Mar 2022 17:34:16 -0300 Subject: [PATCH 04/10] Implement FIDO2 registration --- lib/auth/webauthncli/fido2.go | 229 ++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index bb5deea744598..8373d792d2835 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -28,6 +28,7 @@ import ( "time" "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/protocol/webauthncose" "github.com/fxamacker/cbor/v2" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/trace" @@ -52,6 +53,15 @@ type FIDODevice interface { // Credentials mirrors libfido2.Device.Credentials. Credentials(rpID string, pin string) ([]*libfido2.Credential, 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, @@ -77,6 +87,16 @@ type LoginPrompt interface { PromptAdditionalTouch() error } +// RegisterPrompt is the user interface for FIDO2Register. +type RegisterPrompt interface { + // PromptPIN prompts the user for their PIN. + PromptPIN() (string, error) + // PromptAdditionalTouch prompts the user for an additional security key + // touch. + // Additional touches may be required after PINs. + PromptAdditionalTouch() error +} + // FIDO2Login signs an assertion using available CTAP1 or CTAP2 devices. // It must be called with a context with timeout, otherwise it can run // indefinitely. @@ -315,6 +335,215 @@ func getMFACredentials(dev FIDODevice, pin, rpID, appID string, allowedCreds [][ return actualRPID, cID, nil } +// FIDO2Register registers a new credential using available CTAP1 or CTAP2 +// devices. +// It must be called with a context with timeout, otherwise it can run +// indefinitely. +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 cc == nil: + return nil, trace.BadParameter("credential creation required") + case prompt == nil: + return nil, trace.BadParameter("prompt required") + case len(cc.Response.Challenge) == 0: + return nil, trace.BadParameter("credential creation challenge required") + case cc.Response.RelyingParty.ID == "": + return nil, trace.BadParameter("credential creation relying party ID required") + } + + rrk := cc.Response.AuthenticatorSelection.RequireResidentKey != nil && *cc.Response.AuthenticatorSelection.RequireResidentKey + if rrk { + // Be more pedantic with resident keys, some of this info gets recorded with + // the credential. + switch { + case len(cc.Response.RelyingParty.Name) == 0: + return nil, trace.BadParameter("relying party name required for resident credential") + case len(cc.Response.User.Name) == 0: + return nil, trace.BadParameter("user name required for resident credential") + case len(cc.Response.User.DisplayName) == 0: + return nil, trace.BadParameter("user display name required for resident credential") + case len(cc.Response.User.ID) == 0: + return nil, trace.BadParameter("user ID required for resident credential") + } + } + + // Can we create ES256 keys? + 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 + + if err := runOnFIDO2Devices( + ctx, prompt, false, /* skipAdditionalPrompt */ + /* filter */ func(dev FIDODevice, info *deviceInfo) (bool, error) { + switch { + case (plat && !info.plat) || (rrk && !info.rk) || (uv && !info.uvCapable()): + return false, nil + case len(excludeList) == 0: + return true, nil + } + + // Does the device hold an excluded credential? + switch _, err := dev.Assertion(rp.ID, ccdHash[:], excludeList, "" /* pin */, &libfido2.AssertionOpts{ + UP: libfido2.False, + }); { + case errors.Is(err, libfido2.ErrNoCredentials): + return true, nil + case err == nil: + return false, nil + default: // unexpected error + return false, trace.Wrap(err) + } + }, + /* 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 + }); 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 From 8f624b9035fc29bb8a51c43e8ca810951718fc35 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Tue, 15 Mar 2022 17:34:27 -0300 Subject: [PATCH 05/10] Add registration tests --- lib/auth/webauthncli/fido2_test.go | 552 +++++++++++++++++++++++++++++ 1 file changed, 552 insertions(+) diff --git a/lib/auth/webauthncli/fido2_test.go b/lib/auth/webauthncli/fido2_test.go index fb2f4812fc93c..c3c083f6f5630 100644 --- a/lib/auth/webauthncli/fido2_test.go +++ b/lib/auth/webauthncli/fido2_test.go @@ -28,6 +28,7 @@ import ( "time" "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/protocol/webauthncose" "github.com/fxamacker/cbor/v2" "github.com/google/go-cmp/cmp" "github.com/gravitational/teleport/lib/auth/mocku2f" @@ -595,6 +596,493 @@ func TestFIDO2Login_errors(t *testing.T) { } } +func TestFIDO2Register(t *testing.T) { + resetFIDO2AfterTests(t) + + const rpID = "example.com" + const origin = "https://example.com" + + authOpts := []libfido2.Option{ + {Name: "rk", Value: "true"}, + {Name: "up", Value: "true"}, + {Name: "plat", Value: "false"}, + {Name: "clientPin", Value: "false"}, // supported but unset + } + pinOpts := []libfido2.Option{ + {Name: "rk", Value: "true"}, + {Name: "up", Value: "true"}, + {Name: "plat", Value: "false"}, + {Name: "clientPin", Value: "true"}, // supported and configured + } + + // auth1 is a FIDO2 authenticator without a PIN configured. + auth1 := mustNewFIDO2Device("/path1", "" /* pin */, &libfido2.DeviceInfo{ + Options: authOpts, + }) + // pin1 is a FIDO2 authenticator with a PIN. + pin1 := mustNewFIDO2Device("/pin1", "supersecretpinllama", &libfido2.DeviceInfo{ + Options: pinOpts, + }) + // pin2 is a FIDO2 authenticator with a PIN. + pin2 := mustNewFIDO2Device("/pin2", "supersecretpin2", &libfido2.DeviceInfo{ + Options: pinOpts, + }) + // bio1 is a biometric authenticator. + bio1 := mustNewFIDO2Device("/bio1", "supersecretBIOpin", &libfido2.DeviceInfo{ + Options: []libfido2.Option{ + {Name: "rk", Value: "true"}, + {Name: "up", Value: "true"}, + {Name: "uv", Value: "true"}, + {Name: "plat", Value: "false"}, + {Name: "alwaysUv", Value: "true"}, + {Name: "bioEnroll", Value: "true"}, // supported and configured + {Name: "clientPin", Value: "true"}, // supported and configured + }, + }) + // u2f1 is an authenticator that uses fido-u2f attestation. + u2f1 := mustNewFIDO2Device("/u2f1", "" /* pin */, &libfido2.DeviceInfo{Options: authOpts}) + u2f1.format = "fido-u2f" + // none1 is an authenticator that returns no attestation data. + none1 := mustNewFIDO2Device("/none1", "" /* pin */, &libfido2.DeviceInfo{Options: authOpts}) + none1.format = "none" + + challenge, err := protocol.CreateChallenge() + require.NoError(t, err, "CreateChallenge failed") + + baseCC := &wanlib.CredentialCreation{ + Response: protocol.PublicKeyCredentialCreationOptions{ + Challenge: challenge, + RelyingParty: protocol.RelyingPartyEntity{ + ID: rpID, + }, + Parameters: []protocol.CredentialParameter{ + {Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}, + }, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + UserVerification: protocol.VerificationDiscouraged, + }, + Attestation: protocol.PreferDirectAttestation, + }, + } + pwdlessCC := *baseCC + pwdlessCC.Response.RelyingParty.Name = "Teleport" + pwdlessCC.Response.User = protocol.UserEntity{ + CredentialEntity: protocol.CredentialEntity{ + Name: "llama", + }, + DisplayName: "Llama", + ID: []byte{1, 2, 3, 4, 5}, // arbitrary + } + pwdlessRRK := true + pwdlessCC.Response.AuthenticatorSelection.RequireResidentKey = &pwdlessRRK + pwdlessCC.Response.AuthenticatorSelection.UserVerification = protocol.VerificationRequired + + tests := []struct { + name string + timeout time.Duration + fido2 *fakeFIDO2 + setUP func() + createCredential func() *wanlib.CredentialCreation + prompt wancli.RegisterPrompt + wantErr error + assertResponse func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) + }{ + { + name: "single device, packed attestation", + fido2: newFakeFIDO2(auth1), + setUP: auth1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + assert.Equal(t, auth1.credentialID(), ccr.RawId, "RawId mismatch") + + // Assert attestation algorithm and signature. + require.Equal(t, "packed", attObj.Format, "attestation format mismatch") + assert.Equal(t, int64(webauthncose.AlgES256), attObj.AttStatement["alg"], "attestation alg mismatch") + assert.Equal(t, makeCredentialSig, attObj.AttStatement["sig"], "attestation sig mismatch") + + // Assert attestation certificate. + x5cInterface := attObj.AttStatement["x5c"] + x5c, ok := x5cInterface.([]interface{}) + require.True(t, ok, "attestation x5c type mismatch (got %T)", x5cInterface) + assert.Len(t, x5c, 1, "attestation x5c length mismatch") + assert.Equal(t, auth1.cert(), x5c[0], "attestation cert mismatch") + }, + }, + { + name: "fido-u2f attestation", + fido2: newFakeFIDO2(u2f1), + setUP: u2f1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + // Assert attestation signature. + require.Equal(t, "fido-u2f", attObj.Format, "attestation format mismatch") + assert.Equal(t, makeCredentialSig, attObj.AttStatement["sig"], "attestation sig mismatch") + + // Assert attestation certificate. + x5cInterface := attObj.AttStatement["x5c"] + x5c, ok := x5cInterface.([]interface{}) + require.True(t, ok, "attestation x5c type mismatch (got %T)", x5cInterface) + assert.Len(t, x5c, 1, "attestation x5c length mismatch") + assert.Equal(t, u2f1.cert(), x5c[0], "attestation cert mismatch") + }, + }, + { + name: "none attestation", + fido2: newFakeFIDO2(none1), + setUP: none1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + assert.Equal(t, "none", attObj.Format, "attestation format mismatch") + }, + }, + { + name: "pin device", + fido2: newFakeFIDO2(pin1), + setUP: pin1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + prompt: pin1, + }, + { + name: "multiple valid devices", + fido2: newFakeFIDO2(auth1, pin1, pin2, bio1), + setUP: bio1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + assert.Equal(t, bio1.credentialID(), ccr.RawId, "RawId mismatch (want bio1)") + }, + }, + { + name: "multiple devices, uses pin", + fido2: newFakeFIDO2(auth1, pin1, pin2, bio1), + setUP: pin2.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + prompt: pin2, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + assert.Equal(t, pin2.credentialID(), ccr.RawId, "RawId mismatch (want pin2)") + }, + }, + { + name: "excluded devices, single valid", + fido2: newFakeFIDO2(auth1, bio1), + setUP: bio1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + cp.Response.CredentialExcludeList = []protocol.CredentialDescriptor{ + { + Type: protocol.PublicKeyCredentialType, + CredentialID: auth1.credentialID(), + }, + } + return &cp + }, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + assert.Equal(t, bio1.credentialID(), ccr.RawId, "RawId mismatch (want bio1)") + }, + }, + { + name: "excluded devices, multiple valid", + fido2: newFakeFIDO2(auth1, pin1, pin2, bio1), + setUP: bio1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + cp.Response.CredentialExcludeList = []protocol.CredentialDescriptor{ + { + Type: protocol.PublicKeyCredentialType, + CredentialID: pin1.credentialID(), + }, + { + Type: protocol.PublicKeyCredentialType, + CredentialID: pin2.credentialID(), + }, + } + return &cp + }, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + assert.Equal(t, bio1.credentialID(), ccr.RawId, "RawId mismatch (want bio1)") + }, + }, + { + name: "NOK timeout without devices", + timeout: 10 * time.Millisecond, + fido2: newFakeFIDO2(), + setUP: func() {}, + createCredential: func() *wanlib.CredentialCreation { + cp := *baseCC + return &cp + }, + wantErr: context.DeadlineExceeded, + }, + { + name: "passwordless pin device", + fido2: newFakeFIDO2(pin2), + setUP: pin2.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := pwdlessCC + return &cp + }, + prompt: pin2, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + require.NotEmpty(t, pin2.credentials, "no resident credentials added to pin2") + cred := pin2.credentials[len(pin2.credentials)-1] + assert.Equal(t, cred.ID, ccr.RawId, "RawId mismatch (want pin2 resident credential)") + }, + }, + { + name: "passwordless bio device", + fido2: newFakeFIDO2(bio1), + setUP: bio1.setUP, + createCredential: func() *wanlib.CredentialCreation { + cp := pwdlessCC + return &cp + }, + prompt: bio1, + assertResponse: func(t *testing.T, ccr *wanpb.CredentialCreationResponse, attObj *protocol.AttestationObject) { + require.NotEmpty(t, bio1.credentials, "no resident credentials added to bio1") + cred := bio1.credentials[len(bio1.credentials)-1] + assert.Equal(t, cred.ID, ccr.RawId, "RawId mismatch (want bio1 resident credential)") + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.fido2.setCallbacks() + test.setUP() + + timeout := test.timeout + if timeout == 0 { + timeout = 1 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + prompt := test.prompt + if prompt == nil { + prompt = noopPrompt{} + } + mfaResp, err := wancli.FIDO2Register(ctx, origin, test.createCredential(), prompt) + switch { + case test.wantErr != nil && err == nil: + t.Fatalf("FIDO2Register returned err = nil, wantErr %q", test.wantErr) + case test.wantErr != nil: + require.True(t, errors.Is(err, test.wantErr), "FIDO2Register returned err = %q, wantErr %q", err, test.wantErr) + return + default: + require.NoError(t, err, "FIDO2Register failed") + require.NotNil(t, mfaResp, "mfaResp nil") + } + + // Do a few baseline checks, tests can assert further. + got := mfaResp.GetWebauthn() + require.NotNil(t, got, "credential response nil") + require.NotNil(t, got.Response, "attestation response nil") + assert.NotNil(t, got.Response.ClientDataJson, "ClientDataJSON nil") + want := &wanpb.CredentialCreationResponse{ + Type: string(protocol.PublicKeyCredentialType), + RawId: got.RawId, + Response: &wanpb.AuthenticatorAttestationResponse{ + ClientDataJson: got.Response.ClientDataJson, + AttestationObject: got.Response.AttestationObject, + }, + Extensions: got.Extensions, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("FIDO2Register()/CredentialCreationResponse mismatch (-want +got):\n%v", diff) + } + + attObj := &protocol.AttestationObject{} + err = cbor.Unmarshal(got.Response.AttestationObject, attObj) + require.NoError(t, err, "Failed to unmarshal AttestationObject") + assert.Equal(t, makeCredentialAuthDataRaw, attObj.RawAuthData, "RawAuthData mismatch") + + if test.assertResponse != nil { + test.assertResponse(t, got, attObj) + } + }) + } +} + +func TestFIDO2Register_errors(t *testing.T) { + resetFIDO2AfterTests(t) + + // Make sure we won't call the real libfido2. + f2 := newFakeFIDO2() + f2.setCallbacks() + + const origin = "https://example.com" + okCC := &wanlib.CredentialCreation{ + Response: protocol.PublicKeyCredentialCreationOptions{ + Challenge: make([]byte, 32), + RelyingParty: protocol.RelyingPartyEntity{ + ID: "example.com", + }, + Parameters: []protocol.CredentialParameter{ + {Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}, + }, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + UserVerification: protocol.VerificationDiscouraged, + }, + Attestation: protocol.PreferNoAttestation, + }, + } + + pwdlessOK := *okCC + pwdlessOK.Response.RelyingParty.Name = "Teleport" + pwdlessOK.Response.User = protocol.UserEntity{ + CredentialEntity: protocol.CredentialEntity{ + Name: "llama", + }, + DisplayName: "Llama", + ID: []byte{1, 2, 3, 4, 5}, // arbitrary + } + rrk := true + pwdlessOK.Response.AuthenticatorSelection.RequireResidentKey = &rrk + pwdlessOK.Response.AuthenticatorSelection.UserVerification = protocol.VerificationRequired + + var prompt noopPrompt + + tests := []struct { + name string + origin string + createCC func() *wanlib.CredentialCreation + prompt wancli.RegisterPrompt + wantErr string + }{ + { + name: "ok - timeout", // check that good params are good + origin: origin, + createCC: func() *wanlib.CredentialCreation { return okCC }, + prompt: prompt, + wantErr: context.DeadlineExceeded.Error(), + }, + { + name: "nil origin", + createCC: func() *wanlib.CredentialCreation { return okCC }, + prompt: prompt, + wantErr: "origin", + }, + { + name: "nil cc", + origin: origin, + createCC: func() *wanlib.CredentialCreation { return nil }, + prompt: prompt, + wantErr: "credential creation required", + }, + { + name: "cc without challenge", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := *okCC + cp.Response.Challenge = nil + return &cp + }, + prompt: prompt, + wantErr: "challenge", + }, + { + name: "cc without RPID", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := *okCC + cp.Response.RelyingParty.ID = "" + return &cp + }, + prompt: prompt, + wantErr: "relying party ID", + }, + { + name: "cc unsupported parameters", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := *okCC + cp.Response.Parameters = []protocol.CredentialParameter{ + {Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgEdDSA}, + } + return &cp + }, + prompt: prompt, + wantErr: "ES256", + }, + { + name: "nil pinPrompt", + origin: origin, + createCC: func() *wanlib.CredentialCreation { return okCC }, + wantErr: "prompt", + }, + { + name: "rrk empty RP name", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := pwdlessOK + cp.Response.RelyingParty.Name = "" + return &cp + }, + prompt: prompt, + wantErr: "relying party name", + }, + { + name: "rrk empty user name", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := pwdlessOK + cp.Response.User.Name = "" + return &cp + }, + prompt: prompt, + wantErr: "user name", + }, + { + name: "rrk empty user display name", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := pwdlessOK + cp.Response.User.DisplayName = "" + return &cp + }, + prompt: prompt, + wantErr: "user display name", + }, + { + name: "rrk nil user ID", + origin: origin, + createCC: func() *wanlib.CredentialCreation { + cp := pwdlessOK + cp.Response.User.ID = nil + return &cp + }, + prompt: prompt, + wantErr: "user ID", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + _, err := wancli.FIDO2Register(ctx, test.origin, test.createCC(), test.prompt) + require.Error(t, err, "FIDO2Register returned err = nil, want %q", test.wantErr) + assert.Contains(t, err.Error(), test.wantErr, "FIDO2Register returned err = %q, want %q", err, test.wantErr) + }) + } +} + func resetFIDO2AfterTests(t *testing.T) { pollInterval := wancli.FIDO2PollInterval devLocations := wancli.FIDODeviceLocations @@ -758,6 +1246,70 @@ func (f *fakeFIDO2Device) Credentials(rpID string, pin string) ([]*libfido2.Cred return f.credentials, nil } +func (f *fakeFIDO2Device) MakeCredential( + clientDataHash []byte, + rp libfido2.RelyingParty, + user libfido2.User, + typ libfido2.CredentialType, + pin string, + opts *libfido2.MakeCredentialOpts, +) (*libfido2.Attestation, error) { + switch { + case len(clientDataHash) == 0: + return nil, errors.New("clientDataHash required") + case rp.ID == "": + return nil, errors.New("rp.ID required") + case typ != libfido2.ES256: + return nil, errors.New("bad credential type") + case opts.UV == libfido2.False: // can only be empty or true + return nil, libfido2.ErrUnsupportedOption + case opts.UV == libfido2.True && !f.hasUV(): + return nil, libfido2.ErrUnsupportedOption // PIN authenticators don't like UV + } + + // Validate PIN regardless of opts. + // This is in line with how current YubiKeys behave. + if err := f.validatePIN(pin); err != nil { + return nil, err + } + + if err := f.maybeLockUntilInteraction(true /* up */); err != nil { + return nil, err + } + + cert, sig := f.cert(), makeCredentialSig + if f.format == "none" { + // Do not return attestation data in case of "none". + // This is a hypothetical scenario, as I haven't seen device that does this. + cert, sig = nil, nil + } + + // Did we create a resident credential? Create a new ID for it and record it. + cID := f.key.KeyHandle + if opts.RK == libfido2.True { + cID = make([]byte, 16) // somewhat arbitrary + if _, err := rand.Read(cID); err != nil { + return nil, err + } + f.credentials = append(f.credentials, &libfido2.Credential{ + ID: cID, + Type: libfido2.ES256, + User: user, + }) + } + + return &libfido2.Attestation{ + ClientDataHash: clientDataHash, + AuthData: makeCredentialAuthDataCBOR, + CredentialID: cID, + CredentialType: libfido2.ES256, + PubKey: f.pubKey, + Cert: cert, + Sig: sig, + Format: f.format, + }, nil +} + func (f *fakeFIDO2Device) Assertion( rpID string, clientDataHash []byte, From d361c6e69d364f956a2a7dcd0e525384b1ac55b6 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Wed, 16 Mar 2022 15:05:40 -0300 Subject: [PATCH 06/10] Define runPrompt in terms of LoginPrompt --- lib/auth/webauthncli/fido2.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index 8373d792d2835..a8487ea815686 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -552,10 +552,8 @@ type deviceWithInfo struct { type deviceFilterFunc func(dev FIDODevice, info *deviceInfo) (ok bool, err error) type deviceCallbackFunc func(dev FIDODevice, info *deviceInfo, pin string) error -type runPrompt interface { - PromptPIN() (string, error) - PromptAdditionalTouch() error -} +// runPrompt defines the prompt operations necessary for runOnFIDO2Devices. +type runPrompt LoginPrompt func runOnFIDO2Devices( ctx context.Context, From 659a86d431650cb0571bbfb0f675892ef093b801 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Wed, 16 Mar 2022 15:07:46 -0300 Subject: [PATCH 07/10] Clarify condition with `requiresPIN` --- lib/auth/webauthncli/fido2.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index a8487ea815686..fce6c9c0f8613 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -566,8 +566,11 @@ func runOnFIDO2Devices( } dev, requiresPIN, err := selectDevice(ctx, devices, deviceCallback) - if err != nil || !requiresPIN { + switch { + case err != nil: return trace.Wrap(err) + case !requiresPIN: + return nil } // Selected device requires PIN, let's use the prompt and run the callback From 0bc46d9ae57c5a916c897bf29e2a9fa66e5db1b4 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Wed, 16 Mar 2022 15:19:12 -0300 Subject: [PATCH 08/10] Replace inline comments --- lib/auth/webauthncli/fido2.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index fce6c9c0f8613..329c3f2169ebf 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -150,8 +150,9 @@ func FIDO2Login( var username string var usedAppID bool + skipAdditionalPrompt := passwordless if err := runOnFIDO2Devices( - ctx, prompt, passwordless, /* skipAdditionalPrompt */ + ctx, prompt, skipAdditionalPrompt, /* filter */ func(dev FIDODevice, info *deviceInfo) (bool, error) { switch { case uv && !info.uvCapable(): @@ -163,7 +164,8 @@ func FIDO2Login( } // Does the device have a suitable credential? - if _, err := dev.Assertion(rpID, ccdHash[:], allowedCreds, "" /* pin */, &libfido2.AssertionOpts{ + const pin = "" // not required to filter + if _, err := dev.Assertion(rpID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ UP: libfido2.False, }); err == nil { return true, nil @@ -173,7 +175,7 @@ func FIDO2Login( if appID == "" { return false, nil } - _, err = dev.Assertion(appID, ccdHash[:], allowedCreds, "" /* pin */, &libfido2.AssertionOpts{ + _, err = dev.Assertion(appID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ UP: libfido2.False, }) return err == nil, nil @@ -417,8 +419,9 @@ func FIDO2Register( var mu sync.Mutex var attestation *libfido2.Attestation + const skipAdditionalPrompt = false if err := runOnFIDO2Devices( - ctx, prompt, false, /* skipAdditionalPrompt */ + ctx, prompt, skipAdditionalPrompt, /* filter */ func(dev FIDODevice, info *deviceInfo) (bool, error) { switch { case (plat && !info.plat) || (rrk && !info.rk) || (uv && !info.uvCapable()): @@ -428,7 +431,8 @@ func FIDO2Register( } // Does the device hold an excluded credential? - switch _, err := dev.Assertion(rp.ID, ccdHash[:], excludeList, "" /* pin */, &libfido2.AssertionOpts{ + 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): From 3e4c3ae41e08b5847175764e43c0b7aac4c92901 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Thu, 17 Mar 2022 17:48:29 -0300 Subject: [PATCH 09/10] Add TODO to support other key algorithms --- lib/auth/webauthncli/fido2.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index 329c3f2169ebf..93fffc939ea4d 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -375,6 +375,8 @@ func FIDO2Register( } // 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 { From 47fc78ece01c791cb1b0e004790d6140cfd72c33 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Thu, 17 Mar 2022 17:53:12 -0300 Subject: [PATCH 10/10] Extract callbacks to named variables --- lib/auth/webauthncli/fido2.go | 256 +++++++++++++++++----------------- 1 file changed, 129 insertions(+), 127 deletions(-) diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index 93fffc939ea4d..bd09a47153577 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -150,91 +150,92 @@ func FIDO2Login( var username string var usedAppID bool - skipAdditionalPrompt := passwordless - if err := runOnFIDO2Devices( - ctx, prompt, skipAdditionalPrompt, - /* filter */ func(dev FIDODevice, info *deviceInfo) (bool, error) { - switch { - case uv && !info.uvCapable(): - return false, nil - case passwordless && !info.rk: - return false, nil - case len(allowedCreds) == 0: // Nothing else to check - return true, nil - } - - // Does the device have a suitable credential? - const pin = "" // not required to filter - if _, err := dev.Assertion(rpID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ - UP: libfido2.False, - }); err == nil { - return true, nil - } + filter := func(dev FIDODevice, info *deviceInfo) (bool, error) { + switch { + case uv && !info.uvCapable(): + return false, nil + case passwordless && !info.rk: + return false, nil + case len(allowedCreds) == 0: // Nothing else to check + return true, nil + } - // Try again with the App ID, if present. - if appID == "" { - return false, nil - } - _, err = dev.Assertion(appID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ - UP: libfido2.False, - }) - return err == nil, nil - }, - /* deviceCallback */ func(dev FIDODevice, info *deviceInfo, pin string) error { - var actualRPID string - var cID []byte - var uID []byte - var uName string - if passwordless { - cred, err := getPasswordlessCredentials(dev, pin, rpID, user) - if err != nil { - return trace.Wrap(err) - } - actualRPID = rpID - cID = cred.ID - uID = cred.User.ID - uName = cred.User.Name - } else { - // TODO(codingllama): Ideally we'd rely on fido_assert_id_ptr/_len. - var err error - actualRPID, cID, err = getMFACredentials(dev, pin, rpID, appID, allowedCreds) - if err != nil { - return trace.Wrap(err) - } - } + // Does the device have a suitable credential? + const pin = "" // not required to filter + if _, err := dev.Assertion(rpID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ + UP: libfido2.False, + }); err == nil { + return true, nil + } - if passwordless { - // Ask for another touch before the assertion, we used the first touch - // in the Credentials() call. - if err := prompt.PromptAdditionalTouch(); err != nil { - return trace.Wrap(err) - } - } + // Try again with the App ID, if present. + if appID == "" { + return false, nil + } + _, err = dev.Assertion(appID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ + UP: libfido2.False, + }) + return err == nil, nil + } - opts := &libfido2.AssertionOpts{ - UP: libfido2.True, - } - if uv { - opts.UV = libfido2.True + deviceCallback := func(dev FIDODevice, info *deviceInfo, pin string) error { + var actualRPID string + var cID []byte + var uID []byte + var uName string + if passwordless { + cred, err := getPasswordlessCredentials(dev, pin, rpID, user) + if err != nil { + return trace.Wrap(err) } - resp, err := dev.Assertion(actualRPID, ccdHash[:], [][]byte{cID}, pin, opts) + actualRPID = rpID + cID = cred.ID + uID = cred.User.ID + uName = cred.User.Name + } else { + // TODO(codingllama): Ideally we'd rely on fido_assert_id_ptr/_len. + var err error + actualRPID, cID, err = getMFACredentials(dev, pin, rpID, appID, allowedCreds) if err != nil { return trace.Wrap(err) } + } - // Use the first successful assertion. - // In practice it is very unlikely we'd hit this twice. - mu.Lock() - if assertionResp == nil { - assertionResp = resp - credentialID = cID - userID = uID - username = uName - usedAppID = actualRPID != rpID + if passwordless { + // Ask for another touch before the assertion, we used the first touch + // in the Credentials() call. + if err := prompt.PromptAdditionalTouch(); err != nil { + return trace.Wrap(err) } - mu.Unlock() - return nil - }); err != nil { + } + + opts := &libfido2.AssertionOpts{ + UP: libfido2.True, + } + if uv { + opts.UV = libfido2.True + } + resp, err := dev.Assertion(actualRPID, ccdHash[:], [][]byte{cID}, pin, opts) + if err != nil { + return trace.Wrap(err) + } + + // Use the first successful assertion. + // In practice it is very unlikely we'd hit this twice. + mu.Lock() + if assertionResp == nil { + assertionResp = resp + credentialID = cID + userID = uID + username = uName + usedAppID = actualRPID != rpID + } + mu.Unlock() + return nil + } + + skipAdditionalPrompt := passwordless + if err := runOnFIDO2Devices(ctx, prompt, skipAdditionalPrompt, filter, deviceCallback); err != nil { return nil, "", trace.Wrap(err) } @@ -421,61 +422,62 @@ func FIDO2Register( var mu sync.Mutex var attestation *libfido2.Attestation - const skipAdditionalPrompt = false - if err := runOnFIDO2Devices( - ctx, prompt, skipAdditionalPrompt, - /* filter */ func(dev FIDODevice, info *deviceInfo) (bool, error) { - switch { - case (plat && !info.plat) || (rrk && !info.rk) || (uv && !info.uvCapable()): - return false, nil - case len(excludeList) == 0: - return true, nil - } + filter := func(dev FIDODevice, info *deviceInfo) (bool, error) { + switch { + case (plat && !info.plat) || (rrk && !info.rk) || (uv && !info.uvCapable()): + return false, nil + case len(excludeList) == 0: + return true, 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 true, nil - case err == nil: - return false, nil - default: // unexpected error - return false, trace.Wrap(err) - } - }, - /* 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. + // 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 true, nil + case err == nil: + return false, nil + default: // unexpected error + return false, trace.Wrap(err) + } + } - 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 - } + 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. - resp, err := d.MakeCredential(ccdHash[:], rp, user, libfido2.ES256, pin, opts) - if err != nil { - return trace.Wrap(err) - } + 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 + } - // 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 - }); err != nil { + 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 + } + + const skipAdditionalPrompt = false + if err := runOnFIDO2Devices(ctx, prompt, skipAdditionalPrompt, filter, deviceCallback); err != nil { return nil, trace.Wrap(err) }