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