Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v16] fix: Assert credentials individually on U2F devices #48402

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions lib/auth/webauthncli/fido2.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -220,15 +221,15 @@ func fido2Login(
if uv {
opts.UV = libfido2.True
}
assertions, err := dev.Assertion(actualRPID, ccdHash[:], allowedCreds, pin, opts)
assertions, err := devAssertion(dev, info, actualRPID, ccdHash[:], allowedCreds, pin, opts)
if errors.Is(err, libfido2.ErrUnsupportedOption) && uv && pin != "" {
// Try again if we are getting "unsupported option" and the PIN is set.
// Happens inconsistently in some authenticator series (YubiKey 5).
// We are relying on the fact that, because the PIN is set, the
// authenticator will set the UV bit regardless of it being requested.
log.Debugf("FIDO2: Device %v: retrying assertion without UV", info.path)
opts.UV = libfido2.Default
assertions, err = dev.Assertion(actualRPID, ccdHash[:], allowedCreds, pin, opts)
assertions, err = devAssertion(dev, info, actualRPID, ccdHash[:], allowedCreds, pin, opts)
}
if errors.Is(err, libfido2.ErrNoCredentials) {
// U2F devices error instantly with ErrNoCredentials.
Expand Down Expand Up @@ -312,13 +313,75 @@ func usesAppID(dev FIDODevice, info *deviceInfo, ccdHash []byte, allowedCreds []

isRegistered := func(id string) bool {
const pin = "" // Not necessary here.
_, err := dev.Assertion(id, ccdHash, allowedCreds, pin, opts)
_, err := devAssertion(dev, info, id, ccdHash, allowedCreds, pin, opts)
return err == nil || (!info.fido2 && errors.Is(err, libfido2.ErrUserPresenceRequired))
}

return isRegistered(appID) && !isRegistered(rpID)
}

func devAssertion(
dev FIDODevice,
info *deviceInfo,
rpID string,
ccdHash []byte,
allowedCreds [][]byte,
pin string,
opts *libfido2.AssertionOpts,
) ([]*libfido2.Assertion, error) {
// Handle U2F devices separately when there is more than one allowed
// credential.
// This avoids "internal errors" on older Yubikey models (eg, FIDO U2F
// Security Key firmware 4.1.8).
if !info.fido2 && len(allowedCreds) > 1 {
cred, ok := findFirstKnownCredential(dev, info, rpID, ccdHash, allowedCreds)
if ok {
isCredentialCheck := pin == "" && opts != nil && opts.UP == libfido2.False
if isCredentialCheck {
// No need to assert again, reply as the U2F authenticator would.
return nil, trace.Wrap(libfido2.ErrUserPresenceRequired)
}

if log.IsLevelEnabled(log.DebugLevel) {
credPrefix := hex.EncodeToString(cred)
const prefixLen = 10
if len(credPrefix) > prefixLen {
credPrefix = credPrefix[:prefixLen]
}
log.Debugf("FIDO2: Device %v: Using credential %v...", info.path, credPrefix)
}

allowedCreds = [][]byte{cred}
}
}

assertion, err := dev.Assertion(rpID, ccdHash, allowedCreds, pin, opts)
return assertion, trace.Wrap(err)
}

func findFirstKnownCredential(
dev FIDODevice,
info *deviceInfo,
rpID string,
ccdHash []byte,
allowedCreds [][]byte,
) ([]byte, bool) {
const pin = ""
opts := &libfido2.AssertionOpts{
UP: libfido2.False,
}
for _, cred := range allowedCreds {
_, err := dev.Assertion(rpID, ccdHash, [][]byte{cred}, pin, opts)
// FIDO2 devices return err=nil on up=false queries; U2F devices return
// libfido2.ErrUserPresenceRequired.
// https://github.com/Yubico/libfido2/blob/03c18d396eb209a42bbf62f5f4415203cba2fc50/src/u2f.c#L787-L791.
if err == nil || (!info.fido2 && errors.Is(err, libfido2.ErrUserPresenceRequired)) {
return cred, true
}
}
return nil, false
}

func pickAssertion(
assertions []*libfido2.Assertion, prompt LoginPrompt, user string, passwordless bool,
) (*libfido2.Assertion, error) {
Expand Down Expand Up @@ -452,7 +515,7 @@ func fido2Register(

// Does the device hold an excluded credential?
const pin = "" // not required to filter
switch _, err := dev.Assertion(rp.ID, ccdHash[:], excludeList, pin, &libfido2.AssertionOpts{
switch _, err := devAssertion(dev, info, rp.ID, ccdHash[:], excludeList, pin, &libfido2.AssertionOpts{
UP: libfido2.False,
}); {
case errors.Is(err, libfido2.ErrNoCredentials):
Expand Down
110 changes: 110 additions & 0 deletions lib/auth/webauthncli/fido2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,102 @@ func TestFIDO2Register_u2fExcludedCredentials(t *testing.T) {
require.NoError(t, err, "FIDO2Register errored, expected a successful registration")
}

// TestFIDO2Login_u2fInternalError tests the scenario described by issue
// https://github.com/gravitational/teleport/issues/44912.
func TestFIDO2Login_u2fInternalError(t *testing.T) {
resetFIDO2AfterTests(t)

dev1 := mustNewFIDO2Device("/dev1", "" /* pin */, &libfido2.DeviceInfo{
Options: authOpts,
})
dev2 := mustNewFIDO2Device("/dev2", "" /* pin */, &libfido2.DeviceInfo{
Options: authOpts,
})
u2fDev := mustNewFIDO2Device("/u2f", "" /* pin */, nil /* info */)
u2fDev.u2fOnly = true
u2fDev.errorOnUnknownCredential = true

f2 := newFakeFIDO2(dev1, dev2, u2fDev)
f2.setCallbacks()

const origin = "https://example.com"
ctx := context.Background()

// Register all authenticators.
cc := &wantypes.CredentialCreation{
Response: wantypes.PublicKeyCredentialCreationOptions{
Challenge: make([]byte, 32),
RelyingParty: wantypes.RelyingPartyEntity{
CredentialEntity: protocol.CredentialEntity{
Name: "example.com",
},
ID: "example.com",
},
User: wantypes.UserEntity{
CredentialEntity: protocol.CredentialEntity{
Name: "alpaca",
},
DisplayName: "Alpaca",
ID: []byte{1, 2, 3, 4, 5}, // arbitrary
},
Parameters: []wantypes.CredentialParameter{
{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256},
},
AuthenticatorSelection: wantypes.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyNotRequired(),
ResidentKey: protocol.ResidentKeyRequirementDiscouraged,
UserVerification: protocol.VerificationDiscouraged,
},
Attestation: protocol.PreferNoAttestation,
},
}
allowedCreds := make([]wantypes.CredentialDescriptor, 0, len(f2.devices))
for _, dev := range f2.devices {
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
mfaResp, err := wancli.FIDO2Register(ctx, origin, cc, dev)
cancel()
require.NoError(t, err, "FIDO2Register failed")

allowedCreds = append(allowedCreds, wantypes.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: mfaResp.GetWebauthn().RawId,
})
}

// Sanity check: authenticator errors in the presence of unknown credentials.
u2fDev.open()
_, err := u2fDev.Assertion(
"example.com",
[]byte(`55cde2973243a946b85a477d2e164a35d2e4f3daaeb11ac5e9a1c4cf3297033e`), // clientDataHash
[][]byte{
u2fDev.credentialID(),
bytes.Repeat([]byte("A"), 96),
},
"", // pin
&libfido2.AssertionOpts{UP: libfido2.False},
)
require.ErrorIs(t, err, libfido2.ErrInternal, "u2fDev.Assert error mismatch")
u2fDev.Close()

t.Run("login with multiple credentials", func(t *testing.T) {
assertion := &wantypes.CredentialAssertion{
Response: wantypes.PublicKeyCredentialRequestOptions{
Challenge: make([]byte, 32),
RelyingPartyID: "example.com",
AllowedCredentials: allowedCreds,
UserVerification: protocol.VerificationDiscouraged,
},
}

ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
_, _, err := wancli.FIDO2Login(ctx, origin, assertion, u2fDev, &wancli.LoginOpts{
User: "alpaca",
})
require.NoError(t, err, "FIDO2Login failed")
})
}

func resetFIDO2AfterTests(t *testing.T) {
pollInterval := wancli.FIDO2PollInterval
devLocations := wancli.FIDODeviceLocations
Expand Down Expand Up @@ -2015,6 +2111,10 @@ type fakeFIDO2Device struct {
// Causes libfido2.ErrNotFIDO2 on Info.
u2fOnly bool

// errorOnUnknownCredential makes the device fail assertions if an unknown
// credential is present.
errorOnUnknownCredential 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.
Expand Down Expand Up @@ -2291,6 +2391,9 @@ func (f *fakeFIDO2Device) Assertion(
found = true
break
}
if f.errorOnUnknownCredential {
return nil, fmt.Errorf("failed to get assertion: %w", libfido2.ErrInternal)
}
}
if !found {
return nil, libfido2.ErrNoCredentials
Expand All @@ -2316,6 +2419,13 @@ func (f *fakeFIDO2Device) Assertion(
credIDs := make(map[string]struct{})
for _, cred := range credentialIDs {
credIDs[string(cred)] = struct{}{}

// Simulate "internal error" on unknown credential handles.
// Sometimes happens with Yubikeys firmware 4.1.8.
// Requires a tap to happen.
if f.errorOnUnknownCredential && !bytes.Equal(cred, f.key.KeyHandle) {
return nil, fmt.Errorf("failed to get assertion: %w", libfido2.ErrInternal)
}
}

// Assemble one assertion for each allowed credential we hold.
Expand Down
Loading