Compare commits

..

9 Commits

Author SHA1 Message Date
Ryan Heywood 76ca4b0812
Release keyfork v0.3.0 2025-02-25 23:23:39 -05:00
Ryan Heywood 53665cac2e
keyfork: the wizard is dead! long live the mnemonic generator! 2025-02-25 23:00:23 -05:00
Ryan Heywood a1c3d52c14
keyfork: restructure wizard shard key generation
also: `keyfork provision shard`
2025-02-25 17:02:35 -05:00
Ryan Heywood 674e2e93c5
keyfork: restructure CLI commands to act more like the other commands 2025-02-24 23:16:27 -05:00
Ryan Heywood 88a05f23ac
keyfork-prompt: add choice mechanism, & add to keyfork-shard 2025-02-22 05:29:49 -05:00
Ryan Heywood 98b9dbb811
keyfork-qrcode: restructure to prefer libzbar and compile with both enabled 2025-02-22 02:48:13 -05:00
Ryan Heywood 723194fdd7
keyfork mnemonic generate: userid equivalency, rename provisioner cert_output to output 2025-02-19 20:35:29 -05:00
Ryan Heywood db19b30bfe
keyfork mnemonic generate: feedback improvements
* Touch policy is now set to `on` by default (not fixed, as that's
  irreversible).
* The value passed to `--encrypt-to-self` is the actual encrypted
  output.
* The `cert_output` passed to `--encrypt-to-self` by default is the
  fingerprint of the certificate.
* The OpenPGP provisioner can now be used without identifiers, if the
  correct amount of smartcards are actively plugged into the current
  system.
* The OpenPGP provisioner, when run without `--encrypt-to-self`, will
  output the OpenPGP certificate for the smartcard.
2025-02-19 20:12:27 -05:00
Ryan Heywood 0243212c80
keyfork-prompt: clear terminal before leaving alt screen; fixes linux terminal 2025-02-19 16:31:06 -05:00
29 changed files with 1630 additions and 762 deletions

View File

@ -1,3 +1,96 @@
# Keyfork v0.3.0
The Wizard is Dead. Long Live the Mnemonic Generator.
The `keyfork wizard` subcommand was previously used to perform complex
operations that couldn't be performed with just `keyfork mnemonic generate`.
Since we've introduced complexity into `keyfork mnemonic generate`, it only
makes sense to consolidate all mnemonic generation complexity into one
location. Therefore, `keyfork mnemonic generate` should be a one-stop shop from
going to zero entropy to 256 bits of entropy. :)
The following operations are added:
* `keyfork mnemonic generate --derive=<derivation>`: Allow for the immediate
derivation of a key. The value passed will be parsed directly as though
`keyfork derive` were run. For example,
`keyfork mnemonic generate --derive='openpgp "Ryan Heywood"'` generates an
OpenPGP Transferable Secret Key that is nearly-identical to one generated by
`keyfork derive openpgp "Ryan Heywood"`, with the only exception being the
time the signatures were created.
* `keyfork mnemonic generate --encrypt-to <keyring>`: Encrypt the mnemonic to
an existing OpenPGP keyring or certificate.
* `keyfork mnemonic generate --shard-to <shardfile>`: Shard the mnemonic to
an existing Keyfork Shardfile.
* `keyfork mnemonic generate --shard <config>`: Shard the mnemonic to an
existing set of OpenPGP certificates.
* `keyfork mnemonic generate --encrypt-to-self <file>`: Encrypt the mnemonic to
an OpenPGP certificate generated in `--derive` or `--provision`
* `keyfork mnemonic generate --shard-to-self <file>,<config>`: Shard the
mnemonic to freshly generated certificates, provisioned to OpenPGP
smartcards. This option replaces the traditional Keyfork Wizard, which has
been removed.
* `keyfork mnemonic generate --provision`: Provision a key derived from the new
mnemonic, which can be used for `--encrypt-to-self`, or to just bypass
needing to load the mnemonic to provision with it.
Along with these changes, some other minor additions were added:
* QR code retries in the Shard mechanism are now implemented.
* `keyfork-qrcode` now prefers libzbar and can compile with both.
* `keyfork-prompt` should now work better on AirgapOS and Linux terminals.
### Changes in keyfork:
```
53665ca keyfork: the wizard is dead! long live the mnemonic generator!
a1c3d52 keyfork: restructure wizard shard key generation
674e2e9 keyfork: restructure CLI commands to act more like the other commands
723194f keyfork mnemonic generate: userid equivalency, rename provisioner cert_output to output
db19b30 keyfork mnemonic generate: feedback improvements
```
### Changes in keyfork-bug:
Add `keyfork_bug::assert!()` for asserting with Keyfork Bug printing.
```
88a05f2 keyfork-prompt: add choice mechanism, & add to keyfork-shard
```
### Changes in keyfork-prompt:
```
88a05f2 keyfork-prompt: add choice mechanism, & add to keyfork-shard
0243212 keyfork-prompt: clear terminal before leaving alt screen; fixes linux terminal
```
### Changes in keyfork-qrcode:
```
98b9dbb keyfork-qrcode: restructure to prefer libzbar and compile with both enabled
```
### Changes in keyfork-shard:
```
88a05f2 keyfork-prompt: add choice mechanism, & add to keyfork-shard
aa8526c Release keyfork-shard v0.3.1
```
### Changes in keyfork-zbar:
```
98b9dbb keyfork-qrcode: restructure to prefer libzbar and compile with both enabled
```
### Changes in keyforkd:
```
674e2e9 keyfork: restructure CLI commands to act more like the other commands
```
# Keyfork v0.2.6 # Keyfork v0.2.6
* The `--daemon` flag has been added for `keyfork recover` subcommands. * The `--daemon` flag has been added for `keyfork recover` subcommands.

66
Cargo.lock generated
View File

