From dff2442bdfa4845588108aa6d69209e08a78c159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAc=C3=A1s=20Meier?= Date: Wed, 8 May 2024 08:07:44 -0700 Subject: [PATCH] Add an encrypted config option to pcli (#4343) ## Describe your changes This adds a new option to encrypt the `soft-kms` and `threshold` custody backends with a password, so that spend-key related material is encrypted at rest. This is implemented by: 1. Having a `pcli init --encrypted` flag that applies to both of these backends, which prompts a user for a password (and confirmation) before using that to encrypt the config. 2. Having a `pcli init re-encrypt` command to read an existing config and encrypt its backend, if necessary, to allow importing existing configs. This is also implemented internally in a lazy way, so that a password is only prompted when the custody services methods are actually called, allowing us to not need a password for view only commands. ## Issue ticket number and link Closes #4293. ## Checklist before requesting a review - [x] If this code contains consensus-breaking changes, I have added the "consensus-breaking" label. Otherwise, I declare my belief that there are not consensus-breaking changes, for the following reason: > This is a client-only change. --------- Co-authored-by: cratelyn --- Cargo.lock | 26 ++- crates/bin/pcli/src/command/init.rs | 132 ++++++++--- crates/bin/pcli/src/command/threshold.rs | 23 +- crates/bin/pcli/src/config.rs | 13 +- crates/bin/pcli/src/opt.rs | 14 ++ crates/bin/pcli/src/terminal.rs | 42 +++- crates/custody/Cargo.toml | 1 + crates/custody/src/encrypted.rs | 278 +++++++++++++++++++++++ crates/custody/src/lib.rs | 2 + crates/custody/src/terminal.rs | 45 ++++ crates/custody/src/threshold.rs | 60 +++-- crates/custody/src/threshold/sign.rs | 33 +-- docs/guide/src/pcli/wallet.md | 6 +- docs/guide/src/pcli/wallet/softkms.md | 16 +- docs/guide/src/pcli/wallet/threshold.md | 11 + 15 files changed, 600 insertions(+), 102 deletions(-) create mode 100644 crates/custody/src/encrypted.rs create mode 100644 crates/custody/src/terminal.rs diff --git a/Cargo.lock b/Cargo.lock index 57adc26a19..13bedf087b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,18 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash 0.5.0", +] + [[package]] name = "ark-bls12-377" version = "0.4.0" @@ -4296,6 +4308,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.14" @@ -4347,7 +4370,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", "hmac 0.12.1", - "password-hash", + "password-hash 0.4.2", "sha2 0.10.8", ] @@ -4940,6 +4963,7 @@ name = "penumbra-custody" version = "0.74.0-alpha.1" dependencies = [ "anyhow", + "argon2", "ark-ff", "ark-serialize", "base64 0.21.7", diff --git a/crates/bin/pcli/src/command/init.rs b/crates/bin/pcli/src/command/init.rs index be24359c7b..941f5c2b41 100644 --- a/crates/bin/pcli/src/command/init.rs +++ b/crates/bin/pcli/src/command/init.rs @@ -31,6 +31,11 @@ pub struct InitCmd { parse(try_from_str = Url::parse), )] grpc_url: Url, + /// For configs with spend authority, this will enable password encryption. + /// + /// This has no effect on a view only service. + #[clap(long, action)] + encrypted: bool, } #[derive(Debug, Clone, clap::Subcommand)] @@ -62,8 +67,11 @@ pub enum InitSubCmd { Threshold(ThresholdInitCmd), // This is not accessible directly by the user, because it's impermissible to initialize the // governance subkey as view-only. - #[clap(skip)] + #[clap(skip, display_order = 200)] ViewOnly { full_viewing_key: String }, + /// If relevant, change the current config to an encrypted config, with a password. + #[clap(display_order = 800)] + ReEncrypt, } #[derive(Debug, Clone, clap::Subcommand)] @@ -268,35 +276,35 @@ impl InitCmd { } let home_dir = home_dir.as_ref(); - match &init_type { - InitType::SpendKey => { - // Check that the data_dir is empty before running init: - if home_dir.exists() && home_dir.read_dir()?.next().is_some() { - anyhow::bail!( - "home directory {:?} is not empty; refusing to initialize", - home_dir - ); - } - } - InitType::GovernanceKey => { - // Check that there is no existing governance key before running init: - let config_path = home_dir.join(crate::CONFIG_FILE_NAME); - let config = PcliConfig::load(config_path)?; - if config.governance_custody.is_some() { - anyhow::bail!( - "governance key already exists in config file at {:?}; refusing to overwrite it", - home_dir - ); - } + let existing_config = { + let config_path = home_dir.join(crate::CONFIG_FILE_NAME); + if config_path.exists() { + Some(PcliConfig::load(config_path)?) + } else { + None } - } + }; + let relevant_config_exists = match &init_type { + InitType::SpendKey => existing_config.is_some(), + InitType::GovernanceKey => existing_config + .as_ref() + .is_some_and(|x| x.governance_custody.is_some()), + }; - let (full_viewing_key, custody) = match (&init_type, &subcmd) { - (_, InitSubCmd::SoftKms(cmd)) => { + let (full_viewing_key, custody) = match (&init_type, &subcmd, relevant_config_exists) { + (_, InitSubCmd::SoftKms(cmd), false) => { let spend_key = cmd.spend_key(init_type)?; ( spend_key.full_viewing_key().clone(), - CustodyConfig::SoftKms(spend_key.into()), + if self.encrypted { + let password = ActualTerminal.get_confirmed_password().await?; + CustodyConfig::Encrypted(penumbra_custody::encrypted::Config::create( + &password, + penumbra_custody::encrypted::InnerConfig::SoftKms(spend_key.into()), + )?) + } else { + CustodyConfig::SoftKms(spend_key.into()) + }, ) } ( @@ -305,20 +313,82 @@ impl InitCmd { threshold, num_participants, }), + false, ) => { let config = threshold::dkg(*threshold, *num_participants, &ActualTerminal).await?; - (config.fvk().clone(), CustodyConfig::Threshold(config)) + let fvk = config.fvk().clone(); + let custody_config = if self.encrypted { + let password = ActualTerminal.get_confirmed_password().await?; + CustodyConfig::Encrypted(penumbra_custody::encrypted::Config::create( + &password, + penumbra_custody::encrypted::InnerConfig::Threshold(config), + )?) + } else { + CustodyConfig::Threshold(config) + }; + (fvk, custody_config) } - (_, InitSubCmd::Threshold(ThresholdInitCmd::Deal { .. })) => { + (_, InitSubCmd::Threshold(ThresholdInitCmd::Deal { .. }), _) => { unreachable!("this should already have been handled above") } - (InitType::SpendKey, InitSubCmd::ViewOnly { full_viewing_key }) => { + (InitType::SpendKey, InitSubCmd::ViewOnly { full_viewing_key }, false) => { let full_viewing_key = full_viewing_key.parse()?; (full_viewing_key, CustodyConfig::ViewOnly) } - (InitType::GovernanceKey, InitSubCmd::ViewOnly { .. }) => { + (InitType::GovernanceKey, InitSubCmd::ViewOnly { .. }, false) => { unreachable!("governance keys can't be initialized in view-only mode") } + (typ, InitSubCmd::ReEncrypt, true) => { + let config = existing_config.expect("the config should exist in this branch"); + let fvk = config.full_viewing_key; + let custody = match typ { + InitType::SpendKey => config.custody, + InitType::GovernanceKey => match config + .governance_custody + .expect("the governence custody should exist in this branch") + { + GovernanceCustodyConfig::SoftKms(c) => CustodyConfig::SoftKms(c), + GovernanceCustodyConfig::Threshold(c) => CustodyConfig::Threshold(c), + GovernanceCustodyConfig::Encrypted { config, .. } => { + CustodyConfig::Encrypted(config) + } + }, + }; + let custody = match custody { + x @ CustodyConfig::ViewOnly => x, + x @ CustodyConfig::Encrypted(_) => x, + CustodyConfig::SoftKms(spend_key) => { + let password = ActualTerminal.get_confirmed_password().await?; + CustodyConfig::Encrypted(penumbra_custody::encrypted::Config::create( + &password, + penumbra_custody::encrypted::InnerConfig::SoftKms(spend_key.into()), + )?) + } + CustodyConfig::Threshold(c) => { + let password = ActualTerminal.get_confirmed_password().await?; + CustodyConfig::Encrypted(penumbra_custody::encrypted::Config::create( + &password, + penumbra_custody::encrypted::InnerConfig::Threshold(c), + )?) + } + }; + (fvk, custody) + } + (_, InitSubCmd::ReEncrypt, false) => { + anyhow::bail!("re-encrypt requires existing config to exist",); + } + (InitType::SpendKey, _, true) => { + anyhow::bail!( + "home directory {:?} is not empty; refusing to initialize", + home_dir + ); + } + (InitType::GovernanceKey, _, true) => { + anyhow::bail!( + "governance key already exists in config file at {:?}; refusing to overwrite it", + home_dir + ); + } }; let config = if let InitType::SpendKey = init_type { @@ -336,6 +406,10 @@ impl InitCmd { let governance_custody = match custody { CustodyConfig::SoftKms(config) => GovernanceCustodyConfig::SoftKms(config), CustodyConfig::Threshold(config) => GovernanceCustodyConfig::Threshold(config), + CustodyConfig::Encrypted(config) => GovernanceCustodyConfig::Encrypted { + fvk: full_viewing_key, + config, + }, _ => unreachable!("governance keys can't be initialized in view-only mode"), }; config.governance_custody = Some(governance_custody); diff --git a/crates/bin/pcli/src/command/threshold.rs b/crates/bin/pcli/src/command/threshold.rs index 41c3027cac..25b4c555d3 100644 --- a/crates/bin/pcli/src/command/threshold.rs +++ b/crates/bin/pcli/src/command/threshold.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use penumbra_custody::threshold::Terminal; use crate::{ config::{CustodyConfig, GovernanceCustodyConfig}, @@ -21,19 +22,29 @@ impl ThresholdCmd { #[tracing::instrument(skip(self, app))] pub async fn exec(&self, app: &mut App) -> Result<()> { - let config = match &app.config.custody { + let config = match app.config.custody.clone() { CustodyConfig::Threshold(config) => Some(config), + CustodyConfig::Encrypted(config) => { + let password = ActualTerminal.get_password().await?; + config.convert_to_threshold(&password)? + } _ => None, // If not threshold, we can't sign using threshold config }; let governance_config = match &app.config.governance_custody { - Some(GovernanceCustodyConfig::Threshold(governance_config)) => Some(governance_config), - None => config, // If no governance config, use regular one - _ => None, // If not threshold, we can't sign using governance config + Some(GovernanceCustodyConfig::Threshold(governance_config)) => { + Some(governance_config.clone()) + } + None => config.clone(), // If no governance config, use regular one + _ => None, // If not threshold, we can't sign using governance config }; match self { ThresholdCmd::Sign => { - penumbra_custody::threshold::follow(config, governance_config, &ActualTerminal) - .await + penumbra_custody::threshold::follow( + config.as_ref(), + governance_config.as_ref(), + &ActualTerminal, + ) + .await } } } diff --git a/crates/bin/pcli/src/config.rs b/crates/bin/pcli/src/config.rs index eafb05ee60..8208bc459d 100644 --- a/crates/bin/pcli/src/config.rs +++ b/crates/bin/pcli/src/config.rs @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use url::Url; -use penumbra_custody::{soft_kms::Config as SoftKmsConfig, threshold::Config as ThresholdConfig}; +use penumbra_custody::{ + encrypted::Config as EncryptedConfig, soft_kms::Config as SoftKmsConfig, + threshold::Config as ThresholdConfig, +}; use penumbra_keys::FullViewingKey; /// Configuration data for `pcli`. @@ -50,6 +53,7 @@ impl PcliConfig { spend_key.full_viewing_key() } Some(GovernanceCustodyConfig::Threshold(threshold_config)) => threshold_config.fvk(), + Some(GovernanceCustodyConfig::Encrypted { fvk, .. }) => fvk, None => &self.full_viewing_key, }; GovernanceKey(fvk.spend_verification_key().clone()) @@ -67,6 +71,8 @@ pub enum CustodyConfig { SoftKms(SoftKmsConfig), /// A manual threshold custody service. Threshold(ThresholdConfig), + /// An encrypted custody service. + Encrypted(EncryptedConfig), } /// The governance custody backend to use. @@ -78,6 +84,11 @@ pub enum GovernanceCustodyConfig { SoftKms(SoftKmsConfig), /// A manual threshold custody service. Threshold(ThresholdConfig), + /// An encrypted custody service. + Encrypted { + fvk: FullViewingKey, + config: EncryptedConfig, + }, } impl Default for CustodyConfig { diff --git a/crates/bin/pcli/src/opt.rs b/crates/bin/pcli/src/opt.rs index b9ce54a565..c1ed746db0 100644 --- a/crates/bin/pcli/src/opt.rs +++ b/crates/bin/pcli/src/opt.rs @@ -76,6 +76,13 @@ impl Opt { let custody_svc = CustodyServiceServer::new(threshold_kms); CustodyServiceClient::new(box_grpc_svc::local(custody_svc)) } + CustodyConfig::Encrypted(config) => { + tracing::info!("using encrypted custody service"); + let encrypted_kms = + penumbra_custody::encrypted::Encrypted::new(config.clone(), ActualTerminal); + let custody_svc = CustodyServiceServer::new(encrypted_kms); + CustodyServiceClient::new(box_grpc_svc::local(custody_svc)) + } }; // Build the governance custody service... @@ -98,6 +105,13 @@ impl Opt { let custody_svc = CustodyServiceServer::new(threshold_kms); CustodyServiceClient::new(box_grpc_svc::local(custody_svc)) } + GovernanceCustodyConfig::Encrypted { config, .. } => { + tracing::info!("using separate encrypted custody service for validator voting"); + let encrypted_kms = + penumbra_custody::encrypted::Encrypted::new(config.clone(), ActualTerminal); + let custody_svc = CustodyServiceServer::new(encrypted_kms); + CustodyServiceClient::new(box_grpc_svc::local(custody_svc)) + } }, None => custody.clone(), // If no separate custody for validator voting, use the same one }; diff --git a/crates/bin/pcli/src/terminal.rs b/crates/bin/pcli/src/terminal.rs index 6d827f9808..dd826ceea4 100644 --- a/crates/bin/pcli/src/terminal.rs +++ b/crates/bin/pcli/src/terminal.rs @@ -1,12 +1,34 @@ -use std::io::{Read, Write}; +use std::io::{IsTerminal, Read, Write}; use anyhow::Result; use penumbra_custody::threshold::{SigningRequest, Terminal}; +use termion::input::TermRead; use tonic::async_trait; +async fn read_password(prompt: &str) -> Result { + fn get_possibly_empty_string(prompt: &str) -> Result { + // The `rpassword` crate doesn't support reading from stdin, so we check + // for an interactive session. We must support non-interactive use cases, + // for integration with other tooling. + if std::io::stdin().is_terminal() { + Ok(rpassword::prompt_password(prompt)?) + } else { + Ok(std::io::stdin().lock().read_line()?.unwrap_or_default()) + } + } + + let mut string: String = Default::default(); + while string.is_empty() { + // Keep trying until the user provides an input + string = get_possibly_empty_string(prompt)?; + } + Ok(string) +} + /// For threshold custody, we need to implement this weird terminal abstraction. /// /// This actually does stuff to stdin and stdout. +#[derive(Clone)] pub struct ActualTerminal; #[async_trait] @@ -78,4 +100,22 @@ impl Terminal for ActualTerminal { } Ok(Some(line)) } + + async fn get_password(&self) -> Result { + read_password("Enter Password: ").await + } +} + +impl ActualTerminal { + pub async fn get_confirmed_password(&self) -> Result { + loop { + let password = read_password("Enter Password: ").await?; + let confirmed = read_password("Confirm Password: ").await?; + if password != confirmed { + println!("Password mismatch, please try again."); + continue; + } + return Ok(password); + } + } } diff --git a/crates/custody/Cargo.toml b/crates/custody/Cargo.toml index 5d0a662a3b..d93683643d 100644 --- a/crates/custody/Cargo.toml +++ b/crates/custody/Cargo.toml @@ -5,6 +5,7 @@ edition = {workspace = true} [dependencies] anyhow = {workspace = true} +argon2 = "0.5" ark-ff = {workspace = true} ark-serialize = {workspace = true} base64 = {workspace = true} diff --git a/crates/custody/src/encrypted.rs b/crates/custody/src/encrypted.rs new file mode 100644 index 0000000000..827e510ab7 --- /dev/null +++ b/crates/custody/src/encrypted.rs @@ -0,0 +1,278 @@ +use penumbra_proto::custody::v1::{self as pb, AuthorizeResponse}; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; +use serde_with::{formats::Uppercase, hex::Hex}; +use tokio::sync::OnceCell; +use tonic::{async_trait, Request, Response, Status}; + +use crate::{soft_kms, terminal::Terminal, threshold}; + +mod encryption { + use anyhow::anyhow; + use chacha20poly1305::{ + aead::{AeadInPlace, NewAead}, + ChaCha20Poly1305, + }; + use rand_core::CryptoRngCore; + + /// Represents a password that has been validated for length, and won't cause argon2 errors + #[derive(Clone, Copy)] + pub struct Password<'a>(&'a str); + + impl<'a> Password<'a> { + /// Create a new password, validating its length + pub fn new(password: &'a str) -> anyhow::Result { + anyhow::ensure!(password.len() < argon2::MAX_PWD_LEN, "password too long"); + Ok(Self(password)) + } + } + + impl<'a> TryFrom<&'a str> for Password<'a> { + type Error = anyhow::Error; + + fn try_from(value: &'a str) -> Result { + Self::new(value) + } + } + + // These can be recomputed from the library, at the cost of importing 25 billion traits. + const SALT_SIZE: usize = 32; + const TAG_SIZE: usize = 16; + const KEY_SIZE: usize = 32; + + fn derive_key(salt: &[u8; SALT_SIZE], password: Password<'_>) -> [u8; KEY_SIZE] { + let mut key = [0u8; KEY_SIZE]; + // The only reason this function should fail is because of incorrect static parameters + // we've chosen, since we've validated the length of the password. + argon2::Argon2::hash_password_into( + // Default from the crate, but hardcoded so it doesn't change under us, and following https://datatracker.ietf.org/doc/html/rfc9106. + &argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + argon2::Params::new(1 << 21, 1, 4, Some(KEY_SIZE)) + .expect("the parameters should be valid"), + ), + password.0.as_bytes(), + salt, + &mut key, + ) + .expect("password hashing should not fail with a small enough password"); + key + } + + pub fn encrypt(rng: &mut impl CryptoRngCore, password: Password<'_>, data: &[u8]) -> Vec { + // The scheme here is that we derive a new salt, used that to derive a new unique key + // from the password, then store the salt alongside the ciphertext, and its tag. + // The salt needs to go into the AD section, because we don't want it to be modified, + // since we're not using a key-committing encryption scheme, and a different key may + // successfully decrypt the ciphertext. + let salt = { + let mut out = [0u8; SALT_SIZE]; + rng.fill_bytes(&mut out); + out + }; + let key = derive_key(&salt, password); + + let mut ciphertext = Vec::with_capacity(TAG_SIZE + salt.len() + data.len()); + ciphertext.extend_from_slice(&[0u8; TAG_SIZE]); + ciphertext.extend_from_slice(&salt); + ciphertext.extend_from_slice(&data); + let tag = ChaCha20Poly1305::new(&key.into()) + .encrypt_in_place_detached( + &Default::default(), + &salt, + &mut ciphertext[TAG_SIZE + SALT_SIZE..], + ) + .expect("XChaCha20Poly1305 encryption should not fail"); + ciphertext[0..TAG_SIZE].copy_from_slice(&tag); + ciphertext + } + + pub fn decrypt(password: Password<'_>, data: &[u8]) -> anyhow::Result> { + anyhow::ensure!( + data.len() >= TAG_SIZE + SALT_SIZE, + "provided ciphertext is too short" + ); + let (header, message) = data.split_at(TAG_SIZE + SALT_SIZE); + let mut message = message.to_owned(); + let tag = &header[..TAG_SIZE]; + let salt = &header[TAG_SIZE..TAG_SIZE + SALT_SIZE]; + let key = derive_key( + &salt.try_into().expect("salt is the right length"), + password, + ); + ChaCha20Poly1305::new(&key.into()) + .decrypt_in_place_detached(&Default::default(), &salt, &mut message, tag.into()) + .map_err(|_| anyhow!("failed to decrypt ciphertext"))?; + Ok(message) + } + + #[cfg(test)] + mod test { + use rand_core::OsRng; + + use super::*; + + #[test] + fn test_encryption_decryption_roundtrip() -> anyhow::Result<()> { + let password = "password".try_into()?; + let message = b"hello world"; + let encrypted = encrypt(&mut OsRng, password, message); + let decrypted = decrypt(password, &encrypted)?; + assert_eq!(decrypted.as_slice(), message); + Ok(()) + } + + #[test] + fn test_encryption_fails_with_different_password() -> anyhow::Result<()> { + let password = "password".try_into()?; + let message = b"hello world"; + let encrypted = encrypt(&mut OsRng, password, message); + let decrypted = decrypt("not password".try_into()?, &encrypted); + assert!(decrypted.is_err()); + Ok(()) + } + } +} + +use encryption::{decrypt, encrypt}; + +/// The actual inner configuration used for an encrypted configuration. +#[derive(Serialize, Deserialize)] +pub enum InnerConfig { + SoftKms(soft_kms::Config), + Threshold(threshold::Config), +} + +impl InnerConfig { + pub fn from_bytes(data: &[u8]) -> anyhow::Result { + Ok(serde_json::from_slice(data)?) + } + + pub fn to_bytes(self) -> anyhow::Result> { + Ok(serde_json::to_vec(&self)?) + } +} + +/// The configuration for the encrypted custody backend. +/// +/// This holds a blob of encrypted data that needs to be further deserialized into another config. +#[serde_as] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Config { + #[serde_as(as = "Hex")] + data: Vec, +} + +impl Config { + /// Create a config from an inner config, with the actual params, and an encryption password. + pub fn create(password: &str, inner: InnerConfig) -> anyhow::Result { + let password = password.try_into()?; + Ok(Self { + data: encrypt(&mut OsRng, password, &inner.to_bytes()?), + }) + } + + fn decrypt(self, password: &str) -> anyhow::Result { + let decrypted_data = decrypt(password.try_into()?, &self.data)?; + Ok(InnerConfig::from_bytes(&decrypted_data)?) + } + + // Attempt to convert this to a threshold config, if possible + pub fn convert_to_threshold(self, password: &str) -> anyhow::Result> { + match self.decrypt(password)? { + InnerConfig::SoftKms(_) => Ok(None), + InnerConfig::Threshold(c) => Ok(Some(c)), + } + } +} + +/// Represents a custody service that uses an encrypted configuration. +/// +/// This service wraps either the threshold or solo custody service. +pub struct Encrypted { + config: Config, + terminal: T, + inner: OnceCell>>, +} + +impl Encrypted { + /// Create a new encrypted config, using the terminal to ask for a password + pub fn new(config: Config, terminal: T) -> Self { + Self { + config, + terminal, + inner: Default::default(), + } + } + + async fn get_inner(&self) -> Result<&dyn pb::custody_service_server::CustodyService, Status> { + Ok(self + .inner + .get_or_init(|| async { + let password = self.terminal.get_password().await?; + + let inner = self.config.clone().decrypt(&password)?; + let out: Box = match inner { + InnerConfig::SoftKms(c) => Box::new(soft_kms::SoftKms::new(c)), + InnerConfig::Threshold(c) => { + Box::new(threshold::Threshold::new(c, self.terminal.clone())) + } + }; + Ok(out) + }) + .await + .as_ref() + .map_err(|e| Status::unauthenticated(format!("failed to initialize custody {e}")))? + .as_ref()) + } +} + +#[async_trait] +impl pb::custody_service_server::CustodyService + for Encrypted +{ + async fn authorize( + &self, + request: Request, + ) -> Result, Status> { + self.get_inner().await?.authorize(request).await + } + + async fn authorize_validator_definition( + &self, + request: Request, + ) -> Result, Status> { + self.get_inner() + .await? + .authorize_validator_definition(request) + .await + } + + async fn authorize_validator_vote( + &self, + request: Request, + ) -> Result, Status> { + self.get_inner() + .await? + .authorize_validator_vote(request) + .await + } + + async fn export_full_viewing_key( + &self, + request: Request, + ) -> Result, Status> { + self.get_inner() + .await? + .export_full_viewing_key(request) + .await + } + + async fn confirm_address( + &self, + request: Request, + ) -> Result, Status> { + self.get_inner().await?.confirm_address(request).await + } +} diff --git a/crates/custody/src/lib.rs b/crates/custody/src/lib.rs index fd48986a90..c8c90df608 100644 --- a/crates/custody/src/lib.rs +++ b/crates/custody/src/lib.rs @@ -13,7 +13,9 @@ extern crate serde_with; mod client; mod pre_auth; mod request; +mod terminal; +pub mod encrypted; pub mod null_kms; pub mod policy; pub mod soft_kms; diff --git a/crates/custody/src/terminal.rs b/crates/custody/src/terminal.rs new file mode 100644 index 0000000000..b3b313af55 --- /dev/null +++ b/crates/custody/src/terminal.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use penumbra_governance::ValidatorVoteBody; +use penumbra_stake::validator::Validator; +use penumbra_transaction::TransactionPlan; +use tonic::async_trait; + +#[derive(Debug, Clone)] +pub enum SigningRequest { + TransactionPlan(TransactionPlan), + ValidatorDefinition(Validator), + ValidatorVote(ValidatorVoteBody), +} +/// A trait abstracting over the kind of terminal interface we expect. +/// +/// This is mainly used to accommodate the kind of interaction we have with the CLI +/// interface, but it can also be plugged in with more general backends. +#[async_trait] +pub trait Terminal { + /// Have a user confirm that they want to sign this transaction or other data (e.g. validator + /// definition, validator vote) + /// + /// In an actual terminal, this should display the data to be signed in a human readable + /// form, and then get feedback from the user. + async fn confirm_request(&self, request: &SigningRequest) -> Result; + + /// Push an explanatory message to the terminal. + /// + /// This message has no relation to the actual protocol, it just allows explaining + /// what subsequent data means, and what the user needs to do. + /// + /// Backends can replace this with a no-op. + async fn explain(&self, msg: &str) -> Result<()>; + + /// Broadcast a message to other users. + async fn broadcast(&self, data: &str) -> Result<()>; + + /// Wait for a response from *some* other user, it doesn't matter which. + /// + /// This function should not return None spuriously, when it does, + /// it should continue to return None until a message is broadcast. + async fn next_response(&self) -> Result>; + + /// Wait for the user to supply a password. + async fn get_password(&self) -> Result; +} diff --git a/crates/custody/src/threshold.rs b/crates/custody/src/threshold.rs index 48e10d0f86..17ded7e7be 100644 --- a/crates/custody/src/threshold.rs +++ b/crates/custody/src/threshold.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, Result}; +use penumbra_transaction::AuthorizationData; use rand_core::OsRng; use serde::{Deserialize, Serialize}; use tonic::{async_trait, Request, Response, Status}; @@ -10,12 +11,30 @@ use crate::{AuthorizeRequest, AuthorizeValidatorDefinitionRequest, AuthorizeVali pub use self::config::Config; use self::sign::no_signature_response; -pub use self::sign::{SigningRequest, SigningResponse}; +pub use crate::terminal::{SigningRequest, Terminal}; mod config; mod dkg; mod sign; +/// Authorization data returned in response to some signing request, which may be a request to +/// authorize a transaction, a validator definition, or a validator vote. +#[derive(Clone, Debug)] +pub enum SigningResponse { + /// Authorization data for a transaction. + Transaction(AuthorizationData), + /// Authorization signature for a validator definition. + ValidatorDefinition(decaf377_rdsa::Signature), + /// Authorization signature for a validator vote. + ValidatorVote(decaf377_rdsa::Signature), +} + +impl From for SigningResponse { + fn from(msg: AuthorizationData) -> Self { + Self::Transaction(msg) + } +} + fn to_json(data: &T) -> Result where T: DomainType, @@ -34,37 +53,6 @@ where Ok(serde_json::from_str::<::Proto>(data)?.try_into()?) } -/// A trait abstracting over the kind of terminal interface we expect. -/// -/// This is mainly used to accommodate the kind of interaction we have with the CLI -/// interface, but it can also be plugged in with more general backends. -#[async_trait] -pub trait Terminal { - /// Have a user confirm that they want to sign this transaction or other data (e.g. validator - /// definition, validator vote). - /// - /// In an actual terminal, this should display the data to be signed in a human readable - /// form, and then get feedback from the user. - async fn confirm_request(&self, request: &SigningRequest) -> Result; - - /// Push an explanatory message to the terminal. - /// - /// This message has no relation to the actual protocol, it just allows explaining - /// what subsequent data means, and what the user needs to do. - /// - /// Backends can replace this with a no-op. - async fn explain(&self, msg: &str) -> Result<()>; - - /// Broadcast a message to other users. - async fn broadcast(&self, data: &str) -> Result<()>; - - /// Wait for a response from *some* other user, it doesn't matter which. - /// - /// This function should not return None spuriously, when it does, - /// it should continue to return None until a message is broadcast. - async fn next_response(&self) -> Result>; -} - /// Act as a follower in the signing protocol. /// /// All this function does is produce side effects on the terminal, potentially returning @@ -426,6 +414,10 @@ mod test { async fn next_response(&self) -> Result> { Ok(self.incoming.lock().await.recv().await) } + + async fn get_password(&self) -> Result { + Ok(Default::default()) + } } struct CoordinatorTerminalInner { @@ -466,6 +458,10 @@ mod test { async fn next_response(&self) -> Result> { Ok(self.incoming.lock().await.recv().await) } + + async fn get_password(&self) -> Result { + Ok(Default::default()) + } } fn make_terminals(follower_count: usize) -> (CoordinatorTerminal, Vec) { diff --git a/crates/custody/src/threshold/sign.rs b/crates/custody/src/threshold/sign.rs index b23c6024e2..7e9deff5aa 100644 --- a/crates/custody/src/threshold/sign.rs +++ b/crates/custody/src/threshold/sign.rs @@ -10,17 +10,17 @@ use rand_core::CryptoRngCore; use decaf377_frost as frost; use frost::round1::SigningCommitments; -use penumbra_governance::ValidatorVoteBody; use penumbra_proto::core::component::{ governance::v1::ValidatorVoteBody as ProtoValidatorVoteBody, stake::v1::Validator as ProtoValidator, }; use penumbra_proto::{penumbra::custody::threshold::v1 as pb, DomainType, Message}; -use penumbra_stake::validator::Validator; -use penumbra_transaction::{AuthorizationData, TransactionPlan}; +use penumbra_transaction::AuthorizationData; use penumbra_txhash::EffectHash; -use super::config::Config; +use crate::terminal::SigningRequest; + +use super::{config::Config, SigningResponse}; /// Represents the message sent by the coordinator at the start of the signing process. /// @@ -30,31 +30,6 @@ pub struct CoordinatorRound1 { request: SigningRequest, } -#[derive(Debug, Clone)] -pub enum SigningRequest { - TransactionPlan(TransactionPlan), - ValidatorDefinition(Validator), - ValidatorVote(ValidatorVoteBody), -} - -/// Authorization data returned in response to some signing request, which may be a request to -/// authorize a transaction, a validator definition, or a validator vote. -#[derive(Clone, Debug)] -pub enum SigningResponse { - /// Authorization data for a transaction. - Transaction(AuthorizationData), - /// Authorization signature for a validator definition. - ValidatorDefinition(decaf377_rdsa::Signature), - /// Authorization signature for a validator vote. - ValidatorVote(decaf377_rdsa::Signature), -} - -impl From for SigningResponse { - fn from(msg: AuthorizationData) -> Self { - Self::Transaction(msg) - } -} - impl CoordinatorRound1 { /// View the transaction plan associated with the first message. /// diff --git a/docs/guide/src/pcli/wallet.md b/docs/guide/src/pcli/wallet.md index 5bbbfaa108..ec8105d82f 100644 --- a/docs/guide/src/pcli/wallet.md +++ b/docs/guide/src/pcli/wallet.md @@ -8,9 +8,11 @@ custody backend used to store keys. There are currently three custody backends: 1. The [`softkms` backend](./wallet/softkms.md) is a good default choice for low-security use cases. It stores keys unencrypted in a local config file. -2. The [threshold backend](./wallet/threshold.md) is a good choice for high-security use cases. It provides a shielded multisig, with key material sharded over multiple computers. +2. The [`threshold` backend](./wallet/threshold.md) is a good choice for high-security use cases. It provides a shielded multisig, with key material sharded over multiple computers. 3. The `view-only` backend has no custody at all and only has access to viewing keys. +Furthermore, `softkms` and `threshold` allow encrypting the spend-key related material with a password. + After running `pcli init` with one of the backends described above, `pcli` will be initialized. ## Shielded accounts @@ -123,4 +125,4 @@ definition updates or governance votes, this is possible: Alternatively, rather than using a literal airgap, [magic wormhole](https://magic-wormhole.readthedocs.io/en/latest/) is a fast and secure -method for relaying data between computers without complex network interactions. \ No newline at end of file +method for relaying data between computers without complex network interactions. diff --git a/docs/guide/src/pcli/wallet/softkms.md b/docs/guide/src/pcli/wallet/softkms.md index 6a93aa2de4..31600f6012 100644 --- a/docs/guide/src/pcli/wallet/softkms.md +++ b/docs/guide/src/pcli/wallet/softkms.md @@ -17,4 +17,18 @@ Alternatively, to import an existing wallet, try $ pcli init soft-kms import-phrase Enter seed phrase: Writing generated config to [PATH TO PCLI DATA] -``` \ No newline at end of file +``` + +## Encryption + +A password can be used to generate an encrypted config via: +```bash +$ pcli init --encrypted soft-kms ... +``` +with either the `generate`, or the `import-phrase` command. + +Furthermore, an existing config can be converted to an encrypted one with: +```bash +$ pcli init re-encrypt +``` + diff --git a/docs/guide/src/pcli/wallet/threshold.md b/docs/guide/src/pcli/wallet/threshold.md index 1ab80a557f..75cfc6f634 100644 --- a/docs/guide/src/pcli/wallet/threshold.md +++ b/docs/guide/src/pcli/wallet/threshold.md @@ -81,3 +81,14 @@ the participants securely. An end-to-end example of how this process works is captured in this video: [https://twitter.com/penumbrazone/status/1732844637180862603](https://twitter.com/penumbrazone/status/1732844637180862603) +## Encryption + +A password can be used to generate an encrypted config via: +```bash +$ pcli init --encrypted threshold dkg ... +``` + +Furthermore, an existing config can be converted to an encrypted one with: +```bash +$ pcli init re-encrypt +```