diff --git a/crates/bin/pcli/src/command/validator.rs b/crates/bin/pcli/src/command/validator.rs index f1c836d7d7..56f6600182 100644 --- a/crates/bin/pcli/src/command/validator.rs +++ b/crates/bin/pcli/src/command/validator.rs @@ -4,6 +4,8 @@ use std::{ }; use anyhow::{Context, Result}; +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use decaf377_rdsa::{Signature, SpendAuth}; use rand_core::OsRng; use serde_json::Value; @@ -35,8 +37,16 @@ pub enum ValidatorCmd { /// Manage your validator's definition. #[clap(subcommand)] Definition(DefinitionCmd), - /// Cast a vote on a proposal in your capacity as a validator (see also: `pcli tx vote`). - Vote { + /// Submit and sign votes in your capacity as a validator. + #[clap(subcommand)] + Vote(VoteCmd), +} + +#[derive(Debug, clap::Subcommand)] +pub enum VoteCmd { + /// Cast a vote on a proposal in your capacity as a validator (see also: `pcli tx vote` for + /// delegator voting). + Cast { /// The transaction fee (paid in upenumbra). #[clap(long, default_value = "0", global = true, display_order = 200)] fee: u64, @@ -49,12 +59,30 @@ pub enum ValidatorCmd { /// A comment or justification of the vote. Limited to 1 KB. #[clap(long, default_value = "", global = true, display_order = 400)] reason: String, + /// Use an externally-provided signature to authorize the vote. + /// + /// This is useful for offline signing, e.g. in an airgap setup. The signature for the + /// vote may be generated using the `pcli validator vote sign` command. + #[clap(long, global = true, display_order = 500)] + signature: Option, + }, + /// Sign a vote on a proposal in your capacity as a validator, for submission elsewhere. + Sign { + /// The vote to sign. + #[clap(subcommand)] + vote: super::tx::VoteCmd, + /// A comment or justification of the vote. Limited to 1 KB. + #[clap(long, default_value = "", global = true, display_order = 400)] + reason: String, + /// The file to write the signature to [default: stdout]. + #[clap(long, global = true, display_order = 500)] + signature_file: Option, }, } #[derive(Debug, clap::Subcommand)] pub enum DefinitionCmd { - /// Create a ValidatorDefinition transaction to create or update a validator. + /// Submit a ValidatorDefinition transaction to create or update a validator. Upload { /// The TOML file containing the ValidatorDefinition to upload. #[clap(long)] @@ -65,6 +93,21 @@ pub enum DefinitionCmd { /// Optional. Only spend funds originally received by the given account. #[clap(long, default_value = "0")] source: u32, + /// Use an externally-provided signature to authorize the validator definition. + /// + /// This is useful for offline signing, e.g. in an airgap setup. The signature for the + /// definition may be generated using the `pcli validator definition sign` command. + #[clap(long)] + signature: Option, + }, + /// Sign a validator definition offline for submission elsewhere. + Sign { + /// The TOML file containing the ValidatorDefinition to sign. + #[clap(long)] + file: String, + /// The file to write the signature to [default: stdout]. + #[clap(long)] + signature_file: Option, }, /// Generates a template validator definition for editing. /// @@ -94,22 +137,19 @@ impl ValidatorCmd { pub fn offline(&self) -> bool { match self { ValidatorCmd::Identity { .. } => true, - ValidatorCmd::Definition(DefinitionCmd::Upload { .. }) => false, ValidatorCmd::Definition( - DefinitionCmd::Template { .. } | DefinitionCmd::Fetch { .. }, + DefinitionCmd::Template { .. } | DefinitionCmd::Sign { .. }, ) => true, - ValidatorCmd::Vote { .. } => false, + ValidatorCmd::Definition( + DefinitionCmd::Upload { .. } | DefinitionCmd::Fetch { .. }, + ) => false, + ValidatorCmd::Vote(VoteCmd::Sign { .. }) => true, + ValidatorCmd::Vote(VoteCmd::Cast { .. }) => false, } } // TODO: move use of sk into custody service pub async fn exec(&self, app: &mut App) -> Result<()> { - let sk = match &app.config.custody { - CustodyConfig::SoftKms(config) => config.spend_key.clone(), - _ => { - anyhow::bail!("Validator commands require SoftKMS backend"); - } - }; let fvk = app.config.full_viewing_key.clone(); match self { @@ -123,33 +163,98 @@ impl ValidatorCmd { println!("{ik}"); } } - ValidatorCmd::Definition(DefinitionCmd::Upload { file, fee, source }) => { - // The definitions are stored in a JSON document, - // however for ease of use it's best for us to generate - // the signature here based on the configured wallet. - // - // TODO: eventually we'll probably want to support defining the - // identity key in the JSON file. - // - // We could also support defining multiple validators in a single - // file. - let mut definition_file = - File::open(file).with_context(|| format!("cannot open file {file:?}"))?; - let mut definition: String = String::new(); - definition_file - .read_to_string(&mut definition) - .with_context(|| format!("failed to read file {file:?}"))?; - let new_validator: ValidatorToml = - toml::from_str(&definition).context("Unable to parse validator definition")?; - let new_validator: Validator = new_validator - .try_into() - .context("Unable to parse validator definition")?; - let fee = Fee::from_staking_token_amount((*fee).into()); + ValidatorCmd::Definition(DefinitionCmd::Sign { + file, + signature_file, + }) => { + let new_validator = read_validator_toml(file)?; - // Sign the validator definition with the wallet's spend key. + let input_file_path = std::fs::canonicalize(file) + .with_context(|| format!("invalid path: {file:?}"))?; + let input_file_name = input_file_path + .file_name() + .with_context(|| format!("invalid path: {file:?}"))?; + + // TODO: use the custody abstraction to sign let protobuf_serialized: ProtoValidator = new_validator.clone().into(); let v_bytes = protobuf_serialized.encode_to_vec(); - let auth_sig = sk.spend_auth_key().sign(OsRng, &v_bytes); + let sk = match &app.config.custody { + CustodyConfig::SoftKms(config) => config.spend_key.clone(), + _ => { + anyhow::bail!( + "local validator definition signing currently requires SoftKMS backend" + ); + } + }; + let signature = sk.spend_auth_key().sign(OsRng, &v_bytes); + + if let Some(output_file) = signature_file { + let output_file_path = std::fs::canonicalize(output_file) + .with_context(|| format!("invalid path: {output_file:?}"))?; + let output_file_name = output_file_path + .file_name() + .with_context(|| format!("invalid path: {output_file:?}"))?; + File::create(output_file) + .with_context(|| format!("cannot create file {output_file:?}"))? + .write_all(URL_SAFE.encode(signature.encode_to_vec()).as_bytes()) + .with_context(|| format!("could not write file {output_file:?}"))?; + println!( + "Signed validator definition #{} for {}\nWrote signature to {output_file_path:?}", + new_validator.sequence_number, + new_validator.identity_key, + ); + println!( + "To upload the definition, use the below command with the exact same definition file:\n\n $ pcli validator definition upload --file {:?} --signature - < {:?}", + input_file_name, + output_file_name, + ); + } else { + println!( + "Signed validator defintion #{} for {}\nTo upload the definition, use the below command with the exact same definition file:\n\n $ pcli validator definition upload --file {:?} \\\n --signature {}", + new_validator.sequence_number, + new_validator.identity_key, + input_file_name, + URL_SAFE.encode(signature.encode_to_vec()) + ); + } + } + ValidatorCmd::Definition(DefinitionCmd::Upload { + file, + fee, + source, + signature, + }) => { + let new_validator = read_validator_toml(file)?; + let fee = Fee::from_staking_token_amount((*fee).into()); + + // Sign the validator definition with the wallet's spend key, or instead attach the + // provided signature if present. + let auth_sig = if let Some(signature) = signature { + // The user can specify `-` to read the signature from stdin. + let mut signature = signature.clone(); + if signature == "-" { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + signature = buf; + } + as penumbra_proto::DomainType>::decode( + &URL_SAFE + .decode(signature) + .context("unable to decode signature as base64")?[..], + ) + .context("unable to parse decoded signature")? + } else { + // TODO: use the custody abstraction to sign + let protobuf_serialized: ProtoValidator = new_validator.clone().into(); + let v_bytes = protobuf_serialized.encode_to_vec(); + let sk = match &app.config.custody { + CustodyConfig::SoftKms(config) => config.spend_key.clone(), + _ => { + anyhow::bail!("local validator definition signing currently requires SoftKMS backend"); + } + }; + sk.spend_auth_key().sign(OsRng, &v_bytes) + }; let vd = validator::Definition { validator: new_validator, auth_sig, @@ -172,15 +277,15 @@ impl ValidatorCmd { // never appear on-chain. println!("Uploaded validator definition"); } - ValidatorCmd::Vote { - fee, - source, + ValidatorCmd::Vote(VoteCmd::Sign { vote, reason, - } => { - // TODO: support submitting a separate governance key. - let identity_key = IdentityKey(*sk.full_viewing_key().spend_verification_key()); + signature_file, + }) => { + let identity_key = IdentityKey(fvk.spend_verification_key().clone()); + // Currently this is always just copied from the identity key + // TODO: support a separate governance key let governance_key = GovernanceKey(identity_key.0); let (proposal, vote): (u64, Vote) = (*vote).into(); @@ -198,12 +303,101 @@ impl ValidatorCmd { reason: ValidatorVoteReason(reason.clone()), }; - // TODO: support signing with a separate governance key + // TODO: support signing with a separate governance key rather than the spend auth key + let sk = match &app.config.custody { + CustodyConfig::SoftKms(config) => config.spend_key.clone(), + _ => { + anyhow::bail!( + "local validator vote signing currently requires SoftKMS backend" + ); + } + }; let governance_auth_key = sk.spend_auth_key(); // Generate an authorizing signature with the governance key for the vote body let body_bytes = body.encode_to_vec(); - let auth_sig = governance_auth_key.sign(OsRng, &body_bytes); + let signature = governance_auth_key.sign(OsRng, &body_bytes); + + if let Some(signature_file) = signature_file { + File::create(signature_file) + .with_context(|| format!("cannot create file {signature_file:?}"))? + .write_all(URL_SAFE.encode(signature.encode_to_vec()).as_bytes()) + .context("could not write file")?; + let output_file_path = std::fs::canonicalize(signature_file) + .with_context(|| format!("invalid path: {signature_file:?}"))?; + println!( + "Signed validator vote {vote} on proposal #{proposal} by {identity_key}\nWrote signature to {output_file_path:?}", + ); + println!( + "To cast the vote, use the below command:\n\n $ pcli validator vote cast {vote} --on {proposal} --reason {reason:?} --signature - < {signature_file:?}", + ); + } else { + println!( + "Signed validator vote {vote} on proposal #{proposal} by {identity_key}\nTo cast the vote, use the below command:\n\n $ pcli validator vote cast {vote} --on {proposal} --reason {reason:?} \\\n --signature {}", + URL_SAFE.encode(signature.encode_to_vec()) + ); + } + } + ValidatorCmd::Vote(VoteCmd::Cast { + fee, + source, + vote, + reason, + signature, + }) => { + let identity_key = IdentityKey(fvk.spend_verification_key().clone()); + + // Currently this is always just copied from the identity key + // TODO: support a separate governance key + let governance_key = GovernanceKey(identity_key.0); + + let (proposal, vote): (u64, Vote) = (*vote).into(); + + if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH { + anyhow::bail!("validator vote reason is too long, max 1024 bytes"); + } + + // Construct the vote body + let body = ValidatorVoteBody { + proposal, + vote, + identity_key, + governance_key, + reason: ValidatorVoteReason(reason.clone()), + }; + + // If the user specified a signature, use it. Otherwise, generate a new signature + // using local custody + let auth_sig = if let Some(signature) = signature { + // The user can specify `-` to read the signature from stdin. + let mut signature = signature.clone(); + if signature == "-" { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + signature = buf; + } + as penumbra_proto::DomainType>::decode( + &URL_SAFE + .decode(signature) + .context("unable to decode signature as base64")?[..], + ) + .context("unable to parse decoded signature")? + } else { + // TODO: support signing with a separate governance key rather than the spend auth key + let sk = match &app.config.custody { + CustodyConfig::SoftKms(config) => config.spend_key.clone(), + _ => { + anyhow::bail!( + "local validator vote signing currently requires SoftKMS backend" + ); + } + }; + let governance_auth_key = sk.spend_auth_key(); + + // Generate an authorizing signature with the governance key for the vote body + let body_bytes = body.encode_to_vec(); + governance_auth_key.sign(OsRng, &body_bytes) + }; let vote = ValidatorVote { body, auth_sig }; @@ -338,3 +532,19 @@ fn generate_new_tendermint_keypair() -> anyhow::Result { let priv_consensus_key = tendermint::PrivateKey::Ed25519(slice_signing_key.try_into()?); Ok(priv_consensus_key) } + +/// Parse a validator definition TOML file and return the parsed definition. +fn read_validator_toml(file: &str) -> Result { + let mut definition_file = + File::open(file).with_context(|| format!("cannot open file {file:?}"))?; + let mut definition: String = String::new(); + definition_file + .read_to_string(&mut definition) + .with_context(|| format!("failed to read file {file:?}"))?; + let new_validator: ValidatorToml = + toml::from_str(&definition).context("unable to parse validator definition")?; + let new_validator: Validator = new_validator + .try_into() + .context("unable to parse validator definition")?; + Ok(new_validator) +} diff --git a/docs/guide/src/pcli/governance.md b/docs/guide/src/pcli/governance.md index e10bbc9a5c..467ef3dff3 100644 --- a/docs/guide/src/pcli/governance.md +++ b/docs/guide/src/pcli/governance.md @@ -67,13 +67,17 @@ received when voting in real life at your polling place. ### Voting As A Validator If you are a validator who was active when the proposal started, you can vote on it using the -`validator vote` subcommand of `pcli`. For example, if you wanted to vote "yes" on proposal 1, you +`validator vote cast` subcommand of `pcli`. For example, if you wanted to vote "yes" on proposal 1, you would do: ```bash -pcli validator vote yes --on 1 +pcli validator vote cast yes --on 1 ``` +If your validator uses an airgap custody setup, you can separately sign and cast your vote using the +`pcli validator vote sign` command to output your signature, and the `--signature` option on `pcli +validator vote cast` to attach it and broadcast it. + ### Eligibility And Voting Power Only validators who were active at the time the proposal started voting may vote on proposals. Only