aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Mueller <deso@posteo.net>2020-10-06 22:38:10 -0700
committerDaniel Mueller <deso@posteo.net>2020-10-11 17:07:08 -0700
commited59adcc9280d937f0f6f3627104597681ce7347 (patch)
tree66c686434e4b946d0e546d21aa8adc8ea15b7409
parentf65b150049ec9dc5ff2500d7e54c464d530f2e66 (diff)
downloadnitrocli-ed59adcc9280d937f0f6f3627104597681ce7347.tar.gz
nitrocli-ed59adcc9280d937f0f6f3627104597681ce7347.tar.bz2
Add otp-cache extension
-rw-r--r--Cargo.lock12
-rw-r--r--Cargo.toml3
-rw-r--r--ext/otp-cache/Cargo.toml16
-rw-r--r--ext/otp-cache/src/ext.rs145
-rw-r--r--ext/otp-cache/src/main.rs162
5 files changed, 338 insertions, 0 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 506ffec..d8a1a14 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -223,6 +223,18 @@ dependencies = [
]
[[package]]
+name = "nitrocli-otp-cache"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "directories",
+ "nitrokey",
+ "serde",
+ "structopt",
+ "toml",
+]
+
+[[package]]
name = "nitrokey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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 <robin.krahl@ireas.org>"]
+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<Self> {
+ 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<ffi::OsStr>) -> &mut Nitrocli {
+ self.cmd.arg(arg);
+ self
+ }
+
+ pub fn args<I, S>(&mut self, args: I) -> &mut Nitrocli
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<ffi::OsStr>,
+ {
+ self.cmd.args(args);
+ self
+ }
+
+ pub fn text(&mut self) -> anyhow::Result<String> {
+ 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<OtpAlgorithm, Self::Err> {
+ 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<D>(deserializer: D) -> Result<Self, D::Error>
+ 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<ext::OtpAlgorithm, Vec<Slot>>;
+
+#[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<Cache> {
+ // 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<Cache> {
+ 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<Cache> {
+ 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<String> {
+ ext::Nitrocli::from_context(ctx)
+ .args(&["otp", "get"])
+ .arg(slot.to_string())
+ .arg("--algorithm")
+ .arg(args.algorithm.to_string())
+ .text()
+}