From ed59adcc9280d937f0f6f3627104597681ce7347 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Tue, 6 Oct 2020 22:38:10 -0700 Subject: Add otp-cache extension --- Cargo.lock | 12 ++++ Cargo.toml | 3 + ext/otp-cache/Cargo.toml | 16 +++++ ext/otp-cache/src/ext.rs | 145 +++++++++++++++++++++++++++++++++++++++++ ext/otp-cache/src/main.rs | 162 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 ext/otp-cache/Cargo.toml create mode 100644 ext/otp-cache/src/ext.rs create mode 100644 ext/otp-cache/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 506ffec..d8a1a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,18 @@ dependencies = [ "toml", ] +[[package]] +name = "nitrocli-otp-cache" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "nitrokey", + "serde", + "structopt", + "toml", +] + [[package]] name = "nitrokey" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 1af4f2f..167474b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,3 +81,6 @@ version = "1" [dev-dependencies.tempfile] version = "3.1" + +[workspace] +members = ["ext/*"] diff --git a/ext/otp-cache/Cargo.toml b/ext/otp-cache/Cargo.toml new file mode 100644 index 0000000..85f9ced --- /dev/null +++ b/ext/otp-cache/Cargo.toml @@ -0,0 +1,16 @@ +# Copyright (C) 2020 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" +directories = "3" +nitrokey = "0.7.1" +serde = { version = "1", features = ["derive"] } +structopt = { version = "0.3.17", default-features = false } +toml = "0.5" diff --git a/ext/otp-cache/src/ext.rs b/ext/otp-cache/src/ext.rs new file mode 100644 index 0000000..9ed965b --- /dev/null +++ b/ext/otp-cache/src/ext.rs @@ -0,0 +1,145 @@ +// ext.rs + +// Copyright (C) 2020 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::env; +use std::ffi; +use std::fmt; +use std::process; +use std::str; + +use anyhow::Context as _; + +pub struct Context { + /// The path to the nitrocli binary. + pub nitrocli: ffi::OsString, + /// The nitrokey model to use. + pub model: nitrokey::Model, + /// The verbosity level to use. + pub verbosity: u8, +} + +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 model = env::var_os("NITROCLI_MODEL") + .context("NITROCLI_MODEL environment variable not present") + .context("Failed to retrieve nitrocli model")?; + let model = model + .to_str() + .context("Provided model string is not valid UTF-8")?; + let model = match model { + "pro" => nitrokey::Model::Pro, + "storage" => nitrokey::Model::Storage, + _ => anyhow::bail!("Provided model is not valid: '{}'", model), + }; + + let verbosity = env::var_os("NITROCLI_VERBOSITY") + .context("NITROCLI_VERBOSITY environment variable not present") + .context("Failed to retrieve nitrocli verbosity")?; + 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")?; + + Ok(Self { + nitrocli, + model, + verbosity, + }) + } +} + +#[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")?; + if output.status.success() { + String::from_utf8(output.stdout).map_err(From::from) + } else { + Err(anyhow::anyhow!( + "nitrocli call failed: {}", + String::from_utf8_lossy(&output.stderr) + )) + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum OtpAlgorithm { + Hotp, + Totp, +} + +impl fmt::Display for OtpAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + OtpAlgorithm::Hotp => "hotp", + OtpAlgorithm::Totp => "totp", + } + ) + } +} + +impl str::FromStr for OtpAlgorithm { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "hotp" => Ok(OtpAlgorithm::Hotp), + "totp" => Ok(OtpAlgorithm::Totp), + _ => Err(anyhow::anyhow!("Unexpected OTP algorithm: {}", s)), + } + } +} + +impl<'de> serde::Deserialize<'de> for OtpAlgorithm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + str::FromStr::from_str(&String::deserialize(deserializer)?).map_err(D::Error::custom) + } +} + +impl serde::Serialize for OtpAlgorithm { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} diff --git a/ext/otp-cache/src/main.rs b/ext/otp-cache/src/main.rs new file mode 100644 index 0000000..b554b6e --- /dev/null +++ b/ext/otp-cache/src/main.rs @@ -0,0 +1,162 @@ +// main.rs + +// Copyright (C) 2020 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +mod ext; + +use std::collections; +use std::fs; +use std::io::Write as _; +use std::path; + +use anyhow::Context as _; + +use nitrokey::Device as _; +use nitrokey::GenerateOtp as _; + +use structopt::StructOpt as _; + +type Cache = collections::BTreeMap>; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct Slot { + index: u8, + name: String, +} + +/// Access Nitrokey OTP slots by name +#[derive(Debug, structopt::StructOpt)] +#[structopt(bin_name = "nitrocli otp-cache")] +struct Args { + /// Update the cached slot data + #[structopt(short, long)] + force_update: bool, + /// The OTP algorithm to use + #[structopt(short, long, global = true, default_value = "totp")] + algorithm: ext::OtpAlgorithm, + #[structopt(subcommand)] + cmd: Command, +} + +#[derive(Debug, structopt::StructOpt)] +enum Command { + /// Generates a one-time passwords + Get { + /// The name of the OTP slot to generate a OTP from + name: String, + }, + /// Lists the cached slots and their ID + List, +} + +fn main() -> anyhow::Result<()> { + let args = Args::from_args(); + let ctx = ext::Context::from_env()?; + let mut cache = get_cache(&ctx, &args)?; + let slots = cache.remove(&args.algorithm).unwrap_or_default(); + + match &args.cmd { + Command::Get { name } => match slots.iter().find(|s| &s.name == name) { + Some(slot) => print!("{}", generate_otp(&ctx, &args, slot.index)?), + None => anyhow::bail!("No OTP slot with the given name!"), + }, + Command::List => { + println!("slot\tname"); + for slot in slots { + println!("{}\t{}", slot.index, slot.name); + } + } + } + + Ok(()) +} + +/// Instantiate a cache, either reading it from file or populating it +/// from live data (while also persisting it to a file). +fn get_cache(ctx: &ext::Context, args: &Args) -> anyhow::Result { + // TODO: If we keep invoking nitrokey-rs directly, it would be great + // to honor the verbosity and everything else nitrocli does. + // In that case perhaps a nitrocli-ext crate should provide a + // wrapper. + let mut manager = + nitrokey::take().context("Failed to acquire access to Nitrokey device manager")?; + let device = manager + .connect_model(ctx.model) + .context("Failed to connect to Nitrokey device")?; + + let serial_number = device + .get_serial_number() + .context("Could not query the serial number")?; + + let project_dir = + directories::ProjectDirs::from("", "", "nitrocli-otp-cache").ok_or_else(|| { + anyhow::anyhow!("Could not determine the nitrocli-otp-cache application directory") + })?; + let cache_file = project_dir.cache_dir().join(format!( + "{}-{}.toml", + ctx.model.to_string().to_lowercase(), + serial_number + )); + if args.force_update || !cache_file.is_file() { + let cache = create_cache(&device, args)?; + save_cache(&cache, &cache_file) + .with_context(|| anyhow::anyhow!("Failed to save cache to {}", cache_file.display()))?; + Ok(cache) + } else { + load_cache(&cache_file) + .with_context(|| anyhow::anyhow!("Failed to load cache from {}", cache_file.display())) + } +} + +/// Create a cache based on data retrieved from the provided Nitrokey +/// device. +fn create_cache(device: &nitrokey::DeviceWrapper<'_>, args: &Args) -> anyhow::Result { + let mut cache = Cache::new(); + let mut slot = 0u8; + loop { + let result = match args.algorithm { + ext::OtpAlgorithm::Hotp => device.get_hotp_slot_name(slot), + ext::OtpAlgorithm::Totp => device.get_totp_slot_name(slot), + }; + slot = slot + .checked_add(1) + .context("Encountered integer overflow when iterating OTP slots")?; + match result { + Ok(name) => cache.entry(args.algorithm).or_default().push(Slot { + index: slot - 1, + name, + }), + Err(nitrokey::Error::LibraryError(nitrokey::LibraryError::InvalidSlot)) => return Ok(cache), + Err(nitrokey::Error::CommandError(nitrokey::CommandError::SlotNotProgrammed)) => (), + Err(err) => return Err(err).context("Failed to check OTP slot"), + } + } +} + +/// Save a cache to a file. +fn save_cache(cache: &Cache, path: &path::Path) -> anyhow::Result<()> { + // There is guaranteed to exist a parent because our path is always + // prefixed by the otp-cache directory. + fs::create_dir_all(path.parent().unwrap()).context("Failed to create cache directory")?; + + let mut f = fs::File::create(path).context("Failed to create cache file")?; + let toml = toml::to_vec(cache).context("Failed to convert cache data to TOML")?; + f.write_all(&toml).context("Failed to write cache data")?; + Ok(()) +} + +/// Load a cache from a file. +fn load_cache(path: &path::Path) -> anyhow::Result { + let s = fs::read_to_string(path)?; + toml::from_str(&s).map_err(From::from) +} + +fn generate_otp(ctx: &ext::Context, args: &Args, slot: u8) -> anyhow::Result { + ext::Nitrocli::from_context(ctx) + .args(&["otp", "get"]) + .arg(slot.to_string()) + .arg("--algorithm") + .arg(args.algorithm.to_string()) + .text() +} -- cgit v1.2.1