Initial import.

This commit is contained in:
Dain Nilsson 2018-03-02 13:15:13 +01:00
commit 1b419592bf
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
46 changed files with 18305 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.pyc
*.egg
*.egg-info
build/
dist/
.eggs/
.ropeproject/
ChangeLog
man/*.1
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*

7
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,7 @@
repos:
- repo: git://github.com/pre-commit/pre-commit-hooks
sha: v1.2.3
hooks:
- id: flake8
- id: double-quote-string-fixer
exclude: '^(fido_host|test)/pyu2f/.*'

33
.travis.yml Normal file
View File

@ -0,0 +1,33 @@
language: python
sudo: false
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "pypy-5.7"
- "pypy3.5-5.7.1-beta"
cache:
directories:
- $HOME/.cache/pip
addons:
apt:
packages:
- libffi-dev
- libssl-dev
- swig
install:
- pip install pre-commit
- pip install -e .
script:
- pre-commit run --all-files
- python setup.py test
after_success:
- coveralls

26
COPYING Normal file
View File

@ -0,0 +1,26 @@
Copyright (c) 2018 Yubico AB
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

202
COPYING.APLv2 Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

373
COPYING.MPLv2 Normal file
View File

@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

5
MANIFEST.in Normal file
View File

@ -0,0 +1,5 @@
include COPYING*
include NEWS
include ChangeLog
include fido_host/public_suffix_list.dat
include examples/*

2
NEWS Normal file
View File

@ -0,0 +1,2 @@
* Version 0.0.0 (unreleased)
** Initial version.

49
README Normal file
View File

@ -0,0 +1,49 @@
== fido-host
Provides library functionality for communicationg with a FIDO device over USB.
WARNING: The state of this project is alpha. Expect things to change and break at any time!
This library aims to support the FIDO U2F and FIDO 2.0 protocols for
communicating with a USB authenticator via the Client-to-Authenticator Protocol
(CTAP 1 and 2). In addition to this low-level device access, classes defined in
the `fido_host.client` implement higher level device operations.
For usage, see the examples/ directory.
=== License
This project, with the exception of the files mentioned below, is licensed
under the BSD 2-clause license.
See the COPYING file for the full license text.
This project contains source code from pyu2f (https://github.com/google/pyu2f)
which is licensed under the Apache License, version 2.0.
These files are located in `fido_host/pyu2f/` and `test/pyu2f/`.
See http://www.apache.org/licenses/LICENSE-2.0,
or the COPYING.APACHEv2 file for the full license text.
This project also bundles the public suffix list (https://publicsuffix.org)
which is licensed under the Mozilla Public License, version 2.0.
See https://mozilla.org/MPL/2.0/,
or the COPYING.MPLv2 file for the full license text.
=== Installation
fido-host is installable by running the following command:
# pip install fido-host
Under Linux you will need to add a Udev rule to be able to access the FIDO
device, or run as root. For example, the Udev rule may contain the following:
----
#Udev rule for allowing HID access to Yubico devices for FIDO support.
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", \
MODE="0664", GROUP="plugdev", ATTRS{idVendor}=="1050"
----
=== Dependencies
fido-host is compatible with CPython 2.7, 3.4 onwards, and is tested on
Windows, MacOS, and Linux.
This project depends on Cryptography. For instructions on installing this
dependency, see link:https://cryptography.io/en/latest/installation/.

1
README.adoc Symbolic link
View File

@ -0,0 +1 @@
README

27
appveyor.yml Normal file
View File

@ -0,0 +1,27 @@
# appveyor.yml
# Building, testing and deployment for Windows
# Syntax for this file:
# https://www.appveyor.com/docs/appveyor-yml
environment:
matrix:
- PYTHON: "C:\\Python27"
- PYTHON: "C:\\Python27-x64"
- PYTHON: "C:\\Python34"
- PYTHON: "C:\\Python34-x64"
- PYTHON: "C:\\Python35"
- PYTHON: "C:\\Python35-x64"
- PYTHON: "C:\\Python36"
- PYTHON: "C:\\Python36-x64"
matrix:
fast_finish: true
build_script:
- pip install -e .
test_script:
- python setup.py test

81
examples/credential.py Normal file
View File

@ -0,0 +1,81 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Connects to the first FIDO device found, creates a new credential for it,
and authenticates the credential. This works with both FIDO 2.0 devices as well
as with U2F devices.
"""
from __future__ import print_function, absolute_import
from fido_host.hid import CtapHidDevice
from fido_host.client import Fido2Client
import sys
# Locate a device
dev = next(CtapHidDevice.list_devices(), None)
if not dev:
print('No FIDO device found')
sys.exit(1)
# Set up a FIDO 2 client using the origin https://example.com
client = Fido2Client(dev, 'https://example.com')
# Prepare parameters for makeCredential
rp = {'id': 'example.com', 'name': 'Example RP'}
user = {'id': b'user_id', 'name': 'A. User'}
challenge = 'Y2hhbGxlbmdl'
# Create a credential
print('\nTouch your authenticator device now...\n')
attestation_object, client_data = client.make_credential(rp, user, challenge)
print('New credential created!')
print('CLIENT DATA:', client_data)
print('ATTESTATION OBJECT:', attestation_object)
print()
print('CREDENTIAL DATA:', attestation_object.auth_data.credential_data)
# Prepare parameters for getAssertion
challenge = 'Q0hBTExFTkdF' # Use a new challenge for each call.
allow_list = [{
'type': 'public-key',
'id': attestation_object.auth_data.credential_data.credential_id
}]
# Authenticate the credential
print('\nTouch your authenticator device now...\n')
assertions, client_data = client.get_assertion(rp['id'], challenge, allow_list)
print('Credential authenticated!')
assertion = assertions[0] # Only one cred in allowList, only one response.
print('CLIENT DATA:', client_data)
print()
print('ASSERTION DATA:', assertion)

53
examples/get_info.py Normal file
View File

@ -0,0 +1,53 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Connects to each attached FIDO device, and:
1. If the device supports CBOR commands, perform a getInfo command.
2. If the device supports WINK, perform the wink command.
"""
from __future__ import print_function, absolute_import
from fido_host.hid import CtapHidDevice, CAPABILITY
from fido_host.fido2 import CTAP2
for dev in CtapHidDevice.list_devices():
print('CONNECT: %s' % dev)
if dev.capabilities & CAPABILITY.CBOR:
ctap2 = CTAP2(dev)
info = ctap2.get_info()
print('DEVICE INFO: %s' % info)
else:
print('Device does not support CBOR')
if dev.capabilities & CAPABILITY.WINK:
dev.wink()
print('WINK sent!')
else:
print('Device does not support WINK')

28
fido_host/__init__.py Normal file
View File

@ -0,0 +1,28 @@
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
__version__ = '0.1.0'

167
fido_host/cbor.py Normal file
View File

@ -0,0 +1,167 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Minimal CBOR implementation supporting a subset of functionality and types
required for FIDO 2 CTAP.
"""
import struct
import six
def dump_int(data, mt=0):
if data < 0:
mt = 1
data = -1 - data
mt = mt << 5
if data <= 23:
args = ('>B', mt | data)
elif data <= 0xff:
args = ('>BB', mt | 24, data)
elif data <= 0xffff:
args = ('>BH', mt | 25, data)
elif data <= 0xffffffff:
args = ('>BI', mt | 26, data)
else:
args = ('>BQ', mt | 27, data)
return struct.pack(*args)
def dump_bool(data):
return b'\xf5' if data else b'\xf4'
def dump_list(data):
return dump_int(len(data), mt=4) + b''.join([dumps(x) for x in data])
def _sort_keys(entry):
key = entry[0]
return (six.indexbytes(key, 0), len(key), key)
def dump_dict(data):
items = [(dumps(k), dumps(v)) for k, v in data.items()]
items.sort(key=_sort_keys)
return dump_int(len(items), mt=5) + b''.join([k+v for (k, v) in items])
def dump_bytes(data):
return dump_int(len(data), mt=2) + data
def dump_text(data):
data = data.encode('utf8')
return dump_int(len(data), mt=3) + data
_SERIALIZERS = [
(bool, dump_bool),
(six.integer_types, dump_int),
(dict, dump_dict),
(list, dump_list),
(six.text_type, dump_text),
(six.binary_type, dump_bytes)
]
def dumps(data):
for k, v in _SERIALIZERS:
if isinstance(data, k):
return v(data)
raise ValueError('Unsupported value: {}'.format(data))
def load_int(ai, data):
if ai < 24:
return ai, data
elif ai == 24:
return six.indexbytes(data, 0), data[1:]
elif ai == 25:
return struct.unpack_from('>H', data)[0], data[2:]
elif ai == 26:
return struct.unpack_from('>I', data)[0], data[4:]
elif ai == 27:
return struct.unpack_from('>Q', data)[0], data[8:]
raise ValueError('Invalid additional information')
def load_nint(ai, data):
val, rest = load_int(ai, data)
return -1 - val, rest
def load_bool(ai, data):
return ai == 21, data
def load_bytes(ai, data):
l, data = load_int(ai, data)
return data[:l], data[l:]
def load_text(ai, data):
enc, rest = load_bytes(ai, data)
return enc.decode('utf8'), rest
def load_array(ai, data):
l, data = load_int(ai, data)
values = []
for i in range(l):
val, data = loads(data)
values.append(val)
return values, data
def load_map(ai, data):
l, data = load_int(ai, data)
values = {}
for i in range(l):
k, data = loads(data)
v, data = loads(data)
values[k] = v
return values, data
_DESERIALIZERS = {
0: load_int,
1: load_nint,
2: load_bytes,
3: load_text,
4: load_array,
5: load_map,
7: load_bool
}
def loads(data):
fb = six.indexbytes(data, 0)
return _DESERIALIZERS[fb >> 5](fb & 0b11111, data[1:])

377
fido_host/client.py Normal file
View File

@ -0,0 +1,377 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from __future__ import absolute_import
from .hid import CtapError
from .u2f import CTAP1, APDU, ApduError
from .fido2 import (CTAP2, PinProtocolV1, AttestedCredentialData,
AuthenticatorData, AttestationObject, AssertionResponse)
from .rpid import verify_rp_id, verify_app_id
from .utils import Timeout, sha256, hmac_sha256, websafe_decode, websafe_encode
from enum import IntEnum, unique
import json
class ClientData(bytes):
def __init__(self, data):
self.data = json.loads(data.decode())
self.origin = self.data['origin']
@property
def b64(self):
return websafe_encode(self)
@property
def hash(self):
return sha256(self)
@classmethod
def build(cls, **kwargs):
return cls(json.dumps(kwargs).encode())
@classmethod
def from_b64(cls, data):
return cls(websafe_decode(data))
def __repr__(self):
return self.decode()
def __str__(self):
return self.decode()
class ClientError(Exception):
@unique
class ERR(IntEnum):
OTHER_ERROR = 1
BAD_REQUEST = 2
CONFIGURATION_UNSUPPORTED = 3
DEVICE_INELIGIBLE = 4
TIMEOUT = 5
def __call__(self):
return ClientError(self)
def __init__(self, code):
self.code = ClientError.ERR(code)
def __repr__(self):
return 'U2F Client error: {0} - {0.name}'.format(self.code)
def _call_polling(poll_delay, timeout, func, *args, **kwargs):
with Timeout(timeout or 30) as event:
while not event.is_set():
try:
return func(*args, **kwargs)
except ApduError as e:
if e.code == APDU.USE_NOT_SATISFIED:
event.wait(poll_delay)
else:
raise
raise ClientError.ERR.TIMEOUT()
class U2fClient(object):
def __init__(self, device, origin, verify=verify_app_id):
self.poll_delay = 0.25
self.ctap = CTAP1(device)
self.origin = origin
self._verify = verify_app_id
def _verify_app_id(self, app_id):
try:
if self._verify(app_id, self.origin):
return
except Exception:
pass # Fall through to ClientError
raise ClientError.ERR.BAD_REQUEST()
def register(self, app_id, register_requests, registered_keys,
timeout=None):
self._verify_app_id(app_id)
version = self.ctap.get_version()
dummy_param = b'\0'*32
for key in registered_keys:
if key['version'] != version:
continue
key_app_id = key.get('appId', app_id)
app_param = sha256(key_app_id.encode())
self._verify_app_id(key_app_id)
key_handle = websafe_decode(key['keyHandle'])
try:
self.ctap.authenticate(dummy_param, app_param, key_handle, True)
raise ClientError.ERR.DEVICE_INELIGIBLE() # Bad response
except ApduError as e:
if e.code == APDU.USE_NOT_SATISFIED:
raise ClientError.ERR.DEVICE_INELIGIBLE()
for request in register_requests:
if request['version'] == version:
challenge = request['challenge']
break
else:
raise ClientError.ERR.DEVICE_INELIGIBLE()
client_data = ClientData.build(
typ='navigator.id.finishEnrollment',
challenge=challenge,
origin=self.origin
)
app_param = sha256(app_id.encode())
reg_data = _call_polling(self.poll_delay, timeout, self.ctap.register,
client_data.hash, app_param)
return {
'registrationData': reg_data.b64,
'clientData': client_data.b64
}
def sign(self, app_id, challenge, registered_keys, timeout=None):
client_data = ClientData.build(
typ='navigator.id.getAssertion',
challenge=challenge,
origin=self.origin
)
version = self.ctap.get_version()
for key in registered_keys:
if key['version'] == version:
key_app_id = key.get('appId', app_id)
self._verify_app_id(key_app_id)
key_handle = websafe_decode(key['keyHandle'])
app_param = sha256(key_app_id.encode())
try:
signature_data = _call_polling(
self.poll_delay, timeout, self.ctap.authenticate,
client_data.hash, app_param, key_handle)
break
except ApduError:
pass # Ignore and try next key
else:
raise ClientError.ERR.DEVICE_INELIGIBLE()
return {
'clientData': client_data.b64,
'signatureData': signature_data.b64,
'keyHandle': key['keyHandle']
}
@unique
class CRED_ALGO(IntEnum):
ES256 = -7
RS256 = -257
class Fido2Client(object):
def __init__(self, device, origin, verify=verify_rp_id):
self.ctap1_poll_delay = 0.25
self.origin = origin
self._verify = verify
try:
self.ctap = CTAP2(device)
self.pin_protocol = PinProtocolV1(self.ctap)
self._do_make_credential = self._ctap2_make_credential
self._do_get_assertion = self._ctap2_get_assertion
except ValueError:
self.ctap = CTAP1(device)
self._do_make_credential = self._ctap1_make_credential
self._do_get_assertion = self._ctap1_get_assertion
def _verify_rp_id(self, rp_id):
try:
if self._verify(rp_id, self.origin):
return
except Exception:
pass # Fall through to ClientError
raise ClientError.ERR.BAD_REQUEST()
def make_credential(self, rp, user, challenge, algos=[CRED_ALGO.ES256],
exclude_list=None, extensions=None, rk=False, uv=False,
pin=None, timeout=None):
self._verify_rp_id(rp['id'])
client_data = ClientData.build(
type='webauthn.create',
clientExtensions={},
challenge=challenge,
origin=self.origin
)
attestation = self._do_make_credential(
client_data, rp, user, algos, exclude_list, extensions, rk, uv, pin,
timeout)
return attestation, client_data
def _ctap2_make_credential(self, client_data, rp, user, algos, exclude_list,
extensions, rk, uv, pin, timeout):
key_params = [{'type': 'public-key', 'alg': alg} for alg in algos]
info = self.ctap.get_info()
pin_auth = None
pin_protocol = None
if pin:
pin_protocol = self.pin_protocol.VERSION
if pin_protocol not in info.pin_protocols:
raise ValueError('Device does not support PIN protocol 1!')
pin_token = self.pin_protocol.get_pin_token(pin)
pin_auth = hmac_sha256(pin_token, client_data.hash)[:16]
elif info.options.get('clientPin'):
raise ValueError('PIN required!')
if not (rk or uv):
options = None
else:
options = {}
if rk:
options['rk'] = True
if uv:
options['uv'] = True
return self.ctap.make_credential(client_data.hash, rp, user,
key_params, exclude_list,
extensions, options, pin_auth,
pin_protocol, timeout)
def _ctap1_make_credential(self, client_data, rp, user, algos, exclude_list,
extensions, rk, uv, pin, timeout):
if rk or uv:
raise CtapError(CtapError.ERR.UNSUPPORTED_OPTION)
app_param = sha256(rp['id'].encode())
dummy_param = b'\0'*32
for cred in exclude_list or []:
key_handle = cred['id']
try:
self.ctap.authenticate(dummy_param, app_param, key_handle, True)
raise CtapError(CtapError.ERR.CREDENTIAL_EXCLUDED) # Invalid
except ApduError as e:
if e.code == APDU.USE_NOT_SATISFIED:
raise CtapError(CtapError.ERR.CREDENTIAL_EXCLUDED)
reg_resp = _call_polling(self.ctap1_poll_delay, timeout,
self.ctap.register, client_data.hash,
app_param)
return AttestationObject.create(
'fido-u2f',
AuthenticatorData.create(
app_param,
0x41,
0,
AttestedCredentialData.create(
b'\0'*16, # aaguid
reg_resp.key_handle,
{ # EC256 public key
1: 2,
3: -7,
-1: 1,
-2: reg_resp.public_key[1:1+32],
-3: reg_resp.public_key[33:33+32]
}
)
),
{ # att_statement
'x5c': [reg_resp.certificate],
'sig': reg_resp.signature
}
)
def get_assertion(self, rp_id, challenge, allow_list=None, extensions=None,
rk=False, uv=False, pin=None, timeout=None):
self._verify_rp_id(rp_id)
client_data = ClientData.build(
type='webauthn.get',
clientExtensions={},
challenge=challenge,
origin=self.origin
)
assertions = self._do_get_assertion(
client_data, rp_id, allow_list, extensions, rk, uv, pin, timeout)
return assertions, client_data
def _ctap2_get_assertion(self, client_data, rp_id, allow_list, extensions,
rk, uv, pin, timeout):
info = self.ctap.get_info()
pin_auth = None
pin_protocol = None
if pin:
pin_protocol = self.pin_protocol.VERSION
if pin_protocol not in info.pin_protocols:
raise ValueError('Device does not support PIN protocol 1!')
pin_token = self.pin_protocol.get_pin_token(pin)
pin_auth = hmac_sha256(pin_token, client_data.hash)[:16]
elif info.options.get('clientPin'):
raise ValueError('PIN required!')
if not (rk or uv):
options = None
else:
options = {}
if rk:
options['rk'] = True
if uv:
options['uv'] = True
assertions = [self.ctap.get_assertion(rp_id, client_data.hash,
allow_list, extensions, options,
pin_auth, pin_protocol, timeout)]
for _ in range((assertions[0].number_of_credentials or 1) - 1):
assertions.append(self.ctap.get_next_assertion())
return assertions
def _ctap1_get_assertion(self, client_data, rp_id, allow_list, extensions,
rk, uv, pin, timeout):
if rk or uv or not allow_list:
raise CtapError(CtapError.ERR.UNSUPPORTED_OPTION)
app_param = sha256(rp_id.encode())
client_param = client_data.hash
for cred in allow_list:
try:
auth_resp = _call_polling(self.ctap1_poll_delay, timeout,
self.ctap.authenticate, client_param,
app_param, cred['id'])
return [AssertionResponse.create(
cred,
AuthenticatorData.create(
app_param,
auth_resp.user_presence & 0x01,
auth_resp.counter
),
auth_resp.signature
)]
except ApduError as e:
pass # Ignore this handle
raise CtapError(CtapError.ERR.NO_CREDENTIALS)

61
fido_host/ctap.py Normal file
View File

@ -0,0 +1,61 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import abc
import six
if six.PY2:
@six.add_metaclass(abc.ABCMeta)
class ABC(object):
pass
abc.ABC = ABC
abc.abstractclassmethod = abc.abstractmethod
class CtapDevice(abc.ABC):
"""
CTAP-capable device. Subclasses of this should implement call, as well as
list_devices, which should return a generator over discoverable devices.
"""
@abc.abstractmethod
def call(self, cmd, data=b'', event=None):
"""
cmd is the integer value of the command.
data is the binary string value of the payload.
event is an instance of threading.Event which can be used to cancel the
invocation.
"""
pass
@abc.abstractclassmethod
def list_devices(cls):
"""
Generates instances of cls for discoverable devices.
"""
pass

443
fido_host/fido2.py Normal file
View File

@ -0,0 +1,443 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from __future__ import absolute_import
from . import cbor
from .hid import CTAPHID, CAPABILITY, CtapError
from .utils import Timeout, sha256, hmac_sha256
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from binascii import b2a_hex, a2b_hex
from enum import IntEnum, unique
import struct
import six
def args(*args):
"""
Constructs a dict from a list of arguments for sending a CBOR command.
"""
if args:
return dict((i, v) for i, v in enumerate(args, 1) if v is not None)
return None
def hexstr(bs):
return "h'%s'" % b2a_hex(bs).decode()
def _parse_cbor(data):
resp, rest = cbor.loads(data)
if rest:
raise ValueError('Extraneous data')
return resp
class Info(bytes):
@unique
class KEY(IntEnum):
VERSIONS = 1
EXTENSIONS = 2
AAGUID = 3
OPTIONS = 4
MAX_MSG_SIZE = 5
PIN_PROTOCOLS = 6
def __init__(self, data):
data = dict((Info.KEY(k), v) for (k, v) in _parse_cbor(data).items())
self.versions = data[Info.KEY.VERSIONS]
self.extensions = data.get(Info.KEY.EXTENSIONS, [])
self.aaguid = data[Info.KEY.AAGUID]
self.options = data.get(Info.KEY.OPTIONS, {})
self.max_msg_size = data.get(Info.KEY.MAX_MSG_SIZE)
self.pin_protocols = data.get(Info.KEY.PIN_PROTOCOLS, [])
self.data = data
def __repr__(self):
r = 'Info(versions: %r' % self.versions
if self.extensions:
r += ', extensions: %r' % self.extensions
r += ', aaguid: %s' % hexstr(self.aaguid)
if self.options:
r += ', options: %r' % self.options
r += ', max_message_size: %d' % self.max_msg_size
if self.pin_protocols:
r += ', pin_protocols: %r' % self.pin_protocols
return r + ')'
def __str__(self):
return self.__repr__()
class AttestedCredentialData(bytes):
def __init__(self, _):
self.aaguid, self.credential_id, self.public_key, rest = \
AttestedCredentialData.parse(self)
if rest:
raise ValueError('Wrong length')
def __repr__(self):
return ('AttestedCredentialData(aaguid: %s, credential_id: %s, '
'public_key: %s') % (hexstr(self.aaguid),
hexstr(self.credential_id),
self.public_key)
def __str__(self):
return self.__repr__()
@staticmethod
def parse(data):
aaguid = data[:16]
c_len = struct.unpack('>H', data[16:18])[0]
cred_id = data[18:18+c_len]
pub_key, rest = cbor.loads(data[18+c_len:])
return aaguid, cred_id, pub_key, rest
@classmethod
def create(cls, aaguid, credential_id, public_key):
return cls(aaguid + struct.pack('>H', len(credential_id))
+ credential_id + cbor.dumps(public_key))
@classmethod
def unpack_from(cls, data):
args = cls.parse(data)
return cls.create(*args[:-1]), args[-1]
class AuthenticatorData(bytes):
@unique
class FLAG(IntEnum):
UP = 0x01
UV = 0x04
AT = 0x40
ED = 0x80
def __init__(self, data):
self.rp_id_hash = self[:32]
self.flags, self.counter = struct.unpack('>BI', self[32:32+5])
rest = self[37:]
if self.flags & AuthenticatorData.FLAG.AT:
self.credential_data, rest = \
AttestedCredentialData.unpack_from(self[37:])
else:
self.credential_data = None
if self.flags & AuthenticatorData.FLAG.ED:
self.extensions, rest = cbor.loads(rest)
else:
self.extensions = None
if rest:
raise ValueError('Wrong length')
@classmethod
def create(cls, rp_id_hash, flags, counter, credential_data=b'',
extensions=None):
return cls(
rp_id_hash + struct.pack('>BI', flags, counter) + credential_data +
(cbor.dumps(extensions) if extensions is not None else b'')
)
def __repr__(self):
r = 'AuthenticatorData(rp_id_hash: %s, flags: 0x%02x, counter: %d' %\
(hexstr(self.rp_id_hash), self.flags, self.counter)
if self.credential_data:
r += ', credential_data: %s' % self.credential_data
if self.extensions:
r += ', extensions: %s' % self.extensions
return r + ')'
def __str__(self):
return self.__repr__()
class AttestationObject(bytes):
@unique
class KEY(IntEnum):
FMT = 1
AUTH_DATA = 2
ATT_STMT = 3
def __init__(self, data):
data = dict((AttestationObject.KEY(k), v) for (k, v) in
_parse_cbor(data).items())
self.fmt = data[AttestationObject.KEY.FMT]
self.auth_data = AuthenticatorData(
data[AttestationObject.KEY.AUTH_DATA])
data[AttestationObject.KEY.AUTH_DATA] = self.auth_data
self.att_statement = data[AttestationObject.KEY.ATT_STMT]
self.data = data
def __repr__(self):
return 'AttestationObject(fmt: %r, auth_data: %r, att_statement: %r)' %\
(self.fmt, self.auth_data, self.att_statement)
def __str__(self):
return self.__repr__()
@classmethod
def create(cls, fmt, auth_data, att_stmt):
return cls(cbor.dumps(args(fmt, auth_data, att_stmt)))
class AssertionResponse(bytes):
@unique
class KEY(IntEnum):
CREDENTIAL = 1
AUTH_DATA = 2
SIGNATURE = 3
USER = 4
N_CREDS = 5
def __init__(self, data):
data = dict((AssertionResponse.KEY(k), v) for (k, v) in
_parse_cbor(data).items())
self.credential = data[AssertionResponse.KEY.CREDENTIAL]
self.auth_data = AuthenticatorData(
data[AssertionResponse.KEY.AUTH_DATA])
self.signature = data[AssertionResponse.KEY.SIGNATURE]
self.user = data.get(AssertionResponse.KEY.USER)
self.number_of_credentials = data.get(AssertionResponse.KEY.N_CREDS)
self.data = data
def __repr__(self):
r = 'AssertionResponse(credential: %r, auth_data: %r, signature: %r' %\
(self.credential, self.auth_data, hexstr(self.signature))
if self.user:
r += ', user: %s' % self.user
if self.number_of_credentials is not None:
r += ', number_of_credentials: %d' % self.number_of_credentials
return r + ')'
def __str__(self):
return self.__repr__()
@classmethod
def create(cls, credential, auth_data, signature, user=None, n_creds=None):
return cls(cbor.dumps(args(credential, auth_data, signature, user,
n_creds)))
class CTAP2(object):
@unique
class CMD(IntEnum):
MAKE_CREDENTIAL = 0x01
GET_ASSERTION = 0x02
GET_INFO = 0x04
CLIENT_PIN = 0x06
RESET = 0x07
GET_NEXT_ASSERTION = 0x08
def __init__(self, device):
if not device.capabilities & CAPABILITY.CBOR:
raise ValueError('Device does not support CTAP2.')
self.device = device
def send_cbor(self, cmd, data=None, timeout=None, parse=_parse_cbor):
"""
Sends a CBOR message to the device, and waits for a response.
The optional parameter 'timeout' can either be a numeric time in seconds
or a threading.Event object used to cancel the request.
"""
request = struct.pack('>B', cmd)
if data is not None:
request += cbor.dumps(data)
with Timeout(timeout) as event:
response = self.device.call(CTAPHID.CBOR, request, event)
status = six.indexbytes(response, 0)
if status != 0x00:
raise CtapError(status)
if len(response) == 1:
return None
return parse(response[1:])
def make_credential(self, client_data_hash, rp, user, key_params,
exclude_list=None, extensions=None, options=None,
pin_auth=None, pin_protocol=None, timeout=None):
return self.send_cbor(CTAP2.CMD.MAKE_CREDENTIAL, args(
client_data_hash,
rp,
user,
key_params,
exclude_list,
extensions,
options,
pin_auth,
pin_protocol
), timeout, AttestationObject)
def get_assertion(self, rp_id, client_data_hash, allow_list=None,
extensions=None, options=None, pin_auth=None,
pin_protocol=None, timeout=None):
return self.send_cbor(CTAP2.CMD.GET_ASSERTION, args(
rp_id,
client_data_hash,
allow_list,
extensions,
options,
pin_auth,
pin_protocol
), timeout, AssertionResponse)
def get_info(self):
return self.send_cbor(CTAP2.CMD.GET_INFO, parse=Info)
def client_pin(self, pin_protocol, sub_cmd, key_agreement=None,
pin_auth=None, new_pin_enc=None, pin_hash_enc=None):
return self.send_cbor(CTAP2.CMD.CLIENT_PIN, args(
pin_protocol,
sub_cmd,
key_agreement,
pin_auth,
new_pin_enc,
pin_hash_enc
))
def reset(self, timeout=None):
self.send_cbor(CTAP2.CMD.RESET, timeout)
def get_next_assertion(self):
return self.send_cbor(CTAP2.CMD.GET_NEXT_ASSERTION,
parse=AssertionResponse)
def _pad_pin(pin):
if not isinstance(pin, six.string_types):
raise ValueError('PIN of wrong type, expecting %s' % six.string_types)
if len(pin) < 4:
raise ValueError('PIN must be >= 4 characters')
pin = pin.encode('utf8').ljust(64, b'\0')
pin += b'\0' * (-(len(pin) - 16) % 16)
if len(pin) > 255:
raise ValueError('PIN must be <= 255 bytes')
return pin
class PinProtocolV1(object):
VERSION = 1
IV = b'\x00' * 16
@unique
class CMD(IntEnum):
GET_RETRIES = 0x01
GET_KEY_AGREEMENT = 0x02
SET_PIN = 0x03
CHANGE_PIN = 0x04
GET_PIN_TOKEN = 0x05
@unique
class RESULT(IntEnum):
KEY_AGREEMENT = 0x01
PIN_TOKEN = 0x02
RETRIES = 0x03
def __init__(self, ctap):
self.ctap = ctap
def _init_shared_secret(self):
be = default_backend()
sk = ec.generate_private_key(ec.SECP256R1(), be)
pk = sk.public_key().public_numbers()
key_agreement = {
1: 2,
3: -15,
-1: 1,
-2: a2b_hex('%064x' % pk.x),
-3: a2b_hex('%064x' % pk.y)
}
resp = self.ctap.client_pin(PinProtocolV1.VERSION,
PinProtocolV1.CMD.GET_KEY_AGREEMENT)
pk = resp[PinProtocolV1.RESULT.KEY_AGREEMENT]
x = int(b2a_hex(pk[-2]), 16)
y = int(b2a_hex(pk[-3]), 16)
pk = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key(be)
shared_secret = sha256(sk.exchange(ec.ECDH(), pk))
return key_agreement, shared_secret
def get_pin_token(self, pin):
key_agreement, shared_secret = self._init_shared_secret()
be = default_backend()
cipher = Cipher(algorithms.AES(shared_secret),
modes.CBC(PinProtocolV1.IV), be)
pin_hash = sha256(pin.encode())[:16]
enc = cipher.encryptor()
pin_hash_enc = enc.update(pin_hash) + enc.finalize()
resp = self.ctap.client_pin(PinProtocolV1.VERSION,
PinProtocolV1.CMD.GET_PIN_TOKEN,
key_agreement=key_agreement,
pin_hash_enc=pin_hash_enc)
dec = cipher.decryptor()
return dec.update(resp[PinProtocolV1.RESULT.PIN_TOKEN]) + dec.finalize()
def get_pin_retries(self):
resp = self.ctap.client_pin(PinProtocolV1.VERSION,
PinProtocolV1.CMD.GET_RETRIES)
return resp[PinProtocolV1.RESULT.RETRIES]
def set_pin(self, pin):
pin = _pad_pin(pin)
key_agreement, shared_secret = self._init_shared_secret()
be = default_backend()
cipher = Cipher(algorithms.AES(shared_secret),
modes.CBC(PinProtocolV1.IV), be)
enc = cipher.encryptor()
pin_enc = enc.update(pin) + enc.finalize()
pin_auth = hmac_sha256(shared_secret, pin_enc)[:16]
self.ctap.client_pin(PinProtocolV1.VERSION, PinProtocolV1.CMD.SET_PIN,
key_agreement=key_agreement,
new_pin_enc=pin_enc,
pin_auth=pin_auth)
def change_pin(self, old_pin, new_pin):
new_pin = _pad_pin(new_pin)
key_agreement, shared_secret = self._init_shared_secret()
be = default_backend()
cipher = Cipher(algorithms.AES(shared_secret),
modes.CBC(PinProtocolV1.IV), be)
pin_hash = sha256(old_pin.encode())[:16]
enc = cipher.encryptor()
pin_hash_enc = enc.update(pin_hash) + enc.finalize()
enc = cipher.encryptor()
new_pin_enc = enc.update(new_pin) + enc.finalize()
pin_auth = hmac_sha256(shared_secret, new_pin_enc + pin_hash_enc)[:16]
self.ctap.client_pin(PinProtocolV1.VERSION,
PinProtocolV1.CMD.CHANGE_PIN,
key_agreement=key_agreement,
pin_hash_enc=pin_hash_enc,
new_pin_enc=new_pin_enc,
pin_auth=pin_auth)

178
fido_host/hid.py Normal file
View File

@ -0,0 +1,178 @@
from __future__ import absolute_import
from .ctap import CtapDevice
from .pyu2f import hidtransport
from enum import IntEnum, unique
from threading import Event
import struct
@unique
class CTAPHID(IntEnum):
PING = 0x01
MSG = 0x03
LOCK = 0x04
INIT = 0x06
WINK = 0x08
CBOR = 0x10
CANCEL = 0x11
ERROR = 0x3f
KEEPALIVE = 0x3b
VENDOR_FIRST = 0x40
@unique
class CAPABILITY(IntEnum):
WINK = 0x01
LOCK = 0x02 # Not used
CBOR = 0x04
NMSG = 0x08
def supported(self, flags):
return bool(flags & self)
TYPE_INIT = 0x80
class CtapError(Exception):
@unique
class ERR(IntEnum):
SUCCESS = 0x00
INVALID_COMMAND = 0x01
INVALID_PARAMETER = 0x02
INVALID_LENGTH = 0x03
INVALID_SEQ = 0x04
TIMEOUT = 0x05
CHANNEL_BUSY = 0x06
LOCK_REQUIRED = 0x0A
INVALID_CHANNEL = 0x0B
CBOR_UNEXPECTED_TYPE = 0x11
INVALID_CBOR = 0x12
MISSING_PARAMETER = 0x14
LIMIT_EXCEEDED = 0x15
UNSUPPORTED_EXTENSION = 0x16
CREDENTIAL_EXCLUDED = 0x19
PROCESSING = 0x21
INVALID_CREDENTIAL = 0x22
USER_ACTION_PENDING = 0x23
OPERATION_PENDING = 0x24
NO_OPERATIONS = 0x25
UNSUPPORTED_ALGORITHM = 0x26
OPERATION_DENIED = 0x27
KEY_STORE_FULL = 0x28
NOT_BUSY = 0x29
NO_OPERATION_PENDING = 0x2A
UNSUPPORTED_OPTION = 0x2B
INVALID_OPTION = 0x2C
KEEPALIVE_CANCEL = 0x2D
NO_CREDENTIALS = 0x2E
USER_ACTION_TIMEOUT = 0x2F
NOT_ALLOWED = 0x30
PIN_INVALID = 0x31
PIN_BLOCKED = 0x32
PIN_AUTH_INVALID = 0x33
PIN_AUTH_BLOCKED = 0x34
PIN_NOT_SET = 0x35
PIN_REQUIRED = 0x36
PIN_POLICY_VIOLATION = 0x37
PIN_TOKEN_EXPIRED = 0x38
REQUEST_TOO_LARGE = 0x39
ACTION_TIMEOUT = 0x3A
UP_REQUIRED = 0x3B
OTHER = 0x7F
SPEC_LAST = 0xDF
EXTENSION_FIRST = 0xE0
EXTENSION_LAST = 0xEF
VENDOR_FIRST = 0xF0
VENDOR_LAST = 0xFF
def __str__(self):
return '0x%02X - %s' % (self.value, self.name)
def __init__(self, code):
try:
code = CtapError.ERR(code)
message = 'CTAP error: %s' % code
except ValueError:
message = 'CTAP error: 0x%02X' % code
self.code = code
super(CtapError, self).__init__(message)
class _SingleEvent(object):
def __init__(self):
self.flag = False
def is_set(self):
if not self.flag:
self.flag = True
return False
return True
class CtapHidDevice(CtapDevice):
"""
CtapDevice implementation using the HID transport.
"""
def __init__(self, descriptor, dev):
self.descriptor = descriptor
self._dev = dev
def __repr__(self):
return 'CtapHidDevice(%s)' % self.descriptor['path']
@property
def version(self):
return self._dev.u2fhid_version
@property
def device_version(self):
return self._dev.device_version
@property
def capabilities(self):
return self._dev.capabilities
def call(self, cmd, data=b'', event=None):
event = event or Event()
self._dev.InternalSend(TYPE_INIT | cmd, bytearray(data))
while not event.is_set():
status, resp = self._dev.InternalRecv()
status ^= TYPE_INIT
if status == cmd:
return bytes(resp)
elif status == CTAPHID.ERROR:
raise CtapError(resp[0])
elif status == CTAPHID.KEEPALIVE:
continue
else:
raise CtapError(CtapError.ERR.INVALID_COMMAND)
self.call(CTAPHID.CANCEL, b'', _SingleEvent())
raise CtapError(CtapError.ERR.KEEPALIVE_CANCEL)
def wink(self):
self.call(CTAPHID.WINK)
def ping(self, msg=b'Hello U2F'):
return self.call(CTAPHID.PING, msg)
def lock(self, lock_time=10):
self.call(CTAPHID.LOCK, struct.pack('>B', lock_time))
@classmethod
def list_devices(cls, selector=hidtransport.HidUsageSelector):
for d in hidtransport.hid.Enumerate():
if selector(d):
try:
dev = hidtransport.hid.Open(d['path'])
yield cls(d, hidtransport.UsbHidTransport(dev))
except OSError:
# Insufficient permissions to access device
pass

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implements interface for talking to hid devices.
This module implenets an interface for talking to low level hid devices
using various methods on different platforms.
"""
from __future__ import absolute_import
import sys
class hid(object):
@staticmethod
def Enumerate():
return InternalPlatformSwitch('Enumerate')
@staticmethod
def Open(path):
return InternalPlatformSwitch('__init__', path)
def InternalPlatformSwitch(funcname, *args, **kwargs):
"""Determine, on a platform-specific basis, which module to use."""
# pylint: disable=g-import-not-at-top
clz = None
if sys.platform.startswith('linux'):
from . import linux
clz = linux.LinuxHidDevice
elif sys.platform.startswith('win32'):
from . import windows
clz = windows.WindowsHidDevice
elif sys.platform.startswith('darwin'):
from . import macos
clz = macos.MacOsHidDevice
if not clz:
raise Exception('Unsupported platform: ' + sys.platform)
if funcname == '__init__':
return clz(*args, **kwargs)
return getattr(clz, funcname)(*args, **kwargs)

101
fido_host/pyu2f/base.py Normal file
View File

@ -0,0 +1,101 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implement base classes for hid package.
This module provides the base classes implemented by the
platform-specific modules. It includes a base class for
all implementations built on interacting with file-like objects.
"""
class HidDevice(object):
"""Base class for all HID devices in this package."""
@staticmethod
def Enumerate():
"""Enumerates all the hid devices.
This function enumerates all the hid device and provides metadata
for helping the client select one.
Returns:
A list of dictionaries of metadata. Each implementation is required
to provide at least: vendor_id, product_id, product_string, usage,
usage_page, and path.
"""
pass
def __init__(self, path):
"""Initialize the device at path."""
pass
def GetInReportDataLength(self):
"""Returns the max input report data length in bytes.
Returns the max input report data length in bytes. This excludes the
report id.
"""
pass
def GetOutReportDataLength(self):
"""Returns the max output report data length in bytes.
Returns the max output report data length in bytes. This excludes the
report id.
"""
pass
def Write(self, packet):
"""Writes packet to device.
Writes the packet to the device.
Args:
packet: An array of integers to write to the device. Excludes the report
ID. Must be equal to GetOutReportLength().
"""
pass
def Read(self):
"""Reads packet from device.
Reads the packet from the device.
Returns:
An array of integers read from the device. Excludes the report ID.
The length is equal to GetInReportDataLength().
"""
pass
class DeviceDescriptor(object):
"""Descriptor for basic attributes of the device."""
usage_page = None
usage = None
vendor_id = None
product_id = None
product_string = None
path = None
internal_max_in_report_len = 0
internal_max_out_report_len = 0
def ToPublicDict(self):
out = {}
for k, v in self.__dict__.items():
if not k.startswith('internal_'):
out[k] = v
return out

99
fido_host/pyu2f/errors.py Normal file
View File

@ -0,0 +1,99 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Exceptions that can be raised by the pyu2f library.
All exceptions that can be raised by the pyu2f library. Most of these
are internal coditions, but U2FError and NoDeviceFoundError are public
errors that clients should expect to handle.
"""
class NoDeviceFoundError(Exception):
pass
class U2FError(Exception):
OK = 0
OTHER_ERROR = 1
BAD_REQUEST = 2
CONFIGURATION_UNSUPPORTED = 3
DEVICE_INELIGIBLE = 4
TIMEOUT = 5
def __init__(self, code, cause=None):
self.code = code
if cause:
self.cause = cause
super(U2FError, self).__init__('U2F Error code: %d (cause: %s)' %
(code, str(cause)))
class HidError(Exception):
"""Errors in the hid usb transport protocol."""
pass
class InvalidPacketError(HidError):
pass
class HardwareError(Exception):
"""Errors in the security key hardware that are transport independent."""
pass
class InvalidRequestError(HardwareError):
pass
class ApduError(HardwareError):
def __init__(self, sw1, sw2):
self.sw1 = sw1
self.sw2 = sw2
super(ApduError, self).__init__('Device returned status: %d %d' %
(sw1, sw2))
class TUPRequiredError(HardwareError):
pass
class InvalidKeyHandleError(HardwareError):
pass
class UnsupportedVersionException(Exception):
pass
class InvalidCommandError(Exception):
pass
class InvalidResponseError(Exception):
pass
class InvalidModelError(Exception):
pass
class OsHidError(Exception):
pass
class PluginError(Exception):
pass

View File

@ -0,0 +1,338 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HID Transport for U2F.
This module imports the U2F HID Transport protocol as well as methods
for discovering devices implementing this protocol.
"""
from __future__ import absolute_import
import logging
import os
import struct
import time
import six
from . import errors, hid
def HidUsageSelector(device):
if device['usage_page'] == 0xf1d0 and device['usage'] == 0x01:
return True
return False
def DiscoverLocalHIDU2FDevices(selector=HidUsageSelector):
for d in hid.Enumerate():
if selector(d):
try:
dev = hid.Open(d['path'])
yield UsbHidTransport(dev)
except OSError:
# Insufficient permissions to access device
pass
class UsbHidTransport(object):
"""Implements the U2FHID transport protocol.
This class implements the U2FHID transport protocol from the
FIDO U2F specs. This protocol manages fragmenting longer messages
over a short hid frame (usually 64 bytes). It exposes an APDU
channel through the MSG command as well as a series of other commands
for configuring and interacting with the device.
"""
U2FHID_PING = 0x81
U2FHID_MSG = 0x83
U2FHID_WINK = 0x88
U2FHID_PROMPT = 0x87
U2FHID_INIT = 0x86
U2FHID_LOCK = 0x84
U2FHID_ERROR = 0xbf
U2FHID_SYNC = 0xbc
CTAPHID_KEEPALIVE = 0xbb
U2FHID_BROADCAST_CID = bytearray([0xff, 0xff, 0xff, 0xff])
ERR_CHANNEL_BUSY = bytearray([0x06])
class InitPacket(object):
"""Represent an initial U2FHID packet.
Represent an initial U2FHID packet. This packet contains
metadata necessary to interpret the entire packet stream associated
with a particular exchange (read or write).
Attributes:
packet_size: The size of the hid report (packet) used. Usually 64.
cid: The channel id for the connection to the device.
size: The size of the entire message to be sent (including
all continuation packets)
payload: The portion of the message to put into the init packet.
This must be smaller than packet_size - 7 (the overhead for
an init packet).
"""
def __init__(self, packet_size, cid, cmd, size, payload):
self.packet_size = packet_size
if len(cid) != 4 or cmd > 255 or size >= 2**16:
raise errors.InvalidPacketError()
if len(payload) > self.packet_size - 7:
raise errors.InvalidPacketError()
self.cid = cid # byte array
self.cmd = cmd # number
self.size = size # number (full size of message)
self.payload = payload # byte array (for first packet)
def ToWireFormat(self):
"""Serializes the packet."""
ret = bytearray(64)
ret[0:4] = self.cid
ret[4] = self.cmd
struct.pack_into('>H', ret, 5, self.size)
ret[7:7 + len(self.payload)] = self.payload
return list(six.iterbytes(bytes(ret)))
@staticmethod
def FromWireFormat(packet_size, data):
"""Derializes the packet.
Deserializes the packet from wire format.
Args:
packet_size: The size of all packets (usually 64)
data: List of ints or bytearray containing the data from the wire.
Returns:
InitPacket object for specified data
Raises:
InvalidPacketError: if the data isn't a valid InitPacket
"""
ba = bytearray(data)
if len(ba) != packet_size:
raise errors.InvalidPacketError()
cid = ba[0:4]
cmd = ba[4]
size = struct.unpack('>H', bytes(ba[5:7]))[0]
payload = ba[7:7 + size] # might truncate at packet_size
return UsbHidTransport.InitPacket(packet_size, cid, cmd, size, payload)
class ContPacket(object):
"""Represents a continutation U2FHID packet.
Represents a continutation U2FHID packet. These packets follow
the intial packet and contains the remaining data in a particular
message.
Attributes:
packet_size: The size of the hid report (packet) used. Usually 64.
cid: The channel id for the connection to the device.
seq: The sequence number for this continuation packet. The first
continuation packet is 0 and it increases from there.
payload: The payload to put into this continuation packet. This
must be less than packet_size - 5 (the overhead of the
continuation packet is 5).
"""
def __init__(self, packet_size, cid, seq, payload):
self.packet_size = packet_size
self.cid = cid
self.seq = seq
self.payload = payload
if len(payload) > self.packet_size - 5:
raise errors.InvalidPacketError()
if seq > 127:
raise errors.InvalidPacketError()
def ToWireFormat(self):
"""Serializes the packet."""
ret = bytearray(self.packet_size)
ret[0:4] = self.cid
ret[4] = self.seq
ret[5:5 + len(self.payload)] = self.payload
return [int(x) for x in ret]
@staticmethod
def FromWireFormat(packet_size, data):
"""Derializes the packet.
Deserializes the packet from wire format.
Args:
packet_size: The size of all packets (usually 64)
data: List of ints or bytearray containing the data from the wire.
Returns:
InitPacket object for specified data
Raises:
InvalidPacketError: if the data isn't a valid ContPacket
"""
ba = bytearray(data)
if len(ba) != packet_size:
raise errors.InvalidPacketError()
cid = ba[0:4]
seq = ba[4]
# We don't know the size limit a priori here without seeing the init
# packet, so truncation needs to be done in the higher level protocol
# handling code, unlike the degenerate case of a 1 packet message in an
# init packet, where the size is known.
payload = ba[5:]
return UsbHidTransport.ContPacket(packet_size, cid, seq, payload)
def __init__(self, hid_device, read_timeout_secs=3.0):
self.hid_device = hid_device
in_size = hid_device.GetInReportDataLength()
out_size = hid_device.GetOutReportDataLength()
if in_size != out_size:
raise errors.HardwareError(
'unsupported device with different in/out packet sizes.')
if in_size == 0:
raise errors.HardwareError('unable to determine packet size')
self.packet_size = in_size
self.read_timeout_secs = read_timeout_secs
self.logger = logging.getLogger('pyu2f.hidtransport')
self.InternalInit()
def SendMsgBytes(self, msg):
r = self.InternalExchange(UsbHidTransport.U2FHID_MSG, msg)
return r
def SendBlink(self, length):
return self.InternalExchange(UsbHidTransport.U2FHID_PROMPT,
bytearray([length]))
def SendWink(self):
return self.InternalExchange(UsbHidTransport.U2FHID_WINK, bytearray([]))
def SendPing(self, data):
return self.InternalExchange(UsbHidTransport.U2FHID_PING, data)
def InternalInit(self):
"""Initializes the device and obtains channel id."""
self.cid = UsbHidTransport.U2FHID_BROADCAST_CID
nonce = bytearray(os.urandom(8))
r = self.InternalExchange(UsbHidTransport.U2FHID_INIT, nonce)
if len(r) < 17:
raise errors.HidError('unexpected init reply len')
if r[0:8] != nonce:
raise errors.HidError('nonce mismatch')
self.cid = bytearray(r[8:12])
self.u2fhid_version = r[12]
self.device_version = tuple(r[13:16])
self.capabilities = r[16]
def InternalExchange(self, cmd, payload_in):
"""Sends and receives a message from the device."""
# make a copy because we destroy it below
self.logger.debug('payload: ' + str(list(payload_in)))
payload = bytearray()
payload[:] = payload_in
for _ in range(2):
self.InternalSend(cmd, payload)
ret_cmd, ret_payload = self.InternalRecv()
if ret_cmd == UsbHidTransport.U2FHID_ERROR:
if ret_payload == UsbHidTransport.ERR_CHANNEL_BUSY:
time.sleep(0.5)
continue
raise errors.HidError('Device error: %d' % int(ret_payload[0]))
elif ret_cmd != cmd:
raise errors.HidError('Command mismatch!')
return ret_payload
raise errors.HidError('Device Busy. Please retry')
def InternalSend(self, cmd, payload):
"""Sends a message to the device, including fragmenting it."""
length_to_send = len(payload)
max_payload = self.packet_size - 7
first_frame = payload[0:max_payload]
first_packet = UsbHidTransport.InitPacket(self.packet_size, self.cid, cmd,
len(payload), first_frame)
del payload[0:max_payload]
length_to_send -= len(first_frame)
self.InternalSendPacket(first_packet)
seq = 0
while length_to_send > 0:
max_payload = self.packet_size - 5
next_frame = payload[0:max_payload]
del payload[0:max_payload]
length_to_send -= len(next_frame)
next_packet = UsbHidTransport.ContPacket(self.packet_size, self.cid, seq,
next_frame)
self.InternalSendPacket(next_packet)
seq += 1
def InternalSendPacket(self, packet):
wire = packet.ToWireFormat()
self.logger.debug('sending packet: ' + str(wire))
self.hid_device.Write(wire)
def InternalReadFrame(self):
# TODO(gdasher): Figure out timeouts. Today, this implementation
# blocks forever at the HID level waiting for a response to a report.
# This may not be reasonable behavior (though in practice in seems to be
# OK on the set of devices and machines tested so far).
frame = self.hid_device.Read()
self.logger.debug('recv: ' + str(frame))
return frame
def InternalRecv(self):
"""Receives a message from the device, including defragmenting it."""
first_read = self.InternalReadFrame()
first_packet = UsbHidTransport.InitPacket.FromWireFormat(self.packet_size,
first_read)
data = first_packet.payload
to_read = first_packet.size - len(first_packet.payload)
seq = 0
while to_read > 0:
next_read = self.InternalReadFrame()
next_packet = UsbHidTransport.ContPacket.FromWireFormat(self.packet_size,
next_read)
if self.cid != next_packet.cid:
# Skip over packets that are for communication with other clients.
# HID is broadcast, so we see potentially all communication from the
# device. For well-behaved devices, these should be BUSY messages
# sent to other clients of the device because at this point we're
# in mid-message transit.
continue
if seq != next_packet.seq:
raise errors.HardwareError('Packets received out of order')
# This packet for us at this point, so debit it against our
# balance of bytes to read.
to_read -= len(next_packet.payload)
data.extend(next_packet.payload)
seq += 1
# truncate incomplete frames
data = data[0:first_packet.size]
return (first_packet.cmd, data)

228
fido_host/pyu2f/linux.py Normal file
View File

@ -0,0 +1,228 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implements raw HID interface on Linux using SysFS and device files."""
from __future__ import absolute_import
import os
import struct
import six
from . import base, errors
REPORT_DESCRIPTOR_KEY_MASK = 0xfc
LONG_ITEM_ENCODING = 0xfe
OUTPUT_ITEM = 0x90
INPUT_ITEM = 0x80
COLLECTION_ITEM = 0xa0
REPORT_COUNT = 0x94
REPORT_SIZE = 0x74
USAGE_PAGE = 0x04
USAGE = 0x08
def GetValueLength(rd, pos):
"""Get value length for a key in rd.
For a key at position pos in the Report Descriptor rd, return the length
of the associated value. This supports both short and long format
values.
Args:
rd: Report Descriptor
pos: The position of the key in rd.
Returns:
(key_size, data_len) where key_size is the number of bytes occupied by
the key and data_len is the length of the value associated by the key.
"""
key = six.indexbytes(rd, pos)
if key == LONG_ITEM_ENCODING:
# If the key is tagged as a long item (0xfe), then the format is
# [key (1 byte)] [data len (1 byte)] [item tag (1 byte)] [data (n # bytes)].
# Thus, the entire key record is 3 bytes long.
if pos + 1 < len(rd):
return (3, rd[pos + 1])
else:
raise errors.HidError('Malformed report descriptor')
else:
# If the key is tagged as a short item, then the item tag and data len are
# packed into one byte. The format is thus:
# [tag (high 4 bits)] [type (2 bits)] [size code (2 bits)] [data (n bytes)].
# The size code specifies 1,2, or 4 bytes (0x03 means 4 bytes).
code = key & 0x03
if code <= 0x02:
return (1, code)
elif code == 0x03:
return (1, 4)
raise errors.HidError('Cannot happen')
def ReadLsbBytes(rd, offset, value_size):
"""Reads value_size bytes from rd at offset, least signifcant byte first."""
encoding = None
if value_size == 1:
encoding = '<B'
elif value_size == 2:
encoding = '<H'
elif value_size == 4:
encoding = '<L'
else:
raise errors.HidError('Invalid value size specified')
ret, = struct.unpack(encoding, rd[offset:offset + value_size])
return ret
class NoReportCountFound(Exception):
pass
def ParseReportDescriptor(rd, desc):
"""Parse the binary report descriptor.
Parse the binary report descriptor into a DeviceDescriptor object.
Args:
rd: The binary report descriptor
desc: The DeviceDescriptor object to update with the results
from parsing the descriptor.
Returns:
None
"""
pos = 0
report_count = None
report_size = None
usage_page = None
usage = None
while pos < len(rd):
key = six.indexbytes(rd, pos)
# First step, determine the value encoding (either long or short).
key_size, value_length = GetValueLength(rd, pos)
if key & REPORT_DESCRIPTOR_KEY_MASK == INPUT_ITEM:
if report_count and report_size:
byte_length = (report_count * report_size) // 8
desc.internal_max_in_report_len = max(
desc.internal_max_in_report_len, byte_length)
report_count = None
report_size = None
elif key & REPORT_DESCRIPTOR_KEY_MASK == OUTPUT_ITEM:
if report_count and report_size:
byte_length = (report_count * report_size) // 8
desc.internal_max_out_report_len = max(
desc.internal_max_out_report_len, byte_length)
report_count = None
report_size = None
elif key & REPORT_DESCRIPTOR_KEY_MASK == COLLECTION_ITEM:
if usage_page:
desc.usage_page = usage_page
if usage:
desc.usage = usage
elif key & REPORT_DESCRIPTOR_KEY_MASK == REPORT_COUNT:
if len(rd) >= pos + 1 + value_length:
report_count = ReadLsbBytes(rd, pos + 1, value_length)
elif key & REPORT_DESCRIPTOR_KEY_MASK == REPORT_SIZE:
if len(rd) >= pos + 1 + value_length:
report_size = ReadLsbBytes(rd, pos + 1, value_length)
elif key & REPORT_DESCRIPTOR_KEY_MASK == USAGE_PAGE:
if len(rd) >= pos + 1 + value_length:
usage_page = ReadLsbBytes(rd, pos + 1, value_length)
elif key & REPORT_DESCRIPTOR_KEY_MASK == USAGE:
if len(rd) >= pos + 1 + value_length:
usage = ReadLsbBytes(rd, pos + 1, value_length)
pos += value_length + key_size
return desc
def ParseUevent(uevent, desc):
lines = uevent.split(b'\n')
for line in lines:
line = line.strip()
if not line:
continue
k, v = line.split(b'=')
if k == b'HID_NAME':
desc.product_string = v.decode('utf8')
elif k == b'HID_ID':
_, vid, pid = v.split(b':')
desc.vendor_id = int(vid, 16)
desc.product_id = int(pid, 16)
class LinuxHidDevice(base.HidDevice):
"""Implementation of HID device for linux.
Implementation of HID device interface for linux that uses block
devices to interact with the device and sysfs to enumerate/discover
device metadata.
"""
@staticmethod
def Enumerate():
for hidraw in os.listdir('/sys/class/hidraw'):
rd_path = (
os.path.join(
'/sys/class/hidraw', hidraw,
'device/report_descriptor'))
uevent_path = os.path.join('/sys/class/hidraw', hidraw, 'device/uevent')
rd_file = open(rd_path, 'rb')
uevent_file = open(uevent_path, 'rb')
desc = base.DeviceDescriptor()
desc.path = os.path.join('/dev/', hidraw)
ParseReportDescriptor(rd_file.read(), desc)
ParseUevent(uevent_file.read(), desc)
rd_file.close()
uevent_file.close()
yield desc.ToPublicDict()
def __init__(self, path):
base.HidDevice.__init__(self, path)
self.dev = os.open(path, os.O_RDWR)
self.desc = base.DeviceDescriptor()
self.desc.path = path
rd_file = open(os.path.join('/sys/class/hidraw',
os.path.basename(path),
'device/report_descriptor'), 'rb')
ParseReportDescriptor(rd_file.read(), self.desc)
rd_file.close()
def GetInReportDataLength(self):
"""See base class."""
return self.desc.internal_max_in_report_len
def GetOutReportDataLength(self):
"""See base class."""
return self.desc.internal_max_out_report_len
def Write(self, packet):
"""See base class."""
out = bytes(bytearray([0] + packet)) # Prepend the zero-byte (report ID)
os.write(self.dev, out)
def Read(self):
"""See base class."""
raw_in = os.read(self.dev, self.GetInReportDataLength())
decoded_in = list(six.iterbytes(raw_in))
return decoded_in

448
fido_host/pyu2f/macos.py Normal file
View File

@ -0,0 +1,448 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implements HID device interface on MacOS using IOKit and HIDManager."""
from __future__ import absolute_import
import ctypes
import ctypes.util
import logging
from six.moves.queue import Queue
import sys
import threading
from . import base, errors
logger = logging.getLogger('pyu2f.macos')
# Constants
DEVICE_PATH_BUFFER_SIZE = 512
DEVICE_STRING_PROPERTY_BUFFER_SIZE = 512
HID_DEVICE_PROPERTY_VENDOR_ID = b'VendorID'
HID_DEVICE_PROPERTY_PRODUCT_ID = b'ProductID'
HID_DEVICE_PROPERTY_PRODUCT = b'Product'
HID_DEVICE_PROPERTY_PRIMARY_USAGE = b'PrimaryUsage'
HID_DEVICE_PROPERTY_PRIMARY_USAGE_PAGE = b'PrimaryUsagePage'
HID_DEVICE_PROPERTY_MAX_INPUT_REPORT_SIZE = b'MaxInputReportSize'
HID_DEVICE_PROPERTY_MAX_OUTPUT_REPORT_SIZE = b'MaxOutputReportSize'
HID_DEVICE_PROPERTY_REPORT_ID = b'ReportID'
# Declare C types
class _CFType(ctypes.Structure):
pass
class _CFString(_CFType):
pass
class _CFSet(_CFType):
pass
class _IOHIDManager(_CFType):
pass
class _IOHIDDevice(_CFType):
pass
class _CFRunLoop(_CFType):
pass
class _CFAllocator(_CFType):
pass
# Linter isn't consistent about valid class names. Disabling some of the errors
CF_SET_REF = ctypes.POINTER(_CFSet)
CF_STRING_REF = ctypes.POINTER(_CFString)
CF_TYPE_REF = ctypes.POINTER(_CFType)
CF_RUN_LOOP_REF = ctypes.POINTER(_CFRunLoop)
CF_RUN_LOOP_RUN_RESULT = ctypes.c_int32
CF_ALLOCATOR_REF = ctypes.POINTER(_CFAllocator)
CF_TYPE_ID = ctypes.c_ulong # pylint: disable=invalid-name
CF_INDEX = ctypes.c_long # pylint: disable=invalid-name
CF_TIME_INTERVAL = ctypes.c_double # pylint: disable=invalid-name
IO_RETURN = ctypes.c_uint
IO_HID_REPORT_TYPE = ctypes.c_uint
IO_OBJECT_T = ctypes.c_uint
MACH_PORT_T = ctypes.c_uint
IO_STRING_T = ctypes.c_char_p # pylint: disable=invalid-name
IO_SERVICE_T = IO_OBJECT_T
IO_REGISTRY_ENTRY_T = IO_OBJECT_T
IO_HID_MANAGER_REF = ctypes.POINTER(_IOHIDManager)
IO_HID_DEVICE_REF = ctypes.POINTER(_IOHIDDevice)
IO_HID_REPORT_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.py_object, IO_RETURN,
ctypes.c_void_p, IO_HID_REPORT_TYPE,
ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint8),
CF_INDEX)
# Define C constants
K_CF_NUMBER_SINT32_TYPE = 3
K_CF_STRING_ENCODING_UTF8 = 0x08000100
K_CF_ALLOCATOR_DEFAULT = None
K_IO_SERVICE_PLANE = b'IOService'
K_IO_MASTER_PORT_DEFAULT = 0
K_IO_HID_REPORT_TYPE_OUTPUT = 1
K_IO_RETURN_SUCCESS = 0
K_CF_RUN_LOOP_RUN_STOPPED = 2
K_CF_RUN_LOOP_RUN_TIMED_OUT = 3
K_CF_RUN_LOOP_RUN_HANDLED_SOURCE = 4
# Load relevant libraries
iokit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('IOKit'))
cf = ctypes.cdll.LoadLibrary(ctypes.util.find_library('CoreFoundation'))
# Only use iokit and cf if we're on macos, this way we can still run tests
# on other platforms if we properly mock
if sys.platform.startswith('darwin'):
# Exported constants
K_CF_RUNLOOP_DEFAULT_MODE = CF_STRING_REF.in_dll(cf, 'kCFRunLoopDefaultMode')
# Declare C function prototypes
cf.CFSetGetValues.restype = None
cf.CFSetGetValues.argtypes = [CF_SET_REF, ctypes.POINTER(ctypes.c_void_p)]
cf.CFStringCreateWithCString.restype = CF_STRING_REF
cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p,
ctypes.c_uint32]
cf.CFStringGetCString.restype = ctypes.c_int
cf.CFStringGetCString.argtypes = [CF_STRING_REF, ctypes.c_char_p, CF_INDEX,
ctypes.c_uint32]
cf.CFStringGetLength.restype = CF_INDEX
cf.CFStringGetLength.argtypes = [CF_STRING_REF]
cf.CFGetTypeID.restype = CF_TYPE_ID
cf.CFGetTypeID.argtypes = [CF_TYPE_REF]
cf.CFNumberGetTypeID.restype = CF_TYPE_ID
cf.CFNumberGetValue.restype = ctypes.c_int
cf.CFRelease.restype = None
cf.CFRelease.argtypes = [CF_TYPE_REF]
cf.CFRunLoopGetCurrent.restype = CF_RUN_LOOP_REF
cf.CFRunLoopGetCurrent.argTypes = []
cf.CFRunLoopRunInMode.restype = CF_RUN_LOOP_RUN_RESULT
cf.CFRunLoopRunInMode.argtypes = [CF_STRING_REF, CF_TIME_INTERVAL,
ctypes.c_bool]
iokit.IOObjectRelease.argtypes = [IO_OBJECT_T]
iokit.IOHIDManagerCreate.restype = IO_HID_MANAGER_REF
iokit.IOHIDManagerCopyDevices.restype = CF_SET_REF
iokit.IOHIDManagerCopyDevices.argtypes = [IO_HID_MANAGER_REF]
iokit.IOHIDManagerSetDeviceMatching.restype = None
iokit.IOHIDManagerSetDeviceMatching.argtypes = [IO_HID_MANAGER_REF,
CF_TYPE_REF]
iokit.IOHIDDeviceGetProperty.restype = CF_TYPE_REF
iokit.IOHIDDeviceGetProperty.argtypes = [IO_HID_DEVICE_REF, CF_STRING_REF]
iokit.IOHIDDeviceRegisterInputReportCallback.restype = None
iokit.IOHIDDeviceRegisterInputReportCallback.argtypes = [
IO_HID_DEVICE_REF, ctypes.POINTER(ctypes.c_uint8), CF_INDEX,
IO_HID_REPORT_CALLBACK, ctypes.py_object]
iokit.IORegistryEntryFromPath.restype = IO_REGISTRY_ENTRY_T
iokit.IORegistryEntryFromPath.argtypes = [MACH_PORT_T, IO_STRING_T]
iokit.IOHIDDeviceCreate.restype = IO_HID_DEVICE_REF
iokit.IOHIDDeviceCreate.argtypes = [CF_ALLOCATOR_REF, IO_SERVICE_T]
iokit.IOHIDDeviceScheduleWithRunLoop.restype = None
iokit.IOHIDDeviceScheduleWithRunLoop.argtypes = [IO_HID_DEVICE_REF,
CF_RUN_LOOP_REF,
CF_STRING_REF]
iokit.IOHIDDeviceUnscheduleFromRunLoop.restype = None
iokit.IOHIDDeviceUnscheduleFromRunLoop.argtypes = [IO_HID_DEVICE_REF,
CF_RUN_LOOP_REF,
CF_STRING_REF]
iokit.IOHIDDeviceSetReport.restype = IO_RETURN
iokit.IOHIDDeviceSetReport.argtypes = [IO_HID_DEVICE_REF, IO_HID_REPORT_TYPE,
CF_INDEX,
ctypes.POINTER(ctypes.c_uint8),
CF_INDEX]
else:
logger.warn('Not running on MacOS')
def CFStr(s):
"""Builds a CFString from a python string.
Args:
s: source string
Returns:
CFStringRef representation of the source string
Resulting CFString must be CFReleased when no longer needed.
"""
return cf.CFStringCreateWithCString(None, s, 0)
def GetDeviceIntProperty(dev_ref, key):
"""Reads int property from the HID device."""
cf_key = CFStr(key)
type_ref = iokit.IOHIDDeviceGetProperty(dev_ref, cf_key)
cf.CFRelease(cf_key)
if not type_ref:
return None
if cf.CFGetTypeID(type_ref) != cf.CFNumberGetTypeID():
raise errors.OsHidError('Expected number type, got {}'.format(
cf.CFGetTypeID(type_ref)))
out = ctypes.c_int32()
ret = cf.CFNumberGetValue(type_ref, K_CF_NUMBER_SINT32_TYPE,
ctypes.byref(out))
if not ret:
return None
return out.value
def GetDeviceStringProperty(dev_ref, key):
"""Reads string property from the HID device."""
cf_key = CFStr(key)
type_ref = iokit.IOHIDDeviceGetProperty(dev_ref, cf_key)
cf.CFRelease(cf_key)
if not type_ref:
return None
if cf.CFGetTypeID(type_ref) != cf.CFStringGetTypeID():
raise errors.OsHidError('Expected string type, got {}'.format(
cf.CFGetTypeID(type_ref)))
type_ref = ctypes.cast(type_ref, CF_STRING_REF)
out = ctypes.create_string_buffer(DEVICE_STRING_PROPERTY_BUFFER_SIZE)
ret = cf.CFStringGetCString(type_ref, out, DEVICE_STRING_PROPERTY_BUFFER_SIZE,
K_CF_STRING_ENCODING_UTF8)
if not ret:
return None
return out.value.decode('utf8')
def GetDevicePath(device_handle):
"""Obtains the unique path for the device.
Args:
device_handle: reference to the device
Returns:
A unique path for the device, obtained from the IO Registry
"""
# Obtain device path from IO Registry
io_service_obj = iokit.IOHIDDeviceGetService(device_handle)
str_buffer = ctypes.create_string_buffer(DEVICE_PATH_BUFFER_SIZE)
iokit.IORegistryEntryGetPath(io_service_obj, K_IO_SERVICE_PLANE, str_buffer)
return str_buffer.value
def HidReadCallback(read_queue, result, sender, report_type, report_id, report,
report_length):
"""Handles incoming IN report from HID device."""
del result, sender, report_type, report_id # Unused by the callback function
incoming_bytes = [report[i] for i in range(report_length)]
read_queue.put(incoming_bytes)
# C wrapper around ReadCallback()
# Declared in this scope so it doesn't get GC-ed
REGISTERED_READ_CALLBACK = IO_HID_REPORT_CALLBACK(HidReadCallback)
def DeviceReadThread(hid_device):
"""Binds a device to the thread's run loop, then starts the run loop.
Args:
hid_device: The MacOsHidDevice object
The HID manager requires a run loop to handle Report reads. This thread
function serves that purpose.
"""
# Schedule device events with run loop
hid_device.run_loop_ref = cf.CFRunLoopGetCurrent()
if not hid_device.run_loop_ref:
logger.error('Failed to get current run loop')
return
iokit.IOHIDDeviceScheduleWithRunLoop(hid_device.device_handle,
hid_device.run_loop_ref,
K_CF_RUNLOOP_DEFAULT_MODE)
# Run the run loop
run_loop_run_result = K_CF_RUN_LOOP_RUN_TIMED_OUT
while (run_loop_run_result == K_CF_RUN_LOOP_RUN_TIMED_OUT
or run_loop_run_result == K_CF_RUN_LOOP_RUN_HANDLED_SOURCE):
run_loop_run_result = cf.CFRunLoopRunInMode(
K_CF_RUNLOOP_DEFAULT_MODE,
1000, # Timeout in seconds
False) # Return after source handled
# log any unexpected run loop exit
if run_loop_run_result != K_CF_RUN_LOOP_RUN_STOPPED:
logger.error('Unexpected run loop exit code: %d', run_loop_run_result)
# Unschedule from run loop
iokit.IOHIDDeviceUnscheduleFromRunLoop(hid_device.device_handle,
hid_device.run_loop_ref,
K_CF_RUNLOOP_DEFAULT_MODE)
class MacOsHidDevice(base.HidDevice):
"""Implementation of HID device for MacOS.
Uses IOKit HID Manager to interact with the device.
"""
@staticmethod
def Enumerate():
"""See base class."""
# Init a HID manager
hid_mgr = iokit.IOHIDManagerCreate(None, None)
if not hid_mgr:
raise errors.OsHidError('Unable to obtain HID manager reference')
iokit.IOHIDManagerSetDeviceMatching(hid_mgr, None)
# Get devices from HID manager
device_set_ref = iokit.IOHIDManagerCopyDevices(hid_mgr)
if not device_set_ref:
raise errors.OsHidError('Failed to obtain devices from HID manager')
num = iokit.CFSetGetCount(device_set_ref)
devices = (IO_HID_DEVICE_REF * num)()
iokit.CFSetGetValues(device_set_ref, devices)
# Retrieve and build descriptor dictionaries for each device
descriptors = []
for dev in devices:
d = base.DeviceDescriptor()
d.vendor_id = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_VENDOR_ID)
d.product_id = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_PRODUCT_ID)
d.product_string = GetDeviceStringProperty(dev,
HID_DEVICE_PROPERTY_PRODUCT)
d.usage = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_PRIMARY_USAGE)
d.usage_page = GetDeviceIntProperty(
dev, HID_DEVICE_PROPERTY_PRIMARY_USAGE_PAGE)
d.report_id = GetDeviceIntProperty(dev, HID_DEVICE_PROPERTY_REPORT_ID)
d.path = GetDevicePath(dev)
descriptors.append(d.ToPublicDict())
# Clean up CF objects
cf.CFRelease(device_set_ref)
cf.CFRelease(hid_mgr)
return descriptors
def __init__(self, path):
# Resolve the path to device handle
device_entry = iokit.IORegistryEntryFromPath(K_IO_MASTER_PORT_DEFAULT, path)
if not device_entry:
raise errors.OsHidError('Device path does not match any HID device on '
'the system')
self.device_handle = iokit.IOHIDDeviceCreate(K_CF_ALLOCATOR_DEFAULT,
device_entry)
if not self.device_handle:
raise errors.OsHidError('Failed to obtain device handle from registry '
'entry')
iokit.IOObjectRelease(device_entry)
self.device_path = path
# Open device
result = iokit.IOHIDDeviceOpen(self.device_handle, 0)
if result != K_IO_RETURN_SUCCESS:
raise errors.OsHidError('Failed to open device for communication: {}'
.format(result))
# Create read queue
self.read_queue = Queue()
# Create and start read thread
self.run_loop_ref = None
self.read_thread = threading.Thread(target=DeviceReadThread,
args=(self,))
self.read_thread.daemon = True
self.read_thread.start()
# Read max report sizes for in/out
self.internal_max_in_report_len = GetDeviceIntProperty(
self.device_handle,
HID_DEVICE_PROPERTY_MAX_INPUT_REPORT_SIZE)
if not self.internal_max_in_report_len:
raise errors.OsHidError('Unable to obtain max in report size')
self.internal_max_out_report_len = GetDeviceIntProperty(
self.device_handle,
HID_DEVICE_PROPERTY_MAX_OUTPUT_REPORT_SIZE)
if not self.internal_max_out_report_len:
raise errors.OsHidError('Unable to obtain max out report size')
# Register read callback
self.in_report_buffer = (ctypes.c_uint8 * self.internal_max_in_report_len)()
iokit.IOHIDDeviceRegisterInputReportCallback(
self.device_handle,
self.in_report_buffer,
self.internal_max_in_report_len,
REGISTERED_READ_CALLBACK,
ctypes.py_object(self.read_queue))
def GetInReportDataLength(self):
"""See base class."""
return self.internal_max_in_report_len
def GetOutReportDataLength(self):
"""See base class."""
return self.internal_max_out_report_len
def Write(self, packet):
"""See base class."""
report_id = 0
out_report_buffer = (ctypes.c_uint8 * self.internal_max_out_report_len)()
out_report_buffer[:] = packet[:]
result = iokit.IOHIDDeviceSetReport(self.device_handle,
K_IO_HID_REPORT_TYPE_OUTPUT,
report_id,
out_report_buffer,
self.internal_max_out_report_len)
# Non-zero status indicates failure
if result != K_IO_RETURN_SUCCESS:
raise errors.OsHidError('Failed to write report to device')
def Read(self):
"""See base class."""
return self.read_queue.get(timeout=0xffffffff)
def __del__(self):
# Unregister the callback
iokit.IOHIDDeviceRegisterInputReportCallback(
self.device_handle,
self.in_report_buffer,
self.internal_max_in_report_len,
None,
None)
# Stop the run loop
cf.CFRunLoopStop(self.run_loop_ref)
# Wait for the read thread to exit
self.read_thread.join()

369
fido_host/pyu2f/windows.py Normal file
View File

@ -0,0 +1,369 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implements raw HID device communication on Windows."""
from __future__ import absolute_import
import ctypes
from ctypes import wintypes
import platform
from . import base, errors
# Load relevant DLLs
hid = ctypes.windll.Hid
setupapi = ctypes.windll.SetupAPI
kernel32 = ctypes.windll.Kernel32
# Various structs that are used in the Windows APIs we call
class GUID(ctypes.Structure):
_fields_ = [('Data1', ctypes.c_ulong),
('Data2', ctypes.c_ushort),
('Data3', ctypes.c_ushort),
('Data4', ctypes.c_ubyte * 8)]
# On Windows, SetupAPI.h packs structures differently in 64bit and
# 32bit mode. In 64bit mode, thestructures are packed on 8 byte
# boundaries, while in 32bit mode, they are packed on 1 byte boundaries.
# This is important to get right for some API calls that fill out these
# structures.
if platform.architecture()[0] == '64bit':
SETUPAPI_PACK = 8
elif platform.architecture()[0] == '32bit':
SETUPAPI_PACK = 1
else:
raise errors.HidError('Unknown architecture: %s' % platform.architecture()[0])
class DeviceInterfaceData(ctypes.Structure):
_fields_ = [('cbSize', wintypes.DWORD),
('InterfaceClassGuid', GUID),
('Flags', wintypes.DWORD),
('Reserved', ctypes.POINTER(ctypes.c_ulong))]
_pack_ = SETUPAPI_PACK
class DeviceInterfaceDetailData(ctypes.Structure):
_fields_ = [('cbSize', wintypes.DWORD),
('DevicePath', ctypes.c_byte * 1)]
_pack_ = SETUPAPI_PACK
class HidAttributes(ctypes.Structure):
_fields_ = [('Size', ctypes.c_ulong),
('VendorID', ctypes.c_ushort),
('ProductID', ctypes.c_ushort),
('VersionNumber', ctypes.c_ushort)]
class HidCapabilities(ctypes.Structure):
_fields_ = [('Usage', ctypes.c_ushort),
('UsagePage', ctypes.c_ushort),
('InputReportByteLength', ctypes.c_ushort),
('OutputReportByteLength', ctypes.c_ushort),
('FeatureReportByteLength', ctypes.c_ushort),
('Reserved', ctypes.c_ushort * 17),
('NotUsed', ctypes.c_ushort * 10)]
# Various void* aliases for readability.
HDEVINFO = ctypes.c_void_p
HANDLE = ctypes.c_void_p
PHIDP_PREPARSED_DATA = ctypes.c_void_p # pylint: disable=invalid-name
# This is a HANDLE.
INVALID_HANDLE_VALUE = 0xffffffff
# Status codes
NTSTATUS = ctypes.c_long
HIDP_STATUS_SUCCESS = 0x00110000
FILE_SHARE_READ = 0x00000001
FILE_SHARE_WRITE = 0x00000002
OPEN_EXISTING = 0x03
ERROR_ACCESS_DENIED = 0x05
# CreateFile Flags
GENERIC_WRITE = 0x40000000
GENERIC_READ = 0x80000000
# Function signatures
hid.HidD_GetHidGuid.restype = None
hid.HidD_GetHidGuid.argtypes = [ctypes.POINTER(GUID)]
hid.HidD_GetAttributes.restype = wintypes.BOOLEAN
hid.HidD_GetAttributes.argtypes = [HANDLE, ctypes.POINTER(HidAttributes)]
hid.HidD_GetPreparsedData.restype = wintypes.BOOLEAN
hid.HidD_GetPreparsedData.argtypes = [HANDLE,
ctypes.POINTER(PHIDP_PREPARSED_DATA)]
hid.HidD_FreePreparsedData.restype = wintypes.BOOLEAN
hid.HidD_FreePreparsedData.argtypes = [PHIDP_PREPARSED_DATA]
hid.HidD_GetProductString.restype = wintypes.BOOLEAN
hid.HidD_GetProductString.argtypes = [HANDLE, ctypes.c_void_p, ctypes.c_ulong]
hid.HidP_GetCaps.restype = NTSTATUS
hid.HidP_GetCaps.argtypes = [PHIDP_PREPARSED_DATA,
ctypes.POINTER(HidCapabilities)]
setupapi.SetupDiGetClassDevsA.argtypes = [ctypes.POINTER(GUID), ctypes.c_char_p,
wintypes.HWND, wintypes.DWORD]
setupapi.SetupDiGetClassDevsA.restype = HDEVINFO
setupapi.SetupDiEnumDeviceInterfaces.restype = wintypes.BOOL
setupapi.SetupDiEnumDeviceInterfaces.argtypes = [
HDEVINFO, ctypes.c_void_p, ctypes.POINTER(GUID), wintypes.DWORD,
ctypes.POINTER(DeviceInterfaceData)]
setupapi.SetupDiGetDeviceInterfaceDetailA.restype = wintypes.BOOL
setupapi.SetupDiGetDeviceInterfaceDetailA.argtypes = [
HDEVINFO, ctypes.POINTER(DeviceInterfaceData),
ctypes.POINTER(DeviceInterfaceDetailData), wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p]
kernel32.CreateFileA.restype = HANDLE
kernel32.CreateFileA.argtypes = [
ctypes.c_char_p, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
wintypes.DWORD, wintypes.DWORD, HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [HANDLE]
kernel32.ReadFile.restype = wintypes.BOOL
kernel32.ReadFile.argtypes = [
HANDLE, ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p]
kernel32.WriteFile.restype = wintypes.BOOL
kernel32.WriteFile.argtypes = [
HANDLE, ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p]
def FillDeviceAttributes(device, descriptor):
"""Fill out the attributes of the device.
Fills the devices HidAttributes and product string
into the descriptor.
Args:
device: A handle to the open device
descriptor: The DeviceDescriptor to populate with the
attributes.
Returns:
None
Raises:
WindowsError when unable to obtain attributes or product
string.
"""
attributes = HidAttributes()
result = hid.HidD_GetAttributes(device, ctypes.byref(attributes))
if not result:
raise ctypes.WinError()
buf = ctypes.create_string_buffer(1024)
result = hid.HidD_GetProductString(device, buf, 1024)
if not result:
raise ctypes.WinError()
descriptor.vendor_id = attributes.VendorID
descriptor.product_id = attributes.ProductID
descriptor.product_string = ctypes.wstring_at(buf)
def FillDeviceCapabilities(device, descriptor):
"""Fill out device capabilities.
Fills the HidCapabilitites of the device into descriptor.
Args:
device: A handle to the open device
descriptor: DeviceDescriptor to populate with the
capabilities
Returns:
none
Raises:
WindowsError when unable to obtain capabilitites.
"""
preparsed_data = PHIDP_PREPARSED_DATA(0)
ret = hid.HidD_GetPreparsedData(device, ctypes.byref(preparsed_data))
if not ret:
raise ctypes.WinError()
try:
caps = HidCapabilities()
ret = hid.HidP_GetCaps(preparsed_data, ctypes.byref(caps))
if ret != HIDP_STATUS_SUCCESS:
raise ctypes.WinError()
descriptor.usage = caps.Usage
descriptor.usage_page = caps.UsagePage
descriptor.internal_max_in_report_len = caps.InputReportByteLength
descriptor.internal_max_out_report_len = caps.OutputReportByteLength
finally:
hid.HidD_FreePreparsedData(preparsed_data)
# The python os.open() implementation uses the windows libc
# open() function, which writes CreateFile but does so in a way
# that doesn't let us open the device with the right set of permissions.
# Therefore, we have to directly use the Windows API calls.
# We could use PyWin32, which provides simple wrappers. However, to avoid
# requiring a PyWin32 dependency for clients, we simply also implement it
# using ctypes.
def OpenDevice(path, enum=False):
"""Open the device and return a handle to it."""
desired_access = GENERIC_WRITE | GENERIC_READ
share_mode = FILE_SHARE_READ | FILE_SHARE_WRITE
if enum:
desired_access = 0
h = kernel32.CreateFileA(path,
desired_access,
share_mode,
None, OPEN_EXISTING, 0, None)
if h == INVALID_HANDLE_VALUE:
raise ctypes.WinError()
return h
class WindowsHidDevice(base.HidDevice):
"""Implementation of raw HID interface on Windows."""
@staticmethod
def Enumerate():
"""See base class."""
hid_guid = GUID()
hid.HidD_GetHidGuid(ctypes.byref(hid_guid))
devices = setupapi.SetupDiGetClassDevsA(
ctypes.byref(hid_guid), None, None, 0x12)
index = 0
interface_info = DeviceInterfaceData()
interface_info.cbSize = ctypes.sizeof(DeviceInterfaceData) # pylint: disable=invalid-name
out = []
while True:
result = setupapi.SetupDiEnumDeviceInterfaces(
devices, 0, ctypes.byref(hid_guid), index,
ctypes.byref(interface_info))
index += 1
if not result:
break
detail_len = wintypes.DWORD()
result = setupapi.SetupDiGetDeviceInterfaceDetailA(
devices, ctypes.byref(interface_info), None, 0,
ctypes.byref(detail_len), None)
detail_len = detail_len.value
if detail_len == 0:
# skip this device, some kind of error
continue
buf = ctypes.create_string_buffer(detail_len)
interface_detail = DeviceInterfaceDetailData.from_buffer(buf)
interface_detail.cbSize = ctypes.sizeof(DeviceInterfaceDetailData)
result = setupapi.SetupDiGetDeviceInterfaceDetailA(
devices, ctypes.byref(interface_info),
ctypes.byref(interface_detail), detail_len, None, None)
if not result:
raise ctypes.WinError()
descriptor = base.DeviceDescriptor()
# This is a bit of a hack to work around a limitation of ctypes and
# "header" structures that are common in windows. DevicePath is a
# ctypes array of length 1, but it is backed with a buffer that is much
# longer and contains a null terminated string. So, we read the null
# terminated string off DevicePath here. Per the comment above, the
# alignment of this struct varies depending on architecture, but
# in all cases the path string starts 1 DWORD into the structure.
#
# The path length is:
# length of detail buffer - header length (1 DWORD)
path_len = detail_len - ctypes.sizeof(wintypes.DWORD)
descriptor.path = ctypes.string_at(
ctypes.addressof(interface_detail.DevicePath), path_len)
device = None
try:
device = OpenDevice(descriptor.path, True)
except WindowsError as e: # pylint: disable=undefined-variable
if e.winerror == ERROR_ACCESS_DENIED: # Access Denied, e.g. a keyboard
continue
else:
raise e
try:
FillDeviceAttributes(device, descriptor)
FillDeviceCapabilities(device, descriptor)
out.append(descriptor.ToPublicDict())
except Exception:
continue # Try with next device
finally:
kernel32.CloseHandle(device)
return out
def __init__(self, path):
"""See base class."""
base.HidDevice.__init__(self, path)
self.dev = OpenDevice(path)
self.desc = base.DeviceDescriptor()
FillDeviceCapabilities(self.dev, self.desc)
def GetInReportDataLength(self):
"""See base class."""
return self.desc.internal_max_in_report_len - 1
def GetOutReportDataLength(self):
"""See base class."""
return self.desc.internal_max_out_report_len - 1
def Write(self, packet):
"""See base class."""
if len(packet) != self.GetOutReportDataLength():
raise errors.HidError('Packet length must match report data length.')
out = bytes(bytearray([0] + packet)) # Prepend the zero-byte (report ID)
num_written = wintypes.DWORD()
ret = (
kernel32.WriteFile(
self.dev, out, len(out),
ctypes.byref(num_written), None))
if num_written.value != len(out):
raise errors.HidError(
'Failed to write complete packet. ' + 'Expected %d, but got %d' %
(len(out), num_written.value))
if not ret:
raise ctypes.WinError()
def Read(self):
"""See base class."""
buf = ctypes.create_string_buffer(self.desc.internal_max_in_report_len)
num_read = wintypes.DWORD()
ret = kernel32.ReadFile(
self.dev, buf, len(buf), ctypes.byref(num_read), None)
if num_read.value != self.desc.internal_max_in_report_len:
raise errors.HidError('Failed to read full length report from device.')
if not ret:
raise ctypes.WinError()
# Convert the string buffer to a list of numbers. Throw away the first
# byte, which is the report id (which we don't care about).
return b''.join(buf)[1:]

69
fido_host/rpid.py Normal file
View File

@ -0,0 +1,69 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
These functions validate RP_ID and APP_ID according to simplified TLD+1 rules,
using a bundled copy of the public suffix list fetched from:
https://publicsuffix.org/list/public_suffix_list.dat
Advanced APP_ID values pointing to JSON files containing valid facets are not
supported by this implementation.
"""
import os
import six
from six.moves.urllib.parse import urlparse
tld_fname = os.path.join(os.path.dirname(__file__), 'public_suffix_list.dat')
with open(tld_fname, 'rb') as f:
suffixes = [entry for entry in (line.decode('utf8').strip()
for line in f.readlines())
if entry and not entry.startswith('//')]
def verify_rp_id(rp_id, origin):
if not isinstance(rp_id, six.string_types) or not rp_id:
return False
url = urlparse(origin)
if url.scheme != 'https':
return False
host = url.hostname
if host == rp_id:
return True
if host.endswith('.' + rp_id) and rp_id not in suffixes:
return True
return False
def verify_app_id(app_id, origin):
url = urlparse(app_id)
if url.scheme != 'https':
return False
return verify_rp_id(url.hostname, origin)

151
fido_host/u2f.py Normal file
View File

@ -0,0 +1,151 @@
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from __future__ import absolute_import
from .hid import CTAPHID
from .utils import websafe_encode
from enum import IntEnum, unique
from binascii import b2a_hex
import struct
import six
@unique
class APDU(IntEnum):
OK = 0x9000
USE_NOT_SATISFIED = 0x6985
WRONG_DATA = 0x6a80
class ApduError(Exception):
def __init__(self, code, data=b''):
self.code = code
self.data = data
def __repr__(self):
return 'APDU error: 0x{:04X} {:d} bytes of data'.format(
self.code, len(self.data))
class RegistrationData(bytes):
def __init__(self, _):
if six.indexbytes(self, 0) != 0x05:
raise ValueError('Reserved byte != 0x05')
self.public_key = self[1:66]
kh_len = six.indexbytes(self, 66)
self.key_handle = self[67:67+kh_len]
cert_offs = 67 + kh_len
cert_len = six.indexbytes(self, cert_offs + 1)
if cert_len > 0x80:
n_bytes = cert_len - 0x80
cert_len = int(b2a_hex(self[cert_offs+2:cert_offs+2+n_bytes]), 16) \
+ n_bytes
cert_len += 2
self.certificate = self[cert_offs:cert_offs+cert_len]
self.signature = self[cert_offs+cert_len:]
@property
def b64(self):
return websafe_encode(self)
def __repr__(self):
return ("RegistrationData(public_key: h'%s', key_handle: h'%s', "
"certificate: h'%s', signature: h'%s')") % (
b2a_hex(x).decode() for x in (
self.public_key,
self.key_handle,
self.certificate,
self.signature
)
)
def __str__(self):
return '%r' % self
class SignatureData(bytes):
def __init__(self, _):
self.user_presence, self.counter = struct.unpack('>BI', self[:5])
self.signature = self[5:]
@property
def b64(self):
return websafe_encode(self)
def __repr__(self):
return ('SignatureData(user_presence: 0x%02x, counter: %d, '
"signature: h'%s'") % (self.user_presence, self.counter,
b2a_hex(self.signature))
def __str__(self):
return '%r' % self
class CTAP1(object):
@unique
class INS(IntEnum):
REGISTER = 0x01
AUTHENTICATE = 0x02
VERSION = 0x03
def __init__(self, device):
self.device = device
def send_apdu(self, cla=0, ins=0, p1=0, p2=0, data=b''):
size = len(data)
size_h = size >> 16 & 0xff
size_l = size & 0xffff
apdu = struct.pack('>BBBBBH', cla, ins, p1, p2, size_h, size_l) \
+ data + b'\0\0'
response = self.device.call(CTAPHID.MSG, apdu)
status = struct.unpack('>H', response[-2:])[0]
data = response[:-2]
if status != APDU.OK:
raise ApduError(status, data)
return data
def get_version(self):
return self.send_apdu(ins=CTAP1.INS.VERSION).decode()
def register(self, client_param, app_param):
data = client_param + app_param
response = self.send_apdu(ins=CTAP1.INS.REGISTER, data=data)
return RegistrationData(response)
def authenticate(self, client_param, app_param, key_handle,
check_only=False):
data = client_param + app_param \
+ struct.pack('>B', len(key_handle)) + key_handle
p1 = 0x07 if check_only else 0x03
response = self.send_apdu(ins=CTAP1.INS.AUTHENTICATE, p1=p1, data=data)
return SignatureData(response)

86
fido_host/utils.py Normal file
View File

@ -0,0 +1,86 @@
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from base64 import urlsafe_b64decode, urlsafe_b64encode
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hmac, hashes
from threading import Timer, Event
import six
import numbers
__all__ = [
'Timeout',
'websafe_encode',
'websafe_decode',
'sha256',
'hmac_sha256'
]
def sha256(data):
h = hashes.Hash(hashes.SHA256(), default_backend())
h.update(data)
return h.finalize()
def hmac_sha256(key, data):
h = hmac.HMAC(key, hashes.SHA256(), default_backend())
h.update(data)
return h.finalize()
def websafe_decode(data):
if isinstance(data, six.text_type):
data = data.encode('ascii')
data += b'=' * (-len(data) % 4)
return urlsafe_b64decode(data)
def websafe_encode(data):
if isinstance(data, six.text_type):
data = data.encode('ascii')
return urlsafe_b64encode(data).replace(b'=', b'').decode('ascii')
class Timeout(object):
def __init__(self, time_or_event):
if isinstance(time_or_event, numbers.Number):
self.event = Event()
self.timer = Timer(time_or_event, self.event.set)
else:
self.event = time_or_event
self.timer = None
def __enter__(self):
if self.timer:
self.timer.start()
return self.event
def __exit__(self, exc_type, exc_val, exc_tb):
if self.timer:
self.timer.cancel()
self.timer.join()

2
setup.cfg Normal file
View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 80

79
setup.py Executable file
View File

@ -0,0 +1,79 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from setuptools import setup, find_packages
import re
import sys
VERSION_PATTERN = re.compile(r"(?m)^__version__\s*=\s*['\"](.+)['\"]$")
def get_version():
with open('fido_host/__init__.py', 'r') as f:
match = VERSION_PATTERN.search(f.read())
return match.group(1)
install_requires = ['six', 'cryptography>=1.0']
if sys.version_info < (3, 4):
install_requires.append('enum34')
setup(
name='fido-host',
version=get_version(),
packages=find_packages(),
include_package_data=True,
author='Dain Nilsson',
author_email='dain@yubico.com',
description='Python based FIDO host library',
url='https://github.com/Yubico/python-fido-host',
install_requires=install_requires,
test_suite='test',
tests_require=['mock>=1.0.1', 'pyfakefs>=2.4'],
classifiers=[
'License :: OSI Approved :: BSD License',
'License :: OSI Approved :: Apache Software License',
'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)',
'Operating System :: MacOS',
'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'Topic :: Internet',
'Topic :: Security :: Cryptography',
'Topic :: Software Development :: Libraries :: Python Modules',
]
)

0
test/__init__.py Normal file
View File

0
test/pyu2f/__init__.py Normal file
View File

View File

@ -0,0 +1,183 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.hidtransport."""
from __future__ import absolute_import
import six
import mock
from fido_host.pyu2f import hidtransport, errors
from . import util
import unittest
def MakeKeyboard(path, usage):
d = {}
d['vendor_id'] = 1133 # Logitech
d['product_id'] = 49948
d['path'] = path
d['usage'] = usage
d['usage_page'] = 1
return d
def MakeU2F(path):
d = {}
d['vendor_id'] = 4176
d['product_id'] = 1031
d['path'] = path
d['usage'] = 1
d['usage_page'] = 0xf1d0
return d
def RPad(collection, to_size):
while len(collection) < to_size:
collection.append(0)
return collection
class DiscoveryTest(unittest.TestCase):
def testHidUsageSelector(self):
u2f_device = {'usage_page': 0xf1d0, 'usage': 0x01}
other_device = {'usage_page': 0x06, 'usage': 0x01}
self.assertTrue(hidtransport.HidUsageSelector(u2f_device))
self.assertFalse(hidtransport.HidUsageSelector(other_device))
def testDiscoverLocalDevices(self):
def FakeHidDevice(path):
mock_hid_dev = mock.MagicMock()
mock_hid_dev.GetInReportDataLength.return_value = 64
mock_hid_dev.GetOutReportDataLength.return_value = 64
mock_hid_dev.path = path
return mock_hid_dev
with mock.patch.object(hidtransport, 'hid') as hid_mock:
# We mock out init so that it doesn't try to do the whole init
# handshake with the device, which isn't what we're trying to test
# here.
with mock.patch.object(hidtransport.UsbHidTransport, 'InternalInit') as _:
hid_mock.Enumerate.return_value = [
MakeKeyboard('path1', 6), MakeKeyboard('path2', 2),
MakeU2F('path3'), MakeU2F('path4')
]
mock_hid_dev = mock.MagicMock()
mock_hid_dev.GetInReportDataLength.return_value = 64
mock_hid_dev.GetOutReportDataLength.return_value = 64
hid_mock.Open.side_effect = FakeHidDevice
# Force the iterator into a list
devs = list(hidtransport.DiscoverLocalHIDU2FDevices())
self.assertEqual(hid_mock.Enumerate.call_count, 1)
self.assertEqual(hid_mock.Open.call_count, 2)
self.assertEqual(len(devs), 2)
self.assertEqual(devs[0].hid_device.path, 'path3')
self.assertEqual(devs[1].hid_device.path, 'path4')
class TransportTest(unittest.TestCase):
def testInit(self):
fake_hid_dev = util.FakeHidDevice(bytearray([0x00, 0x00, 0x00, 0x01]))
t = hidtransport.UsbHidTransport(fake_hid_dev)
self.assertEqual(t.cid, bytearray([0x00, 0x00, 0x00, 0x01]))
self.assertEqual(t.u2fhid_version, 0x01)
def testPing(self):
fake_hid_dev = util.FakeHidDevice(bytearray([0x00, 0x00, 0x00, 0x01]))
t = hidtransport.UsbHidTransport(fake_hid_dev)
reply = t.SendPing(b'1234')
self.assertEqual(reply, b'1234')
def testMsg(self):
fake_hid_dev = util.FakeHidDevice(
bytearray([0x00, 0x00, 0x00, 0x01]), bytearray([0x01, 0x90, 0x00]))
t = hidtransport.UsbHidTransport(fake_hid_dev)
reply = t.SendMsgBytes([0x00, 0x01, 0x00, 0x00])
self.assertEqual(reply, bytearray([0x01, 0x90, 0x00]))
def testMsgBusy(self):
fake_hid_dev = util.FakeHidDevice(
bytearray([0x00, 0x00, 0x00, 0x01]), bytearray([0x01, 0x90, 0x00]))
t = hidtransport.UsbHidTransport(fake_hid_dev)
# Each call will retry twice: the first attempt will fail after 2 retreis,
# the second will succeed on the second retry.
fake_hid_dev.SetChannelBusyCount(3)
with mock.patch.object(hidtransport, 'time') as _:
six.assertRaisesRegex(self, errors.HidError, '^Device Busy', t.SendMsgBytes,
[0x00, 0x01, 0x00, 0x00])
reply = t.SendMsgBytes([0x00, 0x01, 0x00, 0x00])
self.assertEqual(reply, bytearray([0x01, 0x90, 0x00]))
def testFragmentedResponseMsg(self):
body = bytearray([x % 256 for x in range(0, 1000)])
fake_hid_dev = util.FakeHidDevice(bytearray([0x00, 0x00, 0x00, 0x01]), body)
t = hidtransport.UsbHidTransport(fake_hid_dev)
reply = t.SendMsgBytes([0x00, 0x01, 0x00, 0x00])
# Confirm we properly reassemble the message
self.assertEqual(reply, bytearray(x % 256 for x in range(0, 1000)))
def testFragmentedSendApdu(self):
body = bytearray(x % 256 for x in range(0, 1000))
fake_hid_dev = util.FakeHidDevice(
bytearray([0x00, 0x00, 0x00, 0x01]), [0x90, 0x00])
t = hidtransport.UsbHidTransport(fake_hid_dev)
reply = t.SendMsgBytes(body)
self.assertEqual(reply, bytearray([0x90, 0x00]))
# 1 init packet from creating transport, 18 packets to send
# the fragmented message
self.assertEqual(len(fake_hid_dev.received_packets), 18)
def testInitPacketShape(self):
packet = hidtransport.UsbHidTransport.InitPacket(
64, bytearray(b'\x00\x00\x00\x01'), 0x83, 2, bytearray(b'\x01\x02'))
self.assertEqual(packet.ToWireFormat(), RPad(
[0, 0, 0, 1, 0x83, 0, 2, 1, 2], 64))
copy = hidtransport.UsbHidTransport.InitPacket.FromWireFormat(
64, packet.ToWireFormat())
self.assertEqual(copy.cid, bytearray(b'\x00\x00\x00\x01'))
self.assertEqual(copy.cmd, 0x83)
self.assertEqual(copy.size, 2)
self.assertEqual(copy.payload, bytearray(b'\x01\x02'))
def testContPacketShape(self):
packet = hidtransport.UsbHidTransport.ContPacket(
64, bytearray(b'\x00\x00\x00\x01'), 5, bytearray(b'\x01\x02'))
self.assertEqual(packet.ToWireFormat(), RPad([0, 0, 0, 1, 5, 1, 2], 64))
copy = hidtransport.UsbHidTransport.ContPacket.FromWireFormat(
64, packet.ToWireFormat())
self.assertEqual(copy.cid, bytearray(b'\x00\x00\x00\x01'))
self.assertEqual(copy.seq, 5)
self.assertEqual(copy.payload, RPad(bytearray(b'\x01\x02'), 59))
if __name__ == '__main__':
unittest.main()

124
test/pyu2f/linux_test.py Normal file
View File

@ -0,0 +1,124 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.hid.linux."""
import base64
import os
import sys
import mock
import six
from six.moves import builtins
from fido_host.pyu2f import linux
try:
from pyfakefs import fake_filesystem # pylint: disable=g-import-not-at-top
except ImportError:
from fakefs import fake_filesystem # pylint: disable=g-import-not-at-top
if sys.version_info[:2] < (2, 7):
import unittest2 as unittest # pylint: disable=g-import-not-at-top
else:
import unittest # pylint: disable=g-import-not-at-top
# These are base64 encoded report descriptors of a Yubico token
# and a Logitech keyboard.
YUBICO_RD = 'BtDxCQGhAQkgFQAm/wB1CJVAgQIJIRUAJv8AdQiVQJECwA=='
KEYBOARD_RD = (
'BQEJAqEBCQGhAAUJGQEpBRUAJQGVBXUBgQKVAXUDgQEFAQkwCTEJOBWBJX91CJUDgQbAwA==')
def AddDevice(fs, dev_name, product_name,
vendor_id, product_id, report_descriptor_b64):
uevent = fs.CreateFile('/sys/class/hidraw/%s/device/uevent' % dev_name)
rd = fs.CreateFile('/sys/class/hidraw/%s/device/report_descriptor' % dev_name)
report_descriptor = base64.b64decode(report_descriptor_b64)
rd.SetContents(report_descriptor)
buf = 'HID_NAME=%s\n' % product_name.encode('utf8')
buf += 'HID_ID=0001:%08X:%08X\n' % (vendor_id, product_id)
uevent.SetContents(buf)
class FakeDeviceOsModule(object):
O_RDWR = os.O_RDWR
path = os.path
data_written = None
data_to_return = None
def open(self, unused_path, unused_opts): # pylint: disable=invalid-name
return 0
def write(self, unused_dev, data): # pylint: disable=invalid-name
self.data_written = data
def read(self, unused_dev, unused_length): # pylint: disable=invalid-name
return self.data_to_return
class LinuxTest(unittest.TestCase):
def setUp(self):
self.fs = fake_filesystem.FakeFilesystem()
self.fs.CreateDirectory('/sys/class/hidraw')
def tearDown(self):
self.fs.RemoveObject('/sys/class/hidraw')
def testCallEnumerate(self):
AddDevice(self.fs, 'hidraw1', 'Logitech USB Keyboard',
0x046d, 0xc31c, KEYBOARD_RD)
AddDevice(self.fs, 'hidraw2', 'Yubico U2F', 0x1050, 0x0407, YUBICO_RD)
with mock.patch.object(linux, 'os', fake_filesystem.FakeOsModule(self.fs)):
fake_open = fake_filesystem.FakeFileOpen(self.fs)
with mock.patch.object(builtins, 'open', fake_open):
devs = list(linux.LinuxHidDevice.Enumerate())
devs = sorted(devs, key=lambda k: k['vendor_id'])
self.assertEqual(len(devs), 2)
self.assertEqual(devs[0]['vendor_id'], 0x046d)
self.assertEqual(devs[0]['product_id'], 0x0c31c)
self.assertEqual(devs[1]['vendor_id'], 0x1050)
self.assertEqual(devs[1]['product_id'], 0x0407)
self.assertEqual(devs[1]['usage_page'], 0xf1d0)
self.assertEqual(devs[1]['usage'], 1)
def testCallOpen(self):
AddDevice(self.fs, 'hidraw1', 'Yubico U2F', 0x1050, 0x0407, YUBICO_RD)
fake_open = fake_filesystem.FakeFileOpen(self.fs)
# The open() builtin is used to read from the fake sysfs
with mock.patch.object(builtins, 'open', fake_open):
# The os.open function is used to interact with the low
# level device. We don't want to use fakefs for that.
fake_dev_os = FakeDeviceOsModule()
with mock.patch.object(linux, 'os', fake_dev_os):
dev = linux.LinuxHidDevice('/dev/hidraw1')
self.assertEqual(dev.GetInReportDataLength(), 64)
self.assertEqual(dev.GetOutReportDataLength(), 64)
dev.Write(list(range(0, 64)))
# The HidDevice implementation prepends a zero-byte representing the
# report ID
self.assertEqual(list(six.iterbytes(fake_dev_os.data_written)),
[0] + list(range(0, 64)))
fake_dev_os.data_to_return = b'x' * 64
self.assertEqual(dev.Read(), [120] * 64) # chr(120) = 'x'
if __name__ == '__main__':
unittest.main()

142
test/pyu2f/macos_test.py Normal file
View File

@ -0,0 +1,142 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.hid.macos."""
import ctypes
import sys
import mock
from fido_host.pyu2f import macos, errors
if sys.version_info[:2] < (2, 7):
import unittest2 as unittest # pylint: disable=g-import-not-at-top
else:
import unittest # pylint: disable=g-import-not-at-top
def init_mock_iokit(mock_iokit):
# Device open should always return 0 (success)
mock_iokit.IOHIDDeviceOpen = mock.Mock(return_value=0)
mock_iokit.IOHIDDeviceSetReport = mock.Mock(return_value=0)
mock_iokit.IOHIDDeviceCreate = mock.Mock(return_value='handle')
def init_mock_cf(mock_cf):
mock_cf.CFGetTypeID = mock.Mock(return_value=123)
mock_cf.CFNumberGetTypeID = mock.Mock(return_value=123)
mock_cf.CFStringGetTypeID = mock.Mock(return_value=123)
def init_mock_get_int_property(mock_get_int_property):
mock_get_int_property.return_value = 64
class MacOsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
macos.MacOsHidDevice.__del__ = lambda x: None
@mock.patch.object(macos.threading, 'Thread')
@mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT,
GetDeviceIntProperty=mock.DEFAULT)
def testInitHidDevice(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name
init_mock_iokit(iokit)
init_mock_cf(cf)
init_mock_get_int_property(GetDeviceIntProperty)
device = macos.MacOsHidDevice('fakepath')
self.assertEqual(64, device.GetInReportDataLength())
self.assertEqual(64, device.GetOutReportDataLength())
@mock.patch.object(macos.threading, 'Thread')
@mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT,
GetDeviceIntProperty=mock.DEFAULT)
def testCallWriteSuccess(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name
init_mock_iokit(iokit)
init_mock_cf(cf)
init_mock_get_int_property(GetDeviceIntProperty)
device = macos.MacOsHidDevice('fakepath')
# Write 64 bytes to device
data = bytearray(range(64))
# Write to device
device.Write(data)
# Validate that write calls into IOKit
set_report_call_args = iokit.IOHIDDeviceSetReport.call_args
self.assertIsNotNone(set_report_call_args)
set_report_call_pos_args = iokit.IOHIDDeviceSetReport.call_args[0]
self.assertEqual(len(set_report_call_pos_args), 5)
self.assertEqual(set_report_call_pos_args[0], 'handle')
self.assertEqual(set_report_call_pos_args[1], 1)
self.assertEqual(set_report_call_pos_args[2], 0)
self.assertEqual(set_report_call_pos_args[4], 64)
report_buffer = set_report_call_pos_args[3]
self.assertEqual(len(report_buffer), 64)
self.assertEqual(bytearray(report_buffer), data, 'Data sent to '
'IOHIDDeviceSetReport should match data sent to the '
'device')
@mock.patch.object(macos.threading, 'Thread')
@mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT,
GetDeviceIntProperty=mock.DEFAULT)
def testCallWriteFailure(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name
init_mock_iokit(iokit)
init_mock_cf(cf)
init_mock_get_int_property(GetDeviceIntProperty)
# Make set report call return failure (non-zero)
iokit.IOHIDDeviceSetReport.return_value = -1
device = macos.MacOsHidDevice('fakepath')
# Write 64 bytes to device
data = bytearray(range(64))
# Write should throw an OsHidError exception
with self.assertRaises(errors.OsHidError):
device.Write(data)
@mock.patch.object(macos.threading, 'Thread')
@mock.patch.multiple(macos, iokit=mock.DEFAULT, cf=mock.DEFAULT,
GetDeviceIntProperty=mock.DEFAULT)
def testCallReadSuccess(self, thread, iokit, cf, GetDeviceIntProperty): # pylint: disable=invalid-name
init_mock_iokit(iokit)
init_mock_cf(cf)
init_mock_get_int_property(GetDeviceIntProperty)
device = macos.MacOsHidDevice('fakepath')
# Call callback for IN report
report = (ctypes.c_uint8 * 64)()
report[:] = range(64)[:]
q = device.read_queue
macos.HidReadCallback(q, None, None, None, 0, report, 64)
# Device read should return the callback data
read_result = device.Read()
self.assertEqual(read_result, list(range(64)), 'Read data should match data '
'passed into the callback')
if __name__ == '__main__':
unittest.main()

149
test/pyu2f/util.py Normal file
View File

@ -0,0 +1,149 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Testing utilties for pyu2f.
Testing utilities such as a fake implementation of the pyhidapi device object
that implements parts of the U2FHID frame protocol. This makes it easy to tests
of higher level abstractions without having to use mock to mock out low level
framing details.
"""
from fido_host.pyu2f import base, hidtransport
class UnsupportedCommandError(Exception):
pass
class FakeHidDevice(base.HidDevice):
"""Implements a fake hiddevice per the pyhidapi interface.
This class implemetns a fake hiddevice that can be patched into
code that uses pyhidapi to communicate with a hiddevice. This device
impelents part of U2FHID protocol and can be used to test interactions
with a security key. It supports arbitrary MSG replies as well as
channel allocation, and ping.
"""
def __init__(self, cid_to_allocate, msg_reply=None):
self.cid_to_allocate = cid_to_allocate
self.msg_reply = msg_reply
self.transaction_active = False
self.full_packet_received = False
self.init_packet = None
self.packet_body = None
self.reply = None
self.seq = 0
self.received_packets = []
self.busy_count = 0
def GetInReportDataLength(self):
return 64
def GetOutReportDataLength(self):
return 64
def Write(self, data):
"""Write to the device.
Writes to the fake hid device. This function is stateful: if a transaction
is currently open with the client, it will continue to accumulate data
for the client->device messages until the expected size is reached.
Args:
data: A list of integers to accept as data payload. It should be 64 bytes
in length: just the report data--NO report ID.
"""
if len(data) < 64:
data = bytearray(data) + bytearray(0 for i in range(0, 64 - len(data)))
if not self.transaction_active:
self.transaction_active = True
self.init_packet = hidtransport.UsbHidTransport.InitPacket.FromWireFormat(
64, data)
self.packet_body = self.init_packet.payload
self.full_packet_received = False
self.received_packets.append(self.init_packet)
else:
cont_packet = hidtransport.UsbHidTransport.ContPacket.FromWireFormat(
64, data)
self.packet_body += cont_packet.payload
self.received_packets.append(cont_packet)
if len(self.packet_body) >= self.init_packet.size:
self.packet_body = self.packet_body[0:self.init_packet.size]
self.full_packet_received = True
def Read(self):
"""Read from the device.
Reads from the fake hid device. This function only works if a transaction
is open and a complete write has taken place. If so, it will return the
next reply packet. It should be called repeatedly until all expected
data has been received. It always reads one report.
Returns:
A list of ints containing the next packet.
Raises:
UnsupportedCommandError: if the requested amount is not 64.
"""
if not self.transaction_active or not self.full_packet_received:
return None
ret = None
if self.busy_count > 0:
ret = hidtransport.UsbHidTransport.InitPacket(
64, self.init_packet.cid, hidtransport.UsbHidTransport.U2FHID_ERROR,
1, hidtransport.UsbHidTransport.ERR_CHANNEL_BUSY)
self.busy_count -= 1
elif self.reply: # reply in progress
next_frame = self.reply[0:59]
self.reply = self.reply[59:]
ret = hidtransport.UsbHidTransport.ContPacket(64, self.init_packet.cid,
self.seq, next_frame)
self.seq += 1
else:
self.InternalGenerateReply()
first_frame = self.reply[0:57]
ret = hidtransport.UsbHidTransport.InitPacket(
64, self.init_packet.cid, self.init_packet.cmd, len(self.reply),
first_frame)
self.reply = self.reply[57:]
if not self.reply: # done after this packet
self.reply = None
self.transaction_active = False
self.seq = 0
return ret.ToWireFormat()
def SetChannelBusyCount(self, busy_count): # pylint: disable=invalid-name
"""Mark the channel busy for next busy_count read calls."""
self.busy_count = busy_count
def InternalGenerateReply(self): # pylint: disable=invalid-name
if self.init_packet.cmd == hidtransport.UsbHidTransport.U2FHID_INIT:
nonce = self.init_packet.payload[0:8]
self.reply = nonce + self.cid_to_allocate + bytearray(
b'\x01\x00\x00\x00\x00')
elif self.init_packet.cmd == hidtransport.UsbHidTransport.U2FHID_PING:
self.reply = self.init_packet.payload
elif self.init_packet.cmd == hidtransport.UsbHidTransport.U2FHID_MSG:
self.reply = self.msg_reply
else:
raise UnsupportedCommandError()

65
test/pyu2f/util_test.py Normal file
View File

@ -0,0 +1,65 @@
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.tests.lib.util."""
from __future__ import absolute_import
import sys
from . import util
if sys.version_info[:2] < (2, 7):
import unittest2 as unittest # pylint: disable=g-import-not-at-top
else:
import unittest # pylint: disable=g-import-not-at-top
class UtilTest(unittest.TestCase):
def testSimplePing(self):
dev = util.FakeHidDevice(cid_to_allocate=None)
dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3])
self.assertEqual(
dev.Read(), [0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3] + [0
for _ in range(54)])
def testErrorBusy(self):
dev = util.FakeHidDevice(cid_to_allocate=None)
dev.SetChannelBusyCount(2)
dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3])
self.assertEqual(
dev.Read(), [0, 0, 0, 1, 0xbf, 0, 1, 6] + [0 for _ in range(56)])
dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3])
self.assertEqual(
dev.Read(), [0, 0, 0, 1, 0xbf, 0, 1, 6] + [0 for _ in range(56)])
dev.Write([0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3])
self.assertEqual(
dev.Read(), [0, 0, 0, 1, 0x81, 0, 3, 1, 2, 3] + [0
for _ in range(54)])
def testFragmentedApdu(self):
dev = util.FakeHidDevice(cid_to_allocate=None, msg_reply=range(85, 0, -1))
dev.Write([0, 0, 0, 1, 0x83, 0, 100] + [x for x in range(57)])
dev.Write([0, 0, 0, 1, 0] + [x for x in range(57, 100)])
self.assertEqual(
dev.Read(), [0, 0, 0, 1, 0x83, 0, 85] + [x for x in range(85, 28, -1)])
self.assertEqual(
dev.Read(),
[0, 0, 0, 1, 0] + [x for x in range(28, 0, -1)] + [0
for _ in range(31)])
if __name__ == '__main__':
unittest.main()

200
test/test_cbor.py Normal file
View File

@ -0,0 +1,200 @@
# coding=utf-8
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from __future__ import unicode_literals
from fido_host import cbor
from binascii import a2b_hex, b2a_hex
import unittest
_TEST_VECTORS = [
('00', 0),
('01', 1),
('0a', 10),
('17', 23),
('1818', 24),
('1819', 25),
('1864', 100),
('1903e8', 1000),
('1a000f4240', 1000000),
('1b000000e8d4a51000', 1000000000000),
('1bffffffffffffffff', 18446744073709551615),
# ('c249010000000000000000', 18446744073709551616),
('3bffffffffffffffff', -18446744073709551616),
# ('c349010000000000000000', -18446744073709551617),
('20', -1),
('29', -10),
('3863', -100),
('3903e7', -1000),
# ('f90000', 0.0),
# ('f98000', -0.0),
# ('f93c00', 1.0),
# ('fb3ff199999999999a', 1.1),
# ('f93e00', 1.5),
# ('f97bff', 65504.0),
# ('fa47c35000', 100000.0),
# ('fa7f7fffff', 3.4028234663852886e+38),
# ('fb7e37e43c8800759c', 1e+300),
# ('f90001', 5.960464477539063e-08),
# ('f90400', 6.103515625e-05),
# ('f9c400', -4.0),
# ('fbc010666666666666', -4.1),
# ('f97c00', None),
# ('f97e00', None),
# ('f9fc00', None),
# ('fa7f800000', None),
# ('fa7fc00000', None),
# ('faff800000', None),
# ('fb7ff0000000000000', None),
# ('fb7ff8000000000000', None),
# ('fbfff0000000000000', None),
('f4', False),
('f5', True),
# ('f6', None),
# ('f7', None),
# ('f0', None),
# ('f818', None),
# ('f8ff', None),
# ('c074323031332d30332d32315432303a30343a30305a', None),
# ('c11a514b67b0', None),
# ('c1fb41d452d9ec200000', None),
# ('d74401020304', None),
# ('d818456449455446', None),
# ('d82076687474703a2f2f7777772e6578616d706c652e636f6d', None),
('40', b''),
('4401020304', b'\1\2\3\4'),
('60', ''),
('6161', 'a'),
('6449455446', 'IETF'),
('62225c', '"\\'),
('62c3bc', 'ü'),
('63e6b0b4', ''),
('64f0908591', '𐅑'),
('80', []),
('83010203', [1, 2, 3]),
('8301820203820405', [1, [2, 3], [4, 5]]),
('98190102030405060708090a0b0c0d0e0f101112131415161718181819', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]), # noqa
('a0', {}),
('a201020304', {1: 2, 3: 4}),
('a26161016162820203', {'a': 1, 'b': [2, 3]}),
('826161a161626163', ['a', {'b': 'c'}]),
('a56161614161626142616361436164614461656145', {'c': 'C', 'd': 'D', 'a': 'A', 'b': 'B', 'e': 'E'}), # noqa
# ('5f42010243030405ff', None),
# ('7f657374726561646d696e67ff', 'streaming'),
# ('9fff', []),
# ('9f018202039f0405ffff', [1, [2, 3], [4, 5]]),
# ('9f01820203820405ff', [1, [2, 3], [4, 5]]),
# ('83018202039f0405ff', [1, [2, 3], [4, 5]]),
# ('83019f0203ff820405', [1, [2, 3], [4, 5]]),
# ('9f0102030405060708090a0b0c0d0e0f101112131415161718181819ff', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]), # noqa
# ('bf61610161629f0203ffff', {'a': 1, 'b': [2, 3]}),
# ('826161bf61626163ff', ['a', {'b': 'c'}]),
# ('bf6346756ef563416d7421ff', {'Amt': -2, 'Fun': True}),
]
def cbor2hex(data):
return b2a_hex(cbor.dumps(data)).decode()
class TestCborTestVectors(unittest.TestCase):
"""
From https://github.com/cbor/test-vectors
Unsupported values are commented out.
"""
def test_vectors(self):
for (data, value) in _TEST_VECTORS:
try:
self.assertEqual(cbor.loads(a2b_hex(data)), (value, b''))
self.assertEqual(cbor2hex(value), data)
except Exception:
print('\nERROR in test vector, %s' % data)
raise
class TestFidoCanonical(unittest.TestCase):
"""
As defined in section 6 of:
https://fidoalliance.org/specs/fido-v2.0-ps-20170927/fido-client-to-authenticator-protocol-v2.0-ps-20170927.html
"""
def test_integers(self):
self.assertEqual(cbor2hex(0), '00')
self.assertEqual(cbor2hex(0), '00')
self.assertEqual(cbor2hex(23), '17')
self.assertEqual(cbor2hex(24), '1818')
self.assertEqual(cbor2hex(255), '18ff')
self.assertEqual(cbor2hex(256), '190100')
self.assertEqual(cbor2hex(65535), '19ffff')
self.assertEqual(cbor2hex(65536), '1a00010000')
self.assertEqual(cbor2hex(4294967295), '1affffffff')
self.assertEqual(cbor2hex(4294967296), '1b0000000100000000')
self.assertEqual(cbor2hex(-1), '20')
self.assertEqual(cbor2hex(-24), '37')
self.assertEqual(cbor2hex(-25), '3818')
def test_key_order(self):
self.assertEqual(cbor2hex({
'3': 0,
b'2': 0,
1: 0
}), 'a30100413200613300')
self.assertEqual(cbor2hex({
'3': 0,
b'': 0,
256: 0
}), 'a3190100004000613300')
self.assertEqual(cbor2hex({
4294967296: 0,
255: 0,
256: 0,
0: 0
}), 'a4000018ff00190100001b000000010000000000')
self.assertEqual(cbor2hex({
b'22': 0,
b'3': 0,
b'111': 0
}), 'a3413300423232004331313100')
self.assertEqual(cbor2hex({
b'001': 0,
b'003': 0,
b'002': 0
}), 'a3433030310043303032004330303300')
self.assertEqual(cbor2hex({
True: 0,
False: 0
}), 'a2f400f500')

274
test/test_client.py Normal file
View File

@ -0,0 +1,274 @@
# coding=utf-8
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import mock
import unittest
from threading import Event
from binascii import a2b_hex
from fido_host.utils import sha256, websafe_decode
from fido_host.u2f import ApduError, APDU, RegistrationData, SignatureData
from fido_host.client import ClientData, U2fClient, ClientError
class TestClientData(unittest.TestCase):
def test_client_data(self):
client_data = ClientData(b'{"typ":"navigator.id.finishEnrollment","challenge":"vqrS6WXDe1JUs5_c3i4-LkKIHRr-3XVb3azuA5TifHo","cid_pubkey":{"kty":"EC","crv":"P-256","x":"HzQwlfXX7Q4S5MtCCnZUNBw3RMzPO9tOyWjBqRl4tJ8","y":"XVguGFLIZx1fXg3wNqfdbn75hi4-_7-BxhMljw42Ht4"},"origin":"http://example.com"}') # noqa
self.assertEqual(client_data.hash, a2b_hex('4142d21c00d94ffb9d504ada8f99b721f4b191ae4e37ca0140f696b6983cfacb')) # noqa
self.assertEqual(client_data.origin, 'http://example.com')
self.assertEqual(client_data, ClientData.from_b64(client_data.b64))
self.assertEqual(client_data.data, {
'typ': 'navigator.id.finishEnrollment',
'challenge': 'vqrS6WXDe1JUs5_c3i4-LkKIHRr-3XVb3azuA5TifHo',
'cid_pubkey': {
'kty': 'EC',
'crv': 'P-256',
'x': 'HzQwlfXX7Q4S5MtCCnZUNBw3RMzPO9tOyWjBqRl4tJ8',
'y': 'XVguGFLIZx1fXg3wNqfdbn75hi4-_7-BxhMljw42Ht4'
},
'origin': 'http://example.com'
})
APP_ID = 'https://foo.example.com'
REG_DATA = RegistrationData(a2b_hex(b'0504b174bc49c7ca254b70d2e5c207cee9cf174820ebd77ea3c65508c26da51b657c1cc6b952f8621697936482da0a6d3d3826a59095daf6cd7c03e2e60385d2f6d9402a552dfdb7477ed65fd84133f86196010b2215b57da75d315b7b9e8fe2e3925a6019551bab61d16591659cbaf00b4950f7abfe6660e2e006f76868b772d70c253082013c3081e4a003020102020a47901280001155957352300a06082a8648ce3d0403023017311530130603550403130c476e756262792050696c6f74301e170d3132303831343138323933325a170d3133303831343138323933325a3031312f302d0603550403132650696c6f74476e756262792d302e342e312d34373930313238303030313135353935373335323059301306072a8648ce3d020106082a8648ce3d030107034200048d617e65c9508e64bcc5673ac82a6799da3c1446682c258c463fffdf58dfd2fa3e6c378b53d795c4a4dffb4199edd7862f23abaf0203b4b8911ba0569994e101300a06082a8648ce3d0403020347003044022060cdb6061e9c22262d1aac1d96d8c70829b2366531dda268832cb836bcd30dfa0220631b1459f09e6330055722c8d89b7f48883b9089b88d60d1d9795902b30410df304502201471899bcc3987e62e8202c9b39c33c19033f7340352dba80fcab017db9230e402210082677d673d891933ade6f617e5dbde2e247e70423fd5ad7804a6d3d3961ef871')) # noqa
SIG_DATA = SignatureData(a2b_hex(b'0100000001304402204b5f0cd17534cedd8c34ee09570ef542a353df4436030ce43d406de870b847780220267bb998fac9b7266eb60e7cb0b5eabdfd5ba9614f53c7b22272ec10047a923f')) # noqa
class TestU2fClient(unittest.TestCase):
def test_register_unsupported_version(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_XXX'
try:
client.register(
APP_ID, [{'version': 'U2F_V2', 'challenge': 'foobar'}], [],
timeout=1)
self.fail('register did not raise error')
except ClientError as e:
self.assertEqual(e.code, ClientError.ERR.DEVICE_INELIGIBLE)
client.ctap.get_version.assert_called_with()
def test_register_existing_key(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.side_effect = ApduError(APDU.USE_NOT_SATISFIED)
try:
client.register(
APP_ID, [{'version': 'U2F_V2', 'challenge': 'foobar'}],
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}],
timeout=1)
self.fail('register did not raise error')
except ClientError as e:
self.assertEqual(e.code, ClientError.ERR.DEVICE_INELIGIBLE)
client.ctap.get_version.assert_called_with()
client.ctap.authenticate.assert_called_once()
# Check keyHandle
self.assertEqual(client.ctap.authenticate.call_args[0][2], b'key')
# Ensure check-only was set
self.assertTrue(client.ctap.authenticate.call_args[0][3])
def test_register(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.side_effect = ApduError(APDU.WRONG_DATA)
client.ctap.register.return_value = REG_DATA
resp = client.register(
APP_ID, [{'version': 'U2F_V2', 'challenge': 'foobar'}],
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}]
)
client.ctap.get_version.assert_called_with()
client.ctap.authenticate.assert_called_once()
client.ctap.register.assert_called_once()
client_param, app_param = client.ctap.register.call_args[0]
self.assertEqual(sha256(websafe_decode(resp['clientData'])),
client_param)
self.assertEqual(websafe_decode(resp['registrationData']),
REG_DATA)
self.assertEqual(sha256(APP_ID.encode()), app_param)
def test_register_await_timeout(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.side_effect = ApduError(APDU.WRONG_DATA)
client.ctap.register.side_effect = ApduError(APDU.USE_NOT_SATISFIED)
client.poll_delay = 0.01
try:
client.register(
APP_ID, [{'version': 'U2F_V2', 'challenge': 'foobar'}],
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}],
timeout=0.1
)
except ClientError as e:
self.assertEqual(e.code, ClientError.ERR.TIMEOUT)
def test_register_await_touch(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.side_effect = ApduError(APDU.WRONG_DATA)
client.ctap.register.side_effect = [
ApduError(APDU.USE_NOT_SATISFIED),
ApduError(APDU.USE_NOT_SATISFIED),
ApduError(APDU.USE_NOT_SATISFIED),
ApduError(APDU.USE_NOT_SATISFIED),
REG_DATA
]
event = Event()
event.wait = mock.MagicMock()
resp = client.register(
APP_ID, [{'version': 'U2F_V2', 'challenge': 'foobar'}],
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}],
timeout=event
)
event.wait.assert_called()
client.ctap.get_version.assert_called_with()
client.ctap.authenticate.assert_called_once()
client.ctap.register.assert_called()
client_param, app_param = client.ctap.register.call_args[0]
self.assertEqual(sha256(websafe_decode(resp['clientData'])),
client_param)
self.assertEqual(websafe_decode(resp['registrationData']),
REG_DATA)
self.assertEqual(sha256(APP_ID.encode()), app_param)
def test_sign_unsupported_version(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_XXX'
try:
client.sign(
APP_ID, 'challenge',
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}]
)
self.fail('register did not raise error')
except ClientError as e:
self.assertEqual(e.code, ClientError.ERR.DEVICE_INELIGIBLE)
client.ctap.get_version.assert_called_with()
def test_sign_missing_key(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.side_effect = ApduError(APDU.WRONG_DATA)
try:
client.sign(
APP_ID, 'challenge',
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}],
)
self.fail('register did not raise error')
except ClientError as e:
self.assertEqual(e.code, ClientError.ERR.DEVICE_INELIGIBLE)
client.ctap.get_version.assert_called_with()
client.ctap.authenticate.assert_called_once()
_, app_param, key_handle = client.ctap.authenticate.call_args[0]
self.assertEqual(app_param, sha256(APP_ID.encode()))
self.assertEqual(key_handle, b'key')
def test_sign(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.return_value = SIG_DATA
resp = client.sign(
APP_ID, 'challenge',
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}],
)
client.ctap.get_version.assert_called_with()
client.ctap.authenticate.assert_called_once()
client_param, app_param, key_handle = \
client.ctap.authenticate.call_args[0]
self.assertEqual(client_param,
sha256(websafe_decode(resp['clientData'])))
self.assertEqual(app_param, sha256(APP_ID.encode()))
self.assertEqual(key_handle, b'key')
self.assertEqual(websafe_decode(resp['signatureData']),
SIG_DATA)
def test_sign_await_touch(self):
client = U2fClient(None, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.authenticate.side_effect = [
ApduError(APDU.USE_NOT_SATISFIED),
ApduError(APDU.USE_NOT_SATISFIED),
ApduError(APDU.USE_NOT_SATISFIED),
ApduError(APDU.USE_NOT_SATISFIED),
SIG_DATA
]
event = Event()
event.wait = mock.MagicMock()
resp = client.sign(
APP_ID, 'challenge',
[{'version': 'U2F_V2', 'keyHandle': 'a2V5'}],
timeout=event
)
event.wait.assert_called()
client.ctap.get_version.assert_called_with()
client.ctap.authenticate.assert_called()
client_param, app_param, key_handle = \
client.ctap.authenticate.call_args[0]
self.assertEqual(client_param,
sha256(websafe_decode(resp['clientData'])))
self.assertEqual(app_param, sha256(APP_ID.encode()))
self.assertEqual(key_handle, b'key')
self.assertEqual(websafe_decode(resp['signatureData']),
SIG_DATA)

255
test/test_fido2.py Normal file
View File

@ -0,0 +1,255 @@
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from fido_host.fido2 import (CTAP2, PinProtocolV1, Info, AttestedCredentialData,
AuthenticatorData, AttestationObject,
AssertionResponse)
from fido_host import cbor
from binascii import a2b_hex
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
import unittest
import mock
_AAGUID = a2b_hex('F8A011F38C0A4D15800617111F9EDC7D')
_INFO = a2b_hex('a60182665532465f5632684649444f5f325f3002826375766d6b686d61632d7365637265740350f8a011f38c0a4d15800617111f9edc7d04a462726bf5627570f564706c6174f469636c69656e7450696ef4051904b0068101') # noqa
class TestInfo(unittest.TestCase):
def test_parse_bytes(self):
info = Info(_INFO)
self.assertEqual(info.versions, ['U2F_V2', 'FIDO_2_0'])
self.assertEqual(info.extensions, ['uvm', 'hmac-secret'])
self.assertEqual(info.aaguid, _AAGUID)
self.assertEqual(info.options, {
'rk': True,
'up': True,
'plat': False,
'clientPin': False
})
self.assertEqual(info.max_msg_size, 1200)
self.assertEqual(info.pin_protocols, [1])
self.assertEqual(info.data, {
Info.KEY.VERSIONS: ['U2F_V2', 'FIDO_2_0'],
Info.KEY.EXTENSIONS: ['uvm', 'hmac-secret'],
Info.KEY.AAGUID: _AAGUID,
Info.KEY.OPTIONS: {
'clientPin': False,
'plat': False,
'rk': True,
'up': True
},
Info.KEY.MAX_MSG_SIZE: 1200,
Info.KEY.PIN_PROTOCOLS: [1]
})
_ATT_CRED_DATA = a2b_hex('f8a011f38c0a4d15800617111f9edc7d0040fe3aac036d14c1e1c65518b698dd1da8f596bc33e11072813466c6bf3845691509b80fb76d59309b8d39e0a93452688f6ca3a39a76f3fc52744fb73948b15783a5010203262001215820643566c206dd00227005fa5de69320616ca268043a38f08bde2e9dc45a5cafaf225820171353b2932434703726aae579fa6542432861fe591e481ea22d63997e1a5290') # noqa
_CRED_ID = a2b_hex('fe3aac036d14c1e1c65518b698dd1da8f596bc33e11072813466c6bf3845691509b80fb76d59309b8d39e0a93452688f6ca3a39a76f3fc52744fb73948b15783') # noqa
_PUB_KEY = {1: 2, 3: -7, -1: 1, -2: a2b_hex('643566c206dd00227005fa5de69320616ca268043a38f08bde2e9dc45a5cafaf'), -3: a2b_hex('171353b2932434703726aae579fa6542432861fe591e481ea22d63997e1a5290')} # noqa
class TestAttestedCredentialData(unittest.TestCase):
def test_parse_bytes(self):
data = AttestedCredentialData(_ATT_CRED_DATA)
self.assertEqual(data.aaguid, _AAGUID)
self.assertEqual(data.credential_id, _CRED_ID)
self.assertEqual(data.public_key, _PUB_KEY)
def test_create_from_args(self):
data = AttestedCredentialData.create(_AAGUID, _CRED_ID, _PUB_KEY)
self.assertEqual(_ATT_CRED_DATA, data)
_AUTH_DATA_MC = a2b_hex('0021F5FC0B85CD22E60623BCD7D1CA48948909249B4776EB515154E57B66AE12410000001CF8A011F38C0A4D15800617111F9EDC7D0040FE3AAC036D14C1E1C65518B698DD1DA8F596BC33E11072813466C6BF3845691509B80FB76D59309B8D39E0A93452688F6CA3A39A76F3FC52744FB73948B15783A5010203262001215820643566C206DD00227005FA5DE69320616CA268043A38F08BDE2E9DC45A5CAFAF225820171353B2932434703726AAE579FA6542432861FE591E481EA22D63997E1A5290') # noqa
_AUTH_DATA_GA = a2b_hex('0021F5FC0B85CD22E60623BCD7D1CA48948909249B4776EB515154E57B66AE12010000001D') # noqa
_RP_ID_HASH = a2b_hex('0021F5FC0B85CD22E60623BCD7D1CA48948909249B4776EB515154E57B66AE12') # noqa
class TestAuthenticatorData(unittest.TestCase):
def test_parse_bytes_make_credential(self):
data = AuthenticatorData(_AUTH_DATA_MC)
self.assertEqual(data.rp_id_hash, _RP_ID_HASH)
self.assertEqual(data.flags, 0x41)
self.assertEqual(data.counter, 28)
self.assertEqual(data.credential_data, _ATT_CRED_DATA)
self.assertIsNone(data.extensions)
def test_parse_bytes_get_assertion(self):
data = AuthenticatorData(_AUTH_DATA_GA)
self.assertEqual(data.rp_id_hash, _RP_ID_HASH)
self.assertEqual(data.flags, 0x01)
self.assertEqual(data.counter, 29)
self.assertIsNone(data.credential_data)
self.assertIsNone(data.extensions)
_MC_RESP = a2b_hex('a301667061636b6564025900c40021f5fc0b85cd22e60623bcd7d1ca48948909249b4776eb515154e57b66ae12410000001cf8a011f38c0a4d15800617111f9edc7d0040fe3aac036d14c1e1c65518b698dd1da8f596bc33e11072813466c6bf3845691509b80fb76d59309b8d39e0a93452688f6ca3a39a76f3fc52744fb73948b15783a5010203262001215820643566c206dd00227005fa5de69320616ca268043a38f08bde2e9dc45a5cafaf225820171353b2932434703726aae579fa6542432861fe591e481ea22d63997e1a529003a363616c67266373696758483046022100cc1ef43edf07de8f208c21619c78a565ddcf4150766ad58781193be8e0a742ed022100f1ed7c7243e45b7d8e5bda6b1abf10af7391789d1ef21b70bd69fed48dba4cb163783563815901973082019330820138a003020102020900859b726cb24b4c29300a06082a8648ce3d0403023047310b300906035504061302555331143012060355040a0c0b59756269636f205465737431223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e301e170d3136313230343131353530305a170d3236313230323131353530305a3047310b300906035504061302555331143012060355040a0c0b59756269636f205465737431223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e3059301306072a8648ce3d020106082a8648ce3d03010703420004ad11eb0e8852e53ad5dfed86b41e6134a18ec4e1af8f221a3c7d6e636c80ea13c3d504ff2e76211bb44525b196c44cb4849979cf6f896ecd2bb860de1bf4376ba30d300b30090603551d1304023000300a06082a8648ce3d0403020349003046022100e9a39f1b03197525f7373e10ce77e78021731b94d0c03f3fda1fd22db3d030e7022100c4faec3445a820cf43129cdb00aabefd9ae2d874f9c5d343cb2f113da23723f3') # noqa
_GA_RESP = a2b_hex('a301a26269645840fe3aac036d14c1e1c65518b698dd1da8f596bc33e11072813466c6bf3845691509b80fb76d59309b8d39e0a93452688f6ca3a39a76f3fc52744fb73948b1578364747970656a7075626c69632d6b6579025900250021f5fc0b85cd22e60623bcd7d1ca48948909249b4776eb515154e57b66ae12010000001d035846304402206765cbf6e871d3af7f01ae96f06b13c90f26f54b905c5166a2c791274fc2397102200b143893586cc799fba4da83b119eaea1bd80ac3ce88fcedb3efbd596a1f4f63') # noqa
_CRED_ID = a2b_hex('FE3AAC036D14C1E1C65518B698DD1DA8F596BC33E11072813466C6BF3845691509B80FB76D59309B8D39E0A93452688F6CA3A39A76F3FC52744FB73948B15783') # noqa
_CRED = {'type': 'public-key', 'id': _CRED_ID}
_SIGNATURE = a2b_hex('304402206765CBF6E871D3AF7F01AE96F06B13C90F26F54B905C5166A2C791274FC2397102200B143893586CC799FBA4DA83B119EAEA1BD80AC3CE88FCEDB3EFBD596A1F4F63') # noqa
class TestCTAP2(unittest.TestCase):
def test_send_cbor_ok(self):
ctap = CTAP2(mock.MagicMock())
ctap.device.call.return_value = b'\0' + cbor.dumps({1: b'response'})
self.assertEqual({1: b'response'}, ctap.send_cbor(2, b'foobar'))
ctap.device.call.assert_called_with(0x10, b'\2' + cbor.dumps(b'foobar'),
None)
def test_get_info(self):
ctap = CTAP2(mock.MagicMock())
ctap.device.call.return_value = b'\0' + _INFO
info = ctap.get_info()
ctap.device.call.assert_called_with(0x10, b'\4', None)
self.assertIsInstance(info, Info)
def test_make_credential(self):
ctap = CTAP2(mock.MagicMock())
ctap.device.call.return_value = b'\0' + _MC_RESP
resp = ctap.make_credential(1, 2, 3, 4)
ctap.device.call.assert_called_with(
0x10, b'\1' + cbor.dumps({1: 1, 2: 2, 3: 3, 4: 4}), None)
self.assertIsInstance(resp, AttestationObject)
self.assertEqual(resp, _MC_RESP)
self.assertEqual(resp.fmt, 'packed')
self.assertEqual(resp.auth_data, _AUTH_DATA_MC)
self.assertSetEqual(set(resp.att_statement.keys()),
{'alg', 'sig', 'x5c'})
def test_get_assertion(self):
ctap = CTAP2(mock.MagicMock())
ctap.device.call.return_value = b'\0' + _GA_RESP
resp = ctap.get_assertion(1, 2)
ctap.device.call.assert_called_with(
0x10, b'\2' + cbor.dumps({1: 1, 2: 2}), None)
self.assertIsInstance(resp, AssertionResponse)
self.assertEqual(resp, _GA_RESP)
self.assertEqual(resp.credential, _CRED)
self.assertEqual(resp.auth_data, _AUTH_DATA_GA)
self.assertEqual(resp.signature, _SIGNATURE)
self.assertIsNone(resp.user)
self.assertIsNone(resp.number_of_credentials)
EC_PRIV = 0x7452e599fee739d8a653f6a507343d12d382249108a651402520b72f24fe7684
EC_PUB_X = a2b_hex('44D78D7989B97E62EA993496C9EF6E8FD58B8B00715F9A89153DDD9C4657E47F') # noqa
EC_PUB_Y = a2b_hex('EC802EE7D22BD4E100F12E48537EB4E7E96ED3A47A0A3BD5F5EEAB65001664F9') # noqa
DEV_PUB_X = a2b_hex('0501D5BC78DA9252560A26CB08FCC60CBE0B6D3B8E1D1FCEE514FAC0AF675168') # noqa
DEV_PUB_Y = a2b_hex('D551B3ED46F665731F95B4532939C25D91DB7EB844BD96D4ABD4083785F8DF47') # noqa
SHARED = a2b_hex('c42a039d548100dfba521e487debcbbb8b66bb7496f8b1862a7a395ed83e1a1c') # noqa
TOKEN_ENC = a2b_hex('7A9F98E31B77BE90F9C64D12E9635040')
TOKEN = a2b_hex('aff12c6dcfbf9df52f7a09211e8865cd')
PIN_HASH_ENC = a2b_hex('afe8327ce416da8ee3d057589c2ce1a9')
class TestPinProtocolV1(unittest.TestCase):
@mock.patch('cryptography.hazmat.primitives.asymmetric.ec.generate_private_key') # noqa
def test_establish_shared_secret(self, patched_generate):
prot = PinProtocolV1(mock.MagicMock())
patched_generate.return_value = ec.derive_private_key(
EC_PRIV,
ec.SECP256R1(),
default_backend()
)
prot.ctap.client_pin.return_value = {
1: {
1: 2,
3: -25,
-1: 1,
-2: DEV_PUB_X,
-3: DEV_PUB_Y
}
}
key_agreement, shared = prot._init_shared_secret()
self.assertEqual(shared, SHARED)
self.assertEqual(key_agreement[-2], EC_PUB_X)
self.assertEqual(key_agreement[-3], EC_PUB_Y)
def test_get_pin_token(self):
prot = PinProtocolV1(mock.MagicMock())
prot._init_shared_secret = mock.Mock(return_value=({}, SHARED))
prot.ctap.client_pin.return_value = {
2: TOKEN_ENC
}
self.assertEqual(prot.get_pin_token('1234'), TOKEN)
prot.ctap.client_pin.assert_called_once()
self.assertEqual(prot.ctap.client_pin.call_args[1]['pin_hash_enc'],
PIN_HASH_ENC)
def test_set_pin(self):
prot = PinProtocolV1(mock.MagicMock())
prot._init_shared_secret = mock.Mock(return_value=({}, SHARED))
prot.set_pin('1234')
prot.ctap.client_pin.assert_called_with(
1,
3,
key_agreement={},
new_pin_enc=a2b_hex('0222fc42c6dd76a274a7057858b9b29d98e8a722ec2dc6668476168c5320473cec9907b4cd76ce7943c96ba5683943211d84471e64d9c51e54763488cd66526a'), # noqa
pin_auth=a2b_hex('7b40c084ccc5794194189ab57836475f')
)
def test_change_pin(self):
prot = PinProtocolV1(mock.MagicMock())
prot._init_shared_secret = mock.Mock(return_value=({}, SHARED))
prot.change_pin('1234', '4321')
prot.ctap.client_pin.assert_called_with(
1,
4,
key_agreement={},
new_pin_enc=a2b_hex('4280e14aac4fcbf02dd079985f0c0ffc9ea7d5f9c173fd1a4c843826f7590cb3c2d080c6923e2fe6d7a52c31ea1309d3fcca3dedae8a2ef14b6330cafc79339e'), # noqa
pin_auth=a2b_hex('fb97e92f3724d7c85e001d7f93e6490a'),
pin_hash_enc=a2b_hex('afe8327ce416da8ee3d057589c2ce1a9')
)
def test_short_pin(self):
prot = PinProtocolV1(mock.MagicMock())
with self.assertRaises(ValueError):
prot.set_pin('123')
def test_long_pin(self):
prot = PinProtocolV1(mock.MagicMock())
with self.assertRaises(ValueError):
prot.set_pin('1'*256)

48
test/test_hid.py Normal file
View File

@ -0,0 +1,48 @@
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from fido_host.hid import CtapHidDevice
import unittest
class HidTest(unittest.TestCase):
def get_device(self):
try:
devs = list(CtapHidDevice.list_devices())
assert len(devs) == 1
return devs[0]
except Exception:
self.skipTest('Tests require a single FIDO HID device')
def test_ping(self):
msg1 = b'hello world!'
msg2 = b' '
msg3 = b''
dev = self.get_device()
self.assertEqual(dev.ping(0x40, 0, 0, msg1), msg1)
self.assertEqual(dev.ping(0x40, 0, 0, msg2), msg2)
self.assertEqual(dev.ping(0x40, 0, 0, msg3), msg3)

106
test/test_rpid.py Normal file
View File

@ -0,0 +1,106 @@
# coding=utf-8
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from fido_host.rpid import verify_app_id, verify_rp_id
import unittest
class TestAppId(unittest.TestCase):
def test_valid_ids(self):
self.assertTrue(verify_app_id('https://example.com',
'https://register.example.com'))
self.assertTrue(verify_app_id('https://example.com',
'https://fido.example.com'))
self.assertTrue(verify_app_id('https://example.com',
'https://www.example.com:444'))
self.assertTrue(verify_app_id(
'https://companyA.hosting.example.com',
'https://fido.companyA.hosting.example.com'
))
self.assertTrue(verify_app_id(
'https://companyA.hosting.example.com',
'https://xyz.companyA.hosting.example.com'
))
def test_invalid_ids(self):
self.assertFalse(verify_app_id('https://example.com',
'http://example.com'))
self.assertFalse(verify_app_id('https://example.com',
'http://www.example.com'))
self.assertFalse(verify_app_id('https://example.com',
'https://example-test.com'))
self.assertFalse(verify_app_id(
'https://companyA.hosting.example.com',
'https://register.example.com'
))
self.assertFalse(verify_app_id(
'https://companyA.hosting.example.com',
'https://companyB.hosting.example.com'
))
def test_effective_tld_names(self):
self.assertFalse(verify_app_id(
'https://appspot.com',
'https://foo.appspot.com'
))
self.assertFalse(verify_app_id(
'https://co.uk',
'https://example.co.uk'
))
class TestRpId(unittest.TestCase):
def test_valid_ids(self):
self.assertTrue(verify_rp_id('example.com',
'https://register.example.com'))
self.assertTrue(verify_rp_id('example.com',
'https://fido.example.com'))
self.assertTrue(verify_rp_id('example.com',
'https://www.example.com:444'))
def test_invalid_ids(self):
self.assertFalse(verify_rp_id('example.com',
'http://example.com'))
self.assertFalse(verify_rp_id('example.com',
'http://www.example.com'))
self.assertFalse(verify_rp_id('example.com',
'https://example-test.com'))
self.assertFalse(verify_rp_id(
'companyA.hosting.example.com',
'https://register.example.com'
))
self.assertFalse(verify_rp_id(
'companyA.hosting.example.com',
'https://companyB.hosting.example.com'
))

95
test/test_u2f.py Normal file
View File

@ -0,0 +1,95 @@
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from fido_host.u2f import CTAP1, ApduError
from binascii import a2b_hex
import unittest
import mock
class TestCTAP1(unittest.TestCase):
def test_send_apdu_ok(self):
ctap = CTAP1(mock.MagicMock())
ctap.device.call.return_value = b'response\x90\x00'
self.assertEqual(b'response', ctap.send_apdu(1, 2, 3, 4, b'foobar'))
ctap.device.call.assert_called_with(0x03, b'\1\2\3\4\0\0\6foobar\0\0')
def test_send_apdu_err(self):
ctap = CTAP1(mock.MagicMock())
ctap.device.call.return_value = b'err\x6a\x80'
try:
ctap.send_apdu(1, 2, 3, 4, b'foobar')
self.fail('send_apdu did not raise error')
except ApduError as e:
self.assertEqual(e.code, 0x6a80)
self.assertEqual(e.data, b'err')
ctap.device.call.assert_called_with(0x03, b'\1\2\3\4\0\0\6foobar\0\0')
def test_get_version(self):
ctap = CTAP1(mock.MagicMock())
ctap.device.call.return_value = b'U2F_V2\x90\x00'
self.assertEqual('U2F_V2', ctap.get_version())
ctap.device.call.assert_called_with(0x03, b'\0\3\0\0\0\0\0\0\0')
def test_register(self):
ctap = CTAP1(mock.MagicMock())
ctap.device.call.return_value = a2b_hex(b'0504b174bc49c7ca254b70d2e5c207cee9cf174820ebd77ea3c65508c26da51b657c1cc6b952f8621697936482da0a6d3d3826a59095daf6cd7c03e2e60385d2f6d9402a552dfdb7477ed65fd84133f86196010b2215b57da75d315b7b9e8fe2e3925a6019551bab61d16591659cbaf00b4950f7abfe6660e2e006f76868b772d70c253082013c3081e4a003020102020a47901280001155957352300a06082a8648ce3d0403023017311530130603550403130c476e756262792050696c6f74301e170d3132303831343138323933325a170d3133303831343138323933325a3031312f302d0603550403132650696c6f74476e756262792d302e342e312d34373930313238303030313135353935373335323059301306072a8648ce3d020106082a8648ce3d030107034200048d617e65c9508e64bcc5673ac82a6799da3c1446682c258c463fffdf58dfd2fa3e6c378b53d795c4a4dffb4199edd7862f23abaf0203b4b8911ba0569994e101300a06082a8648ce3d0403020347003044022060cdb6061e9c22262d1aac1d96d8c70829b2366531dda268832cb836bcd30dfa0220631b1459f09e6330055722c8d89b7f48883b9089b88d60d1d9795902b30410df304502201471899bcc3987e62e8202c9b39c33c19033f7340352dba80fcab017db9230e402210082677d673d891933ade6f617e5dbde2e247e70423fd5ad7804a6d3d3961ef871') + b'\x90\x00' # noqa
resp = ctap.register(b'\1'*32, b'\2'*32)
ctap.device.call.assert_called_with(0x03, b'\0\1\0\0\0\0\x40' +
b'\1'*32 + b'\2'*32 + b'\0\0')
self.assertEqual(resp.public_key, a2b_hex(b'04b174bc49c7ca254b70d2e5c207cee9cf174820ebd77ea3c65508c26da51b657c1cc6b952f8621697936482da0a6d3d3826a59095daf6cd7c03e2e60385d2f6d9')) # noqa
self.assertEqual(resp.key_handle, a2b_hex(b'2a552dfdb7477ed65fd84133f86196010b2215b57da75d315b7b9e8fe2e3925a6019551bab61d16591659cbaf00b4950f7abfe6660e2e006f76868b772d70c25')) # noqa
self.assertEqual(resp.certificate, a2b_hex(b'3082013c3081e4a003020102020a47901280001155957352300a06082a8648ce3d0403023017311530130603550403130c476e756262792050696c6f74301e170d3132303831343138323933325a170d3133303831343138323933325a3031312f302d0603550403132650696c6f74476e756262792d302e342e312d34373930313238303030313135353935373335323059301306072a8648ce3d020106082a8648ce3d030107034200048d617e65c9508e64bcc5673ac82a6799da3c1446682c258c463fffdf58dfd2fa3e6c378b53d795c4a4dffb4199edd7862f23abaf0203b4b8911ba0569994e101300a06082a8648ce3d0403020347003044022060cdb6061e9c22262d1aac1d96d8c70829b2366531dda268832cb836bcd30dfa0220631b1459f09e6330055722c8d89b7f48883b9089b88d60d1d9795902b30410df')) # noqa
self.assertEqual(resp.signature, a2b_hex(b'304502201471899bcc3987e62e8202c9b39c33c19033f7340352dba80fcab017db9230e402210082677d673d891933ade6f617e5dbde2e247e70423fd5ad7804a6d3d3961ef871')) # noqa
def test_authenticate(self):
ctap = CTAP1(mock.MagicMock())
ctap.device.call.return_value = a2b_hex(b'0100000001304402204b5f0cd17534cedd8c34ee09570ef542a353df4436030ce43d406de870b847780220267bb998fac9b7266eb60e7cb0b5eabdfd5ba9614f53c7b22272ec10047a923f') + b'\x90\x00' # noqa
resp = ctap.authenticate(b'\1'*32, b'\2'*32, b'\3'*64)
ctap.device.call.assert_called_with(0x03, b'\0\2\3\0\0\0\x81' +
b'\1'*32 + b'\2'*32 + b'\x40' +
b'\3'*64 + b'\0\0')
self.assertEqual(resp.user_presence, 1)
self.assertEqual(resp.counter, 1)
self.assertEqual(resp.signature, a2b_hex(b'304402204b5f0cd17534cedd8c34ee09570ef542a353df4436030ce43d406de870b847780220267bb998fac9b7266eb60e7cb0b5eabdfd5ba9614f53c7b22272ec10047a923f')) # noqa
ctap.authenticate(b'\1'*32, b'\2'*32, b'\3'*8)
ctap.device.call.assert_called_with(0x03, b'\0\2\3\0\0\0\x49' +
b'\1'*32 + b'\2'*32 + b'\x08' +
b'\3'*8 + b'\0\0')
ctap.authenticate(b'\1'*32, b'\2'*32, b'\3'*8, True)
ctap.device.call.assert_called_with(0x03, b'\0\2\7\0\0\0\x49' +
b'\1'*32 + b'\2'*32 + b'\x08' +
b'\3'*8 + b'\0\0')

118
test/test_utils.py Normal file
View File

@ -0,0 +1,118 @@
# coding=utf-8
# Copyright (c) 2013 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import unittest
from binascii import a2b_hex
from threading import Event
from fido_host.utils import (
Timeout,
hmac_sha256,
sha256,
websafe_encode,
websafe_decode
)
class TestSha256(unittest.TestCase):
def test_sha256_vectors(self):
self.assertEqual(sha256(b'abc'), a2b_hex(b'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad')) # noqa
self.assertEqual(sha256(b'abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'), a2b_hex(b'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1')) # noqa
class TestHmacSha256(unittest.TestCase):
def test_hmac_sha256_vectors(self):
self.assertEqual(hmac_sha256(
b'\x0b'*20,
b'Hi There'
), a2b_hex(b'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7')) # noqa
self.assertEqual(hmac_sha256(
b'Jefe',
b'what do ya want for nothing?'
), a2b_hex(b'5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843')) # noqa
class TestWebSafe(unittest.TestCase):
# Base64 vectors adapted from https://tools.ietf.org/html/rfc4648#section-10
def test_websafe_decode(self):
self.assertEqual(websafe_decode(b''), b'')
self.assertEqual(websafe_decode(b'Zg'), b'f')
self.assertEqual(websafe_decode(b'Zm8'), b'fo')
self.assertEqual(websafe_decode(b'Zm9v'), b'foo')
self.assertEqual(websafe_decode(b'Zm9vYg'), b'foob')
self.assertEqual(websafe_decode(b'Zm9vYmE'), b'fooba')
self.assertEqual(websafe_decode(b'Zm9vYmFy'), b'foobar')
def test_websafe_decode_unicode(self):
self.assertEqual(websafe_decode(u''), b'')
self.assertEqual(websafe_decode(u'Zm9vYmFy'), b'foobar')
def test_websafe_encode(self):
self.assertEqual(websafe_encode(b''), u'')
self.assertEqual(websafe_encode(b'f'), u'Zg')
self.assertEqual(websafe_encode(b'fo'), u'Zm8')
self.assertEqual(websafe_encode(b'foo'), u'Zm9v')
self.assertEqual(websafe_encode(b'foob'), u'Zm9vYg')
self.assertEqual(websafe_encode(b'fooba'), u'Zm9vYmE')
self.assertEqual(websafe_encode(b'foobar'), u'Zm9vYmFy')
def test_websafe_encode_unicode(self):
self.assertEqual(websafe_encode(u''), u'')
self.assertEqual(websafe_encode(u'foobar'), u'Zm9vYmFy')
class TestTimeout(unittest.TestCase):
def test_event(self):
event = Event()
timeout = Timeout(event)
self.assertIsNone(timeout.timer)
with timeout as e:
self.assertIs(event, e)
self.assertFalse(e.is_set())
self.assertFalse(event.is_set())
def test_timer_stops(self):
timeout = Timeout(10)
self.assertFalse(timeout.timer.is_alive())
with timeout as e:
self.assertTrue(timeout.timer.is_alive())
self.assertFalse(e.is_set())
self.assertFalse(timeout.timer.is_alive())
self.assertFalse(e.is_set())
def test_timer_triggers(self):
with Timeout(0.01) as e:
self.assertTrue(e.wait(0.02))
self.assertTrue(e.is_set())