Skip to content

Commit

Permalink
Add otp-cache extension
Browse files Browse the repository at this point in the history
  • Loading branch information
robinkrahl authored and d-e-s-o committed Sep 20, 2020
1 parent f365418 commit 77df580
Show file tree
Hide file tree
Showing 5 changed files with 338 additions and 0 deletions.
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.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,6 @@ version = "1"

[dev-dependencies.tempfile]
version = "3.1"

[workspace]
members = ["extensions/*"]
16 changes: 16 additions & 0 deletions extensions/otp-cache/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
145 changes: 145 additions & 0 deletions extensions/otp-cache/src/ext.rs
Original file line number Diff line number Diff line change
@@ -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")
.ok_or_else(|| anyhow::anyhow!("NITROCLI_BINARY environment variable not present"))
.context("Failed to retrieve nitrocli path")?;

let model = env::var_os("NITROCLI_MODEL")
.ok_or_else(|| anyhow::anyhow!("NITROCLI_MODEL environment variable not present"))
.context("Failed to retrieve nitrocli model")?;
let model = model
.to_str()
.ok_or_else(|| anyhow::anyhow!("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")
.ok_or_else(|| anyhow::anyhow!("NITROCLI_VERBOSITY environment variable not present"))
.context("Failed to retrieve nitrocli verbosity")?;
let verbosity = verbosity
.to_str()
.ok_or_else(|| anyhow::anyhow!("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()?;
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)
}
}
162 changes: 162 additions & 0 deletions extensions/otp-cache/src/main.rs
Original file line number Diff line number Diff line change
@@ -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)
.ok_or_else(|| anyhow::anyhow!("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()
}

0 comments on commit 77df580

Please sign in to comment.