From 86737a2cf5a128303d58dd6e3ceb7c262d18f181 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 10 Apr 2021 07:15:34 +0200 Subject: [PATCH] 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() +}