├── 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 | [](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('