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

mfa: replace u2f-host with github.com/flynn/u2f #5477

Merged
merged 2 commits into from
Feb 4, 2021
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
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