Skip to content

Commit

Permalink
Implement the Touch ID credential picker (#14493)
Browse files Browse the repository at this point in the history
Implement the Touch ID credential picker.

During passwordless authentication, when more than one login is present in
Enclave credentials, `tsh` now asks the end user to pick their desired login.
Credential picker terminal prompts are preceded by a system Touch ID prompt,
which is then reused for authentication, provided less than 10 seconds pass in
the meantime.

I've done a couple of refactors to make the `CredentialInfo` structs similar
between the `webauthncli` and `touchid` packages, so it's easier to trace
parallels between them.

#13901

* Refactor touchid.CredentialInfo
* Refactor wancli.CredentialInfo
* Move fido2_prompt*.go to prompt*.go
* Define the touchid credential picker API
* Add Touch ID credential picker tests
* Implement touchid credential picker
  • Loading branch information
codingllama committed Jul 19, 2022
1 parent f31b9cb commit aecac30
Show file tree
Hide file tree
Showing 12 changed files with 744 additions and 219 deletions.
125 changes: 96 additions & 29 deletions lib/auth/touchid/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,9 @@ type DiagResult struct {

// CredentialInfo holds information about a Secure Enclave credential.
type CredentialInfo struct {
UserHandle []byte
CredentialID string
RPID string
User string
User UserInfo
PublicKey *ecdsa.PublicKey
CreateTime time.Time

Expand All @@ -125,6 +124,12 @@ type CredentialInfo struct {
publicKeyRaw []byte
}

// UserInfo holds information about a credential owner.
type UserInfo struct {
UserHandle []byte
Name string
}

var (
cachedDiag *DiagResult
cachedDiagMU sync.Mutex
Expand Down Expand Up @@ -423,10 +428,18 @@ func makeAttestationData(ceremony protocol.CeremonyType, origin, rpID string, ch
}, nil
}

// CredentialPicker allows users to choose a credential for login.
type CredentialPicker interface {
// PromptCredential prompts the user to pick a credential from the list.
// Prompts only happen if there is more than one credential to choose from.
// Must return one of the pointers from the slice or an error.
PromptCredential(creds []*CredentialInfo) (*CredentialInfo, error)
}

// Login authenticates using a Secure Enclave-backed biometric credential.
// It returns the assertion response and the user that owns the credential to
// sign it.
func Login(origin, user string, assertion *wanlib.CredentialAssertion) (*wanlib.CredentialAssertionResponse, string, error) {
func Login(origin, user string, assertion *wanlib.CredentialAssertion, picker CredentialPicker) (*wanlib.CredentialAssertionResponse, string, error) {
if !IsAvailable() {
return nil, "", ErrNotAvailable
}
Expand All @@ -445,6 +458,8 @@ func Login(origin, user string, assertion *wanlib.CredentialAssertion) (*wanlib.
return nil, "", errors.New("challenge required")
case assertion.Response.RelyingPartyID == "":
return nil, "", errors.New("relying party ID required")
case picker == nil:
return nil, "", errors.New("picker required")
}

rpID := assertion.Response.RelyingPartyID
Expand All @@ -464,29 +479,34 @@ func Login(origin, user string, assertion *wanlib.CredentialAssertion) (*wanlib.
return i1.CreateTime.After(i2.CreateTime)
})

// Verify infos against allowed credentials, if any.
cred, ok := findAllowedCredential(infos, assertion.Response.AllowedCredentials)
if !ok {
return nil, "", ErrCredentialNotFound
}

// Guard first read of chosen credential with an explicit check.
// A more meaningful check can be made once the credential picker is
// implemented.
// Prepare authentication context and prompt for the credential picker.
actx := native.NewAuthContext()
defer actx.Close()
promptPlatform()
if err := actx.Guard(func() {
log.Debugf("Touch ID: using credential %q", cred.CredentialID)
}); err != nil {

var prompted bool
promptOnce := func() {
if prompted {
return
}
promptPlatform()
prompted = true
}

cred, err := pickCredential(
actx,
infos, assertion.Response.AllowedCredentials,
picker, promptOnce, user != "" /* userRequested */)
if err != nil {
return nil, "", trace.Wrap(err)
}
log.Debugf("Touch ID: using credential %q", cred.CredentialID)

attData, err := makeAttestationData(protocol.AssertCeremony, origin, rpID, assertion.Response.Challenge, nil /* cred */)
if err != nil {
return nil, "", trace.Wrap(err)
}

promptOnce() // In case the picker prompt didn't happen.
sig, err := native.Authenticate(actx, cred.CredentialID, attData.digest)
if err != nil {
return nil, "", trace.Wrap(err)
Expand All @@ -506,26 +526,73 @@ func Login(origin, user string, assertion *wanlib.CredentialAssertion) (*wanlib.
},
AuthenticatorData: attData.rawAuthData,
Signature: sig,
UserHandle: cred.UserHandle,
UserHandle: cred.User.UserHandle,
},
}, cred.User, nil
}, cred.User.Name, nil
}

func findAllowedCredential(infos []CredentialInfo, allowedCredentials []protocol.CredentialDescriptor) (CredentialInfo, bool) {
if len(infos) > 0 && len(allowedCredentials) == 0 {
// Default to "first" credential for passwordless
return infos[0], true
}

for _, info := range infos {
for _, cred := range allowedCredentials {
if info.CredentialID == string(cred.CredentialID) {
return info, true
func pickCredential(
actx AuthContext,
infos []CredentialInfo, allowedCredentials []protocol.CredentialDescriptor,
picker CredentialPicker, promptOnce func(), userRequested bool) (*CredentialInfo, error) {
// Handle early exits.
switch l := len(infos); {
// MFA.
case len(allowedCredentials) > 0:
for _, info := range infos {
for _, cred := range allowedCredentials {
if info.CredentialID == string(cred.CredentialID) {
return &info, nil
}
}
}
return nil, ErrCredentialNotFound

// Single credential or specific user requested.
// A requested user means that all credentials are for that user, so there
// would be nothing to pick.
case l == 1 || userRequested:
return &infos[0], nil
}

// Dedup users to avoid confusion.
// This assumes credentials are sorted from most to less preferred.
knownUsers := make(map[string]struct{})
deduped := make([]*CredentialInfo, 0, len(infos))
for _, c := range infos {
if _, ok := knownUsers[c.User.Name]; ok {
continue
}
knownUsers[c.User.Name] = struct{}{}

c := c // Avoid capture-by-reference errors
deduped = append(deduped, &c)
}
if len(deduped) == 1 {
return deduped[0], nil
}

promptOnce()
var choice *CredentialInfo
var choiceErr error
if err := actx.Guard(func() {
choice, choiceErr = picker.PromptCredential(deduped)
}); err != nil {
return nil, trace.Wrap(err)
}
if choiceErr != nil {
return nil, trace.Wrap(choiceErr)
}

return CredentialInfo{}, false
// Is choice a pointer within the slice?
// We could work around this requirement, but it seems better to constrain the
// picker API from the start.
for _, c := range deduped {
if c == choice {
return choice, nil
}
}
return nil, fmt.Errorf("picker returned invalid credential: %#v", choice)
}

// ListCredentials lists all registered Secure Enclave credentials.
Expand Down
6 changes: 4 additions & 2 deletions lib/auth/touchid/api_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,10 +334,12 @@ func readCredentialInfos(find func(**C.CredentialInfo) C.int) ([]CredentialInfo,
}

infos = append(infos, CredentialInfo{
UserHandle: userHandle,
CredentialID: credentialID,
RPID: parsedLabel.rpID,
User: parsedLabel.user,
User: UserInfo{
UserHandle: userHandle,
Name: parsedLabel.user,
},
CreateTime: createTime,
publicKeyRaw: pubKeyRaw,
})
Expand Down
Loading

0 comments on commit aecac30

Please sign in to comment.