diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b6e3ff0d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +# Copyright (C) 2021 The Nitrocli Developers +# SPDX-License-Identifier: CC0-1.0 + +# TODO: +# - Test with system libnitrokey (USE_SYSTEM_LIBNITROKEY=1)? +# - Add support for macos and windows + +name: CI + +on: [push, pull_request] + +env: + RUST_BACKTRACE: 1 + +jobs: + test: + name: Compile and test Rust ${{ matrix.rust }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + rust: [1.42.0, stable, beta, nightly] + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + profile: minimal + override: true + - run: sudo apt-get install libhidapi-dev + - run: cargo build --workspace --bins --tests --verbose + - run: cargo build --workspace --bins --tests --verbose --release + - run: cargo test --workspace --verbose + + clippy: + name: Lint with clippy + runs-on: ubuntu-latest + env: + RUSTFLAGS: -Dwarnings + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: 1.42.0 + components: clippy + override: true + - run: cargo clippy --workspace --all-targets --all-features --verbose -- -A unknown_lints -D warnings + + reuse: + name: Check license annotations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: pip3 install reuse + - run: reuse lint + + rustfmt: + name: Verify code formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + toolchain: 1.42.0 + components: rustfmt + override: true + - run: cargo fmt --all -- --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb5aff7..3d1b1ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ Unreleased ---------- +- Introduced extension support crate, `nitrocli-ext` +- Introduced `otp-cache` and `pws-cache` core extensions - Enabled usage of empty PWS slot fields +- Changed error reporting format to make up only a single line +- Added `NITROCLI_RESOLVED_USB_PATH` environment variable to be used by + extensions - Allowed entering of `base32` encoded strings containing spaces - Fixed pinentry dialog highlighting some messages incorrectly as errors +- Switched to using GitHub Actions as the project's CI pipeline - Bumped `nitrokey` dependency to `0.9.0` +- Added the `--only-aes-key` option to the `reset` command to build a new AES + key without performing a factory reset 0.4.0 diff --git a/Cargo.lock b/Cargo.lock index a644916e..95941fae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,39 @@ dependencies = [ "toml", ] +[[package]] +name = "nitrocli-ext" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "nitrokey", +] + +[[package]] +name = "nitrocli-otp-cache" +version = "0.1.0" +dependencies = [ + "anyhow", + "nitrocli-ext", + "nitrokey", + "serde", + "structopt", + "toml", +] + +[[package]] +name = "nitrocli-pws-cache" +version = "0.1.0" +dependencies = [ + "anyhow", + "nitrocli-ext", + "nitrokey", + "serde", + "structopt", + "toml", +] + [[package]] name = "nitrokey" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 6bb42e53..a4c3624d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,12 +39,12 @@ version = "1.0" [dependencies.base32] version = "0.4.0" -[dependencies.envy] -version = "0.4.2" - [dependencies.directories] version = "3" +[dependencies.envy] +version = "0.4.2" + [dependencies.libc] version = "0.2" @@ -82,3 +82,6 @@ version = "1" [dev-dependencies.tempfile] version = "3.1" + +[workspace] +members = ["ext/*"] diff --git a/ci/gitlab-ci.yml b/ci/gitlab-ci.yml deleted file mode 100644 index 348cc1d1..00000000 --- a/ci/gitlab-ci.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (C) 2020-2021 The Nitrocli Developers -# SPDX-License-Identifier: CC0-1.0 - -# The documentation for the contents of this file can be found at: -# https://docs.gitlab.com/ce/ci/yaml/README.html - -# Official language image. Look for the different tagged releases at: -# https://hub.docker.com/r/library/rust/tags/ -# The recipe for this docker image can be found at: -# https://github.com/rust-lang/docker-rust/blob/8bab191937fcf23569d3a3c31103c1c6f7f2947e/1.42.0/buster/Dockerfile -image: "rust:1.42.0" - -build-test:cargo: - script: - - apt-get update - - apt-get install --assume-yes libudev-dev libhidapi-dev - - rustc --version && cargo --version - - cargo build --bins --tests --verbose - - cargo build --bins --tests --verbose --release - - cargo test --verbose - -lint:clippy: - script: - - rustup component add clippy - - cargo clippy --all-targets --all-features -- -A unknown_lints -D warnings - -lint:reuse: - script: - - apt-get update - - apt-get install --assume-yes python3-pip - - pip3 install reuse - - reuse lint - -format:rustfmt: - script: - - rustup component add rustfmt - - cargo fmt -- --check diff --git a/doc/nitrocli.1 b/doc/nitrocli.1 index 832c90d9..d3301248 100644 --- a/doc/nitrocli.1 +++ b/doc/nitrocli.1 @@ -1,4 +1,4 @@ -.TH NITROCLI 1 2020-10-11 +.TH NITROCLI 1 2021-04-17 .SH NAME nitrocli \- access Nitrokey devices .SH SYNOPSIS @@ -79,12 +79,16 @@ This command locks the password safe (see the Password safe section). On the Nitrokey Storage, it will also close any active encrypted or hidden volumes (see the Storage section). .TP -.B nitrocli reset +.B nitrocli reset \fR[\fB\-\-only-aes-key\fR] Perform a factory reset on the Nitrokey. This command performs a factory reset on the OpenPGP smart card, clears the flash storage and builds a new AES key. The user PIN is reset to 123456, the admin PIN to 12345678. +If the \fB\-\-only-aes-key\fR option is set, the command does not perform a +full factory reset but only creates a new AES key. +The AES key is for example used to encrypt the password safe. + This command requires the admin PIN. To avoid accidental calls of this command, the user has to enter the PIN even if it has been cached. @@ -427,12 +431,18 @@ The program conveys basic configuration information to any extension being started this way. Specifically, it will set each environment variable as described in the Configuration subsection of the Environment section above, if the corresponding \fBnitrocli\fR program configuration was set. In addition, the -following variable will be set unconditionally: +following variables will be set: .TP .B NITROCLI_BINARY The absolute path to the \fBnitrocli\fR binary through which the extension was invoked. This path may be used to recursively invoke \fBnitrocli\fR to implement certain functionality. +.TP +.B NITROCLI_RESOLVED_USB_PATH +The USB path of the device that \fBnitrocli\fR would connect to based on the +\fB\-\-model\fR, \fB\-\-serial-number\fR, and \fB\-\-usb-path\fR options. +If there is no matching Nitrokey device, or if multiple devices match the +options, the environment variable is not set. .P All other variables present in the environment will be passed through to the diff --git a/doc/nitrocli.1.pdf b/doc/nitrocli.1.pdf index 47dadda3..3d6f6c9b 100644 Binary files a/doc/nitrocli.1.pdf and b/doc/nitrocli.1.pdf differ diff --git a/ext/ext/Cargo.toml b/ext/ext/Cargo.toml new file mode 100644 index 00000000..e2ddcfb6 --- /dev/null +++ b/ext/ext/Cargo.toml @@ -0,0 +1,15 @@ +# Cargo.toml + +# Copyright (C) 2021 The Nitrocli Developers +# SPDX-License-Identifier: GPL-3.0-or-later + +[package] +name = "nitrocli-ext" +version = "0.1.0" +authors = ["Robin Krahl "] +edition = "2018" + +[dependencies] +anyhow = "1" +directories = "3" +nitrokey = "0.9" diff --git a/ext/ext/src/lib.rs b/ext/ext/src/lib.rs new file mode 100644 index 00000000..12339628 --- /dev/null +++ b/ext/ext/src/lib.rs @@ -0,0 +1,177 @@ +// lib.rs + +// Copyright (C) 2020-2021 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::env; +use std::ffi; +use std::path; +use std::process; + +use anyhow::Context as _; + +/// A context providing information relevant to `nitrocli` extensions. +#[derive(Debug)] +pub struct Context { + /// Path to the `nitrocli` binary. + nitrocli: ffi::OsString, + /// The path to the USB device that `nitrocli` would connect to, if + /// any. + resolved_usb_path: Option, + /// The verbosity that `nitrocli` should use. + verbosity: Option, + /// The project directory root to use for the extension in question. + project_dirs: directories::ProjectDirs, +} + +impl Context { + /// Create a new `Context` with information provided by `nitrocli` + /// via environment variables. + pub fn from_env() -> anyhow::Result { + let nitrocli = env::var_os("NITROCLI_BINARY") + .context("NITROCLI_BINARY environment variable not present") + .context("Failed to retrieve nitrocli path")?; + + let resolved_usb_path = env::var("NITROCLI_RESOLVED_USB_PATH").ok(); + + let verbosity = env::var_os("NITROCLI_VERBOSITY") + .context("NITROCLI_VERBOSITY environment variable not present") + .context("Failed to retrieve nitrocli verbosity")?; + + let verbosity = if verbosity.len() == 0 { + None + } else { + let verbosity = verbosity + .to_str() + .context("Provided verbosity string is not valid UTF-8")?; + let verbosity = u8::from_str_radix(verbosity, 10).context("Failed to parse verbosity")?; + set_log_level(verbosity); + Some(verbosity) + }; + + let exe = + env::current_exe().context("Failed to determine the path of the extension executable")?; + let name = exe + .file_name() + .context("Failed to extract the name of the extension executable")? + .to_str() + .context("The name of the extension executable contains non-UTF-8 characters")?; + let project_dirs = directories::ProjectDirs::from("", "", name).with_context(|| { + format!( + "Could not determine the application directories for the {} extension", + name + ) + })?; + + Ok(Self { + nitrocli, + resolved_usb_path, + verbosity, + project_dirs, + }) + } + + /// Retrieve `Nitrocli` object for invoking the main `nitrocli` + /// program. + pub fn nitrocli(&self) -> Nitrocli { + Nitrocli::from_context(self) + } + + /// Connect to a Nitrokey (or Librem Key) device as `nitrocli` would. + pub fn connect<'mgr>( + &self, + mgr: &'mgr mut nitrokey::Manager, + ) -> anyhow::Result> { + if let Some(usb_path) = &self.resolved_usb_path { + mgr.connect_path(usb_path.to_owned()).map_err(From::from) + } else { + // TODO: Improve error message. Unfortunately, we can't easily + // determine whether we have no or more than one (matching) + // device. + Err(anyhow::anyhow!("Could not connect to Nitrokey device")) + } + } + + /// Retrieve the path to the directory in which this extension may + /// store cacheable artifacts. + pub fn cache_dir(&self) -> &path::Path { + self.project_dirs.cache_dir() + } +} + +// See src/command.rs in nitrocli core. +fn set_log_level(verbosity: u8) { + let log_lvl = match verbosity { + // The error log level is what libnitrokey uses by default. As such, + // there is no harm in us setting that as well when the user did not + // ask for higher verbosity. + 0 => nitrokey::LogLevel::Error, + 1 => nitrokey::LogLevel::Warning, + 2 => nitrokey::LogLevel::Info, + 3 => nitrokey::LogLevel::DebugL1, + 4 => nitrokey::LogLevel::Debug, + _ => nitrokey::LogLevel::DebugL2, + }; + nitrokey::set_log_level(log_lvl); +} + +/// A type allowing for convenient invocation of `nitrocli` itself. +#[derive(Debug)] +pub struct Nitrocli { + cmd: process::Command, +} + +impl Nitrocli { + /// Create a new `Nitrocli` instance from a `Context`. + fn from_context(ctx: &Context) -> Nitrocli { + Self { + cmd: process::Command::new(&ctx.nitrocli), + } + } + + /// Add an argument to the `nitrocli` invocation. + pub fn arg(&mut self, arg: S) -> &mut Nitrocli + where + S: AsRef, + { + self.cmd.arg(arg); + self + } + + /// Add multiple arguments to the `nitrocli` invocation. + pub fn args(&mut self, args: I) -> &mut Nitrocli + where + I: IntoIterator, + S: AsRef, + { + self.cmd.args(args); + self + } + + /// Invoke `nitrocli` and retrieve its output as a string. + /// + /// Note that any error messages emitted by `nitrocli` will not be + /// intercepted/captured but will directly be passed through. It is + /// recommended that extensions terminate on failure. + pub fn text(&mut self) -> anyhow::Result { + let output = self.cmd.output().context("Failed to invoke nitrocli")?; + // We want additional nitrocli emitted output to be visible to the + // user (typically controlled through -v/--verbose below). Note that + // this means that we will not be able to access this output for + // error reporting purposes. + self.cmd.stderr(process::Stdio::inherit()); + + if output.status.success() { + String::from_utf8(output.stdout).map_err(From::from) + } else { + Err(anyhow::anyhow!("nitrocli call failed")) + } + } + + /// Invoke `nitrocli`. + pub fn spawn(&mut self) -> anyhow::Result<()> { + let mut child = self.cmd.spawn().context("Failed to invoke nitrocli")?; + child.wait().context("Failed to wait on nitrocli")?; + Ok(()) + } +} diff --git a/ext/otp-cache/Cargo.toml b/ext/otp-cache/Cargo.toml new file mode 100644 index 00000000..66fa77b7 --- /dev/null +++ b/ext/otp-cache/Cargo.toml @@ -0,0 +1,21 @@ +# Cargo.toml + +# Copyright (C) 2020-2021 The Nitrocli Developers +# SPDX-License-Identifier: GPL-3.0-or-later + +[package] +name = "nitrocli-otp-cache" +version = "0.1.0" +authors = ["Robin Krahl "] +edition = "2018" + +[dependencies] +anyhow = "1" +nitrokey = "0.9" +serde = { version = "1", features = ["derive"] } +structopt = { version = "0.3.21", default-features = false } +toml = "0.5" + +[dependencies.nitrocli-ext] +version = "0.1" +path = "../ext" diff --git a/ext/otp-cache/src/main.rs b/ext/otp-cache/src/main.rs new file mode 100644 index 00000000..46355de0 --- /dev/null +++ b/ext/otp-cache/src/main.rs @@ -0,0 +1,176 @@ +// main.rs + +// Copyright (C) 2020-2021 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::fs; +use std::io::Write as _; +use std::path; + +use anyhow::Context as _; + +use structopt::StructOpt as _; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct Cache { + hotp: Vec, + totp: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct Slot { + name: String, + id: u8, +} + +/// Access Nitrokey OTP slots by name +/// +/// This command caches the names of the OTP slots on a Nitrokey device +/// and makes it possible to generate a one-time password from a slot +/// with a given name without knowing its index. It only queries the +/// names of the OTP slots if there is no cached data or if the +/// `--force-update` option is set. The cache includes the Nitrokey's +/// serial number so that it is possible to use it with multiple +/// devices. +#[derive(Debug, structopt::StructOpt)] +#[structopt(bin_name = "nitrocli otp-cache")] +struct Args { + /// Always query the slot data even if it is already cached + #[structopt(short, long)] + force_update: bool, + #[structopt(subcommand)] + cmd: Command, +} + +#[derive(Debug, structopt::StructOpt)] +enum Command { + /// Generates a one-time password + Get { + /// The name of the OTP slot to generate a OTP from + name: String, + }, + /// Lists the cached slots and their names + List, +} + +fn main() -> anyhow::Result<()> { + let args = Args::from_args(); + let ctx = nitrocli_ext::Context::from_env()?; + + let cache = get_cache(&ctx, args.force_update)?; + match &args.cmd { + Command::Get { name } => cmd_get(&ctx, &cache, name)?, + Command::List => cmd_list(&cache), + } + Ok(()) +} + +fn cmd_get(ctx: &nitrocli_ext::Context, cache: &Cache, slot_name: &str) -> anyhow::Result<()> { + let totp_slots = cache + .totp + .iter() + .filter(|s| s.name == slot_name) + .collect::>(); + let hotp_slots = cache + .hotp + .iter() + .filter(|s| s.name == slot_name) + .collect::>(); + if totp_slots.len() + hotp_slots.len() > 1 { + Err(anyhow::anyhow!( + "Found multiple OTP slots with the given name" + )) + } else if let Some(slot) = totp_slots.first() { + generate_otp(&ctx, "totp", slot.id) + } else if let Some(slot) = hotp_slots.first() { + generate_otp(&ctx, "hotp", slot.id) + } else { + Err(anyhow::anyhow!("Found no OTP slot with the given name")) + } +} + +fn cmd_list(cache: &Cache) { + println!("alg\tslot\tname"); + for slot in &cache.totp { + println!("totp\t{}\t{}", slot.id, slot.name); + } + for slot in &cache.hotp { + println!("hotp\t{}\t{}", slot.id, slot.name); + } +} + +fn get_cache(ctx: &nitrocli_ext::Context, force_update: bool) -> anyhow::Result { + let mut mgr = nitrokey::take().context("Failed to obtain Nitrokey manager instance")?; + let device = ctx.connect(&mut mgr)?; + let serial_number = get_serial_number(&device)?; + let cache_file = ctx.cache_dir().join(&format!("{}.toml", serial_number)); + + if cache_file.is_file() && !force_update { + load_cache(&cache_file) + } else { + let cache = get_otp_slots(&device)?; + save_cache(&cache, &cache_file)?; + Ok(cache) + } +} + +fn load_cache(path: &path::Path) -> anyhow::Result { + let s = fs::read_to_string(path).context("Failed to read cache file")?; + toml::from_str(&s).context("Failed to parse cache file") +} + +fn save_cache(cache: &Cache, path: &path::Path) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("Failed to create cache parent directory")?; + } + let mut f = fs::File::create(path).context("Failed to create cache file")?; + let data = toml::to_vec(cache).context("Failed to serialize cache")?; + f.write_all(&data).context("Failed to write cache file")?; + Ok(()) +} + +fn get_serial_number<'a>(device: &impl nitrokey::Device<'a>) -> anyhow::Result { + // TODO: Consider using hidapi serial number (if available) + Ok(device.get_serial_number()?.to_string().to_lowercase()) +} + +fn get_otp_slots_fn(device: &D, f: F) -> anyhow::Result> +where + D: nitrokey::GenerateOtp, + F: Fn(&D, u8) -> Result, +{ + let mut slots = Vec::new(); + let mut slot = 0u8; + loop { + let result = f(device, slot); + match result { + Ok(name) => { + slots.push(Slot { name, id: slot }); + } + Err(nitrokey::Error::LibraryError(nitrokey::LibraryError::InvalidSlot)) => break, + Err(nitrokey::Error::CommandError(nitrokey::CommandError::SlotNotProgrammed)) => {} + Err(err) => return Err(err).context("Failed to check OTP slot"), + } + slot = slot + .checked_add(1) + .context("Encountered integer overflow when iterating OTP slots")?; + } + Ok(slots) +} + +fn get_otp_slots(device: &impl nitrokey::GenerateOtp) -> anyhow::Result { + Ok(Cache { + totp: get_otp_slots_fn(device, |device, slot| device.get_totp_slot_name(slot))?, + hotp: get_otp_slots_fn(device, |device, slot| device.get_hotp_slot_name(slot))?, + }) +} + +fn generate_otp(ctx: &nitrocli_ext::Context, algorithm: &str, slot: u8) -> anyhow::Result<()> { + ctx + .nitrocli() + .args(&["otp", "get"]) + .arg(slot.to_string()) + .arg("--algorithm") + .arg(algorithm) + .spawn() +} diff --git a/ext/pws-cache/Cargo.toml b/ext/pws-cache/Cargo.toml new file mode 100644 index 00000000..538a2a31 --- /dev/null +++ b/ext/pws-cache/Cargo.toml @@ -0,0 +1,21 @@ +# Cargo.toml + +# Copyright (C) 2020-2021 The Nitrocli Developers +# SPDX-License-Identifier: GPL-3.0-or-later + +[package] +name = "nitrocli-pws-cache" +version = "0.1.0" +authors = ["Robin Krahl "] +edition = "2018" + +[dependencies] +anyhow = "1" +nitrokey = "0.9" +serde = { version = "1", features = ["derive"] } +structopt = { version = "0.3.21", default-features = false } +toml = "0.5" + +[dependencies.nitrocli-ext] +version = "0.1" +path = "../ext" diff --git a/ext/pws-cache/src/main.rs b/ext/pws-cache/src/main.rs new file mode 100644 index 00000000..08c5eeb7 --- /dev/null +++ b/ext/pws-cache/src/main.rs @@ -0,0 +1,197 @@ +// main.rs + +// Copyright (C) 2020-2021 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::fs; +use std::io::Write as _; +use std::path; + +use anyhow::Context as _; + +use structopt::StructOpt as _; + +// TODO: query from user +const USER_PIN: &str = "123456"; + +#[derive(Debug, Default, serde::Deserialize, serde::Serialize)] +struct Cache { + slots: Vec, +} + +impl Cache { + pub fn find_slot(&self, name: &str) -> anyhow::Result { + let slots = self + .slots + .iter() + .filter(|s| s.name == name) + .collect::>(); + if slots.len() > 1 { + Err(anyhow::anyhow!( + "Found multiple PWS slots with the given name" + )) + } else if let Some(slot) = slots.first() { + Ok(slot.id) + } else { + Err(anyhow::anyhow!("Found no PWS slot with the given name")) + } + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct Slot { + name: String, + id: u8, +} + +/// Access Nitrokey PWS slots by name +/// +/// This command caches the names of the PWS slots on a Nitrokey device +/// and makes it possible to fetch a login or a password from a slot +/// with a given name without knowing its index. It only queries the +/// names of the PWS slots if there is no cached data or if the +/// `--force-update` option is set. The cache includes the Nitrokey's +/// serial number so that it is possible to use it with multiple +/// devices. +#[derive(Debug, structopt::StructOpt)] +#[structopt(bin_name = "nitrocli pws-cache")] +struct Args { + /// Always query the slot data even if it is already cached + #[structopt(short, long)] + force_update: bool, + #[structopt(subcommand)] + cmd: Command, +} + +#[derive(Debug, structopt::StructOpt)] +enum Command { + /// Fetches the login and the password from a PWS slot + Get(GetArgs), + /// Fetches the login from a PWS slot + GetLogin(GetArgs), + /// Fetches the password from a PWS slot + GetPassword(GetArgs), + /// Lists the cached slots and their names + List, +} + +#[derive(Debug, structopt::StructOpt)] +struct GetArgs { + /// The name of the PWS slot to fetch + name: String, +} + +fn main() -> anyhow::Result<()> { + let args = Args::from_args(); + let ctx = nitrocli_ext::Context::from_env()?; + + let cache = get_cache(&ctx, args.force_update)?; + match &args.cmd { + Command::Get(args) => cmd_get(&ctx, &cache, &args.name)?, + Command::GetLogin(args) => cmd_get_login(&ctx, &cache, &args.name)?, + Command::GetPassword(args) => cmd_get_password(&ctx, &cache, &args.name)?, + Command::List => cmd_list(&cache), + } + Ok(()) +} + +fn cmd_get(ctx: &nitrocli_ext::Context, cache: &Cache, slot_name: &str) -> anyhow::Result<()> { + let slot = cache.find_slot(slot_name)?; + prepare_pws_get(ctx, slot) + .arg("--login") + .arg("--password") + .spawn() +} + +fn cmd_get_login( + ctx: &nitrocli_ext::Context, + cache: &Cache, + slot_name: &str, +) -> anyhow::Result<()> { + let slot = cache.find_slot(slot_name)?; + prepare_pws_get(ctx, slot) + .arg("--login") + .arg("--quiet") + .spawn() +} + +fn cmd_get_password( + ctx: &nitrocli_ext::Context, + cache: &Cache, + slot_name: &str, +) -> anyhow::Result<()> { + let slot = cache.find_slot(slot_name)?; + prepare_pws_get(ctx, slot) + .arg("--password") + .arg("--quiet") + .spawn() +} + +fn cmd_list(cache: &Cache) { + println!("slot\tname"); + for slot in &cache.slots { + println!("{}\t{}", slot.id, slot.name); + } +} + +fn get_cache(ctx: &nitrocli_ext::Context, force_update: bool) -> anyhow::Result { + let mut mgr = nitrokey::take().context("Failed to obtain Nitrokey manager instance")?; + let mut device = ctx.connect(&mut mgr)?; + let serial_number = get_serial_number(&device)?; + let cache_file = ctx.cache_dir().join(&format!("{}.toml", serial_number)); + + if cache_file.is_file() && !force_update { + load_cache(&cache_file) + } else { + let cache = get_pws_slots(&mut device)?; + save_cache(&cache, &cache_file)?; + Ok(cache) + } +} + +fn load_cache(path: &path::Path) -> anyhow::Result { + let s = fs::read_to_string(path).context("Failed to read cache file")?; + toml::from_str(&s).context("Failed to parse cache file") +} + +fn save_cache(cache: &Cache, path: &path::Path) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("Failed to create cache parent directory")?; + } + let mut f = fs::File::create(path).context("Failed to create cache file")?; + let data = toml::to_vec(cache).context("Failed to serialize cache")?; + f.write_all(&data).context("Failed to write cache file")?; + Ok(()) +} + +fn get_serial_number<'a>(device: &impl nitrokey::Device<'a>) -> anyhow::Result { + // TODO: Consider using hidapi serial number (if available) + Ok(device.get_serial_number()?.to_string().to_lowercase()) +} + +fn get_pws_slots<'a>(device: &mut impl nitrokey::GetPasswordSafe<'a>) -> anyhow::Result { + let pws = device + .get_password_safe(USER_PIN) + .context("Failed to open password safe")?; + let slots = pws + .get_slots() + .context("Failed to query password safe slots")?; + let mut cache = Cache::default(); + for slot in slots { + if let Some(slot) = slot { + let id = slot.index(); + let name = slot + .get_name() + .with_context(|| format!("Failed to query name for password slot {}", id))?; + cache.slots.push(Slot { name, id }); + } + } + Ok(cache) +} + +fn prepare_pws_get(ctx: &nitrocli_ext::Context, slot: u8) -> nitrocli_ext::Nitrocli { + let mut ncli = ctx.nitrocli(); + let _ = ncli.args(&["pws", "get"]); + let _ = ncli.arg(slot.to_string()); + ncli +} diff --git a/src/args.rs b/src/args.rs index 4b1e21c2..62cf444b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -103,7 +103,7 @@ Command! { /// Accesses the password safe Pws(PwsArgs) => |ctx, args: PwsArgs| args.subcmd.execute(ctx), /// Performs a factory reset - Reset => crate::commands::reset, + Reset(ResetArgs) => |ctx, args: ResetArgs| crate::commands::reset(ctx, args.only_aes_key), /// Prints the status of the connected Nitrokey device Status => crate::commands::status, /// Interacts with the device's unencrypted volume @@ -445,6 +445,13 @@ pub struct PwsStatusArgs { pub all: bool, } +#[derive(Debug, PartialEq, structopt::StructOpt)] +pub struct ResetArgs { + /// Only build a new AES key instead of performing a full factory reset. + #[structopt(long)] + pub only_aes_key: bool, +} + #[derive(Debug, PartialEq, structopt::StructOpt)] pub struct UnencryptedArgs { #[structopt(subcommand)] diff --git a/src/commands.rs b/src/commands.rs index 8a1604b9..92574779 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -513,7 +513,7 @@ pub fn fill(ctx: &mut Context<'_>, attach: bool) -> anyhow::Result<()> { } /// Perform a factory reset. -pub fn reset(ctx: &mut Context<'_>) -> anyhow::Result<()> { +pub fn reset(ctx: &mut Context<'_>, only_aes_key: bool) -> anyhow::Result<()> { with_device(ctx, |ctx, mut device| { let pin_entry = pinentry::PinEntry::from(args::PinType::Admin, &device)?; @@ -522,20 +522,28 @@ pub fn reset(ctx: &mut Context<'_>) -> anyhow::Result<()> { pinentry::clear(&pin_entry).context("Failed to clear cached secret")?; try_with_pin(ctx, &pin_entry, |pin| { - device - .factory_reset(&pin) - .context("Failed to reset to factory settings")?; - // Work around for a timing issue between factory_reset and - // build_aes_key, see - // https://github.com/Nitrokey/nitrokey-storage-firmware/issues/80 - thread::sleep(time::Duration::from_secs(3)); - // Another work around for spurious WrongPassword returns of - // build_aes_key after a factory reset on Pro devices. - // https://github.com/Nitrokey/nitrokey-pro-firmware/issues/57 - let _ = device.get_user_retry_count(); - device - .build_aes_key(nitrokey::DEFAULT_ADMIN_PIN) - .context("Failed to rebuild AES key") + if only_aes_key { + // Similar to the else arm, we have to execute this command to avoid WrongPassword errors + let _ = device.get_user_retry_count(); + device + .build_aes_key(&pin) + .context("Failed to rebuild AES key") + } else { + device + .factory_reset(&pin) + .context("Failed to reset to factory settings")?; + // Work around for a timing issue between factory_reset and + // build_aes_key, see + // https://github.com/Nitrokey/nitrokey-storage-firmware/issues/80 + thread::sleep(time::Duration::from_secs(3)); + // Another work around for spurious WrongPassword returns of + // build_aes_key after a factory reset on Pro devices. + // https://github.com/Nitrokey/nitrokey-pro-firmware/issues/57 + let _ = device.get_user_retry_count(); + device + .build_aes_key(nitrokey::DEFAULT_ADMIN_PIN) + .context("Failed to rebuild AES key") + } }) }) } @@ -1170,6 +1178,10 @@ pub fn extension(ctx: &mut Context<'_>, args: Vec) -> anyhow::Res // a cargo test context. let mut cmd = process::Command::new(&ext_path); + if let Ok(device_info) = find_device(&ctx.config) { + let _ = cmd.env(crate::NITROCLI_RESOLVED_USB_PATH, device_info.path); + } + if let Some(model) = ctx.config.model { let _ = cmd.env(crate::NITROCLI_MODEL, model.to_string()); } diff --git a/src/main.rs b/src/main.rs index 34a2a931..9a5ee4df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,6 +74,7 @@ use structopt::clap::SubCommand; use structopt::StructOpt; const NITROCLI_BINARY: &str = "NITROCLI_BINARY"; +const NITROCLI_RESOLVED_USB_PATH: &str = "NITROCLI_RESOLVED_USB_PATH"; const NITROCLI_MODEL: &str = "NITROCLI_MODEL"; const NITROCLI_USB_PATH: &str = "NITROCLI_USB_PATH"; const NITROCLI_VERBOSITY: &str = "NITROCLI_VERBOSITY"; @@ -232,7 +233,7 @@ fn evaluate_err(err: anyhow::Error, stderr: &mut dyn io::Write) -> i32 { if let Some(err) = err.root_cause().downcast_ref::() { err.0 } else { - let _ = writeln!(stderr, "{:?}", err); + let _ = writeln!(stderr, "{:#}", err); 1 } } diff --git a/src/tests/config.rs b/src/tests/config.rs index b3d27de7..5b0cf8b4 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -1,6 +1,6 @@ // config.rs -// Copyright (C) 2019-2020 The Nitrocli Developers +// Copyright (C) 2019-2021 The Nitrocli Developers // SPDX-License-Identifier: GPL-3.0-or-later use super::*; @@ -11,10 +11,10 @@ fn mutually_exclusive_set_options() { let (rc, out, err) = Nitrocli::new().run(&["config", "set", option1, option2]); assert_ne!(rc, 0); - assert_eq!(out, b""); + assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); let err = String::from_utf8(err).unwrap(); - assert!(err.contains("cannot be used with"), err); + assert!(err.contains("cannot be used with"), "{}", err); } test("-c", "-C"); @@ -36,7 +36,7 @@ $"#, let out = Nitrocli::new().model(model).handle(&["config", "get"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } @@ -50,6 +50,7 @@ fn set_wrong_usage(model: nitrokey::Model) { assert!( err.contains("The argument '--num-lock ' cannot be used with '--no-num-lock'"), + "{}", err, ); } @@ -70,6 +71,6 @@ $"#, .unwrap(); let out = ncli.handle(&["config", "get"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } diff --git a/src/tests/encrypted.rs b/src/tests/encrypted.rs index 81a151a2..085a5687 100644 --- a/src/tests/encrypted.rs +++ b/src/tests/encrypted.rs @@ -32,15 +32,15 @@ $"#, let mut ncli = Nitrocli::new().model(model); let out = ncli.handle(&["status"])?; - assert!(make_re(None).is_match(&out), out); + assert!(make_re(None).is_match(&out), "{}", out); let _ = ncli.handle(&["encrypted", "open"])?; let out = ncli.handle(&["status"])?; - assert!(make_re(Some(true)).is_match(&out), out); + assert!(make_re(Some(true)).is_match(&out), "{}", out); let _ = ncli.handle(&["encrypted", "close"])?; let out = ncli.handle(&["status"])?; - assert!(make_re(Some(false)).is_match(&out), out); + assert!(make_re(Some(false)).is_match(&out), "{}", out); Ok(()) } diff --git a/src/tests/list.rs b/src/tests/list.rs index 71973130..1da2df8d 100644 --- a/src/tests/list.rs +++ b/src/tests/list.rs @@ -23,6 +23,6 @@ fn connected(model: nitrokey::Model) -> anyhow::Result<()> { .unwrap(); let out = Nitrocli::new().model(model).handle(&["list"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } diff --git a/src/tests/otp.rs b/src/tests/otp.rs index 3431e8bc..8a48adb8 100644 --- a/src/tests/otp.rs +++ b/src/tests/otp.rs @@ -1,6 +1,6 @@ // otp.rs -// Copyright (C) 2019-2020 The Nitrocli Developers +// Copyright (C) 2019-2021 The Nitrocli Developers // SPDX-License-Identifier: GPL-3.0-or-later use super::*; @@ -14,8 +14,13 @@ fn set_invalid_slot_raw(model: nitrokey::Model) { .run(&["otp", "set", "100", "name", "1234", "-f", "hex"]); assert_ne!(rc, 0); - assert_eq!(out, b""); - assert_eq!(&err[..24], b"Failed to write OTP slot"); + assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); + assert_eq!( + &err[..24], + b"Failed to write OTP slot", + "{}", + String::from_utf8_lossy(&err) + ); } #[test_device] @@ -43,7 +48,7 @@ fn status(model: nitrokey::Model) -> anyhow::Result<()> { let _ = ncli.handle(&["otp", "set", "0", "the-name", "123456", "-f", "hex"])?; let out = ncli.handle(&["otp", "status"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } diff --git a/src/tests/pws.rs b/src/tests/pws.rs index 15488337..f6c43989 100644 --- a/src/tests/pws.rs +++ b/src/tests/pws.rs @@ -30,7 +30,7 @@ fn status(model: nitrokey::Model) -> anyhow::Result<()> { let _ = ncli.handle(&["pws", "set", "0", "the-name", "the-login", "123456"])?; let out = ncli.handle(&["pws", "status"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } diff --git a/src/tests/reset.rs b/src/tests/reset.rs index 99342843..78fd13c7 100644 --- a/src/tests/reset.rs +++ b/src/tests/reset.rs @@ -1,6 +1,6 @@ // reset.rs -// Copyright (C) 2019-2020 The Nitrocli Developers +// Copyright (C) 2019-2021 The Nitrocli Developers // SPDX-License-Identifier: GPL-3.0-or-later use nitrokey::Authenticate; @@ -43,3 +43,59 @@ fn reset(model: nitrokey::Model) -> anyhow::Result<()> { Ok(()) } + +#[test_device] +fn reset_only_aes_key(model: nitrokey::Model) -> anyhow::Result<()> { + const NEW_USER_PIN: &str = "654321"; + const NAME: &str = "slotname"; + const LOGIN: &str = "sloglogin"; + const PASSWORD: &str = "slotpassword"; + + let mut ncli = Nitrocli::new().model(model).new_user_pin(NEW_USER_PIN); + + // Change the user PIN + let _ = ncli.handle(&["pin", "set", "user"])?; + + // Add an entry to the PWS + { + let mut manager = nitrokey::force_take()?; + let mut device = manager.connect_model(model)?; + let mut pws = device.get_password_safe(NEW_USER_PIN)?; + pws.write_slot(0, NAME, LOGIN, PASSWORD)?; + } + + // Build AES key + let mut ncli = Nitrocli::new().model(model); + let out = ncli.handle(&["reset", "--only-aes-key"])?; + assert!(out.is_empty()); + + // Check that 1) the password store works, i.e., there is an AES key, + // that 2) we can no longer access the stored data, i.e., the AES has + // been replaced, and that 3) the changed user PIN still works, i.e., + // we did not perform a factory reset. + { + let mut manager = nitrokey::force_take()?; + let mut device = manager.connect_model(model)?; + let pws = device.get_password_safe(NEW_USER_PIN)?; + let slot = pws.get_slot_unchecked(0)?; + + if let Ok(name) = slot.get_name() { + assert_ne!(NAME, &name); + } + if let Ok(login) = slot.get_login() { + assert_ne!(LOGIN, &login); + } + if let Ok(password) = slot.get_password() { + assert_ne!(PASSWORD, &password); + } + } + + // Reset the user PIN for other tests + let mut ncli = ncli + .user_pin(NEW_USER_PIN) + .new_user_pin(nitrokey::DEFAULT_USER_PIN); + let out = ncli.handle(&["pin", "set", "user"])?; + assert!(out.is_empty()); + + Ok(()) +} diff --git a/src/tests/run.rs b/src/tests/run.rs index 158bc88a..6ad26886 100644 --- a/src/tests/run.rs +++ b/src/tests/run.rs @@ -1,6 +1,6 @@ // run.rs -// Copyright (C) 2019-2020 The Nitrocli Developers +// Copyright (C) 2019-2021 The Nitrocli Developers // SPDX-License-Identifier: GPL-3.0-or-later use std::collections; @@ -21,11 +21,11 @@ fn no_command_or_option() { let (rc, out, err) = Nitrocli::new().run(&[]); assert_ne!(rc, 0); - assert_eq!(out, b""); + assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); let s = String::from_utf8_lossy(&err).into_owned(); - assert!(s.starts_with("nitrocli"), s); - assert!(s.contains("USAGE:\n"), s); + assert!(s.starts_with("nitrocli"), "{}", s); + assert!(s.contains("USAGE:\n"), "{}", s); } #[test] @@ -37,13 +37,13 @@ fn help_options() { let (rc, out, err) = Nitrocli::new().run(&all); assert_eq!(rc, 0); - assert_eq!(err, b""); + assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); let s = String::from_utf8_lossy(&out).into_owned(); let mut args = args.to_vec(); args.insert(0, "nitrocli"); - assert!(s.starts_with(&args.join("-")), s); - assert!(s.contains("USAGE:\n"), s); + assert!(s.starts_with(&args.join("-")), "{}", s); + assert!(s.contains("USAGE:\n"), "{}", s); } fn test(args: &[&str]) { @@ -93,11 +93,11 @@ fn version_option() { let (rc, out, err) = Nitrocli::new().run(&[opt]); assert_eq!(rc, 0); - assert_eq!(err, b""); + assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); let s = String::from_utf8_lossy(&out).into_owned(); let _ = re; - assert!(re.is_match(&s), out); + assert!(re.is_match(&s), "{}", s); } let re = regex::Regex::new(r"^nitrocli \d+.\d+.\d+(-[^-]+)* using libnitrokey .*\n$").unwrap(); @@ -302,7 +302,11 @@ print("success") let path = ext_dir.path().as_os_str().to_os_string(); // Make sure that the extension appears in the help text. let out = Nitrocli::new().path(&path).handle(&["--help"])?; - assert!(out.contains("ext Run the ext extension\n"), out); + assert!( + out.contains("ext Run the ext extension\n"), + "{}", + out + ); // And, of course, that we can invoke it. let out = Nitrocli::new().path(&path).handle(&["ext"])?; assert_eq!(out, "success\n"); @@ -342,8 +346,8 @@ sys.exit(42); let (rc, out, err) = ncli.run(&["ext"]); assert_eq!(rc, 42); - assert_eq!(out, b""); - assert_eq!(err, b""); + assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); + assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); Ok(()) } @@ -370,7 +374,7 @@ fn extension_arguments(model: nitrokey::Model) -> anyhow::Result<()> { let path = ext_dir.path().as_os_str().to_os_string(); let out = Nitrocli::new().model(model).path(path).handle(&args)?; - assert!(check(&out), out); + assert!(check(&out), "{}", out); Ok(()) } diff --git a/src/tests/status.rs b/src/tests/status.rs index b751984a..7df70cb2 100644 --- a/src/tests/status.rs +++ b/src/tests/status.rs @@ -10,8 +10,13 @@ fn not_found_raw() { let (rc, out, err) = Nitrocli::new().run(&["status"]); assert_ne!(rc, 0); - assert_eq!(out, b""); - assert_eq!(err, b"Nitrokey device not found\n"); + assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); + assert_eq!( + err, + b"Nitrokey device not found\n", + "{}", + String::from_utf8_lossy(&err) + ); } #[test_device] @@ -35,7 +40,7 @@ $"#, .unwrap(); let out = Nitrocli::new().model(model).handle(&["status"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } @@ -53,7 +58,7 @@ $"#, .unwrap(); let out = Nitrocli::new().model(model).handle(&["status"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } @@ -80,6 +85,6 @@ $"#, .unwrap(); let out = Nitrocli::new().model(model).handle(&["status"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) }