feat(proposal): reject proposals with an excessive TTL

This commit is contained in:
CleverWild
2026-08-26 16:47:12 +02:00
parent 6884a59325
commit 5698d1cfb3
4 changed files with 57 additions and 10 deletions

View File

@@ -20,7 +20,8 @@ use kameo::{Actor, actor::ActorRef, messages};
use strum::IntoDiscriminant as _; use strum::IntoDiscriminant as _;
use tracing::{error, warn}; use tracing::{error, warn};
pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days
pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum VoteOutcome { pub enum VoteOutcome {
@@ -37,6 +38,8 @@ pub enum Error {
ProposalNotPending, ProposalNotPending,
#[error("Proposal has expired")] #[error("Proposal has expired")]
ProposalExpired, ProposalExpired,
#[error("Requested TTL exceeds the maximum of {} seconds", MAX_TTL_SECS)]
TtlTooLong,
#[error("Operator already voted on this proposal")] #[error("Operator already voted on this proposal")]
AlreadyVoted, AlreadyVoted,
#[error("Invalid vote signature")] #[error("Invalid vote signature")]
@@ -100,10 +103,14 @@ impl ProposalManager {
&mut self, &mut self,
kind: ProposalKind, kind: ProposalKind,
initiator_id: i32, initiator_id: i32,
ttl_secs: Option<i64>, ttl_secs: Option<u32>,
) -> Result<i32, Error> { ) -> Result<i32, Error> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl)); if ttl > MAX_TTL_SECS {
return Err(Error::TtlTooLong);
}
let expires_at =
SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl)));
let new_proposal = NewProposal { let new_proposal = NewProposal {
kind: kind.discriminant(), kind: kind.discriminant(),

View File

@@ -72,10 +72,11 @@ async fn handle_create(
} }
None => return Err(Status::invalid_argument("Missing proposal kind")), None => return Err(Status::invalid_argument("Missing proposal kind")),
}; };
let ttl_secs = req.ttl_secs.map(i64::from);
let proposal_id = actor let proposal_id = actor
.ask(HandleCreateProposal { kind, ttl_secs }) .ask(HandleCreateProposal {
kind,
ttl_secs: req.ttl_secs,
})
.await .await
.map_err(|e| { .map_err(|e| {
warn!(?e, "create_proposal failed"); warn!(?e, "create_proposal failed");

View File

@@ -286,7 +286,7 @@ impl OperatorSession {
pub(crate) async fn handle_create_proposal( pub(crate) async fn handle_create_proposal(
&mut self, &mut self,
kind: crate::db::models::ProposalKind, kind: crate::db::models::ProposalKind,
ttl_secs: Option<i64>, ttl_secs: Option<u32>,
) -> Result<i32, Error> { ) -> Result<i32, Error> {
use crate::actors::proposal_manager::CreateProposal; use crate::actors::proposal_manager::CreateProposal;
let initiator_id = self.credentials.id; let initiator_id = self.credentials.id;

View File

@@ -4,7 +4,7 @@ use arbiter_server::{
GlobalActors, GlobalActors,
proposal_manager::{ proposal_manager::{
CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal, CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal,
Error as ProposalError, QueryPending, RequestRecoveryWakeup, VoteOutcome, Error as ProposalError, MAX_TTL_SECS, QueryPending, RequestRecoveryWakeup, VoteOutcome,
}, },
}, },
crypto::KeyCell, crypto::KeyCell,
@@ -131,6 +131,45 @@ async fn create_proposal_returns_id() {
assert!(proposal_id > 0); assert!(proposal_id > 0);
} }
#[tokio::test]
async fn create_proposal_caps_the_ttl() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
})
.await
.unwrap();
let key = authn::SigningKey::generate();
let op = register_operator(&db, &key.public_key()).await;
let create = async |ttl: u32| {
actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id: 1 },
initiator_id: op,
ttl_secs: Some(ttl),
})
.await
};
// The boundary itself must still be accepted: the check is `>`, not `>=`.
create(MAX_TTL_SECS)
.await
.expect("a TTL at the ceiling must be accepted");
assert!(matches!(
create(MAX_TTL_SECS + 1).await,
Err(kameo::error::SendError::HandlerError(
ProposalError::TtlTooLong { .. }
))
));
}
#[tokio::test] #[tokio::test]
async fn single_operator_vote_reaches_quorum() { async fn single_operator_vote_reaches_quorum() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -406,13 +445,13 @@ async fn expired_proposal_is_hidden_and_unvotable() {
let client_key = authn::SigningKey::generate(); let client_key = authn::SigningKey::generate();
let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; let client_id = insert_unapproved_client(&db, &client_key.public_key()).await;
// Create proposal with ttl_secs = -1 so it's immediately expired // Create proposal with ttl_secs = 0 so it's immediately expired
let proposal_id = actors let proposal_id = actors
.proposal_manager .proposal_manager
.ask(CreateProposal { .ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id }, kind: ProposalKind::ApproveSdkClient { client_id },
initiator_id: op, initiator_id: op,
ttl_secs: Some(-1), ttl_secs: Some(0),
}) })
.await .await
.unwrap(); .unwrap();