diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 08a8694..ebaf81f 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -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, ) -> Result { - 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, ) -> Result { - 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 { diff --git a/server/crates/arbiter-server/src/crypto/governance.rs b/server/crates/arbiter-server/src/crypto/governance.rs new file mode 100644 index 0000000..9d67902 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/governance.rs @@ -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 { + 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) + )); + } +} diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs index 2db08d8..338986f 100644 --- a/server/crates/arbiter-server/src/crypto/mod.rs +++ b/server/crates/arbiter-server/src/crypto/mod.rs @@ -12,6 +12,7 @@ use rand::{ }; pub mod encryption; +pub mod governance; pub mod integrity; pub mod shamir; diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 226721a..a25dea9 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -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 { - 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)