Compare commits

..

No commits in common. "30a582ed8cece21d73cb5b28ea7dcdd429c2203f" and "1ac99e16f8e71fd598dda27ac536c07b3338885f" have entirely different histories.

14 changed files with 1008 additions and 110 deletions

50
Cargo.lock generated
View File

@ -860,6 +860,15 @@ dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb"
dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "iana-time-zone"
version = "0.1.58"
@ -1065,6 +1074,18 @@ dependencies = [
"sha2",
]
[[package]]
name = "keyfork-pinentry"
version = "0.5.0"
dependencies = [
"nom",
"percent-encoding",
"secrecy",
"thiserror",
"which",
"zeroize",
]
[[package]]
name = "keyfork-plumbing"
version = "0.1.0"
@ -1090,7 +1111,7 @@ dependencies = [
"card-backend",
"card-backend-pcsc",
"keyfork-derive-openpgp",
"keyfork-prompt",
"keyfork-pinentry",
"openpgp-card",
"openpgp-card-sequoia",
"sequoia-openpgp",
@ -1528,6 +1549,12 @@ dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]]
name = "petgraph"
version = "0.6.4"
@ -1917,6 +1944,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secrecy"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
dependencies = [
"zeroize",
]
[[package]]
name = "semver"
version = "1.0.18"
@ -2529,6 +2565,18 @@ version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
[[package]]
name = "which"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
dependencies = [
"either",
"home",
"once_cell",
"rustix 0.38.13",
]
[[package]]
name = "winapi"
version = "0.3.9"

View File

