Skip to content

Commit

Permalink
Permit validator voting across an airgap (#3985)
Browse files Browse the repository at this point in the history
## Describe your changes

This addresses the first changeset described in #3813, permitting
validator definitions and votes to be signed separately and those
signatures to be broadcast from a potentially-unrelated penumbra
account.

This is a breaking change to the CLI for all workflows that use the
`validator vote` subcommand, because it redefines that command to
`validator vote cast` in order to make room for `validator vote sign`.
It is purely a client-side change, and as such is not
consensus-breaking.

## Issue ticket number and link

#3813

## Checklist before requesting a review

- [X] If this code contains consensus-breaking changes, I have added the
"consensus-breaking" label.
  • Loading branch information
plaidfinch authored Mar 12, 2024
1 parent 3a9db32 commit ec9837a
Show file tree
Hide file tree
Showing 2 changed files with 260 additions and 46 deletions.
298 changes: 254 additions & 44 deletions crates/bin/pcli/src/command/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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<String>,
},
/// 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<String>,
},
}

#[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)]
Expand All @@ -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<String>,
},
/// 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<String>,
},
/// Generates a template validator definition for editing.
///
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
<Signature<SpendAuth> 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,
Expand All @@ -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();
Expand All @@ -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;
}
<Signature<SpendAuth> 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 };

Expand Down Expand Up @@ -338,3 +532,19 @@ fn generate_new_tendermint_keypair() -> anyhow::Result<tendermint::PrivateKey> {
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<Validator> {
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)
}
8 changes: 6 additions & 2 deletions docs/guide/src/pcli/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit ec9837a

Please sign in to comment.