Skip to content

Commit

Permalink
Introduce support for user-provided extensions
Browse files Browse the repository at this point in the history
This change introduces support for discovering and executing
user-provided extensions to the program. Extensions are useful for
allowing users to provide additional functionality on top of the
nitrocli proper. Implementation wise we stick to an approach similar to
git or cargo subcommands in nature: we search the directories listed in
the PATH environment variable for a file that starts with "nitrocli-",
followed by the extension name. This file is then executed. It is
assumed that the extension recognizes (or at least not prohibits) the
following arguments: --nitrocli (providing the path to the nitrocli
binary), --model (with the model passed to the main program), and
--verbosity (the verbosity level).
  • Loading branch information
d-e-s-o committed Aug 26, 2020
1 parent c2a9d31 commit 223c848
Show file tree
Hide file tree
Showing 12 changed files with 480 additions and 2 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Unreleased
----------
- Added support for user provided extensions through lookup via the
`PATH` environment variable
- Changed default OTP format from `hex` to `base32`
- Bumped `structopt` dependency to `0.3.17`

Expand Down
90 changes: 90 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 @@ -67,3 +67,6 @@ version = "0.1"

[dev-dependencies.regex]
version = "1"

[dev-dependencies.tempfile]
version = "3.1"
30 changes: 28 additions & 2 deletions doc/nitrocli.1
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ and the password safe.
.TP
\fB\-m\fR, \fB\-\-model pro\fR|\fBstorage\fR
Restrict connections to the given device model.
If this option is not set, nitrocli will connect to any connected Nitrokey Pro
or Nitrokey Storage device.
If this option is not set, \fBnitrocli\fR will connect to any connected Nitrokey
Pro or Nitrokey Storage device.
.TP
\fB\-v\fR, \fB\-\-verbose\fR
Enable additional logging and control its verbosity. Logging enabled through
Expand Down Expand Up @@ -271,6 +271,32 @@ The admin PIN cannot be unblocked.
This operation is equivalent to the unblock PIN option provided by \fBgpg\fR(1)
(using the \fB\-\-change\-pin\fR option).

.SS Extensions
In addition to the above built-in commands, \fBnitrocli\fR supports
user-provided functionality in the form of extensions. An extension can be any
executable file whose filename starts with "nitrocli-" and that is discoverable
through lookup via the \fBPATH\fR environment variable.

An extension should honor the following set of options, which are supplied to
the extension by \fBnitrocli\fR itself:
.TP
\fB\-\-nitrocli\fR \fIpath\fR
The path to the \fBnitrocli\fR binary. This path can be used to recursively
invoke \fBnitrocli\fR to implement certain functionality. This option is
guaranteed to be supplied.
.TP
\fB\-\-model pro\fR|\fBstorage\fR
Restrict connections to the given device model (see the Options section for more
details). This option is supplied only if it was provided by the user to the
invocation of \fBnitrocli\fR itself.
.TP
\fB\-\-verbosity\fR \fIlevel\fR
Control the logging verbosity by setting the log level to \fIlevel\fR. The
default level is 0, which corresponds to an invocation of \fBnitrocli\fR
without additional logging related options. Each additional occurrence of
\fB\-v\fR/\fB\-\-verbose\fR increments the log level accordingly. This option is
guaranteed to be supplied.

.SH ENVIRONMENT
The program honors a set of environment variables that can be used to
suppress interactive PIN entry through \fBpinentry\fR(1). The following
Expand Down
5 changes: 5 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
// * along with this program. If not, see <http://www.gnu.org/licenses/>. *
// *************************************************************************

use std::ffi;

/// Provides access to a Nitrokey device
#[derive(structopt::StructOpt)]
#[structopt(name = "nitrocli")]
Expand Down Expand Up @@ -82,6 +84,9 @@ Command! {
Status => crate::commands::status,
/// Interacts with the device's unencrypted volume
Unencrypted(UnencryptedArgs) => |ctx, args: UnencryptedArgs| args.subcmd.execute(ctx),
/// An extension and its arguments.
#[structopt(external_subcommand)]
Extension(Vec<ffi::OsString>) => crate::commands::extension,
]
}

Expand Down
94 changes: 94 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@
// * along with this program. If not, see <http://www.gnu.org/licenses/>. *
// *************************************************************************

use std::borrow;
use std::env;
use std::ffi;
use std::fmt;
use std::io;
use std::mem;
use std::path;
use std::process;
use std::result;
use std::thread;
use std::time;
Expand Down Expand Up @@ -980,6 +986,94 @@ pub fn pws_status(ctx: &mut ExecCtx<'_>, all: bool) -> Result<()> {
})
}

