diff --git a/Cargo.lock b/Cargo.lock index a644916e..bf9cef1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,27 @@ 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 = "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/doc/nitrocli.1 b/doc/nitrocli.1 index 832c90d9..934411de 100644 --- a/doc/nitrocli.1 +++ b/doc/nitrocli.1 @@ -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/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..1bcb5750 --- /dev/null +++ b/ext/ext/src/lib.rs @@ -0,0 +1,149 @@ +// 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 _; + +#[derive(Debug)] +pub struct Context { + nitrocli: ffi::OsString, + resolved_usb_path: Option, + verbosity: Option, + project_dirs: directories::ProjectDirs, +} + +impl Context { + 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, + }) + } + + pub fn nitrocli(&self) -> Nitrocli { + Nitrocli::from_context(self) + } + + 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")) + } + } + + 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); +} + +#[derive(Debug)] +pub struct Nitrocli { + cmd: process::Command, +} + +impl Nitrocli { + pub fn from_context(ctx: &Context) -> Nitrocli { + Self { + cmd: process::Command::new(&ctx.nitrocli), + } + } + + pub fn arg(&mut self, arg: impl AsRef) -> &mut Nitrocli { + self.cmd.arg(arg); + self + } + + pub fn args(&mut self, args: I) -> &mut Nitrocli + where + I: IntoIterator, + S: AsRef, + { + self.cmd.args(args); + self + } + + 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")) + } + } + + 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..969960f7 --- /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.17", 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..956b8d58 --- /dev/null +++ b/ext/otp-cache/src/main.rs @@ -0,0 +1,168 @@ +// main.rs + +// Copyright (C) 2020-2021 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::fs; +use std::io; +use std::path; + +use anyhow::Context 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<()> { + use structopt::StructOpt as _; + + 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: Vec<_> = cache.totp.iter().filter(|s| s.name == slot_name).collect(); + let hotp_slots: Vec<_> = 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<()> { + use io::Write as _; + + 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: u8 = 0; + 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/src/commands.rs b/src/commands.rs index 8a1604b9..3a4ff897 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1170,6 +1170,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 d1939346..2e7bb4dc 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";