From 5698d1cfb340d8ccc41d9663b8c4caceea8e6351 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 16:47:12 +0200 Subject: [PATCH] feat(proposal): reject proposals with an excessive TTL --- .../src/actors/proposal_manager.rs | 13 ++++-- .../src/grpc/operator/governance.rs | 7 +-- .../src/peers/operator/session/handlers.rs | 2 +- .../crates/arbiter-server/tests/governance.rs | 45 +++++++++++++++++-- 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index af8a036..f421675 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -20,7 +20,8 @@ use kameo::{Actor, actor::ActorRef, messages}; use strum::IntoDiscriminant as _; 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)] pub enum VoteOutcome { @@ -37,6 +38,8 @@ pub enum Error { ProposalNotPending, #[error("Proposal has expired")] ProposalExpired, + #[error("Requested TTL exceeds the maximum of {} seconds", MAX_TTL_SECS)] + TtlTooLong, #[error("Operator already voted on this proposal")] AlreadyVoted, #[error("Invalid vote signature")] @@ -100,10 +103,14 @@ impl ProposalManager { &mut self, kind: ProposalKind, initiator_id: i32, - ttl_secs: Option, + ttl_secs: Option, ) -> Result { 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 { kind: kind.discriminant(), diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 577c166..192eb8d 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -72,10 +72,11 @@ async fn handle_create( } None => return Err(Status::invalid_argument("Missing proposal kind")), }; - let ttl_secs = req.ttl_secs.map(i64::from); - let proposal_id = actor - .ask(HandleCreateProposal { kind, ttl_secs }) + .ask(HandleCreateProposal { + kind, + ttl_secs: req.ttl_secs, + }) .await .map_err(|e| { warn!(?e, "create_proposal failed"); diff --git a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs index 5d89575..d0116cc 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -286,7 +286,7 @@ impl OperatorSession { pub(crate) async fn handle_create_proposal( &mut self, kind: crate::db::models::ProposalKind, - ttl_secs: Option, + ttl_secs: Option, ) -> Result { use crate::actors::proposal_manager::CreateProposal; let initiator_id = self.credentials.id; diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 0dbd1fc..75fb301 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -4,7 +4,7 @@ use arbiter_server::{ GlobalActors, proposal_manager::{ CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal, - Error as ProposalError, QueryPending, RequestRecoveryWakeup, VoteOutcome, + Error as ProposalError, MAX_TTL_SECS, QueryPending, RequestRecoveryWakeup, VoteOutcome, }, }, crypto::KeyCell, @@ -131,6 +131,45 @@ async fn create_proposal_returns_id() { 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] async fn single_operator_vote_reaches_quorum() { 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_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 .proposal_manager .ask(CreateProposal { kind: ProposalKind::ApproveSdkClient { client_id }, initiator_id: op, - ttl_secs: Some(-1), + ttl_secs: Some(0), }) .await .unwrap();