From c206800ad208c05d049a7553fd8be09fd7602090 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 5 Nov 2023 00:45:47 -0500 Subject: [PATCH] keyfork-shard: add keyfork-pinentry --- Cargo.lock | 222 ++++----- Cargo.toml | 1 + keyfork-pinentry/Cargo.toml | 22 + keyfork-pinentry/LICENSE.md | 22 + keyfork-pinentry/src/assuan.rs | 225 +++++++++ keyfork-pinentry/src/error.rs | 114 +++++ keyfork-pinentry/src/lib.rs | 437 ++++++++++++++++++ keyfork-shard/Cargo.toml | 5 +- .../src/bin/keyfork-shard-combine-openpgp.rs | 9 +- keyfork-shard/src/lib.rs | 3 + keyfork-shard/src/openpgp.rs | 10 +- keyfork-shard/src/openpgp/keyring.rs | 47 +- keyfork-shard/src/openpgp/smartcard.rs | 66 +-- keyfork-shard/src/prompt_manager.rs | 74 +++ 14 files changed, 1069 insertions(+), 188 deletions(-) create mode 100644 keyfork-pinentry/Cargo.toml create mode 100644 keyfork-pinentry/LICENSE.md create mode 100644 keyfork-pinentry/src/assuan.rs create mode 100644 keyfork-pinentry/src/error.rs create mode 100644 keyfork-pinentry/src/lib.rs create mode 100644 keyfork-shard/src/prompt_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 53b8b5d..fb0badb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,9 +233,9 @@ dependencies = [ [[package]] name = "buffered-reader" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d3bea5bcc3ecc38fe5388e6bc35e6fe7bd665eb3ae9a44283e15b91ad3867d" +checksum = "2b9b0a25eb06e83579bc985d836e1e3b957a7201301b48538764d2b2e78090d4" dependencies = [ "bzip2", "flate2", @@ -478,7 +478,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", "platforms", "rustc_version", @@ -524,15 +524,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - [[package]] name = "digest" version = "0.10.7" @@ -580,19 +571,10 @@ checksum = "a4b1e0c257a9e9f25f90ff76d7a68360ed497ee519c8e428d1825ef0000799d4" dependencies = [ "der 0.7.8", "elliptic-curve", - "signature 2.1.0", + "signature", "spki 0.7.2", ] -[[package]] -name = "ed25519" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" -dependencies = [ - "signature 1.6.4", -] - [[package]] name = "ed25519" version = "2.2.2" @@ -600,7 +582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60f6d271ca33075c88028be6f04d502853d63a5ece419d269c15315d4fc1cf1d" dependencies = [ "pkcs8 0.10.2", - "signature 2.1.0", + "signature", ] [[package]] @@ -610,7 +592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", - "ed25519 2.2.2", + "ed25519", "serde", "sha2", "zeroize", @@ -630,7 +612,7 @@ checksum = "968405c8fdc9b3bf4df0a6638858cc0b52462836ab6b1c87377785dd09cf1c0b" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", "generic-array", "group", @@ -762,19 +744,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.2.10" @@ -784,7 +753,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -862,7 +831,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "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]] @@ -890,9 +868,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -1033,7 +1011,7 @@ dependencies = [ name = "keyfork-derive-util" version = "0.1.0" dependencies = [ - "digest 0.10.7", + "digest", "ed25519-dalek", "hex-literal", "hmac", @@ -1069,6 +1047,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" @@ -1085,11 +1075,13 @@ dependencies = [ "bincode", "card-backend-pcsc", "keyfork-derive-openpgp", + "keyfork-pinentry", "openpgp-card-sequoia", "sequoia-openpgp", "serde", "sharks", "smex", + "thiserror", ] [[package]] @@ -1135,9 +1127,9 @@ dependencies = [ [[package]] name = "lalrpop" -version = "0.19.12" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" +checksum = "da4081d44f4611b66c6dd725e6de3169f9f63905421e8626fcb86b6a898998b8" dependencies = [ "ascii-canvas", "bit-set", @@ -1148,7 +1140,7 @@ dependencies = [ "lalrpop-util", "petgraph", "regex", - "regex-syntax 0.6.29", + "regex-syntax 0.7.4", "string_cache", "term", "tiny-keccak", @@ -1157,9 +1149,9 @@ dependencies = [ [[package]] name = "lalrpop-util" -version = "0.19.12" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3c48237b9604c5a4702de6b824e02006c3214327564636aef27c1028a8fa0ed" +checksum = "3f35c735096c0293d313e8f2a641627472b83d01b937177fe76e5e2708d31e0d" [[package]] name = "lazy_static" @@ -1275,7 +1267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.48.0", ] @@ -1285,7 +1277,7 @@ version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9fdccf3eae7b161910d2daa2f0155ca35041322e8fe5c5f1f2c9d0b12356336" dependencies = [ - "getrandom 0.2.10", + "getrandom", "libc", "nettle-sys", "thiserror", @@ -1470,7 +1462,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest 0.10.7", + "digest", "hmac", ] @@ -1508,6 +1500,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" @@ -1646,19 +1644,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - [[package]] name = "rand" version = "0.8.5" @@ -1666,20 +1651,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -1705,31 +1680,13 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.10", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", + "getrandom", ] [[package]] @@ -1765,7 +1722,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ - "getrandom 0.2.10", + "getrandom", "redox_syscall 0.2.16", "thiserror", ] @@ -1814,6 +1771,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -1829,7 +1792,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1839,7 +1802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55a77d189da1fee555ad95b7e50e7457d91c0e089ec68ca69ad2989413bbdab4" dependencies = [ "byteorder", - "digest 0.10.7", + "digest", "num-bigint-dig", "num-integer", "num-iter", @@ -1847,7 +1810,7 @@ dependencies = [ "pkcs1", "pkcs8 0.9.0", "rand_core 0.6.4", - "signature 2.1.0", + "signature", "subtle", "zeroize", ] @@ -1932,6 +1895,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" @@ -1940,9 +1912,9 @@ checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" [[package]] name = "sequoia-openpgp" -version = "1.16.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a16854c0f6297de6db4df195e28324dfbc2429802f0e48cd04007db8e3049709" +checksum = "2ea026cf8a70d331c742e3ad7e68fd405d0743ff86630fb4334a1bf8d0e194c7" dependencies = [ "anyhow", "base64", @@ -1950,9 +1922,9 @@ dependencies = [ "bzip2", "chrono", "dyn-clone", - "ed25519 1.5.3", + "ed25519", "flate2", - "getrandom 0.2.10", + "getrandom", "idna", "lalrpop", "lalrpop-util", @@ -1961,9 +1933,9 @@ dependencies = [ "memsec", "nettle", "once_cell", - "rand 0.7.3", + "rand 0.8.5", "regex", - "regex-syntax 0.6.29", + "regex-syntax 0.8.2", "sha1collisiondetection", "thiserror", "xxhash-rust", @@ -2002,11 +1974,11 @@ dependencies = [ [[package]] name = "sha1collisiondetection" -version = "0.2.7" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b20793cf8330b2c7da4c438116660fed24e380bcb8a1bcfff2581b5593a0b38e" +checksum = "31c0b86a052106b16741199985c9ec2bf501f619f70c48fa479b44b093ad9a68" dependencies = [ - "digest 0.9.0", + "digest", "generic-array", ] @@ -2018,7 +1990,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -2056,19 +2028,13 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" - [[package]] name = "signature" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] @@ -2223,18 +2189,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.49" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.49" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" dependencies = [ "proc-macro2", "quote", @@ -2469,12 +2435,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2535,6 +2495,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" diff --git a/Cargo.toml b/Cargo.toml index ca15585..c431567 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "keyfork-derive-util", "keyfork-frame", "keyfork-mnemonic-util", + "keyfork-pinentry", "keyfork-plumbing", "keyfork-shard", "keyfork-slip10-test-data", diff --git a/keyfork-pinentry/Cargo.toml b/keyfork-pinentry/Cargo.toml new file mode 100644 index 0000000..a3d9170 --- /dev/null +++ b/keyfork-pinentry/Cargo.toml @@ -0,0 +1,22 @@ +[package] +# name = "pinentry" +name = "keyfork-pinentry" +description = "API for interacting with pinentry binaries" +version = "0.5.0" +# authors = ["Jack Grigg "] +authors = ["Ryan Heywood "] +# 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" + +[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" diff --git a/keyfork-pinentry/LICENSE.md b/keyfork-pinentry/LICENSE.md new file mode 100644 index 0000000..b1a5332 --- /dev/null +++ b/keyfork-pinentry/LICENSE.md @@ -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. diff --git a/keyfork-pinentry/src/assuan.rs b/keyfork-pinentry/src/assuan.rs new file mode 100644 index 0000000..dbfbe60 --- /dev/null +++ b/keyfork-pinentry/src/assuan.rs @@ -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), + /// Request could not be fulfilled. The possible error codes are defined by + /// `libgpg-error`. + Err { + code: u16, + description: Option, + }, + /// Informational output by the server, which is still processing the request. + Information { + keyword: String, + status: Option, + }, + /// 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, + }, +} + +pub struct Connection { + output: ChildStdin, + input: BufReader, +} + +impl Connection { + pub fn open(name: &Path) -> Result { + 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> { + 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> { + 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) + } +} diff --git a/keyfork-pinentry/src/error.rs b/keyfork-pinentry/src/error.rs new file mode 100644 index 0000000..e05c91c --- /dev/null +++ b/keyfork-pinentry/src/error.rs @@ -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 for the + /// mapping from error code to GPG error type. + code: u16, + + /// A description of the error, if available. + /// + /// See for the + /// likely descriptions. + description: Option, +} + +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) -> 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 for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +impl From for Error { + fn from(e: std::str::Utf8Error) -> Self { + Error::Encoding(e) + } +} +*/ + +impl Error { + pub(crate) fn from_parts(code: u16, description: Option) -> Self { + match code { + GPG_ERR_TIMEOUT => Error::Timeout, + GPG_ERR_CANCELED => Error::Cancelled, + _ => Error::Gpg(GpgError::new(code, description)), + } + } +} diff --git a/keyfork-pinentry/src/lib.rs b/keyfork-pinentry/src/lib.rs new file mode 100644 index 0000000..b901041 --- /dev/null +++ b/keyfork-pinentry/src/lib.rs @@ -0,0 +1,437 @@ +//! `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 = std::result::Result; + +/// Find the expected default pinentry binary +pub fn default_binary() -> Result { + which::which("pinentry-curses").map_err(Into::into) +} + +/// 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, +} + +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 { + 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 { + 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(desc))?; + } + 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, +} + +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 { + 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 { + 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, +} + +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 { + 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(|_| ()) + } +} diff --git a/keyfork-shard/Cargo.toml b/keyfork-shard/Cargo.toml index 574b5d5..221a0e2 100644 --- a/keyfork-shard/Cargo.toml +++ b/keyfork-shard/Cargo.toml @@ -7,16 +7,19 @@ edition = "2021" [features] default = ["openpgp", "openpgp-card"] -openpgp = ["sequoia-openpgp"] +openpgp = ["sequoia-openpgp", "prompt"] openpgp-card = ["openpgp-card-sequoia", "card-backend-pcsc"] +prompt = ["keyfork-pinentry"] [dependencies] anyhow = "1.0.75" bincode = "1.3.3" 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 } sequoia-openpgp = { version = "1.16.1", optional = true } serde = "1.0.188" sharks = "0.5.0" smex = { version = "0.1.0", path = "../smex" } +thiserror = "1.0.50" diff --git a/keyfork-shard/src/bin/keyfork-shard-combine-openpgp.rs b/keyfork-shard/src/bin/keyfork-shard-combine-openpgp.rs index 32b6c02..4edf5c3 100644 --- a/keyfork-shard/src/bin/keyfork-shard-combine-openpgp.rs +++ b/keyfork-shard/src/bin/keyfork-shard-combine-openpgp.rs @@ -1,11 +1,4 @@ -use std::{ - env, - fs::File, - io::stdout, - path::PathBuf, - process::ExitCode, - str::FromStr, -}; +use std::{env, fs::File, io::stdout, path::PathBuf, process::ExitCode, str::FromStr}; use keyfork_shard::openpgp::{combine, discover_certs, openpgp::Cert, parse_messages}; diff --git a/keyfork-shard/src/lib.rs b/keyfork-shard/src/lib.rs index 9c60f1d..ec47690 100644 --- a/keyfork-shard/src/lib.rs +++ b/keyfork-shard/src/lib.rs @@ -1,2 +1,5 @@ #[cfg(feature = "openpgp")] pub mod openpgp; + +#[cfg(feature = "prompt")] +mod prompt_manager; diff --git a/keyfork-shard/src/openpgp.rs b/keyfork-shard/src/openpgp.rs index 43e83b7..14351b6 100644 --- a/keyfork-shard/src/openpgp.rs +++ b/keyfork-shard/src/openpgp.rs @@ -19,7 +19,7 @@ use openpgp::{ }, policy::{NullPolicy, Policy, StandardPolicy}, serialize::{ - stream::{ArbitraryWriter, Encryptor, LiteralWriter, Message, Recipient, Signer}, + stream::{ArbitraryWriter, Encryptor2, LiteralWriter, Message, Recipient, Signer}, Marshal, }, types::KeyFlags, @@ -176,8 +176,8 @@ pub fn combine( // We don't want to invalidate someone's keys just because the old sig expired. let policy = NullPolicy::new(); - let mut keyring = Keyring::new(certs); - let mut manager = SmartcardManager::new(); + let mut keyring = Keyring::new(certs)?; + let mut manager = SmartcardManager::new()?; let content = if keyring.is_empty() { let card_fp = manager.load_any_card()?; eprintln!("key discovery is empty, using hardware smartcard: {card_fp}"); @@ -351,7 +351,7 @@ pub fn split(threshold: u8, certs: Vec, secret: &[u8], output: impl Write) let mut message_output = vec![]; let message = Message::new(&mut message_output); - let message = Encryptor::for_recipients( + let message = Encryptor2::for_recipients( message, encryption_keys .iter() @@ -398,7 +398,7 @@ pub fn split(threshold: u8, certs: Vec, secret: &[u8], output: impl Write) // metadata let mut message_output = vec![]; let message = Message::new(&mut message_output); - let message = Encryptor::for_recipients(message, total_recipients).build()?; + let message = Encryptor2::for_recipients(message, total_recipients).build()?; let mut message = LiteralWriter::new(message).build()?; message.write_all(&pp)?; message.finalize()?; diff --git a/keyfork-shard/src/openpgp/keyring.rs b/keyfork-shard/src/openpgp/keyring.rs index 4bd0149..21dc39a 100644 --- a/keyfork-shard/src/openpgp/keyring.rs +++ b/keyfork-shard/src/openpgp/keyring.rs @@ -1,11 +1,16 @@ +use keyfork_pinentry::ExposeSecret; + use super::openpgp::{ self, cert::Cert, packet::{PKESK, SKESK}, parse::stream::{DecryptionHelper, MessageLayer, MessageStructure, VerificationHelper}, + policy::NullPolicy, KeyHandle, KeyID, }; +use crate::prompt_manager::PromptManager; + #[derive(Clone, Debug)] pub enum KeyringFailure { SecretKeyNotFound, @@ -26,18 +31,19 @@ impl std::fmt::Display for KeyringFailure { impl std::error::Error for KeyringFailure {} -#[derive(Clone, Debug)] pub struct Keyring { full_certs: Vec, root: Option, + pm: PromptManager, } impl Keyring { - pub fn new(certs: impl AsRef<[Cert]>) -> Self { - Self { + pub fn new(certs: impl AsRef<[Cert]>) -> Result> { + Ok(Self { full_certs: certs.as_ref().to_vec(), root: Default::default(), - } + pm: PromptManager::new("keyfork-shard", None)?, + }) } pub fn is_empty(&self) -> bool { @@ -114,14 +120,39 @@ impl DecryptionHelper for &mut Keyring { where D: FnMut(openpgp::types::SymmetricAlgorithm, &openpgp::crypto::SessionKey) -> bool, { + let null = NullPolicy::new(); // unoptimized route: use all locally stored certs for pkesk in pkesks { for cert in self.get_certs_for_pkesk(pkesk) { - for key in cert.keys().secret() { + #[allow(deprecated, clippy::map_flatten)] + let name = cert + .userids() + .next() + .map(|userid| userid.userid().name().transpose()) + .flatten() + .transpose()?; + for key in cert + .keys() + .with_policy(&null, None) + .for_storage_encryption() + .secret() + { let secret_key = key.key().clone(); - // NOTE: Returns an error if using an encrypted secret key. - // TODO: support skipping or validating encrypted secret keys. - let mut keypair = secret_key.into_keypair()?; + let mut keypair = if secret_key.has_unencrypted_secret() { + secret_key.into_keypair()? + } else { + let message = if let Some(name) = name.as_ref() { + format!("Decryption key for: {} ({name})", secret_key.keyid()) + } else { + format!("Decryption key for: {}", secret_key.keyid()) + }; + let passphrase = self + .pm + .prompt_passphrase("Decryption passphrase", message)?; + let key = secret_key + .decrypt_secret(&passphrase.expose_secret().as_str().into())?; + key.into_keypair()? + }; if pkesk .decrypt(&mut keypair, sym_algo) .map(|(algo, sk)| decrypt(algo, &sk)) diff --git a/keyfork-shard/src/openpgp/smartcard.rs b/keyfork-shard/src/openpgp/smartcard.rs index c82a9ed..4b6b06e 100644 --- a/keyfork-shard/src/openpgp/smartcard.rs +++ b/keyfork-shard/src/openpgp/smartcard.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use keyfork_pinentry::ExposeSecret; + use super::openpgp::{ self, cert::Cert, @@ -7,6 +9,7 @@ use super::openpgp::{ parse::stream::{DecryptionHelper, MessageLayer, MessageStructure, VerificationHelper}, Fingerprint, }; +use crate::prompt_manager::PromptManager; use card_backend_pcsc::PcscBackend; use openpgp_card_sequoia::{state::Open, Card}; @@ -28,15 +31,19 @@ impl std::fmt::Display for SmartcardFailure { impl std::error::Error for SmartcardFailure {} -#[derive(Default)] pub struct SmartcardManager { current_card: Option>, root: Option, + pm: PromptManager, } impl SmartcardManager { - pub fn new() -> Self { - Default::default() + pub fn new() -> Result> { + Ok(Self { + current_card: None, + root: None, + pm: PromptManager::new("keyfork-shard", None)?, + }) } // Sets the root cert, returning the old cert @@ -46,40 +53,6 @@ impl SmartcardManager { cert } - /// Utility function to prompt for a newline from standard input. - pub fn prompt(&self, prompt: impl std::fmt::Display) -> std::io::Result<()> { - eprint!("{prompt}: "); - std::io::stdin().read_line(&mut String::new()).map(|_| ()) - } - - /// Utility function to obtain a prompt response from the command line. - pub fn prompt_input(&self, prompt: impl std::fmt::Display) -> std::io::Result { - eprint!("{prompt}: "); - let mut output = String::new(); - std::io::stdin().read_line(&mut output)?; - Ok(output) - } - - /* - /// Return all [`Fingerprint`] for the currently accessible backends. - /// - /// NOTE: Only implemented for decryption keys. - pub fn iter_fingerprints() -> impl Iterator { - PcscBackend::cards(None).into_iter().flat_map(|iter| { - iter.filter_map(|backend| { - let backend = backend.ok()?; - let mut card = Card::::new(backend).ok()?; - let transaction = card.transaction().ok()?; - transaction - .fingerprints() - .ok()? - .decryption() - .map(|fp| Fingerprint::from_bytes(fp.as_bytes())) - }) - }) - } - */ - /// Load any backend. pub fn load_any_card(&mut self) -> Result> { PcscBackend::cards(None)? @@ -141,7 +114,8 @@ impl SmartcardManager { } eprintln!("No matching smartcard detected."); - self.prompt("Please plug in a smart card and press enter")?; + self.pm + .prompt_message("Please plug in a smart card and press enter")?; } Ok(None) @@ -201,8 +175,18 @@ impl DecryptionHelper for &mut SmartcardManager { .fingerprints()? .decryption() .map(|fp| Fingerprint::from_bytes(fp.as_bytes())); - let pin = self.prompt_input("User PIN")?; - let mut user = transaction.to_user_card(pin.as_str().trim())?; + let Some(fp) = fp else { + return Err(SmartcardFailure::SmartCardHasNoDecrypt.into()); + }; + let cardholder_name = transaction.cardholder_name()?; + let card_id = transaction.application_identifier()?.ident(); + let message = if cardholder_name.is_empty() { + format!("Unlock card {card_id}") + } else { + format!("Unlock card {card_id} ({cardholder_name})") + }; + let pin = self.pm.prompt_passphrase("Smartcard User PIN", message)?; + let mut user = transaction.to_user_card(pin.expose_secret().as_str().trim())?; let mut decryptor = user.decryptor(&|| eprintln!("Touch confirmation needed for decryption"))?; for pkesk in pkesks { @@ -211,7 +195,7 @@ impl DecryptionHelper for &mut SmartcardManager { .map(|(algo, sk)| decrypt(algo, &sk)) .unwrap_or(false) { - return Ok(fp); + return Ok(Some(fp)); } } diff --git a/keyfork-shard/src/prompt_manager.rs b/keyfork-shard/src/prompt_manager.rs new file mode 100644 index 0000000..dc46fa3 --- /dev/null +++ b/keyfork-shard/src/prompt_manager.rs @@ -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 = std::result::Result; + +/// 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, + pinentry_binary: impl Into>, + ) -> Result { + 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) -> Result { + 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) -> 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, + description: impl Into>, + ) -> Result { + 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()), + } + } +}