diff --git a/crates/keyfork/src/clap_ext.rs b/crates/keyfork/src/clap_ext.rs new file mode 100644 index 0000000..4cb0eb7 --- /dev/null +++ b/crates/keyfork/src/clap_ext.rs @@ -0,0 +1,60 @@ +//! Extensions to clap. + +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 +where + T::Err: std::error::Error, +{ + /// A mapping between keys and values. + pub values: HashMap, + + /// The first variable for the argument, such as a [`PathBuf`]. + pub inner: T, +} + +/// An error that occurred while parsing a base value or its +#[derive(Debug, thiserror::Error)] +pub enum ValueParseError { + /// No value was given; the required type could not be parsed. + #[error("No value was given")] + NoValue, + + /// The first value could not properly be parsed. + #[error("Could not parse first value: {0}")] + BadParse(String), + + /// Additional values were added, but not in a key=value format. + #[error("A key-value pair was not given")] + BadKeyValue, +} + +impl FromStr for ValueWithOptions +where + ::Err: std::error::Error, +{ + type Err = ValueParseError; + + fn from_str(s: &str) -> Result { + let mut values = s.split(','); + let first = values.next().ok_or(ValueParseError::NoValue)?; + let mut others = HashMap::new(); + for value in values { + let [lhs, rhs] = value + .splitn(2, '=') + .collect::>() + .try_into() + .map_err(|_| ValueParseError::BadKeyValue)?; + others.insert(lhs.to_string(), rhs.to_string()); + } + Ok(Self { + inner: first + .parse() + .map_err(|e: ::Err| ValueParseError::BadParse(e.to_string()))?, + values: others, + }) + } +} diff --git a/crates/keyfork/src/cli/mnemonic.rs b/crates/keyfork/src/cli/mnemonic.rs index 05403a6..d58b2ae 100644 --- a/crates/keyfork/src/cli/mnemonic.rs +++ b/crates/keyfork/src/cli/mnemonic.rs @@ -1,6 +1,34 @@ +use super::provision; use super::Keyfork; +use crate::{clap_ext::*, config}; use clap::{builder::PossibleValue, Parser, Subcommand, ValueEnum}; -use std::fmt::Display; +use std::{ + collections::HashMap, + fmt::Display, + fs::File, + io::Write, + path::{Path, PathBuf}, + str::FromStr, +}; + +use keyfork_derive_openpgp::{ + openpgp::{ + self, + armor::{Kind, Writer}, + packet::UserID, + policy::StandardPolicy, + serialize::{ + stream::{Encryptor2, LiteralWriter, Message, Recipient}, + Serialize, + }, + types::KeyFlags, + }, + XPrv, +}; +use keyfork_prompt::default_handler; +use keyfork_shard::{openpgp::OpenPGP, Format}; + +type StringMap = HashMap; #[derive(Clone, Debug, Default)] pub enum SeedSize { @@ -96,21 +124,22 @@ impl std::str::FromStr for MnemonicSeedSource { } impl MnemonicSeedSource { - pub fn handle(&self, size: &SeedSize) -> Result> { + pub fn handle( + &self, + size: &SeedSize, + ) -> Result> { let size = match size { SeedSize::Bits128 => 128, SeedSize::Bits256 => 256, }; let seed = match self { - MnemonicSeedSource::System => { - keyfork_entropy::generate_entropy_of_size(size / 8)? - } + MnemonicSeedSource::System => keyfork_entropy::generate_entropy_of_size(size / 8)?, MnemonicSeedSource::Playing => todo!(), MnemonicSeedSource::Tarot => todo!(), MnemonicSeedSource::Dice => todo!(), }; let mnemonic = keyfork_mnemonic::Mnemonic::try_from_slice(&seed)?; - Ok(mnemonic.to_string()) + Ok(mnemonic) } } @@ -124,6 +153,10 @@ pub enum MnemonicSubcommands { /// method of generating a seed using system entropy, as well as various forms of loading /// physicalized entropy into a mnemonic. The mnemonic should be stored in a safe location /// (such as a Trezor "recovery seed card") and never persisted digitally. + /// + /// When using the `--shard`, `--shard-to`, `--encrypt-to`, and `--encrypt-to-self` + + /// `--provision` arguments, the mnemonic is _not_ sent to output. The data for the mnemonic is + /// then either split using Keyfork Shard or encrypted using OpenPGP. Generate { /// The source from where a seed is created. #[arg(long, value_enum, default_value_t = Default::default())] @@ -132,17 +165,423 @@ pub enum MnemonicSubcommands { /// The size of the mnemonic, in bits. #[arg(long, default_value_t = Default::default())] size: SeedSize, + + /// 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 + /// output of the encryption will be written to `output.asc`. Otherwise, the default + /// behavior is to write the output to `input.enc.asc`. If the output file already exists, + /// it will not be overwritten, and the command will exit unsuccessfully. + #[arg(long)] + encrypt_to: Option>>, + + /// Shard the mnemonic to the certificates in the given Shardfile. Requires a decrypt + /// operation on the Shardfile to access the metadata and certificates. + /// + /// When given arguments in the format `--encrypt-to input.asc,output=output.asc`, the + /// 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, + /// it will not be overwritten, and the command will exit unsuccessfully. + #[arg(long)] + shard_to: Option>>, + + /// Shard the mnemonic to the provided certificates. + /// + /// The following additional arguments are available: + /// + /// * threshold, m: the minimum amount of shares required to reconstitute the shard. By + /// default, this is the amount of certificates provided. + /// + /// * max, n: the maximum amount of shares. When provided, this is used to ensure the + /// certificate count is correct. This is required when using `threshold` or `m`. + /// + /// * output: the file to write the generated Shardfile to. By default, assuming the + /// certificate input is `input.asc`, the generated Shardfile would be written to + /// `input.shard.asc`. + #[arg(long)] + shard: Option>>, + + /// 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 + /// `--provision openpgp-card` or another relevant provisioner, to ensure the newly + /// generated mnemonic would be decryptable by some form of provisioned hardware. + /// + /// When given arguments in the format `--encrypt-to-self output.asc,output=encrypted.asc`, + /// the output of the OpenPGP certificate will be written to `output.asc`, while the output + /// of the encryption will be written to `encrypted.asc`. Otherwise, the + /// 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=`. It can contain any characters that are not a comma. + #[arg(long)] + encrypt_to_self: Option>, + + /// 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`. + /// + /// Additional arguments, such as the amount of hardware to provision and the + /// account to use when deriving, can be specified by using (for example) + /// `--provision openpgp-card,count=2,account=1`. + #[arg(long)] + provision: Option>, }, } +// NOTE: This function defaults to `.asc` in the event no extension is found. +// This is specific to OpenPGP. If you want to use this function elsewhere (why?), +// be sure to use a relevant extension for your context. +fn determine_valid_output_path>( + path: &Path, + mid_ext: &str, + optional_path: Option, +) -> PathBuf { + match optional_path { + Some(p) => p.as_ref().to_path_buf(), + None => { + let extension = match path.extension() { + Some(ext) => format!("{mid_ext}.{ext}", ext = ext.to_string_lossy()), + None => format!("{mid_ext}.asc"), + }; + path.with_extension(extension) + } + } +} + +fn is_extension_armored(path: &Path) -> bool { + match path.extension().and_then(|s| s.to_str()) { + Some("pgp") | Some("gpg") => false, + Some("asc") => true, + _ => { + eprintln!("unable to determine whether to armor file: {path:?}"); + eprintln!("use .gpg, .pgp, or .asc extension, or `armor=true`"); + eprintln!("defaulting to armored"); + true + } + } +} + +fn do_encrypt_to( + mnemonic: &keyfork_mnemonic::Mnemonic, + path: &Path, + options: &StringMap, +) -> Result<(), Box> { + let policy = StandardPolicy::new(); + + let output_file = determine_valid_output_path(path, "enc", options.get("output")); + + let is_armored = + options.get("armor").is_some_and(|a| a == "true") || is_extension_armored(&output_file); + + let certs = OpenPGP::discover_certs(path)?; + let valid_certs = certs + .iter() + .map(|c| c.with_policy(&policy, None)) + .collect::>>()?; + let recipients = valid_certs.iter().flat_map(|valid_cert| { + let keys = valid_cert.keys().alive().for_storage_encryption(); + keys.map(|key| Recipient::new(key.keyid(), key.key())) + }); + + let mut output = vec![]; + let message = Message::new(&mut output); + let encrypted_message = Encryptor2::for_recipients(message, recipients).build()?; + let mut literal_message = LiteralWriter::new(encrypted_message).build()?; + literal_message.write_all(mnemonic.to_string().as_bytes())?; + literal_message.write_all(b"\n")?; + literal_message.finalize()?; + + let mut file = File::create_new(&output_file)?; + if is_armored { + let mut writer = Writer::new(file, Kind::Message)?; + writer.write_all(&output)?; + writer.finalize()?; + } else { + file.write_all(&output)?; + } + + Ok(()) +} + +fn do_encrypt_to_self( + mnemonic: &keyfork_mnemonic::Mnemonic, + path: &Path, + options: &StringMap, +) -> Result<(), Box> { + let output_file = determine_valid_output_path(path, "enc", options.get("output")); + + let is_armored = + options.get("armor").is_some_and(|a| a == "true") || is_extension_armored(&output_file); + + let account = options + .get("account") + .map(|account| u32::from_str(account)) + .transpose()? + .unwrap_or(0); + let account_index = keyfork_derive_util::DerivationIndex::new(account, true)?; + + let userid = options + .get("userid") + .map(|userid| UserID::from(userid.as_str())); + + let subkeys = [ + KeyFlags::empty().set_certification(), + KeyFlags::empty().set_signing(), + KeyFlags::empty() + .set_transport_encryption() + .set_storage_encryption(), + KeyFlags::empty().set_authentication(), + ]; + + let seed = mnemonic.generate_seed(None); + 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)?; + } + + // a sneaky bit of DRY + do_encrypt_to( + mnemonic, + path, + &StringMap::from([( + String::from("output"), + output_file.to_string_lossy().to_string(), + )]), + )?; + + Ok(()) +} + +#[derive(thiserror::Error, Debug)] +#[error("Either the threshold(m) or the max(n) values are missing")] +struct MissingThresholdOrMax; + +fn do_shard( + mnemonic: &keyfork_mnemonic::Mnemonic, + path: &Path, + options: &StringMap, +) -> Result<(), Box> { + let output_file = determine_valid_output_path(path, "shard", options.get("output")); + + let is_armored = + options.get("armor").is_some_and(|a| a == "true") || is_extension_armored(&output_file); + + let threshold = options + .get("threshold") + .or_else(|| options.get("m")) + .map(|s| u8::from_str(s)) + .transpose()?; + + let max = options + .get("max") + .or_else(|| options.get("n")) + .map(|s| u8::from_str(s)) + .transpose()?; + + let certs = OpenPGP::discover_certs(path)?; + + // if neither are set: false + // if both are set: false + // if only one is set: true + + if threshold.is_some() ^ max.is_some() { + return Err(MissingThresholdOrMax)?; + } + + let (threshold, max) = match threshold.zip(max) { + Some(t) => t, + None => { + let len = u8::try_from(certs.len())?; + (len, len) + } + }; + + let openpgp = keyfork_shard::openpgp::OpenPGP; + + let mut output = vec![]; + openpgp.shard_and_encrypt(threshold, max, mnemonic.as_bytes(), &certs[..], &mut output)?; + + let mut file = File::create_new(&output_file)?; + if is_armored { + file.write_all(&output)?; + } else { + todo!("keyfork does not handle binary shardfiles"); + /* + * NOTE: this code works, but can't be recombined by Keyfork. + * therefore, we'll error, before someone tries to use it. + let mut dearmor = Reader::from_bytes(&output, ReaderMode::Tolerant(None)); + std::io::copy(&mut dearmor, &mut file)?; + */ + } + + Ok(()) +} + +fn do_shard_to( + mnemonic: &keyfork_mnemonic::Mnemonic, + path: &Path, + options: &StringMap, +) -> Result<(), Box> { + let output_file = determine_valid_output_path(path, "new", options.get("output")); + + let is_armored = + options.get("armor").is_some_and(|a| a == "true") || is_extension_armored(&output_file); + + let openpgp = keyfork_shard::openpgp::OpenPGP; + let prompt = default_handler()?; + + let input = File::open(path)?; + let (threshold, certs) = openpgp.decrypt_metadata_from_file( + Some(&[][..]), // the things i must do to avoid qualifying types. + input, + prompt, + )?; + + let mut output = vec![]; + openpgp.shard_and_encrypt( + threshold, + u8::try_from(certs.len())?, + mnemonic.as_bytes(), + &certs[..], + &mut output, + )?; + + let mut file = File::create_new(&output_file)?; + if is_armored { + file.write_all(&output)?; + } else { + todo!("keyfork does not handle binary shardfiles"); + /* + * NOTE: this code works, but can't be recombined by Keyfork. + * therefore, we'll error, before someone tries to use it. + let mut dearmor = Reader::from_bytes(&output, ReaderMode::Tolerant(None)); + std::io::copy(&mut dearmor, &mut file)?; + */ + } + + Ok(()) +} + +#[derive(thiserror::Error, Debug)] +#[error("missing key: {0}")] +struct MissingKey(&'static str); + +fn do_provision( + mnemonic: &keyfork_mnemonic::Mnemonic, + provisioner: &provision::Provisioner, + options: &StringMap, +) -> Result<(), Box> { + let mut options = options.clone(); + let account = options + .remove("account") + .map(|account| u32::from_str(&account)) + .transpose()? + .unwrap_or(0); + let identifier = options + .remove("identifier") + .ok_or(MissingKey("identifier"))? + .split(',') + .map(String::from) + .collect::>(); + 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 provisioner_config = config::Provisioner { + account, + identifier, + metadata: Some(options.clone()), + }; + + provisioner.provision_with_mnemonic(mnemonic, provisioner_config.clone())?; + } + + Ok(()) +} + impl MnemonicSubcommands { pub fn handle( &self, _m: &Mnemonic, _keyfork: &Keyfork, - ) -> Result> { + ) -> Result<(), Box> { match self { - MnemonicSubcommands::Generate { source, size } => source.handle(size), + MnemonicSubcommands::Generate { + source, + size, + encrypt_to, + shard_to, + shard, + encrypt_to_self, + provision, + } => { + // 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. + let mut will_print_mnemonic = + encrypt_to.is_none() || encrypt_to.as_ref().is_some_and(|e| e.is_empty()); + will_print_mnemonic = will_print_mnemonic && shard_to.is_none() + || shard_to.as_ref().is_some_and(|s| s.is_empty()); + will_print_mnemonic = will_print_mnemonic && shard.is_none() + || shard.as_ref().is_some_and(|s| s.is_empty()); + will_print_mnemonic = will_print_mnemonic + && (encrypt_to_self.as_ref().is_none() || provision.as_ref().is_none()); + + let mnemonic = source.handle(size)?; + + if let Some(encrypt_to) = encrypt_to { + for entry in encrypt_to { + do_encrypt_to(&mnemonic, &entry.inner, &entry.values)?; + } + } + + if let Some(encrypt_to_self) = encrypt_to_self { + do_encrypt_to_self(&mnemonic, &encrypt_to_self.inner, &encrypt_to_self.values)?; + } + + if let Some(provisioner) = provision { + do_provision(&mnemonic, &provisioner.inner, &provisioner.values)?; + } + + if let Some(shard_to) = shard_to { + for entry in shard_to { + do_shard_to(&mnemonic, &entry.inner, &entry.values)?; + } + } + + if let Some(shard) = shard { + for entry in shard { + do_shard(&mnemonic, &entry.inner, &entry.values)?; + } + } + + if will_print_mnemonic { + println!("{}", mnemonic); + } + Ok(()) + } } } } diff --git a/crates/keyfork/src/cli/mod.rs b/crates/keyfork/src/cli/mod.rs index ec3dd31..cdc157e 100644 --- a/crates/keyfork/src/cli/mod.rs +++ b/crates/keyfork/src/cli/mod.rs @@ -79,8 +79,7 @@ impl KeyforkCommands { d.handle(keyfork)?; } KeyforkCommands::Mnemonic(m) => { - let response = m.command.handle(m, keyfork)?; - println!("{response}"); + m.command.handle(m, keyfork)?; } KeyforkCommands::Shard(s) => { s.command.handle(s, keyfork)?; diff --git a/crates/keyfork/src/cli/provision/mod.rs b/crates/keyfork/src/cli/provision/mod.rs index 31f4220..544b788 100644 --- a/crates/keyfork/src/cli/provision/mod.rs +++ b/crates/keyfork/src/cli/provision/mod.rs @@ -3,9 +3,15 @@ use crate::config; use clap::{builder::PossibleValue, Parser, Subcommand, ValueEnum}; +use keyfork_derive_util::{DerivationIndex, ExtendedPrivateKey}; + +mod openpgp; + +type Identifier = (String, Option); + #[derive(Debug, Clone)] pub enum Provisioner { - OpenPGPCard(OpenPGPCard), + OpenPGPCard(openpgp::OpenPGPCard), } impl std::fmt::Display for Provisioner { @@ -17,25 +23,52 @@ impl std::fmt::Display for Provisioner { } impl Provisioner { - fn discover(&self) -> Vec<(String, Option)> { + pub fn discover(&self) -> Result, Box> { match self { Provisioner::OpenPGPCard(o) => o.discover(), } } - fn provision( + pub fn provision( &self, provisioner: config::Provisioner, ) -> Result<(), Box> { match self { - Provisioner::OpenPGPCard(o) => o.provision(provisioner), + Provisioner::OpenPGPCard(o) => { + type Prv = ::PrivateKey; + type XPrv = ExtendedPrivateKey; + let account_index = DerivationIndex::new(provisioner.account, true)?; + let path = ::derivation_prefix() + .chain_push(account_index); + let mut client = keyforkd_client::Client::discover_socket()?; + let xprv: XPrv = client.request_xprv(&path)?; + o.provision(xprv, provisioner) + } + } + } + + pub fn provision_with_mnemonic( + &self, + mnemonic: &keyfork_mnemonic::Mnemonic, + provisioner: config::Provisioner, + ) -> Result<(), Box> { + match self { + Provisioner::OpenPGPCard(o) => { + type Prv = ::PrivateKey; + type XPrv = ExtendedPrivateKey; + let account_index = DerivationIndex::new(provisioner.account, true)?; + let path = ::derivation_prefix() + .chain_push(account_index); + let xprv = XPrv::new(mnemonic.generate_seed(None))?.derive_path(&path)?; + o.provision(xprv, provisioner) + } } } } impl ValueEnum for Provisioner { fn value_variants<'a>() -> &'a [Self] { - &[Self::OpenPGPCard(OpenPGPCard)] + &[Self::OpenPGPCard(openpgp::OpenPGPCard)] } fn to_possible_value(&self) -> Option { @@ -45,39 +78,36 @@ impl ValueEnum for Provisioner { } } -trait ProvisionExec { - /// Discover all known places the formatted key can be deployed to. - fn discover(&self) -> Vec<(String, Option)> { - vec![] - } +#[derive(Debug, thiserror::Error)] +#[error("The given value could not be matched as a provisioner: {0} ({1})")] +pub struct ProvisionerFromStrError(String, String); - /// Derive a key and deploy it to a target. - fn provision(&self, p: config::Provisioner) -> Result<(), Box>; +impl std::str::FromStr for Provisioner { + type Err = ProvisionerFromStrError; + + fn from_str(s: &str) -> Result { + ::from_str(s, false) + .map_err(|e| ProvisionerFromStrError(s.to_string(), e)) + } } -#[derive(Clone, Debug)] -pub struct OpenPGPCard; +trait ProvisionExec { + type PrivateKey: keyfork_derive_util::PrivateKey + Clone; -impl ProvisionExec for OpenPGPCard { - fn discover(&self) -> Vec<(String, Option)> { - /* - vec![ - ( - "0006:26144195".to_string(), - Some("Yubicats Heywood".to_string()), - ), - ( - "0006:2614419y".to_string(), - Some("Yubicats Heywood".to_string()), - ), - ] - */ - vec![] + /// Discover all known places the formatted key can be deployed to. + fn discover(&self) -> Result, Box> { + Ok(vec![]) } - fn provision(&self, _p: config::Provisioner) -> Result<(), Box> { - todo!() - } + /// Return the derivation path for deriving keys. + fn derivation_prefix() -> keyfork_derive_util::DerivationPath; + + /// Derive a key and deploy it to a target. + fn provision( + &self, + xprv: keyfork_derive_util::ExtendedPrivateKey, + p: config::Provisioner, + ) -> Result<(), Box>; } #[derive(Subcommand, Clone, Debug)] @@ -118,7 +148,6 @@ impl TryFrom for config::Provisioner { fn try_from(value: Provision) -> Result { Ok(Self { - name: value.provisioner_name.to_string(), account: value.account_id.ok_or(MissingField("account_id"))?, identifier: value.identifier.ok_or(MissingField("identifier"))?, metadata: Default::default(), @@ -130,7 +159,7 @@ impl Provision { pub fn handle(&self, _keyfork: &Keyfork) -> Result<(), Box> { match self.subcommand { Some(ProvisionSubcommands::Discover) => { - let mut iter = self.provisioner_name.discover().into_iter().peekable(); + let mut iter = self.provisioner_name.discover()?.into_iter().peekable(); while let Some((identifier, context)) = iter.next() { println!("Identifier: {identifier}"); if let Some(context) = context { diff --git a/crates/keyfork/src/cli/provision/openpgp.rs b/crates/keyfork/src/cli/provision/openpgp.rs new file mode 100644 index 0000000..9d11c0b --- /dev/null +++ b/crates/keyfork/src/cli/provision/openpgp.rs @@ -0,0 +1,111 @@ +use super::ProvisionExec; +use crate::{config, openpgp_card::factory_reset_current_card}; + +use card_backend_pcsc::PcscBackend; +use keyfork_derive_openpgp::{ + openpgp::{packet::UserID, types::KeyFlags}, + XPrv, +}; +use keyfork_prompt::{ + default_handler, prompt_validated_passphrase, + validators::{SecurePinValidator, Validator}, +}; +use openpgp_card_sequoia::{state::Open, Card}; + +#[derive(Clone, Debug)] +pub struct OpenPGPCard; + +#[derive(thiserror::Error, Debug)] +#[error("Provisioner was unable to find a matching smartcard")] +struct NoMatchingSmartcard; + +impl ProvisionExec for OpenPGPCard { + type PrivateKey = keyfork_derive_openpgp::XPrvKey; + + fn discover(&self) -> Result)>, Box> { + let mut idents = vec![]; + for backend in PcscBackend::cards(None)? { + let backend = backend?; + let mut card = Card::::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 { + keyfork_derive_path_data::paths::OPENPGP.clone() + } + + fn provision( + &self, + xprv: XPrv, + provisioner: config::Provisioner, + ) -> Result<(), Box> { + let mut pm = default_handler()?; + 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 = 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, + )?; + + 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(()) + } +} diff --git a/crates/keyfork/src/cli/wizard.rs b/crates/keyfork/src/cli/wizard.rs index 46c83f1..ac5ef32 100644 --- a/crates/keyfork/src/cli/wizard.rs +++ b/crates/keyfork/src/cli/wizard.rs @@ -1,18 +1,18 @@ 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 openpgp_card_sequoia::{state::Open, types::KeyType, Card}; use keyfork_derive_openpgp::{ openpgp::{ self, armor::{Kind, Writer}, - packet::{UserID, signature::SignatureBuilder}, - serialize::Marshal, - types::{SignatureType, KeyFlags}, + packet::{signature::SignatureBuilder, UserID}, policy::StandardPolicy, + serialize::Marshal, + types::{KeyFlags, SignatureType}, Cert, }, XPrv, @@ -56,54 +56,6 @@ fn derive_key(seed: [u8; 32], index: u8) -> Result { Ok(cert) } -// TODO: extract into crate -/// Factory reset the current card so long as it does not match the last-used backend. -fn factory_reset_current_card( - seen_cards: &mut HashSet, - user_pin: &str, - admin_pin: &str, - cert: &Cert, - card_backend: PcscBackend, -) -> Result<()> { - let policy = openpgp::policy::NullPolicy::new(); - let valid_cert = cert.with_policy(&policy, None)?; - let signing_key = valid_cert - .keys() - .for_signing() - .secret() - .next() - .expect("no signing key found"); - let decryption_key = valid_cert - .keys() - .for_storage_encryption() - .secret() - .next() - .expect("no decryption key found"); - let authentication_key = valid_cert - .keys() - .for_authentication() - .secret() - .next() - .expect("no authentication key found"); - let mut card = Card::::new(card_backend)?; - let mut transaction = card.transaction()?; - let application_identifier = transaction.application_identifier()?.ident(); - if seen_cards.contains(&application_identifier) { - // we were given the same card, error - panic!("Previously used card {application_identifier} was reused"); - } else { - seen_cards.insert(application_identifier); - } - transaction.factory_reset()?; - let mut admin = transaction.to_admin_card("12345678")?; - admin.upload_key(signing_key, KeyType::Signing, None)?; - admin.upload_key(decryption_key, KeyType::Decryption, None)?; - admin.upload_key(authentication_key, KeyType::Authentication, None)?; - transaction.change_user_pin("123456", user_pin)?; - transaction.change_admin_pin("12345678", admin_pin)?; - Ok(()) -} - #[derive(Subcommand, Clone, Debug)] pub enum WizardSubcommands { GenerateShardSecret(GenerateShardSecret), @@ -178,6 +130,8 @@ impl WizardSubcommands { fn cross_sign_certs(certs: &mut [Cert]) -> Result<(), Box> { 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)? @@ -198,7 +152,7 @@ fn cross_sign_certs(certs: &mut [Cert]) -> Result<(), Box let signature = sb.sign_userid_binding( &mut certify_key, signable_cert.primary_key().key(), - &*userid, + &userid, )?; let changed; (*signable_cert, changed) = signable_cert.clone().insert_packets2(signature)?; @@ -266,10 +220,20 @@ impl GenerateShardSecret { &admin_pin_validator, )?; factory_reset_current_card( - &mut seen_cards, + &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, )?; } diff --git a/crates/keyfork/src/config.rs b/crates/keyfork/src/config.rs index f7112b5..f670c0a 100644 --- a/crates/keyfork/src/config.rs +++ b/crates/keyfork/src/config.rs @@ -2,20 +2,19 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct Mnemonic { pub hash: String, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct Provisioner { - pub name: String, pub account: u32, pub identifier: String, pub metadata: Option>, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct Config { pub mnemonic: Mnemonic, pub provisioner: Vec, diff --git a/crates/keyfork/src/main.rs b/crates/keyfork/src/main.rs index 24a7bce..63847bc 100644 --- a/crates/keyfork/src/main.rs +++ b/crates/keyfork/src/main.rs @@ -10,6 +10,8 @@ use keyfork_bin::{Bin, ClosureBin}; mod cli; mod config; +pub mod clap_ext; +mod openpgp_card; fn main() -> ExitCode { let bin = ClosureBin::new(|| { diff --git a/crates/keyfork/src/openpgp_card.rs b/crates/keyfork/src/openpgp_card.rs new file mode 100644 index 0000000..6b22425 --- /dev/null +++ b/crates/keyfork/src/openpgp_card.rs @@ -0,0 +1,51 @@ +use card_backend_pcsc::PcscBackend; +use openpgp_card_sequoia::{state::Open, types::KeyType, Card}; +use keyfork_derive_openpgp::openpgp::{Cert, policy::Policy}; + +/// Factory reset the current card so long as it does not match the last-used backend. +/// +/// The return value of `false` means the filter was matched, whereas `true` means it was +/// successfully provisioned. +pub fn factory_reset_current_card( + card_filter: &mut dyn FnMut(String) -> bool, + user_pin: &str, + admin_pin: &str, + cert: &Cert, + policy: &dyn Policy, + card_backend: PcscBackend, +) -> Result> { + let valid_cert = cert.with_policy(policy, None)?; + let signing_key = valid_cert + .keys() + .for_signing() + .secret() + .next() + .expect("no signing key found"); + let decryption_key = valid_cert + .keys() + .for_storage_encryption() + .secret() + .next() + .expect("no decryption key found"); + let authentication_key = valid_cert + .keys() + .for_authentication() + .secret() + .next() + .expect("no authentication key found"); + let mut card = Card::::new(card_backend)?; + let mut transaction = card.transaction()?; + let application_identifier = transaction.application_identifier()?.ident(); + if !card_filter(application_identifier) { + return Ok(false); + } + transaction.factory_reset()?; + let mut admin = transaction.to_admin_card("12345678")?; + admin.upload_key(signing_key, KeyType::Signing, None)?; + admin.upload_key(decryption_key, KeyType::Decryption, None)?; + admin.upload_key(authentication_key, KeyType::Authentication, None)?; + transaction.change_user_pin("123456", user_pin)?; + transaction.change_admin_pin("12345678", admin_pin)?; + Ok(true) +} +