refactor(crypto): extract governance vote message and verification helpers
This commit is contained in:
@@ -4,6 +4,7 @@ use crate::{
|
|||||||
vault::Vault,
|
vault::Vault,
|
||||||
vault_coordinator::{StartRekey, VaultCoordinator},
|
vault_coordinator::{StartRekey, VaultCoordinator},
|
||||||
},
|
},
|
||||||
|
crypto::governance,
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
functions::unixepoch,
|
functions::unixepoch,
|
||||||
@@ -221,8 +222,6 @@ impl ProposalManager {
|
|||||||
approve: bool,
|
approve: bool,
|
||||||
signature: Vec<u8>,
|
signature: Vec<u8>,
|
||||||
) -> Result<VoteOutcome, Error> {
|
) -> Result<VoteOutcome, Error> {
|
||||||
use arbiter_crypto::authn::{self, SigningContext};
|
|
||||||
|
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
// Load proposal — must exist
|
// Load proposal — must exist
|
||||||
@@ -266,20 +265,8 @@ impl ProposalManager {
|
|||||||
other => Error::DatabaseQuery(other),
|
other => Error::DatabaseQuery(other),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature)
|
||||||
.map_err(|()| Error::InvalidSignature)?;
|
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert vote
|
// Insert vote
|
||||||
diesel::insert_into(schema::proposal_vote::table)
|
diesel::insert_into(schema::proposal_vote::table)
|
||||||
@@ -429,8 +416,6 @@ impl ProposalManager {
|
|||||||
approve: bool,
|
approve: bool,
|
||||||
signature: Vec<u8>,
|
signature: Vec<u8>,
|
||||||
) -> Result<VoteOutcome, Error> {
|
) -> Result<VoteOutcome, Error> {
|
||||||
use arbiter_crypto::authn::{self, SigningContext};
|
|
||||||
|
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
let proposal: Proposal = schema::proposal::table
|
let proposal: Proposal = schema::proposal::table
|
||||||
@@ -481,19 +466,8 @@ impl ProposalManager {
|
|||||||
other => Error::DatabaseQuery(other),
|
other => Error::DatabaseQuery(other),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature)
|
||||||
.map_err(|()| Error::InvalidSignature)?;
|
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::insert_into(schema::recovery_proposal_vote::table)
|
diesel::insert_into(schema::recovery_proposal_vote::table)
|
||||||
.values(&NewRecoveryProposalVote {
|
.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 encryption;
|
||||||
|
pub mod governance;
|
||||||
pub mod integrity;
|
pub mod integrity;
|
||||||
pub mod shamir;
|
pub mod shamir;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use arbiter_crypto::authn::{self, SigningContext};
|
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::{
|
use arbiter_server::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
@@ -10,7 +12,7 @@ use arbiter_server::{
|
|||||||
crypto::KeyCell,
|
crypto::KeyCell,
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
models::{OperatorIdentityId, ProposalId, RecoveryOperatorIdentityId},
|
models::{OperatorIdentityId, RecoveryOperatorIdentityId},
|
||||||
proposal::{
|
proposal::{
|
||||||
ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction,
|
ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction,
|
||||||
persistent_grant, replace_operator,
|
persistent_grant, replace_operator,
|
||||||
@@ -61,13 +63,6 @@ async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: OperatorIdenti
|
|||||||
.unwrap();
|
.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 {
|
async fn insert_evm_wallet(db: &db::DatabasePool) -> i32 {
|
||||||
let mut conn = db.get().await.unwrap();
|
let mut conn = db.get().await.unwrap();
|
||||||
let aead_id: i32 = insert_into(aead_encrypted::table)
|
let aead_id: i32 = insert_into(aead_encrypted::table)
|
||||||
|
|||||||
Reference in New Issue
Block a user