From c04ff249261561526187fbdfd4cd611f2acdbe10 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Mon, 12 Apr 2021 20:27:11 +0200 Subject: [PATCH 01/15] Pass on resolved USB path to extensions To avoid reimplementing the device selection logic in extensions, we introduce a new environment variable NITROCLI_RESOLVED_USB_PATH that is set to the USB path of the single matching Nitrokey device. If no device matches, or if there are multiple matching devices, the variable is not set. --- CHANGELOG.md | 2 ++ doc/nitrocli.1 | 10 ++++++++-- doc/nitrocli.1.pdf | Bin 47756 -> 48011 bytes src/commands.rs | 4 ++++ src/main.rs | 1 + 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb5aff7..6ed90cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ Unreleased ---------- - Enabled usage of empty PWS slot fields +- 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 - Bumped `nitrokey` dependency to `0.9.0` diff --git a/doc/nitrocli.1 b/doc/nitrocli.1 index 832c90d9..43656f4d 100644 --- a/doc/nitrocli.1 +++ b/doc/nitrocli.1 @@ -1,4 +1,4 @@ -.TH NITROCLI 1 2020-10-11 +.TH NITROCLI 1 2021-04-14 .SH NAME nitrocli \- access Nitrokey devices .SH SYNOPSIS @@ -427,12 +427,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 variable 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 47dadda30c6fb41dc0d70e850c3e2f22d74cfb39..c4e7ffabcfc73c616a3e96be4dcf40d65cc08d0f 100644 GIT binary patch delta 17062 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, 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..a2e0a872 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"; From ac3bbae8891d285e9db334ba0d555a02814cf8b8 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Wed, 14 Apr 2021 20:16:20 -0700 Subject: [PATCH 02/15] Build and test the entire workspace With upcoming changes we will have not only the main crate (comprised of two binaries), but an entire workspace containing extensions and other support crates. This change instructs the CI to build and test these crates as it does for the main crate. --- ci/gitlab-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/gitlab-ci.yml b/ci/gitlab-ci.yml index 348cc1d1..26d653f7 100644 --- a/ci/gitlab-ci.yml +++ b/ci/gitlab-ci.yml @@ -15,9 +15,9 @@ build-test:cargo: - 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 + - cargo build --workspace --bins --tests --verbose + - cargo build --workspace --bins --tests --verbose --release + - cargo test --workspace --verbose lint:clippy: script: From c33be48956992eed8892fe94cd4b8a111fedabc9 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Mon, 12 Apr 2021 20:52:46 +0200 Subject: [PATCH 03/15] Add extension support crate nitrocli-ext This patch adds the extension support crate nitrocli-ext as a workspace member. This crate contains useful methods for extensions written in Rust, providing access to the nitrocli binary and to the nitrokey-rs library. --- CHANGELOG.md | 1 + Cargo.lock | 9 +++ Cargo.toml | 3 + ext/ext/Cargo.toml | 15 ++++ ext/ext/src/lib.rs | 177 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 ext/ext/Cargo.toml create mode 100644 ext/ext/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed90cc5..833ae916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ Unreleased ---------- +- Introduced extension support crate, `nitrocli-ext` - Enabled usage of empty PWS slot fields - Added `NITROCLI_RESOLVED_USB_PATH` environment variable to be used by extensions diff --git a/Cargo.lock b/Cargo.lock index a644916e..1d0ea936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,15 @@ dependencies = [ "toml", ] +[[package]] +name = "nitrocli-ext" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "nitrokey", +] + [[package]] name = "nitrokey" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 6bb42e53..8b888a03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,3 +82,6 @@ version = "1" [dev-dependencies.tempfile] version = "3.1" + +[workspace] +members = ["ext/*"] 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(()) + } +} From 2c700dc5a025646486d65e8be6e57737976fb41a Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Thu, 15 Apr 2021 22:38:18 -0700 Subject: [PATCH 04/15] fixup! Pass on resolved USB path to extensions --- doc/nitrocli.1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/nitrocli.1 b/doc/nitrocli.1 index 43656f4d..9b602de6 100644 --- a/doc/nitrocli.1 +++ b/doc/nitrocli.1 @@ -427,7 +427,7 @@ 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: +following variables will be set: .TP .B NITROCLI_BINARY The absolute path to the \fBnitrocli\fR binary through which the extension was From 86737a2cf5a128303d58dd6e3ceb7c262d18f181 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 10 Apr 2021 07:15:34 +0200 Subject: [PATCH 05/15] Add otp-cache extension This patch adds the nitrocli-otp-cache extension that caches OTP data. The per-device cache stores the names, OTP algorithms and IDs of the slots It can be used to access the slots by name instead of slot index. --- CHANGELOG.md | 1 + Cargo.lock | 12 +++ Cargo.toml | 6 +- ext/otp-cache/Cargo.toml | 21 +++++ ext/otp-cache/src/main.rs | 176 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 ext/otp-cache/Cargo.toml create mode 100644 ext/otp-cache/src/main.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 833ae916..3b614808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ Unreleased ---------- - Introduced extension support crate, `nitrocli-ext` +- Introduced `otp-cache` core extension - Enabled usage of empty PWS slot fields - Added `NITROCLI_RESOLVED_USB_PATH` environment variable to be used by extensions diff --git a/Cargo.lock b/Cargo.lock index 1d0ea936..bf9cef1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,18 @@ dependencies = [ "nitrokey", ] +[[package]] +name = "nitrocli-otp-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 8b888a03..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" 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() +} From 7b181e9a54da19d71b38fed75cd7f6c2304eb51b Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 16 Apr 2021 15:35:37 +0200 Subject: [PATCH 06/15] Use string literals for assert messages Fix compiler warnings for dynamic assert messages seen with rustc 1.51. --- src/tests/config.rs | 7 ++++--- src/tests/encrypted.rs | 6 +++--- src/tests/list.rs | 2 +- src/tests/otp.rs | 2 +- src/tests/pws.rs | 2 +- src/tests/run.rs | 18 +++++++++++------- src/tests/status.rs | 6 +++--- 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/tests/config.rs b/src/tests/config.rs index b3d27de7..1ed71b66 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -14,7 +14,7 @@ fn mutually_exclusive_set_options() { assert_eq!(out, b""); 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..45ae7843 100644 --- a/src/tests/otp.rs +++ b/src/tests/otp.rs @@ -43,7 +43,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/run.rs b/src/tests/run.rs index 158bc88a..afa2128a 100644 --- a/src/tests/run.rs +++ b/src/tests/run.rs @@ -24,8 +24,8 @@ fn no_command_or_option() { assert_eq!(out, b""); 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] @@ -42,8 +42,8 @@ fn help_options() { 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]) { @@ -97,7 +97,7 @@ fn version_option() { 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"); @@ -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..1c90a722 100644 --- a/src/tests/status.rs +++ b/src/tests/status.rs @@ -35,7 +35,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 +53,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 +80,6 @@ $"#, .unwrap(); let out = Nitrocli::new().model(model).handle(&["status"])?; - assert!(re.is_match(&out), out); + assert!(re.is_match(&out), "{}", out); Ok(()) } From 6a89f65e5ba3a55b86f1cd285ae44ac017697b6e Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 16 Apr 2021 15:40:54 +0200 Subject: [PATCH 07/15] Add message to assertions on bytes Some of our tests compare byte slices and are very hard to debug if the assertion fails due to they way these slices are printed. This patch adds assertion messages containing the (lossy) string representation of the byte slice to make it easier to debug errors. Fixes #152 --- src/tests/config.rs | 2 +- src/tests/otp.rs | 9 +++++++-- src/tests/run.rs | 10 +++++----- src/tests/status.rs | 9 +++++++-- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/tests/config.rs b/src/tests/config.rs index 1ed71b66..1f36998b 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -11,7 +11,7 @@ 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); diff --git a/src/tests/otp.rs b/src/tests/otp.rs index 45ae7843..a87a8653 100644 --- a/src/tests/otp.rs +++ b/src/tests/otp.rs @@ -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(&out) + ); } #[test_device] diff --git a/src/tests/run.rs b/src/tests/run.rs index afa2128a..81ac9706 100644 --- a/src/tests/run.rs +++ b/src/tests/run.rs @@ -21,7 +21,7 @@ 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); @@ -37,7 +37,7 @@ 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(); @@ -93,7 +93,7 @@ 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; @@ -346,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(&out)); Ok(()) } diff --git a/src/tests/status.rs b/src/tests/status.rs index 1c90a722..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] From 96b60fdeb10f6dc9d3de2593488f2eb2ee3bb445 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Sat, 17 Apr 2021 11:05:33 -0700 Subject: [PATCH 08/15] fixup! Add message to assertions on bytes --- src/tests/otp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/otp.rs b/src/tests/otp.rs index a87a8653..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::*; @@ -19,7 +19,7 @@ fn set_invalid_slot_raw(model: nitrokey::Model) { &err[..24], b"Failed to write OTP slot", "{}", - String::from_utf8_lossy(&out) + String::from_utf8_lossy(&err) ); } From 7fb72ff1b34713a6d389512a1822dff962361d11 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Sat, 17 Apr 2021 11:08:01 -0700 Subject: [PATCH 09/15] fixup! Add message to assertions on bytes --- src/tests/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/config.rs b/src/tests/config.rs index 1f36998b..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::*; From 49f518fda3f00e09fece64ad93069036d67dc36f Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Sat, 17 Apr 2021 11:06:51 -0700 Subject: [PATCH 10/15] fixup! Add message to assertions on bytes --- src/tests/run.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/run.rs b/src/tests/run.rs index 81ac9706..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; @@ -347,7 +347,7 @@ sys.exit(42); let (rc, out, err) = ncli.run(&["ext"]); assert_eq!(rc, 42); assert_eq!(out, b"", "{}", String::from_utf8_lossy(&out)); - assert_eq!(err, b"", "{}", String::from_utf8_lossy(&out)); + assert_eq!(err, b"", "{}", String::from_utf8_lossy(&err)); Ok(()) } From e53a2981b002c31c51c65f07d2430e04457bc270 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Fri, 16 Apr 2021 22:45:15 -0700 Subject: [PATCH 11/15] Change error formatting to omit backtraces The format specifier we use for printing errors returned by the main program will include a backtrace in the output -- if one is available. That's not necessarily a feature that we need: the causal chain of errors should be sufficient and end users are unlikely to have use for a backtrace. This change adjusts the format specifier so that we only print the chain of errors, albeit in a slightly different format: - Previously: > Failed to generate OTP > > Caused by: > Command error: The given slot is not programmed - New: > Failed to generate OTP: Command error: The given slot is not programmed --- CHANGELOG.md | 1 + src/main.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b614808..03d55eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Unreleased - Introduced extension support crate, `nitrocli-ext` - Introduced `otp-cache` core extension - 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 diff --git a/src/main.rs b/src/main.rs index a2e0a872..9a5ee4df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,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 } } From b4212fd850f644ab546a01a7ea4eef55893c2d73 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Thu, 15 Apr 2021 17:56:21 +0200 Subject: [PATCH 12/15] Add GitHub Actions configuration This patch adds a configuration for GitHub Actions that builds and tests nitrocli with the MSRV, stable, beta and nightly Rust, calls clippy and rustfmt and checks the license annotations with reuse-tool. --- .github/workflows/ci.yml | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 333d9de1de58b6059299cfd4d40bb99ac805cd04 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Sat, 17 Apr 2021 11:23:19 -0700 Subject: [PATCH 13/15] Remove GitLab CI configuration With the addition of the GitHub Actions workflow we have found a better working replacement for the GitLab CI. To that end, we no longer want to use the latter. This change removes the corresponding configuration from the repository. --- CHANGELOG.md | 1 + ci/gitlab-ci.yml | 37 ------------------------------------- 2 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 ci/gitlab-ci.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d55eff..c4b6fe13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Unreleased 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` diff --git a/ci/gitlab-ci.yml b/ci/gitlab-ci.yml deleted file mode 100644 index 26d653f7..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 --workspace --bins --tests --verbose - - cargo build --workspace --bins --tests --verbose --release - - cargo test --workspace --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 From 01b8b4f0bd1535d6a90fcd3a7c6f92deea169741 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Wed, 14 Apr 2021 12:14:57 +0200 Subject: [PATCH 14/15] Add --only-aes-key option to reset command This patch adds an --only-aes-key option to the reset command to only build a new AES key without performing a full factory reset. Fixes #69 --- CHANGELOG.md | 2 ++ doc/nitrocli.1 | 8 +++++-- doc/nitrocli.1.pdf | Bin 48011 -> 48261 bytes src/args.rs | 9 ++++++- src/commands.rs | 38 +++++++++++++++++------------ src/tests/reset.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 96 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b6fe13..8394bd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Unreleased - 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/doc/nitrocli.1 b/doc/nitrocli.1 index 9b602de6..d3301248 100644 --- a/doc/nitrocli.1 +++ b/doc/nitrocli.1 @@ -1,4 +1,4 @@ -.TH NITROCLI 1 2021-04-14 +.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. diff --git a/doc/nitrocli.1.pdf b/doc/nitrocli.1.pdf index c4e7ffabcfc73c616a3e96be4dcf40d65cc08d0f..3d6f6c9b0240b08dc184df6dc42ad643e2fd6fb3 100644 GIT binary patch delta 18928 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 ZP(GW-KgQ$wQxF_PaLUGw4%?ZOe*-nQF@FF6 delta 18556 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 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 3a4ff897..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") + } }) }) } 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(()) +} From 6baaa79e502b64b11f2d60e7e42676007f4f812f Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 17 Apr 2021 22:01:48 +0200 Subject: [PATCH 15/15] Add pws-cache extension This patch adds the pws-cache core extension that allows accessing the PWS slots by their name instead of the slot index. Fixes #155. --- CHANGELOG.md | 2 +- Cargo.lock | 12 +++ ext/pws-cache/Cargo.toml | 21 ++++ ext/pws-cache/src/main.rs | 197 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 ext/pws-cache/Cargo.toml create mode 100644 ext/pws-cache/src/main.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8394bd4c..3d1b1ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Unreleased ---------- - Introduced extension support crate, `nitrocli-ext` -- Introduced `otp-cache` core extension +- 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 diff --git a/Cargo.lock b/Cargo.lock index bf9cef1f..95941fae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,18 @@ dependencies = [ "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/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 +}