Skip to content

Commit

Permalink
Add otp-cache extension
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
robinkrahl authored and d-e-s-o committed Apr 10, 2021
1 parent 2c700dc commit 86737a2
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
21 changes: 21 additions & 0 deletions ext/otp-cache/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
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"
176 changes: 176 additions & 0 deletions ext/otp-cache/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<Slot>,
totp: Vec<Slot>,
}

#[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::<Vec<_>>();
let hotp_slots = cache
.hotp
.iter()
.filter(|s| s.name == slot_name)
.collect::<Vec<_>>();
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<Cache> {
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<Cache> {
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<String> {
// TODO: Consider using hidapi serial number (if available)
Ok(device.get_serial_number()?.to_string().to_lowercase())
}

fn get_otp_slots_fn<D, F>(device: &D, f: F) -> anyhow::Result<Vec<Slot>>
where
D: nitrokey::GenerateOtp,
F: Fn(&D, u8) -> Result<String, nitrokey::Error>,
{
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<Cache> {
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()
}

0 comments on commit 86737a2

Please sign in to comment.