refactor(crypto): extract governance vote message and verification helpers
This commit is contained in:
@@ -4,6 +4,7 @@ use crate::{
|
||||
vault::Vault,
|
||||
vault_coordinator::{StartRekey, VaultCoordinator},
|
||||
},
|
||||
crypto::governance,
|
||||
db::{
|
||||
self,
|
||||
functions::unixepoch,
|
||||
@@ -221,8 +222,6 @@ impl ProposalManager {
|
||||
approve: bool,
|
||||
signature: Vec<u8>,
|
||||
) -> Result<VoteOutcome, Error> {
|
||||
use arbiter_crypto::authn::{self, SigningContext};
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
// Load proposal — must exist
|
||||
@@ -266,20 +265,8 @@ impl ProposalManager {
|
||||
other => Error::DatabaseQuery(other),
|
||||
})?;
|
||||
|
||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
// Canonical vote message: proposal_id (i64 big-endian) || approve (u8)
|
||||
let mut vote_msg = Vec::with_capacity(9);
|
||||
vote_msg.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes());
|
||||
vote_msg.push(u8::from(approve));
|
||||
|
||||
let auth_sig = authn::Signature::try_from(signature.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) {
|
||||
return Err(Error::InvalidSignature);
|
||||
}
|
||||
governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature)
|
||||
.map_err(|_| Error::InvalidSignature)?;
|
||||
|
||||
// Insert vote
|
||||
diesel::insert_into(schema::proposal_vote::table)
|
||||
@@ -429,8 +416,6 @@ impl ProposalManager {
|
||||
approve: bool,
|
||||
signature: Vec<u8>,
|
||||
) -> Result<VoteOutcome, Error> {
|
||||
use arbiter_crypto::authn::{self, SigningContext};
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let proposal: Proposal = schema::proposal::table
|
||||
@@ -481,19 +466,8 @@ impl ProposalManager {
|
||||
other => Error::DatabaseQuery(other),
|
||||
})?;
|
||||
|
||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
let mut vote_msg = Vec::with_capacity(9);
|
||||
vote_msg.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes());
|
||||
vote_msg.push(u8::from(approve));
|
||||
|
||||
let auth_sig = authn::Signature::try_from(signature.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) {
|
||||
return Err(Error::InvalidSignature);
|
||||
}
|
||||
governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature)
|
||||
.map_err(|_| Error::InvalidSignature)?;
|
||||
|
||||
diesel::insert_into(schema::recovery_proposal_vote::table)
|
||||
.values(&NewRecoveryProposalVote {
|
||||
|
||||
99
server/crates/arbiter-server/src/crypto/governance.rs
Normal file
99
server/crates/arbiter-server/src/crypto/governance.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! Canonical encoding and verification of governance vote signatures (§3.3).
|
||||
|
||||
use crate::db::models::ProposalId;
|
||||
use arbiter_crypto::authn::{self, SigningContext};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerifyError {
|
||||
#[error("Malformed operator public key")]
|
||||
PublicKey,
|
||||
#[error("Malformed vote signature")]
|
||||
Signature,
|
||||
#[error("Signature does not match this vote")]
|
||||
Mismatch,
|
||||
}
|
||||
|
||||
/// Canonical bytes an operator signs when voting: `proposal_id` as i64 big-endian,
|
||||
/// followed by the approve flag as one byte.
|
||||
///
|
||||
/// The flag is part of the message on purpose: without it an approval could be
|
||||
/// replayed as a rejection of the same proposal.
|
||||
#[must_use]
|
||||
pub fn vote_message(proposal_id: ProposalId, approve: bool) -> Vec<u8> {
|
||||
let mut message = Vec::with_capacity(9);
|
||||
message.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes());
|
||||
message.push(u8::from(approve));
|
||||
message
|
||||
}
|
||||
|
||||
/// Verifies a vote signature against an operator's stored public key.
|
||||
pub fn verify_vote(
|
||||
public_key: &[u8],
|
||||
proposal_id: ProposalId,
|
||||
approve: bool,
|
||||
signature: &[u8],
|
||||
) -> Result<(), VerifyError> {
|
||||
let public_key = authn::PublicKey::try_from(public_key).map_err(|()| VerifyError::PublicKey)?;
|
||||
let signature = authn::Signature::try_from(signature).map_err(|()| VerifyError::Signature)?;
|
||||
|
||||
if public_key.verify_message(
|
||||
&vote_message(proposal_id, approve),
|
||||
SigningContext::GovernanceVote,
|
||||
&signature,
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VerifyError::Mismatch)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{VerifyError, verify_vote, vote_message};
|
||||
use crate::db::models::ProposalId;
|
||||
use arbiter_crypto::authn::{SigningContext, SigningKey};
|
||||
|
||||
#[test]
|
||||
fn vote_message_is_the_id_then_the_approve_flag() {
|
||||
let message = vote_message(ProposalId::from_raw(0x0102), true);
|
||||
assert_eq!(message, vec![0, 0, 0, 0, 0, 0, 1, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_vote_accepts_a_matching_signature() {
|
||||
let key = SigningKey::generate();
|
||||
let id = ProposalId::from_raw(42);
|
||||
let signature = key
|
||||
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
|
||||
.unwrap();
|
||||
|
||||
verify_vote(
|
||||
&key.public_key().to_bytes(),
|
||||
id,
|
||||
true,
|
||||
&signature.to_bytes(),
|
||||
)
|
||||
.expect("a signature over this exact vote must verify");
|
||||
}
|
||||
|
||||
/// The decisive one: an approval must not verify as a rejection of the same
|
||||
/// proposal, or a captured vote could be replayed with its meaning flipped.
|
||||
#[test]
|
||||
fn verify_vote_rejects_a_flipped_approve_flag() {
|
||||
let key = SigningKey::generate();
|
||||
let id = ProposalId::from_raw(42);
|
||||
let signature = key
|
||||
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
verify_vote(
|
||||
&key.public_key().to_bytes(),
|
||||
id,
|
||||
false,
|
||||
&signature.to_bytes()
|
||||
),
|
||||
Err(VerifyError::Mismatch)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use rand::{
|
||||
};
|
||||
|
||||
pub mod encryption;
|
||||
pub mod governance;
|
||||
pub mod integrity;
|
||||
pub mod shamir;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use arbiter_crypto::authn::{self, SigningContext};
|
||||
// The tests must sign exactly what the server verifies, so they share the encoder.
|
||||
use arbiter_server::crypto::governance::vote_message as make_vote_message;
|
||||
use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
@@ -10,7 +12,7 @@ use arbiter_server::{
|
||||
crypto::KeyCell,
|
||||
db::{
|
||||
self,
|
||||
models::{OperatorIdentityId, ProposalId, RecoveryOperatorIdentityId},
|
||||
models::{OperatorIdentityId, RecoveryOperatorIdentityId},
|
||||
proposal::{
|
||||
ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction,
|
||||
persistent_grant, replace_operator,
|
||||
@@ -61,13 +63,6 @@ async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: OperatorIdenti
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn make_vote_message(proposal_id: ProposalId, approve: bool) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(9);
|
||||
msg.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes());
|
||||
msg.push(u8::from(approve));
|
||||
msg
|
||||
}
|
||||
|
||||
async fn insert_evm_wallet(db: &db::DatabasePool) -> i32 {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let aead_id: i32 = insert_into(aead_encrypted::table)
|
||||
|
||||
Reference in New Issue
Block a user