├── .gitignore ├── Changes.rst ├── LICENSE ├── Makefile ├── README.rst ├── common.mk ├── exile ├── __init__.py ├── botocore_signers.py ├── cli.py ├── exceptions.py ├── scard │ ├── __init__.py │ └── const.py ├── util │ └── __init__.py ├── version.py └── ykoath │ ├── __init__.py │ └── const.py ├── setup.cfg ├── setup.py └── test └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Reminder: 2 | # - A leading slash means the pattern is anchored at the root. 3 | # - No leading slash means the pattern matches at any depth. 4 | 5 | # Python files 6 | *.pyc 7 | __pycache__/ 8 | .tox/ 9 | *.egg-info/ 10 | /build/ 11 | /dist/ 12 | /.eggs/ 13 | .coverage 14 | 15 | # IDE project files 16 | /.pydevproject 17 | 18 | # vim python-mode plugin 19 | /.ropeproject 20 | 21 | # IntelliJ IDEA / PyCharm project files 22 | /.idea 23 | /*.iml 24 | 25 | # JS/node/npm/web dev files 26 | node_modules 27 | npm-debug.log 28 | 29 | # OS X metadata files 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /Changes.rst: -------------------------------------------------------------------------------- 1 | Changes for v0.1.1 (2021-09-24) 2 | =============================== 3 | 4 | - Properly recognize newer yubikeys 5 | 6 | - Add support for SET_CODE and VALIDATE 7 | 8 | Changes for v0.1.0 (2019-03-03) 9 | =============================== 10 | 11 | - Add TOTP helpers and docs 12 | 13 | - Manage multiple yubikeys and absent key condition 14 | 15 | - Include signers in package 16 | 17 | - Support HmacV1 and multipart responses 18 | 19 | - Add Linux install instructions 20 | 21 | Changes for v0.0.3 (2019-02-26) 22 | =============================== 23 | 24 | - Begin tests 25 | 26 | Changes for v0.0.2 (2019-02-26) 27 | =============================== 28 | 29 | - Initial release. 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright [yyyy] [name of copyright owner] 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test_deps: 2 | pip install .[test] 3 | 4 | lint: test_deps 5 | ./setup.py flake8 6 | mypy $$(python setup.py --name) --ignore-missing-imports 7 | 8 | test: test_deps lint 9 | coverage run --source=$$(python setup.py --name) ./test/test.py 10 | 11 | init_docs: 12 | cd docs; sphinx-quickstart 13 | 14 | docs: 15 | $(MAKE) -C docs html 16 | 17 | install: clean 18 | pip install wheel 19 | python setup.py bdist_wheel 20 | pip install --upgrade dist/*.whl 21 | 22 | clean: 23 | -rm -rf build dist 24 | -rm -rf *.egg-info 25 | 26 | .PHONY: lint test test_deps docs install clean 27 | 28 | include common.mk 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Exile: Python YubiKey AWS signature library 2 | =========================================== 3 | 4 | **Exile** stores your AWS access key on your YubiKey device and uses it to sign your AWS API requests, protecting you 5 | against credential theft. 6 | 7 | Installation 8 | ------------ 9 | :: 10 | 11 | pip install exile 12 | 13 | On Linux, install `pcsc-lite `_ 14 | (``apt install pcscd``, ``yum install pcsc-lite``). 15 | 16 | Exile requires Python 3.6+. 17 | 18 | Synopsis 19 | -------- 20 | 21 | .. code-block:: python 22 | 23 | import boto3, botocore.auth 24 | from exile import YKOATH, botocore_signers 25 | 26 | def write_active_aws_key_to_yubikey(): 27 | credentials = boto3.Session().get_credentials() 28 | 29 | key_name = "exile-{}-SigV4".format(credentials.access_key) 30 | secret = b"AWS4" + credentials.secret_key.encode() 31 | print("Writing YubiKey OATH SigV4 credential", key_name, "for", credentials.access_key) 32 | YKOATH().put(key_name, secret, algorithm=YKOATH.Algorithm.SHA256) 33 | 34 | key_name = "exile-{}-HmacV1".format(credentials.access_key) 35 | secret = credentials.secret_key.encode() 36 | print("Writing YubiKey OATH HmacV1 credential", key_name, "for", credentials.access_key) 37 | YKOATH().put(key_name, secret, algorithm=YKOATH.Algorithm.SHA1) 38 | 39 | write_active_aws_key_to_yubikey() 40 | botocore_signers.install() 41 | 42 | print("Using YubiKey credential to perform AWS call") 43 | print(boto3.client("sts").get_caller_identity()) 44 | 45 | print("Using YubiKey credential to presign an S3 URL") 46 | print(boto3.client("s3").generate_presigned_url(ClientMethod="get_object", Params={"Bucket": "foo", "Key": "bar"})) 47 | 48 | Storing the secret key on a YubiKey instead of in the home directory (``~/.aws/credentials``) protects it in case the 49 | host computer or its filesystem is compromised. The YubiKey acts as an `HSM 50 | `_, and can optionally be further configured to require user 51 | interaction (pressing a button on the key) to sign the request:: 52 | 53 | YKOATH().put(key_name, secret, algorithm=YKOATH.Algorithm.SHA256, require_touch=True) 54 | 55 | TOTP 56 | ---- 57 | 58 | Because exile uses the `YubiKey OATH `_ protocol, you can also use it to store 59 | `TOTP `_ 60 | `2FA `_ tokens, generate and verify codes:: 61 | 62 | from exile import TOTP 63 | TOTP().save("google", "JBSWY3DPEHPK3PXP") # Or TOTP.save_otpauth_uri("otpauth://...") 64 | TOTP().get("google") # Returns a standard 6-digit TOTP code as a string 65 | TOTP().verify("260153", label="google", at=datetime.datetime.fromtimestamp(1297553958)) 66 | 67 | Authors 68 | ------- 69 | * Andrey Kislyuk 70 | 71 | Links 72 | ----- 73 | * `Project home page (GitHub) `_ 74 | * `Documentation (Read the Docs) `_ 75 | * `Package distribution (PyPI) `_ 76 | * `Change log `_ 77 | 78 | Bugs 79 | ---- 80 | Please report bugs, issues, feature requests, etc. on `GitHub `_. 81 | 82 | License 83 | ------- 84 | Licensed under the terms of the `Apache License, Version 2.0 `_. 85 | 86 | .. image:: https://img.shields.io/travis/com/pyauth/exile.svg 87 | :target: https://travis-ci.com/pyauth/exile 88 | .. image:: https://codecov.io/github/pyauth/exile/coverage.svg?branch=master 89 | :target: https://codecov.io/github/pyauth/exile?branch=master 90 | .. image:: https://img.shields.io/pypi/v/exile.svg 91 | :target: https://pypi.python.org/pypi/exile 92 | .. image:: https://img.shields.io/pypi/l/exile.svg 93 | :target: https://pypi.python.org/pypi/exile 94 | .. image:: https://readthedocs.org/projects/exile/badge/?version=latest 95 | :target: https://exile.readthedocs.io/ 96 | -------------------------------------------------------------------------------- /common.mk: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash -eo pipefail 2 | 3 | release-major: 4 | $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v@{[$$1+1]}.0.0"')) 5 | $(MAKE) release 6 | 7 | release-minor: 8 | $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v$$1.@{[$$2+1]}.0"')) 9 | $(MAKE) release 10 | 11 | release-patch: 12 | $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v$$1.$$2.@{[$$3+1]}"')) 13 | $(MAKE) release 14 | 15 | release: 16 | @if ! git diff --cached --exit-code; then echo "Commit staged files before proceeding"; exit 1; fi 17 | @if [[ -z $$TAG ]]; then echo "Use release-{major,minor,patch}"; exit 1; fi 18 | @if ! type -P pandoc; then echo "Please install pandoc"; exit 1; fi 19 | @if ! type -P sponge; then echo "Please install moreutils"; exit 1; fi 20 | @if ! type -P http; then echo "Please install httpie"; exit 1; fi 21 | @if ! type -P twine; then echo "Please install twine"; exit 1; fi 22 | $(eval REMOTE=$(shell git remote get-url origin | perl -ne '/([^\/\:]+\/[^\/\:]+?)(\.git)?$$/; print $$1')) 23 | $(eval GIT_USER=$(shell git config --get user.email)) 24 | $(eval GH_AUTH=$(shell if grep -q '@github.com' ~/.git-credentials; then echo $$(grep '@github.com' ~/.git-credentials | python3 -c 'import sys, urllib.parse as p; print(p.urlparse(sys.stdin.read()).netloc.split("@")[0])'); else echo $(GIT_USER); fi)) 25 | $(eval RELEASES_API=https://api.github.com/repos/${REMOTE}/releases) 26 | $(eval UPLOADS_API=https://uploads.github.com/repos/${REMOTE}/releases) 27 | git pull 28 | git clean -x --force $$(python setup.py --name) 29 | sed -i -e "s/version=\([\'\"]\)[0-9]*\.[0-9]*\.[0-9]*/version=\1$${TAG:1}/" setup.py 30 | git add setup.py 31 | TAG_MSG=$$(mktemp); \ 32 | echo "# Changes for ${TAG} ($$(date +%Y-%m-%d))" > $$TAG_MSG; \ 33 | git log --pretty=format:%s $$(git describe --abbrev=0)..HEAD >> $$TAG_MSG; \ 34 | $${EDITOR:-emacs} $$TAG_MSG; \ 35 | if [[ -f Changes.md ]]; then cat $$TAG_MSG <(echo) Changes.md | sponge Changes.md; git add Changes.md; fi; \ 36 | if [[ -f Changes.rst ]]; then cat <(pandoc --from markdown --to rst $$TAG_MSG) <(echo) Changes.rst | sponge Changes.rst; git add Changes.rst; fi; \ 37 | git commit -m ${TAG}; \ 38 | git tag --sign --annotate --file $$TAG_MSG ${TAG} 39 | git push --follow-tags 40 | http --check-status --auth ${GH_AUTH} ${RELEASES_API} tag_name=${TAG} name=${TAG} \ 41 | body="$$(git tag --list ${TAG} -n99 | perl -pe 's/^\S+\s*// if $$. == 1' | sed 's/^\s\s\s\s//')" 42 | $(MAKE) install 43 | http --check-status --auth ${GH_AUTH} POST ${UPLOADS_API}/$$(http --auth ${GH_AUTH} ${RELEASES_API}/latest | jq .id)/assets \ 44 | name==$$(basename dist/*.whl) label=="Python Wheel" < dist/*.whl 45 | $(MAKE) release-pypi 46 | 47 | release-pypi: 48 | python setup.py sdist bdist_wheel 49 | twine upload dist/*.tar.gz dist/*.whl --sign --verbose 50 | 51 | .PHONY: release 52 | -------------------------------------------------------------------------------- /exile/__init__.py: -------------------------------------------------------------------------------- 1 | from .scard import SCardManager 2 | from .ykoath import YKOATH, TOTP 3 | -------------------------------------------------------------------------------- /exile/botocore_signers.py: -------------------------------------------------------------------------------- 1 | import botocore.auth 2 | from botocore.compat import encodebytes 3 | from . import YKOATH 4 | 5 | class YKSigV4Auth(botocore.auth.SigV4Auth): 6 | def signature(self, string_to_sign, request): 7 | if not hasattr(self, "_ykoath"): 8 | self._ykoath = YKOATH() 9 | key_name = "exile-{}-SigV4".format(self.credentials.access_key) 10 | k_date = self._ykoath.calculate(key_name, 11 | request.context["timestamp"][0:8].encode(), 12 | want_truncated_response=False) 13 | k_region = self._sign(k_date, self._region_name) 14 | k_service = self._sign(k_region, self._service_name) 15 | k_signing = self._sign(k_service, "aws4_request") 16 | return self._sign(k_signing, string_to_sign, hex=True) 17 | 18 | class YKHmacV1Auth(botocore.auth.HmacV1Auth): 19 | def sign_string(self, string_to_sign): 20 | if not hasattr(self, "_ykoath"): 21 | self._ykoath = YKOATH() 22 | key_name = "exile-{}-HmacV1".format(self.credentials.access_key) 23 | digest = self._ykoath.calculate(key_name, string_to_sign.encode(), want_truncated_response=False) 24 | return encodebytes(digest).strip().decode("utf-8") 25 | 26 | def install(): 27 | botocore.auth.SigV4Auth.signature = YKSigV4Auth.signature 28 | botocore.auth.HmacV1Auth.sign_string = YKHmacV1Auth.sign_string 29 | -------------------------------------------------------------------------------- /exile/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | -------------------------------------------------------------------------------- /exile/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExileError(Exception): 2 | pass 3 | 4 | class SCardError(ExileError): 5 | pass 6 | 7 | class YKOATHError(ExileError): 8 | pass 9 | -------------------------------------------------------------------------------- /exile/scard/__init__.py: -------------------------------------------------------------------------------- 1 | import platform, logging 2 | from enum import Enum 3 | from binascii import b2a_hex, a2b_hex 4 | from ctypes import (cdll, c_void_p, POINTER, c_ulong, c_char, c_uint32, byref, create_string_buffer, c_wchar, 5 | cast, c_char_p) 6 | from ..exceptions import SCardError 7 | from .const import SCardConstants 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | def i2b(i): 12 | return i.to_bytes(1, byteorder="little") 13 | 14 | def b2i(data): 15 | return int(b2a_hex(data), 16) 16 | 17 | class SCARDCONTEXT(c_uint32): 18 | pass 19 | 20 | class SCARDHANDLE(c_uint32): 21 | pass 22 | 23 | class SCard(SCardConstants): 24 | """ 25 | See https://docs.microsoft.com/en-us/windows/desktop/api/winscard/ 26 | """ 27 | def __init__(self): 28 | if platform.system() == "Darwin": 29 | lib_name = "PCSC.framework/PCSC" 30 | elif platform.system() == "Linux": 31 | lib_name = "libpcsclite.so" 32 | elif platform.system() == "Windows": 33 | lib_name = "winscard.dll" 34 | self.pcsc = cdll.LoadLibrary(lib_name) 35 | 36 | def __call__(self, method, *args): 37 | logger.debug(method + str(args)) 38 | status = getattr(self.pcsc, method)(*args) 39 | if status != self.SCardStatus.S_SUCCESS.value: 40 | raise SCardError(self.SCardStatus(status)) 41 | return status 42 | 43 | def EstablishContext(self, dwScope: SCardConstants.Scope, 44 | pvReserved1: c_void_p, 45 | pvReserved2: c_void_p, 46 | phContext: SCARDCONTEXT) -> SCardConstants.SCardStatus: 47 | """ 48 | The SCardEstablishContext function establishes the resource manager context (the scope) within which database 49 | operations are performed. 50 | """ 51 | return self("SCardEstablishContext", dwScope, pvReserved1, pvReserved2, phContext) 52 | 53 | def ReleaseContext(self, hContext: SCARDCONTEXT) -> SCardConstants.SCardStatus: 54 | """ 55 | The SCardReleaseContext function closes an established resource manager context, freeing any resources allocated 56 | under that context, including SCARDHANDLE objects and memory allocated using the SCARD_AUTOALLOCATE length 57 | designator. 58 | """ 59 | return self("SCardReleaseContext", hContext) 60 | 61 | def IsValidContext(self, hContext: SCARDCONTEXT) -> SCardConstants.SCardStatus: 62 | """ 63 | The SCardIsValidContext function determines whether a smart card context handle is valid. 64 | """ 65 | return self("SCardIsValidContext", hContext) 66 | 67 | def SetTimeout(self, hContext: SCARDCONTEXT, dwTimeout: int) -> SCardConstants.SCardStatus: 68 | return self("SCardSetTimeout", hContext, dwTimeout) 69 | 70 | def Connect(self, hContext: SCARDCONTEXT, 71 | szReader: c_char_p, 72 | dwShareMode: SCardConstants.ShareMode, 73 | dwPreferredProtocols: SCardConstants.Protocol, 74 | phCard: SCARDHANDLE, 75 | pdwActiveProtocol: SCardConstants.Protocol) -> SCardConstants.SCardStatus: 76 | """ 77 | The SCardConnect function establishes a connection (using a specific resource manager context) between the 78 | calling application and a smart card contained by a specific reader. If no card exists in the specified reader, 79 | an error is returned. 80 | """ 81 | return self("SCardConnect", hContext, szReader, dwShareMode, dwPreferredProtocols, phCard, pdwActiveProtocol) 82 | 83 | def Reconnect(self, hCard: SCARDHANDLE, 84 | dwShareMode: SCardConstants.ShareMode, 85 | dwPreferredProtocols: SCardConstants.Protocol, 86 | dwInitialization: SCardConstants.Disposition, 87 | pdwActiveProtocol: SCardConstants.Protocol) -> SCardConstants.SCardStatus: 88 | """ 89 | The SCardReconnect function reestablishes an existing connection between the calling application and a smart 90 | card. This function moves a card handle from direct access to general access, or acknowledges and clears an 91 | error condition that is preventing further access to the card. 92 | """ 93 | return self("SCardConnect", hCard, dwShareMode, dwPreferredProtocols, dwInitialization, pdwActiveProtocol) 94 | 95 | def Disconnect(self, hCard: SCARDHANDLE, dwDisposition: SCardConstants.Disposition) -> SCardConstants.SCardStatus: 96 | """ 97 | The SCardDisconnect function terminates a connection previously opened between the calling application and a 98 | smart card in the target reader. 99 | """ 100 | return self("SCardDisconnect", hCard, dwDisposition) 101 | 102 | def BeginTransaction(self, hCard: SCARDHANDLE) -> SCardConstants.SCardStatus: 103 | """ 104 | The SCardBeginTransaction function starts a transaction. 105 | 106 | The function waits for the completion of all other transactions before it begins. After the transaction starts, 107 | all other applications are blocked from accessing the smart card while the transaction is in progress. 108 | """ 109 | return self("SCardBeginTransaction", hCard) 110 | 111 | def EndTransaction(self, hCard: SCARDHANDLE, 112 | dwDisposition: SCardConstants.Disposition) -> SCardConstants.SCardStatus: 113 | """ 114 | The SCardEndTransaction function completes a previously declared transaction, allowing other applications to 115 | resume interactions with the card. 116 | """ 117 | return self("SCardEndTransaction", hCard, dwDisposition) 118 | 119 | def CancelTransaction(self, hCard: SCARDHANDLE) -> SCardConstants.SCardStatus: 120 | raise NotImplementedError() 121 | 122 | def Status(self, hCard: SCARDHANDLE, 123 | mszReaderNames: str, 124 | pcchReaderLen, 125 | pdwState: SCardConstants.CardState, 126 | pdwProtocol: SCardConstants.Protocol, 127 | pbAtr, 128 | pcbAtrLen) -> SCardConstants.SCardStatus: 129 | """ 130 | The SCardStatus function provides the current status of a smart card in a reader. You can call it any time 131 | after a successful call to SCardConnect and before a successful call to SCardDisconnect. It does not affect the 132 | state of the reader or reader driver. 133 | """ 134 | return self("SCardStatus", hCard, mszReaderNames, pcchReaderLen, pdwState, pdwProtocol, pbAtr, pcbAtrLen) 135 | 136 | def GetStatusChange(self, hContext: SCARDCONTEXT, 137 | dwTimeout, 138 | rgReaderStates: SCardConstants.ReaderState, 139 | cReaders) -> SCardConstants.SCardStatus: 140 | return self("SCardGetStatusChange", hContext, dwTimeout, rgReaderStates, cReaders) 141 | 142 | def Control(self, hCard: SCARDHANDLE, 143 | dwControlCode, 144 | pbSendBuffer, 145 | cbSendLength, 146 | pbRecvBuffer, 147 | cbRecvLength, 148 | lpBytesReturned) -> SCardConstants.SCardStatus: 149 | """ 150 | The SCardControl function gives you direct control of the reader. You can call it any time after a successful 151 | call to SCardConnect and before a successful call to SCardDisconnect. The effect on the state of the reader 152 | depends on the control code. 153 | """ 154 | return self("SCardControl", hCard, dwControlCode, pbSendBuffer, cbSendLength, pbRecvBuffer, cbRecvLength, 155 | lpBytesReturned) 156 | 157 | def Transmit(self, hCard: SCARDHANDLE, 158 | pioSendPci, 159 | pbSendBuffer, 160 | cbSendLength, 161 | pioRecvPci, 162 | pbRecvBuffer, 163 | pcbRecvLength) -> SCardConstants.SCardStatus: 164 | """ 165 | The SCardTransmit function sends a service request to the smart card and expects to receive data back from the 166 | card. 167 | """ 168 | return self("SCardTransmit", hCard, pioSendPci, pbSendBuffer, cbSendLength, pioRecvPci, pbRecvBuffer, 169 | pcbRecvLength) 170 | 171 | def ListReaderGroups(self, hContext: SCARDCONTEXT, mszGroups, pcchGroups) -> SCardConstants.SCardStatus: 172 | return self("SCardListReaderGroups", hContext, mszGroups, pcchGroups) 173 | 174 | def ListReaders(self, hContext: SCARDCONTEXT, mszGroups, mszReaders, pcchReaders) -> SCardConstants.SCardStatus: 175 | """ 176 | The SCardListReaders function provides the list of readers within a set of named reader groups, eliminating 177 | duplicates. 178 | 179 | The caller supplies a list of reader groups, and receives the list of readers within the named groups. 180 | Unrecognized group names are ignored. This function only returns readers within the named groups that 181 | are currently attached to the system and available for use. 182 | """ 183 | return self("SCardListReaders", hContext, mszGroups, mszReaders, pcchReaders) 184 | 185 | def Cancel(self, hContext: SCARDCONTEXT) -> SCardConstants.SCardStatus: 186 | """ 187 | The SCardCancel function terminates all outstanding actions within a specific resource manager context. 188 | 189 | The only requests that you can cancel are those that require waiting for external action by the smart card or 190 | user. Any such outstanding action requests will terminate with a status indication that the action was 191 | canceled. This is especially useful to force outstanding SCardGetStatusChange calls to terminate. 192 | """ 193 | return self("SCardCancel", hContext) 194 | 195 | def GetAttrib(self, hCard: SCARDHANDLE, dwAttrId, pbAttr, pcbAttrLen) -> SCardConstants.SCardStatus: 196 | """ 197 | The SCardGetAttrib function retrieves the current reader attributes for the given handle. It does not affect the 198 | state of the reader, driver, or card. 199 | """ 200 | return self("SCardGetAttrib", hCard, dwAttrId, pbAttr, pcbAttrLen) 201 | 202 | def SetAttrib(self, hCard: SCARDHANDLE, dwAttrId, pbAttr, cbAttrLen) -> SCardConstants.SCardStatus: 203 | """ 204 | The SCardSetAttrib function sets the given reader attribute for the given handle. It does not affect the state 205 | of the reader, reader driver, or smart card. Not all attributes are supported by all readers (nor can they be 206 | set at all times) as many of the attributes are under direct control of the transport protocol. 207 | """ 208 | return self("SCardSetAttrib", hCard, dwAttrId, pbAttr, cbAttrLen) 209 | 210 | 211 | class SCardManager(SCard): 212 | def __init__(self): 213 | SCard.__init__(self) 214 | self.ctx = SCARDCONTEXT() 215 | self.protocol = c_ulong() 216 | self.EstablishContext(dwScope=self.Scope.SYSTEM, pvReserved1=0, pvReserved2=0, phContext=byref(self.ctx)) 217 | 218 | def _split_multi_string(self, ms): 219 | p = cast(ms, POINTER(c_char)) 220 | return p[:len(ms)].split(b"\0") 221 | 222 | def _get_send_pci(self): 223 | if self.protocol.value == self.Protocol.T0: 224 | return self.pcsc.g_rgSCardT0Pci 225 | elif self.protocol.value == self.Protocol.T1: 226 | return self.pcsc.g_rgSCardT1Pci 227 | 228 | def __iter__(self): 229 | pcch_readers = c_uint32() 230 | self.ListReaders(hContext=self.ctx, mszGroups=0, mszReaders=0, pcchReaders=byref(pcch_readers)) 231 | s = create_string_buffer(b"\0" * pcch_readers.value) 232 | self.ListReaders(hContext=self.ctx, mszGroups=0, mszReaders=s, pcchReaders=byref(pcch_readers)) 233 | for reader in self._split_multi_string(s): 234 | if reader: 235 | yield SCardReader(name=reader.decode(), manager=self) 236 | 237 | 238 | class SCardReader(SCard): 239 | def __init__(self, name: str, manager: SCardManager) -> None: 240 | SCard.__init__(self) 241 | self.name = name 242 | self.manager = manager 243 | self.handle = SCARDHANDLE() 244 | 245 | def __enter__(self): 246 | self.Connect(hContext=self.manager.ctx, 247 | szReader=c_char_p(self.name.encode()), 248 | dwShareMode=self.ShareMode.SHARED, 249 | dwPreferredProtocols=self.Protocol.ANY, 250 | phCard=byref(self.handle), 251 | pdwActiveProtocol=byref(self.manager.protocol)) 252 | 253 | def __exit__(self, exc_type, exc_value, traceback): 254 | self.Disconnect(hCard=self.handle, dwDisposition=self.Disposition.LEAVE_CARD) 255 | 256 | def send_apdu(self, cla, ins, p1, p2, data): 257 | send_buf = create_string_buffer(i2b(cla) + i2b(ins) + i2b(p1) + i2b(p2) + i2b(len(data)) + data) 258 | recv_buf = create_string_buffer(b"\0" * self.MAX_BUFFER_SIZE_EXTENDED) 259 | recv_len = c_ulong(len(recv_buf)) 260 | self.Transmit(hCard=self.handle, 261 | pioSendPci=self.manager._get_send_pci(), 262 | pbSendBuffer=send_buf, 263 | cbSendLength=len(send_buf), 264 | pioRecvPci=0, 265 | pbRecvBuffer=recv_buf, 266 | pcbRecvLength=byref(recv_len)) 267 | return recv_buf.raw[:recv_len.value] 268 | -------------------------------------------------------------------------------- /exile/scard/const.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class SCardConstants: 5 | """ 6 | https://docs.microsoft.com/en-us/windows/desktop/api/winscard 7 | """ 8 | MAX_BUFFER_SIZE = 264 9 | """Maximum Tx/Rx Buffer for short APDU""" 10 | MAX_BUFFER_SIZE_EXTENDED = 4 + 3 + (1 << 16) + 3 11 | """enhanced (64K + APDU + Lc + Le) Tx/Rx Buffer""" 12 | MAX_ATR_SIZE = 33 13 | MAX_READERNAME = 52 14 | 15 | class SCardStatus(Enum): 16 | S_SUCCESS = 0x00000000 17 | """No error was encountered.""" 18 | F_INTERNAL_ERROR = 0x80100001 19 | """An internal consistency check failed.""" 20 | E_CANCELLED = 0x80100002 21 | """The action was cancelled by an SCardCancel request.""" 22 | E_INVALID_HANDLE = 0x80100003 23 | """The supplied handle was invalid.""" 24 | E_INVALID_PARAMETER = 0x80100004 25 | """One or more of the supplied parameters could not be properly interpreted.""" 26 | E_INVALID_TARGET = 0x80100005 27 | """Registry startup information is missing or invalid.""" 28 | E_NO_MEMORY = 0x80100006 29 | """Not enough memory available to complete this command.""" 30 | F_WAITED_TOO_LONG = 0x80100007 31 | """An internal consistency timer has expired.""" 32 | E_INSUFFICIENT_BUFFER = 0x80100008 33 | """The data buffer to receive returned data is too small for the returned data.""" 34 | E_UNKNOWN_READER = 0x80100009 35 | """The specified reader name is not recognized.""" 36 | E_TIMEOUT = 0x8010000A 37 | """The user-specified timeout value has expired.""" 38 | E_SHARING_VIOLATION = 0x8010000B 39 | """The smart card cannot be accessed because of other connections outstanding.""" 40 | E_NO_SMARTCARD = 0x8010000C 41 | """The operation requires a Smart Card, but no Smart Card is currently in the device.""" 42 | E_UNKNOWN_CARD = 0x8010000D 43 | """The specified smart card name is not recognized.""" 44 | E_CANT_DISPOSE = 0x8010000E 45 | """The system could not dispose of the media in the requested manner.""" 46 | E_PROTO_MISMATCH = 0x8010000F 47 | """The requested protocols are incompatible with the protocol currently in use with the smart card.""" 48 | E_NOT_READY = 0x80100010 49 | """The reader or smart card is not ready to accept commands.""" 50 | E_INVALID_VALUE = 0x80100011 51 | """One or more of the supplied parameters values could not be properly interpreted.""" 52 | E_SYSTEM_CANCELLED = 0x80100012 53 | """The action was cancelled by the system, presumably to log off or shut down.""" 54 | F_COMM_ERROR = 0x80100013 55 | """An internal communications error has been detected.""" 56 | F_UNKNOWN_ERROR = 0x80100014 57 | """An internal error has been detected, but the source is unknown.""" 58 | E_INVALID_ATR = 0x80100015 59 | """An ATR obtained from the registry is not a valid ATR string.""" 60 | E_NOT_TRANSACTED = 0x80100016 61 | """An attempt was made to end a non-existent transaction.""" 62 | E_READER_UNAVAILABLE = 0x80100017 63 | """The specified reader is not currently available for use.""" 64 | P_SHUTDOWN = 0x80100018 65 | """The operation has been aborted to allow the server application to exit.""" 66 | E_PCI_TOO_SMALL = 0x80100019 67 | """The PCI Receive buffer was too small.""" 68 | E_READER_UNSUPPORTED = 0x8010001A 69 | """The reader driver does not meet minimal requirements for support.""" 70 | E_DUPLICATE_READER = 0x8010001B 71 | """The reader driver did not produce a unique reader name.""" 72 | E_CARD_UNSUPPORTED = 0x8010001C 73 | """The smart card does not meet minimal requirements for support.""" 74 | E_NO_SERVICE = 0x8010001D 75 | """The Smart card resource manager is not running.""" 76 | E_SERVICE_STOPPED = 0x8010001E 77 | """The Smart card resource manager has shut down.""" 78 | E_UNEXPECTED = 0x8010001F 79 | """An unexpected card error has occurred.""" 80 | E_ICC_INSTALLATION = 0x80100020 81 | """No primary provider can be found for the smart card.""" 82 | E_ICC_CREATEORDER = 0x80100021 83 | """The requested order of object creation is not supported.""" 84 | E_UNSUPPORTED_FEATURE = 0x80100022 85 | """This smart card does not support the requested feature.""" 86 | E_DIR_NOT_FOUND = 0x80100023 87 | """The identified directory does not exist in the smart card.""" 88 | E_FILE_NOT_FOUND = 0x80100024 89 | """The identified file does not exist in the smart card.""" 90 | E_NO_DIR = 0x80100025 91 | """The supplied path does not represent a smart card directory.""" 92 | E_NO_FILE = 0x80100026 93 | """The supplied path does not represent a smart card file.""" 94 | E_NO_ACCESS = 0x80100027 95 | """Access is denied to this file.""" 96 | E_WRITE_TOO_MANY = 0x80100028 97 | """The smart card does not have enough memory to store the information.""" 98 | E_BAD_SEEK = 0x80100029 99 | """There was an error trying to set the smart card file object pointer.""" 100 | E_INVALID_CHV = 0x8010002A 101 | """The supplied PIN is incorrect.""" 102 | E_UNKNOWN_RES_MNG = 0x8010002B 103 | """An unrecognized error code was returned from a layered component.""" 104 | E_NO_SUCH_CERTIFICATE = 0x8010002C 105 | """The requested certificate does not exist.""" 106 | E_CERTIFICATE_UNAVAILABLE = 0x8010002D 107 | """The requested certificate could not be obtained.""" 108 | E_NO_READERS_AVAILABLE = 0x8010002E 109 | """Cannot find a smart card reader.""" 110 | E_COMM_DATA_LOST = 0x8010002F 111 | """A communications error with the smart card has been detected. Retry the operation.""" 112 | E_NO_KEY_CONTAINER = 0x80100030 113 | """The requested key container does not exist on the smart card.""" 114 | E_SERVER_TOO_BUSY = 0x80100031 115 | """The Smart Card Resource Manager is too busy to complete this operation.""" 116 | 117 | W_UNSUPPORTED_CARD = 0x80100065 118 | """The reader cannot communicate with the card, due to ATR string configuration conflicts.""" 119 | W_UNRESPONSIVE_CARD = 0x80100066 120 | """The smart card is not responding to a reset.""" 121 | W_UNPOWERED_CARD = 0x80100067 122 | """Power has been removed from the smart card, so that further communication is not possible.""" 123 | W_RESET_CARD = 0x80100068 124 | """The smart card has been reset, so any shared state information is invalid.""" 125 | W_REMOVED_CARD = 0x80100069 126 | """The smart card has been removed, so further communication is not possible.""" 127 | 128 | W_SECURITY_VIOLATION = 0x8010006A 129 | """Access was denied because of a security violation.""" 130 | W_WRONG_CHV = 0x8010006B 131 | """The card cannot be accessed because the wrong PIN was presented.""" 132 | W_CHV_BLOCKED = 0x8010006C 133 | """The card cannot be accessed because the maximum number of PIN entry attempts has been reached.""" 134 | W_EOF = 0x8010006D 135 | """The end of the smart card file has been reached.""" 136 | W_CANCELLED_BY_USER = 0x8010006E 137 | """The user pressed "Cancel" on a Smart Card Selection Dialog.""" 138 | W_CARD_NOT_AUTHENTICATED = 0x8010006F 139 | """No PIN was presented to the smart card.""" 140 | 141 | class Scope: 142 | USER = 0x0000 143 | """Scope in user space""" 144 | TERMINAL = 0x0001 145 | """Scope in terminal""" 146 | SYSTEM = 0x0002 147 | """Scope in system""" 148 | 149 | class Protocol: 150 | UNDEFINED = 0x0000 151 | """protocol not set""" 152 | UNSET = UNDEFINED 153 | T0 = 0x0001 154 | """T=0 active protocol.""" 155 | T1 = 0x0002 156 | """T=1 active protocol.""" 157 | RAW = 0x0004 158 | """Raw active protocol.""" 159 | T15 = 0x0008 160 | """T=15 protocol.""" 161 | ANY = T0 | T1 162 | """IFD determines prot.""" 163 | 164 | class ShareMode: 165 | EXCLUSIVE = 0x0001 166 | """Exclusive mode only""" 167 | SHARED = 0x0002 168 | """Shared mode only""" 169 | DIRECT = 0x0003 170 | """Raw mode only""" 171 | 172 | class Disposition: 173 | LEAVE_CARD = 0x0000 174 | """Do nothing on close""" 175 | RESET_CARD = 0x0001 176 | """Reset on close""" 177 | UNPOWER_CARD = 0x0002 178 | """Power down on close""" 179 | EJECT_CARD = 0x0003 180 | """Eject on close""" 181 | 182 | class CardState: 183 | UNKNOWN = 0x0001 184 | """Unknown state""" 185 | ABSENT = 0x0002 186 | """Card is absent""" 187 | PRESENT = 0x0004 188 | """Card is present""" 189 | SWALLOWED = 0x0008 190 | """Card not powered""" 191 | POWERED = 0x0010 192 | """Card is powered""" 193 | NEGOTIABLE = 0x0020 194 | """Ready for PTS""" 195 | SPECIFIC = 0x0040 196 | """PTS has been set""" 197 | 198 | class ReaderState: 199 | UNAWARE = 0x0000 200 | """App wants status""" 201 | IGNORE = 0x0001 202 | """Ignore this reader""" 203 | CHANGED = 0x0002 204 | """State has changed""" 205 | UNKNOWN = 0x0004 206 | """Reader unknown""" 207 | UNAVAILABLE = 0x0008 208 | """Status unavailable""" 209 | EMPTY = 0x0010 210 | """Card removed""" 211 | PRESENT = 0x0020 212 | """Card inserted""" 213 | ATRMATCH = 0x0040 214 | """ATR matches card""" 215 | EXCLUSIVE = 0x0080 216 | """Exclusive Mode""" 217 | INUSE = 0x0100 218 | """Shared Mode""" 219 | MUTE = 0x0200 220 | """Unresponsive card""" 221 | UNPOWERED = 0x0400 222 | """Unpowered card""" 223 | -------------------------------------------------------------------------------- /exile/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyauth/exile/38fd9c4d22edcecae0b6fd4dbc9a9d894f366b2f/exile/util/__init__.py -------------------------------------------------------------------------------- /exile/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.0' 2 | -------------------------------------------------------------------------------- /exile/ykoath/__init__.py: -------------------------------------------------------------------------------- 1 | import base64, struct, typing, hashlib, hmac 2 | from collections import namedtuple 3 | from datetime import datetime 4 | from urllib.parse import urlparse, parse_qs 5 | from ..exceptions import YKOATHError 6 | from ..scard import i2b, SCardManager, SCardReader 7 | from .const import YKOATHConstants 8 | 9 | YKOATHCredential = namedtuple("YKOATHCredential", ("name", "oath_type", "algorithm")) 10 | 11 | class YKOATH(YKOATHConstants): 12 | """ 13 | See https://developers.yubico.com/OATH/YKOATH_Protocol.html 14 | """ 15 | def __init__(self, device: SCardReader = None, password: str = None) -> None: 16 | if device is None: 17 | for reader in SCardManager(): 18 | if reader.name.lower().startswith(self.device_prefix): 19 | device = reader 20 | break 21 | else: 22 | raise YKOATHError("No YubiKey found") 23 | self.device = device 24 | res = self.send_apdu(cla=0, ins=self.Instruction.SELECT, p1=0x04, p2=0, data=self.Application.OATH) 25 | _, self._version, res = self.parse_tlv(res, self.Tag.VERSION) 26 | _, self._id, res = self.parse_tlv(res, self.Tag.NAME) 27 | _, self._challenge, res = self.parse_tlv(res) 28 | if self._challenge and password is not None: 29 | self.validate(password) 30 | 31 | def send_apdu(self, **kwargs): 32 | with self.device: 33 | res = self.device.send_apdu(**kwargs) 34 | while res[-2:-1] == self.Response.MORE_DATA_AVAILABLE.value: 35 | res = res[:-2] + self.device.send_apdu(cla=0, ins=self.Instruction.SEND_REMAINING, p1=0, p2=0, data=b"") 36 | if res[-2:] != self.Response.SUCCESS.value: 37 | raise YKOATHError(self.Response(res[-2:])) 38 | return res 39 | 40 | def parse_tlv(self, data, expect_tag=None): 41 | assert isinstance(data, bytes) 42 | tag, length = data[0], data[1] 43 | if expect_tag: 44 | assert tag == expect_tag 45 | value, data = data[2:2 + length], data[2 + length:] 46 | return tag, value, data 47 | 48 | def put(self, credential_name: str, secret: bytes, require_touch=False, 49 | oath_type=YKOATHConstants.OATHType.TOTP, algorithm=YKOATHConstants.Algorithm.SHA1, digits=6): 50 | secret_header = i2b(oath_type.value | algorithm.value) + i2b(digits) 51 | # secret = hmac_shorten_key(secret, algorithm) 52 | secret = secret.ljust(self.HMAC_MINIMUM_KEY_SIZE, b'\x00') 53 | data = i2b(self.Tag.NAME) + i2b(len(credential_name)) + credential_name.encode() 54 | data += i2b(self.Tag.KEY) + i2b(len(secret_header) + len(secret)) + secret_header + secret 55 | if require_touch: 56 | data += i2b(self.Tag.PROPERTY) + i2b(self.Properties.REQUIRE_TOUCH) 57 | return self.send_apdu(cla=0, ins=self.Instruction.PUT, p1=0, p2=0, data=data) 58 | 59 | def delete(self, credential_name: str): 60 | data = i2b(self.Tag.NAME) + i2b(len(credential_name)) + credential_name.encode() 61 | return self.send_apdu(cla=0, ins=self.Instruction.DELETE, p1=0, p2=0, data=data) 62 | 63 | def reset(self): 64 | return self.send_apdu(cla=0, ins=self.Instruction.RESET, p1=0xde, p2=0xad, data=b"") 65 | 66 | def list(self): 67 | return self.send_apdu(cla=0, ins=self.Instruction.LIST, p1=0, p2=0, data=b"") 68 | 69 | def calculate(self, credential_name: str, challenge: typing.Union[bytes, int], want_truncated_response=True): 70 | chal_bytes = challenge if isinstance(challenge, bytes) else int_to_bytestring(challenge) 71 | data = i2b(self.Tag.NAME) + i2b(len(credential_name)) + credential_name.encode() 72 | data += i2b(self.Tag.CHALLENGE) + i2b(len(chal_bytes)) + chal_bytes 73 | p2 = 0x01 if want_truncated_response else 0 74 | res = self.send_apdu(cla=0, ins=self.Instruction.CALCULATE, p1=0, p2=p2, data=data) 75 | assert res[0] == self.Tag.TRUNCATED_RESPONSE if want_truncated_response else self.Tag.RESPONSE 76 | res_len, digits = res[1], res[2] 77 | if want_truncated_response: 78 | return str(struct.unpack('>I', res[3:3 + res_len - 1])[0]).zfill(digits) 79 | else: 80 | return res[3:3 + res_len - 1] 81 | 82 | def set_code(self, password): 83 | key = hashlib.pbkdf2_hmac('sha256', password.encode(), self._id, 1000) 84 | test_challenge = b'01234567' 85 | test_response = hmac.new(key, test_challenge, 'sha256').digest() 86 | data = i2b(self.Tag.KEY) + i2b(len(key) + 1) + i2b(self.Algorithm.SHA256.value) + key 87 | data += i2b(self.Tag.CHALLENGE) + i2b(len(test_challenge)) + test_challenge 88 | data += i2b(self.Tag.RESPONSE) + i2b(len(test_response)) + test_response 89 | return self.send_apdu(cla=0, ins=self.Instruction.SET_CODE, p1=0, p2=0, data=data) 90 | 91 | def validate(self, password): 92 | key = hashlib.pbkdf2_hmac('sha256', password.encode(), self._id, 1000) 93 | response = hmac.new(key, self._challenge, 'sha256').digest() 94 | data = i2b(self.Tag.RESPONSE) + i2b(len(response)) + response 95 | data += i2b(self.Tag.CHALLENGE) + i2b(len(self._challenge)) + self._challenge 96 | return self.send_apdu(cla=0, ins=self.Instruction.VALIDATE, p1=0, p2=0, data=data) 97 | 98 | def __iter__(self): 99 | res = self.list()[:-2] 100 | while res: 101 | name_tag, name_len, alg = res[0], res[1], res[2] 102 | assert name_tag == self.Tag.NAME_LIST 103 | algorithm, oath_type = self.Algorithm(alg & 0x0f), self.OATHType(alg & 0xf0) 104 | name_data = res[3:3 + name_len - 1] 105 | yield YKOATHCredential(name=name_data.decode(), oath_type=oath_type, algorithm=algorithm) 106 | res = res[name_len + 2:] 107 | 108 | def int_to_bytestring(i: int, padding=8): 109 | result = bytearray() 110 | while i != 0: 111 | result.append(i & 0xFF) 112 | i >>= 8 113 | return bytes(bytearray(reversed(result)).rjust(padding, b'\0')) 114 | 115 | class TOTP(YKOATH): 116 | default_time_step = 30 117 | 118 | def save(self, label: str, secret: str): 119 | self.put(label, base64.b32decode(secret, casefold=True)) 120 | 121 | def save_otpauth_uri(self, otpauth_uri: str): 122 | otpauth = urlparse(otpauth_uri) 123 | assert otpauth.scheme == "otpauth" 124 | assert otpauth.netloc == "totp" 125 | label = otpauth.path.lstrip("/") 126 | secret = parse_qs(otpauth.query)["secret"][0] 127 | self.save(secret=secret, label=label) 128 | 129 | def get(self, label: str, at: datetime = None, time_step: int = default_time_step): 130 | if at is None: 131 | at = datetime.now() 132 | return self.calculate(label, int(at.timestamp() / time_step)) 133 | 134 | def verify(self, code: str, label: str, at: datetime = None, time_step: int = default_time_step): 135 | if self.get(label=label, at=at, time_step=time_step) != code: 136 | raise YKOATHError("TOTP code mismatch") 137 | -------------------------------------------------------------------------------- /exile/ykoath/const.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class YKOATHConstants: 4 | device_prefix = "yubico yubikey" 5 | HMAC_MINIMUM_KEY_SIZE = 14 6 | 7 | class Tag: 8 | NAME = 0x71 9 | NAME_LIST = 0x72 10 | KEY = 0x73 11 | CHALLENGE = 0x74 12 | RESPONSE = 0x75 13 | TRUNCATED_RESPONSE = 0x76 14 | NO_RESPONSE = 0x77 15 | PROPERTY = 0x78 16 | VERSION = 0x79 17 | IMF = 0x7a 18 | ALGORITHM = 0x7b 19 | TOUCH = 0x7c 20 | 21 | class OATHType(Enum): 22 | HOTP = 0x10 23 | TOTP = 0x20 24 | 25 | class Properties: 26 | REQUIRE_TOUCH = 0x02 27 | 28 | class Instruction: 29 | SELECT = 0xa4 30 | PUT = 0x01 31 | DELETE = 0x02 32 | SET_CODE = 0x03 33 | RESET = 0x04 34 | LIST = 0xa1 35 | CALCULATE = 0xa2 36 | VALIDATE = 0xa3 37 | CALCULATE_ALL = 0xa4 38 | SEND_REMAINING = 0xa5 39 | 40 | class Algorithm(Enum): 41 | SHA1 = 0x01 42 | SHA256 = 0x02 43 | SHA512 = 0x03 44 | 45 | class Application: 46 | OTP = b'\xa0\x00\x00\x05\x27\x20\x01' 47 | MGR = b'\xa0\x00\x00\x05\x27\x47\x11\x17' 48 | OPGP = b'\xd2\x76\x00\x01\x24\x01' 49 | OATH = b'\xa0\x00\x00\x05\x27\x21\x01' 50 | PIV = b'\xa0\x00\x00\x03\x08' 51 | U2F = b'\xa0\x00\x00\x06\x47\x2f\x00\x01' 52 | 53 | class Response(Enum): 54 | SUCCESS = b'\x90\x00' 55 | NO_SPACE = b'\x6a\x84' 56 | NOT_FOUND = b'\x69\x84' 57 | AUTH_REQUIRED = b'\x69\x82' 58 | WRONG_SYNTAX = b'\x6a\x80' 59 | GENERIC_ERROR = b'\x65\x81' 60 | MORE_DATA_AVAILABLE = b'\x61' 61 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | [bdist_wheel] 4 | universal=1 5 | [flake8] 6 | max-line-length=120 7 | ignore: E302, E305, E401, F401 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | tests_require = ["coverage", "flake8", "wheel"] 6 | 7 | setup( 8 | name="exile", 9 | version="0.1.1", 10 | url="https://github.com/pyauth/exile", 11 | license="Apache Software License", 12 | author="Andrey Kislyuk", 13 | author_email="kislyuk@gmail.com", 14 | description="Python YubiKey AWS signature library", 15 | long_description=open("README.rst").read(), 16 | install_requires=[], 17 | tests_require=tests_require, 18 | extras_require={ 19 | "test": tests_require, 20 | }, 21 | packages=find_packages(exclude=["test"]), 22 | include_package_data=True, 23 | test_suite="test", 24 | classifiers=[ 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: Apache Software License", 27 | "Operating System :: MacOS :: MacOS X", 28 | "Operating System :: POSIX", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3.4", 31 | "Programming Language :: Python :: 3.5", 32 | "Programming Language :: Python :: 3.6", 33 | "Programming Language :: Python :: 3.7", 34 | "Topic :: Software Development :: Libraries :: Python Modules" 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os, sys, unittest, json, collections, base64, datetime 4 | import boto3, botocore.auth 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # noqa 7 | 8 | from exile import YKOATH, TOTP, SCardManager, botocore_signers 9 | 10 | class TestExile(unittest.TestCase): 11 | def test_scard_manager(self): 12 | for reader in SCardManager(): 13 | with reader: 14 | pass 15 | 16 | def test_exile_totp(self): 17 | TOTP().save("google", "JBSWY3DPEHPK3PXP") 18 | TOTP().get("google") 19 | TOTP().verify("260153", label="google", at=datetime.datetime.fromtimestamp(1297553958)) 20 | otpauth_uri = 'otpauth://totp/Secure%20App:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Secure%20App' 21 | TOTP().save_otpauth_uri(otpauth_uri) 22 | TOTP().verify("260153", label="Secure%20App:alice%40google.com", at=datetime.datetime.fromtimestamp(1297553958)) 23 | 24 | def write_active_aws_key_to_yubikey(self): 25 | credentials = boto3.Session().get_credentials() 26 | 27 | key_name = "exile-{}-SigV4".format(credentials.access_key) 28 | secret = b"AWS4" + credentials.secret_key.encode() 29 | YKOATH().put(key_name, secret, algorithm=YKOATH.Algorithm.SHA256) 30 | 31 | key_name = "exile-{}-HmacV1".format(credentials.access_key) 32 | secret = credentials.secret_key.encode() 33 | YKOATH().put(key_name, secret, algorithm=YKOATH.Algorithm.SHA1) 34 | 35 | def test_exile(self): 36 | self.write_active_aws_key_to_yubikey() 37 | botocore_signers.install() 38 | 39 | boto3.client("sts").get_caller_identity() 40 | boto3.client("s3").generate_presigned_url(ClientMethod="get_object", Params={"Bucket": "foo", "Key": "bar"}) 41 | 42 | if __name__ == '__main__': 43 | unittest.main() 44 | --------------------------------------------------------------------------------