@ -1381,10 +1381,22 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "getrandom"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.13.3+wasi-0.2.2",
"windows-targets",
]
[[package]] [[package]]
name = "ghash" name = "ghash"
version = "0.5.1" version = "0.5.1"
@ -1785,7 +1797,7 @@ dependencies = [
[[package]] [[package]]
name = "keyfork" name = "keyfork"
version = "0.2.6" version = "0.3.0"
dependencies = [ dependencies = [
"base64", "base64",
"card-backend-pcsc", "card-backend-pcsc",
@ -1808,7 +1820,9 @@ dependencies = [
"openpgp-card-sequoia", "openpgp-card-sequoia",
"sequoia-openpgp", "sequoia-openpgp",
"serde", "serde",
"shlex",
"smex", "smex",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
] ]
@ -1822,7 +1836,7 @@ dependencies = [
[[package]] [[package]]
name = "keyfork-bug" name = "keyfork-bug"
version = "0.1.0" version = "0.1.1"
[[package]] [[package]]
name = "keyfork-crossterm" name = "keyfork-crossterm"
@ -1930,7 +1944,7 @@ dependencies = [
[[package]] [[package]]
name = "keyfork-prompt" name = "keyfork-prompt"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"keyfork-bug", "keyfork-bug",
"keyfork-crossterm", "keyfork-crossterm",
@ -1940,8 +1954,9 @@ dependencies = [
[[package]] [[package]]
name = "keyfork-qrcode" name = "keyfork-qrcode"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"cfg-if",
"image", "image",
"keyfork-bug", "keyfork-bug",
"keyfork-zbar", "keyfork-zbar",
@ -1952,7 +1967,7 @@ dependencies = [
[[package]] [[package]]
name = "keyfork-shard" name = "keyfork-shard"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@ -1993,7 +2008,7 @@ dependencies = [
[[package]] [[package]]
name = "keyfork-zbar" name = "keyfork-zbar"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"image", "image",
"keyfork-zbar-sys", "keyfork-zbar-sys",
@ -2243,7 +2258,7 @@ dependencies = [
"hermit-abi 0.3.9", "hermit-abi 0.3.9",
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@ -2253,7 +2268,7 @@ version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44e6ff4a94e5d34a1fd5abbd39418074646e2fa51b257198701330f22fcd6936" checksum = "44e6ff4a94e5d34a1fd5abbd39418074646e2fa51b257198701330f22fcd6936"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.15",
"libc", "libc",
"nettle-sys", "nettle-sys",
"thiserror", "thiserror",
@ -2819,7 +2834,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.15",
] ]
[[package]] [[package]]
@ -2837,7 +2852,7 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.15",
"libredox", "libredox",
"thiserror", "thiserror",
] ]
@ -2907,9 +2922,9 @@ dependencies = [
[[package]] [[package]]
name = "rqrr" name = "rqrr"
version = "0.7.1" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad0cd0432e6beb2f86aa4c8af1bb5edcf3c9bcb9d4836facc048664205458575" checksum = "f126a9b02152815d84315316e7a759ee18a216d057095d56d19cec68a428b385"
dependencies = [ dependencies = [
"g2p", "g2p",
"image", "image",
@ -3066,7 +3081,7 @@ dependencies = [
"ed25519", "ed25519",
"ed25519-dalek", "ed25519-dalek",
"flate2", "flate2",
"getrandom", "getrandom 0.2.15",
"idea", "idea",
"idna", "idna",
"lalrpop", "lalrpop",
@ -3354,12 +3369,13 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.14.0" version = "3.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"fastrand", "fastrand",
"getrandom 0.3.1",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.59.0", "windows-sys 0.59.0",
@ -3725,6 +3741,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.13.3+wasi-0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
dependencies = [
"wit-bindgen-rt",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.95" version = "0.2.95"
@ -3936,6 +3961,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
"bitflags 2.6.0",
]
[[package]] [[package]]
name = "write16" name = "write16"
version = "1.0.0" version = "1.0.0"

View File

@ -76,6 +76,7 @@ thiserror = "1.0.56"
tokio = "1.35.1" tokio = "1.35.1"
v4l = "0.14.0" v4l = "0.14.0"
base64 = "0.22.1" base64 = "0.22.1"
tempfile = "3.17.1"
[profile.release] [profile.release]
debug = true debug = true

View File

@ -123,31 +123,25 @@ To follow these steps please install [git-lfs][gl] and [git-sig][gs].
``` ```
--> -->
1. Install required dependencies 1. Clone repo
### Debian
`sudo apt install build-essential gcc clang llvm pkg-config nettle-dev libpcsclite-dev`
2. Clone repo
```sh ```sh
git clone https://git.distrust.co/public/stack git clone https://git.distrust.co/public/stack
``` ```
3. Verify Git signatures 2. Verify Git signatures
```sh ```sh
git verify-commit HEAD git verify-commit HEAD
``` ```
4. Install binary 3. Install binary
```sh ```sh
cargo install --path crates/keyfork cargo install --path crates/keyfork
``` ```
5. Optionally, build binary for distribution 4. Optionally, build binary for distribution
```sh ```sh
cargo build --release --bin keyfork cargo build --release --bin keyfork

View File

@ -32,7 +32,7 @@ tower = { version = "0.4.13", features = ["tokio", "util"] }
# Personally audited # Personally audited
thiserror = { workspace = true } thiserror = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tempfile = { version = "3.10.0", default-features = false } tempfile = { workspace = true }
[dev-dependencies] [dev-dependencies]
hex-literal = { workspace = true } hex-literal = { workspace = true }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "keyfork-shard" name = "keyfork-shard"
version = "0.3.1" version = "0.3.2"
edition = "2021" edition = "2021"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"

View File

@ -2,9 +2,10 @@
#![allow(clippy::expect_fun_call)] #![allow(clippy::expect_fun_call)]
use std::{ use std::{
io::{stdin, stdout, Read, Write}, io::{Read, Write},
rc::Rc, rc::Rc,
sync::Mutex, str::FromStr,
sync::{LazyLock, Mutex},
}; };
use aes_gcm::{ use aes_gcm::{
@ -22,7 +23,7 @@ use keyfork_prompt::{
mnemonic::{MnemonicSetValidator, MnemonicValidator, WordLength}, mnemonic::{MnemonicSetValidator, MnemonicValidator, WordLength},
Validator, Validator,
}, },
Message as PromptMessage, PromptHandler, Terminal, Message as PromptMessage, PromptHandler,
}; };
use sha2::Sha256; use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
@ -34,6 +35,30 @@ const PLAINTEXT_LENGTH: u8 = 32 // shard
+ 1; // length; + 1; // length;
const ENCRYPTED_LENGTH: u8 = PLAINTEXT_LENGTH + 16; const ENCRYPTED_LENGTH: u8 = PLAINTEXT_LENGTH + 16;
#[derive(PartialEq, Eq, Clone, Copy)]
enum RetryScanMnemonic {
Retry,
Continue,
}
impl keyfork_prompt::Choice for RetryScanMnemonic {
fn identifier(&self) -> Option<char> {
Some(match self {
RetryScanMnemonic::Retry => 'r',
RetryScanMnemonic::Continue => 'c',
})
}
}
impl std::fmt::Display for RetryScanMnemonic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RetryScanMnemonic::Retry => write!(f, "Retry scanning mnemonic."),
RetryScanMnemonic::Continue => write!(f, "Continue to manual mnemonic entry."),
}
}
}
#[cfg(feature = "openpgp")] #[cfg(feature = "openpgp")]
pub mod openpgp; pub mod openpgp;
@ -247,19 +272,28 @@ pub trait Format {
.lock() .lock()
.expect(bug!(POISONED_MUTEX)) .expect(bug!(POISONED_MUTEX))
.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?; .prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?;
if let Ok(Some(qrcode_content)) = loop {
keyfork_qrcode::scan_camera(std::time::Duration::from_secs(30), 0) if let Ok(Some(qrcode_content)) = keyfork_qrcode::scan_camera(
{ std::time::Duration::from_secs(*QRCODE_TIMEOUT),
let decoded_data = BASE64_STANDARD 0,
.decode(qrcode_content) ) {
.expect(bug!("qrcode should contain base64 encoded data")); let decoded_data = BASE64_STANDARD
pubkey_data = Some(decoded_data.try_into().map_err(|_| InvalidData)?) .decode(qrcode_content)
} else { .expect(bug!("qrcode should contain base64 encoded data"));
prompt pubkey_data = Some(decoded_data.try_into().map_err(|_| InvalidData)?);
.lock() break;
.expect(bug!(POISONED_MUTEX)) } else {
.prompt_message(PromptMessage::Text(QRCODE_ERROR.to_string()))?; let mut prompt = prompt.lock().expect(bug!(POISONED_MUTEX));
}; let choice = keyfork_prompt::prompt_choice(
&mut **prompt,
"A QR code could not be scanned. Retry or continue?",
&[RetryScanMnemonic::Retry, RetryScanMnemonic::Continue],
)?;
if choice == RetryScanMnemonic::Continue {
break;
}
};
}
} }
// if QR code scanning failed or was unavailable, read from a set of mnemonics // if QR code scanning failed or was unavailable, read from a set of mnemonics
@ -459,9 +493,13 @@ pub(crate) const HUNK_VERSION: u8 = 2;
pub(crate) const HUNK_OFFSET: usize = 2; pub(crate) const HUNK_OFFSET: usize = 2;
const QRCODE_PROMPT: &str = "Press enter, then present QR code to camera."; const QRCODE_PROMPT: &str = "Press enter, then present QR code to camera.";
const QRCODE_TIMEOUT: u64 = 60; // One minute
const QRCODE_COULDNT_READ: &str = "A QR code could not be scanned. Please enter their words: "; const QRCODE_COULDNT_READ: &str = "A QR code could not be scanned. Please enter their words: ";
const QRCODE_ERROR: &str = "Unable to scan a QR code. Falling back to text entry."; static QRCODE_TIMEOUT: LazyLock<u64> = LazyLock::new(|| {
std::env::var("KEYFORK_QRCODE_TIMEOUT")
.ok()
.and_then(|t| u64::from_str(&t).ok())
.unwrap_or(60)
});
/// Establish ECDH transport for remote operators, receive transport-encrypted shares, decrypt the /// Establish ECDH transport for remote operators, receive transport-encrypted shares, decrypt the
/// shares, and combine them. /// shares, and combine them.
@ -476,7 +514,7 @@ const QRCODE_ERROR: &str = "Unable to scan a QR code. Falling back to text entry
/// The function may panic if it is given payloads generated using a version of Keyfork that is /// The function may panic if it is given payloads generated using a version of Keyfork that is
/// incompatible with the currently running version. /// incompatible with the currently running version.
pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Error>> { pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Error>> {
let mut pm = Terminal::new(stdin(), stdout())?; let mut pm = keyfork_prompt::default_handler()?;
let mut iter_count = None; let mut iter_count = None;
let mut shares = vec![]; let mut shares = vec![];
@ -523,23 +561,34 @@ pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Erro
#[cfg(feature = "qrcode")] #[cfg(feature = "qrcode")]
{ {
pm.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?; pm.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?;
if let Ok(Some(qrcode_content)) = loop {
keyfork_qrcode::scan_camera(std::time::Duration::from_secs(QRCODE_TIMEOUT), 0) if let Ok(Some(qrcode_content)) = keyfork_qrcode::scan_camera(
{ std::time::Duration::from_secs(*QRCODE_TIMEOUT),
let decoded_data = BASE64_STANDARD 0,
.decode(qrcode_content) ) {
.expect(bug!("qrcode should contain base64 encoded data")); let decoded_data = BASE64_STANDARD
assert_eq!( .decode(qrcode_content)
decoded_data.len(), .expect(bug!("qrcode should contain base64 encoded data"));
// Include length of public key assert_eq!(
ENCRYPTED_LENGTH as usize + 32, decoded_data.len(),
bug!("invalid payload data") // Include length of public key
); ENCRYPTED_LENGTH as usize + 32,
let _ = pubkey_data.insert(decoded_data[..32].try_into().map_err(|_| InvalidData)?); bug!("invalid payload data")
let _ = payload_data.insert(decoded_data[32..].to_vec()); );
} else { let _ =
pm.prompt_message(PromptMessage::Text(QRCODE_ERROR.to_string()))?; pubkey_data.insert(decoded_data[..32].try_into().map_err(|_| InvalidData)?);
}; let _ = payload_data.insert(decoded_data[32..].to_vec());
} else {
let choice = keyfork_prompt::prompt_choice(
&mut *pm,
"A QR code could not be scanned. Retry or continue?",
&[RetryScanMnemonic::Retry, RetryScanMnemonic::Continue],
)?;
if choice == RetryScanMnemonic::Continue {
break;
}
};
}
} }
let (pubkey, payload) = match (pubkey_data, payload_data) { let (pubkey, payload) = match (pubkey_data, payload_data) {
@ -550,7 +599,7 @@ pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Erro
}; };
let [pubkey_mnemonic, payload_mnemonic] = prompt_validated_wordlist::<English, _>( let [pubkey_mnemonic, payload_mnemonic] = prompt_validated_wordlist::<English, _>(
&mut pm, &mut *pm,
QRCODE_COULDNT_READ, QRCODE_COULDNT_READ,
3, 3,
&*validator.to_fn(), &*validator.to_fn(),

View File

@ -1,6 +1,6 @@
[package] [package]
name = "keyfork" name = "keyfork"
version = "0.2.6" version = "0.3.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
@ -48,3 +48,5 @@ sequoia-openpgp = { workspace = true }
keyforkd-models.workspace = true keyforkd-models.workspace = true
base64.workspace = true base64.workspace = true
nix = { version = "0.29.0", default-features = false, features = ["process"] } nix = { version = "0.29.0", default-features = false, features = ["process"] }
shlex = "1.3.0"
tempfile.workspace = true

View File

@ -2,20 +2,6 @@
use std::{collections::HashMap, str::FromStr}; use std::{collections::HashMap, str::FromStr};
/// A helper struct for clap arguments that can contain additional arguments. For example:
/// `keyfork mnemonic generate --encrypt-to cert.asc,output=encrypted.asc`.
#[derive(Clone, Debug)]
pub struct ValueWithOptions<T: FromStr>
where
T::Err: std::error::Error,
{
/// A mapping between keys and values.
pub values: HashMap<String, String>,
/// The first variable for the argument, such as a [`PathBuf`].
pub inner: T,
}
/// An error that occurred while parsing a base value or its /// An error that occurred while parsing a base value or its
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ValueParseError { pub enum ValueParseError {
@ -32,6 +18,62 @@ pub enum ValueParseError {
BadKeyValue, BadKeyValue,
} }
/// A helper struct to parse key-value arguments, without any prior argument.
#[derive(Clone, Debug, Default)]
pub struct Options {
/// The values provided.
pub values: HashMap<String, String>,
}
impl std::fmt::Display for Options {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut iter = self.values.iter().peekable();
while let Some((key, value)) = iter.next() {
write!(f, "{key}={value}")?;
if iter.peek().is_some() {
write!(f, ",")?;
}
}
Ok(())
}
}
impl FromStr for Options {
type Err = ValueParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Ok(Default::default())
}
let values = s
.split(',')
.map(|value| {
let [k, v] = value
.splitn(2, '=')
.collect::<Vec<_>>()
.try_into()
.map_err(|_| ValueParseError::BadKeyValue)?;
Ok((k.to_string(), v.to_string()))
})
.collect::<Result<HashMap<String, String>, ValueParseError>>()?;
Ok(Self { values })
}
}
/// A helper struct for clap arguments that can contain additional arguments. For example:
/// `keyfork mnemonic generate --encrypt-to cert.asc,output=encrypted.asc`.
#[derive(Clone, Debug)]
pub struct ValueWithOptions<T: FromStr>
where
T::Err: std::error::Error,
{
/// A mapping between keys and values.
pub values: HashMap<String, String>,
/// The first variable for the argument, such as a [`PathBuf`].
pub inner: T,
}
impl<T: std::str::FromStr> FromStr for ValueWithOptions<T> impl<T: std::str::FromStr> FromStr for ValueWithOptions<T>
where where
<T as FromStr>::Err: std::error::Error, <T as FromStr>::Err: std::error::Error,

View File

@ -1,25 +1,36 @@
use super::Keyfork; use super::{Keyfork, create};
use clap::{Args, Parser, Subcommand, ValueEnum}; use clap::{Args, Parser, Subcommand, ValueEnum};
use std::{fmt::Display, io::Write, path::PathBuf};
use keyfork_derive_openpgp::{ use keyfork_derive_openpgp::openpgp::{
openpgp::{ armor::{Kind, Writer},
armor::{Kind, Writer}, packet::UserID,
packet::UserID, serialize::Marshal,
serialize::Marshal, types::KeyFlags,
types::KeyFlags, Cert,
},
XPrvKey,
}; };
use keyfork_derive_path_data::paths; use keyfork_derive_path_data::paths;
use keyfork_derive_util::{ use keyfork_derive_util::{
request::{DerivationAlgorithm, DerivationRequest, DerivationResponse}, request::DerivationAlgorithm, DerivationIndex, DerivationPath, ExtendedPrivateKey as XPrv,
DerivationIndex, DerivationPath, IndexError, IndexError, PrivateKey,
}; };
use keyforkd_client::Client; use keyforkd_client::Client;
use keyforkd_models::Request;
type OptWrite = Option<Box<dyn Write>>;
type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>; type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
pub trait Deriver {
type Prv: PrivateKey + Clone;
const DERIVATION_ALGORITHM: DerivationAlgorithm;
fn derivation_path(&self) -> DerivationPath;
fn derive_with_xprv(&self, writer: OptWrite, xprv: XPrv<Self::Prv>) -> Result<()>;
fn derive_public_with_xprv(&self, writer: OptWrite, xprv: XPrv<Self::Prv>) -> Result<()>;
}
#[derive(Subcommand, Clone, Debug)] #[derive(Subcommand, Clone, Debug)]
pub enum DeriveSubcommands { pub enum DeriveSubcommands {
/// Derive an OpenPGP Transferable Secret Key (private key). The key is encoded using OpenPGP /// Derive an OpenPGP Transferable Secret Key (private key). The key is encoded using OpenPGP
@ -34,14 +45,51 @@ pub enum DeriveSubcommands {
#[command(name = "openpgp")] #[command(name = "openpgp")]
OpenPGP(OpenPGP), OpenPGP(OpenPGP),
/// Derive a bare key for a specific algorithm, in a given format. /// Derive an Ed25519 key for a specific algorithm, in a given format.
Key(Key), Key(Key),
} }
/// Derivation path to use when deriving OpenPGP keys.
#[derive(ValueEnum, Clone, Debug, Default)]
pub enum Path {
/// The default derivation path; no additional index is used.
#[default]
Default,
/// The Disaster Recovery index.
DisasterRecovery,
}
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Path {
fn as_str(&self) -> &'static str {
match self {
Path::Default => "default",
Path::DisasterRecovery => "disaster-recovery",
}
}
fn derivation_path(&self) -> DerivationPath {
match self {
Self::Default => paths::OPENPGP.clone(),
Self::DisasterRecovery => paths::OPENPGP_DISASTER_RECOVERY.clone(),
}
}
}
#[derive(Args, Clone, Debug)] #[derive(Args, Clone, Debug)]
pub struct OpenPGP { pub struct OpenPGP {
/// Default User ID for the certificate, using the OpenPGP User ID format. /// Default User ID for the certificate, using the OpenPGP User ID format.
user_id: String, user_id: String,
/// Derivation path to use when deriving OpenPGP keys.
#[arg(long, required = false, default_value = "default")]
derivation_path: Path,
} }
/// A format for exporting a key. /// A format for exporting a key.
@ -83,6 +131,18 @@ impl std::str::FromStr for Slug {
} }
} }
impl Display for Slug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.0.inner() & (0b1 << 31)).to_be_bytes().as_slice() {
[0, 0, 0, 0] => Ok(()),
[0, 0, 0, bytes @ ..] | [0, 0, bytes @ ..] | [0, bytes @ ..] | [bytes @ ..] => f
.write_str(
std::str::from_utf8(&bytes[..]).expect("slug constructed from non-utf8"),
),
}
}
}
#[derive(Args, Clone, Debug)] #[derive(Args, Clone, Debug)]
pub struct Key { pub struct Key {
/// The derivation algorithm to derive a key for. /// The derivation algorithm to derive a key for.
@ -98,18 +158,34 @@ pub struct Key {
} }
impl DeriveSubcommands { impl DeriveSubcommands {
fn handle(&self, account: DerivationIndex) -> Result<()> { fn handle(&self, account: DerivationIndex, is_public: bool, writer: OptWrite) -> Result<()> {
match self { match self {
DeriveSubcommands::OpenPGP(opgp) => opgp.handle(account), DeriveSubcommands::OpenPGP(opgp) => {
DeriveSubcommands::Key(key) => key.handle(account), let path = opgp.derivation_path();
let xprv = Client::discover_socket()?
.request_xprv::<<OpenPGP as Deriver>::Prv>(&path.chain_push(account))?;
if is_public {
opgp.derive_public_with_xprv(writer, xprv)
} else {
opgp.derive_with_xprv(writer, xprv)
}
}
DeriveSubcommands::Key(key) => {
let path = key.derivation_path();
let xprv = Client::discover_socket()?
.request_xprv::<<Key as Deriver>::Prv>(&path.chain_push(account))?;
if is_public {
key.derive_public_with_xprv(writer, xprv)
} else {
key.derive_with_xprv(writer, xprv)
}
}
} }
} }
} }
impl OpenPGP { impl OpenPGP {
pub fn handle(&self, account: DerivationIndex) -> Result<()> { fn cert_from_xprv(&self, xprv: keyfork_derive_openpgp::XPrv) -> Result<Cert> {
let path = paths::OPENPGP.clone().chain_push(account);
// TODO: should this be customizable?
let subkeys = vec![ let subkeys = vec![
KeyFlags::empty().set_certification(), KeyFlags::empty().set_certification(),
KeyFlags::empty().set_signing(), KeyFlags::empty().set_signing(),
@ -118,40 +194,100 @@ impl OpenPGP {
.set_storage_encryption(), .set_storage_encryption(),
KeyFlags::empty().set_authentication(), KeyFlags::empty().set_authentication(),
]; ];
let xprv = Client::discover_socket()?.request_xprv::<XPrvKey>(&path)?;
let default_userid = UserID::from(self.user_id.as_str());
let cert = keyfork_derive_openpgp::derive(xprv, &subkeys, &default_userid)?;
let mut w = Writer::new(std::io::stdout(), Kind::SecretKey)?; let userid = UserID::from(&*self.user_id);
keyfork_derive_openpgp::derive(xprv, &subkeys, &userid).map_err(Into::into)
}
}
impl Deriver for OpenPGP {
type Prv = keyfork_derive_openpgp::XPrvKey;
const DERIVATION_ALGORITHM: DerivationAlgorithm = DerivationAlgorithm::Ed25519;
fn derivation_path(&self) -> DerivationPath {
self.derivation_path.derivation_path()
}
fn derive_with_xprv(&self, writer: OptWrite, xprv: XPrv<Self::Prv>) -> Result<()> {
let cert = self.cert_from_xprv(xprv)?;
let writer = match writer {
Some(w) => w,
None => {
let path = PathBuf::from(cert.fingerprint().to_string()).with_extension("asc");
let file = create(&path)?;
Box::new(file)
}
};
let mut writer = Writer::new(writer, Kind::SecretKey)?;
for packet in cert.as_tsk().into_packets() { for packet in cert.as_tsk().into_packets() {
packet.serialize(&mut w)?; packet.serialize(&mut writer)?;
} }
writer.finalize()?;
Ok(())
}
w.finalize()?; fn derive_public_with_xprv(&self, writer: OptWrite, xprv: XPrv<Self::Prv>) -> Result<()> {
let cert = self.cert_from_xprv(xprv)?;
let writer = match writer {
Some(w) => w,
None => {
let path = PathBuf::from(cert.fingerprint().to_string()).with_extension("asc");
let file = create(&path)?;
Box::new(file)
}
};
let mut writer = Writer::new(writer, Kind::PublicKey)?;
for packet in cert.into_packets2() {
packet.serialize(&mut writer)?;
}
writer.finalize()?;
Ok(()) Ok(())
} }
} }
impl Key { impl Deriver for Key {
pub fn handle(&self, account: DerivationIndex) -> Result<()> { // HACK: We're abusing that we use the same key as OpenPGP. Maybe we should use ed25519_dalek.
let mut client = keyforkd_client::Client::discover_socket()?; type Prv = keyfork_derive_openpgp::XPrvKey;
let path = DerivationPath::default() const DERIVATION_ALGORITHM: DerivationAlgorithm = DerivationAlgorithm::Ed25519;
.chain_push(self.slug.0.clone())
.chain_push(account);
let request = DerivationRequest::new(self.derivation_algorithm.clone(), &path);
let request = Request::Derivation(request);
let derived_key: DerivationResponse = client.request(&request)?.try_into()?;
let formatted = match self.format { fn derivation_path(&self) -> DerivationPath {
KeyFormat::Hex => smex::encode(derived_key.data), DerivationPath::default().chain_push(self.slug.0.clone())
}
fn derive_with_xprv(&self, writer: OptWrite, xprv: XPrv<Self::Prv>) -> Result<()> {
let (formatted, ext) = match self.format {
KeyFormat::Hex => (smex::encode(xprv.private_key().to_bytes()), "hex"),
KeyFormat::Base64 => { KeyFormat::Base64 => {
use base64::prelude::*; use base64::prelude::*;
BASE64_STANDARD.encode(derived_key.data) (BASE64_STANDARD.encode(xprv.private_key().to_bytes()), "b64")
} }
}; };
let filename =
PathBuf::from(smex::encode(xprv.public_key().to_bytes())).with_extension(ext);
if let Some(mut writer) = writer {
writeln!(writer, "{formatted}")?;
} else {
std::fs::write(&filename, formatted)?;
}
eprintln!("{formatted}"); Ok(())
}
fn derive_public_with_xprv(&self, writer: OptWrite, xprv: XPrv<Self::Prv>) -> Result<()> {
let (formatted, ext) = match self.format {
KeyFormat::Hex => (smex::encode(xprv.public_key().to_bytes()), "hex"),
KeyFormat::Base64 => {
use base64::prelude::*;
(BASE64_STANDARD.encode(xprv.public_key().to_bytes()), "b64")
}
};
let filename =
PathBuf::from(smex::encode(xprv.public_key().to_bytes())).with_extension(ext);
if let Some(mut writer) = writer {
writeln!(writer, "{formatted}")?;
} else {
std::fs::write(&filename, formatted)?;
}
Ok(()) Ok(())
} }
} }
@ -159,7 +295,7 @@ impl Key {
#[derive(Parser, Debug, Clone)] #[derive(Parser, Debug, Clone)]
pub struct Derive { pub struct Derive {
#[command(subcommand)] #[command(subcommand)]
command: DeriveSubcommands, pub(crate) command: DeriveSubcommands,
/// Account ID. Required for all derivations. /// Account ID. Required for all derivations.
/// ///
@ -167,12 +303,45 @@ pub struct Derive {
/// account ID can often come as a hindrance in the future. As such, it is always required. If /// account ID can often come as a hindrance in the future. As such, it is always required. If
/// the account ID is not relevant, it is assumed to be `0`. /// the account ID is not relevant, it is assumed to be `0`.
#[arg(long, global = true, default_value = "0")] #[arg(long, global = true, default_value = "0")]
account_id: u32, pub(crate) account_id: u32,
/// Whether derivation should return the public key or a private key.
#[arg(long, global = true)]
pub(crate) public: bool,
/// Whether the file should be written to standard output, or to a filename generated by the
/// derivation system.
#[arg(long, global = true, default_value = "false")]
pub to_stdout: bool,
/// The file to write the derived public key to, if not standard output. If omitted, a filename
/// will be generated by the relevant deriver.
#[arg(long, global = true, conflicts_with = "to_stdout")]
pub output: Option<PathBuf>,
} }
impl Derive { impl Derive {
pub fn handle(&self, _k: &Keyfork) -> Result<()> { pub fn handle(&self, _k: &Keyfork) -> Result<()> {
let account = DerivationIndex::new(self.account_id, true)?; let account = DerivationIndex::new(self.account_id, true)?;
self.command.handle(account) let writer = if let Some(output) = self.output.as_deref() {
Some(Box::new(std::fs::File::create(output)?) as Box<dyn Write>)
} else if self.to_stdout {
Some(Box::new(std::io::stdout()) as Box<dyn Write>)
} else {
None
};
self.command.handle(account, self.public, writer)
}
}
impl std::str::FromStr for Derive {
type Err = clap::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Derive::try_parse_from(
[String::from("derive")]
.into_iter()
.chain(shlex::Shlex::new(s)),
)
} }
} }

View File

@ -1,12 +1,17 @@
use super::provision; use super::{
use super::Keyfork; create,
use crate::{clap_ext::*, config}; derive::{self, Deriver},
provision,
Keyfork,
};
use crate::{clap_ext::*, config, openpgp_card::factory_reset_current_card};
use card_backend_pcsc::PcscBackend;
use clap::{builder::PossibleValue, Parser, Subcommand, ValueEnum}; use clap::{builder::PossibleValue, Parser, Subcommand, ValueEnum};
use std::{ use std::{
collections::HashMap, collections::HashMap,
fmt::Display, fmt::Display,
fs::File, fs::File,
io::Write, io::{IsTerminal, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
}; };
@ -15,17 +20,21 @@ use keyfork_derive_openpgp::{
openpgp::{ openpgp::{
self, self,
armor::{Kind, Writer}, armor::{Kind, Writer},
packet::UserID, packet::{UserID, signature::SignatureBuilder},
policy::StandardPolicy, policy::StandardPolicy,
serialize::{ serialize::{
stream::{Encryptor2, LiteralWriter, Message, Recipient}, stream::{Encryptor2, LiteralWriter, Message, Recipient},
Serialize, Serialize,
}, },
types::KeyFlags, types::{KeyFlags, SignatureType},
}, },
XPrv, XPrv,
}; };
use keyfork_prompt::default_handler; use keyfork_derive_util::DerivationIndex;
use keyfork_prompt::{
default_handler, prompt_validated_passphrase,
validators::{SecurePinValidator, Validator},
};
use keyfork_shard::{openpgp::OpenPGP, Format}; use keyfork_shard::{openpgp::OpenPGP, Format};
type StringMap = HashMap<String, String>; type StringMap = HashMap<String, String>;
@ -87,6 +96,7 @@ impl From<&SeedSize> for usize {
} }
} }
} }
#[derive(Clone, Debug, thiserror::Error)] #[derive(Clone, Debug, thiserror::Error)]
pub enum MnemonicSeedSourceParseError { pub enum MnemonicSeedSourceParseError {
#[error("Expected one of system, playing, tarot, dice")] #[error("Expected one of system, playing, tarot, dice")]
@ -143,6 +153,22 @@ impl MnemonicSeedSource {
} }
} }
/// An error occurred while performing an operation.
#[derive(thiserror::Error, Debug)]
pub enum Error {
/// An error occurred when interacting iwth a file.
#[error("Error while performing IO operation on: {1}")]
IOContext(#[source] std::io::Error, PathBuf),
/// A required option was not provided.
#[error("The required option {0} was not provided")]
MissingOption(&'static str),
}
fn context_stub<'a>(path: &'a Path) -> impl Fn(std::io::Error) -> Error + 'a {
|e| Error::IOContext(e, path.to_path_buf())
}
#[derive(Subcommand, Clone, Debug)] #[derive(Subcommand, Clone, Debug)]
pub enum MnemonicSubcommands { pub enum MnemonicSubcommands {
/// Generate a mnemonic using a given entropy source. /// Generate a mnemonic using a given entropy source.
@ -166,6 +192,19 @@ pub enum MnemonicSubcommands {
#[arg(long, default_value_t = Default::default())] #[arg(long, default_value_t = Default::default())]
size: SeedSize, size: SeedSize,
/// Derive a key. By default, a private key is derived. Unlike other arguments in this
/// file, arguments must be passed using the format similar to the CLI. For example:
/// `--derive='openpgp --public "Ryan Heywood <ryan@distrust.co>"'` would be synonymous
/// with starting the Keyfork daemon with the provided mnemonic, then running
/// `keyfork derive openpgp --public "Ryan Heywood <ryan@distrust.co>"`.
///
/// The output of the derived key is written to a filename based on the content of the key;
/// for instance, OpenPGP keys are written to a file identifiable by the certificate's
/// fingerprint. This behavior can be changed by using the `--to-stdout` or `--output`
/// modifiers to the `--derive` command.
#[arg(long)]
derive: Option<derive::Derive>,
/// Encrypt the mnemonic to an OpenPGP certificate in the provided path. /// Encrypt the mnemonic to an OpenPGP certificate in the provided path.
/// ///
/// When given arguments in the format `--encrypt-to input.asc,output=output.asc`, the /// When given arguments in the format `--encrypt-to input.asc,output=output.asc`, the
@ -178,7 +217,7 @@ pub enum MnemonicSubcommands {
/// Shard the mnemonic to the certificates in the given Shardfile. Requires a decrypt /// Shard the mnemonic to the certificates in the given Shardfile. Requires a decrypt
/// operation on the Shardfile to access the metadata and certificates. /// operation on the Shardfile to access the metadata and certificates.
/// ///
/// When given arguments in the format `--encrypt-to input.asc,output=output.asc`, the /// When given arguments in the format `--shard-to input.asc,output=output.asc`, the
/// output of the encryption will be written to `output.asc`. Otherwise, the default /// output of the encryption will be written to `output.asc`. Otherwise, the default
/// behavior is to write the output to `input.new.asc`. If the output file already exists, /// behavior is to write the output to `input.new.asc`. If the output file already exists,
/// it will not be overwritten, and the command will exit unsuccessfully. /// it will not be overwritten, and the command will exit unsuccessfully.
@ -203,33 +242,56 @@ pub enum MnemonicSubcommands {
/// Encrypt the mnemonic to an OpenPGP certificate derived from the mnemonic, writing the /// Encrypt the mnemonic to an OpenPGP certificate derived from the mnemonic, writing the
/// output to the provided path. This command must be run in combination with /// output to the provided path. This command must be run in combination with
/// `--provision openpgp-card` or another relevant provisioner, to ensure the newly /// `--provision openpgp-card`, `--derive openpgp`, or another OpenPGP key derivation
/// generated mnemonic would be decryptable by some form of provisioned hardware. /// mechanism, to ensure the generated mnemonic would be decryptable.
/// ///
/// When given arguments in the format `--encrypt-to-self output.asc,output=encrypted.asc`, /// When used in combination with `--derive` or `--provision` with OpenPGP configurations,
/// the output of the OpenPGP certificate will be written to `output.asc`, while the output /// the default behavior is to encrypt the mnemonic to all derived and provisioned
/// of the encryption will be written to `encrypted.asc`. Otherwise, the /// accounts. By default, the account `0` is used.
/// default behavior is to write the encrypted mnemonic to `output.enc.asc`. If either
/// output file already exists, it will not be overwritten, and the command will exit
/// unsuccessfully.
///
/// Additionally, when given the `account=` option (which must match the `account=` option
/// of the relevant provisioner), the given account will be used instead of the default
/// account of 0.
///
/// Because a new OpenPGP key needs to be created, a User ID can also be supplied, using
/// the option `userid=<your User ID>`. It can contain any characters that are not a comma.
#[arg(long)] #[arg(long)]
encrypt_to_self: Option<ValueWithOptions<PathBuf>>, encrypt_to_self: Option<PathBuf>,
/// Shard the mnemonic to freshly-generated OpenPGP certificates derived from the mnemonic,
/// writing the output to the provided path, and provisioning OpenPGP smartcards with the
/// new certificates.
///
/// The following additional arguments are required:
///
/// * threshold, m: the minimum amount of shares required to reconstitute the shard.
///
/// * max, n: the maximum amount of shares.
///
/// * cards_per_shard: the amount of OpenPGP smartcards to provision per shardholder.
///
/// * cert_output: the file to write all generated OpenPGP certificates to; if not
/// provided, files will be automatically generated for each certificate.
#[arg(long)]
shard_to_self: Option<ValueWithOptions<PathBuf>>,
/// Provision a key derived from the mnemonic to a piece of hardware such as an OpenPGP /// Provision a key derived from the mnemonic to a piece of hardware such as an OpenPGP
/// smartcard. This argument is required when used with `--encrypt-to-self`. /// smartcard. This argument is required when used with `--encrypt-to-self`.
/// ///
/// Additional arguments, such as the amount of hardware to provision and the /// Provisioners may choose to output a public key to the current directory by default, but
/// account to use when deriving, can be specified by using (for example) /// this functionality may be altered on a by-provisioner basis by providing the `output=`
/// `--provision openpgp-card,count=2,account=1`. /// option to `--provisioner-config`. Additionally, Keyfork may choose to disable
/// provisioner output if a matching public key has been derived using `--derive`, which
/// may allow for controlling additional metadata that is not relevant to the provisioned
/// keys, such as an OpenPGP User ID.
#[arg(long)] #[arg(long)]
provision: Option<ValueWithOptions<provision::Provisioner>>, provision: Option<provision::Provision>,
/// The amount of times the provisioner should be run. If provisioning multiple devices at
/// once, this number should be specified to the number of devices, and all devices should
/// be plugged into the system at the same time.
#[arg(long, requires = "provision", default_value = "1")]
provision_count: usize,
/// The configuration to pass to the provisioner. These values are specific to each
/// provisioner, and should be provided in a `key=value,key=value` format. Most
/// provisioners only expect an `output=` option, to be used in place of the default output
/// path, if the provisioner needs to write data to a file, such as an OpenPGP certificate.
#[arg(long, requires = "provision", default_value_t = Options::default())]
provision_config: Options,
}, },
} }
@ -296,7 +358,7 @@ fn do_encrypt_to(
literal_message.write_all(b"\n")?; literal_message.write_all(b"\n")?;
literal_message.finalize()?; literal_message.finalize()?;
let mut file = File::create_new(&output_file)?; let mut file = File::create(&output_file).map_err(context_stub(&output_file))?;
if is_armored { if is_armored {
let mut writer = Writer::new(file, Kind::Message)?; let mut writer = Writer::new(file, Kind::Message)?;
writer.write_all(&output)?; writer.write_all(&output)?;
@ -311,64 +373,53 @@ fn do_encrypt_to(
fn do_encrypt_to_self( fn do_encrypt_to_self(
mnemonic: &keyfork_mnemonic::Mnemonic, mnemonic: &keyfork_mnemonic::Mnemonic,
path: &Path, path: &Path,
options: &StringMap, accounts: &[keyfork_derive_util::DerivationIndex],
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let output_file = determine_valid_output_path(path, "enc", options.get("output")); let mut certs = vec![];
let is_armored = for account in accounts.iter().cloned() {
options.get("armor").is_some_and(|a| a == "true") || is_extension_armored(&output_file); let userid = UserID::from("Keyfork Temporary Key");
let account = options let subkeys = [
.get("account") KeyFlags::empty().set_certification(),
.map(|account| u32::from_str(account)) KeyFlags::empty().set_signing(),
.transpose()? KeyFlags::empty()
.unwrap_or(0); .set_transport_encryption()
let account_index = keyfork_derive_util::DerivationIndex::new(account, true)?; .set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
let userid = options let seed = mnemonic.generate_seed(None);
.get("userid") let xprv = XPrv::new(seed)?;
.map(|userid| UserID::from(userid.as_str())); let derivation_path = keyfork_derive_path_data::paths::OPENPGP
.clone()
.chain_push(account);
let subkeys = [ let cert =
KeyFlags::empty().set_certification(), keyfork_derive_openpgp::derive(xprv.derive_path(&derivation_path)?, &subkeys, &userid)?;
KeyFlags::empty().set_signing(),
KeyFlags::empty()
.set_transport_encryption()
.set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
let seed = mnemonic.generate_seed(None); certs.push(cert);
let xprv = XPrv::new(seed)?;
let derivation_path = keyfork_derive_path_data::paths::OPENPGP
.clone()
.chain_push(account_index);
let cert = keyfork_derive_openpgp::derive(
xprv.derive_path(&derivation_path)?,
&subkeys,
&userid.unwrap_or(UserID::from("Keyfork-Generated Key")),
)?;
let mut file = File::create_new(path)?;
if is_armored {
let mut writer = Writer::new(file, Kind::PublicKey)?;
cert.serialize(&mut writer)?;
writer.finalize()?;
} else {
cert.serialize(&mut file)?;
} }
let mut file = tempfile::NamedTempFile::new()?;
let mut writer = Writer::new(&mut file, Kind::PublicKey)?;
for cert in certs {
cert.serialize(&mut writer)?;
}
writer.finalize()?;
let temp_path = file.into_temp_path();
// a sneaky bit of DRY // a sneaky bit of DRY
do_encrypt_to( do_encrypt_to(
mnemonic, mnemonic,
path, &temp_path,
&StringMap::from([( &StringMap::from([(String::from("output"), path.to_string_lossy().to_string())]),
String::from("output"),
output_file.to_string_lossy().to_string(),
)]),
)?; )?;
temp_path.close()?;
Ok(()) Ok(())
} }
@ -421,7 +472,7 @@ fn do_shard(
let mut output = vec![]; let mut output = vec![];
openpgp.shard_and_encrypt(threshold, max, mnemonic.as_bytes(), &certs[..], &mut output)?; openpgp.shard_and_encrypt(threshold, max, mnemonic.as_bytes(), &certs[..], &mut output)?;
let mut file = File::create_new(&output_file)?; let mut file = File::create(&output_file).map_err(context_stub(&output_file))?;
if is_armored { if is_armored {
file.write_all(&output)?; file.write_all(&output)?;
} else { } else {
@ -466,7 +517,7 @@ fn do_shard_to(
&mut output, &mut output,
)?; )?;
let mut file = File::create_new(&output_file)?; let mut file = File::create(&output_file).map_err(context_stub(&output_file))?;
if is_armored { if is_armored {
file.write_all(&output)?; file.write_all(&output)?;
} else { } else {
@ -482,46 +533,274 @@ fn do_shard_to(
Ok(()) Ok(())
} }
#[derive(thiserror::Error, Debug)] fn derive_key(seed: [u8; 64], index: u8) -> Result<openpgp::Cert, Box<dyn std::error::Error>> {
#[error("missing key: {0}")] let subkeys = vec![
struct MissingKey(&'static str); KeyFlags::empty().set_certification(),
KeyFlags::empty().set_signing(),
KeyFlags::empty()
.set_transport_encryption()
.set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
let subkey = DerivationIndex::new(u32::from(index), true)?;
let path = keyfork_derive_path_data::paths::OPENPGP_SHARD.clone().chain_push(subkey);
let xprv = XPrv::new(seed)
.expect("could not construct master key from seed")
.derive_path(&path)?;
let userid = UserID::from(format!("Keyfork Shard {index}"));
let cert = keyfork_derive_openpgp::derive(xprv, &subkeys, &userid)?;
Ok(cert)
}
fn cross_sign_certs(certs: &mut [openpgp::Cert]) -> Result<(), Box<dyn std::error::Error>> {
let policy = StandardPolicy::new();
#[allow(clippy::unnecessary_to_owned)]
for signing_cert in certs.to_vec() {
let mut certify_key = signing_cert
.with_policy(&policy, None)?
.keys()
.unencrypted_secret()
.for_certification()
.next()
.expect("certify key unusable/not found")
.key()
.clone()
.into_keypair()?;
for signable_cert in certs.iter_mut() {
let sb = SignatureBuilder::new(SignatureType::GenericCertification);
let userid = signable_cert
.userids()
.next()
.expect("a signable user ID is necessary to create web of trust");
let signature = sb.sign_userid_binding(
&mut certify_key,
signable_cert.primary_key().key(),
&userid,
)?;
let changed;
(*signable_cert, changed) = signable_cert.clone().insert_packets2(signature)?;
assert!(
changed,
"OpenPGP certificate was unchanged after inserting packets"
);
}
}
Ok(())
}
fn do_shard_to_self(
mnemonic: &keyfork_mnemonic::Mnemonic,
path: &Path,
options: &StringMap,
) -> Result<(), Box<dyn std::error::Error>> {
let seed = mnemonic.generate_seed(None);
let mut pm = default_handler()?;
let mut certs = vec![];
let mut seen_cards = std::collections::HashSet::new();
let threshold: u8 = options
.get("threshold")
.or(options.get("m"))
.ok_or(Error::MissingOption("threshold"))?
.parse()?;
let max: u8 = options
.get("max")
.or(options.get("n"))
.ok_or(Error::MissingOption("max"))?
.parse()?;
let cards_per_shard = options
.get("cards_per_shard")
.as_deref()
.map(|cps| u8::from_str(cps))
.transpose()?;
let pin_validator = SecurePinValidator {
min_length: Some(8),
..Default::default()
}
.to_fn();
for index in 0..max {
let cert = derive_key(seed, index)?;
for i in 0..cards_per_shard.unwrap_or(1) {
pm.prompt_message(keyfork_prompt::Message::Text(format!(
"Please remove all keys and insert key #{} for user #{}",
(i as u16) + 1,
(index as u16) + 1,
)))?;
let card_backend = loop {
if let Some(c) = PcscBackend::cards(None)?.next().transpose()? {
break c;
}
pm.prompt_message(keyfork_prompt::Message::Text(
"No smart card was found. Please plug in a smart card and press enter"
.to_string(),
))?;
};
let pin = prompt_validated_passphrase(
&mut *pm,
"Please enter the new smartcard PIN: ",
3,
&pin_validator,
)?;
factory_reset_current_card(
&mut |application_identifier| {
if seen_cards.contains(&application_identifier) {
// we were given a previously-seen card, error
// we're gonna panic because this is a significant error
panic!("Previously used card {application_identifier} was reused");
} else {
seen_cards.insert(application_identifier);
true
}
},
pin.trim(),
pin.trim(),
&cert,
&openpgp::policy::NullPolicy::new(),
card_backend,
)?;
}
certs.push(cert);
}
cross_sign_certs(&mut certs)?;
let opgp = OpenPGP;
let output = File::create(path)?;
opgp.shard_and_encrypt(
threshold,
certs.len() as u8,
mnemonic.as_bytes(),
&certs[..],
output,
)?;
match options.get("cert_output") {
Some(path) => {
let cert_file = std::fs::File::create(path)?;
let mut writer = Writer::new(cert_file, Kind::PublicKey)?;
for cert in &certs {
cert.serialize(&mut writer)?;
}
writer.finalize()?;
}
None => {
for cert in &certs {
let path = PathBuf::from(cert.fingerprint().to_string()).with_extension("asc");
let file = create(&path)?;
let mut writer = Writer::new(file, Kind::PublicKey)?;
cert.serialize(&mut writer)?;
writer.finalize()?;
}
}
}
Ok(())
}
fn do_provision( fn do_provision(
mnemonic: &keyfork_mnemonic::Mnemonic, mnemonic: &keyfork_mnemonic::Mnemonic,
provisioner: &provision::Provisioner, provision: &provision::Provision,
options: &StringMap, count: usize,
config: &HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut options = options.clone(); assert!(
let account = options provision.subcommand.is_none(),
.remove("account") "provisioner was given a subcommand; this functionality is not supported"
.map(|account| u32::from_str(&account)) );
.transpose()?
.unwrap_or(0);
let identifier = options
.remove("identifier")
.ok_or(MissingKey("identifier"))?
.split(',')
.map(String::from)
.collect::<Vec<_>>();
let count = options
.remove("count")
.map(|count| usize::from_str(&count))
.transpose()?
.unwrap_or(identifier.len());
for (_, identifier) in (0..count).zip(identifier.into_iter()) { let identifiers = match &provision.identifier {
let provisioner_config = config::Provisioner { Some(identifier) => {
account, vec![identifier.clone()]
identifier, }
metadata: Some(options.clone()), None => provision
.provisioner_name
.discover()?
.into_iter()
.map(|(name, _ctx)| name)
.collect(),
};
assert_eq!(
identifiers.len(),
count,
"amount of provisionable devices discovered did not match provisioner count"
);
for identifier in identifiers {
let provisioner_with_identifier = provision::Provision {
identifier: Some(identifier),
..provision.clone()
}; };
let mut provisioner = config::Provisioner::try_from(provisioner_with_identifier)?;
provisioner.provision_with_mnemonic(mnemonic, provisioner_config.clone())?; match &mut provisioner.metadata {
Some(metadata) => {
metadata.extend(config.clone().into_iter());
}
metadata @ None => {
*metadata = Some(config.clone());
}
};
provision
.provisioner_name
.provision_with_mnemonic(mnemonic, provisioner)?;
} }
Ok(()) Ok(())
} }
fn do_derive(
mnemonic: &keyfork_mnemonic::MnemonicBase<keyfork_mnemonic::English>,
deriver: &derive::Derive,
) -> Result<(), Box<dyn std::error::Error>> {
let writer = if let Some(output) = deriver.output.as_deref() {
Some(Box::new(std::fs::File::create(output)?) as Box<dyn Write>)
} else if deriver.to_stdout {
Some(Box::new(std::io::stdout()) as Box<dyn Write>)
} else {
None
};
match deriver {
derive::Derive {
command: derive::DeriveSubcommands::OpenPGP(opgp),
account_id,
public,
..
} => {
use keyfork_derive_openpgp::XPrv;
let root_xprv = XPrv::new(mnemonic.generate_seed(None))?;
let account = DerivationIndex::new(*account_id, true)?;
let derived = root_xprv.derive_path(&opgp.derivation_path().chain_push(account))?;
if *public {
opgp.derive_public_with_xprv(writer, derived)?;
} else {
opgp.derive_with_xprv(writer, derived)?;
}
}
derive::Derive {
command: derive::DeriveSubcommands::Key(key),
account_id,
public,
..
} => {
// HACK: We're abusing that we use the same key as OpenPGP. Maybe
// we should use ed25519_dalek.
use keyfork_derive_openpgp::XPrv;
let root_xprv = XPrv::new(mnemonic.generate_seed(None))?;
let account = DerivationIndex::new(*account_id, true)?;
let derived = root_xprv.derive_path(&key.derivation_path().chain_push(account))?;
if *public {
key.derive_public_with_xprv(writer, derived)?;
} else {
key.derive_with_xprv(writer, derived)?;
}
}
}
Ok(())
}
impl MnemonicSubcommands { impl MnemonicSubcommands {
pub fn handle( pub fn handle(
&self, &self,
@ -532,25 +811,47 @@ impl MnemonicSubcommands {
MnemonicSubcommands::Generate { MnemonicSubcommands::Generate {
source, source,
size, size,
derive,
encrypt_to, encrypt_to,
shard_to, shard_to,
shard, shard,
encrypt_to_self, encrypt_to_self,
shard_to_self,
provision, provision,
provision_count,
provision_config,
} => { } => {
// NOTE: We should never have a case where there's Some() of empty vec, but // NOTE: We should never have a case where there's Some() of empty vec, but
// we will make sure to check it just in case. // we will make sure to check it just in case.
//
// We do not print the mnemonic if we are:
// * Encrypting to an existing, usable key
// * Encrypting to a newly provisioned key
// * Sharding to an existing Shardfile with usable keys
// * Sharding to existing, usable keys
// * Sharding to newly provisioned keys
let mut will_print_mnemonic = let mut will_print_mnemonic =
encrypt_to.is_none() || encrypt_to.as_ref().is_some_and(|e| e.is_empty()); encrypt_to.is_none() || encrypt_to.as_ref().is_some_and(|e| e.is_empty());
will_print_mnemonic = will_print_mnemonic
&& (encrypt_to_self.as_ref().is_none() || provision.as_ref().is_none());
will_print_mnemonic = will_print_mnemonic && shard_to.is_none() will_print_mnemonic = will_print_mnemonic && shard_to.is_none()
|| shard_to.as_ref().is_some_and(|s| s.is_empty()); || shard_to.as_ref().is_some_and(|s| s.is_empty());
will_print_mnemonic = will_print_mnemonic && shard.is_none() will_print_mnemonic = will_print_mnemonic && shard.is_none()
|| shard.as_ref().is_some_and(|s| s.is_empty()); || shard.as_ref().is_some_and(|s| s.is_empty());
will_print_mnemonic = will_print_mnemonic will_print_mnemonic = will_print_mnemonic && shard_to_self.is_none();
&& (encrypt_to_self.as_ref().is_none() || provision.as_ref().is_none());
let mnemonic = source.handle(size)?; let mnemonic = source.handle(size)?;
if let Some(derive) = derive {
let stdout = std::io::stdout();
if will_print_mnemonic && !stdout.is_terminal() {
eprintln!(
"Writing plaintext mnemonic and derivation output to standard output"
);
}
do_derive(&mnemonic, derive)?;
}
if let Some(encrypt_to) = encrypt_to { if let Some(encrypt_to) = encrypt_to {
for entry in encrypt_to { for entry in encrypt_to {
do_encrypt_to(&mnemonic, &entry.inner, &entry.values)?; do_encrypt_to(&mnemonic, &entry.inner, &entry.values)?;
@ -558,11 +859,70 @@ impl MnemonicSubcommands {
} }
if let Some(encrypt_to_self) = encrypt_to_self { if let Some(encrypt_to_self) = encrypt_to_self {
do_encrypt_to_self(&mnemonic, &encrypt_to_self.inner, &encrypt_to_self.values)?; let mut accounts: std::collections::HashSet<u32> = Default::default();
if let Some(provision::Provision {
provisioner_name: provision::Provisioner::OpenPGPCard(_),
account_id,
..
}) = provision
{
accounts.insert(*account_id);
}
if let Some(derive::Derive {
command: derive::DeriveSubcommands::OpenPGP(_),
account_id,
..
}) = derive
{
accounts.insert(*account_id);
}
let indices = accounts
.into_iter()
.map(|i| DerivationIndex::new(i, true))
.collect::<Result<Vec<_>, _>>()?;
assert!(
!indices.is_empty(),
"neither derived nor provisioned accounts were found"
);
do_encrypt_to_self(&mnemonic, &encrypt_to_self, &indices)?;
}
if let Some(shard_to_self) = shard_to_self {
do_shard_to_self(&mnemonic, &shard_to_self.inner, &shard_to_self.values)?;
} }
if let Some(provisioner) = provision { if let Some(provisioner) = provision {
do_provision(&mnemonic, &provisioner.inner, &provisioner.values)?; // determine if we should write to standard output based on whether we have a
// matching pair of provisioner and public derivation output.
let mut will_output_public_key = true;
if let Some(derive) = derive {
let matches = match (provisioner, derive) {
(
provision::Provision {
provisioner_name: provision::Provisioner::OpenPGPCard(_),
account_id: p_id,
..
},
derive::Derive {
command: derive::DeriveSubcommands::OpenPGP(_),
account_id: d_id,
..
},
) => p_id == d_id,
_ => false,
};
if matches && derive.public {
will_output_public_key = false;
}
}
let mut values = provision_config.values.clone();
if !will_output_public_key && !values.contains_key("output") {
values.insert(String::from("_skip_cert_output"), String::from("1"));
}
do_provision(&mnemonic, provisioner, *provision_count, &values)?;
} }
if let Some(shard_to) = shard_to { if let Some(shard_to) = shard_to {

View File

@ -5,7 +5,11 @@ mod mnemonic;
mod provision; mod provision;
mod recover; mod recover;
mod shard; mod shard;
mod wizard;
pub fn create(path: &std::path::Path) -> std::io::Result<std::fs::File> {
eprintln!("Writing derived key to: {path}", path=path.display());
std::fs::File::create(path)
}
/// The Kitchen Sink of Entropy. /// The Kitchen Sink of Entropy.
#[derive(Parser, Clone, Debug)] #[derive(Parser, Clone, Debug)]
@ -57,9 +61,6 @@ pub enum KeyforkCommands {
/// leaked by any individual deriver. /// leaked by any individual deriver.
Recover(recover::Recover), Recover(recover::Recover),
/// Utilities to automatically manage the setup of Keyfork.
Wizard(wizard::Wizard),
/// Print an autocompletion file to standard output. /// Print an autocompletion file to standard output.
/// ///
/// Keyfork does not manage the installation of completion files. Consult the documentation for /// Keyfork does not manage the installation of completion files. Consult the documentation for
@ -90,9 +91,6 @@ impl KeyforkCommands {
KeyforkCommands::Recover(r) => { KeyforkCommands::Recover(r) => {
r.handle(keyfork)?; r.handle(keyfork)?;
} }
KeyforkCommands::Wizard(w) => {
w.handle(keyfork)?;
}
#[cfg(feature = "completion")] #[cfg(feature = "completion")]
KeyforkCommands::Completion { shell } => { KeyforkCommands::Completion { shell } => {
let mut command = Keyfork::command(); let mut command = Keyfork::command();

View File

@ -12,20 +12,27 @@ type Identifier = (String, Option<String>);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Provisioner { pub enum Provisioner {
OpenPGPCard(openpgp::OpenPGPCard), OpenPGPCard(openpgp::OpenPGPCard),
Shard(openpgp::Shard),
} }
impl std::fmt::Display for Provisioner { impl std::fmt::Display for Provisioner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { f.write_str(self.identifier())
Provisioner::OpenPGPCard(_) => f.write_str("openpgp-card"),
}
} }
} }
impl Provisioner { impl Provisioner {
pub fn identifier(&self) -> &'static str {
match self {
Provisioner::OpenPGPCard(_) => "openpgp-card",
Provisioner::Shard(_) => "shard",
}
}
pub fn discover(&self) -> Result<Vec<Identifier>, Box<dyn std::error::Error>> { pub fn discover(&self) -> Result<Vec<Identifier>, Box<dyn std::error::Error>> {
match self { match self {
Provisioner::OpenPGPCard(o) => o.discover(), Provisioner::OpenPGPCard(o) => o.discover(),
Provisioner::Shard(s) => s.discover(),
} }
} }
@ -44,6 +51,16 @@ impl Provisioner {
let xprv: XPrv = client.request_xprv(&path)?; let xprv: XPrv = client.request_xprv(&path)?;
o.provision(xprv, provisioner) o.provision(xprv, provisioner)
} }
Provisioner::Shard(s) => {
type Prv = <openpgp::Shard as ProvisionExec>::PrivateKey;
type XPrv = ExtendedPrivateKey<Prv>;
let account_index = DerivationIndex::new(provisioner.account, true)?;
let path = <openpgp::Shard as ProvisionExec>::derivation_prefix()
.chain_push(account_index);
let mut client = keyforkd_client::Client::discover_socket()?;
let xprv: XPrv = client.request_xprv(&path)?;
s.provision(xprv, provisioner)
}
} }
} }
@ -62,19 +79,26 @@ impl Provisioner {
let xprv = XPrv::new(mnemonic.generate_seed(None))?.derive_path(&path)?; let xprv = XPrv::new(mnemonic.generate_seed(None))?.derive_path(&path)?;
o.provision(xprv, provisioner) o.provision(xprv, provisioner)
} }
Provisioner::Shard(s) => {
type Prv = <openpgp::Shard as ProvisionExec>::PrivateKey;
type XPrv = ExtendedPrivateKey<Prv>;
let account_index = DerivationIndex::new(provisioner.account, true)?;
let path = <openpgp::Shard as ProvisionExec>::derivation_prefix()
.chain_push(account_index);
let xprv = XPrv::new(mnemonic.generate_seed(None))?.derive_path(&path)?;
s.provision(xprv, provisioner)
}
} }
} }
} }
impl ValueEnum for Provisioner { impl ValueEnum for Provisioner {
fn value_variants<'a>() -> &'a [Self] { fn value_variants<'a>() -> &'a [Self] {
&[Self::OpenPGPCard(openpgp::OpenPGPCard)] &[Self::OpenPGPCard(openpgp::OpenPGPCard), Self::Shard(openpgp::Shard)]
} }
fn to_possible_value(&self) -> Option<PossibleValue> { fn to_possible_value(&self) -> Option<PossibleValue> {
Some(PossibleValue::new(match self { Some(PossibleValue::new(self.identifier()))
Self::OpenPGPCard(_) => "openpgp-card",
}))
} }
} }
@ -124,15 +148,27 @@ pub struct Provision {
#[command(subcommand)] #[command(subcommand)]
pub subcommand: Option<ProvisionSubcommands>, pub subcommand: Option<ProvisionSubcommands>,
provisioner_name: Provisioner, pub provisioner_name: Provisioner,
/// Account ID. /// Account ID.
#[arg(long, required(true))] #[arg(long, default_value = "0")]
account_id: Option<u32>, pub account_id: u32,
/// Identifier of the hardware to deploy to, listable by running the `discover` subcommand. /// Identifier of the hardware to deploy to, listable by running the `discover` subcommand.
#[arg(long, required(true))] #[arg(long)]
identifier: Option<String>, pub identifier: Option<String>,
}
impl std::str::FromStr for Provision {
type Err = clap::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Provision::try_parse_from(
[String::from("provision")]
.into_iter()
.chain(shlex::Shlex::new(s)),
)
}
} }
// NOTE: In the future, this impl will be used by `keyfork recover` to reprovision hardware from // NOTE: In the future, this impl will be used by `keyfork recover` to reprovision hardware from
@ -148,7 +184,7 @@ impl TryFrom<Provision> for config::Provisioner {
fn try_from(value: Provision) -> Result<Self, Self::Error> { fn try_from(value: Provision) -> Result<Self, Self::Error> {
Ok(Self { Ok(Self {
account: value.account_id.ok_or(MissingField("account_id"))?, account: value.account_id,
identifier: value.identifier.ok_or(MissingField("identifier"))?, identifier: value.identifier.ok_or(MissingField("identifier"))?,
metadata: Default::default(), metadata: Default::default(),
}) })
@ -171,7 +207,21 @@ impl Provision {
} }
} }
None => { None => {
self.provisioner_name.provision(self.clone().try_into()?)?; let provisioner_with_identifier = match self.identifier {
Some(_) => self.clone(),
None => {
let identifiers = self.provisioner_name.discover()?;
let [id] = &identifiers[..] else {
panic!("invalid amount of identifiers; pass --identifier");
};
Self {
identifier: Some(id.0.clone()),
..self.clone()
}
}
};
let config = config::Provisioner::try_from(provisioner_with_identifier)?;
self.provisioner_name.provision(config)?;
} }
} }
Ok(()) Ok(())

View File

@ -1,39 +1,119 @@
use super::ProvisionExec; use super::ProvisionExec;
use crate::{config, openpgp_card::factory_reset_current_card}; use crate::{
config,
openpgp_card::{factory_reset_current_card, get_new_pins},
};
use card_backend_pcsc::PcscBackend; use card_backend_pcsc::PcscBackend;
use keyfork_derive_openpgp::{ use keyfork_derive_openpgp::{
openpgp::{packet::UserID, types::KeyFlags}, openpgp::{
armor::{Kind, Writer},
packet::UserID,
serialize::Serialize,
types::KeyFlags,
},
XPrv, XPrv,
}; };
use keyfork_prompt::{ use keyfork_prompt::default_handler;
default_handler, prompt_validated_passphrase,
validators::{SecurePinValidator, Validator},
};
use openpgp_card_sequoia::{state::Open, Card}; use openpgp_card_sequoia::{state::Open, Card};
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct OpenPGPCard;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
#[error("Provisioner was unable to find a matching smartcard")] #[error("Provisioner was unable to find a matching smartcard")]
struct NoMatchingSmartcard; struct NoMatchingSmartcard;
fn discover_cards() -> Result<Vec<(String, Option<String>)>, Box<dyn std::error::Error>> {
let mut idents = vec![];
for backend in PcscBackend::cards(None)? {
let backend = backend?;
let mut card = Card::<Open>::new(backend)?;
let mut transaction = card.transaction()?;
let identifier = transaction.application_identifier()?.ident();
let name = transaction.cardholder_name()?;
let name = (!name.is_empty()).then_some(name);
idents.push((identifier, name));
}
Ok(idents)
}
fn provision_card(
provisioner: config::Provisioner,
xprv: XPrv,
) -> Result<(), Box<dyn std::error::Error>> {
let mut pm = default_handler()?;
let (user_pin, admin_pin) = get_new_pins(&mut *pm)?;
let subkeys = vec![
KeyFlags::empty().set_certification(),
KeyFlags::empty().set_signing(),
KeyFlags::empty()
.set_transport_encryption()
.set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
let userid = match provisioner.metadata.as_ref().and_then(|m| m.get("userid")) {
Some(userid) => UserID::from(userid.as_str()),
None => UserID::from("Keyfork-Provisioned Key"),
};
let cert = keyfork_derive_openpgp::derive(xprv.clone(), &subkeys, &userid)?;
if !provisioner
.metadata
.as_ref()
.is_some_and(|m| m.contains_key("_skip_cert_output"))
{
let cert_output = match provisioner.metadata.as_ref().and_then(|m| m.get("output")) {
Some(cert_output) => PathBuf::from(cert_output),
None => {
let path = PathBuf::from(cert.fingerprint().to_string()).with_extension("asc");
eprintln!(
"Writing OpenPGP certificate to: {path}",
path = path.display()
);
path
}
};
let cert_output_file = std::fs::File::create(cert_output)?;
let mut writer = Writer::new(cert_output_file, Kind::PublicKey)?;
cert.serialize(&mut writer)?;
writer.finalize()?;
}
let mut has_provisioned = false;
for backend in PcscBackend::cards(None)? {
let backend = backend?;
let result = factory_reset_current_card(
&mut |identifier| identifier == provisioner.identifier,
user_pin.trim(),
admin_pin.trim(),
&cert,
&keyfork_derive_openpgp::openpgp::policy::StandardPolicy::new(),
backend,
)?;
has_provisioned = has_provisioned || result;
}
if !has_provisioned {
return Err(NoMatchingSmartcard)?;
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct OpenPGPCard;
impl ProvisionExec for OpenPGPCard { impl ProvisionExec for OpenPGPCard {
type PrivateKey = keyfork_derive_openpgp::XPrvKey; type PrivateKey = keyfork_derive_openpgp::XPrvKey;
fn discover(&self) -> Result<Vec<(String, Option<String>)>, Box<dyn std::error::Error>> { fn discover(&self) -> Result<Vec<(String, Option<String>)>, Box<dyn std::error::Error>> {
let mut idents = vec![]; discover_cards()
for backend in PcscBackend::cards(None)? {
let backend = backend?;
let mut card = Card::<Open>::new(backend)?;
let mut transaction = card.transaction()?;
let identifier = transaction.application_identifier()?.ident();
let name = transaction.cardholder_name()?;
let name = (!name.is_empty()).then_some(name);
idents.push((identifier, name));
}
Ok(idents)
} }
fn derivation_prefix() -> keyfork_derive_util::DerivationPath { fn derivation_prefix() -> keyfork_derive_util::DerivationPath {
@ -45,67 +125,29 @@ impl ProvisionExec for OpenPGPCard {
xprv: XPrv, xprv: XPrv,
provisioner: config::Provisioner, provisioner: config::Provisioner,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut pm = default_handler()?; provision_card(provisioner, xprv)
let user_pin_validator = SecurePinValidator { }
min_length: Some(6), }
..Default::default()
} #[derive(Clone, Debug)]
.to_fn(); pub struct Shard;
let admin_pin_validator = SecurePinValidator {
min_length: Some(8), impl ProvisionExec for Shard {
..Default::default() type PrivateKey = keyfork_derive_openpgp::XPrvKey;
}
.to_fn(); fn discover(&self) -> Result<Vec<(String, Option<String>)>, Box<dyn std::error::Error>> {
discover_cards()
let user_pin = prompt_validated_passphrase( }
&mut *pm,
"Please enter the new smartcard User PIN: ", fn derivation_prefix() -> keyfork_derive_util::DerivationPath {
3, keyfork_derive_path_data::paths::OPENPGP_SHARD.clone()
&user_pin_validator, }
)?;
let admin_pin = prompt_validated_passphrase( fn provision(
&mut *pm, &self,
"Please enter the new smartcard Admin PIN: ", xprv: XPrv,
3, provisioner: config::Provisioner,
&admin_pin_validator, ) -> Result<(), Box<dyn std::error::Error>> {
)?; provision_card(provisioner, xprv)
let mut has_provisioned = false;
for backend in PcscBackend::cards(None)? {
let backend = backend?;
let subkeys = vec![
KeyFlags::empty().set_certification(),
KeyFlags::empty().set_signing(),
KeyFlags::empty()
.set_transport_encryption()
.set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
// NOTE: This User ID doesn't have meaningful context on the card.
// To give it a reasonable name, use `keyfork derive openpgp` or some other system that
// generates the OpenPGP certificate.
let userid = UserID::from("Keyfork-Provisioned Key");
let cert = keyfork_derive_openpgp::derive(xprv.clone(), &subkeys, &userid)?;
let result = factory_reset_current_card(
&mut |identifier| { identifier == provisioner.identifier },
user_pin.trim(),
admin_pin.trim(),
&cert,
&keyfork_derive_openpgp::openpgp::policy::StandardPolicy::new(),
backend,
)?;
has_provisioned = has_provisioned || result;
}
if !has_provisioned {
return Err(NoMatchingSmartcard)?;
}
Ok(())
} }
} }

View File

@ -1,329 +0,0 @@
use super::Keyfork;
use crate::openpgp_card::factory_reset_current_card;
use clap::{Args, Parser, Subcommand};
use std::{collections::HashSet, fs::File, io::IsTerminal, path::PathBuf};
use card_backend_pcsc::PcscBackend;
use keyfork_derive_openpgp::{
openpgp::{
self,
armor::{Kind, Writer},
packet::{signature::SignatureBuilder, UserID},
policy::StandardPolicy,
serialize::Marshal,
types::{KeyFlags, SignatureType},
Cert,
},
XPrv,
};
use keyfork_derive_path_data::paths;
use keyfork_derive_util::DerivationIndex;
use keyfork_mnemonic::Mnemonic;
use keyfork_prompt::{
default_handler, prompt_validated_passphrase,
validators::{SecurePinValidator, Validator},
Message,
};
use keyfork_shard::{openpgp::OpenPGP, Format};
#[derive(thiserror::Error, Debug)]
#[error("Invalid PIN length: {0}")]
pub struct PinLength(usize);
type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
// TODO: refactor to use mnemonic derived seed instead of 256 bit entropy to allow for possible
// recovery in the future.
fn derive_key(seed: [u8; 32], index: u8) -> Result<Cert> {
let subkeys = vec![
KeyFlags::empty().set_certification(),
KeyFlags::empty().set_signing(),
KeyFlags::empty()
.set_transport_encryption()
.set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
let subkey = DerivationIndex::new(u32::from(index), true)?;
let path = paths::OPENPGP_SHARD.clone().chain_push(subkey);
let xprv = XPrv::new(seed)
.expect("could not construct master key from seed")
.derive_path(&path)?;
let userid = UserID::from(format!("Keyfork Shard {index}"));
let cert = keyfork_derive_openpgp::derive(xprv, &subkeys, &userid)?;
Ok(cert)
}
#[derive(Subcommand, Clone, Debug)]
pub enum WizardSubcommands {
GenerateShardSecret(GenerateShardSecret),
BottomsUp(BottomsUp),
}
/// Create a 256 bit secret and shard the secret to smart cards.
///
/// Smart cards will need to be plugged in periodically during the wizard, where they will be
/// factory reset and provisioned to `m/pgp'/shrd'/<share index>`. The secret can then be recovered
/// with `keyfork recover shard` or `keyfork recover remote-shard`. The share file will be printed
/// to standard output.
#[derive(Args, Clone, Debug)]
pub struct GenerateShardSecret {
/// The minimum amount of keys required to decrypt the secret.
#[arg(long)]
threshold: u8,
/// The maximum amount of shards.
#[arg(long)]
max: u8,
/// The amount of smart cards to provision per-shard.
#[arg(long, default_value = "1")]
keys_per_shard: u8,
/// The file to write the generated shard file to.
#[arg(long)]
output: Option<PathBuf>,
/// The file to write generated certificates to.
#[arg(long)]
cert_output: Option<PathBuf>,
}
/// Create a 256 bit secret and shard the secret to previously known OpenPGP certificates,
/// deriving the default OpenPGP certificate for the secret.
///
/// This command was purpose-built for DEFCON and is not intended to be used normally, as it
/// implies keys used for sharding have been generated by a custom source.
#[derive(Args, Clone, Debug)]
pub struct BottomsUp {
/// The location of OpenPGP certificates to use when sharding.
key_discovery: PathBuf,
/// The minimum amount of keys required to decrypt the secret.
#[arg(long)]
threshold: u8,
/// The file to write the generated shard file to.
#[arg(long)]
output_shardfile: PathBuf,
/// The file to write the generated OpenPGP certificate to.
#[arg(long)]
output_cert: PathBuf,
/// The User ID for the generated OpenPGP certificate.
#[arg(long, default_value = "Disaster Recovery")]
user_id: String,
}
impl WizardSubcommands {
// dispatch
fn handle(&self) -> Result<()> {
match self {
WizardSubcommands::GenerateShardSecret(gss) => gss.handle(),
WizardSubcommands::BottomsUp(bu) => bu.handle(),
}
}
}
fn cross_sign_certs(certs: &mut [Cert]) -> Result<(), Box<dyn std::error::Error>> {
let policy = StandardPolicy::new();
#[allow(clippy::unnecessary_to_owned)]
for signing_cert in certs.to_vec() {
let mut certify_key = signing_cert
.with_policy(&policy, None)?
.keys()
.unencrypted_secret()
.for_certification()
.next()
.expect("certify key unusable/not found")
.key()
.clone()
.into_keypair()?;
for signable_cert in certs.iter_mut() {
let sb = SignatureBuilder::new(SignatureType::GenericCertification);
let userid = signable_cert
.userids()
.next()
.expect("a signable user ID is necessary to create web of trust");
let signature = sb.sign_userid_binding(
&mut certify_key,
signable_cert.primary_key().key(),
&userid,
)?;
let changed;
(*signable_cert, changed) = signable_cert.clone().insert_packets2(signature)?;
assert!(
changed,
"OpenPGP certificate was unchanged after inserting packets"
);
}
}
Ok(())
}
impl GenerateShardSecret {
fn handle(&self) -> Result<()> {
let seed = keyfork_entropy::generate_entropy_of_const_size::<{ 256 / 8 }>()?;
let mut pm = default_handler()?;
let mut certs = vec![];
let mut seen_cards: HashSet<String> = HashSet::new();
let stdout = std::io::stdout();
if self.output.is_none() {
assert!(
!stdout.is_terminal(),
"not printing shard to terminal, redirect output"
);
}
let user_pin_validator = SecurePinValidator {
min_length: Some(6),
..Default::default()
}
.to_fn();
let admin_pin_validator = SecurePinValidator {
min_length: Some(8),
..Default::default()
}
.to_fn();
for index in 0..self.max {
let cert = derive_key(seed, index)?;
for i in 0..self.keys_per_shard {
pm.prompt_message(Message::Text(format!(
"Please remove all keys and insert key #{} for user #{}",
(i as u16) + 1,
(index as u16) + 1,
)))?;
let card_backend = loop {
if let Some(c) = PcscBackend::cards(None)?.next().transpose()? {
break c;
}
pm.prompt_message(Message::Text(
"No smart card was found. Please plug in a smart card and press enter"
.to_string(),
))?;
};
let user_pin = prompt_validated_passphrase(
&mut *pm,
"Please enter the new smartcard User PIN: ",
3,
&user_pin_validator,
)?;
let admin_pin = prompt_validated_passphrase(
&mut *pm,
"Please enter the new smartcard Admin PIN: ",
3,
&admin_pin_validator,
)?;
factory_reset_current_card(
&mut |application_identifier| {
if seen_cards.contains(&application_identifier) {
// we were given the same card, error
// we're gonna panic because this is a significant error
panic!("Previously used card {application_identifier} was reused");
} else {
seen_cards.insert(application_identifier);
true
}
},
user_pin.trim(),
admin_pin.trim(),
&cert,
&openpgp::policy::NullPolicy::new(),
card_backend,
)?;
}
certs.push(cert);
}
cross_sign_certs(&mut certs)?;
let opgp = OpenPGP;
if let Some(output_file) = self.output.as_ref() {
let output = File::create(output_file)?;
opgp.shard_and_encrypt(self.threshold, certs.len() as u8, &seed, &certs[..], output)?;
} else {
opgp.shard_and_encrypt(
self.threshold,
certs.len() as u8,
&seed,
&certs[..],
std::io::stdout(),
)?;
}
if let Some(cert_output_file) = self.cert_output.as_ref() {
let output = File::create(cert_output_file)?;
let mut writer = Writer::new(output, Kind::PublicKey)?;
for cert in certs {
cert.serialize(&mut writer)?;
}
writer.finalize()?;
}
Ok(())
}
}
impl BottomsUp {
fn handle(&self) -> Result<()> {
let entropy = keyfork_entropy::generate_entropy_of_const_size::<{ 256 / 8 }>()?;
let mnemonic = Mnemonic::from_array(entropy);
let seed = mnemonic.generate_seed(None);
// TODO: should this allow for customizing the account index from 0? Potential for key reuse
// errors.
let path = paths::OPENPGP_DISASTER_RECOVERY
.clone()
.chain_push(DerivationIndex::new(0, true)?);
let subkeys = [
KeyFlags::empty().set_certification(),
KeyFlags::empty().set_signing(),
KeyFlags::empty()
.set_transport_encryption()
.set_storage_encryption(),
KeyFlags::empty().set_authentication(),
];
let xprv = XPrv::new(seed)
.expect("could not construct master key from seed")
.derive_path(&path)?;
let userid = UserID::from(self.user_id.as_str());
let cert = keyfork_derive_openpgp::derive(xprv, &subkeys, &userid)?;
let certfile = File::create(&self.output_cert)?;
let mut w = Writer::new(certfile, Kind::PublicKey)?;
cert.serialize(&mut w)?;
w.finalize()?;
let opgp = OpenPGP;
let certs = OpenPGP::discover_certs(&self.key_discovery)?;
let shardfile = File::create(&self.output_shardfile)?;
opgp.shard_and_encrypt(
self.threshold,
certs.len() as u8,
&entropy,
&certs[..],
shardfile,
)?;
Ok(())
}
}
#[derive(Parser, Debug, Clone)]
pub struct Wizard {
#[command(subcommand)]
command: WizardSubcommands,
}
impl Wizard {
pub fn handle(&self, _k: &Keyfork) -> Result<()> {
self.command.handle()?;
Ok(())
}
}

View File

@ -2,19 +2,19 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Mnemonic { pub struct Mnemonic {
pub hash: String, pub hash: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Provisioner { pub struct Provisioner {
pub account: u32, pub account: u32,
pub identifier: String, pub identifier: String,
pub metadata: Option<HashMap<String, String>>, pub metadata: Option<HashMap<String, String>>,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Config { pub struct Config {
pub mnemonic: Mnemonic, pub mnemonic: Mnemonic,
pub provisioner: Vec<Provisioner>, pub provisioner: Vec<Provisioner>,

View File

@ -1,6 +1,68 @@
use card_backend_pcsc::PcscBackend; use card_backend_pcsc::PcscBackend;
use openpgp_card_sequoia::{state::Open, types::KeyType, Card}; use keyfork_derive_openpgp::openpgp::{policy::Policy, Cert};
use keyfork_derive_openpgp::openpgp::{Cert, policy::Policy}; use keyfork_prompt::{
prompt_validated_passphrase,
validators::{SecurePinValidator, Validator},
Message, PromptHandler,
};
use openpgp_card_sequoia::{state::Open, types::KeyType, types::TouchPolicy, Card};
pub fn get_new_pins(
pm: &mut dyn PromptHandler,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let user_pin_validator = SecurePinValidator {
min_length: Some(6),
..Default::default()
}
.to_fn();
let admin_pin_validator = SecurePinValidator {
min_length: Some(8),
..Default::default()
}
.to_fn();
let user_pin = loop {
let user_pin = prompt_validated_passphrase(
&mut *pm,
"Please enter the new smartcard User PIN: ",
3,
&user_pin_validator,
)?;
let validated_user_pin = prompt_validated_passphrase(
&mut *pm,
"Please verify the new smartcard User PIN: ",
3,
&user_pin_validator,
)?;
if user_pin != validated_user_pin {
pm.prompt_message(Message::Text("User PINs did not match. Retrying.".into()))?;
} else {
break user_pin;
}
};
let admin_pin = loop {
let admin_pin = prompt_validated_passphrase(
&mut *pm,
"Please enter the new smartcard Admin PIN: ",
3,
&admin_pin_validator,
)?;
let validated_admin_pin = prompt_validated_passphrase(
&mut *pm,
"Please verify the new smartcard Admin PIN: ",
3,
&admin_pin_validator,
)?;
if admin_pin != validated_admin_pin {
pm.prompt_message(Message::Text("Admin PINs did not match. Retrying.".into()))?;
} else {
break admin_pin;
}
};
Ok((user_pin, admin_pin))
}
/// Factory reset the current card so long as it does not match the last-used backend. /// Factory reset the current card so long as it does not match the last-used backend.
/// ///
@ -42,10 +104,12 @@ pub fn factory_reset_current_card(
transaction.factory_reset()?; transaction.factory_reset()?;
let mut admin = transaction.to_admin_card("12345678")?; let mut admin = transaction.to_admin_card("12345678")?;
admin.upload_key(signing_key, KeyType::Signing, None)?; admin.upload_key(signing_key, KeyType::Signing, None)?;
admin.set_touch_policy(KeyType::Signing, TouchPolicy::On)?;
admin.upload_key(decryption_key, KeyType::Decryption, None)?; admin.upload_key(decryption_key, KeyType::Decryption, None)?;
admin.set_touch_policy(KeyType::Decryption, TouchPolicy::On)?;
admin.upload_key(authentication_key, KeyType::Authentication, None)?; admin.upload_key(authentication_key, KeyType::Authentication, None)?;
admin.set_touch_policy(KeyType::Authentication, TouchPolicy::On)?;
transaction.change_user_pin("123456", user_pin)?; transaction.change_user_pin("123456", user_pin)?;
transaction.change_admin_pin("12345678", admin_pin)?; transaction.change_admin_pin("12345678", admin_pin)?;
Ok(true) Ok(true)
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "keyfork-qrcode" name = "keyfork-qrcode"
version = "0.1.2" version = "0.1.3"
repository = "https://git.distrust.co/public/keyfork" repository = "https://git.distrust.co/public/keyfork"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@ -15,8 +15,9 @@ decode-backend-zbar = ["dep:keyfork-zbar"]
[dependencies] [dependencies]
keyfork-bug = { workspace = true } keyfork-bug = { workspace = true }
keyfork-zbar = { workspace = true, optional = true } keyfork-zbar = { workspace = true, optional = true, features = ["image"] }
image = { workspace = true, default-features = false, features = ["jpeg"] } image = { workspace = true, default-features = false, features = ["jpeg"] }
rqrr = { version = "0.7.0", optional = true } rqrr = { version = "0.9.0", optional = true }
thiserror = { workspace = true } thiserror = { workspace = true }
v4l = { workspace = true } v4l = { workspace = true }
cfg-if = "1.0.0"

View File

@ -5,7 +5,7 @@ use std::time::Duration;
use keyfork_qrcode::scan_camera; use keyfork_qrcode::scan_camera;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let output = scan_camera(Duration::from_secs(60 * 10), 0)?; let output = scan_camera(Duration::from_secs(15), 0)?;
if let Some(scanned_text) = output { if let Some(scanned_text) = output {
println!("{scanned_text}"); println!("{scanned_text}");
} }

View File

@ -2,18 +2,17 @@
use keyfork_bug as bug; use keyfork_bug as bug;
use image::ImageReader; use image::{ImageBuffer, ImageReader, Luma};
use std::{ use std::{
io::{Cursor, Write}, io::{Cursor, Write},
time::{Duration, Instant},
process::{Command, Stdio}, process::{Command, Stdio},
time::{Duration, Instant},
}; };
use v4l::{ use v4l::{
buffer::Type, buffer::Type,
io::{userptr::Stream, traits::CaptureStream}, io::{traits::CaptureStream, userptr::Stream},
video::Capture, video::Capture,
FourCC, Device, FourCC,
Device,
}; };
/// A QR code could not be generated. /// A QR code could not be generated.
@ -102,70 +101,117 @@ pub fn qrencode(
const VIDEO_FORMAT_READ_ERROR: &str = "Failed to read video device format"; const VIDEO_FORMAT_READ_ERROR: &str = "Failed to read video device format";
trait Scanner {
fn scan_image(&mut self, image: ImageBuffer<Luma<u8>, Vec<u8>>) -> Option<String>;
}
#[cfg(feature = "decode-backend-zbar")]
mod zbar {
use super::{ImageBuffer, Luma, Scanner};
pub struct Zbar {
scanner: keyfork_zbar::image_scanner::ImageScanner,
}
impl Zbar {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
}
impl Default for Zbar {
fn default() -> Self {
Self {
scanner: keyfork_zbar::image_scanner::ImageScanner::new(),
}
}
}
impl Scanner for Zbar {
fn scan_image(
&mut self,
image: ImageBuffer<Luma<u8>, Vec<u8>>,
) -> Option<String> {
let image = keyfork_zbar::image::Image::from(image);
self.scanner.scan_image(&image).into_iter().next().map(|symbol| {
String::from_utf8_lossy(symbol.data()).into()
})
}
}
}
#[cfg(feature = "decode-backend-rqrr")]
mod rqrr {
use super::{ImageBuffer, Luma, Scanner};
pub struct Rqrr;
impl Scanner for Rqrr {
fn scan_image(
&mut self,
image: ImageBuffer<Luma<u8>, Vec<u8>>,
) -> Option<String> {
let mut image = rqrr::PreparedImage::prepare(image);
for grid in image.detect_grids() {
if let Ok((_, content)) = grid.decode() {
return Some(content);
}
}
None
}
}
}
#[allow(dead_code)]
fn dbg_elapsed(count: u64, instant: Instant) {
let elapsed = instant.elapsed().as_secs();
let framerate = count as f64 / elapsed as f64;
eprintln!("framerate: {count}/{elapsed} = {framerate}");
}
/// Continuously scan the `index`-th camera for a QR code. /// Continuously scan the `index`-th camera for a QR code.
/// ///
/// # Errors /// # Errors
/// ///
/// The function may return an error if the hardware is unable to scan video or if an image could /// The function may return an error if the hardware is unable to scan video or if an image could
/// not be decoded. /// not be decoded.
#[cfg(feature = "decode-backend-rqrr")]
pub fn scan_camera(timeout: Duration, index: usize) -> Result<Option<String>, QRCodeScanError> { pub fn scan_camera(timeout: Duration, index: usize) -> Result<Option<String>, QRCodeScanError> {
let device = Device::new(index)?; let device = Device::new(index)?;
let mut fmt = device.format().unwrap_or_else(bug::panic!(VIDEO_FORMAT_READ_ERROR)); let mut fmt = device
.format()
.unwrap_or_else(bug::panic!(VIDEO_FORMAT_READ_ERROR));
fmt.fourcc = FourCC::new(b"MPG1"); fmt.fourcc = FourCC::new(b"MPG1");
device.set_format(&fmt)?; device.set_format(&fmt)?;
let mut stream = Stream::with_buffers(&device, Type::VideoCapture, 4)?; let mut stream = Stream::with_buffers(&device, Type::VideoCapture, 4)?;
let start = Instant::now(); let start = Instant::now();
cfg_if::cfg_if! {
if #[cfg(feature = "decode-backend-zbar")] {
let mut scanner = zbar::Zbar::default();
} else if #[cfg(feature = "decode-backend-rqrr")] {
let mut scanner = rqrr::Rqrr;
} else {
unimplemented!("neither decode-backend-zbar nor decode-backend-rqrr were selected")
}
};
while Instant::now() #[allow(unused)]
.duration_since(start) let mut count = 0;
< timeout
{ while Instant::now().duration_since(start) < timeout {
count += 1;
let (buffer, _) = stream.next()?; let (buffer, _) = stream.next()?;
let image = ImageReader::new(Cursor::new(buffer)) let image = ImageReader::new(Cursor::new(buffer))
.with_guessed_format()? .with_guessed_format()?
.decode()? .decode()?
.to_luma8(); .to_luma8();
let mut image = rqrr::PreparedImage::prepare(image); if let Some(content) = scanner.scan_image(image) {
for grid in image.detect_grids() { // dbg_elapsed(count, start);
if let Ok((_, content)) = grid.decode() { return Ok(Some(content));
return Ok(Some(content))
}
} }
} }
Ok(None) // dbg_elapsed(count, start);
}
/// Continuously scan the `index`-th camera for a QR code.
///
/// # Errors
///
/// The function may return an error if the hardware is unable to scan video or if an image could
/// not be decoded.
#[cfg(feature = "decode-backend-zbar")]
pub fn scan_camera(timeout: Duration, index: usize) -> Result<Option<String>, QRCodeScanError> {
let device = Device::new(index)?;
let mut fmt = device.format().unwrap_or_else(bug::panic!(VIDEO_FORMAT_READ_ERROR));
fmt.fourcc = FourCC::new(b"MPG1");
device.set_format(&fmt)?;
let mut stream = Stream::with_buffers(&device, Type::VideoCapture, 4)?;
let start = Instant::now();
let mut scanner = keyfork_zbar::image_scanner::ImageScanner::new();
while Instant::now()
.duration_since(start)
< timeout
{
let (buffer, _) = stream.next()?;
let image = ImageReader::new(Cursor::new(buffer))
.with_guessed_format()?
.decode()?;
let image = keyfork_zbar::image::Image::from(image);
for symbol in scanner.scan_image(&image) {
return Ok(Some(String::from_utf8_lossy(symbol.data()).to_string()));
}
}
Ok(None) Ok(None)
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "keyfork-zbar" name = "keyfork-zbar"
version = "0.1.1" version = "0.1.2"
repository = "https://git.distrust.co/public/keyfork" repository = "https://git.distrust.co/public/keyfork"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"

View File

@ -58,7 +58,7 @@ impl Image {
#[cfg(feature = "image")] #[cfg(feature = "image")]
mod impls { mod impls {
use super::*; use super::*;
use image::{DynamicImage, GenericImageView}; use image::{DynamicImage, GenericImageView, ImageBuffer, Luma};
impl From<DynamicImage> for Image { impl From<DynamicImage> for Image {
fn from(value: DynamicImage) -> Self { fn from(value: DynamicImage) -> Self {
@ -70,6 +70,17 @@ mod impls {
image image
} }
} }
impl From<ImageBuffer<Luma<u8>, Vec<u8>>> for Image {
fn from(value: ImageBuffer<Luma<u8>, Vec<u8>>) -> Self {
let mut image = Self::alloc();
let (width, height) = value.dimensions();
image.set_size(width, height);
image.set_format(b"Y800");
image.set_data(value.into_raw());
image
}
}
} }
impl Drop for Image { impl Drop for Image {

View File

@ -1,6 +1,6 @@
[package] [package]
name = "keyfork-bug" name = "keyfork-bug"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"

View File

@ -16,6 +16,12 @@
//! ``` //! ```
//! //!
//! ```rust,should_panic //! ```rust,should_panic
//! let rows = 24;
//! let input_lines_len = 25;
//! assert!(input_lines_len < rows, "{input_lines_len} can't fit in {rows} lines!");
//! ```
//!
//! ```rust,should_panic
//! use std::fs::File; //! use std::fs::File;
//! use keyfork_bug as bug; //! use keyfork_bug as bug;
//! //!
@ -83,6 +89,29 @@ macro_rules! bug {
}}; }};
} }
/// Assert a condition is true, otherwise throwing an error using Keyfork Bug.
///
/// # Examples
/// ```rust
/// let expectations = "conceivable!";
/// let circumstances = "otherwise";
/// assert!(circumstances != expectations, "you keep using that word...");
/// ```
///
/// Variables can be used in the error message, without having to pass them manually.
///
/// ```rust,should_panic
/// let rows = 24;
/// let input_lines_len = 25;
/// assert!(input_lines_len < rows, "{input_lines_len} can't fit in {rows} lines!");
/// ```
#[macro_export]
macro_rules! assert {
($cond:expr, $($input:tt)*) => {
std::assert!($cond, "{}", keyfork_bug::bug!($($input)*));
}
}
/// Return a closure that, when called, panics with a bug report message for Keyfork. Returning a /// Return a closure that, when called, panics with a bug report message for Keyfork. Returning a
/// closure can help handle the `clippy::expect_fun_call` lint. The closure accepts an error /// closure can help handle the `clippy::expect_fun_call` lint. The closure accepts an error
/// argument, so it is suitable for being used with [`Result`] types instead of [`Option`] types. /// argument, so it is suitable for being used with [`Result`] types instead of [`Option`] types.

View File

@ -1,6 +1,6 @@
[package] [package]
name = "keyfork-prompt" name = "keyfork-prompt"
version = "0.2.1" version = "0.2.2"
description = "Prompt management utilities for Keyfork" description = "Prompt management utilities for Keyfork"
repository = "https://git.distrust.co/public/keyfork" repository = "https://git.distrust.co/public/keyfork"
edition = "2021" edition = "2021"

View File

@ -1,15 +1,47 @@
#![allow(missing_docs)] #![allow(missing_docs)]
use keyfork_prompt::{ use keyfork_prompt::default_handler;
Message,
default_handler, #[derive(PartialEq, Eq, Debug, Copy, Clone)]
}; pub enum Choices {
Retry,
Continue,
}
impl std::fmt::Display for Choices {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Choices::Retry => write!(
f,
"Retry with some really long text that I want to cause issues with."
),
Choices::Continue => write!(
f,
"Continue with some really long text that I want to cause issues with."
),
}
}
}
impl keyfork_prompt::Choice for Choices {
fn identifier(&self) -> Option<char> {
Some(match self {
Choices::Retry => 'r',
Choices::Continue => 'c',
})
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut handler = default_handler()?; let mut handler = default_handler()?;
let output = handler.prompt_input("Test message: ")?; let choice = keyfork_prompt::prompt_choice(
handler.prompt_message(Message::Text(format!("Result: {output}")))?; &mut *handler,
"Here are some options!",
&[Choices::Retry, Choices::Continue],
);
dbg!(&choice);
Ok(()) Ok(())
} }

View File

@ -4,9 +4,12 @@
//! directly intended to be machine-readable, but can be used for scriptable automation in a //! directly intended to be machine-readable, but can be used for scriptable automation in a
//! fashion similar to a terminal handler. //! fashion similar to a terminal handler.
use std::io::{IsTerminal, Write}; use std::{
io::{IsTerminal, Write},
str::FromStr,
};
use crate::{BoxResult, Error, Message, PromptHandler, Result}; use crate::{BoxResult, Choice, Error, Message, PromptHandler, Result};
/// A headless prompt handler, usable in situations when a terminal might not be available, or for /// A headless prompt handler, usable in situations when a terminal might not be available, or for
/// scripting purposes where manual input from a terminal is not desirable. /// scripting purposes where manual input from a terminal is not desirable.
@ -58,17 +61,47 @@ impl PromptHandler for Headless {
fn prompt_message(&mut self, prompt: Message) -> Result<()> { fn prompt_message(&mut self, prompt: Message) -> Result<()> {
match prompt { match prompt {
Message::Text(s) => { Message::Text(s) => {
self.stderr.write_all(s.as_bytes())?; writeln!(&mut self.stderr, "{s}")?;
self.stderr.flush()?; self.stderr.flush()?;
} }
Message::Data(s) => { Message::Data(s) => {
self.stderr.write_all(s.as_bytes())?; writeln!(&mut self.stderr, "{s}")?;
self.stderr.flush()?; self.stderr.flush()?;
} }
} }
writeln!(&mut self.stderr, "Press enter to continue.")?;
self.stdin.read_line(&mut String::new())?;
Ok(()) Ok(())
} }
fn prompt_choice_num(&mut self, prompt: &str, choices: &[Box<dyn Choice>]) -> Result<usize> {
writeln!(&mut self.stderr, "{prompt}")?;
for (i, choice) in choices.iter().enumerate() {
match choice.identifier() {
Some(identifier) => {
writeln!(&mut self.stderr, "{i}. ({identifier})\t{choice}")?;
}
None => {
writeln!(&mut self.stderr, "{i}.\t{choice}")?;
}
}
}
self.stderr.flush()?;
let mut line = String::new();
self.stdin.read_line(&mut line)?;
let selector_char = line.chars().next();
if let Some(selector @ ('a'..='z' | 'A'..='Z')) = selector_char {
if let Some((index, _)) = choices.iter().enumerate().find(|(_, choice)| {
choice
.identifier()
.is_some_and(|identifier| selector == identifier)
}) {
return Ok(index);
}
}
usize::from_str(line.trim()).map_err(|e| Error::Custom(e.to_string()))
}
fn prompt_validated_wordlist( fn prompt_validated_wordlist(
&mut self, &mut self,
prompt: &str, prompt: &str,
@ -85,7 +118,7 @@ impl PromptHandler for Headless {
self.stdin.read_line(&mut line)?; self.stdin.read_line(&mut line)?;
if let Err(e) = validator_fn(std::mem::take(&mut line)) { if let Err(e) = validator_fn(std::mem::take(&mut line)) {
last_error = e.to_string(); last_error = e.to_string();
self.stderr.write_all(e.to_string().as_bytes())?; writeln!(&mut self.stderr, "{e}")?;
self.stderr.flush()?; self.stderr.flush()?;
} else { } else {
return Ok(()); return Ok(());
@ -108,8 +141,7 @@ impl PromptHandler for Headless {
self.stdin.read_line(&mut line)?; self.stdin.read_line(&mut line)?;
if let Err(e) = validator_fn(std::mem::take(&mut line)) { if let Err(e) = validator_fn(std::mem::take(&mut line)) {
last_error = e.to_string(); last_error = e.to_string();
self.stderr.write_all(e.to_string().as_bytes())?; writeln!(&mut self.stderr, "{e}")?;
self.stderr.write_all(b"\n")?;
self.stderr.flush()?; self.stderr.flush()?;
} else { } else {
return Ok(()); return Ok(());

View File

@ -50,6 +50,10 @@ pub enum Error {
/// An error occurred while interacting with a terminal. /// An error occurred while interacting with a terminal.
#[error("IO Error: {0}")] #[error("IO Error: {0}")]
IO(#[from] std::io::Error), IO(#[from] std::io::Error),
/// An unexpected error occurred.
#[error("{0}")]
Custom(String),
} }
#[allow(missing_docs)] #[allow(missing_docs)]
@ -64,6 +68,21 @@ pub enum Message {
Data(String), Data(String),
} }
/// A type that may represent an identifier to be used when using a choice prompt.
pub trait Choice: std::fmt::Display {
/// The identifier for the type.
fn identifier(&self) -> Option<char> {
None
}
}
// this way, we can make Box<dyn T> from &T
impl<T: Choice> Choice for &T {
fn identifier(&self) -> Option<char> {
Choice::identifier(*self)
}
}
#[doc(hidden)] #[doc(hidden)]
pub type BoxResult = std::result::Result<(), Box<dyn std::error::Error>>; pub type BoxResult = std::result::Result<(), Box<dyn std::error::Error>>;
@ -98,6 +117,16 @@ pub trait PromptHandler {
/// occurred while waiting for the user to dismiss the message. /// occurred while waiting for the user to dismiss the message.
fn prompt_message(&mut self, prompt: Message) -> Result<()>; fn prompt_message(&mut self, prompt: Message) -> Result<()>;
/// Prompt the user for a choice between the provided options. The returned value is the index
/// of the given choice.
///
/// This method SHOULD NOT be used directly. Instead, use [`prompt_choice`].
///
/// # Errors
/// The method may return an error if the message was not able to be displayed or if the input
/// could not be read.
fn prompt_choice_num(&mut self, prompt: &str, choices: &[Box<dyn Choice>]) -> Result<usize>;
/// Prompt the user for input based on a wordlist, while validating the wordlist using a /// Prompt the user for input based on a wordlist, while validating the wordlist using a
/// provided parser function, returning the type from the parser. A language must be specified /// provided parser function, returning the type from the parser. A language must be specified
/// as the generic parameter `X` (any type implementing [`Wordlist`]) when parsing a wordlist. /// as the generic parameter `X` (any type implementing [`Wordlist`]) when parsing a wordlist.
@ -133,6 +162,29 @@ pub trait PromptHandler {
) -> Result<(), Error>; ) -> Result<(), Error>;
} }
/// Prompt the user for a choice between the provided options. The returned value is the selected
/// choice.
///
/// # Errors
/// The method may return an error if the message was not able to be displayed or if the input
/// could not be read.
#[allow(clippy::missing_panics_doc)]
pub fn prompt_choice<T>(
handler: &mut dyn PromptHandler,
prompt: &str,
choices: &'static [T],
) -> Result<T>
where
T: Choice + Copy + 'static,
{
let boxed_choices = choices
.iter()
.map(|c| Box::new(c) as Box<dyn Choice>)
.collect::<Vec<_>>();
let choice = handler.prompt_choice_num(prompt, boxed_choices.as_slice())?;
Ok(choices[choice])
}
/// Prompt the user for input based on a wordlist, while validating the wordlist using a /// Prompt the user for input based on a wordlist, while validating the wordlist using a
/// provided parser function, returning the type from the parser. A language must be specified /// provided parser function, returning the type from the parser. A language must be specified
/// as the generic parameter `X` (any type implementing [`Wordlist`]) when parsing a wordlist. /// as the generic parameter `X` (any type implementing [`Wordlist`]) when parsing a wordlist.

View File

@ -21,7 +21,7 @@ use keyfork_crossterm::{
use keyfork_bug::bug; use keyfork_bug::bug;
use crate::{BoxResult, Error, Message, PromptHandler}; use crate::{BoxResult, Choice, Error, Message, PromptHandler};
#[allow(missing_docs)] #[allow(missing_docs)]
pub type Result<T, E = Error> = std::result::Result<T, E>; pub type Result<T, E = Error> = std::result::Result<T, E>;
@ -129,14 +129,26 @@ where
{ {
fn drop(&mut self) { fn drop(&mut self) {
self.write self.write
.execute(cursor::EnableBlinking)
.expect(bug!("can't enable blinking"))
.execute(cursor::Show)
.expect(bug!("can't show cursor"))
.execute(DisableBracketedPaste) .execute(DisableBracketedPaste)
.expect(bug!("can't restore bracketed paste")); .expect(bug!("can't restore bracketed paste"));
self.write
.execute(LeaveAlternateScreen)
.expect(bug!("can't leave alternate screen"));
self.terminal self.terminal
.disable_raw_mode() .disable_raw_mode()
.expect(bug!("can't disable raw mode")); .expect(bug!("can't disable raw mode"));
// we don't want to clear error messages
if !std::thread::panicking() {
self.write
.queue(LeaveAlternateScreen)
.expect(bug!("can't leave alternate screen"))
.queue(terminal::Clear(terminal::ClearType::All))
.expect(bug!("can't clear screen"))
.queue(cursor::MoveTo(0, 0))
.expect(bug!("can't move to origin"));
}
self.write.flush().expect(bug!("can't execute terminal reset commands"));
} }
} }
@ -188,9 +200,7 @@ where
prefix_length = line.len(); prefix_length = line.len();
terminal.queue(Print(line))?; terminal.queue(Print(line))?;
if lines.peek().is_some() { if lines.peek().is_some() {
terminal terminal.queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
} }
} }
terminal.flush()?; terminal.flush()?;
@ -257,6 +267,103 @@ where
Ok(input) Ok(input)
} }
fn prompt_choice_num(&mut self, prompt: &str, choices: &[Box<dyn Choice>]) -> Result<usize> {
let mut terminal = self.lock().alternate_screen()?.raw_mode()?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
.queue(cursor::MoveTo(0, 0))?
.queue(cursor::Hide)?;
for line in prompt.lines() {
terminal
.queue(Print(line))?
.queue(cursor::MoveToNextLine(1))?;
terminal.flush()?;
}
let mut active_choice = 0;
let mut drawn = false;
loop {
let (cols, rows) = terminal.size()?;
// all choices, plus their padding, plus the spacing between, minus whitespace at end.
let max_size = choices
.iter()
.fold(0usize, |agg, choice| agg + choice.to_string().len() + 2)
+ std::cmp::max(choices.len(), 1)
- 1;
let horizontal = max_size < cols.into();
keyfork_bug::assert!(
horizontal || usize::from(rows) > prompt.lines().count() + choices.len(),
"screen too small, can't fit choices on {rows}x{cols}",
);
if horizontal {
terminal.queue(cursor::MoveToColumn(0))?;
} else if drawn {
terminal
.queue(cursor::MoveUp(
choices
.len()
.saturating_sub(1)
.try_into()
.expect(keyfork_bug::bug!("more than {} choices provided", u16::MAX)),
))?
.queue(cursor::MoveToColumn(0))?;
} else {
drawn = true;
}
let mut iter = choices.iter().enumerate().peekable();
while let Some((i, choice)) = iter.next() {
// if active choice, flip foreground and background
// if active choice, wrap in []
// if not, wrap in spaces, to preserve spacing and prevent redraws
if i == active_choice {
terminal.queue(PrintStyledContent(Stylize::reverse(format!("[{choice}]"))))?;
} else {
terminal.queue(Print(format!(" {choice} ")))?;
}
if iter.peek().is_some() {
if horizontal {
terminal.queue(Print(" "))?;
} else {
terminal.queue(cursor::MoveToNextLine(1))?;
}
}
}
terminal.flush()?;
if let Event::Key(k) = read()? {
match k.code {
KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => {
return Err(Error::CtrlC);
}
KeyCode::Char(c) => {
for (i, choice) in choices.iter().enumerate() {
if choice.identifier().is_some_and(|id| id == c) {
active_choice = i;
}
}
}
KeyCode::Left | KeyCode::Up => {
active_choice = active_choice.saturating_sub(1);
}
KeyCode::Right | KeyCode::Down => match choices.len().saturating_sub(active_choice) {
0 | 1 => {}
_ => {
active_choice += 1;
}
},
KeyCode::Enter => {
return Ok(active_choice);
}
_ => {}
}
}
}
}
fn prompt_validated_wordlist( fn prompt_validated_wordlist(
&mut self, &mut self,
prompt: &str, prompt: &str,
@ -300,9 +407,7 @@ where
prefix_length = line.len(); prefix_length = line.len();
terminal.queue(Print(line))?; terminal.queue(Print(line))?;
if lines.peek().is_some() { if lines.peek().is_some() {
terminal terminal.queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
} }
} }
terminal.flush()?; terminal.flush()?;
@ -461,9 +566,7 @@ where
prefix_length = line.len(); prefix_length = line.len();
terminal.queue(Print(line))?; terminal.queue(Print(line))?;
if lines.peek().is_some() { if lines.peek().is_some() {
terminal terminal.queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
} }
} }
terminal.flush()?; terminal.flush()?;
@ -529,21 +632,17 @@ where
let len = std::cmp::min(u16::MAX as usize, word.len()) as u16; let len = std::cmp::min(u16::MAX as usize, word.len()) as u16;
written_chars += len + 1; written_chars += len + 1;
if written_chars > cols { if written_chars > cols {
terminal terminal.queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
written_chars = len + 1; written_chars = len + 1;
} }
terminal.queue(Print(word))?.queue(Print(" "))?; terminal.queue(Print(word))?.queue(Print(" "))?;
} }
terminal terminal.queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
} }
} }
Message::Data(data) => { Message::Data(data) => {
let count = data.lines().count(); let count = data.lines().count();
// NOTE: GE to allow a MoveDown(1) // NOTE: GE to allow a MoveToNextLine(1)
if count >= rows as usize { if count >= rows as usize {
let msg = format!( let msg = format!(
"{} {count} {} {rows} {}", "{} {count} {} {rows} {}",
@ -551,14 +650,12 @@ where
); );
terminal terminal
.queue(Print(msg))? .queue(Print(msg))?
.queue(cursor::MoveDown(1))? .queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveToColumn(0))?;
} else { } else {
for line in data.lines() { for line in data.lines() {
terminal terminal
.queue(Print(line))? .queue(Print(line))?
.queue(cursor::MoveDown(1))? .queue(cursor::MoveToNextLine(1))?;
.queue(cursor::MoveToColumn(0))?;
} }
} }
} }
@ -580,7 +677,6 @@ where
_ => (), _ => (),
} }
} }
terminal.queue(cursor::EnableBlinking)?.flush()?;
Ok(()) Ok(())
} }
} }