refactor(crypto): extract governance vote message and verification helpers

This commit is contained in:
CleverWild
2026-08-27 16:44:22 +02:00
parent 0d29d0d532
commit 9e9672a1b1
4 changed files with 108 additions and 39 deletions

View File

@@ -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 {

View 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)
));
}
}

View File

@@ -12,6 +12,7 @@ use rand::{
};
pub mod encryption;
pub mod governance;
pub mod integrity;
pub mod shamir;

View File

@@ -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)