From 1684339abbef4193cd4702cc3ac2a2798964ed3f Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 9 Sep 2020 16:36:36 +0200 Subject: [PATCH 01/34] add go.mod --- go.mod | 8 ++++++++ go.sum | 6 ++++++ 2 files changed, 14 insertions(+) create mode 100644 go.mod create mode 100644 go.sum diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2807548 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/flynn/u2f + +go 1.15 + +require ( + github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a + github.com/fxamacker/cbor/v2 v2.2.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ca8eb06 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +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/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= +github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= From 1a80ea5d864352ca4d6d6c5b989c8c45ca471984 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 9 Sep 2020 16:37:18 +0200 Subject: [PATCH 02/34] add new HID commands and CBOR command support --- u2fhid/hid.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/u2fhid/hid.go b/u2fhid/hid.go index d5fadde..adb791a 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -14,13 +14,16 @@ import ( ) const ( - cmdPing = 0x80 | 0x01 - cmdMsg = 0x80 | 0x03 - cmdLock = 0x80 | 0x04 - cmdInit = 0x80 | 0x06 - cmdWink = 0x80 | 0x08 - cmdSync = 0x80 | 0x3c - cmdError = 0x80 | 0x3f + cmdPing = 0x80 | 0x01 + cmdMsg = 0x80 | 0x03 + cmdLock = 0x80 | 0x04 + cmdInit = 0x80 | 0x06 + cmdWink = 0x80 | 0x08 + cmdCbor = 0x80 | 0x10 + cmdCancel = 0x80 | 0x11 + cmdKeepAlive = 0x80 | 0x3b + cmdSync = 0x80 | 0x3c + cmdError = 0x80 | 0x3f broadcastChannel = 0xffffffff @@ -275,6 +278,12 @@ func (d *Device) Message(data []byte) ([]byte, error) { return d.Command(cmdMsg, data) } +// CBOR sends an encapsulated CBOR protocol message to the device and returns +// the response. +func (d *Device) CBOR(data []byte) ([]byte, error) { + return d.Command(cmdCbor, data) +} + // Close closes the device and frees associated resources. func (d *Device) Close() { d.device.Close() From 7a3d30ef2d4339a96690e9a35fad8832f79e71da Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 9 Sep 2020 16:37:54 +0200 Subject: [PATCH 03/34] add ctap2token getInfo() --- ctap2token/token.go | 144 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 ctap2token/token.go diff --git a/ctap2token/token.go b/ctap2token/token.go new file mode 100644 index 0000000..ab749c6 --- /dev/null +++ b/ctap2token/token.go @@ -0,0 +1,144 @@ +package ctap2token + +import ( + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +const ( + statusSuccess = 0x00 + + cmdMakeCredential = 0x01 + cmdGetAssertion = 0x02 + cmdGetInfo = 0x04 + cmdClientPIN = 0x06 + cmdReset = 0x07 + cmdGetNextAssertion = 0x08 +) + +// CTAP2 error status from https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#error-responses +var ctap2Status = map[byte]string{ + 0x11: "CTAP2_ERR_CBOR_UNEXPECTED_TYPE", + 0x12: "CTAP2_ERR_INVALID_CBOR", + 0x14: "CTAP2_ERR_MISSING_PARAMETER", + 0x15: "CTAP2_ERR_LIMIT_EXCEEDED", + 0x16: "CTAP2_ERR_UNSUPPORTED_EXTENSION", + 0x19: "CTAP2_ERR_CREDENTIAL_EXCLUDED", + 0x21: "CTAP2_ERR_PROCESSING", + 0x22: "CTAP2_ERR_INVALID_CREDENTIAL", + 0x23: "CTAP2_ERR_USER_ACTION_PENDING", + 0x24: "CTAP2_ERR_OPERATION_PENDING", + 0x25: "CTAP2_ERR_NO_OPERATIONS", + 0x26: "CTAP2_ERR_UNSUPPORTED_ALGORITHM", + 0x27: "CTAP2_ERR_OPERATION_DENIED", + 0x28: "CTAP2_ERR_KEY_STORE_FULL", + 0x2A: "CTAP2_ERR_NO_OPERATION_PENDING", + 0x2B: "CTAP2_ERR_UNSUPPORTED_OPTION", + 0x2C: "CTAP2_ERR_INVALID_OPTION", + 0x2D: "CTAP2_ERR_KEEPALIVE_CANCEL", + 0x2E: "CTAP2_ERR_NO_CREDENTIALS", + 0x2F: "CTAP2_ERR_USER_ACTION_TIMEOUT", + 0x30: "CTAP2_ERR_NOT_ALLOWED", + 0x31: "CTAP2_ERR_PIN_INVALID", + 0x32: "CTAP2_ERR_PIN_BLOCKED", + 0x33: "CTAP2_ERR_PIN_AUTH_INVALID", + 0x34: "CTAP2_ERR_PIN_AUTH_BLOCKED", + 0x35: "CTAP2_ERR_PIN_NOT_SET", + 0x36: "CTAP2_ERR_PIN_REQUIRED", + 0x37: "CTAP2_ERR_PIN_POLICY_VIOLATION", + 0x38: "CTAP2_ERR_PIN_TOKEN_EXPIRED", + 0x39: "CTAP2_ERR_REQUEST_TOO_LARGE", + 0x3A: "CTAP2_ERR_ACTION_TIMEOUT", + 0x3B: "CTAP2_ERR_UP_REQUIRED", + 0xDF: "CTAP2_ERR_SPEC_LAST", + 0xE0: "CTAP2_ERR_EXTENSION_FIRST", + 0xEF: "CTAP2_ERR_EXTENSION_LAST", + 0xF0: "CTAP2_ERR_VENDOR_FIRST", + 0xFF: "CTAP2_ERR_VENDOR_LAST", +} + +type Device interface { + // CBOR sends a CBOR encoded message to the device and returns the response. + CBOR(data []byte) ([]byte, error) +} + +// NewToken returns a token that will use Device to communicate with the device. +func NewToken(d Device) *Token { + return &Token{d: d} +} + +// A Token implements the FIDO U2F hardware token messages as defined in the Raw +// Message Formats specification. +type Token struct { + d Device +} + +type MakeCredentialRequest struct{} +type MakeCredentialResponse struct{} + +func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialResponse, error) { + return nil, nil +} + +type GetAssertionRequest struct{} +type GetAssertioNResponse struct{} + +func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertioNResponse, error) { + return nil, nil +} + +type GetInfoResponse struct { + Versions []string `cbor:"1,keyasint,toarray"` + Extensions []string `cbor:"2,keyasint,toarray"` + AAGUID []byte `cbor:"3,keyasint"` + Options map[string]bool `cbor:"4,keyasint"` + MaxMsgSize uint `cbor:"5,keyasint"` + PinProtocol []uint `cbor:"6,keyasint,toarray"` +} + +func (t *Token) GetInfo() (*GetInfoResponse, error) { + resp, err := t.d.CBOR([]byte{cmdGetInfo}) + if err != nil { + return nil, err + } + + infos := &GetInfoResponse{} + if err := cbor.Unmarshal(resp[1:], &infos); err != nil { + return nil, err + } + return infos, nil +} + +type ClientPINRequest struct{} +type ClientPINResponse struct{} + +func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { + return nil, nil +} + +type ResetRequest struct{} +type ResetResponse struct{} + +func (t *Token) Reset(*ResetRequest) (*ResetResponse, error) { + return nil, nil +} + +type GetNextAssertionRequest struct{} +type GetNextAssertionResponse struct{} + +func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionResponse, error) { + return nil, nil +} + +func checkCBORResponse(resp []byte) ([]byte, error) { + if len(resp) == 0 || resp[0] != statusSuccess { + status, ok := ctap2Status[resp[0]] + if !ok { + status = fmt.Sprintf("unknown error %x", resp[0]) + } + return nil, fmt.Errorf("ctap2token: CBOR error: %s", status) + } + + return resp[1:], nil +} From 6daceada7f77813757dd769b54a1a504a6811e28 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 9 Sep 2020 16:38:13 +0200 Subject: [PATCH 04/34] add ctap2token example --- ctap2token/example/main.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 ctap2token/example/main.go diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go new file mode 100644 index 0000000..0767979 --- /dev/null +++ b/ctap2token/example/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + + "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/u2fhid" +) + +func main() { + devices, err := u2fhid.Devices() + if err != nil { + panic(err) + } + + for _, d := range devices { + dev, err := u2fhid.Open(d) + if err != nil { + panic(err) + } + + token := ctap2token.NewToken(dev) + infos, err := token.GetInfo() + if err != nil { + panic(err) + } + fmt.Printf("%#v\n", infos) + } +} From 070ceb5874d47062a49f02f12af237231d3f13ba Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Fri, 11 Sep 2020 15:39:09 +0200 Subject: [PATCH 05/34] add CTAP2 makeCredential support --- ctap2token/example/main.go | 164 ++++++++++++++++++++++- ctap2token/token.go | 261 ++++++++++++++++++++++++++++++++++--- ctap2token/token_test.go | 116 +++++++++++++++++ go.mod | 3 + go.sum | 14 ++ u2fhid/hid.go | 9 +- 6 files changed, 549 insertions(+), 18 deletions(-) create mode 100644 ctap2token/token_test.go diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 0767979..502b9e1 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -1,10 +1,22 @@ package main import ( + "bufio" + "crypto/aes" + "crypto/cipher" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/x509" "fmt" + "math/big" + "os" + "strings" "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/u2fhid" + "github.com/grantae/certinfo" ) func main() { @@ -20,10 +32,160 @@ func main() { } token := ctap2token.NewToken(dev) + infos, err := token.GetInfo() if err != nil { panic(err) } - fmt.Printf("%#v\n", infos) + fmt.Printf("Token infos:\n%#v\n", infos) + + // https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret + fmt.Println("Retrieving key agreement from authenticator") + cp1, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + PinProtocol: ctap2token.PinProtoV1, + SubCommand: ctap2token.GetKeyAgreement, + }) + if err != nil { + panic(err) + } + + fmt.Println("Generating platform key pair") + b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + + aG := cp1.KeyAgreement + aGX := new(big.Int) + aGX.SetBytes(aG.X) + + aGY := new(big.Int) + aGY.SetBytes(aG.Y) + + rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) + + h := sha256.New() + _, err = h.Write(rX.Bytes()) + if err != nil { + panic(err) + } + + sharedSecret := h.Sum(nil) + fmt.Println("Generated shared secret") + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Enter PIN: ") + userPIN, _ := reader.ReadString('\n') + + h.Reset() + _, err = h.Write([]byte(strings.TrimSpace(userPIN))) + if err != nil { + panic(err) + } + + pinHash := h.Sum(nil) + pinHash = pinHash[:aes.BlockSize] + + pinHashEnc := make([]byte, aes.BlockSize) + c, err := aes.NewCipher(sharedSecret) + if err != nil { + panic(err) + } + iv := make([]byte, aes.BlockSize) + cbcEnc := cipher.NewCBCEncrypter(c, iv) + cbcEnc.CryptBlocks(pinHashEnc, pinHash) + fmt.Println("Encrypted user PIN using shared secret") + + pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + SubCommand: ctap2token.GetPinToken, + KeyAgreement: &ctap2token.COSEKey{ + X: bGX.Bytes(), + Y: bGY.Bytes(), + KeyType: ctap2token.EC2, // not required ? + Curve: ctap2token.P256, // not required ? + Alg: ctap2token.ECDHES_HKDF256, // not required ? + }, + PinHashEnc: pinHashEnc, + PinProtocol: ctap2token.PinProtoV1, + }) + if err != nil { + panic(err) + } + + // Decrypt pinToken using shared secret + pinHashDec := make([]byte, aes.BlockSize) + cbcDec := cipher.NewCBCDecrypter(c, iv) + cbcDec.CryptBlocks(pinHashDec, pinResp.PinToken) + fmt.Println("Decrypted authenticator pinToken") + + clientDataHash := make([]byte, 32) + if _, err := rand.Read(clientDataHash); err != nil { + panic(err) + } + + userID := make([]byte, 32) + if _, err := rand.Read(userID); err != nil { + panic(err) + } + + mac := hmac.New(sha256.New, pinHashDec) + _, err = mac.Write(clientDataHash) + if err != nil { + panic(err) + } + + pinAuth := mac.Sum(nil) + pinAuth = pinAuth[:16] + fmt.Println("Signed clientData with pinToken") + + fmt.Println("Sending makeCredential request, please press authenticator button...") + req := &ctap2token.MakeCredentialRequest{ + ClientDataHash: clientDataHash, + RP: ctap2token.CredentialRpEntity{ + ID: "example.com", + Name: "Acme", + }, + User: ctap2token.CredentialUserEntity{ + ID: userID, + Icon: "https://pics.example.com/00/p/aBjjjpqPb.png", + Name: "johnpsmith@example.com", + DisplayName: "John P. Smith", + }, + PubKeyCredParams: []ctap2token.CredentialParam{ + ctap2token.PublicKeyES256, + ctap2token.PublicKeyRS256, + }, + PinAuth: pinAuth, + PinProtocol: ctap2token.PinProtoV1, + } + + resp, err := token.MakeCredential(req) + if err != nil { + panic(err) + } + fmt.Println("Success creating credential!") + + x509certs, ok := resp.AttSmt["x5c"] + if !ok { + panic("no x5c field") + } + + fmt.Println(len(x509certs.([]interface{}))) + + x509cert := x509certs.([]interface{})[0].([]byte) + cert, err := x509.ParseCertificate(x509cert) + if err != nil { + panic(err) + } + certStr, err := certinfo.CertificateText(cert) + if err != nil { + panic(err) + } + + fmt.Println(certStr) + fmt.Println("Signature:") + fmt.Printf("%x\n", resp.AttSmt["sig"]) + fmt.Println("AuthData:") + fmt.Printf("%x\n", resp.AuthData) } } diff --git a/ctap2token/token.go b/ctap2token/token.go index ab749c6..b774c39 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -74,11 +74,139 @@ type Token struct { d Device } -type MakeCredentialRequest struct{} -type MakeCredentialResponse struct{} +type MakeCredentialRequest struct { + ClientDataHash ClientDataHash `cbor:"1,keyasint"` + RP CredentialRpEntity `cbor:"2,keyasint"` + User CredentialUserEntity `cbor:"3,keyasint"` + PubKeyCredParams []CredentialParam `cbor:"4,keyasint"` + ExcludeList []CredentialDescriptor `cbor:"5,keyasint,omitempty"` + Extensions map[string]interface{} `cbor:"6,keyasint,omitempty"` + Options AuthenticatorOptions `cbor:"7,keyasint,omitempty"` + // PinAuth is the first 16 bytes of HMAC-SHA-256 of clientDataHash using + // pinToken which platform got from the authenticator + PinAuth []byte `cbor:"8,keyasint,omitempty"` + // PinProtocol is the PIN protocol version chosen by the client + PinProtocol PinProtocolVersion `cbor:"9,keyasint,omitempty"` +} + +// ClientDataHash is the hash of the ClientData contextual binding specified by host. +type ClientDataHash []byte + +// CredentialRpEntity describes a Relying Party with which +// the new public key credential will be associated. +type CredentialRpEntity struct { + // ID is valid domain string that identifies the WebAuthn Relying Party. + ID string `cbor:"id,omitempty"` + Name string `cbor:"name,omitempty"` + Icon string `cbor:"icon,omitempty"` +} + +// CredentialUserEntity describes the user account to which +// the new public key credential will be associated at the RP +type CredentialUserEntity struct { + ID []byte `cbor:"id"` + Name string `cbor:"name,omitempty"` + DisplayName string `cbor:"displayName,omitempty"` + Icon string `cbor:"icon,omitempty"` +} + +type CredentialParam struct { + Type CredentialType `cbor:"type"` + Alg Alg `cbor:"alg"` +} + +var ( + PublicKeyRS256 CredentialParam = CredentialParam{Type: PublicKey, Alg: RS256} + PublicKeyPS256 CredentialParam = CredentialParam{Type: PublicKey, Alg: PS256} + PublicKeyES256 CredentialParam = CredentialParam{Type: PublicKey, Alg: ES256} +) + +// CredentialType defines the type of credential, as defined in https://www.w3.org/TR/webauthn/#credentialType +type CredentialType string + +const ( + PublicKey CredentialType = "public-key" +) + +// Alg must be the value of one of the algorithms registered on +// https://www.iana.org/assignments/cose/cose.xhtml#algorithms. +type Alg int + +const ( + RS256 Alg = -257 // RSASSA-PKCS1-v1_5 using SHA-256 + PS256 Alg = -37 // RSASSA-PSS w/ SHA-256 + ECDHES_HKDF256 Alg = -25 // ECDH-ES + HKDF-256 + ES256 Alg = -7 // ECDSA w/ SHA-256 +) + +// CredentialDescriptor defines a credential returned by the authenticator, +// as defined by https://www.w3.org/TR/webauthn/#credential-dictionary +type CredentialDescriptor struct { + ID []byte `cbor:"id"` + Type CredentialType `cbor:"type"` + Transports []AuthenticatorTransport `cbor:"transports"` +} + +// AuthenticatorTransport defines hints as to how clients might communicate with a particular authenticator, +// as defined by https://www.w3.org/TR/webauthn/#transport. +type AuthenticatorTransport string + +const ( + // USB indicates the respective authenticator can be contacted over removable USB. + USB AuthenticatorTransport = "usb" + // NFC indicates the respective authenticator can be contacted over Near Field Communication (NFC). + NFC AuthenticatorTransport = "nfc" + // BLE indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low Energy / BLE). + BLE AuthenticatorTransport = "ble" + // Internal indicates the respective authenticator is contacted using a client device-specific transport. + Internal AuthenticatorTransport = "internal" +) + +type AuthenticatorOptions struct { + ResidentKey bool `cbor:"rk,omitempty"` + UserVerification bool `cbor:"uv,omitempty"` +} + +type PinProtocolVersion uint + +const ( + PinProtoV1 PinProtocolVersion = 1 +) + +// MakeCredentialResponse... +// CTAP 2.1 defines Fmt=0x1 and AuthData=0x2 while CTAP 2.0 defines AuthData=0x1 and Fmt=0x2 for some reasons +type MakeCredentialResponse struct { + Fmt string `cbor:"1,keyasint"` + AuthData []byte `cbor:"2,keyasint"` + AttSmt map[string]interface{} `cbor:"3,keyasint"` +} func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialResponse, error) { - return nil, nil + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + reqData, err := enc.Marshal(req) + if err != nil { + return nil, err + } + + data := make([]byte, 0, len(reqData)+1) + data = append(data, cmdMakeCredential) + data = append(data, reqData...) + + resp, err := t.d.CBOR(data) + if err != nil { + return nil, err + } + + respData := &MakeCredentialResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + + return respData, nil } type GetAssertionRequest struct{} @@ -89,12 +217,12 @@ func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertioNResponse, e } type GetInfoResponse struct { - Versions []string `cbor:"1,keyasint,toarray"` - Extensions []string `cbor:"2,keyasint,toarray"` + Versions []string `cbor:"1,keyasint"` + Extensions []string `cbor:"2,keyasint,omitempty"` AAGUID []byte `cbor:"3,keyasint"` - Options map[string]bool `cbor:"4,keyasint"` - MaxMsgSize uint `cbor:"5,keyasint"` - PinProtocol []uint `cbor:"6,keyasint,toarray"` + Options map[string]bool `cbor:"4,keyasint,omitempty"` + MaxMsgSize uint `cbor:"5,keyasint,omitempty"` + PinProtocol []uint `cbor:"6,keyasint,omitempty"` } func (t *Token) GetInfo() (*GetInfoResponse, error) { @@ -104,17 +232,116 @@ func (t *Token) GetInfo() (*GetInfoResponse, error) { } infos := &GetInfoResponse{} - if err := cbor.Unmarshal(resp[1:], &infos); err != nil { + if err := unmarshal(resp, infos); err != nil { return nil, err } + return infos, nil } -type ClientPINRequest struct{} -type ClientPINResponse struct{} +type ClientPINRequest struct { + PinProtocol PinProtocolVersion `cbor:"1,keyasint"` + SubCommand ClientPinSubCommand `cbor:"2,keyasint"` + KeyAgreement *COSEKey `cbor:"3,keyasint,omitempty"` + PinAuth []byte `cbor:"4,keyasint,omitempty"` + NewPinEnc []byte `cbor:"5,keyasint,omitempty"` + PinHashEnc []byte `cbor:"6,keyasint,omitempty"` +} + +type ClientPinSubCommand uint + +const ( + GetRetries ClientPinSubCommand = 0x01 + GetKeyAgreement ClientPinSubCommand = 0x02 + SetPin ClientPinSubCommand = 0x03 + ChangePin ClientPinSubCommand = 0x04 + GetPinToken ClientPinSubCommand = 0x05 +) + +// COSEKey, as defined per https://tools.ietf.org/html/rfc8152#section-7.1 +// Only support Elliptic Curve Public keys for now. +// TODO: find a way to support all key types defined in the RFC +type COSEKey struct { + Y []byte `cbor:"-3,keyasint,omitempty"` + X []byte `cbor:"-2,keyasint,omitempty"` + Curve CurveType `cbor:"-1,keyasint,omitempty"` + + KeyType KeyType `cbor:"1,keyasint"` + KeyID []byte `cbor:"2,keyasint,omitempty"` + Alg Alg `cbor:"3,keyasint,omitempty"` + KeyOps []KeyOperation `cbor:"4,keyasint,omitempty"` + BaseIV []byte `cbor:"5,keyasint,omitempty"` +} + +// KeyType defines a key type from https://tools.ietf.org/html/rfc8152#section-13 +type KeyType int + +const ( + // OKP means Octet Key Pair + OKP KeyType = 0x01 + // EC2 means Elliptic Curve Keys + EC2 KeyType = 0x02 +) + +type CurveType int + +const ( + P256 CurveType = 0x01 + P384 CurveType = 0x02 + P521 CurveType = 0x03 + X25519 CurveType = 0x04 + X448 CurveType = 0x05 + Ed25519 CurveType = 0x06 + Ed448 CurveType = 0x07 +) + +type KeyOperation int + +const ( + Sign KeyOperation = iota + 1 + Verify + Encrypt + Decrypt + WrapKey + UnwrapKey + DeriveKey + DeriveBits + MACCreate + MACVerify +) + +type ClientPINResponse struct { + KeyAgreement *COSEKey `cbor:"1,keyasint,omitempty"` + PinToken []byte `cbor:"2,keyasint,omitempty"` + Retries uint `cbor:"3,keyasint,omitempty"` +} func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { - return nil, nil + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + reqData, err := enc.Marshal(req) + if err != nil { + return nil, err + } + + data := make([]byte, 0, len(reqData)+1) + data = append(data, cmdClientPIN) + data = append(data, reqData...) + + resp, err := t.d.CBOR(data) + if err != nil { + return nil, err + } + + respData := &ClientPINResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + + return respData, nil } type ResetRequest struct{} @@ -131,14 +358,18 @@ func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionRes return nil, nil } -func checkCBORResponse(resp []byte) ([]byte, error) { +func unmarshal(resp []byte, out interface{}) error { if len(resp) == 0 || resp[0] != statusSuccess { status, ok := ctap2Status[resp[0]] if !ok { status = fmt.Sprintf("unknown error %x", resp[0]) } - return nil, fmt.Errorf("ctap2token: CBOR error: %s", status) + return fmt.Errorf("ctap2token: CBOR error: %s", status) + } + + if err := cbor.Unmarshal(resp[1:], out); err != nil { + return err } - return resp[1:], nil + return nil } diff --git a/ctap2token/token_test.go b/ctap2token/token_test.go new file mode 100644 index 0000000..c629d2a --- /dev/null +++ b/ctap2token/token_test.go @@ -0,0 +1,116 @@ +package ctap2token + +import ( + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/stretchr/testify/require" +) + +// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#example-378a57e0 +func TestEncodeCredentialRpEntity(t *testing.T) { + e := CredentialRpEntity{ + Name: "Acme", + } + + enc, err := cbor.CTAP2EncOptions().EncMode() + require.NoError(t, err) + + got, err := enc.Marshal(e) + require.NoError(t, err) + + require.Equal( + t, + "a1646e616d656441636d65", + hex.EncodeToString(got), + ) +} + +// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#example-8e31572a +func TestEncodeCredentialUserEntity(t *testing.T) { + userID, err := base64.StdEncoding.DecodeString("MIIBkzCCATigAwIBAjCCAZMwggE4oAMCAQIwggGTMII=") + require.NoError(t, err) + + e := CredentialUserEntity{ + ID: userID, + Icon: "https://pics.example.com/00/p/aBjjjpqPb.png", + Name: "johnpsmith@example.com", + DisplayName: "John P. Smith", + } + + enc, err := cbor.CTAP2EncOptions().EncMode() + require.NoError(t, err) + + got, err := enc.Marshal(e) + require.NoError(t, err) + + require.Equal( + t, + "a462696458203082019330820138a0030201023082019330820138a0030201023082019330826469636f6e782b68747470733a2f2f706963732e6578616d706c652e636f6d2f30302f702f61426a6a6a707150622e706e67646e616d65766a6f686e70736d697468406578616d706c652e636f6d6b646973706c61794e616d656d4a6f686e20502e20536d697468", + hex.EncodeToString(got), + ) +} + +// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#example-23bb4dbc +func TestEncodeCredentialParameters(t *testing.T) { + params := []CredentialParam{ + PublicKeyES256, + PublicKeyRS256, + } + + enc, err := cbor.CTAP2EncOptions().EncMode() + require.NoError(t, err) + + got, err := enc.Marshal(params) + require.NoError(t, err) + + require.Equal( + t, + "82a263616c672664747970656a7075626c69632d6b6579a263616c6739010064747970656a7075626c69632d6b6579", + hex.EncodeToString(got), + ) +} + +// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#example-da70519c +func TestEncodeMakeCredentialRequest(t *testing.T) { + clientDataHash, err := hex.DecodeString("687134968222ec17202e42505f8ed2b16ae22f16bb05b88c25db9e602645f141") + require.NoError(t, err) + + userID, err := hex.DecodeString("3082019330820138a0030201023082019330820138a003020102308201933082") + require.NoError(t, err) + + req := MakeCredentialRequest{ + ClientDataHash: clientDataHash, + RP: CredentialRpEntity{ + ID: "example.com", + Name: "Acme", + }, + User: CredentialUserEntity{ + ID: userID, + Icon: "https://pics.example.com/00/p/aBjjjpqPb.png", + Name: "johnpsmith@example.com", + DisplayName: "John P. Smith", + }, + PubKeyCredParams: []CredentialParam{ + PublicKeyES256, + PublicKeyRS256, + }, + Options: AuthenticatorOptions{ + ResidentKey: true, + }, + } + + enc, err := cbor.CTAP2EncOptions().EncMode() + require.NoError(t, err) + + got, err := enc.Marshal(req) + require.NoError(t, err) + + require.Equal( + t, + "a5015820687134968222ec17202e42505f8ed2b16ae22f16bb05b88c25db9e602645f14102a26269646b6578616d706c652e636f6d646e616d656441636d6503a462696458203082019330820138a0030201023082019330820138a0030201023082019330826469636f6e782b68747470733a2f2f706963732e6578616d706c652e636f6d2f30302f702f61426a6a6a707150622e706e67646e616d65766a6f686e70736d697468406578616d706c652e636f6d6b646973706c61794e616d656d4a6f686e20502e20536d6974680482a263616c672664747970656a7075626c69632d6b6579a263616c6739010064747970656a7075626c69632d6b657907a162726bf5", + hex.EncodeToString(got), + ) +} diff --git a/go.mod b/go.mod index 2807548..987cfe0 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.15 require ( github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a + github.com/fxamacker/cbor v1.5.1 github.com/fxamacker/cbor/v2 v2.2.0 + github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b + github.com/stretchr/testify v1.6.1 ) diff --git a/go.sum b/go.sum index ca8eb06..9f665c7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,20 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fxamacker/cbor v1.5.1 h1:XjQWBgdmQyqimslUh5r4tUGmoqzHmBFQOImkWGi2awg= +github.com/fxamacker/cbor v1.5.1/go.mod h1:3aPGItF174ni7dDzd6JZ206H8cmr4GDNBGpPa971zsU= github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b h1:NGgE5ELokSf2tZ/bydyDUKrvd/jP8lrAoPNeBuMOTOk= +github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b/go.mod h1:zT/uzhdQGTqlwTq7Lpbj3JoJQWfPfIJ1tE0OidAmih8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/u2fhid/hid.go b/u2fhid/hid.go index adb791a..3454639 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -33,7 +33,7 @@ const ( maxMessageLen = 7609 minInitResponseLen = 17 - responseTimeout = 3 * time.Second + responseTimeout = 10 * time.Second fidoUsagePage = 0xF1D0 u2fUsage = 1 @@ -180,9 +180,14 @@ func (d *Device) readResponse(channel uint32, cmd byte) ([]byte, error) { return nil, fmt.Errorf("u2fhid: received error from device: %s", errMsg) } + // device will send keepalive msg when waiting for the user presence + if msg[4] == cmdKeepAlive { + continue + } + if !haveFirst { if msg[4] != cmd { - return nil, fmt.Errorf("u2fhid: error reading response, unexpected command %d, wanted %d", msg[4], cmd) + return nil, fmt.Errorf("u2fhid: error reading response, unexpected command %x, wanted %x", msg[4], cmd) } haveFirst = true expected = int(binary.BigEndian.Uint16(msg[5:])) From d60d73f32a20e4de7718a5e7efc690dd6cfcc95d Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 15 Sep 2020 18:33:59 +0200 Subject: [PATCH 06/34] add complete ctap2/webauthn flow example --- ctap2token/example/main.go | 263 +++++++++++--------- ctap2token/token.go | 480 ++++++++++++++++++++++++------------- go.mod | 2 - go.sum | 5 +- 4 files changed, 474 insertions(+), 276 deletions(-) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 502b9e1..b603c9e 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -1,22 +1,17 @@ package main import ( - "bufio" - "crypto/aes" - "crypto/cipher" + "bytes" + "crypto/ecdsa" "crypto/elliptic" - "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/x509" "fmt" "math/big" - "os" - "strings" "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/u2fhid" - "github.com/grantae/certinfo" ) func main() { @@ -39,84 +34,84 @@ func main() { } fmt.Printf("Token infos:\n%#v\n", infos) - // https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret - fmt.Println("Retrieving key agreement from authenticator") - cp1, err := token.ClientPIN(&ctap2token.ClientPINRequest{ - PinProtocol: ctap2token.PinProtoV1, - SubCommand: ctap2token.GetKeyAgreement, - }) - if err != nil { - panic(err) - } - - fmt.Println("Generating platform key pair") - b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(err) - } - - aG := cp1.KeyAgreement - aGX := new(big.Int) - aGX.SetBytes(aG.X) - - aGY := new(big.Int) - aGY.SetBytes(aG.Y) - - rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) - - h := sha256.New() - _, err = h.Write(rX.Bytes()) - if err != nil { - panic(err) - } - - sharedSecret := h.Sum(nil) - fmt.Println("Generated shared secret") - - reader := bufio.NewReader(os.Stdin) - fmt.Print("Enter PIN: ") - userPIN, _ := reader.ReadString('\n') - - h.Reset() - _, err = h.Write([]byte(strings.TrimSpace(userPIN))) - if err != nil { - panic(err) - } - - pinHash := h.Sum(nil) - pinHash = pinHash[:aes.BlockSize] - - pinHashEnc := make([]byte, aes.BlockSize) - c, err := aes.NewCipher(sharedSecret) - if err != nil { - panic(err) - } - iv := make([]byte, aes.BlockSize) - cbcEnc := cipher.NewCBCEncrypter(c, iv) - cbcEnc.CryptBlocks(pinHashEnc, pinHash) - fmt.Println("Encrypted user PIN using shared secret") - - pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ - SubCommand: ctap2token.GetPinToken, - KeyAgreement: &ctap2token.COSEKey{ - X: bGX.Bytes(), - Y: bGY.Bytes(), - KeyType: ctap2token.EC2, // not required ? - Curve: ctap2token.P256, // not required ? - Alg: ctap2token.ECDHES_HKDF256, // not required ? - }, - PinHashEnc: pinHashEnc, - PinProtocol: ctap2token.PinProtoV1, - }) - if err != nil { - panic(err) - } - - // Decrypt pinToken using shared secret - pinHashDec := make([]byte, aes.BlockSize) - cbcDec := cipher.NewCBCDecrypter(c, iv) - cbcDec.CryptBlocks(pinHashDec, pinResp.PinToken) - fmt.Println("Decrypted authenticator pinToken") + // // https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret + // fmt.Println("Retrieving key agreement from authenticator") + // cp1, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + // PinProtocol: ctap2token.PinProtoV1, + // SubCommand: ctap2token.GetKeyAgreement, + // }) + // if err != nil { + // panic(err) + // } + + // fmt.Println("Generating platform key pair") + // b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) + // if err != nil { + // panic(err) + // } + + // aG := cp1.KeyAgreement + // aGX := new(big.Int) + // aGX.SetBytes(aG.X) + + // aGY := new(big.Int) + // aGY.SetBytes(aG.Y) + + // rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) + + // h := sha256.New() + // _, err = h.Write(rX.Bytes()) + // if err != nil { + // panic(err) + // } + + // sharedSecret := h.Sum(nil) + // fmt.Println("Generated shared secret") + + // reader := bufio.NewReader(os.Stdin) + // fmt.Print("Enter PIN: ") + // userPIN, _ := reader.ReadString('\n') + + // h.Reset() + // _, err = h.Write([]byte(strings.TrimSpace(userPIN))) + // if err != nil { + // panic(err) + // } + + // pinHash := h.Sum(nil) + // pinHash = pinHash[:aes.BlockSize] + + // pinHashEnc := make([]byte, aes.BlockSize) + // c, err := aes.NewCipher(sharedSecret) + // if err != nil { + // panic(err) + // } + // iv := make([]byte, aes.BlockSize) + // cbcEnc := cipher.NewCBCEncrypter(c, iv) + // cbcEnc.CryptBlocks(pinHashEnc, pinHash) + // fmt.Println("Encrypted user PIN using shared secret") + + // pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + // SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, + // KeyAgreement: &ctap2token.COSEKey{ + // X: bGX.Bytes(), + // Y: bGY.Bytes(), + // KeyType: ctap2token.EC2, + // Curve: ctap2token.P256, + // Alg: ctap2token.ECDHES_HKDF256, + // }, + // PinHashEnc: pinHashEnc, + // PinProtocol: ctap2token.PinProtoV1, + // }) + // if err != nil { + // panic(err) + // } + + // // Decrypt pinToken using shared secret + // pinHashDec := make([]byte, aes.BlockSize) + // cbcDec := cipher.NewCBCDecrypter(c, iv) + // cbcDec.CryptBlocks(pinHashDec, pinResp.PinToken) + // fmt.Println("Decrypted authenticator pinToken") clientDataHash := make([]byte, 32) if _, err := rand.Read(clientDataHash); err != nil { @@ -128,18 +123,18 @@ func main() { panic(err) } - mac := hmac.New(sha256.New, pinHashDec) - _, err = mac.Write(clientDataHash) - if err != nil { - panic(err) - } + // mac := hmac.New(sha256.New, pinHashDec) + // _, err = mac.Write(clientDataHash) + // if err != nil { + // panic(err) + // } - pinAuth := mac.Sum(nil) - pinAuth = pinAuth[:16] - fmt.Println("Signed clientData with pinToken") + // pinAuth := mac.Sum(nil) + // pinAuth = pinAuth[:16] + // fmt.Println("Signed clientData with pinToken") fmt.Println("Sending makeCredential request, please press authenticator button...") - req := &ctap2token.MakeCredentialRequest{ + resp, err := token.MakeCredential(&ctap2token.MakeCredentialRequest{ ClientDataHash: clientDataHash, RP: ctap2token.CredentialRpEntity{ ID: "example.com", @@ -155,37 +150,89 @@ func main() { ctap2token.PublicKeyES256, ctap2token.PublicKeyRS256, }, - PinAuth: pinAuth, - PinProtocol: ctap2token.PinProtoV1, - } - - resp, err := token.MakeCredential(req) + Options: ctap2token.AuthenticatorOptions{ + "clientPin": false, + "uv": false, + }, + // PinUVAuth: pinAuth, + // PinUVAuthProtocol: ctap2token.PinProtoV1, + }) if err != nil { panic(err) } - fmt.Println("Success creating credential!") + fmt.Println("Success creating credential") + // Verify signature with the X509 certificate from the attestation statement x509certs, ok := resp.AttSmt["x5c"] if !ok { panic("no x5c field") } - fmt.Println(len(x509certs.([]interface{}))) - x509cert := x509certs.([]interface{})[0].([]byte) cert, err := x509.ParseCertificate(x509cert) if err != nil { panic(err) } - certStr, err := certinfo.CertificateText(cert) + + signed := append(resp.AuthData, clientDataHash...) + if err := cert.CheckSignature(x509.ECDSAWithSHA256, signed, resp.AttSmt["sig"].([]byte)); err != nil { + panic(err) + } + fmt.Println("MakeCredentials signature is valid!") + + mcpAuthData, err := resp.AuthData.Parse() + if err != nil { + panic(err) + } + fmt.Printf("credentialID: %x\n", mcpAuthData.AttestedCredentialData.CredentialID) + + fmt.Println("Sending GetAssertion request, please press authenticator button...") + getAssertionResp, err := token.GetAssertion(&ctap2token.GetAssertionRequest{ + RPID: "example.com", + AllowList: []*ctap2token.CredentialDescriptor{ + { + ID: mcpAuthData.AttestedCredentialData.CredentialID, + Transports: []ctap2token.AuthenticatorTransport{ctap2token.USB}, + Type: ctap2token.PublicKey, + }, + }, + ClientDataHash: clientDataHash, + // enable UserVerified flag + // PinUVAuth: pinAuth, + // PinUVAuthProtocol: ctap2token.PinProtoV1, + }) if err != nil { panic(err) } - fmt.Println(certStr) - fmt.Println("Signature:") - fmt.Printf("%x\n", resp.AttSmt["sig"]) - fmt.Println("AuthData:") - fmt.Printf("%x\n", resp.AuthData) + if !bytes.Equal(getAssertionResp.Credential.ID, mcpAuthData.AttestedCredentialData.CredentialID) { + panic("CredentialID mismatch") + } + fmt.Printf("Found credential %x\n", getAssertionResp.Credential.ID) + + // Verify signature with the public key from MakeCredential + pubX := new(big.Int) + pubX.SetBytes(mcpAuthData.AttestedCredentialData.CredentialPublicKey.X) + pubY := new(big.Int) + pubY.SetBytes(mcpAuthData.AttestedCredentialData.CredentialPublicKey.Y) + + pubkey := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: pubX, + Y: pubY, + } + + hash := sha256.New() + if _, err := hash.Write(getAssertionResp.AuthData); err != nil { + panic(err) + } + if _, err := hash.Write(clientDataHash); err != nil { + panic(err) + } + + if !ecdsa.VerifyASN1(pubkey, hash.Sum(nil), getAssertionResp.Signature) { + panic("invalid signature") + } + fmt.Println("Signature verified!") } } diff --git a/ctap2token/token.go b/ctap2token/token.go index b774c39..1dec216 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -1,6 +1,8 @@ package ctap2token import ( + "encoding/binary" + "errors" "fmt" "github.com/fxamacker/cbor/v2" @@ -19,6 +21,14 @@ const ( // CTAP2 error status from https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#error-responses var ctap2Status = map[byte]string{ + 0x01: "CTAP1_ERR_INVALID_COMMAND", + 0x02: "CTAP1_ERR_INVALID_PARAMETER", + 0x03: "CTAP1_ERR_INVALID_LENGTH", + 0x04: "CTAP1_ERR_INVALID_SEQ", + 0x05: "CTAP1_ERR_TIMEOUT", + 0x06: "CTAP1_ERR_CHANNEL_BUSY", + 0x0A: "CTAP1_ERR_LOCK_REQUIRED", + 0x0B: "CTAP1_ERR_INVALID_CHANNEL", 0x11: "CTAP2_ERR_CBOR_UNEXPECTED_TYPE", 0x12: "CTAP2_ERR_INVALID_CBOR", 0x14: "CTAP2_ERR_MISSING_PARAMETER", @@ -75,18 +85,198 @@ type Token struct { } type MakeCredentialRequest struct { - ClientDataHash ClientDataHash `cbor:"1,keyasint"` - RP CredentialRpEntity `cbor:"2,keyasint"` - User CredentialUserEntity `cbor:"3,keyasint"` - PubKeyCredParams []CredentialParam `cbor:"4,keyasint"` - ExcludeList []CredentialDescriptor `cbor:"5,keyasint,omitempty"` - Extensions map[string]interface{} `cbor:"6,keyasint,omitempty"` - Options AuthenticatorOptions `cbor:"7,keyasint,omitempty"` - // PinAuth is the first 16 bytes of HMAC-SHA-256 of clientDataHash using + ClientDataHash ClientDataHash `cbor:"1,keyasint"` + RP CredentialRpEntity `cbor:"2,keyasint"` + User CredentialUserEntity `cbor:"3,keyasint"` + PubKeyCredParams []CredentialParam `cbor:"4,keyasint"` + ExcludeList []CredentialDescriptor `cbor:"5,keyasint,omitempty"` + Extensions AuthenticatorExtensions `cbor:"6,keyasint,omitempty"` + Options AuthenticatorOptions `cbor:"7,keyasint,omitempty"` + // PinUVAuth is the first 16 bytes of HMAC-SHA-256 of clientDataHash using // pinToken which platform got from the authenticator - PinAuth []byte `cbor:"8,keyasint,omitempty"` - // PinProtocol is the PIN protocol version chosen by the client - PinProtocol PinProtocolVersion `cbor:"9,keyasint,omitempty"` + PinUVAuth []byte `cbor:"8,keyasint,omitempty"` + // PinUVAuthProtocol is the PIN protocol version chosen by the client + PinUVAuthProtocol PinUVAuthProtocolVersion `cbor:"9,keyasint,omitempty"` +} + +// MakeCredentialResponse +// TODO: structure may be different with different kind of attestations. +type MakeCredentialResponse struct { + Fmt string `cbor:"1,keyasint"` + AuthData AuthData `cbor:"2,keyasint"` + AttSmt map[string]interface{} `cbor:"3,keyasint"` +} + +func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialResponse, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + reqData, err := enc.Marshal(req) + if err != nil { + return nil, err + } + + data := make([]byte, 0, len(reqData)+1) + data = append(data, cmdMakeCredential) + data = append(data, reqData...) + + resp, err := t.d.CBOR(data) + if err != nil { + return nil, err + } + + respData := &MakeCredentialResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + + return respData, nil +} + +type GetAssertionRequest struct { + RPID string `cbor:"1,keyasint"` + ClientDataHash []byte `cbor:"2,keyasint"` + AllowList []*CredentialDescriptor `cbor:"3,keyasint,omitempty"` + Extensions AuthenticatorExtensions `cbor:"4,keyasint,omitempty"` + Options AuthenticatorOptions `cbor:"5,keyasint,omitempty"` + PinUVAuth []byte `cbor:"6,keyasint,omitempty"` + PinUVAuthProtocol PinUVAuthProtocolVersion `cbor:"7,keyasint,omitempty"` +} +type GetAssertionResponse struct { + Credential *CredentialDescriptor `cbor:"1,keyasint,omitempty"` + AuthData AuthData `cbor:"2,keyasint"` + Signature []byte `cbor:"3,keyasint"` + User *CredentialUserEntity `cbor:"4,keyasint,omitempty"` + NumberOfCredentials int `cbor:"5,keyasint,omitempty"` + UserSelected bool `cbor:"6,keyasint,omitempty"` +} + +func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertionResponse, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + reqData, err := enc.Marshal(req) + if err != nil { + return nil, err + } + + data := make([]byte, 0, len(reqData)+1) + data = append(data, cmdGetAssertion) + data = append(data, reqData...) + + resp, err := t.d.CBOR(data) + if err != nil { + return nil, err + } + + respData := &GetAssertionResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + + return respData, nil +} + +type GetInfoResponse struct { + Versions []string `cbor:"1,keyasint"` + Extensions []string `cbor:"2,keyasint,omitempty"` + AAGUID []byte `cbor:"3,keyasint"` + Options AuthenticatorOptions `cbor:"4,keyasint,omitempty"` + MaxMsgSize uint `cbor:"5,keyasint,omitempty"` + PinProtocol []uint `cbor:"6,keyasint,omitempty"` +} + +func (t *Token) GetInfo() (*GetInfoResponse, error) { + resp, err := t.d.CBOR([]byte{cmdGetInfo}) + if err != nil { + return nil, err + } + + infos := &GetInfoResponse{} + if err := unmarshal(resp, infos); err != nil { + return nil, err + } + + return infos, nil +} + +type ClientPINRequest struct { + PinProtocol PinUVAuthProtocolVersion `cbor:"1,keyasint"` + SubCommand ClientPinSubCommand `cbor:"2,keyasint"` + KeyAgreement *COSEKey `cbor:"3,keyasint,omitempty"` + PinAuth []byte `cbor:"4,keyasint,omitempty"` + NewPinEnc []byte `cbor:"5,keyasint,omitempty"` + PinHashEnc []byte `cbor:"6,keyasint,omitempty"` +} + +type ClientPINResponse struct { + KeyAgreement *COSEKey `cbor:"1,keyasint,omitempty"` + PinToken []byte `cbor:"2,keyasint,omitempty"` + Retries uint `cbor:"3,keyasint,omitempty"` + PowerCycleState bool `cbor:"4,keyasint,omitempty"` + UVRetries uint `cbor:"5,keyasint,omitempty"` +} + +func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + reqData, err := enc.Marshal(req) + if err != nil { + return nil, err + } + + data := make([]byte, 0, len(reqData)+1) + data = append(data, cmdClientPIN) + data = append(data, reqData...) + + resp, err := t.d.CBOR(data) + if err != nil { + return nil, err + } + + respData := &ClientPINResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + + return respData, nil +} + +type ResetRequest struct{} +type ResetResponse struct{} + +func (t *Token) Reset(*ResetRequest) (*ResetResponse, error) { + return nil, nil +} + +type GetNextAssertionRequest struct{} +type GetNextAssertionResponse struct{} + +func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionResponse, error) { + return nil, nil +} + +func unmarshal(resp []byte, out interface{}) error { + if len(resp) == 0 || resp[0] != statusSuccess { + status, ok := ctap2Status[resp[0]] + if !ok { + status = fmt.Sprintf("unknown error %x", resp[0]) + } + return fmt.Errorf("ctap2token: CBOR error: %s", status) + } + + if err := cbor.Unmarshal(resp[1:], out); err != nil { + return err + } + + return nil } // ClientDataHash is the hash of the ClientData contextual binding specified by host. @@ -95,7 +285,7 @@ type ClientDataHash []byte // CredentialRpEntity describes a Relying Party with which // the new public key credential will be associated. type CredentialRpEntity struct { - // ID is valid domain string that identifies the WebAuthn Relying Party. + // ID is a valid domain string that identifies the WebAuthn Relying Party. ID string `cbor:"id,omitempty"` Name string `cbor:"name,omitempty"` Icon string `cbor:"icon,omitempty"` @@ -110,6 +300,110 @@ type CredentialUserEntity struct { Icon string `cbor:"icon,omitempty"` } +type AuthData []byte + +const authDataMinLength = 37 + +func (a AuthData) Parse() (*ParsedAuthData, error) { + if len(a) < authDataMinLength { + return nil, errors.New("ctap2token: invalid authData") + } + + out := &ParsedAuthData{ + RPIDHash: a[:32], + Flags: AuthDataFlag{ + UserPresent: (a[32]&authDataFlagUP == authDataFlagUP), + UserVerified: (a[32]&authDataFlagUV == authDataFlagUV), + AttestedCredentialData: (a[32]&authDataFlagAT == authDataFlagAT), + HasExtensions: (a[32]&authDataFlagED == authDataFlagED), + }, + SignCount: binary.BigEndian.Uint32(a[33:authDataMinLength]), + } + + if out.Flags.AttestedCredentialData { + if len(a) <= authDataMinLength { + return nil, errors.New("ctap2token: missing attestedCredentialData") + } + + out.AttestedCredentialData = &AttestedCredentialData{ + AAGUID: a[authDataMinLength:53], + } + + credIDLen := binary.BigEndian.Uint16(a[53:55]) + out.AttestedCredentialData.CredentialID = a[55 : 55+credIDLen] + + // a[55+credIDLen:] may contains the COSEKey + extensions map + // but the decoder will only read the key and silently drop extensions data. + out.AttestedCredentialData.CredentialPublicKey = &COSEKey{} + if err := cbor.Unmarshal(a[55+credIDLen:], out.AttestedCredentialData.CredentialPublicKey); err != nil { + return nil, err + } + } + + if out.Flags.HasExtensions { + // When extensions are available, we must find out where the map start in the cbor data. + // It can either be at a[authDataMinLength:] when out.Flags.AttestedCredentialData is false, + // or at a[(authDataMinLength+16+2+credIDLen+COSEKeyLen):] when out.Flags.AttestedCredentialData is true + // in this case, it requires to cbor-encode back the key to find its length. + startIndex := authDataMinLength + + if out.Flags.AttestedCredentialData { + em, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + pubkeyBytes, err := em.Marshal(out.AttestedCredentialData.CredentialPublicKey) + if err != nil { + return nil, err + } + startIndex += 16 + 2 + len(out.AttestedCredentialData.CredentialID) + len(pubkeyBytes) + } + + if len(a) <= startIndex { + return nil, errors.New("ctap2token: missing extensions") + } + + out.Extensions = make(AuthenticatorExtensions) + if err := cbor.Unmarshal(a[startIndex:], &out.Extensions); err != nil { + return nil, err + } + } + + return out, nil +} + +type ParsedAuthData struct { + RPIDHash []byte // 32 bytes Sha256 RP ID Hash + Flags AuthDataFlag + SignCount uint32 + AttestedCredentialData *AttestedCredentialData + Extensions AuthenticatorExtensions +} + +const ( + authDataFlagUP = 1 << iota + authDataFlagReserved1 + authDataFlagUV + authDataFlagReserved2 + authDataFlagReserved3 + authDataFlagReserved4 + authDataFlagAT + authDataFlagED +) + +type AuthDataFlag struct { + UserPresent bool + UserVerified bool + AttestedCredentialData bool + HasExtensions bool +} + +type AttestedCredentialData struct { + AAGUID []byte // 16 bytes ID for the authenticator + CredentialID []byte + CredentialPublicKey *COSEKey +} + type CredentialParam struct { Type CredentialType `cbor:"type"` Alg Alg `cbor:"alg"` @@ -162,100 +456,26 @@ const ( Internal AuthenticatorTransport = "internal" ) -type AuthenticatorOptions struct { - ResidentKey bool `cbor:"rk,omitempty"` - UserVerification bool `cbor:"uv,omitempty"` -} +type AuthenticatorExtensions map[string]interface{} -type PinProtocolVersion uint +type AuthenticatorOptions map[string]bool + +type PinUVAuthProtocolVersion uint const ( - PinProtoV1 PinProtocolVersion = 1 + PinProtoV1 PinUVAuthProtocolVersion = 1 ) -// MakeCredentialResponse... -// CTAP 2.1 defines Fmt=0x1 and AuthData=0x2 while CTAP 2.0 defines AuthData=0x1 and Fmt=0x2 for some reasons -type MakeCredentialResponse struct { - Fmt string `cbor:"1,keyasint"` - AuthData []byte `cbor:"2,keyasint"` - AttSmt map[string]interface{} `cbor:"3,keyasint"` -} - -func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialResponse, error) { - enc, err := cbor.CTAP2EncOptions().EncMode() - if err != nil { - return nil, err - } - - reqData, err := enc.Marshal(req) - if err != nil { - return nil, err - } - - data := make([]byte, 0, len(reqData)+1) - data = append(data, cmdMakeCredential) - data = append(data, reqData...) - - resp, err := t.d.CBOR(data) - if err != nil { - return nil, err - } - - respData := &MakeCredentialResponse{} - if err := unmarshal(resp, respData); err != nil { - return nil, err - } - - return respData, nil -} - -type GetAssertionRequest struct{} -type GetAssertioNResponse struct{} - -func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertioNResponse, error) { - return nil, nil -} - -type GetInfoResponse struct { - Versions []string `cbor:"1,keyasint"` - Extensions []string `cbor:"2,keyasint,omitempty"` - AAGUID []byte `cbor:"3,keyasint"` - Options map[string]bool `cbor:"4,keyasint,omitempty"` - MaxMsgSize uint `cbor:"5,keyasint,omitempty"` - PinProtocol []uint `cbor:"6,keyasint,omitempty"` -} - -func (t *Token) GetInfo() (*GetInfoResponse, error) { - resp, err := t.d.CBOR([]byte{cmdGetInfo}) - if err != nil { - return nil, err - } - - infos := &GetInfoResponse{} - if err := unmarshal(resp, infos); err != nil { - return nil, err - } - - return infos, nil -} - -type ClientPINRequest struct { - PinProtocol PinProtocolVersion `cbor:"1,keyasint"` - SubCommand ClientPinSubCommand `cbor:"2,keyasint"` - KeyAgreement *COSEKey `cbor:"3,keyasint,omitempty"` - PinAuth []byte `cbor:"4,keyasint,omitempty"` - NewPinEnc []byte `cbor:"5,keyasint,omitempty"` - PinHashEnc []byte `cbor:"6,keyasint,omitempty"` -} - type ClientPinSubCommand uint const ( - GetRetries ClientPinSubCommand = 0x01 - GetKeyAgreement ClientPinSubCommand = 0x02 - SetPin ClientPinSubCommand = 0x03 - ChangePin ClientPinSubCommand = 0x04 - GetPinToken ClientPinSubCommand = 0x05 + GetPINRetries ClientPinSubCommand = 0x01 + GetKeyAgreement ClientPinSubCommand = 0x02 + SetPIN ClientPinSubCommand = 0x03 + ChangePIN ClientPinSubCommand = 0x04 + GetPINUvAuthTokenUsingPIN ClientPinSubCommand = 0x05 + GetPINUvAuthTokenUsingUv ClientPinSubCommand = 0x06 + GetUVRetries ClientPinSubCommand = 0x07 ) // COSEKey, as defined per https://tools.ietf.org/html/rfc8152#section-7.1 @@ -309,67 +529,3 @@ const ( MACCreate MACVerify ) - -type ClientPINResponse struct { - KeyAgreement *COSEKey `cbor:"1,keyasint,omitempty"` - PinToken []byte `cbor:"2,keyasint,omitempty"` - Retries uint `cbor:"3,keyasint,omitempty"` -} - -func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { - enc, err := cbor.CTAP2EncOptions().EncMode() - if err != nil { - return nil, err - } - - reqData, err := enc.Marshal(req) - if err != nil { - return nil, err - } - - data := make([]byte, 0, len(reqData)+1) - data = append(data, cmdClientPIN) - data = append(data, reqData...) - - resp, err := t.d.CBOR(data) - if err != nil { - return nil, err - } - - respData := &ClientPINResponse{} - if err := unmarshal(resp, respData); err != nil { - return nil, err - } - - return respData, nil -} - -type ResetRequest struct{} -type ResetResponse struct{} - -func (t *Token) Reset(*ResetRequest) (*ResetResponse, error) { - return nil, nil -} - -type GetNextAssertionRequest struct{} -type GetNextAssertionResponse struct{} - -func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionResponse, error) { - return nil, nil -} - -func unmarshal(resp []byte, out interface{}) error { - if len(resp) == 0 || resp[0] != statusSuccess { - status, ok := ctap2Status[resp[0]] - if !ok { - status = fmt.Sprintf("unknown error %x", resp[0]) - } - return fmt.Errorf("ctap2token: CBOR error: %s", status) - } - - if err := cbor.Unmarshal(resp[1:], out); err != nil { - return err - } - - return nil -} diff --git a/go.mod b/go.mod index 987cfe0..e9d5d61 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,6 @@ go 1.15 require ( github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a - github.com/fxamacker/cbor v1.5.1 github.com/fxamacker/cbor/v2 v2.2.0 - github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b github.com/stretchr/testify v1.6.1 ) diff --git a/go.sum b/go.sum index 9f665c7..d585d68 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fxamacker/cbor v1.5.1 h1:XjQWBgdmQyqimslUh5r4tUGmoqzHmBFQOImkWGi2awg= -github.com/fxamacker/cbor v1.5.1/go.mod h1:3aPGItF174ni7dDzd6JZ206H8cmr4GDNBGpPa971zsU= github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= -github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b h1:NGgE5ELokSf2tZ/bydyDUKrvd/jP8lrAoPNeBuMOTOk= -github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b/go.mod h1:zT/uzhdQGTqlwTq7Lpbj3JoJQWfPfIJ1tE0OidAmih8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -15,6 +11,7 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 45e882f41aa475d283fa1ad76850b88f73b5222d Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 16 Sep 2020 14:33:12 +0200 Subject: [PATCH 07/34] add reset command --- ctap2token/token.go | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/ctap2token/token.go b/ctap2token/token.go index 1dec216..e4cc56c 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -181,6 +181,14 @@ func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertionResponse, e return respData, nil } +type GetNextAssertionRequest struct{} +type GetNextAssertionResponse struct{} + +func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionResponse, error) { + // TODO + return nil, nil +} + type GetInfoResponse struct { Versions []string `cbor:"1,keyasint"` Extensions []string `cbor:"2,keyasint,omitempty"` @@ -249,28 +257,38 @@ func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { return respData, nil } -type ResetRequest struct{} -type ResetResponse struct{} +// Reset restore an authenticator back to a factory default state. User presence is required. +// In case of authenticators with no display, Reset request MUST have come to the authenticator within 10 seconds +// of powering up of the authenticator +// see: https://fidoalliance.org/specs/fido2/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#authenticatorReset +func (t *Token) Reset() error { + resp, err := t.d.CBOR([]byte{cmdReset}) + if err != nil { + return err + } -func (t *Token) Reset(*ResetRequest) (*ResetResponse, error) { - return nil, nil + return checkResponse(resp) } -type GetNextAssertionRequest struct{} -type GetNextAssertionResponse struct{} - -func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionResponse, error) { - return nil, nil -} +func checkResponse(resp []byte) error { + if len(resp) == 0 { + return errors.New("ctap2token: empty response") + } -func unmarshal(resp []byte, out interface{}) error { - if len(resp) == 0 || resp[0] != statusSuccess { + if resp[0] != statusSuccess { status, ok := ctap2Status[resp[0]] if !ok { status = fmt.Sprintf("unknown error %x", resp[0]) } return fmt.Errorf("ctap2token: CBOR error: %s", status) } + return nil +} + +func unmarshal(resp []byte, out interface{}) error { + if err := checkResponse(resp); err != nil { + return err + } if err := cbor.Unmarshal(resp[1:], out); err != nil { return err From e7017321df3894228716893831deb4030e58f96e Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 16 Sep 2020 14:46:12 +0200 Subject: [PATCH 08/34] add GetNextAssertion --- ctap2token/token.go | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/ctap2token/token.go b/ctap2token/token.go index e4cc56c..0e25328 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -144,7 +144,8 @@ type GetAssertionRequest struct { PinUVAuth []byte `cbor:"6,keyasint,omitempty"` PinUVAuthProtocol PinUVAuthProtocolVersion `cbor:"7,keyasint,omitempty"` } -type GetAssertionResponse struct { + +type AssertionResponse struct { Credential *CredentialDescriptor `cbor:"1,keyasint,omitempty"` AuthData AuthData `cbor:"2,keyasint"` Signature []byte `cbor:"3,keyasint"` @@ -153,7 +154,7 @@ type GetAssertionResponse struct { UserSelected bool `cbor:"6,keyasint,omitempty"` } -func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertionResponse, error) { +func (t *Token) GetAssertion(req *GetAssertionRequest) (*AssertionResponse, error) { enc, err := cbor.CTAP2EncOptions().EncMode() if err != nil { return nil, err @@ -173,7 +174,7 @@ func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertionResponse, e return nil, err } - respData := &GetAssertionResponse{} + respData := &AssertionResponse{} if err := unmarshal(resp, respData); err != nil { return nil, err } @@ -181,12 +182,20 @@ func (t *Token) GetAssertion(req *GetAssertionRequest) (*GetAssertionResponse, e return respData, nil } -type GetNextAssertionRequest struct{} -type GetNextAssertionResponse struct{} +// GetNextAssertion is used to obtain the next per-credential signature for a given GetAssertion request, +// when GetAssertion.NumberOfCredentials is greater than 1. +// see https://fidoalliance.org/specs/fido2/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#authenticatorGetNextAssertion +func (t *Token) GetNextAssertion() (*AssertionResponse, error) { + resp, err := t.d.CBOR([]byte{cmdGetNextAssertion}) + if err != nil { + return nil, err + } -func (t *Token) GetNextAssertion(*GetNextAssertionRequest) (*GetNextAssertionResponse, error) { - // TODO - return nil, nil + respData := &AssertionResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + return respData, nil } type GetInfoResponse struct { From e706c46ec680f413cdebda5eebae923f4af60d6d Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 16 Sep 2020 15:17:52 +0200 Subject: [PATCH 09/34] fix tests --- ctap2token/example/main.go | 186 +++++++++++++++++++------------------ ctap2token/token_test.go | 4 +- 2 files changed, 95 insertions(+), 95 deletions(-) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index b603c9e..7b0af41 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -1,14 +1,20 @@ package main import ( + "bufio" "bytes" + "crypto/aes" + "crypto/cipher" "crypto/ecdsa" "crypto/elliptic" + "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/x509" "fmt" "math/big" + "os" + "strings" "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/u2fhid" @@ -34,84 +40,84 @@ func main() { } fmt.Printf("Token infos:\n%#v\n", infos) - // // https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret - // fmt.Println("Retrieving key agreement from authenticator") - // cp1, err := token.ClientPIN(&ctap2token.ClientPINRequest{ - // PinProtocol: ctap2token.PinProtoV1, - // SubCommand: ctap2token.GetKeyAgreement, - // }) - // if err != nil { - // panic(err) - // } - - // fmt.Println("Generating platform key pair") - // b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) - // if err != nil { - // panic(err) - // } - - // aG := cp1.KeyAgreement - // aGX := new(big.Int) - // aGX.SetBytes(aG.X) - - // aGY := new(big.Int) - // aGY.SetBytes(aG.Y) - - // rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) - - // h := sha256.New() - // _, err = h.Write(rX.Bytes()) - // if err != nil { - // panic(err) - // } - - // sharedSecret := h.Sum(nil) - // fmt.Println("Generated shared secret") - - // reader := bufio.NewReader(os.Stdin) - // fmt.Print("Enter PIN: ") - // userPIN, _ := reader.ReadString('\n') - - // h.Reset() - // _, err = h.Write([]byte(strings.TrimSpace(userPIN))) - // if err != nil { - // panic(err) - // } - - // pinHash := h.Sum(nil) - // pinHash = pinHash[:aes.BlockSize] - - // pinHashEnc := make([]byte, aes.BlockSize) - // c, err := aes.NewCipher(sharedSecret) - // if err != nil { - // panic(err) - // } - // iv := make([]byte, aes.BlockSize) - // cbcEnc := cipher.NewCBCEncrypter(c, iv) - // cbcEnc.CryptBlocks(pinHashEnc, pinHash) - // fmt.Println("Encrypted user PIN using shared secret") - - // pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ - // SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, - // KeyAgreement: &ctap2token.COSEKey{ - // X: bGX.Bytes(), - // Y: bGY.Bytes(), - // KeyType: ctap2token.EC2, - // Curve: ctap2token.P256, - // Alg: ctap2token.ECDHES_HKDF256, - // }, - // PinHashEnc: pinHashEnc, - // PinProtocol: ctap2token.PinProtoV1, - // }) - // if err != nil { - // panic(err) - // } - - // // Decrypt pinToken using shared secret - // pinHashDec := make([]byte, aes.BlockSize) - // cbcDec := cipher.NewCBCDecrypter(c, iv) - // cbcDec.CryptBlocks(pinHashDec, pinResp.PinToken) - // fmt.Println("Decrypted authenticator pinToken") + // https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret + fmt.Println("Retrieving key agreement from authenticator") + cp1, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + PinProtocol: ctap2token.PinProtoV1, + SubCommand: ctap2token.GetKeyAgreement, + }) + if err != nil { + panic(err) + } + + fmt.Println("Generating platform key pair") + b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + + aG := cp1.KeyAgreement + aGX := new(big.Int) + aGX.SetBytes(aG.X) + + aGY := new(big.Int) + aGY.SetBytes(aG.Y) + + rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) + + h := sha256.New() + _, err = h.Write(rX.Bytes()) + if err != nil { + panic(err) + } + + sharedSecret := h.Sum(nil) + fmt.Println("Generated shared secret") + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Enter PIN: ") + userPIN, _ := reader.ReadString('\n') + + h.Reset() + _, err = h.Write([]byte(strings.TrimSpace(userPIN))) + if err != nil { + panic(err) + } + + pinHash := h.Sum(nil) + pinHash = pinHash[:aes.BlockSize] + + pinHashEnc := make([]byte, aes.BlockSize) + c, err := aes.NewCipher(sharedSecret) + if err != nil { + panic(err) + } + iv := make([]byte, aes.BlockSize) + cbcEnc := cipher.NewCBCEncrypter(c, iv) + cbcEnc.CryptBlocks(pinHashEnc, pinHash) + fmt.Println("Encrypted user PIN using shared secret") + + pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, + KeyAgreement: &ctap2token.COSEKey{ + X: bGX.Bytes(), + Y: bGY.Bytes(), + KeyType: ctap2token.EC2, + Curve: ctap2token.P256, + Alg: ctap2token.ECDHES_HKDF256, + }, + PinHashEnc: pinHashEnc, + PinProtocol: ctap2token.PinProtoV1, + }) + if err != nil { + panic(err) + } + + // Decrypt pinToken using shared secret + pinHashDec := make([]byte, aes.BlockSize) + cbcDec := cipher.NewCBCDecrypter(c, iv) + cbcDec.CryptBlocks(pinHashDec, pinResp.PinToken) + fmt.Println("Decrypted authenticator pinToken") clientDataHash := make([]byte, 32) if _, err := rand.Read(clientDataHash); err != nil { @@ -123,15 +129,15 @@ func main() { panic(err) } - // mac := hmac.New(sha256.New, pinHashDec) - // _, err = mac.Write(clientDataHash) - // if err != nil { - // panic(err) - // } + mac := hmac.New(sha256.New, pinHashDec) + _, err = mac.Write(clientDataHash) + if err != nil { + panic(err) + } - // pinAuth := mac.Sum(nil) - // pinAuth = pinAuth[:16] - // fmt.Println("Signed clientData with pinToken") + pinAuth := mac.Sum(nil) + pinAuth = pinAuth[:16] + fmt.Println("Signed clientData with pinToken") fmt.Println("Sending makeCredential request, please press authenticator button...") resp, err := token.MakeCredential(&ctap2token.MakeCredentialRequest{ @@ -150,12 +156,8 @@ func main() { ctap2token.PublicKeyES256, ctap2token.PublicKeyRS256, }, - Options: ctap2token.AuthenticatorOptions{ - "clientPin": false, - "uv": false, - }, - // PinUVAuth: pinAuth, - // PinUVAuthProtocol: ctap2token.PinProtoV1, + PinUVAuth: pinAuth, + PinUVAuthProtocol: ctap2token.PinProtoV1, }) if err != nil { panic(err) diff --git a/ctap2token/token_test.go b/ctap2token/token_test.go index c629d2a..adb2e19 100644 --- a/ctap2token/token_test.go +++ b/ctap2token/token_test.go @@ -97,9 +97,7 @@ func TestEncodeMakeCredentialRequest(t *testing.T) { PublicKeyES256, PublicKeyRS256, }, - Options: AuthenticatorOptions{ - ResidentKey: true, - }, + Options: AuthenticatorOptions{"rk": true}, } enc, err := cbor.CTAP2EncOptions().EncMode() From 8ee9dd2f79bd27e1b0d27d2abbd1cccc22edf175 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 16 Sep 2020 18:50:07 +0200 Subject: [PATCH 10/34] extracted pin handling to dedicated package --- ctap2token/example/main.go | 125 ++++++-------------------- ctap2token/pin/pin.go | 174 +++++++++++++++++++++++++++++++++++++ ctap2token/token.go | 150 +++++++++++++++++++++----------- 3 files changed, 299 insertions(+), 150 deletions(-) create mode 100644 ctap2token/pin/pin.go diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 7b0af41..f44ebbc 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -1,22 +1,19 @@ package main import ( - "bufio" "bytes" - "crypto/aes" - "crypto/cipher" "crypto/ecdsa" "crypto/elliptic" - "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/x509" + "errors" "fmt" "math/big" "os" - "strings" "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" "github.com/flynn/u2f/u2fhid" ) @@ -40,85 +37,6 @@ func main() { } fmt.Printf("Token infos:\n%#v\n", infos) - // https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret - fmt.Println("Retrieving key agreement from authenticator") - cp1, err := token.ClientPIN(&ctap2token.ClientPINRequest{ - PinProtocol: ctap2token.PinProtoV1, - SubCommand: ctap2token.GetKeyAgreement, - }) - if err != nil { - panic(err) - } - - fmt.Println("Generating platform key pair") - b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(err) - } - - aG := cp1.KeyAgreement - aGX := new(big.Int) - aGX.SetBytes(aG.X) - - aGY := new(big.Int) - aGY.SetBytes(aG.Y) - - rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) - - h := sha256.New() - _, err = h.Write(rX.Bytes()) - if err != nil { - panic(err) - } - - sharedSecret := h.Sum(nil) - fmt.Println("Generated shared secret") - - reader := bufio.NewReader(os.Stdin) - fmt.Print("Enter PIN: ") - userPIN, _ := reader.ReadString('\n') - - h.Reset() - _, err = h.Write([]byte(strings.TrimSpace(userPIN))) - if err != nil { - panic(err) - } - - pinHash := h.Sum(nil) - pinHash = pinHash[:aes.BlockSize] - - pinHashEnc := make([]byte, aes.BlockSize) - c, err := aes.NewCipher(sharedSecret) - if err != nil { - panic(err) - } - iv := make([]byte, aes.BlockSize) - cbcEnc := cipher.NewCBCEncrypter(c, iv) - cbcEnc.CryptBlocks(pinHashEnc, pinHash) - fmt.Println("Encrypted user PIN using shared secret") - - pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ - SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, - KeyAgreement: &ctap2token.COSEKey{ - X: bGX.Bytes(), - Y: bGY.Bytes(), - KeyType: ctap2token.EC2, - Curve: ctap2token.P256, - Alg: ctap2token.ECDHES_HKDF256, - }, - PinHashEnc: pinHashEnc, - PinProtocol: ctap2token.PinProtoV1, - }) - if err != nil { - panic(err) - } - - // Decrypt pinToken using shared secret - pinHashDec := make([]byte, aes.BlockSize) - cbcDec := cipher.NewCBCDecrypter(c, iv) - cbcDec.CryptBlocks(pinHashDec, pinResp.PinToken) - fmt.Println("Decrypted authenticator pinToken") - clientDataHash := make([]byte, 32) if _, err := rand.Read(clientDataHash); err != nil { panic(err) @@ -129,18 +47,7 @@ func main() { panic(err) } - mac := hmac.New(sha256.New, pinHashDec) - _, err = mac.Write(clientDataHash) - if err != nil { - panic(err) - } - - pinAuth := mac.Sum(nil) - pinAuth = pinAuth[:16] - fmt.Println("Signed clientData with pinToken") - - fmt.Println("Sending makeCredential request, please press authenticator button...") - resp, err := token.MakeCredential(&ctap2token.MakeCredentialRequest{ + req := &ctap2token.MakeCredentialRequest{ ClientDataHash: clientDataHash, RP: ctap2token.CredentialRpEntity{ ID: "example.com", @@ -156,11 +63,29 @@ func main() { ctap2token.PublicKeyES256, ctap2token.PublicKeyRS256, }, - PinUVAuth: pinAuth, - PinUVAuthProtocol: ctap2token.PinProtoV1, - }) + } + + // first try without user verification + fmt.Println("Sending makeCredential request, please press authenticator button...") + resp, err := token.MakeCredential(req) if err != nil { - panic(err) + // retry but with user verification + if errors.Unwrap(err) != ctap2token.ErrPinRequired { + panic(err) + } + fmt.Print("Enter device PIN: ") + pinHandler := pin.NewInteractiveHandler(token, os.Stdin) + pinAuth, err := pinHandler.Execute(clientDataHash) + if err != nil { + panic(err) + } + req.PinUVAuth = pinAuth + req.PinUVAuthProtocol = ctap2token.PinProtoV1 + + resp, err = token.MakeCredential(req) + if err != nil { + panic(err) + } } fmt.Println("Success creating credential") diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go new file mode 100644 index 0000000..19274fa --- /dev/null +++ b/ctap2token/pin/pin.go @@ -0,0 +1,174 @@ +package pin + +import ( + "bufio" + "crypto/aes" + "crypto/cipher" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "io" + "math/big" + "strings" + + "github.com/flynn/u2f/ctap2token" +) + +type PINHandler interface { + Execute(clientDataHash []byte) (ctap2token.PinUVAuth, error) +} + +type InteractiveHandler struct { + Stdin io.Reader + Stdout io.Writer + + token *ctap2token.Token +} + +var _ PINHandler = (*InteractiveHandler)(nil) + +// NewInteractiveHandler returns an interactive PINHandler, which will read +// the user PIN from the provided reader +func NewInteractiveHandler(t *ctap2token.Token, stdin io.Reader) *InteractiveHandler { + return &InteractiveHandler{ + token: t, + Stdin: stdin, + } +} + +// Execute performs the operations described by the FIDO specification in order to securely +// obtain a token from the authenticator which can be used to verify the user. +// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret +func (h *InteractiveHandler) Execute(clientDataHash []byte) (ctap2token.PinUVAuth, error) { + reader := bufio.NewReader(h.Stdin) + userPIN, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + userPIN = strings.TrimSpace(userPIN) + + return exchangeUserPinToPinAuth(h.token, []byte(userPIN), clientDataHash) +} + +func exchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash []byte) ([]byte, error) { + b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + aGX, aGY, err := getTokenKeyAgreement(token) + if err != nil { + return nil, err + } + + sharedSecret, err := computeSharedSecret(b, aGX, aGY) + if err != nil { + return nil, err + } + + encPinHash, err := hashEncryptPIN(userPIN, sharedSecret) + if err != nil { + return nil, err + } + + pinToken, err := getPINToken(token, encPinHash, bGX, bGY) + if err != nil { + return nil, err + } + + return computePINAuth(pinToken, sharedSecret, clientDataHash) +} + +func getTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error) { + pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + PinProtocol: ctap2token.PinProtoV1, + SubCommand: ctap2token.GetKeyAgreement, + }) + if err != nil { + return nil, nil, err + } + + aGX = new(big.Int) + aGX.SetBytes(pinResp.KeyAgreement.X) + + aGY = new(big.Int) + aGY.SetBytes(pinResp.KeyAgreement.Y) + + return aGX, aGY, nil +} + +func computeSharedSecret(b []byte, aGX, aGY *big.Int) ([]byte, error) { + rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) + sha := sha256.New() + _, err := sha.Write(rX.Bytes()) + if err != nil { + return nil, err + } + + return sha.Sum(nil), nil +} + +func hashEncryptPIN(userPIN []byte, sharedSecret []byte) ([]byte, error) { + sha := sha256.New() + _, err := sha.Write(userPIN) + if err != nil { + return nil, err + } + + pinHash := sha.Sum(nil) + pinHash = pinHash[:aes.BlockSize] + + // encrypt pinHash with AES-CBC using shared secret + pinHashEnc := make([]byte, aes.BlockSize) + c, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, err + } + iv := make([]byte, aes.BlockSize) + cbcEnc := cipher.NewCBCEncrypter(c, iv) + cbcEnc.CryptBlocks(pinHashEnc, pinHash) + + return pinHashEnc, nil +} + +func getPINToken(token *ctap2token.Token, encPinHash []byte, bGX, bGY *big.Int) ([]byte, error) { + pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, + KeyAgreement: &ctap2token.COSEKey{ + X: bGX.Bytes(), + Y: bGY.Bytes(), + KeyType: ctap2token.EC2, + Curve: ctap2token.P256, + Alg: ctap2token.ECDHES_HKDF256, + }, + PinHashEnc: encPinHash, + PinProtocol: ctap2token.PinProtoV1, + }) + if err != nil { + return nil, err + } + + return pinResp.PinToken, nil +} + +func computePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { + // decrypt pinToken using AES-CBC with shared secret + clearPinToken := make([]byte, aes.BlockSize) + c, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, err + } + iv := make([]byte, aes.BlockSize) + cbcDec := cipher.NewCBCDecrypter(c, iv) + cbcDec.CryptBlocks(clearPinToken, pinToken) + + // compute and return pinAuth + mac := hmac.New(sha256.New, clearPinToken) + _, err = mac.Write(data) + if err != nil { + return nil, err + } + pinAuth := mac.Sum(nil) + return pinAuth[:16], nil +} diff --git a/ctap2token/token.go b/ctap2token/token.go index 0e25328..c566495 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -19,53 +19,101 @@ const ( cmdGetNextAssertion = 0x08 ) +var ( + ErrInvalidCommand = errors.New("CTAP1_ERR_INVALID_COMMAND") + ErrInvalidParameter = errors.New("CTAP1_ERR_INVALID_PARAMETER") + ErrInvalidLength = errors.New("CTAP1_ERR_INVALID_LENGTH") + ErrInvalidSeq = errors.New("CTAP1_ERR_INVALID_SEQ") + ErrTimeout = errors.New("CTAP1_ERR_TIMEOUT") + ErrChannelBusy = errors.New("CTAP1_ERR_CHANNEL_BUSY") + ErrLockRequired = errors.New("CTAP1_ERR_LOCK_REQUIRED") + ErrInvalidChannel = errors.New("CTAP1_ERR_INVALID_CHANNEL") + ErrCborUnexpectedType = errors.New("CTAP2_ERR_CBOR_UNEXPECTED_TYPE") + ErrInvalidCbor = errors.New("CTAP2_ERR_INVALID_CBOR") + ErrMissingParameter = errors.New("CTAP2_ERR_MISSING_PARAMETER") + ErrLimitExceeded = errors.New("CTAP2_ERR_LIMIT_EXCEEDED") + ErrUnsupportedExtension = errors.New("CTAP2_ERR_UNSUPPORTED_EXTENSION") + ErrCredentialExcluded = errors.New("CTAP2_ERR_CREDENTIAL_EXCLUDED") + ErrProcessing = errors.New("CTAP2_ERR_PROCESSING") + ErrInvalidCredential = errors.New("CTAP2_ERR_INVALID_CREDENTIAL") + ErrUserActionPending = errors.New("CTAP2_ERR_USER_ACTION_PENDING") + ErrOperationPending = errors.New("CTAP2_ERR_OPERATION_PENDING") + ErrNoOperations = errors.New("CTAP2_ERR_NO_OPERATIONS") + ErrUnsupportedAlgorithm = errors.New("CTAP2_ERR_UNSUPPORTED_ALGORITHM") + ErrOperationDenied = errors.New("CTAP2_ERR_OPERATION_DENIED") + ErrKeyStoreFull = errors.New("CTAP2_ERR_KEY_STORE_FULL") + ErrNoOperationPending = errors.New("CTAP2_ERR_NO_OPERATION_PENDING") + ErrUnsupportedOption = errors.New("CTAP2_ERR_UNSUPPORTED_OPTION") + ErrInvalidOption = errors.New("CTAP2_ERR_INVALID_OPTION") + ErrKeepaliveCancel = errors.New("CTAP2_ERR_KEEPALIVE_CANCEL") + ErrNoCredentials = errors.New("CTAP2_ERR_NO_CREDENTIALS") + ErrUserActionTimeout = errors.New("CTAP2_ERR_USER_ACTION_TIMEOUT") + ErrNotAllowed = errors.New("CTAP2_ERR_NOT_ALLOWED") + ErrPinInvalid = errors.New("CTAP2_ERR_PIN_INVALID") + ErrPinBlocked = errors.New("CTAP2_ERR_PIN_BLOCKED") + ErrPinAuthInvalid = errors.New("CTAP2_ERR_PIN_AUTH_INVALID") + ErrPinAuthBlocked = errors.New("CTAP2_ERR_PIN_AUTH_BLOCKED") + ErrPinNotSet = errors.New("CTAP2_ERR_PIN_NOT_SET") + ErrPinRequired = errors.New("CTAP2_ERR_PIN_REQUIRED") + ErrPinPolicyViolation = errors.New("CTAP2_ERR_PIN_POLICY_VIOLATION") + ErrPinTokenExpired = errors.New("CTAP2_ERR_PIN_TOKEN_EXPIRED") + ErrRequestTooLarge = errors.New("CTAP2_ERR_REQUEST_TOO_LARGE") + ErrActionTimeout = errors.New("CTAP2_ERR_ACTION_TIMEOUT") + ErrUpRequired = errors.New("CTAP2_ERR_UP_REQUIRED") + ErrSpecLast = errors.New("CTAP2_ERR_SPEC_LAST") + ErrExtensionFirst = errors.New("CTAP2_ERR_EXTENSION_FIRST") + ErrExtensionLast = errors.New("CTAP2_ERR_EXTENSION_LAST") + ErrVendorFirst = errors.New("CTAP2_ERR_VENDOR_FIRST") + ErrVendorLast = errors.New("CTAP2_ERR_VENDOR_LAST") +) + // CTAP2 error status from https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#error-responses -var ctap2Status = map[byte]string{ - 0x01: "CTAP1_ERR_INVALID_COMMAND", - 0x02: "CTAP1_ERR_INVALID_PARAMETER", - 0x03: "CTAP1_ERR_INVALID_LENGTH", - 0x04: "CTAP1_ERR_INVALID_SEQ", - 0x05: "CTAP1_ERR_TIMEOUT", - 0x06: "CTAP1_ERR_CHANNEL_BUSY", - 0x0A: "CTAP1_ERR_LOCK_REQUIRED", - 0x0B: "CTAP1_ERR_INVALID_CHANNEL", - 0x11: "CTAP2_ERR_CBOR_UNEXPECTED_TYPE", - 0x12: "CTAP2_ERR_INVALID_CBOR", - 0x14: "CTAP2_ERR_MISSING_PARAMETER", - 0x15: "CTAP2_ERR_LIMIT_EXCEEDED", - 0x16: "CTAP2_ERR_UNSUPPORTED_EXTENSION", - 0x19: "CTAP2_ERR_CREDENTIAL_EXCLUDED", - 0x21: "CTAP2_ERR_PROCESSING", - 0x22: "CTAP2_ERR_INVALID_CREDENTIAL", - 0x23: "CTAP2_ERR_USER_ACTION_PENDING", - 0x24: "CTAP2_ERR_OPERATION_PENDING", - 0x25: "CTAP2_ERR_NO_OPERATIONS", - 0x26: "CTAP2_ERR_UNSUPPORTED_ALGORITHM", - 0x27: "CTAP2_ERR_OPERATION_DENIED", - 0x28: "CTAP2_ERR_KEY_STORE_FULL", - 0x2A: "CTAP2_ERR_NO_OPERATION_PENDING", - 0x2B: "CTAP2_ERR_UNSUPPORTED_OPTION", - 0x2C: "CTAP2_ERR_INVALID_OPTION", - 0x2D: "CTAP2_ERR_KEEPALIVE_CANCEL", - 0x2E: "CTAP2_ERR_NO_CREDENTIALS", - 0x2F: "CTAP2_ERR_USER_ACTION_TIMEOUT", - 0x30: "CTAP2_ERR_NOT_ALLOWED", - 0x31: "CTAP2_ERR_PIN_INVALID", - 0x32: "CTAP2_ERR_PIN_BLOCKED", - 0x33: "CTAP2_ERR_PIN_AUTH_INVALID", - 0x34: "CTAP2_ERR_PIN_AUTH_BLOCKED", - 0x35: "CTAP2_ERR_PIN_NOT_SET", - 0x36: "CTAP2_ERR_PIN_REQUIRED", - 0x37: "CTAP2_ERR_PIN_POLICY_VIOLATION", - 0x38: "CTAP2_ERR_PIN_TOKEN_EXPIRED", - 0x39: "CTAP2_ERR_REQUEST_TOO_LARGE", - 0x3A: "CTAP2_ERR_ACTION_TIMEOUT", - 0x3B: "CTAP2_ERR_UP_REQUIRED", - 0xDF: "CTAP2_ERR_SPEC_LAST", - 0xE0: "CTAP2_ERR_EXTENSION_FIRST", - 0xEF: "CTAP2_ERR_EXTENSION_LAST", - 0xF0: "CTAP2_ERR_VENDOR_FIRST", - 0xFF: "CTAP2_ERR_VENDOR_LAST", +var ctapErrors = map[byte]error{ + 0x01: ErrInvalidCommand, + 0x02: ErrInvalidParameter, + 0x03: ErrInvalidLength, + 0x04: ErrInvalidSeq, + 0x05: ErrTimeout, + 0x06: ErrChannelBusy, + 0x0A: ErrLockRequired, + 0x0B: ErrInvalidChannel, + 0x11: ErrCborUnexpectedType, + 0x12: ErrInvalidCbor, + 0x14: ErrMissingParameter, + 0x15: ErrLimitExceeded, + 0x16: ErrUnsupportedExtension, + 0x19: ErrCredentialExcluded, + 0x21: ErrProcessing, + 0x22: ErrInvalidCredential, + 0x23: ErrUserActionPending, + 0x24: ErrOperationPending, + 0x25: ErrNoOperations, + 0x26: ErrUnsupportedAlgorithm, + 0x27: ErrOperationDenied, + 0x28: ErrKeyStoreFull, + 0x2A: ErrNoOperationPending, + 0x2B: ErrUnsupportedOption, + 0x2C: ErrInvalidOption, + 0x2D: ErrKeepaliveCancel, + 0x2E: ErrNoCredentials, + 0x2F: ErrUserActionTimeout, + 0x30: ErrNotAllowed, + 0x31: ErrPinInvalid, + 0x32: ErrPinBlocked, + 0x33: ErrPinAuthInvalid, + 0x34: ErrPinAuthBlocked, + 0x35: ErrPinNotSet, + 0x36: ErrPinRequired, + 0x37: ErrPinPolicyViolation, + 0x38: ErrPinTokenExpired, + 0x39: ErrRequestTooLarge, + 0x3A: ErrActionTimeout, + 0x3B: ErrUpRequired, + 0xDF: ErrSpecLast, + 0xE0: ErrExtensionFirst, + 0xEF: ErrExtensionLast, + 0xF0: ErrVendorFirst, + 0xFF: ErrVendorLast, } type Device interface { @@ -141,7 +189,7 @@ type GetAssertionRequest struct { AllowList []*CredentialDescriptor `cbor:"3,keyasint,omitempty"` Extensions AuthenticatorExtensions `cbor:"4,keyasint,omitempty"` Options AuthenticatorOptions `cbor:"5,keyasint,omitempty"` - PinUVAuth []byte `cbor:"6,keyasint,omitempty"` + PinUVAuth PinUVAuth `cbor:"6,keyasint,omitempty"` PinUVAuthProtocol PinUVAuthProtocolVersion `cbor:"7,keyasint,omitempty"` } @@ -285,11 +333,11 @@ func checkResponse(resp []byte) error { } if resp[0] != statusSuccess { - status, ok := ctap2Status[resp[0]] + status, ok := ctapErrors[resp[0]] if !ok { - status = fmt.Sprintf("unknown error %x", resp[0]) + status = fmt.Errorf("unknown error %x", resp[0]) } - return fmt.Errorf("ctap2token: CBOR error: %s", status) + return fmt.Errorf("ctap2token: CBOR error: %w", status) } return nil } @@ -487,6 +535,8 @@ type AuthenticatorExtensions map[string]interface{} type AuthenticatorOptions map[string]bool +type PinUVAuth []byte + type PinUVAuthProtocolVersion uint const ( From c405ddc77ff295430ddd9ab4543be728022d4a15 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Fri, 18 Sep 2020 14:26:12 +0200 Subject: [PATCH 11/34] add webauthn registeration handling --- ctap2token/example/main.go | 4 +- ctap2token/pin/pin.go | 34 ++-- ctap2token/token.go | 75 +++++++- webauthn/example/main.go | 87 +++++++++ webauthn/token.go | 380 +++++++++++++++++++++++++++++++++++++ 5 files changed, 563 insertions(+), 17 deletions(-) create mode 100644 webauthn/example/main.go create mode 100644 webauthn/token.go diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index f44ebbc..ea1228f 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "math/big" - "os" "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" @@ -73,8 +72,7 @@ func main() { if errors.Unwrap(err) != ctap2token.ErrPinRequired { panic(err) } - fmt.Print("Enter device PIN: ") - pinHandler := pin.NewInteractiveHandler(token, os.Stdin) + pinHandler := pin.NewInteractiveHandler(token) pinAuth, err := pinHandler.Execute(clientDataHash) if err != nil { panic(err) diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go index 19274fa..76baba8 100644 --- a/ctap2token/pin/pin.go +++ b/ctap2token/pin/pin.go @@ -8,8 +8,10 @@ import ( "crypto/hmac" "crypto/rand" "crypto/sha256" + "fmt" "io" "math/big" + "os" "strings" "github.com/flynn/u2f/ctap2token" @@ -30,10 +32,11 @@ var _ PINHandler = (*InteractiveHandler)(nil) // NewInteractiveHandler returns an interactive PINHandler, which will read // the user PIN from the provided reader -func NewInteractiveHandler(t *ctap2token.Token, stdin io.Reader) *InteractiveHandler { +func NewInteractiveHandler(t *ctap2token.Token) *InteractiveHandler { return &InteractiveHandler{ - token: t, - Stdin: stdin, + token: t, + Stdin: os.Stdin, + Stdout: os.Stdout, } } @@ -41,6 +44,7 @@ func NewInteractiveHandler(t *ctap2token.Token, stdin io.Reader) *InteractiveHan // obtain a token from the authenticator which can be used to verify the user. // see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret func (h *InteractiveHandler) Execute(clientDataHash []byte) (ctap2token.PinUVAuth, error) { + fmt.Fprint(h.Stdout, "Enter device PIN: ") reader := bufio.NewReader(h.Stdin) userPIN, err := reader.ReadString('\n') if err != nil { @@ -57,12 +61,12 @@ func exchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash [ return nil, err } - aGX, aGY, err := getTokenKeyAgreement(token) + aGX, aGY, err := GetTokenKeyAgreement(token) if err != nil { return nil, err } - sharedSecret, err := computeSharedSecret(b, aGX, aGY) + sharedSecret, err := ComputeSharedSecret(b, aGX, aGY) if err != nil { return nil, err } @@ -77,10 +81,10 @@ func exchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash [ return nil, err } - return computePINAuth(pinToken, sharedSecret, clientDataHash) + return ComputePINAuth(pinToken, sharedSecret, clientDataHash) } -func getTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error) { +func GetTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error) { pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ PinProtocol: ctap2token.PinProtoV1, SubCommand: ctap2token.GetKeyAgreement, @@ -98,7 +102,7 @@ func getTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error return aGX, aGY, nil } -func computeSharedSecret(b []byte, aGX, aGY *big.Int) ([]byte, error) { +func ComputeSharedSecret(b []byte, aGX, aGY *big.Int) ([]byte, error) { rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) sha := sha256.New() _, err := sha.Write(rX.Bytes()) @@ -120,16 +124,20 @@ func hashEncryptPIN(userPIN []byte, sharedSecret []byte) ([]byte, error) { pinHash = pinHash[:aes.BlockSize] // encrypt pinHash with AES-CBC using shared secret - pinHashEnc := make([]byte, aes.BlockSize) + return AESCBCEncrypt(sharedSecret, pinHash) +} + +func AESCBCEncrypt(sharedSecret, data []byte) ([]byte, error) { + dataEnc := make([]byte, len(data)) c, err := aes.NewCipher(sharedSecret) if err != nil { return nil, err } iv := make([]byte, aes.BlockSize) cbcEnc := cipher.NewCBCEncrypter(c, iv) - cbcEnc.CryptBlocks(pinHashEnc, pinHash) + cbcEnc.CryptBlocks(dataEnc, data) - return pinHashEnc, nil + return dataEnc, nil } func getPINToken(token *ctap2token.Token, encPinHash []byte, bGX, bGY *big.Int) ([]byte, error) { @@ -152,9 +160,9 @@ func getPINToken(token *ctap2token.Token, encPinHash []byte, bGX, bGY *big.Int) return pinResp.PinToken, nil } -func computePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { +func ComputePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { // decrypt pinToken using AES-CBC with shared secret - clearPinToken := make([]byte, aes.BlockSize) + clearPinToken := make([]byte, len(data)) c, err := aes.NewCipher(sharedSecret) if err != nil { return nil, err diff --git a/ctap2token/token.go b/ctap2token/token.go index c566495..3d47938 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -155,6 +155,24 @@ type MakeCredentialResponse struct { AttSmt map[string]interface{} `cbor:"3,keyasint"` } +func (m *MakeCredentialResponse) AttestationObject() ([]byte, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + // For some reasons, webauthn defines the attestationObject + // with string keys, but FIDO2 specs with integer keys. + // TODO checks with various server implementation what they support + // webauthn.io: string keys + att := make(map[string]interface{}) + att["fmt"] = m.Fmt + att["authData"] = m.AuthData + att["attSmt"] = m.AttSmt + + return enc.Marshal(att) +} + func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialResponse, error) { enc, err := cbor.CTAP2EncOptions().EncMode() if err != nil { @@ -174,7 +192,6 @@ func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialRespo if err != nil { return nil, err } - respData := &MakeCredentialResponse{} if err := unmarshal(resp, respData); err != nil { return nil, err @@ -455,6 +472,62 @@ type ParsedAuthData struct { Extensions AuthenticatorExtensions } +func (p *ParsedAuthData) Bytes() ([]byte, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + out := make([]byte, 0, authDataMinLength) + out = append(out, p.RPIDHash...) + + var flag byte + if p.Flags.UserPresent { + flag |= authDataFlagUP + } + if p.Flags.UserVerified { + flag |= authDataFlagUV + } + if p.Flags.AttestedCredentialData { + flag |= authDataFlagAT + } + if p.Flags.HasExtensions { + flag |= authDataFlagED + } + out = append(out, flag) + + signCount := make([]byte, 4) + binary.BigEndian.PutUint32(signCount, p.SignCount) + out = append(out, signCount...) + + if p.Flags.AttestedCredentialData { + out = append(out, p.AttestedCredentialData.AAGUID...) + + credIDLen := make([]byte, 2) + binary.BigEndian.PutUint16(credIDLen, uint16(len(p.AttestedCredentialData.CredentialID))) + + out = append(out, credIDLen...) + out = append(out, p.AttestedCredentialData.CredentialID...) + + pubkey, err := enc.Marshal(p.AttestedCredentialData.CredentialPublicKey) + if err != nil { + return nil, err + } + + out = append(out, pubkey...) + } + + if p.Flags.HasExtensions { + exts, err := enc.Marshal(p.Extensions) + if err != nil { + return nil, err + } + out = append(out, exts...) + } + + return out, nil +} + const ( authDataFlagUP = 1 << iota authDataFlagReserved1 diff --git a/webauthn/example/main.go b/webauthn/example/main.go new file mode 100644 index 0000000..06ca092 --- /dev/null +++ b/webauthn/example/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" + + "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" + "github.com/flynn/u2f/u2fhid" + "github.com/flynn/u2f/webauthn" +) + +func main() { + devices, err := u2fhid.Devices() + if err != nil { + panic(err) + } + + for _, d := range devices { + dev, err := u2fhid.Open(d) + if err != nil { + panic(err) + } + + t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) + if err != nil { + panic(err) + } + + c := &http.Client{} + // localhost:9005 runs a server from https://github.com/duo-labs/webauthn.io + httpResp, err := c.Get("http://localhost:9005/makeCredential/aaa?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=") + if err != nil { + panic(err) + } + + d, err := httputil.DumpResponse(httpResp, true) + if err != nil { + panic(err) + } + fmt.Printf("response: %s\n", d) + + webauthnReq := &webauthn.RegisterRequest{} + err = json.NewDecoder(httpResp.Body).Decode(webauthnReq) + if err != nil { + panic(err) + } + + origin := "http://localhost:9005" + fmt.Printf("Webauthn registration request for %q. Confirm presence on authenticator when it will blink...\n", origin) + webauthnResp, err := t.Register(origin, webauthnReq) + if err != nil { + panic(err) + } + + rd, _ := json.MarshalIndent(webauthnResp, "", " ") + fmt.Printf("%s\n", rd) + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(webauthnResp); err != nil { + panic(err) + } + + httpPostReq, err := http.NewRequest("POST", "http://localhost:9005/makeCredential", buf) + if err != nil { + panic(err) + } + + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + panic(err) + } + + d, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + panic(err) + } + fmt.Printf("response: %s\n", d) + } +} diff --git a/webauthn/token.go b/webauthn/token.go new file mode 100644 index 0000000..eb51e52 --- /dev/null +++ b/webauthn/token.go @@ -0,0 +1,380 @@ +package webauthn + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "reflect" + + "github.com/flynn/u2f/ctap2token" + ctap2 "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" + "github.com/flynn/u2f/u2ftoken" +) + +type WebauthnToken interface { + // Register is the equivalent to navigator.credential.create() + Register(origin string, req *RegisterRequest) (*RegisterResponse, error) + // Authenticate is the equivalent to navigator.credential.get() + Authenticate(req *AuthenticateRequest) (*AuthenticateResponse, error) +} + +type RegisterRequest struct { + PublicKey struct { + Challenge []byte `json:"challenge"` + Rp struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + } `json:"rp"` + User struct { + ID []byte `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Icon string `json:"icon"` + } `json:"user"` + PubKeyCredParams []struct { + Type string `json:"type"` + Alg int `json:"alg"` + } `json:"pubKeyCredParams"` + ExcludeCredentials []struct { + Type string `json:"type"` + ID []byte `json:"id"` + Transports []string `json:"transports"` + } `json:"excludeCredentials"` + AuthenticatorSelection struct { + AuthenticatorAttachment string `json:"authenticatorAttachment"` + RequireResidentKey bool `json:"requireResidentKey"` + UserVerification string `json:"userVerification"` + } `json:"authenticatorSelection"` + Timeout int `json:"timeout"` + Extensions map[string]interface{} `json:"extensions"` + Attestation string `json:"attestation"` + } `json:"publicKey"` +} + +type RegisterResponse struct { + ID string `json:"id"` + RawID URLEncodedBase64 `json:"rawId"` + Type string `json:"type"` + Response AttestationResponse `json:"response"` +} + +type AttestationResponse struct { + AttestationObject URLEncodedBase64 `json:"attestationObject"` + ClientDataJSON URLEncodedBase64 `json:"clientDataJSON"` +} + +type AuthenticateRequest struct { + PublicKey struct { + Challenge string `json:"challenge"` + Timeout int `json:"timeout"` + RpID string `json:"rpId"` + AllowCredentials []struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"allowCredentials"` + Extensions struct { + TxAuthSimple string `json:"txAuthSimple"` + } `json:"extensions"` + } `json:"publicKey"` +} +type AuthenticateResponse struct { + ID string `json:"id"` + RawID []byte `json:"rawId"` + Type string `json:"type"` + Response AssertionResponse `json:"response"` +} + +type AssertionResponse struct { + AuthenticatorData string `json:"authenticatorData"` + ClientDataJSON string `json:"clientDataJSON"` + Signature string `json:"signature"` + UserHandle string `json:"userHandle"` +} + +type ctap2TWebauthnToken struct { + t *ctap2.Token + pinHandler pin.PINHandler +} + +type ctap1WebauthnToken struct { + t *u2ftoken.Token +} + +type Device interface { + ctap2.Device + u2ftoken.Device +} + +type collectedClientData struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + // TODO tokenBinding ? +} + +// NewToken returns a new WebAuthn capable token. +// It will first try to communicate with the device using FIDO2 / CTAP2 protocol, +// and fallback using U2F / CTAP1 on failure. +// A pinHandler is required when using a CTAP2 compatible authenticator with a configured PIN, when requests +// require user verification. +func NewToken(d Device, pinHandler pin.PINHandler) (WebauthnToken, error) { + t := ctap2.NewToken(d) + if _, err := t.GetInfo(); err != nil { + return &ctap1WebauthnToken{ + t: u2ftoken.NewToken(d), + }, nil + } + return &ctap2TWebauthnToken{ + t: t, + pinHandler: pinHandler, + }, nil +} + +var emptyAAGUID = make([]byte, 16) + +/* +TODO List + - handle custom timeout + - extensions support + - what is collectedClientData.tokenBinding (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding) + - Handle multiple authenticator / multiple transports +*/ + +var supportedCredentialTypes = map[string]ctap2.CredentialType{ + string(ctap2.PublicKey): ctap2.PublicKey, +} +var supportedTransports = map[string]ctap2.AuthenticatorTransport{ + string(ctap2.USB): ctap2.USB, +} + +func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain + + rpID := req.PublicKey.Rp.ID + if rpID == "" { + rpID = effectiveDomain + } + + credTypesAndPubKeyAlgs := make([]ctap2.CredentialParam, 0, len(req.PublicKey.PubKeyCredParams)) + for _, cp := range req.PublicKey.PubKeyCredParams { + t, ok := supportedCredentialTypes[cp.Type] + if !ok { + continue + } + + credTypesAndPubKeyAlgs = append(credTypesAndPubKeyAlgs, ctap2.CredentialParam{ + Type: t, + Alg: ctap2.Alg(cp.Alg), + }) + } + + if len(credTypesAndPubKeyAlgs) == 0 && len(req.PublicKey.PubKeyCredParams) > 0 { + return nil, errors.New("webauthn: credential parameters not supported") + } + + // TODO add support for extensions (bullet point 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) + clientExtensions := make(map[string]interface{}) + + clientData := collectedClientData{ + Type: "webauthn.create", + Challenge: base64.RawURLEncoding.EncodeToString(req.PublicKey.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + } + clientDataJSON, err := json.Marshal(clientData) + if err != nil { + return nil, err + } + + sha := sha256.New() + if _, err := sha.Write(clientDataJSON); err != nil { + return nil, err + } + clientDataHash := sha.Sum(nil) + + excludeList := make([]ctap2.CredentialDescriptor, 0, len(req.PublicKey.ExcludeCredentials)) + for _, c := range req.PublicKey.ExcludeCredentials { + t, ok := supportedCredentialTypes[c.Type] + if !ok { + return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) + } + + transports := make([]ctap2.AuthenticatorTransport, 0, len(c.Transports)) + for _, transport := range c.Transports { + ctapTransport, ok := supportedTransports[transport] + if !ok { + return nil, fmt.Errorf("webauthn: unsupported transport type %q", transport) + } + transports = append(transports, ctapTransport) + } + + excludeList = append(excludeList, ctap2.CredentialDescriptor{ + ID: c.ID, + Transports: transports, + Type: t, + }) + } + + options := make(ctap2.AuthenticatorOptions) + if req.PublicKey.AuthenticatorSelection.RequireResidentKey { + options["rk"] = true + } + + var pinProtocol ctap2.PinUVAuthProtocolVersion + var pinUVAuth []byte + switch req.PublicKey.AuthenticatorSelection.UserVerification { + case "discouraged": + // Do nothing + case "required": + pinProtocol = ctap2.PinProtoV1 + pinUVAuth, err = w.pinHandler.Execute(clientDataHash) + if err != nil { + return nil, err + } + case "preferred": + infos, err := w.t.GetInfo() + if err != nil { + return nil, err + } + + // Most authenticators seems to set clientPin option to true when the PIN is set + // TODO: validate this is a standard way to do that + if pin, ok := infos.Options["clientPin"]; ok && pin { + pinProtocol = ctap2.PinProtoV1 + pinUVAuth, err = w.pinHandler.Execute(clientDataHash) + if err != nil { + return nil, err + } + } + default: + return nil, fmt.Errorf("unsupported user verification option %q", req.PublicKey.AuthenticatorSelection.UserVerification) + } + + resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ + ClientDataHash: clientDataHash, + RP: ctap2.CredentialRpEntity{ + ID: rpID, + Name: req.PublicKey.Rp.Name, + Icon: req.PublicKey.Rp.Icon, + }, + User: ctap2token.CredentialUserEntity{ + ID: req.PublicKey.User.ID, + Icon: req.PublicKey.User.Icon, + Name: req.PublicKey.User.Name, + DisplayName: req.PublicKey.User.DisplayName, + }, + PubKeyCredParams: credTypesAndPubKeyAlgs, + ExcludeList: excludeList, + Extensions: clientExtensions, + Options: options, + PinUVAuth: pinUVAuth, + PinUVAuthProtocol: pinProtocol, + }) + if err != nil { + return nil, err + } + + authData, err := resp.AuthData.Parse() + if err != nil { + return nil, err + } + + switch req.PublicKey.Attestation { + case "none": + isEmptyAAGUID := bytes.Equal(authData.AttestedCredentialData.AAGUID, emptyAAGUID) + _, x5c := resp.AttSmt["x5c"] + _, ecdaaKeyId := resp.AttSmt["ecdaaKeyId"] + if resp.Fmt == "packed" && isEmptyAAGUID && !x5c && !ecdaaKeyId { + break // self attestation is being used and no further action is needed. + } + + authData.AttestedCredentialData.AAGUID = emptyAAGUID + d, err := authData.Bytes() + if err != nil { + return nil, err + } + + resp = &ctap2.MakeCredentialResponse{ + Fmt: "none", + AuthData: d, + AttSmt: make(map[string]interface{}), + } + case "indirect": + // TODO + case "direct": + // Do nothing + default: + return nil, fmt.Errorf("unsupported attestation mode %q", req.PublicKey.Attestation) + } + + attestationObject, err := resp.AttestationObject() + if err != nil { + return nil, err + } + + return &RegisterResponse{ + ID: base64.RawURLEncoding.EncodeToString(authData.AttestedCredentialData.CredentialID), + RawID: authData.AttestedCredentialData.CredentialID, + Type: "public-key", + Response: AttestationResponse{ + ClientDataJSON: clientDataJSON, + AttestationObject: attestationObject, + }, + }, nil +} +func (w *ctap2TWebauthnToken) Authenticate(req *AuthenticateRequest) (*AuthenticateResponse, error) { + panic("not implemented yet") + return nil, nil +} + +func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { + panic("not implemented yet") + return nil, nil +} +func (w *ctap1WebauthnToken) Authenticate(req *AuthenticateRequest) (*AuthenticateResponse, error) { + panic("not implemented yet") + return nil, nil +} + +// URLEncodedBase64 represents a byte slice holding URL-encoded base64 data. +// When fields of this type are unmarshaled from JSON, the data is base64 +// decoded into a byte slice. +type URLEncodedBase64 []byte + +// UnmarshalJSON base64 decodes a URL-encoded value, storing the result in the +// provided byte slice. +func (dest *URLEncodedBase64) UnmarshalJSON(data []byte) error { + // Trim the leading spaces + data = bytes.Trim(data, "\"") + out := make([]byte, base64.RawURLEncoding.DecodedLen(len(data))) + n, err := base64.RawURLEncoding.Decode(out, data) + if err != nil { + return err + } + + v := reflect.ValueOf(dest).Elem() + v.SetBytes(out[:n]) + return nil +} + +// MarshalJSON base64 encodes a non URL-encoded value, storing the result in the +// provided byte slice. +func (data URLEncodedBase64) MarshalJSON() ([]byte, error) { + if data == nil { + return []byte("null"), nil + } + return []byte(`"` + base64.RawURLEncoding.EncodeToString(data) + `"`), nil +} From acdfab1ccc247150ad1e24fb877c187a312c6fc8 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Fri, 18 Sep 2020 17:45:31 +0200 Subject: [PATCH 12/34] add webauthn ctap2 authenticate --- ctap2token/token.go | 9 + webauthn/base64.go | 32 ++++ webauthn/example/authenticate/main.go | 91 ++++++++++ webauthn/example/{ => register}/main.go | 5 +- webauthn/token.go | 220 ++++++++++++++++-------- 5 files changed, 281 insertions(+), 76 deletions(-) create mode 100644 webauthn/base64.go create mode 100644 webauthn/example/authenticate/main.go rename webauthn/example/{ => register}/main.go (91%) diff --git a/ctap2token/token.go b/ctap2token/token.go index 3d47938..51bac02 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -392,6 +392,15 @@ type CredentialUserEntity struct { Icon string `cbor:"icon,omitempty"` } +func (u *CredentialUserEntity) Bytes() ([]byte, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + return enc.Marshal(u) +} + type AuthData []byte const authDataMinLength = 37 diff --git a/webauthn/base64.go b/webauthn/base64.go new file mode 100644 index 0000000..68482e4 --- /dev/null +++ b/webauthn/base64.go @@ -0,0 +1,32 @@ +package webauthn + +import ( + "bytes" + "encoding/base64" + "reflect" +) + +// URLEncodedBase64 is a custom type used in place of []byte for webauthn, +// as the specification require a json RawURLEncoding instead of the default StdEncoding +// implemented by the json package. +type URLEncodedBase64 []byte + +func (dest *URLEncodedBase64) UnmarshalJSON(data []byte) error { + data = bytes.Trim(data, "\"") + out := make([]byte, base64.RawURLEncoding.DecodedLen(len(data))) + n, err := base64.RawURLEncoding.Decode(out, data) + if err != nil { + return err + } + + v := reflect.ValueOf(dest).Elem() + v.SetBytes(out[:n]) + return nil +} + +func (data URLEncodedBase64) MarshalJSON() ([]byte, error) { + if data == nil { + return []byte("null"), nil + } + return []byte(`"` + base64.RawURLEncoding.EncodeToString(data) + `"`), nil +} diff --git a/webauthn/example/authenticate/main.go b/webauthn/example/authenticate/main.go new file mode 100644 index 0000000..8ed2957 --- /dev/null +++ b/webauthn/example/authenticate/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" + + "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" + "github.com/flynn/u2f/u2fhid" + "github.com/flynn/u2f/webauthn" +) + +func main() { + devices, err := u2fhid.Devices() + if err != nil { + panic(err) + } + + for _, d := range devices { + dev, err := u2fhid.Open(d) + if err != nil { + panic(err) + } + + t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) + if err != nil { + panic(err) + } + + c := &http.Client{} + // localhost:9005 runs a server from https://github.com/duo-labs/webauthn.io + httpResp, err := c.Get("http://localhost:9005/assertion/aaaa?userVer=discouraged&txAuthExtension=") + if err != nil { + panic(err) + } + + d, err := httputil.DumpResponse(httpResp, true) + if err != nil { + panic(err) + } + fmt.Printf("response: %s\n", d) + + if httpResp.StatusCode != 200 { + panic("non 200 server response") + } + + authReq := &webauthn.AuthenticateRequest{} + err = json.NewDecoder(httpResp.Body).Decode(authReq) + if err != nil { + panic(err) + } + + origin := "http://localhost:9005" + fmt.Printf("Webauthn authentication request for %q. Confirm presence on authenticator when it will blink...\n", origin) + authResp, err := t.Authenticate(origin, authReq) + if err != nil { + panic(err) + } + + rd, _ := json.MarshalIndent(authResp, "", " ") + fmt.Printf("%s\n", rd) + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(authResp); err != nil { + panic(err) + } + + httpPostReq, err := http.NewRequest("POST", "http://localhost:9005/assertion", buf) + if err != nil { + panic(err) + } + + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + panic(err) + } + + d, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + panic(err) + } + fmt.Printf("response: %s\n", d) + } +} diff --git a/webauthn/example/main.go b/webauthn/example/register/main.go similarity index 91% rename from webauthn/example/main.go rename to webauthn/example/register/main.go index 06ca092..3352002 100644 --- a/webauthn/example/main.go +++ b/webauthn/example/register/main.go @@ -32,7 +32,7 @@ func main() { c := &http.Client{} // localhost:9005 runs a server from https://github.com/duo-labs/webauthn.io - httpResp, err := c.Get("http://localhost:9005/makeCredential/aaa?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=") + httpResp, err := c.Get("http://localhost:9005/makeCredential/aaaa?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=") if err != nil { panic(err) } @@ -42,6 +42,9 @@ func main() { panic(err) } fmt.Printf("response: %s\n", d) + if httpResp.StatusCode != 200 { + panic("non 200 server response") + } webauthnReq := &webauthn.RegisterRequest{} err = json.NewDecoder(httpResp.Body).Decode(webauthnReq) diff --git a/webauthn/token.go b/webauthn/token.go index eb51e52..2e9b536 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "net/url" - "reflect" "github.com/flynn/u2f/ctap2token" ctap2 "github.com/flynn/u2f/ctap2token" @@ -20,7 +19,7 @@ type WebauthnToken interface { // Register is the equivalent to navigator.credential.create() Register(origin string, req *RegisterRequest) (*RegisterResponse, error) // Authenticate is the equivalent to navigator.credential.get() - Authenticate(req *AuthenticateRequest) (*AuthenticateResponse, error) + Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) } type RegisterRequest struct { @@ -71,30 +70,29 @@ type AttestationResponse struct { type AuthenticateRequest struct { PublicKey struct { - Challenge string `json:"challenge"` + Challenge []byte `json:"challenge"` Timeout int `json:"timeout"` RpID string `json:"rpId"` AllowCredentials []struct { Type string `json:"type"` - ID string `json:"id"` + ID []byte `json:"id"` } `json:"allowCredentials"` - Extensions struct { - TxAuthSimple string `json:"txAuthSimple"` - } `json:"extensions"` + UserVerification string `json:"userVerification"` + Extensions map[string]interface{} `json:"extensions"` } `json:"publicKey"` } type AuthenticateResponse struct { ID string `json:"id"` - RawID []byte `json:"rawId"` + RawID URLEncodedBase64 `json:"rawId"` Type string `json:"type"` Response AssertionResponse `json:"response"` } type AssertionResponse struct { - AuthenticatorData string `json:"authenticatorData"` - ClientDataJSON string `json:"clientDataJSON"` - Signature string `json:"signature"` - UserHandle string `json:"userHandle"` + AuthenticatorData URLEncodedBase64 `json:"authenticatorData"` + ClientDataJSON URLEncodedBase64 `json:"clientDataJSON"` + Signature URLEncodedBase64 `json:"signature"` + UserHandle URLEncodedBase64 `json:"userHandle"` } type ctap2TWebauthnToken struct { @@ -143,7 +141,7 @@ TODO List - handle custom timeout - extensions support - what is collectedClientData.tokenBinding (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding) - - Handle multiple authenticator / multiple transports + - Handle multiple authenticator / multiple transports ? */ var supportedCredentialTypes = map[string]ctap2.CredentialType{ @@ -233,34 +231,9 @@ func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*Re options["rk"] = true } - var pinProtocol ctap2.PinUVAuthProtocolVersion - var pinUVAuth []byte - switch req.PublicKey.AuthenticatorSelection.UserVerification { - case "discouraged": - // Do nothing - case "required": - pinProtocol = ctap2.PinProtoV1 - pinUVAuth, err = w.pinHandler.Execute(clientDataHash) - if err != nil { - return nil, err - } - case "preferred": - infos, err := w.t.GetInfo() - if err != nil { - return nil, err - } - - // Most authenticators seems to set clientPin option to true when the PIN is set - // TODO: validate this is a standard way to do that - if pin, ok := infos.Options["clientPin"]; ok && pin { - pinProtocol = ctap2.PinProtoV1 - pinUVAuth, err = w.pinHandler.Execute(clientDataHash) - if err != nil { - return nil, err - } - } - default: - return nil, fmt.Errorf("unsupported user verification option %q", req.PublicKey.AuthenticatorSelection.UserVerification) + pinUVAuth, pinProtocol, err := w.userVerification(req.PublicKey.AuthenticatorSelection.UserVerification, clientDataHash) + if err != nil { + return nil, err } resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ @@ -335,46 +308,143 @@ func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*Re }, }, nil } -func (w *ctap2TWebauthnToken) Authenticate(req *AuthenticateRequest) (*AuthenticateResponse, error) { - panic("not implemented yet") - return nil, nil -} -func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - panic("not implemented yet") - return nil, nil -} -func (w *ctap1WebauthnToken) Authenticate(req *AuthenticateRequest) (*AuthenticateResponse, error) { - panic("not implemented yet") - return nil, nil -} +func (w *ctap2TWebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain + + // TODO if options.rpId is not a "registrable domain suffix" of and is not equal to effectiveDomain, return error + rpID := req.PublicKey.RpID + + if rpID == "" { + rpID = effectiveDomain + } + + // TODO add support for extensions (bullet point 8 from https://www.w3.org/TR/2020/WD-webauthn-2-20200730/#sctn-discover-from-external-source) + clientExtensions := make(map[string]interface{}) + + clientData := collectedClientData{ + Challenge: base64.RawURLEncoding.EncodeToString(req.PublicKey.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + Type: "webauthn.get", + } -// URLEncodedBase64 represents a byte slice holding URL-encoded base64 data. -// When fields of this type are unmarshaled from JSON, the data is base64 -// decoded into a byte slice. -type URLEncodedBase64 []byte - -// UnmarshalJSON base64 decodes a URL-encoded value, storing the result in the -// provided byte slice. -func (dest *URLEncodedBase64) UnmarshalJSON(data []byte) error { - // Trim the leading spaces - data = bytes.Trim(data, "\"") - out := make([]byte, base64.RawURLEncoding.DecodedLen(len(data))) - n, err := base64.RawURLEncoding.Decode(out, data) + clientDataJSON, err := json.Marshal(clientData) if err != nil { - return err + return nil, err + } + + sha := sha256.New() + if _, err := sha.Write(clientDataJSON); err != nil { + return nil, err } + clientDataHash := sha.Sum(nil) - v := reflect.ValueOf(dest).Elem() - v.SetBytes(out[:n]) - return nil + pinUVAuth, pinProtocol, err := w.userVerification(req.PublicKey.UserVerification, clientDataHash) + if err != nil { + return nil, err + } + + allowList := make([]*ctap2.CredentialDescriptor, 0, len(req.PublicKey.AllowCredentials)) + for _, c := range req.PublicKey.AllowCredentials { + t, ok := supportedCredentialTypes[c.Type] + if !ok { + return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) + } + + allowList = append(allowList, &ctap2.CredentialDescriptor{ + ID: c.ID, + Type: t, + }) + } + + resp, err := w.t.GetAssertion(&ctap2.GetAssertionRequest{ + RPID: rpID, + ClientDataHash: clientDataHash, + PinUVAuth: pinUVAuth, + PinUVAuthProtocol: pinProtocol, + AllowList: allowList, + Extensions: clientExtensions, + }) + if err != nil { + return nil, err + } + + userHandle := []byte{} + if resp.User != nil { + var err error + userHandle, err = resp.User.Bytes() + if err != nil { + return nil, err + } + } + + return &AuthenticateResponse{ + ID: base64.RawURLEncoding.EncodeToString(resp.Credential.ID), + RawID: resp.Credential.ID, + Response: AssertionResponse{ + AuthenticatorData: []byte(resp.AuthData), + Signature: resp.Signature, + ClientDataJSON: clientDataJSON, + UserHandle: userHandle, + }, + Type: "public-key", + }, nil } -// MarshalJSON base64 encodes a non URL-encoded value, storing the result in the -// provided byte slice. -func (data URLEncodedBase64) MarshalJSON() ([]byte, error) { - if data == nil { - return []byte("null"), nil +func (w *ctap2TWebauthnToken) userVerification(uv string, clientDataHash []byte) ([]byte, ctap2.PinUVAuthProtocolVersion, error) { + infos, err := w.t.GetInfo() + if err != nil { + return nil, 0, err } - return []byte(`"` + base64.RawURLEncoding.EncodeToString(data) + `"`), nil + + var pinUVAuth []byte + var pinProtocol ctap2.PinUVAuthProtocolVersion + + if uv == "" { + uv = "preferred" + } + + switch uv { + case "discouraged": + // Do nothing + case "required": + if pin, ok := infos.Options["clientPin"]; !ok || !pin { + return nil, 0, errors.New("webauthn: authenticator does not support user verification") + } + + pinProtocol = ctap2.PinProtoV1 + pinUVAuth, err = w.pinHandler.Execute(clientDataHash) + if err != nil { + return nil, 0, err + } + case "preferred": + // Most authenticators seems to set clientPin option to true when the PIN is set + // TODO: validate this is a standard way to do that + if pin, ok := infos.Options["clientPin"]; ok && pin { + pinProtocol = ctap2.PinProtoV1 + pinUVAuth, err = w.pinHandler.Execute(clientDataHash) + if err != nil { + return nil, 0, err + } + } + default: + return nil, 0, fmt.Errorf("unsupported user verification option %q", uv) + } + + return pinUVAuth, pinProtocol, nil +} + +func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { + panic("not implemented yet") +} +func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { + panic("not implemented yet") } From acab0e9c133099b5e4fe31dd56a7e9707967caf8 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Fri, 18 Sep 2020 18:00:21 +0200 Subject: [PATCH 13/34] examples cleanup --- webauthn/example/authenticate/main.go | 29 ++++- webauthn/example/register/main.go | 155 +++++++++++++++----------- 2 files changed, 111 insertions(+), 73 deletions(-) diff --git a/webauthn/example/authenticate/main.go b/webauthn/example/authenticate/main.go index 8ed2957..d52a9a5 100644 --- a/webauthn/example/authenticate/main.go +++ b/webauthn/example/authenticate/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "flag" "fmt" "net/http" "net/http/httputil" @@ -14,6 +15,23 @@ import ( ) func main() { + var host string + var username string + + flag.StringVar(&username, "u", "", "the username to authenticate with") + flag.StringVar(&host, "s", "https://webauthn.io", "the target webauthn server") + flag.Parse() + + if username == "" { + flag.Usage() + panic("username is required") + } + + if host == "" { + flag.Usage() + panic("host is required") + } + devices, err := u2fhid.Devices() if err != nil { panic(err) @@ -32,7 +50,7 @@ func main() { c := &http.Client{} // localhost:9005 runs a server from https://github.com/duo-labs/webauthn.io - httpResp, err := c.Get("http://localhost:9005/assertion/aaaa?userVer=discouraged&txAuthExtension=") + httpResp, err := c.Get(fmt.Sprintf("%s/assertion/%s?userVer=discouraged&txAuthExtension=", host, username)) if err != nil { panic(err) } @@ -44,7 +62,7 @@ func main() { fmt.Printf("response: %s\n", d) if httpResp.StatusCode != 200 { - panic("non 200 server response") + panic("non 200 server response, maybe register first ?") } authReq := &webauthn.AuthenticateRequest{} @@ -53,9 +71,8 @@ func main() { panic(err) } - origin := "http://localhost:9005" - fmt.Printf("Webauthn authentication request for %q. Confirm presence on authenticator when it will blink...\n", origin) - authResp, err := t.Authenticate(origin, authReq) + fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) + authResp, err := t.Authenticate(host, authReq) if err != nil { panic(err) } @@ -68,7 +85,7 @@ func main() { panic(err) } - httpPostReq, err := http.NewRequest("POST", "http://localhost:9005/assertion", buf) + httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/assertion", host), buf) if err != nil { panic(err) } diff --git a/webauthn/example/register/main.go b/webauthn/example/register/main.go index 3352002..eda7baf 100644 --- a/webauthn/example/register/main.go +++ b/webauthn/example/register/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "flag" "fmt" "net/http" "net/http/httputil" @@ -14,77 +15,97 @@ import ( ) func main() { + var host string + var username string + + flag.StringVar(&username, "u", "", "the username to authenticate with") + flag.StringVar(&host, "s", "https://webauthn.io", "the target webauthn server") + flag.Parse() + + if username == "" { + flag.Usage() + panic("username is required") + } + + if host == "" { + flag.Usage() + panic("host is required") + } + devices, err := u2fhid.Devices() if err != nil { panic(err) } - for _, d := range devices { - dev, err := u2fhid.Open(d) - if err != nil { - panic(err) - } - - t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) - if err != nil { - panic(err) - } - - c := &http.Client{} - // localhost:9005 runs a server from https://github.com/duo-labs/webauthn.io - httpResp, err := c.Get("http://localhost:9005/makeCredential/aaaa?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=") - if err != nil { - panic(err) - } - - d, err := httputil.DumpResponse(httpResp, true) - if err != nil { - panic(err) - } - fmt.Printf("response: %s\n", d) - if httpResp.StatusCode != 200 { - panic("non 200 server response") - } - - webauthnReq := &webauthn.RegisterRequest{} - err = json.NewDecoder(httpResp.Body).Decode(webauthnReq) - if err != nil { - panic(err) - } - - origin := "http://localhost:9005" - fmt.Printf("Webauthn registration request for %q. Confirm presence on authenticator when it will blink...\n", origin) - webauthnResp, err := t.Register(origin, webauthnReq) - if err != nil { - panic(err) - } - - rd, _ := json.MarshalIndent(webauthnResp, "", " ") - fmt.Printf("%s\n", rd) - - buf := bytes.NewBuffer(nil) - if err := json.NewEncoder(buf).Encode(webauthnResp); err != nil { - panic(err) - } - - httpPostReq, err := http.NewRequest("POST", "http://localhost:9005/makeCredential", buf) - if err != nil { - panic(err) - } - - for _, c := range httpResp.Cookies() { - httpPostReq.AddCookie(c) - } - - httpPostResp, err := c.Do(httpPostReq) - if err != nil { - panic(err) - } - - d, err = httputil.DumpResponse(httpPostResp, true) - if err != nil { - panic(err) - } - fmt.Printf("response: %s\n", d) + if len(devices) == 0 { + panic("no HID devices found") + } + + d := devices[0] + + dev, err := u2fhid.Open(d) + if err != nil { + panic(err) + } + + t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) + if err != nil { + panic(err) + } + + c := &http.Client{} + + httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) + if err != nil { + panic(err) + } + + dump, err := httputil.DumpResponse(httpResp, true) + if err != nil { + panic(err) + } + fmt.Printf("response: %s\n", dump) + if httpResp.StatusCode != 200 { + panic("non 200 server response") + } + + webauthnReq := &webauthn.RegisterRequest{} + err = json.NewDecoder(httpResp.Body).Decode(webauthnReq) + if err != nil { + panic(err) + } + + fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) + webauthnResp, err := t.Register(host, webauthnReq) + if err != nil { + panic(err) + } + + rd, _ := json.MarshalIndent(webauthnResp, "", " ") + fmt.Printf("%s\n", rd) + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(webauthnResp); err != nil { + panic(err) + } + + httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/makeCredential", host), buf) + if err != nil { + panic(err) + } + + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + panic(err) + } + + dump, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + panic(err) } + fmt.Printf("response: %s\n", dump) } From 8a1578cfde69845aa632458b77ae61081b904411 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 10:27:44 +0200 Subject: [PATCH 14/34] fix webauthn demos and cleanup encoding issues --- ctap2token/token.go | 20 +- webauthn/base64.go | 32 -- webauthn/ctap1.go | 16 + webauthn/ctap2.go | 305 ++++++++++++++++ webauthn/example/authenticate/main.go | 108 ------ webauthn/example/demo.yubico.com/main.go | 251 +++++++++++++ webauthn/example/register/main.go | 111 ------ webauthn/example/webauthn.io/main.go | 226 ++++++++++++ webauthn/token.go | 428 +---------------------- webauthn/types.go | 114 ++++++ 10 files changed, 919 insertions(+), 692 deletions(-) delete mode 100644 webauthn/base64.go create mode 100644 webauthn/ctap1.go create mode 100644 webauthn/ctap2.go delete mode 100644 webauthn/example/authenticate/main.go create mode 100644 webauthn/example/demo.yubico.com/main.go delete mode 100644 webauthn/example/register/main.go create mode 100644 webauthn/example/webauthn.io/main.go create mode 100644 webauthn/types.go diff --git a/ctap2token/token.go b/ctap2token/token.go index 51bac02..321fa41 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -155,24 +155,6 @@ type MakeCredentialResponse struct { AttSmt map[string]interface{} `cbor:"3,keyasint"` } -func (m *MakeCredentialResponse) AttestationObject() ([]byte, error) { - enc, err := cbor.CTAP2EncOptions().EncMode() - if err != nil { - return nil, err - } - - // For some reasons, webauthn defines the attestationObject - // with string keys, but FIDO2 specs with integer keys. - // TODO checks with various server implementation what they support - // webauthn.io: string keys - att := make(map[string]interface{}) - att["fmt"] = m.Fmt - att["authData"] = m.AuthData - att["attSmt"] = m.AttSmt - - return enc.Marshal(att) -} - func (t *Token) MakeCredential(req *MakeCredentialRequest) (*MakeCredentialResponse, error) { enc, err := cbor.CTAP2EncOptions().EncMode() if err != nil { @@ -407,7 +389,7 @@ const authDataMinLength = 37 func (a AuthData) Parse() (*ParsedAuthData, error) { if len(a) < authDataMinLength { - return nil, errors.New("ctap2token: invalid authData") + return nil, fmt.Errorf("ctap2token: authData too short, got %d bytes, want at least %d", len(a), authDataMinLength) } out := &ParsedAuthData{ diff --git a/webauthn/base64.go b/webauthn/base64.go deleted file mode 100644 index 68482e4..0000000 --- a/webauthn/base64.go +++ /dev/null @@ -1,32 +0,0 @@ -package webauthn - -import ( - "bytes" - "encoding/base64" - "reflect" -) - -// URLEncodedBase64 is a custom type used in place of []byte for webauthn, -// as the specification require a json RawURLEncoding instead of the default StdEncoding -// implemented by the json package. -type URLEncodedBase64 []byte - -func (dest *URLEncodedBase64) UnmarshalJSON(data []byte) error { - data = bytes.Trim(data, "\"") - out := make([]byte, base64.RawURLEncoding.DecodedLen(len(data))) - n, err := base64.RawURLEncoding.Decode(out, data) - if err != nil { - return err - } - - v := reflect.ValueOf(dest).Elem() - v.SetBytes(out[:n]) - return nil -} - -func (data URLEncodedBase64) MarshalJSON() ([]byte, error) { - if data == nil { - return []byte("null"), nil - } - return []byte(`"` + base64.RawURLEncoding.EncodeToString(data) + `"`), nil -} diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go new file mode 100644 index 0000000..4df0489 --- /dev/null +++ b/webauthn/ctap1.go @@ -0,0 +1,16 @@ +package webauthn + +import ( + "github.com/flynn/u2f/u2ftoken" +) + +type ctap1WebauthnToken struct { + t *u2ftoken.Token +} + +func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { + panic("not implemented yet") +} +func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { + panic("not implemented yet") +} diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go new file mode 100644 index 0000000..2fa99ef --- /dev/null +++ b/webauthn/ctap2.go @@ -0,0 +1,305 @@ +package webauthn + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + + ctap2 "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" +) + +type ctap2TWebauthnToken struct { + t *ctap2.Token + pinHandler pin.PINHandler +} + +func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain + + rpID := req.Rp.ID + if rpID == "" { + rpID = effectiveDomain + } + + credTypesAndPubKeyAlgs := make([]ctap2.CredentialParam, 0, len(req.PubKeyCredParams)) + for _, cp := range req.PubKeyCredParams { + t, ok := supportedCredentialTypes[cp.Type] + if !ok { + continue + } + + credTypesAndPubKeyAlgs = append(credTypesAndPubKeyAlgs, ctap2.CredentialParam{ + Type: t, + Alg: ctap2.Alg(cp.Alg), + }) + } + + if len(credTypesAndPubKeyAlgs) == 0 && len(req.PubKeyCredParams) > 0 { + return nil, errors.New("webauthn: credential parameters not supported") + } + + // TODO add support for extensions (bullet point 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) + clientExtensions := make(map[string]interface{}) + + clientData := collectedClientData{ + Type: "webauthn.create", + Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + } + clientDataJSON, err := json.Marshal(clientData) + if err != nil { + return nil, err + } + + sha := sha256.New() + if _, err := sha.Write(clientDataJSON); err != nil { + return nil, err + } + clientDataHash := sha.Sum(nil) + + excludeList := make([]ctap2.CredentialDescriptor, 0, len(req.ExcludeCredentials)) + for _, c := range req.ExcludeCredentials { + t, ok := supportedCredentialTypes[c.Type] + if !ok { + return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) + } + + transports := make([]ctap2.AuthenticatorTransport, 0, len(c.Transports)) + for _, transport := range c.Transports { + ctapTransport, ok := supportedTransports[transport] + if !ok { + return nil, fmt.Errorf("webauthn: unsupported transport type %q", transport) + } + transports = append(transports, ctapTransport) + } + + excludeList = append(excludeList, ctap2.CredentialDescriptor{ + ID: c.ID, + Transports: transports, + Type: t, + }) + } + + options := make(ctap2.AuthenticatorOptions) + if req.AuthenticatorSelection.RequireResidentKey { + options["rk"] = true + } + + pinUVAuth, pinProtocol, err := w.userVerification(req.AuthenticatorSelection.UserVerification, clientDataHash) + if err != nil { + return nil, err + } + + resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ + ClientDataHash: clientDataHash, + RP: ctap2.CredentialRpEntity{ + ID: rpID, + Name: req.Rp.Name, + Icon: req.Rp.Icon, + }, + User: ctap2.CredentialUserEntity{ + ID: req.User.ID, + Icon: req.User.Icon, + Name: req.User.Name, + DisplayName: req.User.DisplayName, + }, + PubKeyCredParams: credTypesAndPubKeyAlgs, + ExcludeList: excludeList, + Extensions: clientExtensions, + Options: options, + PinUVAuth: pinUVAuth, + PinUVAuthProtocol: pinProtocol, + }) + if err != nil { + return nil, err + } + + authData, err := resp.AuthData.Parse() + if err != nil { + return nil, err + } + + switch req.Attestation { + case "none": + isEmptyAAGUID := bytes.Equal(authData.AttestedCredentialData.AAGUID, emptyAAGUID) + _, x5c := resp.AttSmt["x5c"] + _, ecdaaKeyId := resp.AttSmt["ecdaaKeyId"] + if resp.Fmt == "packed" && isEmptyAAGUID && !x5c && !ecdaaKeyId { + break // self attestation is being used and no further action is needed. + } + + authData.AttestedCredentialData.AAGUID = emptyAAGUID + d, err := authData.Bytes() + if err != nil { + return nil, err + } + + resp = &ctap2.MakeCredentialResponse{ + Fmt: "none", + AuthData: d, + AttSmt: make(map[string]interface{}), + } + case "indirect": + // TODO + case "direct": + // Do nothing + default: + return nil, fmt.Errorf("unsupported attestation mode %q", req.Attestation) + } + + return &RegisterResponse{ + ID: authData.AttestedCredentialData.CredentialID, + Response: AttestationResponse{ + ClientDataJSON: clientDataJSON, + AttestationObject: AttestationObject{ + Fmt: resp.Fmt, + AuthData: resp.AuthData, + AttSmt: resp.AttSmt, + }, + }, + }, nil +} + +func (w *ctap2TWebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain + + // TODO if options.rpId is not a "registrable domain suffix" of and is not equal to effectiveDomain, return error + rpID := req.RpID + + if rpID == "" { + rpID = effectiveDomain + } + + // TODO add support for extensions (bullet point 8 from https://www.w3.org/TR/2020/WD-webauthn-2-20200730/#sctn-discover-from-external-source) + clientExtensions := make(map[string]interface{}) + + clientData := collectedClientData{ + Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + Type: "webauthn.get", + } + + clientDataJSON, err := json.Marshal(clientData) + if err != nil { + return nil, err + } + + sha := sha256.New() + if _, err := sha.Write(clientDataJSON); err != nil { + return nil, err + } + clientDataHash := sha.Sum(nil) + + pinUVAuth, pinProtocol, err := w.userVerification(req.UserVerification, clientDataHash) + if err != nil { + return nil, err + } + + allowList := make([]*ctap2.CredentialDescriptor, 0, len(req.AllowCredentials)) + for _, c := range req.AllowCredentials { + t, ok := supportedCredentialTypes[c.Type] + if !ok { + return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) + } + + allowList = append(allowList, &ctap2.CredentialDescriptor{ + ID: c.ID, + Type: t, + }) + } + + resp, err := w.t.GetAssertion(&ctap2.GetAssertionRequest{ + RPID: rpID, + ClientDataHash: clientDataHash, + PinUVAuth: pinUVAuth, + PinUVAuthProtocol: pinProtocol, + AllowList: allowList, + Extensions: clientExtensions, + }) + if err != nil { + return nil, err + } + + userHandle := []byte{} + if resp.User != nil { + var err error + userHandle, err = resp.User.Bytes() + if err != nil { + return nil, err + } + } + + return &AuthenticateResponse{ + ID: resp.Credential.ID, + Response: AssertionResponse{ + AuthenticatorData: resp.AuthData, + Signature: resp.Signature, + ClientDataJSON: clientDataJSON, + UserHandle: userHandle, + }, + }, nil +} + +func (w *ctap2TWebauthnToken) userVerification(uv string, clientDataHash []byte) ([]byte, ctap2.PinUVAuthProtocolVersion, error) { + infos, err := w.t.GetInfo() + if err != nil { + return nil, 0, err + } + + var pinUVAuth []byte + var pinProtocol ctap2.PinUVAuthProtocolVersion + + if uv == "" { + uv = "preferred" + } + + switch uv { + case "discouraged": + // Do nothing + case "required": + if pin, ok := infos.Options["clientPin"]; !ok || !pin { + return nil, 0, errors.New("webauthn: authenticator does not support user verification") + } + + pinProtocol = ctap2.PinProtoV1 + pinUVAuth, err = w.pinHandler.Execute(clientDataHash) + if err != nil { + return nil, 0, err + } + case "preferred": + // Most authenticators seems to set clientPin option to true when the PIN is set + // TODO: validate this is a standard way to do that + if pin, ok := infos.Options["clientPin"]; ok && pin { + pinProtocol = ctap2.PinProtoV1 + pinUVAuth, err = w.pinHandler.Execute(clientDataHash) + if err != nil { + return nil, 0, err + } + } + default: + return nil, 0, fmt.Errorf("unsupported user verification option %q", uv) + } + + return pinUVAuth, pinProtocol, nil +} diff --git a/webauthn/example/authenticate/main.go b/webauthn/example/authenticate/main.go deleted file mode 100644 index d52a9a5..0000000 --- a/webauthn/example/authenticate/main.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "net/http" - "net/http/httputil" - - "github.com/flynn/u2f/ctap2token" - "github.com/flynn/u2f/ctap2token/pin" - "github.com/flynn/u2f/u2fhid" - "github.com/flynn/u2f/webauthn" -) - -func main() { - var host string - var username string - - flag.StringVar(&username, "u", "", "the username to authenticate with") - flag.StringVar(&host, "s", "https://webauthn.io", "the target webauthn server") - flag.Parse() - - if username == "" { - flag.Usage() - panic("username is required") - } - - if host == "" { - flag.Usage() - panic("host is required") - } - - devices, err := u2fhid.Devices() - if err != nil { - panic(err) - } - - for _, d := range devices { - dev, err := u2fhid.Open(d) - if err != nil { - panic(err) - } - - t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) - if err != nil { - panic(err) - } - - c := &http.Client{} - // localhost:9005 runs a server from https://github.com/duo-labs/webauthn.io - httpResp, err := c.Get(fmt.Sprintf("%s/assertion/%s?userVer=discouraged&txAuthExtension=", host, username)) - if err != nil { - panic(err) - } - - d, err := httputil.DumpResponse(httpResp, true) - if err != nil { - panic(err) - } - fmt.Printf("response: %s\n", d) - - if httpResp.StatusCode != 200 { - panic("non 200 server response, maybe register first ?") - } - - authReq := &webauthn.AuthenticateRequest{} - err = json.NewDecoder(httpResp.Body).Decode(authReq) - if err != nil { - panic(err) - } - - fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) - authResp, err := t.Authenticate(host, authReq) - if err != nil { - panic(err) - } - - rd, _ := json.MarshalIndent(authResp, "", " ") - fmt.Printf("%s\n", rd) - - buf := bytes.NewBuffer(nil) - if err := json.NewEncoder(buf).Encode(authResp); err != nil { - panic(err) - } - - httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/assertion", host), buf) - if err != nil { - panic(err) - } - - for _, c := range httpResp.Cookies() { - httpPostReq.AddCookie(c) - } - - httpPostResp, err := c.Do(httpPostReq) - if err != nil { - panic(err) - } - - d, err = httputil.DumpResponse(httpPostResp, true) - if err != nil { - panic(err) - } - fmt.Printf("response: %s\n", d) - } -} diff --git a/webauthn/example/demo.yubico.com/main.go b/webauthn/example/demo.yubico.com/main.go new file mode 100644 index 0000000..4100d3c --- /dev/null +++ b/webauthn/example/demo.yubico.com/main.go @@ -0,0 +1,251 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "net/http/httputil" + "os" + + "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" + "github.com/flynn/u2f/u2fhid" + "github.com/flynn/u2f/webauthn" +) + +func main() { + var action string + var session string + flag.StringVar(&action, "a", "", "the webauthn action (authenticate or register)") + flag.StringVar(&session, "s", "", "the session cookie (given by register, provided to authenticate") + flag.Parse() + + if action == "" { + flag.Usage() + fmt.Println("-a is required") + os.Exit(1) + } + + host := "https://demo.yubico.com" + + devices, err := u2fhid.Devices() + if err != nil { + panic(err) + } + + if len(devices) == 0 { + panic("no HID devices found") + } + + d := devices[0] + + dev, err := u2fhid.Open(d) + if err != nil { + panic(err) + } + + t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) + if err != nil { + panic(err) + } + + switch action { + case "register": + err = register(t, host) + case "authenticate": + if session == "" { + flag.Usage() + fmt.Println("-s is required") + os.Exit(1) + } + + err = authenticate(t, host, session) + default: + panic(fmt.Sprintf("invalid action: %s", action)) + } + + if err != nil { + panic(err) + } +} + +func register(t webauthn.Token, host string) error { + c := &http.Client{} + reqBody := bytes.NewBuffer([]byte(`{"userVerification":"preferred"}`)) + httpResp, err := c.Post(fmt.Sprintf("%s/api/v1/simple/webauthn/register-begin", host), "application/json", reqBody) + if err != nil { + return err + } + + d, err := httputil.DumpResponse(httpResp, true) + if err != nil { + return err + } + fmt.Printf("response: %s\n", d) + + if httpResp.StatusCode != 200 { + return errors.New("non 200 server response") + } + + respData := &struct { + Data struct { + PublicKey *webauthn.RegisterRequest `json:"publicKey"` + DisplayName string `json:"displayName"` + Icon string `json:"icon"` + RequestID string `json:"requestId"` + Username string `json:"username"` + } `json:"data"` + }{} + + err = json.NewDecoder(httpResp.Body).Decode(respData) + if err != nil { + return err + } + + fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) + webauthnResp, err := t.Register(host, respData.Data.PublicKey) + if err != nil { + return err + } + + rd, _ := json.MarshalIndent(webauthnResp, "", " ") + fmt.Printf("authenticator response: %s\n", rd) + + attObjBytes, err := webauthnResp.Response.AttestationObject.CBOREncode(true) + if err != nil { + return err + } + + registerHttpResponse := map[string]interface{}{ + "requestId": respData.Data.RequestID, + "username": respData.Data.Username, + "displayName": respData.Data.DisplayName, + "attestation": map[string]interface{}{ + "attestationObject": attObjBytes, + "clientDataJSON": webauthnResp.Response.ClientDataJSON, + }, + } + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(registerHttpResponse); err != nil { + return err + } + + httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/simple/webauthn/register-finish", host), buf) + if err != nil { + return err + } + + httpPostReq.Header.Add("Content-Type", "application/json") + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + return err + } + + d, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + return err + } + + fmt.Printf("response: %s\n", d) + fmt.Printf("session cookie value:\n%s\n", httpPostResp.Cookies()[0].Value) + return nil +} + +func authenticate(t webauthn.Token, host, session string) error { + c := &http.Client{} + reqBody := bytes.NewBuffer([]byte(`{"userVerification":"preferred"}`)) + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/simple/webauthn/authenticate-begin", host), reqBody) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{ + Name: "demo_website_session", + Value: session, + }) + + httpResp, err := c.Do(req) + if err != nil { + panic(err) + } + + d, err := httputil.DumpResponse(httpResp, true) + if err != nil { + panic(err) + } + fmt.Printf("response: %s\n", d) + + if httpResp.StatusCode != 200 { + panic("non 200 server response, maybe register first ?") + } + + respData := &struct { + Data struct { + PublicKey *webauthn.AuthenticateRequest `json:"publicKey"` + RequestID string `json:"requestId"` + Username string `json:"username"` + } `json:"data"` + }{} + + err = json.NewDecoder(httpResp.Body).Decode(respData) + if err != nil { + panic(err) + } + + fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) + webauthnResp, err := t.Authenticate(host, respData.Data.PublicKey) + if err != nil { + panic(err) + } + + rd, _ := json.MarshalIndent(webauthnResp, "", " ") + fmt.Printf("authenticator response: %s\n", rd) + + registerHttpResponse := map[string]interface{}{ + "requestId": respData.Data.RequestID, + "assertion": map[string]interface{}{ + "authenticatorData": webauthnResp.Response.AuthenticatorData, + "clientDataJSON": webauthnResp.Response.ClientDataJSON, + "credentialId": webauthnResp.ID, + "signature": webauthnResp.Response.Signature, + }, + } + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(registerHttpResponse); err != nil { + panic(err) + } + + httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/simple/webauthn/authenticate-finish", host), buf) + if err != nil { + panic(err) + } + + httpPostReq.Header.Add("Content-Type", "application/json") + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + panic(err) + } + + d, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + panic(err) + } + + fmt.Printf("response: %s\n", d) + return nil +} diff --git a/webauthn/example/register/main.go b/webauthn/example/register/main.go deleted file mode 100644 index eda7baf..0000000 --- a/webauthn/example/register/main.go +++ /dev/null @@ -1,111 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "net/http" - "net/http/httputil" - - "github.com/flynn/u2f/ctap2token" - "github.com/flynn/u2f/ctap2token/pin" - "github.com/flynn/u2f/u2fhid" - "github.com/flynn/u2f/webauthn" -) - -func main() { - var host string - var username string - - flag.StringVar(&username, "u", "", "the username to authenticate with") - flag.StringVar(&host, "s", "https://webauthn.io", "the target webauthn server") - flag.Parse() - - if username == "" { - flag.Usage() - panic("username is required") - } - - if host == "" { - flag.Usage() - panic("host is required") - } - - devices, err := u2fhid.Devices() - if err != nil { - panic(err) - } - - if len(devices) == 0 { - panic("no HID devices found") - } - - d := devices[0] - - dev, err := u2fhid.Open(d) - if err != nil { - panic(err) - } - - t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) - if err != nil { - panic(err) - } - - c := &http.Client{} - - httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) - if err != nil { - panic(err) - } - - dump, err := httputil.DumpResponse(httpResp, true) - if err != nil { - panic(err) - } - fmt.Printf("response: %s\n", dump) - if httpResp.StatusCode != 200 { - panic("non 200 server response") - } - - webauthnReq := &webauthn.RegisterRequest{} - err = json.NewDecoder(httpResp.Body).Decode(webauthnReq) - if err != nil { - panic(err) - } - - fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) - webauthnResp, err := t.Register(host, webauthnReq) - if err != nil { - panic(err) - } - - rd, _ := json.MarshalIndent(webauthnResp, "", " ") - fmt.Printf("%s\n", rd) - - buf := bytes.NewBuffer(nil) - if err := json.NewEncoder(buf).Encode(webauthnResp); err != nil { - panic(err) - } - - httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/makeCredential", host), buf) - if err != nil { - panic(err) - } - - for _, c := range httpResp.Cookies() { - httpPostReq.AddCookie(c) - } - - httpPostResp, err := c.Do(httpPostReq) - if err != nil { - panic(err) - } - - dump, err = httputil.DumpResponse(httpPostResp, true) - if err != nil { - panic(err) - } - fmt.Printf("response: %s\n", dump) -} diff --git a/webauthn/example/webauthn.io/main.go b/webauthn/example/webauthn.io/main.go new file mode 100644 index 0000000..dfd0e99 --- /dev/null +++ b/webauthn/example/webauthn.io/main.go @@ -0,0 +1,226 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "net/http/httputil" + "os" + + "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" + "github.com/flynn/u2f/u2fhid" + "github.com/flynn/u2f/webauthn" +) + +func main() { + var username string + var action string + flag.StringVar(&username, "u", "", "the username to authenticate with") + flag.StringVar(&action, "a", "", "the webauthn action (authenticate or register)") + flag.Parse() + + if username == "" { + flag.Usage() + fmt.Println("-u is required") + os.Exit(1) + } + if action == "" { + flag.Usage() + fmt.Println("-a is required") + os.Exit(1) + } + + host := "https://webauthn.io" + + devices, err := u2fhid.Devices() + if err != nil { + panic(err) + } + + if len(devices) == 0 { + panic("no HID devices found") + } + + d := devices[0] + + dev, err := u2fhid.Open(d) + if err != nil { + panic(err) + } + + t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) + if err != nil { + panic(err) + } + + switch action { + case "register": + err = register(t, username, host) + case "authenticate": + err = authenticate(t, username, host) + default: + panic(fmt.Sprintf("invalid action: %s", action)) + } + + if err != nil { + panic(err) + } +} + +func register(t webauthn.Token, username, host string) error { + c := &http.Client{} + + httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) + if err != nil { + return err + } + + dump, err := httputil.DumpResponse(httpResp, true) + if err != nil { + return err + } + fmt.Printf("response: %s\n", dump) + if httpResp.StatusCode != 200 { + return errors.New("non 200 server response") + } + + webauthnReq := &struct { + PublicKey *webauthn.RegisterRequest `json:"publicKey"` + }{} + + err = json.NewDecoder(httpResp.Body).Decode(webauthnReq) + if err != nil { + return err + } + + fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) + webauthnResp, err := t.Register(host, webauthnReq.PublicKey) + if err != nil { + return err + } + + rd, _ := json.MarshalIndent(webauthnResp, "", " ") + fmt.Printf("authenticator response: %s\n", rd) + + attObjBytes, err := webauthnResp.Response.AttestationObject.CBOREncode(false) + if err != nil { + return err + } + + registerHttpResponse := map[string]interface{}{ + "id": base64.RawURLEncoding.EncodeToString(webauthnResp.ID), + "rawId": base64.RawURLEncoding.EncodeToString(webauthnResp.ID), + "type": "public-key", + "response": map[string]interface{}{ + "attestationObject": base64.RawURLEncoding.EncodeToString(attObjBytes), + "clientDataJSON": base64.RawURLEncoding.EncodeToString(webauthnResp.Response.ClientDataJSON), + }, + } + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(registerHttpResponse); err != nil { + return err + } + + httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/makeCredential", host), buf) + if err != nil { + return err + } + + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + return err + } + + dump, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + return err + } + fmt.Printf("response: %s\n", dump) + + return nil +} + +func authenticate(t webauthn.Token, username, host string) error { + c := &http.Client{} + httpResp, err := c.Get(fmt.Sprintf("%s/assertion/%s?userVer=discouraged&txAuthExtension=", host, username)) + if err != nil { + return err + } + + d, err := httputil.DumpResponse(httpResp, true) + if err != nil { + return err + } + fmt.Printf("response: %s\n", d) + + if httpResp.StatusCode != 200 { + return errors.New("non 200 server response, maybe register first ?") + } + + authReq := &struct { + PublicKey *webauthn.AuthenticateRequest `json:"publicKey"` + }{} + + err = json.NewDecoder(httpResp.Body).Decode(authReq) + if err != nil { + return err + } + + fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) + webauthnResp, err := t.Authenticate(host, authReq.PublicKey) + if err != nil { + return err + } + + rd, _ := json.MarshalIndent(webauthnResp, "", " ") + fmt.Printf("authenticator response: %s\n", rd) + + httpAuthResp := map[string]interface{}{ + "id": base64.RawURLEncoding.EncodeToString(webauthnResp.ID), + "rawId": base64.RawURLEncoding.EncodeToString(webauthnResp.ID), + "type": "public-key", + "response": map[string]interface{}{ + "authenticatorData": base64.RawURLEncoding.EncodeToString(webauthnResp.Response.AuthenticatorData), + "signature": base64.RawURLEncoding.EncodeToString(webauthnResp.Response.Signature), + "userHandle": base64.RawURLEncoding.EncodeToString(webauthnResp.Response.UserHandle), + "clientDataJSON": base64.RawURLEncoding.EncodeToString(webauthnResp.Response.ClientDataJSON), + }, + } + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(httpAuthResp); err != nil { + return err + } + + httpPostReq, err := http.NewRequest("POST", fmt.Sprintf("%s/assertion", host), buf) + if err != nil { + return err + } + + for _, c := range httpResp.Cookies() { + httpPostReq.AddCookie(c) + } + + httpPostResp, err := c.Do(httpPostReq) + if err != nil { + return err + } + + d, err = httputil.DumpResponse(httpPostResp, true) + if err != nil { + return err + } + + fmt.Printf("response: %s\n", d) + return nil +} diff --git a/webauthn/token.go b/webauthn/token.go index 2e9b536..59f78d0 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -1,127 +1,26 @@ package webauthn import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/url" - - "github.com/flynn/u2f/ctap2token" ctap2 "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" "github.com/flynn/u2f/u2ftoken" ) -type WebauthnToken interface { - // Register is the equivalent to navigator.credential.create() - Register(origin string, req *RegisterRequest) (*RegisterResponse, error) - // Authenticate is the equivalent to navigator.credential.get() - Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) -} - -type RegisterRequest struct { - PublicKey struct { - Challenge []byte `json:"challenge"` - Rp struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - } `json:"rp"` - User struct { - ID []byte `json:"id"` - DisplayName string `json:"displayName"` - Name string `json:"name"` - Icon string `json:"icon"` - } `json:"user"` - PubKeyCredParams []struct { - Type string `json:"type"` - Alg int `json:"alg"` - } `json:"pubKeyCredParams"` - ExcludeCredentials []struct { - Type string `json:"type"` - ID []byte `json:"id"` - Transports []string `json:"transports"` - } `json:"excludeCredentials"` - AuthenticatorSelection struct { - AuthenticatorAttachment string `json:"authenticatorAttachment"` - RequireResidentKey bool `json:"requireResidentKey"` - UserVerification string `json:"userVerification"` - } `json:"authenticatorSelection"` - Timeout int `json:"timeout"` - Extensions map[string]interface{} `json:"extensions"` - Attestation string `json:"attestation"` - } `json:"publicKey"` -} - -type RegisterResponse struct { - ID string `json:"id"` - RawID URLEncodedBase64 `json:"rawId"` - Type string `json:"type"` - Response AttestationResponse `json:"response"` -} - -type AttestationResponse struct { - AttestationObject URLEncodedBase64 `json:"attestationObject"` - ClientDataJSON URLEncodedBase64 `json:"clientDataJSON"` -} - -type AuthenticateRequest struct { - PublicKey struct { - Challenge []byte `json:"challenge"` - Timeout int `json:"timeout"` - RpID string `json:"rpId"` - AllowCredentials []struct { - Type string `json:"type"` - ID []byte `json:"id"` - } `json:"allowCredentials"` - UserVerification string `json:"userVerification"` - Extensions map[string]interface{} `json:"extensions"` - } `json:"publicKey"` -} -type AuthenticateResponse struct { - ID string `json:"id"` - RawID URLEncodedBase64 `json:"rawId"` - Type string `json:"type"` - Response AssertionResponse `json:"response"` -} - -type AssertionResponse struct { - AuthenticatorData URLEncodedBase64 `json:"authenticatorData"` - ClientDataJSON URLEncodedBase64 `json:"clientDataJSON"` - Signature URLEncodedBase64 `json:"signature"` - UserHandle URLEncodedBase64 `json:"userHandle"` -} - -type ctap2TWebauthnToken struct { - t *ctap2.Token - pinHandler pin.PINHandler -} - -type ctap1WebauthnToken struct { - t *u2ftoken.Token +var supportedCredentialTypes = map[string]ctap2.CredentialType{ + string(ctap2.PublicKey): ctap2.PublicKey, } - -type Device interface { - ctap2.Device - u2ftoken.Device +var supportedTransports = map[string]ctap2.AuthenticatorTransport{ + string(ctap2.USB): ctap2.USB, } -type collectedClientData struct { - Type string `json:"type"` - Challenge string `json:"challenge"` - Origin string `json:"origin"` - // TODO tokenBinding ? -} +var emptyAAGUID = make([]byte, 16) // NewToken returns a new WebAuthn capable token. // It will first try to communicate with the device using FIDO2 / CTAP2 protocol, // and fallback using U2F / CTAP1 on failure. // A pinHandler is required when using a CTAP2 compatible authenticator with a configured PIN, when requests // require user verification. -func NewToken(d Device, pinHandler pin.PINHandler) (WebauthnToken, error) { +func NewToken(d Device, pinHandler pin.PINHandler) (Token, error) { t := ctap2.NewToken(d) if _, err := t.GetInfo(); err != nil { return &ctap1WebauthnToken{ @@ -133,318 +32,3 @@ func NewToken(d Device, pinHandler pin.PINHandler) (WebauthnToken, error) { pinHandler: pinHandler, }, nil } - -var emptyAAGUID = make([]byte, 16) - -/* -TODO List - - handle custom timeout - - extensions support - - what is collectedClientData.tokenBinding (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding) - - Handle multiple authenticator / multiple transports ? -*/ - -var supportedCredentialTypes = map[string]ctap2.CredentialType{ - string(ctap2.PublicKey): ctap2.PublicKey, -} -var supportedTransports = map[string]ctap2.AuthenticatorTransport{ - string(ctap2.USB): ctap2.USB, -} - -func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - originURL, err := url.Parse(origin) - if err != nil { - return nil, fmt.Errorf("webauthn: invalid origin: %w", err) - } - if originURL.Opaque != "" { - return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) - } - - effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain - - rpID := req.PublicKey.Rp.ID - if rpID == "" { - rpID = effectiveDomain - } - - credTypesAndPubKeyAlgs := make([]ctap2.CredentialParam, 0, len(req.PublicKey.PubKeyCredParams)) - for _, cp := range req.PublicKey.PubKeyCredParams { - t, ok := supportedCredentialTypes[cp.Type] - if !ok { - continue - } - - credTypesAndPubKeyAlgs = append(credTypesAndPubKeyAlgs, ctap2.CredentialParam{ - Type: t, - Alg: ctap2.Alg(cp.Alg), - }) - } - - if len(credTypesAndPubKeyAlgs) == 0 && len(req.PublicKey.PubKeyCredParams) > 0 { - return nil, errors.New("webauthn: credential parameters not supported") - } - - // TODO add support for extensions (bullet point 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) - clientExtensions := make(map[string]interface{}) - - clientData := collectedClientData{ - Type: "webauthn.create", - Challenge: base64.RawURLEncoding.EncodeToString(req.PublicKey.Challenge), - Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), - } - clientDataJSON, err := json.Marshal(clientData) - if err != nil { - return nil, err - } - - sha := sha256.New() - if _, err := sha.Write(clientDataJSON); err != nil { - return nil, err - } - clientDataHash := sha.Sum(nil) - - excludeList := make([]ctap2.CredentialDescriptor, 0, len(req.PublicKey.ExcludeCredentials)) - for _, c := range req.PublicKey.ExcludeCredentials { - t, ok := supportedCredentialTypes[c.Type] - if !ok { - return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) - } - - transports := make([]ctap2.AuthenticatorTransport, 0, len(c.Transports)) - for _, transport := range c.Transports { - ctapTransport, ok := supportedTransports[transport] - if !ok { - return nil, fmt.Errorf("webauthn: unsupported transport type %q", transport) - } - transports = append(transports, ctapTransport) - } - - excludeList = append(excludeList, ctap2.CredentialDescriptor{ - ID: c.ID, - Transports: transports, - Type: t, - }) - } - - options := make(ctap2.AuthenticatorOptions) - if req.PublicKey.AuthenticatorSelection.RequireResidentKey { - options["rk"] = true - } - - pinUVAuth, pinProtocol, err := w.userVerification(req.PublicKey.AuthenticatorSelection.UserVerification, clientDataHash) - if err != nil { - return nil, err - } - - resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ - ClientDataHash: clientDataHash, - RP: ctap2.CredentialRpEntity{ - ID: rpID, - Name: req.PublicKey.Rp.Name, - Icon: req.PublicKey.Rp.Icon, - }, - User: ctap2token.CredentialUserEntity{ - ID: req.PublicKey.User.ID, - Icon: req.PublicKey.User.Icon, - Name: req.PublicKey.User.Name, - DisplayName: req.PublicKey.User.DisplayName, - }, - PubKeyCredParams: credTypesAndPubKeyAlgs, - ExcludeList: excludeList, - Extensions: clientExtensions, - Options: options, - PinUVAuth: pinUVAuth, - PinUVAuthProtocol: pinProtocol, - }) - if err != nil { - return nil, err - } - - authData, err := resp.AuthData.Parse() - if err != nil { - return nil, err - } - - switch req.PublicKey.Attestation { - case "none": - isEmptyAAGUID := bytes.Equal(authData.AttestedCredentialData.AAGUID, emptyAAGUID) - _, x5c := resp.AttSmt["x5c"] - _, ecdaaKeyId := resp.AttSmt["ecdaaKeyId"] - if resp.Fmt == "packed" && isEmptyAAGUID && !x5c && !ecdaaKeyId { - break // self attestation is being used and no further action is needed. - } - - authData.AttestedCredentialData.AAGUID = emptyAAGUID - d, err := authData.Bytes() - if err != nil { - return nil, err - } - - resp = &ctap2.MakeCredentialResponse{ - Fmt: "none", - AuthData: d, - AttSmt: make(map[string]interface{}), - } - case "indirect": - // TODO - case "direct": - // Do nothing - default: - return nil, fmt.Errorf("unsupported attestation mode %q", req.PublicKey.Attestation) - } - - attestationObject, err := resp.AttestationObject() - if err != nil { - return nil, err - } - - return &RegisterResponse{ - ID: base64.RawURLEncoding.EncodeToString(authData.AttestedCredentialData.CredentialID), - RawID: authData.AttestedCredentialData.CredentialID, - Type: "public-key", - Response: AttestationResponse{ - ClientDataJSON: clientDataJSON, - AttestationObject: attestationObject, - }, - }, nil -} - -func (w *ctap2TWebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { - originURL, err := url.Parse(origin) - if err != nil { - return nil, fmt.Errorf("webauthn: invalid origin: %w", err) - } - if originURL.Opaque != "" { - return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) - } - - effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain - - // TODO if options.rpId is not a "registrable domain suffix" of and is not equal to effectiveDomain, return error - rpID := req.PublicKey.RpID - - if rpID == "" { - rpID = effectiveDomain - } - - // TODO add support for extensions (bullet point 8 from https://www.w3.org/TR/2020/WD-webauthn-2-20200730/#sctn-discover-from-external-source) - clientExtensions := make(map[string]interface{}) - - clientData := collectedClientData{ - Challenge: base64.RawURLEncoding.EncodeToString(req.PublicKey.Challenge), - Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), - Type: "webauthn.get", - } - - clientDataJSON, err := json.Marshal(clientData) - if err != nil { - return nil, err - } - - sha := sha256.New() - if _, err := sha.Write(clientDataJSON); err != nil { - return nil, err - } - clientDataHash := sha.Sum(nil) - - pinUVAuth, pinProtocol, err := w.userVerification(req.PublicKey.UserVerification, clientDataHash) - if err != nil { - return nil, err - } - - allowList := make([]*ctap2.CredentialDescriptor, 0, len(req.PublicKey.AllowCredentials)) - for _, c := range req.PublicKey.AllowCredentials { - t, ok := supportedCredentialTypes[c.Type] - if !ok { - return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) - } - - allowList = append(allowList, &ctap2.CredentialDescriptor{ - ID: c.ID, - Type: t, - }) - } - - resp, err := w.t.GetAssertion(&ctap2.GetAssertionRequest{ - RPID: rpID, - ClientDataHash: clientDataHash, - PinUVAuth: pinUVAuth, - PinUVAuthProtocol: pinProtocol, - AllowList: allowList, - Extensions: clientExtensions, - }) - if err != nil { - return nil, err - } - - userHandle := []byte{} - if resp.User != nil { - var err error - userHandle, err = resp.User.Bytes() - if err != nil { - return nil, err - } - } - - return &AuthenticateResponse{ - ID: base64.RawURLEncoding.EncodeToString(resp.Credential.ID), - RawID: resp.Credential.ID, - Response: AssertionResponse{ - AuthenticatorData: []byte(resp.AuthData), - Signature: resp.Signature, - ClientDataJSON: clientDataJSON, - UserHandle: userHandle, - }, - Type: "public-key", - }, nil -} - -func (w *ctap2TWebauthnToken) userVerification(uv string, clientDataHash []byte) ([]byte, ctap2.PinUVAuthProtocolVersion, error) { - infos, err := w.t.GetInfo() - if err != nil { - return nil, 0, err - } - - var pinUVAuth []byte - var pinProtocol ctap2.PinUVAuthProtocolVersion - - if uv == "" { - uv = "preferred" - } - - switch uv { - case "discouraged": - // Do nothing - case "required": - if pin, ok := infos.Options["clientPin"]; !ok || !pin { - return nil, 0, errors.New("webauthn: authenticator does not support user verification") - } - - pinProtocol = ctap2.PinProtoV1 - pinUVAuth, err = w.pinHandler.Execute(clientDataHash) - if err != nil { - return nil, 0, err - } - case "preferred": - // Most authenticators seems to set clientPin option to true when the PIN is set - // TODO: validate this is a standard way to do that - if pin, ok := infos.Options["clientPin"]; ok && pin { - pinProtocol = ctap2.PinProtoV1 - pinUVAuth, err = w.pinHandler.Execute(clientDataHash) - if err != nil { - return nil, 0, err - } - } - default: - return nil, 0, fmt.Errorf("unsupported user verification option %q", uv) - } - - return pinUVAuth, pinProtocol, nil -} - -func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - panic("not implemented yet") -} -func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { - panic("not implemented yet") -} diff --git a/webauthn/types.go b/webauthn/types.go new file mode 100644 index 0000000..883bf82 --- /dev/null +++ b/webauthn/types.go @@ -0,0 +1,114 @@ +package webauthn + +import ( + ctap2 "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/u2ftoken" + "github.com/fxamacker/cbor/v2" +) + +type Token interface { + // Register is the equivalent to navigator.credential.create() + Register(origin string, req *RegisterRequest) (*RegisterResponse, error) + // Authenticate is the equivalent to navigator.credential.get() + Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) +} + +type Device interface { + ctap2.Device + u2ftoken.Device +} + +type RegisterRequest struct { + Challenge []byte `json:"challenge"` + Rp struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + } `json:"rp"` + User struct { + ID []byte `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Icon string `json:"icon"` + } `json:"user"` + PubKeyCredParams []struct { + Type string `json:"type"` + Alg int `json:"alg"` + } `json:"pubKeyCredParams"` + ExcludeCredentials []struct { + Type string `json:"type"` + ID []byte `json:"id"` + Transports []string `json:"transports"` + } `json:"excludeCredentials"` + AuthenticatorSelection struct { + AuthenticatorAttachment string `json:"authenticatorAttachment"` + RequireResidentKey bool `json:"requireResidentKey"` + UserVerification string `json:"userVerification"` + } `json:"authenticatorSelection"` + Timeout int `json:"timeout"` + Extensions map[string]interface{} `json:"extensions"` + Attestation string `json:"attestation"` +} + +type RegisterResponse struct { + ID []byte + Response AttestationResponse +} + +type AttestationResponse struct { + AttestationObject AttestationObject + ClientDataJSON []byte +} + +type AttestationObject struct { + Fmt string `cbor:"1,keyasint"` + AuthData []byte `cbor:"2,keyasint"` + AttSmt map[string]interface{} `cbor:"3,keyasint"` +} + +func (m AttestationObject) CBOREncode(keyAsInt bool) ([]byte, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + if !keyAsInt { + att := make(map[string]interface{}) + att["fmt"] = m.Fmt + att["attSmt"] = m.AttSmt + att["authData"] = m.AuthData + return enc.Marshal(att) + } + + return enc.Marshal(m) +} + +type AuthenticateRequest struct { + Challenge []byte `json:"challenge"` + Timeout int `json:"timeout"` + RpID string `json:"rpId"` + AllowCredentials []struct { + Type string `json:"type"` + ID []byte `json:"id"` + } `json:"allowCredentials"` + UserVerification string `json:"userVerification"` + Extensions map[string]interface{} `json:"extensions"` +} +type AuthenticateResponse struct { + ID []byte + Response AssertionResponse +} + +type AssertionResponse struct { + AuthenticatorData []byte + ClientDataJSON []byte + Signature []byte + UserHandle []byte +} + +type collectedClientData struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + // TODO tokenBinding ? +} From f755187b2d4420b9a0442d0c82719efd904a8d5f Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 11:23:40 +0200 Subject: [PATCH 15/34] u2ftoken: parse register response --- u2ftoken/example/main.go | 29 ++++++++++++++++------------- u2ftoken/token.go | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/u2ftoken/example/main.go b/u2ftoken/example/main.go index 4f844fa..4d6496a 100644 --- a/u2ftoken/example/main.go +++ b/u2ftoken/example/main.go @@ -36,10 +36,14 @@ func main() { challenge := make([]byte, 32) app := make([]byte, 32) - io.ReadFull(rand.Reader, challenge) - io.ReadFull(rand.Reader, app) + if _, err := io.ReadFull(rand.Reader, challenge); err != nil { + log.Fatal(err) + } + if _, err := io.ReadFull(rand.Reader, app); err != nil { + log.Fatal(err) + } - var res []byte + var res *u2ftoken.RegisterResponse log.Println("registering, provide user presence") for { res, err = t.Register(u2ftoken.RegisterRequest{Challenge: challenge, Application: app}) @@ -52,13 +56,6 @@ func main() { break } - log.Printf("registered: %x", res) - res = res[66:] - khLen := int(res[0]) - res = res[1:] - keyHandle := res[:khLen] - log.Printf("key handle: %x", keyHandle) - dev.Close() log.Println("reconnecting to device in 3 seconds...") @@ -75,17 +72,23 @@ func main() { } t = u2ftoken.NewToken(dev) - io.ReadFull(rand.Reader, challenge) + if _, err := io.ReadFull(rand.Reader, challenge); err != nil { + log.Fatal(err) + } + req := u2ftoken.AuthenticateRequest{ Challenge: challenge, Application: app, - KeyHandle: keyHandle, + KeyHandle: res.KeyHandle, } if err := t.CheckAuthenticate(req); err != nil { log.Fatal(err) } - io.ReadFull(rand.Reader, challenge) + if _, err := io.ReadFull(rand.Reader, challenge); err != nil { + log.Fatal(err) + } + log.Println("authenticating, provide user presence") for { res, err := t.Authenticate(req) diff --git a/u2ftoken/token.go b/u2ftoken/token.go index 05f8a47..3a9836e 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -3,6 +3,8 @@ package u2ftoken import ( + "crypto/x509" + "encoding/asn1" "encoding/binary" "errors" "fmt" @@ -66,11 +68,18 @@ type RegisterRequest struct { Application []byte } +type RegisterResponse struct { + UserPublicKey []byte + KeyHandle []byte + AttestationCertificate *x509.Certificate + Signature []byte +} + // Register registers an application with the token and returns the raw // registration response message to be passed to the relying party. It returns // ErrPresenceRequired if the call should be retried after proof of user // presence is provided to the token. -func (t *Token) Register(req RegisterRequest) ([]byte, error) { +func (t *Token) Register(req RegisterRequest) (*RegisterResponse, error) { if len(req.Challenge) != 32 { return nil, fmt.Errorf("u2ftoken: Challenge must be exactly 32 bytes") } @@ -96,7 +105,32 @@ func (t *Token) Register(req RegisterRequest) ([]byte, error) { } } - return res.Data, nil + userPubKey := res.Data[1:66] + + khLen := int(res.Data[66]) + keyHandle := res.Data[67 : 67+khLen] + + remaining := res.Data[67+khLen:] + + rawCert := new(asn1.RawValue) + sig, err := asn1.Unmarshal(remaining, rawCert) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(rawCert.FullBytes) + if err != nil { + return nil, err + } + + registerRes := &RegisterResponse{ + UserPublicKey: userPubKey, + KeyHandle: keyHandle, + AttestationCertificate: cert, + Signature: sig, + } + + return registerRes, nil } // An AuthenticateRequires is a message used for authenticating to a relying party From 187936d4ed503eaab3eab0586b55a8b574cfdcb0 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 11:27:42 +0200 Subject: [PATCH 16/34] tidy mod --- go.mod | 3 +++ go.sum | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/go.mod b/go.mod index e9d5d61..e6f2efa 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/flynn/u2f go 1.15 require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/flynn/hid v0.0.0-20190502022136-f1b9b6cc019a github.com/fxamacker/cbor/v2 v2.2.0 + github.com/kr/pretty v0.1.0 // indirect github.com/stretchr/testify v1.6.1 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index d585d68..bf37093 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,16 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -13,5 +20,7 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From c79788dd2f76d45f92edc9c701cefe72ed2f0110 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 16:15:23 +0200 Subject: [PATCH 17/34] webauthn: ctap1 register support --- crypto/cose.go | 75 +++++++++++++ ctap2token/pin/pin.go | 9 +- ctap2token/token.go | 88 +++------------ u2ftoken/token.go | 10 +- webauthn/ctap1.go | 157 ++++++++++++++++++++++++++- webauthn/ctap2.go | 3 +- webauthn/example/webauthn.io/main.go | 2 +- webauthn/types.go | 2 +- 8 files changed, 255 insertions(+), 91 deletions(-) create mode 100644 crypto/cose.go diff --git a/crypto/cose.go b/crypto/cose.go new file mode 100644 index 0000000..37b4017 --- /dev/null +++ b/crypto/cose.go @@ -0,0 +1,75 @@ +package crypto + +import "github.com/fxamacker/cbor/v2" + +// COSEKey, as defined per https://tools.ietf.org/html/rfc8152#section-7.1 +// Only support Elliptic Curve Public keys for now. +// TODO: find a way to support all key types defined in the RFC +type COSEKey struct { + Y []byte `cbor:"-3,keyasint,omitempty"` + X []byte `cbor:"-2,keyasint,omitempty"` + Curve CurveType `cbor:"-1,keyasint,omitempty"` + + KeyType KeyType `cbor:"1,keyasint"` + KeyID []byte `cbor:"2,keyasint,omitempty"` + Alg Alg `cbor:"3,keyasint,omitempty"` + KeyOps []KeyOperation `cbor:"4,keyasint,omitempty"` + BaseIV []byte `cbor:"5,keyasint,omitempty"` +} + +func (k *COSEKey) CBOREncode() ([]byte, error) { + enc, err := cbor.CTAP2EncOptions().EncMode() + if err != nil { + return nil, err + } + + return enc.Marshal(k) +} + +// KeyType defines a key type from https://tools.ietf.org/html/rfc8152#section-13 +type KeyType int + +const ( + // OKP means Octet Key Pair + OKP KeyType = 0x01 + // EC2 means Elliptic Curve Keys + EC2 KeyType = 0x02 +) + +type CurveType int + +const ( + P256 CurveType = 0x01 + P384 CurveType = 0x02 + P521 CurveType = 0x03 + X25519 CurveType = 0x04 + X448 CurveType = 0x05 + Ed25519 CurveType = 0x06 + Ed448 CurveType = 0x07 +) + +type KeyOperation int + +const ( + Sign KeyOperation = iota + 1 + Verify + Encrypt + Decrypt + WrapKey + UnwrapKey + DeriveKey + DeriveBits + MACCreate + MACVerify +) + +// Alg must be the value of one of the algorithms registered on +// https://www.iana.org/assignments/cose/cose.xhtml#algorithms. +type Alg int + +const ( + RS256 Alg = -257 // RSASSA-PKCS1-v1_5 using SHA-256 + PS256 Alg = -37 // RSASSA-PSS w/ SHA-256 + ECDHES_HKDF256 Alg = -25 // ECDH-ES + HKDF-256 + ES256 Alg = -7 // ECDSA w/ SHA-256 +) diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go index 76baba8..f2dfc5c 100644 --- a/ctap2token/pin/pin.go +++ b/ctap2token/pin/pin.go @@ -14,6 +14,7 @@ import ( "os" "strings" + "github.com/flynn/u2f/crypto" "github.com/flynn/u2f/ctap2token" ) @@ -143,12 +144,12 @@ func AESCBCEncrypt(sharedSecret, data []byte) ([]byte, error) { func getPINToken(token *ctap2token.Token, encPinHash []byte, bGX, bGY *big.Int) ([]byte, error) { pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, - KeyAgreement: &ctap2token.COSEKey{ + KeyAgreement: &crypto.COSEKey{ X: bGX.Bytes(), Y: bGY.Bytes(), - KeyType: ctap2token.EC2, - Curve: ctap2token.P256, - Alg: ctap2token.ECDHES_HKDF256, + KeyType: crypto.EC2, + Curve: crypto.P256, + Alg: crypto.ECDHES_HKDF256, }, PinHashEnc: encPinHash, PinProtocol: ctap2token.PinProtoV1, diff --git a/ctap2token/token.go b/ctap2token/token.go index 321fa41..c7a56a5 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/flynn/u2f/crypto" "github.com/fxamacker/cbor/v2" ) @@ -271,18 +272,18 @@ func (t *Token) GetInfo() (*GetInfoResponse, error) { type ClientPINRequest struct { PinProtocol PinUVAuthProtocolVersion `cbor:"1,keyasint"` SubCommand ClientPinSubCommand `cbor:"2,keyasint"` - KeyAgreement *COSEKey `cbor:"3,keyasint,omitempty"` + KeyAgreement *crypto.COSEKey `cbor:"3,keyasint,omitempty"` PinAuth []byte `cbor:"4,keyasint,omitempty"` NewPinEnc []byte `cbor:"5,keyasint,omitempty"` PinHashEnc []byte `cbor:"6,keyasint,omitempty"` } type ClientPINResponse struct { - KeyAgreement *COSEKey `cbor:"1,keyasint,omitempty"` - PinToken []byte `cbor:"2,keyasint,omitempty"` - Retries uint `cbor:"3,keyasint,omitempty"` - PowerCycleState bool `cbor:"4,keyasint,omitempty"` - UVRetries uint `cbor:"5,keyasint,omitempty"` + KeyAgreement *crypto.COSEKey `cbor:"1,keyasint,omitempty"` + PinToken []byte `cbor:"2,keyasint,omitempty"` + Retries uint `cbor:"3,keyasint,omitempty"` + PowerCycleState bool `cbor:"4,keyasint,omitempty"` + UVRetries uint `cbor:"5,keyasint,omitempty"` } func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { @@ -417,7 +418,7 @@ func (a AuthData) Parse() (*ParsedAuthData, error) { // a[55+credIDLen:] may contains the COSEKey + extensions map // but the decoder will only read the key and silently drop extensions data. - out.AttestedCredentialData.CredentialPublicKey = &COSEKey{} + out.AttestedCredentialData.CredentialPublicKey = &crypto.COSEKey{} if err := cbor.Unmarshal(a[55+credIDLen:], out.AttestedCredentialData.CredentialPublicKey); err != nil { return nil, err } @@ -540,18 +541,18 @@ type AuthDataFlag struct { type AttestedCredentialData struct { AAGUID []byte // 16 bytes ID for the authenticator CredentialID []byte - CredentialPublicKey *COSEKey + CredentialPublicKey *crypto.COSEKey } type CredentialParam struct { Type CredentialType `cbor:"type"` - Alg Alg `cbor:"alg"` + Alg crypto.Alg `cbor:"alg"` } var ( - PublicKeyRS256 CredentialParam = CredentialParam{Type: PublicKey, Alg: RS256} - PublicKeyPS256 CredentialParam = CredentialParam{Type: PublicKey, Alg: PS256} - PublicKeyES256 CredentialParam = CredentialParam{Type: PublicKey, Alg: ES256} + PublicKeyRS256 CredentialParam = CredentialParam{Type: PublicKey, Alg: crypto.RS256} + PublicKeyPS256 CredentialParam = CredentialParam{Type: PublicKey, Alg: crypto.PS256} + PublicKeyES256 CredentialParam = CredentialParam{Type: PublicKey, Alg: crypto.ES256} ) // CredentialType defines the type of credential, as defined in https://www.w3.org/TR/webauthn/#credentialType @@ -561,17 +562,6 @@ const ( PublicKey CredentialType = "public-key" ) -// Alg must be the value of one of the algorithms registered on -// https://www.iana.org/assignments/cose/cose.xhtml#algorithms. -type Alg int - -const ( - RS256 Alg = -257 // RSASSA-PKCS1-v1_5 using SHA-256 - PS256 Alg = -37 // RSASSA-PSS w/ SHA-256 - ECDHES_HKDF256 Alg = -25 // ECDH-ES + HKDF-256 - ES256 Alg = -7 // ECDSA w/ SHA-256 -) - // CredentialDescriptor defines a credential returned by the authenticator, // as defined by https://www.w3.org/TR/webauthn/#credential-dictionary type CredentialDescriptor struct { @@ -618,55 +608,3 @@ const ( GetPINUvAuthTokenUsingUv ClientPinSubCommand = 0x06 GetUVRetries ClientPinSubCommand = 0x07 ) - -// COSEKey, as defined per https://tools.ietf.org/html/rfc8152#section-7.1 -// Only support Elliptic Curve Public keys for now. -// TODO: find a way to support all key types defined in the RFC -type COSEKey struct { - Y []byte `cbor:"-3,keyasint,omitempty"` - X []byte `cbor:"-2,keyasint,omitempty"` - Curve CurveType `cbor:"-1,keyasint,omitempty"` - - KeyType KeyType `cbor:"1,keyasint"` - KeyID []byte `cbor:"2,keyasint,omitempty"` - Alg Alg `cbor:"3,keyasint,omitempty"` - KeyOps []KeyOperation `cbor:"4,keyasint,omitempty"` - BaseIV []byte `cbor:"5,keyasint,omitempty"` -} - -// KeyType defines a key type from https://tools.ietf.org/html/rfc8152#section-13 -type KeyType int - -const ( - // OKP means Octet Key Pair - OKP KeyType = 0x01 - // EC2 means Elliptic Curve Keys - EC2 KeyType = 0x02 -) - -type CurveType int - -const ( - P256 CurveType = 0x01 - P384 CurveType = 0x02 - P521 CurveType = 0x03 - X25519 CurveType = 0x04 - X448 CurveType = 0x05 - Ed25519 CurveType = 0x06 - Ed448 CurveType = 0x07 -) - -type KeyOperation int - -const ( - Sign KeyOperation = iota + 1 - Verify - Encrypt - Decrypt - WrapKey - UnwrapKey - DeriveKey - DeriveBits - MACCreate - MACVerify -) diff --git a/u2ftoken/token.go b/u2ftoken/token.go index 3a9836e..d5fe4ad 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -3,7 +3,6 @@ package u2ftoken import ( - "crypto/x509" "encoding/asn1" "encoding/binary" "errors" @@ -71,7 +70,7 @@ type RegisterRequest struct { type RegisterResponse struct { UserPublicKey []byte KeyHandle []byte - AttestationCertificate *x509.Certificate + AttestationCertificate []byte Signature []byte } @@ -118,15 +117,10 @@ func (t *Token) Register(req RegisterRequest) (*RegisterResponse, error) { return nil, err } - cert, err := x509.ParseCertificate(rawCert.FullBytes) - if err != nil { - return nil, err - } - registerRes := &RegisterResponse{ UserPublicKey: userPubKey, KeyHandle: keyHandle, - AttestationCertificate: cert, + AttestationCertificate: rawCert.FullBytes, Signature: sig, } diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index 4df0489..b4158fa 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -1,16 +1,171 @@ package webauthn import ( + "crypto/elliptic" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net/url" + "time" + + "github.com/flynn/u2f/crypto" "github.com/flynn/u2f/u2ftoken" ) +var DefaultRegisterTimeout = 60 + type ctap1WebauthnToken struct { t *u2ftoken.Token } func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - panic("not implemented yet") + // TODO implement spec checks + + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + effectiveDomain := originURL.Hostname() + rpID := req.Rp.ID + if rpID == "" { + rpID = effectiveDomain + } + + useES256 := false + for _, cp := range req.PubKeyCredParams { + if crypto.Alg(cp.Alg) == crypto.ES256 { + useES256 = true + break + } + } + if !useES256 { + return nil, errors.New("webauthn: ctap1 protocol require ES256 algorithm") + } + if req.AuthenticatorSelection.RequireResidentKey { + return nil, errors.New("webauth: ctap1 protocol require rk to be false") + } + if req.AuthenticatorSelection.UserVerification == "required" { + return nil, errors.New("webauth: ctap1 protocol does not support required user verification") + } + + // TODO: If the excludeList is not empty, the platform must send signing request with + // check-only control byte to the CTAP1/U2F authenticator using each of + // the credential ids (key handles) in the excludeList. + // If any of them does not result in an error, that means that this is a known device. + // Afterwards, the platform must still send a dummy registration request (with a dummy appid and invalid challenge) + // to CTAP1/U2F authenticators that it believes are excluded. This makes it so the user still needs to touch + // the CTAP1/U2F authenticator before the RP gets told that the token is already registered. + //for _, exCred := range req.ExcludeCredentials { + //} + + sha := sha256.New() + if _, err := sha.Write([]byte(rpID)); err != nil { + return nil, err + } + rpIDHash := sha.Sum(nil) + + clientData := collectedClientData{ + Type: "webauthn.create", + Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + } + clientDataJSON, err := json.Marshal(clientData) + if err != nil { + return nil, err + } + + sha.Reset() + if _, err := sha.Write(clientDataJSON); err != nil { + return nil, err + } + clientDataHash := sha.Sum(nil) + + if req.Timeout == 0 { + req.Timeout = DefaultRegisterTimeout + } + + resp, err := w.registerWithTimeout(&u2ftoken.RegisterRequest{ + Application: rpIDHash, + Challenge: clientDataHash, + }, time.Duration(req.Timeout)) + if err != nil { + return nil, err + } + + authData := make([]byte, 37) + copy(authData, rpIDHash) + // Let flags be a byte whose zeroth bit (bit 0, UP) is set, + // and whose sixth bit (bit 6, AT) is set, and all other bits + // are zero (bit zero is the least significant bit) + authData[32] = 0x41 + // 4 next bytes are left to 0 + // 16 bytes for AAGUID (all zeros) + 2 bytes for credID len + credID (keyHandle) + 77 bytes COSEKey + attestedCredData := make([]byte, 16, 143+len(resp.KeyHandle)) + + x, y := elliptic.Unmarshal(elliptic.P256(), resp.UserPublicKey) + coseKey := crypto.COSEKey{ + KeyType: crypto.EC2, + Alg: crypto.ES256, + Curve: crypto.P256, + X: x.Bytes(), + Y: y.Bytes(), + } + + coseKeyBytes, err := coseKey.CBOREncode() + if err != nil { + return nil, err + } + + khLen := make([]byte, 2) + binary.BigEndian.PutUint16(khLen, uint16(len(resp.KeyHandle))) + attestedCredData = append(attestedCredData, khLen...) + attestedCredData = append(attestedCredData, resp.KeyHandle...) + attestedCredData = append(attestedCredData, coseKeyBytes...) + + authData = append(authData, attestedCredData...) + + return &RegisterResponse{ + ID: resp.KeyHandle, + Response: AttestationResponse{ + AttestationObject: AttestationObject{ + Fmt: "fido-u2f", + AttSmt: map[string]interface{}{ + "sig": resp.Signature, + "x5c": []interface{}{resp.AttestationCertificate}, + }, + AuthData: authData, + }, + ClientDataJSON: clientDataJSON, + }, + }, nil } + func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { panic("not implemented yet") } + +func (w *ctap1WebauthnToken) registerWithTimeout(req *u2ftoken.RegisterRequest, timeout time.Duration) (*u2ftoken.RegisterResponse, error) { + for { + select { + case <-time.After(timeout * time.Second): + return nil, u2ftoken.ErrPresenceRequired + default: + resp, err := w.t.Register(*req) + if err != nil { + if err != u2ftoken.ErrPresenceRequired { + return nil, err + } + time.Sleep(200 * time.Millisecond) + } else { + return resp, nil + } + } + } +} diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index 2fa99ef..5357f65 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -9,6 +9,7 @@ import ( "fmt" "net/url" + "github.com/flynn/u2f/crypto" ctap2 "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" ) @@ -43,7 +44,7 @@ func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*Re credTypesAndPubKeyAlgs = append(credTypesAndPubKeyAlgs, ctap2.CredentialParam{ Type: t, - Alg: ctap2.Alg(cp.Alg), + Alg: crypto.Alg(cp.Alg), }) } diff --git a/webauthn/example/webauthn.io/main.go b/webauthn/example/webauthn.io/main.go index dfd0e99..70725fb 100644 --- a/webauthn/example/webauthn.io/main.go +++ b/webauthn/example/webauthn.io/main.go @@ -75,7 +75,7 @@ func main() { func register(t webauthn.Token, username, host string) error { c := &http.Client{} - httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=none&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) + httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=direct&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) if err != nil { return err } diff --git a/webauthn/types.go b/webauthn/types.go index 883bf82..fbff266 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -75,7 +75,7 @@ func (m AttestationObject) CBOREncode(keyAsInt bool) ([]byte, error) { if !keyAsInt { att := make(map[string]interface{}) att["fmt"] = m.Fmt - att["attSmt"] = m.AttSmt + att["attStmt"] = m.AttSmt att["authData"] = m.AuthData return enc.Marshal(att) } From e374739c4979269de033973250143cd5745db597 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 17:02:33 +0200 Subject: [PATCH 18/34] webauthn: ctap1 authenticate support --- webauthn/ctap1.go | 112 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index b4158fa..5a8eccf 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -15,15 +15,13 @@ import ( "github.com/flynn/u2f/u2ftoken" ) -var DefaultRegisterTimeout = 60 +var DefaultCTAP1Timeout = 60 type ctap1WebauthnToken struct { t *u2ftoken.Token } func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - // TODO implement spec checks - originURL, err := url.Parse(origin) if err != nil { return nil, fmt.Errorf("webauthn: invalid origin: %w", err) @@ -88,7 +86,7 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg clientDataHash := sha.Sum(nil) if req.Timeout == 0 { - req.Timeout = DefaultRegisterTimeout + req.Timeout = DefaultCTAP1Timeout } resp, err := w.registerWithTimeout(&u2ftoken.RegisterRequest{ @@ -148,7 +146,92 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg } func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { - panic("not implemented yet") + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + effectiveDomain := originURL.Hostname() + rpID := req.RpID + if rpID == "" { + rpID = effectiveDomain + } + + if len(req.AllowCredentials) == 0 { + return nil, errors.New("webauthn: ctap1 require at least one credential") + } + if req.UserVerification == "required" { + return nil, errors.New("webauthn: ctap1 does not support user verification") + } + + if req.Timeout == 0 { + req.Timeout = DefaultCTAP1Timeout + } + + sha := sha256.New() + if _, err := sha.Write([]byte(rpID)); err != nil { + return nil, err + } + rpIDHash := sha.Sum(nil) + + clientData := collectedClientData{ + Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + Type: "webauthn.get", + } + + clientDataJSON, err := json.Marshal(clientData) + if err != nil { + return nil, err + } + + sha.Reset() + if _, err := sha.Write(clientDataJSON); err != nil { + return nil, err + } + clientDataHash := sha.Sum(nil) + + var authReq *u2ftoken.AuthenticateRequest + if len(req.AllowCredentials) > 1 { + for _, cred := range req.AllowCredentials { + authReq = &u2ftoken.AuthenticateRequest{ + Challenge: clientDataHash, + Application: rpIDHash, + KeyHandle: cred.ID, + } + if err := w.t.CheckAuthenticate(*authReq); err == nil { + break + } + } + } else { + authReq = &u2ftoken.AuthenticateRequest{ + Challenge: clientDataHash, + Application: rpIDHash, + KeyHandle: req.AllowCredentials[0].ID, + } + } + + authResp, err := w.authenticateWithTimeout(authReq, time.Duration(req.Timeout)) + if err != nil { + return nil, err + } + + authData := make([]byte, 37) + copy(authData, rpIDHash) + authData[32] = authResp.RawResponse[0] + binary.BigEndian.PutUint32(authData[33:], authResp.Counter) + + return &AuthenticateResponse{ + ID: authReq.KeyHandle, + Response: AssertionResponse{ + AuthenticatorData: authData, + Signature: authResp.Signature, + ClientDataJSON: clientDataJSON, + }, + }, nil } func (w *ctap1WebauthnToken) registerWithTimeout(req *u2ftoken.RegisterRequest, timeout time.Duration) (*u2ftoken.RegisterResponse, error) { @@ -169,3 +252,22 @@ func (w *ctap1WebauthnToken) registerWithTimeout(req *u2ftoken.RegisterRequest, } } } + +func (w *ctap1WebauthnToken) authenticateWithTimeout(req *u2ftoken.AuthenticateRequest, timeout time.Duration) (*u2ftoken.AuthenticateResponse, error) { + for { + select { + case <-time.After(timeout * time.Second): + return nil, u2ftoken.ErrPresenceRequired + default: + resp, err := w.t.Authenticate(*req) + if err != nil { + if err != u2ftoken.ErrPresenceRequired { + return nil, err + } + time.Sleep(200 * time.Millisecond) + } else { + return resp, nil + } + } + } +} From b53d4e086668727f7e1beb49a768b71055186b0e Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 17:41:32 +0200 Subject: [PATCH 19/34] webauthn: ctap1 handle excluded credentials --- webauthn/ctap1.go | 35 +++++++++++++++++++++++++---------- webauthn/types.go | 28 ++++++++++++++++------------ 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index 5a8eccf..a62e694 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -53,16 +53,6 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg return nil, errors.New("webauth: ctap1 protocol does not support required user verification") } - // TODO: If the excludeList is not empty, the platform must send signing request with - // check-only control byte to the CTAP1/U2F authenticator using each of - // the credential ids (key handles) in the excludeList. - // If any of them does not result in an error, that means that this is a known device. - // Afterwards, the platform must still send a dummy registration request (with a dummy appid and invalid challenge) - // to CTAP1/U2F authenticators that it believes are excluded. This makes it so the user still needs to touch - // the CTAP1/U2F authenticator before the RP gets told that the token is already registered. - //for _, exCred := range req.ExcludeCredentials { - //} - sha := sha256.New() if _, err := sha.Write([]byte(rpID)); err != nil { return nil, err @@ -85,6 +75,27 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg } clientDataHash := sha.Sum(nil) + // If the excludeList is not empty, the platform must send signing request with + // check-only control byte to the CTAP1/U2F authenticator using each of + // the credential ids (key handles) in the excludeList. + // If any of them does not result in an error, that means that this is a known device. + // Afterwards, the platform must still send a dummy registration request (with a dummy appid and invalid challenge) + // to CTAP1/U2F authenticators that it believes are excluded. This makes it so the user still needs to touch + // the CTAP1/U2F authenticator before the RP gets told that the token is already registered. + var errCredentialExcluded error + for _, excludedCred := range req.ExcludeCredentials { + if err := w.t.CheckAuthenticate(u2ftoken.AuthenticateRequest{ + Application: rpIDHash, + Challenge: clientDataHash, + KeyHandle: excludedCred.ID, + }); err != u2ftoken.ErrUnknownKeyHandle { + rpIDHash = make([]byte, 32) + clientDataHash = make([]byte, 32) + errCredentialExcluded = errors.New("webauthn: excluded credential") + break + } + } + if req.Timeout == 0 { req.Timeout = DefaultCTAP1Timeout } @@ -97,6 +108,10 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg return nil, err } + if errCredentialExcluded != nil { + return nil, errCredentialExcluded + } + authData := make([]byte, 37) copy(authData, rpIDHash) // Let flags be a byte whose zeroth bit (bit 0, UP) is set, diff --git a/webauthn/types.go b/webauthn/types.go index fbff266..3993a47 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -18,6 +18,12 @@ type Device interface { u2ftoken.Device } +type ExcludedCredential struct { + Type string `json:"type"` + ID []byte `json:"id"` + Transports []string `json:"transports"` +} + type RegisterRequest struct { Challenge []byte `json:"challenge"` Rp struct { @@ -35,11 +41,7 @@ type RegisterRequest struct { Type string `json:"type"` Alg int `json:"alg"` } `json:"pubKeyCredParams"` - ExcludeCredentials []struct { - Type string `json:"type"` - ID []byte `json:"id"` - Transports []string `json:"transports"` - } `json:"excludeCredentials"` + ExcludeCredentials []ExcludedCredential `json:"excludeCredentials"` AuthenticatorSelection struct { AuthenticatorAttachment string `json:"authenticatorAttachment"` RequireResidentKey bool `json:"requireResidentKey"` @@ -83,14 +85,16 @@ func (m AttestationObject) CBOREncode(keyAsInt bool) ([]byte, error) { return enc.Marshal(m) } +type AllowedCredential struct { + Type string `json:"type"` + ID []byte `json:"id"` +} + type AuthenticateRequest struct { - Challenge []byte `json:"challenge"` - Timeout int `json:"timeout"` - RpID string `json:"rpId"` - AllowCredentials []struct { - Type string `json:"type"` - ID []byte `json:"id"` - } `json:"allowCredentials"` + Challenge []byte `json:"challenge"` + Timeout int `json:"timeout"` + RpID string `json:"rpId"` + AllowCredentials []AllowedCredential `json:"allowCredentials"` UserVerification string `json:"userVerification"` Extensions map[string]interface{} `json:"extensions"` } From 1027f71d72a3009c1dafc6c9a53da62411091561 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 22 Sep 2020 17:50:59 +0200 Subject: [PATCH 20/34] webauthn: ctap1 fix authenticate allowed credentials --- webauthn/ctap1.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index a62e694..4db9dc4 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -209,24 +209,19 @@ func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateReques } clientDataHash := sha.Sum(nil) - var authReq *u2ftoken.AuthenticateRequest + authReq := &u2ftoken.AuthenticateRequest{ + Challenge: clientDataHash, + Application: rpIDHash, + KeyHandle: req.AllowCredentials[0].ID, + } + if len(req.AllowCredentials) > 1 { for _, cred := range req.AllowCredentials { - authReq = &u2ftoken.AuthenticateRequest{ - Challenge: clientDataHash, - Application: rpIDHash, - KeyHandle: cred.ID, - } + authReq.KeyHandle = cred.ID if err := w.t.CheckAuthenticate(*authReq); err == nil { break } } - } else { - authReq = &u2ftoken.AuthenticateRequest{ - Challenge: clientDataHash, - Application: rpIDHash, - KeyHandle: req.AllowCredentials[0].ID, - } } authResp, err := w.authenticateWithTimeout(authReq, time.Duration(req.Timeout)) From 368d6db95383724921598a0d83ab67f9b0f050b5 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 23 Sep 2020 16:47:49 +0200 Subject: [PATCH 21/34] add device selection doc --- doc/DEVICE_SELECTION.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 doc/DEVICE_SELECTION.md diff --git a/doc/DEVICE_SELECTION.md b/doc/DEVICE_SELECTION.md new file mode 100644 index 0000000..9b7763e --- /dev/null +++ b/doc/DEVICE_SELECTION.md @@ -0,0 +1,31 @@ +# Device selection + +When multiple authenticators are available, the webauthn specification isn't really clear how to proceed (see for example https://www.w3.org/TR/webauthn/#createCredential, starting at bullet point 19). Some browsers doesn't even support CTAP2 and rely exclusively on CTAP1/U2F protocol, thus making it impossible to use webauthn with user verification in required mode, or they downgrade the preferred mode to behave like the discouraged one, like Firefox 80.0.1 under Linux. +However, Windows browsers seem to offer a better support for CTAP2, and the following flow have been observed: + +``` +List all available devices +For each devices: + Depending on User Verification: + Discouraged: + > Select devices with either CTAP1 or CTAP2 with clientPin = false (exclude CTAP2 devices with clientPin = true) + > For all selected devices + > Send request to all devices, on first success response cancel all others + > On all errors, return error + Preferred: + > Select all CTAP1 and CTAP2 devices + > If multiple devices selected + > Blink all devices waiting for user presence, this will select the device to use + > If user selected a CTAP2 device with clientPin = false + > Guide user to set new pin. + > else if user selected a CTAP2 device with clientPin = true + > Request user PIN + > Send request to selected device with optional PIN if just set or requested + Required: + > Select devices with CTAP2 support + > If multiple CTAP2 devices selected + > Blink all devices waiting for user presence, this will select the device to use + > If user selected a device with clientPin = false + > Guide user to set new pin. + > Send request to selected device with PIN +``` From b2043b89686d0b980c8d55679bc4cd43a7a036fa Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Mon, 28 Sep 2020 14:30:05 +0200 Subject: [PATCH 22/34] add CLI webauthn implementation - add a CLI webauthn implementation, trying to match as close as possible browsers behavior. - includes device selection when multiple authenticators are plugged in. - includes working examples on webauthn.io and demo.yubico.com public webauthn servers, see webauth/example package. - support usb authenticators, using either FIDO U2F/CTAP1 or CTAP2 protocols --- ctap2token/example/main.go | 10 +- ctap2token/pin/pin.go | 134 +++++++-- ctap2token/token.go | 43 ++- doc/DEVICE_SELECTION.md | 1 - u2fhid/hid.go | 34 ++- u2ftoken/token.go | 78 +++++- webauthn/ctap1.go | 102 ++----- webauthn/ctap2.go | 168 ++++-------- webauthn/example/demo.yubico.com/main.go | 28 +- webauthn/example/webauthn.io/main.go | 28 +- webauthn/token.go | 330 +++++++++++++++++++++-- webauthn/types.go | 69 ++++- 12 files changed, 707 insertions(+), 318 deletions(-) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index ea1228f..1e7a18c 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -72,8 +72,14 @@ func main() { if errors.Unwrap(err) != ctap2token.ErrPinRequired { panic(err) } - pinHandler := pin.NewInteractiveHandler(token) - pinAuth, err := pinHandler.Execute(clientDataHash) + + pinHandler := pin.NewInteractiveHandler() + userPIN, err := pinHandler.ReadPIN() + if err != nil { + panic(err) + } + + pinAuth, err := pin.ExchangeUserPinToPinAuth(token, userPIN, clientDataHash) if err != nil { panic(err) } diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go index f2dfc5c..ba8a839 100644 --- a/ctap2token/pin/pin.go +++ b/ctap2token/pin/pin.go @@ -2,72 +2,149 @@ package pin import ( "bufio" + "bytes" "crypto/aes" "crypto/cipher" "crypto/elliptic" "crypto/hmac" "crypto/rand" "crypto/sha256" + "errors" "fmt" "io" "math/big" "os" - "strings" "github.com/flynn/u2f/crypto" "github.com/flynn/u2f/ctap2token" ) type PINHandler interface { - Execute(clientDataHash []byte) (ctap2token.PinUVAuth, error) + ReadPIN() ([]byte, error) + SetPIN(token *ctap2token.Token) ([]byte, error) + Println(msg ...interface{}) } type InteractiveHandler struct { Stdin io.Reader Stdout io.Writer - - token *ctap2token.Token } var _ PINHandler = (*InteractiveHandler)(nil) // NewInteractiveHandler returns an interactive PINHandler, which will read // the user PIN from the provided reader -func NewInteractiveHandler(t *ctap2token.Token) *InteractiveHandler { +func NewInteractiveHandler() *InteractiveHandler { return &InteractiveHandler{ - token: t, Stdin: os.Stdin, Stdout: os.Stdout, } } -// Execute performs the operations described by the FIDO specification in order to securely -// obtain a token from the authenticator which can be used to verify the user. -// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret -func (h *InteractiveHandler) Execute(clientDataHash []byte) (ctap2token.PinUVAuth, error) { - fmt.Fprint(h.Stdout, "Enter device PIN: ") - reader := bufio.NewReader(h.Stdin) - userPIN, err := reader.ReadString('\n') +func (h *InteractiveHandler) ReadPIN() ([]byte, error) { + _, err := fmt.Fprint(h.Stdout, "enter current device PIN: ") if err != nil { return nil, err } - userPIN = strings.TrimSpace(userPIN) - return exchangeUserPinToPinAuth(h.token, []byte(userPIN), clientDataHash) + return getpasswd(h.Stdin) } -func exchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash []byte) ([]byte, error) { +func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { + _, err := fmt.Fprint(h.Stdout, "enter new device PIN: ") + if err != nil { + return nil, err + } + userPIN, err := getpasswd(h.Stdin) + if err != nil { + return nil, err + } + if l := len(userPIN); l < 4 || l >= 64 { + return nil, errors.New("invalid pin, must be between 4 to 63 characters") + } + if userPIN[len(userPIN)-1] == 0 { + return nil, errors.New("invalid pin, must not end with a 0x00 byte") + } + _, err = fmt.Fprint(h.Stdout, "confirm new device PIN: ") + if err != nil { + return nil, err + } + confirmPIN, err := getpasswd(h.Stdin) + if err != nil { + return nil, err + } + if !bytes.Equal(userPIN, confirmPIN) { + return nil, errors.New("pin mismatch") + } + + aGX, aGY, err := getTokenKeyAgreement(token) + if err != nil { + return nil, err + } b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } + sharedSecret, err := computeSharedSecret(b, aGX, aGY) + if err != nil { + return nil, err + } - aGX, aGY, err := GetTokenKeyAgreement(token) + // Normalize pin size to 64 bytes, padding with zeroes + newPIN := make([]byte, 64) + copy(newPIN, userPIN) + newPinEnc, err := aesCBCEncrypt(sharedSecret, newPIN) if err != nil { return nil, err } - sharedSecret, err := ComputeSharedSecret(b, aGX, aGY) + keyAgreement := &crypto.COSEKey{ + X: bGX.Bytes(), + Y: bGY.Bytes(), + KeyType: crypto.EC2, + Curve: crypto.P256, + Alg: crypto.ECDHES_HKDF256, + } + + mac := hmac.New(sha256.New, sharedSecret) + _, err = mac.Write(newPinEnc) + if err != nil { + return nil, err + } + pinAuth := mac.Sum(nil)[:16] + + _, err = token.ClientPIN(&ctap2token.ClientPINRequest{ + SubCommand: ctap2token.SetPIN, + NewPinEnc: newPinEnc, + KeyAgreement: keyAgreement, + PinProtocol: ctap2token.PinProtoV1, + PinAuth: pinAuth, + }) + if err != nil { + return nil, err + } + return userPIN, nil +} + +func (h *InteractiveHandler) Println(msg ...interface{}) { + fmt.Fprintln(h.Stdout, msg...) +} + +// ExchangeUserPinToPinAuth performs the operations described by the FIDO specification in order to securely +// obtain a token from the authenticator which can be used to verify the user. +// see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret +func ExchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash []byte) ([]byte, error) { + b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + aGX, aGY, err := getTokenKeyAgreement(token) + if err != nil { + return nil, err + } + + sharedSecret, err := computeSharedSecret(b, aGX, aGY) if err != nil { return nil, err } @@ -82,10 +159,10 @@ func exchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash [ return nil, err } - return ComputePINAuth(pinToken, sharedSecret, clientDataHash) + return computePINAuth(pinToken, sharedSecret, clientDataHash) } -func GetTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error) { +func getTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error) { pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ PinProtocol: ctap2token.PinProtoV1, SubCommand: ctap2token.GetKeyAgreement, @@ -103,7 +180,7 @@ func GetTokenKeyAgreement(token *ctap2token.Token) (aGX, aGY *big.Int, err error return aGX, aGY, nil } -func ComputeSharedSecret(b []byte, aGX, aGY *big.Int) ([]byte, error) { +func computeSharedSecret(b []byte, aGX, aGY *big.Int) ([]byte, error) { rX, _ := elliptic.P256().ScalarMult(aGX, aGY, b) sha := sha256.New() _, err := sha.Write(rX.Bytes()) @@ -125,10 +202,10 @@ func hashEncryptPIN(userPIN []byte, sharedSecret []byte) ([]byte, error) { pinHash = pinHash[:aes.BlockSize] // encrypt pinHash with AES-CBC using shared secret - return AESCBCEncrypt(sharedSecret, pinHash) + return aesCBCEncrypt(sharedSecret, pinHash) } -func AESCBCEncrypt(sharedSecret, data []byte) ([]byte, error) { +func aesCBCEncrypt(sharedSecret, data []byte) ([]byte, error) { dataEnc := make([]byte, len(data)) c, err := aes.NewCipher(sharedSecret) if err != nil { @@ -161,7 +238,7 @@ func getPINToken(token *ctap2token.Token, encPinHash []byte, bGX, bGY *big.Int) return pinResp.PinToken, nil } -func ComputePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { +func computePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { // decrypt pinToken using AES-CBC with shared secret clearPinToken := make([]byte, len(data)) c, err := aes.NewCipher(sharedSecret) @@ -181,3 +258,12 @@ func ComputePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { pinAuth := mac.Sum(nil) return pinAuth[:16], nil } + +// TODO: improve password input (with no tty echo) +func getpasswd(r io.Reader) ([]byte, error) { + pin, err := bufio.NewReader(r).ReadString('\n') + if err != nil { + return nil, err + } + return []byte(pin[:len(pin)-1]), nil +} diff --git a/ctap2token/token.go b/ctap2token/token.go index c7a56a5..dc9f128 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -1,11 +1,14 @@ package ctap2token import ( + "context" "encoding/binary" "errors" "fmt" + "time" "github.com/flynn/u2f/crypto" + "github.com/flynn/u2f/u2ftoken" "github.com/fxamacker/cbor/v2" ) @@ -120,6 +123,9 @@ var ctapErrors = map[byte]error{ type Device interface { // CBOR sends a CBOR encoded message to the device and returns the response. CBOR(data []byte) ([]byte, error) + Message(data []byte) ([]byte, error) + Init() error + SetResponseTimeout(timeout time.Duration) } // NewToken returns a token that will use Device to communicate with the device. @@ -294,7 +300,7 @@ func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { reqData, err := enc.Marshal(req) if err != nil { - return nil, err + return nil, fmt.Errorf("ctap2token: failed to marshal request: %w", err) } data := make([]byte, 0, len(reqData)+1) @@ -303,17 +309,36 @@ func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { resp, err := t.d.CBOR(data) if err != nil { - return nil, err + return nil, fmt.Errorf("ctap2token: cbor failed: %w", err) } respData := &ClientPINResponse{} if err := unmarshal(resp, respData); err != nil { - return nil, err + return nil, fmt.Errorf("ctap2token: failed to unmarshal response: %w", err) } return respData, nil } +func (t *Token) AuthenticatorSelection(ctx context.Context) error { + ctap1Token := u2ftoken.NewToken(t.d) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + _, err := ctap1Token.Register(u2ftoken.RegisterRequest{ + Application: make([]byte, 32), + Challenge: make([]byte, 32), + }) + if err != u2ftoken.ErrPresenceRequired { + return err + } + time.Sleep(500 * time.Millisecond) + } + } +} + // Reset restore an authenticator back to a factory default state. User presence is required. // In case of authenticators with no display, Reset request MUST have come to the authenticator within 10 seconds // of powering up of the authenticator @@ -327,6 +352,14 @@ func (t *Token) Reset() error { return checkResponse(resp) } +func (t *Token) Cancel() { + t.d.Init() +} + +func (t *Token) SetResponseTimeout(timeout time.Duration) { + t.d.SetResponseTimeout(timeout) +} + func checkResponse(resp []byte) error { if len(resp) == 0 { return errors.New("ctap2token: empty response") @@ -347,6 +380,10 @@ func unmarshal(resp []byte, out interface{}) error { return err } + if len(resp) == 1 { + return nil + } + if err := cbor.Unmarshal(resp[1:], out); err != nil { return err } diff --git a/doc/DEVICE_SELECTION.md b/doc/DEVICE_SELECTION.md index 9b7763e..5ba1c02 100644 --- a/doc/DEVICE_SELECTION.md +++ b/doc/DEVICE_SELECTION.md @@ -11,7 +11,6 @@ For each devices: > Select devices with either CTAP1 or CTAP2 with clientPin = false (exclude CTAP2 devices with clientPin = true) > For all selected devices > Send request to all devices, on first success response cancel all others - > On all errors, return error Preferred: > Select all CTAP1 and CTAP2 devices > If multiple devices selected diff --git a/u2fhid/hid.go b/u2fhid/hid.go index 3454639..daf600a 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -33,7 +33,7 @@ const ( maxMessageLen = 7609 minInitResponseLen = 17 - responseTimeout = 10 * time.Second + defaultResponseTimeout = 60 * time.Second fidoUsagePage = 0xF1D0 u2fUsage = 1 @@ -75,12 +75,13 @@ func Open(info *hid.DeviceInfo) (*Device, error) { } d := &Device{ - info: info, - device: hidDev, - readCh: hidDev.ReadCh(), + info: info, + device: hidDev, + readCh: hidDev.ReadCh(), + responseTimeout: defaultResponseTimeout, } - if err := d.init(); err != nil { + if err := d.Init(); err != nil { return nil, err } @@ -107,9 +108,10 @@ type Device struct { device hid.Device channel uint32 - mtx sync.Mutex - readCh <-chan []byte - buf []byte + mtx sync.Mutex + readCh <-chan []byte + buf []byte + responseTimeout time.Duration } func (d *Device) sendCommand(channel uint32, cmd byte, data []byte) error { @@ -128,7 +130,6 @@ func (d *Device) sendCommand(channel uint32, cmd byte, data []byte) error { n := copy(d.buf[8:], data) data = data[n:] - if err := d.device.Write(d.buf); err != nil { return err } @@ -153,7 +154,7 @@ func (d *Device) sendCommand(channel uint32, cmd byte, data []byte) error { } func (d *Device) readResponse(channel uint32, cmd byte) ([]byte, error) { - timeout := time.After(responseTimeout) + timeout := time.After(d.responseTimeout) haveFirst := false var buf []byte @@ -216,7 +217,7 @@ func (d *Device) readResponse(channel uint32, cmd byte) ([]byte, error) { } } -func (d *Device) init() error { +func (d *Device) Init() error { d.buf = make([]byte, d.info.OutputReportLength+1) nonce := make([]byte, 8) @@ -280,6 +281,9 @@ func (d *Device) Wink() error { // Message sends an encapsulated U2F protocol message to the device and returns // the response. func (d *Device) Message(data []byte) ([]byte, error) { + // Size of header + data + 2 zero bytes for maximum return size. + // see https://en.wikipedia.org/wiki/Smart_card_application_protocol_data_unit + data = append(data, []byte{0, 0}...) return d.Command(cmdMsg, data) } @@ -289,7 +293,15 @@ func (d *Device) CBOR(data []byte) ([]byte, error) { return d.Command(cmdCbor, data) } +func (d *Device) Cancel() { + d.Command(cmdCancel, nil) +} + // Close closes the device and frees associated resources. func (d *Device) Close() { d.device.Close() } + +func (d *Device) SetResponseTimeout(timeout time.Duration) { + d.responseTimeout = timeout +} diff --git a/u2ftoken/token.go b/u2ftoken/token.go index d5fe4ad..c45516e 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -3,10 +3,12 @@ package u2ftoken import ( + "context" "encoding/asn1" "encoding/binary" "errors" "fmt" + "time" ) const ( @@ -24,12 +26,29 @@ const ( statusNoError = 0x9000 statusWrongLength = 0x6700 - statusInvalidData = 0x6984 statusConditionsNotSatisfied = 0x6985 statusWrongData = 0x6a80 + statusClaNotSupported = 0x6e00 statusInsNotSupported = 0x6d00 ) +var ( + ErrUnknownReason = errors.New("unkown reason") + ErrWrongLength = errors.New("the length of the request was invalid") + ErrConditionsNotSatisfied = errors.New("the request was rejected due to test-of-user-presence being required") + ErrWrongData = errors.New("the request was rejected due to an invalid key handle") + ErrCLANotSupported = errors.New("the class byte of the request is not supported") + ErrInsNotSupported = errors.New("the instruction of the request is not supported") +) + +var errorMessages = map[uint16]error{ + statusWrongLength: ErrWrongLength, + statusConditionsNotSatisfied: ErrConditionsNotSatisfied, + statusWrongData: ErrWrongData, + statusClaNotSupported: ErrCLANotSupported, + statusInsNotSupported: ErrInsNotSupported, +} + // ErrPresenceRequired is returned by Register and Authenticate if proof of user // presence must be provide before the operation can be retried successfully. var ErrPresenceRequired = errors.New("u2ftoken: user presence required") @@ -43,6 +62,8 @@ var ErrUnknownKeyHandle = errors.New("u2ftoken: unknown key handle") type Device interface { // Message sends a message to the device and returns the response. Message(data []byte) ([]byte, error) + Init() error + SetResponseTimeout(timeout time.Duration) } // NewToken returns a token that will use Device to communicate with the device. @@ -86,10 +107,12 @@ func (t *Token) Register(req RegisterRequest) (*RegisterResponse, error) { return nil, fmt.Errorf("u2ftoken: Application must be exactly 32 bytes") } + data := append(req.Challenge, req.Application...) + res, err := t.Message(Request{ Param1: authEnforce, Command: cmdRegister, - Data: append(req.Challenge, req.Application...), + Data: data, }) if err != nil { return nil, err @@ -100,7 +123,11 @@ func (t *Token) Register(req RegisterRequest) (*RegisterResponse, error) { case statusConditionsNotSatisfied: return nil, ErrPresenceRequired default: - return nil, fmt.Errorf("u2ftoken: unexpected error %d during registration", res.Status) + errMsg := ErrUnknownReason + if msg, ok := errorMessages[res.Status]; ok { + errMsg = msg + } + return nil, fmt.Errorf("u2ftoken: unexpected error %x during registration: %w", res.Status, errMsg) } } @@ -199,7 +226,11 @@ func (t *Token) Authenticate(req AuthenticateRequest) (*AuthenticateResponse, er if res.Status == statusConditionsNotSatisfied { return nil, ErrPresenceRequired } - return nil, fmt.Errorf("u2ftoken: unexpected error %d during authentication", res.Status) + errMsg := ErrUnknownReason + if msg, ok := errorMessages[res.Status]; ok { + errMsg = msg + } + return nil, fmt.Errorf("u2ftoken: unexpected error %x during authentication: %w", res.Status, errMsg) } if len(res.Data) < 6 { @@ -235,7 +266,11 @@ func (t *Token) CheckAuthenticate(req AuthenticateRequest) error { if res.Status == statusWrongData { return ErrUnknownKeyHandle } - return fmt.Errorf("u2ftoken: unexpected error %d during auth check", res.Status) + errMsg := ErrUnknownReason + if msg, ok := errorMessages[res.Status]; ok { + errMsg = msg + } + return fmt.Errorf("u2ftoken: unexpected error %x during auth check: %w", res.Status, errMsg) } return nil @@ -249,12 +284,35 @@ func (t *Token) Version() (string, error) { } if res.Status != statusNoError { - return "", fmt.Errorf("u2ftoken: unexpected error %d during version request", res.Status) + errMsg := ErrUnknownReason + if msg, ok := errorMessages[res.Status]; ok { + errMsg = msg + } + return "", fmt.Errorf("u2ftoken: unexpected error %x during version request: %w", res.Status, errMsg) } return string(res.Data), nil } +func (t *Token) AuthenticatorSelection(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + _, err := t.Register(RegisterRequest{ + Application: make([]byte, 32), + Challenge: make([]byte, 32), + }) + + if err != ErrPresenceRequired { + return err + } + time.Sleep(500 * time.Millisecond) + } + } +} + // A Request is a low-level request to the token. type Request struct { Command uint8 @@ -292,3 +350,11 @@ func (t *Token) Message(req Request) (*Response, error) { Status: binary.BigEndian.Uint16(data[len(data)-2:]), }, nil } + +func (t *Token) Cancel() { + t.d.Init() +} + +func (t *Token) SetResponseTimeout(timeout time.Duration) { + t.d.SetResponseTimeout(timeout) +} diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index 4db9dc4..d6a25ec 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -1,41 +1,22 @@ package webauthn import ( + "context" "crypto/elliptic" "crypto/sha256" - "encoding/base64" "encoding/binary" - "encoding/json" "errors" - "fmt" - "net/url" "time" "github.com/flynn/u2f/crypto" "github.com/flynn/u2f/u2ftoken" ) -var DefaultCTAP1Timeout = 60 - type ctap1WebauthnToken struct { t *u2ftoken.Token } -func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - originURL, err := url.Parse(origin) - if err != nil { - return nil, fmt.Errorf("webauthn: invalid origin: %w", err) - } - if originURL.Opaque != "" { - return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) - } - - effectiveDomain := originURL.Hostname() - rpID := req.Rp.ID - if rpID == "" { - rpID = effectiveDomain - } - +func (w *ctap1WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { useES256 := false for _, cp := range req.PubKeyCredParams { if crypto.Alg(cp.Alg) == crypto.ES256 { @@ -49,32 +30,21 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg if req.AuthenticatorSelection.RequireResidentKey { return nil, errors.New("webauth: ctap1 protocol require rk to be false") } - if req.AuthenticatorSelection.UserVerification == "required" { + if req.AuthenticatorSelection.UserVerification == UVRequired { return nil, errors.New("webauth: ctap1 protocol does not support required user verification") } sha := sha256.New() - if _, err := sha.Write([]byte(rpID)); err != nil { + if _, err := sha.Write([]byte(req.Rp.ID)); err != nil { return nil, err } rpIDHash := sha.Sum(nil) - clientData := collectedClientData{ - Type: "webauthn.create", - Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), - Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), - } - clientDataJSON, err := json.Marshal(clientData) + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() if err != nil { return nil, err } - sha.Reset() - if _, err := sha.Write(clientDataJSON); err != nil { - return nil, err - } - clientDataHash := sha.Sum(nil) - // If the excludeList is not empty, the platform must send signing request with // check-only control byte to the CTAP1/U2F authenticator using each of // the credential ids (key handles) in the excludeList. @@ -96,10 +66,6 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg } } - if req.Timeout == 0 { - req.Timeout = DefaultCTAP1Timeout - } - resp, err := w.registerWithTimeout(&u2ftoken.RegisterRequest{ Application: rpIDHash, Challenge: clientDataHash, @@ -160,55 +126,25 @@ func (w *ctap1WebauthnToken) Register(origin string, req *RegisterRequest) (*Reg }, nil } -func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { - originURL, err := url.Parse(origin) - if err != nil { - return nil, fmt.Errorf("webauthn: invalid origin: %w", err) - } - if originURL.Opaque != "" { - return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) - } - - effectiveDomain := originURL.Hostname() - rpID := req.RpID - if rpID == "" { - rpID = effectiveDomain - } - +func (w *ctap1WebauthnToken) Authenticate(req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { if len(req.AllowCredentials) == 0 { return nil, errors.New("webauthn: ctap1 require at least one credential") } - if req.UserVerification == "required" { + if req.UserVerification == UVRequired { return nil, errors.New("webauthn: ctap1 does not support user verification") } - if req.Timeout == 0 { - req.Timeout = DefaultCTAP1Timeout - } - sha := sha256.New() - if _, err := sha.Write([]byte(rpID)); err != nil { + if _, err := sha.Write([]byte(req.RpID)); err != nil { return nil, err } rpIDHash := sha.Sum(nil) - clientData := collectedClientData{ - Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), - Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), - Type: "webauthn.get", - } - - clientDataJSON, err := json.Marshal(clientData) + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() if err != nil { return nil, err } - sha.Reset() - if _, err := sha.Write(clientDataJSON); err != nil { - return nil, err - } - clientDataHash := sha.Sum(nil) - authReq := &u2ftoken.AuthenticateRequest{ Challenge: clientDataHash, Application: rpIDHash, @@ -244,6 +180,26 @@ func (w *ctap1WebauthnToken) Authenticate(origin string, req *AuthenticateReques }, nil } +func (w *ctap1WebauthnToken) AuthenticatorSelection(ctx context.Context) error { + return w.t.AuthenticatorSelection(ctx) +} + +func (w *ctap1WebauthnToken) RequireUV() bool { + return false +} + +func (w *ctap1WebauthnToken) SupportRK() bool { + return false +} + +func (w *ctap1WebauthnToken) Cancel() { + w.t.Cancel() +} + +func (w *ctap1WebauthnToken) SetResponseTimeout(timeout time.Duration) { + w.t.SetResponseTimeout(timeout) +} + func (w *ctap1WebauthnToken) registerWithTimeout(req *u2ftoken.RegisterRequest, timeout time.Duration) (*u2ftoken.RegisterResponse, error) { for { select { diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index 5357f65..bd7ad03 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -2,42 +2,32 @@ package webauthn import ( "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/json" + "context" "errors" "fmt" - "net/url" + "time" "github.com/flynn/u2f/crypto" ctap2 "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" ) -type ctap2TWebauthnToken struct { - t *ctap2.Token - pinHandler pin.PINHandler +var supportedCTAP2CredentialTypes = map[string]ctap2.CredentialType{ + string(ctap2.PublicKey): ctap2.PublicKey, +} +var supportedCTAP2Transports = map[string]ctap2.AuthenticatorTransport{ + string(ctap2.USB): ctap2.USB, } -func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { - originURL, err := url.Parse(origin) - if err != nil { - return nil, fmt.Errorf("webauthn: invalid origin: %w", err) - } - if originURL.Opaque != "" { - return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) - } - - effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain - - rpID := req.Rp.ID - if rpID == "" { - rpID = effectiveDomain - } +type ctap2WebauthnToken struct { + t *ctap2.Token + options map[string]bool +} +func (w *ctap2WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { credTypesAndPubKeyAlgs := make([]ctap2.CredentialParam, 0, len(req.PubKeyCredParams)) for _, cp := range req.PubKeyCredParams { - t, ok := supportedCredentialTypes[cp.Type] + t, ok := supportedCTAP2CredentialTypes[cp.Type] if !ok { continue } @@ -55,32 +45,21 @@ func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*Re // TODO add support for extensions (bullet point 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) clientExtensions := make(map[string]interface{}) - clientData := collectedClientData{ - Type: "webauthn.create", - Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), - Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), - } - clientDataJSON, err := json.Marshal(clientData) + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() if err != nil { return nil, err } - sha := sha256.New() - if _, err := sha.Write(clientDataJSON); err != nil { - return nil, err - } - clientDataHash := sha.Sum(nil) - excludeList := make([]ctap2.CredentialDescriptor, 0, len(req.ExcludeCredentials)) for _, c := range req.ExcludeCredentials { - t, ok := supportedCredentialTypes[c.Type] + t, ok := supportedCTAP2CredentialTypes[c.Type] if !ok { return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) } transports := make([]ctap2.AuthenticatorTransport, 0, len(c.Transports)) for _, transport := range c.Transports { - ctapTransport, ok := supportedTransports[transport] + ctapTransport, ok := supportedCTAP2Transports[transport] if !ok { return nil, fmt.Errorf("webauthn: unsupported transport type %q", transport) } @@ -99,15 +78,20 @@ func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*Re options["rk"] = true } - pinUVAuth, pinProtocol, err := w.userVerification(req.AuthenticatorSelection.UserVerification, clientDataHash) - if err != nil { - return nil, err + var pinProtocol ctap2.PinUVAuthProtocolVersion + var pinUVAuth []byte + if len(p.UserPIN) > 0 { + var err error + pinUVAuth, err = pin.ExchangeUserPinToPinAuth(w.t, p.UserPIN, clientDataHash) + if err != nil { + return nil, err + } + pinProtocol = ctap2.PinProtoV1 } - resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ ClientDataHash: clientDataHash, RP: ctap2.CredentialRpEntity{ - ID: rpID, + ID: req.Rp.ID, Name: req.Rp.Name, Icon: req.Rp.Icon, }, @@ -174,52 +158,29 @@ func (w *ctap2TWebauthnToken) Register(origin string, req *RegisterRequest) (*Re }, nil } -func (w *ctap2TWebauthnToken) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { - originURL, err := url.Parse(origin) - if err != nil { - return nil, fmt.Errorf("webauthn: invalid origin: %w", err) - } - if originURL.Opaque != "" { - return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) - } - - effectiveDomain := originURL.Hostname() // TODO validate with https://url.spec.whatwg.org/#valid-domain - - // TODO if options.rpId is not a "registrable domain suffix" of and is not equal to effectiveDomain, return error - rpID := req.RpID - - if rpID == "" { - rpID = effectiveDomain - } - +func (w *ctap2WebauthnToken) Authenticate(req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { // TODO add support for extensions (bullet point 8 from https://www.w3.org/TR/2020/WD-webauthn-2-20200730/#sctn-discover-from-external-source) clientExtensions := make(map[string]interface{}) - clientData := collectedClientData{ - Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), - Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), - Type: "webauthn.get", - } - - clientDataJSON, err := json.Marshal(clientData) + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() if err != nil { return nil, err } - sha := sha256.New() - if _, err := sha.Write(clientDataJSON); err != nil { - return nil, err - } - clientDataHash := sha.Sum(nil) - - pinUVAuth, pinProtocol, err := w.userVerification(req.UserVerification, clientDataHash) - if err != nil { - return nil, err + var pinProtocol ctap2.PinUVAuthProtocolVersion + var pinUVAuth []byte + if len(p.UserPIN) > 0 { + var err error + pinUVAuth, err = pin.ExchangeUserPinToPinAuth(w.t, p.UserPIN, clientDataHash) + if err != nil { + return nil, err + } + pinProtocol = ctap2.PinProtoV1 } allowList := make([]*ctap2.CredentialDescriptor, 0, len(req.AllowCredentials)) for _, c := range req.AllowCredentials { - t, ok := supportedCredentialTypes[c.Type] + t, ok := supportedCTAP2CredentialTypes[c.Type] if !ok { return nil, fmt.Errorf("webauthn: unsupported excluded credential type %q", c.Type) } @@ -231,7 +192,7 @@ func (w *ctap2TWebauthnToken) Authenticate(origin string, req *AuthenticateReque } resp, err := w.t.GetAssertion(&ctap2.GetAssertionRequest{ - RPID: rpID, + RPID: req.RpID, ClientDataHash: clientDataHash, PinUVAuth: pinUVAuth, PinUVAuthProtocol: pinProtocol, @@ -262,45 +223,22 @@ func (w *ctap2TWebauthnToken) Authenticate(origin string, req *AuthenticateReque }, nil } -func (w *ctap2TWebauthnToken) userVerification(uv string, clientDataHash []byte) ([]byte, ctap2.PinUVAuthProtocolVersion, error) { - infos, err := w.t.GetInfo() - if err != nil { - return nil, 0, err - } - - var pinUVAuth []byte - var pinProtocol ctap2.PinUVAuthProtocolVersion +func (w *ctap2WebauthnToken) AuthenticatorSelection(ctx context.Context) error { + return w.t.AuthenticatorSelection(ctx) +} - if uv == "" { - uv = "preferred" - } +func (w *ctap2WebauthnToken) Cancel() { + w.t.Cancel() +} - switch uv { - case "discouraged": - // Do nothing - case "required": - if pin, ok := infos.Options["clientPin"]; !ok || !pin { - return nil, 0, errors.New("webauthn: authenticator does not support user verification") - } +func (w *ctap2WebauthnToken) RequireUV() bool { + return w.options["clientPin"] +} - pinProtocol = ctap2.PinProtoV1 - pinUVAuth, err = w.pinHandler.Execute(clientDataHash) - if err != nil { - return nil, 0, err - } - case "preferred": - // Most authenticators seems to set clientPin option to true when the PIN is set - // TODO: validate this is a standard way to do that - if pin, ok := infos.Options["clientPin"]; ok && pin { - pinProtocol = ctap2.PinProtoV1 - pinUVAuth, err = w.pinHandler.Execute(clientDataHash) - if err != nil { - return nil, 0, err - } - } - default: - return nil, 0, fmt.Errorf("unsupported user verification option %q", uv) - } +func (w *ctap2WebauthnToken) SupportRK() bool { + return w.options["rk"] +} - return pinUVAuth, pinProtocol, nil +func (w *ctap2WebauthnToken) SetResponseTimeout(timeout time.Duration) { + w.t.SetResponseTimeout(timeout) } diff --git a/webauthn/example/demo.yubico.com/main.go b/webauthn/example/demo.yubico.com/main.go index 4100d3c..baa6a8f 100644 --- a/webauthn/example/demo.yubico.com/main.go +++ b/webauthn/example/demo.yubico.com/main.go @@ -10,9 +10,7 @@ import ( "net/http/httputil" "os" - "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" - "github.com/flynn/u2f/u2fhid" "github.com/flynn/u2f/webauthn" ) @@ -31,27 +29,9 @@ func main() { host := "https://demo.yubico.com" - devices, err := u2fhid.Devices() - if err != nil { - panic(err) - } - - if len(devices) == 0 { - panic("no HID devices found") - } - - d := devices[0] - - dev, err := u2fhid.Open(d) - if err != nil { - panic(err) - } - - t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) - if err != nil { - panic(err) - } + t := webauthn.New(webauthn.WithCTAP2PinHandler(pin.NewInteractiveHandler())) + var err error switch action { case "register": err = register(t, host) @@ -72,7 +52,7 @@ func main() { } } -func register(t webauthn.Token, host string) error { +func register(t *webauthn.Webauthn, host string) error { c := &http.Client{} reqBody := bytes.NewBuffer([]byte(`{"userVerification":"preferred"}`)) httpResp, err := c.Post(fmt.Sprintf("%s/api/v1/simple/webauthn/register-begin", host), "application/json", reqBody) @@ -159,7 +139,7 @@ func register(t webauthn.Token, host string) error { return nil } -func authenticate(t webauthn.Token, host, session string) error { +func authenticate(t *webauthn.Webauthn, host, session string) error { c := &http.Client{} reqBody := bytes.NewBuffer([]byte(`{"userVerification":"preferred"}`)) diff --git a/webauthn/example/webauthn.io/main.go b/webauthn/example/webauthn.io/main.go index 70725fb..819f33e 100644 --- a/webauthn/example/webauthn.io/main.go +++ b/webauthn/example/webauthn.io/main.go @@ -11,9 +11,7 @@ import ( "net/http/httputil" "os" - "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" - "github.com/flynn/u2f/u2fhid" "github.com/flynn/u2f/webauthn" ) @@ -37,27 +35,9 @@ func main() { host := "https://webauthn.io" - devices, err := u2fhid.Devices() - if err != nil { - panic(err) - } - - if len(devices) == 0 { - panic("no HID devices found") - } - - d := devices[0] - - dev, err := u2fhid.Open(d) - if err != nil { - panic(err) - } - - t, err := webauthn.NewToken(dev, pin.NewInteractiveHandler(ctap2token.NewToken(dev))) - if err != nil { - panic(err) - } + t := webauthn.New(webauthn.WithCTAP2PinHandler(pin.NewInteractiveHandler())) + var err error switch action { case "register": err = register(t, username, host) @@ -72,7 +52,7 @@ func main() { } } -func register(t webauthn.Token, username, host string) error { +func register(t *webauthn.Webauthn, username, host string) error { c := &http.Client{} httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=direct&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) @@ -150,7 +130,7 @@ func register(t webauthn.Token, username, host string) error { return nil } -func authenticate(t webauthn.Token, username, host string) error { +func authenticate(t *webauthn.Webauthn, username, host string) error { c := &http.Client{} httpResp, err := c.Get(fmt.Sprintf("%s/assertion/%s?userVer=discouraged&txAuthExtension=", host, username)) if err != nil { diff --git a/webauthn/token.go b/webauthn/token.go index 59f78d0..e86f315 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -1,34 +1,322 @@ package webauthn import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/url" + "time" + ctap2 "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/ctap2token/pin" + "github.com/flynn/u2f/u2fhid" "github.com/flynn/u2f/u2ftoken" ) -var supportedCredentialTypes = map[string]ctap2.CredentialType{ - string(ctap2.PublicKey): ctap2.PublicKey, +// DefaultResponseTimeout is the default timeout, in seconds, waiting for a response from a device +var DefaultResponseTimeout = 60 + +// DefaultDeviceSelectionTimeout is the default timeout, in seconds, waiting for the user to select a device +var DefaultDeviceSelectionTimeout = 60 + +var emptyAAGUID = make([]byte, 16) + +type Webauthn struct { + debug bool + pinHandler pin.PINHandler + deviceSelectionTimeout time.Duration } -var supportedTransports = map[string]ctap2.AuthenticatorTransport{ - string(ctap2.USB): ctap2.USB, + +type WebauthnOption func(*Webauthn) + +func WithDebug(enabled bool) WebauthnOption { + return func(a *Webauthn) { + a.debug = enabled + } } -var emptyAAGUID = make([]byte, 16) +func WithCTAP2PinHandler(pinHandler pin.PINHandler) WebauthnOption { + return func(a *Webauthn) { + a.pinHandler = pinHandler + } +} + +func WithDeviceSelectionTimeout(d time.Duration) WebauthnOption { + return func(a *Webauthn) { + a.deviceSelectionTimeout = d + } +} + +func New(opts ...WebauthnOption) *Webauthn { + a := &Webauthn{ + pinHandler: pin.NewInteractiveHandler(), + debug: false, + deviceSelectionTimeout: time.Duration(DefaultDeviceSelectionTimeout) * time.Second, + } + + for _, opt := range opts { + opt(a) + } + + return a +} + +func (a *Webauthn) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + if req.Rp.ID == "" { + req.Rp.ID = originURL.Hostname() + } + + if req.Timeout == 0 { + req.Timeout = DefaultResponseTimeout + } + + authenticators, userPIN, err := a.selectAuthenticators(req.AuthenticatorSelection) + if err != nil { + return nil, err + } + + type authenticatorResponse struct { + authenticator Authenticator + resp *RegisterResponse + err error + } + + respChan := make(chan *authenticatorResponse) + + // Send the request to all selected authenticators + for _, authenticator := range authenticators { + go func(a Authenticator) { + a.SetResponseTimeout(time.Duration(req.Timeout) * time.Second) + resp, err := a.Register(req, &RequestParams{ + ClientData: CollectedClientData{ + Type: "webauthn.create", + Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + }, + UserPIN: userPIN, + }) + respChan <- &authenticatorResponse{ + authenticator: a, + resp: resp, + err: err, + } + }(authenticator) + } + + select { + case authResp := <-respChan: + // cancel any other pending authenticators + for _, a := range authenticators { + if a == authResp.authenticator { + continue + } + a.Cancel() + } + + if authResp.err != nil { + return nil, authResp.err + } + return authResp.resp, nil + case <-time.After(time.Duration(req.Timeout) * time.Second): + for _, a := range authenticators { + a.Cancel() + } + return nil, errors.New("webauthn: timeout waiting for authenticator response") + } +} + +func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { + originURL, err := url.Parse(origin) + if err != nil { + return nil, fmt.Errorf("webauthn: invalid origin: %w", err) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + if req.RpID == "" { + req.RpID = originURL.Hostname() + } + + if req.Timeout == 0 { + req.Timeout = DefaultResponseTimeout + } + + authenticators, userPIN, err := a.selectAuthenticators(AuthenticatorSelection{ + UserVerification: req.UserVerification, + }) + if err != nil { + return nil, err + } + + type authenticatorResponse struct { + authenticator Authenticator + resp *AuthenticateResponse + err error + } + + respChan := make(chan *authenticatorResponse) + + // Send the request to all selected authenticators + for _, authenticator := range authenticators { + go func(a Authenticator) { + a.SetResponseTimeout(time.Duration(req.Timeout) * time.Second) + resp, err := a.Authenticate(req, &RequestParams{ + ClientData: CollectedClientData{ + Type: "webauthn.get", + Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), + Origin: fmt.Sprintf("%s://%s", originURL.Scheme, originURL.Host), + }, + UserPIN: userPIN, + }) + respChan <- &authenticatorResponse{ + authenticator: a, + resp: resp, + err: err, + } + }(authenticator) + } + + select { + case authResp := <-respChan: + // cancel any other pending authenticators + for _, a := range authenticators { + if a == authResp.authenticator { + continue + } + a.Cancel() + } + + if authResp.err != nil { + return nil, authResp.err + } + return authResp.resp, nil + case <-time.After(time.Duration(req.Timeout) * time.Second): + for _, a := range authenticators { + a.Cancel() + } + return nil, errors.New("webauthn: timeout waiting for authenticator response") + } +} + +// selectAuthenticators guide the user into selecting the authenticator to communicate with. +// One or multiple devices can be returned depending on their supported protocols and the AuthenticatorSelection +// requirements. +// If user verification is required, the user will be prompted to enter the device PIN, or to set it. The PIN will +// be returned in order to be exchanged later for a pinAuth code (see pin.ExchangeUserPinToPinAuth). +func (a *Webauthn) selectAuthenticators(opts AuthenticatorSelection) ([]Authenticator, []byte, error) { + var selected []Authenticator + var userPIN []byte + + for len(selected) == 0 { + select { + case <-time.After(a.deviceSelectionTimeout): + return nil, nil, errors.New("webauthn: timeout while waiting for authenticator") + default: + u2fDevInfos, err := u2fhid.Devices() + if err != nil { + return nil, nil, err + } + if len(u2fDevInfos) == 0 { + time.Sleep(200 * time.Millisecond) + continue + } + + for _, devInfo := range u2fDevInfos { + dev, err := u2fhid.Open(devInfo) + if err != nil { + return nil, nil, err + } + + var current Authenticator + var isCTAP2 bool + t := ctap2.NewToken(dev) + if info, err := t.GetInfo(); err == nil { + current = &ctap2WebauthnToken{ + t: t, + options: info.Options, + } + isCTAP2 = true + } else { + current = &ctap1WebauthnToken{ + t: u2ftoken.NewToken(dev), + } + } + + // Skip devices not fullfilling request requirements + if opts.RequireResidentKey && !current.SupportRK() { + continue + } + if opts.UserVerification == UVDiscouraged && current.RequireUV() { + continue + } + if opts.UserVerification == UVRequired && !isCTAP2 { + continue + } + + selected = append(selected, current) + } + } + } + + // When multiple devices are present and UV is needed, we must guide the user to select a single device. + // This is done by sending fake ctap1 register requests to all devices, with a test-user-presence flag. + // The first device to reply a non error is assumed selected by the user. + if opts.UserVerification != UVDiscouraged { + selectedAuth := selected[0] + + if len(selected) > 1 { + a.pinHandler.Println("multiple security keys found. Please select one by touching it...") + respChan := make(chan Authenticator) + ctx, cancel := context.WithTimeout(context.Background(), a.deviceSelectionTimeout) + defer cancel() + for _, s := range selected { + go func(auth Authenticator) { + err := auth.AuthenticatorSelection(ctx) + if err == nil { + respChan <- auth + } + }(s) + } + + select { + case selectedAuth = <-respChan: + selected = []Authenticator{selectedAuth} + a.pinHandler.Println("device selected!") + cancel() // cancel other selection routines + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + } + + // Collect PIN or guide user to set a PIN on CTAP2 authenticators + ctap2Auth, isCTAP2 := selectedAuth.(*ctap2WebauthnToken) + if isCTAP2 { + var err error + if !selectedAuth.RequireUV() { + userPIN, err = a.pinHandler.SetPIN(ctap2Auth.t) + if err != nil { + return nil, nil, err + } + } else { + userPIN, err = a.pinHandler.ReadPIN() + if err != nil { + return nil, nil, err + } + } + } + a.pinHandler.Println("confirm presence on authenticator when it will blink...") + + } -// NewToken returns a new WebAuthn capable token. -// It will first try to communicate with the device using FIDO2 / CTAP2 protocol, -// and fallback using U2F / CTAP1 on failure. -// A pinHandler is required when using a CTAP2 compatible authenticator with a configured PIN, when requests -// require user verification. -func NewToken(d Device, pinHandler pin.PINHandler) (Token, error) { - t := ctap2.NewToken(d) - if _, err := t.GetInfo(); err != nil { - return &ctap1WebauthnToken{ - t: u2ftoken.NewToken(d), - }, nil - } - return &ctap2TWebauthnToken{ - t: t, - pinHandler: pinHandler, - }, nil + return selected, userPIN, nil } diff --git a/webauthn/types.go b/webauthn/types.go index 3993a47..1a65709 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -1,16 +1,33 @@ package webauthn import ( + "context" + "crypto/sha256" + "encoding/json" + "time" + ctap2 "github.com/flynn/u2f/ctap2token" "github.com/flynn/u2f/u2ftoken" "github.com/fxamacker/cbor/v2" ) -type Token interface { +type Authenticator interface { // Register is the equivalent to navigator.credential.create() - Register(origin string, req *RegisterRequest) (*RegisterResponse, error) + Register(req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) // Authenticate is the equivalent to navigator.credential.get() - Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) + Authenticate(req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) + + AuthenticatorSelection(ctx context.Context) error + + SetResponseTimeout(timeout time.Duration) + RequireUV() bool + SupportRK() bool + Cancel() +} + +type RequestParams struct { + UserPIN []byte + ClientData CollectedClientData } type Device interface { @@ -41,17 +58,27 @@ type RegisterRequest struct { Type string `json:"type"` Alg int `json:"alg"` } `json:"pubKeyCredParams"` - ExcludeCredentials []ExcludedCredential `json:"excludeCredentials"` - AuthenticatorSelection struct { - AuthenticatorAttachment string `json:"authenticatorAttachment"` - RequireResidentKey bool `json:"requireResidentKey"` - UserVerification string `json:"userVerification"` - } `json:"authenticatorSelection"` - Timeout int `json:"timeout"` - Extensions map[string]interface{} `json:"extensions"` - Attestation string `json:"attestation"` + ExcludeCredentials []ExcludedCredential `json:"excludeCredentials"` + AuthenticatorSelection AuthenticatorSelection `json:"authenticatorSelection"` + Timeout int `json:"timeout"` + Extensions map[string]interface{} `json:"extensions"` + Attestation string `json:"attestation"` +} + +type AuthenticatorSelection struct { + AuthenticatorAttachment string `json:"authenticatorAttachment"` + RequireResidentKey bool `json:"requireResidentKey"` + UserVerification UserVerification `json:"userVerification"` } +type UserVerification string + +const ( + UVDiscouraged UserVerification = "discouraged" + UVPreferred UserVerification = "preferred" + UVRequired UserVerification = "required" +) + type RegisterResponse struct { ID []byte Response AttestationResponse @@ -95,7 +122,7 @@ type AuthenticateRequest struct { Timeout int `json:"timeout"` RpID string `json:"rpId"` AllowCredentials []AllowedCredential `json:"allowCredentials"` - UserVerification string `json:"userVerification"` + UserVerification UserVerification `json:"userVerification"` Extensions map[string]interface{} `json:"extensions"` } type AuthenticateResponse struct { @@ -110,9 +137,23 @@ type AssertionResponse struct { UserHandle []byte } -type collectedClientData struct { +type CollectedClientData struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` // TODO tokenBinding ? } + +func (c CollectedClientData) EncodeAndHash() (dataJSON []byte, dataHash []byte, err error) { + dataJSON, err = json.Marshal(c) + if err != nil { + return nil, nil, err + } + + sha := sha256.New() + if _, err := sha.Write(dataJSON); err != nil { + return nil, nil, err + } + dataHash = sha.Sum(nil) + return dataJSON, dataHash, nil +} From f3f30b53ce89c356169950b3a293d55509338ec2 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Mon, 28 Sep 2020 15:58:45 +0200 Subject: [PATCH 23/34] cleanup --- ctap2token/example/main.go | 3 ++- ctap2token/pin/pin.go | 15 +++++++++++---- ctap2token/token.go | 28 +++++++++++++++------------- webauthn/ctap2.go | 2 +- webauthn/token.go | 33 +++++++++++++++++++++++---------- webauthn/types.go | 2 +- 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 1e7a18c..765d9d9 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -32,7 +32,8 @@ func main() { infos, err := token.GetInfo() if err != nil { - panic(err) + fmt.Printf("failed to retrieve token info (%v), is the token supporting CTAP2 ?\n", err) + continue } fmt.Printf("Token infos:\n%#v\n", infos) diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go index ba8a839..4c15808 100644 --- a/ctap2token/pin/pin.go +++ b/ctap2token/pin/pin.go @@ -19,6 +19,11 @@ import ( "github.com/flynn/u2f/ctap2token" ) +const ( + PinLengthMin = 4 + PinLengthMax = 63 +) + type PINHandler interface { ReadPIN() ([]byte, error) SetPIN(token *ctap2token.Token) ([]byte, error) @@ -59,11 +64,13 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { if err != nil { return nil, err } - if l := len(userPIN); l < 4 || l >= 64 { - return nil, errors.New("invalid pin, must be between 4 to 63 characters") + + // checks from https://fidoalliance.org/specs/fido2/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#client-pin-uv-support + if l := len(userPIN); l < PinLengthMin || l > PinLengthMax { + return nil, errors.New("invalid pin, must be between 4 to 63 bytes") } if userPIN[len(userPIN)-1] == 0 { - return nil, errors.New("invalid pin, must not end with a 0x00 byte") + return nil, errors.New("invalid pin, must not end with a NUL byte") } _, err = fmt.Fprint(h.Stdout, "confirm new device PIN: ") if err != nil { @@ -74,7 +81,7 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { return nil, err } if !bytes.Equal(userPIN, confirmPIN) { - return nil, errors.New("pin mismatch") + return nil, errors.New("pin confirmation mismatch") } aGX, aGY, err := getTokenKeyAgreement(token) diff --git a/ctap2token/token.go b/ctap2token/token.go index dc9f128..662452d 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -121,10 +121,12 @@ var ctapErrors = map[byte]error{ } type Device interface { - // CBOR sends a CBOR encoded message to the device and returns the response. + // CBOR sends a CTAP2 CBOR encoded message to the device and returns the response. CBOR(data []byte) ([]byte, error) + // Message sends a CTAP1 message to the device and returns the response. Message(data []byte) ([]byte, error) Init() error + // SetResponseTimeout allow to control the maximum time to wait for the device response SetResponseTimeout(timeout time.Duration) } @@ -284,6 +286,18 @@ type ClientPINRequest struct { PinHashEnc []byte `cbor:"6,keyasint,omitempty"` } +type ClientPinSubCommand uint + +const ( + GetPINRetries ClientPinSubCommand = 0x01 + GetKeyAgreement ClientPinSubCommand = 0x02 + SetPIN ClientPinSubCommand = 0x03 + ChangePIN ClientPinSubCommand = 0x04 + GetPINUvAuthTokenUsingPIN ClientPinSubCommand = 0x05 + GetPINUvAuthTokenUsingUv ClientPinSubCommand = 0x06 + GetUVRetries ClientPinSubCommand = 0x07 +) + type ClientPINResponse struct { KeyAgreement *crypto.COSEKey `cbor:"1,keyasint,omitempty"` PinToken []byte `cbor:"2,keyasint,omitempty"` @@ -633,15 +647,3 @@ type PinUVAuthProtocolVersion uint const ( PinProtoV1 PinUVAuthProtocolVersion = 1 ) - -type ClientPinSubCommand uint - -const ( - GetPINRetries ClientPinSubCommand = 0x01 - GetKeyAgreement ClientPinSubCommand = 0x02 - SetPIN ClientPinSubCommand = 0x03 - ChangePIN ClientPinSubCommand = 0x04 - GetPINUvAuthTokenUsingPIN ClientPinSubCommand = 0x05 - GetPINUvAuthTokenUsingUv ClientPinSubCommand = 0x06 - GetUVRetries ClientPinSubCommand = 0x07 -) diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index bd7ad03..421f00a 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -42,7 +42,7 @@ func (w *ctap2WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (* return nil, errors.New("webauthn: credential parameters not supported") } - // TODO add support for extensions (bullet point 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) + // TODO add support for extensions (bullet points 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) clientExtensions := make(map[string]interface{}) clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() diff --git a/webauthn/token.go b/webauthn/token.go index e86f315..a963557 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -17,8 +17,13 @@ import ( // DefaultResponseTimeout is the default timeout, in seconds, waiting for a response from a device var DefaultResponseTimeout = 60 -// DefaultDeviceSelectionTimeout is the default timeout, in seconds, waiting for the user to select a device -var DefaultDeviceSelectionTimeout = 60 +var ( + // DefaultDeviceSelectionTimeout is the default timeout, in seconds, waiting for the user to select a device + DefaultDeviceSelectionTimeout = 30 + // MaxAllowedResponseTimeout defines the maximum response timeout, in seconds. + // When exceeding, the timeout will be forced to this value. + MaxAllowedResponseTimeout = 120 +) var emptyAAGUID = make([]byte, 16) @@ -71,13 +76,17 @@ func (a *Webauthn) Register(origin string, req *RegisterRequest) (*RegisterRespo return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) } - if req.Rp.ID == "" { - req.Rp.ID = originURL.Hostname() + if req.Timeout <= 0 { + req.Timeout = DefaultResponseTimeout + } + if req.Timeout > MaxAllowedResponseTimeout { + req.Timeout = MaxAllowedResponseTimeout } - if req.Timeout == 0 { - req.Timeout = DefaultResponseTimeout + if req.Rp.ID == "" { + req.Rp.ID = originURL.Hostname() } + // TODO check RP ID is a valid domain (https://www.w3.org/TR/webauthn/#CreateCred-DetermineRpId) authenticators, userPIN, err := a.selectAuthenticators(req.AuthenticatorSelection) if err != nil { @@ -143,13 +152,17 @@ func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*Authe return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) } - if req.RpID == "" { - req.RpID = originURL.Hostname() + if req.Timeout <= 0 { + req.Timeout = DefaultResponseTimeout + } + if req.Timeout > MaxAllowedResponseTimeout { + req.Timeout = MaxAllowedResponseTimeout } - if req.Timeout == 0 { - req.Timeout = DefaultResponseTimeout + if req.RpID == "" { + req.RpID = originURL.Hostname() } + // TODO check RP ID is a valid domain (https://www.w3.org/TR/webauthn/#CreateCred-DetermineRpId) authenticators, userPIN, err := a.selectAuthenticators(AuthenticatorSelection{ UserVerification: req.UserVerification, diff --git a/webauthn/types.go b/webauthn/types.go index 1a65709..ece9294 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -141,7 +141,7 @@ type CollectedClientData struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` - // TODO tokenBinding ? + // TODO tokenBinding ? (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding) } func (c CollectedClientData) EncodeAndHash() (dataJSON []byte, dataHash []byte, err error) { From f2f9cc1be2158449934eb508366e9b11137e4fad Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 29 Sep 2020 10:28:58 +0200 Subject: [PATCH 24/34] update device capability detection --- u2fhid/hid.go | 15 ++++++++++++--- webauthn/token.go | 13 ++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/u2fhid/hid.go b/u2fhid/hid.go index daf600a..351bf45 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -27,7 +27,9 @@ const ( broadcastChannel = 0xffffffff - capabilityWink = 1 + capabilityWink = 0x1 + capabilityCBOR = 0x4 + capabilityNMSG = 0x8 minMessageLen = 7 maxMessageLen = 7609 @@ -100,9 +102,14 @@ type Device struct { RawCapabilities uint8 // CapabilityWink is true if the device advertised support for the wink - // command during initilization. Even if this flag is true, the device may + // command during initialization. Even if this flag is true, the device may // not actually do anything if the command is called. CapabilityWink bool + // CapabilityCBOR is true when the device support CBOR encoded messages + // used by the CTAP2 protocol + CapabilityCBOR bool + // CababilityNMSG is true when the device support CTAP1 messages + CababilityNMSG bool info *hid.DeviceInfo device hid.Device @@ -249,6 +256,8 @@ func (d *Device) Init() error { d.BuildDeviceVersion = res[15] d.RawCapabilities = res[16] d.CapabilityWink = d.RawCapabilities&capabilityWink != 0 + d.CapabilityCBOR = d.RawCapabilities&capabilityCBOR != 0 + d.CababilityNMSG = d.RawCapabilities&capabilityNMSG == 0 break } @@ -294,7 +303,7 @@ func (d *Device) CBOR(data []byte) ([]byte, error) { } func (d *Device) Cancel() { - d.Command(cmdCancel, nil) + d.sendCommand(d.channel, cmdCancel, nil) } // Close closes the device and frees associated resources. diff --git a/webauthn/token.go b/webauthn/token.go index a963557..5a51918 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -251,14 +251,17 @@ func (a *Webauthn) selectAuthenticators(opts AuthenticatorSelection) ([]Authenti } var current Authenticator - var isCTAP2 bool - t := ctap2.NewToken(dev) - if info, err := t.GetInfo(); err == nil { + if dev.CapabilityCBOR { + t := ctap2.NewToken(dev) + info, err := t.GetInfo() + if err != nil { + return nil, nil, err + } + current = &ctap2WebauthnToken{ t: t, options: info.Options, } - isCTAP2 = true } else { current = &ctap1WebauthnToken{ t: u2ftoken.NewToken(dev), @@ -272,7 +275,7 @@ func (a *Webauthn) selectAuthenticators(opts AuthenticatorSelection) ([]Authenti if opts.UserVerification == UVDiscouraged && current.RequireUV() { continue } - if opts.UserVerification == UVRequired && !isCTAP2 { + if opts.UserVerification == UVRequired && !dev.CapabilityCBOR { continue } From 91a9b6bc38fece01c7f3672ebd326f78efaa490e Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 29 Sep 2020 15:26:47 +0200 Subject: [PATCH 25/34] improve request cancelation handling --- ctap2token/token.go | 7 +-- u2fhid/hid.go | 12 ++-- u2ftoken/token.go | 7 +-- webauthn/ctap1.go | 26 ++++---- webauthn/ctap2.go | 8 +-- webauthn/example/demo.yubico.com/main.go | 5 +- webauthn/example/webauthn.io/main.go | 5 +- webauthn/token.go | 79 +++++++++++------------- webauthn/types.go | 5 +- 9 files changed, 66 insertions(+), 88 deletions(-) diff --git a/ctap2token/token.go b/ctap2token/token.go index 662452d..dbd6bde 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -125,7 +125,6 @@ type Device interface { CBOR(data []byte) ([]byte, error) // Message sends a CTAP1 message to the device and returns the response. Message(data []byte) ([]byte, error) - Init() error // SetResponseTimeout allow to control the maximum time to wait for the device response SetResponseTimeout(timeout time.Duration) } @@ -348,7 +347,7 @@ func (t *Token) AuthenticatorSelection(ctx context.Context) error { if err != u2ftoken.ErrPresenceRequired { return err } - time.Sleep(500 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } } } @@ -366,10 +365,6 @@ func (t *Token) Reset() error { return checkResponse(resp) } -func (t *Token) Cancel() { - t.d.Init() -} - func (t *Token) SetResponseTimeout(timeout time.Duration) { t.d.SetResponseTimeout(timeout) } diff --git a/u2fhid/hid.go b/u2fhid/hid.go index 351bf45..84dfc03 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -14,16 +14,16 @@ import ( ) const ( - cmdPing = 0x80 | 0x01 - cmdMsg = 0x80 | 0x03 - cmdLock = 0x80 | 0x04 + cmdPing = 0x80 | 0x01 + cmdMsg = 0x80 | 0x03 + //cmdLock = 0x80 | 0x04 cmdInit = 0x80 | 0x06 cmdWink = 0x80 | 0x08 cmdCbor = 0x80 | 0x10 cmdCancel = 0x80 | 0x11 cmdKeepAlive = 0x80 | 0x3b - cmdSync = 0x80 | 0x3c - cmdError = 0x80 | 0x3f + //cmdSync = 0x80 | 0x3c + cmdError = 0x80 | 0x3f broadcastChannel = 0xffffffff @@ -303,7 +303,7 @@ func (d *Device) CBOR(data []byte) ([]byte, error) { } func (d *Device) Cancel() { - d.sendCommand(d.channel, cmdCancel, nil) + _, _ = d.Command(cmdCancel, nil) } // Close closes the device and frees associated resources. diff --git a/u2ftoken/token.go b/u2ftoken/token.go index c45516e..0913583 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -62,7 +62,6 @@ var ErrUnknownKeyHandle = errors.New("u2ftoken: unknown key handle") type Device interface { // Message sends a message to the device and returns the response. Message(data []byte) ([]byte, error) - Init() error SetResponseTimeout(timeout time.Duration) } @@ -308,7 +307,7 @@ func (t *Token) AuthenticatorSelection(ctx context.Context) error { if err != ErrPresenceRequired { return err } - time.Sleep(500 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } } } @@ -351,10 +350,6 @@ func (t *Token) Message(req Request) (*Response, error) { }, nil } -func (t *Token) Cancel() { - t.d.Init() -} - func (t *Token) SetResponseTimeout(timeout time.Duration) { t.d.SetResponseTimeout(timeout) } diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index d6a25ec..360336c 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -16,7 +16,7 @@ type ctap1WebauthnToken struct { t *u2ftoken.Token } -func (w *ctap1WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { +func (w *ctap1WebauthnToken) Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { useES256 := false for _, cp := range req.PubKeyCredParams { if crypto.Alg(cp.Alg) == crypto.ES256 { @@ -66,10 +66,10 @@ func (w *ctap1WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (* } } - resp, err := w.registerWithTimeout(&u2ftoken.RegisterRequest{ + resp, err := w.waitRegister(ctx, &u2ftoken.RegisterRequest{ Application: rpIDHash, Challenge: clientDataHash, - }, time.Duration(req.Timeout)) + }) if err != nil { return nil, err } @@ -126,7 +126,7 @@ func (w *ctap1WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (* }, nil } -func (w *ctap1WebauthnToken) Authenticate(req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { +func (w *ctap1WebauthnToken) Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { if len(req.AllowCredentials) == 0 { return nil, errors.New("webauthn: ctap1 require at least one credential") } @@ -160,7 +160,7 @@ func (w *ctap1WebauthnToken) Authenticate(req *AuthenticateRequest, p *RequestPa } } - authResp, err := w.authenticateWithTimeout(authReq, time.Duration(req.Timeout)) + authResp, err := w.waitAuthenticate(ctx, authReq) if err != nil { return nil, err } @@ -192,19 +192,15 @@ func (w *ctap1WebauthnToken) SupportRK() bool { return false } -func (w *ctap1WebauthnToken) Cancel() { - w.t.Cancel() -} - func (w *ctap1WebauthnToken) SetResponseTimeout(timeout time.Duration) { w.t.SetResponseTimeout(timeout) } -func (w *ctap1WebauthnToken) registerWithTimeout(req *u2ftoken.RegisterRequest, timeout time.Duration) (*u2ftoken.RegisterResponse, error) { +func (w *ctap1WebauthnToken) waitRegister(ctx context.Context, req *u2ftoken.RegisterRequest) (*u2ftoken.RegisterResponse, error) { for { select { - case <-time.After(timeout * time.Second): - return nil, u2ftoken.ErrPresenceRequired + case <-ctx.Done(): + return nil, ctx.Err() default: resp, err := w.t.Register(*req) if err != nil { @@ -219,11 +215,11 @@ func (w *ctap1WebauthnToken) registerWithTimeout(req *u2ftoken.RegisterRequest, } } -func (w *ctap1WebauthnToken) authenticateWithTimeout(req *u2ftoken.AuthenticateRequest, timeout time.Duration) (*u2ftoken.AuthenticateResponse, error) { +func (w *ctap1WebauthnToken) waitAuthenticate(ctx context.Context, req *u2ftoken.AuthenticateRequest) (*u2ftoken.AuthenticateResponse, error) { for { select { - case <-time.After(timeout * time.Second): - return nil, u2ftoken.ErrPresenceRequired + case <-ctx.Done(): + return nil, ctx.Err() default: resp, err := w.t.Authenticate(*req) if err != nil { diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index 421f00a..8b55183 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -24,7 +24,7 @@ type ctap2WebauthnToken struct { options map[string]bool } -func (w *ctap2WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { +func (w *ctap2WebauthnToken) Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { credTypesAndPubKeyAlgs := make([]ctap2.CredentialParam, 0, len(req.PubKeyCredParams)) for _, cp := range req.PubKeyCredParams { t, ok := supportedCTAP2CredentialTypes[cp.Type] @@ -158,7 +158,7 @@ func (w *ctap2WebauthnToken) Register(req *RegisterRequest, p *RequestParams) (* }, nil } -func (w *ctap2WebauthnToken) Authenticate(req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { +func (w *ctap2WebauthnToken) Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { // TODO add support for extensions (bullet point 8 from https://www.w3.org/TR/2020/WD-webauthn-2-20200730/#sctn-discover-from-external-source) clientExtensions := make(map[string]interface{}) @@ -227,10 +227,6 @@ func (w *ctap2WebauthnToken) AuthenticatorSelection(ctx context.Context) error { return w.t.AuthenticatorSelection(ctx) } -func (w *ctap2WebauthnToken) Cancel() { - w.t.Cancel() -} - func (w *ctap2WebauthnToken) RequireUV() bool { return w.options["clientPin"] } diff --git a/webauthn/example/demo.yubico.com/main.go b/webauthn/example/demo.yubico.com/main.go index baa6a8f..02af526 100644 --- a/webauthn/example/demo.yubico.com/main.go +++ b/webauthn/example/demo.yubico.com/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "flag" @@ -86,7 +87,7 @@ func register(t *webauthn.Webauthn, host string) error { } fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) - webauthnResp, err := t.Register(host, respData.Data.PublicKey) + webauthnResp, err := t.Register(context.Background(), host, respData.Data.PublicKey) if err != nil { return err } @@ -183,7 +184,7 @@ func authenticate(t *webauthn.Webauthn, host, session string) error { } fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) - webauthnResp, err := t.Authenticate(host, respData.Data.PublicKey) + webauthnResp, err := t.Authenticate(context.Background(), host, respData.Data.PublicKey) if err != nil { panic(err) } diff --git a/webauthn/example/webauthn.io/main.go b/webauthn/example/webauthn.io/main.go index 819f33e..853ecfc 100644 --- a/webauthn/example/webauthn.io/main.go +++ b/webauthn/example/webauthn.io/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/base64" "encoding/json" "errors" @@ -79,7 +80,7 @@ func register(t *webauthn.Webauthn, username, host string) error { } fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) - webauthnResp, err := t.Register(host, webauthnReq.PublicKey) + webauthnResp, err := t.Register(context.Background(), host, webauthnReq.PublicKey) if err != nil { return err } @@ -157,7 +158,7 @@ func authenticate(t *webauthn.Webauthn, username, host string) error { } fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) - webauthnResp, err := t.Authenticate(host, authReq.PublicKey) + webauthnResp, err := t.Authenticate(context.Background(), host, authReq.PublicKey) if err != nil { return err } diff --git a/webauthn/token.go b/webauthn/token.go index 5a51918..33bbc75 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -67,7 +67,7 @@ func New(opts ...WebauthnOption) *Webauthn { return a } -func (a *Webauthn) Register(origin string, req *RegisterRequest) (*RegisterResponse, error) { +func (a *Webauthn) Register(ctx context.Context, origin string, req *RegisterRequest) (*RegisterResponse, error) { originURL, err := url.Parse(origin) if err != nil { return nil, fmt.Errorf("webauthn: invalid origin: %w", err) @@ -86,9 +86,8 @@ func (a *Webauthn) Register(origin string, req *RegisterRequest) (*RegisterRespo if req.Rp.ID == "" { req.Rp.ID = originURL.Hostname() } - // TODO check RP ID is a valid domain (https://www.w3.org/TR/webauthn/#CreateCred-DetermineRpId) - authenticators, userPIN, err := a.selectAuthenticators(req.AuthenticatorSelection) + authenticators, userPIN, err := a.selectAuthenticators(ctx, req.AuthenticatorSelection) if err != nil { return nil, err } @@ -101,11 +100,15 @@ func (a *Webauthn) Register(origin string, req *RegisterRequest) (*RegisterRespo respChan := make(chan *authenticatorResponse) + timeout := time.Duration(req.Timeout) * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + // Send the request to all selected authenticators for _, authenticator := range authenticators { go func(a Authenticator) { - a.SetResponseTimeout(time.Duration(req.Timeout) * time.Second) - resp, err := a.Register(req, &RequestParams{ + // make sure the HID cnx stay open at least as long as the request needs it. + a.SetResponseTimeout(timeout) + resp, err := a.Register(ctx, req, &RequestParams{ ClientData: CollectedClientData{ Type: "webauthn.create", Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), @@ -124,26 +127,15 @@ func (a *Webauthn) Register(origin string, req *RegisterRequest) (*RegisterRespo select { case authResp := <-respChan: // cancel any other pending authenticators - for _, a := range authenticators { - if a == authResp.authenticator { - continue - } - a.Cancel() - } - - if authResp.err != nil { - return nil, authResp.err - } - return authResp.resp, nil + cancel() + return authResp.resp, authResp.err case <-time.After(time.Duration(req.Timeout) * time.Second): - for _, a := range authenticators { - a.Cancel() - } + cancel() return nil, errors.New("webauthn: timeout waiting for authenticator response") } } -func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { +func (a *Webauthn) Authenticate(ctx context.Context, origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { originURL, err := url.Parse(origin) if err != nil { return nil, fmt.Errorf("webauthn: invalid origin: %w", err) @@ -162,9 +154,8 @@ func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*Authe if req.RpID == "" { req.RpID = originURL.Hostname() } - // TODO check RP ID is a valid domain (https://www.w3.org/TR/webauthn/#CreateCred-DetermineRpId) - authenticators, userPIN, err := a.selectAuthenticators(AuthenticatorSelection{ + authenticators, userPIN, err := a.selectAuthenticators(ctx, AuthenticatorSelection{ UserVerification: req.UserVerification, }) if err != nil { @@ -179,11 +170,15 @@ func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*Authe respChan := make(chan *authenticatorResponse) + timeout := time.Duration(req.Timeout) * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + // Send the request to all selected authenticators for _, authenticator := range authenticators { go func(a Authenticator) { - a.SetResponseTimeout(time.Duration(req.Timeout) * time.Second) - resp, err := a.Authenticate(req, &RequestParams{ + // make sure the HID cnx stay open at least as long as the request needs it. + a.SetResponseTimeout(timeout) + resp, err := a.Authenticate(ctx, req, &RequestParams{ ClientData: CollectedClientData{ Type: "webauthn.get", Challenge: base64.RawURLEncoding.EncodeToString(req.Challenge), @@ -202,21 +197,10 @@ func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*Authe select { case authResp := <-respChan: // cancel any other pending authenticators - for _, a := range authenticators { - if a == authResp.authenticator { - continue - } - a.Cancel() - } - - if authResp.err != nil { - return nil, authResp.err - } - return authResp.resp, nil + cancel() + return authResp.resp, authResp.err case <-time.After(time.Duration(req.Timeout) * time.Second): - for _, a := range authenticators { - a.Cancel() - } + cancel() return nil, errors.New("webauthn: timeout waiting for authenticator response") } } @@ -226,7 +210,7 @@ func (a *Webauthn) Authenticate(origin string, req *AuthenticateRequest) (*Authe // requirements. // If user verification is required, the user will be prompted to enter the device PIN, or to set it. The PIN will // be returned in order to be exchanged later for a pinAuth code (see pin.ExchangeUserPinToPinAuth). -func (a *Webauthn) selectAuthenticators(opts AuthenticatorSelection) ([]Authenticator, []byte, error) { +func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorSelection) ([]Authenticator, []byte, error) { var selected []Authenticator var userPIN []byte @@ -288,12 +272,23 @@ func (a *Webauthn) selectAuthenticators(opts AuthenticatorSelection) ([]Authenti // This is done by sending fake ctap1 register requests to all devices, with a test-user-presence flag. // The first device to reply a non error is assumed selected by the user. if opts.UserVerification != UVDiscouraged { - selectedAuth := selected[0] + // if we require UV, have multiple devies, and at least one + // support CTAP2, we must request the user to select the device first. + // when having multiple CTAP1 devices only, we just skip selection, the user presence test will + // select the device. + ctap2DevicePresent := false + for _, s := range selected { + if _, isCTAP2 := s.(*ctap2WebauthnToken); isCTAP2 { + ctap2DevicePresent = true + break + } + } - if len(selected) > 1 { + selectedAuth := selected[0] + if len(selected) > 1 && ctap2DevicePresent { a.pinHandler.Println("multiple security keys found. Please select one by touching it...") respChan := make(chan Authenticator) - ctx, cancel := context.WithTimeout(context.Background(), a.deviceSelectionTimeout) + ctx, cancel := context.WithTimeout(ctx, a.deviceSelectionTimeout) defer cancel() for _, s := range selected { go func(auth Authenticator) { diff --git a/webauthn/types.go b/webauthn/types.go index ece9294..c849389 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -13,16 +13,15 @@ import ( type Authenticator interface { // Register is the equivalent to navigator.credential.create() - Register(req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) + Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) // Authenticate is the equivalent to navigator.credential.get() - Authenticate(req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) + Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) AuthenticatorSelection(ctx context.Context) error SetResponseTimeout(timeout time.Duration) RequireUV() bool SupportRK() bool - Cancel() } type RequestParams struct { From 4f136337ac266a5b27f65c8aa756a549397d400c Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 29 Sep 2020 15:58:43 +0200 Subject: [PATCH 26/34] renamed device selection document --- doc/{DEVICE_SELECTION.md => WEBAUTHN_DEVICE_SELECTION.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/{DEVICE_SELECTION.md => WEBAUTHN_DEVICE_SELECTION.md} (100%) diff --git a/doc/DEVICE_SELECTION.md b/doc/WEBAUTHN_DEVICE_SELECTION.md similarity index 100% rename from doc/DEVICE_SELECTION.md rename to doc/WEBAUTHN_DEVICE_SELECTION.md From f026a3e5ddfd9c83aa3dd3f0da2758f38f0a4db5 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Tue, 29 Sep 2020 16:20:14 +0200 Subject: [PATCH 27/34] improve pin input pin input now use golang.org/x/crypto/ssh/terminal to not be echoed on stdout anymore. The library is expected to work on *any* go supported OS terminals, but only tested on linux. --- ctap2token/pin/pin.go | 19 ++++++++----------- go.mod | 1 + go.sum | 8 ++++++++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go index 4c15808..23d3985 100644 --- a/ctap2token/pin/pin.go +++ b/ctap2token/pin/pin.go @@ -1,7 +1,6 @@ package pin import ( - "bufio" "bytes" "crypto/aes" "crypto/cipher" @@ -11,12 +10,12 @@ import ( "crypto/sha256" "errors" "fmt" - "io" "math/big" "os" "github.com/flynn/u2f/crypto" "github.com/flynn/u2f/ctap2token" + "golang.org/x/crypto/ssh/terminal" ) const ( @@ -31,8 +30,8 @@ type PINHandler interface { } type InteractiveHandler struct { - Stdin io.Reader - Stdout io.Writer + Stdin *os.File + Stdout *os.File } var _ PINHandler = (*InteractiveHandler)(nil) @@ -266,11 +265,9 @@ func computePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { return pinAuth[:16], nil } -// TODO: improve password input (with no tty echo) -func getpasswd(r io.Reader) ([]byte, error) { - pin, err := bufio.NewReader(r).ReadString('\n') - if err != nil { - return nil, err - } - return []byte(pin[:len(pin)-1]), nil +func getpasswd(r *os.File) ([]byte, error) { + pin, err := terminal.ReadPassword(int(r.Fd())) + // since terminal disable tty echo, we need a newline to keep the display organized + fmt.Println() + return pin, err } diff --git a/go.mod b/go.mod index e6f2efa..1a52b92 100644 --- a/go.mod +++ b/go.mod @@ -8,5 +8,6 @@ require ( github.com/fxamacker/cbor/v2 v2.2.0 github.com/kr/pretty v0.1.0 // indirect github.com/stretchr/testify v1.6.1 + golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index bf37093..aa00cac 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,14 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= From 1897ff27887df4b7d1e45522a033d0a34f78b067 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Wed, 30 Sep 2020 16:32:29 +0200 Subject: [PATCH 28/34] improve cancel on CTAP2 devices --- ctap2token/example/main.go | 2 +- ctap2token/token.go | 53 ++++++++++++++++++++++++++------------ u2fhid/hid.go | 20 +++++++------- u2ftoken/token.go | 5 ++++ webauthn/ctap1.go | 4 +++ webauthn/ctap2.go | 9 +++++-- webauthn/token.go | 34 ++++++++++++++++++++---- webauthn/types.go | 1 + 8 files changed, 94 insertions(+), 34 deletions(-) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 765d9d9..dd3c8b8 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -84,7 +84,7 @@ func main() { if err != nil { panic(err) } - req.PinUVAuth = pinAuth + req.PinUVAuth = &pinAuth req.PinUVAuthProtocol = ctap2token.PinProtoV1 resp, err = token.MakeCredential(req) diff --git a/ctap2token/token.go b/ctap2token/token.go index dbd6bde..30d985e 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -2,13 +2,13 @@ package ctap2token import ( "context" + "crypto/sha256" "encoding/binary" "errors" "fmt" "time" "github.com/flynn/u2f/crypto" - "github.com/flynn/u2f/u2ftoken" "github.com/fxamacker/cbor/v2" ) @@ -127,6 +127,8 @@ type Device interface { Message(data []byte) ([]byte, error) // SetResponseTimeout allow to control the maximum time to wait for the device response SetResponseTimeout(timeout time.Duration) + Cancel() + Close() } // NewToken returns a token that will use Device to communicate with the device. @@ -150,7 +152,9 @@ type MakeCredentialRequest struct { Options AuthenticatorOptions `cbor:"7,keyasint,omitempty"` // PinUVAuth is the first 16 bytes of HMAC-SHA-256 of clientDataHash using // pinToken which platform got from the authenticator - PinUVAuth []byte `cbor:"8,keyasint,omitempty"` + // we need a pointer here to distinguish nil pinUVAuth from empty. + // When nil, it must be omitted from the CBOR encoded request, but included when empty, + PinUVAuth *[]byte `cbor:"8,keyasint,omitempty"` // PinUVAuthProtocol is the PIN protocol version chosen by the client PinUVAuthProtocol PinUVAuthProtocolVersion `cbor:"9,keyasint,omitempty"` } @@ -334,21 +338,28 @@ func (t *Token) ClientPIN(req *ClientPINRequest) (*ClientPINResponse, error) { } func (t *Token) AuthenticatorSelection(ctx context.Context) error { - ctap1Token := u2ftoken.NewToken(t.d) - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - _, err := ctap1Token.Register(u2ftoken.RegisterRequest{ - Application: make([]byte, 32), - Challenge: make([]byte, 32), - }) - if err != u2ftoken.ErrPresenceRequired { - return err - } - time.Sleep(200 * time.Millisecond) - } + dummyHash := make([]byte, sha256.Size) + _, err := t.MakeCredential(&MakeCredentialRequest{ + ClientDataHash: dummyHash, + User: CredentialUserEntity{ + ID: []byte{0x1}, + Name: "dummy", + }, + RP: CredentialRpEntity{ + ID: ".dummy", + }, + PubKeyCredParams: []CredentialParam{ + PublicKeyES256, + }, + PinUVAuth: &[]byte{}, + PinUVAuthProtocol: PinProtoV1, + }) + + switch errors.Unwrap(err) { + case nil, ErrPinAuthInvalid, ErrPinNotSet: + return nil + default: + return err } } @@ -369,6 +380,14 @@ func (t *Token) SetResponseTimeout(timeout time.Duration) { t.d.SetResponseTimeout(timeout) } +func (t *Token) Cancel() { + t.d.Cancel() +} + +func (t *Token) Close() { + t.d.Close() +} + func checkResponse(resp []byte) error { if len(resp) == 0 { return errors.New("ctap2token: empty response") diff --git a/u2fhid/hid.go b/u2fhid/hid.go index 84dfc03..0d488b0 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -42,14 +42,15 @@ const ( ) var errorCodes = map[uint8]string{ - 1: "invalid command", - 2: "invalid parameter", - 3: "invalid message length", - 4: "invalid message sequencing", - 5: "message timed out", - 6: "channel busy", - 7: "command requires channel lock", - 8: "sync command failed", + 0x01: "invalid command", + 0x02: "invalid parameter", + 0x03: "invalid message length", + 0x04: "invalid message sequencing", + 0x05: "message timed out", + 0x06: "channel busy", + 0x07: "command requires channel lock", + 0x08: "sync command failed", + 0x2d: "pending keep alive was cancelled", } // Devices lists available HID devices that advertise the U2F HID protocol. @@ -303,7 +304,8 @@ func (d *Device) CBOR(data []byte) ([]byte, error) { } func (d *Device) Cancel() { - _, _ = d.Command(cmdCancel, nil) + // As the cancel command is sent during an ongoing transaction, transaction semantics do not apply. + _ = d.sendCommand(d.channel, cmdCancel, nil) } // Close closes the device and frees associated resources. diff --git a/u2ftoken/token.go b/u2ftoken/token.go index 0913583..5f03647 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -63,6 +63,7 @@ type Device interface { // Message sends a message to the device and returns the response. Message(data []byte) ([]byte, error) SetResponseTimeout(timeout time.Duration) + Close() } // NewToken returns a token that will use Device to communicate with the device. @@ -353,3 +354,7 @@ func (t *Token) Message(req Request) (*Response, error) { func (t *Token) SetResponseTimeout(timeout time.Duration) { t.d.SetResponseTimeout(timeout) } + +func (t *Token) Close() { + t.d.Close() +} diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index 360336c..5aa5adf 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -196,6 +196,10 @@ func (w *ctap1WebauthnToken) SetResponseTimeout(timeout time.Duration) { w.t.SetResponseTimeout(timeout) } +func (w *ctap1WebauthnToken) Close() { + w.t.Close() +} + func (w *ctap1WebauthnToken) waitRegister(ctx context.Context, req *u2ftoken.RegisterRequest) (*u2ftoken.RegisterResponse, error) { for { select { diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index 8b55183..4e507a0 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -79,13 +79,14 @@ func (w *ctap2WebauthnToken) Register(ctx context.Context, req *RegisterRequest, } var pinProtocol ctap2.PinUVAuthProtocolVersion - var pinUVAuth []byte + var pinUVAuth *[]byte if len(p.UserPIN) > 0 { var err error - pinUVAuth, err = pin.ExchangeUserPinToPinAuth(w.t, p.UserPIN, clientDataHash) + uvAuth, err := pin.ExchangeUserPinToPinAuth(w.t, p.UserPIN, clientDataHash) if err != nil { return nil, err } + pinUVAuth = &uvAuth pinProtocol = ctap2.PinProtoV1 } resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ @@ -238,3 +239,7 @@ func (w *ctap2WebauthnToken) SupportRK() bool { func (w *ctap2WebauthnToken) SetResponseTimeout(timeout time.Duration) { w.t.SetResponseTimeout(timeout) } + +func (w *ctap2WebauthnToken) Close() { + w.t.Close() +} diff --git a/webauthn/token.go b/webauthn/token.go index 33bbc75..a89e6af 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -126,11 +126,13 @@ func (a *Webauthn) Register(ctx context.Context, origin string, req *RegisterReq select { case authResp := <-respChan: - // cancel any other pending authenticators + // cancel any other pending CTAP1 authenticators cancel() + closeAll(authenticators) return authResp.resp, authResp.err case <-time.After(time.Duration(req.Timeout) * time.Second): cancel() + closeAll(authenticators) return nil, errors.New("webauthn: timeout waiting for authenticator response") } } @@ -196,15 +198,23 @@ func (a *Webauthn) Authenticate(ctx context.Context, origin string, req *Authent select { case authResp := <-respChan: - // cancel any other pending authenticators + // cancel any other pending CTAP1 authenticators cancel() + closeAll(authenticators) return authResp.resp, authResp.err case <-time.After(time.Duration(req.Timeout) * time.Second): + closeAll(authenticators) cancel() return nil, errors.New("webauthn: timeout waiting for authenticator response") } } +func closeAll(auths []Authenticator) { + for _, a := range auths { + a.Close() + } +} + // selectAuthenticators guide the user into selecting the authenticator to communicate with. // One or multiple devices can be returned depending on their supported protocols and the AuthenticatorSelection // requirements. @@ -254,12 +264,15 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS // Skip devices not fullfilling request requirements if opts.RequireResidentKey && !current.SupportRK() { + dev.Close() continue } if opts.UserVerification == UVDiscouraged && current.RequireUV() { + dev.Close() continue } if opts.UserVerification == UVRequired && !dev.CapabilityCBOR { + dev.Close() continue } @@ -301,17 +314,28 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS select { case selectedAuth = <-respChan: + // cancel CTAP1 selection routines + cancel() + for _, s := range selected { + if s == selectedAuth { + continue + } + // send a cancel command to CTAP2 devices (they cannot be canceled via go context) + if a, ok := s.(*ctap2WebauthnToken); ok { + a.t.Cancel() + } + // close all devices not selected + s.Close() + } selected = []Authenticator{selectedAuth} a.pinHandler.Println("device selected!") - cancel() // cancel other selection routines case <-ctx.Done(): return nil, nil, ctx.Err() } } // Collect PIN or guide user to set a PIN on CTAP2 authenticators - ctap2Auth, isCTAP2 := selectedAuth.(*ctap2WebauthnToken) - if isCTAP2 { + if ctap2Auth, isCTAP2 := selectedAuth.(*ctap2WebauthnToken); isCTAP2 { var err error if !selectedAuth.RequireUV() { userPIN, err = a.pinHandler.SetPIN(ctap2Auth.t) diff --git a/webauthn/types.go b/webauthn/types.go index c849389..c72764a 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -22,6 +22,7 @@ type Authenticator interface { SetResponseTimeout(timeout time.Duration) RequireUV() bool SupportRK() bool + Close() } type RequestParams struct { From f65213ab7590863d73326b29c3a9d4dedee801c0 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Fri, 22 Jan 2021 10:56:59 +0100 Subject: [PATCH 29/34] fix review comments --- crypto/cose.go | 13 ++++--- ctap2token/example/main.go | 10 ++--- ctap2token/pin/pin.go | 64 +++++++++++++++++++++----------- ctap2token/token.go | 2 +- doc/WEBAUTHN_DEVICE_SELECTION.md | 2 +- u2ftoken/example/main.go | 4 -- u2ftoken/token.go | 8 ++++ webauthn/ctap2.go | 4 +- webauthn/types.go | 1 - 9 files changed, 67 insertions(+), 41 deletions(-) diff --git a/crypto/cose.go b/crypto/cose.go index 37b4017..224db26 100644 --- a/crypto/cose.go +++ b/crypto/cose.go @@ -1,10 +1,11 @@ package crypto -import "github.com/fxamacker/cbor/v2" +import ( + "github.com/fxamacker/cbor/v2" +) // COSEKey, as defined per https://tools.ietf.org/html/rfc8152#section-7.1 -// Only support Elliptic Curve Public keys for now. -// TODO: find a way to support all key types defined in the RFC +// Only supports Elliptic Curve Public keys. type COSEKey struct { Y []byte `cbor:"-3,keyasint,omitempty"` X []byte `cbor:"-2,keyasint,omitempty"` @@ -30,9 +31,9 @@ func (k *COSEKey) CBOREncode() ([]byte, error) { type KeyType int const ( - // OKP means Octet Key Pair + // OKP is an Octet Key Pair OKP KeyType = 0x01 - // EC2 means Elliptic Curve Keys + // EC2 is an Elliptic Curve Key EC2 KeyType = 0x02 ) @@ -63,7 +64,7 @@ const ( MACVerify ) -// Alg must be the value of one of the algorithms registered on +// Alg must be the value of one of the algorithms registered in // https://www.iana.org/assignments/cose/cose.xhtml#algorithms. type Alg int diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index dd3c8b8..94477c1 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -30,12 +30,12 @@ func main() { token := ctap2token.NewToken(dev) - infos, err := token.GetInfo() + info, err := token.GetInfo() if err != nil { - fmt.Printf("failed to retrieve token info (%v), is the token supporting CTAP2 ?\n", err) + fmt.Printf("failed to retrieve token info (%v), does the token support CTAP2 ?\n", err) continue } - fmt.Printf("Token infos:\n%#v\n", infos) + fmt.Printf("Token info:\n%#v\n", info) clientDataHash := make([]byte, 32) if _, err := rand.Read(clientDataHash); err != nil { @@ -80,7 +80,7 @@ func main() { panic(err) } - pinAuth, err := pin.ExchangeUserPinToPinAuth(token, userPIN, clientDataHash) + pinAuth, err := pin.ExchangeUserPin(token, userPIN, clientDataHash) if err != nil { panic(err) } @@ -92,7 +92,7 @@ func main() { panic(err) } } - fmt.Println("Success creating credential") + fmt.Println("Successfully created credential") // Verify signature with the X509 certificate from the attestation statement x509certs, ok := resp.AttSmt["x5c"] diff --git a/ctap2token/pin/pin.go b/ctap2token/pin/pin.go index 23d3985..707f5b0 100644 --- a/ctap2token/pin/pin.go +++ b/ctap2token/pin/pin.go @@ -37,7 +37,7 @@ type InteractiveHandler struct { var _ PINHandler = (*InteractiveHandler)(nil) // NewInteractiveHandler returns an interactive PINHandler, which will read -// the user PIN from the provided reader +// the user PIN from the provided reader. func NewInteractiveHandler() *InteractiveHandler { return &InteractiveHandler{ Stdin: os.Stdin, @@ -63,14 +63,10 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { if err != nil { return nil, err } - - // checks from https://fidoalliance.org/specs/fido2/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#client-pin-uv-support - if l := len(userPIN); l < PinLengthMin || l > PinLengthMax { - return nil, errors.New("invalid pin, must be between 4 to 63 bytes") - } - if userPIN[len(userPIN)-1] == 0 { - return nil, errors.New("invalid pin, must not end with a NUL byte") + if err := validateUserPIN(userPIN); err != nil { + return nil, err } + _, err = fmt.Fprint(h.Stdout, "confirm new device PIN: ") if err != nil { return nil, err @@ -83,17 +79,46 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { return nil, errors.New("pin confirmation mismatch") } + if err := setTokenPIN(token, userPIN); err != nil { + return nil, err + } + + return userPIN, nil +} + +func (h *InteractiveHandler) Println(msg ...interface{}) { + fmt.Fprintln(h.Stdout, msg...) +} + +// validateUserPIN performs checks described from +// https://fidoalliance.org/specs/fido2/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#client-pin-uv-support +// and returns an error if the user PIN is invalid. +func validateUserPIN(userPIN []byte) error { + if l := len(userPIN); l < PinLengthMin || l > PinLengthMax { + return errors.New("invalid pin, must be between 4 to 63 bytes") + } + if userPIN[len(userPIN)-1] == 0 { + return errors.New("invalid pin, must not end with a NUL byte") + } + return nil +} + +func setTokenPIN(token *ctap2token.Token, userPIN []byte) error { + if err := validateUserPIN(userPIN); err != nil { + return err + } + aGX, aGY, err := getTokenKeyAgreement(token) if err != nil { - return nil, err + return err } b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { - return nil, err + return err } sharedSecret, err := computeSharedSecret(b, aGX, aGY) if err != nil { - return nil, err + return err } // Normalize pin size to 64 bytes, padding with zeroes @@ -101,7 +126,7 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { copy(newPIN, userPIN) newPinEnc, err := aesCBCEncrypt(sharedSecret, newPIN) if err != nil { - return nil, err + return err } keyAgreement := &crypto.COSEKey{ @@ -115,7 +140,7 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { mac := hmac.New(sha256.New, sharedSecret) _, err = mac.Write(newPinEnc) if err != nil { - return nil, err + return err } pinAuth := mac.Sum(nil)[:16] @@ -127,19 +152,16 @@ func (h *InteractiveHandler) SetPIN(token *ctap2token.Token) ([]byte, error) { PinAuth: pinAuth, }) if err != nil { - return nil, err + return err } - return userPIN, nil -} -func (h *InteractiveHandler) Println(msg ...interface{}) { - fmt.Fprintln(h.Stdout, msg...) + return nil } -// ExchangeUserPinToPinAuth performs the operations described by the FIDO specification in order to securely +// ExchangeUserPin performs the operations described by the FIDO specification in order to securely // obtain a token from the authenticator which can be used to verify the user. // see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#gettingSharedSecret -func ExchangeUserPinToPinAuth(token *ctap2token.Token, userPIN, clientDataHash []byte) ([]byte, error) { +func ExchangeUserPin(token *ctap2token.Token, userPIN, clientDataHash []byte) ([]byte, error) { b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err @@ -267,7 +289,7 @@ func computePINAuth(pinToken, sharedSecret, data []byte) ([]byte, error) { func getpasswd(r *os.File) ([]byte, error) { pin, err := terminal.ReadPassword(int(r.Fd())) - // since terminal disable tty echo, we need a newline to keep the display organized + // since terminal disables tty echo, we need a newline to keep the display organized fmt.Println() return pin, err } diff --git a/ctap2token/token.go b/ctap2token/token.go index 30d985e..795ce7c 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -125,7 +125,7 @@ type Device interface { CBOR(data []byte) ([]byte, error) // Message sends a CTAP1 message to the device and returns the response. Message(data []byte) ([]byte, error) - // SetResponseTimeout allow to control the maximum time to wait for the device response + // SetResponseTimeout sets the maximum time to wait for a response from the device. SetResponseTimeout(timeout time.Duration) Cancel() Close() diff --git a/doc/WEBAUTHN_DEVICE_SELECTION.md b/doc/WEBAUTHN_DEVICE_SELECTION.md index 5ba1c02..16b2a83 100644 --- a/doc/WEBAUTHN_DEVICE_SELECTION.md +++ b/doc/WEBAUTHN_DEVICE_SELECTION.md @@ -1,6 +1,6 @@ # Device selection -When multiple authenticators are available, the webauthn specification isn't really clear how to proceed (see for example https://www.w3.org/TR/webauthn/#createCredential, starting at bullet point 19). Some browsers doesn't even support CTAP2 and rely exclusively on CTAP1/U2F protocol, thus making it impossible to use webauthn with user verification in required mode, or they downgrade the preferred mode to behave like the discouraged one, like Firefox 80.0.1 under Linux. +When multiple authenticators are available, the webauthn specification isn't really clear how to proceed (see for example https://www.w3.org/TR/webauthn/#createCredential, starting at bullet point 19). Some browsers don't support CTAP2 and rely exclusively on CTAP1/U2F protocol, thus making it impossible to use webauthn with user verification in required mode, or they downgrade the preferred mode to behave like the discouraged one, like Firefox 80.0.1 under Linux. However, Windows browsers seem to offer a better support for CTAP2, and the following flow have been observed: ``` diff --git a/u2ftoken/example/main.go b/u2ftoken/example/main.go index 4d6496a..8a17c1b 100644 --- a/u2ftoken/example/main.go +++ b/u2ftoken/example/main.go @@ -85,10 +85,6 @@ func main() { log.Fatal(err) } - if _, err := io.ReadFull(rand.Reader, challenge); err != nil { - log.Fatal(err) - } - log.Println("authenticating, provide user presence") for { res, err := t.Authenticate(req) diff --git a/u2ftoken/token.go b/u2ftoken/token.go index 5f03647..2edc282 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -131,9 +131,17 @@ func (t *Token) Register(req RegisterRequest) (*RegisterResponse, error) { } } + if len(res.Data) < 67 { + return nil, fmt.Errorf("u2ftoken: incomplete or corrupt registration response, missing public key") + } + userPubKey := res.Data[1:66] khLen := int(res.Data[66]) + + if len(res.Data) < 67+khLen { + return nil, fmt.Errorf("u2ftoken: incomplete or corrupt registration response, missing key handle") + } keyHandle := res.Data[67 : 67+khLen] remaining := res.Data[67+khLen:] diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index 4e507a0..a8e8f5d 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -82,7 +82,7 @@ func (w *ctap2WebauthnToken) Register(ctx context.Context, req *RegisterRequest, var pinUVAuth *[]byte if len(p.UserPIN) > 0 { var err error - uvAuth, err := pin.ExchangeUserPinToPinAuth(w.t, p.UserPIN, clientDataHash) + uvAuth, err := pin.ExchangeUserPin(w.t, p.UserPIN, clientDataHash) if err != nil { return nil, err } @@ -172,7 +172,7 @@ func (w *ctap2WebauthnToken) Authenticate(ctx context.Context, req *Authenticate var pinUVAuth []byte if len(p.UserPIN) > 0 { var err error - pinUVAuth, err = pin.ExchangeUserPinToPinAuth(w.t, p.UserPIN, clientDataHash) + pinUVAuth, err = pin.ExchangeUserPin(w.t, p.UserPIN, clientDataHash) if err != nil { return nil, err } diff --git a/webauthn/types.go b/webauthn/types.go index c72764a..d049426 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -141,7 +141,6 @@ type CollectedClientData struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` - // TODO tokenBinding ? (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding) } func (c CollectedClientData) EncodeAndHash() (dataJSON []byte, dataHash []byte, err error) { From 5558a124189c4162f21e6dc1b1cf878abb4bd77f Mon Sep 17 00:00:00 2001 From: daeMOn Date: Mon, 8 Feb 2021 09:55:32 +0100 Subject: [PATCH 30/34] Apply suggestions from code review Co-authored-by: Jonathan Rudenberg --- ctap2token/token.go | 8 ++++---- doc/WEBAUTHN_DEVICE_SELECTION.md | 4 ++-- u2fhid/hid.go | 4 ++-- webauthn/ctap1.go | 2 +- webauthn/token.go | 16 ++++++++-------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ctap2token/token.go b/ctap2token/token.go index 795ce7c..442023e 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -363,8 +363,8 @@ func (t *Token) AuthenticatorSelection(ctx context.Context) error { } } -// Reset restore an authenticator back to a factory default state. User presence is required. -// In case of authenticators with no display, Reset request MUST have come to the authenticator within 10 seconds +// Reset restores an authenticator back to a factory default state. User presence is required. +// In case of authenticators with no display, the Reset request MUST have come to the authenticator within 10 seconds // of powering up of the authenticator // see: https://fidoalliance.org/specs/fido2/fido-client-to-authenticator-protocol-v2.1-rd-20191217.html#authenticatorReset func (t *Token) Reset() error { @@ -490,10 +490,10 @@ func (a AuthData) Parse() (*ParsedAuthData, error) { } if out.Flags.HasExtensions { - // When extensions are available, we must find out where the map start in the cbor data. + // When extensions are available, we must find out where the map starts in the CBOR data. // It can either be at a[authDataMinLength:] when out.Flags.AttestedCredentialData is false, // or at a[(authDataMinLength+16+2+credIDLen+COSEKeyLen):] when out.Flags.AttestedCredentialData is true - // in this case, it requires to cbor-encode back the key to find its length. + // in this case, it requires us to CBOR-encode back the key to find its length. startIndex := authDataMinLength if out.Flags.AttestedCredentialData { diff --git a/doc/WEBAUTHN_DEVICE_SELECTION.md b/doc/WEBAUTHN_DEVICE_SELECTION.md index 16b2a83..76b6a96 100644 --- a/doc/WEBAUTHN_DEVICE_SELECTION.md +++ b/doc/WEBAUTHN_DEVICE_SELECTION.md @@ -1,7 +1,7 @@ # Device selection -When multiple authenticators are available, the webauthn specification isn't really clear how to proceed (see for example https://www.w3.org/TR/webauthn/#createCredential, starting at bullet point 19). Some browsers don't support CTAP2 and rely exclusively on CTAP1/U2F protocol, thus making it impossible to use webauthn with user verification in required mode, or they downgrade the preferred mode to behave like the discouraged one, like Firefox 80.0.1 under Linux. -However, Windows browsers seem to offer a better support for CTAP2, and the following flow have been observed: +When multiple authenticators are available, the WebAuthn specification isn't really clear how to proceed (see for example https://www.w3.org/TR/webauthn/#createCredential, starting at bullet point 19). Some browsers don't support CTAP2 and rely exclusively on the CTAP1/U2F protocol, thus making it impossible to use WebAuthn with user verification in required mode, or they downgrade the preferred mode to behave like the discouraged one, like Firefox 80.0.1 under Linux. +However, Windows browsers seem to offer a better support for CTAP2, and the following flow has been observed: ``` List all available devices diff --git a/u2fhid/hid.go b/u2fhid/hid.go index 0d488b0..5a2a34f 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -106,10 +106,10 @@ type Device struct { // command during initialization. Even if this flag is true, the device may // not actually do anything if the command is called. CapabilityWink bool - // CapabilityCBOR is true when the device support CBOR encoded messages + // CapabilityCBOR is true when the device supports CBOR encoded messages // used by the CTAP2 protocol CapabilityCBOR bool - // CababilityNMSG is true when the device support CTAP1 messages + // CababilityNMSG is true when the device supports CTAP1 messages CababilityNMSG bool info *hid.DeviceInfo diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index 5aa5adf..d98cf2b 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -45,7 +45,7 @@ func (w *ctap1WebauthnToken) Register(ctx context.Context, req *RegisterRequest, return nil, err } - // If the excludeList is not empty, the platform must send signing request with + // If the excludeList is not empty, the platform must send the signing request with // check-only control byte to the CTAP1/U2F authenticator using each of // the credential ids (key handles) in the excludeList. // If any of them does not result in an error, that means that this is a known device. diff --git a/webauthn/token.go b/webauthn/token.go index a89e6af..de94d02 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -106,7 +106,7 @@ func (a *Webauthn) Register(ctx context.Context, origin string, req *RegisterReq // Send the request to all selected authenticators for _, authenticator := range authenticators { go func(a Authenticator) { - // make sure the HID cnx stay open at least as long as the request needs it. + // make sure the HID connection stays open at least as long as the request needs it. a.SetResponseTimeout(timeout) resp, err := a.Register(ctx, req, &RequestParams{ ClientData: CollectedClientData{ @@ -178,7 +178,7 @@ func (a *Webauthn) Authenticate(ctx context.Context, origin string, req *Authent // Send the request to all selected authenticators for _, authenticator := range authenticators { go func(a Authenticator) { - // make sure the HID cnx stay open at least as long as the request needs it. + // make sure the HID connection stays open at least as long as the request needs it. a.SetResponseTimeout(timeout) resp, err := a.Authenticate(ctx, req, &RequestParams{ ClientData: CollectedClientData{ @@ -215,7 +215,7 @@ func closeAll(auths []Authenticator) { } } -// selectAuthenticators guide the user into selecting the authenticator to communicate with. +// selectAuthenticators guides the user into selecting the authenticator to communicate with. // One or multiple devices can be returned depending on their supported protocols and the AuthenticatorSelection // requirements. // If user verification is required, the user will be prompted to enter the device PIN, or to set it. The PIN will @@ -282,8 +282,8 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS } // When multiple devices are present and UV is needed, we must guide the user to select a single device. - // This is done by sending fake ctap1 register requests to all devices, with a test-user-presence flag. - // The first device to reply a non error is assumed selected by the user. + // This is done by sending fake CTAP1 register requests to all devices, with a test-user-presence flag. + // The first device to reply with a non-error is assumed selected by the user. if opts.UserVerification != UVDiscouraged { // if we require UV, have multiple devies, and at least one // support CTAP2, we must request the user to select the device first. @@ -299,7 +299,7 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS selectedAuth := selected[0] if len(selected) > 1 && ctap2DevicePresent { - a.pinHandler.Println("multiple security keys found. Please select one by touching it...") + a.pinHandler.Println("Multiple security keys found. Please select one by touching it...") respChan := make(chan Authenticator) ctx, cancel := context.WithTimeout(ctx, a.deviceSelectionTimeout) defer cancel() @@ -324,7 +324,7 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS if a, ok := s.(*ctap2WebauthnToken); ok { a.t.Cancel() } - // close all devices not selected + // close all devices not selected s.Close() } selected = []Authenticator{selectedAuth} @@ -349,7 +349,7 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS } } } - a.pinHandler.Println("confirm presence on authenticator when it will blink...") + a.pinHandler.Println("Confirm presence by touching the authenticator when it blinks...") } From 4a1739ff0e78c6ad6b6d202b19f3758fe7ccef40 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Mon, 8 Feb 2021 09:59:40 +0100 Subject: [PATCH 31/34] rename Webauthn -> WebAuthn and split RegisterRequest --- webauthn/ctap1.go | 22 ++++++------ webauthn/ctap2.go | 22 ++++++------ webauthn/example/demo.yubico.com/main.go | 8 ++--- webauthn/example/webauthn.io/main.go | 8 ++--- webauthn/token.go | 40 ++++++++++----------- webauthn/types.go | 45 +++++++++++++----------- 6 files changed, 74 insertions(+), 71 deletions(-) diff --git a/webauthn/ctap1.go b/webauthn/ctap1.go index d98cf2b..76bf71b 100644 --- a/webauthn/ctap1.go +++ b/webauthn/ctap1.go @@ -12,11 +12,11 @@ import ( "github.com/flynn/u2f/u2ftoken" ) -type ctap1WebauthnToken struct { +type ctap1WebAuthnToken struct { t *u2ftoken.Token } -func (w *ctap1WebauthnToken) Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { +func (w *ctap1WebAuthnToken) Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { useES256 := false for _, cp := range req.PubKeyCredParams { if crypto.Alg(cp.Alg) == crypto.ES256 { @@ -35,7 +35,7 @@ func (w *ctap1WebauthnToken) Register(ctx context.Context, req *RegisterRequest, } sha := sha256.New() - if _, err := sha.Write([]byte(req.Rp.ID)); err != nil { + if _, err := sha.Write([]byte(req.RP.ID)); err != nil { return nil, err } rpIDHash := sha.Sum(nil) @@ -126,7 +126,7 @@ func (w *ctap1WebauthnToken) Register(ctx context.Context, req *RegisterRequest, }, nil } -func (w *ctap1WebauthnToken) Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { +func (w *ctap1WebAuthnToken) Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { if len(req.AllowCredentials) == 0 { return nil, errors.New("webauthn: ctap1 require at least one credential") } @@ -180,27 +180,27 @@ func (w *ctap1WebauthnToken) Authenticate(ctx context.Context, req *Authenticate }, nil } -func (w *ctap1WebauthnToken) AuthenticatorSelection(ctx context.Context) error { +func (w *ctap1WebAuthnToken) AuthenticatorSelection(ctx context.Context) error { return w.t.AuthenticatorSelection(ctx) } -func (w *ctap1WebauthnToken) RequireUV() bool { +func (w *ctap1WebAuthnToken) RequireUV() bool { return false } -func (w *ctap1WebauthnToken) SupportRK() bool { +func (w *ctap1WebAuthnToken) SupportRK() bool { return false } -func (w *ctap1WebauthnToken) SetResponseTimeout(timeout time.Duration) { +func (w *ctap1WebAuthnToken) SetResponseTimeout(timeout time.Duration) { w.t.SetResponseTimeout(timeout) } -func (w *ctap1WebauthnToken) Close() { +func (w *ctap1WebAuthnToken) Close() { w.t.Close() } -func (w *ctap1WebauthnToken) waitRegister(ctx context.Context, req *u2ftoken.RegisterRequest) (*u2ftoken.RegisterResponse, error) { +func (w *ctap1WebAuthnToken) waitRegister(ctx context.Context, req *u2ftoken.RegisterRequest) (*u2ftoken.RegisterResponse, error) { for { select { case <-ctx.Done(): @@ -219,7 +219,7 @@ func (w *ctap1WebauthnToken) waitRegister(ctx context.Context, req *u2ftoken.Reg } } -func (w *ctap1WebauthnToken) waitAuthenticate(ctx context.Context, req *u2ftoken.AuthenticateRequest) (*u2ftoken.AuthenticateResponse, error) { +func (w *ctap1WebAuthnToken) waitAuthenticate(ctx context.Context, req *u2ftoken.AuthenticateRequest) (*u2ftoken.AuthenticateResponse, error) { for { select { case <-ctx.Done(): diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index a8e8f5d..4d66bdd 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -19,12 +19,12 @@ var supportedCTAP2Transports = map[string]ctap2.AuthenticatorTransport{ string(ctap2.USB): ctap2.USB, } -type ctap2WebauthnToken struct { +type ctap2WebAuthnToken struct { t *ctap2.Token options map[string]bool } -func (w *ctap2WebauthnToken) Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { +func (w *ctap2WebAuthnToken) Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) { credTypesAndPubKeyAlgs := make([]ctap2.CredentialParam, 0, len(req.PubKeyCredParams)) for _, cp := range req.PubKeyCredParams { t, ok := supportedCTAP2CredentialTypes[cp.Type] @@ -92,9 +92,9 @@ func (w *ctap2WebauthnToken) Register(ctx context.Context, req *RegisterRequest, resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ ClientDataHash: clientDataHash, RP: ctap2.CredentialRpEntity{ - ID: req.Rp.ID, - Name: req.Rp.Name, - Icon: req.Rp.Icon, + ID: req.RP.ID, + Name: req.RP.Name, + Icon: req.RP.Icon, }, User: ctap2.CredentialUserEntity{ ID: req.User.ID, @@ -159,7 +159,7 @@ func (w *ctap2WebauthnToken) Register(ctx context.Context, req *RegisterRequest, }, nil } -func (w *ctap2WebauthnToken) Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { +func (w *ctap2WebAuthnToken) Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) { // TODO add support for extensions (bullet point 8 from https://www.w3.org/TR/2020/WD-webauthn-2-20200730/#sctn-discover-from-external-source) clientExtensions := make(map[string]interface{}) @@ -224,22 +224,22 @@ func (w *ctap2WebauthnToken) Authenticate(ctx context.Context, req *Authenticate }, nil } -func (w *ctap2WebauthnToken) AuthenticatorSelection(ctx context.Context) error { +func (w *ctap2WebAuthnToken) AuthenticatorSelection(ctx context.Context) error { return w.t.AuthenticatorSelection(ctx) } -func (w *ctap2WebauthnToken) RequireUV() bool { +func (w *ctap2WebAuthnToken) RequireUV() bool { return w.options["clientPin"] } -func (w *ctap2WebauthnToken) SupportRK() bool { +func (w *ctap2WebAuthnToken) SupportRK() bool { return w.options["rk"] } -func (w *ctap2WebauthnToken) SetResponseTimeout(timeout time.Duration) { +func (w *ctap2WebAuthnToken) SetResponseTimeout(timeout time.Duration) { w.t.SetResponseTimeout(timeout) } -func (w *ctap2WebauthnToken) Close() { +func (w *ctap2WebAuthnToken) Close() { w.t.Close() } diff --git a/webauthn/example/demo.yubico.com/main.go b/webauthn/example/demo.yubico.com/main.go index 02af526..cdb3cd7 100644 --- a/webauthn/example/demo.yubico.com/main.go +++ b/webauthn/example/demo.yubico.com/main.go @@ -53,7 +53,7 @@ func main() { } } -func register(t *webauthn.Webauthn, host string) error { +func register(t *webauthn.WebAuthn, host string) error { c := &http.Client{} reqBody := bytes.NewBuffer([]byte(`{"userVerification":"preferred"}`)) httpResp, err := c.Post(fmt.Sprintf("%s/api/v1/simple/webauthn/register-begin", host), "application/json", reqBody) @@ -86,7 +86,7 @@ func register(t *webauthn.Webauthn, host string) error { return err } - fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) + fmt.Printf("WebAuthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) webauthnResp, err := t.Register(context.Background(), host, respData.Data.PublicKey) if err != nil { return err @@ -140,7 +140,7 @@ func register(t *webauthn.Webauthn, host string) error { return nil } -func authenticate(t *webauthn.Webauthn, host, session string) error { +func authenticate(t *webauthn.WebAuthn, host, session string) error { c := &http.Client{} reqBody := bytes.NewBuffer([]byte(`{"userVerification":"preferred"}`)) @@ -183,7 +183,7 @@ func authenticate(t *webauthn.Webauthn, host, session string) error { panic(err) } - fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) + fmt.Printf("WebAuthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", respData.Data.Username, host) webauthnResp, err := t.Authenticate(context.Background(), host, respData.Data.PublicKey) if err != nil { panic(err) diff --git a/webauthn/example/webauthn.io/main.go b/webauthn/example/webauthn.io/main.go index 853ecfc..c33f532 100644 --- a/webauthn/example/webauthn.io/main.go +++ b/webauthn/example/webauthn.io/main.go @@ -53,7 +53,7 @@ func main() { } } -func register(t *webauthn.Webauthn, username, host string) error { +func register(t *webauthn.WebAuthn, username, host string) error { c := &http.Client{} httpResp, err := c.Get(fmt.Sprintf("%s/makeCredential/%s?attType=direct&authType=&userVerification=preferred&residentKeyRequirement=false&txAuthExtension=", host, username)) @@ -79,7 +79,7 @@ func register(t *webauthn.Webauthn, username, host string) error { return err } - fmt.Printf("Webauthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) + fmt.Printf("WebAuthn registration request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) webauthnResp, err := t.Register(context.Background(), host, webauthnReq.PublicKey) if err != nil { return err @@ -131,7 +131,7 @@ func register(t *webauthn.Webauthn, username, host string) error { return nil } -func authenticate(t *webauthn.Webauthn, username, host string) error { +func authenticate(t *webauthn.WebAuthn, username, host string) error { c := &http.Client{} httpResp, err := c.Get(fmt.Sprintf("%s/assertion/%s?userVer=discouraged&txAuthExtension=", host, username)) if err != nil { @@ -157,7 +157,7 @@ func authenticate(t *webauthn.Webauthn, username, host string) error { return err } - fmt.Printf("Webauthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) + fmt.Printf("WebAuthn authentication request for %q on %q. Confirm presence on authenticator when it will blink...\n", username, host) webauthnResp, err := t.Authenticate(context.Background(), host, authReq.PublicKey) if err != nil { return err diff --git a/webauthn/token.go b/webauthn/token.go index de94d02..d7de9df 100644 --- a/webauthn/token.go +++ b/webauthn/token.go @@ -27,34 +27,34 @@ var ( var emptyAAGUID = make([]byte, 16) -type Webauthn struct { +type WebAuthn struct { debug bool pinHandler pin.PINHandler deviceSelectionTimeout time.Duration } -type WebauthnOption func(*Webauthn) +type WebAuthnOption func(*WebAuthn) -func WithDebug(enabled bool) WebauthnOption { - return func(a *Webauthn) { +func WithDebug(enabled bool) WebAuthnOption { + return func(a *WebAuthn) { a.debug = enabled } } -func WithCTAP2PinHandler(pinHandler pin.PINHandler) WebauthnOption { - return func(a *Webauthn) { +func WithCTAP2PinHandler(pinHandler pin.PINHandler) WebAuthnOption { + return func(a *WebAuthn) { a.pinHandler = pinHandler } } -func WithDeviceSelectionTimeout(d time.Duration) WebauthnOption { - return func(a *Webauthn) { +func WithDeviceSelectionTimeout(d time.Duration) WebAuthnOption { + return func(a *WebAuthn) { a.deviceSelectionTimeout = d } } -func New(opts ...WebauthnOption) *Webauthn { - a := &Webauthn{ +func New(opts ...WebAuthnOption) *WebAuthn { + a := &WebAuthn{ pinHandler: pin.NewInteractiveHandler(), debug: false, deviceSelectionTimeout: time.Duration(DefaultDeviceSelectionTimeout) * time.Second, @@ -67,7 +67,7 @@ func New(opts ...WebauthnOption) *Webauthn { return a } -func (a *Webauthn) Register(ctx context.Context, origin string, req *RegisterRequest) (*RegisterResponse, error) { +func (a *WebAuthn) Register(ctx context.Context, origin string, req *RegisterRequest) (*RegisterResponse, error) { originURL, err := url.Parse(origin) if err != nil { return nil, fmt.Errorf("webauthn: invalid origin: %w", err) @@ -83,8 +83,8 @@ func (a *Webauthn) Register(ctx context.Context, origin string, req *RegisterReq req.Timeout = MaxAllowedResponseTimeout } - if req.Rp.ID == "" { - req.Rp.ID = originURL.Hostname() + if req.RP.ID == "" { + req.RP.ID = originURL.Hostname() } authenticators, userPIN, err := a.selectAuthenticators(ctx, req.AuthenticatorSelection) @@ -137,7 +137,7 @@ func (a *Webauthn) Register(ctx context.Context, origin string, req *RegisterReq } } -func (a *Webauthn) Authenticate(ctx context.Context, origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { +func (a *WebAuthn) Authenticate(ctx context.Context, origin string, req *AuthenticateRequest) (*AuthenticateResponse, error) { originURL, err := url.Parse(origin) if err != nil { return nil, fmt.Errorf("webauthn: invalid origin: %w", err) @@ -220,7 +220,7 @@ func closeAll(auths []Authenticator) { // requirements. // If user verification is required, the user will be prompted to enter the device PIN, or to set it. The PIN will // be returned in order to be exchanged later for a pinAuth code (see pin.ExchangeUserPinToPinAuth). -func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorSelection) ([]Authenticator, []byte, error) { +func (a *WebAuthn) selectAuthenticators(ctx context.Context, opts AuthenticatorSelection) ([]Authenticator, []byte, error) { var selected []Authenticator var userPIN []byte @@ -252,12 +252,12 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS return nil, nil, err } - current = &ctap2WebauthnToken{ + current = &ctap2WebAuthnToken{ t: t, options: info.Options, } } else { - current = &ctap1WebauthnToken{ + current = &ctap1WebAuthnToken{ t: u2ftoken.NewToken(dev), } } @@ -291,7 +291,7 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS // select the device. ctap2DevicePresent := false for _, s := range selected { - if _, isCTAP2 := s.(*ctap2WebauthnToken); isCTAP2 { + if _, isCTAP2 := s.(*ctap2WebAuthnToken); isCTAP2 { ctap2DevicePresent = true break } @@ -321,7 +321,7 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS continue } // send a cancel command to CTAP2 devices (they cannot be canceled via go context) - if a, ok := s.(*ctap2WebauthnToken); ok { + if a, ok := s.(*ctap2WebAuthnToken); ok { a.t.Cancel() } // close all devices not selected @@ -335,7 +335,7 @@ func (a *Webauthn) selectAuthenticators(ctx context.Context, opts AuthenticatorS } // Collect PIN or guide user to set a PIN on CTAP2 authenticators - if ctap2Auth, isCTAP2 := selectedAuth.(*ctap2WebauthnToken); isCTAP2 { + if ctap2Auth, isCTAP2 := selectedAuth.(*ctap2WebAuthnToken); isCTAP2 { var err error if !selectedAuth.RequireUV() { userPIN, err = a.pinHandler.SetPIN(ctap2Auth.t) diff --git a/webauthn/types.go b/webauthn/types.go index d049426..face6ec 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -42,22 +42,10 @@ type ExcludedCredential struct { } type RegisterRequest struct { - Challenge []byte `json:"challenge"` - Rp struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - } `json:"rp"` - User struct { - ID []byte `json:"id"` - DisplayName string `json:"displayName"` - Name string `json:"name"` - Icon string `json:"icon"` - } `json:"user"` - PubKeyCredParams []struct { - Type string `json:"type"` - Alg int `json:"alg"` - } `json:"pubKeyCredParams"` + Challenge []byte `json:"challenge"` + RP RP `json:"rp"` + User User `json:"user"` + PubKeyCredParams []PubKeyCredParams `json:"pubKeyCredParams"` ExcludeCredentials []ExcludedCredential `json:"excludeCredentials"` AuthenticatorSelection AuthenticatorSelection `json:"authenticatorSelection"` Timeout int `json:"timeout"` @@ -65,6 +53,24 @@ type RegisterRequest struct { Attestation string `json:"attestation"` } +type RP struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` +} + +type User struct { + ID []byte `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Icon string `json:"icon"` +} + +type PubKeyCredParams struct { + Type string `json:"type"` + Alg int `json:"alg"` +} + type AuthenticatorSelection struct { AuthenticatorAttachment string `json:"authenticatorAttachment"` RequireResidentKey bool `json:"requireResidentKey"` @@ -149,10 +155,7 @@ func (c CollectedClientData) EncodeAndHash() (dataJSON []byte, dataHash []byte, return nil, nil, err } - sha := sha256.New() - if _, err := sha.Write(dataJSON); err != nil { - return nil, nil, err - } - dataHash = sha.Sum(nil) + hash := sha256.Sum256(dataJSON) + dataHash = hash[:] return dataJSON, dataHash, nil } From 50ac7cda6d4c367190d92cabef140cd8cde44d90 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Mon, 8 Feb 2021 10:13:58 +0100 Subject: [PATCH 32/34] remove and clarify todos --- ctap2token/token.go | 1 - webauthn/ctap2.go | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ctap2token/token.go b/ctap2token/token.go index 442023e..b809cdb 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -160,7 +160,6 @@ type MakeCredentialRequest struct { } // MakeCredentialResponse -// TODO: structure may be different with different kind of attestations. type MakeCredentialResponse struct { Fmt string `cbor:"1,keyasint"` AuthData AuthData `cbor:"2,keyasint"` diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go index 4d66bdd..3eca142 100644 --- a/webauthn/ctap2.go +++ b/webauthn/ctap2.go @@ -139,7 +139,10 @@ func (w *ctap2WebAuthnToken) Register(ctx context.Context, req *RegisterRequest, AttSmt: make(map[string]interface{}), } case "indirect": - // TODO + // TODO: expose an anonymisation hook ? + // from https://www.w3.org/TR/webauthn-2/#sctn-createCredential: + // The client MAY replace the AAGUID and attestation statement with a more privacy-friendly + // and/or more easily verifiable version of the same data (for example, by employing an Anonymization CA). case "direct": // Do nothing default: From b785fc116b41f54e0ba4a415d972faf9150a8599 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Fri, 26 Feb 2021 16:01:34 +0100 Subject: [PATCH 33/34] fix #14 getAssertion issue with HyperSecu Mini HyperSecu Mini tokens are reporting errors when calling GetAssertion. It seems these tokens don't support the transports field being set on the CredentialDescriptor. Luckily it's optionnal so we can safely remove it. --- ctap2token/example/main.go | 5 ++--- ctap2token/token.go | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 94477c1..6e0b6a7 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -123,9 +123,8 @@ func main() { RPID: "example.com", AllowList: []*ctap2token.CredentialDescriptor{ { - ID: mcpAuthData.AttestedCredentialData.CredentialID, - Transports: []ctap2token.AuthenticatorTransport{ctap2token.USB}, - Type: ctap2token.PublicKey, + ID: mcpAuthData.AttestedCredentialData.CredentialID, + Type: ctap2token.PublicKey, }, }, ClientDataHash: clientDataHash, diff --git a/ctap2token/token.go b/ctap2token/token.go index b809cdb..a8b9409 100644 --- a/ctap2token/token.go +++ b/ctap2token/token.go @@ -629,9 +629,10 @@ const ( // CredentialDescriptor defines a credential returned by the authenticator, // as defined by https://www.w3.org/TR/webauthn/#credential-dictionary type CredentialDescriptor struct { - ID []byte `cbor:"id"` - Type CredentialType `cbor:"type"` - Transports []AuthenticatorTransport `cbor:"transports"` + ID []byte `cbor:"id"` + Type CredentialType `cbor:"type"` + // Don't set transports field when using HyperSecu Mini tokens. + Transports []AuthenticatorTransport `cbor:"transports,omitempty"` } // AuthenticatorTransport defines hints as to how clients might communicate with a particular authenticator, From 840ab5f7207620bf26324e150b81f9e075691db0 Mon Sep 17 00:00:00 2001 From: Flavien Binet Date: Sat, 27 Feb 2021 11:53:16 +0100 Subject: [PATCH 34/34] add extra check for pin before input HyperSecu mini tokens returns wrong error on timeout that we can catch in order to avoid asking for user pin when it's not set --- ctap2token/example/main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ctap2token/example/main.go b/ctap2token/example/main.go index 6e0b6a7..5c68d60 100644 --- a/ctap2token/example/main.go +++ b/ctap2token/example/main.go @@ -74,6 +74,12 @@ func main() { panic(err) } + // HyperSecu Mini returns CTAP2_ERR_PIN_REQUIRED instead of CTAP2_ERR_ACTION_TIMEOUT + // so we need an extra check to ensure the Pin is set on the token before asking the user input. + if pinEnabled, ok := info.Options["clientPin"]; !ok || !pinEnabled { + panic(fmt.Errorf("Got %s error from token but pin is not set.", ctap2token.ErrPinRequired)) + } + pinHandler := pin.NewInteractiveHandler() userPIN, err := pinHandler.ReadPIN() if err != nil {