-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f365418
commit 77df580
Showing
5 changed files
with
338 additions
and
0 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -81,3 +81,6 @@ version = "1" | |
|
||
[dev-dependencies.tempfile] | ||
version = "3.1" | ||
|
||
[workspace] | ||
members = ["extensions/*"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |