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