@ -9,6 +9,7 @@ members = [
"keyfork-derive-util",
"keyfork-frame",
"keyfork-mnemonic-util",
"keyfork-pinentry",
"keyfork-prompt",
"keyfork-plumbing",
"keyfork-shard",

View File

@ -0,0 +1,23 @@
[package]
# name = "pinentry"
name = "keyfork-pinentry"
description = "API for interacting with pinentry binaries"
version = "0.5.0"
# authors = ["Jack Grigg <thestr4d@gmail.com>"]
authors = ["Ryan Heywood <ryan@distrust.co>"]
# repository = "https://github.com/str4d/pinentry-rs"
# readme = "README.md"
# keywords = ["passphrase", "password"]
# categories = ["api-bindings", "command-line-interface"]
# license = "MIT OR Apache-2.0"
license = "MIT"
# edition = "2018"
edition = "2021"
[dependencies]
nom = { version = "7", default-features = false }
percent-encoding = "2.1"
secrecy = "0.8"
thiserror = "1.0.50"
which = { version = "4", default-features = false }
zeroize = "1"

View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2020 Jack Grigg
Copyright (c) 2023 Distrust
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,225 @@
use percent_encoding::percent_decode_str;
use secrecy::{ExposeSecret, SecretString};
use std::borrow::Cow;
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;
use std::process::{ChildStdin, ChildStdout};
use std::process::{Command, Stdio};
use zeroize::Zeroize;
use crate::{Error, Result};
/// Possible response lines from an Assuan server.
///
/// Reference: https://gnupg.org/documentation/manuals/assuan/Server-responses.html
#[allow(dead_code)]
#[derive(Debug)]
enum Response {
/// Request was successful.
Ok(Option<String>),
/// Request could not be fulfilled. The possible error codes are defined by
/// `libgpg-error`.
Err {
code: u16,
description: Option<String>,
},
/// Informational output by the server, which is still processing the request.
Information {
keyword: String,
status: Option<String>,
},
/// Comment line issued only for debugging purposes.
Comment(String),
/// Raw data returned to client.
DataLine(SecretString),
/// The server needs further information from the client.
Inquire {
keyword: String,
parameters: Option<String>,
},
}
pub struct Connection {
output: ChildStdin,
input: BufReader<ChildStdout>,
}
impl Connection {
pub fn open(name: &Path) -> Result<Self> {
let process = Command::new(name)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let output = process.stdin.expect("could open stdin");
let input = BufReader::new(process.stdout.expect("could open stdin"));
let mut conn = Connection { output, input };
// There is always an initial OK server response
conn.read_response()?;
#[cfg(unix)]
{
conn.send_request("OPTION", Some("ttyname=/dev/tty"))?;
conn.send_request(
"OPTION",
Some(&format!(
"ttytype={}",
std::env::var("TERM")
.as_ref()
.map(|s| s.as_str())
.unwrap_or("xterm-256color")
)),
)?;
}
Ok(conn)
}
pub fn send_request(
&mut self,
command: &str,
parameters: Option<&str>,
) -> Result<Option<SecretString>> {
self.output.write_all(command.as_bytes())?;
if let Some(p) = parameters {
self.output.write_all(b" ")?;
self.output.write_all(p.as_bytes())?;
}
self.output.write_all(b"\n")?;
self.read_response()
}
fn read_response(&mut self) -> Result<Option<SecretString>> {
let mut line = String::new();
let mut data = None;
// We loop until we find an OK or ERR response. This is probably sufficient for
// pinentry, but other Assuan protocols might rely on INQUIRE, which needs
// intermediate completion states or callbacks.
loop {
line.zeroize();
self.input.read_line(&mut line)?;
match read::server_response(&line)
.map(|(_, r)| r)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{}", e)))?
{
Response::Ok(_info) => {
/*
if let Some(info) = info {
debug!("< OK {}", info);
}
*/
line.zeroize();
return Ok(data.map(SecretString::new));
}
Response::Err { code, description } => {
line.zeroize();
if let Some(mut buf) = data {
buf.zeroize();
}
return Err(Error::from_parts(code, description));
}
Response::Comment(_comment) => {
// debug!("< # {}", comment)
}
Response::DataLine(data_line) => {
let buf = data.take();
let data_line_decoded =
percent_decode_str(data_line.expose_secret()).decode_utf8()?;
data = Some(buf.unwrap_or_else(String::new) + &data_line_decoded);
if let Cow::Owned(mut data_line_decoded) = data_line_decoded {
data_line_decoded.zeroize();
}
}
_res => {
// info!("< {:?}", res)
}
}
}
}
}
impl Drop for Connection {
fn drop(&mut self) {
let _ = self.send_request("BYE", None);
}
}
mod read {
use nom::{
branch::alt,
bytes::complete::{is_not, tag},
character::complete::{digit1, line_ending},
combinator::{map, opt},
sequence::{pair, preceded, terminated},
IResult,
};
use secrecy::SecretString;
use super::Response;
fn gpg_error_code(input: &str) -> IResult<&str, u16> {
map(digit1, |code| {
#[allow(clippy::from_str_radix_10)]
let full = u32::from_str_radix(code, 10).expect("have decimal digits");
// gpg uses the lowest 16 bits for error codes.
full as u16
})(input)
}
pub(super) fn server_response(input: &str) -> IResult<&str, Response> {
terminated(
alt((
preceded(
tag("OK"),
map(opt(preceded(tag(" "), is_not("\r\n"))), |params| {
Response::Ok(params.map(String::from))
}),
),
preceded(
tag("ERR "),
map(
pair(gpg_error_code, opt(preceded(tag(" "), is_not("\r\n")))),
|(code, description)| Response::Err {
code,
description: description.map(String::from),
},
),
),
preceded(
tag("S "),
map(
pair(is_not(" \r\n"), opt(preceded(tag(" "), is_not("\r\n")))),
|(keyword, status): (&str, _)| Response::Information {
keyword: keyword.to_owned(),
status: status.map(String::from),
},
),
),
preceded(
tag("# "),
map(is_not("\r\n"), |comment: &str| {
Response::Comment(comment.to_owned())
}),
),
preceded(
tag("D "),
map(is_not("\r\n"), |data: &str| {
Response::DataLine(SecretString::new(data.to_owned()))
}),
),
preceded(
tag("INQUIRE "),
map(
pair(is_not(" \r\n"), opt(preceded(tag(" "), is_not("\r\n")))),
|(keyword, parameters): (&str, _)| Response::Inquire {
keyword: keyword.to_owned(),
parameters: parameters.map(String::from),
},
),
),
)),
line_ending,
)(input)
}
}

View File

@ -0,0 +1,114 @@
use std::{fmt, io};
pub(crate) const GPG_ERR_TIMEOUT: u16 = 62;
pub(crate) const GPG_ERR_CANCELED: u16 = 99;
pub(crate) const GPG_ERR_NOT_CONFIRMED: u16 = 114;
/// An uncommon or unexpected GPG error.
///
/// `pinentry` is built on top of Assuan, which inherits all of GPG's error codes. Only
/// some of these error codes are actually used by the common `pinentry` implementations,
/// but it's possible to receive any of them.
#[derive(Debug)]
pub struct GpgError {
/// The GPG error code.
///
/// See <https://github.com/gpg/libgpg-error/blob/master/src/err-codes.h.in> for the
/// mapping from error code to GPG error type.
code: u16,
/// A description of the error, if available.
///
/// See <https://github.com/gpg/libgpg-error/blob/master/src/err-codes.h.in> for the
/// likely descriptions.
description: Option<String>,
}
impl fmt::Display for GpgError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Code {}", self.code)?;
if let Some(desc) = &self.description {
write!(f, ": {}", desc)?;
}
Ok(())
}
}
impl std::error::Error for GpgError {}
impl GpgError {
pub(super) fn new(code: u16, description: Option<String>) -> Self {
GpgError { code, description }
}
/// Returns the GPG code for this error.
pub fn code(&self) -> u16 {
self.code
}
}
/// Errors that may be returned while interacting with `pinentry` binaries.
#[derive(thiserror::Error, Debug)]
pub enum Error {
/// The user cancelled the operation.
#[error("The user cancelled the operation")]
Cancelled,
/// Operation timed out waiting for the user to respond.
#[error("Operation timed out waiting for the user to respond")]
Timeout,
/// An error occurred while finding the `pinentry` binary.
#[error("{0}")]
Which(#[from] which::Error),
/// An I/O error occurred while communicating with the `pinentry` binary.
#[error("{0}")]
Io(#[from] io::Error),
/// An uncommon or unexpected GPG error.
#[error("{0}")]
Gpg(#[from] GpgError),
/// The user's input doesn't decode to valid UTF-8.
#[error("{0}")]
Encoding(#[from] std::str::Utf8Error),
}
/*
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Timeout => write!(f, "Operation timed out"),
Error::Cancelled => write!(f, "Operation cancelled"),
Error::Gpg(e) => e.fmt(f),
Error::Io(e) => e.fmt(f),
Error::Encoding(e) => e.fmt(f),
}
}
}
*/
/*
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
impl From<std::str::Utf8Error> for Error {
fn from(e: std::str::Utf8Error) -> Self {
Error::Encoding(e)
}
}
*/
impl Error {
pub(crate) fn from_parts(code: u16, description: Option<String>) -> Self {
match code {
GPG_ERR_TIMEOUT => Error::Timeout,
GPG_ERR_CANCELED => Error::Cancelled,
_ => Error::Gpg(GpgError::new(code, description)),
}
}
}

455
keyfork-pinentry/src/lib.rs Normal file
View File

@ -0,0 +1,455 @@
//! `keyfork_pinentry` is a library for interacting with the pinentry binaries available on
//! various platforms.
//!
//! # Examples
//!
//! ## Request passphrase or PIN
//!
//! ```no_run
//! use keyfork_pinentry::PassphraseInput;
//! use secrecy::SecretString;
//!
//! let passphrase = if let Ok(mut input) = PassphraseInput::with_default_binary() {
//! // pinentry binary is available!
//! input
//! .with_description("Enter new passphrase for FooBar")
//! .with_prompt("Passphrase:")
//! .with_confirmation("Confirm passphrase:", "Passphrases do not match")
//! .interact()
//! } else {
//! // Fall back to some other passphrase entry method.
//! Ok(SecretString::new("a better passphrase than this".to_owned()))
//! }?;
//! # Ok::<(), keyfork_pinentry::Error>(())
//! ```
//!
//! ## Ask user for confirmation
//!
//! ```no_run
//! use keyfork_pinentry::ConfirmationDialog;
//!
//! if let Ok(mut input) = ConfirmationDialog::with_default_binary() {
//! input
//! .with_ok("Definitely!")
//! .with_not_ok("No thanks")
//! .with_cancel("Maybe later")
//! .confirm("Would you like to play a game?")?;
//! };
//! # Ok::<(), keyfork_pinentry::Error>(())
//! ```
//!
//! ## Display a message
//!
//! ```no_run
//! use keyfork_pinentry::MessageDialog;
//!
//! if let Ok(mut input) = MessageDialog::with_default_binary() {
//! input.with_ok("Got it!").show_message("This will be shown with a single button.")?;
//! };
//! # Ok::<(), keyfork_pinentry::Error>(())
//! ```
// Catch documentation errors caused by code changes.
#![deny(rustdoc::broken_intra_doc_links)]
#![deny(missing_docs)]
pub use secrecy::{ExposeSecret, SecretString};
use std::path::PathBuf;
mod assuan;
mod error;
pub use error::{Error, GpgError};
/// Result type for the `keyfork_pinentry` crate.
pub type Result<T> = std::result::Result<T, Error>;
/// Find the expected default pinentry binary
pub fn default_binary() -> Result<PathBuf> {
which::which("pinentry-curses").map_err(Into::into)
}
fn convert_multiline(line: &str) -> String {
// convert into multiline
let mut converted_line = String::new();
let mut last_end = 0;
for (start, part) in line.match_indices(&['\n', '\r', '%']) {
converted_line.push_str(line.get(last_end..start).unwrap());
converted_line.push_str(match part {
"\n" => "%0A",
"\r" => "%0D",
"%" => "%25",
fb => panic!("expected index given to match_indices, got: {fb}"),
});
last_end = start + part.len();
}
converted_line.push_str(line.get(last_end..line.len()).unwrap());
converted_line
}
/// A dialog for requesting a passphrase from the user.
pub struct PassphraseInput<'a> {
binary: PathBuf,
required: Option<&'a str>,
title: Option<&'a str>,
description: Option<&'a str>,
error: Option<&'a str>,
prompt: Option<&'a str>,
confirmation: Option<(&'a str, &'a str)>,
ok: Option<&'a str>,
cancel: Option<&'a str>,
timeout: Option<u16>,
}
impl<'a> PassphraseInput<'a> {
/// Creates a new PassphraseInput using the binary named `keyfork_pinentry`.
///
/// Returns `Err` if `default_binary()` cannot be found in `PATH`.
pub fn with_default_binary() -> Result<Self> {
default_binary().map(Self::with_binary)
}
/// Creates a new PassphraseInput using the given path to, or name of, a `pinentry`
/// binary.
pub fn with_binary(binary: PathBuf) -> Self {
PassphraseInput {
binary,
required: None,
title: None,
description: None,
error: None,
prompt: None,
confirmation: None,
ok: None,
cancel: None,
timeout: None,
}
}
/// Prevents the user from submitting an empty passphrase.
///
/// The provided error text will be displayed if the user submits an empty passphrase.
/// The dialog will remain open until the user either submits a non-empty passphrase,
/// or selects the "Cancel" button.
pub fn required(&mut self, empty_error: &'a str) -> &mut Self {
self.required = Some(empty_error);
self
}
/// Sets the window title.
///
/// When using this feature you should take care that the window is still identifiable
/// as the pinentry.
pub fn with_title(&mut self, title: &'a str) -> &mut Self {
self.title = Some(title);
self
}
/// Sets the descriptive text to display.
pub fn with_description(&mut self, description: &'a str) -> &mut Self {
self.description = Some(description);
self
}
/// Sets the error text to display.
///
/// This is used to display an error message, for example on a second interaction if
/// the first passphrase was invalid.
pub fn with_error(&mut self, error: &'a str) -> &mut Self {
self.error = Some(error);
self
}
/// Sets the prompt to show.
///
/// When asking for a passphrase or PIN, this sets the text just before the widget for
/// passphrase entry.
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_prompt(&mut self, prompt: &'a str) -> &mut Self {
self.prompt = Some(prompt);
self
}
/// Enables confirmation prompting.
///
/// When asking for a passphrase or PIN, this sets the text just before the widget for
/// the passphrase confirmation entry.
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_confirmation(
&mut self,
confirmation_prompt: &'a str,
mismatch_error: &'a str,
) -> &mut Self {
self.confirmation = Some((confirmation_prompt, mismatch_error));
self
}
/// Sets the text for the button signalling confirmation (the "OK" button).
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_ok(&mut self, ok: &'a str) -> &mut Self {
self.ok = Some(ok);
self
}
/// Sets the text for the button signaling cancellation or disagreement (the "Cancel"
/// button).
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_cancel(&mut self, cancel: &'a str) -> &mut Self {
self.cancel = Some(cancel);
self
}
/// Sets the timeout (in seconds) before returning an error.
pub fn with_timeout(&mut self, timeout: u16) -> &mut Self {
self.timeout = Some(timeout);
self
}
/// Asks for a passphrase or PIN.
pub fn interact(&self) -> Result<SecretString> {
let mut pinentry = assuan::Connection::open(&self.binary)?;
if let Some(title) = &self.title {
pinentry.send_request("SETTITLE", Some(title))?;
}
if let Some(desc) = &self.description {
pinentry.send_request("SETDESC", Some(convert_multiline(desc).as_ref()))?;
}
if let Some(error) = &self.error {
pinentry.send_request("SETERROR", Some(error))?;
}
if let Some(prompt) = &self.prompt {
pinentry.send_request("SETPROMPT", Some(prompt))?;
}
if let Some(ok) = &self.ok {
pinentry.send_request("SETOK", Some(ok))?;
}
if let Some(cancel) = &self.cancel {
pinentry.send_request("SETCANCEL", Some(cancel))?;
}
if let Some((confirmation_prompt, mismatch_error)) = &self.confirmation {
pinentry.send_request("SETREPEAT", Some(confirmation_prompt))?;
pinentry.send_request("SETREPEATERROR", Some(mismatch_error))?;
}
if let Some(timeout) = self.timeout {
pinentry.send_request("SETTIMEOUT", Some(&format!("{}", timeout)))?;
}
loop {
match (pinentry.send_request("GETPIN", None)?, self.required) {
// If the user provides an empty passphrase, GETPIN returns no data.
(None, None) => return Ok(SecretString::new(String::new())),
(Some(passphrase), _) => return Ok(passphrase),
(_, Some(empty_error)) => {
// SETERROR is cleared by GETPIN, so we reset it on each loop.
pinentry.send_request("SETERROR", Some(empty_error))?;
}
}
}
}
}
/// A dialog for requesting a confirmation from the user.
pub struct ConfirmationDialog<'a> {
binary: PathBuf,
title: Option<&'a str>,
ok: Option<&'a str>,
cancel: Option<&'a str>,
not_ok: Option<&'a str>,
timeout: Option<u16>,
}
impl<'a> ConfirmationDialog<'a> {
/// Creates a new ConfirmationDialog using the binary named `pinentry`.
///
/// Returns `Err` if `pinentry` cannot be found in `PATH`.
pub fn with_default_binary() -> Result<Self> {
default_binary().map(Self::with_binary)
}
/// Creates a new ConfirmationDialog using the given path to, or name of, a `pinentry`
/// binary.
pub fn with_binary(binary: PathBuf) -> Self {
ConfirmationDialog {
binary,
title: None,
ok: None,
cancel: None,
not_ok: None,
timeout: None,
}
}
/// Sets the window title.
///
/// When using this feature you should take care that the window is still identifiable
/// as the pinentry.
pub fn with_title(&mut self, title: &'a str) -> &mut Self {
self.title = Some(title);
self
}
/// Sets the text for the button signalling confirmation (the "OK" button).
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_ok(&mut self, ok: &'a str) -> &mut Self {
self.ok = Some(ok);
self
}
/// Sets the text for the button signaling cancellation or disagreement (the "Cancel"
/// button).
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_cancel(&mut self, cancel: &'a str) -> &mut Self {
self.cancel = Some(cancel);
self
}
/// Enables the third non-affirmative response button (the "Not OK" button).
///
/// This can be used in case three buttons are required (to distinguish between
/// cancellation and disagreement).
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_not_ok(&mut self, not_ok: &'a str) -> &mut Self {
self.not_ok = Some(not_ok);
self
}
/// Sets the timeout (in seconds) before returning an error.
pub fn with_timeout(&mut self, timeout: u16) -> &mut Self {
self.timeout = Some(timeout);
self
}
/// Asks for confirmation.
///
/// Returns:
/// - `Ok(true)` if the "OK" button is selected.
/// - `Ok(false)` if:
/// - the "Cancel" button is selected and the "Not OK" button is disabled.
/// - the "Not OK" button is enabled and selected.
/// - `Err(Error::Cancelled)` if the "Cancel" button is selected and the "Not OK"
/// button is enabled.
pub fn confirm(&self, query: &str) -> Result<bool> {
let mut pinentry = assuan::Connection::open(&self.binary)?;
pinentry.send_request("SETDESC", Some(query))?;
if let Some(ok) = &self.ok {
pinentry.send_request("SETOK", Some(ok))?;
}
if let Some(cancel) = &self.cancel {
pinentry.send_request("SETCANCEL", Some(cancel))?;
}
if let Some(not_ok) = &self.not_ok {
pinentry.send_request("SETNOTOK", Some(not_ok))?;
}
if let Some(timeout) = self.timeout {
pinentry.send_request("SETTIMEOUT", Some(&format!("{}", timeout)))?;
}
pinentry
.send_request("CONFIRM", None)
.map(|_| true)
.or_else(|e| match (&e, self.not_ok.is_some()) {
(Error::Cancelled, false) => Ok(false),
(Error::Gpg(gpg), true) if gpg.code() == error::GPG_ERR_NOT_CONFIRMED => Ok(false),
_ => Err(e),
})
}
}
/// A dialog for showing a message to the user.
pub struct MessageDialog<'a> {
binary: PathBuf,
title: Option<&'a str>,
ok: Option<&'a str>,
timeout: Option<u16>,
}
impl<'a> MessageDialog<'a> {
/// Creates a new MessageDialog using the binary named `pinentry`.
///
/// Returns `Err` if `pinentry` cannot be found in `PATH`.
pub fn with_default_binary() -> Result<Self> {
default_binary().map(Self::with_binary)
}
/// Creates a new MessageDialog using the given path to, or name of, a `pinentry`
/// binary.
pub fn with_binary(binary: PathBuf) -> Self {
MessageDialog {
binary,
title: None,
ok: None,
timeout: None,
}
}
/// Sets the window title.
///
/// When using this feature you should take care that the window is still identifiable
/// as the pinentry.
pub fn with_title(&mut self, title: &'a str) -> &mut Self {
self.title = Some(title);
self
}
/// Sets the text for the button signalling confirmation (the "OK" button).
///
/// You should use an underscore in the text only if you know that a modern version of
/// pinentry is used. Modern versions underline the next character after the
/// underscore and use the first such underlined character as a keyboard accelerator.
/// Use a double underscore to escape an underscore.
pub fn with_ok(&mut self, ok: &'a str) -> &mut Self {
self.ok = Some(ok);
self
}
/// Sets the timeout (in seconds) before returning an error.
pub fn with_timeout(&mut self, timeout: u16) -> &mut Self {
self.timeout = Some(timeout);
self
}
/// Shows a message.
pub fn show_message(&self, message: &str) -> Result<()> {
let mut pinentry = assuan::Connection::open(&self.binary)?;
pinentry.send_request("SETDESC", Some(message))?;
if let Some(ok) = &self.ok {
pinentry.send_request("SETOK", Some(ok))?;
}
if let Some(timeout) = self.timeout {
pinentry.send_request("SETTIMEOUT", Some(&format!("{}", timeout)))?;
}
pinentry.send_request("MESSAGE", None).map(|_| ())
}
}

View File

@ -5,8 +5,8 @@ use keyfork_prompt::*;
pub fn main() -> Result<()> {
let mut mgr = PromptManager::new(stdin(), stdout())?;
mgr.prompt_input("Mnemonic: ")?;
mgr.prompt_message("Please press enter.")?;
mgr.prompt_passphrase("Passphrase: ")?;
mgr.prompt_message("Please press enter.")?;
mgr.prompt_message("Please press space bar.")?;
Ok(())
}

View File

@ -5,11 +5,10 @@ use std::{
use crossterm::{
event::{read, Event, KeyCode},
style::{Print, PrintStyledContent, Stylize},
style::Print,
terminal,
cursor,
tty::IsTty,
QueueableCommand,
ExecutableCommand,
};
mod alternate_screen;
@ -51,19 +50,8 @@ where
pub fn prompt_input(&mut self, prompt: &str) -> Result<String> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
.queue(cursor::MoveTo(0, 0))?;
let mut lines = prompt.lines().peekable();
while let Some(line) = lines.next() {
terminal.queue(Print(line))?;
if lines.peek().is_some() {
terminal
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
}
}
terminal.flush()?;
.execute(terminal::Clear(terminal::ClearType::All))?
.execute(Print(prompt))?;
let mut line = String::new();
self.read.read_line(&mut line)?;
Ok(line)
@ -73,21 +61,9 @@ where
pub fn prompt_passphrase(&mut self, prompt: &str) -> Result<String> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
let mut terminal = RawMode::new(&mut terminal)?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
.queue(cursor::MoveTo(0, 0))?;
let mut lines = prompt.lines().peekable();
while let Some(line) = lines.next() {
terminal.queue(Print(line))?;
if lines.peek().is_some() {
terminal
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
}
}
terminal.flush()?;
.execute(terminal::Clear(terminal::ClearType::All))?
.execute(Print(prompt))?;
let mut passphrase = String::new();
loop {
match read()? {
@ -96,17 +72,8 @@ where
passphrase.push('\n');
break;
}
KeyCode::Backspace => {
if passphrase.pop().is_some() {
terminal
.queue(cursor::MoveLeft(1))?
.queue(Print(" "))?
.queue(cursor::MoveLeft(1))?
.flush()?;
}
}
KeyCode::Char(c) => {
terminal.queue(Print("*"))?.flush()?;
terminal.execute(Print("*"))?;
passphrase.push(c);
}
_ => (),
@ -120,26 +87,9 @@ where
pub fn prompt_message(&mut self, prompt: &str) -> Result<()> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
let mut terminal = RawMode::new(&mut terminal)?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
.queue(cursor::MoveTo(0, 0))?;
let mut lines = prompt.lines().peekable();
while let Some(line) = lines.next() {
terminal.queue(Print(line))?;
if lines.peek().is_some() {
terminal
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
}
}
terminal
.queue(cursor::DisableBlinking)?
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?
.queue(PrintStyledContent(" OK ".negative()))?
.flush()?;
.execute(terminal::Clear(terminal::ClearType::All))?
.execute(Print(prompt))?;
loop {
match read()? {
Event::Key(k) => match k.code {
@ -149,7 +99,6 @@ where
_ => (),
}
}
terminal.queue(cursor::EnableBlinking)?.flush()?;
Ok(())
}
}

View File

@ -10,7 +10,7 @@ license = "AGPL-3.0-only"
default = ["openpgp", "openpgp-card"]
openpgp = ["sequoia-openpgp", "prompt"]
openpgp-card = ["openpgp-card-sequoia", "card-backend-pcsc", "card-backend", "dep:openpgp-card"]
prompt = ["keyfork-prompt"]
prompt = ["keyfork-pinentry"]
[dependencies]
anyhow = "1.0.75"
@ -18,6 +18,7 @@ bincode = "1.3.3"
card-backend = { version = "0.2.0", optional = true }
card-backend-pcsc = { version = "0.5.0", optional = true }
keyfork-derive-openpgp = { version = "0.1.0", path = "../keyfork-derive-openpgp" }
keyfork-pinentry = { version = "0.5.0", path = "../keyfork-pinentry", optional = true }
openpgp-card-sequoia = { version = "0.2.0", optional = true }
openpgp-card = { version = "0.4.0", optional = true }
sequoia-openpgp = { version = "1.16.1", optional = true }
@ -25,4 +26,3 @@ serde = "1.0.188"
sharks = "0.5.0"
smex = { version = "0.1.0", path = "../smex" }
thiserror = "1.0.50"
keyfork-prompt = { version = "0.1.0", path = "../keyfork-prompt", optional = true }

View File

@ -1,2 +1,5 @@
#[cfg(feature = "openpgp")]
pub mod openpgp;
#[cfg(feature = "prompt")]
mod prompt_manager;

View File

@ -1,6 +1,4 @@
use std::fs::File;
use keyfork_prompt::{Error as PromptError, PromptManager};
use keyfork_pinentry::ExposeSecret;
use super::openpgp::{
self,
@ -11,6 +9,8 @@ use super::openpgp::{
KeyHandle, KeyID,
};
use crate::prompt_manager::{PinentryError, PromptManager};
use anyhow::Context;
#[derive(thiserror::Error, Debug)]
@ -18,14 +18,8 @@ pub enum Error {
#[error("Secret key was not found")]
SecretKeyNotFound,
#[error("Could not find TTY when prompting")]
NoTTY,
#[error("Could not open TTY: {0}")]
Io(#[from] std::io::Error),
#[error("Prompt failed: {0}")]
Prompt(#[from] PromptError),
Prompt(#[from] PinentryError),
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
@ -33,20 +27,15 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
pub struct Keyring {
full_certs: Vec<Cert>,
root: Option<Cert>,
pm: PromptManager<File, File>,
pm: PromptManager,
}
impl Keyring {
pub fn new(certs: impl AsRef<[Cert]>) -> Result<Self> {
let tty = std::env::vars()
.filter(|(k, _v)| k.as_str() == "GPG_TTY")
.next()
.ok_or(Error::NoTTY)?
.1;
Ok(Self {
full_certs: certs.as_ref().to_vec(),
root: Default::default(),
pm: PromptManager::new(File::open(&tty)?, File::options().write(true).open(&tty)?)?,
pm: PromptManager::new("keyfork-shard", None)?,
})
}
@ -68,6 +57,14 @@ impl Keyring {
pub fn get_cert_for_primary_keyid<'a>(&'a self, keyid: &KeyID) -> Option<&'a Cert> {
self.full_certs.iter().find(|cert| &cert.keyid() == keyid)
}
// NOTE: This can't return an iterator because iterators are all different types
// and returning different types is naughty
fn get_certs_for_pkesk<'a>(&'a self, pkesk: &'a PKESK) -> impl Iterator<Item = &Cert> + 'a {
self.full_certs.iter().filter(move |cert| {
pkesk.recipient().is_wildcard() || cert.keys().any(|k| &k.keyid() == pkesk.recipient())
})
}
}
impl VerificationHelper for &mut Keyring {
@ -120,10 +117,7 @@ impl DecryptionHelper for &mut Keyring {
let null = NullPolicy::new();
// unoptimized route: use all locally stored certs
for pkesk in pkesks {
for cert in self.full_certs.iter().filter(|cert| {
pkesk.recipient().is_wildcard()
|| cert.keys().any(|k| &k.keyid() == pkesk.recipient())
}) {
for cert in self.get_certs_for_pkesk(pkesk) {
#[allow(deprecated, clippy::map_flatten)]
let name = cert
.userids()
@ -146,16 +140,16 @@ impl DecryptionHelper for &mut Keyring {
.context("Has unencrypted secret")?
} else {
let message = if let Some(name) = name.as_ref() {
format!("Decryption key for {} ({name}): ", secret_key.keyid())
format!("Decryption key for: {} ({name})", secret_key.keyid())
} else {
format!("Decryption key for {}: ", secret_key.keyid())
format!("Decryption key for: {}", secret_key.keyid())
};
let passphrase = self
.pm
.prompt_passphrase(&message)
.prompt_passphrase("Decryption passphrase", message)
.context("Decryption passphrase")?;
secret_key
.decrypt_secret(&passphrase.as_str().into())
.decrypt_secret(&passphrase.expose_secret().as_str().into())
.context("has_unencrypted_secret is false, could not decrypt secret")?
.into_keypair()
.context("just-decrypted key")?

View File

@ -1,6 +1,6 @@
use std::{collections::HashSet, fs::File};
use std::collections::HashSet;
use keyfork_prompt::{Error as PromptError, PromptManager};
use keyfork_pinentry::ExposeSecret;
use super::openpgp::{
self,
@ -9,6 +9,7 @@ use super::openpgp::{
parse::stream::{DecryptionHelper, MessageLayer, MessageStructure, VerificationHelper},
Fingerprint,
};
use crate::prompt_manager::{PinentryError, PromptManager};
use anyhow::Context;
use card_backend_pcsc::PcscBackend;
@ -44,14 +45,8 @@ pub enum Error {
#[error("Invalid PIN entered too many times")]
InvalidPIN,
#[error("Could not find TTY when prompting")]
NoTTY,
#[error("Could not open TTY: {0}")]
Io(#[from] std::io::Error),
#[error("Prompt failed: {0}")]
Prompt(#[from] PromptError),
Prompt(#[from] PinentryError),
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
@ -70,20 +65,15 @@ fn format_name(input: impl AsRef<str>) -> String {
pub struct SmartcardManager {
current_card: Option<Card<Open>>,
root: Option<Cert>,
pm: PromptManager<File, File>,
pm: PromptManager,
}
impl SmartcardManager {
pub fn new() -> Result<Self> {
let tty = std::env::vars()
.filter(|(k, _v)| k.as_str() == "GPG_TTY")
.next()
.ok_or(Error::NoTTY)?
.1;
Ok(Self {
current_card: None,
root: None,
pm: PromptManager::new(File::open(&tty)?, File::options().write(true).open(&tty)?)?,
pm: PromptManager::new("keyfork-shard", None)?,
})
}
@ -242,13 +232,13 @@ impl DecryptionHelper for &mut SmartcardManager {
.err_count_pw1();
let rpea = "Remaining PIN entry attempts";
let message = if cardholder_name.is_empty() {
format!("Unlock card {card_id}\n{rpea}: {attempts}\n\nPIN: ")
format!("Unlock card {card_id}\n\n{rpea}: {attempts}")
} else {
format!("Unlock card {card_id} ({cardholder_name})\n{rpea}: {attempts}\n\nPIN: ")
format!("Unlock card {card_id} ({cardholder_name})\n\n{rpea}: {attempts}")
};
let temp_pin = self.pm.prompt_passphrase(&message)?;
let temp_pin = self.pm.prompt_passphrase("Smartcard User PIN", message)?;
let verification_status =
transaction.verify_user_pin(temp_pin.as_str().trim());
transaction.verify_user_pin(temp_pin.expose_secret().as_str().trim());
match verification_status {
Ok(_) => {
pin.replace(temp_pin);
@ -262,7 +252,7 @@ impl DecryptionHelper for &mut SmartcardManager {
}
let pin = pin.ok_or(Error::InvalidPIN)?;
let mut user = transaction
.to_user_card(pin.as_str().trim())
.to_user_card(pin.expose_secret().as_str().trim())
.context("Could not load user smartcard from PIN")?;
let mut decryptor = user
.decryptor(&|| eprintln!("Touch confirmation needed for decryption"))

View File

@ -0,0 +1,74 @@
use std::path::PathBuf;
use keyfork_pinentry::{
self, default_binary, ConfirmationDialog, MessageDialog, PassphraseInput, SecretString,
};
#[derive(thiserror::Error, Debug)]
pub enum PinentryError {
#[error("No pinentry binary found")]
NoPinentryFound,
#[error("{0}")]
Internal(#[from] keyfork_pinentry::Error),
}
pub type Result<T, E = PinentryError> = std::result::Result<T, E>;
/// Display message dialogues, confirmation prompts, and passphrase inputs with keyfork-pinentry.
pub struct PromptManager {
program_title: String,
pinentry_binary: PathBuf,
}
impl PromptManager {
pub fn new(
program_title: impl Into<String>,
pinentry_binary: impl Into<Option<PathBuf>>,
) -> Result<Self> {
let path = match pinentry_binary.into() {
Some(p) => p,
None => default_binary()?,
};
std::fs::metadata(&path).map_err(|_| PinentryError::NoPinentryFound)?;
Ok(Self {
program_title: program_title.into(),
pinentry_binary: path,
})
}
#[allow(dead_code)]
pub fn prompt_confirmation(&self, prompt: impl AsRef<str>) -> Result<bool> {
ConfirmationDialog::with_binary(self.pinentry_binary.clone())
.with_title(&self.program_title)
.confirm(prompt.as_ref())
.map_err(|e| e.into())
}
pub fn prompt_message(&self, prompt: impl AsRef<str>) -> Result<()> {
MessageDialog::with_binary(self.pinentry_binary.clone())
.with_title(&self.program_title)
.show_message(prompt.as_ref())
.map_err(|e| e.into())
}
pub fn prompt_passphrase(
&self,
prompt: impl AsRef<str>,
description: impl Into<Option<String>>,
) -> Result<SecretString> {
match description.into() {
Some(desc) => PassphraseInput::with_binary(self.pinentry_binary.clone())
.with_title(&self.program_title)
.with_prompt(prompt.as_ref())
.with_description(&desc)
.interact()
.map_err(|e| e.into()),
None => PassphraseInput::with_binary(self.pinentry_binary.clone())
.with_title(&self.program_title)
.with_prompt(prompt.as_ref())
.interact()
.map_err(|e| e.into()),
}
}
}