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 +}