Skip to content

Commit

Permalink
mfa: replace u2f-host with github.com/flynn/u2f (#5477)
Browse files Browse the repository at this point in the history
This change removes the need for users to manually install u2f-host.
It also enables us to do U2F authentication with multiple devices.
  • Loading branch information
Andrew Lytvynov authored Feb 4, 2021
1 parent 86908cc commit 491a298
Show file tree
Hide file tree
Showing 20 changed files with 1,985 additions and 144 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ require (
github.com/docker/docker v17.12.0-ce-rc1.0.20180721085148-1ef1cc838816+incompatible
github.com/docker/spdystream v0.0.0-20170912183627-bc6354cbbc29 // indirect
github.com/dustin/go-humanize v1.0.0
github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a // indirect
github.com/flynn/u2f v0.0.0-20180613185708-15554eb68e5d
github.com/fsouza/fake-gcs-server v1.11.6
github.com/ghodss/yaml v1.0.0
github.com/gizak/termui v0.0.0-20190224181052-63c2a0d70943
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a h1:fsyWnwbywFpHJS4T55vDW+UUeWP2WomJbB45/jf4If4=
github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a/go.mod h1:Osz+xPHFsGWK9kZCEVcwXazcF/CHjscCVZosNFgwUIY=
github.com/flynn/u2f v0.0.0-20180613185708-15554eb68e5d h1:2D6Rp/MRcrKnRFr7kfgBOJnJPFN0jPfc36ggct5MaK0=
github.com/flynn/u2f v0.0.0-20180613185708-15554eb68e5d/go.mod h1:shcCQPgKtaJz4obqb6Si031WgtSrW+Tj+ZLq/mRNrM8=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsouza/fake-gcs-server v1.11.6 h1:xRjuDnNG1xqiHyw+K15yRlWGmzgvCd4q3KnVh9duYEE=
Expand Down
140 changes: 76 additions & 64 deletions lib/auth/u2f/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ package u2f

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"time"

"github.com/flynn/u2f/u2ftoken"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/mailgun/ttlmap"
Expand Down Expand Up @@ -148,79 +150,89 @@ func AuthenticateInit(ctx context.Context, params AuthenticateInitParams) (*Auth
//
// Note: the caller must prompt the user to tap the U2F token.
func AuthenticateSignChallenge(ctx context.Context, facet string, challenges ...AuthenticateChallenge) (*AuthenticateChallengeResponse, error) {
// TODO(awly): mfa: u2f-host fails when running multiple processes in
// parallel. This means that with u2f-host, teleport can't authenticate
// using multiple U2F devices. Replace u2f-host with a Go library that can
// prompt multiple devices at once.
c := challenges[0]
base64 := base64.URLEncoding.WithPadding(base64.NoPadding)

// Pass the JSON-encoded data undecoded to the u2f-host binary
challengeRaw, err := json.Marshal(c)
if err != nil {
return nil, trace.Wrap(err)
// Convert from JS-centric github.com/tstranex/u2f format to a more
// wire-centric github.com/flynn/u2f format.
type authenticateRequest struct {
orig AuthenticateChallenge
clientData []byte
converted u2ftoken.AuthenticateRequest
}
cmd := exec.CommandContext(ctx, "u2f-host", "-aauthenticate", "-o", facet)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, trace.Wrap(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, trace.Wrap(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, trace.Wrap(err)
authRequests := make([]authenticateRequest, 0, len(challenges))
for _, chal := range challenges {
kh, err := base64.DecodeString(chal.KeyHandle)
if err != nil {
return nil, trace.BadParameter("invalid KeyHandle %q in AuthenticateChallenge: %v", chal.KeyHandle, err)
}
cd := u2f.ClientData{
Challenge: chal.Challenge,
Origin: facet,
Typ: "navigator.id.getAssertion",
}
cdRaw, err := json.Marshal(cd)
if err != nil {
return nil, trace.Wrap(err)
}
chHash := sha256.Sum256(cdRaw)
appHash := sha256.Sum256([]byte(chal.AppID))
authRequests = append(authRequests, authenticateRequest{
orig: chal,
clientData: cdRaw,
converted: u2ftoken.AuthenticateRequest{
KeyHandle: kh,
Challenge: chHash[:],
Application: appHash[:],
},
})
}

if err := cmd.Start(); err != nil {
return nil, trace.Wrap(err)
}
defer func() {
// If we returned before cmd.Wait was called, clean up the spawned
// process. ProcessState will be empty until cmd.Wait or cmd.Run
// return.
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
cmd.Process.Kill()
var matchedAuthReq *authenticateRequest
var authResp *u2ftoken.AuthenticateResponse
if err := pollLocalDevices(ctx, func(t *u2ftoken.Token) error {
var errs []error
for _, req := range authRequests {
if err := t.CheckAuthenticate(req.converted); err != nil {
if err != u2ftoken.ErrUnknownKeyHandle {
errs = append(errs, trace.Wrap(err))
}
continue
}
res, err := t.Authenticate(req.converted)
if err == u2ftoken.ErrPresenceRequired {
continue
} else if err != nil {
errs = append(errs, trace.Wrap(err))
continue
}
matchedAuthReq = &req
authResp = res
return nil
}
}()
_, err = stdin.Write(challengeRaw)
stdin.Close()
if err != nil {
if len(errs) > 0 {
return trace.NewAggregate(errs...)
}
return errAuthNoKeyOrUserPresence
}); err != nil {
return nil, trace.Wrap(err)
}

// The origin URL is passed back base64-encoded and the keyHandle is passed back as is.
// A very long proxy hostname or keyHandle can overflow a fixed-size buffer.
signResponseLen := 500 + len(challengeRaw) + len(facet)*4/3
signResponseBuf := make([]byte, signResponseLen)
signResponseLen, err = io.ReadFull(stdout, signResponseBuf)
// unexpected EOF means we have read the data completely.
if err == nil {
return nil, trace.LimitExceeded("u2f sign response exceeded buffer size")
}

// Read error message (if any). 100 bytes is more than enough for any error message u2f-host outputs
errMsgBuf := make([]byte, 100)
errMsgLen, err := io.ReadFull(stderr, errMsgBuf)
if err == nil {
return nil, trace.LimitExceeded("u2f error message exceeded buffer size")
if authResp == nil || matchedAuthReq == nil {
// This shouldn't happen if the loop above works correctly, but check
// just in case.
return nil, trace.CompareFailed("failed getting an authentication response from a U2F device")
}

err = cmd.Wait()
if err != nil {
return nil, trace.AccessDenied("u2f-host returned error: " + string(errMsgBuf[:errMsgLen]))
} else if signResponseLen == 0 {
return nil, trace.NotFound("u2f-host returned no error and no sign response")
}

var resp AuthenticateChallengeResponse
if err := json.Unmarshal(signResponseBuf[:signResponseLen], &resp); err != nil {
return nil, trace.Wrap(err)
}
return &resp, nil
// Convert back from github.com/flynn/u2f to github.com/tstranex/u2f
// format.
return &AuthenticateChallengeResponse{
KeyHandle: matchedAuthReq.orig.KeyHandle,
SignatureData: base64.EncodeToString(authResp.RawResponse),
ClientData: base64.EncodeToString(matchedAuthReq.clientData),
}, nil
}

var errAuthNoKeyOrUserPresence = errors.New("no U2F keys for the challenge found or user hasn't tapped the key yet")

// AuthenticateVerifyParams are the parameters for verifying the
// AuthenticationChallengeResponse.
type AuthenticateVerifyParams struct {
Expand Down
73 changes: 71 additions & 2 deletions lib/auth/u2f/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
Copyright 2021 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
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
Expand All @@ -22,7 +21,10 @@ import (
"crypto/x509"
"time"

"github.com/flynn/u2f/u2fhid"
"github.com/flynn/u2f/u2ftoken"
"github.com/gravitational/trace"
"github.com/sirupsen/logrus"
"github.com/tstranex/u2f"

"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -97,3 +99,70 @@ func decodeDevicePubKey(d *types.U2FDevice) (*ecdsa.PublicKey, error) {
}
return pubKey, nil
}

// pollLocalDevices calls fn against all local U2F devices until one succeeds
// (fn returns nil) or until the context is cancelled.
func pollLocalDevices(ctx context.Context, fn func(t *u2ftoken.Token) error) error {
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for {
err := foreachLocalDevice(fn)
if err == nil {
return nil
}
// Don't spam the logs while we're waiting for a key to be plugged
// in or tapped.
if err != errAuthNoKeyOrUserPresence {
logrus.WithError(err).Debugf("Error polling U2F devices for registration")
}

select {
case <-ctx.Done():
return trace.Wrap(ctx.Err())
case <-tick.C:
}
}
}

// foreachLocalDevice runs fn against each currently available U2F device. It
// stops when fn returns nil or when finished iterating against the available
// devices.
//
// You most likely want to call pollLocalDevices instead.
func foreachLocalDevice(fn func(t *u2ftoken.Token) error) error {
// Note: we fetch and open all devices on every polling iteration on
// purpose. This will handle the device that a user inserts after the
// polling has started.
devices, err := u2fhid.Devices()
if err != nil {
return trace.Wrap(err)
}
var errs []error
for _, d := range devices {
dev, err := u2fhid.Open(d)
if err != nil {
errs = append(errs, trace.Wrap(err))
continue
}
// There are usually 1-2 devices plugged in, so deferring closing all
// of them until the end of function is not too wasteful.
defer dev.Close()

t := u2ftoken.NewToken(dev)
// fn is usually t.Authenticate or t.Register. Both methods are
// non-blocking - the U2F device returns u2ftoken.ErrPresenceRequired
// immediately, unless the user has recently tapped the device.
if err := fn(t); err == u2ftoken.ErrPresenceRequired || err == errAuthNoKeyOrUserPresence {
continue
} else if err != nil {
errs = append(errs, trace.Wrap(err))
continue
}
return nil
}

if len(errs) > 0 {
return trace.NewAggregate(errs...)
}
return errAuthNoKeyOrUserPresence
}
Loading

0 comments on commit 491a298

Please sign in to comment.