diff --git a/crypto/cose.go b/crypto/cose.go new file mode 100644 index 0000000..224db26 --- /dev/null +++ b/crypto/cose.go @@ -0,0 +1,76 @@ +package crypto + +import ( + "github.com/fxamacker/cbor/v2" +) + +// COSEKey, as defined per https://tools.ietf.org/html/rfc8152#section-7.1 +// Only supports Elliptic Curve Public keys. +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 is an Octet Key Pair + OKP KeyType = 0x01 + // EC2 is an Elliptic Curve Key + 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 in +// 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/example/main.go b/ctap2token/example/main.go new file mode 100644 index 0000000..5c68d60 --- /dev/null +++ b/ctap2token/example/main.go @@ -0,0 +1,175 @@ +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "errors" + "fmt" + "math/big" + + "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" + "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) + + info, err := token.GetInfo() + if err != nil { + fmt.Printf("failed to retrieve token info (%v), does the token support CTAP2 ?\n", err) + continue + } + fmt.Printf("Token info:\n%#v\n", info) + + 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) + } + + 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, + }, + } + + // first try without user verification + fmt.Println("Sending makeCredential request, please press authenticator button...") + resp, err := token.MakeCredential(req) + if err != nil { + // retry but with user verification + if errors.Unwrap(err) != ctap2token.ErrPinRequired { + 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 { + panic(err) + } + + pinAuth, err := pin.ExchangeUserPin(token, userPIN, 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("Successfully created credential") + + // Verify signature with the X509 certificate from the attestation statement + x509certs, ok := resp.AttSmt["x5c"] + if !ok { + panic("no x5c field") + } + + x509cert := x509certs.([]interface{})[0].([]byte) + cert, err := x509.ParseCertificate(x509cert) + if err != nil { + panic(err) + } + + 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, + Type: ctap2token.PublicKey, + }, + }, + ClientDataHash: clientDataHash, + // enable UserVerified flag + // PinUVAuth: pinAuth, + // PinUVAuthProtocol: ctap2token.PinProtoV1, + }) + if err != nil { + panic(err) + } + + 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/pin/pin.go b/ctap2token/pin/pin.go new file mode 100644 index 0000000..707f5b0 --- /dev/null +++ b/ctap2token/pin/pin.go @@ -0,0 +1,295 @@ +package pin + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "math/big" + "os" + + "github.com/flynn/u2f/crypto" + "github.com/flynn/u2f/ctap2token" + "golang.org/x/crypto/ssh/terminal" +) + +const ( + PinLengthMin = 4 + PinLengthMax = 63 +) + +type PINHandler interface { + ReadPIN() ([]byte, error) + SetPIN(token *ctap2token.Token) ([]byte, error) + Println(msg ...interface{}) +} + +type InteractiveHandler struct { + Stdin *os.File + Stdout *os.File +} + +var _ PINHandler = (*InteractiveHandler)(nil) + +// NewInteractiveHandler returns an interactive PINHandler, which will read +// the user PIN from the provided reader. +func NewInteractiveHandler() *InteractiveHandler { + return &InteractiveHandler{ + Stdin: os.Stdin, + Stdout: os.Stdout, + } +} + +func (h *InteractiveHandler) ReadPIN() ([]byte, error) { + _, err := fmt.Fprint(h.Stdout, "enter current device PIN: ") + if err != nil { + return nil, err + } + + return getpasswd(h.Stdin) +} + +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 err := validateUserPIN(userPIN); err != nil { + return nil, err + } + + _, 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 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 err + } + b, bGX, bGY, err := elliptic.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + sharedSecret, err := computeSharedSecret(b, aGX, aGY) + if err != nil { + return err + } + + // 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 err + } + + 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 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 err + } + + return nil +} + +// 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 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 + } + + 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 + 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(dataEnc, data) + + return dataEnc, nil +} + +func getPINToken(token *ctap2token.Token, encPinHash []byte, bGX, bGY *big.Int) ([]byte, error) { + pinResp, err := token.ClientPIN(&ctap2token.ClientPINRequest{ + SubCommand: ctap2token.GetPINUvAuthTokenUsingPIN, + KeyAgreement: &crypto.COSEKey{ + X: bGX.Bytes(), + Y: bGY.Bytes(), + KeyType: crypto.EC2, + Curve: crypto.P256, + Alg: crypto.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, len(data)) + 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 +} + +func getpasswd(r *os.File) ([]byte, error) { + pin, err := terminal.ReadPassword(int(r.Fd())) + // 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 new file mode 100644 index 0000000..a8b9409 --- /dev/null +++ b/ctap2token/token.go @@ -0,0 +1,663 @@ +package ctap2token + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "time" + + "github.com/flynn/u2f/crypto" + "github.com/fxamacker/cbor/v2" +) + +const ( + statusSuccess = 0x00 + + cmdMakeCredential = 0x01 + cmdGetAssertion = 0x02 + cmdGetInfo = 0x04 + cmdClientPIN = 0x06 + cmdReset = 0x07 + 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 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 { + // 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) + // SetResponseTimeout sets the maximum time to wait for a response from the device. + SetResponseTimeout(timeout time.Duration) + Cancel() + Close() +} + +// 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 { + 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 + // 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"` +} + +// MakeCredentialResponse +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 PinUVAuth `cbor:"6,keyasint,omitempty"` + PinUVAuthProtocol PinUVAuthProtocolVersion `cbor:"7,keyasint,omitempty"` +} + +type AssertionResponse 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) (*AssertionResponse, 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 := &AssertionResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, err + } + + return respData, nil +} + +// 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 + } + + respData := &AssertionResponse{} + 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 *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 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"` + 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, fmt.Errorf("ctap2token: failed to marshal request: %w", 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, fmt.Errorf("ctap2token: cbor failed: %w", err) + } + + respData := &ClientPINResponse{} + if err := unmarshal(resp, respData); err != nil { + return nil, fmt.Errorf("ctap2token: failed to unmarshal response: %w", err) + } + + return respData, nil +} + +func (t *Token) AuthenticatorSelection(ctx context.Context) error { + 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 + } +} + +// 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 { + resp, err := t.d.CBOR([]byte{cmdReset}) + if err != nil { + return err + } + + return checkResponse(resp) +} + +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") + } + + if resp[0] != statusSuccess { + status, ok := ctapErrors[resp[0]] + if !ok { + status = fmt.Errorf("unknown error %x", resp[0]) + } + return fmt.Errorf("ctap2token: CBOR error: %w", status) + } + return nil +} + +func unmarshal(resp []byte, out interface{}) error { + if err := checkResponse(resp); err != nil { + return err + } + + if len(resp) == 1 { + return nil + } + + 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. +type ClientDataHash []byte + +// CredentialRpEntity describes a Relying Party with which +// the new public key credential will be associated. +type CredentialRpEntity struct { + // 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"` +} + +// 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"` +} + +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 + +func (a AuthData) Parse() (*ParsedAuthData, error) { + if len(a) < authDataMinLength { + return nil, fmt.Errorf("ctap2token: authData too short, got %d bytes, want at least %d", len(a), authDataMinLength) + } + + 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 = &crypto.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 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 us 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 +} + +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 + 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 *crypto.COSEKey +} + +type CredentialParam struct { + Type CredentialType `cbor:"type"` + Alg crypto.Alg `cbor:"alg"` +} + +var ( + 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 +type CredentialType string + +const ( + PublicKey CredentialType = "public-key" +) + +// 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"` + // 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, +// 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 AuthenticatorExtensions map[string]interface{} + +type AuthenticatorOptions map[string]bool + +type PinUVAuth []byte + +type PinUVAuthProtocolVersion uint + +const ( + PinProtoV1 PinUVAuthProtocolVersion = 1 +) diff --git a/ctap2token/token_test.go b/ctap2token/token_test.go new file mode 100644 index 0000000..adb2e19 --- /dev/null +++ b/ctap2token/token_test.go @@ -0,0 +1,114 @@ +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{"rk": 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/doc/WEBAUTHN_DEVICE_SELECTION.md b/doc/WEBAUTHN_DEVICE_SELECTION.md new file mode 100644 index 0000000..76b6a96 --- /dev/null +++ b/doc/WEBAUTHN_DEVICE_SELECTION.md @@ -0,0 +1,30 @@ +# 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 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 +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 + 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 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1a52b92 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +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 + 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 new file mode 100644 index 0000000..aa00cac --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +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= +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= +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= +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= diff --git a/u2fhid/hid.go b/u2fhid/hid.go index d5fadde..5a2a34f 100644 --- a/u2fhid/hid.go +++ b/u2fhid/hid.go @@ -14,37 +14,43 @@ import ( ) const ( - cmdPing = 0x80 | 0x01 - cmdMsg = 0x80 | 0x03 - cmdLock = 0x80 | 0x04 - cmdInit = 0x80 | 0x06 - cmdWink = 0x80 | 0x08 - cmdSync = 0x80 | 0x3c + 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 - capabilityWink = 1 + capabilityWink = 0x1 + capabilityCBOR = 0x4 + capabilityNMSG = 0x8 minMessageLen = 7 maxMessageLen = 7609 minInitResponseLen = 17 - responseTimeout = 3 * time.Second + defaultResponseTimeout = 60 * time.Second fidoUsagePage = 0xF1D0 u2fUsage = 1 ) 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. @@ -72,12 +78,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 } @@ -96,17 +103,23 @@ 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 supports CBOR encoded messages + // used by the CTAP2 protocol + CapabilityCBOR bool + // CababilityNMSG is true when the device supports CTAP1 messages + CababilityNMSG bool info *hid.DeviceInfo 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 { @@ -125,7 +138,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 } @@ -150,7 +162,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 @@ -177,9 +189,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:])) @@ -208,7 +225,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) @@ -240,6 +257,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 } @@ -272,10 +291,28 @@ 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) } +// 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) +} + +func (d *Device) Cancel() { + // 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. func (d *Device) Close() { d.device.Close() } + +func (d *Device) SetResponseTimeout(timeout time.Duration) { + d.responseTimeout = timeout +} diff --git a/u2ftoken/example/main.go b/u2ftoken/example/main.go index 4f844fa..8a17c1b 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,19 @@ 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) log.Println("authenticating, provide user presence") for { res, err := t.Authenticate(req) diff --git a/u2ftoken/token.go b/u2ftoken/token.go index 05f8a47..2edc282 100644 --- a/u2ftoken/token.go +++ b/u2ftoken/token.go @@ -3,9 +3,12 @@ package u2ftoken import ( + "context" + "encoding/asn1" "encoding/binary" "errors" "fmt" + "time" ) const ( @@ -23,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") @@ -42,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) + SetResponseTimeout(timeout time.Duration) + Close() } // NewToken returns a token that will use Device to communicate with the device. @@ -66,11 +88,18 @@ type RegisterRequest struct { Application []byte } +type RegisterResponse struct { + UserPublicKey []byte + KeyHandle []byte + AttestationCertificate []byte + 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") } @@ -78,10 +107,12 @@ func (t *Token) Register(req RegisterRequest) ([]byte, 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 @@ -92,11 +123,43 @@ func (t *Token) Register(req RegisterRequest) ([]byte, 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) } } - return res.Data, nil + 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:] + + rawCert := new(asn1.RawValue) + sig, err := asn1.Unmarshal(remaining, rawCert) + if err != nil { + return nil, err + } + + registerRes := &RegisterResponse{ + UserPublicKey: userPubKey, + KeyHandle: keyHandle, + AttestationCertificate: rawCert.FullBytes, + Signature: sig, + } + + return registerRes, nil } // An AuthenticateRequires is a message used for authenticating to a relying party @@ -171,7 +234,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 { @@ -207,7 +274,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 @@ -221,12 +292,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(200 * time.Millisecond) + } + } +} + // A Request is a low-level request to the token. type Request struct { Command uint8 @@ -264,3 +358,11 @@ func (t *Token) Message(req Request) (*Response, error) { Status: binary.BigEndian.Uint16(data[len(data)-2:]), }, nil } + +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 new file mode 100644 index 0000000..76bf71b --- /dev/null +++ b/webauthn/ctap1.go @@ -0,0 +1,239 @@ +package webauthn + +import ( + "context" + "crypto/elliptic" + "crypto/sha256" + "encoding/binary" + "errors" + "time" + + "github.com/flynn/u2f/crypto" + "github.com/flynn/u2f/u2ftoken" +) + +type ctap1WebAuthnToken struct { + t *u2ftoken.Token +} + +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 { + 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 == UVRequired { + return nil, errors.New("webauth: ctap1 protocol does not support required user verification") + } + + sha := sha256.New() + if _, err := sha.Write([]byte(req.RP.ID)); err != nil { + return nil, err + } + rpIDHash := sha.Sum(nil) + + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() + if err != nil { + return nil, err + } + + // 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. + // 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 + } + } + + resp, err := w.waitRegister(ctx, &u2ftoken.RegisterRequest{ + Application: rpIDHash, + Challenge: clientDataHash, + }) + if err != nil { + 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, + // 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(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") + } + if req.UserVerification == UVRequired { + return nil, errors.New("webauthn: ctap1 does not support user verification") + } + + sha := sha256.New() + if _, err := sha.Write([]byte(req.RpID)); err != nil { + return nil, err + } + rpIDHash := sha.Sum(nil) + + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() + if err != nil { + return nil, err + } + + authReq := &u2ftoken.AuthenticateRequest{ + Challenge: clientDataHash, + Application: rpIDHash, + KeyHandle: req.AllowCredentials[0].ID, + } + + if len(req.AllowCredentials) > 1 { + for _, cred := range req.AllowCredentials { + authReq.KeyHandle = cred.ID + if err := w.t.CheckAuthenticate(*authReq); err == nil { + break + } + } + } + + authResp, err := w.waitAuthenticate(ctx, authReq) + 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) 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) 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 { + case <-ctx.Done(): + return nil, ctx.Err() + 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 + } + } + } +} + +func (w *ctap1WebAuthnToken) waitAuthenticate(ctx context.Context, req *u2ftoken.AuthenticateRequest) (*u2ftoken.AuthenticateResponse, error) { + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + 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 + } + } + } +} diff --git a/webauthn/ctap2.go b/webauthn/ctap2.go new file mode 100644 index 0000000..3eca142 --- /dev/null +++ b/webauthn/ctap2.go @@ -0,0 +1,248 @@ +package webauthn + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" + + "github.com/flynn/u2f/crypto" + ctap2 "github.com/flynn/u2f/ctap2token" + "github.com/flynn/u2f/ctap2token/pin" +) + +var supportedCTAP2CredentialTypes = map[string]ctap2.CredentialType{ + string(ctap2.PublicKey): ctap2.PublicKey, +} +var supportedCTAP2Transports = map[string]ctap2.AuthenticatorTransport{ + string(ctap2.USB): ctap2.USB, +} + +type ctap2WebAuthnToken struct { + t *ctap2.Token + options map[string]bool +} + +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] + if !ok { + continue + } + + credTypesAndPubKeyAlgs = append(credTypesAndPubKeyAlgs, ctap2.CredentialParam{ + Type: t, + Alg: crypto.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 points 11 and 12 from https://www.w3.org/TR/webauthn/#createCredential) + clientExtensions := make(map[string]interface{}) + + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() + if err != nil { + return nil, err + } + + excludeList := make([]ctap2.CredentialDescriptor, 0, len(req.ExcludeCredentials)) + for _, c := range req.ExcludeCredentials { + 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 := supportedCTAP2Transports[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 + } + + var pinProtocol ctap2.PinUVAuthProtocolVersion + var pinUVAuth *[]byte + if len(p.UserPIN) > 0 { + var err error + uvAuth, err := pin.ExchangeUserPin(w.t, p.UserPIN, clientDataHash) + if err != nil { + return nil, err + } + pinUVAuth = &uvAuth + pinProtocol = ctap2.PinProtoV1 + } + resp, err := w.t.MakeCredential(&ctap2.MakeCredentialRequest{ + ClientDataHash: clientDataHash, + RP: ctap2.CredentialRpEntity{ + ID: req.RP.ID, + 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: 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: + 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 *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{}) + + clientDataJSON, clientDataHash, err := p.ClientData.EncodeAndHash() + if err != nil { + return nil, err + } + + var pinProtocol ctap2.PinUVAuthProtocolVersion + var pinUVAuth []byte + if len(p.UserPIN) > 0 { + var err error + pinUVAuth, err = pin.ExchangeUserPin(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 := supportedCTAP2CredentialTypes[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: req.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 *ctap2WebAuthnToken) AuthenticatorSelection(ctx context.Context) error { + return w.t.AuthenticatorSelection(ctx) +} + +func (w *ctap2WebAuthnToken) RequireUV() bool { + return w.options["clientPin"] +} + +func (w *ctap2WebAuthnToken) SupportRK() bool { + return w.options["rk"] +} + +func (w *ctap2WebAuthnToken) SetResponseTimeout(timeout time.Duration) { + w.t.SetResponseTimeout(timeout) +} + +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 new file mode 100644 index 0000000..cdb3cd7 --- /dev/null +++ b/webauthn/example/demo.yubico.com/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "net/http/httputil" + "os" + + "github.com/flynn/u2f/ctap2token/pin" + "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" + + t := webauthn.New(webauthn.WithCTAP2PinHandler(pin.NewInteractiveHandler())) + + var err error + 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.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) + 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(context.Background(), 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.WebAuthn, 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(context.Background(), 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/webauthn.io/main.go b/webauthn/example/webauthn.io/main.go new file mode 100644 index 0000000..c33f532 --- /dev/null +++ b/webauthn/example/webauthn.io/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "net/http/httputil" + "os" + + "github.com/flynn/u2f/ctap2token/pin" + "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" + + t := webauthn.New(webauthn.WithCTAP2PinHandler(pin.NewInteractiveHandler())) + + var err error + 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.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)) + 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(context.Background(), 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.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 { + 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(context.Background(), 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 new file mode 100644 index 0000000..d7de9df --- /dev/null +++ b/webauthn/token.go @@ -0,0 +1,357 @@ +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" +) + +// DefaultResponseTimeout is the default timeout, in seconds, waiting for a response from a device +var DefaultResponseTimeout = 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) + +type WebAuthn struct { + debug bool + pinHandler pin.PINHandler + deviceSelectionTimeout time.Duration +} + +type WebAuthnOption func(*WebAuthn) + +func WithDebug(enabled bool) WebAuthnOption { + return func(a *WebAuthn) { + a.debug = enabled + } +} + +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(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) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + if req.Timeout <= 0 { + req.Timeout = DefaultResponseTimeout + } + if req.Timeout > MaxAllowedResponseTimeout { + req.Timeout = MaxAllowedResponseTimeout + } + + if req.RP.ID == "" { + req.RP.ID = originURL.Hostname() + } + + authenticators, userPIN, err := a.selectAuthenticators(ctx, req.AuthenticatorSelection) + if err != nil { + return nil, err + } + + type authenticatorResponse struct { + authenticator Authenticator + resp *RegisterResponse + err error + } + + 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) { + // 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{ + 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 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") + } +} + +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) + } + if originURL.Opaque != "" { + return nil, fmt.Errorf("webauthn: invalid opaque origin %q", origin) + } + + if req.Timeout <= 0 { + req.Timeout = DefaultResponseTimeout + } + if req.Timeout > MaxAllowedResponseTimeout { + req.Timeout = MaxAllowedResponseTimeout + } + + if req.RpID == "" { + req.RpID = originURL.Hostname() + } + + authenticators, userPIN, err := a.selectAuthenticators(ctx, AuthenticatorSelection{ + UserVerification: req.UserVerification, + }) + if err != nil { + return nil, err + } + + type authenticatorResponse struct { + authenticator Authenticator + resp *AuthenticateResponse + err error + } + + 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) { + // 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{ + 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 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 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 +// 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) { + 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 + 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, + } + } else { + current = &ctap1WebAuthnToken{ + t: u2ftoken.NewToken(dev), + } + } + + // 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 + } + + 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 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. + // 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 + } + } + + 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(ctx, 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: + // 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!") + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + } + + // Collect PIN or guide user to set a PIN on CTAP2 authenticators + if ctap2Auth, isCTAP2 := selectedAuth.(*ctap2WebAuthnToken); 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 by touching the authenticator when it blinks...") + + } + + return selected, userPIN, nil +} diff --git a/webauthn/types.go b/webauthn/types.go new file mode 100644 index 0000000..face6ec --- /dev/null +++ b/webauthn/types.go @@ -0,0 +1,161 @@ +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 Authenticator interface { + // Register is the equivalent to navigator.credential.create() + Register(ctx context.Context, req *RegisterRequest, p *RequestParams) (*RegisterResponse, error) + // Authenticate is the equivalent to navigator.credential.get() + Authenticate(ctx context.Context, req *AuthenticateRequest, p *RequestParams) (*AuthenticateResponse, error) + + AuthenticatorSelection(ctx context.Context) error + + SetResponseTimeout(timeout time.Duration) + RequireUV() bool + SupportRK() bool + Close() +} + +type RequestParams struct { + UserPIN []byte + ClientData CollectedClientData +} + +type Device interface { + ctap2.Device + 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 RP `json:"rp"` + User User `json:"user"` + PubKeyCredParams []PubKeyCredParams `json:"pubKeyCredParams"` + ExcludeCredentials []ExcludedCredential `json:"excludeCredentials"` + AuthenticatorSelection AuthenticatorSelection `json:"authenticatorSelection"` + Timeout int `json:"timeout"` + Extensions map[string]interface{} `json:"extensions"` + 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"` + UserVerification UserVerification `json:"userVerification"` +} + +type UserVerification string + +const ( + UVDiscouraged UserVerification = "discouraged" + UVPreferred UserVerification = "preferred" + UVRequired UserVerification = "required" +) + +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["attStmt"] = m.AttSmt + att["authData"] = m.AuthData + return enc.Marshal(att) + } + + 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 []AllowedCredential `json:"allowCredentials"` + UserVerification UserVerification `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"` +} + +func (c CollectedClientData) EncodeAndHash() (dataJSON []byte, dataHash []byte, err error) { + dataJSON, err = json.Marshal(c) + if err != nil { + return nil, nil, err + } + + hash := sha256.Sum256(dataJSON) + dataHash = hash[:] + return dataJSON, dataHash, nil +}