/// Resolve an extension provided by name to an actual path.
///
/// Extensions are (executable) files that have the "nitrocli-" prefix
/// and are discoverable via the `PATH` environment variable.
pub(crate) fn resolve_extension(
path_var: &ffi::OsStr,
ext_name: &ffi::OsStr,
) -> Result<path::PathBuf> {
let mut bin_name = ffi::OsString::from("nitrocli-");
bin_name.push(ext_name);

// The std::env module has several references to the PATH environment
// variable, indicating that this name is considered platform
// independent from their perspective. We do the same.
for dir in env::split_paths(path_var) {
let mut bin_path = dir.clone();
bin_path.push(&bin_name);
// Note that we deliberately do not check whether the file we found
// is executable. If it is not we will just fail later on with a
// permission denied error. The reasons for this behavior are two
// fold:
// 1) Checking whether a file is executable in Rust is painful (as
// of 1.37 there exists the PermissionsExt trait but it is
// available only for Unix based systems).
// 2) It is considered a better user experience to show an extension
// that we found (we list them in the help text) even if it later
// turned out to be not usable over not showing it and silently
// doing nothing -- mostly because anything residing in PATH
// should be executable anyway and given that its name also
// starts with nitrocli- we are pretty sure that's a bug on the
// user's side.
if bin_path.is_file() {
return Ok(bin_path);
}
}

let err = if let Some(name) = bin_name.to_str() {
format!("extension {} not found", name).into()
} else {
borrow::Cow::from("extension not found")
};
Err(io::Error::new(io::ErrorKind::NotFound, err).into())
}

/// Run an extension.
pub fn extension(ctx: &mut ExecCtx<'_>, args: Vec<ffi::OsString>) -> Result<()> {
// Note that while `Command` would actually honor PATH by itself, we
// do not want that behavior because it would circumvent the execution
// context we use for testing. As such, we need to do our own search.
let mut args = args.into_iter();
let ext_name = args
.next()
.ok_or_else(|| Error::from("no extension specified"))?;
let path_var = ctx
.path
.as_ref()
.ok_or_else(|| Error::from("PATH variable not present"))?;
let ext_path = resolve_extension(&path_var, &ext_name)?;

// Note that theoretically we could just exec the extension and be
// done. However, the problem with that approach is that it makes
// testing extension support much more nasty, because the test process
// would be overwritten in the process, requiring us to essentially
// fork & exec nitrocli beforehand -- which is much more involved from
// a cargo test context.
let mut cmd = process::Command::new(&ext_path);

if let Some(model) = ctx.model {
let _ = cmd.args(&["--model", model.as_ref()]);
};

let out = cmd
// TODO: We may want to take this path from the command execution
// context.
.args(&["--nitrocli", &env::current_exe()?.to_string_lossy()])
.args(&["--verbosity", &ctx.verbosity.to_string()])
.args(args)
.output()?;
ctx.stdout.write_all(&out.stdout)?;
ctx.stderr.write_all(&out.stderr)?;

if out.status.success() {
Ok(())
} else {
Err(Error::ExtensionFailed(ext_path, out.status.code()))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
9 changes: 9 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

use std::fmt;
use std::io;
use std::path;
use std::str;
use std::string;

Expand Down Expand Up @@ -47,6 +48,7 @@ pub enum Error {
NitrokeyError(Option<&'static str>, nitrokey::Error),
Utf8Error(str::Utf8Error),
Error(String),
ExtensionFailed(path::PathBuf, Option<i32>),
}

impl TryInto<nitrokey::Error> for Error {
Expand Down Expand Up @@ -113,6 +115,13 @@ impl fmt::Display for Error {
Error::Utf8Error(_) => write!(f, "Encountered UTF-8 conversion error"),
Error::IoError(ref e) => write!(f, "IO error: {}", e),
Error::Error(ref e) => write!(f, "{}", e),
Error::ExtensionFailed(ref path, rc) => {
write!(f, "Extension {} failed", path.to_string_lossy())?;
if let Some(rc) = rc {
write!(f, " with exit code {}", rc)?;
}
Ok(())
}
}
}
}
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ use std::ffi;
use std::io;
use std::process;
use std::result;
use std::str;

use crate::error::Error;

Expand Down Expand Up @@ -121,6 +122,8 @@ pub struct ExecCtx<'io> {
pub stdout: &'io mut dyn io::Write,
/// See `RunCtx::stderr`.
pub stderr: &'io mut dyn io::Write,
/// See `RunCtx::path`.
pub path: Option<ffi::OsString>,
/// See `RunCtx::admin_pin`.
pub admin_pin: Option<ffi::OsString>,
/// See `RunCtx::user_pin`.
Expand Down Expand Up @@ -153,6 +156,7 @@ fn handle_arguments(ctx: &mut RunCtx<'_>, args: Vec<String>) -> Result<()> {
model: args.model,
stdout: ctx.stdout,
stderr: ctx.stderr,
path: ctx.path.take(),
admin_pin: ctx.admin_pin.take(),
user_pin: ctx.user_pin.take(),
new_admin_pin: ctx.new_admin_pin.take(),
Expand Down Expand Up @@ -180,6 +184,8 @@ pub(crate) struct RunCtx<'io> {
pub stdout: &'io mut dyn io::Write,
/// The `Write` object used as standard error throughout the program.
pub stderr: &'io mut dyn io::Write,
/// The content of the `PATH` environment variable.
pub path: Option<ffi::OsString>,
/// The admin PIN, if provided through an environment variable.
pub admin_pin: Option<ffi::OsString>,
/// The user PIN, if provided through an environment variable.
Expand Down Expand Up @@ -217,6 +223,7 @@ fn main() {
let ctx = &mut RunCtx {
stdout: &mut stdout,
stderr: &mut stderr,
path: env::var_os("PATH"),
admin_pin: env::var_os(NITROCLI_ADMIN_PIN),
user_pin: env::var_os(NITROCLI_USER_PIN),
new_admin_pin: env::var_os(NITROCLI_NEW_ADMIN_PIN),
Expand Down
Loading

0 comments on commit 223c848

Please sign in to comment.