├── MANIFEST.in ├── setup.cfg ├── .gitignore ├── tox.ini ├── pyu2f ├── tests │ ├── __init__.py │ ├── lib │ │ ├── __init__.py │ │ └── util.py │ ├── model_test.py │ ├── util_test.py │ ├── apdu_test.py │ ├── hid │ │ ├── linux_test.py │ │ └── macos_test.py │ ├── hidtransport_test.py │ ├── hardware_test.py │ ├── localauthenticator_test.py │ ├── u2f_test.py │ └── customauthenticator_test.py ├── __init__.py ├── convenience │ ├── __init__.py │ ├── authenticator.py │ ├── baseauthenticator.py │ ├── localauthenticator.py │ └── customauthenticator.py ├── hid │ ├── try.py │ ├── __init__.py │ ├── base.py │ ├── linux.py │ ├── windows.py │ └── macos.py ├── errors.py ├── model.py ├── apdu.py ├── hardware.py ├── u2f.py └── hidtransport.py ├── CONTRIBUTING.md ├── .travis.yml ├── README.md ├── setup.py └── LICENSE /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.py[cod] 3 | .pytest_cache/ 4 | .python-version 5 | venv 6 | *.swp 7 | 8 | .tox/ 9 | .cache/ 10 | 11 | build/ 12 | dist/ 13 | *.egg-info 14 | 15 | *.bak 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | # Travis config explicitly specifies the envs 3 | envlist = py36,py37,py38,py39 4 | 5 | skipsdist = True 6 | 7 | [testenv] 8 | deps=pytest 9 | pyfakefs 10 | commands=py.test pyu2f/tests/ 11 | 12 | -------------------------------------------------------------------------------- /pyu2f/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Package marker file.""" 16 | -------------------------------------------------------------------------------- /pyu2f/tests/lib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Package marker file.""" 16 | -------------------------------------------------------------------------------- /pyu2f/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Library for Universal 2nd factor authentication.""" 16 | -------------------------------------------------------------------------------- /pyu2f/convenience/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Convenience classes for U2F signing/authentication flow.""" 16 | -------------------------------------------------------------------------------- /pyu2f/hid/try.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Simple test program for hid interface. 16 | 17 | This simple test program lists all of the HID devices on the 18 | local system. 19 | """ 20 | from __future__ import print_function 21 | 22 | from pyu2f import hid 23 | 24 | 25 | def main(): 26 | devs = hid.Enumerate() 27 | for dev in devs: 28 | print(dev) 29 | 30 | 31 | if __name__ == '__main__': 32 | main() 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult [GitHub Help] for more 22 | information on using pull requests. 23 | 24 | [GitHub Help]: https://help.github.com/articles/about-pull-requests/ 25 | 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: "3.6" 5 | env: TOX_ENV=py36 6 | - python: "3.7" 7 | env: TOX_ENV=py37 8 | - python: "3.8" 9 | env: TOX_ENV=py38 10 | - python: "3.9" 11 | env: TOX_ENV=py39 12 | sudo: false 13 | install: 14 | - pip install tox 15 | script: tox -e $TOX_ENV 16 | deploy: 17 | provider: pypi 18 | user: __token__ 19 | password: 20 | secure: fN5b3I7B9Kma75TXOQuU4MTdkzAQ/+///xILMP3Qp/bkVGh8WTv2k1+zt1IKDR7L9/tV9Ai4VJNbZG1vvxwNWpQu7GWhTBST6GAE3SUf1To8YTnSYFtcvexaudEQOgdAqvXo0KCLj1sBQF+8VyYV6UkphttH8dnXC0l+Xc6KzrK28DKSnWNBjaMLKnADgXamWW/QpfRhp9NCUb2G0Vepq/hl2vgyF7I+fM5NJT/WW/6qBZ9toYjW08JPEaxQRlqmlXZtOCxQCm5Mpowkzhr0JucF51BGKwIz7Kmq8uqFOPlEuPB1Pz3vwIvmKn4zkyOo18vxqfEMl/b1qiz6y9gWXcXH5P+uZ4R9RT4VZPqz5mi4Xtz5HQT1GW600BKFjoauAdcTXbXcdSFo/FnBsow8munu48vlAm6Fcxs3v+2yoA2kqEL+xUKBYaGgoc7hrbD2xqn1gCMeZvRdVrx6IMRPq6VRDTl7mm/ndQH0nabj9k2M5vQBoX9/hwOz6655fmT1BWQaD7C/ShY1zLcNMHrHOVNFuYWt6Op/OMbwVDcCQkm9TStyBHVbeDhwYGegG7AHGNvk0W3aQxNLk7Hz/Hp2jA8UBVWlQ2rVj67cLa56c2i0B/lsurx7YlmCaWHzMtm4iYVIYsf8jiueNBTuhHkZdrm2BJUIyL2rtN0RHXfu6nQ= 21 | on: 22 | tags: true 23 | distributions: sdist bdist_wheel 24 | repo: google/pyu2f 25 | -------------------------------------------------------------------------------- /pyu2f/hid/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implements interface for talking to hid devices. 16 | 17 | This module implenets an interface for talking to low level hid devices 18 | using various methods on different platforms. 19 | """ 20 | import sys 21 | 22 | 23 | def Enumerate(): 24 | return InternalPlatformSwitch('Enumerate') 25 | 26 | 27 | def Open(path): 28 | return InternalPlatformSwitch('__init__', path) 29 | 30 | 31 | def InternalPlatformSwitch(funcname, *args, **kwargs): 32 | """Determine, on a platform-specific basis, which module to use.""" 33 | # pylint: disable=g-import-not-at-top 34 | clz = None 35 | if sys.platform.startswith('linux'): 36 | from pyu2f.hid import linux 37 | clz = linux.LinuxHidDevice 38 | elif sys.platform.startswith('win32'): 39 | from pyu2f.hid import windows 40 | clz = windows.WindowsHidDevice 41 | elif sys.platform.startswith('darwin'): 42 | from pyu2f.hid import macos 43 | clz = macos.MacOsHidDevice 44 | 45 | if not clz: 46 | raise Exception('Unsupported platform: ' + sys.platform) 47 | 48 | if funcname == '__init__': 49 | return clz(*args, **kwargs) 50 | return getattr(clz, funcname)(*args, **kwargs) 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyu2f 2 | 3 | Support for pyu2f library is discontinued, because U2F is an outdated FIDO spec. 4 | In favor of embracing FIDO2 and more secure protocols, [Yubico/python-fido2](https://github.com/Yubico/python-fido2) 5 | is recommended, which also has backward compatible support for U2F. 6 | 7 | [![Build Status](https://travis-ci.org/google/pyu2f.svg?branch=master)](https://travis-ci.org/google/pyu2f) 8 | 9 | pyu2f is a python based U2F host library for Linux, Windows, and MacOS. It 10 | provides functionality for interacting with a U2F device over USB. 11 | 12 | ## Features 13 | 14 | pyu2f uses ctypes to make system calls directly to interface with the USB HID 15 | device. This means that no platform specific shared libraries need to be 16 | compiled for pyu2f to work. 17 | 18 | By default pyu2f will use its own U2F stack implementation to sign requests. If 19 | desired, pyu2f can offload signing to a pluggable command line tool. Offloading 20 | is not yet supported for U2F registration. 21 | 22 | ## Usage 23 | 24 | The recommended approach for U2F signing (authentication) is through the 25 | convenience interface: 26 | 27 | ``` 28 | from pyu2f import model 29 | from pyu2f.convenience import authenticator 30 | 31 | ... 32 | 33 | registered_key = model.RegisteredKey(b64_encoded_key) 34 | challenge_data = [{'key': registered_key, 'challenge': raw_challenge_data}] 35 | 36 | api = authenticator.CreateCompositeAuthenticator(origin) 37 | response = api.Authenticate(app_id, challenge_data) 38 | 39 | ``` 40 | 41 | See baseauthenticator.py for interface details. 42 | 43 | ## Authentication Plugin 44 | 45 | The convenience interface allows for a pluggable authenticator to be defined and 46 | used instead of the built in U2F stack. 47 | 48 | This can be done by setting the SK_SIGNING_PLUGIN environment variable to the 49 | plugin tool. The plugin tool should follow the specification detailed in 50 | customauthenticator.py 51 | 52 | If SK_SIGNING_PLUGIN is set, the convenience layer will invoke the signing 53 | plugin whenver Authenticate() is called. 54 | -------------------------------------------------------------------------------- /pyu2f/convenience/authenticator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Interface to handle end to end flow of U2F signing.""" 16 | 17 | import sys 18 | 19 | from pyu2f.convenience import baseauthenticator 20 | from pyu2f.convenience import customauthenticator 21 | from pyu2f.convenience import localauthenticator 22 | 23 | 24 | def CreateCompositeAuthenticator(origin): 25 | authenticators = [customauthenticator.CustomAuthenticator(origin), 26 | localauthenticator.LocalAuthenticator(origin)] 27 | return CompositeAuthenticator(authenticators) 28 | 29 | 30 | class CompositeAuthenticator(baseauthenticator.BaseAuthenticator): 31 | """Composes multiple authenticators into a single authenticator. 32 | 33 | Priority is based on the order of the list initialized with the instance. 34 | """ 35 | 36 | def __init__(self, authenticators): 37 | self.authenticators = authenticators 38 | 39 | def Authenticate(self, app_id, challenge_data, 40 | print_callback=sys.stderr.write): 41 | """See base class.""" 42 | for authenticator in self.authenticators: 43 | if authenticator.IsAvailable(): 44 | result = authenticator.Authenticate(app_id, 45 | challenge_data, 46 | print_callback) 47 | return result 48 | 49 | raise ValueError('No valid authenticators found') 50 | 51 | def IsAvailable(self): 52 | """See base class.""" 53 | return True 54 | -------------------------------------------------------------------------------- /pyu2f/tests/model_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.model.""" 16 | 17 | import json 18 | import sys 19 | 20 | from pyu2f import errors 21 | from pyu2f import model 22 | 23 | if sys.version_info[:2] < (2, 7): 24 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 25 | else: 26 | import unittest # pylint: disable=g-import-not-at-top 27 | 28 | 29 | class ModelTest(unittest.TestCase): 30 | 31 | def testClientDataRegistration(self): 32 | cd = model.ClientData(model.ClientData.TYP_REGISTRATION, b'ABCD', 33 | 'somemachine') 34 | obj = json.loads(cd.GetJson()) 35 | self.assertEqual(len(list(obj.keys())), 3) 36 | self.assertEqual(obj['typ'], model.ClientData.TYP_REGISTRATION) 37 | self.assertEqual(obj['challenge'], 'QUJDRA') 38 | self.assertEqual(obj['origin'], 'somemachine') 39 | 40 | def testClientDataAuth(self): 41 | cd = model.ClientData(model.ClientData.TYP_AUTHENTICATION, b'ABCD', 42 | 'somemachine') 43 | obj = json.loads(cd.GetJson()) 44 | self.assertEqual(len(list(obj.keys())), 3) 45 | self.assertEqual(obj['typ'], model.ClientData.TYP_AUTHENTICATION) 46 | self.assertEqual(obj['challenge'], 'QUJDRA') 47 | self.assertEqual(obj['origin'], 'somemachine') 48 | 49 | def testClientDataInvalid(self): 50 | self.assertRaises(errors.InvalidModelError, model.ClientData, 'foobar', 51 | b'ABCD', 'somemachine') 52 | 53 | 54 | if __name__ == '__main__': 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Google Inc. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """Setup configuration.""" 18 | 19 | try: 20 | import setuptools 21 | except ImportError: 22 | from ez_setup import use_setuptools 23 | use_setuptools() 24 | import setuptools 25 | 26 | 27 | setuptools.setup( 28 | name='pyu2f', 29 | version='0.1.5', 30 | description='U2F host library for interacting with a U2F device over USB.', 31 | long_description='pyu2f is a python based U2F host library for Linux, ' 32 | 'Windows, and MacOS. It provides functionality for ' 33 | 'interacting with a U2F device over USB.', 34 | url='https://github.com/google/pyu2f/', 35 | author='Google Inc.', 36 | author_email='google-pyu2f@google.com', 37 | # Contained modules and scripts. 38 | packages=setuptools.find_packages(exclude=["pyu2f.tests", "pyu2f.tests.*"]), 39 | install_requires=[ 40 | ], 41 | tests_require=[ 42 | 'pyfakefs>=2.4', 43 | ], 44 | include_package_data=True, 45 | platforms=["Windows", "Linux", "OS X", "macOS"], 46 | # PyPI package information. 47 | classifiers=[ 48 | 'License :: OSI Approved :: Apache Software License', 49 | 'Topic :: Software Development :: Libraries', 50 | 'Topic :: Software Development :: Libraries :: Python Modules', 51 | 'Programming Language :: Python', 52 | 'Programming Language :: Python :: 3', 53 | 'Programming Language :: Python :: 3.6', 54 | 'Programming Language :: Python :: 3.7', 55 | 'Programming Language :: Python :: 3.8', 56 | 'Programming Language :: Python :: 3.9', 57 | ], 58 | license='Apache 2.0', 59 | zip_safe=True, 60 | ) 61 | -------------------------------------------------------------------------------- /pyu2f/convenience/baseauthenticator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Interface to handle end to end flow of U2F signing.""" 16 | import sys 17 | 18 | 19 | class BaseAuthenticator(object): 20 | """Interface to handle end to end flow of U2F signing.""" 21 | 22 | def Authenticate(self, app_id, challenge_data, 23 | print_callback=sys.stderr.write): 24 | """Authenticates app_id with a security key. 25 | 26 | Executes the U2F authentication/signature flow with a security key. 27 | 28 | Args: 29 | app_id: The app_id to register the security key against. 30 | challenge_data: List of dictionaries containing a RegisteredKey ('key') 31 | and the raw challenge data ('challenge') for this key. 32 | print_callback: Callback to print a message to the user. The callback 33 | function takes one argument--the message to display. 34 | 35 | Returns: 36 | A dictionary with the following fields: 37 | 'clientData': url-safe base64 encoded ClientData JSON signed by the key. 38 | 'signatureData': url-safe base64 encoded signature. 39 | 'applicationId': application id. 40 | 'keyHandle': url-safe base64 encoded handle of the key used to sign. 41 | 42 | Raises: 43 | U2FError: There was some kind of problem with registration (e.g. 44 | the device was already registered or there was a timeout waiting 45 | for the test of user presence). 46 | """ 47 | raise NotImplementedError 48 | 49 | def IsAvailable(self): 50 | """Indicates whether the authenticator implementation is available to sign. 51 | 52 | The caller should not call Authenticate() if IsAvailable() returns False 53 | 54 | Returns: 55 | True if the authenticator is available to sign and False otherwise. 56 | 57 | """ 58 | raise NotImplementedError 59 | -------------------------------------------------------------------------------- /pyu2f/tests/util_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.tests.lib.util.""" 16 | 17 | import range 18 | import unittest 19 | 20 | from pyu2f.tests.lib import util 21 | 22 | 23 | class UtilTest(unittest.TestCase): 24 | 25 | def testSimplePing(self): 26 | dev = util.FakeHidDevice(cid_to_allocate=None) 27 | dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3]) 28 | self.assertEqual( 29 | dev.Read(), [0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3] + [0 30 | for _ in range(54)]) 31 | 32 | def testErrorBusy(self): 33 | dev = util.FakeHidDevice(cid_to_allocate=None) 34 | dev.SetChannelBusyCount(2) 35 | dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3]) 36 | self.assertEqual( 37 | dev.Read(), [0, 0, 0, 1, 0xbf, 0, 1, 6] + [0 for _ in range(56)]) 38 | dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3]) 39 | self.assertEqual( 40 | dev.Read(), [0, 0, 0, 1, 0xbf, 0, 1, 6] + [0 for _ in range(56)]) 41 | dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3]) 42 | self.assertEqual( 43 | dev.Read(), [0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3] + [0 44 | for _ in range(54)]) 45 | 46 | def testFragmentedApdu(self): 47 | dev = util.FakeHidDevice(cid_to_allocate=None, 48 | msg_reply=list(range(85, 0, -1))) 49 | dev.Write([0, 0, 0, 1, 0x83, 0, 100] + [x for x in range(57)]) 50 | dev.Write([0, 0, 0, 1, 0] + [x for x in range(57, 100)]) 51 | self.assertEqual( 52 | dev.Read(), [0, 0, 0, 1, 0x83, 0, 85] + [x for x in range(85, 28, -1)]) 53 | self.assertEqual( 54 | dev.Read(), 55 | [0, 0, 0, 1, 0] + [x for x in range(28, 0, -1)] + [0 56 | for _ in range(31)]) 57 | 58 | 59 | if __name__ == '__main__': 60 | unittest.main() 61 | -------------------------------------------------------------------------------- /pyu2f/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Exceptions that can be raised by the pyu2f library. 16 | 17 | All exceptions that can be raised by the pyu2f library. Most of these 18 | are internal coditions, but U2FError and NoDeviceFoundError are public 19 | errors that clients should expect to handle. 20 | """ 21 | 22 | 23 | class NoDeviceFoundError(Exception): 24 | pass 25 | 26 | 27 | class U2FError(Exception): 28 | OK = 0 29 | OTHER_ERROR = 1 30 | BAD_REQUEST = 2 31 | CONFIGURATION_UNSUPPORTED = 3 32 | DEVICE_INELIGIBLE = 4 33 | TIMEOUT = 5 34 | 35 | def __init__(self, code, cause=None): 36 | self.code = code 37 | if cause: 38 | self.cause = cause 39 | super(U2FError, self).__init__("U2F Error code: %d (cause: %s)" % 40 | (code, str(cause))) 41 | 42 | 43 | class HidError(Exception): 44 | """Errors in the hid usb transport protocol.""" 45 | pass 46 | 47 | 48 | class InvalidPacketError(HidError): 49 | pass 50 | 51 | 52 | class HardwareError(Exception): 53 | """Errors in the security key hardware that are transport independent.""" 54 | pass 55 | 56 | 57 | class InvalidRequestError(HardwareError): 58 | pass 59 | 60 | 61 | class ApduError(HardwareError): 62 | 63 | def __init__(self, sw1, sw2): 64 | self.sw1 = sw1 65 | self.sw2 = sw2 66 | super(ApduError, self).__init__("Device returned status: %d %d" % 67 | (sw1, sw2)) 68 | 69 | 70 | class TUPRequiredError(HardwareError): 71 | pass 72 | 73 | 74 | class InvalidKeyHandleError(HardwareError): 75 | pass 76 | 77 | 78 | class UnsupportedVersionException(Exception): 79 | pass 80 | 81 | 82 | class InvalidCommandError(Exception): 83 | pass 84 | 85 | 86 | class InvalidResponseError(Exception): 87 | pass 88 | 89 | 90 | class InvalidModelError(Exception): 91 | pass 92 | 93 | 94 | class OsHidError(Exception): 95 | pass 96 | 97 | 98 | class PluginError(Exception): 99 | pass 100 | -------------------------------------------------------------------------------- /pyu2f/convenience/localauthenticator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Convenience class for U2F signing with local security keys.""" 16 | import base64 17 | import sys 18 | 19 | from pyu2f import errors 20 | from pyu2f import u2f 21 | from pyu2f.convenience import baseauthenticator 22 | 23 | 24 | class LocalAuthenticator(baseauthenticator.BaseAuthenticator): 25 | """Authenticator wrapper around the native python u2f implementation.""" 26 | 27 | def __init__(self, origin): 28 | self.origin = origin 29 | 30 | def Authenticate(self, app_id, challenge_data, 31 | print_callback=sys.stderr.write): 32 | """See base class.""" 33 | # If authenticator is not plugged in, prompt 34 | try: 35 | device = u2f.GetLocalU2FInterface(origin=self.origin) 36 | except errors.NoDeviceFoundError: 37 | print_callback('Please insert your security key and press enter...') 38 | input() 39 | device = u2f.GetLocalU2FInterface(origin=self.origin) 40 | 41 | print_callback('Please touch your security key.\n') 42 | 43 | for challenge_item in challenge_data: 44 | raw_challenge = challenge_item['challenge'] 45 | key = challenge_item['key'] 46 | 47 | try: 48 | result = device.Authenticate(app_id, raw_challenge, [key]) 49 | except errors.U2FError as e: 50 | if e.code == errors.U2FError.DEVICE_INELIGIBLE: 51 | continue 52 | else: 53 | raise 54 | 55 | client_data = self._base64encode(result.client_data.GetJson().encode()) 56 | signature_data = self._base64encode(result.signature_data) 57 | key_handle = self._base64encode(result.key_handle) 58 | 59 | return { 60 | 'clientData': client_data, 61 | 'signatureData': signature_data, 62 | 'applicationId': app_id, 63 | 'keyHandle': key_handle, 64 | } 65 | 66 | raise errors.U2FError(errors.U2FError.DEVICE_INELIGIBLE) 67 | 68 | def IsAvailable(self): 69 | """See base class.""" 70 | return True 71 | 72 | def _base64encode(self, bytes_data): 73 | """Helper method to base64 encode and return str result.""" 74 | return base64.urlsafe_b64encode(bytes_data).decode() 75 | -------------------------------------------------------------------------------- /pyu2f/model.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implements data model for the library. 16 | 17 | This module implements basic data model objects that are necessary 18 | for interacting with the Security Key as well as for implementing 19 | the higher level components of the U2F protocol. 20 | """ 21 | 22 | import base64 23 | import json 24 | 25 | from pyu2f import errors 26 | 27 | 28 | class ClientData(object): 29 | """FIDO U2F ClientData. 30 | 31 | Implements the ClientData object of the FIDO U2F protocol. 32 | """ 33 | TYP_AUTHENTICATION = 'navigator.id.getAssertion' 34 | TYP_REGISTRATION = 'navigator.id.finishEnrollment' 35 | 36 | def __init__(self, typ, raw_server_challenge, origin): 37 | if typ not in [ClientData.TYP_REGISTRATION, ClientData.TYP_AUTHENTICATION]: 38 | raise errors.InvalidModelError() 39 | self.typ = typ 40 | self.raw_server_challenge = raw_server_challenge 41 | self.origin = origin 42 | 43 | def GetJson(self): 44 | """Returns JSON version of ClientData compatible with FIDO spec.""" 45 | 46 | # The U2F Raw Messages specification specifies that the challenge is encoded 47 | # with URL safe Base64 without padding encoding specified in RFC 4648. 48 | # Python does not natively support a paddingless encoding, so we simply 49 | # remove the padding from the end of the string. 50 | server_challenge_b64 = base64.urlsafe_b64encode( 51 | self.raw_server_challenge).decode() 52 | server_challenge_b64 = server_challenge_b64.rstrip('=') 53 | return json.dumps({'typ': self.typ, 54 | 'challenge': server_challenge_b64, 55 | 'origin': self.origin}, sort_keys=True) 56 | 57 | def __repr__(self): 58 | return self.GetJson() 59 | 60 | 61 | class RegisteredKey(object): 62 | 63 | def __init__(self, key_handle, version=u'U2F_V2'): 64 | self.key_handle = key_handle 65 | self.version = version 66 | 67 | 68 | class RegisterResponse(object): 69 | 70 | def __init__(self, registration_data, client_data): 71 | self.registration_data = registration_data 72 | self.client_data = client_data 73 | 74 | 75 | class SignResponse(object): 76 | 77 | def __init__(self, key_handle, signature_data, client_data): 78 | self.key_handle = key_handle 79 | self.signature_data = signature_data 80 | self.client_data = client_data 81 | -------------------------------------------------------------------------------- /pyu2f/hid/base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implement base classes for hid package. 16 | 17 | This module provides the base classes implemented by the 18 | platform-specific modules. It includes a base class for 19 | all implementations built on interacting with file-like objects. 20 | """ 21 | 22 | 23 | class HidDevice(object): 24 | """Base class for all HID devices in this package.""" 25 | 26 | @staticmethod 27 | def Enumerate(): 28 | """Enumerates all the hid devices. 29 | 30 | This function enumerates all the hid device and provides metadata 31 | for helping the client select one. 32 | 33 | Returns: 34 | A list of dictionaries of metadata. Each implementation is required 35 | to provide at least: vendor_id, product_id, product_string, usage, 36 | usage_page, and path. 37 | """ 38 | pass 39 | 40 | def __init__(self, path): 41 | """Initialize the device at path.""" 42 | pass 43 | 44 | def GetInReportDataLength(self): 45 | """Returns the max input report data length in bytes. 46 | 47 | Returns the max input report data length in bytes. This excludes the 48 | report id. 49 | """ 50 | pass 51 | 52 | def GetOutReportDataLength(self): 53 | """Returns the max output report data length in bytes. 54 | 55 | Returns the max output report data length in bytes. This excludes the 56 | report id. 57 | """ 58 | pass 59 | 60 | def Write(self, packet): 61 | """Writes packet to device. 62 | 63 | Writes the packet to the device. 64 | 65 | Args: 66 | packet: An array of integers to write to the device. Excludes the report 67 | ID. Must be equal to GetOutReportLength(). 68 | """ 69 | pass 70 | 71 | def Read(self): 72 | """Reads packet from device. 73 | 74 | Reads the packet from the device. 75 | 76 | Returns: 77 | An array of integers read from the device. Excludes the report ID. 78 | The length is equal to GetInReportDataLength(). 79 | """ 80 | pass 81 | 82 | 83 | class DeviceDescriptor(object): 84 | """Descriptor for basic attributes of the device.""" 85 | 86 | usage_page = None 87 | usage = None 88 | vendor_id = None 89 | product_id = None 90 | product_string = None 91 | path = None 92 | 93 | internal_max_in_report_len = 0 94 | internal_max_out_report_len = 0 95 | 96 | def ToPublicDict(self): 97 | out = {} 98 | for k, v in list(self.__dict__.items()): 99 | if not k.startswith('internal_'): 100 | out[k] = v 101 | return out 102 | -------------------------------------------------------------------------------- /pyu2f/tests/apdu_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.apdu.""" 16 | 17 | import range 18 | import unittest 19 | 20 | from pyu2f import apdu 21 | from pyu2f import errors 22 | 23 | 24 | class ApduTest(unittest.TestCase): 25 | 26 | def testSerializeCommandApdu(self): 27 | cmd = apdu.CommandApdu(0, 0x01, 0x03, 0x04, bytearray([0x10, 0x20, 0x30])) 28 | self.assertEqual( 29 | cmd.ToByteArray(), 30 | bytearray([0x00, 0x01, 0x03, 0x04, 0x00, 0x00, 0x03, 0x10, 0x20, 0x30, 31 | 0x00, 0x00])) 32 | self.assertEqual( 33 | cmd.ToLegacyU2FByteArray(), 34 | bytearray([0x00, 0x01, 0x03, 0x04, 0x00, 0x00, 0x03, 0x10, 0x20, 0x30, 35 | 0x00, 0x00])) 36 | 37 | def testSerializeCommandApduNoData(self): 38 | cmd = apdu.CommandApdu(0, 0x01, 0x03, 0x04) 39 | self.assertEqual(cmd.ToByteArray(), 40 | bytearray([0x00, 0x01, 0x03, 0x04, 0x00, 0x00, 0x00])) 41 | self.assertEqual(cmd.ToLegacyU2FByteArray(), 42 | bytearray([0x00, 0x01, 0x03, 0x04, 43 | 0x00, 0x00, 0x00, 0x00, 0x00])) 44 | 45 | def testSerializeCommandApduTooLong(self): 46 | self.assertRaises(errors.InvalidCommandError, apdu.CommandApdu, 0, 0x01, 47 | 0x03, 0x04, bytearray(0 for x in range(0, 65536))) 48 | 49 | def testResponseApduParse(self): 50 | resp = apdu.ResponseApdu(bytearray([0x05, 0x04, 0x90, 0x00])) 51 | self.assertEqual(resp.body, bytearray([0x05, 0x04])) 52 | self.assertEqual(resp.sw1, 0x90) 53 | self.assertEqual(resp.sw2, 0x00) 54 | self.assertTrue(resp.IsSuccess()) 55 | 56 | def testResponseApduParseNoBody(self): 57 | resp = apdu.ResponseApdu(bytearray([0x69, 0x85])) 58 | self.assertEqual(resp.sw1, 0x69) 59 | self.assertEqual(resp.sw2, 0x85) 60 | self.assertFalse(resp.IsSuccess()) 61 | 62 | def testResponseApduParseInvalid(self): 63 | self.assertRaises(errors.InvalidResponseError, apdu.ResponseApdu, 64 | bytearray([0x05])) 65 | 66 | def testResponseApduCheckSuccessTUPRequired(self): 67 | resp = apdu.ResponseApdu(bytearray([0x69, 0x85])) 68 | self.assertRaises(errors.TUPRequiredError, resp.CheckSuccessOrRaise) 69 | 70 | def testResponseApduCheckSuccessInvalidKeyHandle(self): 71 | resp = apdu.ResponseApdu(bytearray([0x6a, 0x80])) 72 | self.assertRaises(errors.InvalidKeyHandleError, resp.CheckSuccessOrRaise) 73 | 74 | def testResponseApduCheckSuccessOtherError(self): 75 | resp = apdu.ResponseApdu(bytearray([0xfa, 0x05])) 76 | self.assertRaises(errors.ApduError, resp.CheckSuccessOrRaise) 77 | 78 | 79 | if __name__ == '__main__': 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /pyu2f/apdu.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implement the U2F variant of ISO 7816 extended APDU. 16 | 17 | This module implements a subset ISO 7816 APDU encoding. In particular, 18 | it only supports extended length encoding, it only supports commands 19 | that expect a reply, and it does not support explicitly specifying 20 | the length of the expected reply. 21 | 22 | It also implements the U2F variant of ISO 7816 where the Lc field 23 | is always specified, even if there is no data. 24 | """ 25 | import struct 26 | 27 | from pyu2f import errors 28 | 29 | CMD_REGISTER = 0x01 30 | CMD_AUTH = 0x02 31 | CMD_VERSION = 0x03 32 | 33 | 34 | class CommandApdu(object): 35 | """Represents a Command APDU. 36 | 37 | Represents a Command APDU sent to the security key. Encoding 38 | is specified in FIDO U2F standards. 39 | """ 40 | cla = None 41 | ins = None 42 | p1 = None 43 | p2 = None 44 | data = None 45 | 46 | def __init__(self, cla, ins, p1, p2, data=None): 47 | self.cla = cla 48 | self.ins = ins 49 | self.p1 = p1 50 | self.p2 = p2 51 | if data and len(data) > 65535: 52 | raise errors.InvalidCommandError() 53 | if data: 54 | self.data = data 55 | 56 | def ToByteArray(self): 57 | """Serialize the command. 58 | 59 | Encodes the command as per the U2F specs, using the standard 60 | ISO 7816-4 extended encoding. All Commands expect data, so 61 | Le is always present. 62 | 63 | Returns: 64 | Python bytearray of the encoded command. 65 | """ 66 | lc = self.InternalEncodeLc() 67 | out = bytearray(4) # will extend 68 | 69 | out[0] = self.cla 70 | out[1] = self.ins 71 | out[2] = self.p1 72 | out[3] = self.p2 73 | if self.data: 74 | out.extend(lc) 75 | out.extend(self.data) 76 | out.extend([0x00, 0x00]) # Le 77 | else: 78 | out.extend([0x00, 0x00, 0x00]) # Le 79 | return out 80 | 81 | def ToLegacyU2FByteArray(self): 82 | """Serialize the command in the legacy format. 83 | 84 | Encodes the command as per the U2F specs, using the legacy 85 | encoding in which LC is always present. 86 | 87 | Returns: 88 | Python bytearray of the encoded command. 89 | """ 90 | 91 | lc = self.InternalEncodeLc() 92 | out = bytearray(4) # will extend 93 | 94 | out[0] = self.cla 95 | out[1] = self.ins 96 | out[2] = self.p1 97 | out[3] = self.p2 98 | out.extend(lc) 99 | if self.data: 100 | out.extend(self.data) 101 | out.extend([0x00, 0x00]) # Le 102 | 103 | return out 104 | 105 | def InternalEncodeLc(self): 106 | dl = 0 107 | if self.data: 108 | dl = len(self.data) 109 | # The top two bytes are guaranteed to be 0 by the assertion 110 | # in the constructor. 111 | fourbyte = struct.pack('>I', dl) 112 | return bytearray(fourbyte[1:]) 113 | 114 | 115 | class ResponseApdu(object): 116 | """Represents a Response APDU. 117 | 118 | Represents a Response APU sent by the security key. Encoding 119 | is specified in FIDO U2F standards. 120 | """ 121 | body = None 122 | sw1 = None 123 | sw2 = None 124 | 125 | def __init__(self, data): 126 | self.dbg_full_packet = data 127 | if not data or len(data) < 2: 128 | raise errors.InvalidResponseError() 129 | 130 | if len(data) > 2: 131 | self.body = data[:-2] 132 | 133 | self.sw1 = data[-2] 134 | self.sw2 = data[-1] 135 | 136 | def IsSuccess(self): 137 | return self.sw1 == 0x90 and self.sw2 == 0x00 138 | 139 | def CheckSuccessOrRaise(self): 140 | if self.sw1 == 0x69 and self.sw2 == 0x85: # SW_CONDITIONS_NOT_SATISFIED 141 | raise errors.TUPRequiredError() 142 | elif self.sw1 == 0x6a and self.sw2 == 0x80: # SW_WRONG_DATA 143 | raise errors.InvalidKeyHandleError() 144 | elif self.sw1 == 0x67 and self.sw2 == 0x00: # SW_WRONG_LENGTH 145 | raise errors.InvalidKeyHandleError() 146 | elif not self.IsSuccess(): 147 | raise errors.ApduError(self.sw1, self.sw2) 148 | -------------------------------------------------------------------------------- /pyu2f/tests/hid/linux_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.hid.linux.""" 16 | 17 | import base64 18 | import os 19 | import sys 20 | 21 | import mock 22 | 23 | from pyu2f.hid import linux 24 | 25 | # Since the builtins name changed between Python 2 and Python 3, we have to 26 | # make sure to mock the corret one. 27 | if sys.version_info[0] > 2: 28 | import builtins as py_builtins 29 | else: 30 | import __builtin__ as py_builtins 31 | 32 | try: 33 | from pyfakefs import fake_filesystem # pylint: disable=g-import-not-at-top 34 | except ImportError: 35 | from fakefs import fake_filesystem # pylint: disable=g-import-not-at-top 36 | 37 | if sys.version_info[:2] < (2, 7): 38 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 39 | else: 40 | import unittest # pylint: disable=g-import-not-at-top 41 | 42 | 43 | # These are base64 encoded report descriptors of a Yubico token 44 | # and a Logitech keyboard. 45 | YUBICO_RD = 'BtDxCQGhAQkgFQAm/wB1CJVAgQIJIRUAJv8AdQiVQJECwA==' 46 | KEYBOARD_RD = ( 47 | 'BQEJAqEBCQGhAAUJGQEpBRUAJQGVBXUBgQKVAXUDgQEFAQkwCTEJOBWBJX91CJUDgQbAwA==') 48 | 49 | 50 | def AddDevice(fs, dev_name, product_name, 51 | vendor_id, product_id, report_descriptor_b64): 52 | uevent = fs.create_file('/sys/class/hidraw/%s/device/uevent' % dev_name) 53 | rd = fs.create_file('/sys/class/hidraw/%s/device/report_descriptor' % dev_name) 54 | report_descriptor = base64.b64decode(report_descriptor_b64) 55 | rd.set_contents(report_descriptor) 56 | 57 | buf = 'HID_NAME=%s\n' % product_name.encode('utf8') 58 | buf += 'HID_ID=0001:%08X:%08X\n' % (vendor_id, product_id) 59 | uevent.set_contents(buf) 60 | 61 | 62 | class FakeDeviceOsModule(object): 63 | O_RDWR = os.O_RDWR 64 | path = os.path 65 | 66 | data_written = None 67 | data_to_return = None 68 | 69 | def open(self, unused_path, unused_opts): # pylint: disable=invalid-name 70 | return 0 71 | 72 | def write(self, unused_dev, data): # pylint: disable=invalid-name 73 | self.data_written = data 74 | 75 | def read(self, unused_dev, unused_length): # pylint: disable=invalid-name 76 | return self.data_to_return 77 | 78 | 79 | class LinuxTest(unittest.TestCase): 80 | def setUp(self): 81 | self.fs = fake_filesystem.FakeFilesystem() 82 | self.fs.create_dir('/sys/class/hidraw') 83 | 84 | def tearDown(self): 85 | self.fs.remove_object('/sys/class/hidraw') 86 | 87 | def testCallEnumerate(self): 88 | AddDevice(self.fs, 'hidraw1', 'Logitech USB Keyboard', 89 | 0x046d, 0xc31c, KEYBOARD_RD) 90 | AddDevice(self.fs, 'hidraw2', 'Yubico U2F', 0x1050, 0x0407, YUBICO_RD) 91 | with mock.patch.object(linux, 'os', fake_filesystem.FakeOsModule(self.fs)): 92 | fake_open = fake_filesystem.FakeFileOpen(self.fs) 93 | with mock.patch.object(py_builtins, 'open', fake_open): 94 | devs = list(linux.LinuxHidDevice.Enumerate()) 95 | devs = sorted(devs, key=lambda k: (k['vendor_id'])) 96 | 97 | self.assertEqual(len(devs), 2) 98 | self.assertEqual(devs[0]['vendor_id'], 0x046d) 99 | self.assertEqual(devs[0]['product_id'], 0x0c31c) 100 | self.assertEqual(devs[1]['vendor_id'], 0x1050) 101 | self.assertEqual(devs[1]['product_id'], 0x0407) 102 | self.assertEqual(devs[1]['usage_page'], 0xf1d0) 103 | self.assertEqual(devs[1]['usage'], 1) 104 | 105 | def testCallOpen(self): 106 | AddDevice(self.fs, 'hidraw1', 'Yubico U2F', 0x1050, 0x0407, YUBICO_RD) 107 | fake_open = fake_filesystem.FakeFileOpen(self.fs) 108 | # The open() builtin is used to read from the fake sysfs 109 | with mock.patch.object(py_builtins, 'open', fake_open): 110 | # The os.open function is used to interact with the low 111 | # level device. We don't want to use fakefs for that. 112 | fake_dev_os = FakeDeviceOsModule() 113 | with mock.patch.object(linux, 'os', fake_dev_os): 114 | dev = linux.LinuxHidDevice('/dev/hidraw1') 115 | self.assertEqual(dev.GetInReportDataLength(), 64) 116 | self.assertEqual(dev.GetOutReportDataLength(), 64) 117 | 118 | dev.Write(list(range(0, 64))) 119 | # The HidDevice implementation prepends a zero-byte representing the 120 | # report ID 121 | self.assertEqual(list(fake_dev_os.data_written), 122 | [0] + list(range(0, 64))) 123 | 124 | fake_dev_os.data_to_return = b'x' * 64 125 | self.assertEqual(dev.Read(), [120] * 64) # chr(120) = 'x' 126 | 127 | 128 | if __name__ == '__main__': 129 | unittest.main() 130 | -------------------------------------------------------------------------------- /pyu2f/tests/hid/macos_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.hid.macos.""" 16 | 17 | import ctypes 18 | import sys 19 | import mock 20 | 21 | from pyu2f import errors 22 | from pyu2f.hid import macos 23 | 24 | 25 | if sys.version_info[:2] < (2, 7): 26 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 27 | else: 28 | import unittest # pylint: disable=g-import-not-at-top 29 | 30 | 31 | def init_mock_iokit(mock_iokit): 32 | # Device open should always return 0 (success) 33 | mock_iokit.IOHIDDeviceOpen = mock.Mock(return_value=0) 34 | mock_iokit.IOHIDDeviceSetReport = mock.Mock(return_value=0) 35 | mock_iokit.IOHIDDeviceCreate = mock.Mock(return_value='handle') 36 | 37 | 38 | def init_mock_cf(mock_cf): 39 | mock_cf.CFGetTypeID = mock.Mock(return_value=123) 40 | mock_cf.CFNumberGetTypeID = mock.Mock(return_value=123) 41 | mock_cf.CFStringGetTypeID = mock.Mock(return_value=123) 42 | 43 | 44 | def init_mock_get_int_property(mock_get_int_property): 45 | mock_get_int_property.return_value = 64 46 | 47 | 48 | class MacOsTest(unittest.TestCase): 49 | 50 | @mock.patch.object(macos.threading, 'Thread') 51 | @mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT, 52 | GetDeviceIntProperty=mock.DEFAULT) 53 | def testInitHidDevice(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name 54 | init_mock_iokit(iokit) 55 | init_mock_cf(cf) 56 | init_mock_get_int_property(GetDeviceIntProperty) 57 | 58 | device = macos.MacOsHidDevice('fakepath') 59 | 60 | self.assertEqual(64, device.GetInReportDataLength()) 61 | self.assertEqual(64, device.GetOutReportDataLength()) 62 | 63 | @mock.patch.object(macos.threading, 'Thread') 64 | @mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT, 65 | GetDeviceIntProperty=mock.DEFAULT) 66 | def testCallWriteSuccess(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name 67 | init_mock_iokit(iokit) 68 | init_mock_cf(cf) 69 | init_mock_get_int_property(GetDeviceIntProperty) 70 | 71 | device = macos.MacOsHidDevice('fakepath') 72 | 73 | # Write 64 bytes to device 74 | data = bytearray(range(64)) 75 | 76 | # Write to device 77 | device.Write(data) 78 | 79 | # Validate that write calls into IOKit 80 | set_report_call_args = iokit.IOHIDDeviceSetReport.call_args 81 | self.assertIsNotNone(set_report_call_args) 82 | 83 | set_report_call_pos_args = iokit.IOHIDDeviceSetReport.call_args[0] 84 | self.assertEqual(len(set_report_call_pos_args), 5) 85 | self.assertEqual(set_report_call_pos_args[0], 'handle') 86 | self.assertEqual(set_report_call_pos_args[1], 1) 87 | self.assertEqual(set_report_call_pos_args[2], 0) 88 | self.assertEqual(set_report_call_pos_args[4], 64) 89 | 90 | report_buffer = set_report_call_pos_args[3] 91 | self.assertEqual(len(report_buffer), 64) 92 | self.assertEqual(bytearray(report_buffer), data, 'Data sent to ' 93 | 'IOHIDDeviceSetReport should match data sent to the ' 94 | 'device') 95 | 96 | @mock.patch.object(macos.threading, 'Thread') 97 | @mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT, 98 | GetDeviceIntProperty=mock.DEFAULT) 99 | def testCallWriteFailure(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name 100 | init_mock_iokit(iokit) 101 | init_mock_cf(cf) 102 | init_mock_get_int_property(GetDeviceIntProperty) 103 | 104 | # Make set report call return failure (non-zero) 105 | iokit.IOHIDDeviceSetReport.return_value = -1 106 | 107 | device = macos.MacOsHidDevice('fakepath') 108 | 109 | # Write 64 bytes to device 110 | data = bytearray(range(64)) 111 | 112 | # Write should throw an OsHidError exception 113 | with self.assertRaises(errors.OsHidError): 114 | device.Write(data) 115 | 116 | @mock.patch.object(macos.threading, 'Thread') 117 | @mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT, 118 | GetDeviceIntProperty=mock.DEFAULT) 119 | def testCallReadSuccess(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name 120 | init_mock_iokit(iokit) 121 | init_mock_cf(cf) 122 | init_mock_get_int_property(GetDeviceIntProperty) 123 | 124 | device = macos.MacOsHidDevice('fakepath') 125 | 126 | # Call callback for IN report 127 | report = (ctypes.c_uint8 * 64)() 128 | report[:] = range(64)[:] 129 | q = device.read_queue 130 | macos.HidReadCallback(q, None, None, None, 0, report, 64) 131 | 132 | # Device read should return the callback data 133 | read_result = device.Read() 134 | self.assertEqual(read_result, list(range(64)), 'Read data should match ' 135 | 'data passed into the callback') 136 | 137 | 138 | if __name__ == '__main__': 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /pyu2f/tests/lib/util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Testing utilties for pyu2f. 16 | 17 | Testing utilities such as a fake implementation of the pyhidapi device object 18 | that implements parts of the U2FHID frame protocol. This makes it easy to tests 19 | of higher level abstractions without having to use mock to mock out low level 20 | framing details. 21 | """ 22 | from pyu2f import hidtransport 23 | from pyu2f.hid import base 24 | 25 | 26 | class UnsupportedCommandError(Exception): 27 | pass 28 | 29 | 30 | class FakeHidDevice(base.HidDevice): 31 | """Implements a fake hiddevice per the pyhidapi interface. 32 | 33 | This class implemetns a fake hiddevice that can be patched into 34 | code that uses pyhidapi to communicate with a hiddevice. This device 35 | impelents part of U2FHID protocol and can be used to test interactions 36 | with a security key. It supports arbitrary MSG replies as well as 37 | channel allocation, and ping. 38 | """ 39 | 40 | def __init__(self, cid_to_allocate, msg_reply=None): 41 | self.cid_to_allocate = cid_to_allocate 42 | self.msg_reply = msg_reply 43 | self.transaction_active = False 44 | self.full_packet_received = False 45 | self.init_packet = None 46 | self.packet_body = None 47 | self.reply = None 48 | self.seq = 0 49 | self.received_packets = [] 50 | self.busy_count = 0 51 | 52 | def GetInReportDataLength(self): 53 | return 64 54 | 55 | def GetOutReportDataLength(self): 56 | return 64 57 | 58 | def Write(self, data): 59 | """Write to the device. 60 | 61 | Writes to the fake hid device. This function is stateful: if a transaction 62 | is currently open with the client, it will continue to accumulate data 63 | for the client->device messages until the expected size is reached. 64 | 65 | Args: 66 | data: A list of integers to accept as data payload. It should be 64 bytes 67 | in length: just the report data--NO report ID. 68 | """ 69 | 70 | if len(data) < 64: 71 | data = bytearray(data) + bytearray(0 for i in range(0, 64 - len(data))) 72 | 73 | if not self.transaction_active: 74 | self.transaction_active = True 75 | self.init_packet = hidtransport.UsbHidTransport.InitPacket.FromWireFormat( 76 | 64, data) 77 | self.packet_body = self.init_packet.payload 78 | self.full_packet_received = False 79 | self.received_packets.append(self.init_packet) 80 | else: 81 | cont_packet = hidtransport.UsbHidTransport.ContPacket.FromWireFormat( 82 | 64, data) 83 | self.packet_body += cont_packet.payload 84 | self.received_packets.append(cont_packet) 85 | 86 | if len(self.packet_body) >= self.init_packet.size: 87 | self.packet_body = self.packet_body[0:self.init_packet.size] 88 | self.full_packet_received = True 89 | 90 | def Read(self): 91 | """Read from the device. 92 | 93 | Reads from the fake hid device. This function only works if a transaction 94 | is open and a complete write has taken place. If so, it will return the 95 | next reply packet. It should be called repeatedly until all expected 96 | data has been received. It always reads one report. 97 | 98 | Returns: 99 | A list of ints containing the next packet. 100 | 101 | Raises: 102 | UnsupportedCommandError: if the requested amount is not 64. 103 | """ 104 | if not self.transaction_active or not self.full_packet_received: 105 | return None 106 | 107 | ret = None 108 | if self.busy_count > 0: 109 | ret = hidtransport.UsbHidTransport.InitPacket( 110 | 64, self.init_packet.cid, hidtransport.UsbHidTransport.U2FHID_ERROR, 111 | 1, hidtransport.UsbHidTransport.ERR_CHANNEL_BUSY) 112 | self.busy_count -= 1 113 | elif self.reply: # reply in progress 114 | next_frame = self.reply[0:59] 115 | self.reply = self.reply[59:] 116 | 117 | ret = hidtransport.UsbHidTransport.ContPacket(64, self.init_packet.cid, 118 | self.seq, next_frame) 119 | self.seq += 1 120 | else: 121 | self.InternalGenerateReply() 122 | first_frame = self.reply[0:57] 123 | 124 | ret = hidtransport.UsbHidTransport.InitPacket( 125 | 64, self.init_packet.cid, self.init_packet.cmd, len(self.reply), 126 | first_frame) 127 | self.reply = self.reply[57:] 128 | 129 | if not self.reply: # done after this packet 130 | self.reply = None 131 | self.transaction_active = False 132 | self.seq = 0 133 | 134 | return ret.ToWireFormat() 135 | 136 | def SetChannelBusyCount(self, busy_count): # pylint: disable=invalid-name 137 | """Mark the channel busy for next busy_count read calls.""" 138 | self.busy_count = busy_count 139 | 140 | def InternalGenerateReply(self): # pylint: disable=invalid-name 141 | if self.init_packet.cmd == hidtransport.UsbHidTransport.U2FHID_INIT: 142 | nonce = self.init_packet.payload[0:8] 143 | self.reply = nonce + self.cid_to_allocate + bytearray( 144 | b'\x01\x00\x00\x00\x00') 145 | elif self.init_packet.cmd == hidtransport.UsbHidTransport.U2FHID_PING: 146 | self.reply = self.init_packet.payload 147 | elif self.init_packet.cmd == hidtransport.UsbHidTransport.U2FHID_MSG: 148 | self.reply = self.msg_reply 149 | else: 150 | raise UnsupportedCommandError() 151 | -------------------------------------------------------------------------------- /pyu2f/hardware.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """This module implements the low level device API. 16 | 17 | This module exposes a low level SecurityKey class, 18 | representing the physical security key device. 19 | """ 20 | import logging 21 | 22 | from pyu2f import apdu 23 | from pyu2f import errors 24 | 25 | 26 | class SecurityKey(object): 27 | """Low level api for talking to a security key. 28 | 29 | This class implements the low level api specified in FIDO 30 | U2F for talking to a security key. 31 | """ 32 | 33 | def __init__(self, transport): 34 | self.transport = transport 35 | self.use_legacy_format = False 36 | self.logger = logging.getLogger('pyu2f.hardware') 37 | 38 | def CmdRegister(self, challenge_param, app_param): 39 | """Register security key. 40 | 41 | Ask the security key to register with a particular origin & client. 42 | 43 | Args: 44 | challenge_param: Arbitrary 32 byte challenge string. 45 | app_param: Arbitrary 32 byte applciation parameter. 46 | 47 | Returns: 48 | A binary structure containing the key handle, attestation, and a 49 | signature over that by the attestation key. The precise format 50 | is dictated by the FIDO U2F specs. 51 | 52 | Raises: 53 | TUPRequiredError: A Test of User Precense is required to proceed. 54 | ApduError: Something went wrong on the device. 55 | """ 56 | self.logger.debug('CmdRegister') 57 | if len(challenge_param) != 32 or len(app_param) != 32: 58 | raise errors.InvalidRequestError() 59 | 60 | body = bytearray(challenge_param + app_param) 61 | response = self.InternalSendApdu(apdu.CommandApdu( 62 | 0, 63 | apdu.CMD_REGISTER, 64 | 0x03, # Per the U2F reference code tests 65 | 0x00, 66 | body)) 67 | response.CheckSuccessOrRaise() 68 | 69 | return response.body 70 | 71 | def CmdAuthenticate(self, 72 | challenge_param, 73 | app_param, 74 | key_handle, 75 | check_only=False): 76 | """Attempt to obtain an authentication signature. 77 | 78 | Ask the security key to sign a challenge for a particular key handle 79 | in order to authenticate the user. 80 | 81 | Args: 82 | challenge_param: SHA-256 hash of client_data object as a bytes 83 | object. 84 | app_param: SHA-256 hash of the app id as a bytes object. 85 | key_handle: The key handle to use to issue the signature as a bytes 86 | object. 87 | check_only: If true, only check if key_handle is valid. 88 | 89 | Returns: 90 | A binary structure containing the key handle, attestation, and a 91 | signature over that by the attestation key. The precise format 92 | is dictated by the FIDO U2F specs. 93 | 94 | Raises: 95 | TUPRequiredError: If check_only is False, a Test of User Precense 96 | is required to proceed. If check_only is True, this means 97 | the key_handle is valid. 98 | InvalidKeyHandleError: The key_handle is not valid for this device. 99 | ApduError: Something else went wrong on the device. 100 | """ 101 | self.logger.debug('CmdAuthenticate') 102 | if len(challenge_param) != 32 or len(app_param) != 32: 103 | raise errors.InvalidRequestError() 104 | control = 0x07 if check_only else 0x03 105 | 106 | body = bytearray(challenge_param + app_param + 107 | bytearray([len(key_handle)]) + key_handle) 108 | response = self.InternalSendApdu(apdu.CommandApdu( 109 | 0, apdu.CMD_AUTH, control, 0x00, body)) 110 | response.CheckSuccessOrRaise() 111 | 112 | return response.body 113 | 114 | def CmdVersion(self): 115 | """Obtain the version of the device and test transport format. 116 | 117 | Obtains the version of the device and determines whether to use ISO 118 | 7816-4 or the U2f variant. This function should be called at least once 119 | before CmdAuthenticate or CmdRegister to make sure the object is using the 120 | proper transport for the device. 121 | 122 | Returns: 123 | The version of the U2F protocol in use. 124 | """ 125 | self.logger.debug('CmdVersion') 126 | response = self.InternalSendApdu(apdu.CommandApdu( 127 | 0, apdu.CMD_VERSION, 0x00, 0x00)) 128 | 129 | if not response.IsSuccess(): 130 | raise errors.ApduError(response.sw1, response.sw2) 131 | 132 | return response.body 133 | 134 | def CmdBlink(self, time): 135 | self.logger.debug('CmdBlink') 136 | self.transport.SendBlink(time) 137 | 138 | def CmdWink(self): 139 | self.logger.debug('CmdWink') 140 | self.transport.SendWink() 141 | 142 | def CmdPing(self, data): 143 | self.logger.debug('CmdPing') 144 | return self.transport.SendPing(data) 145 | 146 | def InternalSendApdu(self, apdu_to_send): 147 | """Send an APDU to the device. 148 | 149 | Sends an APDU to the device, possibly falling back to the legacy 150 | encoding format that is not ISO7816-4 compatible. 151 | 152 | Args: 153 | apdu_to_send: The CommandApdu object to send 154 | 155 | Returns: 156 | The ResponseApdu object constructed out of the devices reply. 157 | """ 158 | response = None 159 | if not self.use_legacy_format: 160 | response = apdu.ResponseApdu(self.transport.SendMsgBytes( 161 | apdu_to_send.ToByteArray())) 162 | if response.sw1 == 0x67 and response.sw2 == 0x00: 163 | # If we failed using the standard format, retry with the 164 | # legacy format. 165 | self.use_legacy_format = True 166 | return self.InternalSendApdu(apdu_to_send) 167 | else: 168 | response = apdu.ResponseApdu(self.transport.SendMsgBytes( 169 | apdu_to_send.ToLegacyU2FByteArray())) 170 | return response 171 | -------------------------------------------------------------------------------- /pyu2f/tests/hidtransport_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.hidtransport.""" 16 | 17 | import range 18 | import unittest 19 | from unittest import mock 20 | 21 | from pyu2f import errors 22 | from pyu2f import hidtransport 23 | from pyu2f.tests.lib import util 24 | 25 | 26 | def MakeKeyboard(path, usage): 27 | d = {} 28 | d['vendor_id'] = 1133 # Logitech 29 | d['product_id'] = 49948 30 | d['path'] = path 31 | d['usage'] = usage 32 | d['usage_page'] = 1 33 | return d 34 | 35 | 36 | def MakeU2F(path): 37 | d = {} 38 | d['vendor_id'] = 4176 39 | d['product_id'] = 1031 40 | d['path'] = path 41 | d['usage'] = 1 42 | d['usage_page'] = 0xf1d0 43 | return d 44 | 45 | 46 | def RPad(collection, to_size): 47 | while len(collection) < to_size: 48 | collection.append(0) 49 | return collection 50 | 51 | 52 | class DiscoveryTest(unittest.TestCase): 53 | 54 | def testHidUsageSelector(self): 55 | u2f_device = {'usage_page': 0xf1d0, 'usage': 0x01} 56 | other_device = {'usage_page': 0x06, 'usage': 0x01} 57 | self.assertTrue(hidtransport.HidUsageSelector(u2f_device)) 58 | self.assertFalse(hidtransport.HidUsageSelector(other_device)) 59 | 60 | def testDiscoverLocalDevices(self): 61 | 62 | def FakeHidDevice(path): 63 | mock_hid_dev = mock.MagicMock() 64 | mock_hid_dev.GetInReportDataLength.return_value = 64 65 | mock_hid_dev.GetOutReportDataLength.return_value = 64 66 | mock_hid_dev.path = path 67 | return mock_hid_dev 68 | 69 | with mock.patch.object(hidtransport, 'hid') as hid_mock: 70 | # We mock out init so that it doesn't try to do the whole init 71 | # handshake with the device, which isn't what we're trying to test 72 | # here. 73 | with mock.patch.object(hidtransport.UsbHidTransport, 'InternalInit') as _: 74 | hid_mock.Enumerate.return_value = [ 75 | MakeKeyboard('path1', 6), MakeKeyboard('path2', 2), 76 | MakeU2F('path3'), MakeU2F('path4') 77 | ] 78 | mock_hid_dev = mock.MagicMock() 79 | mock_hid_dev.GetInReportDataLength.return_value = 64 80 | mock_hid_dev.GetOutReportDataLength.return_value = 64 81 | hid_mock.Open.side_effect = FakeHidDevice 82 | 83 | # Force the iterator into a list 84 | devs = list(hidtransport.DiscoverLocalHIDU2FDevices()) 85 | 86 | self.assertEqual(hid_mock.Enumerate.call_count, 1) 87 | self.assertEqual(hid_mock.Open.call_count, 2) 88 | self.assertEqual(len(devs), 2) 89 | 90 | self.assertEqual(devs[0].hid_device.path, 'path3') 91 | self.assertEqual(devs[1].hid_device.path, 'path4') 92 | 93 | 94 | class TransportTest(unittest.TestCase): 95 | 96 | def testInit(self): 97 | fake_hid_dev = util.FakeHidDevice(bytearray([0x00, 0x00, 0x00, 0x01])) 98 | t = hidtransport.UsbHidTransport(fake_hid_dev) 99 | self.assertEqual(t.cid, bytearray([0x00, 0x00, 0x00, 0x01])) 100 | self.assertEqual(t.u2fhid_version, 0x01) 101 | 102 | def testPing(self): 103 | fake_hid_dev = util.FakeHidDevice(bytearray([0x00, 0x00, 0x00, 0x01])) 104 | t = hidtransport.UsbHidTransport(fake_hid_dev) 105 | 106 | reply = t.SendPing(b'1234') 107 | self.assertEqual(reply, b'1234') 108 | 109 | def testMsg(self): 110 | fake_hid_dev = util.FakeHidDevice( 111 | bytearray([0x00, 0x00, 0x00, 0x01]), bytearray([0x01, 0x90, 0x00])) 112 | t = hidtransport.UsbHidTransport(fake_hid_dev) 113 | 114 | reply = t.SendMsgBytes([0x00, 0x01, 0x00, 0x00]) 115 | self.assertEqual(reply, bytearray([0x01, 0x90, 0x00])) 116 | 117 | def testMsgBusy(self): 118 | fake_hid_dev = util.FakeHidDevice( 119 | bytearray([0x00, 0x00, 0x00, 0x01]), bytearray([0x01, 0x90, 0x00])) 120 | t = hidtransport.UsbHidTransport(fake_hid_dev) 121 | 122 | # Each call will retry twice: the first attempt will fail after 2 retreis, 123 | # the second will succeed on the second retry. 124 | fake_hid_dev.SetChannelBusyCount(3) 125 | with mock.patch.object(hidtransport, 'time') as _: 126 | self.assertRaisesRegex(errors.HidError, '^Device Busy', t.SendMsgBytes, 127 | [0x00, 0x01, 0x00, 0x00]) 128 | 129 | reply = t.SendMsgBytes([0x00, 0x01, 0x00, 0x00]) 130 | self.assertEqual(reply, bytearray([0x01, 0x90, 0x00])) 131 | 132 | def testFragmentedResponseMsg(self): 133 | body = bytearray([x % 256 for x in range(0, 1000)]) 134 | fake_hid_dev = util.FakeHidDevice(bytearray([0x00, 0x00, 0x00, 0x01]), body) 135 | t = hidtransport.UsbHidTransport(fake_hid_dev) 136 | 137 | reply = t.SendMsgBytes([0x00, 0x01, 0x00, 0x00]) 138 | # Confirm we properly reassemble the message 139 | self.assertEqual(reply, bytearray(x % 256 for x in range(0, 1000))) 140 | 141 | def testFragmentedSendApdu(self): 142 | body = bytearray(x % 256 for x in range(0, 1000)) 143 | fake_hid_dev = util.FakeHidDevice( 144 | bytearray([0x00, 0x00, 0x00, 0x01]), [0x90, 0x00]) 145 | t = hidtransport.UsbHidTransport(fake_hid_dev) 146 | 147 | reply = t.SendMsgBytes(body) 148 | self.assertEqual(reply, bytearray([0x90, 0x00])) 149 | # 1 init packet from creating transport, 18 packets to send 150 | # the fragmented message 151 | self.assertEqual(len(fake_hid_dev.received_packets), 18) 152 | 153 | def testInitPacketShape(self): 154 | packet = hidtransport.UsbHidTransport.InitPacket( 155 | 64, bytearray(b'\x00\x00\x00\x01'), 0x83, 2, bytearray(b'\x01\x02')) 156 | 157 | self.assertEqual(packet.ToWireFormat(), RPad( 158 | [0, 0, 0, 1, 0x83, 0, 2, 1, 2], 64)) 159 | 160 | copy = hidtransport.UsbHidTransport.InitPacket.FromWireFormat( 161 | 64, packet.ToWireFormat()) 162 | self.assertEqual(copy.cid, bytearray(b'\x00\x00\x00\x01')) 163 | self.assertEqual(copy.cmd, 0x83) 164 | self.assertEqual(copy.size, 2) 165 | self.assertEqual(copy.payload, bytearray(b'\x01\x02')) 166 | 167 | def testContPacketShape(self): 168 | packet = hidtransport.UsbHidTransport.ContPacket( 169 | 64, bytearray(b'\x00\x00\x00\x01'), 5, bytearray(b'\x01\x02')) 170 | 171 | self.assertEqual(packet.ToWireFormat(), RPad([0, 0, 0, 1, 5, 1, 2], 64)) 172 | 173 | copy = hidtransport.UsbHidTransport.ContPacket.FromWireFormat( 174 | 64, packet.ToWireFormat()) 175 | self.assertEqual(copy.cid, bytearray(b'\x00\x00\x00\x01')) 176 | self.assertEqual(copy.seq, 5) 177 | self.assertEqual(copy.payload, RPad(bytearray(b'\x01\x02'), 59)) 178 | 179 | 180 | if __name__ == '__main__': 181 | unittest.main() 182 | -------------------------------------------------------------------------------- /pyu2f/u2f.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implement a high level U2F API analogous to the javascript API spec. 16 | 17 | This modules implements a high level U2F API that is analogous in spirit 18 | to the high level U2F javascript API. It supports both registration and 19 | authetication. For the purposes of this API, the "origin" is the hostname 20 | of the machine this library is running on. 21 | """ 22 | 23 | import hashlib 24 | import socket 25 | import time 26 | 27 | from pyu2f import errors 28 | from pyu2f import hardware 29 | from pyu2f import hidtransport 30 | from pyu2f import model 31 | 32 | 33 | def GetLocalU2FInterface(origin=socket.gethostname()): 34 | """Obtains a U2FInterface for the first valid local U2FHID device found.""" 35 | hid_transports = hidtransport.DiscoverLocalHIDU2FDevices() 36 | for t in hid_transports: 37 | try: 38 | return U2FInterface(security_key=hardware.SecurityKey(transport=t), 39 | origin=origin) 40 | except errors.UnsupportedVersionException: 41 | # Skip over devices that don't speak the proper version of the protocol. 42 | pass 43 | 44 | # Unable to find a device 45 | raise errors.NoDeviceFoundError() 46 | 47 | 48 | class U2FInterface(object): 49 | """High level U2F interface. 50 | 51 | Implements a high level interface in the spirit of the FIDO U2F 52 | javascript API high level interface. It supports registration 53 | and authentication (signing). 54 | 55 | IMPORTANT NOTE: This class does NOT validate the app id against the 56 | origin. In particular, any user can assert any app id all the way to 57 | the device. The security model of a python library is such that doing 58 | so would not provide significant benfit as it could be bypassed by the 59 | caller talking to a lower level of the API. In fact, so could the origin 60 | itself. The origin is still set to a plausible value (the hostname) by 61 | this library. 62 | 63 | TODO(gdasher): Figure out a plan on how to address this gap/document the 64 | consequences of this more clearly. 65 | """ 66 | 67 | def __init__(self, security_key, origin=socket.gethostname()): 68 | self.origin = origin 69 | self.security_key = security_key 70 | 71 | if self.security_key.CmdVersion() != b'U2F_V2': 72 | raise errors.UnsupportedVersionException() 73 | 74 | def Register(self, app_id, challenge, registered_keys): 75 | """Registers app_id with the security key. 76 | 77 | Executes the U2F registration flow with the security key. 78 | 79 | Args: 80 | app_id: The app_id to register the security key against. 81 | challenge: Server challenge passed to the security key. 82 | registered_keys: List of keys already registered for this app_id+user. 83 | 84 | Returns: 85 | RegisterResponse with key_handle and attestation information in it ( 86 | encoded in FIDO U2F binary format within registration_data field). 87 | 88 | Raises: 89 | U2FError: There was some kind of problem with registration (e.g. 90 | the device was already registered or there was a timeout waiting 91 | for the test of user presence). 92 | """ 93 | client_data = model.ClientData(model.ClientData.TYP_REGISTRATION, challenge, 94 | self.origin) 95 | challenge_param = self.InternalSHA256(client_data.GetJson()) 96 | app_param = self.InternalSHA256(app_id) 97 | 98 | for key in registered_keys: 99 | try: 100 | # skip non U2F_V2 keys 101 | if key.version != u'U2F_V2': 102 | continue 103 | resp = self.security_key.CmdAuthenticate(challenge_param, app_param, 104 | key.key_handle, True) 105 | # check_only mode CmdAuthenticate should always raise some 106 | # exception 107 | raise errors.HardwareError('Should Never Happen') 108 | 109 | except errors.TUPRequiredError: 110 | # This indicates key was valid. Thus, no need to register 111 | raise errors.U2FError(errors.U2FError.DEVICE_INELIGIBLE) 112 | except errors.InvalidKeyHandleError as e: 113 | # This is the case of a key for a different token, so we just ignore it. 114 | pass 115 | except errors.HardwareError as e: 116 | raise errors.U2FError(errors.U2FError.BAD_REQUEST, e) 117 | 118 | # Now register the new key 119 | for _ in range(30): 120 | try: 121 | resp = self.security_key.CmdRegister(challenge_param, app_param) 122 | return model.RegisterResponse(resp, client_data) 123 | except errors.TUPRequiredError as e: 124 | self.security_key.CmdWink() 125 | time.sleep(0.5) 126 | except errors.HardwareError as e: 127 | raise errors.U2FError(errors.U2FError.BAD_REQUEST, e) 128 | 129 | raise errors.U2FError(errors.U2FError.TIMEOUT) 130 | 131 | def Authenticate(self, app_id, challenge, registered_keys): 132 | """Authenticates app_id with the security key. 133 | 134 | Executes the U2F authentication/signature flow with the security key. 135 | 136 | Args: 137 | app_id: The app_id to register the security key against. 138 | challenge: Server challenge passed to the security key as a bytes object. 139 | registered_keys: List of keys already registered for this app_id+user. 140 | 141 | Returns: 142 | SignResponse with client_data, key_handle, and signature_data. The client 143 | data is an object, while the signature_data is encoded in FIDO U2F binary 144 | format. 145 | 146 | Raises: 147 | U2FError: There was some kind of problem with authentication (e.g. 148 | there was a timeout while waiting for the test of user presence.) 149 | """ 150 | client_data = model.ClientData(model.ClientData.TYP_AUTHENTICATION, 151 | challenge, self.origin) 152 | app_param = self.InternalSHA256(app_id) 153 | challenge_param = self.InternalSHA256(client_data.GetJson()) 154 | num_invalid_keys = 0 155 | for key in registered_keys: 156 | try: 157 | if key.version != u'U2F_V2': 158 | continue 159 | for _ in range(30): 160 | try: 161 | resp = self.security_key.CmdAuthenticate(challenge_param, app_param, 162 | key.key_handle) 163 | return model.SignResponse(key.key_handle, resp, client_data) 164 | except errors.TUPRequiredError: 165 | self.security_key.CmdWink() 166 | time.sleep(0.5) 167 | except errors.InvalidKeyHandleError: 168 | num_invalid_keys += 1 169 | continue 170 | except errors.HardwareError as e: 171 | raise errors.U2FError(errors.U2FError.BAD_REQUEST, e) 172 | 173 | if num_invalid_keys == len(registered_keys): 174 | # In this case, all provided keys were invalid. 175 | raise errors.U2FError(errors.U2FError.DEVICE_INELIGIBLE) 176 | 177 | # In this case, the TUP was not pressed. 178 | raise errors.U2FError(errors.U2FError.TIMEOUT) 179 | 180 | def InternalSHA256(self, string): 181 | md = hashlib.sha256() 182 | md.update(string.encode()) 183 | return md.digest() 184 | -------------------------------------------------------------------------------- /pyu2f/tests/hardware_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.hardware.""" 16 | 17 | import sys 18 | 19 | import mock 20 | 21 | from pyu2f import errors 22 | from pyu2f import hardware 23 | 24 | if sys.version_info[:2] < (2, 7): 25 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 26 | else: 27 | import unittest # pylint: disable=g-import-not-at-top 28 | 29 | 30 | class HardwareTest(unittest.TestCase): 31 | 32 | def testSimpleCommands(self): 33 | mock_transport = mock.MagicMock() 34 | sk = hardware.SecurityKey(mock_transport) 35 | 36 | sk.CmdBlink(5) 37 | mock_transport.SendBlink.assert_called_once_with(5) 38 | 39 | sk.CmdWink() 40 | mock_transport.SendWink.assert_called_once_with() 41 | 42 | sk.CmdPing(bytearray(b'foo')) 43 | mock_transport.SendPing.assert_called_once_with(bytearray(b'foo')) 44 | 45 | def testRegisterInvalidParams(self): 46 | mock_transport = mock.MagicMock() 47 | sk = hardware.SecurityKey(mock_transport) 48 | 49 | self.assertRaises(errors.InvalidRequestError, sk.CmdRegister, '1234', 50 | '1234') 51 | 52 | def testRegisterSuccess(self): 53 | mock_transport = mock.MagicMock() 54 | sk = hardware.SecurityKey(mock_transport) 55 | 56 | challenge_param = b'01234567890123456789012345678901' 57 | app_param = b'01234567890123456789012345678901' 58 | 59 | mock_transport.SendMsgBytes.return_value = bytearray( 60 | [0x01, 0x02, 0x90, 0x00]) 61 | 62 | reply = sk.CmdRegister(challenge_param, app_param) 63 | self.assertEqual(reply, bytearray([0x01, 0x02])) 64 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 65 | (sent_msg,), _ = mock_transport.SendMsgBytes.call_args 66 | self.assertEqual(sent_msg[0:4], bytearray([0x00, 0x01, 0x03, 0x00])) 67 | self.assertEqual(sent_msg[7:-2], bytearray(challenge_param + app_param)) 68 | 69 | def testRegisterTUPRequired(self): 70 | mock_transport = mock.MagicMock() 71 | sk = hardware.SecurityKey(mock_transport) 72 | 73 | challenge_param = b'01234567890123456789012345678901' 74 | app_param = b'01234567890123456789012345678901' 75 | 76 | mock_transport.SendMsgBytes.return_value = bytearray([0x69, 0x85]) 77 | 78 | self.assertRaises(errors.TUPRequiredError, sk.CmdRegister, challenge_param, 79 | app_param) 80 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 81 | 82 | def testVersion(self): 83 | mock_transport = mock.MagicMock() 84 | sk = hardware.SecurityKey(mock_transport) 85 | 86 | mock_transport.SendMsgBytes.return_value = bytearray(b'U2F_V2\x90\x00') 87 | 88 | reply = sk.CmdVersion() 89 | self.assertEqual(reply, bytearray(b'U2F_V2')) 90 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 91 | (sent_msg,), _ = mock_transport.SendMsgBytes.call_args 92 | self.assertEqual(sent_msg, bytearray( 93 | [0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00])) 94 | 95 | def testVersionFallback(self): 96 | mock_transport = mock.MagicMock() 97 | sk = hardware.SecurityKey(mock_transport) 98 | 99 | mock_transport.SendMsgBytes.side_effect = [ 100 | bytearray([0x67, 0x00]), 101 | bytearray(b'U2F_V2\x90\x00')] 102 | 103 | reply = sk.CmdVersion() 104 | self.assertEqual(reply, bytearray(b'U2F_V2')) 105 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 2) 106 | (sent_msg,), _ = mock_transport.SendMsgBytes.call_args_list[0] 107 | self.assertEqual(len(sent_msg), 7) 108 | self.assertEqual(sent_msg[0:4], bytearray([0x00, 0x03, 0x00, 0x00])) 109 | self.assertEqual(sent_msg[4:7], bytearray([0x00, 0x00, 0x00])) # Le 110 | (sent_msg,), _ = mock_transport.SendMsgBytes.call_args_list[1] 111 | self.assertEqual(len(sent_msg), 9) 112 | self.assertEqual(sent_msg[0:4], bytearray([0x00, 0x03, 0x00, 0x00])) 113 | self.assertEqual(sent_msg[4:7], bytearray([0x00, 0x00, 0x00])) # Lc 114 | self.assertEqual(sent_msg[7:9], bytearray([0x00, 0x00])) # Le 115 | 116 | def testVersionErrors(self): 117 | mock_transport = mock.MagicMock() 118 | sk = hardware.SecurityKey(mock_transport) 119 | 120 | mock_transport.SendMsgBytes.return_value = bytearray([0xfa, 0x05]) 121 | 122 | self.assertRaises(errors.ApduError, sk.CmdVersion) 123 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 124 | 125 | def testAuthenticateSuccess(self): 126 | mock_transport = mock.MagicMock() 127 | sk = hardware.SecurityKey(mock_transport) 128 | 129 | challenge_param = b'01234567890123456789012345678901' 130 | app_param = b'01234567890123456789012345678901' 131 | key_handle = b'\x01\x02\x03\x04' 132 | 133 | mock_transport.SendMsgBytes.return_value = bytearray( 134 | [0x01, 0x02, 0x90, 0x00]) 135 | 136 | reply = sk.CmdAuthenticate(challenge_param, app_param, key_handle) 137 | self.assertEqual(reply, bytearray([0x01, 0x02])) 138 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 139 | (sent_msg,), _ = mock_transport.SendMsgBytes.call_args 140 | self.assertEqual(sent_msg[0:4], bytearray([0x00, 0x02, 0x03, 0x00])) 141 | self.assertEqual( 142 | sent_msg[7:-2], 143 | bytearray(challenge_param + app_param + bytearray([4, 1, 2, 3, 4]))) 144 | 145 | def testAuthenticateCheckOnly(self): 146 | mock_transport = mock.MagicMock() 147 | sk = hardware.SecurityKey(mock_transport) 148 | 149 | challenge_param = b'01234567890123456789012345678901' 150 | app_param = b'01234567890123456789012345678901' 151 | key_handle = b'\x01\x02\x03\x04' 152 | 153 | mock_transport.SendMsgBytes.return_value = bytearray( 154 | [0x01, 0x02, 0x90, 0x00]) 155 | 156 | reply = sk.CmdAuthenticate(challenge_param, 157 | app_param, 158 | key_handle, 159 | check_only=True) 160 | self.assertEqual(reply, bytearray([0x01, 0x02])) 161 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 162 | (sent_msg,), _ = mock_transport.SendMsgBytes.call_args 163 | self.assertEqual(sent_msg[0:4], bytearray([0x00, 0x02, 0x07, 0x00])) 164 | self.assertEqual( 165 | sent_msg[7:-2], 166 | bytearray(challenge_param + app_param + bytearray([4, 1, 2, 3, 4]))) 167 | 168 | def testAuthenticateTUPRequired(self): 169 | mock_transport = mock.MagicMock() 170 | sk = hardware.SecurityKey(mock_transport) 171 | 172 | challenge_param = b'01234567890123456789012345678901' 173 | app_param = b'01234567890123456789012345678901' 174 | key_handle = b'\x01\x02\x03\x04' 175 | 176 | mock_transport.SendMsgBytes.return_value = bytearray([0x69, 0x85]) 177 | 178 | self.assertRaises(errors.TUPRequiredError, sk.CmdAuthenticate, 179 | challenge_param, app_param, key_handle) 180 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 181 | 182 | def testAuthenticateInvalidKeyHandle(self): 183 | mock_transport = mock.MagicMock() 184 | sk = hardware.SecurityKey(mock_transport) 185 | 186 | challenge_param = b'01234567890123456789012345678901' 187 | app_param = b'01234567890123456789012345678901' 188 | key_handle = b'\x01\x02\x03\x04' 189 | 190 | mock_transport.SendMsgBytes.return_value = bytearray([0x6a, 0x80]) 191 | 192 | self.assertRaises(errors.InvalidKeyHandleError, sk.CmdAuthenticate, 193 | challenge_param, app_param, key_handle) 194 | self.assertEqual(mock_transport.SendMsgBytes.call_count, 1) 195 | 196 | 197 | if __name__ == '__main__': 198 | unittest.main() 199 | -------------------------------------------------------------------------------- /pyu2f/tests/localauthenticator_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.convenience.localauthenticator.""" 16 | 17 | import base64 18 | import sys 19 | 20 | import mock 21 | from pyu2f import errors 22 | from pyu2f import model 23 | from pyu2f.convenience import localauthenticator 24 | 25 | 26 | if sys.version_info[:2] < (2, 7): 27 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 28 | else: 29 | import unittest # pylint: disable=g-import-not-at-top 30 | 31 | 32 | # Input/ouput values recorded from a successful signing flow 33 | SIGN_SUCCESS = { 34 | 'app_id': 'test_app_id', 35 | 'app_id_hash_encoded': 'TnMguTdPn7OcIO9f-0CgfQdY254bvc6WR-DTPZnJ49w=', 36 | 'challenge': b'asdfasdf', 37 | 'challenge_hash_encoded': 'qhJtbTQvsU0BmLLpDWes-3zFGbegR2wp1mv5BJ2BwC0=', 38 | 'key_handle_encoded': ('iBbl9-VYt-XSdWeHVNX-gfQcXGzlrAQ7BcngVNUxWijIQQlnZEI' 39 | '4Vb0Bp2ydBCbIQu_5rNlKqPH6NK1TtnM7fA=='), 40 | 'origin': 'test_origin', 41 | 'signature_data_encoded': ('AQAAAI8wRQIhALlIPo6Hg8HwzELdYRIXnAnpsiHYCSXHex' 42 | 'CS34eiS2ixAiBt3TRmKE1A9WyMjc3JGrGI7gSPg-QzDSNL' 43 | 'aIj7JwcCTA=='), 44 | 'client_data_encoded': ('eyJjaGFsbGVuZ2UiOiAiWVhOa1ptRnpaR1kiLCAib3JpZ2luI' 45 | 'jogInRlc3Rfb3JpZ2luIiwgInR5cCI6ICJuYXZpZ2F0b3IuaW' 46 | 'QuZ2V0QXNzZXJ0aW9uIn0='), 47 | 'u2f_version': 'U2F_V2' 48 | } 49 | SIGN_SUCCESS['registered_key'] = model.RegisteredKey( 50 | base64.urlsafe_b64decode(SIGN_SUCCESS['key_handle_encoded'])) 51 | SIGN_SUCCESS['client_data'] = model.ClientData( 52 | model.ClientData.TYP_AUTHENTICATION, 53 | SIGN_SUCCESS['challenge'], 54 | SIGN_SUCCESS['origin']) 55 | 56 | 57 | @mock.patch.object(sys, 'stderr', new=mock.MagicMock()) 58 | class LocalAuthenticatorTest(unittest.TestCase): 59 | 60 | @mock.patch.object(localauthenticator.u2f, 'GetLocalU2FInterface') 61 | def testSignSuccess(self, mock_get_u2f_method): 62 | """Test successful signing with a valid key.""" 63 | # Prepare u2f mocks 64 | mock_u2f = mock.MagicMock() 65 | mock_get_u2f_method.return_value = mock_u2f 66 | 67 | mock_authenticate = mock.MagicMock() 68 | mock_u2f.Authenticate = mock_authenticate 69 | 70 | mock_authenticate.return_value = model.SignResponse( 71 | base64.urlsafe_b64decode(SIGN_SUCCESS['key_handle_encoded']), 72 | base64.urlsafe_b64decode(SIGN_SUCCESS['signature_data_encoded']), 73 | SIGN_SUCCESS['client_data'] 74 | ) 75 | 76 | # Call LocalAuthenticator 77 | challenge_data = [{'key': SIGN_SUCCESS['registered_key'], 78 | 'challenge': SIGN_SUCCESS['challenge']}] 79 | authenticator = localauthenticator.LocalAuthenticator('testorigin') 80 | self.assertTrue(authenticator.IsAvailable()) 81 | response = authenticator.Authenticate(SIGN_SUCCESS['app_id'], 82 | challenge_data) 83 | 84 | # Validate that u2f authenticate was called with the correct values 85 | self.assertTrue(mock_authenticate.called) 86 | authenticate_args = mock_authenticate.call_args[0] 87 | self.assertEqual(len(authenticate_args), 3) 88 | self.assertEqual(authenticate_args[0], SIGN_SUCCESS['app_id']) 89 | self.assertEqual(authenticate_args[1], SIGN_SUCCESS['challenge']) 90 | registered_keys = authenticate_args[2] 91 | self.assertEqual(len(registered_keys), 1) 92 | self.assertEqual(registered_keys[0], SIGN_SUCCESS['registered_key']) 93 | 94 | # Validate authenticator response 95 | self.assertEqual(response.get('clientData'), 96 | SIGN_SUCCESS['client_data_encoded']) 97 | self.assertEqual(response.get('signatureData'), 98 | SIGN_SUCCESS['signature_data_encoded']) 99 | self.assertEqual(response.get('applicationId'), 100 | SIGN_SUCCESS['app_id']) 101 | self.assertEqual(response.get('keyHandle'), 102 | SIGN_SUCCESS['key_handle_encoded']) 103 | 104 | @mock.patch.object(localauthenticator.u2f, 'GetLocalU2FInterface') 105 | def testSignMultipleIneligible(self, mock_get_u2f_method): 106 | """Test signing with multiple keys registered, but none eligible.""" 107 | # Prepare u2f mocks 108 | mock_u2f = mock.MagicMock() 109 | mock_get_u2f_method.return_value = mock_u2f 110 | 111 | mock_authenticate = mock.MagicMock() 112 | mock_u2f.Authenticate = mock_authenticate 113 | 114 | mock_authenticate.side_effect = errors.U2FError( 115 | errors.U2FError.DEVICE_INELIGIBLE) 116 | 117 | # Call LocalAuthenticator 118 | challenge_item = {'key': SIGN_SUCCESS['registered_key'], 119 | 'challenge': SIGN_SUCCESS['challenge']} 120 | challenge_data = [challenge_item, challenge_item] 121 | 122 | authenticator = localauthenticator.LocalAuthenticator('testorigin') 123 | 124 | with self.assertRaises(errors.U2FError) as cm: 125 | authenticator.Authenticate(SIGN_SUCCESS['app_id'], 126 | challenge_data) 127 | 128 | self.assertEqual(cm.exception.code, errors.U2FError.DEVICE_INELIGIBLE) 129 | 130 | @mock.patch.object(localauthenticator.u2f, 'GetLocalU2FInterface') 131 | def testSignMultipleSuccess(self, mock_get_u2f_method): 132 | """Test signing with multiple keys registered and one is eligible.""" 133 | # Prepare u2f mocks 134 | mock_u2f = mock.MagicMock() 135 | mock_get_u2f_method.return_value = mock_u2f 136 | 137 | mock_authenticate = mock.MagicMock() 138 | mock_u2f.Authenticate = mock_authenticate 139 | 140 | return_value = model.SignResponse( 141 | base64.urlsafe_b64decode(SIGN_SUCCESS['key_handle_encoded']), 142 | base64.urlsafe_b64decode(SIGN_SUCCESS['signature_data_encoded']), 143 | SIGN_SUCCESS['client_data'] 144 | ) 145 | 146 | mock_authenticate.side_effect = [ 147 | errors.U2FError(errors.U2FError.DEVICE_INELIGIBLE), 148 | return_value 149 | ] 150 | 151 | # Call LocalAuthenticator 152 | challenge_item = {'key': SIGN_SUCCESS['registered_key'], 153 | 'challenge': SIGN_SUCCESS['challenge']} 154 | challenge_data = [challenge_item, challenge_item] 155 | 156 | authenticator = localauthenticator.LocalAuthenticator('testorigin') 157 | response = authenticator.Authenticate(SIGN_SUCCESS['app_id'], 158 | challenge_data) 159 | 160 | # Validate that u2f authenticate was called with the correct values 161 | self.assertTrue(mock_authenticate.called) 162 | authenticate_args = mock_authenticate.call_args[0] 163 | self.assertEqual(len(authenticate_args), 3) 164 | self.assertEqual(authenticate_args[0], SIGN_SUCCESS['app_id']) 165 | self.assertEqual(authenticate_args[1], SIGN_SUCCESS['challenge']) 166 | registered_keys = authenticate_args[2] 167 | self.assertEqual(len(registered_keys), 1) 168 | self.assertEqual(registered_keys[0], SIGN_SUCCESS['registered_key']) 169 | 170 | # Validate authenticator response 171 | self.assertEqual(response.get('clientData'), 172 | SIGN_SUCCESS['client_data_encoded']) 173 | self.assertEqual(response.get('signatureData'), 174 | SIGN_SUCCESS['signature_data_encoded']) 175 | self.assertEqual(response.get('applicationId'), 176 | SIGN_SUCCESS['app_id']) 177 | self.assertEqual(response.get('keyHandle'), 178 | SIGN_SUCCESS['key_handle_encoded']) 179 | 180 | 181 | if __name__ == '__main__': 182 | unittest.main() 183 | -------------------------------------------------------------------------------- /pyu2f/hid/linux.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implements raw HID interface on Linux using SysFS and device files.""" 16 | from __future__ import division 17 | 18 | import os 19 | import struct 20 | 21 | from pyu2f import errors 22 | from pyu2f.hid import base 23 | 24 | REPORT_DESCRIPTOR_KEY_MASK = 0xfc 25 | LONG_ITEM_ENCODING = 0xfe 26 | OUTPUT_ITEM = 0x90 27 | INPUT_ITEM = 0x80 28 | COLLECTION_ITEM = 0xa0 29 | REPORT_COUNT = 0x94 30 | REPORT_SIZE = 0x74 31 | USAGE_PAGE = 0x04 32 | USAGE = 0x08 33 | 34 | 35 | def GetValueLength(rd, pos): 36 | """Get value length for a key in rd. 37 | 38 | For a key at position pos in the Report Descriptor rd, return the length 39 | of the associated value. This supports both short and long format 40 | values. 41 | 42 | Args: 43 | rd: Report Descriptor 44 | pos: The position of the key in rd. 45 | 46 | Returns: 47 | (key_size, data_len) where key_size is the number of bytes occupied by 48 | the key and data_len is the length of the value associated by the key. 49 | """ 50 | rd = bytearray(rd) 51 | key = rd[pos] 52 | if key == LONG_ITEM_ENCODING: 53 | # If the key is tagged as a long item (0xfe), then the format is 54 | # [key (1 byte)] [data len (1 byte)] [item tag (1 byte)] [data (n # bytes)]. 55 | # Thus, the entire key record is 3 bytes long. 56 | if pos + 1 < len(rd): 57 | return (3, rd[pos + 1]) 58 | else: 59 | raise errors.HidError('Malformed report descriptor') 60 | 61 | else: 62 | # If the key is tagged as a short item, then the item tag and data len are 63 | # packed into one byte. The format is thus: 64 | # [tag (high 4 bits)] [type (2 bits)] [size code (2 bits)] [data (n bytes)]. 65 | # The size code specifies 1,2, or 4 bytes (0x03 means 4 bytes). 66 | code = key & 0x03 67 | if code <= 0x02: 68 | return (1, code) 69 | elif code == 0x03: 70 | return (1, 4) 71 | 72 | raise errors.HidError('Cannot happen') 73 | 74 | 75 | def ReadLsbBytes(rd, offset, value_size): 76 | """Reads value_size bytes from rd at offset, least signifcant byte first.""" 77 | 78 | encoding = None 79 | if value_size == 1: 80 | encoding = '= pos + 1 + value_length: 144 | report_count = ReadLsbBytes(rd, pos + 1, value_length) 145 | elif key & REPORT_DESCRIPTOR_KEY_MASK == REPORT_SIZE: 146 | if len(rd) >= pos + 1 + value_length: 147 | report_size = ReadLsbBytes(rd, pos + 1, value_length) 148 | elif key & REPORT_DESCRIPTOR_KEY_MASK == USAGE_PAGE: 149 | if len(rd) >= pos + 1 + value_length: 150 | usage_page = ReadLsbBytes(rd, pos + 1, value_length) 151 | elif key & REPORT_DESCRIPTOR_KEY_MASK == USAGE: 152 | if len(rd) >= pos + 1 + value_length: 153 | usage = ReadLsbBytes(rd, pos + 1, value_length) 154 | 155 | pos += value_length + key_size 156 | return desc 157 | 158 | 159 | def ParseUevent(uevent, desc): 160 | lines = uevent.split(b'\n') 161 | for line in lines: 162 | line = line.strip() 163 | if not line: 164 | continue 165 | k, v = line.split(b'=') 166 | if k == b'HID_NAME': 167 | desc.product_string = v.decode('utf8') 168 | elif k == b'HID_ID': 169 | _, vid, pid = v.split(b':') 170 | desc.vendor_id = int(vid, 16) 171 | desc.product_id = int(pid, 16) 172 | 173 | 174 | class LinuxHidDevice(base.HidDevice): 175 | """Implementation of HID device for linux. 176 | 177 | Implementation of HID device interface for linux that uses block 178 | devices to interact with the device and sysfs to enumerate/discover 179 | device metadata. 180 | """ 181 | 182 | @staticmethod 183 | def Enumerate(): 184 | hidraw_devices = [] 185 | try: 186 | hidraw_devices = os.listdir('/sys/class/hidraw') 187 | except FileNotFoundError: 188 | raise errors.OsHidError('No hidraw device is available') 189 | 190 | for dev in hidraw_devices: 191 | rd_path = ( 192 | os.path.join( 193 | '/sys/class/hidraw', dev, 194 | 'device/report_descriptor')) 195 | uevent_path = os.path.join('/sys/class/hidraw', dev, 'device/uevent') 196 | rd_file = open(rd_path, 'rb') 197 | uevent_file = open(uevent_path, 'rb') 198 | desc = base.DeviceDescriptor() 199 | desc.path = os.path.join('/dev/', dev) 200 | ParseReportDescriptor(rd_file.read(), desc) 201 | ParseUevent(uevent_file.read(), desc) 202 | 203 | rd_file.close() 204 | uevent_file.close() 205 | yield desc.ToPublicDict() 206 | 207 | def __init__(self, path): 208 | base.HidDevice.__init__(self, path) 209 | self.dev = os.open(path, os.O_RDWR) 210 | self.desc = base.DeviceDescriptor() 211 | self.desc.path = path 212 | rd_file = open(os.path.join('/sys/class/hidraw', 213 | os.path.basename(path), 214 | 'device/report_descriptor'), 'rb') 215 | ParseReportDescriptor(rd_file.read(), self.desc) 216 | rd_file.close() 217 | 218 | def GetInReportDataLength(self): 219 | """See base class.""" 220 | return self.desc.internal_max_in_report_len 221 | 222 | def GetOutReportDataLength(self): 223 | """See base class.""" 224 | return self.desc.internal_max_out_report_len 225 | 226 | def Write(self, packet): 227 | """See base class.""" 228 | out = bytearray([0] + packet) # Prepend the zero-byte (report ID) 229 | os.write(self.dev, out) 230 | 231 | def Read(self): 232 | """See base class.""" 233 | raw_in = os.read(self.dev, self.GetInReportDataLength()) 234 | decoded_in = list(bytearray(raw_in)) 235 | return decoded_in 236 | -------------------------------------------------------------------------------- /pyu2f/tests/u2f_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.u2f.""" 16 | 17 | import sys 18 | 19 | import mock 20 | 21 | from pyu2f import errors 22 | from pyu2f import model 23 | from pyu2f import u2f 24 | 25 | if sys.version_info[:2] < (2, 7): 26 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 27 | else: 28 | import unittest # pylint: disable=g-import-not-at-top 29 | 30 | 31 | class U2fTest(unittest.TestCase): 32 | 33 | def testRegisterSuccessWithTUP(self): 34 | mock_sk = mock.MagicMock() 35 | mock_sk.CmdRegister.side_effect = [errors.TUPRequiredError, 'regdata'] 36 | mock_sk.CmdVersion.return_value = b'U2F_V2' 37 | 38 | u2f_api = u2f.U2FInterface(mock_sk) 39 | 40 | resp = u2f_api.Register('testapp', b'ABCD', []) 41 | self.assertEqual(mock_sk.CmdRegister.call_count, 2) 42 | self.assertEqual(mock_sk.CmdWink.call_count, 1) 43 | self.assertEqual(resp.client_data.raw_server_challenge, b'ABCD') 44 | self.assertEqual(resp.client_data.typ, 'navigator.id.finishEnrollment') 45 | self.assertEqual(resp.registration_data, 'regdata') 46 | 47 | def testRegisterSuccessWithPreviousKeys(self): 48 | mock_sk = mock.MagicMock() 49 | mock_sk.CmdAuthenticate.side_effect = errors.InvalidKeyHandleError 50 | mock_sk.CmdRegister.side_effect = [errors.TUPRequiredError, 'regdata'] 51 | mock_sk.CmdVersion.return_value = b'U2F_V2' 52 | 53 | u2f_api = u2f.U2FInterface(mock_sk) 54 | 55 | resp = u2f_api.Register('testapp', b'ABCD', [model.RegisteredKey('khA')]) 56 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 1) 57 | # Should be "Check only" 58 | self.assertTrue(mock_sk.CmdAuthenticate.call_args[0][3]) 59 | 60 | self.assertEqual(mock_sk.CmdRegister.call_count, 2) 61 | self.assertEqual(mock_sk.CmdWink.call_count, 1) 62 | self.assertEqual(resp.client_data.raw_server_challenge, b'ABCD') 63 | self.assertEqual(resp.client_data.typ, 'navigator.id.finishEnrollment') 64 | self.assertEqual(resp.registration_data, 'regdata') 65 | 66 | def testRegisterFailAlreadyRegistered(self): 67 | mock_sk = mock.MagicMock() 68 | mock_sk.CmdAuthenticate.side_effect = errors.TUPRequiredError 69 | mock_sk.CmdVersion.return_value = b'U2F_V2' 70 | 71 | u2f_api = u2f.U2FInterface(mock_sk) 72 | 73 | with self.assertRaises(errors.U2FError) as cm: 74 | u2f_api.Register('testapp', b'ABCD', [model.RegisteredKey('khA')]) 75 | self.assertEqual(cm.exception.code, errors.U2FError.DEVICE_INELIGIBLE) 76 | 77 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 1) 78 | # Should be "Check only" 79 | self.assertTrue(mock_sk.CmdAuthenticate.call_args[0][3]) 80 | 81 | self.assertEqual(mock_sk.CmdRegister.call_count, 0) 82 | self.assertEqual(mock_sk.CmdWink.call_count, 0) 83 | 84 | def testRegisterTimeout(self): 85 | mock_sk = mock.MagicMock() 86 | mock_sk.CmdRegister.side_effect = errors.TUPRequiredError 87 | mock_sk.CmdVersion.return_value = b'U2F_V2' 88 | u2f_api = u2f.U2FInterface(mock_sk) 89 | 90 | # Speed up the test by mocking out sleep to do nothing 91 | with mock.patch.object(u2f, 'time') as _: 92 | with self.assertRaises(errors.U2FError) as cm: 93 | u2f_api.Register('testapp', b'ABCD', []) 94 | self.assertEqual(cm.exception.code, errors.U2FError.TIMEOUT) 95 | self.assertEqual(mock_sk.CmdRegister.call_count, 30) 96 | self.assertEqual(mock_sk.CmdWink.call_count, 30) 97 | 98 | def testRegisterError(self): 99 | mock_sk = mock.MagicMock() 100 | mock_sk.CmdRegister.side_effect = errors.ApduError(0xff, 0xff) 101 | mock_sk.CmdVersion.return_value = b'U2F_V2' 102 | u2f_api = u2f.U2FInterface(mock_sk) 103 | 104 | with self.assertRaises(errors.U2FError) as cm: 105 | u2f_api.Register('testapp', b'ABCD', []) 106 | self.assertEqual(cm.exception.code, errors.U2FError.BAD_REQUEST) 107 | self.assertEqual(cm.exception.cause.sw1, 0xff) 108 | self.assertEqual(cm.exception.cause.sw2, 0xff) 109 | 110 | self.assertEqual(mock_sk.CmdRegister.call_count, 1) 111 | self.assertEqual(mock_sk.CmdWink.call_count, 0) 112 | 113 | def testAuthenticateSuccessWithTUP(self): 114 | mock_sk = mock.MagicMock() 115 | mock_sk.CmdAuthenticate.side_effect = [errors.TUPRequiredError, 'signature'] 116 | mock_sk.CmdVersion.return_value = b'U2F_V2' 117 | 118 | u2f_api = u2f.U2FInterface(mock_sk) 119 | 120 | resp = u2f_api.Authenticate('testapp', b'ABCD', 121 | [model.RegisteredKey('khA')]) 122 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 2) 123 | self.assertEqual(mock_sk.CmdWink.call_count, 1) 124 | self.assertEqual(resp.key_handle, 'khA') 125 | self.assertEqual(resp.client_data.raw_server_challenge, b'ABCD') 126 | self.assertEqual(resp.client_data.typ, 'navigator.id.getAssertion') 127 | self.assertEqual(resp.signature_data, 'signature') 128 | 129 | def testAuthenticateSuccessSkipInvalidKey(self): 130 | mock_sk = mock.MagicMock() 131 | mock_sk.CmdAuthenticate.side_effect = [errors.InvalidKeyHandleError, 132 | 'signature'] 133 | mock_sk.CmdVersion.return_value = b'U2F_V2' 134 | 135 | u2f_api = u2f.U2FInterface(mock_sk) 136 | 137 | resp = u2f_api.Authenticate( 138 | 'testapp', b'ABCD', 139 | [model.RegisteredKey('khA'), model.RegisteredKey('khB')]) 140 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 2) 141 | self.assertEqual(mock_sk.CmdWink.call_count, 0) 142 | self.assertEqual(resp.key_handle, 'khB') 143 | self.assertEqual(resp.client_data.raw_server_challenge, b'ABCD') 144 | self.assertEqual(resp.client_data.typ, 'navigator.id.getAssertion') 145 | self.assertEqual(resp.signature_data, 'signature') 146 | 147 | def testAuthenticateSuccessSkipInvalidVersion(self): 148 | mock_sk = mock.MagicMock() 149 | mock_sk.CmdAuthenticate.return_value = 'signature' 150 | mock_sk.CmdVersion.return_value = b'U2F_V2' 151 | 152 | u2f_api = u2f.U2FInterface(mock_sk) 153 | 154 | resp = u2f_api.Authenticate('testapp', 155 | b'ABCD', 156 | [model.RegisteredKey('khA', 157 | version='U2F_V3'), 158 | model.RegisteredKey('khB')]) 159 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 1) 160 | self.assertEqual(mock_sk.CmdWink.call_count, 0) 161 | self.assertEqual(resp.key_handle, 'khB') 162 | self.assertEqual(resp.client_data.raw_server_challenge, b'ABCD') 163 | self.assertEqual(resp.client_data.typ, 'navigator.id.getAssertion') 164 | self.assertEqual(resp.signature_data, 'signature') 165 | 166 | def testAuthenticateTimeout(self): 167 | mock_sk = mock.MagicMock() 168 | mock_sk.CmdAuthenticate.side_effect = errors.TUPRequiredError 169 | mock_sk.CmdVersion.return_value = b'U2F_V2' 170 | u2f_api = u2f.U2FInterface(mock_sk) 171 | 172 | # Speed up the test by mocking out sleep to do nothing 173 | with mock.patch.object(u2f, 'time') as _: 174 | with self.assertRaises(errors.U2FError) as cm: 175 | u2f_api.Authenticate('testapp', b'ABCD', [model.RegisteredKey('khA')]) 176 | self.assertEqual(cm.exception.code, errors.U2FError.TIMEOUT) 177 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 30) 178 | self.assertEqual(mock_sk.CmdWink.call_count, 30) 179 | 180 | def testAuthenticateAllKeysInvalid(self): 181 | mock_sk = mock.MagicMock() 182 | mock_sk.CmdAuthenticate.side_effect = errors.InvalidKeyHandleError 183 | mock_sk.CmdVersion.return_value = b'U2F_V2' 184 | 185 | u2f_api = u2f.U2FInterface(mock_sk) 186 | with self.assertRaises(errors.U2FError) as cm: 187 | u2f_api.Authenticate('testapp', b'ABCD', 188 | [model.RegisteredKey('khA'), 189 | model.RegisteredKey('khB')]) 190 | self.assertEqual(cm.exception.code, errors.U2FError.DEVICE_INELIGIBLE) 191 | 192 | u2f_api = u2f.U2FInterface(mock_sk) 193 | 194 | def testAuthenticateError(self): 195 | mock_sk = mock.MagicMock() 196 | mock_sk.CmdAuthenticate.side_effect = errors.ApduError(0xff, 0xff) 197 | mock_sk.CmdVersion.return_value = b'U2F_V2' 198 | u2f_api = u2f.U2FInterface(mock_sk) 199 | 200 | with self.assertRaises(errors.U2FError) as cm: 201 | u2f_api.Authenticate('testapp', b'ABCD', [model.RegisteredKey('khA')]) 202 | self.assertEqual(cm.exception.code, errors.U2FError.BAD_REQUEST) 203 | self.assertEqual(cm.exception.cause.sw1, 0xff) 204 | self.assertEqual(cm.exception.cause.sw2, 0xff) 205 | 206 | self.assertEqual(mock_sk.CmdAuthenticate.call_count, 1) 207 | self.assertEqual(mock_sk.CmdWink.call_count, 0) 208 | 209 | 210 | if __name__ == '__main__': 211 | unittest.main() 212 | -------------------------------------------------------------------------------- /pyu2f/convenience/customauthenticator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Class to offload the end to end flow of U2F signing.""" 16 | 17 | import base64 18 | import hashlib 19 | import json 20 | import os 21 | import struct 22 | import subprocess 23 | import sys 24 | 25 | from pyu2f import errors 26 | from pyu2f import model 27 | from pyu2f.convenience import baseauthenticator 28 | 29 | SK_SIGNING_PLUGIN_ENV_VAR = 'SK_SIGNING_PLUGIN' 30 | U2F_SIGNATURE_TIMEOUT_SECONDS = 5 31 | 32 | SK_SIGNING_PLUGIN_NO_ERROR = 0 33 | SK_SIGNING_PLUGIN_TOUCH_REQUIRED = 0x6985 34 | SK_SIGNING_PLUGIN_WRONG_DATA = 0x6A80 35 | 36 | 37 | class CustomAuthenticator(baseauthenticator.BaseAuthenticator): 38 | """Offloads U2F signing to a pluggable command-line tool. 39 | 40 | Offloads U2F signing to a signing plugin which takes the form of a 41 | command-line tool. The command-line tool is configurable via the 42 | SK_SIGNING_PLUGIN environment variable. 43 | 44 | The signing plugin should implement the following interface: 45 | 46 | Communication occurs over stdin/stdout, and messages are both sent and 47 | received in the form: 48 | 49 | [4 bytes - payload size (little-endian)][variable bytes - json payload] 50 | 51 | Signing Request JSON 52 | { 53 | "type": "sign_helper_request", 54 | "signData": [{ 55 | "keyHandle": , 56 | "appIdHash": , 57 | "challengeHash": , 58 | "version": U2F protocol version (usually "U2F_V2") 59 | },...], 60 | "timeoutSeconds": 61 | } 62 | 63 | Signing Response JSON 64 | { 65 | "type": "sign_helper_reply", 66 | "code": . 67 | "errorDetail": , 68 | "responseData": { 69 | "appIdHash": , 70 | "challengeHash": , 71 | "keyHandle": , 72 | "version": , 73 | "signatureData": 74 | } 75 | } 76 | 77 | Possible response error codes are: 78 | 79 | NoError = 0 80 | UnknownError = -127 81 | TouchRequired = 0x6985 82 | WrongData = 0x6a80 83 | """ 84 | 85 | def __init__(self, origin): 86 | self.origin = origin 87 | 88 | def Authenticate(self, app_id, challenge_data, 89 | print_callback=sys.stderr.write): 90 | """See base class.""" 91 | 92 | # Ensure environment variable is present 93 | plugin_cmd = os.environ.get(SK_SIGNING_PLUGIN_ENV_VAR) 94 | if plugin_cmd is None: 95 | raise errors.PluginError('{} env var is not set' 96 | .format(SK_SIGNING_PLUGIN_ENV_VAR)) 97 | 98 | # Prepare input to signer 99 | client_data_map, signing_input = self._BuildPluginRequest( 100 | app_id, challenge_data, self.origin) 101 | 102 | # Call plugin 103 | print_callback('Please insert and touch your security key\n') 104 | response = self._CallPlugin([plugin_cmd], signing_input) 105 | 106 | # Handle response 107 | key_challenge_pair = (response['keyHandle'], response['challengeHash']) 108 | client_data_json = client_data_map[key_challenge_pair] 109 | client_data = client_data_json.encode() 110 | return self._BuildAuthenticatorResponse(app_id, client_data, response) 111 | 112 | def IsAvailable(self): 113 | """See base class.""" 114 | return os.environ.get(SK_SIGNING_PLUGIN_ENV_VAR) is not None 115 | 116 | def _BuildPluginRequest(self, app_id, challenge_data, origin): 117 | """Builds a JSON request in the form that the plugin expects.""" 118 | client_data_map = {} 119 | encoded_challenges = [] 120 | app_id_hash_encoded = self._Base64Encode(self._SHA256(app_id)) 121 | for challenge_item in challenge_data: 122 | key = challenge_item['key'] 123 | key_handle_encoded = self._Base64Encode(key.key_handle) 124 | 125 | raw_challenge = challenge_item['challenge'] 126 | 127 | client_data_json = model.ClientData( 128 | model.ClientData.TYP_AUTHENTICATION, 129 | raw_challenge, 130 | origin).GetJson() 131 | 132 | challenge_hash_encoded = self._Base64Encode( 133 | self._SHA256(client_data_json)) 134 | 135 | # Populate challenges list 136 | encoded_challenges.append({ 137 | 'appIdHash': app_id_hash_encoded, 138 | 'challengeHash': challenge_hash_encoded, 139 | 'keyHandle': key_handle_encoded, 140 | 'version': key.version, 141 | }) 142 | 143 | # Populate ClientData map 144 | key_challenge_pair = (key_handle_encoded, challenge_hash_encoded) 145 | client_data_map[key_challenge_pair] = client_data_json 146 | 147 | signing_request = { 148 | 'type': 'sign_helper_request', 149 | 'signData': encoded_challenges, 150 | 'timeoutSeconds': U2F_SIGNATURE_TIMEOUT_SECONDS, 151 | 'localAlways': True 152 | } 153 | 154 | return client_data_map, json.dumps(signing_request) 155 | 156 | def _BuildAuthenticatorResponse(self, app_id, client_data, plugin_response): 157 | """Builds the response to return to the caller.""" 158 | encoded_client_data = self._Base64Encode(client_data) 159 | signature_data = str(plugin_response['signatureData']) 160 | key_handle = str(plugin_response['keyHandle']) 161 | 162 | response = { 163 | 'clientData': encoded_client_data, 164 | 'signatureData': signature_data, 165 | 'applicationId': app_id, 166 | 'keyHandle': key_handle, 167 | } 168 | return response 169 | 170 | def _CallPlugin(self, cmd, input_json): 171 | """Calls the plugin and validates the response.""" 172 | # Calculate length of input 173 | input_length = len(input_json) 174 | length_bytes_le = struct.pack(' 255 or size >= 2**16: 89 | raise errors.InvalidPacketError() 90 | if len(payload) > self.packet_size - 7: 91 | raise errors.InvalidPacketError() 92 | 93 | self.cid = cid # byte array 94 | self.cmd = cmd # number 95 | self.size = size # number (full size of message) 96 | self.payload = payload # byte array (for first packet) 97 | 98 | def ToWireFormat(self): 99 | """Serializes the packet.""" 100 | ret = bytearray(64) 101 | ret[0:4] = self.cid 102 | ret[4] = self.cmd 103 | struct.pack_into('>H', ret, 5, self.size) 104 | ret[7:7 + len(self.payload)] = self.payload 105 | return list(map(int, ret)) 106 | 107 | @staticmethod 108 | def FromWireFormat(packet_size, data): 109 | """Derializes the packet. 110 | 111 | Deserializes the packet from wire format. 112 | 113 | Args: 114 | packet_size: The size of all packets (usually 64) 115 | data: List of ints or bytearray containing the data from the wire. 116 | 117 | Returns: 118 | InitPacket object for specified data 119 | 120 | Raises: 121 | InvalidPacketError: if the data isn't a valid InitPacket 122 | """ 123 | ba = bytearray(data) 124 | if len(ba) != packet_size: 125 | raise errors.InvalidPacketError() 126 | cid = ba[0:4] 127 | cmd = ba[4] 128 | size = struct.unpack('>H', bytes(ba[5:7]))[0] 129 | payload = ba[7:7 + size] # might truncate at packet_size 130 | return UsbHidTransport.InitPacket(packet_size, cid, cmd, size, payload) 131 | 132 | class ContPacket(object): 133 | """Represents a continutation U2FHID packet. 134 | 135 | Represents a continutation U2FHID packet. These packets follow 136 | the intial packet and contains the remaining data in a particular 137 | message. 138 | 139 | Attributes: 140 | packet_size: The size of the hid report (packet) used. Usually 64. 141 | cid: The channel id for the connection to the device. 142 | seq: The sequence number for this continuation packet. The first 143 | continuation packet is 0 and it increases from there. 144 | payload: The payload to put into this continuation packet. This 145 | must be less than packet_size - 5 (the overhead of the 146 | continuation packet is 5). 147 | """ 148 | 149 | def __init__(self, packet_size, cid, seq, payload): 150 | self.packet_size = packet_size 151 | self.cid = cid 152 | self.seq = seq 153 | self.payload = payload 154 | if len(payload) > self.packet_size - 5: 155 | raise errors.InvalidPacketError() 156 | if seq > 127: 157 | raise errors.InvalidPacketError() 158 | 159 | def ToWireFormat(self): 160 | """Serializes the packet.""" 161 | ret = bytearray(self.packet_size) 162 | ret[0:4] = self.cid 163 | ret[4] = self.seq 164 | ret[5:5 + len(self.payload)] = self.payload 165 | return list(map(int, ret)) 166 | 167 | @staticmethod 168 | def FromWireFormat(packet_size, data): 169 | """Derializes the packet. 170 | 171 | Deserializes the packet from wire format. 172 | 173 | Args: 174 | packet_size: The size of all packets (usually 64) 175 | data: List of ints or bytearray containing the data from the wire. 176 | 177 | Returns: 178 | InitPacket object for specified data 179 | 180 | Raises: 181 | InvalidPacketError: if the data isn't a valid ContPacket 182 | """ 183 | ba = bytearray(data) 184 | if len(ba) != packet_size: 185 | raise errors.InvalidPacketError() 186 | cid = ba[0:4] 187 | seq = ba[4] 188 | # We don't know the size limit a priori here without seeing the init 189 | # packet, so truncation needs to be done in the higher level protocol 190 | # handling code, unlike the degenerate case of a 1 packet message in an 191 | # init packet, where the size is known. 192 | payload = ba[5:] 193 | return UsbHidTransport.ContPacket(packet_size, cid, seq, payload) 194 | 195 | def __init__(self, hid_device, read_timeout_secs=3.0): 196 | self.hid_device = hid_device 197 | 198 | in_size = hid_device.GetInReportDataLength() 199 | out_size = hid_device.GetOutReportDataLength() 200 | if in_size != out_size: 201 | raise errors.HardwareError( 202 | 'unsupported device with different in/out packet sizes.') 203 | if in_size == 0: 204 | raise errors.HardwareError('unable to determine packet size') 205 | 206 | self.packet_size = in_size 207 | self.read_timeout_secs = read_timeout_secs 208 | self.logger = logging.getLogger('pyu2f.hidtransport') 209 | 210 | self.InternalInit() 211 | 212 | def SendMsgBytes(self, msg): 213 | r = self.InternalExchange(UsbHidTransport.U2FHID_MSG, msg) 214 | return r 215 | 216 | def SendBlink(self, length): 217 | return self.InternalExchange(UsbHidTransport.U2FHID_PROMPT, 218 | bytearray([length])) 219 | 220 | def SendWink(self): 221 | return self.InternalExchange(UsbHidTransport.U2FHID_WINK, bytearray([])) 222 | 223 | def SendPing(self, data): 224 | return self.InternalExchange(UsbHidTransport.U2FHID_PING, data) 225 | 226 | def InternalInit(self): 227 | """Initializes the device and obtains channel id.""" 228 | self.cid = UsbHidTransport.U2FHID_BROADCAST_CID 229 | nonce = bytearray(os.urandom(8)) 230 | r = self.InternalExchange(UsbHidTransport.U2FHID_INIT, nonce) 231 | if len(r) < 17: 232 | raise errors.HidError('unexpected init reply len') 233 | if r[0:8] != nonce: 234 | raise errors.HidError('nonce mismatch') 235 | self.cid = bytearray(r[8:12]) 236 | 237 | self.u2fhid_version = r[12] 238 | 239 | def InternalExchange(self, cmd, payload_in): 240 | """Sends and receives a message from the device.""" 241 | # make a copy because we destroy it below 242 | self.logger.debug('payload: ' + str(list(payload_in))) 243 | payload = bytearray() 244 | payload[:] = payload_in 245 | for _ in range(2): 246 | self.InternalSend(cmd, payload) 247 | ret_cmd, ret_payload = self.InternalRecv() 248 | 249 | if ret_cmd == UsbHidTransport.U2FHID_ERROR: 250 | if ret_payload == UsbHidTransport.ERR_CHANNEL_BUSY: 251 | time.sleep(0.5) 252 | continue 253 | raise errors.HidError('Device error: %d' % int(ret_payload[0])) 254 | elif ret_cmd != cmd: 255 | raise errors.HidError('Command mismatch!') 256 | 257 | return ret_payload 258 | raise errors.HidError('Device Busy. Please retry') 259 | 260 | def InternalSend(self, cmd, payload): 261 | """Sends a message to the device, including fragmenting it.""" 262 | length_to_send = len(payload) 263 | 264 | max_payload = self.packet_size - 7 265 | first_frame = payload[0:max_payload] 266 | first_packet = UsbHidTransport.InitPacket(self.packet_size, self.cid, cmd, 267 | len(payload), first_frame) 268 | del payload[0:max_payload] 269 | length_to_send -= len(first_frame) 270 | self.InternalSendPacket(first_packet) 271 | 272 | seq = 0 273 | while length_to_send > 0: 274 | max_payload = self.packet_size - 5 275 | next_frame = payload[0:max_payload] 276 | del payload[0:max_payload] 277 | length_to_send -= len(next_frame) 278 | next_packet = UsbHidTransport.ContPacket(self.packet_size, self.cid, seq, 279 | next_frame) 280 | self.InternalSendPacket(next_packet) 281 | seq += 1 282 | 283 | def InternalSendPacket(self, packet): 284 | wire = packet.ToWireFormat() 285 | self.logger.debug('sending packet: ' + str(wire)) 286 | self.hid_device.Write(wire) 287 | 288 | def InternalReadFrame(self): 289 | # TODO(gdasher): Figure out timeouts. Today, this implementation 290 | # blocks forever at the HID level waiting for a response to a report. 291 | # This may not be reasonable behavior (though in practice in seems to be 292 | # OK on the set of devices and machines tested so far). 293 | frame = self.hid_device.Read() 294 | self.logger.debug('recv: ' + str(frame)) 295 | return frame 296 | 297 | def InternalRecv(self): 298 | """Receives a message from the device, including defragmenting it.""" 299 | first_read = self.InternalReadFrame() 300 | first_packet = UsbHidTransport.InitPacket.FromWireFormat(self.packet_size, 301 | first_read) 302 | 303 | data = first_packet.payload 304 | to_read = first_packet.size - len(first_packet.payload) 305 | 306 | seq = 0 307 | while to_read > 0: 308 | next_read = self.InternalReadFrame() 309 | next_packet = UsbHidTransport.ContPacket.FromWireFormat(self.packet_size, 310 | next_read) 311 | if self.cid != next_packet.cid: 312 | # Skip over packets that are for communication with other clients. 313 | # HID is broadcast, so we see potentially all communication from the 314 | # device. For well-behaved devices, these should be BUSY messages 315 | # sent to other clients of the device because at this point we're 316 | # in mid-message transit. 317 | continue 318 | 319 | if seq != next_packet.seq: 320 | raise errors.HardwareError('Packets received out of order') 321 | 322 | # This packet for us at this point, so debit it against our 323 | # balance of bytes to read. 324 | to_read -= len(next_packet.payload) 325 | 326 | data.extend(next_packet.payload) 327 | seq += 1 328 | 329 | # truncate incomplete frames 330 | data = data[0:first_packet.size] 331 | return (first_packet.cmd, data) 332 | -------------------------------------------------------------------------------- /pyu2f/hid/windows.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Implements raw HID device communication on Windows.""" 16 | 17 | import ctypes 18 | from ctypes import wintypes 19 | 20 | import platform 21 | 22 | from pyu2f import errors 23 | from pyu2f.hid import base 24 | 25 | 26 | # Load relevant DLLs 27 | hid = ctypes.windll.Hid 28 | setupapi = ctypes.windll.SetupAPI 29 | kernel32 = ctypes.windll.Kernel32 30 | 31 | 32 | # Various structs that are used in the Windows APIs we call 33 | class GUID(ctypes.Structure): 34 | _fields_ = [("Data1", ctypes.c_ulong), 35 | ("Data2", ctypes.c_ushort), 36 | ("Data3", ctypes.c_ushort), 37 | ("Data4", ctypes.c_ubyte * 8)] 38 | 39 | # On Windows, SetupAPI.h packs structures differently in 64bit and 40 | # 32bit mode. In 64bit mode, thestructures are packed on 8 byte 41 | # boundaries, while in 32bit mode, they are packed on 1 byte boundaries. 42 | # This is important to get right for some API calls that fill out these 43 | # structures. 44 | if platform.architecture()[0] == "64bit": 45 | SETUPAPI_PACK = 8 46 | elif platform.architecture()[0] == "32bit": 47 | SETUPAPI_PACK = 1 48 | else: 49 | raise errors.HidError("Unknown architecture: %s" % platform.architecture()[0]) 50 | 51 | 52 | class DeviceInterfaceData(ctypes.Structure): 53 | _fields_ = [("cbSize", wintypes.DWORD), 54 | ("InterfaceClassGuid", GUID), 55 | ("Flags", wintypes.DWORD), 56 | ("Reserved", ctypes.POINTER(ctypes.c_ulong))] 57 | _pack_ = SETUPAPI_PACK 58 | 59 | 60 | class DeviceInterfaceDetailData(ctypes.Structure): 61 | _fields_ = [("cbSize", wintypes.DWORD), 62 | ("DevicePath", ctypes.c_byte * 1)] 63 | _pack_ = SETUPAPI_PACK 64 | 65 | 66 | class HidAttributes(ctypes.Structure): 67 | _fields_ = [("Size", ctypes.c_ulong), 68 | ("VendorID", ctypes.c_ushort), 69 | ("ProductID", ctypes.c_ushort), 70 | ("VersionNumber", ctypes.c_ushort)] 71 | 72 | 73 | class HidCapabilities(ctypes.Structure): 74 | _fields_ = [("Usage", ctypes.c_ushort), 75 | ("UsagePage", ctypes.c_ushort), 76 | ("InputReportByteLength", ctypes.c_ushort), 77 | ("OutputReportByteLength", ctypes.c_ushort), 78 | ("FeatureReportByteLength", ctypes.c_ushort), 79 | ("Reserved", ctypes.c_ushort * 17), 80 | ("NotUsed", ctypes.c_ushort * 10)] 81 | 82 | # Various void* aliases for readability. 83 | HDEVINFO = ctypes.c_void_p 84 | HANDLE = ctypes.c_void_p 85 | PHIDP_PREPARSED_DATA = ctypes.c_void_p # pylint: disable=invalid-name 86 | 87 | # This is a HANDLE. 88 | INVALID_HANDLE_VALUE = 0xffffffff 89 | 90 | # Status codes 91 | NTSTATUS = ctypes.c_long 92 | HIDP_STATUS_SUCCESS = 0x00110000 93 | FILE_SHARE_READ = 0x00000001 94 | FILE_SHARE_WRITE = 0x00000002 95 | OPEN_EXISTING = 0x03 96 | ERROR_ACCESS_DENIED = 0x05 97 | 98 | # CreateFile Flags 99 | GENERIC_WRITE = 0x40000000 100 | GENERIC_READ = 0x80000000 101 | 102 | # Function signatures 103 | hid.HidD_GetHidGuid.restype = None 104 | hid.HidD_GetHidGuid.argtypes = [ctypes.POINTER(GUID)] 105 | hid.HidD_GetAttributes.restype = wintypes.BOOLEAN 106 | hid.HidD_GetAttributes.argtypes = [HANDLE, ctypes.POINTER(HidAttributes)] 107 | hid.HidD_GetPreparsedData.restype = wintypes.BOOLEAN 108 | hid.HidD_GetPreparsedData.argtypes = [HANDLE, 109 | ctypes.POINTER(PHIDP_PREPARSED_DATA)] 110 | hid.HidD_FreePreparsedData.restype = wintypes.BOOLEAN 111 | hid.HidD_FreePreparsedData.argtypes = [PHIDP_PREPARSED_DATA] 112 | hid.HidD_GetProductString.restype = wintypes.BOOLEAN 113 | hid.HidD_GetProductString.argtypes = [HANDLE, ctypes.c_void_p, ctypes.c_ulong] 114 | hid.HidP_GetCaps.restype = NTSTATUS 115 | hid.HidP_GetCaps.argtypes = [PHIDP_PREPARSED_DATA, 116 | ctypes.POINTER(HidCapabilities)] 117 | 118 | setupapi.SetupDiGetClassDevsA.argtypes = [ctypes.POINTER(GUID), ctypes.c_char_p, 119 | wintypes.HWND, wintypes.DWORD] 120 | setupapi.SetupDiGetClassDevsA.restype = HDEVINFO 121 | setupapi.SetupDiEnumDeviceInterfaces.restype = wintypes.BOOL 122 | setupapi.SetupDiEnumDeviceInterfaces.argtypes = [ 123 | HDEVINFO, ctypes.c_void_p, ctypes.POINTER(GUID), wintypes.DWORD, 124 | ctypes.POINTER(DeviceInterfaceData)] 125 | setupapi.SetupDiGetDeviceInterfaceDetailA.restype = wintypes.BOOL 126 | setupapi.SetupDiGetDeviceInterfaceDetailA.argtypes = [ 127 | HDEVINFO, ctypes.POINTER(DeviceInterfaceData), 128 | ctypes.POINTER(DeviceInterfaceDetailData), wintypes.DWORD, 129 | ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p] 130 | 131 | kernel32.CreateFileA.restype = HANDLE 132 | kernel32.CreateFileA.argtypes = [ 133 | ctypes.c_char_p, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, 134 | wintypes.DWORD, wintypes.DWORD, HANDLE] 135 | kernel32.CloseHandle.restype = wintypes.BOOL 136 | kernel32.CloseHandle.argtypes = [HANDLE] 137 | kernel32.ReadFile.restype = wintypes.BOOL 138 | kernel32.ReadFile.argtypes = [ 139 | HANDLE, ctypes.c_void_p, wintypes.DWORD, 140 | ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p] 141 | kernel32.WriteFile.restype = wintypes.BOOL 142 | kernel32.WriteFile.argtypes = [ 143 | HANDLE, ctypes.c_void_p, wintypes.DWORD, 144 | ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p] 145 | 146 | 147 | def FillDeviceAttributes(device, descriptor): 148 | """Fill out the attributes of the device. 149 | 150 | Fills the devices HidAttributes and product string 151 | into the descriptor. 152 | 153 | Args: 154 | device: A handle to the open device 155 | descriptor: The DeviceDescriptor to populate with the 156 | attributes. 157 | 158 | Returns: 159 | None 160 | 161 | Raises: 162 | WindowsError when unable to obtain attributes or product 163 | string. 164 | """ 165 | attributes = HidAttributes() 166 | result = hid.HidD_GetAttributes(device, ctypes.byref(attributes)) 167 | if not result: 168 | raise ctypes.WinError() 169 | 170 | buf = ctypes.create_string_buffer(1024) 171 | result = hid.HidD_GetProductString(device, buf, 1024) 172 | 173 | if not result: 174 | raise ctypes.WinError() 175 | 176 | descriptor.vendor_id = attributes.VendorID 177 | descriptor.product_id = attributes.ProductID 178 | descriptor.product_string = ctypes.wstring_at(buf) 179 | 180 | 181 | def FillDeviceCapabilities(device, descriptor): 182 | """Fill out device capabilities. 183 | 184 | Fills the HidCapabilitites of the device into descriptor. 185 | 186 | Args: 187 | device: A handle to the open device 188 | descriptor: DeviceDescriptor to populate with the 189 | capabilities 190 | 191 | Returns: 192 | none 193 | 194 | Raises: 195 | WindowsError when unable to obtain capabilitites. 196 | """ 197 | preparsed_data = PHIDP_PREPARSED_DATA(0) 198 | ret = hid.HidD_GetPreparsedData(device, ctypes.byref(preparsed_data)) 199 | if not ret: 200 | raise ctypes.WinError() 201 | 202 | try: 203 | caps = HidCapabilities() 204 | ret = hid.HidP_GetCaps(preparsed_data, ctypes.byref(caps)) 205 | 206 | if ret != HIDP_STATUS_SUCCESS: 207 | raise ctypes.WinError() 208 | 209 | descriptor.usage = caps.Usage 210 | descriptor.usage_page = caps.UsagePage 211 | descriptor.internal_max_in_report_len = caps.InputReportByteLength 212 | descriptor.internal_max_out_report_len = caps.OutputReportByteLength 213 | 214 | finally: 215 | hid.HidD_FreePreparsedData(preparsed_data) 216 | 217 | 218 | # The python os.open() implementation uses the windows libc 219 | # open() function, which writes CreateFile but does so in a way 220 | # that doesn't let us open the device with the right set of permissions. 221 | # Therefore, we have to directly use the Windows API calls. 222 | # We could use PyWin32, which provides simple wrappers. However, to avoid 223 | # requiring a PyWin32 dependency for clients, we simply also implement it 224 | # using ctypes. 225 | def OpenDevice(path, enum=False): 226 | """Open the device and return a handle to it.""" 227 | desired_access = GENERIC_WRITE | GENERIC_READ 228 | share_mode = FILE_SHARE_READ | FILE_SHARE_WRITE 229 | if enum: 230 | desired_access = 0 231 | 232 | h = kernel32.CreateFileA(path, 233 | desired_access, 234 | share_mode, 235 | None, OPEN_EXISTING, 0, None) 236 | if h == INVALID_HANDLE_VALUE: 237 | raise ctypes.WinError() 238 | return h 239 | 240 | 241 | class WindowsHidDevice(base.HidDevice): 242 | """Implementation of raw HID interface on Windows.""" 243 | 244 | @staticmethod 245 | def Enumerate(): 246 | """See base class.""" 247 | hid_guid = GUID() 248 | hid.HidD_GetHidGuid(ctypes.byref(hid_guid)) 249 | 250 | devices = setupapi.SetupDiGetClassDevsA( 251 | ctypes.byref(hid_guid), None, None, 0x12) 252 | index = 0 253 | interface_info = DeviceInterfaceData() 254 | interface_info.cbSize = ctypes.sizeof(DeviceInterfaceData) # pylint: disable=invalid-name 255 | 256 | out = [] 257 | while True: 258 | result = setupapi.SetupDiEnumDeviceInterfaces( 259 | devices, 0, ctypes.byref(hid_guid), index, 260 | ctypes.byref(interface_info)) 261 | index += 1 262 | if not result: 263 | break 264 | 265 | detail_len = wintypes.DWORD() 266 | result = setupapi.SetupDiGetDeviceInterfaceDetailA( 267 | devices, ctypes.byref(interface_info), None, 0, 268 | ctypes.byref(detail_len), None) 269 | 270 | detail_len = detail_len.value 271 | if detail_len == 0: 272 | # skip this device, some kind of error 273 | continue 274 | 275 | buf = ctypes.create_string_buffer(detail_len) 276 | interface_detail = DeviceInterfaceDetailData.from_buffer(buf) 277 | interface_detail.cbSize = ctypes.sizeof(DeviceInterfaceDetailData) 278 | 279 | result = setupapi.SetupDiGetDeviceInterfaceDetailA( 280 | devices, ctypes.byref(interface_info), 281 | ctypes.byref(interface_detail), detail_len, None, None) 282 | 283 | if not result: 284 | raise ctypes.WinError() 285 | 286 | descriptor = base.DeviceDescriptor() 287 | # This is a bit of a hack to work around a limitation of ctypes and 288 | # "header" structures that are common in windows. DevicePath is a 289 | # ctypes array of length 1, but it is backed with a buffer that is much 290 | # longer and contains a null terminated string. So, we read the null 291 | # terminated string off DevicePath here. Per the comment above, the 292 | # alignment of this struct varies depending on architecture, but 293 | # in all cases the path string starts 1 DWORD into the structure. 294 | # 295 | # The path length is: 296 | # length of detail buffer - header length (1 DWORD) 297 | path_len = detail_len - ctypes.sizeof(wintypes.DWORD) 298 | descriptor.path = ctypes.string_at( 299 | ctypes.addressof(interface_detail.DevicePath), path_len) 300 | 301 | device = None 302 | try: 303 | device = OpenDevice(descriptor.path, True) 304 | except WindowsError as e: # pylint: disable=undefined-variable 305 | if e.winerror == ERROR_ACCESS_DENIED: # Access Denied, e.g. a keyboard 306 | continue 307 | else: 308 | raise e 309 | 310 | try: 311 | FillDeviceAttributes(device, descriptor) 312 | FillDeviceCapabilities(device, descriptor) 313 | out.append(descriptor.ToPublicDict()) 314 | except WindowsError as e: 315 | continue # skip this device 316 | finally: 317 | kernel32.CloseHandle(device) 318 | 319 | return out 320 | 321 | def __init__(self, path): 322 | """See base class.""" 323 | base.HidDevice.__init__(self, path) 324 | self.dev = OpenDevice(path) 325 | self.desc = base.DeviceDescriptor() 326 | FillDeviceCapabilities(self.dev, self.desc) 327 | 328 | def GetInReportDataLength(self): 329 | """See base class.""" 330 | return self.desc.internal_max_in_report_len - 1 331 | 332 | def GetOutReportDataLength(self): 333 | """See base class.""" 334 | return self.desc.internal_max_out_report_len - 1 335 | 336 | def Write(self, packet): 337 | """See base class.""" 338 | if len(packet) != self.GetOutReportDataLength(): 339 | raise errors.HidError("Packet length must match report data length.") 340 | 341 | packet_data = [0] + packet # Prepend the zero-byte (report ID) 342 | out = bytes(bytearray(packet_data)) 343 | num_written = wintypes.DWORD() 344 | ret = ( 345 | kernel32.WriteFile( 346 | self.dev, out, len(out), 347 | ctypes.byref(num_written), None)) 348 | if num_written.value != len(out): 349 | raise errors.HidError( 350 | "Failed to write complete packet. " + "Expected %d, but got %d" % 351 | (len(out), num_written.value)) 352 | if not ret: 353 | raise ctypes.WinError() 354 | 355 | def Read(self): 356 | """See base class.""" 357 | buf = ctypes.create_string_buffer(self.desc.internal_max_in_report_len) 358 | num_read = wintypes.DWORD() 359 | ret = kernel32.ReadFile( 360 | self.dev, buf, len(buf), ctypes.byref(num_read), None) 361 | 362 | if num_read.value != self.desc.internal_max_in_report_len: 363 | raise errors.HidError("Failed to read full length report from device.") 364 | 365 | if not ret: 366 | raise ctypes.WinError() 367 | 368 | # Convert the string buffer to a list of numbers. Throw away the first 369 | # byte, which is the report id (which we don't care about). 370 | return list(bytearray(buf[1:])) 371 | 372 | def __del__(self): 373 | """Closes the file handle when object is GC-ed.""" 374 | if hasattr(self, 'dev'): 375 | kernel32.CloseHandle(self.dev) 376 | -------------------------------------------------------------------------------- /pyu2f/tests/customauthenticator_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for pyu2f.convenience.customauthenticator.""" 16 | 17 | import base64 18 | import json 19 | import struct 20 | import sys 21 | 22 | import mock 23 | from pyu2f import errors 24 | from pyu2f import model 25 | from pyu2f.convenience import customauthenticator 26 | 27 | 28 | if sys.version_info[:2] < (2, 7): 29 | import unittest2 as unittest # pylint: disable=g-import-not-at-top 30 | else: 31 | import unittest # pylint: disable=g-import-not-at-top 32 | 33 | 34 | # Input/ouput values recorded from a successful signing flow 35 | SIGN_SUCCESS = { 36 | 'app_id': 'test_app_id', 37 | 'app_id_hash_encoded': 'TnMguTdPn7OcIO9f-0CgfQdY254bvc6WR-DTPZnJ49w', 38 | 'challenge': b'asdfasdf', 39 | 'challenge_hash_encoded': 'qhJtbTQvsU0BmLLpDWes-3zFGbegR2wp1mv5BJ2BwC0', 40 | 'key_handle_encoded': ('iBbl9-VYt-XSdWeHVNX-gfQcXGzlrAQ7BcngVNUxWijIQQlnZEI' 41 | '4Vb0Bp2ydBCbIQu_5rNlKqPH6NK1TtnM7fA'), 42 | 'origin': 'test_origin', 43 | 'signature_data_encoded': ('AQAAAI8wRQIhALlIPo6Hg8HwzELdYRIXnAnpsiHYCSXHex' 44 | 'CS34eiS2ixAiBt3TRmKE1A9WyMjc3JGrGI7gSPg-QzDSNL' 45 | 'aIj7JwcCTA'), 46 | 'client_data_encoded': ('eyJjaGFsbGVuZ2UiOiAiWVhOa1ptRnpaR1kiLCAib3JpZ2luI' 47 | 'jogInRlc3Rfb3JpZ2luIiwgInR5cCI6ICJuYXZpZ2F0b3IuaW' 48 | 'QuZ2V0QXNzZXJ0aW9uIn0'), 49 | 'u2f_version': 'U2F_V2', 50 | 'registered_key': model.RegisteredKey(base64.urlsafe_b64decode( 51 | 'iBbl9-VYt-XSdWeHVNX-gfQcXGzlrAQ7BcngVNUxWijIQQlnZEI4Vb0Bp2ydBCbIQu' 52 | '_5rNlKqPH6NK1TtnM7fA==' 53 | )) 54 | } 55 | 56 | 57 | @mock.patch.object(sys, 'stderr', new=mock.MagicMock()) 58 | class CustomAuthenticatorTest(unittest.TestCase): 59 | 60 | @mock.patch.object(customauthenticator.os.environ, 'get', return_value=None) 61 | def testEnvVarNotSet(self, os_get_method): 62 | authenticator = customauthenticator.CustomAuthenticator('testorigin') 63 | 64 | self.assertFalse(authenticator.IsAvailable(), 65 | 'Should return false when no env var is present') 66 | 67 | # Assert exception if Authenticate is called when no env var is set 68 | with self.assertRaises(errors.PluginError): 69 | authenticator.Authenticate('appid', {}) 70 | 71 | @mock.patch.object(customauthenticator.subprocess, 'Popen') 72 | @mock.patch.object(customauthenticator.os.environ, 'get', 73 | return_value='gnubbyagent') 74 | def testSuccessAuthenticate(self, os_get_method, popen_method): 75 | """Test plugin Authenticate success.""" 76 | valid_plugin_response = { 77 | 'type': 'sign_helper_reply', 78 | 'code': 0, 79 | 'errorDetail': '', 80 | 'responseData': { 81 | 'appIdHash': SIGN_SUCCESS['app_id_hash_encoded'], 82 | 'challengeHash': SIGN_SUCCESS['challenge_hash_encoded'], 83 | 'keyHandle': SIGN_SUCCESS['key_handle_encoded'], 84 | 'version': SIGN_SUCCESS['u2f_version'], 85 | 'signatureData': SIGN_SUCCESS['signature_data_encoded']}, 86 | 'data': None 87 | } 88 | valid_plugin_response_json = json.dumps(valid_plugin_response).encode() 89 | valid_plugin_response_len = struct.pack('