├── .github └── workflows │ ├── build-release-binaries.yml │ └── ci.yml ├── .gitignore ├── 70-solo2.rules ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Info.plist ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile ├── README.md ├── bacon.toml ├── build.rs ├── data └── r1.der ├── pkg ├── arch-bin │ ├── .SRCINFO │ ├── .gitignore │ ├── Makefile │ └── PKGBUILD ├── arch-git │ ├── .SRCINFO │ ├── .gitignore │ ├── Makefile │ └── PKGBUILD └── arch │ ├── .SRCINFO │ ├── .gitignore │ ├── .gitrepo │ ├── Makefile │ └── PKGBUILD └── src ├── apps.rs ├── apps ├── admin.rs ├── fido.rs ├── ndef.rs ├── oath.rs ├── piv.rs ├── provision.rs └── qa.rs ├── bin └── solo2 │ ├── cli.rs │ └── main.rs ├── device.rs ├── device ├── ctap.rs └── pcsc.rs ├── error.rs ├── firmware.rs ├── firmware └── github.rs ├── lib.rs ├── pki.rs ├── pki └── dev.rs ├── str4d_dev_pki.rs ├── transport.rs ├── transport ├── ctap.rs └── pcsc.rs └── uuid.rs /.github/workflows/build-release-binaries.yml: -------------------------------------------------------------------------------- 1 | name: Build release binaries (and publish them if this is a tag) 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | binaries: 7 | name: ${{ matrix.os }} for ${{ matrix.target }} 8 | runs-on: ${{ matrix.os }} 9 | timeout-minutes: 30 10 | strategy: 11 | matrix: 12 | target: 13 | # - x86_64-unknown-linux-musl 14 | - x86_64-unknown-linux-gnu 15 | # - aarch64-unknown-linux-musl 16 | - x86_64-pc-windows-msvc 17 | - x86_64-apple-darwin 18 | # - x86_64-unknown-freebsd 19 | include: 20 | # - os: ubuntu-latest 21 | # target: x86_64-unknown-linux-musl 22 | # artifact_name: target/x86_64-unknown-linux-musl/release/solo2 23 | # release_name: x86_64-unknown-linux-musl 24 | # cross: true 25 | # cargo_flags: "" 26 | - os: ubuntu-latest 27 | target: x86_64-unknown-linux-gnu 28 | artifact_name: target/x86_64-unknown-linux-gnu/release/solo2 29 | release_name: x86_64-unknown-linux-gnu 30 | cross: false 31 | cargo_flags: "" 32 | # - os: ubuntu-latest 33 | # target: aarch64-unknown-linux-musl 34 | # artifact_name: target/aarch64-unknown-linux-musl/release/solo2 35 | # release_name: aarch64-unknown-linux-musl 36 | # cross: true 37 | # cargo_flags: "" 38 | - os: windows-latest 39 | target: x86_64-pc-windows-msvc 40 | artifact_name: target/x86_64-pc-windows-msvc/release/solo2.exe 41 | release_name: x86_64-pc-windows-msvc.exe 42 | cross: false 43 | cargo_flags: "" 44 | - os: macos-latest 45 | target: x86_64-apple-darwin 46 | artifact_name: target/x86_64-apple-darwin/release/solo2 47 | release_name: x86_64-apple-darwin 48 | cross: false 49 | cargo_flags: "" 50 | # - os: ubuntu-latest 51 | # target: x86_64-unknown-freebsd 52 | # artifact_name: target/x86_64-unknown-freebsd/release/solo2 53 | # release_name: x86_64-unknown-freebsd 54 | # cross: true 55 | # cargo_flags: "" 56 | 57 | steps: 58 | - name: Ubuntu dependencies 59 | if: matrix.os == 'ubuntu-latest' 60 | run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev libudev-dev 61 | 62 | - name: Checkout code 63 | uses: actions/checkout@v2 64 | 65 | - name: Setup Rust toolchain 66 | uses: actions-rs/toolchain@v1 67 | with: 68 | toolchain: stable 69 | target: ${{ matrix.target }} 70 | 71 | - name: cargo build 72 | uses: actions-rs/cargo@v1 73 | with: 74 | command: build 75 | args: --release --locked --features dev-pki --target=${{ matrix.target }} ${{ matrix.cargo_flags }} 76 | use-cross: ${{ matrix.cross }} 77 | 78 | - name: Upload binary 79 | uses: actions/upload-artifact@v2 80 | with: 81 | name: solo2-${{ matrix.release_name }} 82 | path: ${{ matrix.artifact_name }} 83 | 84 | - name: udev rules 85 | if: matrix.os == 'ubuntu-latest' 86 | uses: actions/upload-artifact@v2 87 | with: 88 | name: 70-solo2.rules 89 | path: 70-solo2.rules 90 | 91 | - name: Bash completions 92 | if: matrix.os == 'ubuntu-latest' 93 | uses: actions/upload-artifact@v2 94 | with: 95 | name: solo2.completions.bash 96 | path: target/x86_64-unknown-linux-gnu/release/solo2.bash 97 | 98 | - name: Fish completions 99 | if: matrix.os == 'ubuntu-latest' 100 | uses: actions/upload-artifact@v2 101 | with: 102 | name: solo2.completions.fish 103 | path: target/x86_64-unknown-linux-gnu/release/solo2.fish 104 | 105 | - name: PowerShell completions 106 | if: matrix.os == 'ubuntu-latest' 107 | uses: actions/upload-artifact@v2 108 | with: 109 | name: solo2.completions.powershell 110 | path: target/x86_64-unknown-linux-gnu/release/_solo2.ps1 111 | 112 | - name: Zsh completions 113 | if: matrix.os == 'ubuntu-latest' 114 | uses: actions/upload-artifact@v2 115 | with: 116 | name: solo2.completions.zsh 117 | path: target/x86_64-unknown-linux-gnu/release/_solo2 118 | 119 | ### 120 | # Below this line, steps will only be ran if a tag was pushed. 121 | ### 122 | 123 | - name: Get tag name 124 | id: tag_name 125 | run: | 126 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} 127 | shell: bash 128 | if: startsWith(github.ref, 'refs/tags/v') 129 | 130 | - name: Get CHANGELOG.md entry 131 | id: changelog_reader 132 | uses: mindsers/changelog-reader-action@v1 133 | with: 134 | version: ${{ steps.tag_name.outputs.current_version }} 135 | path: ./CHANGELOG.md 136 | if: startsWith(github.ref, 'refs/tags/v') 137 | 138 | - name: Publish binary 139 | uses: svenstaro/upload-release-action@v2 140 | with: 141 | repo_token: ${{ secrets.GITHUB_TOKEN }} 142 | file: ${{ matrix.artifact_name }} 143 | tag: ${{ github.ref }} 144 | asset_name: solo2-$tag-${{ matrix.release_name }} 145 | body: ${{ steps.changelog_reader.outputs.log_entry }} 146 | if: startsWith(github.ref, 'refs/tags/v') 147 | 148 | - name: Publish udev rules 149 | uses: svenstaro/upload-release-action@v2 150 | with: 151 | repo_token: ${{ secrets.GITHUB_TOKEN }} 152 | file: 70-solo2.rules 153 | tag: ${{ github.ref }} 154 | asset_name: 70-solo2.rules 155 | body: ${{ steps.changelog_reader.outputs.log_entry }} 156 | if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' 157 | 158 | - name: Publish Bash completions 159 | uses: svenstaro/upload-release-action@v2 160 | with: 161 | repo_token: ${{ secrets.GITHUB_TOKEN }} 162 | file: target/x86_64-unknown-linux-gnu/release/solo2.bash 163 | tag: ${{ github.ref }} 164 | asset_name: solo2.completions.bash 165 | body: ${{ steps.changelog_reader.outputs.log_entry }} 166 | if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' 167 | 168 | - name: Publish Fish completions 169 | uses: svenstaro/upload-release-action@v2 170 | with: 171 | repo_token: ${{ secrets.GITHUB_TOKEN }} 172 | file: target/x86_64-unknown-linux-gnu/release/solo2.fish 173 | tag: ${{ github.ref }} 174 | asset_name: solo2.completions.fish 175 | body: ${{ steps.changelog_reader.outputs.log_entry }} 176 | if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' 177 | 178 | - name: Publish PowerShell completions 179 | uses: svenstaro/upload-release-action@v2 180 | with: 181 | repo_token: ${{ secrets.GITHUB_TOKEN }} 182 | file: target/x86_64-unknown-linux-gnu/release/_solo2.ps1 183 | tag: ${{ github.ref }} 184 | asset_name: solo2.completions.powershell 185 | body: ${{ steps.changelog_reader.outputs.log_entry }} 186 | if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' 187 | 188 | - name: Publish Zsh completions 189 | uses: svenstaro/upload-release-action@v2 190 | with: 191 | repo_token: ${{ secrets.GITHUB_TOKEN }} 192 | file: target/x86_64-unknown-linux-gnu/release/_solo2 193 | tag: ${{ github.ref }} 194 | asset_name: solo2.completions.zsh 195 | body: ${{ steps.changelog_reader.outputs.log_entry }} 196 | if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' 197 | 198 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | 10 | jobs: 11 | ci: 12 | name: ${{ matrix.rust }} on ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | timeout-minutes: 30 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | rust: [stable] 19 | 20 | steps: 21 | - name: Ubuntu dependencies 22 | if: matrix.os == 'ubuntu-latest' 23 | run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev libudev-dev 24 | 25 | - name: Checkout code 26 | uses: actions/checkout@v2 27 | 28 | - name: Setup Rust toolchain 29 | uses: actions-rs/toolchain@v1 30 | with: 31 | profile: minimal 32 | toolchain: ${{ matrix.rust }} 33 | override: true 34 | components: rustfmt, clippy 35 | 36 | - name: cargo build --locked --features dev-pki 37 | uses: actions-rs/cargo@v1 38 | with: 39 | command: build 40 | 41 | - name: cargo test 42 | uses: actions-rs/cargo@v1 43 | with: 44 | command: test 45 | args: --features dev-pki,network-tests 46 | 47 | - name: cargo fmt 48 | if: matrix.os == 'ubuntu-latest' 49 | uses: actions-rs/cargo@v1 50 | with: 51 | command: fmt 52 | args: --all -- --check 53 | 54 | - name: cargo clippy 55 | if: matrix.os == 'ubuntu-latest' 56 | uses: actions-rs/cargo@v1 57 | with: 58 | command: clippy 59 | args: -- -D warnings 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /70-solo2.rules: -------------------------------------------------------------------------------- 1 | # NXP LPC55 ROM bootloader (unmodified) 2 | SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1fc9", ATTRS{idProduct}=="0021", TAG+="uaccess" 3 | # NXP LPC55 ROM bootloader (with Solo 2 VID:PID) 4 | SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="b000", TAG+="uaccess" 5 | # Solo 2 6 | SUBSYSTEM=="tty", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="beee", TAG+="uaccess" 7 | # Solo 2 8 | SUBSYSTEM=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="beee", TAG+="uaccess" 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.2] - 2023-01-17 9 | 10 | - bump lpc55 dependency, enabling flash progress callback 11 | - bump external dependencies 12 | 13 | ## [0.2.1] - 2022-09-12 14 | 15 | - quick hint in `solo2 update` when LPC 55 udev rule might be missing 16 | 17 | ## [0.2.0] - 2022-05-24 18 | 19 | - `--dry-run` flag for updating 20 | - winking 21 | - readout factory settings lock status (if admin app supports it) 22 | - `--all` flag for apps (likely not consistently working yet) 23 | - "boot-to-bootrom" renamed to "maintenance" 24 | - pull device Trussed certificates from web 25 | - don't attempt firmware rollbacks 26 | - timeout if owner is not present for firmware update 27 | - `--verbose` flag to configure log level instead of env variable 28 | - fix for multiple smartcard readers (@borgoat) 29 | 30 | ## [0.1.1] - 2022-01-07 31 | 32 | - Implement CTAP basics (unlocks firmware update on macOS + conservative Linux) 33 | 34 | ## [0.1.0] - 2021-11-21 35 | 36 | - Give the owner a chance to tap device during update 37 | - Bump to version 0.1 so we can distinguish patch and breaking release in the future 38 | 39 | ## [0.0.7] - 2021-11-21 40 | 41 | - Fix the Windows 10 bug (via `lpc55-host` bump) 42 | - Fix the incorrect udev rules file 43 | - Fix and improve the AUR Arch Linux package (@Foxboron) 44 | - Completely redesign the update process (modeling Device, Firmware, etc.) 45 | - Re-activate OATH (via released `flexiber`) 46 | - Expose parts of Solo 2 PKI 47 | 48 | ## [0.0.6] - 2021-11-06 49 | 50 | ### Changed 51 | 52 | - No more async - we're not a high throughput webserver 53 | - Nicer user dialogs (dialoguer/indicatif) 54 | - Model devices modes (bootloader/card) 55 | - Add udev rules 56 | 57 | ## [0.0.5] - 2021-11-06 58 | 59 | ### Added 60 | 61 | - Display firmware version in human-readable format 62 | - Start using a Changelog 63 | - Add CI with cargo clippy/fmt 64 | - Add binary releases following [svenstaro/miniserve](https://github.com/svenstaro/miniserve) 65 | 66 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "solo2" 3 | version = "0.2.2" 4 | authors = ["SoloKeys, built with Trussed®."] 5 | edition = "2021" 6 | rust-version = "1.60" 7 | repository = "https://github.com/solokeys/solo2-cli" 8 | description = "Library and CLI for the SoloKeys Solo 2 security key" 9 | license = "Apache-2.0 OR MIT" 10 | readme = "README.md" 11 | documentation = "https://docs.rs/solo2" 12 | 13 | [[bin]] 14 | name = "solo2" 15 | required-features = ["cli"] 16 | 17 | [dependencies] 18 | log = "0.4.14" 19 | anyhow = "1.0.40" 20 | # ctap-hid-fido2 = "2.1.1" 21 | data-encoding = "2.3.2" 22 | flexiber = { version = "0.1.0", features = ["std"] } 23 | getrandom = "0.2" 24 | hex = "0.4.3" 25 | hex-literal = "0.3.1" 26 | hidapi = { version = "2", default-features = false, features = ["linux-static-hidraw"] } 27 | iso7816 = "0.1.0" 28 | lpc55 = "0.2" 29 | # TODO: replace use of lazy_static with once_cell in CLI, 30 | # even though we no longer use this in our CTAP impl. 31 | # once_cell = "1.8" 32 | pcsc = "2.4" 33 | # reqwest = { version = "0.11", features = ["json"] } 34 | serde_json = "1.0.64" 35 | sha-1 = "0.10" 36 | sha2 = "0.10" 37 | time = "0.3" 38 | x509-parser = { version = "0.14.0", features = ["verify"] } 39 | 40 | # download 41 | dialoguer = "0.10" 42 | indicatif = "0.17" 43 | ureq = { version = "2.1.1", features = ["json"] } 44 | 45 | # cli 46 | atty = { version = "0.2.14", optional = true } 47 | clap = { version = "4", features = ["cargo", "derive"], optional = true } 48 | clap_complete = { version = "4", optional = true } 49 | clap-verbosity-flag = { version = "2", optional = true } 50 | ctrlc = { version = "3.2.0", optional = true } 51 | lazy_static = { version = "1.4.0", optional = true } 52 | pretty_env_logger = { version = "0.4.0", optional = true } 53 | 54 | # dev-pki 55 | p256 = { version = "0.12", optional = true, features = ["pkcs8"] } 56 | pkcs8 = { version = "0.9", optional = true, features = ["alloc"] } 57 | rand_core = { version = "0.6.2", optional = true } 58 | rcgen = { version = "0.10", optional = true } 59 | yasna = { version = "0.5.0", optional = true } 60 | webbrowser = "0.8" 61 | 62 | # needed in build.rs 63 | [build-dependencies] 64 | clap = { version = "4", features = ["cargo", "derive"] } 65 | clap_complete = "4" 66 | clap-verbosity-flag = "2" 67 | lazy_static = "1.4.0" 68 | 69 | [features] 70 | default = ["cli"] 71 | cli = ["atty", "clap", "clap_complete", "clap-verbosity-flag", "ctrlc", "lazy_static", "pretty_env_logger"] 72 | dev-pki = ["p256", "pkcs8", "rand_core", "rcgen", "yasna"] 73 | # It's not allowed to use the network when building for docs.rs, and the same 74 | # for most corporate networks. The tests behind this flag do things like downloading 75 | # certificates from Solo 2 PKI public data. 76 | network-tests = [] 77 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 SoloKeys 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cargo build --release --features cli --bin solo2 3 | ls -sh target/release/solo2 4 | 5 | 6 | # for AUR things, kudos to 7 | 8 | push-aur: 9 | git subtree push -P pkg/arch ssh://aur@aur.archlinux.org/solo2-cli.git master 10 | 11 | .PHONY: local-aur 12 | .ONESHELL: 13 | local-aur: 14 | cd pkg/arch 15 | mkdir -p ./src 16 | ln -srfT $(CURDIR) ./src/solo2-cli-0.0.7 17 | makepkg --holdver --syncdeps --noextract --force --install 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository is incomplete and under active development. 2 | 3 | # 🐝 solo2 library and CLI 4 | 5 | The Solo 2 device can operate in one of two modes (USB VID:PID in brackets): 6 | - regular (Solo 2) mode ([1209:BEEE][beee-pid]) 7 | - maintenance (LPC 55) mode ([1209:B000][b000-pid]) 8 | 9 | In regular mode, the PCSC or CTAP interface is used opportunistically. 10 | In maintenance mode, NXP's custom HID protocol is used (via [`lpc55-host`][lpc55-host]). 11 | 12 | On Linux, maintenance mode needs [udev rules][udev-rules]. 13 | 14 | You can switch to LPC 55 mode using `solo2 app admin maintenance` 15 | You can switch to Solo 2 mode using `solo2 bootloader reboot` (or by replugging the device). 16 | 17 | Solo 2 is supported by Ludovic Rousseau's [CCID][solokeys-ccid] driver since release 1.4.35 (July 25, 2021). 18 | Unfortunately, Debian and macOS have not updated yet. 19 | 20 | The included [Info.plist](Info.plist) works. 21 | 22 | [beee-pid]: https://pid.codes/1209/BEEE/ 23 | [b000-pid]: https://pid.codes/1209/B000/ 24 | [lpc55-host]: https://docs.rs/lpc55 25 | [solokeys-ccid]: https://ccid.apdu.fr/ccid/shouldwork.html#0x12090xBEEE 26 | [udev-rules]: https://github.com/solokeys/solo2-cli/blob/main/70-solo2.rules 27 | 28 | ### ⚠ DANGER ⚠ 29 | 30 | If the firmware is invalid according to the bootloader, the device always stays in bootloader mode. This is OK. 31 | 32 | **BUT**: If the firmware is valid according to the bootloader, and the device boots into it, but the firmware has issues 33 | (e.g., panics), the only way to get back into bootloader mode and flash a new firmware is by attaching a debugger. 34 | 35 | This is quite fiddly, and needs a [special cable][tag-connect]. 36 | We recommend using NXP's [development board][dev-board] instead. 37 | 38 | [tag-connect]: https://www.tag-connect.com/product/tc2030-ctx-nl-6-pin-no-legs-cable-with-10-pin-micro-connector-for-cortex-processors 39 | [dev-board]: https://www.nxp.com/design/development-boards/lpcxpresso-boards/lpcxpresso55s69-development-board:LPC55S69-EVK 40 | 41 | ### Installation 42 | [![Packaging status](https://repology.org/badge/vertical-allrepos/solo2-cli.svg)](https://repology.org/project/solo2-cli/versions) 43 | 44 | 45 | ``` 46 | cargo install solo2 47 | ``` 48 | 49 | For experimental "PKI lite" support, use `cargo install --features dev-pki solo2`. 50 | This is not intended to and will not grow into full PKI creation + management functionality, 51 | the goal is only to enable developing and testing all functionality of all official apps. 52 | 53 | ### Examples 54 | 55 | If the key is in regular mode, and its firmware contains the admin app: 56 | - `solo2 app admin uuid` reads out the serial number. 57 | - `solo2 app admin maintenance` switches to bootloader mode. 58 | 59 | If the key is in regular mode, and its firmware contains the NDEF app: 60 | - `solo2 app ndef capabilities` reads out the NDEF capabilities. 61 | 62 | If the key is in maintenance mode: 63 | - `solo2 bootloader reboot` switches to regular mode (if the firmware is valid). 64 | 65 | Note that subcommands are inferred, so e.g. `solo2 b r` works like `solo2 bootloader reboot`. 66 | 67 | 68 | ### Logging 69 | 70 | Uses [`pretty_env_logger`][pretty-env-logger], configured via `--verbose` flags. 71 | For instance, `-v` logs INFO and `-vv` logs `DEBUG` level logs. 72 | 73 | [pretty-env-logger]: https://docs.rs/pretty_env_logger/ 74 | 75 | 76 | ### License 77 | 78 | SoloKeys is fully open source. 79 | 80 | All software, unless otherwise noted, is dual licensed under [Apache 2.0](LICENSE-APACHE) and [MIT](LICENSE-MIT). 81 | You may use SoloKeys software under the terms of either the Apache 2.0 license or MIT license. 82 | 83 | Unless you explicitly state otherwise, 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. 84 | 85 | All hardware, unless otherwise noted, is licensed under [CERN-OHL-S-2.0](https://spdx.org/licenses/CERN-OHL-S-2.0.html). 86 | 87 | All documentation, unless otherwise noted, is licensed under [CC-BY-SA-4.0](https://spdx.org/licenses/CC-BY-SA-4.0.html). 88 | 89 | The file [Info.plist](Info.plist) is from [CCID][ccid-git], which is licensed under [LGPL-2.1][ccid-license]. 90 | 91 | [ccid-git]: https://salsa.debian.org/rousseau/CCID 92 | [ccid-license]: https://salsa.debian.org/rousseau/CCID/-/blob/master/COPYING 93 | -------------------------------------------------------------------------------- /bacon.toml: -------------------------------------------------------------------------------- 1 | # This is a configuration file for the bacon tool 2 | # More info at 3 | 4 | default_job = "cli" 5 | 6 | [jobs] 7 | 8 | [jobs.build] 9 | command = ["cargo", "build", "--color", "always"] 10 | need_stdout = false 11 | 12 | [jobs.cli] 13 | command = ["cargo", "check", "--features", "cli", "--bin", "solo2", "--color", "always"] 14 | need_stdout = false 15 | 16 | [jobs.dev-pki] 17 | command = ["cargo", "check", "--features", "dev-pki", "--bin", "solo2", "--color", "always"] 18 | need_stdout = false 19 | 20 | [jobs.ctap-list] 21 | command = ["cargo", "build", "--example", "list-ctap", "--color", "always"] 22 | need_stdout = false 23 | 24 | [jobs.check-lib] 25 | command = ["cargo", "check", "--color", "always"] 26 | need_stdout = false 27 | 28 | [jobs.clippy] 29 | command = ["cargo", "clippy", "--color", "always"] 30 | need_stdout = false 31 | 32 | [jobs.doc] 33 | command = ["cargo", "doc", "--color", "always"] 34 | need_stdout = false 35 | 36 | [jobs.test] 37 | command = ["cargo", "test", "--color", "always"] 38 | need_stdout = true 39 | 40 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs; 3 | use std::path; 4 | use std::process; 5 | 6 | #[cfg(feature = "cli")] 7 | #[path = "src/bin/solo2/cli.rs"] 8 | mod cli; 9 | 10 | fn main() { 11 | // OUT_DIR is set by Cargo and it's where any additional build artifacts 12 | // are written. 13 | let env_outdir = match env::var_os("OUT_DIR") { 14 | Some(outdir) => outdir, 15 | None => { 16 | eprintln!( 17 | "OUT_DIR environment variable not defined. \ 18 | Please file a bug: \ 19 | https://github.com/BurntSushi/ripgrep/issues/new" 20 | ); 21 | process::exit(1); 22 | } 23 | }; 24 | 25 | // // empty file that is used in scripts/cargo-out-dir 26 | // let stamp_path = path::Path::new(&env_outdir).join("solo2-stamp"); 27 | // if let Err(err) = fs::File::create(&stamp_path) { 28 | // panic!("failed to write {}: {}", stamp_path.display(), err); 29 | // } 30 | 31 | // place side by side with binaries 32 | let outdir = path::PathBuf::from(path::PathBuf::from(env_outdir).ancestors().nth(3).unwrap()); 33 | fs::create_dir_all(&outdir).unwrap(); 34 | println!("{:?}", &outdir); 35 | 36 | #[cfg(feature = "cli")] 37 | { 38 | use clap_complete::{generate_to, shells}; 39 | 40 | // Use clap to build completion files. 41 | // Pro-tip: use `fd -HIe bash` to get OUT_DIR 42 | use clap::CommandFactory as _; 43 | let mut app = cli::Cli::command(); 44 | generate_to(shells::Bash, &mut app, "solo2", &outdir).unwrap(); 45 | generate_to(shells::Fish, &mut app, "solo2", &outdir).unwrap(); 46 | generate_to(shells::PowerShell, &mut app, "solo2", &outdir).unwrap(); 47 | generate_to(shells::Zsh, &mut app, "solo2", &outdir).unwrap(); 48 | } 49 | 50 | // // Make the current git hash available to the build. 51 | // if let Some(rev) = git_revision_hash() { 52 | // // this works, but it doesn't get picked up in app :/ 53 | // println!("cargo:rustc-env=LPC55_BUILD_GIT_HASH={}", rev); 54 | // } 55 | } 56 | 57 | // fn git_revision_hash() -> Option { 58 | // let result = process::Command::new("git") 59 | // .args(&["rev-parse", "--short=10", "HEAD"]) 60 | // .output(); 61 | // result.ok().and_then(|output| { 62 | // let v = String::from_utf8_lossy(&output.stdout).trim().to_string(); 63 | // if v.is_empty() { 64 | // None 65 | // } else { 66 | // Some(v) 67 | // } 68 | // }) 69 | // } 70 | -------------------------------------------------------------------------------- /data/r1.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solokeys/solo2-cli/5fd8e5a877bd22e87ad88e537f0cf468592d18f1/data/r1.der -------------------------------------------------------------------------------- /pkg/arch-bin/.SRCINFO: -------------------------------------------------------------------------------- 1 | pkgbase = solo2-cli-bin 2 | pkgdesc = Solo 2 CLI 3 | pkgver = 0.2.1 4 | pkgrel = 1 5 | url = https://github.com/solokeys/solo2-cli 6 | arch = x86_64 7 | license = Apache 8 | license = MIT 9 | makedepends = git 10 | makedepends = systemd 11 | depends = systemd-libs 12 | depends = ccid 13 | provides = solo2-cli 14 | conflicts = solo2-cli 15 | source = solo2::https://github.com/solokeys/solo2-cli/releases/download/v0.2.1/solo2-v0.2.1-x86_64-unknown-linux-gnu 16 | source = 70-solo2.rules::https://github.com/solokeys/solo2-cli/releases/download/v0.2.1/70-solo2.rules 17 | source = solo2.bash::https://github.com/solokeys/solo2-cli/releases/download/v0.2.1/solo2.completions.bash 18 | source = solo2.zsh::https://github.com/solokeys/solo2-cli/releases/download/v0.2.1/solo2.completions.zsh 19 | source = LICENSE-MIT::https://github.com/solokeys/solo2-cli/raw/v0.2.1/LICENSE-MIT 20 | sha256sums = 3bede0161a0d9da51d961b6c1c46af2da7defaf450dd42ce7ed57a8b8bb0b2a4 21 | sha256sums = 4133644b12a4e938f04e19e3059f9aec08f1c36b1b33b2f729b5815c88099fe3 22 | sha256sums = a892afc3c71eb09c1d8e57745dabbbe415f6cfd3f8b49ee6084518a07b73d9a8 23 | sha256sums = 70bd6aa5ebfb2ec67b12f546d34af9cfe2ffe92e0366c44c9ce0633d0582ebf3 24 | sha256sums = bdc889204ff84470aaad9f6fc66829cd1cdfb78b307fe3a8c0fe7be5353e1165 25 | 26 | pkgname = solo2-cli-bin 27 | -------------------------------------------------------------------------------- /pkg/arch-bin/.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | src 3 | *solo2* 4 | LICENSE* 5 | -------------------------------------------------------------------------------- /pkg/arch-bin/Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -rf pkg src *solo2* LICENSE* 3 | -------------------------------------------------------------------------------- /pkg/arch-bin/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Nicolas Stalder 2 | # Helpful suggestions by Foxboron 3 | pkgname=solo2-cli-bin 4 | pkgver=0.2.1 5 | pkgrel=1 6 | pkgdesc='Solo 2 CLI' 7 | arch=('x86_64') 8 | url="https://github.com/solokeys/solo2-cli" 9 | license=(Apache MIT) 10 | # we only need `libudev.so`, during build we also need `pkgconfig/udev/.pc` 11 | depends=(systemd-libs ccid) 12 | # note we do not need Arch `hidapi` package here, it's a git submodule of Rust hidapi 13 | makedepends=(git systemd) 14 | provides=(solo2-cli) 15 | conflicts=(solo2-cli) 16 | 17 | source=( 18 | "solo2::${url}/releases/download/v${pkgver}/solo2-v${pkgver}-x86_64-unknown-linux-gnu" 19 | "70-solo2.rules::${url}/releases/download/v${pkgver}/70-solo2.rules" 20 | "solo2.bash::${url}/releases/download/v${pkgver}/solo2.completions.bash" 21 | "solo2.zsh::${url}/releases/download/v${pkgver}/solo2.completions.zsh" 22 | "LICENSE-MIT::${url}/raw/v${pkgver}/LICENSE-MIT") 23 | sha256sums=('3bede0161a0d9da51d961b6c1c46af2da7defaf450dd42ce7ed57a8b8bb0b2a4' 24 | '4133644b12a4e938f04e19e3059f9aec08f1c36b1b33b2f729b5815c88099fe3' 25 | 'a892afc3c71eb09c1d8e57745dabbbe415f6cfd3f8b49ee6084518a07b73d9a8' 26 | '70bd6aa5ebfb2ec67b12f546d34af9cfe2ffe92e0366c44c9ce0633d0582ebf3' 27 | 'bdc889204ff84470aaad9f6fc66829cd1cdfb78b307fe3a8c0fe7be5353e1165') 28 | 29 | package() { 30 | install -Dm755 solo2 "$pkgdir/usr/bin/solo2" 31 | install -Dm644 LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgnamefull/LICENSE-MIT" 32 | 33 | # completions 34 | install -Dm644 solo2.zsh "$pkgdir/usr/share/zsh/site-functions/_solo2" 35 | install -Dm644 solo2.bash "$pkgdir/usr/share/bash-completion/completions/solo2" 36 | 37 | # udev rule 38 | install -Dm644 70-solo2.rules -t "$pkgdir/usr/lib/udev/rules.d" 39 | } 40 | -------------------------------------------------------------------------------- /pkg/arch-git/.SRCINFO: -------------------------------------------------------------------------------- 1 | pkgbase = solo2-cli-git 2 | pkgdesc = Command line interface to SoloKeys Solo 2 devices 3 | pkgver = r66.dd05f51 4 | pkgrel = 1 5 | url = https://github.com/solokeys/solo2-cli 6 | arch = any 7 | license = MIT 8 | makedepends = cargo 9 | makedepends = git 10 | makedepends = systemd 11 | depends = systemd-libs 12 | depends = ccid 13 | provides = solo2-cli 14 | conflicts = solo2-cli 15 | source = git+https://github.com/solokeys/solo2-cli.git#branch=main 16 | sha256sums = SKIP 17 | 18 | pkgname = solo2-cli-git 19 | -------------------------------------------------------------------------------- /pkg/arch-git/.gitignore: -------------------------------------------------------------------------------- 1 | pkg/ 2 | src/ 3 | *.tar.* 4 | solo2-cli 5 | -------------------------------------------------------------------------------- /pkg/arch-git/Makefile: -------------------------------------------------------------------------------- 1 | PKG := solo2-cli-git 2 | 3 | build: 4 | makepkg -f 5 | 6 | install: 7 | yes | makepkg -i 8 | 9 | clean: 10 | rm -rf pkg src 11 | rm -rf $(PKG)* 12 | 13 | update-srcinfo: 14 | makepkg --printsrcinfo > .SRCINFO 15 | 16 | generate-checksums: 17 | makepkg -g -f -p PKGBUILD 18 | -------------------------------------------------------------------------------- /pkg/arch-git/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Nicolas Stalder 2 | pkgname=solo2-cli-git 3 | pkgver=r66.dd05f51 4 | pkgrel=1 5 | pkgdesc="Command line interface to SoloKeys Solo 2 devices" 6 | url="https://github.com/solokeys/solo2-cli" 7 | arch=(any) 8 | license=(MIT) 9 | depends=(systemd-libs ccid) 10 | makedepends=(cargo git systemd) 11 | provides=(solo2-cli) 12 | conflicts=(solo2-cli) 13 | 14 | source=('git+https://github.com/solokeys/solo2-cli.git#branch=main') 15 | # add dummy entries for `make generate-checksums` to create SHA256 instead of MD5 check sums 16 | sha256sums=('SKIP') 17 | 18 | pkgver() { 19 | cd "$srcdir/solo2-cli" 20 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" 21 | } 22 | 23 | prepare() { 24 | cd $srcdir/solo2-cli 25 | cargo fetch --locked --target "$CARCH-unknown-linux-gnu" 26 | } 27 | 28 | build() { 29 | cd $srcdir/solo2-cli 30 | export RUSTUP_TOOLCHAIN=stable 31 | export CARGO_TARGET_DIR=target 32 | cargo build --release --frozen --all-features 33 | } 34 | 35 | check() { 36 | cd $srcdir/solo2-cli 37 | export RUSTUP_TOOLCHAIN=stable 38 | # make sure shared libs work 39 | target/release/solo2 --version 40 | cargo test --release --all-features 41 | } 42 | 43 | package() { 44 | install -Dm755 "$srcdir/solo2-cli/target/release/solo2" "$pkgdir/usr/bin/solo2" 45 | install -Dm644 "$srcdir/solo2-cli/LICENSE-MIT" "$pkgdir/usr/share/licenses/$pkgname/LICENSE-MIT" 46 | 47 | # completions 48 | install -Dm644 $srcdir/solo2-cli/target/release/_solo2 -t "$pkgdir/usr/share/zsh/site-functions" 49 | install -Dm644 $srcdir/solo2-cli/target/release/solo2.bash "$pkgdir/usr/share/bash-completion/completions/solo2" 50 | 51 | # udev rule 52 | install -Dm644 $srcdir/solo2-cli/70-solo2.rules -t "$pkgdir/usr/lib/udev/rules.d" 53 | } 54 | -------------------------------------------------------------------------------- /pkg/arch/.SRCINFO: -------------------------------------------------------------------------------- 1 | pkgbase = solo2-cli 2 | pkgdesc = Solo 2 CLI 3 | pkgver = 0.2.1 4 | pkgrel = 1 5 | url = https://github.com/solokeys/solo2-cli 6 | arch = x86_64 7 | license = Apache 8 | license = MIT 9 | makedepends = cargo 10 | makedepends = git 11 | makedepends = systemd 12 | depends = systemd-libs 13 | depends = ccid 14 | source = solo2-cli-0.2.1.tar.gz::https://github.com/solokeys/solo2-cli/archive/refs/tags/v0.2.1.tar.gz 15 | sha256sums = f797a53046f7fb66ffd9f76063c4b9abdd88299b89d18c28a451a4f62573a334 16 | 17 | pkgname = solo2-cli 18 | -------------------------------------------------------------------------------- /pkg/arch/.gitignore: -------------------------------------------------------------------------------- 1 | solo2* 2 | pkg 3 | src 4 | -------------------------------------------------------------------------------- /pkg/arch/.gitrepo: -------------------------------------------------------------------------------- 1 | ; DO NOT EDIT (unless you know what you are doing) 2 | ; 3 | ; This subdirectory is a git "subrepo", and this file is maintained by the 4 | ; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme 5 | ; 6 | [subrepo] 7 | remote = ssh://aur@aur.archlinux.org/solo2-cli.git 8 | branch = master 9 | commit = 44eca59f75469c279adab1bb4ebfd7a31e8868e3 10 | method = merge 11 | cmdver = 0.4.3 12 | parent = 9c07ce8bfc00e06c033c3d257caf3ab2801140e3 13 | -------------------------------------------------------------------------------- /pkg/arch/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | makepkg -f 3 | 4 | install: 5 | makepkg -i 6 | 7 | clean: 8 | rm -rf solo2* 9 | rm -rf pkg src 10 | -------------------------------------------------------------------------------- /pkg/arch/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Nicolas Stalder 2 | # Helpful suggestions by Foxboron 3 | pkgname=solo2-cli 4 | pkgver=0.2.1 5 | pkgrel=1 6 | pkgdesc='Solo 2 CLI' 7 | arch=('x86_64') 8 | url="https://github.com/solokeys/solo2-cli" 9 | license=(Apache MIT) 10 | # we only need `libudev.so`, during build we also need `pkgconfig/udev/.pc` 11 | depends=(systemd-libs ccid) 12 | # note we do not need Arch `hidapi` package here, it's a git submodule of Rust hidapi 13 | makedepends=(cargo git systemd) 14 | source=( 15 | "$pkgname-$pkgver.tar.gz::https://github.com/solokeys/solo2-cli/archive/refs/tags/v${pkgver}.tar.gz" 16 | ) 17 | sha256sums=( 18 | "ae9ef9dd174a8b8294941635a3a66dc9062fd4b595d5f1f6507b5a5a232d6932" 19 | ) 20 | 21 | prepare() { 22 | cd "${pkgname}-${pkgver}" 23 | cargo fetch --locked --target "$CARCH-unknown-linux-gnu" 24 | } 25 | 26 | build() { 27 | cd "${pkgname}-${pkgver}" 28 | export RUSTUP_TOOLCHAIN=stable 29 | export CARGO_TARGET_DIR=target 30 | # dev-pki feature not activated due to bug in v0.1.1 31 | # cargo build --release --frozen --all-features 32 | cargo build --features dev-pki --release --frozen 33 | } 34 | 35 | check() { 36 | cd "${pkgname}-${pkgver}" 37 | export RUSTUP_TOOLCHAIN=stable 38 | # make sure shared libs work 39 | target/release/solo2 --version 40 | # dev-pki feature not activated due to bug in v0.1.1 41 | # cargo test --release --frozen --all-features 42 | cargo test --features dev-pki --release --frozen 43 | } 44 | 45 | package() { 46 | cd "${pkgname}-${pkgver}" 47 | install -Dm755 target/release/solo2 "$pkgdir/usr/bin/solo2" 48 | install -Dm644 LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgnamefull/LICENSE-MIT" 49 | 50 | # completions 51 | install -Dm644 target/release/_solo2 -t "$pkgdir/usr/share/zsh/site-functions" 52 | install -Dm644 target/release/solo2.bash "$pkgdir/usr/share/bash-completion/completions/solo2" 53 | 54 | # udev rule 55 | install -Dm644 70-solo2.rules -t "$pkgdir/usr/lib/udev/rules.d" 56 | } 57 | -------------------------------------------------------------------------------- /src/apps.rs: -------------------------------------------------------------------------------- 1 | //! Middleware to use the Trussed apps on a Solo 2 device. 2 | 3 | use hex_literal::hex; 4 | 5 | use crate::{Result, Transport}; 6 | 7 | /// Temporarily wrap an exclusive pointer to a transport, after selecting the app. 8 | /// 9 | /// If instead apps were traits on transports - where would we store the app ID? 10 | #[macro_export] 11 | macro_rules! app( 12 | () => { 13 | 14 | pub struct App<'t> { 15 | #[allow(dead_code)] 16 | transport: &'t mut dyn $crate::Transport, 17 | } 18 | 19 | impl<'t> From<&'t mut dyn $crate::Transport> for App<'t> { 20 | fn from(transport: &'t mut dyn $crate::Transport) -> App<'t> { 21 | Self { transport } 22 | } 23 | } 24 | } 25 | ); 26 | 27 | #[macro_export] 28 | macro_rules! ctap_app( 29 | () => { 30 | 31 | pub struct App<'t> { 32 | #[allow(dead_code)] 33 | transport: &'t mut $crate::device::ctap::Device, 34 | } 35 | 36 | impl<'t> From<&'t mut $crate::device::ctap::Device> for App<'t> { 37 | fn from(transport: &'t mut $crate::device::ctap::Device) -> App<'t> { 38 | Self { transport } 39 | } 40 | } 41 | 42 | impl<'t> core::ops::Deref for App<'t> { 43 | type Target = $crate::device::ctap::Device; 44 | fn deref(&self) -> &Self::Target { 45 | self.transport 46 | } 47 | } 48 | 49 | impl<'t> core::ops::DerefMut for App<'t> { 50 | fn deref_mut(&mut self) -> &mut Self::Target { 51 | self.transport 52 | } 53 | } 54 | } 55 | ); 56 | 57 | #[macro_export] 58 | macro_rules! pcsc_app( 59 | () => { 60 | 61 | pub struct App<'t> { 62 | #[allow(dead_code)] 63 | transport: &'t mut $crate::device::pcsc::Device, 64 | } 65 | 66 | impl<'t> From<&'t mut $crate::device::pcsc::Device> for App<'t> { 67 | fn from(transport: &'t mut $crate::device::pcsc::Device) -> App<'t> { 68 | Self { transport } 69 | } 70 | } 71 | 72 | impl<'t> core::ops::Deref for App<'t> { 73 | type Target = $crate::device::pcsc::Device; 74 | fn deref(&self) -> &Self::Target { 75 | self.transport 76 | } 77 | } 78 | 79 | impl<'t> core::ops::DerefMut for App<'t> { 80 | fn deref_mut(&mut self) -> &mut Self::Target { 81 | self.transport 82 | } 83 | } 84 | } 85 | ); 86 | 87 | pub mod admin; 88 | pub use admin::App as Admin; 89 | pub mod fido; 90 | pub use fido::App as Fido; 91 | pub mod ndef; 92 | pub use ndef::App as Ndef; 93 | pub mod oath; 94 | pub use oath::App as Oath; 95 | pub mod piv; 96 | pub use piv::App as Piv; 97 | pub mod provision; 98 | pub mod qa; 99 | 100 | /// well-known Registered Application Provider Identifiers. 101 | pub struct Rid; 102 | impl Rid { 103 | pub const NFC_FORUM: &'static [u8] = &hex!("D276000085"); 104 | pub const NIST: &'static [u8] = &hex!("A000000308"); 105 | pub const SOLOKEYS: &'static [u8] = &hex!("A000000847"); 106 | pub const YUBICO: &'static [u8] = &hex!("A000000527"); 107 | } 108 | 109 | /// well-known Proprietary Application Identifier Extensions. 110 | pub struct Pix; 111 | impl Pix { 112 | pub const ADMIN: &'static [u8] = &hex!("00000001"); 113 | pub const NDEF: &'static [u8] = &hex!("0101"); 114 | pub const OATH: &'static [u8] = &hex!("2101"); 115 | // the full PIX ends with 0100 for version 01.00, 116 | // truncated is enough to select 117 | // pub const PIV_VERSIONED: &'static [u8] = &hex!("000010000100"); 118 | pub const PIV: &'static [u8] = &hex!("00001000"); 119 | pub const PROVISION: &'static [u8] = &hex!("01000001"); 120 | pub const QA: &'static [u8] = &hex!("01000000"); 121 | } 122 | 123 | pub trait PcscSelect<'t>: From<&'t mut crate::device::pcsc::Device> { 124 | const RID: &'static [u8]; 125 | const PIX: &'static [u8]; 126 | 127 | fn application_id() -> Vec { 128 | let mut aid: Vec = Default::default(); 129 | aid.extend_from_slice(Self::RID); 130 | aid.extend_from_slice(Self::PIX); 131 | aid 132 | } 133 | 134 | fn select(transport: &'t mut crate::device::pcsc::Device) -> Result { 135 | transport.select(Self::application_id())?; 136 | Ok(Self::from(transport)) 137 | } 138 | } 139 | 140 | pub trait Select<'t>: From<&'t mut dyn Transport> { 141 | const RID: &'static [u8]; 142 | const PIX: &'static [u8]; 143 | 144 | fn application_id() -> Vec { 145 | let mut aid: Vec = Default::default(); 146 | aid.extend_from_slice(Self::RID); 147 | aid.extend_from_slice(Self::PIX); 148 | aid 149 | } 150 | 151 | fn select(transport: &'t mut dyn Transport) -> Result { 152 | transport.select(Self::application_id())?; 153 | Ok(Self::from(transport)) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/apps/admin.rs: -------------------------------------------------------------------------------- 1 | use crate::{Result, Uuid, Version}; 2 | 3 | crate::app!(); 4 | 5 | impl<'t> crate::Select<'t> for App<'t> { 6 | const RID: &'static [u8] = super::Rid::SOLOKEYS; 7 | const PIX: &'static [u8] = super::Pix::ADMIN; 8 | } 9 | 10 | impl App<'_> { 11 | pub const BOOT_TO_BOOTROM_COMMAND: u8 = 0x51; 12 | pub const REBOOT_COMMAND: u8 = 0x53; 13 | pub const VERSION_COMMAND: u8 = 0x61; 14 | pub const UUID_COMMAND: u8 = 0x62; 15 | pub const WINK_COMMAND: u8 = 0x08; 16 | pub const LOCKED_COMMAND: u8 = 0x63; 17 | 18 | /// Reboot the Solo 2 to maintenance mode (LPC 55 bootloader). 19 | /// 20 | /// NOTE: This command requires user confirmation (by tapping the device). 21 | /// Current firmware implementation has no timeout, so if the user aborts 22 | /// the operation host-side, the device is "stuck" until replug. 23 | /// 24 | /// Rebooting can cause the connection to return error, which should 25 | /// be special-cased by the caller. 26 | pub fn maintenance(&mut self) -> Result<()> { 27 | self.transport 28 | .instruct(Self::BOOT_TO_BOOTROM_COMMAND) 29 | .map(drop) 30 | } 31 | 32 | /// Reboot the Solo 2 normally. 33 | /// 34 | /// Rebooting can cause the connection to return error, which should 35 | /// be special-cased by the caller. 36 | pub fn reboot(&mut self) -> Result<()> { 37 | self.transport.instruct(Self::REBOOT_COMMAND).map(drop) 38 | } 39 | 40 | /// The UUID of the device. 41 | /// 42 | /// This can be fetched in multiple other ways, and is also visible in bootloader mode. 43 | /// Responding successfully to this command is our criterion for treating a smartcard 44 | /// as a Solo 2 device. 45 | /// 46 | /// NB: In early firmware, this command isn't implemented on the CTAP transport. 47 | pub fn uuid(&mut self) -> Result { 48 | let version_bytes = self.transport.instruct(Self::UUID_COMMAND)?; 49 | let bytes: &[u8] = &version_bytes; 50 | let _bytes_array: [u8; 16] = bytes.try_into().unwrap(); 51 | Ok(Uuid::from_u128( 52 | bytes 53 | .try_into() 54 | .map_err(|_| anyhow::anyhow!("expected 16 byte UUID, got {}", &hex::encode(bytes))) 55 | .map(u128::from_be_bytes)?, 56 | )) 57 | } 58 | 59 | /// The version of the [Firmware][crate::Firmware] currently running on the Solo 2. 60 | pub fn version(&mut self) -> Result { 61 | let version_bytes = self.transport.instruct(Self::VERSION_COMMAND)?; 62 | let bytes: [u8; 4] = version_bytes.as_slice().try_into().map_err(|_| { 63 | anyhow::anyhow!( 64 | "expected 4 bytes version, got {}", 65 | &hex::encode(version_bytes) 66 | ) 67 | })?; 68 | Ok(bytes.into()) 69 | } 70 | 71 | /// Send the wink command (which fido-authenticator does not implement). 72 | pub fn wink(&mut self) -> Result<()> { 73 | self.transport.instruct(Self::WINK_COMMAND).map(drop) 74 | } 75 | 76 | pub fn locked(&mut self) -> Result { 77 | let locked = self.transport.instruct(Self::LOCKED_COMMAND)?; 78 | locked 79 | .first() 80 | .map(|&locked| locked == 1) 81 | .ok_or_else(|| anyhow::anyhow!("response to locked status empty")) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/apps/fido.rs: -------------------------------------------------------------------------------- 1 | // use crate::{Result, transport::Init}; 2 | 3 | ctap_app!(); 4 | 5 | // impl<'t> crate::Select<'t> for App<'t> { 6 | // const RID: &'static [u8] = super::Rid::NFC_FORUM; 7 | // const PIX: &'static [u8] = super::Pix::NDEF; 8 | // } 9 | 10 | // impl App<'_> { 11 | // pub fn info(&mut self) -> Result { 12 | // self.transport 13 | // .call(Instruction::Select.into(), &Self::CAPABILITIES_PARAMETER) 14 | // .map(drop)?; 15 | // self.fetch() 16 | // } 17 | // } 18 | -------------------------------------------------------------------------------- /src/apps/ndef.rs: -------------------------------------------------------------------------------- 1 | use iso7816::Instruction; 2 | 3 | use crate::Result; 4 | 5 | app!(); 6 | 7 | impl<'t> crate::Select<'t> for App<'t> { 8 | const RID: &'static [u8] = super::Rid::NFC_FORUM; 9 | const PIX: &'static [u8] = super::Pix::NDEF; 10 | } 11 | 12 | impl App<'_> { 13 | const CAPABILITIES_PARAMETER: [u8; 2] = [0xE1, 0x03]; 14 | const DATA_PARAMETER: [u8; 2] = [0xE1, 0x04]; 15 | 16 | fn fetch(&mut self) -> Result> { 17 | self.transport.instruct(Instruction::ReadBinary.into()) 18 | } 19 | 20 | pub fn capabilities(&mut self) -> Result> { 21 | self.transport 22 | .call(Instruction::Select.into(), &Self::CAPABILITIES_PARAMETER) 23 | .map(drop)?; 24 | self.fetch() 25 | } 26 | 27 | pub fn data(&mut self) -> Result> { 28 | self.transport 29 | .call(Instruction::Select.into(), &Self::DATA_PARAMETER) 30 | .map(drop)?; 31 | self.fetch() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/apps/oath.rs: -------------------------------------------------------------------------------- 1 | use core::fmt::{self, Write as _}; 2 | 3 | use anyhow::anyhow; 4 | use flexiber::{Decodable, Encodable, TaggedSlice}; 5 | 6 | use crate::{Error, Result}; 7 | 8 | // pcsc_app!(); 9 | app!(); 10 | 11 | // impl<'t> crate::apps::PcscSelect<'t> for App<'t> { 12 | impl<'t> crate::Select<'t> for App<'t> { 13 | const RID: &'static [u8] = super::Rid::YUBICO; 14 | const PIX: &'static [u8] = super::Pix::OATH; 15 | // fn select(transport: &'t mut dyn Transport) -> Result { 16 | // return Err(anyhow::anyhow!("OATH app not supported on this transport")); 17 | // } 18 | } 19 | 20 | #[derive(Clone, Copy, Debug, Eq, Default, PartialEq)] 21 | pub struct Hotp { 22 | pub initial_counter: u32, 23 | } 24 | 25 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 26 | pub struct Totp { 27 | pub period: u32, 28 | } 29 | 30 | impl Default for Totp { 31 | fn default() -> Self { 32 | Self { period: 30 } 33 | } 34 | } 35 | 36 | #[derive(Clone, Eq, PartialEq)] 37 | pub enum Kind { 38 | Hotp(Hotp), 39 | Totp(Totp), 40 | } 41 | 42 | impl From<&Kind> for u8 { 43 | fn from(kind: &Kind) -> u8 { 44 | match kind { 45 | Kind::Hotp(_) => 0x1, 46 | Kind::Totp(_) => 0x2, 47 | } 48 | } 49 | } 50 | 51 | impl fmt::Debug for Kind { 52 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 53 | match self { 54 | Self::Hotp(hotp) => hotp.fmt(f), 55 | Self::Totp(totp) => totp.fmt(f), 56 | } 57 | } 58 | } 59 | 60 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 61 | #[repr(u8)] 62 | pub enum Digest { 63 | Sha1 = 0x1, 64 | Sha256 = 0x2, 65 | } 66 | 67 | impl TryFrom<&str> for Digest { 68 | type Error = Error; 69 | fn try_from(name: &str) -> Result { 70 | Ok(match name.to_uppercase().as_ref() { 71 | "SHA1" => Self::Sha1, 72 | "SHA256" => Self::Sha256, 73 | name => return Err(anyhow!("Unknown or unimplemented hash algorithm {}", name)), 74 | }) 75 | } 76 | } 77 | 78 | impl Default for Digest { 79 | fn default() -> Self { 80 | Self::Sha1 81 | } 82 | } 83 | 84 | #[derive(Clone, Eq, PartialEq)] 85 | pub struct Secret(Vec); 86 | 87 | impl fmt::Debug for Secret { 88 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 89 | write!(f, "'{}'", hex::encode(&self.0)) 90 | } 91 | } 92 | 93 | impl Secret { 94 | const MINIMUM_SIZE: usize = 14; 95 | 96 | /// Decode the secret from a base 32 representation. 97 | /// 98 | /// Note: The secret is later used as an HMAC key. 99 | /// 100 | /// It is a property of HMAC that a key that is longer than the digest 101 | /// block size is first shortened by applying the digest. For SHA-1 and 102 | /// SHA-2, the block size is 64 bytes (512 bits). 103 | /// 104 | /// Therefore, applying the shortening in this implementation has no effect 105 | /// on the calculated OTP, but it does make communication with the OATH 106 | /// authenticator more efficient for oversized secrets. 107 | /// 108 | /// Note: The secret is always padded to at least 14 bytes with zero bytes, 109 | /// following `ykman`. This is a bit strange (?), as RFC 4226, section 4 says 110 | /// 111 | /// "The algorithm MUST use a strong shared secret. The length of the shared 112 | /// secret MUST be least 128 bits. This document RECOMMENDs a shared secret 113 | /// length of 160 bits." 114 | /// 115 | /// But 14B = 112b < 128b. 116 | pub fn from_base32(encoded: &str, digest: Digest) -> Result { 117 | let unshortened = data_encoding::BASE32.decode(encoded.as_bytes())?; 118 | let mut shortened = match digest { 119 | Digest::Sha1 => { 120 | use sha1::{Digest, Sha1}; 121 | let block_size = 64; 122 | if unshortened.len() > block_size { 123 | trace!( 124 | "shortening {} as {} > {}", 125 | hex::encode(&unshortened), 126 | unshortened.len(), 127 | block_size 128 | ); 129 | let shortened = Sha1::digest(&unshortened).as_slice().to_vec(); 130 | trace!("...to {}", hex::encode(&shortened)); 131 | shortened 132 | } else { 133 | unshortened 134 | } 135 | } 136 | Digest::Sha256 => { 137 | use sha2::{Digest, Sha256}; 138 | let block_size = 64; 139 | if unshortened.len() > block_size { 140 | trace!( 141 | "shortening {} as {} > {}", 142 | hex::encode(&unshortened), 143 | unshortened.len(), 144 | block_size 145 | ); 146 | let shortened = Sha256::digest(&unshortened).as_slice().to_vec(); 147 | trace!("...to {}", hex::encode(&shortened)); 148 | shortened 149 | } else { 150 | unshortened 151 | } 152 | } 153 | }; 154 | 155 | shortened.resize(core::cmp::max(shortened.len(), Self::MINIMUM_SIZE), 0); 156 | 157 | Ok(Self(shortened)) 158 | } 159 | } 160 | 161 | #[derive(Clone, Debug, Eq, PartialEq)] 162 | pub struct Credential { 163 | // add device UUID/serial? 164 | // pub uuid: [u8; 16], 165 | pub label: String, 166 | pub issuer: Option, 167 | pub secret: Secret, 168 | pub kind: Kind, 169 | pub algorithm: Digest, 170 | pub digits: u8, 171 | } 172 | 173 | impl Credential { 174 | pub fn default_totp(label: &str, secret32: &str) -> Result { 175 | let secret = Secret::from_base32(&secret32.to_uppercase(), Digest::default())?; 176 | 177 | Ok(Self { 178 | label: label.to_string(), 179 | issuer: None, 180 | secret, 181 | kind: Kind::Totp(Totp { period: 30 }), 182 | algorithm: Digest::default(), 183 | digits: 6, 184 | }) 185 | } 186 | } 187 | 188 | // #[derive(Clone, Debug, PartialEq)] 189 | // pub struct CredentialId { 190 | // pub label: String, 191 | // gt 192 | // } 193 | 194 | impl Credential { 195 | pub fn id(&self) -> String { 196 | let mut id = String::new(); 197 | if let Kind::Totp(totp) = self.kind { 198 | if totp != Default::default() { 199 | write!(id, "{}/", totp.period).ok(); 200 | } 201 | } 202 | if let Some(issuer) = &self.issuer { 203 | write!(id, "{}:", issuer).ok(); 204 | } 205 | id += &self.label; 206 | id 207 | } 208 | 209 | pub fn key(&self) -> Vec { 210 | let mut key = vec![ 211 | (u8::from(&self.kind) << 4) + self.algorithm as u8, 212 | self.digits, 213 | ]; 214 | key.extend_from_slice(&self.secret.0); 215 | 216 | key 217 | } 218 | } 219 | 220 | impl fmt::Display for Credential { 221 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 222 | // Write strictly the first element into the supplied output 223 | // stream: `f`. Returns `fmt::Result` which indicates whether the 224 | // operation succeeded or failed. Note that `write!` uses syntax which 225 | // is very similar to `println!`. 226 | write!(f, "{}", self.id()) 227 | } 228 | } 229 | 230 | pub struct Authenticate { 231 | pub label: String, 232 | pub timestamp: u64, 233 | } 234 | 235 | impl Authenticate { 236 | pub fn with_label(label: &str) -> Authenticate { 237 | use std::time::SystemTime; 238 | Self { 239 | label: label.to_string(), 240 | timestamp: { 241 | let since_epoch = SystemTime::now() 242 | .duration_since(SystemTime::UNIX_EPOCH) 243 | .unwrap(); 244 | since_epoch.as_secs() 245 | }, 246 | } 247 | } 248 | } 249 | 250 | pub enum Command { 251 | Register(Credential), 252 | // Authenticate(CredentialId), 253 | Authenticate(Authenticate), 254 | Delete(String), 255 | List, 256 | Reset, 257 | } 258 | 259 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 260 | #[repr(u8)] 261 | pub enum Tag { 262 | CredentialId = 0x71, 263 | NameList = 0x72, 264 | Key = 0x73, 265 | Challenge = 0x74, 266 | InitialCounter = 0x7A, 267 | } 268 | 269 | impl TryFrom for Tag { 270 | type Error = Error; 271 | fn try_from(byte: u8) -> Result { 272 | use Tag::*; 273 | Ok(match byte { 274 | 0x71 => CredentialId, 275 | 0x72 => NameList, 276 | 0x73 => Key, 277 | 0x74 => Challenge, 278 | 0x7A => InitialCounter, 279 | byte => return Err(anyhow!("Not a known tag: {}", byte)), 280 | }) 281 | } 282 | } 283 | 284 | impl flexiber::TagLike for Tag { 285 | fn embedding(self) -> flexiber::Tag { 286 | // flexiber::SimpleTag::emb 287 | flexiber::Tag { 288 | class: flexiber::Class::Universal, 289 | constructed: false, 290 | number: self as u8 as u16, 291 | } 292 | } 293 | } 294 | 295 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 296 | #[repr(u8)] 297 | pub enum Instruction { 298 | Put = 0x1, 299 | Delete = 0x2, 300 | Reset = 0x4, 301 | List = 0xA1, 302 | Calculate = 0xA2, 303 | } 304 | 305 | impl Encodable for Tag { 306 | fn encoded_length(&self) -> flexiber::Result { 307 | Ok(1u8.into()) 308 | } 309 | fn encode(&self, encoder: &mut flexiber::Encoder<'_>) -> flexiber::Result<()> { 310 | encoder.encode(&[*self as u8]) 311 | } 312 | } 313 | 314 | impl Decodable<'_> for Tag { 315 | fn decode(decoder: &mut flexiber::Decoder<'_>) -> flexiber::Result { 316 | use flexiber::TagLike; 317 | let simple_tag: flexiber::SimpleTag = decoder.decode()?; 318 | let byte = simple_tag.embedding().number as u8; 319 | let tag: Tag = byte 320 | .try_into() 321 | .map_err(|_| flexiber::Error::from(flexiber::ErrorKind::InvalidTag { byte }))?; 322 | Ok(tag) 323 | } 324 | } 325 | 326 | impl App<'_> { 327 | /// Returns the credential ID. 328 | pub fn register(&mut self, credential: Credential) -> Result { 329 | info!(" registering credential {:?}", &credential); 330 | // data = Tlv(TAG_NAME, cred_id) + Tlv( 331 | // TAG_KEY, 332 | // struct.pack("BB", TAG_PROPERTY, PROP_REQUIRE_TOUCH) 337 | 338 | // if d.counter > 0: 339 | // data += Tlv(TAG_IMF, struct.pack(">I", d.counter)) 340 | 341 | // self.protocol.send_apdu(0, INS_PUT, 0, 0, data) 342 | 343 | let mut data = Vec::new(); 344 | 345 | let credential_id = credential.id(); 346 | debug!("credential ID: {}", credential_id); 347 | let credential_id_part = TaggedSlice::from(Tag::CredentialId, credential_id.as_bytes()) 348 | .map_err(|e| e.kind())? 349 | .to_vec() 350 | .map_err(|e| e.kind())?; 351 | data.extend_from_slice(&credential_id_part); 352 | 353 | let key = credential.key(); 354 | debug!("key: {}", hex::encode(&key)); 355 | let key_part = TaggedSlice::from(Tag::Key, &key) 356 | .map_err(|e| e.kind())? 357 | .to_vec() 358 | .map_err(|e| e.kind())?; 359 | data.extend_from_slice(&key_part); 360 | 361 | if let Kind::Hotp(Hotp { initial_counter }) = credential.kind { 362 | let counter_part = 363 | TaggedSlice::from(Tag::InitialCounter, &initial_counter.to_be_bytes()) 364 | .map_err(|e| e.kind())? 365 | .to_vec() 366 | .map_err(|e| e.kind())?; 367 | data.extend_from_slice(&counter_part); 368 | } 369 | 370 | // TODO: touch.... 371 | // if touch_required: 372 | // data += struct.pack(b">BB", TAG_PROPERTY, PROP_REQUIRE_TOUCH) 373 | 374 | self.transport 375 | .call(Instruction::Put as u8, &data) 376 | .map(drop)?; 377 | 378 | Ok(credential_id) 379 | } 380 | 381 | /// Very limited implementation, more to come. 382 | /// 383 | /// Does *not* respect non-default TOTP periods. 384 | pub fn authenticate(&mut self, authenticate: Authenticate) -> Result { 385 | let mut data = Vec::new(); 386 | 387 | let credential_id = authenticate.label; 388 | debug!("credential ID: {}", credential_id); 389 | let credential_id_part = TaggedSlice::from(Tag::CredentialId, credential_id.as_bytes()) 390 | .map_err(|e| e.kind())? 391 | .to_vec() 392 | .map_err(|e| e.kind())?; 393 | data.extend_from_slice(&credential_id_part); 394 | 395 | let challenge = authenticate.timestamp / (Totp::default().period as u64); 396 | let challenge_bytes = challenge.to_be_bytes(); 397 | let challenge_part = TaggedSlice::from(Tag::Challenge, &challenge_bytes) 398 | .map_err(|e| e.kind())? 399 | .to_vec() 400 | .map_err(|e| e.kind())?; 401 | data.extend_from_slice(&challenge_part); 402 | 403 | let response = 404 | self.transport 405 | .call_iso(0, Instruction::Calculate as u8, 0x00, 0x01, &data)?; 406 | debug!("response: {}", hex::encode(&response)); 407 | 408 | assert_eq!(response[0], 0x76); 409 | assert_eq!(response[1], 5); 410 | let digits = response[2] as usize; 411 | let truncated_code = u32::from_be_bytes(response[3..].try_into().unwrap()); 412 | let code = (truncated_code & 0x7FFFFFFF) % 10u32.pow(digits as _); 413 | Ok(format!("{:0digits$}", code, digits = digits)) 414 | } 415 | 416 | pub fn delete(&mut self, label: String) -> Result<()> { 417 | let mut data = Vec::new(); 418 | 419 | let credential_id = label; 420 | debug!("credential ID: {}", credential_id); 421 | let credential_id_part = TaggedSlice::from(Tag::CredentialId, credential_id.as_bytes()) 422 | .map_err(|e| e.kind())? 423 | .to_vec() 424 | .map_err(|e| e.kind())?; 425 | data.extend_from_slice(&credential_id_part); 426 | 427 | self.transport 428 | .call(Instruction::Delete as u8, &data) 429 | .map(drop) 430 | } 431 | 432 | pub fn list(&mut self) -> Result> { 433 | let mut labels = Vec::new(); 434 | 435 | let response = self.transport.instruct(Instruction::List as u8)?; 436 | if response.is_empty() { 437 | debug!("no credentials"); 438 | return Ok(labels); 439 | } 440 | debug!("{:?}", &hex::encode(&response)); 441 | let mut decoder = flexiber::Decoder::new(response.as_slice()); 442 | 443 | loop { 444 | let data = decoder 445 | .decode_tagged_slice(Tag::NameList) 446 | .map_err(|e| e.kind())?; 447 | // debug!("{:?}", &hex::encode(data)); 448 | // let kind = data[0] ... 449 | let credential_id = std::str::from_utf8(&data[1..])?; 450 | trace!("{:?}", &credential_id); 451 | labels.push(credential_id.to_string()); 452 | if decoder.is_finished() { 453 | return Ok(labels); 454 | } 455 | } 456 | } 457 | 458 | pub fn reset(&mut self) -> Result<()> { 459 | self.transport 460 | .call_iso(0, Instruction::Reset as u8, 0xDE, 0xAD, &[]) 461 | .map(drop) 462 | // _, self._salt, self._challenge = _parse_select(self.protocol.select(AID.OATH)) 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /src/apps/piv.rs: -------------------------------------------------------------------------------- 1 | app!(); 2 | 3 | impl<'t> crate::Select<'t> for App<'t> { 4 | const RID: &'static [u8] = super::Rid::NIST; 5 | const PIX: &'static [u8] = super::Pix::PIV; 6 | } 7 | -------------------------------------------------------------------------------- /src/apps/provision.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use iso7816::Instruction; 3 | 4 | use crate::Result; 5 | 6 | app!(); 7 | 8 | impl<'t> crate::Select<'t> for App<'t> { 9 | const RID: &'static [u8] = super::Rid::SOLOKEYS; 10 | const PIX: &'static [u8] = super::Pix::PROVISION; 11 | } 12 | 13 | impl App<'_> { 14 | // seems to be destructive currently 15 | // const BOOT_TO_BOOTROM_COMMAND: u8 = 0x51; 16 | const GENERATE_P256_ATTESTATION: u8 = 0xbc; 17 | const GENERATE_ED255_ATTESTATION: u8 = 0xbb; 18 | const GENERATE_X255_ATTESTATION: u8 = 0xb7; 19 | const BOOT_TO_BOOTROM: u8 = 0x51; 20 | const GET_UUID: u8 = 0x62; 21 | const REFORMAT_FS: u8 = 0xbd; 22 | const STORE_P256_ATTESTATION_CERT: u8 = 0xba; 23 | const STORE_ED255_ATTESTATION_CERT: u8 = 0xb9; 24 | const STORE_X255_ATTESTATION_CERT: u8 = 0xb6; 25 | const STORE_T1_INTERMEDIATE_PUBKEY: u8 = 0xb5; 26 | const WRITE_FILE: u8 = 0xbf; 27 | 28 | const PATH_ID: [u8; 2] = [0xe1, 0x01]; 29 | const DATA_ID: [u8; 2] = [0xe1, 0x02]; 30 | 31 | pub fn generate_trussed_ed255_attestation_key(&mut self) -> Result<[u8; 32]> { 32 | Ok(self 33 | .transport 34 | .instruct(Self::GENERATE_ED255_ATTESTATION)? 35 | .as_slice() 36 | .try_into()?) 37 | } 38 | 39 | pub fn generate_trussed_p256_attestation_key(&mut self) -> Result<[u8; 64]> { 40 | Ok(self 41 | .transport 42 | .instruct(Self::GENERATE_P256_ATTESTATION)? 43 | .as_slice() 44 | .try_into()?) 45 | } 46 | 47 | pub fn generate_trussed_x255_attestation_key(&mut self) -> Result<[u8; 32]> { 48 | Ok(self 49 | .transport 50 | .instruct(Self::GENERATE_X255_ATTESTATION)? 51 | .as_slice() 52 | .try_into()?) 53 | } 54 | 55 | pub fn reformat_filesystem(&mut self) -> Result<()> { 56 | self.transport.instruct(Self::REFORMAT_FS).map(drop) 57 | } 58 | 59 | pub fn store_trussed_ed255_attestation_certificate(&mut self, der: &[u8]) -> Result<()> { 60 | self.transport 61 | .call(Self::STORE_ED255_ATTESTATION_CERT, der) 62 | .map(drop) 63 | } 64 | 65 | pub fn store_trussed_p256_attestation_certificate(&mut self, der: &[u8]) -> Result<()> { 66 | self.transport 67 | .call(Self::STORE_P256_ATTESTATION_CERT, der) 68 | .map(drop) 69 | } 70 | 71 | pub fn store_trussed_x255_attestation_certificate(&mut self, der: &[u8]) -> Result<()> { 72 | self.transport 73 | .call(Self::STORE_X255_ATTESTATION_CERT, der) 74 | .map(drop) 75 | } 76 | 77 | pub fn store_trussed_t1_intermediate_public_key(&mut self, public_key: [u8; 32]) -> Result<()> { 78 | self.transport 79 | .call(Self::STORE_T1_INTERMEDIATE_PUBKEY, &public_key) 80 | .map(drop) 81 | } 82 | 83 | pub fn boot_to_bootrom(&mut self) -> Result<()> { 84 | self.transport.instruct(Self::BOOT_TO_BOOTROM).map(drop) 85 | } 86 | 87 | pub fn uuid(&mut self) -> Result { 88 | let version_bytes = self.transport.instruct(Self::GET_UUID)?; 89 | let bytes: &[u8] = &version_bytes; 90 | bytes 91 | .try_into() 92 | .map_err(|_| anyhow::anyhow!("expected 16 byte UUID, got {}", &hex::encode(bytes))) 93 | .map(u128::from_be_bytes) 94 | } 95 | 96 | pub fn write_file(&mut self, data: &[u8], path: &str) -> Result<()> { 97 | if data.len() > 8192 { 98 | return Err(anyhow!("data too long (8192 byte limit)")); 99 | } 100 | if path.as_bytes().len() > 128 { 101 | return Err(anyhow!("path {} too long (128 byte limit)", path)); 102 | } 103 | 104 | self.transport 105 | .call(Instruction::Select.into(), &Self::PATH_ID) 106 | .map(drop)?; 107 | self.transport 108 | .call(Instruction::WriteBinary.into(), path.as_bytes()) 109 | .map(drop)?; 110 | 111 | self.transport 112 | .call(Instruction::Select.into(), &Self::DATA_ID) 113 | .map(drop)?; 114 | self.transport 115 | .call(Instruction::WriteBinary.into(), data) 116 | .map(drop)?; 117 | 118 | self.transport.instruct(Self::WRITE_FILE).map(drop) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/apps/qa.rs: -------------------------------------------------------------------------------- 1 | app!(); 2 | 3 | impl<'t> crate::Select<'t> for App<'t> { 4 | const RID: &'static [u8] = super::Rid::SOLOKEYS; 5 | const PIX: &'static [u8] = super::Pix::QA; 6 | } 7 | -------------------------------------------------------------------------------- /src/bin/solo2/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{self, crate_authors, crate_version, Args, Parser, Subcommand, ValueEnum}; 2 | 3 | /// CLI to update and use Solo 2 security keys. 4 | /// 5 | /// Print more logs by adding `-v` or `-vv`. 6 | /// 7 | /// Project homepage: 8 | /// 9 | /// Trussed homepage: 10 | 11 | // 12 | // Design: [Rain's Rust CLI recommendations][cli-recommendations] is a good read. 13 | // 14 | // [cli-recommendations]: https://rust-cli-recommendations.sunshowers.io/ 15 | 16 | #[derive(Parser)] 17 | #[clap(infer_subcommands = true)] 18 | #[clap(author = crate_authors!())] 19 | #[clap(version = crate_version!())] 20 | pub struct Cli { 21 | #[clap(flatten)] 22 | pub global_options: GlobalOptions, 23 | #[clap(subcommand)] 24 | pub subcommand: Subcommands, 25 | } 26 | 27 | #[derive(Debug, Args)] 28 | pub struct GlobalOptions { 29 | /// Prefer CTAP transport. 30 | #[clap(global = true, help_heading = "TRANSPORT", long)] 31 | pub ctap: bool, 32 | 33 | /// Prefer PCSC transport. 34 | #[clap(global = true, help_heading = "TRANSPORT", long)] 35 | pub pcsc: bool, 36 | 37 | /// Specify UUID of a Solo 2 device. 38 | #[clap(global = true, help_heading = "SELECTION", long, short)] 39 | pub uuid: Option, 40 | 41 | /// Interact with all applicable Solo 2 devices. 42 | #[clap( 43 | global = true, 44 | help_heading = "SELECTION", 45 | long, 46 | // would conflict with OATH's algorithm flag 47 | // short, 48 | conflicts_with = "uuid" 49 | )] 50 | pub all: bool, 51 | 52 | /// Verbosity level (can be specified multiple times) 53 | #[clap(flatten)] 54 | pub verbose: clap_verbosity_flag::Verbosity, 55 | } 56 | 57 | #[derive(Subcommand)] 58 | pub enum Subcommands { 59 | #[clap(subcommand)] 60 | App(Apps), 61 | 62 | #[clap(subcommand)] 63 | Bootloader(Bootloader), 64 | 65 | #[clap(subcommand)] 66 | Completion(Completion), 67 | 68 | /// List all available devices 69 | #[clap(visible_alias = "ls")] 70 | List, 71 | 72 | #[clap(subcommand)] 73 | Pki(Pki), 74 | 75 | /// Update to latest firmware published by SoloKeys. Warns on Major updates. 76 | Update { 77 | /// Just show the version that would be installed 78 | #[clap(long, short = 'n')] 79 | dry_run: bool, 80 | /// DANGER! Proceed with major updates without prompt 81 | #[clap(long, short)] 82 | yes: bool, 83 | /// Update all connected SoloKeys Solo 2 devices 84 | #[clap(long, short)] 85 | all: bool, 86 | /// Update to a specific firmware secure boot file (.sb2) 87 | #[clap(long, short)] 88 | with: Option, 89 | }, 90 | } 91 | 92 | #[derive(Subcommand)] 93 | /// Interact with bootloader 94 | pub enum Bootloader { 95 | /// List all available bootloaders 96 | #[clap(visible_alias = "ls")] 97 | List, 98 | // NB: If we convert lpc55-host to clap 3, should be possible 99 | // to slot in its CLI here. 100 | 101 | // /// Run a sequence of bootloader provision commands defined in the config file 102 | // Provision { 103 | // /// Configuration file containing settings 104 | // config: String, 105 | // }, 106 | /// Reboots (into device if firmware is valid) 107 | Reboot, 108 | } 109 | 110 | #[derive(Subcommand)] 111 | /// Generate shell completion scripts 112 | pub enum Completion { 113 | /// Print completion script for Bash 114 | Bash, 115 | /// Print completion script for Fish 116 | Fish, 117 | /// Print completion script for PowerShell 118 | PowerShell, 119 | /// Print completion script for Zsh 120 | Zsh, 121 | } 122 | 123 | #[derive(Subcommand)] 124 | /// PKI-related 125 | pub enum Pki { 126 | #[clap(subcommand)] 127 | Ca(Ca), 128 | #[cfg(feature = "dev-pki")] 129 | #[clap(subcommand)] 130 | Dev(Dev), 131 | Web, 132 | } 133 | 134 | #[derive(Subcommand)] 135 | /// CA-related 136 | pub enum Ca { 137 | /// Fetch one of the well-known Solo 2 PKI certificates in DER format 138 | FetchCertificate { 139 | /// Name of authority, e.g. R1, T1, S3, etc. 140 | authority: String, 141 | }, 142 | } 143 | 144 | #[derive(Subcommand)] 145 | /// PKI for development 146 | pub enum Dev { 147 | /// Fetch one of the well-known Solo 2 PKI certificates in DER format 148 | Fido { 149 | /// Output file for private P256 key in binary format 150 | key: String, 151 | /// Output file for self-signed certificate in DER format 152 | cert: String, 153 | }, 154 | } 155 | 156 | #[derive(Subcommand)] 157 | #[clap(infer_subcommands = true)] 158 | /// Interact with on-device applications 159 | pub enum Apps { 160 | #[clap(subcommand)] 161 | Admin(Admin), 162 | #[clap(subcommand)] 163 | Fido(Fido), 164 | #[clap(subcommand)] 165 | Ndef(Ndef), 166 | #[clap(subcommand)] 167 | Oath(Oath), 168 | #[clap(subcommand)] 169 | Piv(Piv), 170 | #[clap(subcommand)] 171 | Provision(Provision), 172 | #[clap(subcommand)] 173 | Qa(Qa), 174 | } 175 | 176 | #[derive(Subcommand)] 177 | #[clap(infer_subcommands = true)] 178 | /// admin app 179 | pub enum Admin { 180 | /// Print the application's AID 181 | Aid, 182 | /// Is device locked? (not available in early firmware) 183 | Locked, 184 | /// Switch device to maintenance mode (reboot into LPC 55 bootloader) 185 | #[clap(alias = "boot-to-bootrom")] 186 | Maintenance, 187 | /// Reboot device (as Solo 2) 188 | #[clap(alias = "reboot")] 189 | Restart, 190 | /// Return device UUID (not available over CTAP in early firmware) 191 | Uuid, 192 | /// Return device firmware version 193 | Version, 194 | /// Wink the device 195 | Wink, 196 | } 197 | 198 | #[derive(Subcommand)] 199 | #[clap(infer_subcommands = true)] 200 | /// FIDO app 201 | pub enum Fido { 202 | /// FIDO init response 203 | Init, 204 | /// FIDO wink 205 | Wink, 206 | } 207 | 208 | #[derive(Subcommand)] 209 | #[clap(infer_subcommands = true)] 210 | /// NDEF app 211 | pub enum Ndef { 212 | /// Print the application's AID 213 | Aid, 214 | /// NDEF capabilities 215 | Capabilities, 216 | /// NDEF data 217 | Data, 218 | } 219 | 220 | #[derive(Subcommand)] 221 | #[clap(infer_subcommands = true)] 222 | /// OATH app 223 | pub enum Oath { 224 | /// Print the application's AID 225 | Aid, 226 | // Authenticate, 227 | /// Delete existing credential 228 | Delete { 229 | /// Label of credential 230 | label: String, 231 | }, 232 | /// List all credentials 233 | List, 234 | /// Register new credential 235 | Register(OathRegister), 236 | /// Reset OATH app, deleting all credentials 237 | Reset, 238 | /// Calculate TOTP for a registered credential 239 | Totp { 240 | /// Label of credential 241 | label: String, 242 | /// timestamp to use to generate the OTP, as seconds since the UNIX epoch 243 | timestamp: Option, 244 | }, 245 | } 246 | 247 | #[derive(Args)] 248 | pub struct OathRegister { 249 | /// label to use for the OATH secret, e.g. alice@trussed.dev 250 | pub label: String, 251 | /// the actual OATH seed, e.g. JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP 252 | pub secret: String, 253 | 254 | /// (optional) issuer to use for the OATH credential, e.g., example.com 255 | #[clap(long, short)] 256 | pub issuer: Option, 257 | 258 | #[clap(default_value = "sha1", long, short, value_enum)] 259 | pub algorithm: OathAlgorithm, 260 | #[clap(default_value = "totp", long, short, value_enum)] 261 | pub kind: OathKind, 262 | 263 | /// (only HOTP) initial counter to use for HOTPs 264 | #[clap(default_value = "0", long, short)] //, required_if_eq("kind", "hotp"))] 265 | pub counter: u32, 266 | 267 | /// number of digits to output 268 | // #[clap(default_value = "6", possible_values=["6", "7", "8"], long, short)] 269 | // TODO: figure out how to put this check back in 270 | #[clap(long, short)] 271 | pub digits: u8, 272 | 273 | /// (only TOTP) period in seconds for which a TOTP is valid 274 | #[clap(default_value = "30", long, short)] //, required_if_eq("kind", "totp"))] 275 | pub period: u32, 276 | } 277 | 278 | // ignore case? 279 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] 280 | /// hash algorithm to use in OTP generation 281 | pub enum OathAlgorithm { 282 | Sha1, 283 | Sha256, 284 | } 285 | 286 | // ignore case? 287 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] 288 | /// kind of OATH credential to register 289 | pub enum OathKind { 290 | Hotp, 291 | Totp, 292 | } 293 | 294 | #[derive(Subcommand)] 295 | #[clap(infer_subcommands = true)] 296 | /// PIV app 297 | pub enum Piv { 298 | /// Print the application's AID 299 | Aid, 300 | } 301 | 302 | #[derive(Subcommand)] 303 | #[clap(infer_subcommands = true)] 304 | /// Provision app 305 | pub enum Provision { 306 | /// Print the application's AID 307 | Aid, 308 | /// Generate new Trussed Ed255 attestation key 309 | GenerateEd255Key, 310 | /// Generate new Trussed P256 attestation key 311 | GenerateP256Key, 312 | /// Generate new Trussed X255 attestation key 313 | GenerateX255Key, 314 | 315 | /// Store Trussed Ed255 attestation certificate 316 | StoreEd255Cert { 317 | /// Certificate in DER format 318 | der: String, 319 | }, 320 | /// Store Trussed P256 attestation certificate 321 | StoreP256Cert { 322 | /// Certificate in DER format 323 | der: String, 324 | }, 325 | /// Store Trussed X255 attestation certificate 326 | StoreX255Cert { 327 | /// Certificate in DER format 328 | der: String, 329 | }, 330 | 331 | /// Store Trussed T1 intermediate public key 332 | StoreT1Pubkey { 333 | /// Ed255 public key (raw, 32 bytes) 334 | bytes: String, 335 | }, 336 | /// Store FIDO batch attestation certificate 337 | StoreFidoBatchCert { 338 | /// Attestation certificate 339 | cert: String, 340 | }, 341 | /// Store FIDO batch attestation private key 342 | StoreFidoBatchKey { 343 | /// P256 private key in internal format 344 | bytes: String, 345 | }, 346 | 347 | /// Reformat the internal filesystem 348 | ReformatFilesystem, 349 | 350 | /// Write binary file to specified path 351 | WriteFile { 352 | /// binary data file 353 | data: String, 354 | /// path in internal filesystem 355 | path: String, 356 | }, 357 | } 358 | 359 | #[derive(Subcommand)] 360 | #[clap(infer_subcommands = true)] 361 | /// QA app 362 | pub enum Qa { 363 | /// Print the application's AID 364 | Aid, 365 | } 366 | 367 | ///// Return the "long" format of lpc55's version string. 368 | ///// 369 | ///// If a revision hash is given, then it is used. If one isn't given, then 370 | ///// the SOLO2_CLI_BUILD_GIT_HASH env var is inspected for it. If that isn't set, 371 | ///// then a revision hash is not included in the version string returned. 372 | //pub fn long_version(revision_hash: Option<&str>) -> String { 373 | // // Do we have a git hash? 374 | // // (Yes, if ripgrep was built on a machine with `git` installed.) 375 | // let hash = match revision_hash.or(option_env!("SOLO2_CLI_BUILD_GIT_HASH")) { 376 | // None => String::new(), 377 | // Some(githash) => format!(" (rev {})", githash), 378 | // }; 379 | // format!("{}{}", crate_version!(), hash) 380 | //} 381 | -------------------------------------------------------------------------------- /src/bin/solo2/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate log; 3 | 4 | mod cli; 5 | 6 | use anyhow::anyhow; 7 | use solo2::{Device, Select as _, Solo2, Uuid, UuidSelectable}; 8 | 9 | fn main() { 10 | restore_cursor_on_ctrl_c(); 11 | 12 | use clap::Parser; 13 | let args = cli::Cli::parse(); 14 | 15 | pretty_env_logger::formatted_builder() 16 | .filter(None, args.global_options.verbose.log_level_filter()) 17 | .init(); 18 | 19 | if let Err(err) = try_main(args) { 20 | eprintln!("Error: {}", err); 21 | std::process::exit(1); 22 | } 23 | } 24 | 25 | fn try_main(args: cli::Cli) -> anyhow::Result<()> { 26 | let uuid: Option = args 27 | .global_options 28 | .uuid 29 | .map(|uuid| uuid.parse()) 30 | .transpose()?; 31 | 32 | if args.global_options.ctap { 33 | Solo2::prefer_ctap(); 34 | } 35 | if args.global_options.pcsc { 36 | Solo2::prefer_pcsc(); 37 | } 38 | 39 | // use cli::Subcommands::*; 40 | use cli::Apps::*; 41 | match args.subcommand { 42 | cli::Subcommands::App(app) => { 43 | let solo2s: Vec = 44 | all_or_unwrap_or_interactively_select(uuid, args.global_options.all, "Solo 2")?; 45 | 46 | // let uuid = solo2.uuid(); 47 | 48 | solo2s.into_iter().try_for_each(|mut solo2| { 49 | match &app { 50 | Admin(admin) => { 51 | use cli::Admin::*; 52 | use solo2::apps::Admin; 53 | 54 | let mut app = Admin::select(&mut solo2)?; 55 | 56 | match admin { 57 | Aid => { 58 | println!("{}", hex::encode(Admin::application_id()).to_uppercase()); 59 | return Ok(()); 60 | } 61 | Locked => { 62 | let locked = app.locked()?; 63 | println!("locked: {}", locked); 64 | } 65 | Restart => { 66 | info!("attempting restart of devices"); 67 | app.reboot()?; 68 | } 69 | Maintenance => { 70 | // TODO: figure out the correct solution 71 | #[allow(clippy::drop_non_drop)] 72 | drop(app); 73 | println!("Tap button on key to reboot into bootloader/maintenance mode, or replug to abort..."); 74 | solo2.into_lpc55()?; 75 | } 76 | Uuid => { 77 | let uuid = app.uuid()?; 78 | println!("{:X}", uuid.simple()); 79 | } 80 | Version => { 81 | let version = app.version()?; 82 | println!("{}", version.to_calver()); 83 | } 84 | Wink => { 85 | app.wink()?; 86 | } 87 | } 88 | Ok(()) 89 | } 90 | Fido(fido) => { 91 | use cli::Fido::*; 92 | use solo2::apps::Fido; 93 | 94 | let app = Fido::from(solo2.as_ctap_mut().ok_or_else(|| anyhow!("CTAP unavailable"))?); 95 | 96 | match fido { 97 | Init => { 98 | println!("{:?}", app.init()?); 99 | } 100 | Wink => { 101 | let channel = app.init()?.channel; 102 | app.wink(channel)?; 103 | } 104 | } 105 | Ok(()) 106 | } 107 | Ndef(ndef) => { 108 | use cli::Ndef::*; 109 | use solo2::apps::Ndef; 110 | 111 | let mut app = Ndef::select(&mut solo2)?; 112 | 113 | match ndef { 114 | Aid => { 115 | println!("{}", hex::encode(Ndef::application_id()).to_uppercase()); 116 | return Ok(()); 117 | } 118 | Capabilities => { 119 | let capabilities = app.capabilities()?; 120 | println!("{}", hex::encode(capabilities)); 121 | } 122 | Data => { 123 | let data = app.data()?; 124 | println!("{}", hex::encode(data)); 125 | } 126 | } 127 | Ok(()) 128 | } 129 | Oath(oath) => { 130 | use cli::Oath::*; 131 | use solo2::apps::Oath; 132 | 133 | let mut app = Oath::select(&mut solo2)?; 134 | 135 | match oath { 136 | Aid => { 137 | println!("{}", hex::encode(Oath::application_id()).to_uppercase()); 138 | Ok(()) 139 | } 140 | Delete { label } => { 141 | app.delete(label.clone())?; 142 | Ok(()) 143 | } 144 | List => { 145 | let labels = app.list()?; 146 | for label in labels { 147 | println!("{}", label); 148 | } 149 | Ok(()) 150 | } 151 | // TODO: factor out the conversion 152 | Register(args) => { 153 | use solo2::apps::oath; 154 | 155 | if args.digits < 6 || args.digits > 8 { 156 | return Err(anyhow::anyhow!("Invalid number of OATH digits")); 157 | } 158 | use cli::OathAlgorithm; 159 | let digest = match args.algorithm { 160 | OathAlgorithm::Sha1 => oath::Digest::Sha1, 161 | OathAlgorithm::Sha256 => oath::Digest::Sha256, 162 | }; 163 | let secret = 164 | solo2::apps::oath::Secret::from_base32(&args.secret.to_uppercase(), digest)?; 165 | use cli::OathKind; 166 | let kind = match args.kind { 167 | OathKind::Hotp => oath::Kind::Hotp(oath::Hotp { 168 | initial_counter: args.counter, 169 | }), 170 | OathKind::Totp => oath::Kind::Totp(oath::Totp { 171 | period: args.period, 172 | }), 173 | }; 174 | let credential = solo2::apps::oath::Credential { 175 | label: args.label.clone(), 176 | issuer: args.issuer.clone(), 177 | secret, 178 | kind, 179 | algorithm: digest, 180 | digits: args.digits, 181 | }; 182 | let credential_id = app.register(credential)?; 183 | println!("{}", credential_id); 184 | Ok(()) 185 | } 186 | Reset => app.reset(), 187 | // TODO: factor out the conversion 188 | Totp { label, timestamp } => { 189 | use solo2::apps::oath; 190 | use std::time::SystemTime; 191 | 192 | let timestamp = timestamp 193 | .clone() 194 | .map(|s| s.parse()) 195 | .transpose()? 196 | .unwrap_or_else(|| { 197 | let since_epoch = SystemTime::now() 198 | .duration_since(SystemTime::UNIX_EPOCH) 199 | .unwrap(); 200 | since_epoch.as_secs() 201 | }); 202 | let authenticate = oath::Authenticate { label: label.clone(), timestamp }; 203 | let code = app.authenticate(authenticate)?; 204 | println!("{}", code); 205 | Ok(()) 206 | } 207 | } 208 | } 209 | Piv(piv) => { 210 | use cli::Piv::*; 211 | use solo2::apps::Piv; 212 | 213 | // let mut app = Piv::select(&mut solo2)?; 214 | Piv::select(&mut solo2)?; 215 | 216 | match piv { 217 | Aid => { 218 | println!("{}", hex::encode(Piv::application_id()).to_uppercase()); 219 | Ok(()) 220 | } 221 | } 222 | } 223 | Provision(provision) => { 224 | use cli::Provision::*; 225 | use solo2::apps::provision::App as Provision; 226 | 227 | let mut app = Provision::select(&mut solo2)?; 228 | 229 | match provision { 230 | Aid => { 231 | println!( 232 | "{}", 233 | hex::encode(Provision::application_id()).to_uppercase() 234 | ); 235 | return Ok(()); 236 | } 237 | GenerateEd255Key => { 238 | let public_key = app.generate_trussed_ed255_attestation_key()?; 239 | println!("{}", hex::encode(public_key)); 240 | } 241 | GenerateP256Key => { 242 | let public_key = app.generate_trussed_p256_attestation_key()?; 243 | println!("{}", hex::encode(public_key)); 244 | } 245 | GenerateX255Key => { 246 | let public_key = app.generate_trussed_x255_attestation_key()?; 247 | println!("{}", hex::encode(public_key)); 248 | } 249 | ReformatFilesystem => app.reformat_filesystem()?, 250 | StoreEd255Cert { der } => { 251 | let certificate = std::fs::read(der)?; 252 | app.store_trussed_ed255_attestation_certificate(&certificate)?; 253 | } 254 | StoreP256Cert { der } => { 255 | let certificate = std::fs::read(der)?; 256 | app.store_trussed_p256_attestation_certificate(&certificate)?; 257 | } 258 | StoreX255Cert { der } => { 259 | let certificate = std::fs::read(der)?; 260 | app.store_trussed_x255_attestation_certificate(&certificate)?; 261 | } 262 | StoreT1Pubkey { bytes } => { 263 | let pubkey: [u8; 32] = std::fs::read(bytes)?.as_slice().try_into()?; 264 | app.store_trussed_t1_intermediate_public_key(pubkey)?; 265 | } 266 | StoreFidoBatchCert { cert } => { 267 | let cert = std::fs::read(cert)?; 268 | app.write_file(&cert, "/fido/x5c/00")?; 269 | } 270 | StoreFidoBatchKey { bytes } => { 271 | let key = std::fs::read(bytes)?; 272 | app.write_file(&key, "/fido/sec/00")?; 273 | } 274 | WriteFile { data, path } => { 275 | let data = std::fs::read(data)?; 276 | app.write_file(&data, path)?; 277 | } 278 | } 279 | Ok(()) 280 | } 281 | Qa(cmd) => { 282 | use cli::Qa::*; 283 | use solo2::apps::qa::App; 284 | 285 | App::select(&mut solo2)?; 286 | 287 | match cmd { 288 | Aid => { 289 | println!("{}", hex::encode(App::application_id()).to_uppercase()); 290 | } 291 | } 292 | Ok(()) 293 | } 294 | } 295 | })?; 296 | } 297 | cli::Subcommands::Pki(pki) => { 298 | match pki { 299 | cli::Pki::Ca(ca) => match ca { 300 | cli::Ca::FetchCertificate { authority } => { 301 | use std::io::{stdout, Write as _}; 302 | let authority: solo2::pki::Authority = authority.as_str().try_into()?; 303 | let certificate = solo2::pki::fetch_certificate(authority)?; 304 | if atty::is(atty::Stream::Stdout) { 305 | eprintln!("Some things to do with the DER data"); 306 | eprintln!( 307 | "* redirect to a file: `> {}.der`", 308 | &authority.name().to_lowercase() 309 | ); 310 | eprintln!("* inspect contents by piping to step: `| step certificate inspect`"); 311 | return Err(anyhow::anyhow!("Refusing to write binary data to stdout")); 312 | } 313 | stdout().write_all(certificate.der())?; 314 | } 315 | }, 316 | #[cfg(feature = "dev-pki")] 317 | cli::Pki::Dev(dev) => match dev { 318 | cli::Dev::Fido { key, cert } => { 319 | let (aaguid, key_trussed, key_pem, certificate) = 320 | solo2::pki::dev::generate_selfsigned_fido(); 321 | 322 | info!("\n{}", key_pem); 323 | info!("\n{}", certificate.serialize_pem()?); 324 | 325 | std::fs::write(key, &key_trussed)?; 326 | std::fs::write(cert, &certificate.serialize_der()?)?; 327 | 328 | println!("{}", hex::encode_upper(aaguid)); 329 | } 330 | }, 331 | cli::Pki::Web => { 332 | let solo2: Solo2 = unwrap_or_interactively_select(uuid, "Solo 2")?; 333 | let uuid = solo2.uuid().simple(); 334 | let url = format!("https://s2pki.net/s2/{uuid}/x255.txt"); 335 | println!("=> {}", url); 336 | webbrowser::open(&url)?; 337 | } 338 | } 339 | } 340 | cli::Subcommands::Bootloader(args) => match args { 341 | cli::Bootloader::Reboot => { 342 | let bootloader = match uuid { 343 | Some(uuid) => lpc55::Bootloader::having(uuid)?, 344 | None => interactively_select(lpc55::Bootloader::list(), "Solo 2 bootloaders")?, 345 | }; 346 | bootloader.reboot(); 347 | } 348 | cli::Bootloader::List => { 349 | let bootloaders = lpc55::Bootloader::list(); 350 | for bootloader in bootloaders { 351 | println!("{}", &Device::Lpc55(bootloader)); 352 | } 353 | } 354 | }, 355 | cli::Subcommands::Completion(args) => { 356 | use clap::CommandFactory as _; 357 | use clap_complete::{generate, shells::*}; 358 | use std::io::stdout; 359 | let mut app = cli::Cli::command(); 360 | match args { 361 | cli::Completion::Bash => generate(Bash, &mut app, "solo2", &mut stdout()), 362 | cli::Completion::Fish => generate(Fish, &mut app, "solo2", &mut stdout()), 363 | cli::Completion::PowerShell => { 364 | generate(PowerShell, &mut app, "solo2", &mut stdout()) 365 | } 366 | cli::Completion::Zsh => generate(Zsh, &mut app, "solo2", &mut stdout()), 367 | } 368 | } 369 | cli::Subcommands::List => { 370 | let devices = solo2::Device::list(); 371 | for device in devices { 372 | println!("{}", &device); 373 | } 374 | } 375 | cli::Subcommands::Update { 376 | dry_run, 377 | yes, 378 | all, 379 | with, 380 | } => { 381 | let firmware: solo2::Firmware = with 382 | .map(solo2::Firmware::read_from_file) 383 | .unwrap_or_else(|| { 384 | println!("Downloading latest release from https://github.com/solokeys/solo2/"); 385 | solo2::Firmware::download_latest() 386 | })?; 387 | 388 | println!( 389 | "Fetched firmware version {} ({})", 390 | &firmware.version().to_calver(), 391 | &firmware.version().to_semver(), 392 | ); 393 | 394 | if dry_run { 395 | return Ok(()); 396 | } 397 | 398 | if all { 399 | for device in Device::list() { 400 | let bar = indicatif::ProgressBar::new(firmware.len() as u64); 401 | let progress = |bytes: usize| bar.set_position(bytes as u64); 402 | device.program(firmware.clone(), yes, Some(&progress))?; 403 | } 404 | return Ok(()); 405 | } else { 406 | let device = match uuid { 407 | Some(uuid) => Device::having(uuid)?, 408 | None => interactively_select(Device::list(), "Solo 2 devices")?, 409 | }; 410 | let bar = indicatif::ProgressBar::new(firmware.len() as u64); 411 | let progress = |bytes: usize| bar.set_position(bytes as u64); 412 | return device.program(firmware, yes, Some(&progress)); 413 | } 414 | } 415 | } 416 | 417 | Ok(()) 418 | } 419 | 420 | /// description: plural of thing to be selected, e.g. "Solo 2 devices" 421 | pub fn interactively_select( 422 | candidates: Vec, 423 | description: &str, 424 | ) -> anyhow::Result { 425 | let mut candidates = match candidates.len() { 426 | 0 => return Err(anyhow!("Empty list of {}", description)), 427 | 1 => { 428 | let mut candidates = candidates; 429 | return Ok(candidates.remove(0)); 430 | } 431 | _ => candidates, 432 | }; 433 | 434 | let items: Vec = candidates 435 | .iter() 436 | .map(|candidate| format!("{}", &candidate)) 437 | .collect(); 438 | 439 | use dialoguer::{theme, Select}; 440 | // let selection = Select::with_theme(&theme::SimpleTheme) 441 | let selection = Select::with_theme(&theme::ColorfulTheme::default()) 442 | .with_prompt(format!( 443 | "Multiple {} available, select one or hit Escape key", 444 | description 445 | )) 446 | .items(&items) 447 | .default(0) 448 | .interact_opt()? 449 | .ok_or_else(|| anyhow!("No candidate selected"))?; 450 | 451 | Ok(candidates.remove(selection)) 452 | } 453 | 454 | pub fn all_or_unwrap_or_interactively_select( 455 | uuid: Option, 456 | all: bool, 457 | description: &str, 458 | ) -> anyhow::Result> { 459 | let thing = match uuid { 460 | Some(uuid) => vec![T::having(uuid)?], 461 | None => match all { 462 | true => T::list(), 463 | false => vec![interactively_select(T::list(), description)?], 464 | }, 465 | }; 466 | Ok(thing) 467 | } 468 | 469 | pub fn unwrap_or_interactively_select( 470 | uuid: Option, 471 | description: &str, 472 | ) -> anyhow::Result { 473 | let thing = match uuid { 474 | Some(uuid) => T::having(uuid)?, 475 | None => interactively_select(T::list(), description)?, 476 | }; 477 | Ok(thing) 478 | } 479 | 480 | /// In `dialoguer` dialogs, the cursor is hidden and, if the user interrupts via Ctrl-C, 481 | /// not shown again (for reasons). This is a best effort attempt to show the cursor again 482 | /// in these situations. 483 | fn restore_cursor_on_ctrl_c() { 484 | ctrlc::set_handler(move || { 485 | let term = dialoguer::console::Term::stderr(); 486 | term.show_cursor().ok(); 487 | // Ctrl-C exit code = 130 488 | std::process::exit(130); 489 | }) 490 | .ok(); 491 | } 492 | -------------------------------------------------------------------------------- /src/device.rs: -------------------------------------------------------------------------------- 1 | //! Solo 2 devices, which may be in regular or bootloader mode. 2 | use core::sync::atomic::{AtomicBool, Ordering}; 3 | use std::collections::{BTreeMap, BTreeSet}; 4 | 5 | use anyhow::anyhow; 6 | use lpc55::bootloader::{Bootloader as Lpc55, UuidSelectable}; 7 | 8 | use crate::{apps::Admin, Firmware, Result, Select as _, Uuid, Version}; 9 | use core::fmt; 10 | 11 | pub mod ctap; 12 | pub mod pcsc; 13 | 14 | /// A [SoloKeys][solokeys] [Solo 2][solo2] device, in regular mode. 15 | /// 16 | /// From an inventory perspective, the core identifier is a UUID (16 bytes / 128 bits). 17 | /// 18 | /// From an interface perspective, either the CTAP or PCSC transport must be available. 19 | /// Therefore, it is an invariant that at least one is interface, and the device itself 20 | /// implements [Transport][crate::Transport]. 21 | /// 22 | /// [solokeys]: https://solokeys.com 23 | /// [solo2]: https://solo2.dev 24 | pub struct Solo2 { 25 | ctap: Option, 26 | pcsc: Option, 27 | locked: Option, 28 | uuid: Uuid, 29 | version: Version, 30 | } 31 | 32 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 33 | pub enum TransportPreference { 34 | Ctap, 35 | Pcsc, 36 | } 37 | 38 | static PREFER_CTAP: AtomicBool = AtomicBool::new(false); 39 | 40 | impl Solo2 { 41 | pub fn transport_preference() -> TransportPreference { 42 | if PREFER_CTAP.load(Ordering::SeqCst) { 43 | TransportPreference::Ctap 44 | } else { 45 | TransportPreference::Pcsc 46 | } 47 | } 48 | 49 | pub fn prefer_ctap() { 50 | PREFER_CTAP.store(true, Ordering::SeqCst); 51 | } 52 | 53 | pub fn prefer_pcsc() { 54 | PREFER_CTAP.store(false, Ordering::SeqCst); 55 | } 56 | 57 | /// NB: Requires user tap 58 | pub fn into_lpc55(self) -> Result { 59 | let mut solo2 = self; 60 | let uuid = solo2.uuid; 61 | // AGAIN: This requires user tap! 62 | let now = std::time::Instant::now(); 63 | Admin::select(&mut solo2)?.maintenance().ok(); 64 | drop(solo2); 65 | 66 | std::thread::sleep(std::time::Duration::from_secs(1)); 67 | let mut lpc55 = Lpc55::having(uuid); 68 | while lpc55.is_err() { 69 | if now.elapsed().as_secs() > 15 { 70 | return Err(anyhow!("User prompt to confirm maintenance timed out (or udev rules for LPC 55 mode missing)!")); 71 | } 72 | std::thread::sleep(std::time::Duration::from_secs(1)); 73 | lpc55 = Lpc55::having(uuid); 74 | } 75 | 76 | lpc55 77 | } 78 | } 79 | 80 | impl fmt::Debug for Solo2 { 81 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::result::Result<(), fmt::Error> { 82 | write!( 83 | f, 84 | "Solo 2 {:X} (CTAP: {:?}, PCSC: {:?}, Version: {} aka {})", 85 | &self.uuid.simple(), 86 | &self.ctap, 87 | &self.pcsc, 88 | &self.version.to_semver(), 89 | &self.version.to_calver(), 90 | ) 91 | } 92 | } 93 | 94 | impl fmt::Display for Solo2 { 95 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 96 | let transports = match (self.ctap.is_some(), self.pcsc.is_some()) { 97 | (true, true) => "CTAP+PCSC", 98 | (true, false) => "CTAP only", 99 | (false, true) => "PCSC only", 100 | _ => unreachable!(), 101 | }; 102 | let lock_status = match self.locked { 103 | Some(true) => ", locked", 104 | Some(false) => ", unlocked", 105 | None => "", 106 | }; 107 | write!( 108 | f, 109 | "Solo 2 {:X} ({}, firmware {}{})", 110 | &self.uuid.simple(), 111 | transports, 112 | &self.version().to_calver(), 113 | lock_status, 114 | ) 115 | } 116 | } 117 | 118 | impl UuidSelectable for Solo2 { 119 | fn try_uuid(&mut self) -> Result { 120 | Ok(self.uuid) 121 | } 122 | 123 | fn list() -> Vec { 124 | // iterator/lifetime woes avoiding the explicit for loop 125 | let mut ctaps = BTreeMap::new(); 126 | for mut device in ctap::list().into_iter() { 127 | if let Ok(uuid) = device.try_uuid() { 128 | ctaps.insert(uuid, device); 129 | } 130 | } 131 | // iterator/lifetime woes avoiding the explicit for loop 132 | let mut pcscs = BTreeMap::new(); 133 | for mut device in pcsc::list().into_iter() { 134 | if let Ok(uuid) = device.try_uuid() { 135 | pcscs.insert(uuid, device); 136 | } 137 | } 138 | 139 | let uuids = BTreeSet::from_iter(ctaps.keys().chain(pcscs.keys()).copied()); 140 | let mut devices = Vec::new(); 141 | for uuid in uuids.iter() { 142 | // a bit roundabout, but hey, "it works". 143 | let mut device = Self { 144 | ctap: ctaps.remove(uuid), 145 | pcsc: pcscs.remove(uuid), 146 | locked: None, 147 | uuid: *uuid, 148 | version: Version { 149 | major: 0, 150 | minor: 0, 151 | patch: 0, 152 | }, 153 | }; 154 | if let Ok(mut admin) = Admin::select(&mut device) { 155 | if let Ok(locked) = admin.locked() { 156 | device.locked = Some(locked); 157 | } 158 | } 159 | if let Ok(mut admin) = Admin::select(&mut device) { 160 | if let Ok(version) = admin.version() { 161 | device.version = version; 162 | devices.push(device); 163 | } 164 | } 165 | } 166 | devices 167 | } 168 | } 169 | 170 | impl Solo2 { 171 | /// UUID of device. 172 | pub fn uuid(&self) -> Uuid { 173 | self.uuid 174 | } 175 | 176 | /// Firmware version on device. 177 | pub fn version(&self) -> Version { 178 | self.version 179 | } 180 | 181 | pub fn as_ctap(&self) -> Option<&ctap::Device> { 182 | self.ctap.as_ref() 183 | } 184 | 185 | pub fn as_ctap_mut(&mut self) -> Option<&mut ctap::Device> { 186 | self.ctap.as_mut() 187 | } 188 | 189 | pub fn as_pcsc(&self) -> Option<&pcsc::Device> { 190 | self.pcsc.as_ref() 191 | } 192 | 193 | pub fn as_pcsc_mut(&mut self) -> Option<&mut pcsc::Device> { 194 | self.pcsc.as_mut() 195 | } 196 | } 197 | 198 | impl TryFrom for Solo2 { 199 | type Error = crate::Error; 200 | fn try_from(device: ctap::Device) -> Result { 201 | let mut device = device; 202 | let locked = Admin::select(&mut device)?.locked().ok(); 203 | let uuid = device.try_uuid()?; 204 | let version = Admin::select(&mut device)?.version()?; 205 | 206 | Ok(Solo2 { 207 | ctap: Some(device), 208 | pcsc: None, 209 | locked, 210 | uuid, 211 | version, 212 | }) 213 | } 214 | } 215 | 216 | impl TryFrom for Solo2 { 217 | type Error = crate::Error; 218 | fn try_from(device: pcsc::Device) -> Result { 219 | let mut device = device; 220 | let mut admin = Admin::select(&mut device)?; 221 | let uuid = admin.uuid()?; 222 | let version = admin.version()?; 223 | Ok(Solo2 { 224 | ctap: None, 225 | pcsc: Some(device), 226 | locked: None, 227 | uuid, 228 | version, 229 | }) 230 | } 231 | } 232 | 233 | /// A SoloKeys Solo 2 device, which may be in regular ([Solo2]) or update ([Lpc55]) mode. 234 | /// 235 | /// Not every [pcsc::Device] is a [Device]; currently if it reacts to the SoloKeys administrative 236 | /// [App][crate::apps::admin::App] with a valid UUID, then we treat it as such. 237 | // #[derive(Debug, Eq, PartialEq)] 238 | pub enum Device { 239 | Lpc55(Lpc55), 240 | Solo2(Solo2), 241 | } 242 | 243 | impl fmt::Display for Device { 244 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 245 | use Device::*; 246 | match self { 247 | Lpc55(lpc55) => write!(f, "LPC 55 {:X}", Uuid::from_u128(lpc55.uuid).simple()), 248 | Solo2(solo2) => solo2.fmt(f), 249 | } 250 | } 251 | } 252 | 253 | impl UuidSelectable for Device { 254 | fn try_uuid(&mut self) -> Result { 255 | Ok(self.uuid()) 256 | } 257 | 258 | fn list() -> Vec { 259 | let lpc55s = Lpc55::list().into_iter().map(Device::from); 260 | let solo2s = Solo2::list().into_iter().map(Device::from); 261 | lpc55s.chain(solo2s).collect() 262 | } 263 | 264 | /// Fails is if zero or >1 devices have the given UUID. 265 | fn having(uuid: Uuid) -> Result { 266 | let mut candidates: Vec = Self::list() 267 | .into_iter() 268 | .filter(|card| card.uuid() == uuid) 269 | .collect(); 270 | match candidates.len() { 271 | 0 => Err(anyhow!("No usable device has UUID {:X}", uuid.simple())), 272 | 1 => Ok(candidates.remove(0)), 273 | n => Err(anyhow!( 274 | "Multiple ({}) devices have UUID {:X}", 275 | n, 276 | uuid.simple() 277 | )), 278 | } 279 | } 280 | } 281 | 282 | impl Device { 283 | fn uuid(&self) -> Uuid { 284 | match self { 285 | Device::Lpc55(lpc55) => Uuid::from_u128(lpc55.uuid), 286 | Device::Solo2(solo2) => solo2.uuid(), 287 | } 288 | } 289 | 290 | /// NB: will hang if in bootloader mode and Solo 2 firmware does not 291 | /// come up cleanly. 292 | pub fn into_solo2(self) -> Result { 293 | match self { 294 | Device::Solo2(solo2) => Ok(solo2), 295 | Device::Lpc55(lpc55) => { 296 | let uuid = Uuid::from_u128(lpc55.uuid); 297 | lpc55.reboot(); 298 | drop(lpc55); 299 | 300 | std::thread::sleep(std::time::Duration::from_secs(1)); 301 | let mut solo2 = Solo2::having(uuid); 302 | while solo2.is_err() { 303 | std::thread::sleep(std::time::Duration::from_secs(1)); 304 | solo2 = Solo2::having(uuid); 305 | } 306 | 307 | solo2 308 | } 309 | } 310 | } 311 | 312 | /// NB: Requires user tap if device is in Solo2 mode. 313 | pub fn into_lpc55(self) -> Result { 314 | match self { 315 | Device::Lpc55(lpc55) => Ok(lpc55), 316 | Device::Solo2(solo2) => solo2.into_lpc55(), 317 | } 318 | } 319 | 320 | pub fn program( 321 | self, 322 | firmware: Firmware, 323 | skip_major_prompt: bool, 324 | progress: Option<&dyn Fn(usize)>, 325 | ) -> Result<()> { 326 | // If device is in Solo2 mode 327 | // - if firmware is major version bump, confirm with dialogue 328 | // - prompt user tap and get into bootloader 329 | // let device_version: Version = admin.version()?; 330 | // let new_version = firmware.version(); 331 | 332 | let lpc55 = match self { 333 | Device::Lpc55(lpc55) => lpc55, 334 | Device::Solo2(solo2) => { 335 | // If device is in Solo2 mode 336 | // - if firmware is major version bump, confirm with dialogue 337 | // - prompt user tap and get into Lpc55 bootloader 338 | 339 | info!("device fw version: {}", solo2.version.to_calver()); 340 | info!("new fw version: {}", firmware.version().to_calver()); 341 | 342 | if solo2.version > firmware.version() { 343 | println!("Firmware version on device higher than firmware version used."); 344 | println!("This would be rejected by the device."); 345 | return Err(anyhow!("Firmware rollback attempt")); 346 | } 347 | 348 | let fw_major = firmware.version().major; 349 | let major_version_bump = fw_major > solo2.version.major; 350 | if !skip_major_prompt && major_version_bump { 351 | use dialoguer::{theme, Confirm}; 352 | println!("Warning: This is is major update and it could risk breaking any current credentials on your key."); 353 | println!("Check latest release notes here to double check: https://github.com/solokeys/solo2/releases"); 354 | println!( 355 | "If you haven't used your key for anything yet, you can ignore this.\n" 356 | ); 357 | 358 | if Confirm::with_theme(&theme::ColorfulTheme::default()) 359 | .with_prompt("Continue?") 360 | .wait_for_newline(true) 361 | .interact()? 362 | { 363 | println!("Continuing"); 364 | } else { 365 | return Err(anyhow!("User aborted.")); 366 | } 367 | } 368 | 369 | println!("Tap button on key to confirm, or replug to abort..."); 370 | Self::Solo2(solo2).into_lpc55() 371 | .map_err(|e| { 372 | if std::env::consts::OS == "linux" { 373 | println!("\nIf you touched the key and the LED is off, you are likely missing udev rules for LPC 55 mode."); 374 | println!("Either run `sudo solo2 update`, or install "); 375 | println!("Specifically, you need this line:"); 376 | // SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="b000", TAG+="uaccess" 377 | println!(r#"SUBSYSTEM=="hidraw", ATTRS{{idVendor}}=="1209", ATTRS{{idProduct}}=="b000", TAG+="uaccess"#); 378 | println!(); 379 | } 380 | e 381 | })? 382 | } 383 | }; 384 | 385 | println!("LPC55 Bootloader detected. The LED should be off."); 386 | println!("Writing new firmware..."); 387 | firmware.write_to(&lpc55, progress); 388 | 389 | println!("Done. Rebooting key. The LED should turn back on."); 390 | Self::Lpc55(lpc55).into_solo2().map(drop) 391 | } 392 | } 393 | 394 | impl From for Device { 395 | fn from(lpc55: Lpc55) -> Device { 396 | Device::Lpc55(lpc55) 397 | } 398 | } 399 | 400 | impl From for Device { 401 | fn from(solo2: Solo2) -> Device { 402 | Device::Solo2(solo2) 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/device/ctap.rs: -------------------------------------------------------------------------------- 1 | //! Access to CTAPHID devices 2 | //! 3 | //! The most convenient entry point is the `list() -> Vec` function. 4 | 5 | use std::fmt; 6 | 7 | use hidapi; 8 | 9 | // use crate::{apps, Result, Uuid}; 10 | use crate::{Result, Uuid, UuidSelectable}; 11 | 12 | // This is not such a hot idea after all. 13 | // For instance, `lpc55-host` uses `hiadpi` as well, and with 14 | // use claiming it, will never get an instance. 15 | // 16 | // static SESSION: Lazy> = Lazy::new(|| { 17 | // hidapi::HidApi::new().ok()//map_err(|e| e.into()) 18 | // }); 19 | 20 | const FIDO_USAGE_PAGE: u16 = 0xF1D0; 21 | const FIDO_USAGE: u16 = 0x1; 22 | 23 | /// A session with the PCSC service (running `pcscd` instance) 24 | pub struct Session { 25 | session: hidapi::HidApi, 26 | } 27 | 28 | #[derive(Clone, Debug, Eq, PartialEq)] 29 | pub struct Info { 30 | /// the unique identifier for access on all platforms 31 | pub path: std::ffi::CString, 32 | pub vid: u16, 33 | pub pid: u16, 34 | pub serial: String, 35 | pub manufacturer: String, 36 | pub product: String, 37 | } 38 | 39 | // #[derive(Clone)] 40 | pub struct Device { 41 | pub(crate) device: hidapi::HidDevice, 42 | info: Info, 43 | } 44 | 45 | pub fn list() -> Vec { 46 | Session::new() 47 | .map(|session| session.devices()) 48 | .unwrap_or_else(|_| vec![]) 49 | } 50 | 51 | impl From for Info { 52 | fn from(info: hidapi::DeviceInfo) -> Self { 53 | Self { 54 | path: info.path().to_owned(), 55 | vid: info.vendor_id(), 56 | pid: info.product_id(), 57 | manufacturer: info.manufacturer_string().unwrap_or("").to_string(), 58 | product: info.product_string().unwrap_or("").to_string(), 59 | serial: info.serial_number().unwrap_or("").to_string(), 60 | } 61 | } 62 | } 63 | 64 | impl Device { 65 | pub fn info(&self) -> &Info { 66 | &self.info 67 | } 68 | } 69 | 70 | impl fmt::Debug for Device { 71 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 72 | self.info.fmt(f) 73 | } 74 | } 75 | 76 | // // we're not trying to be thread safe here. 77 | // // recreating the not exported lock to prevent lockup. 78 | // // in any case, the hidapi-rs API sucks... 79 | // static LOCK: bool = false; 80 | 81 | // impl Drop for Session { 82 | // fn drop(&mut self) { 83 | // unsafe { LOCK = false } 84 | // } 85 | // } 86 | 87 | impl Session { 88 | /// TODO: Whereas in the PCSC case, the daemon may not be running 89 | /// e.g. in Linux, here the RW lock may already be taken. 90 | /// 91 | /// This seems dissimilar in semantics, so maybe there should not 92 | /// be a common name. 93 | pub fn is_available() -> bool { 94 | Self::new().is_ok() 95 | } 96 | 97 | pub fn new() -> Result { 98 | Ok(Self { 99 | session: hidapi::HidApi::new()?, 100 | }) 101 | } 102 | 103 | pub fn infos(&self) -> Vec { 104 | self.session 105 | .device_list() 106 | .filter(|info| info.usage_page() == FIDO_USAGE_PAGE && info.usage() == FIDO_USAGE) 107 | .map(|info| info.clone().into()) 108 | .collect() 109 | } 110 | 111 | pub fn devices(&self) -> Vec { 112 | self.infos() 113 | .into_iter() 114 | .filter_map(|info| { 115 | self.session 116 | .open_path(&info.path) 117 | .map(|device| Device { device, info }) 118 | .ok() 119 | }) 120 | .collect() 121 | } 122 | } 123 | 124 | impl UuidSelectable for Device { 125 | /// We'd kinda like to use the `admin-app` 126 | fn try_uuid(&mut self) -> Result { 127 | let maybe_uuid = hex::decode(&self.info().serial)?; 128 | Ok(Uuid::from_slice(&maybe_uuid)?) 129 | } 130 | 131 | fn list() -> Vec { 132 | list() 133 | } 134 | 135 | // fn having(uuid: Uuid) -> Result { 136 | // let mut candidates: Vec = Self::list() 137 | // .into_iter() 138 | // .filter(|card| card.uuid() == uuid) 139 | // .collect(); 140 | // match candidates.len() { 141 | // 0 => Err(anyhow!("No usable device has UUID {:X}", uuid.to_simple())), 142 | // 1 => Ok(candidates.remove(0)), 143 | // n => Err(anyhow!( 144 | // "Multiple ({}) devices have UUID {:X}", 145 | // n, 146 | // uuid.to_simple() 147 | // )), 148 | // } 149 | // } 150 | } 151 | -------------------------------------------------------------------------------- /src/device/pcsc.rs: -------------------------------------------------------------------------------- 1 | //! Device interface to Solo 2 devices. 2 | //! 3 | //! Might grow into an independent more idiomatic replacement for [pcsc][pcsc], 4 | //! which is currently used internally. 5 | //! 6 | //! A long-shot idea is to rewrite the entire PCSC/CCID stack in pure Rust, 7 | //! restricting to "things that are directly attached via USB", i.e. ICCD. 8 | //! 9 | //! Having that would allow doing the essentially same thing over HID (instead of 10 | //! reinventing a custom HID class), or even a custom USB class (circumventing 11 | //! the WebUSB/WebHID restrictions). Also we could easily upgrade to USB 2.0 HS, 12 | //! instead of being stuck with full-speed USB only. 13 | //! 14 | //! [pcsc]: https://docs.rs/pcsc/ 15 | //! 16 | 17 | use core::fmt; 18 | use lpc55::bootloader::UuidSelectable; 19 | 20 | use pcsc::{Protocols, Scope, ShareMode}; 21 | 22 | use crate::{apps::admin::App as Admin, Result, Select as _, Uuid}; 23 | 24 | /// A session with the PCSC service (running `pcscd` instance) 25 | pub struct Session { 26 | session: pcsc::Context, 27 | } 28 | 29 | #[derive(Clone, Debug, Eq, PartialEq)] 30 | pub struct Info { 31 | /// the unique identifier for access on all platforms 32 | pub name: String, 33 | 34 | pub serial: String, 35 | // pub kind: String, 36 | pub vendor: String, 37 | pub version: String, 38 | pub atr: String, 39 | } 40 | 41 | /// An [ICCD][iccd] smartcard. 42 | /// 43 | /// This object does not necessarily have a UUID, which is a Trussed/SoloKeys thing. 44 | /// 45 | /// [iccd]: https://www.usb.org/document-library/smart-card-iccd-version-10 46 | // #[derive(Clone)] 47 | pub struct Device { 48 | pub(crate) device: pcsc::Card, 49 | pub name: String, 50 | } 51 | 52 | pub fn list() -> Vec { 53 | Session::new() 54 | .map(|session| session.devices()) 55 | .unwrap_or_else(|_| vec![]) 56 | } 57 | 58 | impl Session { 59 | /// The environment may not have an accessible PCSC service running. 60 | /// 61 | /// This performs the check. 62 | pub fn is_available() -> bool { 63 | Self::new().is_ok() 64 | } 65 | 66 | /// Establishes a user session with the PCSC service, if available. 67 | pub fn new() -> Result { 68 | Ok(Self { 69 | session: pcsc::Context::establish(Scope::User)?, 70 | }) 71 | } 72 | 73 | /// Get a connection to a smartcard by name. 74 | /// 75 | /// We prefer to use normal Rust types for the smartcard names, meaning 76 | /// that cards with weird non-UTF8 names won't be addressable. 77 | pub fn connect(&self, info: &str) -> Result { 78 | let cstring = std::ffi::CString::new(info.as_bytes()).unwrap(); 79 | Ok(Device { 80 | device: self 81 | .session 82 | .connect(&cstring, ShareMode::Shared, Protocols::ANY)?, 83 | name: info.to_string(), 84 | }) 85 | } 86 | 87 | /// List all smartcards names in the system. 88 | /// 89 | /// We prefer to use normal Rust types for the smartcard names, meaning 90 | /// that cards with weird non-UTF8 names won't be addressable. 91 | pub fn infos(&self) -> Result> { 92 | let mut card_names_buffer = vec![0; self.session.list_readers_len()?]; 93 | let infos = self 94 | .session 95 | .list_readers(&mut card_names_buffer)? 96 | .map(|name_cstr| name_cstr.to_string_lossy().to_string()) 97 | .filter_map(|name| self.connect(&name).ok().map(|device| (name, device))) 98 | .map(|(name, device)| { 99 | Info { 100 | name, 101 | // kind: device.attribute(pcsc::Attribute::VendorIfdType), 102 | vendor: device.attribute(pcsc::Attribute::VendorName), 103 | serial: device.attribute(pcsc::Attribute::VendorIfdSerialNo), 104 | version: device.attribute(pcsc::Attribute::VendorIfdVersion), 105 | atr: device.attribute(pcsc::Attribute::AtrString), 106 | } 107 | }) 108 | .collect(); 109 | Ok(infos) 110 | } 111 | 112 | /// Get all of the usable smartcards in the system. 113 | pub fn devices(&self) -> Vec { 114 | self.infos() 115 | .unwrap_or_else(|_| vec![]) 116 | .iter() 117 | .filter_map(|info| self.connect(&info.name).ok()) 118 | .collect() 119 | } 120 | } 121 | 122 | impl Device { 123 | // serial: CStr 124 | // vendor: CStr 125 | // version: [u8] (?) 126 | // atr: [u8] 127 | fn attribute(&self, attribute: pcsc::Attribute) -> String { 128 | let attribute = self.device.get_attribute_owned(attribute).ok(); 129 | attribute 130 | .map(|attribute| String::from_utf8_lossy(&attribute).to_string()) 131 | .map(|mut attribute| { 132 | while let Some('\0') = attribute.chars().last() { 133 | attribute.truncate(attribute.len() - 1); 134 | } 135 | attribute 136 | }) 137 | .unwrap_or_else(|| "".to_string()) 138 | } 139 | } 140 | 141 | impl fmt::Debug for Device { 142 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::result::Result<(), fmt::Error> { 143 | write!(f, "{}", &self.name) 144 | } 145 | } 146 | 147 | impl fmt::Display for Device { 148 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> core::result::Result<(), fmt::Error> { 149 | fmt::Debug::fmt(self, f) 150 | } 151 | } 152 | 153 | impl UuidSelectable for Device { 154 | fn try_uuid(&mut self) -> Result { 155 | let mut admin = Admin::select(self)?; 156 | admin.uuid() 157 | } 158 | 159 | /// Infallible method listing all usable smartcards. 160 | /// 161 | /// To find out more about issues along the way, construct the session, 162 | /// list the smartcards, attach them, etc. 163 | fn list() -> Vec { 164 | let session = match Session::new() { 165 | Ok(session) => session, 166 | _ => return Vec::default(), 167 | }; 168 | session.devices() 169 | } 170 | 171 | // fn having(uuid: Uuid) -> Result { 172 | // use super::Device as ToBeRenamed; 173 | // let device = ToBeRenamed::having(uuid)?; 174 | // match device { 175 | // ToBeRenamed::Solo2(solo2) => Ok(solo2.into_inner()), 176 | // _ => Err(anyhow!( 177 | // "No smartcard found with UUID {:X}", 178 | // uuid.to_simple() 179 | // )), 180 | // } 181 | // } 182 | } 183 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error and Result types. 2 | //! 3 | //! Currently we just use `anyhow`. In time, library errors should be modeled properly, 4 | //! and implemented e.g. using `thiserror`. 5 | //! 6 | pub type Error = anyhow::Error; 7 | pub type Result = anyhow::Result; 8 | -------------------------------------------------------------------------------- /src/firmware.rs: -------------------------------------------------------------------------------- 1 | //! Signed firmware releases for Solo 2 devices. 2 | //! 3 | use anyhow::anyhow; 4 | /// Version of a firmware 5 | pub use lpc55::secure_binary::Version; 6 | 7 | use crate::Result; 8 | 9 | pub mod github; 10 | 11 | #[derive(Clone, Eq, PartialEq)] 12 | pub struct Firmware { 13 | content: Vec, 14 | version: Version, 15 | } 16 | 17 | impl Firmware { 18 | pub fn version(&self) -> Version { 19 | self.version 20 | } 21 | 22 | // TODO: To implement this, upstream `lpc55` library needs to gain support 23 | // for decoding signed SB2.1 files. 24 | // 25 | // /// Returns Ok if the firmware has a valid signature. 26 | // pub fn verify(&self) -> Result<()> { 27 | // } 28 | 29 | pub fn write_to<'a>( 30 | &self, 31 | bootloader: &lpc55::Bootloader, 32 | progress: Option<&'a dyn Fn(usize)>, 33 | ) { 34 | bootloader.receive_sb_file(&self.content, progress); 35 | } 36 | 37 | /// This is not the best we can do; should instead verify the Firmware data is valid, and the signatures verify against [`R1`][crate::pki::Authority::R1]. 38 | pub fn verify_hexhash(&self, sha256_hex_hash: &str) -> Result<()> { 39 | use sha2::{Digest, Sha256}; 40 | 41 | let mut hasher = Sha256::new(); 42 | hasher.update(&self.content); 43 | 44 | (hex::encode(hasher.finalize()) == sha256_hex_hash) 45 | .then(|| ()) 46 | .ok_or_else(|| anyhow!("Sha2 hash on downloaded firmware did not verify!")) 47 | } 48 | 49 | pub fn new(content: Vec) -> Result { 50 | let header_bytes = &content.as_slice()[..96]; 51 | let header = lpc55::secure_binary::Sb2Header::from_bytes(header_bytes)?; 52 | 53 | Ok(Self { 54 | content, 55 | version: header.product_version(), 56 | }) 57 | } 58 | 59 | pub fn read_from_file>(path: P) -> Result { 60 | Self::new(std::fs::read(path)?) 61 | } 62 | 63 | pub fn download_latest() -> Result { 64 | let specs = github::Release::fetch_spec()?; 65 | specs.fetch_firmware() 66 | } 67 | 68 | pub fn len(&self) -> usize { 69 | self.content.len() 70 | } 71 | 72 | pub fn is_empty(&self) -> bool { 73 | self.len() == 0 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/firmware/github.rs: -------------------------------------------------------------------------------- 1 | //! Micro-client to fetch GitHub release assets 2 | //! 3 | //! We'd use one of the many existing clients, if only there were a non-async one. 4 | use std::io::Read as _; 5 | 6 | use anyhow::anyhow; 7 | use serde_json::{from_value, Value}; 8 | 9 | use super::{Firmware, Result}; 10 | 11 | /// An asset that can be downloaded from GitHub 12 | #[derive(Clone, Debug)] 13 | pub struct AssetSpec { 14 | pub name: String, 15 | pub url: String, 16 | pub len: usize, 17 | } 18 | 19 | impl TryFrom for AssetSpec { 20 | type Error = crate::Error; 21 | fn try_from(value: Value) -> Result { 22 | let name: String = from_value(value["name"].clone())?; 23 | let url: String = from_value(value["browser_download_url"].clone())?; 24 | let len: usize = from_value(value["size"].clone())?; 25 | Ok(Self { name, url, len }) 26 | } 27 | } 28 | 29 | impl AssetSpec { 30 | /// Attempt to download an asset from GitHub 31 | pub fn fetch_asset(&self) -> Result> { 32 | let reader = ureq::get(&self.url) 33 | .set("User-Agent", "solo2-cli") 34 | .call()? 35 | .into_reader(); 36 | 37 | let pb = indicatif::ProgressBar::new(self.len as _); 38 | let mut buffer = Vec::new(); 39 | pb.wrap_read(reader).read_to_end(&mut buffer)?; 40 | if self.len == buffer.len() { 41 | Ok(buffer) 42 | } else { 43 | Err(anyhow!("Truncated download from {}", &self.url)) 44 | } 45 | } 46 | } 47 | 48 | /// A very specific set of assets on GitHub, presumed to contain 49 | /// an SB2.1 firmware file and a corresponding SHA-256 digest of the contents. 50 | #[derive(Clone, Debug)] 51 | pub struct Release { 52 | pub tag: String, 53 | pub assets: Vec, 54 | } 55 | 56 | impl Release { 57 | const URL_LATEST: &'static str = "https://api.github.com/repos/solokeys/solo2/releases/latest"; 58 | const HASH_TEMPLATE: &'static str = "solo2-firmware-{}.sha2"; 59 | const SB2_TEMPLATE: &'static str = "solo2-firmware-{}.sb2"; 60 | 61 | pub fn fetch_spec() -> Result { 62 | let response: Value = ureq::get(Self::URL_LATEST) 63 | .set("User-Agent", "solo2-cli") 64 | .call()? 65 | .into_json()?; 66 | let tag: String = from_value(response["tag_name"].clone())?; 67 | 68 | let assets: Vec = from_value(response["assets"].clone())?; 69 | let assets: Vec = assets 70 | .into_iter() 71 | .map(AssetSpec::try_from) 72 | .filter_map(|x| x.ok()) 73 | .collect(); 74 | 75 | Ok(Self { tag, assets }) 76 | } 77 | 78 | pub fn fetch_hash(&self) -> Result { 79 | let spec = self.assets.iter() 80 | // poor man's format! 81 | .find(|asset| asset.name == Self::HASH_TEMPLATE.replace("{}", &self.tag)) 82 | .ok_or_else(|| anyhow!("Unable to find hash digest in latest SoloKeys release. Please open ticket on solokeys.com/solo2 or contact hello@solokeys.com."))?; 83 | 84 | let hash_data = &spec.fetch_asset()?; 85 | let hash = std::str::from_utf8(hash_data) 86 | .map_err(|_| anyhow!("Invalid hash digest in latest SoloKeys release. Please open ticket on solokeys.com/solo2 or contact hello@solokeys.com."))?; 87 | let hash = hash.split_whitespace().next().unwrap().to_string(); 88 | Ok(hash) 89 | } 90 | 91 | pub fn fetch_firmware(&self) -> Result { 92 | let spec = self.assets.iter() 93 | // poor man's format! 94 | .find(|asset| asset.name == Self::SB2_TEMPLATE.replace("{}", &self.tag)) 95 | .ok_or_else(|| anyhow!("Unable to find firmware SB2 file in latest SoloKeys release. Please open ticket on solokeys.com/solo2 or contact hello@solokeys.com."))?; 96 | 97 | let firmware = Firmware::new(spec.fetch_asset()?)?; 98 | firmware.verify_hexhash(&self.fetch_hash()?)?; 99 | Ok(firmware) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings, trivial_casts, unused_qualifications)] 2 | 3 | #[macro_use] 4 | extern crate log; 5 | 6 | pub use lpc55::{uuid::Uuid, UuidSelectable}; 7 | 8 | pub mod apps; 9 | pub use apps::{admin::App as Admin, Select}; 10 | pub mod device; 11 | pub use device::{Device, Solo2}; 12 | pub mod error; 13 | pub use error::{Error, Result}; 14 | pub mod firmware; 15 | pub use firmware::{Firmware, Version}; 16 | pub mod pki; 17 | pub mod transport; 18 | pub use transport::Transport; 19 | -------------------------------------------------------------------------------- /src/pki.rs: -------------------------------------------------------------------------------- 1 | //! # Solo 2 PKI 2 | //! 3 | //! Each Solo 2 device has a unique identity, its 128 bit UUID, which is set by NXP in read-only 4 | //! memory. Backing this up is a PKI infrastructure, hosted under , and 5 | //! explained in the following. 6 | //! 7 | //! At the root is [`R1`][r1], an offline RSA-4096 keypair with a self-signed certificate. 8 | //! It can be obtained via . 9 | //! 10 | //! ## Trussed certificates 11 | //! 12 | //! Under `R1` sit the intermediate Trussed certificate authorities [`T1`][t1] and [`T2`][t2], which have 13 | //! an Ed255 and P256 keypair, respectively. They are signed by `R1` with pathlen = 1. 14 | //! The certificates are available via: 15 | //! - 16 | //! - 17 | //! 18 | //! We have two since there is use for both NIST-based (P256) and djb-based (Ed255/X255) certificate chains. 19 | //! 20 | //! Each Solo 2 device then has three embedded certificates, backed by three keypairs which are 21 | //! generated on-device during production, after the device has been locked. In their X509v3 22 | //! extension with OID `1.3.6.1.4.1.54053.1.1` they contain the UUID of the device. 23 | //! The certificates are as follows: 24 | //! 25 | //! - The Ed255 Trussed device leaf certificate (pathlen = 0), signed by `T1`, with key usages 26 | //! `Certificate Sign` and `CRL Sign`. 27 | //! - The X255 Trussed device entity certificate, signed by `T1`, with key usage `Key Agreement` 28 | //! - the P256 Trussed device leaf certificate (pathlen = 0), signed by `T2`, with key usages 29 | //! `Certificate Sign`, `CRL Sign`, and `Key Agreement`. 30 | //! 31 | //! ## Firmware certificates 32 | //! 33 | //! The NXP bootloader's secure boot mechanism has space for four certificates, which may be revoked 34 | //! individually. Correspondingly, we have four entity certificates `S1`, `S2`, `S3`, `S4`, split 35 | //! designated as active/backup and US/CH development centers. They are available from 36 | //! , etc. For firmware signing purposes, they are self-signed; from a PKI 37 | //! perspective we additionally cross-certified them via `R1`. 38 | //! 39 | //! ## FIDO certificates 40 | //! 41 | //! There is an intermediate CA called `F1`, which signs the batch certificates for FIDO metadata, 42 | //! which are used during device attestation. These batch certificates must be model specific, we 43 | //! have prepared one each for Solo 2A+ (USB-A + NFC), Solo 2C+ (USB-C + NFC), Solo 2A (USB-A 44 | //! only), Solo 2C (USB-C only). 45 | //! 46 | //! [r1]: https://s2pki.net/i/r1/r1.txt 47 | //! [t1]: https://s2pki.net/i/t1/t1.txt 48 | //! [t2]: https://s2pki.net/i/t2/t2.txt 49 | 50 | #[cfg(feature = "dev-pki")] 51 | pub mod dev; 52 | 53 | use std::io::Read as _; 54 | 55 | pub use x509_parser::certificate::X509Certificate; 56 | 57 | use crate::Result; 58 | 59 | pub const S2PKI_TLD: &str = "s2pki.net"; 60 | 61 | /// Certificate authorities for Solo 2 PKI. 62 | /// 63 | /// For more information, read [pki][crate::pki] module level documentation. 64 | #[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] 65 | pub enum Authority { 66 | /// The root RSA-4096 certificate authority 67 | R1, 68 | /// The Trussed intermediate CA for Ed255 + X255 69 | T1, 70 | /// The Trussed intermediate CA for P256 71 | T2, 72 | /// Solo 2 firmware signing authority US active 73 | S1, 74 | /// Solo 2 firmware signing authority US backup 75 | S2, 76 | /// Solo 2 firmware signing authority CH active 77 | S3, 78 | /// Solo 2 firmware signing authority CH backup 79 | S4, 80 | /// The SoloKeys FIDO intermediate CA (P256) 81 | F1, 82 | /// Current Solo 2A+ FIDO batch entity certificate 83 | B1, 84 | /// Current Solo 2C+ FIDO batch entity certificate 85 | B2, 86 | /// Current Solo 2A FIDO batch entity certificate 87 | B3, 88 | /// Current Solo 2C FIDO batch entity certificate 89 | B4, 90 | } 91 | 92 | impl Authority { 93 | pub fn name(&self) -> String { 94 | format!("{:?}", self) 95 | } 96 | } 97 | 98 | impl TryFrom<&str> for Authority { 99 | type Error = crate::Error; 100 | fn try_from(name: &str) -> Result { 101 | Ok(match name.to_uppercase().as_str() { 102 | "B1" => Authority::B1, 103 | "B2" => Authority::B2, 104 | "B3" => Authority::B3, 105 | "B4" => Authority::B4, 106 | "F1" => Authority::F1, 107 | "R1" => Authority::R1, 108 | "S1" => Authority::S1, 109 | "S2" => Authority::S2, 110 | "S3" => Authority::S3, 111 | "S4" => Authority::S4, 112 | "T1" => Authority::T1, 113 | "T2" => Authority::T2, 114 | _ => return Err(anyhow::anyhow!("Unknown authority name {}", name)), 115 | }) 116 | } 117 | } 118 | 119 | /// An owned wrapper for `x509_parser::certificate::X509Certificate`. 120 | /// 121 | /// In `lpc55`, we enforce RSA signatures... 122 | #[derive(Clone, Debug)] 123 | pub struct Certificate { 124 | der: Vec, 125 | } 126 | 127 | impl Certificate { 128 | pub fn try_from_der(der: &[u8]) -> Result { 129 | use x509_parser::prelude::FromDer; 130 | X509Certificate::from_der(der)?; 131 | Ok(Self { der: der.to_vec() }) 132 | } 133 | 134 | pub fn der(&self) -> &[u8] { 135 | &self.der 136 | } 137 | 138 | pub fn certificate(&self) -> X509Certificate<'_> { 139 | use x509_parser::prelude::FromDer; 140 | X509Certificate::from_der(&self.der).unwrap().1 141 | } 142 | } 143 | 144 | /// Canonical URI for Authority Information Access (i.e., where to get the certificate in DER 145 | /// format). 146 | /// 147 | /// This is `http://i.s2pki.net/{lower case CA name}/`. 148 | /// 149 | /// There are also other formats available, e.g., `https://s2pki.net/i/r1/r1.{der,pem,txt}`. 150 | pub fn authority_information_access(authority: Authority) -> String { 151 | format!("http://i.{}/{:?}/", S2PKI_TLD, authority).to_lowercase() 152 | } 153 | 154 | /// Download the certificate of an [`Authority`]. 155 | pub fn fetch_certificate(authority: Authority) -> Result { 156 | let mut der = Vec::new(); 157 | ureq::get(&authority_information_access(authority)) 158 | .call()? 159 | .into_reader() 160 | .read_to_end(&mut der)?; 161 | Certificate::try_from_der(&der) 162 | } 163 | 164 | #[cfg(all(test, feature = "network-tests"))] 165 | mod test { 166 | use super::*; 167 | 168 | #[test] 169 | fn urls() { 170 | assert_eq!( 171 | authority_information_access(Authority::R1), 172 | "http://i.s2pki.net/r1/" 173 | ); 174 | } 175 | 176 | #[test] 177 | fn r1() { 178 | let r1 = fetch_certificate(Authority::R1).unwrap(); 179 | assert_eq!(r1.der(), include_bytes!("../data/r1.der"),); 180 | } 181 | 182 | #[test] 183 | fn t1t2() { 184 | let r1 = fetch_certificate(Authority::R1).unwrap(); 185 | // lifetimes... 186 | let r1 = r1.certificate(); 187 | let r1_pubkey = Some(r1.public_key()); 188 | let t1 = fetch_certificate(Authority::T1).unwrap(); 189 | let t2 = fetch_certificate(Authority::T2).unwrap(); 190 | assert!(t1.certificate().verify_signature(r1_pubkey).is_ok()); 191 | assert!(t2.certificate().verify_signature(r1_pubkey).is_ok()); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/pki/dev.rs: -------------------------------------------------------------------------------- 1 | //! Overly simplistic "PKI" to enable all functionality of Solo 2 apps. 2 | //! 3 | //! In particular, there is no root CA, no use of hardware keys / PKCS #11, 4 | //! no revocation, etc. etc. 5 | 6 | use rand_core::{OsRng, RngCore}; 7 | 8 | #[repr(u16)] 9 | pub enum Kind { 10 | Shared = 1, 11 | Symmetric = 2, 12 | Symmetric32Nonce = 3, 13 | Ed255 = 4, 14 | P256 = 5, 15 | X255 = 6, 16 | } 17 | 18 | fn trussed_serialized_key(sensitive: bool, kind: Kind, material: &[u8]) -> Vec { 19 | let mut buffer = Vec::new(); 20 | let mut flags = 0u16; 21 | // we shouldn't be in the business of injecting "local" keys 22 | // if local { flags |= 1; } 23 | if sensitive { 24 | flags |= 2; 25 | } 26 | 27 | buffer.extend_from_slice(flags.to_be_bytes().as_ref()); 28 | buffer.extend_from_slice((kind as u16).to_be_bytes().as_ref()); 29 | buffer.extend_from_slice(material); 30 | 31 | buffer 32 | } 33 | 34 | pub fn generate_selfsigned_fido() -> ([u8; 16], [u8; 36], String, rcgen::Certificate) { 35 | let alg = &rcgen::PKCS_ECDSA_P256_SHA256; 36 | 37 | // 0. generate AAGUID 38 | let mut aaguid = [0u8; 16]; 39 | OsRng.fill_bytes(&mut aaguid); 40 | 41 | // 1. generate a keypair, massage into Trussed keystore binary format 42 | let keypair = rcgen::KeyPair::generate(alg).unwrap(); 43 | 44 | let key_pkcs8 = keypair.serialize_der(); 45 | let key_pem = keypair.serialize_pem(); 46 | 47 | let key_info: p256::pkcs8::PrivateKeyInfo = key_pkcs8.as_slice().try_into().unwrap(); 48 | let secret_key: [u8; 32] = p256::SecretKey::try_from(key_info) 49 | .unwrap() 50 | .to_be_bytes() 51 | .try_into() 52 | .unwrap(); 53 | 54 | let sensitive = true; 55 | let kind = Kind::P256; 56 | let key_trussed: [u8; 36] = trussed_serialized_key(sensitive, kind, secret_key.as_ref()) 57 | .try_into() 58 | .unwrap(); 59 | 60 | // 2. generate self-signed certificate 61 | 62 | let mut tbs = rcgen::CertificateParams::default(); 63 | tbs.alg = alg; 64 | tbs.serial_number = Some(OsRng.next_u64()); 65 | 66 | let now = time::OffsetDateTime::now_utc(); 67 | tbs.not_before = now; 68 | tbs.not_after = now + time::Duration::days(50 * 365); 69 | 70 | // https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements 71 | let mut subject = rcgen::DistinguishedName::new(); 72 | subject.push(rcgen::DnType::CountryName, "AQ"); 73 | subject.push(rcgen::DnType::OrganizationName, "Example Vendor"); 74 | subject.push( 75 | rcgen::DnType::OrganizationalUnitName, 76 | "Authenticator Attestation", 77 | ); 78 | subject.push(rcgen::DnType::CommonName, "example.com"); 79 | tbs.distinguished_name = subject; 80 | 81 | tbs.key_pair = Some(keypair); 82 | tbs.is_ca = rcgen::IsCa::NoCa; 83 | // TODO: check if `authorityKeyIdentifier=keyid,issuer` is both needed 84 | // NB: for self-signed, rcgen does not follow this instruction 85 | tbs.use_authority_key_identifier_extension = true; 86 | 87 | let extensions = vec![ 88 | // AAGUID 89 | // https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements 90 | // id-fido-gen-ce-aaguid 91 | // Not technically necessary, as we don't have "multiple models", just a new random key + cert. 92 | rcgen::CustomExtension::from_oid_content( 93 | // FIDO's PEN + 1.1.4 94 | &[1, 3, 6, 1, 4, 1, 45724, 1, 1, 4], 95 | yasna::construct_der(|writer| writer.write_bytes(aaguid.as_ref())), 96 | ), 97 | ]; 98 | // cf. the following, this is not necessary 99 | // https://groups.google.com/a/fidoalliance.org/g/fido-dev/c/pfuWJvM-OQQ 100 | // https://github.com/w3c/webauthn/issues/817 101 | // // Transports 102 | // extensions.push(rcgen::CustomExtension::from_oid_content( 103 | // // FIDO's PEN + 2.1.1 104 | // &[1, 3, 6, 1, 4, 1, 45724, 2, 1, 1], 105 | // // https://fidoalliance.org/fido-technote-detailed-look-fido-u2f-v1-2/#:~:text=transports-,FIDOU2FTransports%20%3A%3A%3D,uSBInternal(4,-Raw 106 | // { 107 | // let mut bits = 0u8; 108 | // // USB 109 | // bits |= 1 << 2; 110 | // if nfc { 111 | // bits |= 1 << 3; 112 | // } 113 | // yasna::construct_der(|writer| writer.write_bitvec_bytes(&[bits], 4)) 114 | // }, 115 | // )); 116 | tbs.custom_extensions = extensions; 117 | 118 | let cert = rcgen::Certificate::from_params(tbs).unwrap(); 119 | 120 | (aaguid, key_trussed, key_pem, cert) 121 | } 122 | -------------------------------------------------------------------------------- /src/str4d_dev_pki.rs: -------------------------------------------------------------------------------- 1 | //! Overly simplistic "PKI" to enable all functionality of Solo 2 apps. 2 | //! 3 | //! In particular, there is no root CA, no use of hardware keys / PKCS #11, 4 | //! no revocation, etc. etc. 5 | 6 | use rand_core::{RngCore, OsRng}; 7 | use x509::RelativeDistinguishedName; 8 | 9 | struct MockAlgorithmId; 10 | 11 | impl AlgorithmIdentifier for MockAlgorithmId { 12 | type AlgorithmOid = &'static [u64]; 13 | 14 | fn algorithm(&self) -> Self::AlgorithmOid { 15 | &[1, 1, 1, 1] 16 | } 17 | 18 | fn parameters( 19 | &self, 20 | w: cookie_factory::WriteContext, 21 | ) -> cookie_factory::GenResult { 22 | Ok(w) 23 | } 24 | } 25 | 26 | pub fn generate_fido(nfc: bool) { 27 | let mut serial = [0u8; 20]; 28 | OsRng.fill_bytes(serial.as_mut()); 29 | 30 | let algorithm = 31 | let issuer = [ 32 | RelativeDistinguishedName::country("AQ"), 33 | RelativeDistinguishedName::organization("Fake Organization"), 34 | RelativeDistinguishedName::organizational_unit("Fake FIDO Attestation"), 35 | RelativeDistinguishedName::common_name("example.com"), 36 | ]; 37 | 38 | let not_before = chrono::Utc::now(); 39 | let not_after = None, 40 | 41 | let extensions = []; 42 | 43 | let tbs = x509::write::tbs_certificate( 44 | &serial, 45 | &issuer, 46 | ¬_before, 47 | ¬_after, 48 | &extensions; 49 | 50 | ); 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/transport.rs: -------------------------------------------------------------------------------- 1 | //! Partial abstraction (to-be-improved) 2 | 3 | use crate::{Result, Solo2}; 4 | 5 | pub mod ctap; 6 | pub mod pcsc; 7 | 8 | // Applications that only implement single-byte instructions 9 | // with byte slice responses can be implemented on this 10 | // transport abstraction (over CTAP and PCSC). 11 | pub trait Transport { 12 | /// The minimal higher-level interface to a transport. 13 | fn call(&mut self, instruction: u8, data: &[u8]) -> Result>; 14 | /// Shortcut for when no data is needed. 15 | fn instruct(&mut self, instruction: u8) -> Result> { 16 | self.call(instruction, &[]) 17 | } 18 | /// Call in the funny ISO 7816 fashion with three extra parameters. 19 | /// Note that only the PCSC transport implements this, not the CTAP transport. 20 | fn call_iso( 21 | &mut self, 22 | class: u8, 23 | instruction: u8, 24 | p1: u8, 25 | p2: u8, 26 | data: &[u8], 27 | ) -> Result>; 28 | fn select(&mut self, aid: Vec) -> Result<()>; 29 | } 30 | 31 | impl Transport for ctap::Device { 32 | fn call(&mut self, instruction: u8, data: &[u8]) -> Result> { 33 | use ctap::{Code, Command}; 34 | let init = self.init()?; 35 | let command = Command::new(Code::from(instruction)).with_data(data); 36 | ctap::Device::call(self, init.channel, command) 37 | } 38 | 39 | fn call_iso(&mut self, _: u8, _: u8, _: u8, _: u8, _: &[u8]) -> Result> { 40 | Err(anyhow::anyhow!( 41 | "p1/p2 parameters not supported on this transport" 42 | )) 43 | } 44 | 45 | fn select(&mut self, _: Vec) -> Result<()> { 46 | Ok(()) 47 | } 48 | } 49 | 50 | impl Transport for pcsc::Device { 51 | fn call(&mut self, instruction: u8, data: &[u8]) -> Result> { 52 | pcsc::Device::call(self, 0, instruction, 0x00, 0x00, Some(data)) 53 | } 54 | 55 | fn call_iso( 56 | &mut self, 57 | class: u8, 58 | instruction: u8, 59 | p1: u8, 60 | p2: u8, 61 | data: &[u8], 62 | ) -> Result> { 63 | self.call(class, instruction, p1, p2, Some(data)) 64 | } 65 | 66 | fn select(&mut self, aid: Vec) -> Result<()> { 67 | let answer_to_select = pcsc::Device::call( 68 | self, 69 | 0, 70 | iso7816::Instruction::Select.into(), 71 | 0x04, 72 | 0x00, 73 | Some(&aid), 74 | )?; 75 | // let answer_to_select = app.select()?; 76 | info!( 77 | "answer to selecting {}: {}", 78 | &hex::encode(&aid), 79 | &hex::encode(answer_to_select) 80 | ); 81 | Ok(()) 82 | } 83 | } 84 | 85 | impl Transport for Solo2 { 86 | fn call(&mut self, instruction: u8, data: &[u8]) -> Result> { 87 | use crate::device::TransportPreference::*; 88 | match Solo2::transport_preference() { 89 | Ctap => { 90 | if let Some(device) = self.as_ctap_mut() { 91 | info!("using CTAP as minimal transport"); 92 | Transport::call(device, instruction, data) 93 | } else if let Some(device) = self.as_pcsc_mut() { 94 | info!("using PCSC as minimal transport"); 95 | Transport::call(device, instruction, data) 96 | } else { 97 | // INVARIANT: Solo2 needs either CTAP or PCSC transport 98 | unreachable!() 99 | } 100 | } 101 | Pcsc => { 102 | if let Some(device) = self.as_pcsc_mut() { 103 | info!("using PCSC as minimal transport"); 104 | Transport::call(device, instruction, data) 105 | } else if let Some(device) = self.as_ctap_mut() { 106 | info!("using CTAP as minimal transport"); 107 | Transport::call(device, instruction, data) 108 | } else { 109 | // INVARIANT: Solo2 needs either CTAP or PCSC transport 110 | unreachable!() 111 | } 112 | } 113 | } 114 | } 115 | 116 | fn call_iso( 117 | &mut self, 118 | class: u8, 119 | instruction: u8, 120 | p1: u8, 121 | p2: u8, 122 | data: &[u8], 123 | ) -> Result> { 124 | if let Some(device) = self.as_pcsc_mut() { 125 | device.call_iso(class, instruction, p1, p2, data) 126 | } else { 127 | Err(anyhow::anyhow!( 128 | "p1/p2 parameters not supported on this transport" 129 | )) 130 | } 131 | } 132 | 133 | fn select(&mut self, aid: Vec) -> Result<()> { 134 | if let Some(device) = self.as_pcsc_mut() { 135 | device.select(aid) 136 | } else { 137 | Ok(()) 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/transport/ctap.rs: -------------------------------------------------------------------------------- 1 | //! Simplistic CTAPHID transport protocol implementation. 2 | //! 3 | //! Can switch to `ctaphid` once it stabilizes. 4 | 5 | pub use crate::{device::ctap::Device, Result}; 6 | 7 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 8 | /// CTAPHID commands 9 | pub enum Code { 10 | Ping, 11 | Init, 12 | Wink, 13 | Error, 14 | Keepalive, 15 | Vendor(VendorCode), 16 | } 17 | 18 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 19 | #[repr(u8)] 20 | /// Status conferred by a Keepalive response 21 | pub enum Status { 22 | Processing, 23 | UserPresenceNeeded, 24 | Other(u8), 25 | } 26 | 27 | impl From for u8 { 28 | fn from(status: Status) -> u8 { 29 | use Status::*; 30 | match status { 31 | Processing => 1, 32 | UserPresenceNeeded => 2, 33 | Other(status) => status, 34 | } 35 | } 36 | } 37 | 38 | impl From for Status { 39 | fn from(status: u8) -> Self { 40 | use Status::*; 41 | match status { 42 | 1 => Processing, 43 | 2 => UserPresenceNeeded, 44 | _ => Other(status), 45 | } 46 | } 47 | } 48 | 49 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 50 | pub enum Error { 51 | InvalidCommand = 1, 52 | InvalidParameter = 2, 53 | InvalidLength = 3, 54 | InvalidSequenceNumber = 4, 55 | MessageTimeout = 5, 56 | ChannelBusy = 6, 57 | ChannelLockRequired = 0xA, 58 | ChannelIdInvalid = 0xB, 59 | UnspecifiedError = 0x7F, 60 | } 61 | 62 | impl From for Error { 63 | fn from(error: u8) -> Self { 64 | use Error::*; 65 | match error { 66 | 1 => InvalidCommand, 67 | 2 => InvalidParameter, 68 | 3 => InvalidLength, 69 | 4 => InvalidSequenceNumber, 70 | 5 => MessageTimeout, 71 | 6 => ChannelBusy, 72 | 0xA => ChannelLockRequired, 73 | 0xB => ChannelIdInvalid, 74 | // yes, yes, but come on ;) 75 | _ => UnspecifiedError, 76 | } 77 | } 78 | } 79 | 80 | impl From for Code { 81 | fn from(code: u8) -> Self { 82 | use Code::*; 83 | match code { 84 | 0x1 => Ping, 85 | 0x6 => Init, 86 | 0x8 => Wink, 87 | 0x3F => Error, 88 | 0x3B => Keepalive, 89 | vendor_code @ 0x40..=0x7F => Vendor(VendorCode::new(vendor_code)), 90 | _ => panic!(), 91 | } 92 | } 93 | } 94 | 95 | impl From for u8 { 96 | fn from(code: Code) -> u8 { 97 | use Code::*; 98 | match code { 99 | Ping => 0x1, 100 | Init => 0x6, 101 | Wink => 0x8, 102 | Error => 0x3F, 103 | Keepalive => 0x3B, 104 | Vendor(code) => code.0, 105 | } 106 | } 107 | } 108 | 109 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 110 | pub struct VendorCode(u8); 111 | impl VendorCode { 112 | /// Must be at least 0x40 and at most 0x7F, else panic. 113 | pub const fn new(vendor_code: u8) -> Self { 114 | assert!(vendor_code >= 0x40); 115 | assert!(vendor_code <= 0x7F); 116 | Self(vendor_code) 117 | } 118 | } 119 | 120 | pub struct Command { 121 | code: Code, 122 | data: Vec, 123 | } 124 | 125 | impl Command { 126 | pub fn new(code: Code) -> Self { 127 | Self { code, data: vec![] } 128 | } 129 | 130 | pub fn with_data(self, data: &[u8]) -> Self { 131 | assert!(data.len() <= 7609); 132 | Self { 133 | code: self.code, 134 | data: data.to_vec(), 135 | } 136 | } 137 | 138 | pub fn packets(&self, channel: Channel) -> impl Iterator + '_ { 139 | use std::iter; 140 | 141 | let l = self.data.len(); 142 | assert!(l <= 7609); 143 | let data = &self.data; 144 | let init_l = core::cmp::min(l, 64 - 7); 145 | // dbg!("init_l", init_l); 146 | 147 | let mut init_packet = [0u8; 64]; 148 | init_packet[..4].copy_from_slice(&channel.0.to_be_bytes()); 149 | init_packet[4] = u8::from(self.code) | (1 << 7); 150 | init_packet[5..][..2].copy_from_slice(&(l as u16).to_be_bytes()); 151 | init_packet[7..][..init_l].copy_from_slice(&data[..init_l]); 152 | 153 | let init_iter = iter::once(init_packet); 154 | 155 | let cont_iter = data[init_l..] 156 | .chunks(64 - 5) 157 | .enumerate() 158 | .map(move |(i, chunk)| { 159 | // dbg!("cont", i, chunk.len()); 160 | let mut cont_packet = [0u8; 64]; 161 | cont_packet[..4].copy_from_slice(&channel.0.to_be_bytes()); 162 | cont_packet[4] = i as u8; 163 | cont_packet[5..][..chunk.len()].copy_from_slice(chunk); 164 | cont_packet 165 | }); 166 | 167 | init_iter.chain(cont_iter) 168 | } 169 | } 170 | 171 | // pub struct Packets { 172 | // command: Command, 173 | // } 174 | 175 | // impl Iterator for Packets { 176 | // type Item = [u8; 64]; 177 | 178 | // fn next( 179 | // } 180 | 181 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 182 | pub struct Channel(u32); 183 | 184 | impl Channel { 185 | pub const BROADCAST: Self = Self(0xffff_ffff); 186 | } 187 | 188 | impl Device { 189 | pub fn call(&self, channel: Channel, request: Command) -> Result> { 190 | let result: Result> = request 191 | .packets(channel) 192 | .enumerate() 193 | .map(|(_i, packet)| { 194 | // need to prefix report ID 195 | let mut prefixed = vec![0]; 196 | prefixed.extend_from_slice(&packet); 197 | self.device.write(&prefixed).map_err(|e| e.into()).map(drop) //|size| println!("sent {}", size)) 198 | }) 199 | .collect(); 200 | result?; 201 | 202 | let mut packet = [0u8; 64]; 203 | // trace!("packet: {}", hex::encode(packet)); 204 | loop { 205 | let read = self.device.read(&mut packet)?; 206 | assert!(read >= 7); 207 | if packet[..4] != channel.0.to_be_bytes() { 208 | // got response for other channel 209 | continue; 210 | } 211 | 212 | if packet[4] == u8::from(Code::Keepalive) | (1 << 7) { 213 | let status = Status::from(packet[7]); 214 | info!("received keepalive, status {:?}", status); 215 | continue; 216 | } 217 | 218 | // the assertion on packet[4] below is is not the case for failed commands (!) 219 | if packet[4] == u8::from(Code::Error) | (1 << 7) { 220 | return Err(anyhow::anyhow!("error: {:?}", Error::from(packet[7]))); 221 | } 222 | 223 | assert_eq!(packet[4], u8::from(request.code) | (1 << 7)); 224 | break; 225 | } 226 | 227 | let l = u16::from_be_bytes(packet[5..][..2].try_into().unwrap()); 228 | let mut data = vec![0u8; l as _]; 229 | let init_l = core::cmp::min(l, 64 - 7) as usize; 230 | data[..init_l].copy_from_slice(&packet[7..][..init_l]); 231 | 232 | let result: Result> = data[init_l..] 233 | .chunks_mut(64 - 5) 234 | .enumerate() 235 | .map(|(i, chunk)| { 236 | let read = self.device.read(&mut packet).unwrap(); 237 | assert!(read >= 5); 238 | // dbg!(hex::encode(&packet[..read])); 239 | assert_eq!(packet[..4], channel.0.to_be_bytes()); 240 | assert_eq!(packet[4], i as u8); 241 | chunk.copy_from_slice(&packet[5..][..chunk.len()]); //64- 5]); 242 | Ok(()) 243 | }) 244 | .collect(); 245 | result?; 246 | 247 | // let cont_iter = data[init_l..].chunks(64 - 5).enumerate() 248 | // .map(move |(i, chunk)| { 249 | // let mut cont_packet = [0u8; 64]; 250 | // cont_packet[..4].copy_from_slice(&channel.0.to_be_bytes()); 251 | // cont_packet[4] = i as u8; 252 | // cont_packet[5..][..chunk.len()].copy_from_slice(chunk); 253 | // cont_packet 254 | // }); 255 | Ok(data) 256 | } 257 | 258 | pub fn init(&self) -> Result { 259 | let mut nonce = [0u8; 8]; 260 | getrandom::getrandom(&mut nonce).unwrap(); 261 | // dbg!(hex::encode(&nonce)); 262 | let command = Command::new(Code::Init).with_data(&nonce); 263 | let response = self.call(Channel::BROADCAST, command)?; 264 | // let mut packet = [0u8; 64]; 265 | // let read = self.device.read(&mut packet)?; 266 | assert_eq!(response.len(), 17); 267 | assert_eq!(response[..8], nonce); 268 | let version = response[12]; 269 | assert_eq!(version, 2); 270 | let capabilities = response[16]; 271 | 272 | Ok(Init { 273 | channel: Channel(u32::from_be_bytes(response[8..][..4].try_into().unwrap())), 274 | // version: response[12], 275 | major: response[13], 276 | minor: response[14], 277 | build: response[15], 278 | can_wink: (capabilities & 1) != 0, 279 | can_cbor: (capabilities & 4) != 0, 280 | can_msg: (capabilities & 8) == 0, 281 | }) 282 | // // assert_eq!(nonce, response[..8]) 283 | // println!("response: {}", hex::encode(&response));//&packet[..read])); 284 | // todo!(); 285 | } 286 | 287 | pub fn ping(&self, channel: Channel, data: &[u8]) -> Result> { 288 | let command = Command::new(Code::Ping).with_data(data); 289 | let response = self.call(channel, command)?; 290 | 291 | assert_eq!(data, response); 292 | Ok(response) 293 | } 294 | 295 | pub fn wink(&self, channel: Channel) -> Result> { 296 | let command = Command::new(Code::Wink); 297 | let response = self.call(channel, command)?; 298 | Ok(response) 299 | } 300 | } 301 | 302 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 303 | pub struct Init { 304 | pub channel: Channel, 305 | // pub version: u8, 306 | pub major: u8, 307 | pub minor: u8, 308 | pub build: u8, 309 | pub can_wink: bool, 310 | pub can_cbor: bool, 311 | pub can_msg: bool, 312 | } 313 | -------------------------------------------------------------------------------- /src/transport/pcsc.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use iso7816::Status; 3 | 4 | pub use crate::{device::pcsc::Device, Error, Result}; 5 | 6 | impl Device { 7 | pub fn call( 8 | &mut self, 9 | cla: u8, 10 | ins: u8, 11 | p1: u8, 12 | p2: u8, 13 | data: Option<&[u8]>, 14 | ) -> Result> { 15 | let data = data.unwrap_or(&[]); 16 | let mut send_buffer = Vec::::with_capacity(data.len() + 16); 17 | 18 | send_buffer.push(cla); 19 | send_buffer.push(ins); 20 | send_buffer.push(p1); 21 | send_buffer.push(p2); 22 | 23 | // TODO: checks, chain, ... 24 | let l = data.len(); 25 | if l > 0 { 26 | if l <= 255 { 27 | send_buffer.push(l as u8); 28 | } else { 29 | send_buffer.push(0); 30 | send_buffer.extend_from_slice(&(l as u16).to_be_bytes()); 31 | } 32 | send_buffer.extend_from_slice(data); 33 | } 34 | 35 | send_buffer.push(0); 36 | if l > 255 { 37 | send_buffer.push(0); 38 | } 39 | 40 | debug!(">> {}", hex::encode(&send_buffer)); 41 | 42 | let mut recv_buffer = vec![0; 3072]; 43 | 44 | let l = self.device.transmit(&send_buffer, &mut recv_buffer)?.len(); 45 | debug!("RECV {} bytes", l); 46 | recv_buffer.resize(l, 0); 47 | debug!("<< {}", hex::encode(&recv_buffer)); 48 | 49 | if l < 2 { 50 | return Err(anyhow!( 51 | "response should end with two status bytes! received {}", 52 | hex::encode(recv_buffer) 53 | )); 54 | } 55 | let sw2 = recv_buffer.pop().unwrap(); 56 | let sw1 = recv_buffer.pop().unwrap(); 57 | 58 | let status = (sw1, sw2).try_into(); 59 | if Ok(Status::Success) != status { 60 | return Err(if !recv_buffer.is_empty() { 61 | anyhow!( 62 | "card signaled error {:?} ({:X}, {:X}) with data {}", 63 | status, 64 | sw1, 65 | sw2, 66 | hex::encode(recv_buffer) 67 | ) 68 | } else { 69 | anyhow!("card signaled error: {:?} ({:X}, {:X})", status, sw1, sw2) 70 | }); 71 | } 72 | 73 | Ok(recv_buffer) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/uuid.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | 3 | #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] 4 | pub struct Uuid(u128); 5 | 6 | impl From for Uuid { 7 | fn from(number: u128) -> Self { 8 | Self(number) 9 | } 10 | } 11 | 12 | impl From<[u8; 16]> for Uuid { 13 | fn from(hex: [u8; 16]) -> Self { 14 | Self(u128::from_be_bytes(hex)) 15 | } 16 | } 17 | 18 | impl TryFrom<&[u8]> for Uuid { 19 | type Error = Error; 20 | fn try_from(slice: &[u8]) -> Result { 21 | let array: [u8; 16] = slice.try_into()?; 22 | Ok(array.into()) 23 | } 24 | } 25 | 26 | impl Uuid { 27 | pub fn bytes(&self) -> [u8; 16] { 28 | self.0.to_be_bytes() 29 | } 30 | 31 | pub fn hex(&self) -> String { 32 | hex::encode_upper(self.bytes()) 33 | } 34 | 35 | pub fn u128(&self) -> u128 { 36 | self.0 37 | } 38 | 39 | pub fn from_bytes(bytes: &[u8]) -> Result { 40 | bytes.try_into() 41 | } 42 | 43 | pub fn from_hex(hex: &str) -> Result { 44 | let bytes = hex::decode(hex)?; 45 | bytes.as_slice().try_into() 46 | } 47 | } 48 | --------------------------------------------------------------------------------