mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 14:59:53 +08:00
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.
2352 lines
69 KiB
Go
2352 lines
69 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_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fxamacker/cbor/v2"
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/keys-pub/go-libfido2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
wanpb "github.com/gravitational/teleport/api/types/webauthn"
|
|
"github.com/gravitational/teleport/lib/auth/mocku2f"
|
|
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
|
|
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
|
|
)
|
|
|
|
var (
|
|
makeCredentialAuthDataRaw, makeCredentialAuthDataCBOR, makeCredentialSig []byte
|
|
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
|
|
}
|
|
}
|
|
|
|
// Capture common authenticator options.
|
|
var (
|
|
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
|
|
}
|
|
)
|
|
|
|
// simplePicker is a credential picker that always picks the first credential.
|
|
type simplePicker struct{}
|
|
|
|
func (p simplePicker) PromptCredential(creds []*wancli.CredentialInfo) (*wancli.CredentialInfo, error) {
|
|
return creds[0], nil
|
|
}
|
|
|
|
type noopPrompt struct {
|
|
simplePicker
|
|
}
|
|
|
|
func (p noopPrompt) PromptPIN() (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
func (p noopPrompt) PromptTouch() (wancli.TouchAcknowledger, error) {
|
|
return func() error { return nil }, nil
|
|
}
|
|
|
|
// pinCancelPrompt exercises cancellation after device selection.
|
|
type pinCancelPrompt struct {
|
|
simplePicker
|
|
|
|
pin string
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (p *pinCancelPrompt) PromptPIN() (string, error) {
|
|
p.cancel()
|
|
return p.pin, nil
|
|
}
|
|
|
|
func (p pinCancelPrompt) PromptTouch() (wancli.TouchAcknowledger, error) {
|
|
// 2nd touch never happens
|
|
return func() error { return nil }, nil
|
|
}
|
|
|
|
func TestIsFIDO2Available(t *testing.T) {
|
|
const fido2Key = "TELEPORT_FIDO2"
|
|
tests := []struct {
|
|
name string
|
|
setenv func()
|
|
want bool
|
|
}{
|
|
{
|
|
name: "env var unset",
|
|
setenv: func() {
|
|
os.Unsetenv(fido2Key)
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "env var set to 1",
|
|
setenv: func() {
|
|
t.Setenv(fido2Key, "1")
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "env var set to 0",
|
|
setenv: func() {
|
|
t.Setenv(fido2Key, "0")
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
test.setenv()
|
|
got := wancli.IsFIDO2Available()
|
|
require.Equal(t, test.want, got, "IsFIDO2Available")
|
|
})
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
// User IDs and names for resident credentials / passwordless.
|
|
const llamaName = "llama"
|
|
const alpacaName = "alpaca"
|
|
llamaID := make([]byte, 16)
|
|
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()
|
|
createAssertion func() *wanlib.CredentialAssertion
|
|
prompt wancli.LoginPrompt
|
|
opts *wancli.LoginOpts
|
|
// assertResponse and wantErr are mutually exclusive.
|
|
assertResponse func(t *testing.T, resp *wanpb.CredentialAssertionResponse)
|
|
wantErr string
|
|
wantUser 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: "NOK single candidate times out",
|
|
timeout: 10 * time.Millisecond,
|
|
fido2: newFakeFIDO2(auth1, pin1),
|
|
setUP: func() {}, // no interaction
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
cp := *baseAssertion
|
|
cp.Response.AllowedCredentials = []protocol.CredentialDescriptor{
|
|
{CredentialID: auth1.credentialID()},
|
|
}
|
|
return &cp
|
|
},
|
|
wantErr: context.DeadlineExceeded.Error(),
|
|
},
|
|
{
|
|
name: "NOK cancel after PIN",
|
|
fido2: newFakeFIDO2(pin3, bio2), // pin3 and bio2 have resident credentials
|
|
setUP: pin3.setUP, // user chooses pin3, but cancels before further touches
|
|
prompt: &pinCancelPrompt{pin: pin3.pin}, // cancel set on test body
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
cp := *baseAssertion
|
|
cp.Response.AllowedCredentials = nil // passwordless forces PIN
|
|
cp.Response.UserVerification = protocol.VerificationRequired
|
|
return &cp
|
|
},
|
|
wantErr: context.Canceled.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)
|
|
},
|
|
wantUser: "", // single account response
|
|
},
|
|
{
|
|
name: "passwordless biometric (llama)",
|
|
fido2: newFakeFIDO2(bio2),
|
|
setUP: bio2.setUP,
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
cp := *baseAssertion
|
|
cp.Response.AllowedCredentials = nil
|
|
cp.Response.UserVerification = protocol.VerificationRequired
|
|
return &cp
|
|
},
|
|
prompt: bio2,
|
|
opts: &wancli.LoginOpts{
|
|
User: llamaName,
|
|
},
|
|
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)
|
|
},
|
|
wantUser: llamaName,
|
|
},
|
|
{
|
|
name: "passwordless biometric (alpaca)",
|
|
fido2: newFakeFIDO2(bio2),
|
|
setUP: bio2.setUP,
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
cp := *baseAssertion
|
|
cp.Response.AllowedCredentials = nil
|
|
cp.Response.UserVerification = protocol.VerificationRequired
|
|
return &cp
|
|
},
|
|
prompt: bio2,
|
|
opts: &wancli.LoginOpts{
|
|
User: alpacaName,
|
|
},
|
|
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)
|
|
},
|
|
wantUser: alpacaName,
|
|
},
|
|
{
|
|
name: "passwordless single-choice credential picker",
|
|
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)
|
|
},
|
|
wantUser: "", // single account response
|
|
},
|
|
{
|
|
name: "passwordless multi-choice credential picker",
|
|
fido2: newFakeFIDO2(bio2),
|
|
setUP: bio2.setUP,
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
cp := *baseAssertion
|
|
cp.Response.AllowedCredentials = nil
|
|
cp.Response.UserVerification = protocol.VerificationRequired
|
|
return &cp
|
|
},
|
|
prompt: bio2, // picks first credential from list.
|
|
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)
|
|
},
|
|
wantUser: llamaName,
|
|
},
|
|
{
|
|
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 unknown user",
|
|
fido2: newFakeFIDO2(bio2),
|
|
setUP: bio2.setUP,
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
cp := *baseAssertion
|
|
cp.Response.AllowedCredentials = nil
|
|
cp.Response.UserVerification = protocol.VerificationRequired
|
|
return &cp
|
|
},
|
|
prompt: bio2,
|
|
opts: &wancli.LoginOpts{
|
|
User: "camel", // unknown
|
|
},
|
|
wantErr: "no credentials for user",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
runTest := func(t *testing.T, f2 *fakeFIDO2) {
|
|
f2.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{}
|
|
}
|
|
if pp, ok := prompt.(*pinCancelPrompt); ok {
|
|
pp.cancel = cancel
|
|
}
|
|
|
|
// Run FIDO2Login asynchronously, so we can fail the test if it hangs.
|
|
// mfaResp and err checked below.
|
|
var mfaResp *proto.MFAAuthenticateResponse
|
|
var actualUser string
|
|
var err error
|
|
done := make(chan struct{})
|
|
go func() {
|
|
mfaResp, actualUser, err = wancli.FIDO2Login(ctx, origin, test.createAssertion(), prompt, test.opts)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done: // OK, proceed.
|
|
case <-time.After(timeout + 1*time.Second):
|
|
t.Fatal("Timed out waiting for FIDO2Login")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
assert.Equal(t, test.wantUser, actualUser, "actual user mismatch")
|
|
}
|
|
|
|
// Run tests against both "metered" and "non-metered" fake variants, so we
|
|
// can ensure both behave correctly.
|
|
// There shouldn't be much of a difference, but tests are fast enough that
|
|
// it doesn't hurt either.
|
|
t.Run(test.name+"/metered", func(t *testing.T) {
|
|
runTest(t, test.fido2)
|
|
})
|
|
t.Run(test.name+"/nonMetered", func(t *testing.T) {
|
|
runTest(t, test.fido2.withNonMeteredLocations())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFIDO2Login_retryUVFailures(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
const user = "llama"
|
|
pin1 := mustNewFIDO2Device("/pin1", "supersecretpinllama", &libfido2.DeviceInfo{
|
|
Options: pinOpts,
|
|
}, &libfido2.Credential{
|
|
ID: []byte{1, 1, 1, 1, 1},
|
|
User: libfido2.User{
|
|
ID: []byte{1, 1, 1, 1, 2},
|
|
Name: user,
|
|
},
|
|
})
|
|
pin1.failUV = true // fail UV regardless of PIN
|
|
|
|
f2 := newFakeFIDO2(pin1).withNonMeteredLocations()
|
|
f2.setCallbacks()
|
|
|
|
const rpID = "example.com"
|
|
const origin = "https://example.com"
|
|
ctx := context.Background()
|
|
assertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5}, // arbitrary
|
|
RelyingPartyID: rpID,
|
|
UserVerification: protocol.VerificationRequired,
|
|
},
|
|
}
|
|
|
|
pin1.setUP()
|
|
_, _, err := wancli.FIDO2Login(ctx, origin, assertion, pin1 /* prompt */, nil /* opts */)
|
|
require.NoError(t, err, "FIDO2Login failed UV retry")
|
|
}
|
|
|
|
func TestFIDO2Login_singleResidentCredential(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
const user1Name = "llama"
|
|
const user2Name = "alpaca"
|
|
user1ID := []byte{1, 1, 1, 1, 1}
|
|
user2ID := []byte{1, 1, 1, 1, 2}
|
|
|
|
oneCredential := mustNewFIDO2Device("/bio1", "supersecretBIO1pin", &libfido2.DeviceInfo{
|
|
Options: bioOpts,
|
|
}, &libfido2.Credential{
|
|
ID: []byte{1, 1, 1, 1, 1},
|
|
User: libfido2.User{
|
|
ID: user1ID,
|
|
Name: user1Name,
|
|
},
|
|
})
|
|
manyCredentials := mustNewFIDO2Device("/bio2", "supersecretBIO2pin", &libfido2.DeviceInfo{
|
|
Options: bioOpts,
|
|
},
|
|
&libfido2.Credential{
|
|
ID: user1ID,
|
|
User: libfido2.User{
|
|
ID: user1ID,
|
|
Name: user1Name,
|
|
},
|
|
},
|
|
&libfido2.Credential{
|
|
ID: user2ID,
|
|
User: libfido2.User{
|
|
ID: user2ID,
|
|
Name: user2Name,
|
|
},
|
|
})
|
|
|
|
f2 := newFakeFIDO2(oneCredential, manyCredentials).withNonMeteredLocations()
|
|
f2.setCallbacks()
|
|
|
|
const rpID = "example.com"
|
|
const origin = "https://example.com"
|
|
ctx := context.Background()
|
|
assertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5}, // arbitrary
|
|
RelyingPartyID: rpID,
|
|
UserVerification: protocol.VerificationRequired,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
up func()
|
|
prompt wancli.LoginPrompt
|
|
opts *wancli.LoginOpts
|
|
wantUserID []byte
|
|
// Actual user is empty for all single account cases.
|
|
// Authenticators don't return the data.
|
|
wantUser string
|
|
}{
|
|
{
|
|
name: "single credential with empty user",
|
|
up: oneCredential.setUP,
|
|
prompt: oneCredential,
|
|
wantUserID: user1ID,
|
|
},
|
|
{
|
|
name: "single credential with correct user",
|
|
up: oneCredential.setUP,
|
|
prompt: oneCredential,
|
|
opts: &wancli.LoginOpts{
|
|
User: user1Name, // happens to match
|
|
},
|
|
wantUserID: user1ID,
|
|
},
|
|
{
|
|
name: "single credential with ignored user",
|
|
up: oneCredential.setUP,
|
|
prompt: oneCredential,
|
|
opts: &wancli.LoginOpts{
|
|
User: user2Name, // ignored, we just can't know
|
|
},
|
|
wantUserID: user1ID,
|
|
},
|
|
{
|
|
name: "multi credentials",
|
|
up: manyCredentials.setUP,
|
|
prompt: manyCredentials,
|
|
opts: &wancli.LoginOpts{
|
|
User: user2Name, // respected, authenticator returns the data
|
|
},
|
|
wantUserID: user2ID,
|
|
wantUser: user2Name,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
test.up()
|
|
|
|
resp, gotUser, err := wancli.FIDO2Login(ctx, origin, assertion, test.prompt, test.opts)
|
|
require.NoError(t, err, "FIDO2Login failed")
|
|
gotUserID := resp.GetWebauthn().GetResponse().GetUserHandle()
|
|
assert.Equal(t, test.wantUserID, gotUserID, "FIDO2Login user ID mismatch")
|
|
assert.Equal(t, test.wantUser, gotUser, "FIDO2Login user mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
type countingPrompt struct {
|
|
wancli.LoginPrompt
|
|
count, ackCount int
|
|
}
|
|
|
|
func (cp *countingPrompt) PromptTouch() (wancli.TouchAcknowledger, error) {
|
|
cp.count++
|
|
ack, err := cp.LoginPrompt.PromptTouch()
|
|
return func() error {
|
|
cp.ackCount++
|
|
return ack()
|
|
}, err
|
|
}
|
|
|
|
func TestFIDO2Login_PromptTouch(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
const rpID = "example.com"
|
|
const origin = "https://example.com"
|
|
|
|
// auth1 is a FIDO2 authenticator without a PIN configured.
|
|
auth1 := mustNewFIDO2Device("/auth1", "" /* pin */, &libfido2.DeviceInfo{
|
|
Options: authOpts,
|
|
})
|
|
// pin1 is a FIDO2 authenticator with a PIN and resident credentials.
|
|
pin1 := mustNewFIDO2Device("/pin1", "supersecretpin1", &libfido2.DeviceInfo{
|
|
Options: pinOpts,
|
|
}, &libfido2.Credential{
|
|
ID: []byte{1, 1, 1, 1},
|
|
User: libfido2.User{
|
|
ID: []byte("alpacaID"),
|
|
Name: "alpaca",
|
|
},
|
|
})
|
|
// bio1 is a biometric authenticator with configured resident credentials.
|
|
bio1 := mustNewFIDO2Device("/bio1", "supersecretBIO1pin", &libfido2.DeviceInfo{
|
|
Options: bioOpts,
|
|
}, &libfido2.Credential{
|
|
ID: []byte{1, 1, 1, 2},
|
|
User: libfido2.User{
|
|
ID: []byte("llamaID"),
|
|
Name: "llama",
|
|
},
|
|
}, &libfido2.Credential{
|
|
ID: []byte{1, 1, 1, 3},
|
|
User: libfido2.User{
|
|
ID: []byte("alpacaID"),
|
|
Name: "alpaca",
|
|
},
|
|
})
|
|
|
|
mfaAssertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: make([]byte, 32),
|
|
RelyingPartyID: rpID,
|
|
AllowedCredentials: []protocol.CredentialDescriptor{
|
|
{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: auth1.credentialID(),
|
|
},
|
|
{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: pin1.credentialID(),
|
|
},
|
|
{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: bio1.credentialID(),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
pwdlessAssertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: make([]byte, 32),
|
|
RelyingPartyID: rpID,
|
|
UserVerification: protocol.VerificationRequired,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
fido2 *fakeFIDO2
|
|
assertion *wanlib.CredentialAssertion
|
|
prompt wancli.LoginPrompt
|
|
opts *wancli.LoginOpts
|
|
wantTouches int
|
|
}{
|
|
{
|
|
name: "MFA requires single touch",
|
|
fido2: newFakeFIDO2(auth1, pin1, bio1),
|
|
assertion: mfaAssertion,
|
|
prompt: auth1,
|
|
wantTouches: 1,
|
|
},
|
|
{
|
|
name: "Passwordless PIN plugged requires two touches",
|
|
fido2: newFakeFIDO2(pin1).withNonMeteredLocations(),
|
|
assertion: pwdlessAssertion,
|
|
prompt: pin1,
|
|
wantTouches: 2,
|
|
},
|
|
{
|
|
name: "Passwordless PIN not plugged requires two touches",
|
|
fido2: newFakeFIDO2(pin1),
|
|
assertion: pwdlessAssertion,
|
|
prompt: pin1,
|
|
wantTouches: 2,
|
|
},
|
|
{
|
|
name: "Passwordless Bio requires one touch",
|
|
fido2: newFakeFIDO2(bio1),
|
|
assertion: pwdlessAssertion,
|
|
prompt: bio1,
|
|
opts: &wancli.LoginOpts{
|
|
User: "llama",
|
|
},
|
|
wantTouches: 1,
|
|
},
|
|
{
|
|
name: "Passwordless with multiple devices requires two touches",
|
|
fido2: newFakeFIDO2(pin1, bio1),
|
|
assertion: pwdlessAssertion,
|
|
prompt: pin1,
|
|
wantTouches: 2,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
test.fido2.setCallbacks()
|
|
|
|
// Set a timeout, just in case.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
prompt := &countingPrompt{LoginPrompt: test.prompt}
|
|
_, _, err := wancli.FIDO2Login(ctx, origin, test.assertion, prompt, test.opts)
|
|
require.NoError(t, err, "FIDO2Login errored")
|
|
assert.Equal(t, test.wantTouches, prompt.count, "FIDO2Login did an unexpected number of touch prompts")
|
|
assert.Equal(t, test.wantTouches, prompt.ackCount, "FIDO2Login did an unexpected number of touch acknowledgements")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFIDO2Login_u2fDevice(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
dev := mustNewFIDO2Device("/u2f", "" /* pin */, nil /* info */)
|
|
dev.u2fOnly = true
|
|
|
|
f2 := newFakeFIDO2(dev).withNonMeteredLocations()
|
|
f2.setCallbacks()
|
|
|
|
const rpID = "example.com"
|
|
const origin = "https://example.com"
|
|
|
|
// Set a ctx timeout in case something goes wrong.
|
|
// Under normal circumstances the test gets nowhere near this timeout.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
cc := &wanlib.CredentialCreation{
|
|
Response: protocol.PublicKeyCredentialCreationOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5}, // arbitrary
|
|
RelyingParty: protocol.RelyingPartyEntity{
|
|
ID: rpID,
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "rp name",
|
|
},
|
|
},
|
|
Parameters: []protocol.CredentialParameter{
|
|
{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
Algorithm: webauthncose.AlgES256,
|
|
},
|
|
},
|
|
User: protocol.UserEntity{
|
|
ID: []byte{1, 2, 3, 4, 1}, // arbitrary,
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "user name",
|
|
},
|
|
DisplayName: "user display name",
|
|
},
|
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
Attestation: protocol.PreferNoAttestation,
|
|
},
|
|
}
|
|
|
|
dev.setUP() // simulate touch
|
|
ccr, err := wancli.FIDO2Register(ctx, origin, cc, dev /* prompt */)
|
|
require.NoError(t, err, "FIDO2Register errored")
|
|
|
|
assertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5}, // arbitrary
|
|
RelyingPartyID: rpID,
|
|
AllowedCredentials: []protocol.CredentialDescriptor{
|
|
{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: ccr.GetWebauthn().GetRawId(),
|
|
},
|
|
},
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
}
|
|
|
|
dev.setUP() // simulate touch
|
|
_, _, err = wancli.FIDO2Login(ctx, origin, assertion, dev /* prompt */, nil /* opts */)
|
|
assert.NoError(t, err, "FIDO2Login errored")
|
|
}
|
|
|
|
func TestFIDO2Login_bioErrorHandling(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
// bio is a biometric authenticator with configured resident credentials.
|
|
bio := mustNewFIDO2Device("/bio", "supersecretBIOpin", &libfido2.DeviceInfo{
|
|
Options: bioOpts,
|
|
}, &libfido2.Credential{
|
|
User: libfido2.User{
|
|
ID: []byte{1, 2, 3, 4, 5}, // unimportant
|
|
Name: "llama",
|
|
},
|
|
})
|
|
|
|
f2 := newFakeFIDO2(bio).withNonMeteredLocations()
|
|
f2.setCallbacks()
|
|
|
|
// Prepare a passwordless assertion.
|
|
// MFA would do as well; both are realistic here.
|
|
const origin = "https://example.com"
|
|
assertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5},
|
|
RelyingPartyID: "example.com",
|
|
AllowedCredentials: nil, // passwordless
|
|
UserVerification: protocol.VerificationRequired, // passwordless
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
setAssertionErrors func()
|
|
wantMsg string
|
|
}{
|
|
{
|
|
name: "success (sanity check)",
|
|
setAssertionErrors: func() { bio.assertionErrors = nil },
|
|
},
|
|
{
|
|
name: "libfido2 error 60 fails with custom message",
|
|
setAssertionErrors: func() {
|
|
bio.assertionErrors = []error{
|
|
libfido2.Error{Code: 60},
|
|
}
|
|
},
|
|
wantMsg: "user verification function",
|
|
},
|
|
{
|
|
name: "libfido2 error 63 retried",
|
|
setAssertionErrors: func() {
|
|
bio.assertionErrors = []error{
|
|
libfido2.Error{Code: 63},
|
|
libfido2.Error{Code: 63},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "error retry has a limit",
|
|
setAssertionErrors: func() {
|
|
bio.assertionErrors = []error{
|
|
libfido2.Error{Code: 63},
|
|
libfido2.Error{Code: 63},
|
|
libfido2.Error{Code: 63},
|
|
libfido2.Error{Code: 63},
|
|
libfido2.Error{Code: 63},
|
|
}
|
|
},
|
|
wantMsg: "libfido2 error 63",
|
|
},
|
|
{
|
|
name: "retry on operation denied",
|
|
setAssertionErrors: func() {
|
|
bio.assertionErrors = []error{
|
|
// Note: this happens only for UV=false assertions. UV=true failures
|
|
// return error 63.
|
|
libfido2.ErrOperationDenied,
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
test.setAssertionErrors()
|
|
|
|
// Use a ctx with timeout just to be safe. We shouldn't hit the timeout.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
_, _, err := wancli.FIDO2Login(ctx, origin, assertion, bio /* prompt */, nil /* opts */)
|
|
if test.wantMsg == "" {
|
|
require.NoError(t, err, "FIDO2Login returned non-nil error")
|
|
} else {
|
|
require.ErrorContains(t, err, test.wantMsg, "FIDO2Login returned an unexpected error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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"
|
|
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 prompt",
|
|
origin: origin,
|
|
assertion: okAssertion,
|
|
wantErr: "prompt",
|
|
},
|
|
{
|
|
name: "assertion without challenge",
|
|
origin: origin,
|
|
assertion: &nilChallengeAssertion,
|
|
prompt: prompt,
|
|
wantErr: "challenge",
|
|
},
|
|
}
|
|
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, test.assertion, test.prompt, nil /* opts */)
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFIDO2_LoginRegister_interactionErrors tests scenarios where the user
|
|
// picks a security key that cannot be used for that particular flow (and the
|
|
// subsequent error message).
|
|
func TestFIDO2_LoginRegister_interactionErrors(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
notRegistered := mustNewFIDO2Device("/notregistered", "mysupersecretpinLLAMA", &libfido2.DeviceInfo{
|
|
Options: pinOpts,
|
|
})
|
|
// PIN capable but unset.
|
|
noPIN := mustNewFIDO2Device("/nouv", "" /* pin */, &libfido2.DeviceInfo{
|
|
Options: authOpts,
|
|
})
|
|
// fictional PIN capable, RK incapable device.
|
|
noRK := mustNewFIDO2Device("/nork", "" /* pin */, &libfido2.DeviceInfo{
|
|
Options: []libfido2.Option{
|
|
{Name: "rk", Value: "false"}, // not capable
|
|
{Name: "up", Value: "true"}, // expected setting
|
|
{Name: "plat", Value: "false"}, // expected setting
|
|
{Name: "clientPin", Value: "true"}, // supported and configured
|
|
},
|
|
})
|
|
// U2F only device (no FIDO2 capabilities).
|
|
u2f := mustNewFIDO2Device("/u2f", "" /* pin */, nil /* info */)
|
|
u2f.u2fOnly = true
|
|
|
|
f2 := newFakeFIDO2(notRegistered, noPIN, noRK, u2f).withNonMeteredLocations()
|
|
f2.setCallbacks()
|
|
|
|
const rpID = "goteleport.com"
|
|
const origin = "https://goteleport.com"
|
|
mfaCC := &wanlib.CredentialCreation{
|
|
Response: protocol.PublicKeyCredentialCreationOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5}, // arbitrary
|
|
RelyingParty: protocol.RelyingPartyEntity{
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "Teleport",
|
|
},
|
|
ID: rpID,
|
|
},
|
|
User: protocol.UserEntity{
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "llama",
|
|
},
|
|
DisplayName: "Llama",
|
|
ID: []byte{1, 1, 1, 1, 1}, // arbitrary
|
|
},
|
|
Parameters: []protocol.CredentialParameter{
|
|
{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
Algorithm: webauthncose.AlgES256,
|
|
},
|
|
},
|
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
|
RequireResidentKey: protocol.ResidentKeyNotRequired(),
|
|
ResidentKey: protocol.ResidentKeyRequirementDiscouraged,
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
Attestation: protocol.PreferNoAttestation,
|
|
},
|
|
}
|
|
|
|
// Setup: register all devices (except "notRegistered") for MFA.
|
|
// A typical MFA registration allows all kinds of devices.
|
|
ctx := context.Background()
|
|
var registeredCreds [][]byte
|
|
for _, dev := range []*fakeFIDO2Device{noPIN, noRK, u2f} {
|
|
resp, err := wancli.FIDO2Register(ctx, origin, mfaCC, dev)
|
|
if err != nil {
|
|
t.Fatalf("FIDO2Register failed, device %v: %v", dev.path, err)
|
|
}
|
|
registeredCreds = append(registeredCreds, resp.GetWebauthn().RawId)
|
|
}
|
|
|
|
mfaAssertion := &wanlib.CredentialAssertion{
|
|
Response: protocol.PublicKeyCredentialRequestOptions{
|
|
Challenge: []byte{1, 2, 3, 4, 5}, // arbitrary
|
|
RelyingPartyID: rpID,
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
}
|
|
for _, cred := range registeredCreds {
|
|
mfaAssertion.Response.AllowedCredentials = append(mfaAssertion.Response.AllowedCredentials, protocol.CredentialDescriptor{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: cred,
|
|
})
|
|
}
|
|
|
|
pwdlessAssertion := *mfaAssertion
|
|
pwdlessAssertion.Response.AllowedCredentials = nil
|
|
pwdlessAssertion.Response.UserVerification = protocol.VerificationRequired
|
|
|
|
// FIDO2Login interaction tests.
|
|
for _, test := range []struct {
|
|
name string
|
|
createAssertion func() *wanlib.CredentialAssertion
|
|
prompt wancli.LoginPrompt
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "no registered credential",
|
|
createAssertion: func() *wanlib.CredentialAssertion { return mfaAssertion },
|
|
prompt: notRegistered,
|
|
wantErr: "lacks registered credential",
|
|
},
|
|
{
|
|
// Theoretically could happen, but not something we do today.
|
|
name: "mfa lacks UV",
|
|
createAssertion: func() *wanlib.CredentialAssertion {
|
|
mfaUV := *mfaAssertion
|
|
mfaUV.Response.UserVerification = protocol.VerificationRequired
|
|
return &mfaUV
|
|
},
|
|
prompt: noPIN, // PIN unset means it cannot do UV
|
|
wantErr: "user verification",
|
|
},
|
|
{
|
|
name: "passwordless lacks UV",
|
|
createAssertion: func() *wanlib.CredentialAssertion { return &pwdlessAssertion },
|
|
prompt: noPIN, // PIN unset means it cannot do UV
|
|
wantErr: "passwordless",
|
|
},
|
|
{
|
|
// Fictional scenario, no real-world authenticators match.
|
|
name: "passwordless lacks RK",
|
|
createAssertion: func() *wanlib.CredentialAssertion { return &pwdlessAssertion },
|
|
prompt: noRK,
|
|
wantErr: "passwordless",
|
|
},
|
|
{
|
|
name: "passwordless U2F",
|
|
createAssertion: func() *wanlib.CredentialAssertion { return &pwdlessAssertion },
|
|
prompt: u2f,
|
|
wantErr: context.DeadlineExceeded.Error(), // silently filtered, times out
|
|
},
|
|
} {
|
|
t.Run("login/"+test.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, _, err := wancli.FIDO2Login(ctx, origin, test.createAssertion(), test.prompt, nil /* opts */)
|
|
assert.ErrorContains(t, err, test.wantErr, "FIDO2Login error mismatch")
|
|
})
|
|
}
|
|
|
|
excludeCC := *mfaCC
|
|
for _, cred := range registeredCreds {
|
|
excludeCC.Response.CredentialExcludeList = append(excludeCC.Response.CredentialExcludeList, protocol.CredentialDescriptor{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: cred,
|
|
})
|
|
}
|
|
|
|
pwdlessCC := *mfaCC
|
|
pwdlessCC.Response.AuthenticatorSelection.RequireResidentKey = protocol.ResidentKeyRequired()
|
|
pwdlessCC.Response.AuthenticatorSelection.ResidentKey = protocol.ResidentKeyRequirementRequired
|
|
pwdlessCC.Response.AuthenticatorSelection.UserVerification = protocol.VerificationRequired
|
|
|
|
// FIDO2Register interaction tests.
|
|
for _, test := range []struct {
|
|
name string
|
|
createCC func() *wanlib.CredentialCreation
|
|
prompt wancli.RegisterPrompt
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "excluded credential",
|
|
createCC: func() *wanlib.CredentialCreation { return &excludeCC },
|
|
prompt: noPIN,
|
|
wantErr: "registered credential",
|
|
},
|
|
{
|
|
name: "excluded credential (U2F)",
|
|
createCC: func() *wanlib.CredentialCreation { return &excludeCC },
|
|
prompt: u2f,
|
|
wantErr: context.DeadlineExceeded.Error(), // silently filtered, times out
|
|
},
|
|
{
|
|
name: "passwordless lacks UV",
|
|
createCC: func() *wanlib.CredentialCreation { return &pwdlessCC },
|
|
prompt: noPIN, // PIN unset means it cannot do UV
|
|
wantErr: "user verification",
|
|
},
|
|
{
|
|
// Fictional scenario, no real-world authenticators match.
|
|
name: "passwordless lacks RK",
|
|
createCC: func() *wanlib.CredentialCreation { return &pwdlessCC },
|
|
prompt: noRK,
|
|
wantErr: "resident key",
|
|
},
|
|
{
|
|
name: "passwordless U2F",
|
|
createCC: func() *wanlib.CredentialCreation { return &pwdlessCC },
|
|
prompt: u2f,
|
|
wantErr: context.DeadlineExceeded.Error(), // silently filtered, times out
|
|
},
|
|
} {
|
|
t.Run("register/"+test.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := wancli.FIDO2Register(ctx, origin, test.createCC(), test.prompt)
|
|
assert.ErrorContains(t, err, test.wantErr, "FIDO2Register error mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFIDO2Register(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
const rpID = "example.com"
|
|
const origin = "https://example.com"
|
|
|
|
// 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,
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "rp name",
|
|
},
|
|
},
|
|
Parameters: []protocol.CredentialParameter{
|
|
{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256},
|
|
},
|
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
User: protocol.UserEntity{
|
|
ID: []byte{1, 2, 3, 4, 1}, // arbitrary,
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "user name",
|
|
},
|
|
DisplayName: "user display name",
|
|
},
|
|
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)")
|
|
},
|
|
},
|
|
{
|
|
name: "passwordless ResidentKey=required",
|
|
fido2: newFakeFIDO2(pin2),
|
|
setUP: pin2.setUP,
|
|
createCredential: func() *wanlib.CredentialCreation {
|
|
cp := pwdlessCC
|
|
cp.Response.AuthenticatorSelection.RequireResidentKey = nil
|
|
cp.Response.AuthenticatorSelection.ResidentKey = protocol.ResidentKeyRequirementRequired
|
|
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)")
|
|
},
|
|
},
|
|
}
|
|
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",
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "rp name",
|
|
},
|
|
},
|
|
Parameters: []protocol.CredentialParameter{
|
|
{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256},
|
|
},
|
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
User: protocol.UserEntity{
|
|
ID: []byte{1, 2, 3, 4, 1}, // arbitrary,
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "user name",
|
|
},
|
|
DisplayName: "user display name",
|
|
},
|
|
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: "cc without challenge",
|
|
origin: origin,
|
|
createCC: func() *wanlib.CredentialCreation {
|
|
cp := *okCC
|
|
cp.Response.Challenge = nil
|
|
return &cp
|
|
},
|
|
prompt: prompt,
|
|
wantErr: "challenge",
|
|
},
|
|
{
|
|
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",
|
|
},
|
|
}
|
|
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 TestFIDO2Register_u2fExcludedCredentials(t *testing.T) {
|
|
resetFIDO2AfterTests(t)
|
|
|
|
u2fDev := mustNewFIDO2Device("/u2f", "" /* pin */, nil /* info */)
|
|
u2fDev.u2fOnly = true
|
|
|
|
// otherDev is FIDO2 in this test, but it could be any non-registered device.
|
|
otherDev := mustNewFIDO2Device("/fido2", "" /* pin */, &libfido2.DeviceInfo{
|
|
Options: authOpts,
|
|
})
|
|
|
|
f2 := newFakeFIDO2(u2fDev, otherDev).withNonMeteredLocations()
|
|
f2.setCallbacks()
|
|
|
|
const origin = "https://example.com"
|
|
cc := &wanlib.CredentialCreation{
|
|
Response: protocol.PublicKeyCredentialCreationOptions{
|
|
Challenge: make([]byte, 32),
|
|
RelyingParty: protocol.RelyingPartyEntity{
|
|
ID: "example.com",
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "rp name",
|
|
},
|
|
},
|
|
Parameters: []protocol.CredentialParameter{
|
|
{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256},
|
|
},
|
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
|
UserVerification: protocol.VerificationDiscouraged,
|
|
},
|
|
User: protocol.UserEntity{
|
|
ID: []byte{1, 2, 3, 4, 1}, // arbitrary,
|
|
CredentialEntity: protocol.CredentialEntity{
|
|
Name: "user name",
|
|
},
|
|
DisplayName: "user display name",
|
|
},
|
|
Attestation: protocol.PreferNoAttestation,
|
|
},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Setup: register the U2F device.
|
|
resp, err := wancli.FIDO2Register(ctx, origin, cc, u2fDev)
|
|
require.NoError(t, err, "FIDO2Register errored")
|
|
|
|
// Setup: mark the registered credential as excluded.
|
|
cc.Response.CredentialExcludeList = append(cc.Response.CredentialExcludeList, protocol.CredentialDescriptor{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: resp.GetWebauthn().GetRawId(),
|
|
})
|
|
|
|
// Register a new device, making sure a failed excluded credential assertion
|
|
// won't break the ceremony.
|
|
_, err = wancli.FIDO2Register(ctx, origin, cc, otherDev)
|
|
require.NoError(t, err, "FIDO2Register errored, expected a successful registration")
|
|
}
|
|
|
|
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 {
|
|
useNonMeteredLocs bool
|
|
|
|
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
|
|
}
|
|
|
|
// withNonMeteredLocations makes fakeFIDO2 return all known devices immediately.
|
|
// Useful to test flows that optimize for plugged devices.
|
|
func (f *fakeFIDO2) withNonMeteredLocations() *fakeFIDO2 {
|
|
f.useNonMeteredLocs = true
|
|
return f
|
|
}
|
|
|
|
func (f *fakeFIDO2) setCallbacks() {
|
|
if f.useNonMeteredLocs {
|
|
*wancli.FIDODeviceLocations = f.DeviceLocations
|
|
} else {
|
|
*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) DeviceLocations() ([]*libfido2.DeviceLocation, error) {
|
|
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 {
|
|
simplePicker
|
|
|
|
// Set to true to cause "unsupported option" UV errors, regardless of other
|
|
// conditions.
|
|
failUV bool
|
|
|
|
// Set to true to simulate an U2F-only device.
|
|
// Causes libfido2.ErrNotFIDO2 on Info.
|
|
u2fOnly bool
|
|
|
|
// assertionErrors is a chain of errors to return from Assertion.
|
|
// Errors are returned from start to end and removed, one-by-one, on each
|
|
// invocation of the Assertion method.
|
|
// If the slice is empty, Assertion runs normally.
|
|
assertionErrors []error
|
|
|
|
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
|
|
|
|
// cond guards up and cancel.
|
|
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
|
|
}
|
|
|
|
return &fakeFIDO2Device{
|
|
path: path,
|
|
pin: pin,
|
|
credentials: creds,
|
|
format: "packed",
|
|
info: info,
|
|
key: key,
|
|
pubKey: pubKeyCBOR,
|
|
cond: sync.NewCond(&sync.Mutex{}),
|
|
}, nil
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) PromptPIN() (string, error) {
|
|
return f.pin, nil
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) PromptTouch() (wancli.TouchAcknowledger, error) {
|
|
f.setUP()
|
|
return func() error { return nil }, 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) {
|
|
if f.u2fOnly {
|
|
return nil, libfido2.ErrNotFIDO2
|
|
}
|
|
return f.info, nil
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) setUP() {
|
|
f.cond.L.Lock()
|
|
f.up = true
|
|
f.cond.L.Unlock()
|
|
f.cond.Broadcast()
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) Cancel() error {
|
|
f.cond.L.Lock()
|
|
f.cancel = true
|
|
f.cond.L.Unlock()
|
|
f.cond.Broadcast()
|
|
return 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
|
|
case opts.RK == libfido2.True && !f.hasRK():
|
|
// TODO(codingllama): Confirm scenario with a real authenticator.
|
|
return nil, libfido2.ErrUnsupportedOption
|
|
}
|
|
|
|
// 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,
|
|
credentialIDs [][]byte,
|
|
pin string,
|
|
opts *libfido2.AssertionOpts,
|
|
) ([]*libfido2.Assertion, error) {
|
|
// Give preference to simulated errors.
|
|
if len(f.assertionErrors) > 0 {
|
|
err := f.assertionErrors[0]
|
|
f.assertionErrors = f.assertionErrors[1:]
|
|
return nil, err
|
|
}
|
|
|
|
switch {
|
|
case rpID == "":
|
|
return nil, errors.New("rp.ID required")
|
|
case len(clientDataHash) == 0:
|
|
return nil, errors.New("clientDataHash required")
|
|
}
|
|
|
|
// Validate UV.
|
|
switch {
|
|
case opts.UV == "": // OK, actually works as false.
|
|
case opts.UV == libfido2.True && f.failUV:
|
|
// Emulate UV failures, as seen in some devices regardless of other
|
|
// settings.
|
|
return nil, libfido2.ErrUnsupportedOption
|
|
case opts.UV == libfido2.True && f.isBio(): // OK.
|
|
case opts.UV == libfido2.True && f.hasClientPin() && pin != "": // OK, doubles as UV.
|
|
default: // Anything else is invalid, including libfido2.False.
|
|
return nil, libfido2.ErrUnsupportedOption
|
|
}
|
|
|
|
// Validate PIN only if present and UP is required.
|
|
// This is in line with how current YubiKeys behave.
|
|
// TODO(codingllama): This should probably take UV into consideration.
|
|
privilegedAccess := f.isBio()
|
|
if pin != "" && opts.UP == libfido2.True {
|
|
if err := f.validatePIN(pin); err != nil {
|
|
return nil, err
|
|
}
|
|
privilegedAccess = true
|
|
}
|
|
|
|
// Block for user presence before accessing any credential data.
|
|
if err := f.maybeLockUntilInteraction(opts.UP == libfido2.True); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Does our explicitly set RPID match?
|
|
// Used to simulate U2F App ID.
|
|
if f.wantRPID != "" && f.wantRPID != rpID {
|
|
return nil, libfido2.ErrNoCredentials
|
|
}
|
|
|
|
// Index credentialIDs for easier use.
|
|
credIDs := make(map[string]struct{})
|
|
for _, cred := range credentialIDs {
|
|
credIDs[string(cred)] = struct{}{}
|
|
}
|
|
|
|
// Assemble one assertion for each allowed credential we hold.
|
|
var assertions []*libfido2.Assertion
|
|
|
|
// "base" credential. Only add an assertion if explicitly requested.
|
|
if _, ok := credIDs[string(f.key.KeyHandle)]; ok {
|
|
// Simulate Yubikey4 and require UP, even if UP==false is set.
|
|
if f.u2fOnly && opts.UP == libfido2.False {
|
|
return nil, libfido2.ErrUserPresenceRequired
|
|
}
|
|
|
|
assertions = append(assertions, &libfido2.Assertion{
|
|
AuthDataCBOR: assertionAuthDataCBOR,
|
|
Sig: assertionSig,
|
|
CredentialID: f.key.KeyHandle,
|
|
User: libfido2.User{
|
|
// We don't hold data about the user for the "base" credential / MFA
|
|
// scenario.
|
|
// A typical authenticator might choose to save some data within the
|
|
// key handle itself.
|
|
},
|
|
})
|
|
}
|
|
|
|
// Resident credentials.
|
|
if privilegedAccess {
|
|
for _, resident := range f.credentials {
|
|
allowed := len(credIDs) == 0
|
|
if !allowed {
|
|
_, allowed = credIDs[string(resident.ID)]
|
|
}
|
|
if !allowed {
|
|
continue
|
|
}
|
|
assertions = append(assertions, &libfido2.Assertion{
|
|
AuthDataCBOR: assertionAuthDataCBOR,
|
|
Sig: assertionSig,
|
|
HMACSecret: []byte{},
|
|
CredentialID: resident.ID,
|
|
User: libfido2.User{
|
|
ID: resident.User.ID,
|
|
Name: resident.User.Name,
|
|
DisplayName: resident.User.DisplayName,
|
|
Icon: resident.User.Icon,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
switch len(assertions) {
|
|
case 0:
|
|
return nil, libfido2.ErrNoCredentials
|
|
case 1:
|
|
// Remove user name / display name / icon.
|
|
// See the authenticatorGetAssertion response structure, 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
|
|
assertions[0].User.Name = ""
|
|
assertions[0].User.DisplayName = ""
|
|
assertions[0].User.Icon = ""
|
|
return assertions, nil
|
|
default:
|
|
return assertions, 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) hasClientPin() bool {
|
|
return f.hasBoolOpt("clientPin")
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) hasRK() bool {
|
|
return f.hasBoolOpt("rk")
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) hasUV() bool {
|
|
return f.hasBoolOpt("uv")
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) isBio() bool {
|
|
return f.hasBoolOpt("bioEnroll")
|
|
}
|
|
|
|
func (f *fakeFIDO2Device) hasBoolOpt(name string) bool {
|
|
if f.info == nil {
|
|
return false
|
|
}
|
|
|
|
for _, opt := range f.info.Options {
|
|
if opt.Name == name {
|
|
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.cond.L.Lock()
|
|
for !f.up && !f.cancel {
|
|
f.cond.Wait()
|
|
}
|
|
defer f.cond.L.Unlock()
|
|
|
|
// Record/reset state.
|
|
isCancel := f.cancel
|
|
f.up = false
|
|
f.cancel = false
|
|
|
|
if isCancel {
|
|
return libfido2.ErrKeepaliveCancel
|
|
}
|
|
return nil
|
|
}
|