├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── CHANGELOG.md.license ├── Cargo.lock ├── Cargo.lock.license ├── Cargo.toml ├── LICENSES ├── CC0-1.0.txt └── GPL-3.0-or-later.txt ├── Makefile ├── README.md ├── README.md.license ├── build.rs ├── contrib ├── README.md ├── README.md.license └── nix │ ├── flake.lock │ ├── flake.lock.license │ └── flake.nix ├── doc ├── CONTRIBUTING.md ├── CONTRIBUTING.md.license ├── config.example.toml ├── config.example.toml.license ├── nitrocli.1 ├── nitrocli.1.license ├── nitrocli.1.pdf ├── nitrocli.1.pdf.license ├── packaging.md └── packaging.md.license ├── ext ├── ext.rs └── otp_cache.rs ├── src ├── arg_util.rs ├── args.rs ├── commands.rs ├── config.rs ├── main.rs ├── output.rs ├── pinentry.rs ├── redefine.rs ├── tests │ ├── config.rs │ ├── encrypted.rs │ ├── extension_var_test.py │ ├── extensions.rs │ ├── fill.rs │ ├── hidden.rs │ ├── list.rs │ ├── lock.rs │ ├── mod.rs │ ├── otp.rs │ ├── pin.rs │ ├── pws.rs │ ├── reset.rs │ ├── run.rs │ ├── status.rs │ └── unencrypted.rs └── tty │ ├── linux.rs │ ├── mod.rs │ ├── stub.rs │ └── tty.sh └── var ├── binary-size.py └── shell-complete.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | # .editorconfig 2 | 3 | # Copyright (C) 2021 The Nitrocli Developers 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | [*.rs] 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2024 The Nitrocli Developers 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | # Please see the documentation for all configuration options: 5 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: github-actions 10 | rebase-strategy: auto 11 | directory: / 12 | schedule: 13 | interval: daily 14 | - package-ecosystem: cargo 15 | versioning-strategy: auto 16 | directory: / 17 | schedule: 18 | interval: daily 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2025 The Nitrocli Developers 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | # TODO: 5 | # - Test with system libnitrokey (USE_SYSTEM_LIBNITROKEY=1)? 6 | # - Add support for macos and windows 7 | 8 | name: Test 9 | 10 | on: 11 | pull_request: 12 | push: 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | RUST_BACKTRACE: 1 17 | # Build without debug information enabled to decrease compilation time 18 | # and binary sizes in CI. This option is assumed to only have marginal 19 | # effects on the generated code, likely only in terms of section 20 | # arrangement. See 21 | # https://doc.rust-lang.org/cargo/reference/environment-variables.html 22 | # https://doc.rust-lang.org/rustc/codegen-options/index.html#debuginfo 23 | RUSTFLAGS: '-C debuginfo=0' 24 | 25 | jobs: 26 | test: 27 | name: Build and test [${{ matrix.rust }}] 28 | runs-on: ubuntu-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | rust: [stable, beta, nightly] 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: dtolnay/rust-toolchain@master 36 | with: 37 | toolchain: ${{ matrix.rust }} 38 | - run: sudo apt-get install --yes --no-install-recommends libhidapi-dev 39 | - run: cargo build --workspace --bins --tests --verbose 40 | - run: cargo build --workspace --bins --tests --verbose --release 41 | - run: cargo test --workspace --verbose 42 | 43 | build-minimum: 44 | name: Build with minimum supported Rust version 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | - run: sudo apt-get install --yes --no-install-recommends libhidapi-dev 49 | - name: Install Nightly Rust 50 | uses: dtolnay/rust-toolchain@nightly 51 | - run: cargo +nightly -Z minimal-versions update 52 | - name: Install minimum Rust 53 | uses: dtolnay/rust-toolchain@master 54 | with: 55 | # Please adjust README file when bumping version. 56 | toolchain: 1.56.1 57 | - name: Build 58 | run: cargo build --bins --locked 59 | 60 | clippy: 61 | name: Lint with clippy 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: dtolnay/rust-toolchain@stable 66 | - run: cargo clippy --workspace --no-deps --all-targets --all-features -- -A unknown_lints -A deprecated -D warnings 67 | 68 | reuse: 69 | name: Check license annotations 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v4 73 | - uses: actions/setup-python@v5 74 | with: 75 | python-version: '3.10' 76 | - run: pip3 install reuse 77 | - run: reuse lint 78 | 79 | rustfmt: 80 | name: Check code formatting 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v4 84 | - uses: dtolnay/rust-toolchain@nightly 85 | with: 86 | components: rustfmt 87 | - run: cargo +nightly fmt -- --check 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 The Nitrocli Developers 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | target 5 | *.swp 6 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 The Nitrocli Developers 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | tab_spaces = 2 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Unreleased 2 | ---------- 3 | - Introduced `otp-cache` core extension 4 | - Included `git` tag/revision in `-V`/`--version` output 5 | - Automatically set `GPG_TTY` environment variable when running 6 | `gpg-connect-agent` unless it is already set 7 | - Fixed endless loop when a Nitrokey FIDO2 device is present 8 | - Added community maintained Nix flake to the repository 9 | - Adjusted program to use Rust Edition 2021 10 | - Updated minimum supported Rust version to `1.56.0` 11 | - Bumped `anyhow` dependency to `1.0.89` 12 | - Bumped `base32` dependency to `0.5.1` 13 | - Bumped `directories` dependency to `5.0.1` 14 | - Bumped `libc` dependency to `0.2.159` 15 | - Bumped `serde` dependency to `1.0.147` 16 | - Bumped `structopt` dependency to `0.3.26` 17 | - Bumped `tempfile` dependency to `0.3.16` 18 | - Bumped `toml` dependency to `0.5.9` 19 | 20 | 21 | 0.4.1 22 | ----- 23 | - Enabled usage of empty PWS slot fields 24 | - Changed error reporting format to make up only a single line 25 | - Added the `pws add` subcommand to write to a new slot 26 | - Added the `pws update` subcommand to update an existing PWS slot 27 | - Removed the `pws set` subcommand 28 | - Added the `--only-aes-key` option to the `reset` command to build a new AES 29 | key without performing a factory reset 30 | - Added support for reading PWS passwords and OTP secrets from stdin 31 | - Changed the `otp set`, `pws add` and `pws update` commands to check the 32 | length of the input data to improve the error messages 33 | - Added `NITROCLI_RESOLVED_USB_PATH` environment variable to be used by 34 | extensions 35 | - Allowed entering of `base32` encoded strings containing spaces 36 | - Fixed pinentry dialog highlighting some messages incorrectly as errors 37 | - Fixed handling of empty user input through pinentry 38 | - Switched to using GitHub Actions as the project's CI pipeline 39 | - Updated minimum supported Rust version to `1.43.0` 40 | - Bumped `nitrokey` dependency to `0.9.0` 41 | - Bumped `anyhow` dependency to `1.0.40` 42 | - Bumped `directories` dependency to `3.0.2` 43 | - Bumped `libc` dependency to `0.2.94` 44 | - Bumped `serde` dependency to `1.0.125` 45 | 46 | 47 | 0.4.0 48 | ----- 49 | - Added support for the Librem Key 50 | - Added support for user provided extensions through lookup via the 51 | `PATH` environment variable 52 | - Added the `fill` command that fills the SD card of a Nitrokey Storage device 53 | with random data 54 | - Added the `termion` dependency in version `1.5.6` 55 | - Added SD card usage information to the output of the `status` command for 56 | Storage devices 57 | - Renamed the `--{no-,}{numlock,capslock,scrollock}` options of the `config 58 | set` command to `--{no-,}{num-lock,caps-lock,scroll-lock}` 59 | - Added support for generating completion scripts for shells other than 60 | `bash` 61 | - Bumped `anyhow` dependency to `1.0.39` 62 | - Bumped `libc` dependency to `0.2.90` 63 | - Bumped `nitrokey` dependency to `0.8.0` 64 | - Bumped `serde` dependency to `1.0.118` 65 | - Bumped `structopt` dependency to `0.3.21` 66 | - Bumped `toml` dependency to `0.5.8` 67 | - Bumped various transitive dependencies to most recent versions 68 | 69 | 70 | 0.3.5 71 | ----- 72 | - Added support for configuration files 73 | - Added support for configuration files that can be used to set 74 | default values for some arguments 75 | - Added `toml` dependency in version `0.5.6` 76 | - Added `serde` dependency in version `1.0.114` 77 | - Added `envy` dependency in version `0.4.2` 78 | - Added `merge` dependency in version `0.1.0` 79 | - Added `directories` dependency in version `3.0.1` 80 | - Reworked connection handling for multiple attached Nitrokey devices: 81 | - Fail if multiple attached devices match the filter options (or no filter 82 | options are set) 83 | - Added `--serial-number` option that restricts the serial number of the 84 | device to connect to 85 | - Added `--usb-path` option that restricts the USB path of the device to 86 | connect to 87 | - Bumped `structopt` dependency to `0.3.17` 88 | 89 | 90 | 0.3.4 91 | ----- 92 | - Changed default OTP format from `hex` to `base32` 93 | - Improved error reporting format and fidelity 94 | - Added `anyhow` dependency in version `1.0.32` 95 | - Reworked environment variables: 96 | - Added the `NITROCLI_MODEL` and `NITROCLI_VERBOSITY` variables that 97 | set the defaults for the `--model` and `--verbose` options 98 | - Changed the handling of the `NITROCLI_NO_CACHE` variable to check 99 | the value of the variable instead of only the presence 100 | - Declared public API to be the man page 101 | - Adjusted license & copyright headers to comply with REUSE 3.0 102 | - Added CI stage checking compliance 103 | - Updated minimum required Rust version to `1.42.0` 104 | - Bumped `nitrokey` dependency to `0.7.1` 105 | - Bumped `proc-macro2` dependency to `1.0.19` 106 | - Bumped `syn` dependency to `1.0.36` 107 | 108 | 109 | 0.3.3 110 | ----- 111 | - Added bash completion support via `shell-complete` utility program 112 | - Updated minimum required Rust version to `1.40.0` 113 | - Converted `Cargo.lock` to new lock file format 114 | - Bumped `libc` dependency to `0.2.69` 115 | - Bumped `structopt` dependency to `0.3.13` 116 | - Bumped various transitive dependencies to most recent versions 117 | 118 | 119 | 0.3.2 120 | ----- 121 | - Added the `list` command that lists all attached Nitrokey devices 122 | - Reworked argument handling: 123 | - Added `structopt` dependency in version `0.3.7` 124 | - Replaced `argparse` with `structopt` 125 | - Removed `argparse` dependency 126 | - Made the `--verbose` and `--model` options global 127 | - Removed vendored dependencies and moved source code into repository 128 | root 129 | - Bumped `nitrokey` dependency to `0.6.0` 130 | - Bumped `quote` dependency to `1.0.3` 131 | - Bumped `syn` dependency to `1.0.14` 132 | 133 | 134 | 0.3.1 135 | ----- 136 | - Added note about interaction with GnuPG to `README` file 137 | - Bumped `nitrokey` dependency to `0.4.0` 138 | - Bumped `nitrokey-sys` dependency to `3.5.0` 139 | - Added `lazy_static` dependency in version `1.4.0` 140 | - Added `cfg-if` dependency in version `0.1.10` 141 | - Added `getrandom` dependency in version `0.1.13` 142 | 143 | 144 | 0.3.0 145 | ----- 146 | - Added `unencrypted` command with `set` subcommand for changing the 147 | unencrypted volume's read-write mode 148 | - Changed `storage hidden` subcommand to `hidden` top-level command 149 | - Renamed `storage` command to `encrypted` 150 | - Removed `storage status` subcommand 151 | - Moved its output into `status` command 152 | - Removed previously deprecated `--ascii` option from `otp set` command 153 | - Fixed wrong hexadecimal conversion used in `otp set` command 154 | - Bumped `nitrokey` dependency to `0.3.5` 155 | - Bumped `libc` dependency to `0.2.66` 156 | - Bumped `cc` dependency to `1.0.48` 157 | 158 | 159 | 0.2.4 160 | ----- 161 | - Added the `reset` command to perform a factory reset 162 | - Added the `-V`/`--version` option to print the program's version 163 | - Check the status of a PWS slot before accessing it in `pws get` 164 | - Added `NITROCLI_NO_CACHE` environment variable to bypass caching of 165 | secrets 166 | - Clear cached PIN entry as part of `pin set` command to prevent 167 | spurious authentication failures 168 | - Bumped `libc` dependency to `0.2.57` 169 | - Bumped `cc` dependency to `1.0.37` 170 | 171 | 172 | 0.2.3 173 | ----- 174 | - Added the `storage hidden` subcommand for working with hidden volumes 175 | - Store cached PINs on a per-device basis to better support multi-device 176 | scenarios 177 | - Further decreased binary size by using system allocator 178 | - Bumped `nitrokey` dependency to `0.3.4` 179 | - Bumped `rand` dependency to `0.6.4` 180 | - Removed `rustc_version`, `semver`, and `semver-parser` dependencies 181 | - Bumped `nitrokey-sys` dependency to `3.4.3` 182 | - Bumped `libc` dependency to `0.2.47` 183 | 184 | 185 | 0.2.2 186 | ----- 187 | - Added the `-v`/`--verbose` option to control libnitrokey log level 188 | - Added the `-m`/`--model` option to restrict connections to a device 189 | model 190 | - Added the `-f`/`--format` option for the `otp set` subcommand to 191 | choose the secret format 192 | - Deprecated the `--ascii` option 193 | - Honor `NITROCLI_ADMIN_PIN` and `NITROCLI_USER_PIN` as well as 194 | `NITROCLI_NEW_ADMIN_PIN` and `NITROCLI_NEW_USER_PIN` environment 195 | variables for non-interactive PIN supply 196 | - Format `nitrokey` reported errors in more user-friendly format 197 | - Bumped `nitrokey` dependency to `0.3.1` 198 | 199 | 200 | 0.2.1 201 | ----- 202 | - Added the `pws` command for accessing the password safe 203 | - Added the `lock` command for locking the Nitrokey device 204 | - Adjusted release build compile options to optimize binary for size 205 | - Bumped `nitrokey` dependency to `0.2.3` 206 | - Bumped `rand` dependency to `0.6.1` 207 | - Added `rustc_version` version `0.2.3`, `semver` version `0.9.0`, and 208 | `semver-parser` version `0.7.0` as indirect dependencies 209 | - Bumped `cc` dependency to `1.0.28` 210 | 211 | 212 | 0.2.0 213 | ----- 214 | - Use the `nitrokey` crate for the `open`, `close`, and `status` 215 | commands instead of directly communicating with the Nitrokey device 216 | - Added `nitrokey` version `0.2.1` as a direct dependency and 217 | `nitrokey-sys` version `3.4.1` as well as `rand` version `0.4.3` as 218 | indirect dependencies 219 | - Removed the `hid`, `hidapi-sys` and `pkg-config` dependencies 220 | - Added the `otp` command for working with one-time passwords 221 | - Added the `config` command for reading and writing the device configuration 222 | - Added the `pin` command for managing PINs 223 | - Renamed the `clear` command to `pin clear` 224 | - Moved `open` and `close` commands as subcommands into newly introduced 225 | `storage` command 226 | - Moved printing of storage related information from `status` command 227 | into new `storage status` subcommand 228 | - Made `status` command work with Nitrokey Pro devices 229 | - Enabled CI pipeline comprising code style conformance checks, linting, 230 | and building of the project 231 | - Added badges indicating pipeline status, current `crates.io` published 232 | version of the crate, and minimum version of `rustc` required 233 | - Fixed wrong messages in the pinentry dialog that were caused by unescaped 234 | spaces in a string 235 | - Use the `argparse` crate to parse the command-line arguments 236 | - Added `argparse` dependency in version `0.2.2` 237 | 238 | 239 | 0.1.3 240 | ----- 241 | - Show PIN related errors through `pinentry` native reporting mechanism 242 | instead of emitting them to `stdout` 243 | - Added a `man` page (`nitrocli(1)`) for the program to the repository 244 | - Adjusted program to use Rust Edition 2018 245 | - Enabled more lints 246 | - Applied a couple of `clippy` reported suggestions 247 | - Added categories to `Cargo.toml` 248 | - Changed dependency version requirements to be less strict (only up to 249 | the minor version and not the patch level) 250 | - Bumped `pkg-config` dependency to `0.3.14` 251 | - Bumped `libc` dependency to `0.2.45` 252 | - Bumped `cc` dependency to `1.0.25` 253 | 254 | 255 | 0.1.2 256 | ----- 257 | - Replaced deprecated `gcc` dependency with `cc` and bumped to `1.0.4` 258 | - Bumped `hid` dependency to `0.4.1` 259 | - Bumped `hidapi-sys` dependency to `0.1.4` 260 | - Bumped `libc` dependency to `0.2.36` 261 | 262 | 263 | 0.1.1 264 | ----- 265 | - Fixed display of firmware version for `status` command 266 | - Removed workaround for incorrect CRC checksum produced by the Nitrokey 267 | Storage device 268 | - The problem has been fixed upstream (`nitrokey-storage-firmware` 269 | [issue #32](https://github.com/Nitrokey/nitrokey-storage-firmware/issues/32)) 270 | - In order to be usable, a minimum firmware version of 0.47 is required 271 | 272 | 273 | 0.1.0 274 | ----- 275 | - Initial release 276 | -------------------------------------------------------------------------------- /CHANGELOG.md.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anyhow" 16 | version = "1.0.97" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" 19 | 20 | [[package]] 21 | name = "autocfg" 22 | version = "1.0.1" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 25 | 26 | [[package]] 27 | name = "base32" 28 | version = "0.5.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" 31 | 32 | [[package]] 33 | name = "bitflags" 34 | version = "1.3.2" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 37 | 38 | [[package]] 39 | name = "bitflags" 40 | version = "2.8.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 43 | 44 | [[package]] 45 | name = "cc" 46 | version = "1.0.67" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" 49 | 50 | [[package]] 51 | name = "cfg-if" 52 | version = "1.0.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 55 | 56 | [[package]] 57 | name = "clap" 58 | version = "2.34.0" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" 61 | dependencies = [ 62 | "bitflags 1.3.2", 63 | "textwrap", 64 | "unicode-width", 65 | ] 66 | 67 | [[package]] 68 | name = "directories" 69 | version = "5.0.1" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 72 | dependencies = [ 73 | "dirs-sys", 74 | ] 75 | 76 | [[package]] 77 | name = "dirs-sys" 78 | version = "0.4.1" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 81 | dependencies = [ 82 | "libc", 83 | "option-ext", 84 | "redox_users", 85 | "windows-sys 0.48.0", 86 | ] 87 | 88 | [[package]] 89 | name = "envy" 90 | version = "0.4.2" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" 93 | dependencies = [ 94 | "serde", 95 | ] 96 | 97 | [[package]] 98 | name = "errno" 99 | version = "0.3.10" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 102 | dependencies = [ 103 | "libc", 104 | "windows-sys 0.59.0", 105 | ] 106 | 107 | [[package]] 108 | name = "fastrand" 109 | version = "2.3.0" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 112 | 113 | [[package]] 114 | name = "getrandom" 115 | version = "0.1.16" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 118 | dependencies = [ 119 | "cfg-if", 120 | "libc", 121 | "wasi 0.9.0+wasi-snapshot-preview1", 122 | ] 123 | 124 | [[package]] 125 | name = "getrandom" 126 | version = "0.2.4" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" 129 | dependencies = [ 130 | "cfg-if", 131 | "libc", 132 | "wasi 0.10.2+wasi-snapshot-preview1", 133 | ] 134 | 135 | [[package]] 136 | name = "getrandom" 137 | version = "0.3.1" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" 140 | dependencies = [ 141 | "cfg-if", 142 | "libc", 143 | "wasi 0.13.3+wasi-0.2.2", 144 | "windows-targets 0.52.6", 145 | ] 146 | 147 | [[package]] 148 | name = "grev" 149 | version = "0.1.3" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "4ea0f363a3828bf3018a6a65a0bf270ce17e7563c13bea743ce25cbc1784f48d" 152 | dependencies = [ 153 | "anyhow", 154 | ] 155 | 156 | [[package]] 157 | name = "heck" 158 | version = "0.3.3" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 161 | dependencies = [ 162 | "unicode-segmentation", 163 | ] 164 | 165 | [[package]] 166 | name = "lazy_static" 167 | version = "1.4.0" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 170 | 171 | [[package]] 172 | name = "libc" 173 | version = "0.2.171" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" 176 | 177 | [[package]] 178 | name = "linux-raw-sys" 179 | version = "0.4.15" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 182 | 183 | [[package]] 184 | name = "log" 185 | version = "0.4.14" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 188 | dependencies = [ 189 | "cfg-if", 190 | ] 191 | 192 | [[package]] 193 | name = "memchr" 194 | version = "2.7.4" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 197 | 198 | [[package]] 199 | name = "merge" 200 | version = "0.1.0" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "10bbef93abb1da61525bbc45eeaff6473a41907d19f8f9aa5168d214e10693e9" 203 | dependencies = [ 204 | "merge_derive", 205 | "num-traits", 206 | ] 207 | 208 | [[package]] 209 | name = "merge_derive" 210 | version = "0.1.0" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "209d075476da2e63b4b29e72a2ef627b840589588e71400a25e3565c4f849d07" 213 | dependencies = [ 214 | "proc-macro-error", 215 | "proc-macro2", 216 | "quote", 217 | "syn", 218 | ] 219 | 220 | [[package]] 221 | name = "nitrocli" 222 | version = "0.4.1" 223 | dependencies = [ 224 | "anyhow", 225 | "base32", 226 | "directories", 227 | "envy", 228 | "grev", 229 | "libc", 230 | "log", 231 | "merge", 232 | "nitrokey", 233 | "nitrokey-test", 234 | "nitrokey-test-state", 235 | "proc-macro2", 236 | "progressing", 237 | "regex", 238 | "serde", 239 | "structopt", 240 | "tempfile", 241 | "termion", 242 | "toml", 243 | ] 244 | 245 | [[package]] 246 | name = "nitrokey" 247 | version = "0.9.0" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "ddeb2d19d5499ab4740c0131562e8c4b2c13f8954677be4318c1efc944911531" 250 | dependencies = [ 251 | "lazy_static", 252 | "libc", 253 | "nitrokey-sys", 254 | "rand_core", 255 | ] 256 | 257 | [[package]] 258 | name = "nitrokey-sys" 259 | version = "3.7.0" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "d88466a33516e986e87aeb072307356605bb9ac5b13cd95647ee53a6c5d09641" 262 | dependencies = [ 263 | "cc", 264 | ] 265 | 266 | [[package]] 267 | name = "nitrokey-test" 268 | version = "0.5.0" 269 | source = "registry+https://github.com/rust-lang/crates.io-index" 270 | checksum = "e651e412a94d7285f525bfc52b93684fb357c3d846fde61d5ebf79a2454ef2a4" 271 | dependencies = [ 272 | "proc-macro2", 273 | "quote", 274 | "syn", 275 | ] 276 | 277 | [[package]] 278 | name = "nitrokey-test-state" 279 | version = "0.1.0" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "a59b732ed6d5212424ed31ec9649f05652bcbc38f45f2292b27a6044e7098803" 282 | 283 | [[package]] 284 | name = "num-traits" 285 | version = "0.2.14" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 288 | dependencies = [ 289 | "autocfg", 290 | ] 291 | 292 | [[package]] 293 | name = "numtoa" 294 | version = "0.1.0" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 297 | 298 | [[package]] 299 | name = "once_cell" 300 | version = "1.20.2" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 303 | 304 | [[package]] 305 | name = "option-ext" 306 | version = "0.2.0" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 309 | 310 | [[package]] 311 | name = "proc-macro-error" 312 | version = "1.0.4" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 315 | dependencies = [ 316 | "proc-macro-error-attr", 317 | "proc-macro2", 318 | "quote", 319 | "syn", 320 | "version_check", 321 | ] 322 | 323 | [[package]] 324 | name = "proc-macro-error-attr" 325 | version = "1.0.4" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 328 | dependencies = [ 329 | "proc-macro2", 330 | "quote", 331 | "version_check", 332 | ] 333 | 334 | [[package]] 335 | name = "proc-macro2" 336 | version = "1.0.89" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 339 | dependencies = [ 340 | "unicode-ident", 341 | ] 342 | 343 | [[package]] 344 | name = "progressing" 345 | version = "3.0.2" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "97b7db19a74ba7c34de36558abed080568491d2b8999a34de914b1793b0b4b1b" 348 | dependencies = [ 349 | "log", 350 | ] 351 | 352 | [[package]] 353 | name = "quote" 354 | version = "1.0.18" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" 357 | dependencies = [ 358 | "proc-macro2", 359 | ] 360 | 361 | [[package]] 362 | name = "rand_core" 363 | version = "0.5.1" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 366 | dependencies = [ 367 | "getrandom 0.1.16", 368 | ] 369 | 370 | [[package]] 371 | name = "redox_syscall" 372 | version = "0.2.10" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" 375 | dependencies = [ 376 | "bitflags 1.3.2", 377 | ] 378 | 379 | [[package]] 380 | name = "redox_termios" 381 | version = "0.1.2" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" 384 | dependencies = [ 385 | "redox_syscall", 386 | ] 387 | 388 | [[package]] 389 | name = "redox_users" 390 | version = "0.4.0" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 393 | dependencies = [ 394 | "getrandom 0.2.4", 395 | "redox_syscall", 396 | ] 397 | 398 | [[package]] 399 | name = "regex" 400 | version = "1.11.1" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 403 | dependencies = [ 404 | "aho-corasick", 405 | "memchr", 406 | "regex-automata", 407 | "regex-syntax", 408 | ] 409 | 410 | [[package]] 411 | name = "regex-automata" 412 | version = "0.4.8" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 415 | dependencies = [ 416 | "aho-corasick", 417 | "memchr", 418 | "regex-syntax", 419 | ] 420 | 421 | [[package]] 422 | name = "regex-syntax" 423 | version = "0.8.5" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 426 | 427 | [[package]] 428 | name = "rustix" 429 | version = "0.38.44" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 432 | dependencies = [ 433 | "bitflags 2.8.0", 434 | "errno", 435 | "libc", 436 | "linux-raw-sys", 437 | "windows-sys 0.59.0", 438 | ] 439 | 440 | [[package]] 441 | name = "serde" 442 | version = "1.0.156" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "314b5b092c0ade17c00142951e50ced110ec27cea304b1037c6969246c2469a4" 445 | dependencies = [ 446 | "serde_derive", 447 | ] 448 | 449 | [[package]] 450 | name = "serde_derive" 451 | version = "1.0.156" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "d7e29c4601e36bcec74a223228dce795f4cd3616341a4af93520ca1a837c087d" 454 | dependencies = [ 455 | "proc-macro2", 456 | "quote", 457 | "syn", 458 | ] 459 | 460 | [[package]] 461 | name = "structopt" 462 | version = "0.3.26" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" 465 | dependencies = [ 466 | "clap", 467 | "lazy_static", 468 | "structopt-derive", 469 | ] 470 | 471 | [[package]] 472 | name = "structopt-derive" 473 | version = "0.4.18" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" 476 | dependencies = [ 477 | "heck", 478 | "proc-macro-error", 479 | "proc-macro2", 480 | "quote", 481 | "syn", 482 | ] 483 | 484 | [[package]] 485 | name = "syn" 486 | version = "1.0.109" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 489 | dependencies = [ 490 | "proc-macro2", 491 | "quote", 492 | "unicode-ident", 493 | ] 494 | 495 | [[package]] 496 | name = "tempfile" 497 | version = "3.16.0" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" 500 | dependencies = [ 501 | "cfg-if", 502 | "fastrand", 503 | "getrandom 0.3.1", 504 | "once_cell", 505 | "rustix", 506 | "windows-sys 0.59.0", 507 | ] 508 | 509 | [[package]] 510 | name = "termion" 511 | version = "1.5.6" 512 | source = "registry+https://github.com/rust-lang/crates.io-index" 513 | checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" 514 | dependencies = [ 515 | "libc", 516 | "numtoa", 517 | "redox_syscall", 518 | "redox_termios", 519 | ] 520 | 521 | [[package]] 522 | name = "textwrap" 523 | version = "0.11.0" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 526 | dependencies = [ 527 | "unicode-width", 528 | ] 529 | 530 | [[package]] 531 | name = "toml" 532 | version = "0.5.9" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" 535 | dependencies = [ 536 | "serde", 537 | ] 538 | 539 | [[package]] 540 | name = "unicode-ident" 541 | version = "1.0.13" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 544 | 545 | [[package]] 546 | name = "unicode-segmentation" 547 | version = "1.9.0" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" 550 | 551 | [[package]] 552 | name = "unicode-width" 553 | version = "0.1.9" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" 556 | 557 | [[package]] 558 | name = "version_check" 559 | version = "0.9.4" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 562 | 563 | [[package]] 564 | name = "wasi" 565 | version = "0.9.0+wasi-snapshot-preview1" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 568 | 569 | [[package]] 570 | name = "wasi" 571 | version = "0.10.2+wasi-snapshot-preview1" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 574 | 575 | [[package]] 576 | name = "wasi" 577 | version = "0.13.3+wasi-0.2.2" 578 | source = "registry+https://github.com/rust-lang/crates.io-index" 579 | checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" 580 | dependencies = [ 581 | "wit-bindgen-rt", 582 | ] 583 | 584 | [[package]] 585 | name = "windows-sys" 586 | version = "0.48.0" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 589 | dependencies = [ 590 | "windows-targets 0.48.5", 591 | ] 592 | 593 | [[package]] 594 | name = "windows-sys" 595 | version = "0.59.0" 596 | source = "registry+https://github.com/rust-lang/crates.io-index" 597 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 598 | dependencies = [ 599 | "windows-targets 0.52.6", 600 | ] 601 | 602 | [[package]] 603 | name = "windows-targets" 604 | version = "0.48.5" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 607 | dependencies = [ 608 | "windows_aarch64_gnullvm 0.48.5", 609 | "windows_aarch64_msvc 0.48.5", 610 | "windows_i686_gnu 0.48.5", 611 | "windows_i686_msvc 0.48.5", 612 | "windows_x86_64_gnu 0.48.5", 613 | "windows_x86_64_gnullvm 0.48.5", 614 | "windows_x86_64_msvc 0.48.5", 615 | ] 616 | 617 | [[package]] 618 | name = "windows-targets" 619 | version = "0.52.6" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 622 | dependencies = [ 623 | "windows_aarch64_gnullvm 0.52.6", 624 | "windows_aarch64_msvc 0.52.6", 625 | "windows_i686_gnu 0.52.6", 626 | "windows_i686_gnullvm", 627 | "windows_i686_msvc 0.52.6", 628 | "windows_x86_64_gnu 0.52.6", 629 | "windows_x86_64_gnullvm 0.52.6", 630 | "windows_x86_64_msvc 0.52.6", 631 | ] 632 | 633 | [[package]] 634 | name = "windows_aarch64_gnullvm" 635 | version = "0.48.5" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 638 | 639 | [[package]] 640 | name = "windows_aarch64_gnullvm" 641 | version = "0.52.6" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 644 | 645 | [[package]] 646 | name = "windows_aarch64_msvc" 647 | version = "0.48.5" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 650 | 651 | [[package]] 652 | name = "windows_aarch64_msvc" 653 | version = "0.52.6" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 656 | 657 | [[package]] 658 | name = "windows_i686_gnu" 659 | version = "0.48.5" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 662 | 663 | [[package]] 664 | name = "windows_i686_gnu" 665 | version = "0.52.6" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 668 | 669 | [[package]] 670 | name = "windows_i686_gnullvm" 671 | version = "0.52.6" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 674 | 675 | [[package]] 676 | name = "windows_i686_msvc" 677 | version = "0.48.5" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 680 | 681 | [[package]] 682 | name = "windows_i686_msvc" 683 | version = "0.52.6" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 686 | 687 | [[package]] 688 | name = "windows_x86_64_gnu" 689 | version = "0.48.5" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 692 | 693 | [[package]] 694 | name = "windows_x86_64_gnu" 695 | version = "0.52.6" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 698 | 699 | [[package]] 700 | name = "windows_x86_64_gnullvm" 701 | version = "0.48.5" 702 | source = "registry+https://github.com/rust-lang/crates.io-index" 703 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 704 | 705 | [[package]] 706 | name = "windows_x86_64_gnullvm" 707 | version = "0.52.6" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 710 | 711 | [[package]] 712 | name = "windows_x86_64_msvc" 713 | version = "0.48.5" 714 | source = "registry+https://github.com/rust-lang/crates.io-index" 715 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 716 | 717 | [[package]] 718 | name = "windows_x86_64_msvc" 719 | version = "0.52.6" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 722 | 723 | [[package]] 724 | name = "wit-bindgen-rt" 725 | version = "0.33.0" 726 | source = "registry+https://github.com/rust-lang/crates.io-index" 727 | checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" 728 | dependencies = [ 729 | "bitflags 2.8.0", 730 | ] 731 | -------------------------------------------------------------------------------- /Cargo.lock.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | # Cargo.toml 2 | 3 | # Copyright (C) 2017-2024 The Nitrocli Developers 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | [package] 7 | name = "nitrocli" 8 | version = "0.4.1" 9 | edition = "2021" 10 | rust-version = "1.56" 11 | authors = ["Daniel Mueller "] 12 | license = "GPL-3.0-or-later" 13 | homepage = "https://github.com/d-e-s-o/nitrocli" 14 | repository = "https://github.com/d-e-s-o/nitrocli.git" 15 | readme = "README.md" 16 | categories = ["command-line-utilities", "authentication", "cryptography", "hardware-support"] 17 | keywords = ["nitrokey", "nitrokey-storage", "nitrokey-pro", "cli", "usb"] 18 | description = """ 19 | A command line tool for interacting with Nitrokey devices. 20 | """ 21 | exclude = ["rustfmt.toml"] 22 | default-run = "nitrocli" 23 | 24 | [[bin]] 25 | name = "shell-complete" 26 | path = "var/shell-complete.rs" 27 | 28 | [[bin]] 29 | name = "nitrocli-otp-cache" 30 | path = "ext/otp_cache.rs" 31 | 32 | [build-dependencies] 33 | anyhow = "1.0" 34 | grev = "0.1.3" 35 | 36 | [profile.release] 37 | opt-level = "z" 38 | lto = true 39 | codegen-units = 1 40 | incremental = false 41 | 42 | [dependencies.anyhow] 43 | version = "1.0" 44 | 45 | [dependencies.base32] 46 | version = "0.5.1" 47 | 48 | [dependencies.directories] 49 | version = "5" 50 | 51 | [dependencies.envy] 52 | version = "0.4.2" 53 | 54 | [dependencies.libc] 55 | version = "0.2" 56 | 57 | [dependencies.merge] 58 | version = "0.1" 59 | 60 | [dependencies.nitrokey] 61 | version = "0.9.0" 62 | 63 | [dependencies.progressing] 64 | version = "3.0.2" 65 | 66 | [dependencies.serde] 67 | version = "1.0.156" 68 | features = ["derive"] 69 | 70 | [dependencies.structopt] 71 | version = "0.3.21" 72 | default-features = false 73 | 74 | [dependencies.termion] 75 | version = "1.5.5" 76 | 77 | [dependencies.toml] 78 | version = "0.5.6" 79 | 80 | [dev-dependencies.nitrokey-test] 81 | version = "0.5" 82 | 83 | [dev-dependencies.nitrokey-test-state] 84 | version = "0.1" 85 | 86 | [dev-dependencies.regex] 87 | version = "1" 88 | 89 | [dev-dependencies.tempfile] 90 | version = "3.1" 91 | 92 | [dev-dependencies] 93 | # A set of unused dependencies that we require to force correct minimum versions 94 | # of transitive dependencies, for cases where our dependencies have incorrect 95 | # dependency specifications themselves. 96 | # error: cannot find macro `log` in this scope 97 | _log_unused = { package = "log", version = "0.4.4" } 98 | # error[E0635]: unknown feature `proc_macro_span_shrink` 99 | _proc_macro2_unused = { package = "proc-macro2", version = "1.0.60" } 100 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Copyright (C) 2017-2021 The Nitrocli Developers 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | SHELL := bash 7 | 8 | PS2PDF ?= ps2pdf 9 | 10 | NITROCLI_MAN := doc/nitrocli.1 11 | NITROCLI_PDF := $(addsuffix .pdf,$(NITROCLI_MAN)) 12 | 13 | .PHONY: doc 14 | doc: $(NITROCLI_PDF) 15 | 16 | # We assume and do not check existence of man, which, false, and echo 17 | # commands. 18 | $(NITROCLI_PDF): $(NITROCLI_MAN) 19 | @which $(PS2PDF) &> /dev/null || \ 20 | (echo "$(PS2PDF) command not found, unable to generate documentation"; false) 21 | @man --local-file --troff $(<) | $(PS2PDF) - $(@) 22 | 23 | KEY ?= 0x952DD6F8F34D8B8E 24 | 25 | .PHONY: sign 26 | sign: 27 | @test -n "$(REL)" || \ 28 | (echo "Please set REL environment variable to the release to verify (e.g., '0.2.1')."; false) 29 | @mkdir -p pkg/ 30 | wget --quiet "https://github.com/d-e-s-o/nitrocli/archive/v$(REL).zip" \ 31 | -O "pkg/nitrocli-$(REL).zip" 32 | @set -euo pipefail && DIR1=$$(mktemp -d) && DIR2=$$(mktemp -d) && \ 33 | unzip -q pkg/nitrocli-$(REL).zip -d $${DIR1} && \ 34 | git -C $$(git rev-parse --show-toplevel) archive --prefix=nitrocli-$(REL)/ v$(REL) | \ 35 | tar -x -C $${DIR2} && \ 36 | diff -u -r $${DIR1} $${DIR2} && \ 37 | echo "Github zip archive verified successfully" && \ 38 | (rm -r $${DIR1} && rm -r $${DIR2}) 39 | wget --quiet "https://github.com/d-e-s-o/nitrocli/archive/v$(REL).tar.gz" \ 40 | -O "pkg/nitrocli-$(REL).tar.gz" 41 | @set -euo pipefail && DIR1=$$(mktemp -d) && DIR2=$$(mktemp -d) && \ 42 | tar -xz -C $${DIR1} -f pkg/nitrocli-$(REL).tar.gz && \ 43 | git -C $$(git rev-parse --show-toplevel) archive --prefix=nitrocli-$(REL)/ v$(REL) | \ 44 | tar -x -C $${DIR2} && \ 45 | diff -u -r $${DIR1} $${DIR2} && \ 46 | echo "Github tarball verified successfully" && \ 47 | (rm -r $${DIR1} && rm -r $${DIR2}) 48 | @cd pkg && sha256sum nitrocli-$(REL).tar.gz nitrocli-$(REL).zip > nitrocli-$(REL).sha256.DIGEST 49 | @gpg --sign --armor --detach-sign --default-key=$(KEY) --yes \ 50 | --output pkg/nitrocli-$(REL).sha256.DIGEST.sig pkg/nitrocli-$(REL).sha256.DIGEST 51 | @gpg --verify pkg/nitrocli-$(REL).sha256.DIGEST.sig 52 | @cd pkg && sha256sum --check < nitrocli-$(REL).sha256.DIGEST 53 | @echo "All checks successful. Please attach" 54 | @echo " pkg/nitrocli-$(REL).sha256.DIGEST" 55 | @echo " pkg/nitrocli-$(REL).sha256.DIGEST.sig" 56 | @echo "to https://github.com/d-e-s-o/nitrocli/releases/tag/v$(REL)" 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pipeline](https://github.com/d-e-s-o/nitrocli/actions/workflows/.github/workflows/test.yml/badge.svg)](https://github.com/d-e-s-o/nitrocli/commits/main) 2 | [![crates.io](https://img.shields.io/crates/v/nitrocli.svg)](https://crates.io/crates/nitrocli) 3 | [![rustc](https://img.shields.io/badge/rustc-1.56+-blue.svg)](https://blog.rust-lang.org/2021/10/21/Rust-1.56.0.html) 4 | 5 | nitrocli 6 | ======== 7 | 8 | - [Changelog](CHANGELOG.md) 9 | 10 | **nitrocli** is a program that provides a command line interface for 11 | interaction with [Nitrokey Pro][nitrokey-pro], [Nitrokey 12 | Storage][nitrokey-storage], and [Librem Key][librem-key] devices. 13 | 14 | 15 | The following commands are currently supported: 16 | - list: List all attached Nitrokey devices. 17 | - status: Report status information about the Nitrokey. 18 | - lock: Lock the Nitrokey. 19 | - config: Access the Nitrokey's configuration 20 | - get: Read the current configuration. 21 | - set: Change the configuration. 22 | - encrypted: Work with the Nitrokey Storage's encrypted volume. 23 | - open: Open the encrypted volume. The user PIN needs to be entered. 24 | - close: Close the encrypted volume. 25 | - hidden: Work with the Nitrokey Storage's hidden volume. 26 | - create: Create a hidden volume. 27 | - open: Open a hidden volume with a password. 28 | - close: Close a hidden volume. 29 | - otp: Access one-time passwords (OTP). 30 | - get: Generate a one-time password. 31 | - set: Set an OTP slot. 32 | - status: List all OTP slots. 33 | - clear: Delete an OTP slot. 34 | - pin: Manage the Nitrokey's PINs. 35 | - clear: Remove the user and admin PIN from gpg-agent's cache. 36 | - set: Change the admin or the user PIN. 37 | - unblock: Unblock and reset the user PIN. 38 | - pws: Access the password safe (PWS). 39 | - get: Query the data on a PWS slot. 40 | - set: Set the data on a PWS slot. 41 | - status: List all PWS slots. 42 | - clear: Delete a PWS slot. 43 | - unencrypted: Work with the Nitrokey Storage's unencrypted volume. 44 | - set: Change the read-write mode of the unencrypted volume. 45 | 46 | 47 | Usage 48 | ----- 49 | 50 | Usage is as simple as providing the name of the respective command as a 51 | parameter (note that some commands are organized through subcommands, 52 | which are required as well), e.g.: 53 | ```sh 54 | # Open the nitrokey's encrypted volume. 55 | $ nitrocli storage open 56 | 57 | $ nitrocli status 58 | Status: 59 | model: Storage 60 | serial number: 0x00053141 61 | firmware version: v0.54 62 | user retry count: 3 63 | admin retry count: 3 64 | Storage: 65 | SD card ID: 0x05dcad1d 66 | SD card usage: 24% .. 99% not written 67 | firmware: unlocked 68 | storage keys: created 69 | volumes: 70 | unencrypted: active 71 | encrypted: active 72 | hidden: inactive 73 | 74 | # Close it again. 75 | $ nitrocli storage close 76 | ``` 77 | 78 | More examples, a more detailed explanation of the purpose, the potential 79 | subcommands, as well as the parameters of each command are provided in 80 | the [`man` page](doc/nitrocli.1.pdf). 81 | 82 | 83 | Installation 84 | ------------ 85 | 86 | In addition to Rust itself and Cargo, its package management tool, the 87 | following dependencies are required: 88 | - **hidapi**: In order to provide USB access this library is used. 89 | - **GnuPG**: The `gpg-connect-agent` program allows the user to enter 90 | PINs. 91 | 92 | #### Via Packages 93 | Packages are available for: 94 | - Arch Linux: [`nitrocli`][nitrocli-arch] 95 | - Debian: [`nitrocli`][nitrocli-debian] (since Debian Buster) 96 | - Gentoo Linux: [`app-crypt/nitrocli`][nitrocli-gentoo] ebuild 97 | - NixOS: [`nitrocli`][nitrocli-nixos] 98 | - Ubuntu: [`nitrocli`][nitrocli-ubuntu] (since Ubuntu 19.04) 99 | 100 | #### From Crates.io 101 | **nitrocli** is [published][nitrocli-cratesio] on crates.io and can 102 | directly be installed from there: 103 | ```sh 104 | $ cargo install nitrocli --root=$PWD/nitrocli 105 | ``` 106 | 107 | #### From Source 108 | After cloning the repository the build is as simple as running: 109 | ```sh 110 | $ cargo build --release 111 | ``` 112 | 113 | It is recommended that the resulting executable be installed in a 114 | directory accessible via the `PATH` environment variable. 115 | 116 | #### With Nix flakes 117 | ##### Running nitrocli 118 | Repository comes with a `flake.nix` file, so it can be run directly: 119 | 120 | ```sh 121 | $ nix run d-e-s-o/nitrocli 122 | ``` 123 | 124 | ##### Installing system-wide 125 | **nitrocli** can be installed by adding the repository flake as an input: 126 | 127 | ```nix 128 | { 129 | inputs = { 130 | nitrocli.url = "github:d-e-s-o/nitrocli?dir=contrib/nix"; 131 | ... 132 | }; 133 | 134 | outputs = { 135 | nitrocli, 136 | ... 137 | }: { 138 | # ... 139 | # Where modules are defined 140 | environment.systemPackages = [ nitrocli.defaultPackage ]; 141 | }; 142 | ... 143 | } 144 | ``` 145 | 146 | #### Shell Completion 147 | **nitrocli** comes with completion support for options and arguments to 148 | them (for various shells). A completion script can be generated via the 149 | `shell-complete` utility program and then only needs to be sourced to 150 | make the current shell provide context-sensitive tab completion support. 151 | ```sh 152 | $ cargo run --bin=shell-complete bash > nitrocli.bash 153 | $ source nitrocli.bash 154 | ``` 155 | 156 | The generated completion script (`bash` specific, in this case) can be 157 | installed system-wide as usual and sourced through Bash initialization 158 | files, such as `~/.bashrc`. 159 | 160 | Completion scripts for other shells work in a similar manner. Please 161 | refer to the help text (`--help`) of the `shell-complete` program for 162 | the list of supported shells. 163 | 164 | 165 | Known Problems 166 | -------------- 167 | 168 | - Due to a problem with the default `hidapi` version on macOS, users are 169 | advised to build and install [`libnitrokey`][] from source and then 170 | set the `USE_SYSTEM_LIBNITROKEY` environment variable when building 171 | `nitrocli` using one of the methods described above. 172 | - `nitrocli` cannot connect to a Nitrokey device that is currently being 173 | accessed by `nitrokey-app` ([upstream issue][libnitrokey#32]). To 174 | prevent this problem, quit `nitrokey-app` before using `nitrocli`. 175 | - Applications using the Nitrokey device (such as `nitrocli` or 176 | `nitrokey-app`) cannot easily share access with an instance of 177 | scdaemon/GnuPG running shortly afterwards ([upstream 178 | issue][libnitrokey#137]). As a workaround, users can kill `scdaemon` 179 | after calling `nitrocli` with `gpg-connect-agent 'SCD KILLSCD' /bye`. 180 | 181 | 182 | Public API and Stability 183 | ------------------------ 184 | 185 | **nitrocli** follows the [Semantic Versioning specification 2.0.0][semver]. 186 | Its public API is defined by the [nitrocli(1) `man` page](doc/nitrocli.1.pdf). 187 | 188 | 189 | Contributing 190 | ------------ 191 | 192 | Contributions are generally welcome. Please follow the guidelines 193 | outlined in [CONTRIBUTING.md](doc/CONTRIBUTING.md). 194 | 195 | 196 | Acknowledgments 197 | --------------- 198 | 199 | Robin Krahl ([@robinkrahl](https://github.com/robinkrahl)) has been 200 | a crucial help for the development of **nitrocli**. 201 | 202 | The [Nitrokey GmbH][nitrokey-gmbh] has generously provided the necessary 203 | hardware in the form of Nitrokey Pro and Nitrokey Storage devices for 204 | developing and testing the program. 205 | 206 | [Purism][purism] was kind enough to help development of support for 207 | Librem Keys by providing the necessary hardware devices to test on. 208 | 209 | 210 | License 211 | ------- 212 | **nitrocli** is made available under the terms of the 213 | [GPLv3][gplv3-tldr]. 214 | 215 | See the [LICENSE](LICENSE) file that accompanies this distribution for 216 | the full text of the license. 217 | 218 | `nitrocli` complies with [version 3.0 of the REUSE specification][reuse]. 219 | 220 | 221 | [`libnitrokey`]: https://github.com/nitrokey/libnitrokey 222 | [nitrokey-gmbh]: https://www.nitrokey.com 223 | [nitrokey-pro]: https://shop.nitrokey.com/shop/product/nitrokey-pro-2-3 224 | [nitrokey-storage]: https://shop.nitrokey.com/shop/product/nitrokey-storage-2-56 225 | [librem-key]: https://puri.sm/products/librem-key/ 226 | [nitrocli-arch]: https://archlinux.org/packages/community/x86_64/nitrocli/ 227 | [nitrocli-cratesio]: https://crates.io/crates/nitrocli 228 | [nitrocli-debian]: https://packages.debian.org/stable/nitrocli 229 | [nitrocli-gentoo]: https://packages.gentoo.org/packages/app-crypt/nitrocli 230 | [nitrocli-nixos]: https://search.nixos.org/packages?channel=unstable&show=nitrocli 231 | [nitrocli-ubuntu]: https://packages.ubuntu.com/search?keywords=nitrocli 232 | [gplv3-tldr]: https://tldrlegal.com/license/gnu-general-public-license-v3-(gpl-3) 233 | [libnitrokey#32]: https://github.com/Nitrokey/libnitrokey/issues/32 234 | [libnitrokey#137]: https://github.com/Nitrokey/libnitrokey/issues/137 235 | [purism]: https://puri.sm/ 236 | [reuse]: https://reuse.software/practices/3.0/ 237 | [semver]: https://semver.org 238 | -------------------------------------------------------------------------------- /README.md.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2021-2023 The Nitrocli Developers 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use anyhow::Result; 5 | 6 | use grev::git_revision_auto; 7 | 8 | fn main() -> Result<()> { 9 | let directory = env!("CARGO_MANIFEST_DIR"); 10 | if let Some(git_revision) = git_revision_auto(directory)? { 11 | println!("cargo:rustc-env=NITROCLI_GIT_REVISION={}", git_revision); 12 | } 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /contrib/README.md: -------------------------------------------------------------------------------- 1 | This is the `contrib/` directory of **nitrocli** project. Files located 2 | here are: 3 | - provided and maintained by the project's community 4 | - not official part of the project 5 | - provided without any warranty 6 | -------------------------------------------------------------------------------- /contrib/README.md.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /contrib/nix/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "naersk": { 4 | "inputs": { 5 | "nixpkgs": "nixpkgs" 6 | }, 7 | "locked": { 8 | "lastModified": 1662220400, 9 | "narHash": "sha256-9o2OGQqu4xyLZP9K6kNe1pTHnyPz0Wr3raGYnr9AIgY=", 10 | "owner": "nix-community", 11 | "repo": "naersk", 12 | "rev": "6944160c19cb591eb85bbf9b2f2768a935623ed3", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "nix-community", 17 | "ref": "master", 18 | "repo": "naersk", 19 | "type": "github" 20 | } 21 | }, 22 | "nixpkgs": { 23 | "locked": { 24 | "lastModified": 1663264531, 25 | "narHash": "sha256-2ncO5chPXlTxaebDlhx7MhL0gOEIWxzSyfsl0r0hxQk=", 26 | "owner": "NixOS", 27 | "repo": "nixpkgs", 28 | "rev": "454887a35de6317a30be284e8adc2d2f6d8a07c4", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "id": "nixpkgs", 33 | "type": "indirect" 34 | } 35 | }, 36 | "nixpkgs_2": { 37 | "locked": { 38 | "lastModified": 1663264531, 39 | "narHash": "sha256-2ncO5chPXlTxaebDlhx7MhL0gOEIWxzSyfsl0r0hxQk=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "454887a35de6317a30be284e8adc2d2f6d8a07c4", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "NixOS", 47 | "ref": "nixpkgs-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "naersk": "naersk", 55 | "nixpkgs": "nixpkgs_2", 56 | "utils": "utils" 57 | } 58 | }, 59 | "utils": { 60 | "locked": { 61 | "lastModified": 1659877975, 62 | "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", 63 | "owner": "numtide", 64 | "repo": "flake-utils", 65 | "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "numtide", 70 | "repo": "flake-utils", 71 | "type": "github" 72 | } 73 | } 74 | }, 75 | "root": "root", 76 | "version": 7 77 | } 78 | -------------------------------------------------------------------------------- /contrib/nix/flake.lock.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /contrib/nix/flake.nix: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2022 The Nitrocli Developers 2 | # SPDX-License-Identifier: CC0-1.0 3 | { 4 | inputs = { 5 | naersk.url = "github:nix-community/naersk/master"; 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 7 | utils.url = "github:numtide/flake-utils"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, utils, naersk }: 11 | utils.lib.eachDefaultSystem (system: 12 | let 13 | pkgs = import nixpkgs { inherit system; }; 14 | naersk-lib = pkgs.callPackage naersk { }; 15 | in 16 | rec 17 | { 18 | packages.default = naersk-lib.buildPackage { 19 | root = ./../../.; 20 | nativeBuildInputs = with pkgs; [ hidapi ]; 21 | postInstall = '' 22 | # copy the manpages 23 | install -D --mode 0644 ${./../../.}/doc/nitrocli.1 $out/share/man/man1 24 | # make completions 25 | mkdir --parents $out/share/bash-completion/completions/ 26 | cargo run --bin=shell-complete bash > $out/share/bash-completion/completions/nitrocli 27 | mkdir --parents $out/share/zsh/site-functions/ 28 | cargo run --bin=shell-complete zsh > $out/share/zsh/site-functions/_nitrocli 29 | mkdir --parents $out/share/fish/vendor_completions.d/ 30 | cargo run --bin=shell-complete fish > $out/share/fish/vendor_completions.d/nitrocli.fish 31 | ''; 32 | }; 33 | 34 | apps.default = utils.lib.mkApp { 35 | drv = packages.default; 36 | name = "nitrocli"; 37 | }; 38 | 39 | devShell = with pkgs; mkShell { 40 | buildInputs = [ cargo rustc rustfmt pre-commit rustPackages.clippy hidapi ]; 41 | RUST_SRC_PATH = rustPlatform.rustLibSrc; 42 | }; 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /doc/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | The following rules generally apply for pull requests and code changes: 2 | 3 | **Submit Pull Requests to the `devel` branch** 4 | 5 | The `devel` branch is where experimental features reside. After some 6 | soak time they may be ported over to `main` and a release will be cut 7 | that includes them. 8 | 9 | **Keep documentation up-to-date** 10 | 11 | Please make an effort to keep the documentation up-to-date to the extent 12 | possible and necessary for the change at hand. That includes adjusting 13 | the [README](../README.md) and [`man` page](nitrocli.1) as well as 14 | regenerating the PDF rendered version of the latter by running `make 15 | doc`. 16 | 17 | **Blend with existing patterns and style** 18 | 19 | To keep the code as consistent as possible, please try not to diverge 20 | from the existing style used in a file. Specifically for Rust source 21 | code, use [`rustfmt`](https://github.com/rust-lang/rustfmt) and 22 | [`clippy`](https://github.com/rust-lang/rust-clippy) to achieve a 23 | minimum level of consistency and prevent known bugs, respectively. 24 | -------------------------------------------------------------------------------- /doc/CONTRIBUTING.md.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /doc/config.example.toml: -------------------------------------------------------------------------------- 1 | # This is an example configuration file for nitrocli. To use it, place it at 2 | # ${XDG_CONFIG_HOME}/nitrocli/config.toml, where XDG_CONFIG_HOME defaults to 3 | # ${HOME}. 4 | 5 | # The model to connect to (string, "pro", "storage", or "librem", default: 6 | # not set). 7 | model = "pro" 8 | # The serial number of the device to connect to (list of strings, default: 9 | # empty). 10 | serial_numbers = ["0xf00baa", "deadbeef"] 11 | # The USB path of the device to connect to (string, default: empty). 12 | usb_path = "004:001:00" 13 | # Do not cache secrets (boolean, default: false). 14 | no_cache = true 15 | # The log level (integer, default: 0). 16 | verbosity = 2 17 | -------------------------------------------------------------------------------- /doc/config.example.toml.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /doc/nitrocli.1.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /doc/nitrocli.1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-e-s-o/nitrocli/749c0f0679fbe9593956988bf00cbb6da1ad2fb0/doc/nitrocli.1.pdf -------------------------------------------------------------------------------- /doc/nitrocli.1.pdf.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /doc/packaging.md: -------------------------------------------------------------------------------- 1 | How to package nitrocli 2 | ======================= 3 | 4 | This document describes how to update the packaged versions of nitrocli. 5 | 6 | Arch Linux 7 | ---------- 8 | 9 | The Arch Linux package is maintained as part of the community repository. 10 | 11 | Debian 12 | ------ 13 | 14 | 1. Clone or fork the Git repository at 15 | https://salsa.debian.org/rust-team/debcargo-conf. 16 | 2. Execute `./update.sh nitrocli`. 17 | 3. Check and, if necessary, update the Debian changelog in the file 18 | `src/nitrocli/debian/changelog`. 19 | 4. Verify that the package builds successfully by running `./build.sh nitrocli` 20 | in the `build` directory. (This requires an `sbuild` environment as 21 | described in the `README.rst` file.) 22 | 5. Inspect the generated package by running `dpkg-deb --info` and `dpkg-deb 23 | --contents` on it. 24 | 6. If you have push access to the repository, create the 25 | `src/nitrocli/debian/RFS` file to indicate that `nitrocli` can be updated. 26 | 7. Add and commit your changes. If you have push access, push them. 27 | Otherwise create a merge request and indicate that `nitrocli` is ready for 28 | upload in its description. 29 | 30 | For more information, see the [Teams/RustPackaging][] page in the Debian Wiki 31 | and the [README.rst file][] in the debcargo-conf repository. 32 | 33 | For detailed information on the status of the Debian package, check the [Debian 34 | Package Tracker][]. 35 | 36 | Ubuntu 37 | ------ 38 | 39 | The `nitrocli` package for Ubuntu is automatically generated from the Debian 40 | package. For detailed information on the status of the Ubuntu package, check 41 | [Launchpad][]. 42 | 43 | [Arch User Repository]: https://wiki.archlinux.org/index.php/Arch_User_Repository 44 | [Teams/RustPackaging]: https://wiki.debian.org/Teams/RustPackaging 45 | [README.rst file]: https://salsa.debian.org/rust-team/debcargo-conf/blob/master/README.rst 46 | [Debian Package Tracker]: https://tracker.debian.org/pkg/rust-nitrocli 47 | [Launchpad]: https://launchpad.net/ubuntu/+source/rust-nitrocli 48 | -------------------------------------------------------------------------------- /doc/packaging.md.license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 The Nitrocli Developers 2 | SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /ext/ext.rs: -------------------------------------------------------------------------------- 1 | // ext.rs 2 | 3 | // Copyright (C) 2020-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::env; 7 | use std::ffi; 8 | use std::path; 9 | use std::process; 10 | 11 | use anyhow::Context as _; 12 | 13 | /// A context providing information relevant to `nitrocli` extensions. 14 | #[derive(Debug)] 15 | pub struct Context { 16 | /// Path to the `nitrocli` binary. 17 | nitrocli: ffi::OsString, 18 | /// The path to the USB device that `nitrocli` would connect to, if 19 | /// any. 20 | resolved_usb_path: Option, 21 | /// The verbosity that `nitrocli` should use. 22 | #[allow(dead_code)] 23 | verbosity: Option, 24 | /// The project directory root to use for the extension in question. 25 | project_dirs: directories::ProjectDirs, 26 | } 27 | 28 | impl Context { 29 | /// Create a new `Context` with information provided by `nitrocli` 30 | /// via environment variables. 31 | pub fn from_env() -> anyhow::Result { 32 | let nitrocli = env::var_os("NITROCLI_BINARY") 33 | .context("NITROCLI_BINARY environment variable not present") 34 | .context("Failed to retrieve nitrocli path")?; 35 | 36 | let resolved_usb_path = env::var("NITROCLI_RESOLVED_USB_PATH").ok(); 37 | 38 | let verbosity = env::var_os("NITROCLI_VERBOSITY") 39 | .context("NITROCLI_VERBOSITY environment variable not present") 40 | .context("Failed to retrieve nitrocli verbosity")?; 41 | 42 | let verbosity = if verbosity.len() == 0 { 43 | None 44 | } else { 45 | let verbosity = verbosity 46 | .to_str() 47 | .context("Provided verbosity string is not valid UTF-8")?; 48 | let verbosity = verbosity.parse().context("Failed to parse verbosity")?; 49 | set_log_level(verbosity); 50 | Some(verbosity) 51 | }; 52 | 53 | let exe = 54 | env::current_exe().context("Failed to determine the path of the extension executable")?; 55 | let name = exe 56 | .file_name() 57 | .context("Failed to extract the name of the extension executable")? 58 | .to_str() 59 | .context("The name of the extension executable contains non-UTF-8 characters")?; 60 | let project_dirs = directories::ProjectDirs::from("", "", name).with_context(|| { 61 | format!( 62 | "Could not determine the application directories for the {} extension", 63 | name 64 | ) 65 | })?; 66 | 67 | Ok(Self { 68 | nitrocli, 69 | resolved_usb_path, 70 | verbosity, 71 | project_dirs, 72 | }) 73 | } 74 | 75 | /// Retrieve `Nitrocli` object for invoking the main `nitrocli` 76 | /// program. 77 | pub fn nitrocli(&self) -> Nitrocli { 78 | Nitrocli::from_context(self) 79 | } 80 | 81 | /// Connect to a Nitrokey (or Librem Key) device as `nitrocli` would. 82 | pub fn connect<'mgr>( 83 | &self, 84 | mgr: &'mgr mut nitrokey::Manager, 85 | ) -> anyhow::Result> { 86 | if let Some(usb_path) = &self.resolved_usb_path { 87 | mgr.connect_path(usb_path.to_owned()).map_err(From::from) 88 | } else { 89 | // TODO: Improve error message. Unfortunately, we can't easily 90 | // determine whether we have no or more than one (matching) 91 | // device. 92 | Err(anyhow::anyhow!("Could not connect to Nitrokey device")) 93 | } 94 | } 95 | 96 | /// Retrieve the path to the directory in which this extension may 97 | /// store cacheable artifacts. 98 | pub fn cache_dir(&self) -> &path::Path { 99 | self.project_dirs.cache_dir() 100 | } 101 | } 102 | 103 | // See src/command.rs in nitrocli core. 104 | fn set_log_level(verbosity: u8) { 105 | let log_lvl = match verbosity { 106 | // The error log level is what libnitrokey uses by default. As such, 107 | // there is no harm in us setting that as well when the user did not 108 | // ask for higher verbosity. 109 | 0 => nitrokey::LogLevel::Error, 110 | 1 => nitrokey::LogLevel::Warning, 111 | 2 => nitrokey::LogLevel::Info, 112 | 3 => nitrokey::LogLevel::DebugL1, 113 | 4 => nitrokey::LogLevel::Debug, 114 | _ => nitrokey::LogLevel::DebugL2, 115 | }; 116 | nitrokey::set_log_level(log_lvl); 117 | } 118 | 119 | /// A type allowing for convenient invocation of `nitrocli` itself. 120 | #[derive(Debug)] 121 | pub struct Nitrocli { 122 | cmd: process::Command, 123 | } 124 | 125 | impl Nitrocli { 126 | /// Create a new `Nitrocli` instance from a `Context`. 127 | fn from_context(ctx: &Context) -> Nitrocli { 128 | Self { 129 | cmd: process::Command::new(&ctx.nitrocli), 130 | } 131 | } 132 | 133 | /// Add an argument to the `nitrocli` invocation. 134 | pub fn arg(&mut self, arg: S) -> &mut Nitrocli 135 | where 136 | S: AsRef, 137 | { 138 | self.cmd.arg(arg); 139 | self 140 | } 141 | 142 | /// Add multiple arguments to the `nitrocli` invocation. 143 | pub fn args(&mut self, args: I) -> &mut Nitrocli 144 | where 145 | I: IntoIterator, 146 | S: AsRef, 147 | { 148 | self.cmd.args(args); 149 | self 150 | } 151 | 152 | /// Invoke `nitrocli`. 153 | pub fn spawn(&mut self) -> anyhow::Result<()> { 154 | let mut child = self.cmd.spawn().context("Failed to invoke nitrocli")?; 155 | child.wait().context("Failed to wait on nitrocli")?; 156 | Ok(()) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /ext/otp_cache.rs: -------------------------------------------------------------------------------- 1 | // otp_cache.rs 2 | 3 | // Copyright (C) 2020-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::fs; 7 | use std::io::Write as _; 8 | use std::path; 9 | 10 | use anyhow::Context as _; 11 | use structopt::StructOpt as _; 12 | 13 | mod ext; 14 | 15 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 16 | struct Cache { 17 | hotp: Vec, 18 | totp: Vec, 19 | } 20 | 21 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 22 | struct Slot { 23 | name: String, 24 | id: u8, 25 | } 26 | 27 | /// Access Nitrokey OTP slots by name 28 | /// 29 | /// This command caches the names of the OTP slots on a Nitrokey device 30 | /// and makes it possible to generate a one-time password from a slot 31 | /// with a given name without knowing its index. It only queries the 32 | /// names of the OTP slots if there is no cached data or if the 33 | /// `--force-update` option is set. The cache includes the Nitrokey's 34 | /// serial number so that it is possible to use it with multiple 35 | /// devices. 36 | #[derive(Debug, structopt::StructOpt)] 37 | #[structopt(bin_name = "nitrocli otp-cache")] 38 | struct Args { 39 | /// Always query the slot data even if it is already cached 40 | #[structopt(short, long, global = true)] 41 | force_update: bool, 42 | #[structopt(subcommand)] 43 | cmd: Command, 44 | } 45 | 46 | #[derive(Debug, structopt::StructOpt)] 47 | enum Command { 48 | /// Generates a one-time password 49 | Get { 50 | /// The name of the OTP slot to generate a OTP from 51 | name: String, 52 | }, 53 | /// Lists the cached slots and their names 54 | List, 55 | } 56 | 57 | fn main() -> anyhow::Result<()> { 58 | let args = Args::from_args(); 59 | let ctx = ext::Context::from_env()?; 60 | 61 | let cache = get_cache(&ctx, args.force_update)?; 62 | match &args.cmd { 63 | Command::Get { name } => cmd_get(&ctx, &cache, name)?, 64 | Command::List => cmd_list(&cache), 65 | } 66 | Ok(()) 67 | } 68 | 69 | fn cmd_get(ctx: &ext::Context, cache: &Cache, slot_name: &str) -> anyhow::Result<()> { 70 | let totp_slots = cache 71 | .totp 72 | .iter() 73 | .filter(|s| s.name == slot_name) 74 | .collect::>(); 75 | let hotp_slots = cache 76 | .hotp 77 | .iter() 78 | .filter(|s| s.name == slot_name) 79 | .collect::>(); 80 | if totp_slots.len() + hotp_slots.len() > 1 { 81 | Err(anyhow::anyhow!( 82 | "Found multiple OTP slots with the given name" 83 | )) 84 | } else if let Some(slot) = totp_slots.first() { 85 | generate_otp(ctx, "totp", slot.id) 86 | } else if let Some(slot) = hotp_slots.first() { 87 | generate_otp(ctx, "hotp", slot.id) 88 | } else { 89 | Err(anyhow::anyhow!("Found no OTP slot with the given name")) 90 | } 91 | } 92 | 93 | fn cmd_list(cache: &Cache) { 94 | println!("alg\tslot\tname"); 95 | for slot in &cache.totp { 96 | println!("totp\t{}\t{}", slot.id, slot.name); 97 | } 98 | for slot in &cache.hotp { 99 | println!("hotp\t{}\t{}", slot.id, slot.name); 100 | } 101 | } 102 | 103 | fn get_cache(ctx: &ext::Context, force_update: bool) -> anyhow::Result { 104 | let mut mgr = nitrokey::take().context("Failed to obtain Nitrokey manager instance")?; 105 | let device = ctx.connect(&mut mgr)?; 106 | let serial_number = get_serial_number(&device)?; 107 | let cache_file = ctx.cache_dir().join(format!("{}.toml", serial_number)); 108 | 109 | if cache_file.is_file() && !force_update { 110 | load_cache(&cache_file) 111 | } else { 112 | let cache = get_otp_slots(&device)?; 113 | save_cache(&cache, &cache_file)?; 114 | Ok(cache) 115 | } 116 | } 117 | 118 | fn load_cache(path: &path::Path) -> anyhow::Result { 119 | let s = fs::read_to_string(path).context("Failed to read cache file")?; 120 | toml::from_str(&s).context("Failed to parse cache file") 121 | } 122 | 123 | fn save_cache(cache: &Cache, path: &path::Path) -> anyhow::Result<()> { 124 | if let Some(parent) = path.parent() { 125 | fs::create_dir_all(parent).context("Failed to create cache parent directory")?; 126 | } 127 | let mut f = fs::File::create(path).context("Failed to create cache file")?; 128 | let data = toml::to_vec(cache).context("Failed to serialize cache")?; 129 | f.write_all(&data).context("Failed to write cache file")?; 130 | Ok(()) 131 | } 132 | 133 | fn get_serial_number<'a>(device: &impl nitrokey::Device<'a>) -> anyhow::Result { 134 | // TODO: Consider using hidapi serial number (if available) 135 | Ok(device.get_serial_number()?.to_string().to_lowercase()) 136 | } 137 | 138 | fn get_otp_slots_fn(device: &D, f: F) -> anyhow::Result> 139 | where 140 | D: nitrokey::GenerateOtp, 141 | F: Fn(&D, u8) -> Result, 142 | { 143 | let mut slots = Vec::new(); 144 | let mut slot = 0u8; 145 | loop { 146 | let result = f(device, slot); 147 | match result { 148 | Ok(name) => { 149 | slots.push(Slot { name, id: slot }); 150 | } 151 | Err(nitrokey::Error::LibraryError(nitrokey::LibraryError::InvalidSlot)) => break, 152 | Err(nitrokey::Error::CommandError(nitrokey::CommandError::SlotNotProgrammed)) => {} 153 | Err(err) => return Err(err).context("Failed to check OTP slot"), 154 | } 155 | slot = slot 156 | .checked_add(1) 157 | .context("Encountered integer overflow when iterating OTP slots")?; 158 | } 159 | Ok(slots) 160 | } 161 | 162 | fn get_otp_slots(device: &impl nitrokey::GenerateOtp) -> anyhow::Result { 163 | Ok(Cache { 164 | totp: get_otp_slots_fn(device, |device, slot| device.get_totp_slot_name(slot))?, 165 | hotp: get_otp_slots_fn(device, |device, slot| device.get_hotp_slot_name(slot))?, 166 | }) 167 | } 168 | 169 | fn generate_otp(ctx: &ext::Context, algorithm: &str, slot: u8) -> anyhow::Result<()> { 170 | ctx 171 | .nitrocli() 172 | .args(["otp", "get"].iter()) 173 | .arg(slot.to_string()) 174 | .arg("--algorithm") 175 | .arg(algorithm) 176 | .spawn() 177 | } 178 | -------------------------------------------------------------------------------- /src/arg_util.rs: -------------------------------------------------------------------------------- 1 | // arg_util.rs 2 | 3 | // Copyright (C) 2019-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | macro_rules! count { 7 | ($head:ident) => { 1 }; 8 | ($head:ident, $($tail:ident),*) => { 9 | 1 + count!($($tail),*) 10 | } 11 | } 12 | 13 | /// Translate an optional source into an optional destination. 14 | macro_rules! tr { 15 | ($dst:tt, $src:tt) => { 16 | $dst 17 | }; 18 | } 19 | 20 | macro_rules! Command { 21 | ( $(#[$docs:meta])* $name:ident, [ 22 | $( $(#[$doc:meta])* $var:ident$(($inner:ty))? => $exec:expr, ) * 23 | ] ) => { 24 | $(#[$docs])* 25 | #[derive(Debug, PartialEq, structopt::StructOpt)] 26 | pub enum $name { 27 | $( 28 | $(#[$doc])* 29 | $var$(($inner))?, 30 | )* 31 | } 32 | 33 | impl $name { 34 | pub fn execute( 35 | self, 36 | ctx: &mut crate::Context<'_>, 37 | ) -> anyhow::Result<()> { 38 | #[allow(clippy::redundant_closure_call)] 39 | match self { 40 | $( 41 | $name::$var$((tr!(args, $inner)))? => $exec(ctx $(,tr!(args, $inner))?), 42 | )* 43 | } 44 | } 45 | } 46 | }; 47 | } 48 | 49 | /// A macro for generating an enum with a set of simple (i.e., no 50 | /// parameters) variants and their textual representations. 51 | // TODO: Right now we hard code the derives we create. We may want to 52 | // make this set configurable. 53 | macro_rules! Enum { 54 | ( $(#[$docs:meta])* $name:ident, [ $( $var:ident => $str:expr, ) *] ) => { 55 | $(#[$docs])* 56 | #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] 57 | pub enum $name { 58 | $( 59 | $var, 60 | )* 61 | } 62 | 63 | enum_int! {$name, [ 64 | $( $var => $str, )* 65 | ]} 66 | }; 67 | } 68 | 69 | macro_rules! enum_int { 70 | ( $name:ident, [ $( $var:ident => $str:expr, ) *] ) => { 71 | impl $name { 72 | #[allow(unused)] 73 | pub fn all(&self) -> [$name; count!($($var),*) ] { 74 | $name::all_variants() 75 | } 76 | 77 | pub fn all_variants() -> [$name; count!($($var),*) ] { 78 | [ 79 | $( 80 | $name::$var, 81 | )* 82 | ] 83 | } 84 | 85 | #[allow(unused)] 86 | pub fn all_str() -> [&'static str; count!($($var),*)] { 87 | [ 88 | $( 89 | $str, 90 | )* 91 | ] 92 | } 93 | } 94 | 95 | impl ::std::convert::AsRef for $name { 96 | fn as_ref(&self) -> &'static str { 97 | match *self { 98 | $( 99 | $name::$var => $str, 100 | )* 101 | } 102 | } 103 | } 104 | 105 | impl ::std::fmt::Display for $name { 106 | fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { 107 | write!(f, "{}", self.as_ref()) 108 | } 109 | } 110 | 111 | impl ::std::str::FromStr for $name { 112 | type Err = ::std::string::String; 113 | 114 | fn from_str(s: &str) -> ::std::result::Result { 115 | match s { 116 | $( 117 | $str => Ok($name::$var), 118 | )* 119 | _ => Err( 120 | format!( 121 | "expected one of {}", 122 | $name::all_str().join(", "), 123 | ) 124 | ) 125 | } 126 | } 127 | } 128 | }; 129 | } 130 | 131 | #[cfg(test)] 132 | mod tests { 133 | Enum! {Command, [ 134 | Var1 => "var1", 135 | Var2 => "2", 136 | Var3 => "crazy", 137 | ]} 138 | 139 | #[test] 140 | fn all_variants() { 141 | assert_eq!( 142 | Command::all_variants(), 143 | [Command::Var1, Command::Var2, Command::Var3] 144 | ) 145 | } 146 | 147 | #[test] 148 | fn text_representations() { 149 | assert_eq!(Command::Var1.as_ref(), "var1"); 150 | assert_eq!(Command::Var2.as_ref(), "2"); 151 | assert_eq!(Command::Var3.as_ref(), "crazy"); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | // args.rs 2 | 3 | // Copyright (C) 2020-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::ffi; 7 | 8 | /// Provides access to a Nitrokey device 9 | #[derive(Debug, structopt::StructOpt)] 10 | #[structopt(name = "nitrocli", no_version)] 11 | pub struct Args { 12 | /// Increases the log level (can be supplied multiple times) 13 | #[structopt(short, long, global = true, parse(from_occurrences))] 14 | pub verbose: u8, 15 | /// Selects the device model to connect to 16 | #[structopt(short, long, global = true, possible_values = &DeviceModel::all_str())] 17 | pub model: Option, 18 | /// Sets the serial number of the device to connect to. Can be set 19 | /// multiple times to allow multiple serial numbers 20 | // TODO: Add short options (avoid collisions). 21 | #[structopt( 22 | long = "serial-number", 23 | global = true, 24 | multiple = true, 25 | number_of_values = 1 26 | )] 27 | pub serial_numbers: Vec, 28 | /// Sets the USB path of the device to connect to 29 | #[structopt(long, global = true)] 30 | pub usb_path: Option, 31 | /// Disables the cache for all secrets. 32 | #[structopt(long, global = true)] 33 | pub no_cache: bool, 34 | #[structopt(subcommand)] 35 | pub cmd: Command, 36 | } 37 | 38 | Enum! { 39 | /// The available Nitrokey models. 40 | DeviceModel, [ 41 | Librem => "librem", 42 | Pro => "pro", 43 | Storage => "storage", 44 | ] 45 | } 46 | 47 | impl From for nitrokey::Model { 48 | fn from(model: DeviceModel) -> nitrokey::Model { 49 | match model { 50 | DeviceModel::Librem => nitrokey::Model::Librem, 51 | DeviceModel::Pro => nitrokey::Model::Pro, 52 | DeviceModel::Storage => nitrokey::Model::Storage, 53 | } 54 | } 55 | } 56 | 57 | impl TryFrom for DeviceModel { 58 | type Error = anyhow::Error; 59 | 60 | fn try_from(model: nitrokey::Model) -> Result { 61 | match model { 62 | nitrokey::Model::Librem => Ok(DeviceModel::Librem), 63 | nitrokey::Model::Pro => Ok(DeviceModel::Pro), 64 | nitrokey::Model::Storage => Ok(DeviceModel::Storage), 65 | _ => Err(anyhow::anyhow!("Unsupported device model: {}", model)), 66 | } 67 | } 68 | } 69 | 70 | impl<'de> serde::Deserialize<'de> for DeviceModel { 71 | fn deserialize(deserializer: D) -> Result 72 | where 73 | D: serde::Deserializer<'de>, 74 | { 75 | use serde::de::Error as _; 76 | use std::str::FromStr as _; 77 | 78 | let s = String::deserialize(deserializer)?; 79 | DeviceModel::from_str(&s).map_err(D::Error::custom) 80 | } 81 | } 82 | 83 | Command! { 84 | /// A top-level command for nitrocli. 85 | Command, [ 86 | /// Reads or writes the device configuration 87 | Config(ConfigArgs) => |ctx, args: ConfigArgs| args.subcmd.execute(ctx), 88 | /// Interacts with the device's encrypted volume 89 | Encrypted(EncryptedArgs) => |ctx, args: EncryptedArgs| args.subcmd.execute(ctx), 90 | /// Fills the SD card with random data 91 | Fill(FillArgs) => |ctx, args: FillArgs| crate::commands::fill(ctx, args.attach), 92 | /// Interacts with the device's hidden volume 93 | Hidden(HiddenArgs) => |ctx, args: HiddenArgs| args.subcmd.execute(ctx), 94 | /// Lists the attached Nitrokey devices 95 | List(ListArgs) => |ctx, args: ListArgs| crate::commands::list(ctx, args.no_connect), 96 | /// Locks the connected Nitrokey device 97 | Lock => crate::commands::lock, 98 | /// Accesses one-time passwords 99 | Otp(OtpArgs) => |ctx, args: OtpArgs| args.subcmd.execute(ctx), 100 | /// Manages the Nitrokey PINs 101 | Pin(PinArgs) => |ctx, args: PinArgs| args.subcmd.execute(ctx), 102 | /// Accesses the password safe 103 | Pws(PwsArgs) => |ctx, args: PwsArgs| args.subcmd.execute(ctx), 104 | /// Performs a factory reset 105 | Reset(ResetArgs) => |ctx, args: ResetArgs| crate::commands::reset(ctx, args.only_aes_key), 106 | /// Prints the status of the connected Nitrokey device 107 | Status => crate::commands::status, 108 | /// Interacts with the device's unencrypted volume 109 | Unencrypted(UnencryptedArgs) => |ctx, args: UnencryptedArgs| args.subcmd.execute(ctx), 110 | /// An extension and its arguments. 111 | #[structopt(external_subcommand)] 112 | Extension(Vec) => crate::commands::extension, 113 | ] 114 | } 115 | 116 | #[derive(Debug, PartialEq, structopt::StructOpt)] 117 | pub struct ConfigArgs { 118 | #[structopt(subcommand)] 119 | subcmd: ConfigCommand, 120 | } 121 | 122 | Command! {ConfigCommand, [ 123 | /// Prints the Nitrokey configuration 124 | Get => crate::commands::config_get, 125 | /// Changes the Nitrokey configuration 126 | Set(ConfigSetArgs) => crate::commands::config_set, 127 | ]} 128 | 129 | #[derive(Debug, PartialEq, structopt::StructOpt)] 130 | pub struct ConfigSetArgs { 131 | /// Sets the Num Lock option to the given HOTP slot 132 | #[structopt(short = "n", long)] 133 | pub num_lock: Option, 134 | /// Unsets the Num Lock option 135 | #[structopt(short = "N", long, conflicts_with("num-lock"))] 136 | pub no_num_lock: bool, 137 | /// Sets the Cap Lock option to the given HOTP slot 138 | #[structopt(short = "c", long)] 139 | pub caps_lock: Option, 140 | /// Unsets the Caps Lock option 141 | #[structopt(short = "C", long, conflicts_with("caps-lock"))] 142 | pub no_caps_lock: bool, 143 | /// Sets the Scroll Lock option to the given HOTP slot 144 | #[structopt(short = "s", long)] 145 | pub scroll_lock: Option, 146 | /// Unsets the Scroll Lock option 147 | #[structopt(short = "S", long, conflicts_with("scroll-lock"))] 148 | pub no_scroll_lock: bool, 149 | /// Requires the user PIN to generate one-time passwords 150 | #[structopt(short = "o", long)] 151 | pub otp_pin: bool, 152 | /// Allows one-time password generation without PIN 153 | #[structopt(short = "O", long, conflicts_with("otp-pin"))] 154 | pub no_otp_pin: bool, 155 | } 156 | 157 | #[derive(Clone, Copy, Debug)] 158 | pub enum ConfigOption { 159 | Enable(T), 160 | Disable, 161 | Ignore, 162 | } 163 | 164 | impl ConfigOption { 165 | pub fn try_from(disable: bool, value: Option, name: &'static str) -> anyhow::Result { 166 | if disable { 167 | anyhow::ensure!( 168 | value.is_none(), 169 | "--{name} and --no-{name} are mutually exclusive", 170 | name = name 171 | ); 172 | Ok(ConfigOption::Disable) 173 | } else { 174 | match value { 175 | Some(value) => Ok(ConfigOption::Enable(value)), 176 | None => Ok(ConfigOption::Ignore), 177 | } 178 | } 179 | } 180 | 181 | pub fn or(self, default: Option) -> Option { 182 | match self { 183 | ConfigOption::Enable(value) => Some(value), 184 | ConfigOption::Disable => None, 185 | ConfigOption::Ignore => default, 186 | } 187 | } 188 | } 189 | 190 | #[derive(Debug, PartialEq, structopt::StructOpt)] 191 | pub struct EncryptedArgs { 192 | #[structopt(subcommand)] 193 | subcmd: EncryptedCommand, 194 | } 195 | 196 | Command! {EncryptedCommand, [ 197 | /// Closes the encrypted volume on a Nitrokey Storage 198 | Close => crate::commands::encrypted_close, 199 | /// Opens the encrypted volume on a Nitrokey Storage 200 | Open => crate::commands::encrypted_open, 201 | ]} 202 | 203 | #[derive(Debug, PartialEq, structopt::StructOpt)] 204 | pub struct FillArgs { 205 | /// Checks if a fill operation is already running and show its progress instead of starting a new 206 | /// operation. 207 | #[structopt(short, long)] 208 | attach: bool, 209 | } 210 | 211 | #[derive(Debug, PartialEq, structopt::StructOpt)] 212 | pub struct HiddenArgs { 213 | #[structopt(subcommand)] 214 | subcmd: HiddenCommand, 215 | } 216 | 217 | Command! {HiddenCommand, [ 218 | /// Closes the hidden volume on a Nitrokey Storage 219 | Close => crate::commands::hidden_close, 220 | /// Creates a hidden volume on a Nitrokey Storage 221 | Create(HiddenCreateArgs) => |ctx, args: HiddenCreateArgs| { 222 | crate::commands::hidden_create(ctx, args.slot, args.start, args.end) 223 | }, 224 | /// Opens the hidden volume on a Nitrokey Storage 225 | Open => crate::commands::hidden_open, 226 | ]} 227 | 228 | #[derive(Debug, PartialEq, structopt::StructOpt)] 229 | pub struct HiddenCreateArgs { 230 | /// The hidden volume slot to use 231 | pub slot: u8, 232 | /// The start location of the hidden volume as a percentage of the encrypted volume's size (0-99) 233 | pub start: u8, 234 | /// The end location of the hidden volume as a percentage of the encrypted volume's size (1-100) 235 | pub end: u8, 236 | } 237 | 238 | #[derive(Debug, PartialEq, structopt::StructOpt)] 239 | pub struct ListArgs { 240 | /// Only print the information that is available without connecting to a device 241 | #[structopt(short, long)] 242 | pub no_connect: bool, 243 | } 244 | 245 | #[derive(Debug, PartialEq, structopt::StructOpt)] 246 | pub struct OtpArgs { 247 | #[structopt(subcommand)] 248 | subcmd: OtpCommand, 249 | } 250 | 251 | Command! {OtpCommand, [ 252 | /// Clears a one-time password slot 253 | Clear(OtpClearArgs) => |ctx, args: OtpClearArgs| { 254 | crate::commands::otp_clear(ctx, args.slot, args.algorithm) 255 | }, 256 | /// Generates a one-time password 257 | Get(OtpGetArgs) => |ctx, args: OtpGetArgs| { 258 | crate::commands::otp_get(ctx, args.slot, args.algorithm, args.time) 259 | }, 260 | /// Configures a one-time password slot 261 | Set(OtpSetArgs) => crate::commands::otp_set, 262 | /// Prints the status of the one-time password slots 263 | Status(OtpStatusArgs) => |ctx, args: OtpStatusArgs| crate::commands::otp_status(ctx, args.all), 264 | ]} 265 | 266 | #[derive(Debug, PartialEq, structopt::StructOpt)] 267 | pub struct OtpClearArgs { 268 | /// The OTP algorithm to use 269 | #[structopt(short, long, default_value = OtpAlgorithm::Totp.as_ref(), 270 | possible_values = &OtpAlgorithm::all_str())] 271 | pub algorithm: OtpAlgorithm, 272 | /// The OTP slot to clear 273 | pub slot: u8, 274 | } 275 | 276 | #[derive(Debug, PartialEq, structopt::StructOpt)] 277 | pub struct OtpGetArgs { 278 | /// The OTP algorithm to use 279 | #[structopt(short, long, default_value = OtpAlgorithm::Totp.as_ref(), 280 | possible_values = &OtpAlgorithm::all_str())] 281 | pub algorithm: OtpAlgorithm, 282 | /// The time to use for TOTP generation (Unix timestamp) [default: system time] 283 | #[structopt(short, long)] 284 | pub time: Option, 285 | /// The OTP slot to use 286 | pub slot: u8, 287 | } 288 | 289 | #[derive(Debug, PartialEq, structopt::StructOpt)] 290 | pub struct OtpSetArgs { 291 | /// The OTP algorithm to use 292 | #[structopt(short, long, default_value = OtpAlgorithm::Totp.as_ref(), 293 | possible_values = &OtpAlgorithm::all_str())] 294 | pub algorithm: OtpAlgorithm, 295 | /// The number of digits to use for the one-time password 296 | #[structopt(short, long, default_value = OtpMode::SixDigits.as_ref(), 297 | possible_values = &OtpMode::all_str())] 298 | pub digits: OtpMode, 299 | /// The counter value for HOTP 300 | #[structopt(short, long, default_value = "0")] 301 | pub counter: u64, 302 | /// The time window for TOTP 303 | #[structopt(short, long, default_value = "30")] 304 | pub time_window: u16, 305 | /// The format of the secret 306 | #[structopt(short, long, default_value = OtpSecretFormat::Base32.as_ref(), 307 | possible_values = &OtpSecretFormat::all_str())] 308 | pub format: OtpSecretFormat, 309 | /// The OTP slot to use 310 | pub slot: u8, 311 | /// The name of the slot 312 | pub name: String, 313 | /// The secret to store on the slot as a base32 encoded string (or in 314 | /// the format set with the --format option) 315 | pub secret: String, 316 | } 317 | 318 | #[derive(Debug, PartialEq, structopt::StructOpt)] 319 | pub struct OtpStatusArgs { 320 | /// Shows slots that are not programmed 321 | #[structopt(short, long)] 322 | pub all: bool, 323 | } 324 | 325 | Enum! {OtpAlgorithm, [ 326 | Hotp => "hotp", 327 | Totp => "totp", 328 | ]} 329 | 330 | Enum! {OtpMode, [ 331 | SixDigits => "6", 332 | EightDigits => "8", 333 | ]} 334 | 335 | impl From for nitrokey::OtpMode { 336 | fn from(mode: OtpMode) -> Self { 337 | match mode { 338 | OtpMode::SixDigits => nitrokey::OtpMode::SixDigits, 339 | OtpMode::EightDigits => nitrokey::OtpMode::EightDigits, 340 | } 341 | } 342 | } 343 | 344 | Enum! {OtpSecretFormat, [ 345 | Ascii => "ascii", 346 | Base32 => "base32", 347 | Hex => "hex", 348 | ]} 349 | 350 | #[derive(Debug, PartialEq, structopt::StructOpt)] 351 | pub struct PinArgs { 352 | #[structopt(subcommand)] 353 | subcmd: PinCommand, 354 | } 355 | 356 | Command! {PinCommand, [ 357 | /// Clears the cached PINs 358 | Clear => crate::commands::pin_clear, 359 | /// Changes a PIN 360 | Set(PinSetArgs) => |ctx, args: PinSetArgs| crate::commands::pin_set(ctx, args.pintype), 361 | /// Unblocks and resets the user PIN 362 | Unblock => crate::commands::pin_unblock, 363 | ]} 364 | 365 | Enum! { 366 | /// PIN type requested from pinentry. 367 | /// 368 | /// The available PIN types correspond to the PIN types used by the 369 | /// Nitrokey devices: user and admin. 370 | PinType, [ 371 | Admin => "admin", 372 | User => "user", 373 | ] 374 | } 375 | 376 | #[derive(Debug, PartialEq, structopt::StructOpt)] 377 | pub struct PinSetArgs { 378 | /// The PIN type to change 379 | #[structopt(name = "type", possible_values = &PinType::all_str())] 380 | pub pintype: PinType, 381 | } 382 | 383 | #[derive(Debug, PartialEq, structopt::StructOpt)] 384 | pub struct PwsArgs { 385 | #[structopt(subcommand)] 386 | subcmd: PwsCommand, 387 | } 388 | 389 | Command! {PwsCommand, [ 390 | /// Clears a password safe slot 391 | Clear(PwsClearArgs) => |ctx, args: PwsClearArgs| crate::commands::pws_clear(ctx, args.slot), 392 | /// Reads a password safe slot 393 | Get(PwsGetArgs) => |ctx, args: PwsGetArgs| { 394 | crate::commands::pws_get(ctx, args.slot, args.name, args.login, args.password, args.quiet) 395 | }, 396 | /// Adds a new password safe slot 397 | Add(PwsAddArgs) => |ctx, args: PwsAddArgs| { 398 | crate::commands::pws_add(ctx, &args.name, &args.login, &args.password, args.slot) 399 | }, 400 | /// Updates a password safe slot 401 | Update(PwsUpdateArgs) => |ctx, args: PwsUpdateArgs| { 402 | crate::commands::pws_update( 403 | ctx, 404 | args.slot, 405 | args.name.as_deref(), 406 | args.login.as_deref(), 407 | args.password.as_deref() 408 | ) 409 | }, 410 | /// Prints the status of the password safe slots 411 | Status(PwsStatusArgs) => |ctx, args: PwsStatusArgs| crate::commands::pws_status(ctx, args.all), 412 | ]} 413 | 414 | #[derive(Debug, PartialEq, structopt::StructOpt)] 415 | pub struct PwsClearArgs { 416 | /// The PWS slot to clear 417 | pub slot: u8, 418 | } 419 | 420 | #[derive(Debug, PartialEq, structopt::StructOpt)] 421 | pub struct PwsGetArgs { 422 | /// Shows the name stored on the slot 423 | #[structopt(short, long)] 424 | pub name: bool, 425 | /// Shows the login stored on the slot 426 | #[structopt(short, long)] 427 | pub login: bool, 428 | /// Shows the password stored on the slot 429 | #[structopt(short, long)] 430 | pub password: bool, 431 | /// Prints the stored data without description 432 | #[structopt(short, long)] 433 | pub quiet: bool, 434 | /// The PWS slot to read 435 | pub slot: u8, 436 | } 437 | 438 | #[derive(Debug, PartialEq, structopt::StructOpt)] 439 | pub struct PwsAddArgs { 440 | /// The name to store on the slot 441 | pub name: String, 442 | /// The login to store on the slot 443 | pub login: String, 444 | /// The password to store on the slot 445 | pub password: String, 446 | /// The number of the slot to write 447 | /// 448 | /// If this option is not set, the first unprogrammed slot is used. 449 | #[structopt(short, long)] 450 | pub slot: Option, 451 | } 452 | 453 | #[derive(Debug, PartialEq, structopt::StructOpt)] 454 | pub struct PwsUpdateArgs { 455 | /// The PWS slot to update 456 | pub slot: u8, 457 | /// The new name to store on the slot 458 | #[structopt(short, long)] 459 | pub name: Option, 460 | /// The new login to store on the slot 461 | #[structopt(short, long)] 462 | pub login: Option, 463 | /// The new password to store on the slot 464 | #[structopt(short, long)] 465 | pub password: Option, 466 | } 467 | 468 | #[derive(Debug, PartialEq, structopt::StructOpt)] 469 | pub struct PwsStatusArgs { 470 | /// Shows slots that are not programmed 471 | #[structopt(short, long)] 472 | pub all: bool, 473 | } 474 | 475 | #[derive(Debug, PartialEq, structopt::StructOpt)] 476 | pub struct ResetArgs { 477 | /// Only build a new AES key instead of performing a full factory reset. 478 | #[structopt(long)] 479 | pub only_aes_key: bool, 480 | } 481 | 482 | #[derive(Debug, PartialEq, structopt::StructOpt)] 483 | pub struct UnencryptedArgs { 484 | #[structopt(subcommand)] 485 | subcmd: UnencryptedCommand, 486 | } 487 | 488 | Command! {UnencryptedCommand, [ 489 | /// Changes the configuration of the unencrypted volume on a Nitrokey Storage 490 | Set(UnencryptedSetArgs) => |ctx, args: UnencryptedSetArgs| { 491 | crate::commands::unencrypted_set(ctx, args.mode) 492 | }, 493 | ]} 494 | 495 | #[derive(Debug, PartialEq, structopt::StructOpt)] 496 | pub struct UnencryptedSetArgs { 497 | /// The mode to change to 498 | #[structopt(name = "type", possible_values = &UnencryptedVolumeMode::all_str())] 499 | pub mode: UnencryptedVolumeMode, 500 | } 501 | 502 | Enum! {UnencryptedVolumeMode, [ 503 | ReadWrite => "read-write", 504 | ReadOnly => "read-only", 505 | ]} 506 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | // config.rs 2 | 3 | // Copyright (C) 2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::fs; 7 | use std::path; 8 | use std::str::FromStr as _; 9 | 10 | use serde::de::Error as _; 11 | use serde::Deserialize as _; 12 | 13 | use crate::args; 14 | 15 | use anyhow::Context as _; 16 | 17 | /// The name of nitrocli's configuration file relative to the 18 | /// application configuration directory. 19 | /// 20 | /// The application configuration directory is determined using the 21 | /// `directories` crate. For Unix, it is `$XDG_CONFIG_HOME/nitrocli` 22 | /// (defaults to `$HOME/.config/nitrocli`). 23 | const CONFIG_FILE: &str = "config.toml"; 24 | 25 | /// The configuration for nitrocli, usually read from configuration 26 | /// files and environment variables. 27 | #[derive(Clone, Debug, Default, PartialEq, merge::Merge, serde::Deserialize)] 28 | pub struct Config { 29 | /// The model to connect to. 30 | pub model: Option, 31 | /// The serial numbers of the device to connect to. 32 | #[merge(strategy = merge::vec::overwrite_empty)] 33 | #[serde(default, deserialize_with = "deserialize_serial_number_vec")] 34 | pub serial_numbers: Vec, 35 | /// The USB path of the device to connect to. 36 | pub usb_path: Option, 37 | /// Whether to bypass the cache for all secrets or not. 38 | #[merge(strategy = merge::bool::overwrite_false)] 39 | #[serde(default)] 40 | pub no_cache: bool, 41 | /// The log level. 42 | #[merge(strategy = merge::num::overwrite_zero)] 43 | #[serde(default)] 44 | pub verbosity: u8, 45 | } 46 | 47 | fn deserialize_serial_number_vec<'de, D>(d: D) -> Result, D::Error> 48 | where 49 | D: serde::Deserializer<'de>, 50 | { 51 | let strings = Vec::::deserialize(d).map_err(D::Error::custom)?; 52 | let result = strings 53 | .iter() 54 | .map(|s| nitrokey::SerialNumber::from_str(s)) 55 | .collect::>(); 56 | result.map_err(D::Error::custom) 57 | } 58 | 59 | impl Config { 60 | pub fn load() -> anyhow::Result { 61 | use merge::Merge as _; 62 | 63 | let mut config = Config::default(); 64 | if let Some(user_config) = load_user_config()? { 65 | config.merge(user_config); 66 | } 67 | config.merge(load_env_config()?); 68 | 69 | Ok(config) 70 | } 71 | 72 | pub fn update(&mut self, args: &args::Args) { 73 | if args.model.is_some() { 74 | self.model = args.model; 75 | } 76 | if !args.serial_numbers.is_empty() { 77 | self.serial_numbers = args.serial_numbers.clone(); 78 | } 79 | if args.usb_path.is_some() { 80 | self.usb_path = args.usb_path.clone(); 81 | } 82 | if args.no_cache { 83 | self.no_cache = true; 84 | } 85 | if args.verbose > 0 { 86 | self.verbosity = args.verbose; 87 | } 88 | } 89 | } 90 | 91 | fn load_user_config() -> anyhow::Result> { 92 | let project_dirs = directories::ProjectDirs::from("", "", "nitrocli") 93 | .context("Could not determine the nitrocli application directory")?; 94 | let path = project_dirs.config_dir().join(CONFIG_FILE); 95 | if path.is_file() { 96 | read_config_file(&path).map(Some) 97 | } else { 98 | Ok(None) 99 | } 100 | } 101 | 102 | fn load_env_config() -> anyhow::Result { 103 | envy::prefixed("NITROCLI_") 104 | .from_env() 105 | .context("Failed to parse environment variables") 106 | } 107 | 108 | pub fn read_config_file(path: &path::Path) -> anyhow::Result { 109 | let s = fs::read_to_string(path) 110 | .with_context(|| format!("Failed to read configuration file '{}'", path.display()))?; 111 | toml::from_str(&s) 112 | .with_context(|| format!("Failed to parse configuration file '{}'", path.display())) 113 | } 114 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // main.rs 2 | 3 | // Copyright (C) 2017-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | #![warn( 7 | bad_style, 8 | broken_intra_doc_links, 9 | dead_code, 10 | future_incompatible, 11 | improper_ctypes, 12 | late_bound_lifetime_arguments, 13 | missing_debug_implementations, 14 | missing_docs, 15 | no_mangle_generic_items, 16 | non_shorthand_field_patterns, 17 | nonstandard_style, 18 | overflowing_literals, 19 | path_statements, 20 | patterns_in_fns_without_body, 21 | proc_macro_derive_resolution_fallback, 22 | renamed_and_removed_lints, 23 | rust_2018_compatibility, 24 | rust_2018_idioms, 25 | stable_features, 26 | trivial_bounds, 27 | trivial_numeric_casts, 28 | type_alias_bounds, 29 | tyvar_behind_raw_pointer, 30 | unconditional_recursion, 31 | unreachable_code, 32 | unreachable_patterns, 33 | unstable_features, 34 | unstable_name_collisions, 35 | unused, 36 | unused_comparisons, 37 | unused_import_braces, 38 | unused_lifetimes, 39 | unused_qualifications, 40 | unused_results, 41 | while_true 42 | )] 43 | 44 | //! Nitrocli is a program providing a command line interface to certain 45 | //! commands of Nitrokey Pro and Storage devices. 46 | 47 | #[macro_use] 48 | mod redefine; 49 | #[macro_use] 50 | mod arg_util; 51 | 52 | mod args; 53 | mod commands; 54 | mod config; 55 | mod output; 56 | mod pinentry; 57 | #[cfg(test)] 58 | mod tests; 59 | mod tty; 60 | 61 | use std::env; 62 | use std::error; 63 | use std::ffi; 64 | use std::fmt; 65 | use std::io; 66 | use std::process; 67 | use std::str; 68 | 69 | use structopt::clap::ErrorKind; 70 | use structopt::clap::SubCommand; 71 | use structopt::StructOpt; 72 | 73 | const NITROCLI_BINARY: &str = "NITROCLI_BINARY"; 74 | const NITROCLI_RESOLVED_USB_PATH: &str = "NITROCLI_RESOLVED_USB_PATH"; 75 | const NITROCLI_MODEL: &str = "NITROCLI_MODEL"; 76 | const NITROCLI_USB_PATH: &str = "NITROCLI_USB_PATH"; 77 | const NITROCLI_VERBOSITY: &str = "NITROCLI_VERBOSITY"; 78 | const NITROCLI_NO_CACHE: &str = "NITROCLI_NO_CACHE"; 79 | const NITROCLI_SERIAL_NUMBERS: &str = "NITROCLI_SERIAL_NUMBERS"; 80 | 81 | const NITROCLI_ADMIN_PIN: &str = "NITROCLI_ADMIN_PIN"; 82 | const NITROCLI_USER_PIN: &str = "NITROCLI_USER_PIN"; 83 | const NITROCLI_NEW_ADMIN_PIN: &str = "NITROCLI_NEW_ADMIN_PIN"; 84 | const NITROCLI_NEW_USER_PIN: &str = "NITROCLI_NEW_USER_PIN"; 85 | const NITROCLI_PASSWORD: &str = "NITROCLI_PASSWORD"; 86 | 87 | /// A special error type that indicates the desire to exit directly, 88 | /// without additional error reporting. 89 | /// 90 | /// This error is mostly used by the extension support code so that we 91 | /// are able to mirror the extension's exit code while preserving our 92 | /// context logic and the fairly isolated testing it enables. 93 | struct DirectExitError(i32); 94 | 95 | impl fmt::Debug for DirectExitError { 96 | fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { 97 | unreachable!() 98 | } 99 | } 100 | 101 | impl fmt::Display for DirectExitError { 102 | fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { 103 | unreachable!() 104 | } 105 | } 106 | 107 | impl error::Error for DirectExitError {} 108 | 109 | /// Parse the command-line arguments and execute the selected command. 110 | fn handle_arguments(ctx: &mut Context<'_>, argv: Vec) -> anyhow::Result<()> { 111 | let version = get_version_string(); 112 | let clap = args::Args::clap().version(version.as_str()); 113 | match clap.get_matches_from_safe(argv.iter()) { 114 | Ok(matches) => { 115 | let args = args::Args::from_clap(&matches); 116 | ctx.config.update(&args); 117 | args.cmd.execute(ctx) 118 | } 119 | Err(mut err) => { 120 | if err.kind == ErrorKind::HelpDisplayed { 121 | // For the convenience of the user we'd like to list the 122 | // available extensions in the help text. At the same time, we 123 | // don't want to unconditionally iterate through PATH (which may 124 | // contain directories with loads of files that need scanning) 125 | // for every command invoked. So we do that listing only if a 126 | // help text is actually displayed. 127 | let path = ctx.path.clone().unwrap_or_default(); 128 | if let Ok(extensions) = commands::discover_extensions(&path) { 129 | let mut clap = args::Args::clap(); 130 | for name in extensions { 131 | // Because of clap's brain dead API, we see no other way 132 | // but to leak the string we created here. That's okay, 133 | // though, because we exit in a moment anyway. 134 | let about = Box::leak(format!("Run the {} extension", name).into_boxed_str()); 135 | clap = clap.subcommand( 136 | SubCommand::with_name(&name) 137 | // Use some magic number here that causes all 138 | // extensions to be listed after all other 139 | // subcommands. 140 | .display_order(1000) 141 | .about(about as &'static str), 142 | ); 143 | } 144 | // At this point we are *pretty* sure that repeated invocation 145 | // will result in another error. So should be fine to unwrap 146 | // here. 147 | err = clap.get_matches_from_safe(argv.iter()).unwrap_err(); 148 | } 149 | } 150 | 151 | if err.use_stderr() { 152 | Err(err.into()) 153 | } else { 154 | println!(ctx, "{}", err.message)?; 155 | Ok(()) 156 | } 157 | } 158 | } 159 | } 160 | 161 | fn get_version_string() -> String { 162 | let version = env!("CARGO_PKG_VERSION"); 163 | let built_from = if let Some(git_revision) = option_env!("NITROCLI_GIT_REVISION") { 164 | format!(" (built from {})", git_revision) 165 | } else { 166 | "".to_string() 167 | }; 168 | let libnitrokey = if let Ok(library_version) = nitrokey::get_library_version() { 169 | format!("libnitrokey {}", library_version) 170 | } else { 171 | "an undetectable libnitrokey version".to_string() 172 | }; 173 | 174 | format!("{}{} using {}", version, built_from, libnitrokey) 175 | } 176 | 177 | /// The context used when running the program. 178 | #[allow(missing_debug_implementations)] 179 | pub struct Context<'io> { 180 | /// The `Read` object used as standard input throughout the program. 181 | pub stdin: &'io mut dyn io::Read, 182 | /// The `Write` object used as standard output throughout the program. 183 | pub stdout: &'io mut dyn io::Write, 184 | /// The `Write` object used as standard error throughout the program. 185 | pub stderr: &'io mut dyn io::Write, 186 | /// Whether `stdout` is a TTY. 187 | pub is_tty: bool, 188 | /// The content of the `PATH` environment variable. 189 | pub path: Option, 190 | /// The admin PIN, if provided through an environment variable. 191 | pub admin_pin: Option, 192 | /// The user PIN, if provided through an environment variable. 193 | pub user_pin: Option, 194 | /// The new admin PIN to set, if provided through an environment variable. 195 | /// 196 | /// This variable is only used by commands that change the admin PIN. 197 | pub new_admin_pin: Option, 198 | /// The new user PIN, if provided through an environment variable. 199 | /// 200 | /// This variable is only used by commands that change the user PIN. 201 | pub new_user_pin: Option, 202 | /// A password used by some commands, if provided through an environment variable. 203 | pub password: Option, 204 | /// The configuration, usually read from configuration files and environment 205 | /// variables. 206 | pub config: config::Config, 207 | } 208 | 209 | impl<'io> Context<'io> { 210 | fn from_env( 211 | stdin: &'io mut I, 212 | stdout: &'io mut O, 213 | stderr: &'io mut E, 214 | is_tty: bool, 215 | config: config::Config, 216 | ) -> Context<'io> 217 | where 218 | I: io::Read, 219 | O: io::Write, 220 | E: io::Write, 221 | { 222 | Context { 223 | stdin, 224 | stdout, 225 | stderr, 226 | is_tty, 227 | // The std::env module has several references to the PATH 228 | // environment variable, indicating that this name is considered 229 | // platform independent from their perspective. We do the same. 230 | path: env::var_os("PATH"), 231 | admin_pin: env::var_os(NITROCLI_ADMIN_PIN), 232 | user_pin: env::var_os(NITROCLI_USER_PIN), 233 | new_admin_pin: env::var_os(NITROCLI_NEW_ADMIN_PIN), 234 | new_user_pin: env::var_os(NITROCLI_NEW_USER_PIN), 235 | password: env::var_os(NITROCLI_PASSWORD), 236 | config, 237 | } 238 | } 239 | } 240 | 241 | fn evaluate_err(err: anyhow::Error, stderr: &mut dyn io::Write) -> i32 { 242 | if let Some(err) = err.root_cause().downcast_ref::() { 243 | err.0 244 | } else { 245 | let _ = writeln!(stderr, "{:#}", err); 246 | 1 247 | } 248 | } 249 | 250 | fn run<'ctx, 'io: 'ctx>(ctx: &'ctx mut Context<'io>, args: Vec) -> i32 { 251 | handle_arguments(ctx, args) 252 | .map(|()| 0) 253 | .unwrap_or_else(|err| evaluate_err(err, ctx.stderr)) 254 | } 255 | 256 | fn main() { 257 | use std::io::Write; 258 | 259 | let mut stdin = io::stdin(); 260 | let mut stdout = io::stdout(); 261 | let mut stderr = io::stderr(); 262 | 263 | let rc = match config::Config::load() { 264 | Ok(config) => { 265 | let is_tty = termion::is_tty(&stdout); 266 | let args = env::args().collect::>(); 267 | let ctx = &mut Context::from_env(&mut stdin, &mut stdout, &mut stderr, is_tty, config); 268 | 269 | run(ctx, args) 270 | } 271 | Err(err) => evaluate_err(err, &mut stderr), 272 | }; 273 | 274 | // We exit the process the hard way below. The problem is that because 275 | // of this, buffered IO may not be flushed. So make sure to explicitly 276 | // flush before exiting. Note that stderr is unbuffered, alleviating 277 | // the need for any flushing there. 278 | // Ideally we would just make `main` return an i32 and let Rust deal 279 | // with all of this, but the `process::Termination` functionality is 280 | // still unstable and we have no way to convince the caller to "just 281 | // exit" without printing additional information. 282 | let _ = stdout.flush(); 283 | process::exit(rc); 284 | } 285 | -------------------------------------------------------------------------------- /src/output.rs: -------------------------------------------------------------------------------- 1 | // output.rs 2 | 3 | // Copyright (C) 2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use anyhow::Context as _; 7 | 8 | use progressing::Baring as _; 9 | 10 | use termion::cursor::DetectCursorPos as _; 11 | use termion::raw::IntoRawMode as _; 12 | 13 | use crate::Context; 14 | 15 | /// A progress bar that can be printed to an interactive output. 16 | pub struct ProgressBar { 17 | /// Whether to redraw the entire progress bar in the next call to `draw`. 18 | redraw: bool, 19 | /// The current progress of the progress bar (0 <= progress <= 100). 20 | progress: u8, 21 | /// Toggled on every call to `draw` to print a pulsing indicator. 22 | toggle: bool, 23 | /// Whether this progress bar finished. 24 | finished: bool, 25 | } 26 | 27 | impl ProgressBar { 28 | /// Creates a new empty progress bar. 29 | pub fn new(progress: u8) -> ProgressBar { 30 | ProgressBar { 31 | redraw: true, 32 | progress, 33 | toggle: false, 34 | finished: false, 35 | } 36 | } 37 | 38 | /// Whether this progress bar is finished. 39 | pub fn is_finished(&self) -> bool { 40 | self.finished 41 | } 42 | 43 | /// Updates the progress bar with the given progress (0 <= progress <= 100). 44 | pub fn update(&mut self, progress: u8) -> anyhow::Result<()> { 45 | anyhow::ensure!(!self.finished, "Tried to update finished progress bar"); 46 | anyhow::ensure!( 47 | progress <= 100, 48 | "Progress bar value out of range: {}", 49 | progress 50 | ); 51 | if progress != self.progress { 52 | self.redraw = true; 53 | self.progress = progress; 54 | } 55 | self.toggle = !self.toggle; 56 | Ok(()) 57 | } 58 | 59 | /// Finish this progress bar. 60 | /// 61 | /// A finished progress bar may no longer be updated. 62 | pub fn finish(&mut self) { 63 | self.finished = true; 64 | self.redraw = true; 65 | self.progress = 100; 66 | } 67 | 68 | /// Print the progress bar to the stdout set in the given context. 69 | /// 70 | /// On every call of this method (as long as the progress bar is not 71 | /// finished), a pulsing indicator is printed to show that the process 72 | /// is still running. If there was progress since the last call to 73 | /// `draw`, or if this is the first call, this function will also 74 | /// print the progress bar itself. 75 | pub fn draw(&self, ctx: &mut Context<'_>) -> anyhow::Result<()> { 76 | if !ctx.is_tty { 77 | return Ok(()); 78 | } 79 | 80 | let pos = ctx 81 | .stdout 82 | .into_raw_mode() 83 | .context("Failed to activate raw mode")? 84 | .cursor_pos() 85 | .context("Failed to query cursor position")?; 86 | 87 | let progress_char = if self.toggle && !self.finished { 88 | "." 89 | } else { 90 | " " 91 | }; 92 | 93 | if self.redraw { 94 | let mut progress_bar = progressing::mapping::Bar::with_range(0, 100); 95 | progress_bar.set(self.progress); 96 | 97 | print!(ctx, "{}", termion::clear::CurrentLine)?; 98 | print!(ctx, "{}", termion::cursor::Goto(1, pos.1))?; 99 | print!(ctx, " {} {}", progress_char, progress_bar)?; 100 | if self.finished { 101 | println!(ctx)?; 102 | } 103 | } else { 104 | print!(ctx, "{}{}", termion::cursor::Goto(2, pos.1), progress_char)?; 105 | } 106 | 107 | ctx.stdout.flush()?; 108 | Ok(()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/pinentry.rs: -------------------------------------------------------------------------------- 1 | // pinentry.rs 2 | 3 | // Copyright (C) 2017-2022 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::borrow; 7 | use std::env; 8 | use std::ffi; 9 | use std::fmt; 10 | use std::process; 11 | use std::str; 12 | 13 | use anyhow::Context as _; 14 | 15 | use crate::args; 16 | use crate::tty; 17 | use crate::Context; 18 | 19 | type CowStr = borrow::Cow<'static, str>; 20 | 21 | /// A trait representing a secret to be entered by the user. 22 | pub trait SecretEntry: fmt::Debug { 23 | /// The cache ID to use for this secret. 24 | fn cache_id(&self) -> Option; 25 | /// The prompt to display when asking for the secret. 26 | fn prompt(&self) -> CowStr; 27 | /// The description to display when asking for the secret. 28 | fn description(&self, mode: Mode) -> CowStr; 29 | /// The minimum number of characters the secret needs to have. 30 | fn min_len(&self) -> u8; 31 | } 32 | 33 | #[derive(Debug)] 34 | pub struct PinEntry { 35 | pin_type: args::PinType, 36 | model: nitrokey::Model, 37 | serial: nitrokey::SerialNumber, 38 | } 39 | 40 | impl PinEntry { 41 | pub fn from<'mgr, D>(pin_type: args::PinType, device: &D) -> anyhow::Result 42 | where 43 | D: nitrokey::Device<'mgr>, 44 | { 45 | let model = device.get_model(); 46 | let serial = device 47 | .get_serial_number() 48 | .context("Failed to retrieve serial number")?; 49 | 50 | Ok(Self { 51 | pin_type, 52 | model, 53 | serial, 54 | }) 55 | } 56 | 57 | pub fn pin_type(&self) -> args::PinType { 58 | self.pin_type 59 | } 60 | } 61 | 62 | impl SecretEntry for PinEntry { 63 | fn cache_id(&self) -> Option { 64 | let model = match self.model { 65 | nitrokey::Model::Librem => "librem", 66 | nitrokey::Model::Pro => "pro", 67 | nitrokey::Model::Storage => "storage", 68 | _ => "unknown", 69 | }; 70 | let suffix = format!("{}:{}", model, self.serial); 71 | let cache_id = match self.pin_type { 72 | args::PinType::Admin => format!("nitrocli:admin:{}", suffix), 73 | args::PinType::User => format!("nitrocli:user:{}", suffix), 74 | }; 75 | Some(cache_id.into()) 76 | } 77 | 78 | fn prompt(&self) -> CowStr { 79 | match self.pin_type { 80 | args::PinType::Admin => "Admin PIN", 81 | args::PinType::User => "User PIN", 82 | } 83 | .into() 84 | } 85 | 86 | fn description(&self, mode: Mode) -> CowStr { 87 | format!( 88 | "{} for\r{} {}", 89 | match self.pin_type { 90 | args::PinType::Admin => match mode { 91 | Mode::Choose => "Please enter a new admin PIN", 92 | Mode::Confirm => "Please confirm the new admin PIN", 93 | Mode::Query => "Please enter the admin PIN", 94 | }, 95 | args::PinType::User => match mode { 96 | Mode::Choose => "Please enter a new user PIN", 97 | Mode::Confirm => "Please confirm the new user PIN", 98 | Mode::Query => "Please enter the user PIN", 99 | }, 100 | }, 101 | self.model, 102 | self.serial, 103 | ) 104 | .into() 105 | } 106 | 107 | fn min_len(&self) -> u8 { 108 | match self.pin_type { 109 | args::PinType::Admin => 8, 110 | args::PinType::User => 6, 111 | } 112 | } 113 | } 114 | 115 | #[derive(Debug)] 116 | pub struct PwdEntry { 117 | model: nitrokey::Model, 118 | serial: nitrokey::SerialNumber, 119 | } 120 | 121 | impl PwdEntry { 122 | pub fn from<'mgr, D>(device: &D) -> anyhow::Result 123 | where 124 | D: nitrokey::Device<'mgr>, 125 | { 126 | let model = device.get_model(); 127 | let serial = device 128 | .get_serial_number() 129 | .context("Failed to retrieve serial number")?; 130 | 131 | Ok(Self { model, serial }) 132 | } 133 | } 134 | 135 | impl SecretEntry for PwdEntry { 136 | fn cache_id(&self) -> Option { 137 | None 138 | } 139 | 140 | fn prompt(&self) -> CowStr { 141 | "Password".into() 142 | } 143 | 144 | fn description(&self, mode: Mode) -> CowStr { 145 | format!( 146 | "{} for\r{} {}", 147 | match mode { 148 | Mode::Choose => "Please enter a new hidden volume password", 149 | Mode::Confirm => "Please confirm the new hidden volume password", 150 | Mode::Query => "Please enter a hidden volume password", 151 | }, 152 | self.model, 153 | self.serial, 154 | ) 155 | .into() 156 | } 157 | 158 | fn min_len(&self) -> u8 { 159 | // More or less arbitrary minimum length based on the fact that the 160 | // manual mentions six letter passwords in examples. Users 161 | // *probably* should go longer than that, but we don't want to be 162 | // too opinionated. 163 | 6 164 | } 165 | } 166 | 167 | /// Secret entry mode for pinentry. 168 | /// 169 | /// This enum describes the context of the pinentry query, for example 170 | /// prompting for the current secret or requesting a new one. The mode 171 | /// may affect the pinentry description and whether a quality bar is 172 | /// shown. 173 | #[derive(Clone, Copy, Debug, PartialEq)] 174 | pub enum Mode { 175 | /// Let the user choose a new secret. 176 | Choose, 177 | /// Let the user confirm the previously chosen secret. 178 | Confirm, 179 | /// Query an existing secret. 180 | Query, 181 | } 182 | 183 | impl Mode { 184 | fn show_quality_bar(self) -> bool { 185 | self == Mode::Choose 186 | } 187 | } 188 | 189 | fn parse_pinentry_pin(response: R) -> anyhow::Result 190 | where 191 | R: AsRef, 192 | { 193 | const DATA_PREFIX: &str = "D "; 194 | const ERR_PREFIX: &str = "ERR "; 195 | 196 | let string = response.as_ref(); 197 | let lines: Vec<&str> = string.lines().collect(); 198 | 199 | // We expect the response to be of the form: 200 | // > D passphrase 201 | // > OK 202 | // or potentially: 203 | // > ERR 83886179 Operation cancelled 204 | // 205 | // Furthermore, in case of an empty password we'd get just an OK. 206 | match lines.as_slice() { 207 | ["OK"] => Ok(String::new()), 208 | [line, "OK"] if line.starts_with(DATA_PREFIX) => { 209 | let (_, pass) = line.split_at(DATA_PREFIX.len()); 210 | Ok(pass.to_string()) 211 | } 212 | [line] if line.starts_with(ERR_PREFIX) => { 213 | let (_, error) = line.split_at(ERR_PREFIX.len()); 214 | anyhow::bail!("{}", error); 215 | } 216 | _ => anyhow::bail!("Unexpected response: {}", string), 217 | } 218 | } 219 | 220 | /// Ensure that the `GPG_TTY` environment variable is present in the 221 | /// environment, setting it as appropriate if that is not currently the 222 | /// case. 223 | fn ensure_gpg_tty(command: &mut process::Command) -> &mut process::Command { 224 | const GPG_TTY: &str = "GPG_TTY"; 225 | 226 | if let Some(tty) = env::var_os(GPG_TTY) { 227 | // We don't strictly speaking need to set the variable here, because 228 | // it would be inherited anyway. But we want to make that explicit. 229 | command.env(GPG_TTY, tty) 230 | } else if let Ok(tty) = tty::retrieve_tty() { 231 | command.env(GPG_TTY, tty) 232 | } else { 233 | command 234 | } 235 | } 236 | 237 | /// Connect to `gpg-agent`, run the provided command, and return the 238 | /// output it emitted. 239 | fn gpg_agent(command: C) -> anyhow::Result 240 | where 241 | C: AsRef, 242 | { 243 | ensure_gpg_tty(&mut process::Command::new("gpg-connect-agent")) 244 | .arg(command) 245 | .arg("/bye") 246 | .output() 247 | .context("Failed to invoke gpg-connect-agent") 248 | } 249 | 250 | /// Inquire a secret from the user. 251 | /// 252 | /// This function inquires a secret from the user or returns a cached 253 | /// entry, if available (and if caching is not disabled for the given 254 | /// execution context). If an error message is set, it is displayed in 255 | /// the entry dialog. The mode describes the context of the pinentry 256 | /// dialog. It is used to choose an appropriate description and to 257 | /// decide whether a quality bar is shown in the dialog. 258 | pub fn inquire( 259 | ctx: &mut Context<'_>, 260 | entry: &E, 261 | mode: Mode, 262 | error_msg: Option<&str>, 263 | ) -> anyhow::Result 264 | where 265 | E: SecretEntry, 266 | { 267 | let cache_id = entry 268 | .cache_id() 269 | .and_then(|id| if ctx.config.no_cache { None } else { Some(id) }) 270 | // "X" is a sentinel value indicating that no caching is desired. 271 | .unwrap_or_else(|| "X".into()) 272 | .into(); 273 | 274 | let error_msg = error_msg 275 | .map(|msg| msg.replace(' ', "+")) 276 | .unwrap_or_else(|| String::from("+")); 277 | let prompt = entry.prompt().replace(' ', "+"); 278 | let description = entry.description(mode).replace(' ', "+"); 279 | 280 | let mut command = "GET_PASSPHRASE --data ".to_string(); 281 | if mode.show_quality_bar() { 282 | command += "--qualitybar "; 283 | } 284 | command += &[cache_id, error_msg, prompt, description].join(" "); 285 | 286 | // An error reported for the GET_PASSPHRASE command does not actually 287 | // cause gpg-connect-agent to exit with a non-zero error code, we have 288 | // to evaluate the output to determine success/failure. 289 | let output = gpg_agent(command)?; 290 | let response = 291 | str::from_utf8(&output.stdout).context("Failed to parse gpg-connect-agent output as UTF-8")?; 292 | parse_pinentry_pin(response).context("Failed to parse pinentry secret") 293 | } 294 | 295 | fn check(entry: &E, secret: &str) -> anyhow::Result<()> 296 | where 297 | E: SecretEntry, 298 | { 299 | if secret.len() < usize::from(entry.min_len()) { 300 | anyhow::bail!( 301 | "The secret must be at least {} characters long", 302 | entry.min_len() 303 | ) 304 | } else { 305 | Ok(()) 306 | } 307 | } 308 | 309 | pub fn choose(ctx: &mut Context<'_>, entry: &E) -> anyhow::Result 310 | where 311 | E: SecretEntry, 312 | { 313 | clear(entry)?; 314 | let chosen = inquire(ctx, entry, Mode::Choose, None)?; 315 | clear(entry)?; 316 | check(entry, &chosen)?; 317 | 318 | let confirmed = inquire(ctx, entry, Mode::Confirm, None)?; 319 | clear(entry)?; 320 | 321 | if chosen != confirmed { 322 | anyhow::bail!("Entered secrets do not match") 323 | } else { 324 | Ok(chosen) 325 | } 326 | } 327 | 328 | fn parse_pinentry_response(response: R) -> anyhow::Result<()> 329 | where 330 | R: AsRef, 331 | { 332 | let string = response.as_ref(); 333 | let lines = string.lines().collect::>(); 334 | 335 | if lines.len() == 1 && lines[0] == "OK" { 336 | // We got the only valid answer we accept. 337 | return Ok(()); 338 | } 339 | anyhow::bail!("Unexpected response: {}", string) 340 | } 341 | 342 | /// Clear the cached secret represented by the given entry. 343 | pub fn clear(entry: &E) -> anyhow::Result<()> 344 | where 345 | E: SecretEntry, 346 | { 347 | if let Some(cache_id) = entry.cache_id() { 348 | let command = format!("CLEAR_PASSPHRASE {}", cache_id); 349 | let output = gpg_agent(command)?; 350 | let response = str::from_utf8(&output.stdout) 351 | .context("Failed to parse gpg-connect-agent output as UTF-8")?; 352 | 353 | parse_pinentry_response(response).context("Failed to parse pinentry response") 354 | } else { 355 | Ok(()) 356 | } 357 | } 358 | 359 | #[cfg(test)] 360 | mod tests { 361 | use super::*; 362 | 363 | #[test] 364 | fn parse_pinentry_pin_empty() { 365 | let response = "OK\n"; 366 | let expected = ""; 367 | 368 | assert_eq!(parse_pinentry_pin(response).unwrap(), expected) 369 | } 370 | 371 | #[test] 372 | fn parse_pinentry_pin_good() { 373 | let response = "D passphrase\nOK\n"; 374 | let expected = "passphrase"; 375 | 376 | assert_eq!(parse_pinentry_pin(response).unwrap(), expected) 377 | } 378 | 379 | #[test] 380 | fn parse_pinentry_pin_error() { 381 | let error = "83886179 Operation cancelled"; 382 | let response = "ERR ".to_string() + error + "\n"; 383 | let expected = error; 384 | 385 | let error = parse_pinentry_pin(response).unwrap_err(); 386 | assert_eq!(error.to_string(), expected) 387 | } 388 | 389 | #[test] 390 | fn parse_pinentry_pin_unexpected() { 391 | let response = "foobar\n"; 392 | let expected = format!("Unexpected response: {}", response); 393 | let error = parse_pinentry_pin(response).unwrap_err(); 394 | assert_eq!(error.to_string(), expected) 395 | } 396 | 397 | #[test] 398 | fn parse_pinentry_response_ok() { 399 | assert!(parse_pinentry_response("OK\n").is_ok()) 400 | } 401 | 402 | #[test] 403 | fn parse_pinentry_response_ok_no_newline() { 404 | assert!(parse_pinentry_response("OK").is_ok()) 405 | } 406 | 407 | #[test] 408 | fn parse_pinentry_response_unexpected() { 409 | let response = "ERR 42"; 410 | let expected = format!("Unexpected response: {}", response); 411 | let error = parse_pinentry_response(response).unwrap_err(); 412 | assert_eq!(error.to_string(), expected) 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /src/redefine.rs: -------------------------------------------------------------------------------- 1 | // redefine.rs 2 | 3 | // Copyright (C) 2019-2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | // A replacement of the standard println!() macro that requires an 7 | // execution context as the first argument and prints to its stdout. 8 | macro_rules! println { 9 | ($ctx:expr) => { 10 | writeln!($ctx.stdout, "") 11 | }; 12 | ($ctx:expr, $($arg:tt)*) => { 13 | writeln!($ctx.stdout, $($arg)*) 14 | }; 15 | } 16 | 17 | macro_rules! print { 18 | ($ctx:expr, $($arg:tt)*) => { 19 | write!($ctx.stdout, $($arg)*) 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/tests/config.rs: -------------------------------------------------------------------------------- 1 | // config.rs 2 | 3 | // Copyright (C) 2019-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test] 9 | fn mutually_exclusive_set_options() { 10 | fn test(option1: &str, option2: &str) { 11 | let (rc, out, err) = Nitrocli::new().run(&["config", "set", option1, option2]); 12 | 13 | assert_ne!(rc, 0); 14 | assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); 15 | 16 | let err = String::from_utf8(err).unwrap(); 17 | assert!(err.contains("cannot be used with"), "{}", err); 18 | } 19 | 20 | test("-c", "-C"); 21 | test("-o", "-O"); 22 | test("-s", "-S"); 23 | } 24 | 25 | #[test_device] 26 | fn get(model: nitrokey::Model) -> anyhow::Result<()> { 27 | let re = regex::Regex::new( 28 | r#"^Config: 29 | num lock binding: (not set|\d+) 30 | caps lock binding: (not set|\d+) 31 | scroll lock binding: (not set|\d+) 32 | require user PIN for OTP: (true|false) 33 | $"#, 34 | ) 35 | .unwrap(); 36 | 37 | let out = Nitrocli::new().model(model).handle(&["config", "get"])?; 38 | 39 | assert!(re.is_match(&out), "{}", out); 40 | Ok(()) 41 | } 42 | 43 | #[test_device] 44 | fn set_wrong_usage(model: nitrokey::Model) { 45 | let err = Nitrocli::new() 46 | .model(model) 47 | .handle(&["config", "set", "--num-lock", "2", "-N"]) 48 | .unwrap_err() 49 | .to_string(); 50 | 51 | assert!( 52 | err.contains("The argument '--num-lock ' cannot be used with '--no-num-lock'"), 53 | "{}", 54 | err, 55 | ); 56 | } 57 | 58 | #[test_device] 59 | fn set_get(model: nitrokey::Model) -> anyhow::Result<()> { 60 | let mut ncli = Nitrocli::new().model(model); 61 | let _ = ncli.handle(&["config", "set", "-s", "1", "-c", "0", "-N"])?; 62 | 63 | let re = regex::Regex::new( 64 | r#"^Config: 65 | num lock binding: not set 66 | caps lock binding: 0 67 | scroll lock binding: 1 68 | require user PIN for OTP: (true|false) 69 | $"#, 70 | ) 71 | .unwrap(); 72 | 73 | let out = ncli.handle(&["config", "get"])?; 74 | assert!(re.is_match(&out), "{}", out); 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /src/tests/encrypted.rs: -------------------------------------------------------------------------------- 1 | // encrypted.rs 2 | 3 | // Copyright (C) 2019-2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test_device(storage)] 9 | fn status_open_close(model: nitrokey::Model) -> anyhow::Result<()> { 10 | fn make_re(open: Option) -> regex::Regex { 11 | let encrypted = match open { 12 | Some(open) => { 13 | if open { 14 | "active" 15 | } else { 16 | "(read-only|inactive)" 17 | } 18 | } 19 | None => "(read-only|active|inactive)", 20 | }; 21 | let re = format!( 22 | r#" 23 | volumes: 24 | unencrypted: (read-only|active|inactive) 25 | encrypted: {} 26 | hidden: (read-only|active|inactive) 27 | $"#, 28 | encrypted 29 | ); 30 | regex::Regex::new(&re).unwrap() 31 | } 32 | 33 | let mut ncli = Nitrocli::new().model(model); 34 | let out = ncli.handle(&["status"])?; 35 | assert!(make_re(None).is_match(&out), "{}", out); 36 | 37 | let _ = ncli.handle(&["encrypted", "open"])?; 38 | let out = ncli.handle(&["status"])?; 39 | assert!(make_re(Some(true)).is_match(&out), "{}", out); 40 | 41 | let _ = ncli.handle(&["encrypted", "close"])?; 42 | let out = ncli.handle(&["status"])?; 43 | assert!(make_re(Some(false)).is_match(&out), "{}", out); 44 | 45 | Ok(()) 46 | } 47 | 48 | #[test_device(pro)] 49 | fn encrypted_open_on_pro(model: nitrokey::Model) { 50 | let err = Nitrocli::new() 51 | .model(model) 52 | .handle(&["encrypted", "open"]) 53 | .unwrap_err() 54 | .to_string(); 55 | 56 | assert_eq!( 57 | err, 58 | "This command is only available on the Nitrokey Storage", 59 | ); 60 | } 61 | 62 | #[test_device(storage)] 63 | fn encrypted_open_close(model: nitrokey::Model) -> anyhow::Result<()> { 64 | let mut ncli = Nitrocli::new().model(model); 65 | let out = ncli.handle(&["encrypted", "open"])?; 66 | assert!(out.is_empty()); 67 | 68 | { 69 | let mut manager = nitrokey::force_take()?; 70 | let device = manager.connect_storage()?; 71 | assert!(device.get_storage_status()?.encrypted_volume.active); 72 | assert!(!device.get_storage_status()?.hidden_volume.active); 73 | } 74 | 75 | let out = ncli.handle(&["encrypted", "close"])?; 76 | assert!(out.is_empty()); 77 | 78 | { 79 | let mut manager = nitrokey::force_take()?; 80 | let device = manager.connect_storage()?; 81 | assert!(!device.get_storage_status()?.encrypted_volume.active); 82 | assert!(!device.get_storage_status()?.hidden_volume.active); 83 | } 84 | 85 | Ok(()) 86 | } 87 | -------------------------------------------------------------------------------- /src/tests/extension_var_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (C) 2020-2022 The Nitrocli Developers 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | from argparse import ( 7 | ArgumentParser, 8 | ) 9 | from enum import ( 10 | Enum, 11 | ) 12 | from os import ( 13 | environ, 14 | ) 15 | from sys import ( 16 | argv, 17 | exit, 18 | ) 19 | 20 | 21 | def main(args): 22 | """The extension's main function.""" 23 | parser = ArgumentParser() 24 | parser.add_argument(dest="env", action="store", default=None) 25 | parser.add_argument("--nitrocli", action="store", default=None) 26 | parser.add_argument("--model", action="store", default=None) 27 | # We deliberately store the argument to this option as a string 28 | # because we can differentiate between None and a valid value, in 29 | # order to verify that it really was supplied. 30 | parser.add_argument("--verbosity", action="store", default=None) 31 | 32 | namespace = parser.parse_args(args[1:]) 33 | try: 34 | print(environ[f"{namespace.env}"]) 35 | except KeyError: 36 | return 1 37 | 38 | return 0 39 | 40 | 41 | if __name__ == "__main__": 42 | exit(main(argv)) 43 | -------------------------------------------------------------------------------- /src/tests/extensions.rs: -------------------------------------------------------------------------------- 1 | // extensions.rs 2 | 3 | // Copyright (C) 2020-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::env; 7 | use std::fs; 8 | 9 | use super::*; 10 | 11 | #[test] 12 | fn no_extensions_to_discover() -> anyhow::Result<()> { 13 | let exts = crate::commands::discover_extensions(&ffi::OsString::new())?; 14 | assert!(exts.is_empty(), "{:?}", exts); 15 | Ok(()) 16 | } 17 | 18 | #[test] 19 | fn extension_discovery() -> anyhow::Result<()> { 20 | let dir1 = tempfile::tempdir()?; 21 | let dir2 = tempfile::tempdir()?; 22 | 23 | { 24 | let ext1_path = dir1.path().join("nitrocli-ext1"); 25 | let ext2_path = dir1.path().join("nitrocli-ext2"); 26 | let ext3_path = dir2.path().join("nitrocli-super-1337-extensions111one"); 27 | let _ext1 = fs::File::create(ext1_path)?; 28 | let _ext2 = fs::File::create(ext2_path)?; 29 | let _ext3 = fs::File::create(ext3_path)?; 30 | 31 | let path = env::join_paths([dir1.path(), dir2.path()].iter())?; 32 | let mut exts = crate::commands::discover_extensions(&path)?; 33 | // We can't assume a fixed ordering of extensions, because that is 34 | // platform/file system dependent. So sort here to fix it. 35 | exts.sort(); 36 | assert_eq!(exts, vec!["ext1", "ext2", "super-1337-extensions111one"]); 37 | } 38 | Ok(()) 39 | } 40 | 41 | #[test] 42 | fn resolve_extensions() -> anyhow::Result<()> { 43 | let dir1 = tempfile::tempdir()?; 44 | let dir2 = tempfile::tempdir()?; 45 | 46 | { 47 | let ext1_path = dir1.path().join("nitrocli-ext1"); 48 | let ext2_path = dir1.path().join("nitrocli-ext2"); 49 | let ext3_path = dir2.path().join("nitrocli-super-1337-extensions111one"); 50 | let _ext1 = fs::File::create(&ext1_path)?; 51 | let _ext2 = fs::File::create(&ext2_path)?; 52 | let _ext3 = fs::File::create(&ext3_path)?; 53 | 54 | let path = env::join_paths([dir1.path(), dir2.path()].iter())?; 55 | assert_eq!( 56 | crate::commands::resolve_extension(&path, ffi::OsStr::new("ext1"))?, 57 | ext1_path 58 | ); 59 | assert_eq!( 60 | crate::commands::resolve_extension(&path, ffi::OsStr::new("ext2"))?, 61 | ext2_path 62 | ); 63 | assert_eq!( 64 | crate::commands::resolve_extension(&path, ffi::OsStr::new("super-1337-extensions111one"))?, 65 | ext3_path 66 | ); 67 | 68 | let err = 69 | crate::commands::resolve_extension(ffi::OsStr::new(""), ffi::OsStr::new("ext1")).unwrap_err(); 70 | assert_eq!(err.to_string(), "Extension nitrocli-ext1 not found"); 71 | } 72 | Ok(()) 73 | } 74 | -------------------------------------------------------------------------------- /src/tests/fill.rs: -------------------------------------------------------------------------------- 1 | // fill.rs 2 | 3 | // Copyright (C) 2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | // Ignore this test as it takes about one hour to execute 9 | #[ignore] 10 | #[test_device(storage)] 11 | fn fill(model: nitrokey::Model) -> anyhow::Result<()> { 12 | let res = Nitrocli::new().model(model).handle(&["fill"]); 13 | assert!(res.is_ok()); 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /src/tests/hidden.rs: -------------------------------------------------------------------------------- 1 | // hidden.rs 2 | 3 | // Copyright (C) 2019-2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test_device(storage)] 9 | fn hidden_create_open_close(model: nitrokey::Model) -> anyhow::Result<()> { 10 | let mut ncli = Nitrocli::new().model(model).password("1234567"); 11 | let out = ncli.handle(&["hidden", "create", "0", "50", "100"])?; 12 | assert!(out.is_empty()); 13 | 14 | let out = ncli.handle(&["hidden", "open"])?; 15 | assert!(out.is_empty()); 16 | 17 | { 18 | let mut manager = nitrokey::force_take()?; 19 | let device = manager.connect_storage()?; 20 | assert!(!device.get_storage_status()?.encrypted_volume.active); 21 | assert!(device.get_storage_status()?.hidden_volume.active); 22 | } 23 | 24 | let out = ncli.handle(&["hidden", "close"])?; 25 | assert!(out.is_empty()); 26 | 27 | { 28 | let mut manager = nitrokey::force_take()?; 29 | let device = manager.connect_storage()?; 30 | assert!(!device.get_storage_status()?.encrypted_volume.active); 31 | assert!(!device.get_storage_status()?.hidden_volume.active); 32 | } 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/tests/list.rs: -------------------------------------------------------------------------------- 1 | // list.rs 2 | 3 | // Copyright (C) 2020-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test_device] 9 | fn not_connected() -> anyhow::Result<()> { 10 | let res = Nitrocli::new().handle(&["list"])?; 11 | assert_eq!(res, "No Nitrokey device connected\n"); 12 | 13 | Ok(()) 14 | } 15 | 16 | #[test_device] 17 | fn connected(model: nitrokey::Model) -> anyhow::Result<()> { 18 | let re = regex::Regex::new( 19 | r#"^USB path\tmodel\tserial number 20 | ([[:^space:]]+\t(Nitrokey Pro|Nitrokey Storage|Librem Key|unknown)\t0x[[:xdigit:]]+ 21 | )+$"#, 22 | ) 23 | .unwrap(); 24 | 25 | let out = Nitrocli::new().model(model).handle(&["list"])?; 26 | assert!(re.is_match(&out), "{}", out); 27 | Ok(()) 28 | } 29 | -------------------------------------------------------------------------------- /src/tests/lock.rs: -------------------------------------------------------------------------------- 1 | // lock.rs 2 | 3 | // Copyright (C) 2019-2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test_device(pro)] 9 | fn lock_pro(model: nitrokey::Model) -> anyhow::Result<()> { 10 | // We can't really test much more here than just success of the command. 11 | let out = Nitrocli::new().model(model).handle(&["lock"])?; 12 | assert!(out.is_empty()); 13 | 14 | Ok(()) 15 | } 16 | 17 | #[test_device(storage)] 18 | fn lock_storage(model: nitrokey::Model) -> anyhow::Result<()> { 19 | let mut ncli = Nitrocli::new().model(model); 20 | let _ = ncli.handle(&["encrypted", "open"])?; 21 | 22 | let out = ncli.handle(&["lock"])?; 23 | assert!(out.is_empty()); 24 | 25 | let mut manager = nitrokey::force_take()?; 26 | let device = manager.connect_storage()?; 27 | assert!(!device.get_storage_status()?.encrypted_volume.active); 28 | 29 | Ok(()) 30 | } 31 | -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | // mod.rs 2 | 3 | // Copyright (C) 2019-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::ffi; 7 | 8 | use nitrokey_test::test as test_device; 9 | 10 | mod config; 11 | mod encrypted; 12 | mod extensions; 13 | mod fill; 14 | mod hidden; 15 | mod list; 16 | mod lock; 17 | mod otp; 18 | mod pin; 19 | mod pws; 20 | mod reset; 21 | mod run; 22 | mod status; 23 | mod unencrypted; 24 | 25 | struct Nitrocli { 26 | stdin: String, 27 | model: Option, 28 | path: Option, 29 | admin_pin: Option, 30 | user_pin: Option, 31 | new_admin_pin: Option, 32 | new_user_pin: Option, 33 | password: Option, 34 | } 35 | 36 | impl Nitrocli { 37 | pub fn new() -> Self { 38 | Self { 39 | stdin: String::new(), 40 | model: None, 41 | path: None, 42 | admin_pin: Some(nitrokey::DEFAULT_ADMIN_PIN.into()), 43 | user_pin: Some(nitrokey::DEFAULT_USER_PIN.into()), 44 | new_admin_pin: None, 45 | new_user_pin: None, 46 | password: None, 47 | } 48 | } 49 | 50 | /// Set the model to use. 51 | fn model(mut self, model: nitrokey::Model) -> Self { 52 | self.model = Some(model); 53 | self 54 | } 55 | 56 | /// Set the password to use for certain operations. 57 | fn password(mut self, password: impl Into) -> Self { 58 | self.password = Some(password.into()); 59 | self 60 | } 61 | 62 | /// Set the `PATH` used for looking up extensions. 63 | fn path(mut self, path: impl Into) -> Self { 64 | self.path = Some(path.into()); 65 | self 66 | } 67 | 68 | pub fn stdin(mut self, stdin: impl Into) -> Self { 69 | self.stdin = stdin.into(); 70 | self 71 | } 72 | 73 | pub fn admin_pin(mut self, pin: impl Into) -> Self { 74 | self.admin_pin = Some(pin.into()); 75 | self 76 | } 77 | 78 | pub fn new_admin_pin(mut self, pin: impl Into) -> Self { 79 | self.new_admin_pin = Some(pin.into()); 80 | self 81 | } 82 | 83 | pub fn user_pin(mut self, pin: impl Into) -> Self { 84 | self.user_pin = Some(pin.into()); 85 | self 86 | } 87 | 88 | pub fn new_user_pin(mut self, pin: impl Into) -> Self { 89 | self.new_user_pin = Some(pin.into()); 90 | self 91 | } 92 | 93 | fn model_to_arg(model: nitrokey::Model) -> &'static str { 94 | match model { 95 | nitrokey::Model::Librem => "--model=librem", 96 | nitrokey::Model::Pro => "--model=pro", 97 | nitrokey::Model::Storage => "--model=storage", 98 | _ => panic!("Unexpected model in test suite: {}", model), 99 | } 100 | } 101 | 102 | fn do_run(&mut self, args: &[&str], f: F) -> (R, Vec, Vec) 103 | where 104 | F: FnOnce(&mut crate::Context<'_>, Vec) -> R, 105 | { 106 | let args = ["nitrocli"] 107 | .iter() 108 | .cloned() 109 | .chain(self.model.map(Self::model_to_arg)) 110 | .chain(args.iter().cloned()) 111 | .map(ToOwned::to_owned) 112 | .collect(); 113 | 114 | let mut stdin = self.stdin.as_bytes(); 115 | let mut stdout = Vec::new(); 116 | let mut stderr = Vec::new(); 117 | 118 | let ctx = &mut crate::Context { 119 | stdin: &mut stdin, 120 | stdout: &mut stdout, 121 | stderr: &mut stderr, 122 | is_tty: false, 123 | path: self.path.clone(), 124 | admin_pin: self.admin_pin.clone(), 125 | user_pin: self.user_pin.clone(), 126 | new_admin_pin: self.new_admin_pin.clone(), 127 | new_user_pin: self.new_user_pin.clone(), 128 | password: self.password.clone(), 129 | config: crate::config::Config { 130 | no_cache: true, 131 | ..Default::default() 132 | }, 133 | }; 134 | 135 | (f(ctx, args), stdout, stderr) 136 | } 137 | 138 | /// Run `nitrocli`'s `run` function. 139 | pub fn run(&mut self, args: &[&str]) -> (i32, Vec, Vec) { 140 | self.do_run(args, |c, a| crate::run(c, a)) 141 | } 142 | 143 | /// Run `nitrocli`'s `handle_arguments` function. 144 | pub fn handle(&mut self, args: &[&str]) -> anyhow::Result { 145 | let (res, out, _) = self.do_run(args, crate::handle_arguments); 146 | res.map(|_| String::from_utf8_lossy(&out).into_owned()) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/tests/otp.rs: -------------------------------------------------------------------------------- 1 | // otp.rs 2 | 3 | // Copyright (C) 2019-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | use crate::args; 9 | 10 | #[test_device] 11 | fn set_invalid_slot_raw(model: nitrokey::Model) { 12 | let (rc, out, err) = Nitrocli::new() 13 | .model(model) 14 | .run(&["otp", "set", "100", "name", "1234", "-f", "hex"]); 15 | 16 | assert_ne!(rc, 0); 17 | assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); 18 | assert_eq!( 19 | &err[..24], 20 | b"Failed to write OTP slot", 21 | "{}", 22 | String::from_utf8_lossy(&err) 23 | ); 24 | } 25 | 26 | #[test_device] 27 | fn set_invalid_slot(model: nitrokey::Model) { 28 | let err = Nitrocli::new() 29 | .model(model) 30 | .handle(&["otp", "set", "100", "name", "1234", "-f", "hex"]) 31 | .unwrap_err() 32 | .to_string(); 33 | 34 | assert_eq!(err, "Failed to write OTP slot"); 35 | } 36 | 37 | #[test_device] 38 | fn set_overlong_name(model: nitrokey::Model) { 39 | let err = Nitrocli::new() 40 | .model(model) 41 | .handle(&["otp", "set", "0", "1234567890123456", "1234"]) 42 | .unwrap_err() 43 | .to_string(); 44 | assert_eq!( 45 | err, 46 | "The provided slot name is too long (actual length: 16 bytes, maximum length: 15 bytes)" 47 | ); 48 | 49 | let err = Nitrocli::new() 50 | .model(model) 51 | .handle(&["otp", "set", "0", "ö23456789012345", "1234"]) 52 | .unwrap_err() 53 | .to_string(); 54 | assert_eq!( 55 | err, 56 | "The provided slot name is too long (actual length: 16 bytes, maximum length: 15 bytes)" 57 | ); 58 | 59 | let err = Nitrocli::new() 60 | .model(model) 61 | .handle(&["otp", "set", "0", "1234567890123456789012345", "1234"]) 62 | .unwrap_err() 63 | .to_string(); 64 | assert_eq!( 65 | err, 66 | "The provided slot name is too long (actual length: 25 bytes, maximum length: 15 bytes)" 67 | ); 68 | } 69 | 70 | #[test_device] 71 | fn status(model: nitrokey::Model) -> anyhow::Result<()> { 72 | let re = regex::Regex::new( 73 | r#"^alg\tslot\tname 74 | ((totp|hotp)\t\d+\t.+\n)+$"#, 75 | ) 76 | .unwrap(); 77 | 78 | let mut ncli = Nitrocli::new().model(model); 79 | // Make sure that we have at least something to display by ensuring 80 | // that there is one slot programmed. 81 | let _ = ncli.handle(&["otp", "set", "0", "the-name", "123456", "-f", "hex"])?; 82 | 83 | let out = ncli.handle(&["otp", "status"])?; 84 | assert!(re.is_match(&out), "{}", out); 85 | Ok(()) 86 | } 87 | 88 | #[test_device] 89 | fn set_get_hotp(model: nitrokey::Model) -> anyhow::Result<()> { 90 | // Secret and expected HOTP values as per RFC 4226: Appendix D -- HOTP 91 | // Algorithm: Test Values. 92 | const SECRET: &str = "12345678901234567890"; 93 | const OTP1: &str = concat!(755224, "\n"); 94 | const OTP2: &str = concat!(287082, "\n"); 95 | 96 | let mut ncli = Nitrocli::new().model(model); 97 | let _ = ncli.handle(&[ 98 | "otp", "set", "-a", "hotp", "-f", "ascii", "1", "name", SECRET, 99 | ])?; 100 | 101 | let out = ncli.handle(&["otp", "get", "-a", "hotp", "1"])?; 102 | assert_eq!(out, OTP1); 103 | 104 | let out = ncli.handle(&["otp", "get", "-a", "hotp", "1"])?; 105 | assert_eq!(out, OTP2); 106 | Ok(()) 107 | } 108 | 109 | #[test_device] 110 | fn set_get_totp(model: nitrokey::Model) -> anyhow::Result<()> { 111 | // Secret and expected TOTP values as per RFC 6238: Appendix B -- 112 | // Test Vectors. 113 | const SECRET: &str = "12345678901234567890"; 114 | const TIME: &str = stringify!(1111111111); 115 | const OTP: &str = concat!(14050471, "\n"); 116 | 117 | let mut ncli = Nitrocli::new().model(model); 118 | let _ = ncli.handle(&["otp", "set", "-d", "8", "-f", "ascii", "2", "name", SECRET])?; 119 | 120 | let out = ncli.handle(&["otp", "get", "-t", TIME, "2"])?; 121 | assert_eq!(out, OTP); 122 | Ok(()) 123 | } 124 | 125 | #[test_device] 126 | fn set_totp_uneven_chars(model: nitrokey::Model) -> anyhow::Result<()> { 127 | let secrets = [ 128 | (args::OtpSecretFormat::Hex, "123"), 129 | (args::OtpSecretFormat::Base32, "FBILDWWGA2"), 130 | ]; 131 | 132 | for (format, secret) in &secrets { 133 | let mut ncli = Nitrocli::new().model(model); 134 | let _ = ncli.handle(&["otp", "set", "-f", format.as_ref(), "3", "foobar", secret])?; 135 | } 136 | Ok(()) 137 | } 138 | 139 | #[test_device] 140 | fn set_stdin(model: nitrokey::Model) -> anyhow::Result<()> { 141 | const SECRET: &str = "12345678901234567890"; 142 | const TIME: &str = stringify!(1111111111); 143 | const OTP: &str = concat!(14050471, "\n"); 144 | 145 | let _ = Nitrocli::new() 146 | .model(model) 147 | .stdin(SECRET) 148 | .handle(&["otp", "set", "-d", "8", "-f", "ascii", "2", "name", "-"])?; 149 | 150 | let out = Nitrocli::new() 151 | .model(model) 152 | .handle(&["otp", "get", "-t", TIME, "2"])?; 153 | assert_eq!(out, OTP); 154 | Ok(()) 155 | } 156 | 157 | #[test_device] 158 | fn clear(model: nitrokey::Model) -> anyhow::Result<()> { 159 | let mut ncli = Nitrocli::new().model(model); 160 | let _ = ncli.handle(&["otp", "set", "3", "hotp-test", "abcdef"])?; 161 | let _ = ncli.handle(&["otp", "clear", "3"])?; 162 | let res = ncli.handle(&["otp", "get", "3"]); 163 | 164 | let err = res.unwrap_err().to_string(); 165 | assert_eq!(err, "Failed to generate OTP"); 166 | Ok(()) 167 | } 168 | -------------------------------------------------------------------------------- /src/tests/pin.rs: -------------------------------------------------------------------------------- 1 | // pin.rs 2 | 3 | // Copyright (C) 2019-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use nitrokey::Authenticate; 7 | use nitrokey::Device; 8 | 9 | use super::*; 10 | 11 | #[test_device] 12 | fn unblock(model: nitrokey::Model) -> anyhow::Result<()> { 13 | { 14 | let mut manager = nitrokey::force_take()?; 15 | let device = manager.connect_model(model)?; 16 | let (device, err) = device.authenticate_user("wrong-pin").unwrap_err(); 17 | match err { 18 | nitrokey::Error::CommandError(nitrokey::CommandError::WrongPassword) => (), 19 | _ => panic!("Unexpected error variant found: {:?}", err), 20 | } 21 | assert!(device.get_user_retry_count()? < 3); 22 | } 23 | 24 | let _ = Nitrocli::new().model(model).handle(&["pin", "unblock"])?; 25 | 26 | { 27 | let mut manager = nitrokey::force_take()?; 28 | let device = manager.connect_model(model)?; 29 | assert_eq!(device.get_user_retry_count()?, 3); 30 | } 31 | Ok(()) 32 | } 33 | 34 | #[test_device] 35 | fn set_user(model: nitrokey::Model) -> anyhow::Result<()> { 36 | let ncli = Nitrocli::new().model(model); 37 | // Set a new user PIN. 38 | let mut ncli = ncli.new_user_pin("new-pin"); 39 | let out = ncli.handle(&["pin", "set", "user"])?; 40 | assert!(out.is_empty()); 41 | 42 | { 43 | let mut manager = nitrokey::force_take()?; 44 | let device = manager.connect_model(model)?; 45 | let (_, err) = device 46 | .authenticate_user(nitrokey::DEFAULT_USER_PIN) 47 | .unwrap_err(); 48 | 49 | match err { 50 | nitrokey::Error::CommandError(nitrokey::CommandError::WrongPassword) => (), 51 | _ => panic!("Unexpected error variant found: {:?}", err), 52 | } 53 | } 54 | 55 | // Revert to the default user PIN. 56 | let mut ncli = ncli 57 | .user_pin("new-pin") 58 | .new_user_pin(nitrokey::DEFAULT_USER_PIN); 59 | 60 | let out = ncli.handle(&["pin", "set", "user"])?; 61 | assert!(out.is_empty()); 62 | 63 | { 64 | let mut manager = nitrokey::force_take()?; 65 | let device = manager.connect_model(model)?; 66 | let _ = device 67 | .authenticate_user(nitrokey::DEFAULT_USER_PIN) 68 | .unwrap(); 69 | } 70 | Ok(()) 71 | } 72 | -------------------------------------------------------------------------------- /src/tests/pws.rs: -------------------------------------------------------------------------------- 1 | // pws.rs 2 | 3 | // Copyright (C) 2019-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use nitrokey::GetPasswordSafe as _; 7 | 8 | use super::*; 9 | 10 | fn clear_pws(model: nitrokey::Model) -> anyhow::Result<()> { 11 | let mut manager = nitrokey::force_take()?; 12 | let mut device = manager.connect_model(model)?; 13 | let mut pws = device.get_password_safe(nitrokey::DEFAULT_USER_PIN)?; 14 | let slots_to_clear = pws 15 | .get_slots()? 16 | .into_iter() 17 | .flatten() 18 | .map(|s| s.index()) 19 | .collect::>(); 20 | for slot in slots_to_clear { 21 | pws.erase_slot(slot)?; 22 | } 23 | Ok(()) 24 | } 25 | 26 | fn assert_slot( 27 | model: nitrokey::Model, 28 | slot: u8, 29 | name: &str, 30 | login: &str, 31 | password: &str, 32 | ) -> anyhow::Result<()> { 33 | let mut ncli = Nitrocli::new().model(model); 34 | let out = ncli.handle(&["pws", "get", &slot.to_string(), "--quiet"])?; 35 | assert_eq!(format!("{}\n{}\n{}\n", name, login, password), out); 36 | Ok(()) 37 | } 38 | 39 | #[test_device] 40 | fn add_invalid_slot(model: nitrokey::Model) { 41 | let err = Nitrocli::new() 42 | .model(model) 43 | .handle(&["pws", "add", "--slot", "100", "name", "login", "1234"]) 44 | .unwrap_err() 45 | .to_string(); 46 | 47 | assert_eq!(err, "Encountered invalid slot index: 100"); 48 | } 49 | 50 | #[test_device] 51 | fn add_overlong_data(model: nitrokey::Model) { 52 | let err = Nitrocli::new() 53 | .model(model) 54 | .handle(&[ 55 | "pws", 56 | "add", 57 | "--slot", 58 | "1", 59 | "123456789012", 60 | "123456789012345678901234567890123", 61 | "123456789012345678901", 62 | ]) 63 | .unwrap_err() 64 | .to_string(); 65 | assert_eq!( 66 | err, 67 | "Multiple provided strings are too long: 68 | slot name (actual length: 12 bytes, maximum length: 11 bytes) 69 | login (actual length: 33 bytes, maximum length: 32 bytes) 70 | password (actual length: 21 bytes, maximum length: 20 bytes)" 71 | ); 72 | 73 | let err = Nitrocli::new() 74 | .model(model) 75 | .handle(&[ 76 | "pws", 77 | "add", 78 | "--slot", 79 | "1", 80 | "123456789012", 81 | "12345678901234567890123456789012", 82 | "12345678901234567890", 83 | ]) 84 | .unwrap_err() 85 | .to_string(); 86 | assert_eq!( 87 | err, 88 | "The provided slot name is too long (actual length: 12 bytes, maximum length: 11 bytes)" 89 | ); 90 | } 91 | 92 | #[test_device] 93 | fn update_overlong_data(model: nitrokey::Model) { 94 | let err = Nitrocli::new() 95 | .model(model) 96 | .handle(&[ 97 | "pws", 98 | "update", 99 | "1", 100 | "--name", 101 | "123456789012", 102 | "--login", 103 | "123456789012345678901234567890123", 104 | "--password", 105 | "123456789012345678901", 106 | ]) 107 | .unwrap_err() 108 | .to_string(); 109 | assert_eq!( 110 | err, 111 | "Multiple provided strings are too long: 112 | slot name (actual length: 12 bytes, maximum length: 11 bytes) 113 | login (actual length: 33 bytes, maximum length: 32 bytes) 114 | password (actual length: 21 bytes, maximum length: 20 bytes)" 115 | ); 116 | 117 | let err = Nitrocli::new() 118 | .model(model) 119 | .handle(&["pws", "update", "1", "--name", "123456789012"]) 120 | .unwrap_err() 121 | .to_string(); 122 | assert_eq!( 123 | err, 124 | "The provided slot name is too long (actual length: 12 bytes, maximum length: 11 bytes)" 125 | ); 126 | } 127 | 128 | #[test_device] 129 | fn status(model: nitrokey::Model) -> anyhow::Result<()> { 130 | let re = regex::Regex::new( 131 | r#"^slot\tname 132 | (\d+\t.+\n)+$"#, 133 | ) 134 | .unwrap(); 135 | 136 | clear_pws(model)?; 137 | 138 | let mut ncli = Nitrocli::new().model(model); 139 | // Make sure that we have at least something to display by ensuring 140 | // that there are there is one slot programmed. 141 | let _ = ncli.handle(&["pws", "add", "the-name", "the-login", "123456"])?; 142 | 143 | let out = ncli.handle(&["pws", "status"])?; 144 | assert!(re.is_match(&out), "{}", out); 145 | Ok(()) 146 | } 147 | 148 | #[test_device] 149 | fn add_get(model: nitrokey::Model) -> anyhow::Result<()> { 150 | const NAME: &str = "dropbox"; 151 | const LOGIN: &str = "d-e-s-o"; 152 | const PASSWORD: &str = "my-secret-password"; 153 | 154 | clear_pws(model)?; 155 | 156 | let mut ncli = Nitrocli::new().model(model); 157 | let _ = ncli.handle(&["pws", "add", "--slot", "1", NAME, LOGIN, PASSWORD])?; 158 | 159 | let out = ncli.handle(&["pws", "get", "1", "--quiet", "--name"])?; 160 | assert_eq!(out, format!("{}\n", NAME)); 161 | 162 | let out = ncli.handle(&["pws", "get", "1", "--quiet", "--login"])?; 163 | assert_eq!(out, format!("{}\n", LOGIN)); 164 | 165 | let out = ncli.handle(&["pws", "get", "1", "--quiet", "--password"])?; 166 | assert_eq!(out, format!("{}\n", PASSWORD)); 167 | 168 | assert_slot(model, 1, NAME, LOGIN, PASSWORD)?; 169 | 170 | let out = ncli.handle(&["pws", "get", "1"])?; 171 | assert_eq!( 172 | out, 173 | format!( 174 | "name: {}\nlogin: {}\npassword: {}\n", 175 | NAME, LOGIN, PASSWORD 176 | ), 177 | ); 178 | Ok(()) 179 | } 180 | 181 | #[test_device] 182 | fn add_empty(model: nitrokey::Model) -> anyhow::Result<()> { 183 | clear_pws(model)?; 184 | 185 | let mut ncli = Nitrocli::new().model(model); 186 | let _ = ncli.handle(&["pws", "add", "--slot", "1", "", "", ""])?; 187 | 188 | let out = ncli.handle(&["pws", "get", "1", "--quiet", "--name"])?; 189 | assert_eq!(out, "\n"); 190 | 191 | let out = ncli.handle(&["pws", "get", "1", "--quiet", "--login"])?; 192 | assert_eq!(out, "\n"); 193 | 194 | let out = ncli.handle(&["pws", "get", "1", "--quiet", "--password"])?; 195 | assert_eq!(out, "\n"); 196 | 197 | assert_slot(model, 1, "", "", "")?; 198 | 199 | let out = ncli.handle(&["pws", "get", "1"])?; 200 | assert_eq!(out, "name: \nlogin: \npassword: \n",); 201 | Ok(()) 202 | } 203 | 204 | #[test_device] 205 | fn add_reset_get(model: nitrokey::Model) -> anyhow::Result<()> { 206 | const NAME: &str = "some/svc"; 207 | const LOGIN: &str = "a\\user"; 208 | const PASSWORD: &str = "!@&-)*(&+%^@"; 209 | 210 | clear_pws(model)?; 211 | 212 | let mut ncli = Nitrocli::new().model(model); 213 | let _ = ncli.handle(&["pws", "add", "--slot", "2", NAME, LOGIN, PASSWORD])?; 214 | 215 | let out = ncli.handle(&["reset"])?; 216 | assert_eq!(out, ""); 217 | 218 | let res = ncli.handle(&["pws", "get", "2"]); 219 | let err = res.unwrap_err().to_string(); 220 | assert_eq!(err, "Failed to access PWS slot"); 221 | Ok(()) 222 | } 223 | 224 | #[test_device] 225 | fn clear(model: nitrokey::Model) -> anyhow::Result<()> { 226 | clear_pws(model)?; 227 | 228 | let mut ncli = Nitrocli::new().model(model); 229 | let _ = ncli.handle(&["pws", "clear", "10"])?; 230 | let _ = ncli.handle(&[ 231 | "pws", 232 | "add", 233 | "--slot", 234 | "10", 235 | "clear-test", 236 | "some-login", 237 | "abcdef", 238 | ])?; 239 | let _ = ncli.handle(&["pws", "clear", "10"])?; 240 | let res = ncli.handle(&["pws", "get", "10"]); 241 | 242 | let err = res.unwrap_err().to_string(); 243 | assert_eq!(err, "Failed to access PWS slot"); 244 | Ok(()) 245 | } 246 | 247 | #[test_device] 248 | fn update_unprogrammed(model: nitrokey::Model) -> anyhow::Result<()> { 249 | clear_pws(model)?; 250 | 251 | let mut ncli = Nitrocli::new().model(model); 252 | let res = ncli.handle(&["pws", "update", "10", "--name", "test"]); 253 | 254 | let err = res.unwrap_err().to_string(); 255 | assert_eq!(err, "Failed to query PWS slot"); 256 | Ok(()) 257 | } 258 | 259 | #[test_device] 260 | fn update_no_options(model: nitrokey::Model) -> anyhow::Result<()> { 261 | let mut ncli = Nitrocli::new().model(model); 262 | let res = ncli.handle(&["pws", "update", "10"]); 263 | 264 | let err = res.unwrap_err().to_string(); 265 | assert_eq!( 266 | err, 267 | "You have to set at least one of --name, --login, or --password" 268 | ); 269 | Ok(()) 270 | } 271 | 272 | #[test_device] 273 | fn update(model: nitrokey::Model) -> anyhow::Result<()> { 274 | const NAME_BEFORE: &str = "name-before"; 275 | const NAME_AFTER: &str = "name-after"; 276 | const LOGIN_BEFORE: &str = "login-before"; 277 | const LOGIN_AFTER: &str = "login-after"; 278 | const PASSWORD_BEFORE: &str = "password-before"; 279 | const PASSWORD_AFTER: &str = "password-after"; 280 | 281 | clear_pws(model)?; 282 | 283 | let mut ncli = Nitrocli::new().model(model); 284 | let _ = ncli.handle(&[ 285 | "pws", 286 | "add", 287 | "--slot", 288 | "10", 289 | NAME_BEFORE, 290 | LOGIN_BEFORE, 291 | PASSWORD_BEFORE, 292 | ])?; 293 | 294 | assert_slot(model, 10, NAME_BEFORE, LOGIN_BEFORE, PASSWORD_BEFORE)?; 295 | 296 | let _ = ncli.handle(&["pws", "update", "10", "--name", NAME_AFTER])?; 297 | assert_slot(model, 10, NAME_AFTER, LOGIN_BEFORE, PASSWORD_BEFORE)?; 298 | 299 | let _ = ncli.handle(&["pws", "update", "10", "--login", LOGIN_AFTER])?; 300 | assert_slot(model, 10, NAME_AFTER, LOGIN_AFTER, PASSWORD_BEFORE)?; 301 | 302 | let _ = ncli.handle(&["pws", "update", "10", "--password", PASSWORD_AFTER])?; 303 | assert_slot(model, 10, NAME_AFTER, LOGIN_AFTER, PASSWORD_AFTER)?; 304 | 305 | let _ = ncli.handle(&[ 306 | "pws", 307 | "update", 308 | "10", 309 | "--name", 310 | NAME_BEFORE, 311 | "--login", 312 | LOGIN_BEFORE, 313 | "--password", 314 | PASSWORD_BEFORE, 315 | ])?; 316 | assert_slot(model, 10, NAME_BEFORE, LOGIN_BEFORE, PASSWORD_BEFORE)?; 317 | 318 | Ok(()) 319 | } 320 | 321 | #[test_device] 322 | fn update_stdin(model: nitrokey::Model) -> anyhow::Result<()> { 323 | clear_pws(model)?; 324 | 325 | let mut ncli = Nitrocli::new().model(model); 326 | 327 | let _ = ncli.handle(&["pws", "add", "--slot", "0", "name0", "login0", "pass0rd"])?; 328 | let _ = ncli 329 | .stdin("passw1rd") 330 | .handle(&["pws", "update", "0", "--password", "-"])?; 331 | 332 | assert_slot(model, 0, "name0", "login0", "passw1rd")?; 333 | 334 | Ok(()) 335 | } 336 | 337 | #[test_device] 338 | fn add_full(model: nitrokey::Model) -> anyhow::Result<()> { 339 | clear_pws(model)?; 340 | 341 | let mut ncli = Nitrocli::new().model(model); 342 | 343 | // Fill all PWS slots 344 | { 345 | let mut manager = nitrokey::force_take()?; 346 | let mut device = manager.connect_model(model)?; 347 | let mut pws = device.get_password_safe(nitrokey::DEFAULT_USER_PIN)?; 348 | for slot in 0..pws.get_slot_count() { 349 | pws.write_slot(slot, "name", "login", "passw0rd")?; 350 | } 351 | } 352 | 353 | // Try to add another one 354 | let res = ncli.handle(&["pws", "add", "name", "login", "passw0rd"]); 355 | 356 | let err = res.unwrap_err().to_string(); 357 | assert_eq!(err, "All PWS slots are already programmed"); 358 | Ok(()) 359 | } 360 | 361 | #[test_device] 362 | fn add_existing(model: nitrokey::Model) -> anyhow::Result<()> { 363 | clear_pws(model)?; 364 | 365 | let mut ncli = Nitrocli::new().model(model); 366 | 367 | // Fill slot 0 368 | let _ = ncli.handle(&["pws", "add", "--slot", "0", "name0", "login0", "pass0rd"])?; 369 | 370 | // Try to add slot 0 371 | let res = ncli.handle(&["pws", "add", "--slot", "0", "name", "login", "passw0rd"]); 372 | 373 | let err = res.unwrap_err().to_string(); 374 | assert_eq!(err, "The PWS slot 0 is already programmed"); 375 | Ok(()) 376 | } 377 | 378 | #[test_device] 379 | fn add_slot(model: nitrokey::Model) -> anyhow::Result<()> { 380 | clear_pws(model)?; 381 | 382 | let mut ncli = Nitrocli::new().model(model); 383 | 384 | // Fill slots 0 and 5 385 | let _ = ncli.handle(&["pws", "add", "--slot", "0", "name0", "login0", "passw0rd"])?; 386 | let _ = ncli.handle(&["pws", "add", "--slot", "5", "name5", "login5", "passw5rd"])?; 387 | 388 | // Try to add slot 1 389 | let out = ncli.handle(&["pws", "add", "--slot", "1", "name1", "login1", "passw1rd"])?; 390 | assert_eq!("Added PWS slot 1\n", out); 391 | 392 | assert_slot(model, 0, "name0", "login0", "passw0rd")?; 393 | assert_slot(model, 1, "name1", "login1", "passw1rd")?; 394 | assert_slot(model, 5, "name5", "login5", "passw5rd")?; 395 | 396 | Ok(()) 397 | } 398 | 399 | #[test_device] 400 | fn add(model: nitrokey::Model) -> anyhow::Result<()> { 401 | clear_pws(model)?; 402 | 403 | let mut ncli = Nitrocli::new().model(model); 404 | 405 | // Fill slots 0 and 5 406 | let _ = ncli.handle(&["pws", "add", "--slot", "0", "name0", "login0", "pass0rd"])?; 407 | let _ = ncli.handle(&["pws", "add", "--slot", "5", "name5", "login5", "pass5rd"])?; 408 | 409 | // Try to add another one 410 | let out = ncli.handle(&["pws", "add", "name1", "login1", "passw1rd"])?; 411 | assert_eq!("Added PWS slot 1\n", out); 412 | 413 | assert_slot(model, 1, "name1", "login1", "passw1rd")?; 414 | 415 | Ok(()) 416 | } 417 | 418 | #[test_device] 419 | fn add_stdin(model: nitrokey::Model) -> anyhow::Result<()> { 420 | clear_pws(model)?; 421 | 422 | let mut ncli = Nitrocli::new().model(model); 423 | 424 | // Fill slots 0 and 5 425 | let _ = ncli.handle(&["pws", "add", "--slot", "0", "name0", "login0", "pass0rd"])?; 426 | let _ = ncli.handle(&["pws", "add", "--slot", "5", "name5", "login5", "pass5rd"])?; 427 | 428 | // Try to add another one 429 | let out = ncli 430 | .stdin("passw1rd") 431 | .handle(&["pws", "add", "name1", "login1", "-"])?; 432 | assert_eq!("Added PWS slot 1\n", out); 433 | 434 | assert_slot(model, 1, "name1", "login1", "passw1rd")?; 435 | 436 | Ok(()) 437 | } 438 | -------------------------------------------------------------------------------- /src/tests/reset.rs: -------------------------------------------------------------------------------- 1 | // reset.rs 2 | 3 | // Copyright (C) 2019-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use nitrokey::Authenticate; 7 | use nitrokey::GetPasswordSafe; 8 | 9 | use super::*; 10 | 11 | #[test_device] 12 | fn reset(model: nitrokey::Model) -> anyhow::Result<()> { 13 | let new_admin_pin = "87654321"; 14 | let mut ncli = Nitrocli::new().model(model).new_admin_pin(new_admin_pin); 15 | 16 | // Change the admin PIN. 17 | let _ = ncli.handle(&["pin", "set", "admin"])?; 18 | 19 | { 20 | let mut manager = nitrokey::force_take()?; 21 | // Check that the admin PIN has been changed. 22 | let device = manager.connect_model(model)?; 23 | let _ = device.authenticate_admin(new_admin_pin).unwrap(); 24 | } 25 | 26 | // Perform factory reset 27 | let mut ncli = ncli.admin_pin(new_admin_pin); 28 | let out = ncli.handle(&["reset"])?; 29 | assert!(out.is_empty()); 30 | 31 | { 32 | let mut manager = nitrokey::force_take()?; 33 | // Check that the admin PIN has been reset. 34 | let device = manager.connect_model(model)?; 35 | let mut device = device 36 | .authenticate_admin(nitrokey::DEFAULT_ADMIN_PIN) 37 | .unwrap(); 38 | 39 | // Check that the password store works, i.e., the AES key has been 40 | // built. 41 | let _ = device.get_password_safe(nitrokey::DEFAULT_USER_PIN)?; 42 | } 43 | 44 | Ok(()) 45 | } 46 | 47 | #[test_device] 48 | fn reset_only_aes_key(model: nitrokey::Model) -> anyhow::Result<()> { 49 | const NEW_USER_PIN: &str = "654321"; 50 | const NAME: &str = "slotname"; 51 | const LOGIN: &str = "sloglogin"; 52 | const PASSWORD: &str = "slotpassword"; 53 | 54 | let mut ncli = Nitrocli::new().model(model).new_user_pin(NEW_USER_PIN); 55 | 56 | // Change the user PIN 57 | let _ = ncli.handle(&["pin", "set", "user"])?; 58 | 59 | // Add an entry to the PWS 60 | { 61 | let mut manager = nitrokey::force_take()?; 62 | let mut device = manager.connect_model(model)?; 63 | let mut pws = device.get_password_safe(NEW_USER_PIN)?; 64 | pws.write_slot(0, NAME, LOGIN, PASSWORD)?; 65 | } 66 | 67 | // Build AES key 68 | let mut ncli = Nitrocli::new().model(model); 69 | let out = ncli.handle(&["reset", "--only-aes-key"])?; 70 | assert!(out.is_empty()); 71 | 72 | // Check that 1) the password store works, i.e., there is an AES key, 73 | // that 2) we can no longer access the stored data, i.e., the AES has 74 | // been replaced, and that 3) the changed user PIN still works, i.e., 75 | // we did not perform a factory reset. 76 | { 77 | let mut manager = nitrokey::force_take()?; 78 | let mut device = manager.connect_model(model)?; 79 | let pws = device.get_password_safe(NEW_USER_PIN)?; 80 | let slot = pws.get_slot_unchecked(0)?; 81 | 82 | if let Ok(name) = slot.get_name() { 83 | assert_ne!(NAME, &name); 84 | } 85 | if let Ok(login) = slot.get_login() { 86 | assert_ne!(LOGIN, &login); 87 | } 88 | if let Ok(password) = slot.get_password() { 89 | assert_ne!(PASSWORD, &password); 90 | } 91 | } 92 | 93 | // Reset the user PIN for other tests 94 | let mut ncli = ncli 95 | .user_pin(NEW_USER_PIN) 96 | .new_user_pin(nitrokey::DEFAULT_USER_PIN); 97 | let out = ncli.handle(&["pin", "set", "user"])?; 98 | assert!(out.is_empty()); 99 | 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /src/tests/run.rs: -------------------------------------------------------------------------------- 1 | // run.rs 2 | 3 | // Copyright (C) 2019-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::collections; 7 | use std::convert::TryFrom as _; 8 | use std::convert::TryInto as _; 9 | use std::fs; 10 | use std::io::Write; 11 | use std::ops; 12 | use std::os::unix::fs::OpenOptionsExt; 13 | use std::path; 14 | 15 | use super::*; 16 | use crate::args; 17 | 18 | #[test] 19 | fn no_command_or_option() { 20 | let (rc, out, err) = Nitrocli::new().run(&[]); 21 | 22 | assert_ne!(rc, 0); 23 | assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); 24 | 25 | let s = String::from_utf8_lossy(&err).into_owned(); 26 | assert!(s.starts_with("nitrocli"), "{}", s); 27 | assert!(s.contains("USAGE:\n"), "{}", s); 28 | } 29 | 30 | #[test] 31 | fn help_options() { 32 | fn test_run(args: &[&str], help: &str) { 33 | let mut all = args.to_vec(); 34 | all.push(help); 35 | 36 | let (rc, out, err) = Nitrocli::new().run(&all); 37 | 38 | assert_eq!(rc, 0); 39 | assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); 40 | 41 | let s = String::from_utf8_lossy(&out).into_owned(); 42 | let mut args = args.to_vec(); 43 | args.insert(0, "nitrocli"); 44 | assert!(s.starts_with(&args.join("-")), "{}", s); 45 | assert!(s.contains("USAGE:\n"), "{}", s); 46 | } 47 | 48 | fn test(args: &[&str]) { 49 | test_run(args, "--help"); 50 | test_run(args, "-h"); 51 | } 52 | 53 | test(&[]); 54 | test(&["config"]); 55 | test(&["config", "get"]); 56 | test(&["config", "set"]); 57 | test(&["encrypted"]); 58 | test(&["encrypted", "open"]); 59 | test(&["encrypted", "close"]); 60 | test(&["hidden"]); 61 | test(&["hidden", "close"]); 62 | test(&["hidden", "create"]); 63 | test(&["hidden", "open"]); 64 | test(&["lock"]); 65 | test(&["otp"]); 66 | test(&["otp", "clear"]); 67 | test(&["otp", "get"]); 68 | test(&["otp", "set"]); 69 | test(&["otp", "status"]); 70 | test(&["pin"]); 71 | test(&["pin", "clear"]); 72 | test(&["pin", "set"]); 73 | test(&["pin", "unblock"]); 74 | test(&["pws"]); 75 | test(&["pws", "clear"]); 76 | test(&["pws", "get"]); 77 | test(&["pws", "add"]); 78 | test(&["pws", "update"]); 79 | test(&["pws", "status"]); 80 | test(&["reset"]); 81 | test(&["status"]); 82 | test(&["unencrypted"]); 83 | test(&["unencrypted", "set"]); 84 | } 85 | 86 | #[test] 87 | #[ignore] 88 | fn version_option() { 89 | // clap sends the version output directly to stdout: https://github.com/clap-rs/clap/issues/1390 90 | // Therefore we ignore this test for the time being. 91 | 92 | fn test(re: ®ex::Regex, opt: &'static str) { 93 | let (rc, out, err) = Nitrocli::new().run(&[opt]); 94 | 95 | assert_eq!(rc, 0); 96 | assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); 97 | 98 | let s = String::from_utf8_lossy(&out).into_owned(); 99 | let _ = re; 100 | assert!(re.is_match(&s), "{}", s); 101 | } 102 | 103 | let re = regex::Regex::new(r"^nitrocli \d+.\d+.\d+(-[^-]+)* using libnitrokey .*\n$").unwrap(); 104 | 105 | test(&re, "--version"); 106 | test(&re, "-V"); 107 | } 108 | 109 | #[test] 110 | fn config_file() { 111 | let config = crate::config::read_config_file(path::Path::new("doc/config.example.toml")).unwrap(); 112 | 113 | assert_eq!(Some(args::DeviceModel::Pro), config.model); 114 | assert!(config.no_cache); 115 | assert_eq!(2, config.verbosity); 116 | } 117 | 118 | #[test_device] 119 | fn connect_multiple(_model: nitrokey::Model) -> anyhow::Result<()> { 120 | let devices = nitrokey::list_devices()?; 121 | if devices.len() > 1 { 122 | let res = Nitrocli::new().handle(&["status"]); 123 | let err = res.unwrap_err().to_string(); 124 | assert_eq!( 125 | err, 126 | "Multiple Nitrokey devices found. Use the --model, --serial-number, and --usb-path options to select one" 127 | ); 128 | } 129 | Ok(()) 130 | } 131 | 132 | #[test_device] 133 | fn connect_serial_number(_model: nitrokey::Model) -> anyhow::Result<()> { 134 | let devices = nitrokey::list_devices()?; 135 | for serial_number in devices.iter().filter_map(|d| d.serial_number) { 136 | let res = Nitrocli::new().handle(&["status", &format!("--serial-number={}", serial_number)])?; 137 | assert!(res.contains(&format!("serial number: {}\n", serial_number))); 138 | } 139 | Ok(()) 140 | } 141 | 142 | #[test_device] 143 | fn connect_wrong_serial_number(_model: nitrokey::Model) { 144 | let res = Nitrocli::new().handle(&["status", "--serial-number=0xdeadbeef"]); 145 | let err = res.unwrap_err().to_string(); 146 | assert_eq!( 147 | err, 148 | "Nitrokey device not found (filter: serial number in [0xdeadbeef])" 149 | ); 150 | } 151 | 152 | #[test_device] 153 | fn connect_usb_path(_model: nitrokey::Model) -> anyhow::Result<()> { 154 | for device in nitrokey::list_devices()? { 155 | let res = Nitrocli::new().handle(&["status", &format!("--usb-path={}", device.path)]); 156 | assert!(res.is_ok()); 157 | let res = res?; 158 | if let Some(model) = device.model { 159 | assert!(res.contains(&format!("model: {}\n", model))); 160 | } 161 | if let Some(sn) = device.serial_number { 162 | assert!(res.contains(&format!("serial number: {}\n", sn))); 163 | } 164 | } 165 | Ok(()) 166 | } 167 | 168 | #[test_device] 169 | fn connect_wrong_usb_path(_model: nitrokey::Model) { 170 | let res = Nitrocli::new().handle(&["status", "--usb-path=not-a-path"]); 171 | let err = res.unwrap_err().to_string(); 172 | assert_eq!( 173 | err, 174 | "Nitrokey device not found (filter: usb path=not-a-path)" 175 | ); 176 | } 177 | 178 | #[test_device] 179 | fn connect_model(_model: nitrokey::Model) -> anyhow::Result<()> { 180 | let devices = nitrokey::list_devices()?; 181 | let mut model_counts = collections::BTreeMap::new(); 182 | let _ = model_counts.insert(args::DeviceModel::Pro, 0); 183 | let _ = model_counts.insert(args::DeviceModel::Storage, 0); 184 | for nkmodel in devices.iter().filter_map(|d| d.model) { 185 | let model = nkmodel.try_into().expect("Unexpected Nitrokey model"); 186 | *model_counts.entry(model).or_default() += 1; 187 | } 188 | 189 | for (model, count) in model_counts { 190 | let res = Nitrocli::new().handle(&["status", &format!("--model={}", model)]); 191 | if count == 0 { 192 | let err = res.unwrap_err().to_string(); 193 | assert_eq!( 194 | err, 195 | format!("Nitrokey device not found (filter: model={})", model) 196 | ); 197 | } else if count == 1 { 198 | assert!(res?.contains(&format!( 199 | "model: {}\n", 200 | nitrokey::Model::from(model) 201 | ))); 202 | } else { 203 | let err = res.unwrap_err().to_string(); 204 | assert_eq!( 205 | err, 206 | format!( 207 | "Multiple Nitrokey devices found (filter: model={}). ", 208 | model 209 | ) + "Use the --model, --serial-number, and --usb-path options to select one" 210 | ); 211 | } 212 | } 213 | 214 | Ok(()) 215 | } 216 | 217 | #[test_device] 218 | fn connect_usb_path_model_serial(_model: nitrokey::Model) -> anyhow::Result<()> { 219 | let devices = nitrokey::list_devices()?; 220 | for device in devices { 221 | let model = device.model.map(|nkmodel| { 222 | TryInto::::try_into(nkmodel).expect("Unexpected Nitrokey model") 223 | }); 224 | let mut args = Vec::new(); 225 | args.push("status".to_owned()); 226 | args.push(format!("--usb-path={}", device.path)); 227 | if let Some(model) = model { 228 | args.push(format!("--model={}", model)); 229 | } 230 | if let Some(sn) = device.serial_number { 231 | args.push(format!("--serial-number={}", sn)); 232 | } 233 | 234 | let res = Nitrocli::new().handle(&args.iter().map(ops::Deref::deref).collect::>())?; 235 | if let Some(model) = device.model { 236 | assert!(res.contains(&format!("model: {}\n", model))); 237 | } 238 | if let Some(sn) = device.serial_number { 239 | assert!(res.contains(&format!("serial number: {}\n", sn))); 240 | } 241 | } 242 | Ok(()) 243 | } 244 | 245 | #[test_device] 246 | fn connect_usb_path_model_wrong_serial(_model: nitrokey::Model) -> anyhow::Result<()> { 247 | let devices = nitrokey::list_devices()?; 248 | for device in devices { 249 | let model = device.model.map(|nkmodel| { 250 | TryInto::::try_into(nkmodel).expect("Unexpected Nitrokey model") 251 | }); 252 | let mut args = Vec::new(); 253 | args.push("status".to_owned()); 254 | args.push(format!("--usb-path={}", device.path)); 255 | if let Some(model) = model { 256 | args.push(format!("--model={}", model)); 257 | } 258 | args.push("--serial-number=0xdeadbeef".to_owned()); 259 | 260 | let res = Nitrocli::new().handle(&args.iter().map(ops::Deref::deref).collect::>()); 261 | let err = res.unwrap_err().to_string(); 262 | if let Some(model) = model { 263 | assert_eq!( 264 | err, 265 | format!( 266 | "Nitrokey device not found (filter: model={}, serial number in [0xdeadbeef], usb path={})", 267 | model, 268 | device.path 269 | ) 270 | ); 271 | } else { 272 | assert_eq!( 273 | err, 274 | format!( 275 | "Nitrokey device not found (filter: serial number in [0xdeadbeef], usb path={})", 276 | device.path 277 | ) 278 | ); 279 | } 280 | } 281 | Ok(()) 282 | } 283 | 284 | #[test] 285 | fn extension() -> anyhow::Result<()> { 286 | let ext_dir = tempfile::tempdir()?; 287 | { 288 | let mut ext = fs::OpenOptions::new() 289 | .create(true) 290 | .truncate(true) 291 | .mode(0o755) 292 | .write(true) 293 | .open(ext_dir.path().join("nitrocli-ext"))?; 294 | 295 | ext.write_all( 296 | br#"#!/usr/bin/env python 297 | print("success") 298 | "#, 299 | )?; 300 | } 301 | 302 | let path = ext_dir.path().as_os_str().to_os_string(); 303 | // Make sure that the extension appears in the help text. 304 | let out = Nitrocli::new().path(&path).handle(&["--help"])?; 305 | assert!( 306 | out.contains("ext Run the ext extension\n"), 307 | "{}", 308 | out 309 | ); 310 | // And, of course, that we can invoke it. 311 | let out = Nitrocli::new().path(&path).handle(&["ext"])?; 312 | assert_eq!(out, "success\n"); 313 | Ok(()) 314 | } 315 | 316 | #[test] 317 | fn extension_failure() -> anyhow::Result<()> { 318 | let ext_dir = tempfile::tempdir()?; 319 | { 320 | let mut ext = fs::OpenOptions::new() 321 | .create(true) 322 | .truncate(true) 323 | .mode(0o755) 324 | .write(true) 325 | .open(ext_dir.path().join("nitrocli-ext"))?; 326 | 327 | ext.write_all( 328 | br#"#!/usr/bin/env python 329 | import sys 330 | sys.exit(42); 331 | "#, 332 | )?; 333 | } 334 | 335 | let path = ext_dir.path().as_os_str().to_os_string(); 336 | let mut ncli = Nitrocli::new().path(path); 337 | 338 | let err = ncli.handle(&["ext"]).unwrap_err(); 339 | // The extension is responsible for printing any error messages. 340 | // Nitrocli is expected not to mess with them, including adding 341 | // additional information. 342 | if let Some(crate::DirectExitError(rc)) = err.downcast_ref::() { 343 | assert_eq!(*rc, 42) 344 | } else { 345 | panic!("encountered unexpected error: {:#}", err) 346 | } 347 | 348 | let (rc, out, err) = ncli.run(&["ext"]); 349 | assert_eq!(rc, 42); 350 | assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); 351 | assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); 352 | Ok(()) 353 | } 354 | 355 | #[test_device] 356 | fn extension_arguments(model: nitrokey::Model) -> anyhow::Result<()> { 357 | fn test(model: nitrokey::Model, what: &str, args: &[&str], check: F) -> anyhow::Result<()> 358 | where 359 | F: FnOnce(&str) -> bool, 360 | { 361 | let ext_dir = tempfile::tempdir()?; 362 | { 363 | let mut ext = fs::OpenOptions::new() 364 | .create(true) 365 | .truncate(true) 366 | .mode(0o755) 367 | .write(true) 368 | .open(ext_dir.path().join("nitrocli-ext"))?; 369 | 370 | ext.write_all(include_bytes!("extension_var_test.py"))?; 371 | } 372 | 373 | let mut args = args.to_vec(); 374 | args.append(&mut vec!["ext", what]); 375 | 376 | let path = ext_dir.path().as_os_str().to_os_string(); 377 | let out = Nitrocli::new().model(model).path(path).handle(&args)?; 378 | 379 | assert!(check(&out), "{}", out); 380 | Ok(()) 381 | } 382 | 383 | test(model, "NITROCLI_BINARY", &[], |out| { 384 | path::Path::new(out) 385 | .file_stem() 386 | .unwrap() 387 | .to_str() 388 | .unwrap() 389 | .trim() 390 | .contains("nitrocli") 391 | })?; 392 | test(model, "NITROCLI_MODEL", &[], |out| { 393 | out == args::DeviceModel::try_from(model).unwrap().to_string() + "\n" 394 | })?; 395 | test(model, "NITROCLI_NO_CACHE", &[], |out| out == "true\n")?; 396 | test(model, "NITROCLI_SERIAL_NUMBERS", &[], |out| out == "\n")?; 397 | test(model, "NITROCLI_VERBOSITY", &[], |out| out == "0\n")?; 398 | test(model, "NITROCLI_VERBOSITY", &["-v"], |out| out == "1\n")?; 399 | test(model, "NITROCLI_VERBOSITY", &["-v", "--verbose"], |out| { 400 | out == "2\n" 401 | })?; 402 | 403 | // NITROCLI_USB_PATH should not be set, so the program errors out. 404 | let _ = test(model, "NITROCLI_USB_PATH", &[], |out| out == "\n").unwrap_err(); 405 | 406 | let tty = crate::tty::retrieve_tty().unwrap(); 407 | test(model, "GPG_TTY", &[], |out| { 408 | // It's conceivable that this check fails if the user has set 409 | // GPG_TTY to a different TTY than the current one. We declare that 410 | // as not supported for testing purposes. 411 | out.trim() == tty.as_os_str() 412 | })?; 413 | Ok(()) 414 | } 415 | -------------------------------------------------------------------------------- /src/tests/status.rs: -------------------------------------------------------------------------------- 1 | // status.rs 2 | 3 | // Copyright (C) 2019-2021 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test_device] 9 | fn not_found_raw() { 10 | let (rc, out, err) = Nitrocli::new().run(&["status"]); 11 | 12 | assert_ne!(rc, 0); 13 | assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); 14 | assert_eq!( 15 | err, 16 | b"Nitrokey device not found\n", 17 | "{}", 18 | String::from_utf8_lossy(&err) 19 | ); 20 | } 21 | 22 | #[test_device] 23 | fn not_found() { 24 | let res = Nitrocli::new().handle(&["status"]); 25 | let err = res.unwrap_err().to_string(); 26 | assert_eq!(err, "Nitrokey device not found"); 27 | } 28 | 29 | #[test_device(librem)] 30 | fn output_librem(model: nitrokey::Model) -> anyhow::Result<()> { 31 | let re = regex::Regex::new( 32 | r#"^Status: 33 | model: Librem Key 34 | serial number: 0x[[:xdigit:]]{8} 35 | firmware version: v\d+\.\d+ 36 | user retry count: [0-3] 37 | admin retry count: [0-3] 38 | $"#, 39 | ) 40 | .unwrap(); 41 | 42 | let out = Nitrocli::new().model(model).handle(&["status"])?; 43 | assert!(re.is_match(&out), "{}", out); 44 | Ok(()) 45 | } 46 | 47 | #[test_device(pro)] 48 | fn output_pro(model: nitrokey::Model) -> anyhow::Result<()> { 49 | let re = regex::Regex::new( 50 | r#"^Status: 51 | model: Nitrokey Pro 52 | serial number: 0x[[:xdigit:]]{8} 53 | firmware version: v\d+\.\d+ 54 | user retry count: [0-3] 55 | admin retry count: [0-3] 56 | $"#, 57 | ) 58 | .unwrap(); 59 | 60 | let out = Nitrocli::new().model(model).handle(&["status"])?; 61 | assert!(re.is_match(&out), "{}", out); 62 | Ok(()) 63 | } 64 | 65 | #[test_device(storage)] 66 | fn output_storage(model: nitrokey::Model) -> anyhow::Result<()> { 67 | let re = regex::Regex::new( 68 | r#"^Status: 69 | model: Nitrokey Storage 70 | serial number: 0x[[:xdigit:]]{8} 71 | firmware version: v\d+\.\d+ 72 | user retry count: [0-3] 73 | admin retry count: [0-3] 74 | Storage: 75 | SD card ID: 0x[[:xdigit:]]{8} 76 | SD card usage: \d+% .. \d+% not written 77 | firmware: (un)?locked 78 | storage keys: (not )?created 79 | volumes: 80 | unencrypted: (read-only|active|inactive) 81 | encrypted: (read-only|active|inactive) 82 | hidden: (read-only|active|inactive) 83 | $"#, 84 | ) 85 | .unwrap(); 86 | 87 | let out = Nitrocli::new().model(model).handle(&["status"])?; 88 | assert!(re.is_match(&out), "{}", out); 89 | Ok(()) 90 | } 91 | -------------------------------------------------------------------------------- /src/tests/unencrypted.rs: -------------------------------------------------------------------------------- 1 | // unencrypted.rs 2 | 3 | // Copyright (C) 2019-2020 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use super::*; 7 | 8 | #[test_device(storage)] 9 | fn unencrypted_set_read_write(model: nitrokey::Model) -> anyhow::Result<()> { 10 | let mut ncli = Nitrocli::new().model(model); 11 | let out = ncli.handle(&["unencrypted", "set", "read-write"])?; 12 | assert!(out.is_empty()); 13 | 14 | { 15 | let mut manager = nitrokey::force_take()?; 16 | let device = manager.connect_storage()?; 17 | assert!(device.get_storage_status()?.unencrypted_volume.active); 18 | assert!(!device.get_storage_status()?.unencrypted_volume.read_only); 19 | } 20 | 21 | let out = ncli.handle(&["unencrypted", "set", "read-only"])?; 22 | assert!(out.is_empty()); 23 | 24 | { 25 | let mut manager = nitrokey::force_take()?; 26 | let device = manager.connect_storage()?; 27 | assert!(device.get_storage_status()?.unencrypted_volume.active); 28 | assert!(device.get_storage_status()?.unencrypted_volume.read_only); 29 | } 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /src/tty/linux.rs: -------------------------------------------------------------------------------- 1 | // linux.rs 2 | 3 | // Copyright (C) 2022-2024 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::fmt; 7 | use std::fs; 8 | use std::io; 9 | use std::io::BufRead as _; 10 | use std::os::unix::io::AsRawFd as _; 11 | use std::os::unix::io::RawFd; 12 | use std::path; 13 | use std::str::FromStr as _; 14 | 15 | use anyhow::Context as _; 16 | 17 | /// The prefix used in a `/proc//status` file line indicating the 18 | /// line containing the parent PID. 19 | const PROC_PARENT_PID_PREFIX: &str = "PPid:"; 20 | 21 | /// An enumeration representing the `` path component in 22 | /// `/proc//`. 23 | enum Process { 24 | Current, 25 | Pid(u32), 26 | } 27 | 28 | impl fmt::Display for Process { 29 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 30 | match self { 31 | Self::Current => write!(f, "self"), 32 | Self::Pid(pid) => write!(f, "{}", pid), 33 | } 34 | } 35 | } 36 | 37 | /// Find the parent of a process. 38 | fn find_parent(process: &Process) -> anyhow::Result { 39 | let status_path = format!("/proc/{}/status", process); 40 | // TODO: Use `File::options` once we bumped the minimum supported Rust 41 | // version to 1.58. 42 | let file = fs::OpenOptions::new() 43 | .write(false) 44 | .read(true) 45 | .create(false) 46 | .open(&status_path) 47 | .with_context(|| format!("Failed to open {}", status_path))?; 48 | let mut file = io::BufReader::new(file); 49 | let mut line = String::new(); 50 | 51 | loop { 52 | let count = file.read_line(&mut line)?; 53 | if count == 0 { 54 | break Err(anyhow::anyhow!( 55 | "Status file {} ended unexpectedly", 56 | status_path 57 | )); 58 | } 59 | 60 | if let Some(line) = line.strip_prefix(PROC_PARENT_PID_PREFIX) { 61 | let line = line.trim(); 62 | let pid = u32::from_str(line).with_context(|| { 63 | format!( 64 | "Encountered string '{}' cannot be parsed as a file descriptor", 65 | line 66 | ) 67 | })?; 68 | break Ok(Process::Pid(pid)); 69 | } 70 | line.clear(); 71 | } 72 | } 73 | 74 | /// Check whether the file at the provided path actually represents a 75 | /// TTY. 76 | fn represents_tty(path: &path::Path) -> anyhow::Result { 77 | let file = fs::OpenOptions::new() 78 | .write(false) 79 | .read(true) 80 | .create(false) 81 | .open(path) 82 | .with_context(|| format!("Failed to open file {}", path.display()))?; 83 | 84 | // We could evaluate `errno` on failure, but we do not actually care 85 | // why it's not a TTY. 86 | let rc = unsafe { libc::isatty(file.as_raw_fd()) }; 87 | Ok(rc == 1) 88 | } 89 | 90 | /// Retrieve a path to a file descriptor in a process, if possible. 91 | fn retrieve_fd_path(process: &Process, fd: RawFd) -> anyhow::Result { 92 | let fd_path = format!("/proc/{}/fd/{}", process, fd); 93 | fs::read_link(&fd_path).with_context(|| format!("Failed to read symbolic link {}", fd_path)) 94 | } 95 | 96 | /// Retrieve the path to the TTY used by a process. 97 | fn retrieve_tty_impl(mut process: Process) -> anyhow::Result { 98 | let stdin_fd = io::stdin().as_raw_fd(); 99 | // We assume stdin to merely be the constant 0. That's an assumption 100 | // we apply to all processes (but can only check for the current one). 101 | debug_assert_eq!(stdin_fd, 0); 102 | 103 | loop { 104 | let path = retrieve_fd_path(&process, stdin_fd)?; 105 | if let Ok(true) = represents_tty(&path) { 106 | break Ok(path); 107 | } 108 | 109 | process = find_parent(&process)?; 110 | // Terminate our search once we reached the root process, which has 111 | // a parent PID of 0. 112 | if matches!(process, Process::Pid(pid) if pid == 0) { 113 | break Err(anyhow::anyhow!("Process has no TTY")); 114 | } 115 | } 116 | } 117 | 118 | /// Retrieve a path to the TTY used for stdin, if any. 119 | pub(crate) fn retrieve_tty() -> anyhow::Result { 120 | retrieve_tty_impl(Process::Current) 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | 127 | use std::process; 128 | 129 | /// Check that we can retrieve the path to the TTY used for stdin. 130 | #[test] 131 | fn tty_retrieval() { 132 | // We may be run with stdin not referring to a TTY in CI. 133 | if unsafe { libc::isatty(io::stdin().as_raw_fd()) } == 0 { 134 | return; 135 | } 136 | 137 | let tty = retrieve_tty().unwrap(); 138 | // To check sanity of the reported path at least somewhat, we just 139 | // try opening the file, which should be possible. Note that we open 140 | // in write mode, because for one reason or another we would not 141 | // actually fail opening a *directory* in read-only mode. 142 | let _file = fs::OpenOptions::new() 143 | .create(false) 144 | .write(true) 145 | .read(true) 146 | .open(tty) 147 | .unwrap(); 148 | } 149 | 150 | /// Check that we can properly retrieve the TTY via a parent process. 151 | #[test] 152 | fn parent_tty_retrieval() { 153 | // If *we* don't have a TTY readily available we are probably run in 154 | // CI and don't have permission to access the parent's TTY either. 155 | // We really can only skip the test then. 156 | if unsafe { libc::isatty(io::stdin().as_raw_fd()) } == 0 { 157 | return; 158 | } 159 | 160 | #[allow(clippy::zombie_processes)] 161 | fn test(stdin: process::Stdio, redirection: &str) { 162 | let mut child = process::Command::new("sh") 163 | .stdin(stdin) 164 | // We need to read a line from stdout in order to find out the 165 | // (recursive) child's PID. 166 | .stdout(process::Stdio::piped()) 167 | // We assume being run from the project root, which is what 168 | // `cargo` does. That may not be the case if the binary is 169 | // executed manually, though. That's unsupported. 170 | .arg("src/tty/tty.sh") 171 | .arg(redirection) 172 | .spawn() 173 | .unwrap(); 174 | 175 | let mut line = String::new(); 176 | let mut stdout = io::BufReader::new(child.stdout.as_mut().unwrap()); 177 | let _ = stdout.read_line(&mut line).unwrap(); 178 | let pid = u32::from_str(line.trim()).unwrap(); 179 | 180 | let process = Process::Pid(pid); 181 | let tty = retrieve_tty_impl(process).unwrap(); 182 | let _file = fs::OpenOptions::new() 183 | .create(false) 184 | .write(true) 185 | .read(true) 186 | .open(tty) 187 | .unwrap(); 188 | 189 | // Clean up the child. Note that we could end up leaking the 190 | // processes earlier if any of the unwraps above fails. We made 191 | // the child terminate on its own after a while, though, instead 192 | // of increasing test complexity and decreasing debuggability by 193 | // handling all unwraps gracefully. 194 | let () = child.kill().unwrap(); 195 | } 196 | 197 | test(process::Stdio::null(), "pipe"); 198 | test(process::Stdio::null(), "devnull"); 199 | test(process::Stdio::inherit(), "pipe"); 200 | test(process::Stdio::inherit(), "devnull"); 201 | test(process::Stdio::piped(), "pipe"); 202 | test(process::Stdio::piped(), "devnull"); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/tty/mod.rs: -------------------------------------------------------------------------------- 1 | // mod.rs 2 | 3 | // Copyright (C) 2022 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | #[cfg(target_os = "linux")] 7 | mod linux; 8 | #[cfg(not(target_os = "linux"))] 9 | mod stub; 10 | 11 | #[cfg(target_os = "linux")] 12 | pub(crate) use linux::retrieve_tty; 13 | #[cfg(not(target_os = "linux"))] 14 | pub(crate) use stub::retrieve_tty; 15 | -------------------------------------------------------------------------------- /src/tty/stub.rs: -------------------------------------------------------------------------------- 1 | // stub.rs 2 | 3 | // Copyright (C) 2022 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::path; 7 | 8 | pub(crate) fn retrieve_tty() -> anyhow::Result { 9 | Err(()) 10 | } 11 | -------------------------------------------------------------------------------- /src/tty/tty.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Copyright (C) 2022 The Nitrocli Developers 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # We support testing both /dev/null and pipe redirection of stdin and 7 | # the first argument controls which one to use. 8 | stdin=${1:-"pipe"} 9 | instance=${2:-1} 10 | 11 | if [ $instance -ge 5 ]; then 12 | # Print own process ID and then just wait for a while until we get 13 | # killed. 14 | echo $$ 15 | sleep 60 16 | else 17 | # Invoke the script recursively, doing an actual fork and not just an 18 | # exec in order to spawn a new process. Also redirect stdin to 19 | # simulate it not referring to a TTY directly. 20 | if [ $stdin != "pipe" ]; then 21 | sh $0 $stdin $((instance + 1)) < /dev/null 22 | else 23 | cat | sh $0 $stdin $((instance + 1)) 24 | fi 25 | fi 26 | -------------------------------------------------------------------------------- /var/binary-size.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -B 2 | 3 | # Copyright (C) 2019-2020 The Nitrocli Developers 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | from argparse import ( 7 | ArgumentParser, 8 | ArgumentTypeError, 9 | ) 10 | from concurrent.futures import ( 11 | ThreadPoolExecutor, 12 | ) 13 | from json import ( 14 | loads as jsonLoad, 15 | ) 16 | from os import ( 17 | stat, 18 | ) 19 | from os.path import ( 20 | join, 21 | ) 22 | from subprocess import ( 23 | check_call, 24 | check_output, 25 | ) 26 | from sys import ( 27 | argv, 28 | exit, 29 | ) 30 | from tempfile import ( 31 | TemporaryDirectory, 32 | ) 33 | 34 | UNITS = { 35 | "byte": 1, 36 | "kib": 1024, 37 | "mib": 1024 * 1024, 38 | } 39 | 40 | def unit(string): 41 | """Create a unit.""" 42 | if string in UNITS: 43 | return UNITS[string] 44 | else: 45 | raise ArgumentTypeError("Invalid unit: \"%s\"." % string) 46 | 47 | 48 | def nitrocliPath(cwd): 49 | """Determine the path to the nitrocli release build binary.""" 50 | out = check_output(["cargo", "metadata", "--format-version=1"], cwd=cwd) 51 | data = jsonLoad(out) 52 | return join(data["target_directory"], "release", "nitrocli") 53 | 54 | 55 | def fileSize(path): 56 | """Determine the size of the file at the given path.""" 57 | return stat(path).st_size 58 | 59 | 60 | def repoRoot(): 61 | """Retrieve the root directory of the current git repository.""" 62 | out = check_output(["git", "rev-parse", "--show-toplevel"]) 63 | return out.decode().strip() 64 | 65 | 66 | def resolveCommit(commit): 67 | """Resolve a commit into a SHA1 hash.""" 68 | out = check_output(["git", "rev-parse", "--verify", "%s^{commit}" % commit]) 69 | return out.decode().strip() 70 | 71 | 72 | def determineSizeAt(root, rev): 73 | """Determine the size of the nitrocli release build binary at the given git revision.""" 74 | sha1 = resolveCommit(rev) 75 | with TemporaryDirectory() as cwd: 76 | check_call(["git", "clone", root, cwd]) 77 | check_call(["git", "checkout", "--quiet", sha1], cwd=cwd) 78 | check_call(["cargo", "build", "--quiet", "--release"], cwd=cwd) 79 | 80 | ncli = nitrocliPath(cwd) 81 | check_call(["strip", ncli]) 82 | return fileSize(ncli) 83 | 84 | 85 | def setupArgumentParser(): 86 | """Create and initialize an argument parser.""" 87 | parser = ArgumentParser() 88 | parser.add_argument( 89 | "revs", metavar="REVS", nargs="+", 90 | help="The revisions at which to measure the release binary size.", 91 | ) 92 | parser.add_argument( 93 | "-u", "--unit", default="byte", dest="unit", metavar="UNIT", type=unit, 94 | help="The unit in which to output the result (%s)." % "|".join(UNITS.keys()), 95 | ) 96 | return parser 97 | 98 | 99 | def main(args): 100 | """Determine the size of the nitrocli binary at given git revisions.""" 101 | parser = setupArgumentParser() 102 | ns = parser.parse_args(args) 103 | root = repoRoot() 104 | futures = [] 105 | executor = ThreadPoolExecutor() 106 | 107 | for rev in ns.revs: 108 | futures += [executor.submit(lambda r=rev: determineSizeAt(root, r))] 109 | 110 | executor.shutdown(wait=True) 111 | 112 | for future in futures: 113 | print(int(round(future.result() / ns.unit, 0))) 114 | 115 | return 0 116 | 117 | 118 | if __name__ == "__main__": 119 | exit(main(argv[1:])) 120 | -------------------------------------------------------------------------------- /var/shell-complete.rs: -------------------------------------------------------------------------------- 1 | // shell-complete.rs 2 | 3 | // Copyright (C) 2020-2022 The Nitrocli Developers 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | use std::io; 7 | 8 | use structopt::clap; 9 | use structopt::StructOpt as _; 10 | 11 | #[allow(unused)] 12 | mod nitrocli { 13 | include!("../src/arg_util.rs"); 14 | 15 | // We only need a stripped down version of the `Command` macro. 16 | macro_rules! Command { 17 | ( $(#[$docs:meta])* $name:ident, [ 18 | $( $(#[$doc:meta])* $var:ident$(($inner:ty))? => $exec:expr, ) * 19 | ] ) => { 20 | $(#[$docs])* 21 | #[derive(Debug, PartialEq, structopt::StructOpt)] 22 | pub enum $name { 23 | $( 24 | $(#[$doc])* 25 | $var$(($inner))?, 26 | )* 27 | } 28 | }; 29 | } 30 | 31 | include!("../src/args.rs"); 32 | } 33 | 34 | /// Generate a shell completion script for nitrocli. 35 | /// 36 | /// The script will be emitted to standard output. 37 | #[derive(Debug, structopt::StructOpt)] 38 | struct Args { 39 | /// The shell for which to generate a completion script for. 40 | #[structopt(possible_values = &clap::Shell::variants())] 41 | shell: clap::Shell, 42 | /// The command for which to generate the shell completion script. 43 | #[structopt(default_value = "nitrocli")] 44 | command: String, 45 | } 46 | 47 | fn generate_for_shell(command: &str, shell: clap::Shell, output: &mut W) 48 | where 49 | W: io::Write, 50 | { 51 | let mut app = nitrocli::Args::clap(); 52 | app.gen_completions_to(command, shell, output); 53 | } 54 | 55 | fn main() { 56 | let args = Args::from_args(); 57 | generate_for_shell(&args.command, args.shell, &mut io::stdout()) 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use super::*; 63 | 64 | use std::io; 65 | use std::ops::Add as _; 66 | use std::process; 67 | 68 | /// Separate the given words by newlines. 69 | fn lines<'w, W>(mut words: W) -> String 70 | where 71 | W: Iterator, 72 | { 73 | let first = words.next().unwrap_or(""); 74 | words 75 | .fold(first.to_string(), |words, word| { 76 | format!("{}\n{}", words, word) 77 | }) 78 | .add("\n") 79 | } 80 | 81 | /// Check if `bash` is present on the system. 82 | fn has_bash() -> bool { 83 | // We deliberately only indicate that bash does not exist if we 84 | // get a file-not-found error. We don't expect any other error but 85 | // should there be one things will blow up later. 86 | !matches!( 87 | process::Command::new("bash").arg("-c").arg("exit").spawn(), 88 | Err(ref err) if err.kind() == io::ErrorKind::NotFound 89 | ) 90 | } 91 | 92 | /// Perform a bash completion of the given arguments to nitrocli. 93 | fn complete_bash<'w, W>(words: W) -> Vec 94 | where 95 | W: ExactSizeIterator, 96 | { 97 | let mut buffer = Vec::new(); 98 | generate_for_shell("nitrocli", clap::Shell::Bash, &mut buffer); 99 | 100 | let script = String::from_utf8(buffer).unwrap(); 101 | let command = format!( 102 | " 103 | set -e; 104 | eval '{script}'; 105 | export COMP_WORDS=({words}); 106 | export COMP_CWORD={index}; 107 | _nitrocli; 108 | echo -n ${{COMPREPLY}} 109 | ", 110 | index = words.len(), 111 | words = lines(Some("nitrocli").into_iter().chain(words)), 112 | script = script 113 | ); 114 | 115 | let output = process::Command::new("bash") 116 | .arg("-c") 117 | .arg(command) 118 | .output() 119 | .unwrap(); 120 | 121 | output.stdout 122 | } 123 | 124 | #[test] 125 | fn array_lines() { 126 | assert_eq!(&lines(vec![].into_iter()), "\n"); 127 | assert_eq!(&lines(vec!["first"].into_iter()), "first\n"); 128 | assert_eq!( 129 | &lines(vec!["first", "second"].into_iter()), 130 | "first\nsecond\n" 131 | ); 132 | assert_eq!( 133 | &lines(vec!["first", "second", "third"].into_iter()), 134 | "first\nsecond\nthird\n" 135 | ); 136 | } 137 | 138 | #[test] 139 | fn complete_all_the_things() { 140 | if !has_bash() { 141 | return; 142 | } 143 | 144 | assert_eq!(complete_bash(vec!["stat"].into_iter()), b"status"); 145 | assert_eq!( 146 | complete_bash(vec!["status", "--ver"].into_iter()), 147 | b"--version" 148 | ); 149 | assert_eq!(complete_bash(vec!["--version"].into_iter()), b"--version"); 150 | assert_eq!(complete_bash(vec!["--model", "s"].into_iter()), b"storage"); 151 | assert_eq!( 152 | complete_bash(vec!["otp", "get", "--model", "p"].into_iter()), 153 | b"pro" 154 | ); 155 | } 156 | } 157 | --------------------------------------------------------------------------------