mirror of https://github.com/Yubico/python-fido2
Initial import.
This commit is contained in:
commit
1b419592bf
|
@ -0,0 +1,15 @@
|
||||||
|
*.pyc
|
||||||
|
*.egg
|
||||||
|
*.egg-info
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.eggs/
|
||||||
|
.ropeproject/
|
||||||
|
ChangeLog
|
||||||
|
man/*.1
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
|
@ -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/.*'
|
|
@ -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
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -0,0 +1,5 @@
|
||||||
|
include COPYING*
|
||||||
|
include NEWS
|
||||||
|
include ChangeLog
|
||||||
|
include fido_host/public_suffix_list.dat
|
||||||
|
include examples/*
|
|
@ -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/.
|
|
@ -0,0 +1 @@
|
||||||
|
README
|
|
@ -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
|
|
@ -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)
|
|
@ -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')
|
|
@ -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'
|
|
@ -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:])
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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()
|
|
@ -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:]
|
|
@ -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)
|
|
@ -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)
|
|
@ -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()
|
|
@ -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,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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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')
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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'
|
||||||
|
))
|
|
@ -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')
|
|
@ -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())
|
Loading…
Reference in New Issue