pax_global_header00006660000000000000000000000064141350047260014514gustar00rootroot0000000000000052 comment=f087629a8f17cf9de79832bda242057aae4a2475 solo-python-0.0.31/000077500000000000000000000000001413500472600140705ustar00rootroot00000000000000solo-python-0.0.31/.editorconfig000066400000000000000000000003001413500472600165360ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf indent_style = space indent_size = 4 tab_width = 4 trim_trailing_whitespace = true insert_final_newline = true [Makefile] indent_style = tab solo-python-0.0.31/.envrc000066400000000000000000000001301413500472600152000ustar00rootroot00000000000000# to use this, install [direnv](https://direnv.net/) source venv/bin/activate unset PS1 solo-python-0.0.31/.flake8000066400000000000000000000003641413500472600152460ustar00rootroot00000000000000# template suggested by `black` [flake8] ignore = E203, E266, E501, W503 exclude = solo/solotool.py max-line-length = 80 # max-complexity = 18 # temporary increase due to solo.key.update complexity max-complexity = 30 select = B,C,E,F,W,T4,B9 solo-python-0.0.31/.gitignore000066400000000000000000000002641413500472600160620ustar00rootroot00000000000000# Please consider # https://help.github.com/articles/ignoring-files/#create-a-global-gitignore # before adding to this file dist/ venv/ firmware-*.json *.sha2 *.hex *.pyc .tags* solo-python-0.0.31/CHANGELOG.md000066400000000000000000000131311413500472600157000ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.0.31] - 2021-10-23 - pin argument for make-credential @HeroicKatora ## [0.0.30] - 2021-04-29 - Replace old python-fido2 internal method used with fix from @enrikb ## [0.0.29] - 2021-04-28 - Fix some typos in last release @enrikb ## [0.0.28] - 2021-04-25 - Upgraded to new 0.9.1 fido2 library - Removed use of serial_number if it's not present in upstream fido2 library - Removed use of UDP communication - bumped isort version to >=5.0.0 and changed Makefile accordingly, see [Upgrading isort to 5.0.0](https://pycqa.github.io/isort/docs/upgrade_guides/5.0.0/) ## [0.0.27] - 2021-01-20 - pin fido2 dep to 0.8 series @uli-heller - improve programming robustness @enrikb - prompt for PIN in `solo key verify`, if needed @enrikb ## [0.0.26] - 2020-07-17 - fix bungled reference to `STABLE_VERSION` file ## [0.0.25] - 2020-07-17 - credential management commands @rgerganov - static password vendor command @rgerganov - sign-file vendor command @rgerganov - typo @lolaum - use `main` as main branch ## [0.0.24] - 2020-02-27 ## Added - Add command to disable bootloader-access / updates on Solo devices - Improve admin/root/udev warning for Linux and Windows - Add ping command ## [0.0.23] - 2019-01-20 ## Changed - Fix Timeout ## [0.0.22] - 2019-01-20 ### Changed - Fix fallout of move to fido2 0.8 ## [0.0.21] - 2019-12-01 ### Changed - Update to use python-fido2 v0.8 ## [0.0.20] - 2019-12-01 ### Added - Add fingerprints for checking more Solo model certificates (Tap, Somu, Solo) ### Changed - Attestation key marker in mergehex operation ## [0.0.19] - 2019-11-09 ### Added - `solo key set-pin` and `change-pin` commands. ## [0.0.18] - 2019-10-29 ### Added ### Changed - When signing, signatures were incorrectly annotated with `2.3.0` version. updated to `2.5.3`. ## [0.0.17] - 2019-10-28 ### Added ### Changed - remove `--hacker` and `--secure` options when auto-updating. - pull `firmware-*.json` instead of choosing between hacker and secure ## [0.0.16] - 2019-10-28 ### Added - option to specify attestation certificate with attestation key - mergehex operation adds in attestation certificate - mergehex operation adds in lock status with `--lock` flag ### Changed - attestation key requires associate attestation cert - sign operation adds 2 signatures for 2 different versions of solo bootloader - solo version attempts to uses HID version command to additionally see lock status of key. ## [0.0.15] - 2019-08-30 ### Added - `solo.hmac_secret.make_credential` method - separate `solo key make-credential` CLI target ### Changed - remove credential generation from `solo.hmac_secret.simple_secret` - demand `credential_id` in `solo key challenge-response` ## [0.0.14] - 2019-08-30 ### Added - challenge-response via `hmac-secret` ## [0.0.13] - 2019-08-19 ### Changed - implement passing PIN to `solo key verify` ## [0.0.12] - 2019-08-08 ### Changed - update fido2 to 0.7.0 ## [0.0.11] - 2019-05-27 ### Changed - adjust to and pin fido2 0.6.0 dependency (@conorpp) - only warn if run as sudo ## [0.0.10] - 2019-03-18 ### Added - solo client improvements - experimental interface to feed kernel entropy from key: `sudo ALLOW_ROOT= /path/to/solo key rng feedkernel` ## [0.0.9] - 2019-03-18 ### Added - enforce `solo` command does not run as root ## [0.0.8] - 2019-03-18 ### Added - `solo key probe` interface ### Changed - fixes to set options bytes to leave DFU mode ## [0.0.7] - 2019-03-08 ### Changed - Exit properly on boot to bootloader failure - `--alpha` flag for `update` ## [0.0.6] - 2019-02-27 ### Changed - Fix bootloader-version command (@Thor77) - Reboot to bootloader in `program` if necessary ### Added - yes flag for `update` ## [0.0.6a3] - 2019-02-20 ### Changed - Typo ## [0.0.6a2] - 2019-02-20 ### Added - Monkey-patch to communicate via UDP with software key - Flag `--udp` to use it for certain `solo key` commands ## [0.0.6a1] - 2019-02-19 ### Added - Serial number support ## [0.0.5] - 2019-02-18 ### Changed - Initial feedback from https://github.com/solokeys/solo/issues/113 ## [0.0.4] - 2019-02-18 ### Changed - Enforce passing exactly one of `--hacker|--secure` in `solo key update` ## [0.0.3] - 2019-02-18 ### Changed - Bugfix in `solo.dfu` - Minor improvements ## [0.0.2] - 2019-02-18 ### Changed - Fix broken `solo program dfu` command - Remove `solotool` script installation - Add Python version classifiers ## [0.0.1] - 2019-02-17 ### Added - Implement `solo key update [--hacker]` ## [0.0.1a8] - 2019-02-17 ### Added - Forgot to add some files in last release - Add client/dfu find\_all methods - Add `solo ls` command ## [0.0.1a7] - 2019-02-16 ### Added - More implementation of `solo program aux` (mode changes) - Implement `solo program bootloader`. - More comments ## [0.0.1a6] - 2019-02-16 ### Changed - Implements part of `solo program dfu` and `solo program aux` - Adds Conor's change allowing to pass in raw devices to DFU+SoloClient ## [0.0.1a5] - 2019-02-16 ### Changed - Unwrap genkey from CLI to operations ## [0.0.1a4] - 2019-02-16 ### Added - Everything moved out of solotool, except programming chunk ## [0.0.1a3] - 2019-02-16 ### Added - Start redo of CLI using click ## [0.0.1a2] - 2019-02-15 ### Changed - Bugfixes related to refactor ## [0.0.1a1] - 2019-02-15 ### Changed - Start to split out commands, helpers and client ## [0.0.1a0] - 2019-02-15 ### Added - Initial import of `solotool.py` script from [solo](https://github.com/solokeys/solo) solo-python-0.0.31/LICENSE-APACHE000066400000000000000000000240171413500472600160200ustar00rootroot00000000000000Apache 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. solo-python-0.0.31/LICENSE-MIT000066400000000000000000000017771413500472600155400ustar00rootroot00000000000000Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. solo-python-0.0.31/Makefile000066400000000000000000000016241413500472600155330ustar00rootroot00000000000000.PHONY: black build clean publish reinstall # setup development environment init: update-venv # ensure this passes before commiting check: lint black --check solo/ isort --profile black --check-only solo/ # automatic code fixes fix: black isort black: black solo/ isort: isort --profile black solo/ lint: flake8 solo/ semi-clean: rm -rf **/__pycache__ clean: semi-clean rm -rf venv rm -rf dist # Package management VERSION_FILE := "solo/VERSION" VERSION := $(shell cat $(VERSION_FILE)) tag: git tag -a $(VERSION) -m"v$(VERSION)" git push origin $(VERSION) build: check flit build publish: check flit --repository pypi publish venv: python3 -m venv venv venv/bin/pip install -U pip # re-run if dev or runtime dependencies change, # or when adding new scripts update-venv: venv venv/bin/pip install -U pip venv/bin/pip install -U -r dev-requirements.txt venv/bin/flit install --symlink solo-python-0.0.31/README.md000066400000000000000000000121771413500472600153570ustar00rootroot00000000000000![](https://img.shields.io/pypi/l/solo-python.svg?style=flat) ![](https://img.shields.io/pypi/pyversions/solo-python.svg?style=flat) ![](https://img.shields.io/pypi/v/solo-python.svg) ![](https://img.shields.io/pypi/wheel/solo-python.svg?style=flat) # Python tool and library for SoloKeys ## Getting Started We require Python >= 3.6 and corresponding `pip3` command. We intend to support Linux, Windows and macOS. Other platforms aren't supported by the [FIDO2 library](https://github.com/Yubico/python-fido2) we rely on. To get started, run `pip3 install solo-python`, this installs both the `solo` library and the `solo` interface. Possible issues: - on Linux, ensure you have suitable udev rules in place: - on Windows, optionally install a libusb backend: For development, we suggest you run `make init` instead, which - sets up a virtual environment - installs development requirements such as `black` - installs `solo` as symlink using our packaging tool `flit`, including all runtime dependencies listed in [`pyproject.toml`](pyproject.toml) One way to ensure the virtual environment is active is to use [direnv](https://direnv.net/). ## Solo Tool For help, run `solo --help` after installation. The tool has a hierarchy of commands and subcommands. Example: ```bash solo ls # lists all Solo keys connected to your machine solo version # outputs version of installed `solo` library and tool solo key wink # blinks the LED solo key verify # checks whether your Solo is genuine solo key rng hexbytes # outputs some random hex bytes generated on your key solo key version # outputs the version of the firmware on your key ``` ## Firmware Update Upon release of signed firmware updates in [solokeys/solo](https://github.com/solokeys/solo), to update the firmware on your Solo to the latest version: - update your `solo` tool if necessary via `pip3 install --upgrade solo-python` - plug in your key, keeping the button pressed until the LED flashes yellow - run `solo key update` For possibly helpful additional information, see . ## Library Usage The previous `solotool.py` has been refactored into a library with associated CLI tool called `solo`. It is still work in progress, example usage: ```python import solo client = solo.client.find() client.wink() random_bytes = client.get_rng(32) print(random_bytes.hex()) ``` Comprehensive documentation coming, for now these are the main components - `solo.client`: connect to Solo Hacker and Solo Secure keys in firmware or bootloader mode - `solo.dfu`: connect to Solo Hacker in dfu mode (disabled on Solo Secure keys) - `solo.cli`: implementation of the `solo` command line interface ## Challenge-Response By abuse of the `hmac-secret` extension, we can generate static challenge responses, which are scoped to a credential. A use case might be e.g. unlocking a [LUKS-encrypted](https://github.com/saravanan30erd/solokey-full-disk-encryption) drive. **DANGER** The generated reponses depend on both the key and the credential. There is no way to extract or backup from the physical key, so if you intend to use the "response" as a static password, make sure to store it somewhere separately, e.g. on paper. **DANGER** Also, if you generate a new credential with the same `(host, user_id)` pair, it will likely overwrite the old credential, and you lose the capability to generate the original responses too. **DANGER** This functionality has not been sufficiently debugged, please generate GitHub issues if you detect anything. There are two steps: 1. Generate a credential. This can be done with `solo key make-credential`, storing the (hex-encoded) generated `credential_id` for the next step. 2. Pick a challenge, and generate the associated response. This can be done with `solo key challenge-response `. ## License Licensed under either of - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) at your option. ## Contributing Any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. Code is to be formatted and linted according to [Black](https://black.readthedocs.io/) and our [Flake8](http://flake8.pycqa.org/en/latest/) [configuration](.flake8) Run `make check` to test compliance, run `make fix` to apply some automatic fixes. We keep a [CHANGELOG](CHANGELOG.md). ## Releasing For maintainers: - adjust `solo/VERSION` file as appropriate - add entry or entries to `CHANGELOG.md` (no need to repeat commit messages, but point out major changes in such a way that a user of the library has an easy entrypoint to follow development) - run `make check` and/or `make fix` to ensure code consistency - run `make build` to double check - run `make publish` (assumes a `~/.pypirc` file with entry `[pypi]`), or `flit publish` manually - run `make tag` to tag the release and push it solo-python-0.0.31/dev-requirements.txt000066400000000000000000000002751413500472600201340ustar00rootroot00000000000000# Please do not add runtime dependencies here. # Please do not add a `requirements.txt` file, use `pyproject.toml` instead. black flake8 flit_core==2.3.0 flit==2.3.0 ipython isort>=5.0.0 solo-python-0.0.31/pyproject.toml000066400000000000000000000015471413500472600170130ustar00rootroot00000000000000 [build-system] requires = ["flit"] build-backend = "flit.buildapi" [tool.flit.metadata] module = "solo" dist-name = "solo-python" # Unfortunately, `solo` is in use on PyPI author = "SoloKeys" author-email = "hello@solokeys.com" home-page = "https://github.com/solokeys/solo-python" requires-python = ">=3.6" description-file = "README.md" requires = [ "click >= 7.1", "cryptography", "ecdsa", "fido2 >= 0.9.1", "intelhex", "pyserial", "pyusb", "requests", ] classifiers=[ "License :: OSI Approved :: MIT License", "License :: OSI Approved :: Apache Software License", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", ] [tool.flit.scripts] solo = "solo.cli:solo_cli" solo-python-0.0.31/solo/000077500000000000000000000000001413500472600150445ustar00rootroot00000000000000solo-python-0.0.31/solo/VERSION000066400000000000000000000000071413500472600161110ustar00rootroot000000000000000.0.31 solo-python-0.0.31/solo/__init__.py000066400000000000000000000012071413500472600171550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. # """Python library for SoloKeys.""" import pathlib from . import client, commands, dfu, helpers, operations __version__ = open(pathlib.Path(__file__).parent / "VERSION").read().strip() del pathlib __all__ = ["client", "commands", "dfu", "enums", "exceptions", "helpers", "operations"] solo-python-0.0.31/solo/cli/000077500000000000000000000000001413500472600156135ustar00rootroot00000000000000solo-python-0.0.31/solo/cli/__init__.py000066400000000000000000000112131413500472600177220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import json import click import usb.core import solo import solo.operations from solo.cli.key import key from solo.cli.monitor import monitor from solo.cli.program import program from ._checks import init_checks init_checks() @click.group() def solo_cli(): pass solo_cli.add_command(key) solo_cli.add_command(monitor) solo_cli.add_command(program) @click.command() def version(): """Version of python-solo library and tool.""" print(solo.__version__) solo_cli.add_command(version) @click.command() @click.option("--input-seed-file") @click.argument("output_pem_file") def genkey(input_seed_file, output_pem_file): """Generates key pair that can be used for Solo signed firmware updates. \b * Generates NIST P256 keypair. * Public key must be copied into correct source location in solo bootloader * The private key can be used for signing updates. * You may optionally supply a file to seed the RNG for key generating. """ vk = solo.operations.genkey(output_pem_file, input_seed_file=input_seed_file) print("Public key in various formats:") print() print([c for c in vk.to_string()]) print() print("".join(["%02x" % c for c in vk.to_string()])) print() print('"\\x' + "\\x".join(["%02x" % c for c in vk.to_string()]) + '"') print() solo_cli.add_command(genkey) @click.command() @click.argument("verifying-key") @click.argument("app-hex") @click.argument("output-json") def sign(verifying_key, app_hex, output_json): """Signs a firmware hex file, outputs a .json file that can be used for signed update.""" msg = solo.operations.sign_firmware(verifying_key, app_hex) print("Saving signed firmware to", output_json) with open(output_json, "wb+") as fh: fh.write(json.dumps(msg).encode()) solo_cli.add_command(sign) @click.command() @click.option("--attestation-key", help="attestation key in hex") @click.option("--attestation-cert", help="attestation certificate file") @click.option( "--lock", help="Indicate to lock device from unsigned changes permanently.", default=False, is_flag=True, ) @click.argument("input_hex_files", nargs=-1) @click.argument("output_hex_file") @click.option( "--end_page", help="Set APPLICATION_END_PAGE. Should be in sync with firmware settings.", default=20, type=int, ) def mergehex( attestation_key, attestation_cert, lock, input_hex_files, output_hex_file, end_page ): """Merges hex files, and patches in the attestation key. \b If no attestation key is passed, uses default Solo Hacker one. Note that later hex files replace data of earlier ones, if they overlap. """ solo.operations.mergehex( input_hex_files, output_hex_file, attestation_key=attestation_key, APPLICATION_END_PAGE=end_page, attestation_cert=attestation_cert, lock=lock, ) solo_cli.add_command(mergehex) @click.command() @click.option( "-a", "--all", is_flag=True, default=False, help="Show ST DFU devices too." ) def ls(all): """List Solos (in firmware or bootloader mode) and potential Solos in dfu mode.""" solos = solo.client.find_all() print(":: Solos") for c in solos: descriptor = c.dev.descriptor if hasattr(descriptor, "product_name"): product_name = descriptor.product_name elif c.is_solo_bootloader(): product_name = "Solo Bootloader device" else: product_name = "FIDO2 device" if hasattr(descriptor, "serial_number"): serial_or_path = descriptor.serial_number else: serial_or_path = descriptor.path print(f"{serial_or_path}: {product_name}") if all: print(":: Potential Solos in DFU mode") try: st_dfus = solo.dfu.find_all() for d in st_dfus: dev_raw = d.dev dfu_serial = dev_raw.serial_number print(f"{dfu_serial}") except usb.core.NoBackendError: print("No libusb available.") print( "This error is only relevant if you plan to use the ST DFU interface." ) print("If you are on Windows, please install a driver:") print("https://github.com/libusb/libusb/wiki/Windows#driver-installation") solo_cli.add_command(ls) solo-python-0.0.31/solo/cli/_checks.py000066400000000000000000000020341413500472600175630ustar00rootroot00000000000000import ctypes import os import platform LINUX_ROOT_WARNING = """THIS COMMAND SHOULD NOT BE RUN AS ROOT! Please install udev rules and run `solo` as regular user (without sudo). For more information, see: https://docs.solokeys.io/solo/udev""" WINDOWS_CTAP_WARNING = """Try running `solo` with administrator privileges! FIDO CTAP access is restricted on Windows 10 version 1903 and higher.""" def windows_ctap_restriction(): win_ver = platform.sys.getwindowsversion() return ( # Windows 10 1903 and higher win_ver.major == 10 and win_ver.build >= 18362 and ctypes.windll.shell32.IsUserAnAdmin() != 1 ) def windows_checks(): if windows_ctap_restriction(): print(WINDOWS_CTAP_WARNING) def linux_checks(): if os.environ.get("ALLOW_ROOT") is None and os.geteuid() == 0: print(LINUX_ROOT_WARNING) def init_checks(): os_family = platform.sys.platform if os_family.startswith("linux"): linux_checks() elif os_family.startswith("win32"): windows_checks() solo-python-0.0.31/solo/cli/key.py000066400000000000000000000521031413500472600167560ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import base64 import getpass import hashlib import os import sys import time import click from cryptography.hazmat.primitives import hashes from fido2.client import ClientError as Fido2ClientError from fido2.ctap1 import ApduError from fido2.ctap2 import CredentialManagement import solo import solo.fido2 from solo.cli.update import update # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @click.group() def key(): """Interact with Solo keys, see subcommands.""" pass @click.group(name="credential") def cred(): """Credential management, see subcommands.""" pass @click.group() def rng(): """Access TRNG on key, see subcommands.""" pass @click.command() @click.option("--count", default=8, help="How many bytes to generate (defaults to 8)") @click.option("-s", "--serial", help="Serial number of Solo to use") def hexbytes(count, serial): """Output COUNT number of random bytes, hex-encoded.""" if not 0 <= count <= 255: print(f"Number of bytes must be between 0 and 255, you passed {count}") sys.exit(1) print(solo.client.find(serial).get_rng(count).hex()) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def raw(serial): """Output raw entropy endlessly.""" p = solo.client.find(serial) while True: r = p.get_rng(255) sys.stdout.buffer.write(r) @click.command() @click.option("--count", default=64, help="How many bytes to generate (defaults to 8)") @click.option("-s", "--serial", help="Serial number of Solo to use") def feedkernel(count, serial): """Feed random bytes to /dev/random.""" if os.name != "posix": print("This is a Linux-specific command!") sys.exit(1) if not 0 <= count <= 255: print(f"Number of bytes must be between 0 and 255, you passed {count}") sys.exit(1) p = solo.client.find(serial) import fcntl import struct RNDADDENTROPY = 0x40085203 entropy_info_file = "/proc/sys/kernel/random/entropy_avail" print(f"Entropy before: 0x{open(entropy_info_file).read().strip()}") r = p.get_rng(count) # man 4 random # RNDADDENTROPY # Add some additional entropy to the input pool, incrementing the # entropy count. This differs from writing to /dev/random or # /dev/urandom, which only adds some data but does not increment the # entropy count. The following structure is used: # struct rand_pool_info { # int entropy_count; # int buf_size; # __u32 buf[0]; # }; # Here entropy_count is the value added to (or subtracted from) the # entropy count, and buf is the buffer of size buf_size which gets # added to the entropy pool. entropy_bits_per_byte = 2 # maximum 8, tend to be pessimistic t = struct.pack(f"ii{count}s", count * entropy_bits_per_byte, count, r) with open("/dev/random", mode="wb") as fh: fcntl.ioctl(fh, RNDADDENTROPY, t) print(f"Entropy after: 0x{open(entropy_info_file).read().strip()}") @click.command() @click.option("-s", "--serial", help="Serial number of Solo use") @click.option( "--host", help="Relying party's host", default="solokeys.dev", show_default=True ) @click.option("--user", help="User ID", default="they", show_default=True) @click.option("--pin", help="PIN", default=None) @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) @click.option( "--prompt", help="Prompt for user", default="Touch your authenticator to generate a credential...", show_default=True, ) def make_credential(serial, host, user, udp, prompt, pin): """Generate a credential. Pass `--prompt ""` to output only the `credential_id` as hex. """ import solo.hmac_secret # check for PIN if not pin: pin = getpass.getpass("PIN (leave empty for no PIN): ") if not pin: pin = None solo.hmac_secret.make_credential( host=host, user_id=user, serial=serial, output=True, prompt=prompt, udp=udp, pin=pin, ) @click.command() @click.option("-s", "--serial", help="Serial number of Solo use") @click.option("--host", help="Relying party's host", default="solokeys.dev") @click.option("--user", help="User ID", default="they") @click.option("--pin", help="PIN", default=None) @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) @click.option( "--prompt", help="Prompt for user", default="Touch your authenticator to generate a reponse...", show_default=True, ) @click.argument("credential-id") @click.argument("challenge") def challenge_response(serial, host, user, prompt, credential_id, challenge, udp, pin): """Uses `hmac-secret` to implement a challenge-response mechanism. We abuse hmac-secret, which gives us `HMAC(K, hash(challenge))`, where `K` is a secret tied to the `credential_id`. We hash the challenge first, since a 32 byte value is expected (in original usage, it's a salt). This means that we first need to setup a credential_id; this depends on the specific authenticator used. To do this, use `solo key make-credential`. If so desired, user and relying party can be changed from the defaults. The prompt can be suppressed using `--prompt ""`. """ import solo.hmac_secret # check for PIN if not pin: pin = getpass.getpass("PIN (leave empty for no PIN): ") if not pin: pin = None solo.hmac_secret.simple_secret( credential_id, challenge, host=host, user_id=user, serial=serial, prompt=prompt, output=True, udp=udp, pin=pin, ) @click.command() @click.option("-s", "--serial", help="Serial number of Solo use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) @click.argument("hash-type") @click.argument("filename") def probe(serial, udp, hash_type, filename): """Calculate HASH.""" # hash_type = hash_type.upper() assert hash_type in ("SHA256", "SHA512", "RSA2048", "Ed25519") data = open(filename, "rb").read() # < CTAPHID_BUFFER_SIZE # https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-client-to-authenticator-protocol-v2.0-id-20180227.html#usb-message-and-packet-structure # also account for padding (see data below....) # so 6kb is conservative assert len(data) <= 6 * 1024 p = solo.client.find(serial, udp=udp) import fido2 serialized_command = fido2.cbor.dumps({"subcommand": hash_type, "data": data}) from solo.commands import SoloBootloader result = p.send_data_hid(SoloBootloader.HIDCommandProbe, serialized_command) result_hex = result.hex() print(result_hex) if hash_type == "Ed25519": print(f"content: {result[64:]}") # print(f"content from hex: {bytes.fromhex(result_hex[128:]).decode()}") print(f"content from hex: {bytes.fromhex(result_hex[128:])}") print(f"signature: {result[:128]}") import nacl.signing # verify_key = nacl.signing.VerifyKey(bytes.fromhex("c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a")) verify_key = nacl.signing.VerifyKey( bytes.fromhex( "c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a" ) ) try: verify_key.verify(result) verified = True except nacl.exceptions.BadSignatureError: verified = False print(f"verified? {verified}") # print(fido2.cbor.loads(result)) # @click.command() # @click.option("-s", "--serial", help="Serial number of Solo to use") # @click.argument("filename") # def sha256sum(serial, filename): # """Calculate SHA256 hash of FILENAME.""" # data = open(filename, 'rb').read() # # CTAPHID_BUFFER_SIZE # # https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-client-to-authenticator-protocol-v2.0-id-20180227.html#usb-message-and-packet-structure # assert len(data) <= 7609 # p = solo.client.find(serial) # sha256sum = p.calculate_sha256(data) # print(sha256sum.hex().lower()) # @click.command() # @click.option("-s", "--serial", help="Serial number of Solo to use") # @click.argument("filename") # def sha512sum(serial, filename): # """Calculate SHA512 hash of FILENAME.""" # data = open(filename, 'rb').read() # # CTAPHID_BUFFER_SIZE # # https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-client-to-authenticator-protocol-v2.0-id-20180227.html#usb-message-and-packet-structure # assert len(data) <= 7609 # p = solo.client.find(serial) # sha512sum = p.calculate_sha512(data) # print(sha512sum.hex().lower()) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def reset(serial): """Reset key - wipes all credentials!!!""" if click.confirm( "Warning: Your credentials will be lost!!! Do you wish to continue?" ): print("Press the button to confirm -- again, your credentials will be lost!!!") solo.client.find(serial).reset() click.echo("....aaaand they're gone") @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") # @click.option("--new-pin", help="change current pin") def change_pin(serial): """Change pin of current key""" old_pin = getpass.getpass("Please enter old pin: ") new_pin = getpass.getpass("Please enter new pin: ") confirm_pin = getpass.getpass("Please confirm new pin: ") if new_pin != confirm_pin: click.echo("New pin are mismatched. Please try again!") return try: solo.client.find(serial).change_pin(old_pin, new_pin) click.echo("Done. Please use new pin to verify key") except Exception as e: print(e) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") # @click.option("--new-pin", help="change current pin") def set_pin(serial): """Set pin of current key""" new_pin = getpass.getpass("Please enter new pin: ") confirm_pin = getpass.getpass("Please confirm new pin: ") if new_pin != confirm_pin: click.echo("New pin are mismatched. Please try again!") return try: solo.client.find(serial).set_pin(new_pin) click.echo("Done. Please use new pin to verify key") except Exception as e: print(e) @click.command() @click.option("--pin", help="PIN for to access key") @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) def verify(pin, serial, udp): """Verify key is valid Solo Secure or Solo Hacker.""" key = solo.client.find(serial, udp=udp) if ( key.client and ("clientPin" in key.client.info.options) and key.client.info.options["clientPin"] and not pin ): pin = getpass.getpass("PIN: ") # Any longer and this needs to go in a submodule print("Please press the button on your Solo key") try: cert = key.make_credential(pin=pin) except Fido2ClientError as e: cause = str(e.cause) if "PIN required" in cause: print("Your key has a PIN set but none was provided.") sys.exit(1) # error 0x31 if "PIN_INVALID" in cause: print("Your key has a different PIN. Please try to remember it :)") sys.exit(1) # error 0x34 (power cycle helps) if "PIN_AUTH_BLOCKED" in cause: print( "Your key's PIN authentication is blocked due to too many incorrect attempts." ) print("Please plug it out and in again, then again!") print( "Please be careful, after too many incorrect attempts, the key will fully block." ) sys.exit(1) # error 0x32 (only reset helps) if "PIN_BLOCKED" in cause: print( "Your key's PIN is blocked. To use it again, you need to fully reset it." ) print("You can do this using: `solo key reset`") sys.exit(1) # error 0x01 if "INVALID_COMMAND" in cause: print("Error getting credential, is your key in bootloader mode?") print("Try: `solo program aux leave-bootloader`") sys.exit(1) raise fingerprints = [ { "fingerprint": b"r\xd5\x831&\xac\xfc\xe9\xa8\xe8&`\x18\xe6AI4\xc8\xbeJ\xb8h_\x91\xb0\x99!\x13\xbb\xd42\x95", "msg": "Valid Solo (<=3.0.0) firmware from SoloKeys.", }, { "fingerprint": b"\xd0ml\xcb\xda}\xe5j\x16'\xc2\xa7\x89\x9c5\xa2\xa3\x16\xc8Q\xb3j\xd8\xed~\xd7\x84y\xbbx~\xf7", "msg": "Solo Hacker firmware.", }, { "fingerprint": b"\x05\x92\xe1\xb2\xba\x8ea\rb\x9a\x9b\xc0\x15\x19~J\xda\xdc16\xe0\xa0\xa1v\xd9\xb5}\x17\xa6\xb8\x0b8", "msg": "Local software emulation.", }, { "fingerprint": b"\xb3k\x03!\x11d\xdb\x1d`A>\xc0\xf8\xd8'\xe0\xee\xc2\x04\xbe)\x06S\x00\x94\x0e\xd9\xc5\x9b\x90S?", "msg": "Valid Solo Tap with firmware from SoloKeys.", }, { "fingerprint": b"\x8d\xde\x12\xdb\x98\xe8|\x90\xc9\xd6#\x1a\x9c\xd8\xfe?T\xdf\x82\xb7=s.\x8er\xec\x9f\x98\xf8\xb5\xc6\xc1", "msg": "Valid Somu with firmware from SoloKeys.", }, { "fingerprint": b"2u\x85\xe4\x9eIl\xff\xde\xbcK(\x06\x08\x1814\xe7\xcb\xf4\xc0\x16pg\x94v)\x1c\xd9\xb9\x81\x04", "msg": "Valid Solo with firmware from SoloKeys.", }, ] known = False for f in fingerprints: if cert.fingerprint(hashes.SHA256()) == f["fingerprint"]: print(f["msg"]) known = True break if not known: print("Unknown fingerprint! ", cert.fingerprint(hashes.SHA256())) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) def version(serial, udp): """Version of firmware on key.""" try: res = solo.client.find(serial, udp=udp).solo_version() major, minor, patch = res[:3] locked = "" if len(res) > 3: if res[3]: locked = "locked" else: locked = "unlocked" print(f"{major}.{minor}.{patch} {locked}") except solo.exceptions.NoSoloFoundError: print("No Solo found.") print("If you are on Linux, are your udev rules up to date?") except (solo.exceptions.NoSoloFoundError, ApduError): # Older print("Firmware is out of date (key does not know the SOLO_VERSION command).") @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) def wink(serial, udp): """Send wink command to key (blinks LED a few times).""" solo.client.find(serial, udp=udp).wink() @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) @click.option("--ping-data", default="pong", help="Data to send (default: pong)") def ping(serial, udp, ping_data): """Send ping command to key""" client = solo.client.find(serial, udp=udp) start = time.time() res = client.ping(ping_data) end = time.time() duration = int((end - start) * 1000) print(f"ping returned: {res}") print(f"took {duration} ms") @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") @click.argument("sequence") def keyboard(serial, sequence): """Program the specified key sequence to Solo""" dev = solo.client.find(serial) buf = sequence.encode("ascii") if len(buf) > 64: print("Keyboard sequence cannot exceed 64 bytes") else: dev.program_kbd(buf) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def disable_updates(serial): """Permanently disable firmware updates on Solo. Cannot be undone. Solo must be in bootloader mode.""" dev = solo.client.find(serial) dev.use_hid() if dev.disable_solo_bootloader(): print( "Success, firmware updates have been permanently disabled on this device." ) print("You will not be able to access bootloader mode again.") else: print("Failed to disable the firmware update.") @click.command(name="info") @click.option("--pin", help="PIN for to access key") @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) def cred_info(pin, serial, udp): """Get credentials metadata""" if not pin: pin = getpass.getpass("PIN: ") client = solo.client.find(serial, udp=udp) cm = client.cred_mgmt(pin) meta = cm.get_metadata() existing = meta[CredentialManagement.RESULT.EXISTING_CRED_COUNT] remaining = meta[CredentialManagement.RESULT.MAX_REMAINING_COUNT] print("Existing resident keys: {}".format(existing)) print("Remaining resident keys: {}".format(remaining)) @click.command(name="ls") @click.option("--pin", help="PIN for to access key") @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) def cred_ls(pin, serial, udp): """List stored credentials""" if not pin: pin = getpass.getpass("PIN: ") client = solo.client.find(serial, udp=udp) cm = client.cred_mgmt(pin) meta = cm.get_metadata() existing = meta[CredentialManagement.RESULT.EXISTING_CRED_COUNT] if existing == 0: print("No resident credentials on this device.") return rps = cm.enumerate_rps() all_creds = {} for rp in rps: rp_id = rp[CredentialManagement.RESULT.RP]["id"] creds = cm.enumerate_creds(rp[CredentialManagement.RESULT.RP_ID_HASH]) all_creds[rp_id] = creds if all_creds: print("{:20}{:20}{}".format("Relying Party", "Username", "Credential ID")) print("-" * 53) for rp_id, creds in all_creds.items(): for cred in creds: user = cred.get(CredentialManagement.RESULT.USER, "") cred_id = cred[CredentialManagement.RESULT.CREDENTIAL_ID]["id"] cred_id_b64 = base64.b64encode(cred_id).decode("ascii") print("{:20}{:20}{}".format(rp_id, user["name"], cred_id_b64)) @click.command(name="rm") @click.option("--pin", help="PIN for to access key") @click.option("-s", "--serial", help="Serial number of Solo to use") @click.option( "--udp", is_flag=True, default=False, help="Communicate over UDP with software key" ) @click.argument("credential-id") def cred_rm(pin, credential_id, serial, udp): """Remove stored credential""" if not pin: pin = getpass.getpass("PIN: ") client = solo.client.find(serial, udp=udp) cm = client.cred_mgmt(pin) cred = {"id": base64.b64decode(credential_id), "type": "public-key"} cm.delete_cred(cred) @click.command() @click.option("--pin", help="PIN for to access key") @click.option("-s", "--serial", help="Serial number of Solo to use") @click.argument("credential-id") @click.argument("filename") def sign_file(pin, serial, credential_id, filename): """Sign the specified file using the given credential-id""" dev = solo.client.find(serial) dgst = hashlib.sha256() with open(filename, "rb") as f: while True: data = f.read(64 * 1024) if not data: break dgst.update(data) print("{0} {1}".format(dgst.hexdigest(), filename)) print("Please press the button on your Solo key") ret = dev.sign_hash(base64.b64decode(credential_id), dgst.digest(), pin) sig = ret[1] sig_file = filename + ".sig" print("Saving signature to " + sig_file) with open(sig_file, "wb") as f: f.write(sig) key.add_command(rng) rng.add_command(hexbytes) rng.add_command(raw) rng.add_command(feedkernel) key.add_command(make_credential) key.add_command(challenge_response) key.add_command(reset) key.add_command(update) key.add_command(probe) key.add_command(change_pin) key.add_command(set_pin) # key.add_command(sha256sum) # key.add_command(sha512sum) key.add_command(version) key.add_command(verify) key.add_command(wink) key.add_command(disable_updates) key.add_command(ping) key.add_command(keyboard) key.add_command(cred) key.add_command(sign_file) cred.add_command(cred_info) cred.add_command(cred_ls) cred.add_command(cred_rm) solo-python-0.0.31/solo/cli/monitor.py000066400000000000000000000025431413500472600176600ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import sys import time import click import serial @click.command() @click.argument("serial_port") def monitor(serial_port): """Reads Solo Hacker serial output from USB serial port SERIAL_PORT. SERIAL-PORT is something like /dev/ttyACM0 or COM10. Automatically reconnects. Baud rate is 115200. """ ser = None def reconnect(): while True: time.sleep(0.02) try: ser = serial.Serial(serial_port, 115200, timeout=0.05) return ser except serial.SerialException: pass while True: try: if ser is None: ser = serial.Serial(serial_port, 115200, timeout=0.05) data = ser.read(1) sys.stdout.buffer.write(data) sys.stdout.flush() except serial.SerialException: if ser is not None: ser.close() print(f"reconnecting {serial_port}...") ser = reconnect() print("done") solo-python-0.0.31/solo/cli/program.py000066400000000000000000000205611413500472600176400ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import sys import time import click import usb from fido2.ctap import CtapError import solo from solo.dfu import hot_patch_windows_libusb @click.group() def program(): """Program a key.""" pass # @click.command() # def ctap(): # """Program via CTAP (either CTAP1 or CTAP2) (assumes Solo bootloader).""" # pass # program.add_command(ctap) @click.command() @click.option("-s", "--serial", help="serial number of DFU to use") @click.option( "-a", "--connect-attempts", default=8, help="number of times to attempt connecting" ) # @click.option("--attach", default=False, help="Attempt switching to DFU before starting") @click.option( "-d", "--detach", default=False, is_flag=True, help="Reboot after successful programming", ) @click.option("-n", "--dry-run", is_flag=True, help="Just attach and detach") @click.argument("firmware") # , help="firmware (bundle) to program") def dfu(serial, connect_attempts, detach, dry_run, firmware): """Program via STMicroelectronics DFU interface. Enter dfu mode using `solo program aux enter-dfu` first. """ import time import usb.core from intelhex import IntelHex dfu = solo.dfu.find(serial, attempts=connect_attempts) if dfu is None: print("No STU DFU device found.") if serial is not None: print("Serial number used: ", serial) sys.exit(1) dfu.init() if not dry_run: # The actual programming # TODO: move to `operations.py` or elsewhere ih = IntelHex() ih.fromfile(firmware, format="hex") chunk = 2048 # Why is this unused # seg = ih.segments()[0] size = sum([max(x[1] - x[0], chunk) for x in ih.segments()]) total = 0 t1 = time.time() * 1000 print("erasing...") try: dfu.mass_erase() except usb.core.USBError: # garbage write, sometimes needed before mass_erase dfu.write_page(0x08000000 + 2048 * 10, "ZZFF" * (2048 // 4)) dfu.mass_erase() page = 0 for start, end in ih.segments(): for i in range(start, end, chunk): page += 1 data = ih.tobinarray(start=i, size=chunk) dfu.write_page(i, data) total += chunk # here and below, progress would overshoot 100% otherwise progress = min(100, total / float(size) * 100) sys.stdout.write( "downloading %.2f%% %08x - %08x ... \r" % (progress, i, i + page) ) # time.sleep(0.100) # print('done') # print(dfu.read_mem(i,16)) t2 = time.time() * 1000 print() print("time: %d ms" % (t2 - t1)) print("verifying...") progress = 0 for start, end in ih.segments(): for i in range(start, end, chunk): data1 = dfu.read_mem(i, 2048) data2 = ih.tobinarray(start=i, size=chunk) total += chunk progress = min(100, total / float(size) * 100) sys.stdout.write( "reading %.2f%% %08x - %08x ... \r" % (progress, i, i + page) ) if (end - start) == chunk: assert data1 == data2 print() print("firmware readback verified.") if detach: dfu.prepare_options_bytes_detach() dfu.detach() print("Please powercycle the device (pull out, plug in again)") hot_patch_windows_libusb() program.add_command(dfu) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") @click.argument("firmware") # , help="firmware (bundle) to program") def bootloader(serial, firmware): """Program via Solo bootloader interface. \b FIRMWARE argument should be either a .hex or .json file. If the bootloader is verifying, the .json is needed containing a signature for the verifying key in the bootloader. If the bootloader is nonverifying, either .hex or .json can be used. DANGER: if you try to flash a firmware with signature that doesn't match the bootloader's verifying key, you will be stuck in bootloader mode until you find a signed firmware that does match. Enter bootloader mode using `solo program aux enter-bootloader` first. """ p = solo.client.find(serial) try: p.use_hid() p.program_file(firmware) except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: print("Not in bootloader mode. Attempting to switch...") else: raise e p.enter_bootloader_or_die() print("Solo rebooted. Reconnecting...") time.sleep(0.5) p = solo.client.find(serial) if p is None: print("Cannot find Solo device.") sys.exit(1) p.use_hid() p.program_file(firmware) program.add_command(bootloader) @click.group() def aux(): """Auxiliary commands related to firmware/bootloader/dfu mode.""" pass program.add_command(aux) def _enter_bootloader(serial): p = solo.client.find(serial) p.enter_bootloader_or_die() print("Solo rebooted. Reconnecting...") time.sleep(0.5) if solo.client.find(serial) is None: raise RuntimeError("Failed to reconnect!") @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def enter_bootloader(serial): """Switch from Solo firmware to Solo bootloader. Note that after powercycle, you will be in the firmware again, assuming it is valid. """ return _enter_bootloader(serial) aux.add_command(enter_bootloader) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def leave_bootloader(serial): """Switch from Solo bootloader to Solo firmware.""" p = solo.client.find(serial) # this is a bit too low-level... # p.exchange(solo.commands.SoloBootloader.done, 0, b"A" * 64) p.reboot() aux.add_command(leave_bootloader) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def enter_dfu(serial): """Switch from Solo bootloader to ST DFU bootloader. This changes the boot options of the key, which only reliably take effect after a powercycle. """ p = solo.client.find(serial) try: p.enter_st_dfu() print("Please powercycle the device (pull out, plug in again)") except Exception as e: if "wrong channel" in str(e).lower(): print( "Command wasn't accepted by Solo. It must be in bootloader mode first and be a 'hacker' device." ) aux.add_command(enter_dfu) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def leave_dfu(serial): """Leave ST DFU bootloader. Switches to Solo bootloader or firmware, latter if firmware is valid. This changes the boot options of the key, which only reliably take effect after a powercycle. """ dfu = solo.dfu.find(serial) # select option bytes dfu.init() dfu.prepare_options_bytes_detach() try: dfu.detach() except usb.core.USBError: pass hot_patch_windows_libusb() print("Please powercycle the device (pull out, plug in again)") aux.add_command(leave_dfu) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def reboot(serial): """Reboot. \b This should reboot from anything (firmware, bootloader, DFU). Separately, need to be able to set boot options. """ # this implementation actually only works for bootloader # firmware doesn't have a reboot command solo.client.find(serial).reboot() aux.add_command(reboot) @click.command() @click.option("-s", "--serial", help="Serial number of Solo to use") def bootloader_version(serial): """Version of bootloader.""" p = solo.client.find(serial) print(".".join(map(str, p.bootloader_version()))) aux.add_command(bootloader_version) solo-python-0.0.31/solo/cli/update.py000066400000000000000000000167631413500472600174640ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import base64 import hashlib import json import sys import tempfile import time import click import requests from fido2.ctap import CtapError from fido2.ctap1 import ApduError import solo from solo import helpers @click.command() @click.option("-s", "--serial", help="Serial number of Solo key to target") @click.option( "-y", "--yes", is_flag=True, help="Don't ask for confirmation before flashing" ) @click.option( "-lfs", "--local-firmware-server", is_flag=True, default=False, hidden=True, help="Development option: pull firmware from http://localhost:8000", ) @click.option( "--alpha", is_flag=True, default=False, hidden=True, help="Development option: use release refered to by ALPHA_VERSION", ) def update(serial, yes, local_firmware_server, alpha): """Update Solo key to latest firmware version.""" # Determine target key try: solo_client = solo.client.find(serial) except solo.exceptions.NoSoloFoundError: print() print("No Solo key found!") print() print("If you are on Linux, are your udev rules up to date?") print("Try adding a rule line such as the following:") print('ATTRS{idVendor}=="0483", ATTRS{idProduct}=="a2ca", TAG+="uaccess"') print("For more, see https://docs.solokeys.io/solo/udev/") print() sys.exit(1) except solo.exceptions.NonUniqueDeviceError: print() print("Multiple Solo keys are plugged in! Please:") # print(" * unplug all but one key, or") # print(" * specify target key via `--serial SERIAL_NUMBER`") print(" * unplug all but one key") print() sys.exit(1) except Exception: print() print("Unhandled error connecting to key.") print("Please report via https://github.com/solokeys/solo-python/issues/") print() sys.exit(1) # Ensure we are in bootloader mode try: solo_client.is_solo_bootloader() except (RuntimeError, ApduError): print("Please switch key to bootloader mode:") print("Unplug, hold button, plug in, wait for flashing yellow light.") sys.exit(1) # Get firmware version to use try: if alpha: version_file = "ALPHA_VERSION" else: version_file = "STABLE_VERSION" fetch_url = ( f"https://raw.githubusercontent.com/solokeys/solo/master/{version_file}" ) r = requests.get(fetch_url) if r.status_code != 200: print( f"Could not fetch version name from {version_file} in solokeys/solo repository!" ) sys.exit(1) version = r.text.split()[0].strip() # Windows BOM haha # if version.encode() == b'\xef\xbf\xbd\xef\xbf\xbd1\x00.\x001\x00.\x000\x00': # version = '1.1.0' try: assert version.count(".") == 2 major, minor, patch_and_more = version.split(".") if "-" in patch_and_more: patch, pre = patch_and_more.split("-") # noqa: F841 else: patch, pre = patch_and_more, None # noqa: F841 major, minor, patch = map(int, (major, minor, patch)) except Exception: print(f"Abnormal version format '{version}'") sys.exit(1) except Exception: print("Error fetching version name from solokeys/solo repository!") sys.exit(1) # Get firmware to use if local_firmware_server: base_url = "http://localhost:8000" else: base_url = f"https://github.com/solokeys/solo/releases/download/{version}" firmware_file_github = f"firmware-{version}.json" firmware_url = f"{base_url}/{firmware_file_github}" extension = firmware_url.rsplit(".")[-1] try: r = requests.get(firmware_url) if r.status_code != 200: print( "Could not fetch official firmware build from solokeys/solo repository releases!" ) print(f"URL attempted: {firmware_url}") sys.exit(1) content = r.content try: # might as well use r.json() here too json_content = json.loads(content.decode()) except Exception: print(f"Invalid JSON content fetched from {firmware_url}!") sys.exit(1) with tempfile.NamedTemporaryFile(suffix="." + extension, delete=False) as fh: fh.write(r.content) firmware_file = fh.name print(f"Wrote temporary copy of {firmware_file_github} to {firmware_file}") except Exception: print("Problem fetching {firmware_url}!") sys.exit(1) # Check sha256sum m = hashlib.sha256() firmware_content = base64.b64decode( helpers.from_websafe(json_content["firmware"]).encode() ) crlf_firmware_content = b"\r\n".join(firmware_content.split(b"\n")) m.update(crlf_firmware_content) our_digest = m.hexdigest() digest_url = firmware_url.rsplit(".", 1)[0] + ".sha2" official_digest = requests.get(digest_url).text.split()[0] if our_digest != official_digest: print( "sha256sum of downloaded firmware file does not coincide with published sha256sum!" ) print(f"sha256sum(downloaded): {our_digest}") print(f"sha256sum(published): {official_digest}") sys.exit(1) print(f"sha256sums coincide: {official_digest}") # Actually flash it... solo_client.use_hid() try: # We check the key accepted signature ourselves, # for more pertinent error messaging. if not solo_client.is_solo_bootloader(): print("Switching into bootloader mode...") solo_client.enter_bootloader_or_die() time.sleep(1.5) solo_client = solo.client.find(serial) solo_client.set_reboot(False) sig = solo_client.program_file(firmware_file) except Exception as e: if isinstance(e, CtapError): if e.code == CtapError.ERR.INVALID_COMMAND: print("Could not switch into bootloader mode.") print("Please put key into bootloader mode:") print("1. Unplug key") print("2. While holding button, plug in key for 2s") sys.exit(1) print("error:") print("problem flashing firmware!") print(e) sys.exit(1) try: print("bootloader is verifying signature...") solo_client.verify_flash(sig) print("...pass!") except Exception: print("...error!") print() print("Your key did not accept the firmware's signature! Possible reasons:") print( ' * Tried to flash "hacker" firmware on custom hacker key with verifying bootloader' ) print() print( "Currently, your key does not work. Please run update again with correct parameters" ) sys.exit(1) # NB: There is a remaining error case: Flashing secure firmware on hacker key # will give rise to an incorrect attestation certificate. print() print( f"Congratulations, your key was updated to the latest firmware version: {version}" ) solo-python-0.0.31/solo/client.py000066400000000000000000000027231413500472600167000ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import time from fido2.hid import CtapHidDevice import solo.exceptions from .devices import solo_v1 def find(solo_serial=None, retries=5, raw_device=None, udp=False): if udp: print("UDP is not supported in latest version of solo-python.") print("Please install version solo-python==0.0.27 and fido2==8.1 to do that.") # Try looking for V1 device. p = solo_v1.Client() # This... is not the right way to do it yet p.use_u2f() for i in range(retries): try: p.find_device(dev=raw_device, solo_serial=solo_serial) return p except RuntimeError: time.sleep(0.2) # return None raise solo.exceptions.NoSoloFoundError("no Solo found") def find_all(): hid_devices = list(CtapHidDevice.list_devices()) solo_devices = [ d for d in hid_devices if all( ( d.descriptor.vid == 1155, d.descriptor.pid == 41674, # "Solo" in d.descriptor["product_string"], ) ) ] return [find(raw_device=device) for device in solo_devices] solo-python-0.0.31/solo/commands.py000066400000000000000000000030661413500472600172240ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. class STM32L4: class options: nBOOT0 = 1 << 27 nSWBOOT0 = 1 << 26 class SoloExtension: version = 0x14 rng = 0x15 class SoloBootloader: write = 0x40 done = 0x41 check = 0x42 erase = 0x43 version = 0x44 reboot = 0x45 st_dfu = 0x46 disable = 0x47 CommandBoot = 0x50 CommandEnterBoot = 0x51 CommandEnterSTBoot = 0x52 CommandRNG = 0x60 CommandProbe = 0x70 TAG = b"\x8C\x27\x90\xf6" class DFU: class type: SEND = 0x21 RECEIVE = 0xA1 class bmReq: DETACH = 0x00 DNLOAD = 0x01 UPLOAD = 0x02 GETSTATUS = 0x03 CLRSTATUS = 0x04 GETSTATE = 0x05 ABORT = 0x06 class state: APP_IDLE = 0x00 APP_DETACH = 0x01 IDLE = 0x02 DOWNLOAD_SYNC = 0x03 DOWNLOAD_BUSY = 0x04 DOWNLOAD_IDLE = 0x05 MANIFEST_SYNC = 0x06 MANIFEST = 0x07 MANIFEST_WAIT_RESET = 0x08 UPLOAD_IDLE = 0x09 ERROR = 0x0A class status: def __init__(self, s): self.status = s[0] self.timeout = s[1] + (s[2] << 8) + (s[3] << 16) self.state = s[4] self.istring = s[5] solo-python-0.0.31/solo/devices/000077500000000000000000000000001413500472600164665ustar00rootroot00000000000000solo-python-0.0.31/solo/devices/__init__.py000066400000000000000000000000001413500472600205650ustar00rootroot00000000000000solo-python-0.0.31/solo/devices/base.py000066400000000000000000000107331413500472600177560ustar00rootroot00000000000000import struct from cryptography import x509 from cryptography.hazmat.backends import default_backend from fido2.attestation import Attestation from fido2.ctap2 import CTAP2, CredentialManagement from fido2.hid import CTAPHID from fido2.utils import hmac_sha256 from fido2.webauthn import PublicKeyCredentialCreationOptions from solo import helpers # Base class # Currently some methods are implemented here since they are the same in both devices. class SoloClient: def __init__( self, ): self.origin = "https://example.org" self.host = "example.org" self.user_id = b"they" self.do_reboot = True def set_reboot(self, val): """option to reboot after programming""" self.do_reboot = val def reboot( self, ): pass def find_device(self, dev=None, solo_serial=None): pass def get_current_hid_device( self, ): """Return current device class for CTAPHID interface if available.""" pass def get_current_fido_client( self, ): """Return current fido2 client if available.""" pass def send_data_hid(self, cmd, data): if not isinstance(data, bytes): data = struct.pack("%dB" % len(data), *[ord(x) for x in data]) with helpers.Timeout(1.0) as event: return self.get_current_hid_device().call(cmd, data, event) def bootloader_version( self, ): pass def solo_version( self, ): pass def get_rng(self, num=0): pass def wink( self, ): self.send_data_hid(CTAPHID.WINK, b"") def ping(self, data="pong"): return self.send_data_hid(CTAPHID.PING, data) def reset( self, ): CTAP2(self.get_current_hid_device()).reset() def change_pin(self, old_pin, new_pin): client = self.get_current_fido_client() client.client_pin.change_pin(old_pin, new_pin) def set_pin(self, new_pin): client = self.get_current_fido_client() client.client_pin.set_pin(new_pin) def make_credential(self, pin=None): client = self.get_current_fido_client() rp = {"id": self.host, "name": "example site"} user = {"id": self.user_id, "name": "example user"} challenge = b"Y2hhbGxlbmdl" options = PublicKeyCredentialCreationOptions( rp, user, challenge, [{"type": "public-key", "alg": -8}, {"type": "public-key", "alg": -7}], ) result = client.make_credential(options, pin=pin) attest = result.attestation_object data = result.client_data try: attest.verify(data.hash) except AttributeError: verifier = Attestation.for_type(attest.fmt) verifier().verify(attest.att_statement, attest.auth_data, data.hash) print("Register valid") x5c = attest.att_statement["x5c"][0] cert = x509.load_der_x509_certificate(x5c, default_backend()) return cert def cred_mgmt(self, pin): client = self.get_current_fido_client() token = client.client_pin.get_pin_token(pin) ctap2 = CTAP2(self.get_current_hid_device()) return CredentialManagement(ctap2, client.client_pin.protocol, token) def enter_solo_bootloader( self, ): """ If solo is configured as solo hacker or something similar, this command will tell the token to boot directly to the bootloader so it can be reprogrammed """ pass def enter_bootloader_or_die(self): pass def is_solo_bootloader( self, ): """For now, solo bootloader could be the NXP bootrom on Solo v2.""" pass def program_kbd(self, cmd): ctap2 = CTAP2(self.get_current_hid_device()) return ctap2.send_cbor(0x51, cmd) def sign_hash(self, credential_id, dgst, pin): ctap2 = CTAP2(self.get_current_hid_device()) client = self.get_current_fido_client() if pin: pin_token = client.client_pin.get_pin_token(pin) pin_auth = hmac_sha256(pin_token, dgst)[:16] return ctap2.send_cbor( 0x50, {1: dgst, 2: {"id": credential_id, "type": "public-key"}, 3: pin_auth}, ) else: return ctap2.send_cbor( 0x50, {1: dgst, 2: {"id": credential_id, "type": "public-key"}} ) def program_file(self, name): pass solo-python-0.0.31/solo/devices/solo_v1.py000066400000000000000000000256431413500472600204340ustar00rootroot00000000000000import base64 import json import struct import sys import tempfile import time from threading import Event from fido2.client import Fido2Client from fido2.ctap import CtapError from fido2.ctap1 import CTAP1 from fido2.ctap2 import CTAP2 from fido2.hid import CTAPHID, CtapHidDevice from intelhex import IntelHex import solo from solo import helpers from solo.commands import SoloBootloader, SoloExtension from .base import SoloClient class Client(SoloClient): def __init__( self, ): SoloClient.__init__(self) self.exchange = self.exchange_hid @staticmethod def format_request(cmd, addr=0, data=b"A" * 16): # not sure why this is here? # arr = b"\x00" * 9 addr = struct.pack("H", len(data)) return cmd + addr[:3] + SoloBootloader.TAG + length + data def reboot( self, ): """option to reboot after programming""" try: self.exchange(SoloBootloader.reboot) except OSError: pass def find_device(self, dev=None, solo_serial=None): if dev is None: devices = list(CtapHidDevice.list_devices()) if solo_serial is not None: for d in devices: if not hasattr(d, "serial_number"): print( "Currently serial numbers are not supported with current fido2 library. Please upgrade: pip3 install fido2 --upgrade" ) sys.exit(1) devices = [ d for d in devices if d.descriptor.serial_number == solo_serial ] if len(devices) > 1: raise solo.exceptions.NonUniqueDeviceError if len(devices) == 0: raise RuntimeError("No FIDO device found") dev = devices[0] self.dev = dev self.ctap1 = CTAP1(dev) try: self.ctap2 = CTAP2(dev) except CtapError: self.ctap2 = None try: self.client = Fido2Client(dev, self.origin) except CtapError: print("Not using FIDO2 interface.") self.client = None if self.exchange == self.exchange_hid: self.send_data_hid(CTAPHID.INIT, "\x11\x11\x11\x11\x11\x11\x11\x11") return self.dev def get_current_hid_device( self, ): return self.dev def get_current_fido_client( self, ): return self.client def use_u2f( self, ): self.exchange = self.exchange_u2f def use_hid( self, ): self.exchange = self.exchange_hid def send_only_hid(self, cmd, data): if not isinstance(data, bytes): data = struct.pack("%dB" % len(data), *[ord(x) for x in data]) no_reply = Event() no_reply.set() try: self.dev.call(0x80 | cmd, bytearray(data), no_reply) except IOError: pass def exchange_hid(self, cmd, addr=0, data=b"A" * 16): req = Client.format_request(cmd, addr, data) data = self.send_data_hid(SoloBootloader.CommandBoot, req) ret = data[0] if ret != CtapError.ERR.SUCCESS: raise CtapError(ret) return data[1:] def exchange_u2f(self, cmd, addr=0, data=b"A" * 16): appid = b"A" * 32 chal = b"B" * 32 req = Client.format_request(cmd, addr, data) res = self.ctap1.authenticate(chal, appid, req) ret = res.signature[0] if ret != CtapError.ERR.SUCCESS: raise CtapError(ret) return res.signature[1:] def exchange_fido2(self, cmd, addr=0, data=b"A" * 16): chal = b"B" * 32 req = Client.format_request(cmd, addr, data) assertion = self.ctap2.get_assertion( self.host, chal, [{"id": req, "type": "public-key"}] ) res = assertion ret = res.signature[0] if ret != CtapError.ERR.SUCCESS: raise RuntimeError("Device returned non-success code %02x" % (ret,)) return res.signature[1:] def bootloader_version( self, ): data = self.exchange(SoloBootloader.version) if len(data) > 2: return (data[0], data[1], data[2]) return (0, 0, data[0]) def solo_version( self, ): try: return self.send_data_hid(0x61, b"") except CtapError: data = self.exchange(SoloExtension.version) return (data[0], data[1], data[2]) def write_flash(self, addr, data): self.exchange(SoloBootloader.write, addr, data) def get_rng(self, num=0): ret = self.send_data_hid(SoloBootloader.CommandRNG, struct.pack("B", num)) return ret def verify_flash(self, sig): """ Tells device to check signature against application. If it passes, the application will boot. Exception raises if signature fails. """ self.exchange(SoloBootloader.done, 0, sig) def enter_solo_bootloader( self, ): """ If solo is configured as solo hacker or something similar, this command will tell the token to boot directly to the bootloader so it can be reprogrammed """ if self.exchange != self.exchange_hid: self.send_data_hid(CTAPHID.INIT, "\x11\x11\x11\x11\x11\x11\x11\x11") self.send_data_hid(SoloBootloader.CommandEnterBoot, "") def enter_bootloader_or_die(self): try: self.enter_solo_bootloader() # except OSError: # pass except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: print( "Could not switch into bootloader mode. Please hold down the button for 2s while you plug token in." ) sys.exit(1) else: raise (e) def is_solo_bootloader( self, ): try: self.bootloader_version() return True except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: pass else: raise (e) return False def enter_st_dfu( self, ): """ If solo is configured as solo hacker or something similar, this command will tell the token to boot directly to the st DFU so it can be reprogrammed. Warning, you could brick your device. """ soloboot = self.is_solo_bootloader() if soloboot or self.exchange == self.exchange_u2f: req = Client.format_request(SoloBootloader.st_dfu) self.send_only_hid(SoloBootloader.CommandBoot, req) else: self.send_only_hid(SoloBootloader.CommandEnterSTBoot, "") def disable_solo_bootloader( self, ): """ Disables the Solo bootloader. Only do this if you want to void the possibility of any updates. If you've started from a solo hacker, make you you've programmed a final/production build! """ if not self.is_solo_bootloader(): print("Device must be in bootloader mode.") return False ret = self.exchange( SoloBootloader.disable, 0, b"\xcd\xde\xba\xaa" ) # magic number if ret[0] != CtapError.ERR.SUCCESS: print("Failed to disable bootloader") return False time.sleep(0.1) self.exchange(SoloBootloader.reboot) return True def program_file(self, name): def parseField(f): return base64.b64decode(helpers.from_websafe(f).encode()) def isCorrectVersion(current, target): """current is tuple (x,y,z). target is string '>=x.y.z'. Return True if current satisfies the target expression. """ if "=" in target: target = target.split("=") assert target[0] in [">", "<"] target_num = [int(x) for x in target[1].split(".")] assert len(target_num) == 3 comp = target[0] + "=" else: assert target[0] in [">", "<"] target_num = [int(x) for x in target[1:].split(".")] comp = target[0] target_num = ( (target_num[0] << 16) | (target_num[1] << 8) | (target_num[2] << 0) ) current_num = (current[0] << 16) | (current[1] << 8) | (current[2] << 0) return eval(str(current_num) + comp + str(target_num)) if name.lower().endswith(".json"): data = json.loads(open(name, "r").read()) fw = parseField(data["firmware"]) sig = None if "versions" in data: current = (0, 0, 0) try: current = self.bootloader_version() except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: pass else: raise (e) for v in data["versions"]: if isCorrectVersion(current, v): print("using signature version", v) sig = parseField(data["versions"][v]["signature"]) break if sig is None: raise RuntimeError( "Improperly formatted firmware file. Could not match version." ) else: sig = parseField(data["signature"]) ih = IntelHex() tmp = tempfile.NamedTemporaryFile(delete=False) tmp.write(fw) tmp.seek(0) tmp.close() ih.fromfile(tmp.name, format="hex") else: if not name.lower().endswith(".hex"): print('Warning, assuming "%s" is an Intel Hex file.' % name) sig = None ih = IntelHex() ih.fromfile(name, format="hex") if self.exchange == self.exchange_hid: chunk = 2048 else: chunk = 240 total = 0 size = sum(seg[1] - seg[0] for seg in ih.segments()) t1 = time.time() * 1000 print("erasing firmware...") for seg in ih.segments(): for i in range(seg[0], seg[1], chunk): s = i e = min(i + chunk, seg[1]) data = ih.tobinarray(start=i, size=e - s) self.write_flash(i, data) total += chunk progress = total / float(size) * 100 sys.stdout.write("updating firmware %.2f%%...\r" % progress) sys.stdout.write("updated firmware 100% \r\n") t2 = time.time() * 1000 print("time: %.2f s" % ((t2 - t1) / 1000.0)) if sig is None: sig = b"A" * 64 if self.do_reboot: self.verify_flash(sig) return sig solo-python-0.0.31/solo/dfu.py000066400000000000000000000215161413500472600162010ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import errno import struct import time import usb._objfinalizer import usb.core import usb.util import solo.exceptions from solo.commands import DFU, STM32L4 def find(dfu_serial=None, attempts=8, raw_device=None, altsetting=1): """dfu_serial is the ST bootloader serial number. It is not directly the ST chip identifier, but related via https://github.com/libopencm3/libopencm3/blob/master/lib/stm32/desig.c#L68 """ for i in range(attempts): dfu = DFUDevice() try: dfu.find(ser=dfu_serial, dev=raw_device, altsetting=altsetting) return dfu except RuntimeError: time.sleep(0.25) # return None raise Exception("no DFU found") def find_all(): st_dfus = usb.core.find(idVendor=0x0483, idProduct=0xDF11, find_all=True) return [find(raw_device=st_dfu) for st_dfu in st_dfus] def hot_patch_windows_libusb(): # hot patch for windows libusb backend olddel = usb._objfinalizer._AutoFinalizedObjectBase.__del__ def newdel(self): try: olddel(self) except OSError: pass usb._objfinalizer._AutoFinalizedObjectBase.__del__ = newdel class DFUDevice: def __init__( self, ): pass @staticmethod def addr2list(a): return [a & 0xFF, (a >> 8) & 0xFF, (a >> 16) & 0xFF, (a >> 24) & 0xFF] @staticmethod def addr2block(addr, size): addr -= 0x08000000 addr //= size addr += 2 return addr @staticmethod def block2addr(addr, size): addr -= 2 addr *= size addr += 0x08000000 return addr def find(self, altsetting=0, ser=None, dev=None): if dev is not None: self.dev = dev else: if ser: devs = usb.core.find(idVendor=0x0483, idProduct=0xDF11, find_all=True) eligible = [ d for d in devs if ser == usb.util.get_string(d, d.iSerialNumber) ] if len(eligible) > 1: raise solo.exceptions.NonUniqueDeviceError if len(eligible) == 0: raise RuntimeError("No ST DFU devices found.") self.dev = eligible[0] print("connecting to ", ser) else: eligible = list( usb.core.find(idVendor=0x0483, idProduct=0xDF11, find_all=True) ) if len(eligible) > 1: raise solo.exceptions.NonUniqueDeviceError if len(eligible) == 0: raise RuntimeError("No ST DFU devices found.") self.dev = eligible[0] if self.dev is None: raise RuntimeError("No ST DFU devices found.") self.dev.set_configuration() for cfg in self.dev: for intf in cfg: if intf.bAlternateSetting == altsetting: intf.set_altsetting() self.intf = intf self.intNum = intf.bInterfaceNumber return self.dev raise RuntimeError("No ST DFU alternate-%d found." % altsetting) # Main memory == 0 # option bytes == 1 def set_alt(self, alt): for cfg in self.dev: for intf in cfg: # print(intf, intf.bAlternateSetting) if intf.bAlternateSetting == alt: intf.set_altsetting() self.intf = intf self.intNum = intf.bInterfaceNumber # return self.dev def init(self): if self.state() == DFU.state.ERROR: self.clear_status() def close(self): pass def get_status(self): tries = 3 while True: try: # bmReqType, bmReq, wValue, wIndex, data/size s = self.dev.ctrl_transfer( DFU.type.RECEIVE, DFU.bmReq.GETSTATUS, 0, self.intNum, 6 ) break except usb.core.USBError as e: if e.errno == errno.EPIPE: if tries > 0: tries -= 1 time.sleep(0.01) else: # do not pass on EPIPE which might be swallowed by 'click' raise RuntimeError("Failed to get status from DFU.") else: raise return DFU.status(s) def state(self): return self.get_status().state def clear_status(self): # bmReqType, bmReq, wValue, wIndex, data/size self.dev.ctrl_transfer(DFU.type.SEND, DFU.bmReq.CLRSTATUS, 0, self.intNum, None) def upload(self, block, size): """ address is ((block – 2) × size) + 0x08000000 """ # bmReqType, bmReq, wValue, wIndex, data/size return self.dev.ctrl_transfer( DFU.type.RECEIVE, DFU.bmReq.UPLOAD, block, self.intNum, size ) def set_addr(self, addr): # must get_status after to take effect return self.dnload(0x0, [0x21] + DFUDevice.addr2list(addr)) def dnload(self, block, data): # bmReqType, bmReq, wValue, wIndex, data/size return self.dev.ctrl_transfer( DFU.type.SEND, DFU.bmReq.DNLOAD, block, self.intNum, data ) def erase(self, a): d = [0x41, a & 0xFF, (a >> 8) & 0xFF, (a >> 16) & 0xFF, (a >> 24) & 0xFF] return self.dnload(0x0, d) def mass_erase(self): # self.set_addr(0x08000000) # self.block_on_state(DFU.state.DOWNLOAD_BUSY) # assert(DFU.state.DOWNLOAD_IDLE == self.state()) self.dnload(0x0, [0x41]) self.block_on_state(DFU.state.DOWNLOAD_BUSY) assert DFU.state.DOWNLOAD_IDLE == self.state() def write_page(self, addr, data): if self.state() not in (DFU.state.IDLE, DFU.state.DOWNLOAD_IDLE): self.clear_status() self.clear_status() if self.state() not in (DFU.state.IDLE, DFU.state.DOWNLOAD_IDLE): raise RuntimeError("DFU device not in correct state for writing memory.") addr = DFUDevice.addr2block(addr, len(data)) # print('flashing %d bytes to block %d/%08x...' % (len(data), addr,oldaddr)) self.dnload(addr, data) self.block_on_state(DFU.state.DOWNLOAD_BUSY) assert DFU.state.DOWNLOAD_IDLE == self.state() def read_mem(self, addr, size): addr = DFUDevice.addr2block(addr, size) if self.state() not in (DFU.state.IDLE, DFU.state.UPLOAD_IDLE): self.clear_status() self.clear_status() if self.state() not in (DFU.state.IDLE, DFU.state.UPLOAD_IDLE): raise RuntimeError("DFU device not in correct state for reading memory.") return self.upload(addr, size) def block_on_state(self, state): s = self.get_status() while s.state == state: time.sleep(s.timeout / 1000.0) s = self.get_status() def read_option_bytes(self): ptr = 0x1FFF7800 # option byte address for STM32l432 self.set_addr(ptr) self.block_on_state(DFU.state.DOWNLOAD_BUSY) m = self.read_mem(0, 16) return m def write_option_bytes(self, m): self.block_on_state(DFU.state.DOWNLOAD_BUSY) try: m = self.write_page(0, m) self.block_on_state(DFU.state.DOWNLOAD_BUSY) except OSError: print("Warning: OSError with write_page") def prepare_options_bytes_detach(self): # Necessary to prevent future errors... m = self.read_mem(0, 16) self.write_option_bytes(m) # m = self.read_option_bytes() op = struct.unpack(" or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. from enum import Enum class SoloMode(Enum): firmware = 1 bootloader = 2 dfu = 3 class SoloVariant(Enum): secure = 1 hacker = 2 solo-python-0.0.31/solo/exceptions.py000066400000000000000000000013571413500472600176050ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. class NonUniqueDeviceError(Exception): """When specifying a potentially destructive command... we check that either there is exactly one applicable device, or demand passing the serial number (same for ST DFU bootloader and Solo bootloader+firmware. """ pass class NoSoloFoundError(Exception): """Can signify no Solo, or missing udev rule on Linux.""" pass solo-python-0.0.31/solo/fido2/000077500000000000000000000000001413500472600160475ustar00rootroot00000000000000solo-python-0.0.31/solo/fido2/__init__.py000066400000000000000000000000001413500472600201460ustar00rootroot00000000000000solo-python-0.0.31/solo/helpers.py000066400000000000000000000027231413500472600170640ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. from numbers import Number from threading import Event, Timer def to_websafe(data): data = data.replace("+", "-") data = data.replace("/", "_") data = data.replace("=", "") return data def from_websafe(data): data = data.replace("-", "+") data = data.replace("_", "/") return data + "=="[: (3 * len(data)) % 4] class Timeout(object): """Utility class for adding a timeout to an event. :param time_or_event: A number, in seconds, or a threading.Event object. :ivar event: The Event associated with the Timeout. :ivar timer: The Timer associated with the Timeout, if any. """ def __init__(self, time_or_event): if isinstance(time_or_event, 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() solo-python-0.0.31/solo/hmac_secret.py000066400000000000000000000054211413500472600176750ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. # # isort:skip_file import binascii import hashlib import secrets import solo.client def make_credential( host="solokeys.dev", user_id="they", serial=None, pin=None, prompt="Touch your authenticator to generate a credential...", output=True, udp=False, ): user_id = user_id.encode() client = solo.client.find(solo_serial=serial, udp=udp).get_current_fido_client() rp = {"id": host, "name": "Example RP"} client.host = host client.origin = f"https://{client.host}" client.user_id = user_id user = {"id": user_id, "name": "A. User"} challenge = secrets.token_bytes(32) if prompt: print(prompt) attestation_object = client.make_credential( { "rp": rp, "user": user, "challenge": challenge, "pubKeyCredParams": [ {"type": "public-key", "alg": -8}, {"type": "public-key", "alg": -7}, ], "extensions": {"hmacCreateSecret": True}, }, pin=pin, ).attestation_object credential = attestation_object.auth_data.credential_data credential_id = credential.credential_id if output: print(credential_id.hex()) return credential_id def simple_secret( credential_id, secret_input, host="solokeys.dev", user_id="they", serial=None, pin=None, prompt="Touch your authenticator to generate a response...", output=True, udp=False, ): user_id = user_id.encode() client = solo.client.find(solo_serial=serial, udp=udp).get_current_fido_client() # rp = {"id": host, "name": "Example RP"} client.host = host client.origin = f"https://{client.host}" client.user_id = user_id # user = {"id": user_id, "name": "A. User"} credential_id = binascii.a2b_hex(credential_id) allow_list = [{"type": "public-key", "id": credential_id}] challenge = secrets.token_bytes(32) h = hashlib.sha256() h.update(secret_input.encode()) salt = h.digest() if prompt: print(prompt) assertion = client.get_assertion( { "rpId": host, "challenge": challenge, "allowCredentials": allow_list, "extensions": {"hmacGetSecret": {"salt1": salt}}, }, pin=pin, ).get_response(0) output = assertion.extension_results["hmacGetSecret"]["output1"] if output: print(output.hex()) return output solo-python-0.0.31/solo/operations.py000066400000000000000000000201771413500472600176100ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. import binascii import struct from intelhex import IntelHex from solo import helpers def genkey(output_pem_file, input_seed_file=None): from ecdsa import NIST256p, SigningKey from ecdsa.util import randrange_from_seed__trytryagain if input_seed_file is not None: seed = input_seed_file print("using input seed file ", seed) rng = open(seed, "rb").read() secexp = randrange_from_seed__trytryagain(rng, NIST256p.order) sk = SigningKey.from_secret_exponent(secexp, curve=NIST256p) else: sk = SigningKey.generate(curve=NIST256p) sk_name = output_pem_file print(f"Signing key for signing device firmware: {sk_name}") with open(sk_name, "wb+") as fh: fh.write(sk.to_pem()) vk = sk.get_verifying_key() return vk hacker_attestation_cert = b"".join( [ b"0\x82\x02\xe90\x82\x02\x8e\xa0\x03\x02\x01\x02\x02\x01\x010" b"\n\x06\x08*\x86H\xce=\x04\x03\x020\x81\x821\x0b0\t\x06\x03U" b"\x04\x06\x13\x02US1\x110\x0f\x06\x03U\x04\x08\x0c\x08Maryla" b"nd1\x140\x12\x06\x03U\x04\n\x0c\x0bSOLO HACKER1\x100\x0e\x06" b"\x03U\x04\x0b\x0c\x07Root CA1\x150\x13\x06\x03U\x04\x03\x0c" b"\x0csolokeys.com1!0\x1f\x06\t*\x86H\x86\xf7\r\x01\t\x01\x16" b"\x12hello@solokeys.com0 \x17\r181211022012Z\x18\x0f20681128" b"022012Z0\x81\x941\x0b0\t\x06\x03U\x04\x06\x13\x02US1\x110\x0f" b"\x06\x03U\x04\x08\x0c\x08Maryland1\x140\x12\x06\x03U\x04\n\x0c" b'\x0bSOLO HACKER1"0 \x06\x03U\x04\x0b\x0c\x19Authenticator Atte' b"station1\x150\x13\x06\x03U\x04\x03\x0c\x0csolokeys.com1!0\x1f" b"\x06\t*\x86H\x86\xf7\r\x01\t\x01\x16\x12hello@solokeys.com0Y0" b"\x13\x06\x07*\x86H\xce=\x02\x01\x06\x08*\x86H\xce=\x03\x01\x07" b"\x03B\x00\x04}x\xf6\xbe\xca@v;\xc7\\\xe3\xac\xf4'\x12\xc3\x94" b"\x98\x137\xa6A\x0e\x92\xf6\x9a;\x15G\x8d\xb6\xce\xd9\xd3O9\x13" b"\xed\x12{\x81\x14;\xe8\xf9L\x968\xfe\xe3\xd6\xcb\x1bS\x93\xa2t" b"\xf7\x13\x9a\x0f\x9d^\xa6\xa3\x81\xde0\x81\xdb0\x1d\x06\x03U" b"\x1d\x0e\x04\x16\x04\x14\x9a\xfb\xa2!\t#\xb5\xe4z*\x1dzlN\x03" b"\x89\x92\xa3\x0e\xc20\x81\xa1\x06\x03U\x1d#\x04\x81\x990\x81" b"\x96\xa1\x81\x88\xa4\x81\x850\x81\x821\x0b0\t\x06\x03U\x04\x06" b"\x13\x02US1\x110\x0f\x06\x03U\x04\x08\x0c\x08Maryland1\x140\x12" b"\x06\x03U\x04\n\x0c\x0bSOLO HACKER1\x100\x0e\x06\x03U\x04\x0b\x0c" b"\x07Root CA1\x150\x13\x06\x03U\x04\x03\x0c\x0csolokeys.com1!0\x1f" b"\x06\t*\x86H\x86\xf7\r\x01\t\x01\x16\x12hello@solokeys.com\x82\t" b"\x00\xeb\xd4\x84P\x14\xab\xd1W0\t\x06\x03U\x1d\x13\x04\x020\x000" b"\x0b\x06\x03U\x1d\x0f\x04\x04\x03\x02\x04\xf00\n\x06\x08*\x86H\xce=" b"\x04\x03\x02\x03I\x000F\x02!\x00\xa1{*\x1dNB\xa8hmea\x1e\xf5\xfem" b"\xc6\x99\xae| \x83\x16\xba\xd6\xe5\x0f\xd7\r~\x05\xda\xc9\x02!\x00" b"\x92I\xf3\x0bW\xd1\x19r\xf2uZ\xa2\xe0\xb6\xbd\x0f\x078\xd0\xe5\xa2" b"O\xa0\xf3\x87a\x82\xd8\xcdH\xfcW" ] ) def mergehex( input_hex_files, output_hex_file, attestation_key=None, attestation_cert=None, APPLICATION_END_PAGE=20, lock=False, ): """Merges hex files, and patches in the attestation key. If no attestation key is passed, uses default Solo Hacker one. Note that later hex files replace data of earlier ones, if they overlap. """ if attestation_key is not None and attestation_cert is None: raise RuntimeError("Need to provide certificate with attestation_key") if attestation_key is None and attestation_cert is not None: raise RuntimeError("Need to provide certificate with attestation_key") if attestation_key is None: # generic / hacker attestation key attestation_key = ( "1b2626ecc8f69b0f69e34fb236d76466ba12ac16c3ab5750ba064e8b90e02448" ) if attestation_cert is None: attestation_cert = hacker_attestation_cert else: attestation_cert = open(attestation_cert, "rb").read() if len(attestation_cert) < 100: raise RuntimeError("Attestation certificate is invalid") # TODO put definitions somewhere else def flash_addr(num): return 0x08000000 + num * 2048 PAGES = 128 APPLICATION_END_PAGE = PAGES - APPLICATION_END_PAGE AUTH_WORD_ADDR = flash_addr(APPLICATION_END_PAGE) - 8 ATTEST_ADDR = flash_addr(PAGES - 15) print(f"app end page: {APPLICATION_END_PAGE}") first = IntelHex(input_hex_files[0]) for input_hex_file in input_hex_files[1:]: print(f"merging {first} with {input_hex_file}") first.merge(IntelHex(input_hex_file), overlap="replace") first[flash_addr(APPLICATION_END_PAGE - 1)] = 0x41 first[flash_addr(APPLICATION_END_PAGE - 1) + 1] = 0x41 # authorize boot first[AUTH_WORD_ADDR + 0] = 0 first[AUTH_WORD_ADDR + 1] = 0 first[AUTH_WORD_ADDR + 2] = 0 first[AUTH_WORD_ADDR + 3] = 0 # make sure bootloader is enabled first[AUTH_WORD_ADDR + 4] = 0xFF first[AUTH_WORD_ADDR + 5] = 0xFF first[AUTH_WORD_ADDR + 6] = 0xFF first[AUTH_WORD_ADDR + 7] = 0xFF # patch in the attestation key key = binascii.unhexlify(attestation_key) for i, x in enumerate(key): first[ATTEST_ADDR + i] = x offset = 32 # patch in device settings / i.e. lock byte in little endian 64 int. lock_byte = 0x02 if lock else 0x00 device_settings = struct.pack("2.5.3": {"signature": v2["signature"]}, }, } def sign_firmware_for_version(sk_name, hex_file, APPLICATION_END_PAGE): # Maybe this is not the optimal module... import base64 import binascii from hashlib import sha256 from ecdsa import SigningKey from intelhex import IntelHex sk = SigningKey.from_pem(open(sk_name).read()) fw = open(hex_file, "r").read() fw = base64.b64encode(fw.encode()) fw = helpers.to_websafe(fw.decode()) ih = IntelHex() ih.fromfile(hex_file, format="hex") # start of firmware and the size of the flash region allocated for it. # TODO put this somewhere else. START = ih.segments()[0][0] # keep in sync with targets/stm32l432/src/memory_layout.h PAGES = 128 PAGE_SIZE = 2048 END = (0x08000000 + ((PAGES - APPLICATION_END_PAGE) * PAGE_SIZE)) - 8 ih = IntelHex(hex_file) # segs = ih.segments() arr = ih.tobinarray(start=START, size=END - START) im_size = END - START print("im_size: ", im_size) print("firmware_size: ", len(arr)) byts = (arr).tobytes() if hasattr(arr, "tobytes") else (arr).tostring() h = sha256() h.update(byts) sig = binascii.unhexlify(h.hexdigest()) print("hash", binascii.hexlify(sig)) sig = sk.sign_digest(sig) print("sig", binascii.hexlify(sig)) sig = base64.b64encode(sig) sig = helpers.to_websafe(sig.decode()) # msg = {'data': read()} msg = {"firmware": fw, "signature": sig} return msg solo-python-0.0.31/solo/solotool.py000066400000000000000000000237761413500472600173070ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2019 SoloKeys Developers # # Licensed under the Apache License, Version 2.0, or the MIT license , at your option. This file may not be # copied, modified, or distributed except according to those terms. # Programs solo using the Solo bootloader import argparse import array import base64 import binascii import json import os import socket import struct import sys import tempfile import time from binascii import hexlify, unhexlify from hashlib import sha256 import click import serial import usb._objfinalizer import usb.core from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from fido2.attestation import Attestation from fido2.client import ClientError, Fido2Client from fido2.ctap import CtapError from fido2.ctap1 import CTAP1, ApduError from fido2.ctap2 import CTAP2 from fido2.hid import CTAPHID, CtapHidDevice from intelhex import IntelHex import solo from solo import helpers def get_firmware_object(sk_name, hex_file): # move to helpers return helpers.sign_firmware(sk_name, hex_file) def attempt_to_find_device(p): found = False for i in range(0, 5): try: p.find_device() found = True break except RuntimeError: time.sleep(0.2) return found def attempt_to_boot_bootloader(p): try: p.enter_solo_bootloader() except OSError: pass except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: print( "Solo appears to not be a solo hacker. Try holding down the button for 2 while you plug token in." ) sys.exit(1) else: raise (e) print("Solo rebooted. Reconnecting...") time.sleep(0.500) if not attempt_to_find_device(p): raise RuntimeError("Failed to reconnect!") def solo_main(): # moved to new CLI pass def asked_for_help(): for i, v in enumerate(sys.argv): if v == "-h" or v == "--help": return True return False def monitor_main(): # moved to new CLI pass def genkey_main(): # moved to new CLI pass def sign_main(): # moved to new CLI pass def use_dfu(args): fw = args.__dict__["[firmware]"] for i in range(0, 8): dfu = DFUDevice() try: dfu.find(ser=args.dfu_serial) except RuntimeError: time.sleep(0.25) dfu = None if dfu is None: print("No STU DFU device found. ") if args.dfu_serial: print("Serial number used: ", args.dfu_serial) sys.exit(1) dfu.init() if fw: ih = IntelHex() ih.fromfile(fw, format="hex") chunk = 2048 seg = ih.segments()[0] size = sum([max(x[1] - x[0], chunk) for x in ih.segments()]) total = 0 t1 = time.time() * 1000 print("erasing...") try: dfu.mass_erase() except usb.core.USBError: dfu.write_page(0x08000000 + 2048 * 10, "ZZFF" * (2048 // 4)) dfu.mass_erase() page = 0 for start, end in ih.segments(): for i in range(start, end, chunk): page += 1 s = i data = ih.tobinarray(start=i, size=chunk) dfu.write_page(i, data) total += chunk progress = total / float(size) * 100 sys.stdout.write( "downloading %.2f%% %08x - %08x ... \r" % (progress, i, i + page) ) # time.sleep(0.100) # print('done') # print(dfu.read_mem(i,16)) t2 = time.time() * 1000 print() print("time: %d ms" % (t2 - t1)) print("verifying...") progress = 0 for start, end in ih.segments(): for i in range(start, end, chunk): data1 = dfu.read_mem(i, 2048) data2 = ih.tobinarray(start=i, size=chunk) total += chunk progress = total / float(size) * 100 sys.stdout.write( "reading %.2f%% %08x - %08x ... \r" % (progress, i, i + page) ) if (end - start) == chunk: assert data1 == data2 print() print("firmware readback verified.") if args.detach: dfu.detach() def programmer_main(): parser = argparse.ArgumentParser() parser.add_argument( "[firmware]", nargs="?", default="", help="firmware file. Either a JSON or hex file. JSON file contains signature while hex does not.", ) parser.add_argument( "--use-hid", action="store_true", help="Programs using custom HID command (default). Quicker than using U2F authenticate which is what a browser has to use.", ) parser.add_argument( "--use-u2f", action="store_true", help="Programs using U2F authenticate. This is what a web application will use.", ) parser.add_argument( "--no-reset", action="store_true", help="Don't reset after writing firmware. Stay in bootloader mode.", ) parser.add_argument( "--reset-only", action="store_true", help="Don't write anything, try to boot without a signature.", ) parser.add_argument( "--reboot", action="store_true", help="Tell bootloader to reboot." ) parser.add_argument( "--enter-bootloader", action="store_true", help="Don't write anything, try to enter bootloader. Typically only supported by Solo Hacker builds.", ) parser.add_argument( "--st-dfu", action="store_true", help="Don't write anything, try to enter ST DFU. Warning, you could brick your Solo if you overwrite everything. You should reprogram the option bytes just to be safe (boot to Solo bootloader first, then run this command).", ) parser.add_argument( "--disable", action="store_true", help="Disable the Solo bootloader. Cannot be undone. No future updates can be applied.", ) parser.add_argument( "--detach", action="store_true", help="Detach from ST DFU and boot from main flash. Must be in DFU mode.", ) parser.add_argument( "--dfu-serial", default="", help="Specify a serial number for a specific DFU device to connect to.", ) parser.add_argument( "--use-dfu", action="store_true", help="Boot to ST-DFU before continuing." ) args = parser.parse_args() fw = args.__dict__["[firmware]"] try: p = solo.client.find() if args.use_dfu: print("entering dfu..") try: attempt_to_boot_bootloader(p) p.enter_st_dfu() except RuntimeError: # already in DFU mode? pass except RuntimeError: print("No Solo device detected.") if fw or args.detach: use_dfu(args) sys.exit(0) else: sys.exit(1) if args.detach: use_dfu(args) sys.exit(0) if args.use_u2f: p.use_u2f() if args.no_reset: p.set_reboot(False) if args.enter_bootloader: print("Attempting to boot into bootloader mode...") attempt_to_boot_bootloader(p) sys.exit(0) if args.reboot: p.reboot() sys.exit(0) if args.st_dfu: print("Sending command to boot into ST DFU...") p.enter_st_dfu() sys.exit(0) if args.disable: p.disable_solo_bootloader() sys.exit(0) if fw == "" and not args.reset_only: print("Need to supply firmware filename, or see help for more options.") parser.print_help() sys.exit(1) try: p.bootloader_version() except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: print("Bootloader not active. Attempting to boot into bootloader mode...") attempt_to_boot_bootloader(p) else: raise (e) except ApduError: print("Bootloader not active. Attempting to boot into bootloader mode...") attempt_to_boot_bootloader(p) if args.reset_only: p.exchange(SoloBootloader.done, 0, b"A" * 64) else: p.program_file(fw) def main_mergehex(): # moved to new CLI pass def main_version(): print(solo.__version__) def main_main(): if sys.version_info[0] < 3: print("Sorry, python3 is required.") sys.exit(1) if len(sys.argv) < 2 or (len(sys.argv) == 2 and asked_for_help()): print("Diverse command line tool for working with Solo") print("usage: solotool [options] [-h]") print("commands: program, solo, monitor, sign, genkey, mergehex, version") print( """ Examples: {0} program {0} program --use-dfu {0} program --reboot {0} program --enter-bootloader {0} program --st-dfu {0} solo --wink {0} solo --rng {0} monitor {0} sign {0} genkey [rng-seed-file] {0} mergehex bootloader.hex solo.hex combined.hex {0} version """.format( "solotool" ) ) sys.exit(1) c = sys.argv[1] sys.argv = sys.argv[:1] + sys.argv[2:] sys.argv[0] = sys.argv[0] + " " + c if c == "program": programmer_main() elif c == "solo": solo_main() elif c == "monitor": monitor_main() elif c == "sign": sign_main() elif c == "genkey": genkey_main() elif c == "mergehex": main_mergehex() elif c == "version": main_version() else: print("invalid command: %s" % c) if __name__ == "__main__": main_main()