From 28b7276e110eae81fa5ba74425223489da8e7383 Mon Sep 17 00:00:00 2001 From: Skipper Date: Fri, 1 May 2026 10:58:10 +0200 Subject: [PATCH 01/66] WIP: some things --- server/Cargo.lock | 210 ++++++++++++++++-- server/crates/arbiter-crypto/src/safecell.rs | 10 +- server/crates/arbiter-server/Cargo.toml | 1 + .../arbiter-server/src/actors/vault/mod.rs | 195 +++++++++++++--- .../crates/arbiter-server/src/crypto/mod.rs | 6 +- .../arbiter-server/src/evm/safe_signer.rs | 2 +- .../arbiter-server/src/grpc/client/vault.rs | 1 + .../arbiter-server/src/grpc/operator/vault.rs | 3 +- .../src/grpc/operator/vault_gate/outbound.rs | 3 +- 9 files changed, 380 insertions(+), 51 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index e36c264..ba262e3 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -786,6 +786,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "vsss-rs", "x25519-dalek 2.0.1", ] @@ -1283,7 +1284,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -1612,8 +1613,22 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130" +dependencies = [ + "num-traits", + "rand_core 0.6.4", + "serdect 0.3.0", "subtle", "zeroize", ] @@ -1624,7 +1639,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1927,7 +1942,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2007,7 +2022,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve", "rfc6979", - "serdect", + "serdect 0.2.0", "signature 2.2.0", "spki 0.7.3", ] @@ -2040,16 +2055,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.7", "group", + "hkdf", "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", - "serdect", + "serdect 0.2.0", "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" +dependencies = [ + "elliptic-curve", + "heapless", + "hex", + "multiexp", + "serde", "zeroize", ] @@ -2123,6 +2154,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec", "rand_core 0.6.4", "subtle", ] @@ -2323,6 +2355,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649" +dependencies = [ + "rustversion", + "serde_core", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2406,6 +2449,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2446,6 +2498,16 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2473,6 +2535,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2543,6 +2614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" dependencies = [ "ctutils", + "serde", "typenum", "zeroize", ] @@ -2808,7 +2880,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2947,7 +3019,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "serdect", + "serdect 0.2.0", "sha2 0.10.9", "signature 2.2.0", ] @@ -3290,6 +3362,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "multiexp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" +dependencies = [ + "ff", + "group", + "rand_core 0.6.4", + "rustversion", + "std-shims", + "zeroize", +] + [[package]] name = "multimap" version = "0.10.1" @@ -3321,6 +3407,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3329,6 +3429,19 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "rand 0.8.6", + "serde", ] [[package]] @@ -3346,6 +3459,29 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4454,9 +4590,9 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", - "serdect", + "serdect 0.2.0", "subtle", "zeroize", ] @@ -4622,6 +4758,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4787,6 +4933,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -4831,6 +4983,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "std-shims" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" +dependencies = [ + "hashbrown 0.16.1", + "rustversion", + "spin", +] + [[package]] name = "string_morph" version = "0.1.0" @@ -5574,6 +5737,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsss-rs" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c" +dependencies = [ + "crypto-bigint 0.5.5", + "crypto-bigint 0.6.1", + "elliptic-curve", + "elliptic-curve-tools", + "generic-array 1.4.1", + "hex", + "hybrid-array", + "num", + "rand_core 0.6.4", + "serde", + "sha3 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/server/crates/arbiter-crypto/src/safecell.rs b/server/crates/arbiter-crypto/src/safecell.rs index 15b0044..b16194c 100644 --- a/server/crates/arbiter-crypto/src/safecell.rs +++ b/server/crates/arbiter-crypto/src/safecell.rs @@ -22,7 +22,7 @@ pub trait SafeCellHandle { fn read(&mut self) -> Self::CellRead<'_>; fn write(&mut self) -> Self::CellWrite<'_>; - fn new_inline(f: F) -> Self + fn new_inline_default(f: F) -> Self where Self: Sized, T: Default, @@ -36,6 +36,14 @@ pub trait SafeCellHandle { cell } + fn new_inline(f: Box) -> Self + where + Self: Sized, + F: for<'a> FnOnce() -> T, + { + Self::new(f()) + } + #[inline(always)] fn read_inline(&mut self, f: F) -> R where diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 7790bd6..bc7cb3f 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -50,6 +50,7 @@ subtle = "2.6.1" x25519-dalek.workspace = true k256.workspace = true kameo_actors.workspace = true +vsss-rs = "5.4.0" [dev-dependencies] proptest = "1.11.0" diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index 51b7e33..a17ef63 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::{ crypto::{ KeyCell, derive_key, @@ -6,7 +8,7 @@ use crate::{ }, db::{ self, - models::{self, RootKeyHistory, RootKeyHistoryId}, + models::{self, OperatorId, OperatorIdentityId, RootKeyHistory, RootKeyHistoryId}, schema::{self}, }, }; @@ -15,10 +17,11 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use chrono::Utc; use diesel::{ ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper, - dsl::{insert_into, update}, + dsl::{count, insert_into, update}, + select, }; use diesel_async::{AsyncConnection, RunQueryDsl}; -use hmac::{KeyInit as _, Mac as _}; +use hmac::{KeyInit as _, Mac as _, digest::common}; use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo_actors::message_bus::{MessageBus, Publish}; use strum::{EnumDiscriminants, IntoDiscriminant}; @@ -62,6 +65,15 @@ pub enum Error { BrokenDatabase, } +#[derive(Debug, thiserror::Error)] +pub enum UnsealError {} + +#[derive(Debug, thiserror::Error)] +pub enum BootstrapError { + #[error("That operator already contributed his share")] + AlreadyContributed, +} + struct Unsealed { root_key_history_id: RootKeyHistoryId, root_key: KeyCell, @@ -73,8 +85,15 @@ enum State { #[default] Unbootstrapped, + Bootstrapping { + declared_operators: u64, + current_passphrases: HashMap>>, + }, + Sealed { + threshold: u64, // basically, quorum size root_key_history_id: RootKeyHistoryId, + current_shares: HashMap>>, }, Unsealed(Unsealed), } @@ -90,7 +109,6 @@ pub struct Vault { events: ActorRef, } -#[messages] impl Vault { pub async fn new(db: db::DatabasePool, events: ActorRef) -> Result { let state = { @@ -103,9 +121,17 @@ impl Vault { .await?; match root_key_history { - Some(root_key_history) => State::Sealed { - root_key_history_id: root_key_history.id, - }, + Some(root_key_history) => { + let operator_count: i64 = schema::operator::table + .count() + .get_result(&mut conn) + .await?; + State::Sealed { + root_key_history_id: root_key_history.id, + current_shares: HashMap::default(), + threshold: shamir_threshold(operator_count.cast_unsigned()), // invariant: db couldn't return negative number of rows + } + } None => State::Unbootstrapped, } }; @@ -154,19 +180,28 @@ impl Vault { const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> { match state { State::Unsealed(unsealed) => Ok(unsealed), + State::Bootstrapping { .. } => Err(Error::NotBootstrapped), State::Unbootstrapped => Err(Error::NotBootstrapped), State::Sealed { .. } => Err(Error::Sealed), } } - #[message] - pub async fn bootstrap(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { - if !matches!(self.state, State::Unbootstrapped) { + pub async fn finalize_bootstrap(&mut self) -> Result<(), Error> { + let State::Bootstrapping { + declared_operators, + current_passphrases, + } = &mut self.state + else { return Err(Error::AlreadyBootstrapped); - } - let salt = v1::generate_salt(); - let mut seal_key = derive_key(seal_key_raw, &salt); + }; let mut root_key = KeyCell::new_secure_random(); + let root_key_salt = v1::generate_salt(); + + let mut seal_key = KeyCell::new_secure_random(); + + let shares = seal_key.0.read_inline(|seal_key| { + generate_shamir_shares(current_passphrases.len() as u64, seal_key.as_slice()) + }); // Zero nonces are fine because they are one-time let root_key_nonce = Nonce::default(); @@ -182,11 +217,21 @@ impl Vault { }) })?; + let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let mut conn = self.db.get().await?; - let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let root_key_history_id = conn .transaction(async |conn| { + for ((operator_id, raw_passphrase), raw_share) in + current_passphrases.iter_mut().zip(shares.iter()) + { + let salt = v1::generate_salt(); + let mut share_seal_key = derive_key(&mut raw_passphrase, &salt); + let share_encryption_nonce = Nonce::default(); + + let share_key = derive_key(&mut raw_passphrase, &salt); + } + let root_key_history_id = insert_into(schema::root_key_history::table) .values(&models::NewRootKeyHistory { ciphertext: root_key_ciphertext.clone(), @@ -194,7 +239,7 @@ impl Vault { root_key_encryption_nonce: root_key_nonce.to_vec(), data_encryption_nonce: data_encryption_nonce_bytes.clone(), schema_version: 1, - salt: salt.to_vec(), + salt: root_key_salt.to_vec(), }) .returning(schema::root_key_history::id) .get_result(&mut *conn) @@ -221,11 +266,59 @@ impl Vault { Ok(()) } +} + +// Seal / unseal / bootstrap stuff. Will be separated into another actor, eventually +#[messages] +impl Vault { + #[message] + pub async fn start_bootstrap(&mut self, declared_operators: u64) -> Result<(), Error> { + if !matches!(&self.state, State::Unbootstrapped) { + return Err(Error::AlreadyBootstrapped); + } + + self.state = State::Bootstrapping { + declared_operators, + current_passphrases: HashMap::default(), + }; + Ok(()) + } #[message] - pub async fn try_unseal(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { + pub async fn contribute_bootstrap( + &mut self, + operator: OperatorIdentityId, + key_raw: SafeCell>, + ) -> Result<(), Error> { + let State::Bootstrapping { + current_passphrases, + declared_operators, + } = &mut self.state + else { + return Err(Error::AlreadyBootstrapped); + }; + + if current_passphrases.contains_key(&operator) { + return Err(Error::AlreadyBootstrapped); + } + current_passphrases.insert(operator, key_raw); + + if current_passphrases.len() == declared_operators { + return self.finalize_bootstrap(seal_key_raw); + } + + Ok(()) + } + + #[message] + pub async fn contribute_unseal( + &mut self, + operator: OperatorId, + key_raw: SafeCell>, + ) -> Result<(), Error> { let State::Sealed { root_key_history_id, + current_shares, } = &self.state else { return Err(Error::NotBootstrapped); @@ -246,7 +339,7 @@ impl Vault { error!("Broken database: invalid salt for root key"); Error::BrokenDatabase })?; - let mut seal_key = derive_key(seal_key_raw, &salt); + let mut seal_key = derive_key(key_raw, &salt); let mut root_key = SafeCell::new(current_key.ciphertext.clone()); @@ -277,6 +370,25 @@ impl Vault { Ok(()) } + #[message] + pub async fn seal(&mut self) -> Result<(), Error> { + let Unsealed { + root_key_history_id, + .. + } = Self::expect_unsealed(&mut self.state)?; + + self.state = State::Sealed { + root_key_history_id: *root_key_history_id, + current_shares: HashMap::new(), + }; + let _ = self.events.tell(Publish(events::VaultResealed)).await; + Ok(()) + } +} + +// Server-side cryptographic operations +#[messages] +impl Vault { #[message] pub async fn decrypt(&mut self, aead_id: i32) -> Result>, Error> { let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; @@ -394,26 +506,47 @@ impl Vault { Ok(hmac.verify_slice(&expected_mac).is_ok()) } +} - #[message] - pub async fn seal(&mut self) -> Result<(), Error> { - let Unsealed { - root_key_history_id, - .. - } = Self::expect_unsealed(&mut self.state)?; - - self.state = State::Sealed { - root_key_history_id: *root_key_history_id, - }; - let _ = self.events.tell(Publish(events::VaultResealed)).await; - Ok(()) +/// According to the spec, the quorum is 50% + 1 +/// with exception for 1 and 2 operators, those require exactly the number of operators registered +fn shamir_threshold(comittee_size: u64) -> u64 { + if comittee_size == 2 || comittee_size == 1 { + return comittee_size; } + + let half_comittee = match comittee_size % 2 != 0 { + true => (comittee_size - 1) / 2, + false => comittee_size / 2, + }; + + half_comittee + 1 +} + +/// Beware: this function accepts raw key references (without memory protection) +fn generate_shamir_shares(threshold: u64, key: &[u8]) -> Vec>> { + use vsss_rs::{shamir, *}; + + type P256Share = DefaultShare, IdentifierPrimeField>; + + let mut osrng = rand_core::OsRng::default(); + let sk = SecretKey::random(&mut osrng); + let nzs = sk.to_nonzero_scalar(); + let shared_secret = IdentifierPrimeField(*nzs.as_ref()); + let res = shamir::split_secret::(2, 3, &shared_secret, &mut osrng); + assert!(res.is_ok()); + let shares = res.unwrap(); + let res = shares.combine(); + assert!(res.is_ok()); + let scalar = res.unwrap(); + let nzs_dup = NonZeroScalar::from_repr(scalar.0.to_repr()).unwrap(); + let sk_dup = SecretKey::from(nzs_dup); + assert_eq!(sk_dup.to_bytes(), sk.to_bytes()); } #[cfg(test)] mod tests { use crate::actors::GlobalActors; - use crate::db::models::RootKeyHistory; use arbiter_crypto::safecell::SafeCellHandle as _; use super::*; @@ -423,7 +556,7 @@ mod tests { .await .unwrap(); let seal_key = SafeCell::new(b"test-seal-key".to_vec()); - actor.bootstrap(seal_key).await.unwrap(); + actor.finalize_bootstrap(seal_key).await.unwrap(); actor } diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs index 440cb24..13f2485 100644 --- a/server/crates/arbiter-server/src/crypto/mod.rs +++ b/server/crates/arbiter-server/src/crypto/mod.rs @@ -28,7 +28,7 @@ impl TryFrom>> for KeyCell { if value.len() != size_of::() { return Err(()); } - let cell = SafeCell::new_inline(|cell_write: &mut Key| { + let cell = SafeCell::new_inline_default(|cell_write: &mut Key| { cell_write.copy_from_slice(&value); }); Ok(Self(cell)) @@ -37,7 +37,7 @@ impl TryFrom>> for KeyCell { impl KeyCell { pub fn new_secure_random() -> Self { - let key = SafeCell::new_inline(|key_buffer: &mut Key| { + let key = SafeCell::new_inline_default(|key_buffer: &mut Key| { let mut rng = StdRng::try_from_rng(&mut SysRng) .expect("Rng failure is unrecoverable and should panic"); rng.fill_bytes(key_buffer); @@ -94,7 +94,7 @@ impl KeyCell { } /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. -pub fn derive_key(mut password: SafeCell>, salt: &Salt) -> KeyCell { +pub fn derive_key(password: &mut SafeCell>, salt: &Salt) -> KeyCell { let params = { #[cfg(debug_assertions)] { diff --git a/server/crates/arbiter-server/src/evm/safe_signer.rs b/server/crates/arbiter-server/src/evm/safe_signer.rs index 02597a8..300b142 100644 --- a/server/crates/arbiter-server/src/evm/safe_signer.rs +++ b/server/crates/arbiter-server/src/evm/safe_signer.rs @@ -44,7 +44,7 @@ impl std::fmt::Debug for SafeSigner { /// Returns the protected key bytes and the derived Ethereum address. pub fn generate(rng: &mut impl rand::Rng) -> (SafeCell<[u8; 32]>, Address) { loop { - let mut cell = SafeCell::new_inline(|w: &mut [u8; 32]| { + let mut cell = SafeCell::new_inline_default(|w: &mut [u8; 32]| { rng.fill_bytes(w); }); diff --git a/server/crates/arbiter-server/src/grpc/client/vault.rs b/server/crates/arbiter-server/src/grpc/client/vault.rs index f5561b9..95d4ba8 100644 --- a/server/crates/arbiter-server/src/grpc/client/vault.rs +++ b/server/crates/arbiter-server/src/grpc/client/vault.rs @@ -31,6 +31,7 @@ pub(super) async fn dispatch( VaultRequestPayload::QueryState(()) => { let state = match actor.ask(HandleQueryVaultState {}).await { Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, + Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping, Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index ac1c293..582ab13 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -3,7 +3,6 @@ use crate::{ peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, }; use arbiter_proto::{ - proto::shared::VaultState as ProtoVaultState, proto::operator::{ operator_response::Payload as OperatorResponsePayload, vault::{ @@ -11,6 +10,7 @@ use arbiter_proto::{ response::Payload as VaultResponsePayload, }, }, + proto::shared::VaultState as ProtoVaultState, }; use kameo::actor::ActorRef; @@ -47,6 +47,7 @@ async fn handle_query_vault_state( let state = match actor.ask(HandleQueryVaultState {}).await { Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Sealed) => ProtoVaultState::Sealed, + Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Err(err) => { warn!(error = ?err, "Failed to query vault state"); diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs index 4a2f072..268b7d5 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs @@ -4,7 +4,6 @@ use crate::{ peers::operator::vault_gate::{self as vault_gate}, }; use arbiter_proto::proto::{ - shared::VaultState as ProtoVaultState, operator::{ operator_response::Payload as OperatorResponsePayload, vault::{ @@ -17,6 +16,7 @@ use arbiter_proto::proto::{ }, }, }, + shared::VaultState as ProtoVaultState, }; use tonic::Status; @@ -46,6 +46,7 @@ impl Convert for VaultState { fn convert(self) -> OperatorResponsePayload { let proto_state = match self { Self::Unbootstrapped => ProtoVaultState::Unbootstrapped, + Self::Bootstrapping => ProtoVaultState::Boostrapping, Self::Sealed => ProtoVaultState::Sealed, Self::Unsealed => ProtoVaultState::Unsealed, }; -- 2.49.1 From 3d3a4be806ec8798d9090c309ffd592f7ff190c3 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:42:37 +0200 Subject: [PATCH 02/66] fix(server::peers::operator::auth): make ChallengeContext pub for smlang state machine smlang generates a public state enum whose variants contain ChallengeContext, requiring the type itself to be fully public. Also tightens the wildcard arm in client auth to an exhaustive match. --- .../arbiter-server/src/peers/client/auth.rs | 2 +- .../src/peers/operator/auth/state.rs | 20 +++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/server/crates/arbiter-server/src/peers/client/auth.rs b/server/crates/arbiter-server/src/peers/client/auth.rs index 3742f97..f488161 100644 --- a/server/crates/arbiter-server/src/peers/client/auth.rs +++ b/server/crates/arbiter-server/src/peers/client/auth.rs @@ -298,7 +298,7 @@ where let signature = expect_message(transport, |req: Inbound| match req { Inbound::AuthChallengeSolution { signature } => Some(signature), - _ => None, + Inbound::AuthChallengeRequest { .. } => None, }) .await .map_err(|e| { diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index 9862612..a7c0ae7 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -14,19 +14,19 @@ use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; use diesel_async::RunQueryDsl; use tracing::error; -pub(super) struct ChallengeRequest { - pub(super) pubkey: authn::PublicKey, - pub(super) bootstrap_token: Option, +pub(crate) struct ChallengeRequest { + pub(crate) pubkey: authn::PublicKey, + pub(crate) bootstrap_token: Option, } -pub(super) struct ChallengeContext { - pub(super) challenge: AuthChallenge, - pub(super) pubkey: authn::PublicKey, - pub(super) bootstrap_token: Option, +pub struct ChallengeContext { + pub challenge: AuthChallenge, + pub pubkey: authn::PublicKey, + pub bootstrap_token: Option, } -pub(super) struct ChallengeSolution { - pub(super) solution: Vec, +pub(crate) struct ChallengeSolution { + pub(crate) solution: Vec, } smlang::statemachine!( @@ -127,8 +127,6 @@ where }) } - #[allow(missing_docs)] - #[allow(clippy::unused_unit)] async fn verify_solution( &mut self, ChallengeContext { -- 2.49.1 From 928799fa07a8ad08a26c608700ea99a87280516e Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:42:43 +0200 Subject: [PATCH 03/66] feat(server::actors::evm): implement operator_delete_grant Sets revoked_at on the evm_basic_grant row; returns NotFound if the grant does not exist. Wires the handler in OperatorSession replacing the todo!(). --- .../arbiter-server/src/actors/evm/mod.rs | 34 +++++++--------- .../src/peers/operator/session/handlers.rs | 39 ++++++++++--------- .../src/peers/operator/session/mod.rs | 2 +- 3 files changed, 35 insertions(+), 40 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 033d11f..481c9fa 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -160,29 +160,23 @@ impl EvmActor { } #[message] - #[expect(clippy::unused_async, reason = "reserved for impl")] - pub async fn operator_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> { - // let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - // let vault = self.vault.clone(); + pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; - // diesel_async::AsyncConnection::transaction(&mut conn, |conn| { - // Box::pin(async move { - // diesel::update(schema::evm_basic_grant::table) - // .filter(schema::evm_basic_grant::id.eq(grant_id)) - // .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) - // .execute(conn) - // .await?; + let affected = diesel::update(schema::evm_basic_grant::table) + .filter(schema::evm_basic_grant::id.eq(grant_id)) + .set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now())) + .execute(&mut conn) + .await + .map_err(DatabaseError::from)?; - // let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?; + if affected == 0 { + return Err(Error::Database(DatabaseError::from( + diesel::result::Error::NotFound, + ))); + } - // diesel::result::QueryResult::Ok(()) - // }) - // }) - // .await - // .map_err(DatabaseError::from)?; - - // Ok(()) - todo!() + Ok(()) } #[message] 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 08a000d..f2b9b3e 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -2,8 +2,8 @@ use super::{Error, OperatorSession}; use crate::{ actors::{ evm::{ - ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants, - SignTransactionError as EvmSignError, + ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorDeleteGrant, + OperatorListGrants, SignTransactionError as EvmSignError, }, flow_coordinator::client_connect_approval::ClientApprovalAnswer, vault::VaultState, @@ -122,22 +122,23 @@ impl OperatorSession { } #[message] - pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> { - // match self - // .props - // .actors - // .evm - // .ask(OperatorDeleteGrant { grant_id }) - // .await - // { - // Ok(()) => Ok(()), - // Err(err) => { - // error!(?err, "EVM grant delete failed"); - // Err(GrantMutationError::Internal) - // } - // } - let _ = grant_id; - todo!() + pub(crate) async fn handle_grant_delete( + &mut self, + grant_id: i32, + ) -> Result<(), GrantMutationError> { + match self + .props + .actors + .evm + .ask(OperatorDeleteGrant { grant_id }) + .await + { + Ok(()) => Ok(()), + Err(err) => { + error!(?err, "EVM grant delete failed"); + Err(GrantMutationError::Internal) + } + } } #[message] @@ -217,8 +218,8 @@ impl OperatorSession { pub(crate) async fn handle_list_wallet_access( &mut self, ) -> Result, Error> { - let mut conn = self.props.db.get().await?; use crate::db::schema::evm_wallet_access; + let mut conn = self.props.db.get().await?; let access_entries = evm_wallet_access::table .select(EvmWalletAccess::as_select()) .load::<_>(&mut conn) diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 79281bb..0fe2c84 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -63,7 +63,7 @@ impl OperatorSession { Self { props, sender, - pending_client_approvals: Default::default(), + pending_client_approvals: HashMap::default(), } } } -- 2.49.1 From 8159902027c1cefe46d69da27a62c4b40318e573 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:42:49 +0200 Subject: [PATCH 04/66] feat(server::db): add share_salt column to operator table Each operator row now stores a 32-byte random salt used to derive the per-operator share encryption key from their passphrase (Argon2 KDF). --- .../arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql | 1 + server/crates/arbiter-server/src/db/models.rs | 1 + server/crates/arbiter-server/src/db/schema.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 37509f1..732c6b6 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -56,6 +56,7 @@ create table if not exists operator ( share blob not null, share_nonce blob not null, + share_salt blob not null default (randomblob(32)), created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 557d579..1f57b1b 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -285,6 +285,7 @@ pub struct Operator { pub id: OperatorId, pub share: Vec, pub share_nonce: Vec, + pub share_salt: Vec, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, } diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c2f9869..981d885 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -157,6 +157,7 @@ diesel::table! { id -> Nullable, share -> Binary, share_nonce -> Binary, + share_salt -> Binary, created_at -> Integer, updated_at -> Integer, } -- 2.49.1 From 0695ec96a8190ff78268957da273cb3ccc70fc23 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:42:56 +0200 Subject: [PATCH 05/66] feat(server::crypto): add Shamir secret sharing utilities Wraps vsss_rs Gf256::split_array / combine_array into thin split_key / combine_shares helpers. Also widens derive_key salt parameter from &[u8;16] to &[u8] to accommodate the 32-byte share salts. --- .../src/crypto/encryption/v1.rs | 12 ++++----- .../crates/arbiter-server/src/crypto/mod.rs | 9 ++++--- .../arbiter-server/src/crypto/shamir.rs | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 server/crates/arbiter-server/src/crypto/shamir.rs diff --git a/server/crates/arbiter-server/src/crypto/encryption/v1.rs b/server/crates/arbiter-server/src/crypto/encryption/v1.rs index 0dac366..bc921c5 100644 --- a/server/crates/arbiter-server/src/crypto/encryption/v1.rs +++ b/server/crates/arbiter-server/src/crypto/encryption/v1.rs @@ -61,12 +61,12 @@ mod tests { #[test] fn derive_seal_key_deterministic() { static PASSWORD: &[u8] = b"password"; - let password = SafeCell::new(PASSWORD.to_vec()); - let password2 = SafeCell::new(PASSWORD.to_vec()); + let mut password = SafeCell::new(PASSWORD.to_vec()); + let mut password2 = SafeCell::new(PASSWORD.to_vec()); let salt = generate_salt(); - let mut key1 = derive_key(password, &salt); - let mut key2 = derive_key(password2, &salt); + let mut key1 = derive_key(&mut password, &salt); + let mut key2 = derive_key(&mut password2, &salt); let key1_reader = key1.0.read(); let key2_reader = key2.0.read(); @@ -77,10 +77,10 @@ mod tests { #[test] fn successful_derive() { static PASSWORD: &[u8] = b"password"; - let password = SafeCell::new(PASSWORD.to_vec()); + let mut password = SafeCell::new(PASSWORD.to_vec()); let salt = generate_salt(); - let mut key = derive_key(password, &salt); + let mut key = derive_key(&mut password, &salt); let key_reader = key.0.read(); assert_ne!(key_reader.as_slice(), &[0u8; 32][..]); diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs index 13f2485..a100001 100644 --- a/server/crates/arbiter-server/src/crypto/mod.rs +++ b/server/crates/arbiter-server/src/crypto/mod.rs @@ -1,5 +1,5 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; -use encryption::v1::{Nonce, Salt}; +use encryption::v1::Nonce; use argon2::{Algorithm, Argon2}; use chacha20poly1305::{ @@ -13,6 +13,7 @@ use rand::{ pub mod encryption; pub mod integrity; +pub mod shamir; pub struct KeyCell(pub SafeCell); impl From> for KeyCell { @@ -94,7 +95,7 @@ impl KeyCell { } /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. -pub fn derive_key(password: &mut SafeCell>, salt: &Salt) -> KeyCell { +pub fn derive_key(password: &mut SafeCell>, salt: &[u8]) -> KeyCell { let params = { #[cfg(debug_assertions)] { @@ -132,10 +133,10 @@ mod tests { #[test] fn encrypt_decrypt() { static PASSWORD: &[u8] = b"password"; - let password = SafeCell::new(PASSWORD.to_vec()); + let mut password = SafeCell::new(PASSWORD.to_vec()); let salt = generate_salt(); - let mut key = derive_key(password, &salt); + let mut key = derive_key(&mut password, &salt); let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 let associated_data = b"associated data"; let mut buffer = b"secret data".to_vec(); diff --git a/server/crates/arbiter-server/src/crypto/shamir.rs b/server/crates/arbiter-server/src/crypto/shamir.rs new file mode 100644 index 0000000..b369390 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/shamir.rs @@ -0,0 +1,27 @@ +use vsss_rs::Gf256; + +#[derive(Debug, thiserror::Error)] +pub enum ShamirError { + #[error("Failed to split key: {0}")] + Split(String), + #[error("Failed to combine shares: {0}")] + Combine(String), +} + +/// Split `key` into `total` shares where any `threshold` shares can reconstruct it. +/// Each returned Vec is a share with format [`identifier_byte`, `value_bytes`...]. +pub fn split_key( + threshold: usize, + total: usize, + key: &[u8], + rng: impl rand_core::RngCore + rand_core::CryptoRng, +) -> Result>, ShamirError> { + Gf256::split_array(threshold, total, key, rng) + .map_err(|e| ShamirError::Split(format!("{e:?}"))) +} + +/// Reconstruct the secret from `threshold` or more shares. +pub fn combine_shares(shares: &[Vec]) -> Result, ShamirError> { + Gf256::combine_array(shares) + .map_err(|e| ShamirError::Combine(format!("{e:?}"))) +} -- 2.49.1 From fc7f2b1a031cd57cb99ed5d06be0c3c1efeabb62 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:43:02 +0200 Subject: [PATCH 06/66] refactor(server::actors::vault): clean up Bootstrap/TryUnseal, remove Bootstrapping state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstrap and TryUnseal now accept a SafeCell> seal key directly. The Bootstrapping intermediate state is removed — multi-operator coordination is the responsibility of VaultCoordinator, which calls Bootstrap atomically once all shares are collected. --- .../arbiter-server/src/actors/vault/mod.rs | 237 ++++-------------- .../arbiter-server/src/grpc/client/vault.rs | 1 - .../arbiter-server/src/grpc/operator/vault.rs | 1 - 3 files changed, 52 insertions(+), 187 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index a17ef63..94b7d9d 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -1,15 +1,13 @@ -use std::collections::HashMap; - use crate::{ crypto::{ - KeyCell, derive_key, + KeyCell, encryption::v1::{self, Nonce}, integrity::v1::HmacSha256, }, db::{ self, - models::{self, OperatorId, OperatorIdentityId, RootKeyHistory, RootKeyHistoryId}, - schema::{self}, + models::{self, RootKeyHistory, RootKeyHistoryId}, + schema, }, }; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; @@ -17,11 +15,10 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use chrono::Utc; use diesel::{ ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper, - dsl::{count, insert_into, update}, - select, + dsl::{insert_into, update}, }; use diesel_async::{AsyncConnection, RunQueryDsl}; -use hmac::{KeyInit as _, Mac as _, digest::common}; +use hmac::{KeyInit as _, Mac as _}; use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo_actors::message_bus::{MessageBus, Publish}; use strum::{EnumDiscriminants, IntoDiscriminant}; @@ -65,15 +62,6 @@ pub enum Error { BrokenDatabase, } -#[derive(Debug, thiserror::Error)] -pub enum UnsealError {} - -#[derive(Debug, thiserror::Error)] -pub enum BootstrapError { - #[error("That operator already contributed his share")] - AlreadyContributed, -} - struct Unsealed { root_key_history_id: RootKeyHistoryId, root_key: KeyCell, @@ -85,15 +73,8 @@ enum State { #[default] Unbootstrapped, - Bootstrapping { - declared_operators: u64, - current_passphrases: HashMap>>, - }, - Sealed { - threshold: u64, // basically, quorum size root_key_history_id: RootKeyHistoryId, - current_shares: HashMap>>, }, Unsealed(Unsealed), } @@ -121,17 +102,9 @@ impl Vault { .await?; match root_key_history { - Some(root_key_history) => { - let operator_count: i64 = schema::operator::table - .count() - .get_result(&mut conn) - .await?; - State::Sealed { - root_key_history_id: root_key_history.id, - current_shares: HashMap::default(), - threshold: shamir_threshold(operator_count.cast_unsigned()), // invariant: db couldn't return negative number of rows - } - } + Some(root_key_history) => State::Sealed { + root_key_history_id: root_key_history.id, + }, None => State::Unbootstrapped, } }; @@ -139,7 +112,7 @@ impl Vault { Ok(Self { db, state, events }) } - // Exclusive transaction to avoid race condtions if multiple vaults write + // Exclusive transaction to avoid race conditions if multiple vaults write // additional layer of protection against nonce-reuse async fn get_new_nonce( pool: &db::DatabasePool, @@ -180,37 +153,33 @@ impl Vault { const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> { match state { State::Unsealed(unsealed) => Ok(unsealed), - State::Bootstrapping { .. } => Err(Error::NotBootstrapped), State::Unbootstrapped => Err(Error::NotBootstrapped), State::Sealed { .. } => Err(Error::Sealed), } } +} - pub async fn finalize_bootstrap(&mut self) -> Result<(), Error> { - let State::Bootstrapping { - declared_operators, - current_passphrases, - } = &mut self.state - else { +#[messages] +impl Vault { + #[message] + pub async fn bootstrap(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { + if !matches!(&self.state, State::Unbootstrapped) { return Err(Error::AlreadyBootstrapped); - }; + } + let mut root_key = KeyCell::new_secure_random(); - let root_key_salt = v1::generate_salt(); - - let mut seal_key = KeyCell::new_secure_random(); - - let shares = seal_key.0.read_inline(|seal_key| { - generate_shamir_shares(current_passphrases.len() as u64, seal_key.as_slice()) - }); + let mut seal_key = KeyCell::try_from(seal_key_raw).map_err(|()| Error::InvalidKey)?; // Zero nonces are fine because they are one-time let root_key_nonce = Nonce::default(); let data_encryption_nonce = Nonce::default(); - let root_key_ciphertext: Vec = root_key.0.read_inline(|reader| { - let root_key_reader = reader.as_slice(); + // Generate salt (kept for schema compat) + let root_key_salt = v1::generate_salt(); + + let root_key_ciphertext: Vec = root_key.0.read_inline(|rk| { seal_key - .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader) + .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice()) .map_err(|err| { error!(?err, "Fatal bootstrap error"); Error::Encryption(err) @@ -222,16 +191,6 @@ impl Vault { let root_key_history_id = conn .transaction(async |conn| { - for ((operator_id, raw_passphrase), raw_share) in - current_passphrases.iter_mut().zip(shares.iter()) - { - let salt = v1::generate_salt(); - let mut share_seal_key = derive_key(&mut raw_passphrase, &salt); - let share_encryption_nonce = Nonce::default(); - - let share_key = derive_key(&mut raw_passphrase, &salt); - } - let root_key_history_id = insert_into(schema::root_key_history::table) .values(&models::NewRootKeyHistory { ciphertext: root_key_ciphertext.clone(), @@ -266,82 +225,28 @@ impl Vault { Ok(()) } -} - -// Seal / unseal / bootstrap stuff. Will be separated into another actor, eventually -#[messages] -impl Vault { - #[message] - pub async fn start_bootstrap(&mut self, declared_operators: u64) -> Result<(), Error> { - if !matches!(&self.state, State::Unbootstrapped) { - return Err(Error::AlreadyBootstrapped); - } - - self.state = State::Bootstrapping { - declared_operators, - current_passphrases: HashMap::default(), - }; - Ok(()) - } #[message] - pub async fn contribute_bootstrap( - &mut self, - operator: OperatorIdentityId, - key_raw: SafeCell>, - ) -> Result<(), Error> { - let State::Bootstrapping { - current_passphrases, - declared_operators, - } = &mut self.state - else { - return Err(Error::AlreadyBootstrapped); - }; - - if current_passphrases.contains_key(&operator) { - return Err(Error::AlreadyBootstrapped); - } - current_passphrases.insert(operator, key_raw); - - if current_passphrases.len() == declared_operators { - return self.finalize_bootstrap(seal_key_raw); - } - - Ok(()) - } - - #[message] - pub async fn contribute_unseal( - &mut self, - operator: OperatorId, - key_raw: SafeCell>, - ) -> Result<(), Error> { + pub async fn try_unseal(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { let State::Sealed { root_key_history_id, - current_shares, } = &self.state else { return Err(Error::NotBootstrapped); }; + let root_key_history_id = *root_key_history_id; - // We don't want to hold connection while doing expensive KDF work + // We don't want to hold connection while doing expensive work let current_key = { let mut conn = self.db.get().await?; schema::root_key_history::table - .filter(schema::root_key_history::id.eq(*root_key_history_id)) + .filter(schema::root_key_history::id.eq(root_key_history_id)) .select(RootKeyHistory::as_select()) .first(&mut conn) .await? }; - let salt = ¤t_key.salt; - let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| { - error!("Broken database: invalid salt for root key"); - Error::BrokenDatabase - })?; - let mut seal_key = derive_key(key_raw, &salt); - - let mut root_key = SafeCell::new(current_key.ciphertext.clone()); + let mut seal_key = KeyCell::try_from(seal_key_raw).map_err(|()| Error::InvalidKey)?; let nonce = Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { @@ -349,19 +254,22 @@ impl Vault { Error::BrokenDatabase })?; + let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone()); seal_key - .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) + .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes) .map_err(|err| { error!(?err, "Failed to unseal root key: invalid seal key"); Error::InvalidKey })?; + let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| { + error!("Broken database: invalid encryption key size"); + Error::BrokenDatabase + })?; + self.state = State::Unsealed(Unsealed { root_key_history_id: current_key.id, - root_key: KeyCell::try_from(root_key).map_err(|err| { - error!(?err, "Broken database: invalid encryption key size"); - Error::BrokenDatabase - })?, + root_key, }); info!("Vault unsealed successfully"); @@ -379,7 +287,6 @@ impl Vault { self.state = State::Sealed { root_key_history_id: *root_key_history_id, - current_shares: HashMap::new(), }; let _ = self.events.tell(Publish(events::VaultResealed)).await; Ok(()) @@ -466,12 +373,10 @@ impl Vault { root_key_history_id, } = Self::expect_unsealed(&mut self.state)?; - let mut hmac = root_key - .0 - .read_inline(|k| match HmacSha256::new_from_slice(k) { - Ok(v) => v, - Err(_) => unreachable!("HMAC accepts keys of any size"), - }); + let mut hmac = root_key.0.read_inline(|k| { + HmacSha256::new_from_slice(k) + .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) + }); hmac.update(&root_key_history_id.to_raw().to_be_bytes()); hmac.update(&mac_input); @@ -495,12 +400,10 @@ impl Vault { return Ok(false); } - let mut hmac = root_key - .0 - .read_inline(|k| match HmacSha256::new_from_slice(k) { - Ok(v) => v, - Err(_) => unreachable!("HMAC accepts keys of any size"), - }); + let mut hmac = root_key.0.read_inline(|k| { + HmacSha256::new_from_slice(k) + .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) + }); hmac.update(&key_version.to_raw().to_be_bytes()); hmac.update(&mac_input); @@ -508,42 +411,6 @@ impl Vault { } } -/// According to the spec, the quorum is 50% + 1 -/// with exception for 1 and 2 operators, those require exactly the number of operators registered -fn shamir_threshold(comittee_size: u64) -> u64 { - if comittee_size == 2 || comittee_size == 1 { - return comittee_size; - } - - let half_comittee = match comittee_size % 2 != 0 { - true => (comittee_size - 1) / 2, - false => comittee_size / 2, - }; - - half_comittee + 1 -} - -/// Beware: this function accepts raw key references (without memory protection) -fn generate_shamir_shares(threshold: u64, key: &[u8]) -> Vec>> { - use vsss_rs::{shamir, *}; - - type P256Share = DefaultShare, IdentifierPrimeField>; - - let mut osrng = rand_core::OsRng::default(); - let sk = SecretKey::random(&mut osrng); - let nzs = sk.to_nonzero_scalar(); - let shared_secret = IdentifierPrimeField(*nzs.as_ref()); - let res = shamir::split_secret::(2, 3, &shared_secret, &mut osrng); - assert!(res.is_ok()); - let shares = res.unwrap(); - let res = shares.combine(); - assert!(res.is_ok()); - let scalar = res.unwrap(); - let nzs_dup = NonZeroScalar::from_repr(scalar.0.to_repr()).unwrap(); - let sk_dup = SecretKey::from(nzs_dup); - assert_eq!(sk_dup.to_bytes(), sk.to_bytes()); -} - #[cfg(test)] mod tests { use crate::actors::GlobalActors; @@ -555,8 +422,8 @@ mod tests { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); - actor.finalize_bootstrap(seal_key).await.unwrap(); + let seal_key = SafeCell::new([0u8; 32].to_vec()); + actor.bootstrap(seal_key).await.unwrap(); actor } @@ -565,12 +432,12 @@ mod tests { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { let db = db::create_test_pool().await; let mut actor = bootstrapped_actor(&db).await; - let root_key_history_id = match actor.state { - State::Unsealed(Unsealed { - root_key_history_id, - .. - }) => root_key_history_id, - _ => panic!("expected unsealed state"), + let State::Unsealed(Unsealed { + root_key_history_id, + .. + }) = actor.state + else { + panic!("expected unsealed state"); }; let n1 = Vault::get_new_nonce(&db, root_key_history_id) diff --git a/server/crates/arbiter-server/src/grpc/client/vault.rs b/server/crates/arbiter-server/src/grpc/client/vault.rs index 95d4ba8..f5561b9 100644 --- a/server/crates/arbiter-server/src/grpc/client/vault.rs +++ b/server/crates/arbiter-server/src/grpc/client/vault.rs @@ -31,7 +31,6 @@ pub(super) async fn dispatch( VaultRequestPayload::QueryState(()) => { let state = match actor.ask(HandleQueryVaultState {}).await { Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, - Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping, Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index 582ab13..5dc7820 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -47,7 +47,6 @@ async fn handle_query_vault_state( let state = match actor.ask(HandleQueryVaultState {}).await { Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Sealed) => ProtoVaultState::Sealed, - Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Err(err) => { warn!(error = ?err, "Failed to query vault state"); -- 2.49.1 From 83075e9df7a7ad3caf6a96c7b564f880ac4dd29b Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:43:09 +0200 Subject: [PATCH 07/66] feat(server): introduce VaultCoordinator for multi-operator Shamir bootstrap/unseal VaultCoordinator collects operator passphrases, splits the seal key into Shamir shares on bootstrap (encrypting each share with the operator's passphrase via Argon2 + XChaCha20-Poly1305), and reconstructs the seal key from threshold shares on unseal. Adds vsss-rs 5.4.0 and rand_core 0.6 dependencies. --- server/Cargo.lock | 8 +- server/Cargo.toml | 5 +- server/crates/arbiter-server/Cargo.toml | 1 + .../crates/arbiter-server/src/actors/mod.rs | 9 +- .../src/actors/vault_coordinator/mod.rs | 316 ++++++++++++++++++ 5 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index ba262e3..ca8307e 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -771,6 +771,7 @@ dependencies = [ "proptest", "prost-types", "rand 0.10.1", + "rand_core 0.6.4", "rcgen", "restructed", "rstest", @@ -3027,7 +3028,7 @@ dependencies = [ [[package]] name = "kameo" version = "0.20.0" -source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a" +source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" dependencies = [ "downcast-rs", "dyn-clone", @@ -3041,7 +3042,7 @@ dependencies = [ [[package]] name = "kameo_actors" version = "0.5.0" -source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a" +source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" dependencies = [ "futures", "glob", @@ -3053,9 +3054,8 @@ dependencies = [ [[package]] name = "kameo_macros" version = "0.20.0" -source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a" +source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" dependencies = [ - "darling 0.23.0", "heck", "proc-macro2", "quote", diff --git a/server/Cargo.toml b/server/Cargo.toml index 34ef2fe..1fcd8bd 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,8 +12,8 @@ base64 = "0.22.1" chrono = { version = "0.4.44", features = ["serde"] } futures = "0.3.32" k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } -kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} -kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} +kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"} +kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"} hmac = "0.13.0" miette = { version = "7.6.0", features = ["fancy", "serde"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] } @@ -106,7 +106,6 @@ indexing_slicing = "warn" infinite_loop = "warn" inline_asm_x86_att_syntax = "warn" inline_asm_x86_intel_syntax = "warn" -integer_division = "warn" large_include_file = "warn" lossy_float_literal = "warn" map_with_unused_argument_over_ranges = "warn" diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index bc7cb3f..8b5bbc3 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -51,6 +51,7 @@ x25519-dalek.workspace = true k256.workspace = true kameo_actors.workspace = true vsss-rs = "5.4.0" +rand_core = "0.6" [dev-dependencies] proptest = "1.11.0" diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index e9900ae..ec8f113 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -2,6 +2,7 @@ use crate::{ actors::{ bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, operator_registry::OperatorRegistry, vault::Vault, + vault_coordinator::VaultCoordinator, }, db, }; @@ -15,6 +16,7 @@ pub mod evm; pub mod flow_coordinator; pub mod operator_registry; pub mod vault; +pub mod vault_coordinator; #[derive(Error, Debug)] pub enum SpawnError { @@ -30,6 +32,7 @@ pub enum SpawnError { pub struct GlobalActors { pub vault: ActorRef, pub bootstrapper: ActorRef, + pub vault_coordinator: ActorRef, pub flow_coordinator: ActorRef, pub operator_registry: ActorRef, pub evm: ActorRef, @@ -47,7 +50,11 @@ impl GlobalActors { let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); Ok(Self { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), + evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())), + vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new( + db, + key_holder.clone(), + )), vault: key_holder, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( operator_registry.clone(), diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs new file mode 100644 index 0000000..e9b44a3 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -0,0 +1,316 @@ +use std::collections::HashMap; + +use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use diesel::{ExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; +use kameo::{Actor, actor::ActorRef, messages}; +use rand_core::{OsRng, RngCore as _}; +use tracing::error; + +use crate::{ + actors::vault::{Bootstrap, TryUnseal, Vault}, + crypto::{derive_key, encryption::v1::Nonce, shamir}, + db::{self, models, schema}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Already coordinating a bootstrap")] + AlreadyBootstrapping, + #[error("Already coordinating an unseal")] + AlreadyUnsealing, + #[error("Bootstrap not in progress")] + NotBootstrapping, + #[error("Unseal not in progress")] + NotUnsealing, + #[error("Operator already contributed")] + DuplicateContribution, + #[error("Operator not found in database")] + OperatorNotFound, + #[error("Invalid passphrase (decryption failed)")] + InvalidPassphrase, + #[error("Shamir error: {0}")] + Shamir(String), + #[error("Database connection error: {0}")] + DatabaseConnection(#[from] db::PoolError), + #[error("Database query error: {0}")] + DatabaseQuery(#[from] diesel::result::Error), + #[error("Encryption error")] + Encryption, + #[error("Vault error")] + VaultError, + #[error("Broken database")] + BrokenDatabase, +} + +// Passphrases stored as plain Vec (not SafeCell) so CoordinatorState is Sync. +// They are ephemeral and dropped immediately after use. +enum CoordinatorState { + Idle, + Bootstrapping { + declared_count: usize, + passphrases: HashMap>, + }, + Unsealing { + threshold: usize, + passphrases: HashMap>, + }, +} + +#[derive(Actor)] +pub struct VaultCoordinator { + db: db::DatabasePool, + vault: ActorRef, + state: CoordinatorState, +} + +impl VaultCoordinator { + pub const fn new(db: db::DatabasePool, vault: ActorRef) -> Self { + Self { + db, + vault, + state: CoordinatorState::Idle, + } + } +} + +const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1"; + +const fn shamir_threshold(n: usize) -> usize { + match n { + 0 => panic!("No operators"), + 1 => 1, + 2 => 2, + n => n / 2 + 1, + } +} + +async fn finalize_bootstrap( + db: db::DatabasePool, + vault: ActorRef, + passphrases: HashMap>, +) -> Result<(), Error> { + let total = passphrases.len(); + let threshold = shamir_threshold(total); + + // Generate random 32-byte seal key + let mut seal_key_bytes = vec![0u8; 32]; + OsRng.fill_bytes(&mut seal_key_bytes); + + // Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs) + let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng) + .map_err(|e| Error::Shamir(e.to_string()))?; + + let seal_key = SafeCell::new(seal_key_bytes); + + let mut conn = db.get().await?; + + for ((operator_id_raw, passphrase_bytes), share) in passphrases.into_iter().zip(shares) { + // Generate a fresh share_salt for this operator + let mut share_salt = vec![0u8; 32]; + OsRng.fill_bytes(&mut share_salt); + + // Derive share encryption key from passphrase + salt + let mut passphrase_cell = SafeCell::new(passphrase_bytes); + let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); + + // Encrypt this operator's share + let nonce = Nonce::default(); + let encrypted_share = share_seal_key + .encrypt(&nonce, SHARE_AAD, &share) + .map_err(|_| Error::Encryption)?; + + let nonce_bytes = nonce.to_vec(); + + diesel::replace_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(operator_id_raw)), + schema::operator::share.eq(&encrypted_share), + schema::operator::share_nonce.eq(&nonce_bytes), + schema::operator::share_salt.eq(&share_salt), + schema::operator::created_at.eq(models::SqliteTimestamp::now()), + schema::operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + vault + .ask(Bootstrap { + seal_key_raw: seal_key, + }) + .await + .map_err(|err| { + error!(?err, "Vault bootstrap failed"); + Error::VaultError + })?; + + Ok(()) +} + +async fn finalize_unseal( + db: db::DatabasePool, + vault: ActorRef, + passphrases: HashMap>, +) -> Result<(), Error> { + let mut conn = db.get().await?; + let mut shares: Vec> = Vec::new(); + + for (operator_id_raw, passphrase_bytes) in passphrases { + let (encrypted_share, share_nonce_bytes, share_salt): (Vec, Vec, Vec) = + schema::operator::table + .filter(schema::operator::id.eq(Some(operator_id_raw))) + .select(( + schema::operator::share, + schema::operator::share_nonce, + schema::operator::share_salt, + )) + .first(&mut conn) + .await + .map_err(|_| Error::OperatorNotFound)?; + + let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| { + error!(operator_id = operator_id_raw, "Invalid nonce in DB"); + Error::BrokenDatabase + })?; + + let mut passphrase_cell = SafeCell::new(passphrase_bytes); + let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); + + let mut share_buffer = SafeCell::new(encrypted_share); + share_seal_key + .decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer) + .map_err(|_| Error::InvalidPassphrase)?; + + let decrypted_share = share_buffer.read().clone(); + shares.push(decrypted_share); + } + + let seal_key_bytes = + shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?; + + let seal_key = SafeCell::new(seal_key_bytes); + + vault + .ask(TryUnseal { + seal_key_raw: seal_key, + }) + .await + .map_err(|err| { + error!(?err, "Vault unseal failed"); + Error::VaultError + })?; + + Ok(()) +} + +#[messages] +impl VaultCoordinator { + /// Phase 1 of multi-operator bootstrap: declare the committee size. + #[message] + #[expect(clippy::unused_async, reason = "kameo requires messages to be async")] + pub async fn start_bootstrap( + &mut self, + operator_id: i32, + declared_count: usize, + ) -> Result<(), Error> { + let _ = operator_id; + if !matches!(self.state, CoordinatorState::Idle) { + return Err(Error::AlreadyBootstrapping); + } + self.state = CoordinatorState::Bootstrapping { + declared_count, + passphrases: HashMap::new(), + }; + Ok(()) + } + + /// Phase 2 of multi-operator bootstrap: contribute a passphrase. + /// Returns Ok(true) when all operators contributed and bootstrap finalized. + #[message] + pub async fn contribute_bootstrap( + &mut self, + operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Bootstrapping { + declared_count, + passphrases, + } = &mut self.state + else { + return Err(Error::NotBootstrapping); + }; + + if passphrases.contains_key(&operator_id) { + return Err(Error::DuplicateContribution); + } + + // Extract bytes immediately so state stays Sync + let passphrase_bytes = passphrase.read().to_vec(); + passphrases.insert(operator_id, passphrase_bytes); + + if passphrases.len() < *declared_count { + return Ok(false); + } + + let CoordinatorState::Bootstrapping { passphrases, .. } = + std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_bootstrap(self.db.clone(), self.vault.clone(), passphrases).await?; + Ok(true) + } + + /// Contribute a passphrase for vault unseal. + /// Returns Ok(true) when threshold reached and vault is unsealed. + #[message] + pub async fn contribute_unseal( + &mut self, + operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + if matches!(self.state, CoordinatorState::Idle) { + let mut conn = self.db.get().await?; + let count: i64 = schema::operator::table + .count() + .get_result(&mut conn) + .await?; + let threshold = shamir_threshold(usize::try_from(count).unwrap_or_default()); + + self.state = CoordinatorState::Unsealing { + threshold, + passphrases: HashMap::new(), + }; + } + + let CoordinatorState::Unsealing { + threshold, + passphrases, + } = &mut self.state + else { + return Err(Error::NotUnsealing); + }; + + if passphrases.contains_key(&operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + passphrases.insert(operator_id, passphrase_bytes); + + if passphrases.len() < *threshold { + return Ok(false); + } + + let CoordinatorState::Unsealing { passphrases, .. } = + std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_unseal(self.db.clone(), self.vault.clone(), passphrases).await?; + Ok(true) + } +} -- 2.49.1 From 59cb65f3e1824431d5d6c57516fe7dade9902ecb Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 19:43:17 +0200 Subject: [PATCH 08/66] feat(server::grpc): wire Shamir committee bootstrap and unseal proto messages Adds DeclareCommittee and ContributePassphrase variants to bootstrap.proto, ContributePassphrase to unseal.proto, and AwaitingContributions result codes to both. Implements corresponding inbound converters and outbound reply mappings. VaultGate handlers delegate to VaultCoordinator. --- .../src/grpc/operator/vault_gate/inbound.rs | 40 ++++++++++++-- .../src/grpc/operator/vault_gate/outbound.rs | 35 +++++++++++- .../src/peers/operator/vault_gate/mod.rs | 53 ++++++++++++++++++- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index 6a08235..b0955eb 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -1,14 +1,16 @@ use crate::{ grpc::{Convert, TryConvert}, peers::operator::vault_gate::{ - self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey, + self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase, + HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake, + HandleUnsealEncryptedKey, }, }; use arbiter_proto::proto::operator::{ operator_request::Payload as OperatorRequestPayload, vault::{ self as proto_vault, - bootstrap::{self as proto_bootstrap}, + bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload}, request::Payload as VaultRequestPayload, unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload}, }, @@ -73,6 +75,13 @@ impl TryConvert for UnsealRequestPayload { match self { Self::Start(start) => start.try_convert(), Self::EncryptedKey(key) => Ok(key.convert()), + Self::ContributePassphrase(cp) => Ok( + vault_gate::Inbound::HandleContributeUnsealPassphrase( + HandleContributeUnsealPassphrase { + passphrase: cp.passphrase, + }, + ), + ), } } } @@ -107,12 +116,35 @@ impl TryConvert for proto_bootstrap::Request { type Error = Status; fn try_convert(self) -> Result { - self.encrypted_key - .ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))? + self.payload + .ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))? .try_convert() } } +impl TryConvert for BootstrapRequestPayload { + type Output = vault_gate::Inbound; + type Error = Status; + + fn try_convert(self) -> Result { + match self { + Self::EncryptedKey(key) => key.try_convert(), + Self::DeclareCommittee(dc) => Ok( + vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee { + count: dc.count as usize, + }), + ), + Self::ContributePassphrase(cp) => Ok( + vault_gate::Inbound::HandleContributeBootstrapPassphrase( + HandleContributeBootstrapPassphrase { + passphrase: cp.passphrase, + }, + ), + ), + } + } +} + impl TryConvert for proto_bootstrap::BootstrapEncryptedKey { type Output = vault_gate::Inbound; type Error = Status; diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs index 268b7d5..1e44ca4 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs @@ -46,7 +46,6 @@ impl Convert for VaultState { fn convert(self) -> OperatorResponsePayload { let proto_state = match self { Self::Unbootstrapped => ProtoVaultState::Unbootstrapped, - Self::Bootstrapping => ProtoVaultState::Boostrapping, Self::Sealed => ProtoVaultState::Sealed, Self::Unsealed => ProtoVaultState::Unsealed, }; @@ -111,6 +110,40 @@ impl TryConvert for vault_gate::Outbound { }; Ok(wrap_bootstrap_response(proto_result)) } + Self::HandleDeclareCommittee(result) => { + let proto_result = match result { + Ok(()) => ProtoBootstrapResult::Success, + Err(err) => { + warn!(?err, "declare committee failed"); + return Err(Status::internal("Failed to declare committee")); + } + }; + Ok(wrap_bootstrap_response(proto_result)) + } + Self::HandleContributeBootstrapPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoBootstrapResult::Success, + Ok(false) => ProtoBootstrapResult::AwaitingContributions, + Err(err) => { + warn!(?err, "contribute bootstrap passphrase failed"); + return Err(Status::internal("Failed to contribute bootstrap passphrase")); + } + }; + Ok(wrap_bootstrap_response(proto_result)) + } + Self::HandleContributeUnsealPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoUnsealResult::Success, + Ok(false) => ProtoUnsealResult::AwaitingContributions, + Err(err) => { + warn!(?err, "contribute unseal passphrase failed"); + return Err(Status::internal("Failed to contribute unseal passphrase")); + } + }; + Ok(wrap_unseal_response(UnsealResponsePayload::Result( + proto_result.into(), + ))) + } } } } diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index 6a8a265..b74ff3e 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -3,6 +3,7 @@ use crate::{ actors::{ GlobalActors, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, + vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap}, }, crypto::integrity::{self}, db::DatabasePool, @@ -17,6 +18,9 @@ use tokio::sync::oneshot; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret}; +pub use VaultGateMessage as Inbound; +pub use VaultGateMessageReply as Outbound; + pub mod state; #[derive(Debug, thiserror::Error)] @@ -118,8 +122,7 @@ impl VaultGate { } } } - -#[messages(messages = Inbound, replies = Outbound)] +#[messages(enum)] impl VaultGate { #[message] pub fn handle_handshake( @@ -234,6 +237,52 @@ impl VaultGate { Ok(answer) } + + #[message] + pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> { + self.actors + .vault_coordinator + .ask(StartBootstrap { + operator_id: self.auth_creds.id, + declared_count: count, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub async fn handle_contribute_bootstrap_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + use arbiter_crypto::safecell::SafeCell; + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeBootstrap { + operator_id: self.auth_creds.id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub async fn handle_contribute_unseal_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + use arbiter_crypto::safecell::SafeCell; + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeUnseal { + operator_id: self.auth_creds.id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } } impl Message for VaultGate { -- 2.49.1 From 80ba30d430a6da749de91c7645d751f88ab03cee Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 21:11:48 +0200 Subject: [PATCH 09/66] fix(server::tests): tighten unseal test seal_key params to &[u8; 32] --- .../arbiter-server/src/crypto/integrity/v1.rs | 2 +- .../crates/arbiter-server/tests/client/auth.rs | 2 +- server/crates/arbiter-server/tests/common/mod.rs | 2 +- .../crates/arbiter-server/tests/operator/auth.rs | 8 ++++---- .../arbiter-server/tests/operator/unseal.rs | 16 ++++++++-------- .../arbiter-server/tests/vault/concurrency.rs | 2 +- .../arbiter-server/tests/vault/lifecycle.rs | 10 +++++----- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index edb2274..c777967 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -235,7 +235,7 @@ mod tests { ); actor .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), + seal_key_raw: SafeCell::new([0u8; 32].to_vec()), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index a7320f6..964f282 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -100,7 +100,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key_raw: SafeCell::new([0u8; 32].to_vec()), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/common/mod.rs b/server/crates/arbiter-server/tests/common/mod.rs index 83a2f81..33cca5e 100644 --- a/server/crates/arbiter-server/tests/common/mod.rs +++ b/server/crates/arbiter-server/tests/common/mod.rs @@ -19,7 +19,7 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { .await .unwrap(); actor - .bootstrap(SafeCell::new(b"test-seal-key".to_vec())) + .bootstrap(SafeCell::new([0u8; 32].to_vec())) .await .unwrap(); actor diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 433c03d..7d91a55 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -157,7 +157,7 @@ pub async fn bootstrap_token_auth() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key_raw: SafeCell::new([0u8; 32].to_vec()), }) .await .unwrap(); @@ -275,7 +275,7 @@ pub async fn challenge_auth() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key_raw: SafeCell::new([0u8; 32].to_vec()), }) .await .unwrap(); @@ -361,7 +361,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key_raw: SafeCell::new([0u8; 32].to_vec()), }) .await .unwrap(); @@ -434,7 +434,7 @@ pub async fn challenge_auth_rejects_invalid_signature() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), + seal_key_raw: SafeCell::new([0u8; 32].to_vec()), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index 365e6ec..c2267d2 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -22,7 +22,7 @@ use tokio::sync::oneshot; use x25519_dalek::{EphemeralSecret, PublicKey}; async fn setup_sealed_gate( - seal_key: &[u8], + seal_key: &[u8; 32], ) -> ( db::DatabasePool, kameo::actor::ActorRef, @@ -50,7 +50,7 @@ async fn setup_sealed_gate( async fn client_dh_encrypt( gate: &kameo::actor::ActorRef, - key_to_send: &[u8], + key_to_send: &[u8; 32], ) -> HandleUnsealEncryptedKey { let client_secret = EphemeralSecret::random(); let client_public = PublicKey::from(&client_secret); @@ -83,7 +83,7 @@ async fn client_dh_encrypt( #[tokio::test] #[test_log::test] pub async fn unseal_success() { - let seal_key = b"test-seal-key"; + let seal_key = b"test-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let encrypted_key = client_dh_encrypt(&gate, seal_key).await; @@ -95,10 +95,10 @@ pub async fn unseal_success() { #[tokio::test] #[test_log::test] pub async fn unseal_wrong_seal_key() { - let seal_key = b"test-seal-key"; + let seal_key = b"test-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; - let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await; + let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await; let response = gate.ask(encrypted_key).await; assert!(matches!( @@ -112,7 +112,7 @@ pub async fn unseal_wrong_seal_key() { #[tokio::test] #[test_log::test] pub async fn unseal_corrupted_ciphertext() { - let seal_key = b"test-seal-key"; + let seal_key = b"test-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let client_secret = EphemeralSecret::random(); @@ -143,11 +143,11 @@ pub async fn unseal_corrupted_ciphertext() { #[tokio::test] #[test_log::test] pub async fn unseal_retry_after_invalid_key() { - let seal_key = b"real-seal-key"; + let seal_key = b"real-seal-key-padded-to-32bytes!"; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; { - let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await; + let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await; let response = gate.ask(encrypted_key).await; assert!(matches!( diff --git a/server/crates/arbiter-server/tests/vault/concurrency.rs b/server/crates/arbiter-server/tests/vault/concurrency.rs index ee84f4a..48d9a34 100644 --- a/server/crates/arbiter-server/tests/vault/concurrency.rs +++ b/server/crates/arbiter-server/tests/vault/concurrency.rs @@ -166,7 +166,7 @@ async fn decrypt_roundtrip_after_high_concurrency() { .await .unwrap(); decryptor - .try_unseal(SafeCell::new(b"test-seal-key".to_vec())) + .try_unseal(SafeCell::new([0u8; 32].to_vec())) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 25017c4..1f3dc38 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -20,7 +20,7 @@ async fn test_bootstrap() { .await .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); + let seal_key = SafeCell::new([0u8; 32].to_vec()); actor.bootstrap(seal_key).await.unwrap(); let mut conn = db.get().await.unwrap(); @@ -43,7 +43,7 @@ async fn test_bootstrap_rejects_double() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; - let seal_key2 = SafeCell::new(b"test-seal-key".to_vec()); + let seal_key2 = SafeCell::new([0u8; 32].to_vec()); let err = actor.bootstrap(seal_key2).await.unwrap_err(); assert!(matches!(err, Error::AlreadyBootstrapped)); } @@ -105,7 +105,7 @@ async fn test_unseal_correct_password() { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await .unwrap(); - let seal_key = SafeCell::new(b"test-seal-key".to_vec()); + let seal_key = SafeCell::new([0u8; 32].to_vec()); actor.try_unseal(seal_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap(); @@ -129,11 +129,11 @@ async fn test_unseal_wrong_then_correct_password() { .await .unwrap(); - let bad_key = SafeCell::new(b"wrong-password".to_vec()); + let bad_key = SafeCell::new([1u8; 32].to_vec()); let err = actor.try_unseal(bad_key).await.unwrap_err(); assert!(matches!(err, Error::InvalidKey)); - let good_key = SafeCell::new(b"test-seal-key".to_vec()); + let good_key = SafeCell::new([0u8; 32].to_vec()); actor.try_unseal(good_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap(); -- 2.49.1 From 240fd3eb63383a1769322946641d87a5b57cdbb4 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 21:15:07 +0200 Subject: [PATCH 10/66] refactor(server::crypto): use fixed-size [u8; 32] and KeyCell throughout seal key API --- .../arbiter-server/src/actors/vault/mod.rs | 10 +++---- .../src/actors/vault_coordinator/mod.rs | 16 +++++------ .../arbiter-server/src/crypto/integrity/v1.rs | 4 +-- .../crates/arbiter-server/src/crypto/mod.rs | 9 +++++++ .../arbiter-server/src/crypto/shamir.rs | 12 +++++---- .../src/peers/operator/vault_gate/mod.rs | 27 +++++++------------ .../arbiter-server/tests/client/auth.rs | 7 ++--- .../crates/arbiter-server/tests/common/mod.rs | 3 +-- .../arbiter-server/tests/operator/auth.rs | 13 ++++----- .../arbiter-server/tests/operator/unseal.rs | 7 ++--- .../arbiter-server/tests/vault/concurrency.rs | 2 +- .../arbiter-server/tests/vault/lifecycle.rs | 12 ++++----- 12 files changed, 53 insertions(+), 69 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index 94b7d9d..e29cb24 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -162,13 +162,12 @@ impl Vault { #[messages] impl Vault { #[message] - pub async fn bootstrap(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { + pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { if !matches!(&self.state, State::Unbootstrapped) { return Err(Error::AlreadyBootstrapped); } let mut root_key = KeyCell::new_secure_random(); - let mut seal_key = KeyCell::try_from(seal_key_raw).map_err(|()| Error::InvalidKey)?; // Zero nonces are fine because they are one-time let root_key_nonce = Nonce::default(); @@ -227,7 +226,7 @@ impl Vault { } #[message] - pub async fn try_unseal(&mut self, seal_key_raw: SafeCell>) -> Result<(), Error> { + pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { let State::Sealed { root_key_history_id, } = &self.state @@ -246,8 +245,6 @@ impl Vault { .await? }; - let mut seal_key = KeyCell::try_from(seal_key_raw).map_err(|()| Error::InvalidKey)?; - let nonce = Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { error!("Broken database: invalid nonce for root key"); @@ -422,8 +419,7 @@ mod tests { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await .unwrap(); - let seal_key = SafeCell::new([0u8; 32].to_vec()); - actor.bootstrap(seal_key).await.unwrap(); + actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap(); actor } diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index e9b44a3..77ed021 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -9,7 +9,7 @@ use tracing::error; use crate::{ actors::vault::{Bootstrap, TryUnseal, Vault}, - crypto::{derive_key, encryption::v1::Nonce, shamir}, + crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir}, db::{self, models, schema}, }; @@ -94,14 +94,14 @@ async fn finalize_bootstrap( let threshold = shamir_threshold(total); // Generate random 32-byte seal key - let mut seal_key_bytes = vec![0u8; 32]; + let mut seal_key_bytes = [0u8; 32]; OsRng.fill_bytes(&mut seal_key_bytes); // Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs) let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng) .map_err(|e| Error::Shamir(e.to_string()))?; - let seal_key = SafeCell::new(seal_key_bytes); + let seal_key = KeyCell::from(seal_key_bytes); let mut conn = db.get().await?; @@ -136,9 +136,7 @@ async fn finalize_bootstrap( } vault - .ask(Bootstrap { - seal_key_raw: seal_key, - }) + .ask(Bootstrap { seal_key }) .await .map_err(|err| { error!(?err, "Vault bootstrap failed"); @@ -189,12 +187,10 @@ async fn finalize_unseal( let seal_key_bytes = shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?; - let seal_key = SafeCell::new(seal_key_bytes); + let seal_key = KeyCell::from(seal_key_bytes); vault - .ask(TryUnseal { - seal_key_raw: seal_key, - }) + .ask(TryUnseal { seal_key }) .await .map_err(|err| { error!(?err, "Vault unseal failed"); diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index c777967..9feb840 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -215,8 +215,6 @@ mod tests { }, db::{self, schema}, }; - use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; - use super::{Error, Integrable, sign_entity, verify_entity}; #[derive(Clone, arbiter_macros::Hashable)] struct DummyEntity { @@ -235,7 +233,7 @@ mod tests { ); actor .ask(Bootstrap { - seal_key_raw: SafeCell::new([0u8; 32].to_vec()), + seal_key: crate::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs index a100001..2db08d8 100644 --- a/server/crates/arbiter-server/src/crypto/mod.rs +++ b/server/crates/arbiter-server/src/crypto/mod.rs @@ -21,6 +21,15 @@ impl From> for KeyCell { Self(value) } } +impl From<[u8; 32]> for KeyCell { + fn from(bytes: [u8; 32]) -> Self { + let cell = SafeCell::new_inline_default(|key: &mut Key| { + key.copy_from_slice(&bytes); + }); + Self(cell) + } +} + impl TryFrom>> for KeyCell { type Error = (); diff --git a/server/crates/arbiter-server/src/crypto/shamir.rs b/server/crates/arbiter-server/src/crypto/shamir.rs index b369390..c379685 100644 --- a/server/crates/arbiter-server/src/crypto/shamir.rs +++ b/server/crates/arbiter-server/src/crypto/shamir.rs @@ -13,15 +13,17 @@ pub enum ShamirError { pub fn split_key( threshold: usize, total: usize, - key: &[u8], + key: &[u8; 32], rng: impl rand_core::RngCore + rand_core::CryptoRng, ) -> Result>, ShamirError> { - Gf256::split_array(threshold, total, key, rng) + Gf256::split_array(threshold, total, key.as_slice(), rng) .map_err(|e| ShamirError::Split(format!("{e:?}"))) } /// Reconstruct the secret from `threshold` or more shares. -pub fn combine_shares(shares: &[Vec]) -> Result, ShamirError> { - Gf256::combine_array(shares) - .map_err(|e| ShamirError::Combine(format!("{e:?}"))) +pub fn combine_shares(shares: &[Vec]) -> Result<[u8; 32], ShamirError> { + let bytes = Gf256::combine_array(shares) + .map_err(|e| ShamirError::Combine(format!("{e:?}")))?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned())) } diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index b74ff3e..2e73555 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -5,7 +5,7 @@ use crate::{ vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap}, }, - crypto::integrity::{self}, + crypto::{KeyCell, integrity::{self}}, db::DatabasePool, }; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; @@ -102,11 +102,9 @@ impl VaultGate { nonce: &[u8], ciphertext: &[u8], associated_data: &[u8], - ) -> Result>, ()> { + ) -> Result { let nonce = XNonce::from_slice(nonce); - let cipher = XChaCha20Poly1305::new(secret.as_bytes().into()); - let mut key_buffer = SafeCell::new(ciphertext.to_vec()); let decryption_result = key_buffer.write_inline(|write_handle| { @@ -114,7 +112,9 @@ impl VaultGate { }); match decryption_result { - Ok(()) => Ok(key_buffer), + Ok(()) => KeyCell::try_from(key_buffer).map_err(|()| { + error!("Decrypted key material has unexpected length"); + }), Err(err) => { error!(?err, "Failed to decrypt encrypted key material"); Err(()) @@ -122,6 +122,7 @@ impl VaultGate { } } } + #[messages(enum)] impl VaultGate { #[message] @@ -155,17 +156,14 @@ impl VaultGate { return Err(Error::State); }; - let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) - else { + let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else { return Err(Error::InvalidKey); }; match self .actors .vault - .ask(TryUnseal { - seal_key_raw: seal_key_buffer, - }) + .ask(TryUnseal { seal_key }) .await { Ok(()) => { @@ -195,17 +193,14 @@ impl VaultGate { return Err(Error::State); }; - let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) - else { + let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else { return Err(Error::InvalidKey); }; match self .actors .vault - .ask(Bootstrap { - seal_key_raw: seal_key_buffer, - }) + .ask(Bootstrap { seal_key }) .await { Ok(()) => { @@ -255,7 +250,6 @@ impl VaultGate { &mut self, passphrase: Vec, ) -> Result { - use arbiter_crypto::safecell::SafeCell; let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator @@ -272,7 +266,6 @@ impl VaultGate { &mut self, passphrase: Vec, ) -> Result { - use arbiter_crypto::safecell::SafeCell; let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 964f282..facc4e5 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,8 +1,5 @@ use super::common::ChannelTransport; -use arbiter_crypto::{ - authn::{self, AuthChallenge, CLIENT_CONTEXT}, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; use arbiter_proto::{ ClientMetadata, transport::{Receiver, Sender}, @@ -100,7 +97,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new([0u8; 32].to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/common/mod.rs b/server/crates/arbiter-server/tests/common/mod.rs index 33cca5e..598eee9 100644 --- a/server/crates/arbiter-server/tests/common/mod.rs +++ b/server/crates/arbiter-server/tests/common/mod.rs @@ -2,7 +2,6 @@ dead_code, reason = "Common test utilities that may not be used in every test" )] -use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_proto::transport::{Bi, Error, Receiver, Sender}; use arbiter_server::{ actors::{GlobalActors, vault::Vault}, @@ -19,7 +18,7 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { .await .unwrap(); actor - .bootstrap(SafeCell::new([0u8; 32].to_vec())) + .bootstrap(arbiter_server::crypto::KeyCell::from([0u8; 32])) .await .unwrap(); actor diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 7d91a55..76afc1a 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -1,8 +1,5 @@ use super::common::ChannelTransport; -use arbiter_crypto::{ - authn::{self, AuthChallenge, OPERATOR_CONTEXT}, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_server::{ actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, @@ -157,7 +154,7 @@ pub async fn bootstrap_token_auth() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new([0u8; 32].to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); @@ -275,7 +272,7 @@ pub async fn challenge_auth() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new([0u8; 32].to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); @@ -361,7 +358,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new([0u8; 32].to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); @@ -434,7 +431,7 @@ pub async fn challenge_auth_rejects_invalid_signature() { actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new([0u8; 32].to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index c2267d2..6acfdba 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -1,7 +1,4 @@ -use arbiter_crypto::{ - authn, - safecell::{SafeCell, SafeCellHandle as _}, -}; +use arbiter_crypto::authn; use arbiter_server::{ actors::{ GlobalActors, @@ -34,7 +31,7 @@ async fn setup_sealed_gate( actors .vault .ask(Bootstrap { - seal_key_raw: SafeCell::new(seal_key.to_vec()), + seal_key: arbiter_server::crypto::KeyCell::from(*seal_key), }) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/vault/concurrency.rs b/server/crates/arbiter-server/tests/vault/concurrency.rs index 48d9a34..4e94fa9 100644 --- a/server/crates/arbiter-server/tests/vault/concurrency.rs +++ b/server/crates/arbiter-server/tests/vault/concurrency.rs @@ -166,7 +166,7 @@ async fn decrypt_roundtrip_after_high_concurrency() { .await .unwrap(); decryptor - .try_unseal(SafeCell::new([0u8; 32].to_vec())) + .try_unseal(arbiter_server::crypto::KeyCell::from([0u8; 32])) .await .unwrap(); diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 1f3dc38..6148590 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -5,7 +5,7 @@ use arbiter_server::{ GlobalActors, vault::{Error, Vault}, }, - crypto::encryption::v1::{Nonce, ROOT_KEY_TAG}, + crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, }; @@ -20,7 +20,7 @@ async fn test_bootstrap() { .await .unwrap(); - let seal_key = SafeCell::new([0u8; 32].to_vec()); + let seal_key = KeyCell::from([0u8; 32]); actor.bootstrap(seal_key).await.unwrap(); let mut conn = db.get().await.unwrap(); @@ -43,7 +43,7 @@ async fn test_bootstrap_rejects_double() { let db = db::create_test_pool().await; let mut actor = common::bootstrapped_vault(&db).await; - let seal_key2 = SafeCell::new([0u8; 32].to_vec()); + let seal_key2 = KeyCell::from([0u8; 32]); let err = actor.bootstrap(seal_key2).await.unwrap_err(); assert!(matches!(err, Error::AlreadyBootstrapped)); } @@ -105,7 +105,7 @@ async fn test_unseal_correct_password() { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .await .unwrap(); - let seal_key = SafeCell::new([0u8; 32].to_vec()); + let seal_key = KeyCell::from([0u8; 32]); actor.try_unseal(seal_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap(); @@ -129,11 +129,11 @@ async fn test_unseal_wrong_then_correct_password() { .await .unwrap(); - let bad_key = SafeCell::new([1u8; 32].to_vec()); + let bad_key = KeyCell::from([1u8; 32]); let err = actor.try_unseal(bad_key).await.unwrap_err(); assert!(matches!(err, Error::InvalidKey)); - let good_key = SafeCell::new([0u8; 32].to_vec()); + let good_key = KeyCell::from([0u8; 32]); actor.try_unseal(good_key).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap(); -- 2.49.1 From b6c91c56eb6a968c9cdef6489af04d6818e2f0e3 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Fri, 12 Jun 2026 22:01:17 +0200 Subject: [PATCH 11/66] housekepping: add fixme for start_bootstrap's operator_id --- .../crates/arbiter-server/src/actors/vault_coordinator/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 77ed021..68a8f95 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -210,7 +210,7 @@ impl VaultCoordinator { operator_id: i32, declared_count: usize, ) -> Result<(), Error> { - let _ = operator_id; + let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins if !matches!(self.state, CoordinatorState::Idle) { return Err(Error::AlreadyBootstrapping); } -- 2.49.1 From 966b4c8828c8bf75f98eb60e771bd0218cce8280 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:06:23 +0200 Subject: [PATCH 12/66] feat(proto): add governance proposal/vote RPC definitions --- server/crates/arbiter-proto/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index 0b91e11..802285e 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -23,6 +23,10 @@ pub mod proto { tonic::include_proto!("arbiter.operator.evm"); } + pub mod governance { + tonic::include_proto!("arbiter.operator.governance"); + } + pub mod sdk_client { tonic::include_proto!("arbiter.operator.sdk_client"); } -- 2.49.1 From b2632661f88578d6443893adb1c35c21a9e4471e Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:06:30 +0200 Subject: [PATCH 13/66] feat(db): add proposal and proposal_vote tables --- .../2026-02-14-171124-0000_init/up.sql | 21 +++++ server/crates/arbiter-server/src/db/models.rs | 86 ++++++++++++++++++- server/crates/arbiter-server/src/db/schema.rs | 28 ++++++ 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 732c6b6..849025e 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -216,3 +216,24 @@ create table if not exists integrity_envelope ( ) STRICT; create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id); + +create table if not exists proposal ( + id integer not null primary key, + kind text not null, + payload blob not null, + initiator_id integer not null references operator_identity(id) on delete restrict, + created_at integer not null default(unixepoch('now')), + expires_at integer not null, + status text not null default 'pending' + check (status in ('pending', 'approved', 'rejected', 'expired')) +) STRICT; + +create table if not exists proposal_vote ( + id integer not null primary key, + proposal_id integer not null references proposal(id) on delete cascade, + operator_id integer not null references operator_identity(id) on delete restrict, + approve integer not null check (approve in (0, 1)), + signature blob not null, + voted_at integer not null default(unixepoch('now')), + unique (proposal_id, operator_id) +) STRICT; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 1f57b1b..a546a62 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -15,10 +15,11 @@ use restructed::Models; pub mod types { use chrono::{DateTime, Utc}; use diesel::{ + backend::Backend, deserialize::{FromSql, FromSqlRow}, expression::AsExpression, serialize::{IsNull, ToSql}, - sql_types::Integer, + sql_types::{Integer, Text}, sqlite::{Sqlite, SqliteType}, }; @@ -61,7 +62,7 @@ pub mod types { impl FromSql for SqliteTimestamp { fn from_sql( - mut bytes: ::RawValue<'_>, + mut bytes: ::RawValue<'_>, ) -> diesel::deserialize::Result { let Some(SqliteType::Long) = bytes.value_type() else { return Err(format!( @@ -141,6 +142,45 @@ pub mod types { declare_id!(TlsHistoryId); declare_id!(EvmWalletId); declare_id!(ClientId); + + #[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)] + #[diesel(sql_type = Text)] + pub enum ProposalStatus { + Pending, + Approved, + Rejected, + Expired, + } + + impl ToSql for ProposalStatus { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + let s: &str = match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Rejected => "rejected", + Self::Expired => "expired", + }; + >::to_sql(s, out) + } + } + + impl FromSql for ProposalStatus { + fn from_sql( + bytes: ::RawValue<'_>, + ) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + match s.as_str() { + "pending" => Ok(Self::Pending), + "approved" => Ok(Self::Approved), + "rejected" => Ok(Self::Rejected), + "expired" => Ok(Self::Expired), + other => Err(format!("Unknown proposal status: {other}").into()), + } + } + } } pub use types::*; @@ -438,3 +478,45 @@ pub struct IntegrityEnvelope { pub signed_at: SqliteTimestamp, pub created_at: SqliteTimestamp, } + +#[derive(Debug, Queryable, Selectable, Identifiable)] +#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] +pub struct Proposal { + pub id: i32, + pub kind: String, + pub payload: Vec, + pub initiator_id: i32, + pub created_at: SqliteTimestamp, + pub expires_at: SqliteTimestamp, + pub status: ProposalStatus, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] +pub struct NewProposal { + pub kind: String, + pub payload: Vec, + pub initiator_id: i32, + // status defaults to 'pending' at the DB layer + pub expires_at: SqliteTimestamp, +} + +#[derive(Debug, Queryable, Selectable, Identifiable)] +#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))] +pub struct ProposalVote { + pub id: i32, + pub proposal_id: i32, + pub operator_id: i32, + pub approve: bool, + pub signature: Vec, + pub voted_at: SqliteTimestamp, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))] +pub struct NewProposalVote { + pub proposal_id: i32, + pub operator_id: i32, + pub approve: bool, + pub signature: Vec, +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 981d885..c6ce19e 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -172,6 +172,29 @@ diesel::table! { } } +diesel::table! { + proposal (id) { + id -> Integer, + kind -> Text, + payload -> Binary, + initiator_id -> Integer, + created_at -> Integer, + expires_at -> Integer, + status -> Text, + } +} + +diesel::table! { + proposal_vote (id) { + id -> Integer, + proposal_id -> Integer, + operator_id -> Integer, + approve -> Bool, + signature -> Binary, + voted_at -> Integer, + } +} + diesel::table! { program_client (id) { id -> Integer, @@ -225,6 +248,9 @@ diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id)); diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); +diesel::joinable!(proposal -> operator_identity (initiator_id)); +diesel::joinable!(proposal_vote -> proposal (proposal_id)); +diesel::joinable!(proposal_vote -> operator_identity (operator_id)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, @@ -245,6 +271,8 @@ diesel::allow_tables_to_appear_in_same_query!( operator, operator_identity, program_client, + proposal, + proposal_vote, root_key_history, tls_history, ); -- 2.49.1 From 074e6501ec839d200a4e223c3462b4b97112168d Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:06:36 +0200 Subject: [PATCH 14/66] feat(crypto): expose governance signing context and make shamir_threshold pub const --- server/crates/arbiter-crypto/src/authn/v1.rs | 6 ++++++ .../src/actors/vault_coordinator/mod.rs | 11 +---------- server/crates/arbiter-server/src/crypto/shamir.rs | 12 ++++++++++++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/server/crates/arbiter-crypto/src/authn/v1.rs b/server/crates/arbiter-crypto/src/authn/v1.rs index 6f38e44..6f86936 100644 --- a/server/crates/arbiter-crypto/src/authn/v1.rs +++ b/server/crates/arbiter-crypto/src/authn/v1.rs @@ -8,6 +8,7 @@ use rand::RngExt; pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; +pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote"; const NONCE_SIZE: usize = 32; @@ -90,6 +91,11 @@ impl PublicKey { self.0 .verify_with_context(&challenge, context, &signature.0) } + + #[must_use] + pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool { + self.0.verify_with_context(message, context, &signature.0) + } } impl Signature { diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 68a8f95..107ab3e 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -9,7 +9,7 @@ use tracing::error; use crate::{ actors::vault::{Bootstrap, TryUnseal, Vault}, - crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir}, + crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold}, db::{self, models, schema}, }; @@ -76,15 +76,6 @@ impl VaultCoordinator { const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1"; -const fn shamir_threshold(n: usize) -> usize { - match n { - 0 => panic!("No operators"), - 1 => 1, - 2 => 2, - n => n / 2 + 1, - } -} - async fn finalize_bootstrap( db: db::DatabasePool, vault: ActorRef, diff --git a/server/crates/arbiter-server/src/crypto/shamir.rs b/server/crates/arbiter-server/src/crypto/shamir.rs index c379685..61c59e1 100644 --- a/server/crates/arbiter-server/src/crypto/shamir.rs +++ b/server/crates/arbiter-server/src/crypto/shamir.rs @@ -20,6 +20,18 @@ pub fn split_key( .map_err(|e| ShamirError::Split(format!("{e:?}"))) } +/// Returns the minimum number of shares required to reconstruct the secret +/// for a committee of `n` operators. +#[must_use] +pub const fn shamir_threshold(n: usize) -> usize { + match n { + 0 => panic!("No operators"), + 1 => 1, + 2 => 2, + n => n / 2 + 1, + } +} + /// Reconstruct the secret from `threshold` or more shares. pub fn combine_shares(shares: &[Vec]) -> Result<[u8; 32], ShamirError> { let bytes = Gf256::combine_array(shares) -- 2.49.1 From f16d0a26e218e8723dc7a928d9acbb09268126be Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:06:43 +0200 Subject: [PATCH 15/66] feat(server): introduce ProposalManager actor with quorum voting logic --- .../crates/arbiter-server/src/actors/mod.rs | 8 +- .../src/actors/proposal_manager.rs | 399 ++++++++++++++++++ 2 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 server/crates/arbiter-server/src/actors/proposal_manager.rs diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index ec8f113..8f1cf58 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -1,7 +1,7 @@ use crate::{ actors::{ bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, - operator_registry::OperatorRegistry, vault::Vault, + operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault, vault_coordinator::VaultCoordinator, }, db, @@ -15,6 +15,7 @@ pub mod bootstrap; pub mod evm; pub mod flow_coordinator; pub mod operator_registry; +pub mod proposal_manager; pub mod vault; pub mod vault_coordinator; @@ -36,6 +37,7 @@ pub struct GlobalActors { pub flow_coordinator: ActorRef, pub operator_registry: ActorRef, pub evm: ActorRef, + pub proposal_manager: ActorRef, pub events: ActorRef, } @@ -52,6 +54,10 @@ impl GlobalActors { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())), vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new( + db.clone(), + key_holder.clone(), + )), + proposal_manager: ProposalManager::spawn(ProposalManager::new( db, key_holder.clone(), )), diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs new file mode 100644 index 0000000..3abdb31 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -0,0 +1,399 @@ +use crate::{ + actors::vault::Vault, + db::{ + self, + models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp}, + schema, + }, +}; +use chrono::Utc; +use diesel::{ExpressionMethods as _, QueryDsl}; +use diesel_async::RunQueryDsl; +use kameo::{actor::ActorRef, messages}; +use tracing::{error, warn}; + +pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days + +#[derive(Debug, Clone)] +pub enum ProposalKind { + ApproveSdkClient { client_id: i32 }, +} + +impl ProposalKind { + pub const fn kind_str(&self) -> &'static str { + match self { + Self::ApproveSdkClient { .. } => "approve_sdk_client", + } + } + + pub fn encode_payload(&self) -> Vec { + match self { + Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), + } + } + + pub fn decode(kind: &str, payload: &[u8]) -> Result { + match kind { + "approve_sdk_client" => { + let bytes = <[u8; 4]>::try_from(payload) + .map_err(|_| "invalid payload for approve_sdk_client".to_owned())?; + Ok(Self::ApproveSdkClient { + client_id: i32::from_be_bytes(bytes), + }) + } + other => Err(format!("unknown proposal kind: {other}")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VoteOutcome { + Pending, + QuorumApproved, + QuorumRejected, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Proposal not found")] + ProposalNotFound, + #[error("Proposal is not pending")] + ProposalNotPending, + #[error("Operator already voted on this proposal")] + AlreadyVoted, + #[error("Invalid vote signature")] + InvalidSignature, + #[error("Operator not found")] + OperatorNotFound, + #[error("Database connection error: {0}")] + DatabaseConnection(#[from] db::PoolError), + #[error("Database query error: {0}")] + DatabaseQuery(#[from] diesel::result::Error), + #[error("Execution failed: {0}")] + ExecutionFailed(String), +} + +#[derive(Debug)] +pub struct ProposalSummary { + pub id: i32, + pub kind: String, + pub initiator_id: i32, + pub expires_at: SqliteTimestamp, + pub approve_count: i64, + pub reject_count: i64, +} + +pub struct ProposalManager { + pub(crate) db: db::DatabasePool, + pub(crate) vault: ActorRef, +} + +impl ProposalManager { + pub const fn new(db: db::DatabasePool, vault: ActorRef) -> Self { + Self { db, vault } + } +} + +impl kameo::Actor for ProposalManager { + type Args = Self; + type Error = (); + + async fn on_start( + args: Self::Args, + actor_ref: ActorRef, + ) -> Result { + let weak = actor_ref.downgrade(); + tokio::spawn(async move { + loop { + tokio::time::sleep(tokio::time::Duration::from_hours(1)).await; + match weak.upgrade() { + Some(r) => { + let _ = r.ask(ExpireStale).await; + } + None => break, + } + } + }); + Ok(args) + } +} + +#[messages] +impl ProposalManager { + #[message] + pub async fn create_proposal( + &mut self, + kind: ProposalKind, + initiator_id: i32, + ttl_secs: Option, + ) -> Result { + let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); + let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl)); + + let new_proposal = NewProposal { + kind: kind.kind_str().to_owned(), + payload: kind.encode_payload(), + initiator_id, + expires_at, + }; + + let mut conn = self.db.get().await?; + let id: i32 = diesel::insert_into(schema::proposal::table) + .values(&new_proposal) + .returning(schema::proposal::id) + .get_result(&mut conn) + .await?; + + Ok(id) + } + + #[message] + pub async fn query_pending(&mut self, operator_id: i32) -> Vec { + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "fixme! #84; this will break in 2038" + )] + let now_ts = Utc::now().timestamp() as i32; + + let Ok(mut conn) = self.db.get().await else { + warn!("query_pending: failed to acquire DB connection"); + return vec![]; + }; + + let voted_ids: Vec = schema::proposal_vote::table + .filter(schema::proposal_vote::operator_id.eq(operator_id)) + .select(schema::proposal_vote::proposal_id) + .load(&mut conn) + .await + .unwrap_or_default(); + + let proposals: Vec = schema::proposal::table + .filter(schema::proposal::status.eq(ProposalStatus::Pending)) + .filter(schema::proposal::expires_at.gt(now_ts)) + .filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids))) + .load(&mut conn) + .await + .unwrap_or_default(); + + let mut summaries = Vec::with_capacity(proposals.len()); + for p in proposals { + let approve_count: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(p.id)) + .filter(schema::proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await + .unwrap_or(0); + let reject_count: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(p.id)) + .filter(schema::proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await + .unwrap_or(0); + summaries.push(ProposalSummary { + id: p.id, + kind: p.kind, + initiator_id: p.initiator_id, + expires_at: p.expires_at, + approve_count, + reject_count, + }); + } + summaries + } + + #[message] + pub async fn expire_stale(&mut self) -> usize { + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "fixme! #84; this will break in 2038" + )] + let now_ts = Utc::now().timestamp() as i32; + + let Ok(mut conn) = self.db.get().await else { + warn!("expire_stale: failed to acquire DB connection"); + return 0; + }; + + diesel::update(schema::proposal::table) + .filter(schema::proposal::status.eq(ProposalStatus::Pending)) + .filter(schema::proposal::expires_at.lt(now_ts)) + .set(schema::proposal::status.eq(ProposalStatus::Expired)) + .execute(&mut conn) + .await + .unwrap_or(0) + } + + #[message] + pub async fn cast_vote( + &mut self, + proposal_id: i32, + operator_id: i32, + approve: bool, + signature: Vec, + ) -> Result { + use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; + + let mut conn = self.db.get().await?; + + // Load proposal — must exist + let proposal: Proposal = schema::proposal::table + .find(proposal_id) + .first(&mut conn) + .await + .map_err(|e| match e { + diesel::result::Error::NotFound => Error::ProposalNotFound, + other => Error::DatabaseQuery(other), + })?; + + // Check for duplicate vote before status check so AlreadyVoted takes priority + let existing: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::proposal_vote::operator_id.eq(operator_id)) + .count() + .get_result(&mut conn) + .await?; + if existing > 0 { + return Err(Error::AlreadyVoted); + } + + if proposal.status != ProposalStatus::Pending { + return Err(Error::ProposalNotPending); + } + + // Load operator public key from operator_identity + let pubkey_bytes: Vec = schema::operator_identity::table + .find(operator_id) + .select(schema::operator_identity::public_key) + .first(&mut conn) + .await + .map_err(|e| match e { + diesel::result::Error::NotFound => Error::OperatorNotFound, + 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_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, GOVERNANCE_CONTEXT, &auth_sig) { + return Err(Error::InvalidSignature); + } + + // Insert vote + diesel::insert_into(schema::proposal_vote::table) + .values(&NewProposalVote { + proposal_id, + operator_id, + approve, + signature, + }) + .execute(&mut conn) + .await?; + + // Quorum check + let total_operators: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await?; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::as_conversions, + reason = "operator count is always a small positive integer" + )] + let threshold = crate::crypto::shamir::shamir_threshold(total_operators as usize); + + let approve_count: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + + let reject_count: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + + #[expect( + clippy::cast_possible_wrap, + clippy::as_conversions, + reason = "threshold is derived from operator count, always fits i64" + )] + let threshold_i64 = threshold as i64; + + if approve_count >= threshold_i64 { + diesel::update(schema::proposal::table.find(proposal_id)) + .set(schema::proposal::status.eq(ProposalStatus::Approved)) + .execute(&mut conn) + .await?; + drop(conn); // release connection before async execution + self.execute_proposal(&proposal).await?; + return Ok(VoteOutcome::QuorumApproved); + } + + if reject_count > total_operators - threshold_i64 { + diesel::update(schema::proposal::table.find(proposal_id)) + .set(schema::proposal::status.eq(ProposalStatus::Rejected)) + .execute(&mut conn) + .await?; + return Ok(VoteOutcome::QuorumRejected); + } + + Ok(VoteOutcome::Pending) + } +} + +impl ProposalManager { + async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { + let kind = ProposalKind::decode(&proposal.kind, &proposal.payload) + .map_err(Error::ExecutionFailed)?; + match kind { + ProposalKind::ApproveSdkClient { client_id } => { + self.execute_approve_sdk_client(client_id).await + } + } + } + + async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { + use arbiter_crypto::authn; + use crate::{ + crypto::integrity, + peers::client::ClientCredentials, + }; + + let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; + + let pubkey_bytes: Vec = schema::program_client::table + .find(client_id) + .select(schema::program_client::public_key) + .first(&mut conn) + .await + .map_err(|e| Error::ExecutionFailed(format!("client not found: {e}")))?; + + let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice()) + .map_err(|()| Error::ExecutionFailed("invalid client public key".to_owned()))?; + + let creds = ClientCredentials { pubkey }; + + integrity::sign_entity(&mut conn, &self.vault, &creds, client_id) + .await + .map_err(|e| { + error!(?e, "Failed to sign integrity envelope for client"); + Error::ExecutionFailed(e.to_string()) + }) + } +} -- 2.49.1 From e6459aade800bc79d0bace92b62f92ea12d5d4d3 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:06:51 +0200 Subject: [PATCH 16/66] feat(server::grpc): wire governance RPCs through operator session --- .../arbiter-server/src/grpc/operator.rs | 2 + .../src/grpc/operator/governance.rs | 126 ++++++++++++++++++ .../arbiter-server/src/peers/operator/mod.rs | 1 + .../src/peers/operator/session/handlers.rs | 56 ++++++++ .../src/peers/operator/session/mod.rs | 6 +- 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 server/crates/arbiter-server/src/grpc/operator/governance.rs diff --git a/server/crates/arbiter-server/src/grpc/operator.rs b/server/crates/arbiter-server/src/grpc/operator.rs index cdd4e80..aef89a6 100644 --- a/server/crates/arbiter-server/src/grpc/operator.rs +++ b/server/crates/arbiter-server/src/grpc/operator.rs @@ -19,6 +19,7 @@ use tracing::{error, info, warn}; mod auth; mod evm; +mod governance; mod inbound; mod outbound; mod sdk_client; @@ -115,6 +116,7 @@ async fn dispatch_inner( warn!("Unsupported post-auth operator auth request"); Err(Status::invalid_argument("Unsupported operator request")) } + OperatorRequestPayload::Governance(req) => governance::dispatch(actor, req).await, } } diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs new file mode 100644 index 0000000..f993c05 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -0,0 +1,126 @@ +use crate::{ + actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome}, + peers::operator::{ + OperatorSession, + session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending}, + }, +}; +use arbiter_proto::proto::operator::{ + governance::{ + self as proto_gov, CreateProposalRequest, QueryPendingRequest, QueryPendingResponse, + VoteOutcome as ProtoVoteOutcome, create_proposal_request::Kind as ProtoKind, + request::Payload as GovRequestPayload, response::Payload as GovResponsePayload, + }, + operator_response::Payload as OperatorResponsePayload, +}; +use kameo::actor::ActorRef; +use tonic::Status; +use tracing::warn; + +const fn wrap(payload: GovResponsePayload) -> OperatorResponsePayload { + OperatorResponsePayload::Governance(proto_gov::Response { + payload: Some(payload), + }) +} + +pub(super) async fn dispatch( + actor: &ActorRef, + req: proto_gov::Request, +) -> Result, Status> { + let Some(payload) = req.payload else { + return Err(Status::invalid_argument( + "Missing governance request payload", + )); + }; + + match payload { + GovRequestPayload::Create(req) => handle_create(actor, req).await, + GovRequestPayload::Vote(req) => handle_vote(actor, req).await, + GovRequestPayload::Query(QueryPendingRequest {}) => handle_query(actor).await, + } +} + +async fn handle_create( + actor: &ActorRef, + req: CreateProposalRequest, +) -> Result, Status> { + let kind = match req.kind { + Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient { + client_id: p.client_id, + }, + 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 }) + .await + .map_err(|e| { + warn!(?e, "create_proposal failed"); + Status::internal("Failed to create proposal") + })?; + + Ok(Some(wrap(GovResponsePayload::Created( + proto_gov::CreateProposalResponse { proposal_id }, + )))) +} + +async fn handle_vote( + actor: &ActorRef, + req: proto_gov::CastVoteRequest, +) -> Result, Status> { + let result = actor + .ask(HandleCastVote { + proposal_id: req.proposal_id, + approve: req.approve, + signature: req.signature, + }) + .await; + + let outcome = match result { + Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending, + Ok(VoteOutcome::QuorumApproved) => ProtoVoteOutcome::Approved, + Ok(VoteOutcome::QuorumRejected) => ProtoVoteOutcome::Rejected, + Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => { + return Err(Status::invalid_argument("Already voted on this proposal")); + } + Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) => { + return Err(Status::invalid_argument("Invalid vote signature")); + } + Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => { + return Err(Status::not_found("Proposal not found")); + } + Err(e) => { + warn!(?e, "cast_vote failed"); + return Err(Status::internal("Failed to cast vote")); + } + }; + + Ok(Some(wrap(GovResponsePayload::Voted( + proto_gov::VoteResponse { + outcome: outcome.into(), + }, + )))) +} + +async fn handle_query( + actor: &ActorRef, +) -> Result, Status> { + let summaries = actor.ask(HandleQueryPending {}).await.unwrap_or_default(); + + let proposals = summaries + .into_iter() + .map(|s| proto_gov::ProposalSummary { + id: s.id, + kind: s.kind, + initiator_id: s.initiator_id, + expires_at: s.expires_at.0.timestamp(), + approve_count: s.approve_count, + reject_count: s.reject_count, + }) + .collect(); + + Ok(Some(wrap(GovResponsePayload::Pending( + QueryPendingResponse { proposals }, + )))) +} diff --git a/server/crates/arbiter-server/src/peers/operator/mod.rs b/server/crates/arbiter-server/src/peers/operator/mod.rs index 0869d51..fe532bc 100644 --- a/server/crates/arbiter-server/src/peers/operator/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/mod.rs @@ -180,6 +180,7 @@ where Ok(OperatorSession::spawn(OperatorSession::new( props.clone(), + creds.clone(), oob_sender, ))) } 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 f2b9b3e..426561d 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -279,3 +279,59 @@ impl OperatorSession { Ok(clients) } } + +#[messages] +impl OperatorSession { + #[message] + pub(crate) async fn handle_create_proposal( + &mut self, + kind: crate::actors::proposal_manager::ProposalKind, + ttl_secs: Option, + ) -> Result { + use crate::actors::proposal_manager::CreateProposal; + let initiator_id = self.credentials.id; + self.props + .actors + .proposal_manager + .ask(CreateProposal { kind, initiator_id, ttl_secs }) + .await + .map_err(|e| { + error!(?e, "create_proposal failed"); + Error::internal("Failed to create proposal") + }) + } + + #[message] + pub(crate) async fn handle_cast_vote( + &mut self, + proposal_id: i32, + approve: bool, + signature: Vec, + ) -> Result { + use crate::actors::proposal_manager::CastVote; + let operator_id = self.credentials.id; + self.props + .actors + .proposal_manager + .ask(CastVote { proposal_id, operator_id, approve, signature }) + .await + .map_err(|err| match err { + SendError::HandlerError(e) => e, + _ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()), + }) + } + + #[message] + pub(crate) async fn handle_query_pending( + &mut self, + ) -> Vec { + use crate::actors::proposal_manager::QueryPending; + let operator_id = self.credentials.id; + self.props + .actors + .proposal_manager + .ask(QueryPending { operator_id }) + .await + .unwrap_or_default() + } +} diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 0fe2c84..083f106 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -1,4 +1,4 @@ -use super::{OutOfBand, OperatorConnection}; +use super::{Credentials, OutOfBand, OperatorConnection}; use crate::{ actors::{ flow_coordinator::client_connect_approval::ClientApprovalController, @@ -51,6 +51,7 @@ pub struct PendingClientApproval { pub struct OperatorSession { props: OperatorConnection, + credentials: Credentials, sender: Box>, pending_client_approvals: HashMap, PendingClientApproval>, @@ -59,9 +60,10 @@ pub struct OperatorSession { pub mod handlers; impl OperatorSession { - pub(crate) fn new(props: OperatorConnection, sender: Box>) -> Self { + pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box>) -> Self { Self { props, + credentials, sender, pending_client_approvals: HashMap::default(), } -- 2.49.1 From 3e61d807b4851d2155790169aec578f27d98b5f8 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:06:57 +0200 Subject: [PATCH 17/66] test(server): governance integration tests --- .../crates/arbiter-server/tests/governance.rs | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 server/crates/arbiter-server/tests/governance.rs diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs new file mode 100644 index 0000000..bbc65b0 --- /dev/null +++ b/server/crates/arbiter-server/tests/governance.rs @@ -0,0 +1,424 @@ +use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; +use arbiter_server::{ + actors::{ + GlobalActors, + proposal_manager::{CastVote, CreateProposal, Error as ProposalError, ExpireStale, ProposalKind, QueryPending, VoteOutcome}, + }, + crypto::KeyCell, + db, +}; +use arbiter_server::actors::vault::Bootstrap; +use arbiter_server::db::schema::operator_identity; +use diesel::{ExpressionMethods, QueryDsl, insert_into}; +use diesel_async::RunQueryDsl; + +async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { + let mut conn = db.get().await.unwrap(); + insert_into(operator_identity::table) + .values(operator_identity::public_key.eq(pubkey.to_bytes())) + .returning(operator_identity::id) + .get_result::(&mut conn) + .await + .unwrap() +} + +fn make_vote_message(proposal_id: i32, approve: bool) -> Vec { + let mut msg = Vec::with_capacity(9); + msg.extend_from_slice(&(proposal_id as i64).to_be_bytes()); + msg.push(u8::from(approve)); + msg +} + +async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { + use arbiter_server::db::schema::{client_metadata, program_client}; + let mut conn = db.get().await.unwrap(); + let metadata_id: i32 = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq("test-client"), + client_metadata::description.eq(Option::::None), + client_metadata::version.eq(Option::::None), + )) + .returning(client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + + insert_into(program_client::table) + .values(( + program_client::public_key.eq(pubkey.to_bytes()), + program_client::metadata_id.eq(metadata_id), + )) + .returning(program_client::id) + .get_result(&mut conn) + .await + .unwrap() +} + +#[tokio::test] +async fn create_proposal_returns_id() { + 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 proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id: 42 }, + initiator_id: 1, + ttl_secs: None, + }) + .await + .unwrap(); + + assert!(proposal_id > 0); +} + +#[tokio::test] +async fn single_operator_vote_reaches_quorum() { + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); +} + +#[tokio::test] +async fn two_operator_first_vote_is_pending() { + 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 key1 = authn::SigningKey::generate(); + let key2 = authn::SigningKey::generate(); + let op1 = register_operator(&db, &key1.public_key()).await; + let _op2 = register_operator(&db, &key2.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op1, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = key1.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op1, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::Pending); +} + +#[tokio::test] +async fn duplicate_vote_rejected() { + 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 client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + // Second vote same operator + let sig2 = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig2.to_bytes(), + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) + )); +} + +#[tokio::test] +async fn invalid_signature_rejected() { + 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 client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: vec![0u8; 32], // garbage + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) + )); +} + +#[tokio::test] +async fn query_pending_excludes_already_voted() { + 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 signing_key = authn::SigningKey::generate(); + let op = register_operator(&db, &signing_key.public_key()).await; + + let client_key1 = authn::SigningKey::generate(); + let client_id1 = insert_unapproved_client(&db, &client_key1.public_key()).await; + let client_key2 = authn::SigningKey::generate(); + let client_id2 = insert_unapproved_client(&db, &client_key2.public_key()).await; + + let p1 = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id: client_id1 }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + let p2 = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id: client_id2 }, + initiator_id: op, + ttl_secs: None, + }) + .await + .unwrap(); + + // Vote on p1 — with 1 operator this reaches quorum (QuorumApproved) + let msg = make_vote_message(p1, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id: p1, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + // QueryPending should return only p2 + let pending = actors + .proposal_manager + .ask(QueryPending { operator_id: op }) + .await + .unwrap(); + + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, p2); +} + +#[tokio::test] +async fn expire_stale_marks_old_proposals_expired() { + 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 signing_key = authn::SigningKey::generate(); + let op = register_operator(&db, &signing_key.public_key()).await; + + 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 + let _proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op, + ttl_secs: Some(-1), + }) + .await + .unwrap(); + + let expired = actors + .proposal_manager + .ask(ExpireStale) + .await + .unwrap(); + assert_eq!(expired, 1); + + let pending = actors + .proposal_manager + .ask(QueryPending { operator_id: op }) + .await + .unwrap(); + assert!(pending.is_empty()); +} + +#[tokio::test] +async fn approve_sdk_client_writes_integrity_envelope() { + use arbiter_server::db::schema::integrity_envelope; + + 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 client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq("client_credentials")) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} -- 2.49.1 From ba8748a17b0be0fcb0a0f31e0673623414aa732f Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 15:51:22 +0200 Subject: [PATCH 18/66] feat(server): ProposalKind ::GrantWalletAccess and ::ApproveServerUpdate --- .../src/actors/proposal_manager.rs | 41 ++++++ .../src/grpc/operator/governance.rs | 5 + .../crates/arbiter-server/tests/governance.rs | 118 +++++++++++++++++- 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 3abdb31..a67471f 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -17,18 +17,29 @@ pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days #[derive(Debug, Clone)] pub enum ProposalKind { ApproveSdkClient { client_id: i32 }, + GrantWalletAccess { wallet_id: i32, client_id: i32 }, + ApproveServerUpdate, } impl ProposalKind { pub const fn kind_str(&self) -> &'static str { match self { Self::ApproveSdkClient { .. } => "approve_sdk_client", + Self::GrantWalletAccess { .. } => "grant_wallet_access", + Self::ApproveServerUpdate => "approve_server_update", } } pub fn encode_payload(&self) -> Vec { match self { Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), + Self::GrantWalletAccess { wallet_id, client_id } => { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&wallet_id.to_be_bytes()); + buf.extend_from_slice(&client_id.to_be_bytes()); + buf + } + Self::ApproveServerUpdate => vec![], } } @@ -41,6 +52,15 @@ impl ProposalKind { client_id: i32::from_be_bytes(bytes), }) } + "grant_wallet_access" => { + let bytes = <[u8; 8]>::try_from(payload) + .map_err(|_| "invalid payload for grant_wallet_access".to_owned())?; + Ok(Self::GrantWalletAccess { + wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()), + client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), + }) + } + "approve_server_update" => Ok(Self::ApproveServerUpdate), other => Err(format!("unknown proposal kind: {other}")), } } @@ -365,9 +385,30 @@ impl ProposalManager { ProposalKind::ApproveSdkClient { client_id } => { self.execute_approve_sdk_client(client_id).await } + ProposalKind::GrantWalletAccess { wallet_id, client_id } => { + self.execute_grant_wallet_access(wallet_id, client_id).await + } + ProposalKind::ApproveServerUpdate => Ok(()), } } + async fn execute_grant_wallet_access(&self, wallet_id: i32, client_id: i32) -> Result<(), Error> { + use crate::db::models::EvmWalletId; + + let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; + + diesel::insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(wallet_id)), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .execute(&mut conn) + .await + .map_err(|e| Error::ExecutionFailed(format!("grant wallet access: {e}")))?; + + Ok(()) + } + async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { use arbiter_crypto::authn; use crate::{ diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index f993c05..d783941 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -48,6 +48,11 @@ async fn handle_create( Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient { client_id: p.client_id, }, + Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess { + wallet_id: p.wallet_id, + client_id: p.client_id, + }, + Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, None => return Err(Status::invalid_argument("Missing proposal kind")), }; let ttl_secs = req.ttl_secs.map(i64::from); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index bbc65b0..db4603c 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -8,7 +8,7 @@ use arbiter_server::{ db, }; use arbiter_server::actors::vault::Bootstrap; -use arbiter_server::db::schema::operator_identity; +use arbiter_server::db::schema::{aead_encrypted, evm_wallet, evm_wallet_access, operator_identity}; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -29,6 +29,30 @@ fn make_vote_message(proposal_id: i32, approve: bool) -> Vec { 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) + .values(( + aead_encrypted::current_nonce.eq(vec![0u8; 4]), + aead_encrypted::ciphertext.eq(vec![0u8; 32]), + aead_encrypted::tag.eq(vec![0u8; 16]), + aead_encrypted::associated_root_key_id.eq(0i32), + )) + .returning(aead_encrypted::id) + .get_result::(&mut conn) + .await + .unwrap(); + insert_into(evm_wallet::table) + .values(( + evm_wallet::address.eq(vec![0u8; 20]), + evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(evm_wallet::id) + .get_result::(&mut conn) + .await + .unwrap() +} + async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { use arbiter_server::db::schema::{client_metadata, program_client}; let mut conn = db.get().await.unwrap(); @@ -422,3 +446,95 @@ async fn approve_sdk_client_writes_integrity_envelope() { .unwrap(); assert_eq!(count, 1); } + +#[tokio::test] +async fn grant_wallet_access_on_quorum_approval() { + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let wallet_id = insert_evm_wallet(&db).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::GrantWalletAccess { wallet_id, client_id }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_wallet_access::table + .filter(evm_wallet_access::wallet_id.eq(wallet_id)) + .filter(evm_wallet_access::client_id.eq(client_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn approve_server_update_reaches_quorum() { + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveServerUpdate, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); +} -- 2.49.1 From 25b86b14e9c5e8ee6377d82ff0dfb01954ffab06 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 16:46:04 +0200 Subject: [PATCH 19/66] feat(server): ProposalKind::ReplaceOperator --- .../src/actors/proposal_manager.rs | 36 +++++++++++++ .../src/grpc/operator/governance.rs | 3 ++ .../crates/arbiter-server/tests/governance.rs | 50 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index a67471f..d70cc0f 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -19,6 +19,7 @@ pub enum ProposalKind { ApproveSdkClient { client_id: i32 }, GrantWalletAccess { wallet_id: i32, client_id: i32 }, ApproveServerUpdate, + ReplaceOperator { new_pubkey: Vec }, } impl ProposalKind { @@ -27,6 +28,7 @@ impl ProposalKind { Self::ApproveSdkClient { .. } => "approve_sdk_client", Self::GrantWalletAccess { .. } => "grant_wallet_access", Self::ApproveServerUpdate => "approve_server_update", + Self::ReplaceOperator { .. } => "replace_operator", } } @@ -40,6 +42,14 @@ impl ProposalKind { buf } Self::ApproveServerUpdate => vec![], + Self::ReplaceOperator { new_pubkey } => { + #[expect(clippy::cast_possible_truncation, reason = "pubkey is always 32 bytes")] + let len = new_pubkey.len() as u32; + let mut buf = Vec::with_capacity(4 + new_pubkey.len()); + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(new_pubkey); + buf + } } } @@ -61,6 +71,19 @@ impl ProposalKind { }) } "approve_server_update" => Ok(Self::ApproveServerUpdate), + "replace_operator" => { + let (len_bytes, rest) = payload + .split_first_chunk::<4>() + .ok_or_else(|| "replace_operator payload too short".to_owned())?; + let len = u32::from_be_bytes(*len_bytes); + let len = usize::try_from(len).unwrap_or(usize::MAX); + if rest.len() < len { + return Err("replace_operator payload truncated".to_owned()); + } + Ok(Self::ReplaceOperator { + new_pubkey: rest[..len].to_vec(), + }) + } other => Err(format!("unknown proposal kind: {other}")), } } @@ -389,6 +412,9 @@ impl ProposalManager { self.execute_grant_wallet_access(wallet_id, client_id).await } ProposalKind::ApproveServerUpdate => Ok(()), + ProposalKind::ReplaceOperator { new_pubkey } => { + self.execute_replace_operator(new_pubkey).await + } } } @@ -409,6 +435,16 @@ impl ProposalManager { Ok(()) } + async fn execute_replace_operator(&self, new_pubkey: Vec) -> Result<(), Error> { + let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; + diesel::insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(&new_pubkey)) + .execute(&mut conn) + .await + .map_err(|e| Error::ExecutionFailed(format!("replace operator: {e}")))?; + Ok(()) + } + async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { use arbiter_crypto::authn; use crate::{ diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index d783941..f45f1f2 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -53,6 +53,9 @@ async fn handle_create( client_id: p.client_id, }, Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, + Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { + new_pubkey: p.new_pubkey, + }, None => return Err(Status::invalid_argument("Missing proposal kind")), }; let ttl_secs = req.ttl_secs.map(i64::from); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index db4603c..3f2491c 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -500,6 +500,56 @@ async fn grant_wallet_access_on_quorum_approval() { assert_eq!(count, 1); } +#[tokio::test] +async fn replace_operator_inserts_identity_row() { + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let new_op_key = authn::SigningKey::generate(); + let new_pubkey = new_op_key.public_key().to_bytes().to_vec(); + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator { new_pubkey }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = operator_identity::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 2); // original + new +} + #[tokio::test] async fn approve_server_update_reaches_quorum() { let db = db::create_test_pool().await; -- 2.49.1 From 9e4209768364d600b2e8566797475b70b7d4ee72 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 21:20:06 +0200 Subject: [PATCH 20/66] feat(server): ProposalKind::UpdateShamirParameters --- .../src/actors/proposal_manager.rs | 22 +++++++++++ .../src/grpc/operator/governance.rs | 4 ++ .../crates/arbiter-server/tests/governance.rs | 39 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index d70cc0f..9d90ff4 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -20,6 +20,7 @@ pub enum ProposalKind { GrantWalletAccess { wallet_id: i32, client_id: i32 }, ApproveServerUpdate, ReplaceOperator { new_pubkey: Vec }, + UpdateShamirParameters { new_n: u8 }, } impl ProposalKind { @@ -29,6 +30,7 @@ impl ProposalKind { Self::GrantWalletAccess { .. } => "grant_wallet_access", Self::ApproveServerUpdate => "approve_server_update", Self::ReplaceOperator { .. } => "replace_operator", + Self::UpdateShamirParameters { .. } => "update_shamir_parameters", } } @@ -50,6 +52,7 @@ impl ProposalKind { buf.extend_from_slice(new_pubkey); buf } + Self::UpdateShamirParameters { new_n } => vec![*new_n], } } @@ -84,6 +87,12 @@ impl ProposalKind { new_pubkey: rest[..len].to_vec(), }) } + "update_shamir_parameters" => { + let &[new_n] = payload else { + return Err("invalid payload for update_shamir_parameters".to_owned()); + }; + Ok(Self::UpdateShamirParameters { new_n }) + } other => Err(format!("unknown proposal kind: {other}")), } } @@ -415,6 +424,9 @@ impl ProposalManager { ProposalKind::ReplaceOperator { new_pubkey } => { self.execute_replace_operator(new_pubkey).await } + ProposalKind::UpdateShamirParameters { new_n } => { + self.execute_update_shamir_parameters(new_n) + } } } @@ -445,6 +457,16 @@ impl ProposalManager { Ok(()) } + #[expect( + clippy::unused_self, + clippy::unnecessary_wraps, + reason = "signature must match other execute_* methods" + )] + fn execute_update_shamir_parameters(&self, new_n: u8) -> Result<(), Error> { + warn!(new_n, "UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band"); + Ok(()) + } + async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { use arbiter_crypto::authn; use crate::{ diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index f45f1f2..82fb6c5 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -56,6 +56,10 @@ async fn handle_create( Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { new_pubkey: p.new_pubkey, }, + Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters { + #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "new_n is always a small operator count")] + new_n: p.new_n as u8, + }, None => return Err(Status::invalid_argument("Missing proposal kind")), }; let ttl_secs = req.ttl_secs.map(i64::from); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 3f2491c..34230a0 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -550,6 +550,45 @@ async fn replace_operator_inserts_identity_row() { assert_eq!(count, 2); // original + new } +#[tokio::test] +async fn update_shamir_parameters_reaches_quorum() { + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::UpdateShamirParameters { new_n: 5 }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); +} + #[tokio::test] async fn approve_server_update_reaches_quorum() { let db = db::create_test_pool().await; -- 2.49.1 From 277fb3c92d2e14c9ca784496b2edd79e8ba6d900 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 21:27:41 +0200 Subject: [PATCH 21/66] feat(server): ProposalKind::ApprovePersistentGrant --- server/Cargo.lock | 1 + server/crates/arbiter-server/Cargo.toml | 1 + .../crates/arbiter-server/src/actors/mod.rs | 4 +- .../src/actors/proposal_manager.rs | 102 ++++++++++++++++-- .../src/grpc/operator/governance.rs | 4 + .../crates/arbiter-server/tests/governance.rs | 90 +++++++++++++++- 6 files changed, 190 insertions(+), 12 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index ca8307e..0929efa 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -769,6 +769,7 @@ dependencies = [ "mutants", "pem", "proptest", + "prost", "prost-types", "rand 0.10.1", "rand_core 0.6.4", diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 8b5bbc3..8e4225f 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -42,6 +42,7 @@ pem = "3.0.6" sha2.workspace = true hmac.workspace = true alloy.workspace = true +prost.workspace = true prost-types.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102" diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index 8f1cf58..3ed0d84 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -50,9 +50,9 @@ impl GlobalActors { let message_bus = Self::spawn_message_bus(); let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); + let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())); Ok(Self { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())), vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new( db.clone(), key_holder.clone(), @@ -60,6 +60,7 @@ impl GlobalActors { proposal_manager: ProposalManager::spawn(ProposalManager::new( db, key_holder.clone(), + evm.clone(), )), vault: key_holder, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( @@ -67,6 +68,7 @@ impl GlobalActors { )), operator_registry, events: message_bus, + evm, }) } } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 9d90ff4..3d62dda 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -1,5 +1,5 @@ use crate::{ - actors::vault::Vault, + actors::{evm::EvmActor, vault::Vault}, db::{ self, models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp}, @@ -21,6 +21,7 @@ pub enum ProposalKind { ApproveServerUpdate, ReplaceOperator { new_pubkey: Vec }, UpdateShamirParameters { new_n: u8 }, + ApprovePersistentGrant { payload_bytes: Vec }, } impl ProposalKind { @@ -31,6 +32,7 @@ impl ProposalKind { Self::ApproveServerUpdate => "approve_server_update", Self::ReplaceOperator { .. } => "replace_operator", Self::UpdateShamirParameters { .. } => "update_shamir_parameters", + Self::ApprovePersistentGrant { .. } => "approve_persistent_grant", } } @@ -45,7 +47,7 @@ impl ProposalKind { } Self::ApproveServerUpdate => vec![], Self::ReplaceOperator { new_pubkey } => { - #[expect(clippy::cast_possible_truncation, reason = "pubkey is always 32 bytes")] + #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "pubkey is always 32 bytes")] let len = new_pubkey.len() as u32; let mut buf = Vec::with_capacity(4 + new_pubkey.len()); buf.extend_from_slice(&len.to_be_bytes()); @@ -53,6 +55,7 @@ impl ProposalKind { buf } Self::UpdateShamirParameters { new_n } => vec![*new_n], + Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(), } } @@ -80,12 +83,11 @@ impl ProposalKind { .ok_or_else(|| "replace_operator payload too short".to_owned())?; let len = u32::from_be_bytes(*len_bytes); let len = usize::try_from(len).unwrap_or(usize::MAX); - if rest.len() < len { - return Err("replace_operator payload truncated".to_owned()); - } - Ok(Self::ReplaceOperator { - new_pubkey: rest[..len].to_vec(), - }) + let new_pubkey = rest + .get(..len) + .ok_or_else(|| "replace_operator payload truncated".to_owned())? + .to_vec(); + Ok(Self::ReplaceOperator { new_pubkey }) } "update_shamir_parameters" => { let &[new_n] = payload else { @@ -93,6 +95,9 @@ impl ProposalKind { }; Ok(Self::UpdateShamirParameters { new_n }) } + "approve_persistent_grant" => Ok(Self::ApprovePersistentGrant { + payload_bytes: payload.to_vec(), + }), other => Err(format!("unknown proposal kind: {other}")), } } @@ -138,11 +143,12 @@ pub struct ProposalSummary { pub struct ProposalManager { pub(crate) db: db::DatabasePool, pub(crate) vault: ActorRef, + pub(crate) evm: ActorRef, } impl ProposalManager { - pub const fn new(db: db::DatabasePool, vault: ActorRef) -> Self { - Self { db, vault } + pub const fn new(db: db::DatabasePool, vault: ActorRef, evm: ActorRef) -> Self { + Self { db, vault, evm } } } @@ -427,6 +433,9 @@ impl ProposalManager { ProposalKind::UpdateShamirParameters { new_n } => { self.execute_update_shamir_parameters(new_n) } + ProposalKind::ApprovePersistentGrant { payload_bytes } => { + self.execute_approve_persistent_grant(payload_bytes).await + } } } @@ -467,6 +476,79 @@ impl ProposalManager { Ok(()) } + async fn execute_approve_persistent_grant(&self, payload_bytes: Vec) -> Result<(), Error> { + use arbiter_proto::proto::operator::governance::{ + ApprovePersistentGrantPayload, + approve_persistent_grant_payload::Specific, + }; + use crate::{ + actors::evm::OperatorCreateGrant, + evm::policies::{ + SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, + ether_transfer, token_transfers, + }, + }; + use alloy::primitives::{Address, U256}; + use chrono::Duration; + use prost::Message as _; + + let payload = ApprovePersistentGrantPayload::decode(payload_bytes.as_slice()) + .map_err(|e| Error::ExecutionFailed(format!("decode grant payload: {e}")))?; + + let basic = SharedGrantSettings { + wallet_access_id: payload.wallet_access_id, + chain: payload.chain_id, + valid_from: payload.valid_from_secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + valid_until: payload.valid_until_secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + max_gas_fee_per_gas: payload.max_gas_fee_per_gas.map(|b| U256::from_be_slice(b.as_slice())), + max_priority_fee_per_gas: payload.max_priority_fee_per_gas.map(|b| U256::from_be_slice(b.as_slice())), + rate_limit: payload.rate_limit.map(|r| TransactionRateLimit { + count: r.count, + window: Duration::seconds(r.window_secs), + }), + }; + + let grant = match payload.specific { + Some(Specific::EtherTransfer(spec)) => { + let target: Vec
= spec.targets + .iter() + .map(|b| Address::from_slice(b.as_slice())) + .collect(); + let limit = spec.limit + .map(|l| VolumeRateLimit { + max_volume: U256::from_be_slice(l.max_volume.as_slice()), + window: Duration::seconds(l.window_secs), + }) + .ok_or_else(|| Error::ExecutionFailed("missing ether transfer limit".to_owned()))?; + SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit }) + } + Some(Specific::TokenTransfer(spec)) => { + let token_contract = Address::from_slice(spec.token_contract.as_slice()); + let target = spec.target.map(|b| Address::from_slice(b.as_slice())); + let volume_limits: Vec = spec.volume_limits + .iter() + .map(|l| VolumeRateLimit { + max_volume: U256::from_be_slice(l.max_volume.as_slice()), + window: Duration::seconds(l.window_secs), + }) + .collect(); + SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract, + target, + volume_limits, + }) + } + None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())), + }; + + self.evm + .ask(OperatorCreateGrant { basic, grant }) + .await + .map_err(|e| Error::ExecutionFailed(format!("create grant: {e}")))?; + + Ok(()) + } + async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { use arbiter_crypto::authn; use crate::{ diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 82fb6c5..ebde759 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -60,6 +60,10 @@ async fn handle_create( #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "new_n is always a small operator count")] new_n: p.new_n as u8, }, + Some(ProtoKind::ApprovePersistentGrant(p)) => { + use prost::Message as _; + ProposalKind::ApprovePersistentGrant { payload_bytes: p.encode_to_vec() } + } None => return Err(Status::invalid_argument("Missing proposal kind")), }; let ttl_secs = req.ttl_secs.map(i64::from); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 34230a0..de8731c 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -8,7 +8,7 @@ use arbiter_server::{ db, }; use arbiter_server::actors::vault::Bootstrap; -use arbiter_server::db::schema::{aead_encrypted, evm_wallet, evm_wallet_access, operator_identity}; +use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity}; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -500,6 +500,94 @@ async fn grant_wallet_access_on_quorum_approval() { assert_eq!(count, 1); } +#[tokio::test] +async fn approve_persistent_grant_creates_basic_grant_row() { + use arbiter_proto::proto::operator::governance::{ + ApprovePersistentGrantPayload, EtherTransferSpecProto, VolumeLimitProto, + approve_persistent_grant_payload::Specific, + }; + use prost::Message as _; + + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + // Insert a dummy wallet and client, then a wallet_access row + let wallet_id = insert_evm_wallet(&db).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let mut conn = db.get().await.unwrap(); + let wallet_access_id: i32 = insert_into(evm_wallet_access::table) + .values(( + evm_wallet_access::wallet_id.eq(wallet_id), + evm_wallet_access::client_id.eq(client_id), + )) + .returning(evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + drop(conn); + + let payload = ApprovePersistentGrantPayload { + wallet_access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: Some(Specific::EtherTransfer(EtherTransferSpecProto { + targets: vec![vec![0u8; 20]], + limit: Some(VolumeLimitProto { + max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes_vec(), + window_secs: 86400, + }), + })), + }; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApprovePersistentGrant { payload_bytes: payload.encode_to_vec() }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_basic_grant::table + .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} + #[tokio::test] async fn replace_operator_inserts_identity_row() { let db = db::create_test_pool().await; -- 2.49.1 From d12109c2c90f37255e5cbf80a5ad9c57ad1a26b3 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 21:32:23 +0200 Subject: [PATCH 22/66] feat(server): ProposalKind::ApproveOneOffTransaction --- .../2026-02-14-171124-0000_init/up.sql | 7 ++ .../src/actors/proposal_manager.rs | 75 +++++++++++ server/crates/arbiter-server/src/db/models.rs | 8 ++ server/crates/arbiter-server/src/db/schema.rs | 10 ++ .../src/grpc/operator/governance.rs | 4 + .../crates/arbiter-server/tests/governance.rs | 117 +++++++++++++++++- 6 files changed, 220 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 849025e..5e417f4 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -237,3 +237,10 @@ create table if not exists proposal_vote ( voted_at integer not null default(unixepoch('now')), unique (proposal_id, operator_id) ) STRICT; + + +create table if not exists proposal_result ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + data blob not null, + created_at integer not null default(unixepoch('now')) +) STRICT; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 3d62dda..d66b422 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -22,6 +22,7 @@ pub enum ProposalKind { ReplaceOperator { new_pubkey: Vec }, UpdateShamirParameters { new_n: u8 }, ApprovePersistentGrant { payload_bytes: Vec }, + ApproveOneOffTransaction { payload_bytes: Vec }, } impl ProposalKind { @@ -33,6 +34,7 @@ impl ProposalKind { Self::ReplaceOperator { .. } => "replace_operator", Self::UpdateShamirParameters { .. } => "update_shamir_parameters", Self::ApprovePersistentGrant { .. } => "approve_persistent_grant", + Self::ApproveOneOffTransaction { .. } => "approve_one_off_transaction", } } @@ -56,6 +58,7 @@ impl ProposalKind { } Self::UpdateShamirParameters { new_n } => vec![*new_n], Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(), + Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), } } @@ -98,6 +101,9 @@ impl ProposalKind { "approve_persistent_grant" => Ok(Self::ApprovePersistentGrant { payload_bytes: payload.to_vec(), }), + "approve_one_off_transaction" => Ok(Self::ApproveOneOffTransaction { + payload_bytes: payload.to_vec(), + }), other => Err(format!("unknown proposal kind: {other}")), } } @@ -436,6 +442,9 @@ impl ProposalManager { ProposalKind::ApprovePersistentGrant { payload_bytes } => { self.execute_approve_persistent_grant(payload_bytes).await } + ProposalKind::ApproveOneOffTransaction { payload_bytes } => { + self.execute_approve_one_off_transaction(proposal.id, payload_bytes).await + } } } @@ -476,6 +485,72 @@ impl ProposalManager { Ok(()) } + async fn execute_approve_one_off_transaction( + &self, + proposal_id: i32, + payload_bytes: Vec, + ) -> Result<(), Error> { + use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; + use crate::actors::evm::ClientSignTransaction; + use crate::db::models::NewProposalResult; + use alloy::{ + consensus::TxEip1559, + eips::eip2930::AccessList, + primitives::{Address, Bytes, TxKind, U256}, + }; + use prost::Message as _; + + let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice()) + .map_err(|e| Error::ExecutionFailed(format!("decode one-off tx payload: {e}")))?; + + let wallet_address = Address::from_slice(p.wallet_address.as_slice()); + let to = Address::from_slice(p.to.as_slice()); + + let transaction = TxEip1559 { + chain_id: p.chain_id, + nonce: p.nonce, + gas_limit: p.gas_limit, + max_fee_per_gas: u128::from_be_bytes( + p.max_fee_per_gas + .as_slice() + .try_into() + .map_err(|_| Error::ExecutionFailed("invalid max_fee_per_gas".to_owned()))?, + ), + max_priority_fee_per_gas: u128::from_be_bytes( + p.max_priority_fee_per_gas + .as_slice() + .try_into() + .map_err(|_| Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned()))?, + ), + to: TxKind::Call(to), + value: U256::from_be_slice(p.value.as_slice()), + input: Bytes::from(p.input), + access_list: AccessList::default(), + }; + + let sig = self + .evm + .ask(ClientSignTransaction { + client_id: p.client_id, + wallet_address, + transaction, + }) + .await + .map_err(|e| Error::ExecutionFailed(format!("sign one-off tx: {e}")))?; + + let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; + diesel::insert_into(schema::proposal_result::table) + .values(NewProposalResult { + proposal_id, + data: sig.as_bytes().to_vec(), + }) + .execute(&mut conn) + .await + .map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?; + + Ok(()) + } + async fn execute_approve_persistent_grant(&self, payload_bytes: Vec) -> Result<(), Error> { use arbiter_proto::proto::operator::governance::{ ApprovePersistentGrantPayload, diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index a546a62..1dc282a 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -520,3 +520,11 @@ pub struct NewProposalVote { pub approve: bool, pub signature: Vec, } + + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))] +pub struct NewProposalResult { + pub proposal_id: i32, + pub data: Vec, +} \ No newline at end of file diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c6ce19e..3c04ac2 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -184,6 +184,14 @@ diesel::table! { } } +diesel::table! { + proposal_result (proposal_id) { + proposal_id -> Integer, + data -> Binary, + created_at -> Integer, + } +} + diesel::table! { proposal_vote (id) { id -> Integer, @@ -249,11 +257,13 @@ diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::joinable!(proposal -> operator_identity (initiator_id)); +diesel::joinable!(proposal_result -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> operator_identity (operator_id)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, + proposal_result, arbiter_settings, client_metadata, client_metadata_history, diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index ebde759..840ebb2 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -64,6 +64,10 @@ async fn handle_create( use prost::Message as _; ProposalKind::ApprovePersistentGrant { payload_bytes: p.encode_to_vec() } } + Some(ProtoKind::ApproveOneOffTransaction(p)) => { + use prost::Message as _; + ProposalKind::ApproveOneOffTransaction { payload_bytes: p.encode_to_vec() } + } None => return Err(Status::invalid_argument("Missing proposal kind")), }; let ttl_secs = req.ttl_secs.map(i64::from); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index de8731c..b6fca7b 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -8,7 +8,7 @@ use arbiter_server::{ db, }; use arbiter_server::actors::vault::Bootstrap; -use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity}; +use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, proposal_result}; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -588,6 +588,121 @@ async fn approve_persistent_grant_creates_basic_grant_row() { assert_eq!(count, 1); } +#[tokio::test] +async fn approve_one_off_transaction_stores_result() { + use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; + use arbiter_server::actors::evm::{Generate, OperatorCreateGrant}; + use arbiter_server::evm::policies::{ + SharedGrantSettings, SpecificGrant, VolumeRateLimit, ether_transfer, + }; + use alloy::primitives::{Address, U256}; + use chrono::Duration; + use prost::Message as _; + + 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 signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + // Create a real encrypted wallet + let (wallet_id, wallet_address) = actors.evm.ask(Generate {}).await.unwrap(); + + // Create a client and wallet_access + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let mut conn = db.get().await.unwrap(); + let wallet_access_id: i32 = insert_into(evm_wallet_access::table) + .values(( + evm_wallet_access::wallet_id.eq(wallet_id), + evm_wallet_access::client_id.eq(client_id), + )) + .returning(evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + drop(conn); + + // Create a grant that permits ether transfer to address zero + let to_address = Address::ZERO; + actors + .evm + .ask(OperatorCreateGrant { + basic: SharedGrantSettings { + wallet_access_id, + chain: 1, + valid_from: None, + valid_until: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + }, + grant: SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: vec![to_address], + limit: VolumeRateLimit { + max_volume: U256::from(1_000_000_000_000_000_000u128), + window: Duration::hours(24), + }, + }), + }) + .await + .unwrap(); + + // Encode the one-off transaction payload + let payload = ApproveOneOffTransactionPayload { + client_id, + wallet_address: wallet_address.as_slice().to_vec(), + chain_id: 1, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 1u128.to_be_bytes().to_vec(), + max_priority_fee_per_gas: 1u128.to_be_bytes().to_vec(), + to: to_address.as_slice().to_vec(), + value: U256::from(1u64).to_be_bytes_vec(), + input: vec![], + }; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveOneOffTransaction { payload_bytes: payload.encode_to_vec() }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = proposal_result::table + .filter(proposal_result::proposal_id.eq(proposal_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} + #[tokio::test] async fn replace_operator_inserts_identity_row() { let db = db::create_test_pool().await; -- 2.49.1 From 291ef2e83127baa625e82b4bf60ed617c9682647 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 21:53:46 +0200 Subject: [PATCH 23/66] refactor(server): typed pubkey len via u32::try_from in ReplaceOperator --- .../src/actors/proposal_manager.rs | 87 ++++++++++++------- .../src/grpc/operator/governance.rs | 3 +- .../crates/arbiter-server/tests/governance.rs | 2 +- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index d66b422..f97ed65 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -41,7 +41,10 @@ impl ProposalKind { pub fn encode_payload(&self) -> Vec { match self { Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), - Self::GrantWalletAccess { wallet_id, client_id } => { + Self::GrantWalletAccess { + wallet_id, + client_id, + } => { let mut buf = Vec::with_capacity(8); buf.extend_from_slice(&wallet_id.to_be_bytes()); buf.extend_from_slice(&client_id.to_be_bytes()); @@ -49,8 +52,7 @@ impl ProposalKind { } Self::ApproveServerUpdate => vec![], Self::ReplaceOperator { new_pubkey } => { - #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "pubkey is always 32 bytes")] - let len = new_pubkey.len() as u32; + let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32"); let mut buf = Vec::with_capacity(4 + new_pubkey.len()); buf.extend_from_slice(&len.to_be_bytes()); buf.extend_from_slice(new_pubkey); @@ -153,7 +155,11 @@ pub struct ProposalManager { } impl ProposalManager { - pub const fn new(db: db::DatabasePool, vault: ActorRef, evm: ActorRef) -> Self { + pub const fn new( + db: db::DatabasePool, + vault: ActorRef, + evm: ActorRef, + ) -> Self { Self { db, vault, evm } } } @@ -162,10 +168,7 @@ impl kameo::Actor for ProposalManager { type Args = Self; type Error = (); - async fn on_start( - args: Self::Args, - actor_ref: ActorRef, - ) -> Result { + async fn on_start(args: Self::Args, actor_ref: ActorRef) -> Result { let weak = actor_ref.downgrade(); tokio::spawn(async move { loop { @@ -429,9 +432,10 @@ impl ProposalManager { ProposalKind::ApproveSdkClient { client_id } => { self.execute_approve_sdk_client(client_id).await } - ProposalKind::GrantWalletAccess { wallet_id, client_id } => { - self.execute_grant_wallet_access(wallet_id, client_id).await - } + ProposalKind::GrantWalletAccess { + wallet_id, + client_id, + } => self.execute_grant_wallet_access(wallet_id, client_id).await, ProposalKind::ApproveServerUpdate => Ok(()), ProposalKind::ReplaceOperator { new_pubkey } => { self.execute_replace_operator(new_pubkey).await @@ -443,12 +447,17 @@ impl ProposalManager { self.execute_approve_persistent_grant(payload_bytes).await } ProposalKind::ApproveOneOffTransaction { payload_bytes } => { - self.execute_approve_one_off_transaction(proposal.id, payload_bytes).await + self.execute_approve_one_off_transaction(proposal.id, payload_bytes) + .await } } } - async fn execute_grant_wallet_access(&self, wallet_id: i32, client_id: i32) -> Result<(), Error> { + async fn execute_grant_wallet_access( + &self, + wallet_id: i32, + client_id: i32, + ) -> Result<(), Error> { use crate::db::models::EvmWalletId; let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; @@ -481,7 +490,10 @@ impl ProposalManager { reason = "signature must match other execute_* methods" )] fn execute_update_shamir_parameters(&self, new_n: u8) -> Result<(), Error> { - warn!(new_n, "UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band"); + warn!( + new_n, + "UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band" + ); Ok(()) } @@ -490,7 +502,6 @@ impl ProposalManager { proposal_id: i32, payload_bytes: Vec, ) -> Result<(), Error> { - use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; use crate::actors::evm::ClientSignTransaction; use crate::db::models::NewProposalResult; use alloy::{ @@ -498,6 +509,7 @@ impl ProposalManager { eips::eip2930::AccessList, primitives::{Address, Bytes, TxKind, U256}, }; + use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; use prost::Message as _; let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice()) @@ -520,7 +532,9 @@ impl ProposalManager { p.max_priority_fee_per_gas .as_slice() .try_into() - .map_err(|_| Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned()))?, + .map_err(|_| { + Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned()) + })?, ), to: TxKind::Call(to), value: U256::from_be_slice(p.value.as_slice()), @@ -552,10 +566,6 @@ impl ProposalManager { } async fn execute_approve_persistent_grant(&self, payload_bytes: Vec) -> Result<(), Error> { - use arbiter_proto::proto::operator::governance::{ - ApprovePersistentGrantPayload, - approve_persistent_grant_payload::Specific, - }; use crate::{ actors::evm::OperatorCreateGrant, evm::policies::{ @@ -564,6 +574,9 @@ impl ProposalManager { }, }; use alloy::primitives::{Address, U256}; + use arbiter_proto::proto::operator::governance::{ + ApprovePersistentGrantPayload, approve_persistent_grant_payload::Specific, + }; use chrono::Duration; use prost::Message as _; @@ -573,10 +586,18 @@ impl ProposalManager { let basic = SharedGrantSettings { wallet_access_id: payload.wallet_access_id, chain: payload.chain_id, - valid_from: payload.valid_from_secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - valid_until: payload.valid_until_secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - max_gas_fee_per_gas: payload.max_gas_fee_per_gas.map(|b| U256::from_be_slice(b.as_slice())), - max_priority_fee_per_gas: payload.max_priority_fee_per_gas.map(|b| U256::from_be_slice(b.as_slice())), + valid_from: payload + .valid_from_secs + .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + valid_until: payload + .valid_until_secs + .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + max_gas_fee_per_gas: payload + .max_gas_fee_per_gas + .map(|b| U256::from_be_slice(b.as_slice())), + max_priority_fee_per_gas: payload + .max_priority_fee_per_gas + .map(|b| U256::from_be_slice(b.as_slice())), rate_limit: payload.rate_limit.map(|r| TransactionRateLimit { count: r.count, window: Duration::seconds(r.window_secs), @@ -585,22 +606,27 @@ impl ProposalManager { let grant = match payload.specific { Some(Specific::EtherTransfer(spec)) => { - let target: Vec
= spec.targets + let target: Vec
= spec + .targets .iter() .map(|b| Address::from_slice(b.as_slice())) .collect(); - let limit = spec.limit + let limit = spec + .limit .map(|l| VolumeRateLimit { max_volume: U256::from_be_slice(l.max_volume.as_slice()), window: Duration::seconds(l.window_secs), }) - .ok_or_else(|| Error::ExecutionFailed("missing ether transfer limit".to_owned()))?; + .ok_or_else(|| { + Error::ExecutionFailed("missing ether transfer limit".to_owned()) + })?; SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit }) } Some(Specific::TokenTransfer(spec)) => { let token_contract = Address::from_slice(spec.token_contract.as_slice()); let target = spec.target.map(|b| Address::from_slice(b.as_slice())); - let volume_limits: Vec = spec.volume_limits + let volume_limits: Vec = spec + .volume_limits .iter() .map(|l| VolumeRateLimit { max_volume: U256::from_be_slice(l.max_volume.as_slice()), @@ -625,11 +651,8 @@ impl ProposalManager { } async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { + use crate::{crypto::integrity, peers::client::ClientCredentials}; use arbiter_crypto::authn; - use crate::{ - crypto::integrity, - peers::client::ClientCredentials, - }; let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 840ebb2..df28e0a 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -54,7 +54,8 @@ async fn handle_create( }, Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { - new_pubkey: p.new_pubkey, + new_pubkey: p.new_pubkey.try_into() + .map_err(|_| Status::invalid_argument("replace_operator: pubkey must be 32 bytes"))?, }, Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters { #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "new_n is always a small operator count")] diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index b6fca7b..ca86e64 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -717,7 +717,7 @@ async fn replace_operator_inserts_identity_row() { let op_id = register_operator(&db, &signing_key.public_key()).await; let new_op_key = authn::SigningKey::generate(); - let new_pubkey = new_op_key.public_key().to_bytes().to_vec(); + let new_pubkey = new_op_key.public_key().to_bytes(); let proposal_id = actors .proposal_manager -- 2.49.1 From 57200cbc50daa44e900e653fad647ea918b9b797 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 22:13:07 +0200 Subject: [PATCH 24/66] feat(server): two-operator vault requires at least one recovery share --- .../src/actors/vault_coordinator/mod.rs | 7 +++++ .../src/grpc/operator/vault_gate/inbound.rs | 1 + .../src/peers/operator/vault_gate/mod.rs | 7 ++++- .../arbiter-server/tests/vault/lifecycle.rs | 28 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 107ab3e..1074763 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -39,6 +39,8 @@ pub enum Error { Encryption, #[error("Vault error")] VaultError, + #[error("Two-operator vaults require at least one recovery share")] + TwoOperatorsRequireRecovery, #[error("Broken database")] BrokenDatabase, } @@ -200,11 +202,15 @@ impl VaultCoordinator { &mut self, operator_id: i32, declared_count: usize, + recovery_count: usize, ) -> Result<(), Error> { let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins if !matches!(self.state, CoordinatorState::Idle) { return Err(Error::AlreadyBootstrapping); } + if declared_count == 2 && recovery_count == 0 { + return Err(Error::TwoOperatorsRequireRecovery); + } self.state = CoordinatorState::Bootstrapping { declared_count, passphrases: HashMap::new(), @@ -223,6 +229,7 @@ impl VaultCoordinator { let CoordinatorState::Bootstrapping { declared_count, passphrases, + .. } = &mut self.state else { return Err(Error::NotBootstrapping); diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index b0955eb..5342282 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -132,6 +132,7 @@ impl TryConvert for BootstrapRequestPayload { Self::DeclareCommittee(dc) => Ok( vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee { count: dc.count as usize, + recovery_count: dc.recovery_count as usize, }), ), Self::ContributePassphrase(cp) => Ok( diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index 2e73555..611ba82 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -234,12 +234,17 @@ impl VaultGate { } #[message] - pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> { + pub async fn handle_declare_committee( + &mut self, + count: usize, + recovery_count: usize, + ) -> Result<(), Error> { self.actors .vault_coordinator .ask(StartBootstrap { operator_id: self.auth_creds.id, declared_count: count, + recovery_count, }) .await .map_err(|_| Error::internal("VaultCoordinator unavailable")) diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 6148590..9065654 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -4,6 +4,7 @@ use arbiter_server::{ actors::{ GlobalActors, vault::{Error, Vault}, + vault_coordinator::{Error as CoordinatorError, StartBootstrap, VaultCoordinator}, }, crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, @@ -11,6 +12,7 @@ use arbiter_server::{ use diesel::{QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; +use kameo::actor::Spawn as _; #[tokio::test] #[test_log::test] @@ -139,3 +141,29 @@ async fn test_unseal_wrong_then_correct_password() { let mut decrypted = actor.decrypt(aead_id).await.unwrap(); assert_eq!(*decrypted.read(), plaintext); } + +#[tokio::test] +#[test_log::test] +async fn two_operator_vault_requires_recovery_share() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref)); + + let err = coordinator + .ask(StartBootstrap { + operator_id: 1, + declared_count: 2, + recovery_count: 0, + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::TwoOperatorsRequireRecovery) + ), + "expected TwoOperatorsRequireRecovery, got {err:?}" + ); +} -- 2.49.1 From 19a62e71953019423013c5e4df58ed691921aeb9 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 22:20:48 +0200 Subject: [PATCH 25/66] =?UTF-8?q?feat(server):=20key-rotation=20proposals?= =?UTF-8?q?=20require=20full=20quorum=20(=C2=A73.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actors/proposal_manager.rs | 13 ++++- .../crates/arbiter-server/tests/governance.rs | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index f97ed65..dc11a23 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -64,6 +64,12 @@ impl ProposalKind { } } + /// Key-rotation proposals require every operator to approve (§3.3). + #[must_use] + pub fn requires_full_quorum(kind: &str) -> bool { + matches!(kind, "replace_operator" | "update_shamir_parameters") + } + pub fn decode(kind: &str, payload: &[u8]) -> Result { match kind { "approve_sdk_client" => { @@ -379,7 +385,12 @@ impl ProposalManager { clippy::as_conversions, reason = "operator count is always a small positive integer" )] - let threshold = crate::crypto::shamir::shamir_threshold(total_operators as usize); + let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) { + // §3.3: key-rotation proposals require every operator to approve + total_operators as usize + } else { + crate::crypto::shamir::shamir_threshold(total_operators as usize) + }; let approve_count: i64 = schema::proposal_vote::table .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index ca86e64..7cb1cff 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -792,6 +792,54 @@ async fn update_shamir_parameters_reaches_quorum() { assert_eq!(outcome, VoteOutcome::QuorumApproved); } +#[tokio::test] +async fn key_rotation_requires_full_quorum() { + // §3.3: ReplaceOperator needs all 3 operators to approve, not just shamir_threshold(3)=2 + 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 key1 = authn::SigningKey::generate(); + let key2 = authn::SigningKey::generate(); + let key3 = authn::SigningKey::generate(); + let op1 = register_operator(&db, &key1.public_key()).await; + let op2 = register_operator(&db, &key2.public_key()).await; + let op3 = register_operator(&db, &key3.public_key()).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator { new_pubkey }, + initiator_id: op1, + ttl_secs: None, + }) + .await + .unwrap(); + + let cast = |op_id, key: &authn::SigningKey| { + let actors = actors.clone(); + let sig = key.sign_message(&make_vote_message(proposal_id, true), GOVERNANCE_CONTEXT).unwrap(); + async move { + actors + .proposal_manager + .ask(CastVote { proposal_id, operator_id: op_id, approve: true, signature: sig.to_bytes() }) + .await + .unwrap() + } + }; + + // With shamir_threshold(3)=2, two approvals would suffice for a normal proposal. + // For key rotation, they must not. + assert_eq!(cast(op1, &key1).await, VoteOutcome::Pending); + assert_eq!(cast(op2, &key2).await, VoteOutcome::Pending); + assert_eq!(cast(op3, &key3).await, VoteOutcome::QuorumApproved); +} + #[tokio::test] async fn approve_server_update_reaches_quorum() { let db = db::create_test_pool().await; -- 2.49.1 From 6e3fa736e0c80b2bd771d0421b7ade1bf06c7803 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 22:31:10 +0200 Subject: [PATCH 26/66] =?UTF-8?q?feat(server):=20recovery=20operators=20wi?= =?UTF-8?q?th=20sleeping/wakeup=20mechanism=20(=C2=A73.5/=C2=A73.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-02-14-171124-0000_init/up.sql | 32 +++ .../src/actors/proposal_manager.rs | 258 +++++++++++++++++- server/crates/arbiter-server/src/db/models.rs | 15 + server/crates/arbiter-server/src/db/schema.rs | 36 +++ .../crates/arbiter-server/tests/governance.rs | 234 +++++++++++++++- 5 files changed, 567 insertions(+), 8 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 5e417f4..08ba766 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -244,3 +244,35 @@ create table if not exists proposal_result ( data blob not null, created_at integer not null default(unixepoch('now')) ) STRICT; + +-- =============================== +-- Recovery Operators (§3.5/§3.6) +-- =============================== + +create table if not exists recovery_operator_identity ( + id integer not null primary key, + public_key blob not null unique, + created_at integer not null default(unixepoch('now')), + updated_at integer not null default(unixepoch('now')) +) STRICT; + +-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL +-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days. +create table if not exists recovery_wakeup_request ( + id integer not null primary key, + requested_by integer not null references operator_identity(id) on delete restrict, + requested_at integer not null default(unixepoch('now')), + cancelled_by integer references operator_identity(id) on delete restrict, + cancelled_at integer +) STRICT; + +-- Votes cast by recovery operators; only allowed on replace_operator proposals. +create table if not exists recovery_proposal_vote ( + id integer not null primary key, + proposal_id integer not null references proposal(id) on delete cascade, + recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict, + approve integer not null check (approve in (0, 1)), + signature blob not null, + voted_at integer not null default(unixepoch('now')), + unique (proposal_id, recovery_operator_id) +) STRICT; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index dc11a23..684afad 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -2,7 +2,10 @@ use crate::{ actors::{evm::EvmActor, vault::Vault}, db::{ self, - models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp}, + models::{ + NewProposal, NewProposalVote, NewRecoveryProposalVote, + NewRecoveryWakeupRequest, Proposal, ProposalStatus, SqliteTimestamp, + }, schema, }, }; @@ -142,6 +145,14 @@ pub enum Error { DatabaseQuery(#[from] diesel::result::Error), #[error("Execution failed: {0}")] ExecutionFailed(String), + #[error("Recovery operators are sleeping")] + RecoveryNotActive, + #[error("Recovery operators may only vote on operator replacement")] + NotAllowedForRecoveryOperator, + #[error("A recovery wake-up is already pending or active")] + WakeupAlreadyPending, + #[error("No active recovery wake-up to cancel")] + NoActiveWakeup, } #[derive(Debug)] @@ -379,6 +390,15 @@ impl ProposalManager { .count() .get_result(&mut conn) .await?; + let recovery_active = Self::is_recovery_active_conn(&mut conn).await?; + let total_recovery: i64 = if recovery_active { + schema::recovery_operator_identity::table + .count() + .get_result(&mut conn) + .await? + } else { + 0 + }; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, @@ -386,25 +406,40 @@ impl ProposalManager { reason = "operator count is always a small positive integer" )] let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) { - // §3.3: key-rotation proposals require every operator to approve - total_operators as usize + // §3.3: key-rotation proposals require every eligible voter to approve + // §3.5: when recovery is active, recovery operators also vote on replace_operator + (total_operators + total_recovery) as usize } else { crate::crypto::shamir::shamir_threshold(total_operators as usize) }; - let approve_count: i64 = schema::proposal_vote::table + let ordinary_approve: i64 = schema::proposal_vote::table .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) .filter(schema::proposal_vote::approve.eq(true)) .count() .get_result(&mut conn) .await?; + let recovery_approve: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::recovery_proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + let approve_count = ordinary_approve + recovery_approve; - let reject_count: i64 = schema::proposal_vote::table + let ordinary_reject: i64 = schema::proposal_vote::table .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) .filter(schema::proposal_vote::approve.eq(false)) .count() .get_result(&mut conn) .await?; + let recovery_reject: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::recovery_proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + let reject_count = ordinary_reject + recovery_reject; #[expect( clippy::cast_possible_wrap, @@ -423,7 +458,184 @@ impl ProposalManager { return Ok(VoteOutcome::QuorumApproved); } - if reject_count > total_operators - threshold_i64 { + let total_eligible = total_operators + total_recovery; + if reject_count > total_eligible - threshold_i64 { + diesel::update(schema::proposal::table.find(proposal_id)) + .set(schema::proposal::status.eq(ProposalStatus::Rejected)) + .execute(&mut conn) + .await?; + return Ok(VoteOutcome::QuorumRejected); + } + + Ok(VoteOutcome::Pending) + } + + /// §3.6: Any ordinary operator may request recovery wake-up. + /// Fails if a wake-up is already pending or active. + #[message] + pub async fn request_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> { + let mut conn = self.db.get().await?; + if Self::has_uncancelled_wakeup(&mut conn).await? { + return Err(Error::WakeupAlreadyPending); + } + diesel::insert_into(schema::recovery_wakeup_request::table) + .values(&NewRecoveryWakeupRequest { + requested_by: operator_id, + }) + .execute(&mut conn) + .await?; + Ok(()) + } + + /// §3.6: Any ordinary operator may cancel a pending wake-up request. + /// Fails if there is no uncancelled request. + #[message] + pub async fn cancel_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> { + let mut conn = self.db.get().await?; + let rows_updated = diesel::update(schema::recovery_wakeup_request::table) + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .set(( + schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)), + schema::recovery_wakeup_request::cancelled_at + .eq(Some(SqliteTimestamp::now())), + )) + .execute(&mut conn) + .await?; + if rows_updated == 0 { + return Err(Error::NoActiveWakeup); + } + Ok(()) + } + + /// §3.5: Recovery operators may only vote on operator replacement proposals. + /// §3.6: Voting is gated behind recovery being active (14-day window elapsed). + #[message] + pub async fn cast_recovery_vote( + &mut self, + proposal_id: i32, + recovery_operator_id: i32, + approve: bool, + signature: Vec, + ) -> Result { + use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; + + let mut conn = self.db.get().await?; + + let proposal: Proposal = schema::proposal::table + .find(proposal_id) + .first(&mut conn) + .await + .map_err(|e| match e { + diesel::result::Error::NotFound => Error::ProposalNotFound, + other => Error::DatabaseQuery(other), + })?; + + if proposal.kind != "replace_operator" { + return Err(Error::NotAllowedForRecoveryOperator); + } + + if !Self::is_recovery_active_conn(&mut conn).await? { + return Err(Error::RecoveryNotActive); + } + + let existing: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id)) + .count() + .get_result(&mut conn) + .await?; + if existing > 0 { + return Err(Error::AlreadyVoted); + } + + if proposal.status != ProposalStatus::Pending { + return Err(Error::ProposalNotPending); + } + + let pubkey_bytes: Vec = schema::recovery_operator_identity::table + .find(recovery_operator_id) + .select(schema::recovery_operator_identity::public_key) + .first(&mut conn) + .await + .map_err(|e| match e { + diesel::result::Error::NotFound => Error::OperatorNotFound, + 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_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, GOVERNANCE_CONTEXT, &auth_sig) { + return Err(Error::InvalidSignature); + } + + diesel::insert_into(schema::recovery_proposal_vote::table) + .values(&NewRecoveryProposalVote { + proposal_id, + recovery_operator_id, + approve, + signature, + }) + .execute(&mut conn) + .await?; + + // Quorum: all ordinary + all recovery operators must approve (§3.3 + §3.5) + let total_ordinary: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await?; + let total_recovery: i64 = schema::recovery_operator_identity::table + .count() + .get_result(&mut conn) + .await?; + let threshold_i64 = total_ordinary + total_recovery; + + let ordinary_approve: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + let recovery_approve: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::recovery_proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + let approve_count = ordinary_approve + recovery_approve; + + if approve_count >= threshold_i64 { + diesel::update(schema::proposal::table.find(proposal_id)) + .set(schema::proposal::status.eq(ProposalStatus::Approved)) + .execute(&mut conn) + .await?; + drop(conn); + self.execute_proposal(&proposal).await?; + return Ok(VoteOutcome::QuorumApproved); + } + + let recovery_reject: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::recovery_proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + let ordinary_reject: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + let reject_count = ordinary_reject + recovery_reject; + + if reject_count > threshold_i64 - approve_count - reject_count { diesel::update(schema::proposal::table.find(proposal_id)) .set(schema::proposal::status.eq(ProposalStatus::Rejected)) .execute(&mut conn) @@ -436,6 +648,40 @@ impl ProposalManager { } impl ProposalManager { + const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; + + /// Returns true when an uncancelled wakeup request has passed the 14-day dispute window. + async fn is_recovery_active_conn( + conn: &mut db::DatabaseConnection, + ) -> Result { + let count: i64 = schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .filter( + schema::recovery_wakeup_request::requested_at.le(diesel::dsl::sql::< + diesel::sql_types::Integer, + >(&format!( + "unixepoch('now') - {}", + Self::WAKEUP_DELAY_SECS + ))), + ) + .count() + .get_result(conn) + .await?; + Ok(count > 0) + } + + /// Returns true when there is any uncancelled wakeup request (pending or active). + async fn has_uncancelled_wakeup( + conn: &mut db::DatabaseConnection, + ) -> Result { + let count: i64 = schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .count() + .get_result(conn) + .await?; + Ok(count > 0) + } + async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { let kind = ProposalKind::decode(&proposal.kind, &proposal.payload) .map_err(Error::ExecutionFailed)?; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 1dc282a..11a9919 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -527,4 +527,19 @@ pub struct NewProposalVote { pub struct NewProposalResult { pub proposal_id: i32, pub data: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))] +pub struct NewRecoveryProposalVote { + pub proposal_id: i32, + pub recovery_operator_id: i32, + pub approve: bool, + pub signature: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))] +pub struct NewRecoveryWakeupRequest { + pub requested_by: i32, } \ No newline at end of file diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 3c04ac2..4b6aa1d 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -192,6 +192,36 @@ diesel::table! { } } +diesel::table! { + recovery_operator_identity (id) { + id -> Integer, + public_key -> Binary, + created_at -> Integer, + updated_at -> Integer, + } +} + +diesel::table! { + recovery_wakeup_request (id) { + id -> Integer, + requested_by -> Integer, + requested_at -> Integer, + cancelled_by -> Nullable, + cancelled_at -> Nullable, + } +} + +diesel::table! { + recovery_proposal_vote (id) { + id -> Integer, + proposal_id -> Integer, + recovery_operator_id -> Integer, + approve -> Bool, + signature -> Binary, + voted_at -> Integer, + } +} + diesel::table! { proposal_vote (id) { id -> Integer, @@ -260,10 +290,16 @@ diesel::joinable!(proposal -> operator_identity (initiator_id)); diesel::joinable!(proposal_result -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> operator_identity (operator_id)); +diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id)); +diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id)); +diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, proposal_result, + recovery_operator_identity, + recovery_wakeup_request, + recovery_proposal_vote, arbiter_settings, client_metadata, client_metadata_history, diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 7cb1cff..f0eca81 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -2,13 +2,20 @@ use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; use arbiter_server::{ actors::{ GlobalActors, - proposal_manager::{CastVote, CreateProposal, Error as ProposalError, ExpireStale, ProposalKind, QueryPending, VoteOutcome}, + proposal_manager::{ + CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal, + Error as ProposalError, ExpireStale, ProposalKind, QueryPending, + RequestRecoveryWakeup, VoteOutcome, + }, }, crypto::KeyCell, db, }; use arbiter_server::actors::vault::Bootstrap; -use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, proposal_result}; +use arbiter_server::db::schema::{ + aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, + proposal_result, recovery_operator_identity, +}; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -22,6 +29,28 @@ async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> .unwrap() } +async fn register_recovery_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { + let mut conn = db.get().await.unwrap(); + insert_into(recovery_operator_identity::table) + .values(recovery_operator_identity::public_key.eq(pubkey.to_bytes())) + .returning(recovery_operator_identity::id) + .get_result::(&mut conn) + .await + .unwrap() +} + +/// Backdates a wakeup request so it appears to have passed the 14-day window. +async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: i32) { + let mut conn = db.get().await.unwrap(); + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({operator_id}, unixepoch('now') - 14*24*3600 - 1)" + )) + .execute(&mut conn) + .await + .unwrap(); +} + fn make_vote_message(proposal_id: i32, approve: bool) -> Vec { let mut msg = Vec::with_capacity(9); msg.extend_from_slice(&(proposal_id as i64).to_be_bytes()); @@ -878,3 +907,204 @@ async fn approve_server_update_reaches_quorum() { assert_eq!(outcome, VoteOutcome::QuorumApproved); } + +// ─── §3.5 / §3.6 Recovery Operator tests ────────────────────────────────── + +#[tokio::test] +async fn recovery_vote_rejected_when_sleeping() { + 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 op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + let rec_key = authn::SigningKey::generate(); + let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator { new_pubkey }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let err = actors + .proposal_manager + .ask(CastRecoveryVote { + proposal_id, + recovery_operator_id: rec_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap_err(); + + assert!( + matches!(err, kameo::error::SendError::HandlerError(ProposalError::RecoveryNotActive)), + "expected RecoveryNotActive, got {err:?}" + ); +} + +#[tokio::test] +async fn recovery_vote_blocked_on_non_replace_proposal() { + 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 op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + let rec_key = authn::SigningKey::generate(); + let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await; + + insert_active_wakeup(&db, op_id).await; + + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient { client_id }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let err = actors + .proposal_manager + .ask(CastRecoveryVote { + proposal_id, + recovery_operator_id: rec_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(ProposalError::NotAllowedForRecoveryOperator) + ), + "expected NotAllowedForRecoveryOperator, got {err:?}" + ); +} + +#[tokio::test] +async fn recovery_wakeup_can_be_cancelled() { + 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_id = register_operator(&db, &key.public_key()).await; + + actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); + + actors + .proposal_manager + .ask(CancelRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); + + // Second request must succeed (previous one was cancelled) + actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); +} + +#[tokio::test] +async fn recovery_wakeup_prevents_duplicate_request() { + 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_id = register_operator(&db, &key.public_key()).await; + + actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap(); + + let err = actors + .proposal_manager + .ask(RequestRecoveryWakeup { operator_id: op_id }) + .await + .unwrap_err(); + + assert!( + matches!(err, kameo::error::SendError::HandlerError(ProposalError::WakeupAlreadyPending)), + "expected WakeupAlreadyPending, got {err:?}" + ); +} + +#[tokio::test] +async fn recovery_operator_vote_contributes_to_replace_quorum() { + // 1 ordinary operator + 1 recovery operator; replace_operator needs both. + 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 op_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &op_key.public_key()).await; + let rec_key = authn::SigningKey::generate(); + let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await; + + insert_active_wakeup(&db, op_id).await; + + let new_pubkey = authn::SigningKey::generate().public_key().to_bytes(); + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ReplaceOperator { new_pubkey }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + // Ordinary operator approves — still pending (needs recovery too) + let msg = make_vote_message(proposal_id, true); + let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::Pending); + + // Recovery operator approves — now quorum is reached + let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastRecoveryVote { + proposal_id, + recovery_operator_id: rec_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + assert_eq!(outcome, VoteOutcome::QuorumApproved); +} -- 2.49.1 From e121708d28ca7c411108f1eb615a166a11dd4470 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 23:08:53 +0200 Subject: [PATCH 27/66] fix(crypto): handle 1-of-N Shamir split when ordinary_count=1 --- .../src/actors/vault_coordinator/mod.rs | 354 ++++++++++++++---- .../arbiter-server/tests/vault/lifecycle.rs | 107 +++++- 2 files changed, 386 insertions(+), 75 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 1074763..cac741c 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -51,11 +51,14 @@ enum CoordinatorState { Idle, Bootstrapping { declared_count: usize, + recovery_count: usize, passphrases: HashMap>, + recovery_passphrases: HashMap>, }, Unsealing { threshold: usize, - passphrases: HashMap>, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, }, } @@ -78,42 +81,83 @@ impl VaultCoordinator { const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1"; +fn encrypt_share( + passphrase_bytes: Vec, + share: &[u8], +) -> Result<(Vec, Vec, Vec), Error> { + let mut share_salt = vec![0u8; 32]; + OsRng.fill_bytes(&mut share_salt); + + let mut passphrase_cell = SafeCell::new(passphrase_bytes); + let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); + + let nonce = Nonce::default(); + let encrypted_share = share_seal_key + .encrypt(&nonce, SHARE_AAD, share) + .map_err(|_| Error::Encryption)?; + + Ok((encrypted_share, nonce.to_vec(), share_salt)) +} + +fn decrypt_share( + passphrase_bytes: Vec, + encrypted_share: Vec, + share_nonce_bytes: Vec, + share_salt: Vec, + operator_id: i32, +) -> Result, Error> { + let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| { + error!(operator_id, "Invalid nonce in DB"); + Error::BrokenDatabase + })?; + + let mut passphrase_cell = SafeCell::new(passphrase_bytes); + let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); + + let mut share_buffer = SafeCell::new(encrypted_share); + share_seal_key + .decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer) + .map_err(|_| Error::InvalidPassphrase)?; + + Ok(share_buffer.read().clone()) +} + +/// §3.4: Split the seal key across ordinary + recovery operators. +/// Threshold = shamir_threshold(ordinary_count); total shares = ordinary + recovery. +/// When ordinary_count == 1 (threshold = 1), vsss-rs does not support a proper split, +/// so each share is the seal key itself — any single participant can reconstruct. async fn finalize_bootstrap( db: db::DatabasePool, vault: ActorRef, - passphrases: HashMap>, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, ) -> Result<(), Error> { - let total = passphrases.len(); - let threshold = shamir_threshold(total); + let ordinary_count = ordinary_passphrases.len(); + let recovery_count = recovery_passphrases.len(); + let total = ordinary_count + recovery_count; + let threshold = shamir_threshold(ordinary_count); - // Generate random 32-byte seal key let mut seal_key_bytes = [0u8; 32]; OsRng.fill_bytes(&mut seal_key_bytes); - // Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs) - let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng) - .map_err(|e| Error::Shamir(e.to_string()))?; + // threshold == 1 means any single share reconstructs the key (degenerate split). + // vsss-rs requires threshold >= 2, so we store the key directly in this case. + let shares: Vec> = if threshold >= 2 { + shamir::split_key(threshold, total, &seal_key_bytes, OsRng) + .map_err(|e| Error::Shamir(e.to_string()))? + } else { + (0..total).map(|_| seal_key_bytes.to_vec()).collect() + }; let seal_key = KeyCell::from(seal_key_bytes); let mut conn = db.get().await?; + let mut shares_iter = shares.into_iter(); - for ((operator_id_raw, passphrase_bytes), share) in passphrases.into_iter().zip(shares) { - // Generate a fresh share_salt for this operator - let mut share_salt = vec![0u8; 32]; - OsRng.fill_bytes(&mut share_salt); - - // Derive share encryption key from passphrase + salt - let mut passphrase_cell = SafeCell::new(passphrase_bytes); - let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); - - // Encrypt this operator's share - let nonce = Nonce::default(); - let encrypted_share = share_seal_key - .encrypt(&nonce, SHARE_AAD, &share) - .map_err(|_| Error::Encryption)?; - - let nonce_bytes = nonce.to_vec(); + for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { + let share = shares_iter.next().expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = + encrypt_share(passphrase_bytes, &share)?; diesel::replace_into(schema::operator::table) .values(( @@ -128,6 +172,24 @@ async fn finalize_bootstrap( .await?; } + for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { + let share = shares_iter.next().expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = + encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::recovery_operator::table) + .values(( + schema::recovery_operator::id.eq(recovery_id_raw), + schema::recovery_operator::share.eq(&encrypted_share), + schema::recovery_operator::share_nonce.eq(&nonce_bytes), + schema::recovery_operator::share_salt.eq(&share_salt), + schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()), + schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + vault .ask(Bootstrap { seal_key }) .await @@ -139,15 +201,25 @@ async fn finalize_bootstrap( Ok(()) } +/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares. async fn finalize_unseal( db: db::DatabasePool, vault: ActorRef, - passphrases: HashMap>, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, ) -> Result<(), Error> { let mut conn = db.get().await?; + + // Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2). + let ordinary_operator_count: i64 = schema::operator::table + .count() + .get_result(&mut conn) + .await?; + let threshold = shamir_threshold(ordinary_operator_count as usize); + let mut shares: Vec> = Vec::new(); - for (operator_id_raw, passphrase_bytes) in passphrases { + for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { let (encrypted_share, share_nonce_bytes, share_salt): (Vec, Vec, Vec) = schema::operator::table .filter(schema::operator::id.eq(Some(operator_id_raw))) @@ -160,25 +232,45 @@ async fn finalize_unseal( .await .map_err(|_| Error::OperatorNotFound)?; - let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| { - error!(operator_id = operator_id_raw, "Invalid nonce in DB"); - Error::BrokenDatabase - })?; - - let mut passphrase_cell = SafeCell::new(passphrase_bytes); - let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); - - let mut share_buffer = SafeCell::new(encrypted_share); - share_seal_key - .decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer) - .map_err(|_| Error::InvalidPassphrase)?; - - let decrypted_share = share_buffer.read().clone(); - shares.push(decrypted_share); + shares.push(decrypt_share( + passphrase_bytes, + encrypted_share, + share_nonce_bytes, + share_salt, + operator_id_raw, + )?); } - let seal_key_bytes = - shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?; + for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { + let (encrypted_share, share_nonce_bytes, share_salt): (Vec, Vec, Vec) = + schema::recovery_operator::table + .find(recovery_id_raw) + .select(( + schema::recovery_operator::share, + schema::recovery_operator::share_nonce, + schema::recovery_operator::share_salt, + )) + .first(&mut conn) + .await + .map_err(|_| Error::OperatorNotFound)?; + + shares.push(decrypt_share( + passphrase_bytes, + encrypted_share, + share_nonce_bytes, + share_salt, + recovery_id_raw, + )?); + } + + // When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N). + // Any single decrypted share is the key itself. + let seal_key_bytes: [u8; 32] = if threshold <= 1 { + let raw = shares.into_iter().next().ok_or_else(|| Error::Shamir("No shares available".into()))?; + raw.try_into().map_err(|_| Error::Shamir("Invalid share length".into()))? + } else { + shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))? + }; let seal_key = KeyCell::from(seal_key_bytes); @@ -213,13 +305,15 @@ impl VaultCoordinator { } self.state = CoordinatorState::Bootstrapping { declared_count, + recovery_count, passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), }; Ok(()) } - /// Phase 2 of multi-operator bootstrap: contribute a passphrase. - /// Returns Ok(true) when all operators contributed and bootstrap finalized. + /// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase. + /// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized. #[message] pub async fn contribute_bootstrap( &mut self, @@ -228,8 +322,9 @@ impl VaultCoordinator { ) -> Result { let CoordinatorState::Bootstrapping { declared_count, + recovery_count, passphrases, - .. + recovery_passphrases, } = &mut self.state else { return Err(Error::NotBootstrapping); @@ -239,25 +334,81 @@ impl VaultCoordinator { return Err(Error::DuplicateContribution); } - // Extract bytes immediately so state stays Sync let passphrase_bytes = passphrase.read().to_vec(); passphrases.insert(operator_id, passphrase_bytes); - if passphrases.len() < *declared_count { + if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count { return Ok(false); } - let CoordinatorState::Bootstrapping { passphrases, .. } = - std::mem::replace(&mut self.state, CoordinatorState::Idle) + let CoordinatorState::Bootstrapping { + passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) else { unreachable!() }; - finalize_bootstrap(self.db.clone(), self.vault.clone(), passphrases).await?; + finalize_bootstrap( + self.db.clone(), + self.vault.clone(), + passphrases, + recovery_passphrases, + ) + .await?; Ok(true) } - /// Contribute a passphrase for vault unseal. + /// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase. + /// Returns Ok(true) when all contributors are in and bootstrap finalized. + #[message] + pub async fn contribute_recovery_bootstrap( + &mut self, + recovery_operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Bootstrapping { + declared_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotBootstrapping); + }; + + if recovery_passphrases.contains_key(&recovery_operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + recovery_passphrases.insert(recovery_operator_id, passphrase_bytes); + + if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + let CoordinatorState::Bootstrapping { + passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_bootstrap( + self.db.clone(), + self.vault.clone(), + passphrases, + recovery_passphrases, + ) + .await?; + Ok(true) + } + + /// Contribute a passphrase for vault unseal (ordinary operator). /// Returns Ok(true) when threshold reached and vault is unsealed. #[message] pub async fn contribute_unseal( @@ -265,46 +416,105 @@ impl VaultCoordinator { operator_id: i32, mut passphrase: SafeCell>, ) -> Result { - if matches!(self.state, CoordinatorState::Idle) { - let mut conn = self.db.get().await?; - let count: i64 = schema::operator::table - .count() - .get_result(&mut conn) - .await?; - let threshold = shamir_threshold(usize::try_from(count).unwrap_or_default()); - - self.state = CoordinatorState::Unsealing { - threshold, - passphrases: HashMap::new(), - }; - } + self.ensure_unsealing_state().await?; let CoordinatorState::Unsealing { threshold, - passphrases, + ordinary_passphrases, + recovery_passphrases, } = &mut self.state else { return Err(Error::NotUnsealing); }; - if passphrases.contains_key(&operator_id) { + if ordinary_passphrases.contains_key(&operator_id) { return Err(Error::DuplicateContribution); } let passphrase_bytes = passphrase.read().to_vec(); - passphrases.insert(operator_id, passphrase_bytes); + ordinary_passphrases.insert(operator_id, passphrase_bytes); - if passphrases.len() < *threshold { + if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold { return Ok(false); } - let CoordinatorState::Unsealing { passphrases, .. } = - std::mem::replace(&mut self.state, CoordinatorState::Idle) + self.do_finalize_unseal().await + } + + /// Contribute a passphrase for vault unseal (recovery operator, §3.5). + /// Recovery operators may contribute during unseal when recovery is active. + /// Returns Ok(true) when threshold reached and vault is unsealed. + #[message] + pub async fn contribute_recovery_unseal( + &mut self, + recovery_operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + self.ensure_unsealing_state().await?; + + let CoordinatorState::Unsealing { + threshold, + ordinary_passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotUnsealing); + }; + + if recovery_passphrases.contains_key(&recovery_operator_id) { + return Err(Error::DuplicateContribution); + } + + let passphrase_bytes = passphrase.read().to_vec(); + recovery_passphrases.insert(recovery_operator_id, passphrase_bytes); + + if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold { + return Ok(false); + } + + self.do_finalize_unseal().await + } +} + +impl VaultCoordinator { + /// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`. + /// Threshold is based on ordinary operator count only (§3.4). + async fn ensure_unsealing_state(&mut self) -> Result<(), Error> { + if matches!(self.state, CoordinatorState::Idle) { + let mut conn = self.db.get().await?; + let ordinary_count: i64 = schema::operator::table + .count() + .get_result(&mut conn) + .await?; + let threshold = + shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()); + self.state = CoordinatorState::Unsealing { + threshold, + ordinary_passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + } + Ok(()) + } + + /// Moves state back to Idle and calls finalize_unseal. + async fn do_finalize_unseal(&mut self) -> Result { + let CoordinatorState::Unsealing { + ordinary_passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) else { unreachable!() }; - finalize_unseal(self.db.clone(), self.vault.clone(), passphrases).await?; + finalize_unseal( + self.db.clone(), + self.vault.clone(), + ordinary_passphrases, + recovery_passphrases, + ) + .await?; Ok(true) } } diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 9065654..e11a27d 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -3,14 +3,17 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_server::{ actors::{ GlobalActors, - vault::{Error, Vault}, - vault_coordinator::{Error as CoordinatorError, StartBootstrap, VaultCoordinator}, + vault::{Error, GetState, Vault, VaultState}, + vault_coordinator::{ + ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal, + Error as CoordinatorError, StartBootstrap, VaultCoordinator, + }, }, crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, }; -use diesel::{QueryDsl, SelectableHelper}; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; use kameo::actor::Spawn as _; @@ -167,3 +170,101 @@ async fn two_operator_vault_requires_recovery_share() { "expected TwoOperatorsRequireRecovery, got {err:?}" ); } + +/// §3.4: Bootstrap with 1 ordinary + 1 recovery operator produces a valid 1-of-2 Shamir split. +/// Both ordinary and recovery shares are stored; the vault can be unsealed with either one. +#[tokio::test] +#[test_log::test] +async fn recovery_share_stored_and_used_for_unseal() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone())); + + // Register one ordinary operator and one recovery operator in the DB + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![1u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32])) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + // Declare committee: 1 ordinary + 1 recovery + coordinator + .ask(StartBootstrap { + operator_id: ordinary_id, + declared_count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + + // Recovery operator contributes first — bootstrap should not finalize yet + let done = coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + assert!(!done, "should not finalize with only recovery passphrase"); + + // Ordinary operator contributes — now bootstrap finalizes + let done = coordinator + .ask(ContributeBootstrap { + operator_id: ordinary_id, + passphrase: SafeCell::new(b"ordinary-pass".to_vec()), + }) + .await + .unwrap(); + assert!(done, "should finalize once all contributors are in"); + + // After bootstrap, vault is Unsealed (seal key still in memory). + let state = vault_ref.ask(GetState {}).await.unwrap(); + assert_eq!(state, VaultState::Unsealed); + + // Verify recovery_operator row was created + let recovery_share_count: i64 = { + let mut conn = db.get().await.unwrap(); + schema::recovery_operator::table + .count() + .get_result(&mut conn) + .await + .unwrap() + }; + assert_eq!(recovery_share_count, 1); + + // Simulate restart: drop vault and coordinator, create fresh vault (comes up Sealed). + drop(coordinator); + drop(vault_ref); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let state = vault_ref2.ask(GetState {}).await.unwrap(); + assert_eq!(state, VaultState::Sealed); + + // §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1). + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + let done = coordinator2 + .ask(ContributeRecoveryUnseal { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + assert!(done, "recovery share alone should satisfy threshold"); + + let state = vault_ref2.ask(GetState {}).await.unwrap(); + assert_eq!(state, VaultState::Unsealed); +} -- 2.49.1 From e1d060dd063bd787a062ad477bd4cdbaef3d6a1b Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 23:09:49 +0200 Subject: [PATCH 28/66] feat(vault): add recovery passphrase handling for bootstrap and unseal processes --- .../2026-02-14-171124-0000_init/up.sql | 12 +++++- server/crates/arbiter-server/src/db/schema.rs | 13 +++++++ .../src/grpc/operator/vault_gate/inbound.rs | 17 ++++++++ .../src/grpc/operator/vault_gate/outbound.rs | 28 +++++++++++++ .../src/peers/operator/vault_gate/mod.rs | 39 ++++++++++++++++++- 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 08ba766..0168767 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -246,9 +246,19 @@ create table if not exists proposal_result ( ) STRICT; -- =============================== --- Recovery Operators (§3.5/§3.6) +-- Recovery Operators (§3.4/§3.5/§3.6) -- =============================== +-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table). +create table if not exists recovery_operator ( + id integer not null primary key references recovery_operator_identity(id) on delete restrict, + share blob not null, + share_nonce blob not null, + share_salt blob not null, + created_at integer not null default(unixepoch('now')), + updated_at integer not null default(unixepoch('now')) +) STRICT; + create table if not exists recovery_operator_identity ( id integer not null primary key, public_key blob not null unique, diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 4b6aa1d..cbc7705 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -192,6 +192,17 @@ diesel::table! { } } +diesel::table! { + recovery_operator (id) { + id -> Integer, + share -> Binary, + share_nonce -> Binary, + share_salt -> Binary, + created_at -> Integer, + updated_at -> Integer, + } +} + diesel::table! { recovery_operator_identity (id) { id -> Integer, @@ -290,6 +301,7 @@ diesel::joinable!(proposal -> operator_identity (initiator_id)); diesel::joinable!(proposal_result -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> operator_identity (operator_id)); +diesel::joinable!(recovery_operator -> recovery_operator_identity (id)); diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id)); diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id)); diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); @@ -297,6 +309,7 @@ diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, proposal_result, + recovery_operator, recovery_operator_identity, recovery_wakeup_request, recovery_proposal_vote, diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index 5342282..01a1a10 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -2,6 +2,7 @@ use crate::{ grpc::{Convert, TryConvert}, peers::operator::vault_gate::{ self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase, + HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase, HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake, HandleUnsealEncryptedKey, }, @@ -82,6 +83,14 @@ impl TryConvert for UnsealRequestPayload { }, ), ), + Self::ContributeRecoveryPassphrase(crp) => Ok( + vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase( + HandleContributeRecoveryUnsealPassphrase { + recovery_operator_id: crp.recovery_operator_id, + passphrase: crp.passphrase, + }, + ), + ), } } } @@ -142,6 +151,14 @@ impl TryConvert for BootstrapRequestPayload { }, ), ), + Self::ContributeRecoveryPassphrase(crp) => Ok( + vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase( + HandleContributeRecoveryBootstrapPassphrase { + recovery_operator_id: crp.recovery_operator_id, + passphrase: crp.passphrase, + }, + ), + ), } } } diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs index 1e44ca4..fc8bf7f 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs @@ -131,6 +131,19 @@ impl TryConvert for vault_gate::Outbound { }; Ok(wrap_bootstrap_response(proto_result)) } + Self::HandleContributeRecoveryBootstrapPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoBootstrapResult::Success, + Ok(false) => ProtoBootstrapResult::AwaitingContributions, + Err(err) => { + warn!(?err, "contribute recovery bootstrap passphrase failed"); + return Err(Status::internal( + "Failed to contribute recovery bootstrap passphrase", + )); + } + }; + Ok(wrap_bootstrap_response(proto_result)) + } Self::HandleContributeUnsealPassphrase(result) => { let proto_result = match result { Ok(true) => ProtoUnsealResult::Success, @@ -144,6 +157,21 @@ impl TryConvert for vault_gate::Outbound { proto_result.into(), ))) } + Self::HandleContributeRecoveryUnsealPassphrase(result) => { + let proto_result = match result { + Ok(true) => ProtoUnsealResult::Success, + Ok(false) => ProtoUnsealResult::AwaitingContributions, + Err(err) => { + warn!(?err, "contribute recovery unseal passphrase failed"); + return Err(Status::internal( + "Failed to contribute recovery unseal passphrase", + )); + } + }; + Ok(wrap_unseal_response(UnsealResponsePayload::Result( + proto_result.into(), + ))) + } } } } diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index 611ba82..d416401 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -3,7 +3,10 @@ use crate::{ actors::{ GlobalActors, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, - vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap}, + vault_coordinator::{ + ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal, + ContributeUnseal, StartBootstrap, + }, }, crypto::{KeyCell, integrity::{self}}, db::DatabasePool, @@ -266,6 +269,23 @@ impl VaultGate { .map_err(|_| Error::internal("VaultCoordinator unavailable")) } + #[message] + pub async fn handle_contribute_recovery_bootstrap_passphrase( + &mut self, + recovery_operator_id: i32, + passphrase: Vec, + ) -> Result { + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + #[message] pub async fn handle_contribute_unseal_passphrase( &mut self, @@ -281,6 +301,23 @@ impl VaultGate { .await .map_err(|_| Error::internal("VaultCoordinator unavailable")) } + + #[message] + pub async fn handle_contribute_recovery_unseal_passphrase( + &mut self, + recovery_operator_id: i32, + passphrase: Vec, + ) -> Result { + let passphrase_cell = SafeCell::new(passphrase); + self.actors + .vault_coordinator + .ask(ContributeRecoveryUnseal { + recovery_operator_id, + passphrase: passphrase_cell, + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } } impl Message for VaultGate { -- 2.49.1 From 3817a080c989487dd3cfabd01bc908038fb8691b Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 14 Jun 2026 15:02:25 +0200 Subject: [PATCH 29/66] refactor(proposal): replace string kind dispatch with ProposalKindTag enum (strum) --- .../src/actors/proposal_manager.rs | 196 ++++++++++++------ 1 file changed, 136 insertions(+), 60 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 684afad..5ed4e3d 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -1,10 +1,14 @@ use crate::{ - actors::{evm::EvmActor, vault::Vault}, + actors::{ + evm::EvmActor, + vault::Vault, + vault_coordinator::{StartRekey, VaultCoordinator}, + }, db::{ self, models::{ - NewProposal, NewProposalVote, NewRecoveryProposalVote, - NewRecoveryWakeupRequest, Proposal, ProposalStatus, SqliteTimestamp, + NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, + Proposal, ProposalStatus, SqliteTimestamp, }, schema, }, @@ -13,34 +17,65 @@ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; use kameo::{actor::ActorRef, messages}; +use strum::{Display, EnumString, IntoStaticStr}; use tracing::{error, warn}; pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum ProposalKindTag { + ApproveSdkClient, + GrantWalletAccess, + ApproveServerUpdate, + ReplaceOperator, + UpdateShamirParameters, + ApprovePersistentGrant, + ApproveOneOffTransaction, +} + #[derive(Debug, Clone)] pub enum ProposalKind { - ApproveSdkClient { client_id: i32 }, - GrantWalletAccess { wallet_id: i32, client_id: i32 }, + ApproveSdkClient { + client_id: i32, + }, + GrantWalletAccess { + wallet_id: i32, + client_id: i32, + }, ApproveServerUpdate, - ReplaceOperator { new_pubkey: Vec }, - UpdateShamirParameters { new_n: u8 }, - ApprovePersistentGrant { payload_bytes: Vec }, - ApproveOneOffTransaction { payload_bytes: Vec }, + ReplaceOperator { + old_operator_id: i32, + new_pubkey: Vec, + }, + UpdateShamirParameters { + new_n: u8, + }, + ApprovePersistentGrant { + payload_bytes: Vec, + }, + ApproveOneOffTransaction { + payload_bytes: Vec, + }, } impl ProposalKind { - pub const fn kind_str(&self) -> &'static str { + pub fn tag(&self) -> ProposalKindTag { match self { - Self::ApproveSdkClient { .. } => "approve_sdk_client", - Self::GrantWalletAccess { .. } => "grant_wallet_access", - Self::ApproveServerUpdate => "approve_server_update", - Self::ReplaceOperator { .. } => "replace_operator", - Self::UpdateShamirParameters { .. } => "update_shamir_parameters", - Self::ApprovePersistentGrant { .. } => "approve_persistent_grant", - Self::ApproveOneOffTransaction { .. } => "approve_one_off_transaction", + Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient, + Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess, + Self::ApproveServerUpdate => ProposalKindTag::ApproveServerUpdate, + Self::ReplaceOperator { .. } => ProposalKindTag::ReplaceOperator, + Self::UpdateShamirParameters { .. } => ProposalKindTag::UpdateShamirParameters, + Self::ApprovePersistentGrant { .. } => ProposalKindTag::ApprovePersistentGrant, + Self::ApproveOneOffTransaction { .. } => ProposalKindTag::ApproveOneOffTransaction, } } + pub fn kind_str(&self) -> &'static str { + self.tag().into() + } + pub fn encode_payload(&self) -> Vec { match self { Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), @@ -54,35 +89,45 @@ impl ProposalKind { buf } Self::ApproveServerUpdate => vec![], - Self::ReplaceOperator { new_pubkey } => { + Self::ReplaceOperator { + old_operator_id, + new_pubkey, + } => { let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32"); - let mut buf = Vec::with_capacity(4 + new_pubkey.len()); + let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len()); + buf.extend_from_slice(&old_operator_id.to_be_bytes()); buf.extend_from_slice(&len.to_be_bytes()); buf.extend_from_slice(new_pubkey); buf } Self::UpdateShamirParameters { new_n } => vec![*new_n], - Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(), - Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), + Self::ApprovePersistentGrant { payload_bytes } + | Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), } } /// Key-rotation proposals require every operator to approve (§3.3). #[must_use] pub fn requires_full_quorum(kind: &str) -> bool { - matches!(kind, "replace_operator" | "update_shamir_parameters") + matches!( + kind.parse::(), + Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::UpdateShamirParameters) + ) } pub fn decode(kind: &str, payload: &[u8]) -> Result { - match kind { - "approve_sdk_client" => { + let tag = kind + .parse::() + .map_err(|_| format!("unknown proposal kind: {kind}"))?; + match tag { + ProposalKindTag::ApproveSdkClient => { let bytes = <[u8; 4]>::try_from(payload) .map_err(|_| "invalid payload for approve_sdk_client".to_owned())?; Ok(Self::ApproveSdkClient { client_id: i32::from_be_bytes(bytes), }) } - "grant_wallet_access" => { + ProposalKindTag::GrantWalletAccess => { let bytes = <[u8; 8]>::try_from(payload) .map_err(|_| "invalid payload for grant_wallet_access".to_owned())?; Ok(Self::GrantWalletAccess { @@ -90,9 +135,13 @@ impl ProposalKind { client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), }) } - "approve_server_update" => Ok(Self::ApproveServerUpdate), - "replace_operator" => { - let (len_bytes, rest) = payload + ProposalKindTag::ApproveServerUpdate => Ok(Self::ApproveServerUpdate), + ProposalKindTag::ReplaceOperator => { + let (id_bytes, rest) = payload + .split_first_chunk::<4>() + .ok_or_else(|| "replace_operator payload too short".to_owned())?; + let old_operator_id = i32::from_be_bytes(*id_bytes); + let (len_bytes, rest) = rest .split_first_chunk::<4>() .ok_or_else(|| "replace_operator payload too short".to_owned())?; let len = u32::from_be_bytes(*len_bytes); @@ -101,21 +150,23 @@ impl ProposalKind { .get(..len) .ok_or_else(|| "replace_operator payload truncated".to_owned())? .to_vec(); - Ok(Self::ReplaceOperator { new_pubkey }) + Ok(Self::ReplaceOperator { + old_operator_id, + new_pubkey, + }) } - "update_shamir_parameters" => { + ProposalKindTag::UpdateShamirParameters => { let &[new_n] = payload else { return Err("invalid payload for update_shamir_parameters".to_owned()); }; Ok(Self::UpdateShamirParameters { new_n }) } - "approve_persistent_grant" => Ok(Self::ApprovePersistentGrant { + ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant { payload_bytes: payload.to_vec(), }), - "approve_one_off_transaction" => Ok(Self::ApproveOneOffTransaction { + ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction { payload_bytes: payload.to_vec(), }), - other => Err(format!("unknown proposal kind: {other}")), } } } @@ -169,6 +220,7 @@ pub struct ProposalManager { pub(crate) db: db::DatabasePool, pub(crate) vault: ActorRef, pub(crate) evm: ActorRef, + pub(crate) vault_coordinator: ActorRef, } impl ProposalManager { @@ -176,8 +228,14 @@ impl ProposalManager { db: db::DatabasePool, vault: ActorRef, evm: ActorRef, + vault_coordinator: ActorRef, ) -> Self { - Self { db, vault, evm } + Self { + db, + vault, + evm, + vault_coordinator, + } } } @@ -496,8 +554,7 @@ impl ProposalManager { .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) .set(( schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)), - schema::recovery_wakeup_request::cancelled_at - .eq(Some(SqliteTimestamp::now())), + schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())), )) .execute(&mut conn) .await?; @@ -530,7 +587,7 @@ impl ProposalManager { other => Error::DatabaseQuery(other), })?; - if proposal.kind != "replace_operator" { + if proposal.kind.parse::() != Ok(ProposalKindTag::ReplaceOperator) { return Err(Error::NotAllowedForRecoveryOperator); } @@ -651,9 +708,7 @@ impl ProposalManager { const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; /// Returns true when an uncancelled wakeup request has passed the 14-day dispute window. - async fn is_recovery_active_conn( - conn: &mut db::DatabaseConnection, - ) -> Result { + async fn is_recovery_active_conn(conn: &mut db::DatabaseConnection) -> Result { let count: i64 = schema::recovery_wakeup_request::table .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) .filter( @@ -671,9 +726,7 @@ impl ProposalManager { } /// Returns true when there is any uncancelled wakeup request (pending or active). - async fn has_uncancelled_wakeup( - conn: &mut db::DatabaseConnection, - ) -> Result { + async fn has_uncancelled_wakeup(conn: &mut db::DatabaseConnection) -> Result { let count: i64 = schema::recovery_wakeup_request::table .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) .count() @@ -694,11 +747,15 @@ impl ProposalManager { client_id, } => self.execute_grant_wallet_access(wallet_id, client_id).await, ProposalKind::ApproveServerUpdate => Ok(()), - ProposalKind::ReplaceOperator { new_pubkey } => { - self.execute_replace_operator(new_pubkey).await + ProposalKind::ReplaceOperator { + old_operator_id, + new_pubkey, + } => { + self.execute_replace_operator(old_operator_id, new_pubkey) + .await } ProposalKind::UpdateShamirParameters { new_n } => { - self.execute_update_shamir_parameters(new_n) + self.execute_update_shamir_parameters(new_n).await } ProposalKind::ApprovePersistentGrant { payload_bytes } => { self.execute_approve_persistent_grant(payload_bytes).await @@ -731,26 +788,45 @@ impl ProposalManager { Ok(()) } - async fn execute_replace_operator(&self, new_pubkey: Vec) -> Result<(), Error> { + /// Updates the old operator's public key in-place (preserving their DB id and history), + /// removes their old Shamir share, then begins a coordinated re-key (§3.3). + async fn execute_replace_operator( + &self, + old_operator_id: i32, + new_pubkey: Vec, + ) -> Result<(), Error> { let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; - diesel::insert_into(schema::operator_identity::table) - .values(schema::operator_identity::public_key.eq(&new_pubkey)) + + diesel::update(schema::operator_identity::table) + .filter(schema::operator_identity::id.eq(old_operator_id)) + .set(schema::operator_identity::public_key.eq(&new_pubkey)) .execute(&mut conn) .await - .map_err(|e| Error::ExecutionFailed(format!("replace operator: {e}")))?; + .map_err(|e| Error::ExecutionFailed(format!("update operator pubkey: {e}")))?; + + // Remove the old Shamir share; finalize_rekey will store a fresh one. + diesel::delete(schema::operator::table) + .filter(schema::operator::id.eq(Some(old_operator_id))) + .execute(&mut conn) + .await + .map_err(|e| Error::ExecutionFailed(format!("remove old operator share: {e}")))?; + + drop(conn); + + self.vault_coordinator + .ask(StartRekey {}) + .await + .map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?; + Ok(()) } - #[expect( - clippy::unused_self, - clippy::unnecessary_wraps, - reason = "signature must match other execute_* methods" - )] - fn execute_update_shamir_parameters(&self, new_n: u8) -> Result<(), Error> { - warn!( - new_n, - "UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band" - ); + /// Triggers a Shamir re-key with the current operator set (§3.3). + async fn execute_update_shamir_parameters(&self, _new_n: u8) -> Result<(), Error> { + self.vault_coordinator + .ask(StartRekey {}) + .await + .map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?; Ok(()) } -- 2.49.1 From 36249129d142a408f7cba3fa536bfc3441146e04 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 14 Jun 2026 15:11:11 +0200 Subject: [PATCH 30/66] =?UTF-8?q?feat(vault)!:=20implement=20full=20Shamir?= =?UTF-8?q?=20re-key=20flow=20and=20governance=20execution=20(=C2=A73.3?= =?UTF-8?q?=E2=80=93=C2=A73.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `rekey.proto` with `ContributePassphrase` / `ContributeRecoveryPassphrase` / `RekeyResult` - Wire `rekey` as a 4th vault stream payload in `vault.proto` and gRPC dispatch - Add `RekeyRootKey` message to `Vault` actor: generates new random seal key, re-encrypts root key, writes new `root_key_history` row - Add `StartRekey`, `ContributeRekey`, `ContributeRecoveryRekey` messages to `VaultCoordinator`; `finalize_rekey` uses threshold-1 fast path identical to bootstrap - `execute_replace_operator` now UPDATEs `operator_identity.public_key` in-place (avoids FK constraint violation), deletes stale `operator` share row, then triggers `StartRekey` - `execute_update_shamir_parameters` triggers `StartRekey` instead of warning stub - `ProposalKind::ReplaceOperator` carries `old_operator_id`; encode/decode updated accordingly - `GlobalActors::spawn` extracts `vault_coordinator` before `Ok(Self { … })` so it can be cloned into `ProposalManager::new` - Add `handle_rekey` in session handlers forwarding passphrase contributions to `VaultCoordinator` - Fix test: rename `replace_operator_inserts_identity_row` → `replace_operator_updates_pubkey_and_starts_rekey`, assert count stays 1 and pubkey is updated --- server/clippy.toml | 2 + server/crates/arbiter-proto/src/lib.rs | 4 + .../crates/arbiter-server/src/actors/mod.rs | 10 +- .../src/actors/proposal_manager.rs | 2 +- .../arbiter-server/src/actors/vault/mod.rs | 53 ++++ .../src/actors/vault_coordinator/mod.rs | 263 +++++++++++++++--- .../src/grpc/operator/governance.rs | 18 +- .../arbiter-server/src/grpc/operator/vault.rs | 58 +++- .../src/grpc/operator/vault_gate/inbound.rs | 3 + .../src/peers/operator/session/handlers.rs | 43 +++ .../crates/arbiter-server/tests/governance.rs | 22 +- 11 files changed, 429 insertions(+), 49 deletions(-) diff --git a/server/clippy.toml b/server/clippy.toml index bed3c74..8fd6ebd 100644 --- a/server/clippy.toml +++ b/server/clippy.toml @@ -26,3 +26,5 @@ trait-assoc-item-kinds-order = [ "type", "fn", ] # community tested standard + +too-many-lines-threshold = 150 diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index 802285e..17f7582 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -38,6 +38,10 @@ pub mod proto { tonic::include_proto!("arbiter.operator.vault.bootstrap"); } + pub mod rekey { + tonic::include_proto!("arbiter.operator.vault.rekey"); + } + pub mod unseal { tonic::include_proto!("arbiter.operator.vault.unseal"); } diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index 3ed0d84..0412374 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -51,18 +51,20 @@ impl GlobalActors { let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())); + let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new( + db.clone(), + key_holder.clone(), + )); Ok(Self { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new( - db.clone(), - key_holder.clone(), - )), proposal_manager: ProposalManager::spawn(ProposalManager::new( db, key_holder.clone(), evm.clone(), + vault_coordinator.clone(), )), vault: key_holder, + vault_coordinator, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( operator_registry.clone(), )), diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 5ed4e3d..a4e655e 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -60,7 +60,7 @@ pub enum ProposalKind { } impl ProposalKind { - pub fn tag(&self) -> ProposalKindTag { + pub const fn tag(&self) -> ProposalKindTag { match self { Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient, Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess, diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index e29cb24..ad9d721 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -275,6 +275,59 @@ impl Vault { Ok(()) } + /// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row. + /// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal. + #[message] + pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> { + let Unsealed { + root_key, + root_key_history_id, + } = Self::expect_unsealed(&mut self.state)?; + + let new_nonce = Nonce::default(); + let new_salt = v1::generate_salt(); + + let new_ciphertext: Vec = root_key.0.read_inline(|rk| { + new_seal_key + .encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice()) + .map_err(|err| { + error!(?err, "Fatal rekey error"); + Error::Encryption(err) + }) + })?; + + let data_encryption_nonce = Nonce::default(); + + let mut conn = self.db.get().await?; + let new_root_key_history_id: i32 = conn + .transaction(async |conn| { + let new_id = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: new_ciphertext, + tag: v1::ROOT_KEY_TAG.to_vec(), + root_key_encryption_nonce: new_nonce.to_vec(), + data_encryption_nonce: data_encryption_nonce.to_vec(), + schema_version: 1, + salt: new_salt.to_vec(), + }) + .returning(schema::root_key_history::id) + .get_result::(&mut *conn) + .await?; + + update(schema::arbiter_settings::table) + .set(schema::arbiter_settings::root_key_id.eq(new_id)) + .execute(&mut *conn) + .await?; + + Result::<_, diesel::result::Error>::Ok(new_id) + }) + .await?; + + *root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id); + info!("Vault root key rekeyed successfully"); + Ok(()) + } + #[message] pub async fn seal(&mut self) -> Result<(), Error> { let Unsealed { diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index cac741c..16d3297 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -8,7 +8,7 @@ use rand_core::{OsRng, RngCore as _}; use tracing::error; use crate::{ - actors::vault::{Bootstrap, TryUnseal, Vault}, + actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault}, crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold}, db::{self, models, schema}, }; @@ -19,6 +19,8 @@ pub enum Error { AlreadyBootstrapping, #[error("Already coordinating an unseal")] AlreadyUnsealing, + #[error("Rekey not in progress")] + NotRekeying, #[error("Bootstrap not in progress")] NotBootstrapping, #[error("Unseal not in progress")] @@ -60,6 +62,15 @@ enum CoordinatorState { ordinary_passphrases: HashMap>, recovery_passphrases: HashMap>, }, + /// Shamir re-key after `replace_operator` or `update_shamir_parameters` is approved (§3.3). + /// Collects new passphrases from all current operators, then generates a fresh seal key, + /// re-splits it, and re-encrypts the vault root key. + Rekeying { + ordinary_count: usize, + recovery_count: usize, + passphrases: HashMap>, + recovery_passphrases: HashMap>, + }, } #[derive(Actor)] @@ -102,17 +113,17 @@ fn encrypt_share( fn decrypt_share( passphrase_bytes: Vec, encrypted_share: Vec, - share_nonce_bytes: Vec, - share_salt: Vec, + share_nonce_bytes: &[u8], + share_salt: &[u8], operator_id: i32, ) -> Result, Error> { - let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| { + let nonce = Nonce::try_from(share_nonce_bytes).map_err(|()| { error!(operator_id, "Invalid nonce in DB"); Error::BrokenDatabase })?; let mut passphrase_cell = SafeCell::new(passphrase_bytes); - let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt); + let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt); let mut share_buffer = SafeCell::new(encrypted_share); share_seal_key @@ -123,8 +134,8 @@ fn decrypt_share( } /// §3.4: Split the seal key across ordinary + recovery operators. -/// Threshold = shamir_threshold(ordinary_count); total shares = ordinary + recovery. -/// When ordinary_count == 1 (threshold = 1), vsss-rs does not support a proper split, +/// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery. +/// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split, /// so each share is the seal key itself — any single participant can reconstruct. async fn finalize_bootstrap( db: db::DatabasePool, @@ -146,7 +157,7 @@ async fn finalize_bootstrap( shamir::split_key(threshold, total, &seal_key_bytes, OsRng) .map_err(|e| Error::Shamir(e.to_string()))? } else { - (0..total).map(|_| seal_key_bytes.to_vec()).collect() + std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect() }; let seal_key = KeyCell::from(seal_key_bytes); @@ -155,9 +166,10 @@ async fn finalize_bootstrap( let mut shares_iter = shares.into_iter(); for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { - let share = shares_iter.next().expect("split_key returned enough shares"); - let (encrypted_share, nonce_bytes, share_salt) = - encrypt_share(passphrase_bytes, &share)?; + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; diesel::replace_into(schema::operator::table) .values(( @@ -173,9 +185,10 @@ async fn finalize_bootstrap( } for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { - let share = shares_iter.next().expect("split_key returned enough shares"); - let (encrypted_share, nonce_bytes, share_salt) = - encrypt_share(passphrase_bytes, &share)?; + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; diesel::replace_into(schema::recovery_operator::table) .values(( @@ -190,13 +203,10 @@ async fn finalize_bootstrap( .await?; } - vault - .ask(Bootstrap { seal_key }) - .await - .map_err(|err| { - error!(?err, "Vault bootstrap failed"); - Error::VaultError - })?; + vault.ask(Bootstrap { seal_key }).await.map_err(|err| { + error!(?err, "Vault bootstrap failed"); + Error::VaultError + })?; Ok(()) } @@ -235,8 +245,8 @@ async fn finalize_unseal( shares.push(decrypt_share( passphrase_bytes, encrypted_share, - share_nonce_bytes, - share_salt, + &share_nonce_bytes, + &share_salt, operator_id_raw, )?); } @@ -257,8 +267,8 @@ async fn finalize_unseal( shares.push(decrypt_share( passphrase_bytes, encrypted_share, - share_nonce_bytes, - share_salt, + &share_nonce_bytes, + &share_salt, recovery_id_raw, )?); } @@ -266,19 +276,100 @@ async fn finalize_unseal( // When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N). // Any single decrypted share is the key itself. let seal_key_bytes: [u8; 32] = if threshold <= 1 { - let raw = shares.into_iter().next().ok_or_else(|| Error::Shamir("No shares available".into()))?; - raw.try_into().map_err(|_| Error::Shamir("Invalid share length".into()))? + let raw = shares + .into_iter() + .next() + .ok_or_else(|| Error::Shamir("No shares available".into()))?; + raw.try_into() + .map_err(|_| Error::Shamir("Invalid share length".into()))? } else { shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))? }; let seal_key = KeyCell::from(seal_key_bytes); + vault.ask(TryUnseal { seal_key }).await.map_err(|err| { + error!(?err, "Vault unseal failed"); + Error::VaultError + })?; + + Ok(()) +} + +/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key. +/// Called after `replace_operator` or `update_shamir_parameters` is approved and all contributors submit. +async fn finalize_rekey( + db: db::DatabasePool, + vault: ActorRef, + ordinary_passphrases: HashMap>, + recovery_passphrases: HashMap>, +) -> Result<(), Error> { + let ordinary_count = ordinary_passphrases.len(); + let recovery_count = recovery_passphrases.len(); + let total = ordinary_count + recovery_count; + let threshold = shamir_threshold(ordinary_count); + + let mut new_seal_key_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut new_seal_key_bytes); + + let shares: Vec> = if threshold >= 2 { + shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng) + .map_err(|e| Error::Shamir(e.to_string()))? + } else { + std::iter::repeat_with(|| new_seal_key_bytes.to_vec()) + .take(total) + .collect() + }; + + let mut conn = db.get().await?; + let mut shares_iter = shares.into_iter(); + + for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(operator_id_raw)), + schema::operator::share.eq(&encrypted_share), + schema::operator::share_nonce.eq(&nonce_bytes), + schema::operator::share_salt.eq(&share_salt), + schema::operator::created_at.eq(models::SqliteTimestamp::now()), + schema::operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { + let share = shares_iter + .next() + .expect("split_key returned enough shares"); + let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?; + + diesel::replace_into(schema::recovery_operator::table) + .values(( + schema::recovery_operator::id.eq(recovery_id_raw), + schema::recovery_operator::share.eq(&encrypted_share), + schema::recovery_operator::share_nonce.eq(&nonce_bytes), + schema::recovery_operator::share_salt.eq(&share_salt), + schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()), + schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()), + )) + .execute(&mut conn) + .await?; + } + + drop(conn); + + let new_seal_key = KeyCell::from(new_seal_key_bytes); vault - .ask(TryUnseal { seal_key }) + .ask(RekeyRootKey { new_seal_key }) .await .map_err(|err| { - error!(?err, "Vault unseal failed"); + error!(?err, "Vault rekey failed"); Error::VaultError })?; @@ -486,8 +577,7 @@ impl VaultCoordinator { .count() .get_result(&mut conn) .await?; - let threshold = - shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()); + let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()); self.state = CoordinatorState::Unsealing { threshold, ordinary_passphrases: HashMap::new(), @@ -517,4 +607,115 @@ impl VaultCoordinator { .await?; Ok(true) } + + async fn do_finalize_rekey(&mut self) -> Result { + let CoordinatorState::Rekeying { + passphrases, + recovery_passphrases, + .. + } = std::mem::replace(&mut self.state, CoordinatorState::Idle) + else { + unreachable!() + }; + + finalize_rekey( + self.db.clone(), + self.vault.clone(), + passphrases, + recovery_passphrases, + ) + .await?; + Ok(true) + } +} + +#[messages] +impl VaultCoordinator { + /// Begin Shamir re-key after a key-rotation proposal is approved (§3.3). + /// Queries the current operator and recovery operator counts from the DB, + /// then transitions to Rekeying state awaiting contributions from all of them. + #[message] + pub async fn start_rekey(&mut self) -> Result<(), Error> { + if !matches!(self.state, CoordinatorState::Idle) { + return Err(Error::AlreadyBootstrapping); + } + let mut conn = self.db.get().await?; + let ordinary_count: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await?; + let recovery_count: i64 = schema::recovery_operator_identity::table + .count() + .get_result(&mut conn) + .await?; + self.state = CoordinatorState::Rekeying { + ordinary_count: ordinary_count as usize, + recovery_count: recovery_count as usize, + passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + Ok(()) + } + + /// Contribute an ordinary operator passphrase for the re-key. + /// Returns Ok(true) when all contributors have submitted and the re-key is complete. + #[message] + pub async fn contribute_rekey( + &mut self, + operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Rekeying { + ordinary_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotRekeying); + }; + + if passphrases.contains_key(&operator_id) { + return Err(Error::DuplicateContribution); + } + + passphrases.insert(operator_id, passphrase.read().to_vec()); + + if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + self.do_finalize_rekey().await + } + + /// Contribute a recovery operator passphrase for the re-key. + /// Returns Ok(true) when all contributors have submitted and the re-key is complete. + #[message] + pub async fn contribute_recovery_rekey( + &mut self, + recovery_operator_id: i32, + mut passphrase: SafeCell>, + ) -> Result { + let CoordinatorState::Rekeying { + ordinary_count, + recovery_count, + passphrases, + recovery_passphrases, + } = &mut self.state + else { + return Err(Error::NotRekeying); + }; + + if recovery_passphrases.contains_key(&recovery_operator_id) { + return Err(Error::DuplicateContribution); + } + + recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec()); + + if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count { + return Ok(false); + } + + self.do_finalize_rekey().await + } } diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index df28e0a..7b8cc48 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -54,20 +54,28 @@ async fn handle_create( }, Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { - new_pubkey: p.new_pubkey.try_into() - .map_err(|_| Status::invalid_argument("replace_operator: pubkey must be 32 bytes"))?, + old_operator_id: p.old_operator_id, + new_pubkey: p.new_pubkey, }, Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters { - #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "new_n is always a small operator count")] + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "new_n is always a small operator count" + )] new_n: p.new_n as u8, }, Some(ProtoKind::ApprovePersistentGrant(p)) => { use prost::Message as _; - ProposalKind::ApprovePersistentGrant { payload_bytes: p.encode_to_vec() } + ProposalKind::ApprovePersistentGrant { + payload_bytes: p.encode_to_vec(), + } } Some(ProtoKind::ApproveOneOffTransaction(p)) => { use prost::Message as _; - ProposalKind::ApproveOneOffTransaction { payload_bytes: p.encode_to_vec() } + ProposalKind::ApproveOneOffTransaction { + payload_bytes: p.encode_to_vec(), + } } None => return Err(Status::invalid_argument("Missing proposal kind")), }; diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index 5dc7820..793e254 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -1,12 +1,20 @@ use crate::{ actors::vault::VaultState, - peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, + peers::operator::{ + OperatorSession, + session::handlers::{ + HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase, + HandleQueryVaultState, + }, + }, }; use arbiter_proto::{ proto::operator::{ operator_response::Payload as OperatorResponsePayload, vault::{ - self as proto_vault, request::Payload as VaultRequestPayload, + self as proto_vault, + rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult}, + request::Payload as VaultRequestPayload, response::Payload as VaultResponsePayload, }, }, @@ -33,6 +41,7 @@ pub(super) async fn dispatch( match payload { VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await, + VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await, VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { Err(Status::permission_denied( "Vault is already unsealed; unseal/bootstrap not permitted in session", @@ -41,6 +50,51 @@ pub(super) async fn dispatch( } } +async fn handle_rekey( + actor: &ActorRef, + req: proto_rekey::Request, +) -> Result, Status> { + use arbiter_proto::proto::operator::vault::rekey::request::Payload as RekeyPayload; + + let payload = req + .payload + .ok_or_else(|| Status::invalid_argument("Missing rekey payload"))?; + + let done: bool = match payload { + RekeyPayload::ContributePassphrase(cp) => actor + .ask(HandleContributeRekeyPassphrase { + passphrase: cp.passphrase, + }) + .await + .map_err(|e| { + warn!(?e, "rekey passphrase contribution failed"); + Status::internal("Rekey contribution failed") + })?, + RekeyPayload::ContributeRecoveryPassphrase(crp) => actor + .ask(HandleContributeRecoveryRekeyPassphrase { + recovery_operator_id: crp.recovery_operator_id, + passphrase: crp.passphrase, + }) + .await + .map_err(|e| { + warn!(?e, "rekey recovery passphrase contribution failed"); + Status::internal("Rekey recovery contribution failed") + })?, + }; + + let proto_result = if done { + ProtoRekeyResult::Success + } else { + ProtoRekeyResult::AwaitingContributions + }; + + Ok(Some(wrap_vault_response(VaultResponsePayload::Rekey( + proto_rekey::Response { + result: proto_result.into(), + }, + )))) +} + async fn handle_query_vault_state( actor: &ActorRef, ) -> Result, Status> { diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index 01a1a10..6e90a9c 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -53,6 +53,9 @@ impl TryConvert for VaultRequestPayload { Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState), Self::Unseal(req) => req.try_convert(), Self::Bootstrap(req) => req.try_convert(), + Self::Rekey(_) => Err(Status::permission_denied( + "Rekey requires an authenticated session", + )), } } } 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 426561d..eb60f49 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -335,3 +335,46 @@ impl OperatorSession { .unwrap_or_default() } } + +#[messages] +impl OperatorSession { + #[message] + pub(crate) async fn handle_contribute_rekey_passphrase( + &mut self, + passphrase: Vec, + ) -> Result { + use crate::actors::vault_coordinator::ContributeRekey; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; + + let operator_id = self.credentials.id; + self.props + .actors + .vault_coordinator + .ask(ContributeRekey { + operator_id, + passphrase: SafeCell::new(passphrase), + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } + + #[message] + pub(crate) async fn handle_contribute_recovery_rekey_passphrase( + &mut self, + recovery_operator_id: i32, + passphrase: Vec, + ) -> Result { + use crate::actors::vault_coordinator::ContributeRecoveryRekey; + use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; + + self.props + .actors + .vault_coordinator + .ask(ContributeRecoveryRekey { + recovery_operator_id, + passphrase: SafeCell::new(passphrase), + }) + .await + .map_err(|_| Error::internal("VaultCoordinator unavailable")) + } +} diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index f0eca81..e7e0c28 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -733,7 +733,7 @@ async fn approve_one_off_transaction_stores_result() { } #[tokio::test] -async fn replace_operator_inserts_identity_row() { +async fn replace_operator_updates_pubkey_and_starts_rekey() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); actors @@ -751,7 +751,7 @@ async fn replace_operator_inserts_identity_row() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { new_pubkey }, + kind: ProposalKind::ReplaceOperator { old_operator_id: op_id, new_pubkey: new_pubkey.clone() }, initiator_id: op_id, ttl_secs: None, }) @@ -774,12 +774,22 @@ async fn replace_operator_inserts_identity_row() { assert_eq!(outcome, VoteOutcome::QuorumApproved); let mut conn = db.get().await.unwrap(); + // The old identity row is updated in-place; count stays the same. let count: i64 = operator_identity::table .count() .get_result(&mut conn) .await .unwrap(); - assert_eq!(count, 2); // original + new + assert_eq!(count, 1); + + // Verify the public key was updated to the new one. + let stored_pubkey: Vec = operator_identity::table + .filter(operator_identity::id.eq(op_id)) + .select(operator_identity::public_key) + .first(&mut conn) + .await + .unwrap(); + assert_eq!(stored_pubkey, new_pubkey.clone()); } #[tokio::test] @@ -843,7 +853,7 @@ async fn key_rotation_requires_full_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { new_pubkey }, + kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, initiator_id: op1, ttl_secs: None, }) @@ -925,7 +935,7 @@ async fn recovery_vote_rejected_when_sleeping() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { new_pubkey }, + kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, initiator_id: op_id, ttl_secs: None, }) @@ -1072,7 +1082,7 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { new_pubkey }, + kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, initiator_id: op_id, ttl_secs: None, }) -- 2.49.1 From 957c5096df00fc84b1e7bd760278fdf75590eaca Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 24 Aug 2026 15:03:10 +0200 Subject: [PATCH 31/66] chore(deps): bump kameo version --- server/Cargo.lock | 25 ++++++++++++++++++------- server/Cargo.toml | 4 ++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 0929efa..63b4afa 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -3028,8 +3028,8 @@ dependencies = [ [[package]] name = "kameo" -version = "0.20.0" -source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" +version = "0.22.2" +source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7" dependencies = [ "downcast-rs", "dyn-clone", @@ -3042,8 +3042,8 @@ dependencies = [ [[package]] name = "kameo_actors" -version = "0.5.0" -source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" +version = "0.8.1" +source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7" dependencies = [ "futures", "glob", @@ -3054,13 +3054,13 @@ dependencies = [ [[package]] name = "kameo_macros" -version = "0.20.0" -source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" +version = "0.21.1" +source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -5098,6 +5098,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-solidity" version = "1.5.7" diff --git a/server/Cargo.toml b/server/Cargo.toml index 1fcd8bd..c9c6ff9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,8 +12,8 @@ base64 = "0.22.1" chrono = { version = "0.4.44", features = ["serde"] } futures = "0.3.32" k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } -kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"} -kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"} +kameo = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"} +kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"} hmac = "0.13.0" miette = { version = "7.6.0", features = ["fancy", "serde"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] } -- 2.49.1 From f03997ea5663ed89f2ed88f3d579b44de47c0958 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 25 Aug 2026 20:35:45 +0200 Subject: [PATCH 32/66] chore(lints): fix regression caused by rust 1.98.0 --- server/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/server/Cargo.toml b/server/Cargo.toml index c9c6ff9..d270e3e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -167,3 +167,4 @@ nursery = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way +unused_async_trait_impl = "allow" -- 2.49.1 From f13c1cf9d1379f7742a01bd884eeb3b736f296f1 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 25 Aug 2026 20:36:19 +0200 Subject: [PATCH 33/66] fix(db): require share_salt to be supplied by the daemon --- .../migrations/2026-02-14-171124-0000_init/up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 0168767..1bd40c3 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -56,7 +56,7 @@ create table if not exists operator ( share blob not null, share_nonce blob not null, - share_salt blob not null default (randomblob(32)), + share_salt blob not null, created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) -- 2.49.1 From f881102f0a1679c1b7e428d4d84bd6b1d4e8a381 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 12:49:11 +0200 Subject: [PATCH 34/66] refactor(proposal): drop the expired status, enforce expiry on every vote --- .../2026-02-14-171124-0000_init/up.sql | 2 +- .../src/actors/proposal_manager.rs | 57 ++++--------------- server/crates/arbiter-server/src/db/models.rs | 3 - .../crates/arbiter-server/tests/governance.rs | 32 +++++++---- 4 files changed, 35 insertions(+), 59 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 1bd40c3..60244bd 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -225,7 +225,7 @@ create table if not exists proposal ( created_at integer not null default(unixepoch('now')), expires_at integer not null, status text not null default 'pending' - check (status in ('pending', 'approved', 'rejected', 'expired')) + check (status in ('pending', 'approved', 'rejected')) ) STRICT; create table if not exists proposal_vote ( diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index a4e655e..21e69f9 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -16,7 +16,7 @@ use crate::{ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; -use kameo::{actor::ActorRef, messages}; +use kameo::{Actor, actor::ActorRef, messages}; use strum::{Display, EnumString, IntoStaticStr}; use tracing::{error, warn}; @@ -184,6 +184,8 @@ pub enum Error { ProposalNotFound, #[error("Proposal is not pending")] ProposalNotPending, + #[error("Proposal has expired")] + ProposalExpired, #[error("Operator already voted on this proposal")] AlreadyVoted, #[error("Invalid vote signature")] @@ -216,6 +218,7 @@ pub struct ProposalSummary { pub reject_count: i64, } +#[derive(Actor)] pub struct ProposalManager { pub(crate) db: db::DatabasePool, pub(crate) vault: ActorRef, @@ -239,27 +242,6 @@ impl ProposalManager { } } -impl kameo::Actor for ProposalManager { - type Args = Self; - type Error = (); - - async fn on_start(args: Self::Args, actor_ref: ActorRef) -> Result { - let weak = actor_ref.downgrade(); - tokio::spawn(async move { - loop { - tokio::time::sleep(tokio::time::Duration::from_hours(1)).await; - match weak.upgrade() { - Some(r) => { - let _ = r.ask(ExpireStale).await; - } - None => break, - } - } - }); - Ok(args) - } -} - #[messages] impl ProposalManager { #[message] @@ -346,29 +328,6 @@ impl ProposalManager { summaries } - #[message] - pub async fn expire_stale(&mut self) -> usize { - #[expect( - clippy::cast_possible_truncation, - clippy::as_conversions, - reason = "fixme! #84; this will break in 2038" - )] - let now_ts = Utc::now().timestamp() as i32; - - let Ok(mut conn) = self.db.get().await else { - warn!("expire_stale: failed to acquire DB connection"); - return 0; - }; - - diesel::update(schema::proposal::table) - .filter(schema::proposal::status.eq(ProposalStatus::Pending)) - .filter(schema::proposal::expires_at.lt(now_ts)) - .set(schema::proposal::status.eq(ProposalStatus::Expired)) - .execute(&mut conn) - .await - .unwrap_or(0) - } - #[message] pub async fn cast_vote( &mut self, @@ -406,6 +365,10 @@ impl ProposalManager { return Err(Error::ProposalNotPending); } + if proposal.expires_at.0 <= Utc::now() { + return Err(Error::ProposalExpired); + } + // Load operator public key from operator_identity let pubkey_bytes: Vec = schema::operator_identity::table .find(operator_id) @@ -609,6 +572,10 @@ impl ProposalManager { return Err(Error::ProposalNotPending); } + if proposal.expires_at.0 <= Utc::now() { + return Err(Error::ProposalExpired); + } + let pubkey_bytes: Vec = schema::recovery_operator_identity::table .find(recovery_operator_id) .select(schema::recovery_operator_identity::public_key) diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 11a9919..c0c466b 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -149,7 +149,6 @@ pub mod types { Pending, Approved, Rejected, - Expired, } impl ToSql for ProposalStatus { @@ -161,7 +160,6 @@ pub mod types { Self::Pending => "pending", Self::Approved => "approved", Self::Rejected => "rejected", - Self::Expired => "expired", }; >::to_sql(s, out) } @@ -176,7 +174,6 @@ pub mod types { "pending" => Ok(Self::Pending), "approved" => Ok(Self::Approved), "rejected" => Ok(Self::Rejected), - "expired" => Ok(Self::Expired), other => Err(format!("Unknown proposal status: {other}").into()), } } diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index e7e0c28..6f6c68e 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, ExpireStale, ProposalKind, QueryPending, + Error as ProposalError, ProposalKind, QueryPending, RequestRecoveryWakeup, VoteOutcome, }, }, @@ -382,7 +382,7 @@ async fn query_pending_excludes_already_voted() { } #[tokio::test] -async fn expire_stale_marks_old_proposals_expired() { +async fn expired_proposal_is_hidden_and_unvotable() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); actors @@ -398,7 +398,7 @@ async fn expire_stale_marks_old_proposals_expired() { let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; // Create proposal with ttl_secs = -1 so it's immediately expired - let _proposal_id = actors + let proposal_id = actors .proposal_manager .ask(CreateProposal { kind: ProposalKind::ApproveSdkClient { client_id }, @@ -408,19 +408,31 @@ async fn expire_stale_marks_old_proposals_expired() { .await .unwrap(); - let expired = actors - .proposal_manager - .ask(ExpireStale) - .await - .unwrap(); - assert_eq!(expired, 1); - + // The row keeps status 'pending' (nothing sweeps it), but reads must skip it. let pending = actors .proposal_manager .ask(QueryPending { operator_id: op }) .await .unwrap(); assert!(pending.is_empty()); + + // And the write path must refuse it rather than rely on a status flip. + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let result = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op, + approve: true, + signature: sig.to_bytes(), + }) + .await; + + assert!(matches!( + result, + Err(kameo::error::SendError::HandlerError(ProposalError::ProposalExpired)) + )); } #[tokio::test] -- 2.49.1 From a501283b0cdcaf3c6530efe35cf2143787746628 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 13:06:59 +0200 Subject: [PATCH 35/66] refactor(crypto): replace byte-slice signing contexts with `SigningContext` enum --- server/Cargo.lock | 1 + server/Cargo.toml | 1 + server/crates/arbiter-client/src/auth.rs | 4 +- server/crates/arbiter-crypto/Cargo.toml | 3 +- server/crates/arbiter-crypto/src/authn/v1.rs | 64 +++++++++++----- server/crates/arbiter-server/Cargo.toml | 2 +- .../src/actors/proposal_manager.rs | 8 +- .../arbiter-server/src/peers/client/auth.rs | 4 +- .../src/peers/operator/auth/state.rs | 4 +- .../arbiter-server/tests/client/auth.rs | 4 +- .../crates/arbiter-server/tests/governance.rs | 74 ++++++++++++++----- .../arbiter-server/tests/operator/auth.rs | 4 +- 12 files changed, 121 insertions(+), 52 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 63b4afa..b8ee5e4 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -707,6 +707,7 @@ dependencies = [ "memsafe", "ml-dsa", "rand 0.10.1", + "strum 0.28.0", "thiserror", "x-wing", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index d270e3e..0cadbce 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -27,6 +27,7 @@ rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post rustls-pki-types = "1.14.1" sha2 = "0.11" smlang = "0.8.0" +strum = { version = "0.28.0", features = ["derive"] } thiserror = "2.0.18" tokio = { version = "1.52.1", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] } diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index eae51e9..6829302 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -2,7 +2,7 @@ use crate::{ storage::StorageError, transport::{ClientTransport, next_request_id}, }; -use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey}; +use arbiter_crypto::authn::{self, SigningContext, SigningKey}; use arbiter_proto::{ ClientMetadata, proto::{ @@ -110,7 +110,7 @@ async fn send_auth_challenge_solution( }; let challenge_payload: Vec = challenge.format(); let signature = key - .sign_message(&challenge_payload, CLIENT_CONTEXT) + .sign_message(&challenge_payload, SigningContext::Client) .map_err(|_| AuthError::UnexpectedAuthResponse)? .to_bytes(); diff --git a/server/crates/arbiter-crypto/Cargo.toml b/server/crates/arbiter-crypto/Cargo.toml index 3cbe8ec..2238670 100644 --- a/server/crates/arbiter-crypto/Cargo.toml +++ b/server/crates/arbiter-crypto/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" ml-dsa = {workspace = true, optional = true } rand = {workspace = true, optional = true} memsafe = {version = "0.4.0", optional = true} +strum = { workspace = true, optional = true } hmac.workspace = true alloy.workspace = true x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] } @@ -18,7 +19,7 @@ workspace = true [features] default = ["authn", "safecell"] -authn = ["dep:ml-dsa", "dep:rand"] +authn = ["dep:ml-dsa", "dep:rand", "dep:strum"] safecell = ["dep:memsafe"] [lib] diff --git a/server/crates/arbiter-crypto/src/authn/v1.rs b/server/crates/arbiter-crypto/src/authn/v1.rs index 6f86936..5e147e8 100644 --- a/server/crates/arbiter-crypto/src/authn/v1.rs +++ b/server/crates/arbiter-crypto/src/authn/v1.rs @@ -5,10 +5,25 @@ use ml_dsa::{ SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _, }; use rand::RngExt; +use strum::IntoStaticStr; -pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; -pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; -pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote"; +/// Domain separation tag mixed into every ML-DSA signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] +pub enum SigningContext { + #[strum(serialize = "arbiter_client")] + Client, + #[strum(serialize = "arbiter_operator")] + Operator, + #[strum(serialize = "arbiter_governance_vote")] + GovernanceVote, +} + +impl SigningContext { + #[must_use] + pub fn as_bytes(self) -> &'static [u8] { + <&'static str>::from(self).as_bytes() + } +} const NONCE_SIZE: usize = 32; @@ -86,15 +101,26 @@ impl PublicKey { } #[must_use] - pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool { + pub fn verify( + &self, + challenge: &AuthChallenge, + context: SigningContext, + signature: &Signature, + ) -> bool { let challenge = challenge.format(); self.0 - .verify_with_context(&challenge, context, &signature.0) + .verify_with_context(&challenge, context.as_bytes(), &signature.0) } #[must_use] - pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool { - self.0.verify_with_context(message, context, &signature.0) + pub fn verify_message( + &self, + message: &[u8], + context: SigningContext, + signature: &Signature, + ) -> bool { + self.0 + .verify_with_context(message, context.as_bytes(), &signature.0) } } @@ -121,17 +147,21 @@ impl SigningKey { self.0.verifying_key().into() } - pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result { + pub fn sign_message( + &self, + message: &[u8], + context: SigningContext, + ) -> Result { self.0 .signing_key() - .sign_deterministic(message, context) + .sign_deterministic(message, context.as_bytes()) .map(Into::into) } pub fn sign_challenge( &self, challenge: &AuthChallenge, - context: &[u8], + context: SigningContext, ) -> Result { let challenge = challenge.format(); @@ -198,7 +228,7 @@ mod tests { use crate::authn::AuthChallenge; - use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT}; + use super::{PublicKey, Signature, SigningContext, SigningKey}; #[test] fn public_key_round_trip_decodes() { @@ -214,7 +244,7 @@ mod tests { fn signature_round_trip_decodes() { let key = SigningKey::generate(); let signature = key - .sign_message(b"challenge", CLIENT_CONTEXT) + .sign_message(b"challenge", SigningContext::Client) .expect("signature should be created"); let decoded = @@ -229,11 +259,11 @@ mod tests { let public_key = key.public_key(); let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = key - .sign_challenge(&challenge, CLIENT_CONTEXT) + .sign_challenge(&challenge, SigningContext::Client) .expect("signature should be created"); - assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); - assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature)); + assert!(public_key.verify(&challenge, SigningContext::Client, &signature)); + assert!(!public_key.verify(&challenge, SigningContext::Operator, &signature)); } #[test] @@ -246,13 +276,13 @@ mod tests { let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = restored - .sign_challenge(&challenge, CLIENT_CONTEXT) + .sign_challenge(&challenge, SigningContext::Client) .expect("signature should be created"); assert!( restored .public_key() - .verify(&challenge, CLIENT_CONTEXT, &signature) + .verify(&challenge, SigningContext::Client, &signature) ); } } diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 8e4225f..ee58227 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -37,7 +37,7 @@ kameo.workspace = true chacha20poly1305 = { version = "0.10.1", features = ["std"] } argon2 = { version = "0.5.3", features = ["zeroize"] } restructed = "0.2.2" -strum = { version = "0.28.0", features = ["derive"] } +strum.workspace = true pem = "3.0.6" sha2.workspace = true hmac.workspace = true diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 21e69f9..d1b2d7b 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -336,7 +336,7 @@ impl ProposalManager { approve: bool, signature: Vec, ) -> Result { - use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; + use arbiter_crypto::authn::{self, SigningContext}; let mut conn = self.db.get().await?; @@ -391,7 +391,7 @@ impl ProposalManager { let auth_sig = authn::Signature::try_from(signature.as_slice()) .map_err(|()| Error::InvalidSignature)?; - if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) { + if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) { return Err(Error::InvalidSignature); } @@ -537,7 +537,7 @@ impl ProposalManager { approve: bool, signature: Vec, ) -> Result { - use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; + use arbiter_crypto::authn::{self, SigningContext}; let mut conn = self.db.get().await?; @@ -596,7 +596,7 @@ impl ProposalManager { let auth_sig = authn::Signature::try_from(signature.as_slice()) .map_err(|()| Error::InvalidSignature)?; - if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) { + if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) { return Err(Error::InvalidSignature); } diff --git a/server/crates/arbiter-server/src/peers/client/auth.rs b/server/crates/arbiter-server/src/peers/client/auth.rs index f488161..c27c2b1 100644 --- a/server/crates/arbiter-server/src/peers/client/auth.rs +++ b/server/crates/arbiter-server/src/peers/client/auth.rs @@ -12,7 +12,7 @@ use crate::{ schema::program_client, }, }; -use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, transport::{Bi, expect_message}, @@ -306,7 +306,7 @@ where Error::Transport })?; - if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) { + if !pubkey.verify(&challenge, SigningContext::Client, &signature) { error!("Challenge solution verification failed"); return Err(Error::InvalidChallengeSolution); } diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index a7c0ae7..ab74c3e 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -7,7 +7,7 @@ use crate::{ db::{DatabasePool, schema::operator_identity}, peers::operator::auth::Outbound, }; -use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; @@ -141,7 +141,7 @@ where Error::InvalidChallengeSolution })?; - let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature); + let valid = pubkey.verify(challenge, SigningContext::Operator, &signature); if !valid { self.transport diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index facc4e5..945e82b 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,5 +1,5 @@ use super::common::ChannelTransport; -use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, transport::{Receiver, Sender}, @@ -71,7 +71,7 @@ async fn insert_registered_client( fn sign_client_challenge(key: &SigningKey, challenge: &AuthChallenge) -> authn::Signature { let challenge = challenge.format(); key.signing_key() - .sign_deterministic(&challenge, CLIENT_CONTEXT) + .sign_deterministic(&challenge, SigningContext::Client.as_bytes()) .unwrap() .into() } diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 6f6c68e..75aac57 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -1,4 +1,4 @@ -use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; +use arbiter_crypto::authn::{self, SigningContext}; use arbiter_server::{ actors::{ GlobalActors, @@ -159,7 +159,9 @@ async fn single_operator_vote_reaches_quorum() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager @@ -203,7 +205,9 @@ async fn two_operator_first_vote_is_pending() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = key1.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = key1 + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager @@ -246,7 +250,9 @@ async fn duplicate_vote_rejected() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); actors .proposal_manager .ask(CastVote { @@ -259,7 +265,9 @@ async fn duplicate_vote_rejected() { .unwrap(); // Second vote same operator - let sig2 = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig2 = key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let result = actors .proposal_manager .ask(CastVote { @@ -357,7 +365,9 @@ async fn query_pending_excludes_already_voted() { // Vote on p1 — with 1 operator this reaches quorum (QuorumApproved) let msg = make_vote_message(p1, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -418,7 +428,9 @@ async fn expired_proposal_is_hidden_and_unvotable() { // And the write path must refuse it rather than rely on a status flip. let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let result = actors .proposal_manager .ask(CastVote { @@ -464,7 +476,9 @@ async fn approve_sdk_client_writes_integrity_envelope() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = op_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -516,7 +530,9 @@ async fn grant_wallet_access_on_quorum_approval() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -605,7 +621,9 @@ async fn approve_persistent_grant_creates_basic_grant_row() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -720,7 +738,9 @@ async fn approve_one_off_transaction_stores_result() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -771,7 +791,9 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -828,7 +850,9 @@ async fn update_shamir_parameters_reaches_quorum() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -874,7 +898,9 @@ async fn key_rotation_requires_full_quorum() { let cast = |op_id, key: &authn::SigningKey| { let actors = actors.clone(); - let sig = key.sign_message(&make_vote_message(proposal_id, true), GOVERNANCE_CONTEXT).unwrap(); + let sig = key + .sign_message(&make_vote_message(proposal_id, true), SigningContext::GovernanceVote) + .unwrap(); async move { actors .proposal_manager @@ -915,7 +941,9 @@ async fn approve_server_update_reaches_quorum() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -955,7 +983,9 @@ async fn recovery_vote_rejected_when_sleeping() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let err = actors .proposal_manager .ask(CastRecoveryVote { @@ -999,7 +1029,9 @@ async fn recovery_vote_blocked_on_non_replace_proposal() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let err = actors .proposal_manager .ask(CastRecoveryVote { @@ -1103,7 +1135,9 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { // Ordinary operator approves — still pending (needs recovery too) let msg = make_vote_message(proposal_id, true); - let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = op_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -1117,7 +1151,9 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { assert_eq!(outcome, VoteOutcome::Pending); // Recovery operator approves — now quorum is reached - let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastRecoveryVote { diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 76afc1a..5e12a9a 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -1,5 +1,5 @@ use super::common::ChannelTransport; -use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_server::{ actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, @@ -24,7 +24,7 @@ fn sign_operator_challenge( ) -> authn::Signature { let challenge = challenge.format(); key.signing_key() - .sign_deterministic(&challenge, OPERATOR_CONTEXT) + .sign_deterministic(&challenge, SigningContext::Operator.as_bytes()) .unwrap() .into() } -- 2.49.1 From 180f93c1a7cf4da8d041b895234b53a1c9a75e43 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 14:36:41 +0200 Subject: [PATCH 36/66] refactor(proposal): derive ProposalKindTag from ProposalKind with strum --- .../src/actors/proposal_manager.rs | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index d1b2d7b..8e0f173 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -17,24 +17,19 @@ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; use kameo::{Actor, actor::ActorRef, messages}; -use strum::{Display, EnumString, IntoStaticStr}; +use strum::{Display, EnumDiscriminants, EnumString, IntoDiscriminant as _, IntoStaticStr}; use tracing::{error, warn}; pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days -#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -pub enum ProposalKindTag { - ApproveSdkClient, - GrantWalletAccess, - ApproveServerUpdate, - ReplaceOperator, - UpdateShamirParameters, - ApprovePersistentGrant, - ApproveOneOffTransaction, -} - -#[derive(Debug, Clone)] +/// A governance proposal and the parameters it carries. +#[derive(Debug, Clone, EnumDiscriminants)] +#[strum_discriminants( + name(ProposalKindTag), + vis(pub), + derive(Display, EnumString, IntoStaticStr), + strum(serialize_all = "snake_case") +)] pub enum ProposalKind { ApproveSdkClient { client_id: i32, @@ -60,22 +55,6 @@ pub enum ProposalKind { } impl ProposalKind { - pub const fn tag(&self) -> ProposalKindTag { - match self { - Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient, - Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess, - Self::ApproveServerUpdate => ProposalKindTag::ApproveServerUpdate, - Self::ReplaceOperator { .. } => ProposalKindTag::ReplaceOperator, - Self::UpdateShamirParameters { .. } => ProposalKindTag::UpdateShamirParameters, - Self::ApprovePersistentGrant { .. } => ProposalKindTag::ApprovePersistentGrant, - Self::ApproveOneOffTransaction { .. } => ProposalKindTag::ApproveOneOffTransaction, - } - } - - pub fn kind_str(&self) -> &'static str { - self.tag().into() - } - pub fn encode_payload(&self) -> Vec { match self { Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), @@ -255,7 +234,7 @@ impl ProposalManager { let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl)); let new_proposal = NewProposal { - kind: kind.kind_str().to_owned(), + kind: <&'static str>::from(kind.discriminant()).to_owned(), payload: kind.encode_payload(), initiator_id, expires_at, -- 2.49.1 From aa884339f781649709bc899c5c5aeb35d23b68d5 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 14:53:38 +0200 Subject: [PATCH 37/66] refactor(proposal): remove the ApproveServerUpdate proposal kind --- protobufs/operator/governance.proto | 3 -- .../src/actors/proposal_manager.rs | 4 -- .../src/grpc/operator/governance.rs | 1 - .../crates/arbiter-server/tests/governance.rs | 41 ------------------- 4 files changed, 49 deletions(-) diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 132a69f..4ee4ba1 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -14,7 +14,6 @@ message CreateProposalRequest { oneof kind { ApproveSdkClientPayload approve_sdk_client = 1; GrantWalletAccessPayload grant_wallet_access = 3; - ApproveServerUpdatePayload approve_server_update = 4; ReplaceOperatorPayload replace_operator = 5; UpdateShamirParametersPayload update_shamir_parameters = 6; ApprovePersistentGrantPayload approve_persistent_grant = 7; @@ -32,8 +31,6 @@ message UpdateShamirParametersPayload { uint32 new_n = 1; } -message ApproveServerUpdatePayload {} - message ApproveSdkClientPayload { int32 client_id = 1; } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 8e0f173..fbfa5cb 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -38,7 +38,6 @@ pub enum ProposalKind { wallet_id: i32, client_id: i32, }, - ApproveServerUpdate, ReplaceOperator { old_operator_id: i32, new_pubkey: Vec, @@ -67,7 +66,6 @@ impl ProposalKind { buf.extend_from_slice(&client_id.to_be_bytes()); buf } - Self::ApproveServerUpdate => vec![], Self::ReplaceOperator { old_operator_id, new_pubkey, @@ -114,7 +112,6 @@ impl ProposalKind { client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), }) } - ProposalKindTag::ApproveServerUpdate => Ok(Self::ApproveServerUpdate), ProposalKindTag::ReplaceOperator => { let (id_bytes, rest) = payload .split_first_chunk::<4>() @@ -692,7 +689,6 @@ impl ProposalManager { wallet_id, client_id, } => self.execute_grant_wallet_access(wallet_id, client_id).await, - ProposalKind::ApproveServerUpdate => Ok(()), ProposalKind::ReplaceOperator { old_operator_id, new_pubkey, diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 7b8cc48..5346f88 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -52,7 +52,6 @@ async fn handle_create( wallet_id: p.wallet_id, client_id: p.client_id, }, - Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { old_operator_id: p.old_operator_id, new_pubkey: p.new_pubkey, diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 75aac57..9b69276 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -917,47 +917,6 @@ async fn key_rotation_requires_full_quorum() { assert_eq!(cast(op3, &key3).await, VoteOutcome::QuorumApproved); } -#[tokio::test] -async fn approve_server_update_reaches_quorum() { - 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 signing_key = authn::SigningKey::generate(); - let op_id = register_operator(&db, &signing_key.public_key()).await; - - let proposal_id = actors - .proposal_manager - .ask(CreateProposal { - kind: ProposalKind::ApproveServerUpdate, - initiator_id: op_id, - ttl_secs: None, - }) - .await - .unwrap(); - - let msg = make_vote_message(proposal_id, true); - let sig = signing_key - .sign_message(&msg, SigningContext::GovernanceVote) - .unwrap(); - let outcome = actors - .proposal_manager - .ask(CastVote { - proposal_id, - operator_id: op_id, - approve: true, - signature: sig.to_bytes(), - }) - .await - .unwrap(); - - assert_eq!(outcome, VoteOutcome::QuorumApproved); -} - // ─── §3.5 / §3.6 Recovery Operator tests ────────────────────────────────── #[tokio::test] -- 2.49.1 From d64478b3016126d12e77d9cb4a827b222042b198 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 15:26:17 +0200 Subject: [PATCH 38/66] refactor(proposal): replace UpdateShamirParameters with parameterless TriggerRekey --- protobufs/operator/governance.proto | 18 +++++++--------- .../src/actors/proposal_manager.rs | 21 ++++++------------- .../src/actors/vault_coordinator/mod.rs | 4 ++-- .../src/grpc/operator/governance.rs | 9 +------- .../crates/arbiter-server/tests/governance.rs | 4 ++-- 5 files changed, 19 insertions(+), 37 deletions(-) diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 4ee4ba1..589635b 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package arbiter.operator.governance; +import "google/protobuf/empty.proto"; + message Request { oneof payload { CreateProposalRequest create = 1; @@ -13,13 +15,13 @@ message Request { message CreateProposalRequest { oneof kind { ApproveSdkClientPayload approve_sdk_client = 1; - GrantWalletAccessPayload grant_wallet_access = 3; - ReplaceOperatorPayload replace_operator = 5; - UpdateShamirParametersPayload update_shamir_parameters = 6; - ApprovePersistentGrantPayload approve_persistent_grant = 7; - ApproveOneOffTransactionPayload approve_one_off_transaction = 8; + GrantWalletAccessPayload grant_wallet_access = 2; + ReplaceOperatorPayload replace_operator = 3; + google.protobuf.Empty trigger_rekey = 4; + ApprovePersistentGrantPayload approve_persistent_grant = 5; + ApproveOneOffTransactionPayload approve_one_off_transaction = 6; } - optional uint32 ttl_secs = 2; + optional uint32 ttl_secs = 7; } message ReplaceOperatorPayload { @@ -27,10 +29,6 @@ message ReplaceOperatorPayload { bytes new_pubkey = 2; } -message UpdateShamirParametersPayload { - uint32 new_n = 1; -} - message ApproveSdkClientPayload { int32 client_id = 1; } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index fbfa5cb..5e633fa 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -42,9 +42,7 @@ pub enum ProposalKind { old_operator_id: i32, new_pubkey: Vec, }, - UpdateShamirParameters { - new_n: u8, - }, + TriggerRekey, ApprovePersistentGrant { payload_bytes: Vec, }, @@ -77,7 +75,7 @@ impl ProposalKind { buf.extend_from_slice(new_pubkey); buf } - Self::UpdateShamirParameters { new_n } => vec![*new_n], + Self::TriggerRekey => vec![], Self::ApprovePersistentGrant { payload_bytes } | Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), } @@ -88,7 +86,7 @@ impl ProposalKind { pub fn requires_full_quorum(kind: &str) -> bool { matches!( kind.parse::(), - Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::UpdateShamirParameters) + Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::TriggerRekey) ) } @@ -131,12 +129,7 @@ impl ProposalKind { new_pubkey, }) } - ProposalKindTag::UpdateShamirParameters => { - let &[new_n] = payload else { - return Err("invalid payload for update_shamir_parameters".to_owned()); - }; - Ok(Self::UpdateShamirParameters { new_n }) - } + ProposalKindTag::TriggerRekey => Ok(Self::TriggerRekey), ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant { payload_bytes: payload.to_vec(), }), @@ -696,9 +689,7 @@ impl ProposalManager { self.execute_replace_operator(old_operator_id, new_pubkey) .await } - ProposalKind::UpdateShamirParameters { new_n } => { - self.execute_update_shamir_parameters(new_n).await - } + ProposalKind::TriggerRekey => self.execute_trigger_rekey().await, ProposalKind::ApprovePersistentGrant { payload_bytes } => { self.execute_approve_persistent_grant(payload_bytes).await } @@ -764,7 +755,7 @@ impl ProposalManager { } /// Triggers a Shamir re-key with the current operator set (§3.3). - async fn execute_update_shamir_parameters(&self, _new_n: u8) -> Result<(), Error> { + async fn execute_trigger_rekey(&self) -> Result<(), Error> { self.vault_coordinator .ask(StartRekey {}) .await diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 16d3297..1b723bd 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -62,7 +62,7 @@ enum CoordinatorState { ordinary_passphrases: HashMap>, recovery_passphrases: HashMap>, }, - /// Shamir re-key after `replace_operator` or `update_shamir_parameters` is approved (§3.3). + /// Shamir re-key after `replace_operator` or `trigger_rekey` is approved (§3.3). /// Collects new passphrases from all current operators, then generates a fresh seal key, /// re-splits it, and re-encrypts the vault root key. Rekeying { @@ -297,7 +297,7 @@ async fn finalize_unseal( } /// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key. -/// Called after `replace_operator` or `update_shamir_parameters` is approved and all contributors submit. +/// Called after `replace_operator` or `trigger_rekey` is approved and all contributors submit. async fn finalize_rekey( db: db::DatabasePool, vault: ActorRef, diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 5346f88..efd4b67 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -56,14 +56,7 @@ async fn handle_create( old_operator_id: p.old_operator_id, new_pubkey: p.new_pubkey, }, - Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters { - #[expect( - clippy::cast_possible_truncation, - clippy::as_conversions, - reason = "new_n is always a small operator count" - )] - new_n: p.new_n as u8, - }, + Some(ProtoKind::TriggerRekey(())) => ProposalKind::TriggerRekey, Some(ProtoKind::ApprovePersistentGrant(p)) => { use prost::Message as _; ProposalKind::ApprovePersistentGrant { diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 9b69276..6ab35b6 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -827,7 +827,7 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { } #[tokio::test] -async fn update_shamir_parameters_reaches_quorum() { +async fn trigger_rekey_reaches_quorum() { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); actors @@ -842,7 +842,7 @@ async fn update_shamir_parameters_reaches_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::UpdateShamirParameters { new_n: 5 }, + kind: ProposalKind::TriggerRekey, initiator_id: op_id, ttl_secs: None, }) -- 2.49.1 From b364d9548910d5eb84f402d22be352ac892c3cdf Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 15:30:48 +0200 Subject: [PATCH 39/66] refactor(proposal): drop the Quorum prefix from VoteOutcome variants --- .../src/actors/proposal_manager.rs | 12 +++++----- .../src/grpc/operator/governance.rs | 4 ++-- .../crates/arbiter-server/tests/governance.rs | 22 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 5e633fa..32ba289 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -143,8 +143,8 @@ impl ProposalKind { #[derive(Debug, Clone, PartialEq, Eq)] pub enum VoteOutcome { Pending, - QuorumApproved, - QuorumRejected, + Approved, + Rejected, } #[derive(Debug, thiserror::Error)] @@ -445,7 +445,7 @@ impl ProposalManager { .await?; drop(conn); // release connection before async execution self.execute_proposal(&proposal).await?; - return Ok(VoteOutcome::QuorumApproved); + return Ok(VoteOutcome::Approved); } let total_eligible = total_operators + total_recovery; @@ -454,7 +454,7 @@ impl ProposalManager { .set(schema::proposal::status.eq(ProposalStatus::Rejected)) .execute(&mut conn) .await?; - return Ok(VoteOutcome::QuorumRejected); + return Ok(VoteOutcome::Rejected); } Ok(VoteOutcome::Pending) @@ -611,7 +611,7 @@ impl ProposalManager { .await?; drop(conn); self.execute_proposal(&proposal).await?; - return Ok(VoteOutcome::QuorumApproved); + return Ok(VoteOutcome::Approved); } let recovery_reject: i64 = schema::recovery_proposal_vote::table @@ -633,7 +633,7 @@ impl ProposalManager { .set(schema::proposal::status.eq(ProposalStatus::Rejected)) .execute(&mut conn) .await?; - return Ok(VoteOutcome::QuorumRejected); + return Ok(VoteOutcome::Rejected); } Ok(VoteOutcome::Pending) diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index efd4b67..1053545 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -100,8 +100,8 @@ async fn handle_vote( let outcome = match result { Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending, - Ok(VoteOutcome::QuorumApproved) => ProtoVoteOutcome::Approved, - Ok(VoteOutcome::QuorumRejected) => ProtoVoteOutcome::Rejected, + Ok(VoteOutcome::Approved) => ProtoVoteOutcome::Approved, + Ok(VoteOutcome::Rejected) => ProtoVoteOutcome::Rejected, Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => { return Err(Status::invalid_argument("Already voted on this proposal")); } diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 6ab35b6..4bdd4d3 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -174,7 +174,7 @@ async fn single_operator_vote_reaches_quorum() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); } #[tokio::test] @@ -363,7 +363,7 @@ async fn query_pending_excludes_already_voted() { .await .unwrap(); - // Vote on p1 — with 1 operator this reaches quorum (QuorumApproved) + // Vote on p1 — with 1 operator this reaches quorum (Approved) let msg = make_vote_message(p1, true); let sig = signing_key .sign_message(&msg, SigningContext::GovernanceVote) @@ -378,7 +378,7 @@ async fn query_pending_excludes_already_voted() { }) .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); // QueryPending should return only p2 let pending = actors @@ -490,7 +490,7 @@ async fn approve_sdk_client_writes_integrity_envelope() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); let mut conn = db.get().await.unwrap(); let count: i64 = integrity_envelope::table @@ -544,7 +544,7 @@ async fn grant_wallet_access_on_quorum_approval() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); let mut conn = db.get().await.unwrap(); let count: i64 = evm_wallet_access::table @@ -635,7 +635,7 @@ async fn approve_persistent_grant_creates_basic_grant_row() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); let mut conn = db.get().await.unwrap(); let count: i64 = evm_basic_grant::table @@ -752,7 +752,7 @@ async fn approve_one_off_transaction_stores_result() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); let mut conn = db.get().await.unwrap(); let count: i64 = proposal_result::table @@ -805,7 +805,7 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); let mut conn = db.get().await.unwrap(); // The old identity row is updated in-place; count stays the same. @@ -864,7 +864,7 @@ async fn trigger_rekey_reaches_quorum() { .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); } #[tokio::test] @@ -914,7 +914,7 @@ async fn key_rotation_requires_full_quorum() { // For key rotation, they must not. assert_eq!(cast(op1, &key1).await, VoteOutcome::Pending); assert_eq!(cast(op2, &key2).await, VoteOutcome::Pending); - assert_eq!(cast(op3, &key3).await, VoteOutcome::QuorumApproved); + assert_eq!(cast(op3, &key3).await, VoteOutcome::Approved); } // ─── §3.5 / §3.6 Recovery Operator tests ────────────────────────────────── @@ -1123,5 +1123,5 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { }) .await .unwrap(); - assert_eq!(outcome, VoteOutcome::QuorumApproved); + assert_eq!(outcome, VoteOutcome::Approved); } -- 2.49.1 From 6884a5932585220b1a4222b78f14c0b2717de024 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 16:32:43 +0200 Subject: [PATCH 40/66] refactor(proposal): type ProposalSummary::kind as ProposalKindTag --- .../src/actors/proposal_manager.rs | 132 +--------------- server/crates/arbiter-server/src/db/models.rs | 144 +++++++++++++++++- .../src/grpc/operator/governance.rs | 5 +- .../src/peers/operator/session/handlers.rs | 2 +- .../crates/arbiter-server/tests/governance.rs | 5 +- 5 files changed, 152 insertions(+), 136 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 32ba289..af8a036 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -8,7 +8,7 @@ use crate::{ self, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, - Proposal, ProposalStatus, SqliteTimestamp, + Proposal, ProposalKind, ProposalKindTag, ProposalStatus, SqliteTimestamp, }, schema, }, @@ -17,129 +17,11 @@ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; use kameo::{Actor, actor::ActorRef, messages}; -use strum::{Display, EnumDiscriminants, EnumString, IntoDiscriminant as _, IntoStaticStr}; +use strum::IntoDiscriminant as _; use tracing::{error, warn}; pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days -/// A governance proposal and the parameters it carries. -#[derive(Debug, Clone, EnumDiscriminants)] -#[strum_discriminants( - name(ProposalKindTag), - vis(pub), - derive(Display, EnumString, IntoStaticStr), - strum(serialize_all = "snake_case") -)] -pub enum ProposalKind { - ApproveSdkClient { - client_id: i32, - }, - GrantWalletAccess { - wallet_id: i32, - client_id: i32, - }, - ReplaceOperator { - old_operator_id: i32, - new_pubkey: Vec, - }, - TriggerRekey, - ApprovePersistentGrant { - payload_bytes: Vec, - }, - ApproveOneOffTransaction { - payload_bytes: Vec, - }, -} - -impl ProposalKind { - pub fn encode_payload(&self) -> Vec { - match self { - Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), - Self::GrantWalletAccess { - wallet_id, - client_id, - } => { - let mut buf = Vec::with_capacity(8); - buf.extend_from_slice(&wallet_id.to_be_bytes()); - buf.extend_from_slice(&client_id.to_be_bytes()); - buf - } - Self::ReplaceOperator { - old_operator_id, - new_pubkey, - } => { - let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32"); - let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len()); - buf.extend_from_slice(&old_operator_id.to_be_bytes()); - buf.extend_from_slice(&len.to_be_bytes()); - buf.extend_from_slice(new_pubkey); - buf - } - Self::TriggerRekey => vec![], - Self::ApprovePersistentGrant { payload_bytes } - | Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), - } - } - - /// Key-rotation proposals require every operator to approve (§3.3). - #[must_use] - pub fn requires_full_quorum(kind: &str) -> bool { - matches!( - kind.parse::(), - Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::TriggerRekey) - ) - } - - pub fn decode(kind: &str, payload: &[u8]) -> Result { - let tag = kind - .parse::() - .map_err(|_| format!("unknown proposal kind: {kind}"))?; - match tag { - ProposalKindTag::ApproveSdkClient => { - let bytes = <[u8; 4]>::try_from(payload) - .map_err(|_| "invalid payload for approve_sdk_client".to_owned())?; - Ok(Self::ApproveSdkClient { - client_id: i32::from_be_bytes(bytes), - }) - } - ProposalKindTag::GrantWalletAccess => { - let bytes = <[u8; 8]>::try_from(payload) - .map_err(|_| "invalid payload for grant_wallet_access".to_owned())?; - Ok(Self::GrantWalletAccess { - wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()), - client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), - }) - } - ProposalKindTag::ReplaceOperator => { - let (id_bytes, rest) = payload - .split_first_chunk::<4>() - .ok_or_else(|| "replace_operator payload too short".to_owned())?; - let old_operator_id = i32::from_be_bytes(*id_bytes); - let (len_bytes, rest) = rest - .split_first_chunk::<4>() - .ok_or_else(|| "replace_operator payload too short".to_owned())?; - let len = u32::from_be_bytes(*len_bytes); - let len = usize::try_from(len).unwrap_or(usize::MAX); - let new_pubkey = rest - .get(..len) - .ok_or_else(|| "replace_operator payload truncated".to_owned())? - .to_vec(); - Ok(Self::ReplaceOperator { - old_operator_id, - new_pubkey, - }) - } - ProposalKindTag::TriggerRekey => Ok(Self::TriggerRekey), - ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant { - payload_bytes: payload.to_vec(), - }), - ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction { - payload_bytes: payload.to_vec(), - }), - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum VoteOutcome { Pending, @@ -180,7 +62,7 @@ pub enum Error { #[derive(Debug)] pub struct ProposalSummary { pub id: i32, - pub kind: String, + pub kind: ProposalKindTag, pub initiator_id: i32, pub expires_at: SqliteTimestamp, pub approve_count: i64, @@ -224,7 +106,7 @@ impl ProposalManager { let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl)); let new_proposal = NewProposal { - kind: <&'static str>::from(kind.discriminant()).to_owned(), + kind: kind.discriminant(), payload: kind.encode_payload(), initiator_id, expires_at, @@ -395,7 +277,7 @@ impl ProposalManager { clippy::as_conversions, reason = "operator count is always a small positive integer" )] - let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) { + let threshold = if proposal.kind.requires_full_quorum() { // §3.3: key-rotation proposals require every eligible voter to approve // §3.5: when recovery is active, recovery operators also vote on replace_operator (total_operators + total_recovery) as usize @@ -519,7 +401,7 @@ impl ProposalManager { other => Error::DatabaseQuery(other), })?; - if proposal.kind.parse::() != Ok(ProposalKindTag::ReplaceOperator) { + if proposal.kind != ProposalKindTag::ReplaceOperator { return Err(Error::NotAllowedForRecoveryOperator); } @@ -672,7 +554,7 @@ impl ProposalManager { } async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { - let kind = ProposalKind::decode(&proposal.kind, &proposal.payload) + let kind = ProposalKind::decode(proposal.kind, &proposal.payload) .map_err(Error::ExecutionFailed)?; match kind { ProposalKind::ApproveSdkClient { client_id } => { diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index c0c466b..d946e43 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -22,6 +22,7 @@ pub mod types { sql_types::{Integer, Text}, sqlite::{Sqlite, SqliteType}, }; + use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr}; #[derive(Debug, FromSqlRow, AsExpression, Clone)] #[diesel(sql_type = Integer)] @@ -166,9 +167,7 @@ pub mod types { } impl FromSql for ProposalStatus { - fn from_sql( - bytes: ::RawValue<'_>, - ) -> diesel::deserialize::Result { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { let s = >::from_sql(bytes)?; match s.as_str() { "pending" => Ok(Self::Pending), @@ -178,6 +177,141 @@ pub mod types { } } } + + /// A governance proposal and the parameters it carries. + #[derive(Debug, Clone, EnumDiscriminants)] + #[strum_discriminants( + name(ProposalKindTag), + vis(pub), + derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow), + diesel(sql_type = Text), + strum(serialize_all = "snake_case") + )] + pub enum ProposalKind { + ApproveSdkClient { + client_id: i32, + }, + GrantWalletAccess { + wallet_id: i32, + client_id: i32, + }, + ReplaceOperator { + old_operator_id: i32, + new_pubkey: Vec, + }, + TriggerRekey, + ApprovePersistentGrant { + payload_bytes: Vec, + }, + ApproveOneOffTransaction { + payload_bytes: Vec, + }, + } + + impl ProposalKind { + pub fn encode_payload(&self) -> Vec { + match self { + Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), + Self::GrantWalletAccess { + wallet_id, + client_id, + } => { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&wallet_id.to_be_bytes()); + buf.extend_from_slice(&client_id.to_be_bytes()); + buf + } + Self::ReplaceOperator { + old_operator_id, + new_pubkey, + } => { + let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32"); + let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len()); + buf.extend_from_slice(&old_operator_id.to_be_bytes()); + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(new_pubkey); + buf + } + Self::TriggerRekey => vec![], + Self::ApprovePersistentGrant { payload_bytes } + | Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), + } + } + + /// Key-rotation proposals require every operator to approve (§3.3). + pub fn decode(tag: ProposalKindTag, payload: &[u8]) -> Result { + match tag { + ProposalKindTag::ApproveSdkClient => { + let bytes = <[u8; 4]>::try_from(payload) + .map_err(|_| "invalid payload for approve_sdk_client".to_owned())?; + Ok(Self::ApproveSdkClient { + client_id: i32::from_be_bytes(bytes), + }) + } + ProposalKindTag::GrantWalletAccess => { + let bytes = <[u8; 8]>::try_from(payload) + .map_err(|_| "invalid payload for grant_wallet_access".to_owned())?; + Ok(Self::GrantWalletAccess { + wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()), + client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), + }) + } + ProposalKindTag::ReplaceOperator => { + let (id_bytes, rest) = payload + .split_first_chunk::<4>() + .ok_or_else(|| "replace_operator payload too short".to_owned())?; + let old_operator_id = i32::from_be_bytes(*id_bytes); + let (len_bytes, rest) = rest + .split_first_chunk::<4>() + .ok_or_else(|| "replace_operator payload too short".to_owned())?; + let len = u32::from_be_bytes(*len_bytes); + let len = usize::try_from(len).unwrap_or(usize::MAX); + let new_pubkey = rest + .get(..len) + .ok_or_else(|| "replace_operator payload truncated".to_owned())? + .to_vec(); + Ok(Self::ReplaceOperator { + old_operator_id, + new_pubkey, + }) + } + ProposalKindTag::TriggerRekey => Ok(Self::TriggerRekey), + ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant { + payload_bytes: payload.to_vec(), + }), + ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction { + payload_bytes: payload.to_vec(), + }), + } + } + } + + impl ProposalKindTag { + /// Key-rotation proposals require every operator to approve (§3.3). + #[must_use] + pub const fn requires_full_quorum(self) -> bool { + matches!(self, Self::ReplaceOperator | Self::TriggerRekey) + } + } + + impl ToSql for ProposalKindTag { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + >::to_sql(<&'static str>::from(*self), out) + } + } + + impl FromSql for ProposalKindTag { + fn from_sql( + bytes: ::RawValue<'_>, + ) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + s.parse() + .map_err(|_| format!("Unknown proposal kind: {s}").into()) + } + } } pub use types::*; @@ -480,7 +614,7 @@ pub struct IntegrityEnvelope { #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] pub struct Proposal { pub id: i32, - pub kind: String, + pub kind: ProposalKindTag, pub payload: Vec, pub initiator_id: i32, pub created_at: SqliteTimestamp, @@ -491,7 +625,7 @@ pub struct Proposal { #[derive(Debug, Insertable)] #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] pub struct NewProposal { - pub kind: String, + pub kind: ProposalKindTag, pub payload: Vec, pub initiator_id: i32, // status defaults to 'pending' at the DB layer diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 1053545..577c166 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -1,5 +1,6 @@ use crate::{ - actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome}, + actors::proposal_manager::{Error as ProposalError, VoteOutcome}, + db::models::ProposalKind, peers::operator::{ OperatorSession, session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending}, @@ -133,7 +134,7 @@ async fn handle_query( .into_iter() .map(|s| proto_gov::ProposalSummary { id: s.id, - kind: s.kind, + kind: <&'static str>::from(s.kind).to_owned(), initiator_id: s.initiator_id, expires_at: s.expires_at.0.timestamp(), approve_count: s.approve_count, 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 eb60f49..5d89575 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -285,7 +285,7 @@ impl OperatorSession { #[message] pub(crate) async fn handle_create_proposal( &mut self, - kind: crate::actors::proposal_manager::ProposalKind, + kind: crate::db::models::ProposalKind, ttl_secs: Option, ) -> Result { use crate::actors::proposal_manager::CreateProposal; diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 4bdd4d3..0dbd1fc 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -4,12 +4,11 @@ use arbiter_server::{ GlobalActors, proposal_manager::{ CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal, - Error as ProposalError, ProposalKind, QueryPending, - RequestRecoveryWakeup, VoteOutcome, + Error as ProposalError, QueryPending, RequestRecoveryWakeup, VoteOutcome, }, }, crypto::KeyCell, - db, + db::{self, models::ProposalKind}, }; use arbiter_server::actors::vault::Bootstrap; use arbiter_server::db::schema::{ -- 2.49.1 From 5698d1cfb340d8ccc41d9663b8c4caceea8e6351 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 26 Aug 2026 16:47:12 +0200 Subject: [PATCH 41/66] 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(); -- 2.49.1 From 15b826310b588bb58adf2320a4cb25a491572b15 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 00:45:35 +0200 Subject: [PATCH 42/66] refactor(db): replace the proposal payload blob with typed child tables --- .../2026-02-14-171124-0000_init/up.sql | 77 ++++- .../src/actors/proposal_manager.rs | 188 +++++------- server/crates/arbiter-server/src/db/mod.rs | 1 + server/crates/arbiter-server/src/db/models.rs | 142 +--------- .../src/db/proposal/approve_sdk_client.rs | 43 +++ .../src/db/proposal/grant_wallet_access.rs | 44 +++ .../arbiter-server/src/db/proposal/mod.rs | 243 ++++++++++++++++ .../src/db/proposal/one_off_transaction.rs | 102 +++++++ .../src/db/proposal/persistent_grant.rs | 267 ++++++++++++++++++ .../src/db/proposal/replace_operator.rs | 44 +++ .../src/db/proposal/trigger_rekey.rs | 28 ++ server/crates/arbiter-server/src/db/schema.rs | 103 ++++++- .../src/grpc/operator/governance.rs | 141 +++++++-- .../src/peers/operator/session/handlers.rs | 2 +- .../crates/arbiter-server/tests/governance.rs | 95 ++++--- 15 files changed, 1200 insertions(+), 320 deletions(-) create mode 100644 server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/mod.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/persistent_grant.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/replace_operator.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 60244bd..819dbe7 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -220,7 +220,6 @@ create unique index if not exists uniq_integrity_envelope_entity on integrity_en create table if not exists proposal ( id integer not null primary key, kind text not null, - payload blob not null, initiator_id integer not null references operator_identity(id) on delete restrict, created_at integer not null default(unixepoch('now')), expires_at integer not null, @@ -228,6 +227,82 @@ create table if not exists proposal ( check (status in ('pending', 'approved', 'rejected')) ) STRICT; +-- Parameters of an approved-or-pending proposal +create table if not exists proposal_approve_sdk_client ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + client_id integer not null references program_client(id) on delete restrict +) STRICT; + +create table if not exists proposal_grant_wallet_access ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + wallet_id integer not null references evm_wallet(id) on delete restrict, + client_id integer not null references program_client(id) on delete restrict +) STRICT; + +create table if not exists proposal_replace_operator ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + old_operator_id integer not null references operator_identity(id) on delete restrict, + new_pubkey blob not null +) STRICT; + +-- The transaction an operator votes to sign. +create table if not exists proposal_one_off_transaction ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + client_id integer not null references program_client(id) on delete restrict, + wallet_address blob not null check (length(wallet_address) = 20), + chain_id integer not null, + nonce integer not null, + gas_limit integer not null, + max_fee_per_gas blob not null check (length(max_fee_per_gas) = 16), + max_priority_fee_per_gas blob not null check (length(max_priority_fee_per_gas) = 16), + to_address blob not null check (length(to_address) = 20), + value blob not null check (length(value) = 32), + input blob not null +) STRICT; + +-- The grant an operator votes to creat +create table if not exists proposal_persistent_grant ( + proposal_id integer not null primary key references proposal (id) on delete cascade, + wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, + chain_id integer not null, -- EIP-155 chain ID + valid_from integer, -- unix timestamp (seconds), null = no lower bound + valid_until integer, -- unix timestamp (seconds), null = no upper bound + max_gas_fee_per_gas blob check (max_gas_fee_per_gas is null or length(max_gas_fee_per_gas) = 32), + max_priority_fee_per_gas blob check (max_priority_fee_per_gas is null or length(max_priority_fee_per_gas) = 32), + rate_limit_count integer, -- max transactions in window, null = unlimited + rate_limit_window_secs integer, -- window duration in seconds, null = unlimited + check ((rate_limit_count is null) = (rate_limit_window_secs is null)) +) STRICT; + +-- `specific = ether_transfer` +create table if not exists proposal_persistent_grant_ether ( + proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade, + window_secs integer not null, + max_volume blob not null check (length(max_volume) = 32) +) STRICT; + +create table if not exists proposal_persistent_grant_ether_target ( + id integer not null primary key, + proposal_id integer not null references proposal_persistent_grant_ether (proposal_id) on delete cascade, + address blob not null check (length(address) = 20) +) STRICT; + +create unique index if not exists uniq_proposal_ether_target on proposal_persistent_grant_ether_target (proposal_id, address); + +-- `specific = token_transfer` +create table if not exists proposal_persistent_grant_token ( + proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade, + token_contract blob not null check (length(token_contract) = 20), + receiver blob check (receiver is null or length(receiver) = 20) +) STRICT; + +create table if not exists proposal_persistent_grant_token_limit ( + id integer not null primary key, + proposal_id integer not null references proposal_persistent_grant_token (proposal_id) on delete cascade, + window_secs integer not null, + max_volume blob not null check (length(max_volume) = 32) +) STRICT; + create table if not exists proposal_vote ( id integer not null primary key, proposal_id integer not null references proposal(id) on delete cascade, diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index f421675..d43bd17 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -8,14 +8,15 @@ use crate::{ self, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, - Proposal, ProposalKind, ProposalKindTag, ProposalStatus, SqliteTimestamp, + Proposal, ProposalStatus, SqliteTimestamp, }, + proposal::{ProposalKind, ProposalKindTag, one_off_transaction, persistent_grant}, schema, }, }; use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl}; -use diesel_async::RunQueryDsl; +use diesel_async::{AsyncConnection as _, RunQueryDsl}; use kameo::{Actor, actor::ActorRef, messages}; use strum::IntoDiscriminant as _; use tracing::{error, warn}; @@ -112,18 +113,23 @@ impl ProposalManager { let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl))); - let new_proposal = NewProposal { - kind: kind.discriminant(), - payload: kind.encode_payload(), - initiator_id, - expires_at, - }; - - let mut conn = self.db.get().await?; - let id: i32 = diesel::insert_into(schema::proposal::table) - .values(&new_proposal) - .returning(schema::proposal::id) - .get_result(&mut conn) + let id: i32 = self + .db + .get() + .await? + .transaction(async |conn| { + let id: i32 = diesel::insert_into(schema::proposal::table) + .values(&NewProposal { + kind: kind.discriminant(), + initiator_id, + expires_at, + }) + .returning(schema::proposal::id) + .get_result(conn) + .await?; + db::proposal::insert_kind(conn, id, &kind).await?; + Ok::<_, diesel::result::Error>(id) + }) .await?; Ok(id) @@ -561,29 +567,26 @@ impl ProposalManager { } async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { - let kind = ProposalKind::decode(proposal.kind, &proposal.payload) - .map_err(Error::ExecutionFailed)?; + let mut conn = self.db.get().await?; + let kind = db::proposal::load_kind(&mut conn, proposal.id, proposal.kind).await?; + drop(conn); + match kind { - ProposalKind::ApproveSdkClient { client_id } => { - self.execute_approve_sdk_client(client_id).await + ProposalKind::ApproveSdkClient(s) => self.execute_approve_sdk_client(s.client_id).await, + ProposalKind::GrantWalletAccess(s) => { + self.execute_grant_wallet_access(s.wallet_id, s.client_id) + .await } - ProposalKind::GrantWalletAccess { - wallet_id, - client_id, - } => self.execute_grant_wallet_access(wallet_id, client_id).await, - ProposalKind::ReplaceOperator { - old_operator_id, - new_pubkey, - } => { - self.execute_replace_operator(old_operator_id, new_pubkey) + ProposalKind::ReplaceOperator(s) => { + self.execute_replace_operator(s.old_operator_id, s.new_pubkey) .await } ProposalKind::TriggerRekey => self.execute_trigger_rekey().await, - ProposalKind::ApprovePersistentGrant { payload_bytes } => { - self.execute_approve_persistent_grant(payload_bytes).await + ProposalKind::ApprovePersistentGrant(grant) => { + self.execute_approve_persistent_grant(*grant).await } - ProposalKind::ApproveOneOffTransaction { payload_bytes } => { - self.execute_approve_one_off_transaction(proposal.id, payload_bytes) + ProposalKind::ApproveOneOffTransaction(tx) => { + self.execute_approve_one_off_transaction(proposal.id, *tx) .await } } @@ -655,7 +658,7 @@ impl ProposalManager { async fn execute_approve_one_off_transaction( &self, proposal_id: i32, - payload_bytes: Vec, + tx: one_off_transaction::Settings, ) -> Result<(), Error> { use crate::actors::evm::ClientSignTransaction; use crate::db::models::NewProposalResult; @@ -664,44 +667,24 @@ impl ProposalManager { eips::eip2930::AccessList, primitives::{Address, Bytes, TxKind, U256}, }; - use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; - use prost::Message as _; - - let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice()) - .map_err(|e| Error::ExecutionFailed(format!("decode one-off tx payload: {e}")))?; - - let wallet_address = Address::from_slice(p.wallet_address.as_slice()); - let to = Address::from_slice(p.to.as_slice()); let transaction = TxEip1559 { - chain_id: p.chain_id, - nonce: p.nonce, - gas_limit: p.gas_limit, - max_fee_per_gas: u128::from_be_bytes( - p.max_fee_per_gas - .as_slice() - .try_into() - .map_err(|_| Error::ExecutionFailed("invalid max_fee_per_gas".to_owned()))?, - ), - max_priority_fee_per_gas: u128::from_be_bytes( - p.max_priority_fee_per_gas - .as_slice() - .try_into() - .map_err(|_| { - Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned()) - })?, - ), - to: TxKind::Call(to), - value: U256::from_be_slice(p.value.as_slice()), - input: Bytes::from(p.input), + chain_id: tx.chain_id, + nonce: tx.nonce, + gas_limit: tx.gas_limit, + max_fee_per_gas: tx.max_fee_per_gas, + max_priority_fee_per_gas: tx.max_priority_fee_per_gas, + to: TxKind::Call(Address::from(tx.to)), + value: U256::from_be_bytes(tx.value), + input: Bytes::from(tx.input), access_list: AccessList::default(), }; let sig = self .evm .ask(ClientSignTransaction { - client_id: p.client_id, - wallet_address, + client_id: tx.client_id, + wallet_address: Address::from(tx.wallet_address), transaction, }) .await @@ -720,7 +703,10 @@ impl ProposalManager { Ok(()) } - async fn execute_approve_persistent_grant(&self, payload_bytes: Vec) -> Result<(), Error> { + async fn execute_approve_persistent_grant( + &self, + grant: persistent_grant::Settings, + ) -> Result<(), Error> { use crate::{ actors::evm::OperatorCreateGrant, evm::policies::{ @@ -729,72 +715,46 @@ impl ProposalManager { }, }; use alloy::primitives::{Address, U256}; - use arbiter_proto::proto::operator::governance::{ - ApprovePersistentGrantPayload, approve_persistent_grant_payload::Specific, - }; use chrono::Duration; - use prost::Message as _; - let payload = ApprovePersistentGrantPayload::decode(payload_bytes.as_slice()) - .map_err(|e| Error::ExecutionFailed(format!("decode grant payload: {e}")))?; + let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit { + max_volume: U256::from_be_bytes(limit.max_volume), + window: Duration::seconds(limit.window_secs), + }; let basic = SharedGrantSettings { - wallet_access_id: payload.wallet_access_id, - chain: payload.chain_id, - valid_from: payload + wallet_access_id: grant.wallet_access_id, + chain: grant.chain_id, + valid_from: grant .valid_from_secs .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - valid_until: payload + valid_until: grant .valid_until_secs .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - max_gas_fee_per_gas: payload - .max_gas_fee_per_gas - .map(|b| U256::from_be_slice(b.as_slice())), - max_priority_fee_per_gas: payload - .max_priority_fee_per_gas - .map(|b| U256::from_be_slice(b.as_slice())), - rate_limit: payload.rate_limit.map(|r| TransactionRateLimit { + max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes), + max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes), + rate_limit: grant.rate_limit.map(|r| TransactionRateLimit { count: r.count, window: Duration::seconds(r.window_secs), }), }; - let grant = match payload.specific { - Some(Specific::EtherTransfer(spec)) => { - let target: Vec
= spec - .targets - .iter() - .map(|b| Address::from_slice(b.as_slice())) - .collect(); - let limit = spec - .limit - .map(|l| VolumeRateLimit { - max_volume: U256::from_be_slice(l.max_volume.as_slice()), - window: Duration::seconds(l.window_secs), - }) - .ok_or_else(|| { - Error::ExecutionFailed("missing ether transfer limit".to_owned()) - })?; - SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit }) - } - Some(Specific::TokenTransfer(spec)) => { - let token_contract = Address::from_slice(spec.token_contract.as_slice()); - let target = spec.target.map(|b| Address::from_slice(b.as_slice())); - let volume_limits: Vec = spec - .volume_limits - .iter() - .map(|l| VolumeRateLimit { - max_volume: U256::from_be_slice(l.max_volume.as_slice()), - window: Duration::seconds(l.window_secs), - }) - .collect(); - SpecificGrant::TokenTransfer(token_transfers::Settings { - token_contract, - target, - volume_limits, + let grant = match grant.specific { + persistent_grant::Specific::EtherTransfer { targets, limit } => { + SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: targets.into_iter().map(Address::from).collect(), + limit: volume(limit), }) } - None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())), + persistent_grant::Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract: Address::from(token_contract), + target: receiver.map(Address::from), + volume_limits: volume_limits.into_iter().map(volume).collect(), + }), }; self.evm diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index ef7cb56..1ade36f 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -9,6 +9,7 @@ use thiserror::Error; use tracing::info; pub mod models; +pub mod proposal; pub mod schema; pub type DatabaseConnection = SyncConnectionWrapper; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index d946e43..3ab5a75 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -9,6 +9,7 @@ use crate::db::schema::{ integrity_envelope, root_key_history, tls_history, }; +use crate::db::proposal::ProposalKindTag; use diesel::{prelude::*, sqlite::Sqlite}; use restructed::Models; @@ -22,7 +23,6 @@ pub mod types { sql_types::{Integer, Text}, sqlite::{Sqlite, SqliteType}, }; - use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr}; #[derive(Debug, FromSqlRow, AsExpression, Clone)] #[diesel(sql_type = Integer)] @@ -177,141 +177,6 @@ pub mod types { } } } - - /// A governance proposal and the parameters it carries. - #[derive(Debug, Clone, EnumDiscriminants)] - #[strum_discriminants( - name(ProposalKindTag), - vis(pub), - derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow), - diesel(sql_type = Text), - strum(serialize_all = "snake_case") - )] - pub enum ProposalKind { - ApproveSdkClient { - client_id: i32, - }, - GrantWalletAccess { - wallet_id: i32, - client_id: i32, - }, - ReplaceOperator { - old_operator_id: i32, - new_pubkey: Vec, - }, - TriggerRekey, - ApprovePersistentGrant { - payload_bytes: Vec, - }, - ApproveOneOffTransaction { - payload_bytes: Vec, - }, - } - - impl ProposalKind { - pub fn encode_payload(&self) -> Vec { - match self { - Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), - Self::GrantWalletAccess { - wallet_id, - client_id, - } => { - let mut buf = Vec::with_capacity(8); - buf.extend_from_slice(&wallet_id.to_be_bytes()); - buf.extend_from_slice(&client_id.to_be_bytes()); - buf - } - Self::ReplaceOperator { - old_operator_id, - new_pubkey, - } => { - let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32"); - let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len()); - buf.extend_from_slice(&old_operator_id.to_be_bytes()); - buf.extend_from_slice(&len.to_be_bytes()); - buf.extend_from_slice(new_pubkey); - buf - } - Self::TriggerRekey => vec![], - Self::ApprovePersistentGrant { payload_bytes } - | Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), - } - } - - /// Key-rotation proposals require every operator to approve (§3.3). - pub fn decode(tag: ProposalKindTag, payload: &[u8]) -> Result { - match tag { - ProposalKindTag::ApproveSdkClient => { - let bytes = <[u8; 4]>::try_from(payload) - .map_err(|_| "invalid payload for approve_sdk_client".to_owned())?; - Ok(Self::ApproveSdkClient { - client_id: i32::from_be_bytes(bytes), - }) - } - ProposalKindTag::GrantWalletAccess => { - let bytes = <[u8; 8]>::try_from(payload) - .map_err(|_| "invalid payload for grant_wallet_access".to_owned())?; - Ok(Self::GrantWalletAccess { - wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()), - client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), - }) - } - ProposalKindTag::ReplaceOperator => { - let (id_bytes, rest) = payload - .split_first_chunk::<4>() - .ok_or_else(|| "replace_operator payload too short".to_owned())?; - let old_operator_id = i32::from_be_bytes(*id_bytes); - let (len_bytes, rest) = rest - .split_first_chunk::<4>() - .ok_or_else(|| "replace_operator payload too short".to_owned())?; - let len = u32::from_be_bytes(*len_bytes); - let len = usize::try_from(len).unwrap_or(usize::MAX); - let new_pubkey = rest - .get(..len) - .ok_or_else(|| "replace_operator payload truncated".to_owned())? - .to_vec(); - Ok(Self::ReplaceOperator { - old_operator_id, - new_pubkey, - }) - } - ProposalKindTag::TriggerRekey => Ok(Self::TriggerRekey), - ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant { - payload_bytes: payload.to_vec(), - }), - ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction { - payload_bytes: payload.to_vec(), - }), - } - } - } - - impl ProposalKindTag { - /// Key-rotation proposals require every operator to approve (§3.3). - #[must_use] - pub const fn requires_full_quorum(self) -> bool { - matches!(self, Self::ReplaceOperator | Self::TriggerRekey) - } - } - - impl ToSql for ProposalKindTag { - fn to_sql<'b>( - &'b self, - out: &mut diesel::serialize::Output<'b, '_, Sqlite>, - ) -> diesel::serialize::Result { - >::to_sql(<&'static str>::from(*self), out) - } - } - - impl FromSql for ProposalKindTag { - fn from_sql( - bytes: ::RawValue<'_>, - ) -> diesel::deserialize::Result { - let s = >::from_sql(bytes)?; - s.parse() - .map_err(|_| format!("Unknown proposal kind: {s}").into()) - } - } } pub use types::*; @@ -615,7 +480,6 @@ pub struct IntegrityEnvelope { pub struct Proposal { pub id: i32, pub kind: ProposalKindTag, - pub payload: Vec, pub initiator_id: i32, pub created_at: SqliteTimestamp, pub expires_at: SqliteTimestamp, @@ -626,7 +490,6 @@ pub struct Proposal { #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] pub struct NewProposal { pub kind: ProposalKindTag, - pub payload: Vec, pub initiator_id: i32, // status defaults to 'pending' at the DB layer pub expires_at: SqliteTimestamp, @@ -652,7 +515,6 @@ pub struct NewProposalVote { pub signature: Vec, } - #[derive(Debug, Insertable)] #[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))] pub struct NewProposalResult { @@ -673,4 +535,4 @@ pub struct NewRecoveryProposalVote { #[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))] pub struct NewRecoveryWakeupRequest { pub requested_by: i32, -} \ No newline at end of file +} diff --git a/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs new file mode 100644 index 0000000..d623010 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs @@ -0,0 +1,43 @@ +//! Approving an SDK client so it may authenticate against the vault. + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, schema::proposal_approve_sdk_client as table}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub client_id: i32, +} + +pub struct ApproveSdkClient; + +impl Proposal for ApproveSdkClient { + const KIND: ProposalKindTag = ProposalKindTag::ApproveSdkClient; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs new file mode 100644 index 0000000..5156b9b --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs @@ -0,0 +1,44 @@ +//! Granting an SDK client visibility of a wallet. + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, schema::proposal_grant_wallet_access as table}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub wallet_id: i32, + pub client_id: i32, +} + +pub struct GrantWalletAccess; + +impl Proposal for GrantWalletAccess { + const KIND: ProposalKindTag = ProposalKindTag::GrantWalletAccess; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/mod.rs b/server/crates/arbiter-server/src/db/proposal/mod.rs new file mode 100644 index 0000000..b70ac73 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/mod.rs @@ -0,0 +1,243 @@ +//! Governed actions and the parameters they carry. +//! +//! Laid out the way [`crate::evm::policies::Policy`] is: a unit type per kind, its +//! parameters as an associated `Settings`, and the persistence for those parameters +//! implemented next to them. Everything downstream is generic over [`Proposal`], so a +//! new kind is a new module plus one arm in each dispatcher -- nothing else in the +//! codebase has to learn about it. + +use crate::db::DatabaseConnection; +use diesel::{ + QueryResult, + backend::Backend, + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + serialize::ToSql, + sql_types::Text, + sqlite::Sqlite, +}; +use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr}; + +pub mod approve_sdk_client; +pub mod grant_wallet_access; +pub mod one_off_transaction; +pub mod persistent_grant; +pub mod replace_operator; +pub mod trigger_rekey; + +pub use approve_sdk_client::ApproveSdkClient; +pub use grant_wallet_access::GrantWalletAccess; +pub use one_off_transaction::OneOffTransaction; +pub use persistent_grant::PersistentGrant; +pub use replace_operator::ReplaceOperator; +pub use trigger_rekey::TriggerRekey; + +/// A governed action that owns the child table holding its parameters. +pub trait Proposal: Sized { + /// The value stored in `proposal.kind` for this action. + const KIND: ProposalKindTag; + + /// Parameters the action is voted on with. + type Settings: Send + Sync + 'static; + + /// Writes the child row carrying `settings`. + fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> impl Future> + Send; + + /// Reads the child row back. A missing row surfaces as [`diesel::result::Error::NotFound`], + /// which is what a proposal without its parameters is. + fn load( + proposal_id: i32, + conn: &mut DatabaseConnection, + ) -> impl Future> + Send; +} + +/// Parameters of a proposal, in the one shape that can cross the actor boundary. +/// +/// Every variant holds the `Settings` of the matching [`Proposal`] implementation, so +/// the two cannot drift. +#[derive(Debug, Clone, EnumDiscriminants)] +#[strum_discriminants( + name(ProposalKindTag), + vis(pub), + derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow), + diesel(sql_type = Text), + strum(serialize_all = "snake_case") +)] +pub enum ProposalKind { + ApproveSdkClient(approve_sdk_client::Settings), + GrantWalletAccess(grant_wallet_access::Settings), + ReplaceOperator(replace_operator::Settings), + TriggerRekey, + ApprovePersistentGrant(Box), + ApproveOneOffTransaction(Box), +} + +impl ProposalKindTag { + /// Key-rotation proposals require every operator to approve (§3.3). + #[must_use] + pub const fn requires_full_quorum(self) -> bool { + matches!(self, Self::ReplaceOperator | Self::TriggerRekey) + } +} + +/// Pins every implementation to the variant it is dispatched from. Without this a +/// mistyped `KIND` would compile and only show up as a proposal stored under the +/// wrong `proposal.kind`. +const _: () = { + assert!( + matches!(ApproveSdkClient::KIND, ProposalKindTag::ApproveSdkClient), + "ApproveSdkClient::KIND must be ProposalKindTag::ApproveSdkClient" + ); + assert!( + matches!(GrantWalletAccess::KIND, ProposalKindTag::GrantWalletAccess), + "GrantWalletAccess::KIND must be ProposalKindTag::GrantWalletAccess" + ); + assert!( + matches!(ReplaceOperator::KIND, ProposalKindTag::ReplaceOperator), + "ReplaceOperator::KIND must be ProposalKindTag::ReplaceOperator" + ); + assert!( + matches!(TriggerRekey::KIND, ProposalKindTag::TriggerRekey), + "TriggerRekey::KIND must be ProposalKindTag::TriggerRekey" + ); + assert!( + matches!( + PersistentGrant::KIND, + ProposalKindTag::ApprovePersistentGrant + ), + "PersistentGrant::KIND must be ProposalKindTag::ApprovePersistentGrant" + ); + assert!( + matches!( + OneOffTransaction::KIND, + ProposalKindTag::ApproveOneOffTransaction + ), + "OneOffTransaction::KIND must be ProposalKindTag::ApproveOneOffTransaction" + ); +}; + +/// Writes the child row carrying this proposal's parameters. +/// +/// The only place the create path has to know every kind; each arm hands straight off +/// to the implementation that owns the table. +pub async fn insert_kind( + conn: &mut DatabaseConnection, + proposal_id: i32, + kind: &ProposalKind, +) -> QueryResult<()> { + match kind { + ProposalKind::ApproveSdkClient(s) => ApproveSdkClient::insert(proposal_id, s, conn).await, + ProposalKind::GrantWalletAccess(s) => GrantWalletAccess::insert(proposal_id, s, conn).await, + ProposalKind::ReplaceOperator(s) => ReplaceOperator::insert(proposal_id, s, conn).await, + ProposalKind::TriggerRekey => TriggerRekey::insert(proposal_id, &(), conn).await, + ProposalKind::ApprovePersistentGrant(s) => { + PersistentGrant::insert(proposal_id, s, conn).await + } + ProposalKind::ApproveOneOffTransaction(s) => { + OneOffTransaction::insert(proposal_id, s, conn).await + } + } +} + +/// Reads the parameters back for a `proposal.kind` that is only known at runtime. +pub async fn load_kind( + conn: &mut DatabaseConnection, + proposal_id: i32, + tag: ProposalKindTag, +) -> QueryResult { + Ok(match tag { + ProposalKindTag::ApproveSdkClient => { + ProposalKind::ApproveSdkClient(ApproveSdkClient::load(proposal_id, conn).await?) + } + ProposalKindTag::GrantWalletAccess => { + ProposalKind::GrantWalletAccess(GrantWalletAccess::load(proposal_id, conn).await?) + } + ProposalKindTag::ReplaceOperator => { + ProposalKind::ReplaceOperator(ReplaceOperator::load(proposal_id, conn).await?) + } + ProposalKindTag::TriggerRekey => { + TriggerRekey::load(proposal_id, conn).await?; + ProposalKind::TriggerRekey + } + ProposalKindTag::ApprovePersistentGrant => ProposalKind::ApprovePersistentGrant(Box::new( + PersistentGrant::load(proposal_id, conn).await?, + )), + ProposalKindTag::ApproveOneOffTransaction => ProposalKind::ApproveOneOffTransaction( + Box::new(OneOffTransaction::load(proposal_id, conn).await?), + ), + }) +} + +impl ToSql for ProposalKindTag { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + >::to_sql(<&'static str>::from(*self), out) + } +} + +impl FromSql for ProposalKindTag { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + s.parse() + .map_err(|_| format!("Unknown proposal kind: {s}").into()) + } +} + +/// SQLite has no unsigned integers; the column is `BigInt`, so a value that does not +/// round-trip is a corrupt row rather than something to silently wrap. +pub(crate) fn as_i64(value: u64) -> QueryResult { + i64::try_from(value).map_err(|_| diesel::result::Error::SerializationError(Box::new(Overflow))) +} + +pub(crate) fn as_u64(value: i64) -> QueryResult { + u64::try_from(value) + .map_err(|_| diesel::result::Error::DeserializationError(Box::new(Overflow))) +} + +pub(crate) fn fixed_bytes( + bytes: &[u8], + column: &'static str, +) -> QueryResult<[u8; N]> { + <[u8; N]>::try_from(bytes) + .map_err(|_| diesel::result::Error::DeserializationError(Box::new(WrongLength(column)))) +} + +/// Reads a fixed-width column into an array, labelling failures with the column it came +/// from. +/// +/// The label is taken from the field itself, so renaming a column cannot leave a stale +/// name behind in the error -- which is the whole reason this is a macro and not a +/// second argument. +/// +/// - `fixed!(row.column)` for a `Vec` field +/// - `fixed!(opt row.column)` for a `Option>` one +/// - `fixed!(binding)` for a local +macro_rules! fixed { + (opt $src:ident.$field:ident) => { + $src.$field + .as_deref() + .map(|value| $crate::db::proposal::fixed_bytes(value, stringify!($field))) + .transpose() + }; + ($src:ident.$field:ident) => { + $crate::db::proposal::fixed_bytes(&$src.$field, stringify!($field)) + }; + ($binding:ident) => { + $crate::db::proposal::fixed_bytes(&$binding, stringify!($binding)) + }; +} +pub(crate) use fixed; + +#[derive(Debug, thiserror::Error)] +#[error("value does not fit a SQLite integer")] +struct Overflow; + +#[derive(Debug, thiserror::Error)] +#[error("column {0} has the wrong byte length")] +struct WrongLength(&'static str); diff --git a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs new file mode 100644 index 0000000..d66cbd4 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs @@ -0,0 +1,102 @@ +//! Signing a single EIP-1559 transaction. + +use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; +use crate::db::{DatabaseConnection, schema::proposal_one_off_transaction}; +use diesel::{ + Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, + sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub client_id: i32, + pub wallet_address: [u8; 20], + pub chain_id: u64, + pub nonce: u64, + pub gas_limit: u64, + pub max_fee_per_gas: u128, + pub max_priority_fee_per_gas: u128, + pub to: [u8; 20], + pub value: [u8; 32], + pub input: Vec, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_one_off_transaction, check_for_backend(Sqlite))] +struct Row { + proposal_id: i32, + client_id: i32, + wallet_address: Vec, + chain_id: i64, + nonce: i64, + gas_limit: i64, + max_fee_per_gas: Vec, + max_priority_fee_per_gas: Vec, + to_address: Vec, + value: Vec, + input: Vec, +} + +impl Row { + fn new(proposal_id: i32, settings: &Settings) -> QueryResult { + Ok(Self { + proposal_id, + client_id: settings.client_id, + wallet_address: settings.wallet_address.to_vec(), + chain_id: as_i64(settings.chain_id)?, + nonce: as_i64(settings.nonce)?, + gas_limit: as_i64(settings.gas_limit)?, + max_fee_per_gas: settings.max_fee_per_gas.to_be_bytes().to_vec(), + max_priority_fee_per_gas: settings.max_priority_fee_per_gas.to_be_bytes().to_vec(), + to_address: settings.to.to_vec(), + value: settings.value.to_vec(), + input: settings.input.clone(), + }) + } + + fn into_settings(self) -> QueryResult { + Ok(Settings { + client_id: self.client_id, + wallet_address: fixed!(self.wallet_address)?, + chain_id: as_u64(self.chain_id)?, + nonce: as_u64(self.nonce)?, + gas_limit: as_u64(self.gas_limit)?, + max_fee_per_gas: u128::from_be_bytes(fixed!(self.max_fee_per_gas)?), + max_priority_fee_per_gas: u128::from_be_bytes(fixed!(self.max_priority_fee_per_gas)?), + to: fixed!(self.to_address)?, + value: fixed!(self.value)?, + input: self.input, + }) + } +} + +pub struct OneOffTransaction; + +impl Proposal for OneOffTransaction { + const KIND: ProposalKindTag = ProposalKindTag::ApproveOneOffTransaction; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(proposal_one_off_transaction::table) + .values(&Row::new(proposal_id, settings)?) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + let row: Row = proposal_one_off_transaction::table + .find(proposal_id) + .select(Row::as_select()) + .first(conn) + .await?; + + row.into_settings() + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs new file mode 100644 index 0000000..495a349 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs @@ -0,0 +1,267 @@ +//! Creating a standing EVM grant. +use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; +use crate::db::{ + DatabaseConnection, + schema::{ + proposal_persistent_grant, proposal_persistent_grant_ether, + proposal_persistent_grant_ether_target, proposal_persistent_grant_token, + proposal_persistent_grant_token_limit, + }, +}; +use diesel::{ + ExpressionMethods as _, Insertable, OptionalExtension as _, QueryDsl as _, QueryResult, + Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub wallet_access_id: i32, + pub chain_id: u64, + pub valid_from_secs: Option, + pub valid_until_secs: Option, + pub max_gas_fee_per_gas: Option<[u8; 32]>, + pub max_priority_fee_per_gas: Option<[u8; 32]>, + pub rate_limit: Option, + pub specific: Specific, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateLimit { + pub count: u32, + pub window_secs: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VolumeLimit { + pub max_volume: [u8; 32], + pub window_secs: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Specific { + EtherTransfer { + targets: Vec<[u8; 20]>, + limit: VolumeLimit, + }, + TokenTransfer { + token_contract: [u8; 20], + receiver: Option<[u8; 20]>, + volume_limits: Vec, + }, +} + +/// Shared settings, mirroring `evm_basic_grant`. +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant, check_for_backend(Sqlite))] +struct BaseRow { + proposal_id: i32, + wallet_access_id: i32, + chain_id: i64, + valid_from: Option, + valid_until: Option, + max_gas_fee_per_gas: Option>, + max_priority_fee_per_gas: Option>, + rate_limit_count: Option, + rate_limit_window_secs: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant_ether, check_for_backend(Sqlite))] +struct EtherRow { + proposal_id: i32, + window_secs: i64, + max_volume: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_persistent_grant_ether_target, check_for_backend(Sqlite))] +struct NewEtherTarget { + proposal_id: i32, + address: Vec, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant_token, check_for_backend(Sqlite))] +struct TokenRow { + proposal_id: i32, + token_contract: Vec, + receiver: Option>, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_persistent_grant_token_limit, check_for_backend(Sqlite))] +struct NewTokenLimit { + proposal_id: i32, + window_secs: i64, + max_volume: Vec, +} + +impl BaseRow { + fn new(proposal_id: i32, settings: &Settings) -> QueryResult { + Ok(Self { + proposal_id, + wallet_access_id: settings.wallet_access_id, + chain_id: as_i64(settings.chain_id)?, + valid_from: settings.valid_from_secs, + valid_until: settings.valid_until_secs, + max_gas_fee_per_gas: settings.max_gas_fee_per_gas.map(|v| v.to_vec()), + max_priority_fee_per_gas: settings.max_priority_fee_per_gas.map(|v| v.to_vec()), + // SQLite stores integers signed; a rate-limit count is a `u32`, so it + // round-trips through the bit pattern rather than a fallible range check. + rate_limit_count: settings.rate_limit.map(|r| r.count.cast_signed()), + rate_limit_window_secs: settings.rate_limit.map(|r| r.window_secs), + }) + } + + fn into_settings(self, specific: Specific) -> QueryResult { + Ok(Settings { + wallet_access_id: self.wallet_access_id, + chain_id: as_u64(self.chain_id)?, + valid_from_secs: self.valid_from, + valid_until_secs: self.valid_until, + max_gas_fee_per_gas: fixed!(opt self.max_gas_fee_per_gas)?, + max_priority_fee_per_gas: fixed!(opt self.max_priority_fee_per_gas)?, + rate_limit: self.rate_limit_count.zip(self.rate_limit_window_secs).map( + |(count, window_secs)| RateLimit { + count: count.cast_unsigned(), + window_secs, + }, + ), + specific, + }) + } +} + +pub struct PersistentGrant; + +impl Proposal for PersistentGrant { + const KIND: ProposalKindTag = ProposalKindTag::ApprovePersistentGrant; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(proposal_persistent_grant::table) + .values(&BaseRow::new(proposal_id, settings)?) + .execute(conn) + .await?; + + match &settings.specific { + Specific::EtherTransfer { targets, limit } => { + diesel::insert_into(proposal_persistent_grant_ether::table) + .values(&EtherRow { + proposal_id, + window_secs: limit.window_secs, + max_volume: limit.max_volume.to_vec(), + }) + .execute(conn) + .await?; + + // Row at a time: SQLite has no multi-row VALUES clause in diesel-async. + for address in targets { + diesel::insert_into(proposal_persistent_grant_ether_target::table) + .values(&NewEtherTarget { + proposal_id, + address: address.to_vec(), + }) + .execute(conn) + .await?; + } + } + Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => { + diesel::insert_into(proposal_persistent_grant_token::table) + .values(&TokenRow { + proposal_id, + token_contract: token_contract.to_vec(), + receiver: receiver.map(|r| r.to_vec()), + }) + .execute(conn) + .await?; + + for limit in volume_limits { + diesel::insert_into(proposal_persistent_grant_token_limit::table) + .values(&NewTokenLimit { + proposal_id, + window_secs: limit.window_secs, + max_volume: limit.max_volume.to_vec(), + }) + .execute(conn) + .await?; + } + } + } + Ok(()) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + let base: BaseRow = proposal_persistent_grant::table + .find(proposal_id) + .select(BaseRow::as_select()) + .first(conn) + .await?; + + let ether: Option = proposal_persistent_grant_ether::table + .find(proposal_id) + .select(EtherRow::as_select()) + .first(conn) + .await + .optional()?; + + let specific = if let Some(ether) = ether { + let addresses: Vec> = proposal_persistent_grant_ether_target::table + .filter(proposal_persistent_grant_ether_target::proposal_id.eq(proposal_id)) + .select(proposal_persistent_grant_ether_target::address) + .load(conn) + .await?; + let targets = addresses + .iter() + .map(|address| fixed!(address)) + .collect::>>()?; + Specific::EtherTransfer { + targets, + limit: VolumeLimit { + max_volume: fixed!(ether.max_volume)?, + window_secs: ether.window_secs, + }, + } + } else { + let token: TokenRow = proposal_persistent_grant_token::table + .find(proposal_id) + .select(TokenRow::as_select()) + .first(conn) + .await?; + let rows: Vec<(i64, Vec)> = proposal_persistent_grant_token_limit::table + .filter(proposal_persistent_grant_token_limit::proposal_id.eq(proposal_id)) + .select(( + proposal_persistent_grant_token_limit::window_secs, + proposal_persistent_grant_token_limit::max_volume, + )) + .load(conn) + .await?; + let volume_limits = rows + .into_iter() + .map(|(window_secs, max_volume)| { + Ok(VolumeLimit { + max_volume: fixed!(max_volume)?, + window_secs, + }) + }) + .collect::>>()?; + Specific::TokenTransfer { + token_contract: fixed!(token.token_contract)?, + receiver: fixed!(opt token.receiver)?, + volume_limits, + } + }; + + base.into_settings(specific) + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/replace_operator.rs b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs new file mode 100644 index 0000000..d9e448a --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs @@ -0,0 +1,44 @@ +//! Replacing an operator's key, which also triggers a Shamir re-key (§3.3). + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, schema::proposal_replace_operator as table}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub old_operator_id: i32, + pub new_pubkey: Vec, +} + +pub struct ReplaceOperator; + +impl Proposal for ReplaceOperator { + const KIND: ProposalKindTag = ProposalKindTag::ReplaceOperator; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs new file mode 100644 index 0000000..6cf23bc --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs @@ -0,0 +1,28 @@ +//! A Shamir re-key over the current operator set (§3.3). + +use super::{Proposal, ProposalKindTag}; +use crate::db::DatabaseConnection; +use diesel::QueryResult; + +pub struct TriggerRekey; + +impl Proposal for TriggerRekey { + const KIND: ProposalKindTag = ProposalKindTag::TriggerRekey; + + type Settings = (); + + async fn insert( + _proposal_id: i32, + _settings: &Self::Settings, + _conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + Ok(()) + } + + async fn load( + _proposal_id: i32, + _conn: &mut DatabaseConnection, + ) -> QueryResult { + Ok(()) + } +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index cbc7705..b14da10 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -176,7 +176,6 @@ diesel::table! { proposal (id) { id -> Integer, kind -> Text, - payload -> Binary, initiator_id -> Integer, created_at -> Integer, expires_at -> Integer, @@ -184,6 +183,92 @@ diesel::table! { } } +diesel::table! { + proposal_approve_sdk_client (proposal_id) { + proposal_id -> Integer, + client_id -> Integer, + } +} + +diesel::table! { + proposal_grant_wallet_access (proposal_id) { + proposal_id -> Integer, + wallet_id -> Integer, + client_id -> Integer, + } +} + +diesel::table! { + proposal_replace_operator (proposal_id) { + proposal_id -> Integer, + old_operator_id -> Integer, + new_pubkey -> Binary, + } +} + +diesel::table! { + proposal_one_off_transaction (proposal_id) { + proposal_id -> Integer, + client_id -> Integer, + wallet_address -> Binary, + chain_id -> BigInt, + nonce -> BigInt, + gas_limit -> BigInt, + max_fee_per_gas -> Binary, + max_priority_fee_per_gas -> Binary, + to_address -> Binary, + value -> Binary, + input -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant (proposal_id) { + proposal_id -> Integer, + wallet_access_id -> Integer, + chain_id -> BigInt, + valid_from -> Nullable, + valid_until -> Nullable, + max_gas_fee_per_gas -> Nullable, + max_priority_fee_per_gas -> Nullable, + rate_limit_count -> Nullable, + rate_limit_window_secs -> Nullable, + } +} + +diesel::table! { + proposal_persistent_grant_ether (proposal_id) { + proposal_id -> Integer, + window_secs -> BigInt, + max_volume -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant_ether_target (id) { + id -> Integer, + proposal_id -> Integer, + address -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant_token (proposal_id) { + proposal_id -> Integer, + token_contract -> Binary, + receiver -> Nullable, + } +} + +diesel::table! { + proposal_persistent_grant_token_limit (id) { + id -> Integer, + proposal_id -> Integer, + window_secs -> BigInt, + max_volume -> Binary, + } +} + diesel::table! { proposal_result (proposal_id) { proposal_id -> Integer, @@ -299,6 +384,13 @@ diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::joinable!(proposal -> operator_identity (initiator_id)); diesel::joinable!(proposal_result -> proposal (proposal_id)); +diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id)); +diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id)); +diesel::joinable!(proposal_replace_operator -> proposal (proposal_id)); +diesel::joinable!(proposal_one_off_transaction -> proposal (proposal_id)); +diesel::joinable!(proposal_persistent_grant -> proposal (proposal_id)); +diesel::joinable!(proposal_persistent_grant_ether -> proposal_persistent_grant (proposal_id)); +diesel::joinable!(proposal_persistent_grant_token -> proposal_persistent_grant (proposal_id)); diesel::joinable!(proposal_vote -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> operator_identity (operator_id)); diesel::joinable!(recovery_operator -> recovery_operator_identity (id)); @@ -309,6 +401,15 @@ diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, proposal_result, + proposal_approve_sdk_client, + proposal_grant_wallet_access, + proposal_replace_operator, + proposal_one_off_transaction, + proposal_persistent_grant, + proposal_persistent_grant_ether, + proposal_persistent_grant_ether_target, + proposal_persistent_grant_token, + proposal_persistent_grant_token_limit, recovery_operator, recovery_operator_identity, recovery_wakeup_request, diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 192eb8d..987ab94 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -1,6 +1,9 @@ use crate::{ actors::proposal_manager::{Error as ProposalError, VoteOutcome}, - db::models::ProposalKind, + db::proposal::{ + ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, + persistent_grant, replace_operator, + }, peers::operator::{ OperatorSession, session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending}, @@ -46,29 +49,29 @@ async fn handle_create( req: CreateProposalRequest, ) -> Result, Status> { let kind = match req.kind { - Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient { - client_id: p.client_id, - }, - Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess { - wallet_id: p.wallet_id, - client_id: p.client_id, - }, - Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { - old_operator_id: p.old_operator_id, - new_pubkey: p.new_pubkey, - }, + Some(ProtoKind::ApproveSdkClient(p)) => { + ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { + client_id: p.client_id, + }) + } + Some(ProtoKind::GrantWalletAccess(p)) => { + ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { + wallet_id: p.wallet_id, + client_id: p.client_id, + }) + } + Some(ProtoKind::ReplaceOperator(p)) => { + ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: p.old_operator_id, + new_pubkey: p.new_pubkey, + }) + } Some(ProtoKind::TriggerRekey(())) => ProposalKind::TriggerRekey, Some(ProtoKind::ApprovePersistentGrant(p)) => { - use prost::Message as _; - ProposalKind::ApprovePersistentGrant { - payload_bytes: p.encode_to_vec(), - } + ProposalKind::ApprovePersistentGrant(Box::new(parse_persistent_grant(p)?)) } Some(ProtoKind::ApproveOneOffTransaction(p)) => { - use prost::Message as _; - ProposalKind::ApproveOneOffTransaction { - payload_bytes: p.encode_to_vec(), - } + ProposalKind::ApproveOneOffTransaction(Box::new(parse_one_off_transaction(p)?)) } None => return Err(Status::invalid_argument("Missing proposal kind")), }; @@ -88,6 +91,104 @@ async fn handle_create( )))) } +/// Validates the grant where the request enters, so a malformed one is refused before +/// any operator votes on it instead of failing after quorum. +fn parse_persistent_grant( + p: proto_gov::ApprovePersistentGrantPayload, +) -> Result { + use proto_gov::approve_persistent_grant_payload::Specific; + + let volume = + |l: proto_gov::VolumeLimitProto| -> Result { + Ok(persistent_grant::VolumeLimit { + max_volume: fixed(&l.max_volume, "max_volume must be 32 bytes")?, + window_secs: l.window_secs, + }) + }; + + let specific = match p.specific { + Some(Specific::EtherTransfer(spec)) => { + let targets = spec + .targets + .iter() + .map(|target| fixed(target, "ether transfer target must be 20 bytes")) + .collect::, _>>()?; + let limit = spec + .limit + .ok_or_else(|| Status::invalid_argument("missing ether transfer limit"))?; + persistent_grant::Specific::EtherTransfer { + targets, + limit: volume(limit)?, + } + } + Some(Specific::TokenTransfer(spec)) => { + let volume_limits = spec + .volume_limits + .into_iter() + .map(volume) + .collect::, _>>()?; + persistent_grant::Specific::TokenTransfer { + token_contract: fixed(&spec.token_contract, "token_contract must be 20 bytes")?, + receiver: spec + .target + .map(|t| fixed(&t, "token transfer target must be 20 bytes")) + .transpose()?, + volume_limits, + } + } + None => return Err(Status::invalid_argument("missing grant specific")), + }; + + Ok(persistent_grant::Settings { + wallet_access_id: p.wallet_access_id, + chain_id: p.chain_id, + valid_from_secs: p.valid_from_secs, + valid_until_secs: p.valid_until_secs, + max_gas_fee_per_gas: p + .max_gas_fee_per_gas + .map(|v| fixed(&v, "max_gas_fee_per_gas must be 32 bytes")) + .transpose()?, + max_priority_fee_per_gas: p + .max_priority_fee_per_gas + .map(|v| fixed(&v, "max_priority_fee_per_gas must be 32 bytes")) + .transpose()?, + rate_limit: p.rate_limit.map(|r| persistent_grant::RateLimit { + count: r.count, + window_secs: r.window_secs, + }), + specific, + }) +} + +fn fixed(bytes: &[u8], message: &'static str) -> Result<[u8; N], Status> { + <[u8; N]>::try_from(bytes).map_err(|_| Status::invalid_argument(message)) +} + +/// Validates the transaction where the request enters, so a malformed one is refused +/// before any operator votes on it instead of failing after quorum. +fn parse_one_off_transaction( + p: proto_gov::ApproveOneOffTransactionPayload, +) -> Result { + Ok(one_off_transaction::Settings { + client_id: p.client_id, + wallet_address: fixed(&p.wallet_address, "wallet_address must be 20 bytes")?, + chain_id: p.chain_id, + nonce: p.nonce, + gas_limit: p.gas_limit, + max_fee_per_gas: u128::from_be_bytes(fixed( + &p.max_fee_per_gas, + "max_fee_per_gas must be 16 bytes", + )?), + max_priority_fee_per_gas: u128::from_be_bytes(fixed( + &p.max_priority_fee_per_gas, + "max_priority_fee_per_gas must be 16 bytes", + )?), + to: fixed(&p.to, "to must be 20 bytes")?, + value: fixed(&p.value, "value must be 32 bytes")?, + input: p.input, + }) +} + async fn handle_vote( actor: &ActorRef, req: proto_gov::CastVoteRequest, 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 d0116cc..bc83e85 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -285,7 +285,7 @@ impl OperatorSession { #[message] pub(crate) async fn handle_create_proposal( &mut self, - kind: crate::db::models::ProposalKind, + kind: crate::db::proposal::ProposalKind, ttl_secs: Option, ) -> Result { use crate::actors::proposal_manager::CreateProposal; diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 75fb301..e217c9c 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -8,7 +8,13 @@ use arbiter_server::{ }, }, crypto::KeyCell, - db::{self, models::ProposalKind}, + db::{ + self, + proposal::{ + ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, + persistent_grant, replace_operator, + }, + }, }; use arbiter_server::actors::vault::Bootstrap; use arbiter_server::db::schema::{ @@ -121,7 +127,7 @@ async fn create_proposal_returns_id() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: 42 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 42 }), initiator_id: 1, ttl_secs: None, }) @@ -150,7 +156,7 @@ async fn create_proposal_caps_the_ttl() { actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: 1 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 1 }), initiator_id: op, ttl_secs: Some(ttl), }) @@ -165,7 +171,7 @@ async fn create_proposal_caps_the_ttl() { assert!(matches!( create(MAX_TTL_SECS + 1).await, Err(kameo::error::SendError::HandlerError( - ProposalError::TtlTooLong { .. } + ProposalError::TtlTooLong )) )); } @@ -189,7 +195,7 @@ async fn single_operator_vote_reaches_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -235,7 +241,7 @@ async fn two_operator_first_vote_is_pending() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op1, ttl_secs: None, }) @@ -280,7 +286,7 @@ async fn duplicate_vote_rejected() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: None, }) @@ -340,7 +346,7 @@ async fn invalid_signature_rejected() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: None, }) @@ -384,7 +390,7 @@ async fn query_pending_excludes_already_voted() { let p1 = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: client_id1 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id1 }), initiator_id: op, ttl_secs: None, }) @@ -394,7 +400,7 @@ async fn query_pending_excludes_already_voted() { let p2 = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: client_id2 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id2 }), initiator_id: op, ttl_secs: None, }) @@ -449,7 +455,7 @@ async fn expired_proposal_is_hidden_and_unvotable() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: Some(0), }) @@ -506,7 +512,7 @@ async fn approve_sdk_client_writes_integrity_envelope() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -560,7 +566,7 @@ async fn grant_wallet_access_on_quorum_approval() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::GrantWalletAccess { wallet_id, client_id }, + kind: ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { wallet_id, client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -597,12 +603,6 @@ async fn grant_wallet_access_on_quorum_approval() { #[tokio::test] async fn approve_persistent_grant_creates_basic_grant_row() { - use arbiter_proto::proto::operator::governance::{ - ApprovePersistentGrantPayload, EtherTransferSpecProto, VolumeLimitProto, - approve_persistent_grant_payload::Specific, - }; - use prost::Message as _; - let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); actors @@ -631,7 +631,7 @@ async fn approve_persistent_grant_creates_basic_grant_row() { .unwrap(); drop(conn); - let payload = ApprovePersistentGrantPayload { + let grant = persistent_grant::Settings { wallet_access_id, chain_id: 1, valid_from_secs: None, @@ -639,19 +639,19 @@ async fn approve_persistent_grant_creates_basic_grant_row() { max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, - specific: Some(Specific::EtherTransfer(EtherTransferSpecProto { - targets: vec![vec![0u8; 20]], - limit: Some(VolumeLimitProto { - max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes_vec(), + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes(), window_secs: 86400, - }), - })), + }, + }, }; let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApprovePersistentGrant { payload_bytes: payload.encode_to_vec() }, + kind: ProposalKind::ApprovePersistentGrant(Box::new(grant)), initiator_id: op_id, ttl_secs: None, }) @@ -687,14 +687,12 @@ async fn approve_persistent_grant_creates_basic_grant_row() { #[tokio::test] async fn approve_one_off_transaction_stores_result() { - use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; + use alloy::primitives::{Address, U256}; use arbiter_server::actors::evm::{Generate, OperatorCreateGrant}; use arbiter_server::evm::policies::{ SharedGrantSettings, SpecificGrant, VolumeRateLimit, ether_transfer, }; - use alloy::primitives::{Address, U256}; use chrono::Duration; - use prost::Message as _; let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); @@ -751,24 +749,23 @@ async fn approve_one_off_transaction_stores_result() { .await .unwrap(); - // Encode the one-off transaction payload - let payload = ApproveOneOffTransactionPayload { + let transaction = one_off_transaction::Settings { client_id, - wallet_address: wallet_address.as_slice().to_vec(), + wallet_address: wallet_address.into(), chain_id: 1, nonce: 0, gas_limit: 21000, - max_fee_per_gas: 1u128.to_be_bytes().to_vec(), - max_priority_fee_per_gas: 1u128.to_be_bytes().to_vec(), - to: to_address.as_slice().to_vec(), - value: U256::from(1u64).to_be_bytes_vec(), + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to: to_address.into(), + value: U256::from(1u64).to_be_bytes(), input: vec![], }; let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveOneOffTransaction { payload_bytes: payload.encode_to_vec() }, + kind: ProposalKind::ApproveOneOffTransaction(Box::new(transaction)), initiator_id: op_id, ttl_secs: None, }) @@ -821,7 +818,10 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: op_id, new_pubkey: new_pubkey.clone() }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: op_id, + new_pubkey: new_pubkey.clone(), + }), initiator_id: op_id, ttl_secs: None, }) @@ -927,7 +927,10 @@ async fn key_rotation_requires_full_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: 1, + new_pubkey, + }), initiator_id: op1, ttl_secs: None, }) @@ -972,7 +975,10 @@ async fn recovery_vote_rejected_when_sleeping() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: 1, + new_pubkey, + }), initiator_id: op_id, ttl_secs: None, }) @@ -1018,7 +1024,7 @@ async fn recovery_vote_blocked_on_non_replace_proposal() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -1123,7 +1129,10 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: 1, + new_pubkey, + }), initiator_id: op_id, ttl_secs: None, }) -- 2.49.1 From 8421a09d9fabe4ea499d1adabf000b06acc0e730 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 12:46:40 +0200 Subject: [PATCH 43/66] refactor(db!: store the one-off transaction signature by component --- .../2026-02-14-171124-0000_init/up.sql | 14 ++++++-- .../src/actors/proposal_manager.rs | 8 +---- server/crates/arbiter-server/src/db/models.rs | 7 ---- .../src/db/proposal/one_off_transaction.rs | 34 ++++++++++++++++++- server/crates/arbiter-server/src/db/schema.rs | 10 +++--- .../crates/arbiter-server/tests/governance.rs | 21 ++++++++---- 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 819dbe7..36a5310 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -314,9 +314,17 @@ create table if not exists proposal_vote ( ) STRICT; -create table if not exists proposal_result ( - proposal_id integer not null primary key references proposal(id) on delete cascade, - data blob not null, +-- The signature the vault produced for an approved transaction, by component. +-- +-- secp256k1 signatures have three common encodings (Electrum v=27/28, raw parity, +-- ERC-2098 compact); a single blob would not say which one it holds. `y_parity` is +-- the raw bit -- add 27 to rebuild the Electrum form `Signature::as_bytes` emits. +create table if not exists proposal_one_off_transaction_result ( + proposal_id integer not null primary key + references proposal_one_off_transaction (proposal_id) on delete cascade, + r blob not null check (length(r) = 32), + s blob not null check (length(s) = 32), + y_parity integer not null check (y_parity in (0, 1)), created_at integer not null default(unixepoch('now')) ) STRICT; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index d43bd17..e7ab118 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -661,7 +661,6 @@ impl ProposalManager { tx: one_off_transaction::Settings, ) -> Result<(), Error> { use crate::actors::evm::ClientSignTransaction; - use crate::db::models::NewProposalResult; use alloy::{ consensus::TxEip1559, eips::eip2930::AccessList, @@ -691,12 +690,7 @@ impl ProposalManager { .map_err(|e| Error::ExecutionFailed(format!("sign one-off tx: {e}")))?; let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; - diesel::insert_into(schema::proposal_result::table) - .values(NewProposalResult { - proposal_id, - data: sig.as_bytes().to_vec(), - }) - .execute(&mut conn) + one_off_transaction::store_signature(proposal_id, &sig, &mut conn) .await .map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 3ab5a75..fd749d1 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -515,13 +515,6 @@ pub struct NewProposalVote { pub signature: Vec, } -#[derive(Debug, Insertable)] -#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))] -pub struct NewProposalResult { - pub proposal_id: i32, - pub data: Vec, -} - #[derive(Debug, Insertable)] #[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))] pub struct NewRecoveryProposalVote { diff --git a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs index d66cbd4..4f2ec97 100644 --- a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs +++ b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs @@ -1,7 +1,10 @@ //! Signing a single EIP-1559 transaction. use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; -use crate::db::{DatabaseConnection, schema::proposal_one_off_transaction}; +use crate::db::{ + DatabaseConnection, + schema::{proposal_one_off_transaction, proposal_one_off_transaction_result}, +}; use diesel::{ Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, @@ -100,3 +103,32 @@ impl Proposal for OneOffTransaction { row.into_settings() } } + +/// The signature the vault produced for an approved transaction. +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_one_off_transaction_result, check_for_backend(Sqlite))] +struct SignatureRow { + proposal_id: i32, + r: Vec, + s: Vec, + y_parity: i32, +} + +/// Records the signature produced for an approved transaction, by component, so what +/// came back is as readable as what was signed. +pub async fn store_signature( + proposal_id: i32, + signature: &alloy::signers::Signature, + conn: &mut DatabaseConnection, +) -> QueryResult<()> { + diesel::insert_into(proposal_one_off_transaction_result::table) + .values(&SignatureRow { + proposal_id, + r: signature.r().to_be_bytes::<32>().to_vec(), + s: signature.s().to_be_bytes::<32>().to_vec(), + y_parity: i32::from(signature.v()), + }) + .execute(conn) + .await + .map(drop) +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index b14da10..8123290 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -270,9 +270,11 @@ diesel::table! { } diesel::table! { - proposal_result (proposal_id) { + proposal_one_off_transaction_result (proposal_id) { proposal_id -> Integer, - data -> Binary, + r -> Binary, + s -> Binary, + y_parity -> Integer, created_at -> Integer, } } @@ -383,7 +385,7 @@ diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::joinable!(proposal -> operator_identity (initiator_id)); -diesel::joinable!(proposal_result -> proposal (proposal_id)); +diesel::joinable!(proposal_one_off_transaction_result -> proposal_one_off_transaction (proposal_id)); diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id)); diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id)); diesel::joinable!(proposal_replace_operator -> proposal (proposal_id)); @@ -400,7 +402,7 @@ diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, - proposal_result, + proposal_one_off_transaction_result, proposal_approve_sdk_client, proposal_grant_wallet_access, proposal_replace_operator, diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index e217c9c..426af2d 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -19,7 +19,7 @@ use arbiter_server::{ use arbiter_server::actors::vault::Bootstrap; use arbiter_server::db::schema::{ aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, - proposal_result, recovery_operator_identity, + proposal_one_off_transaction_result, recovery_operator_identity, }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -790,13 +790,20 @@ async fn approve_one_off_transaction_stores_result() { assert_eq!(outcome, VoteOutcome::Approved); let mut conn = db.get().await.unwrap(); - let count: i64 = proposal_result::table - .filter(proposal_result::proposal_id.eq(proposal_id)) - .count() - .get_result(&mut conn) + let (r, s, y_parity): (Vec, Vec, i32) = proposal_one_off_transaction_result::table + .find(proposal_id) + .select(( + proposal_one_off_transaction_result::r, + proposal_one_off_transaction_result::s, + proposal_one_off_transaction_result::y_parity, + )) + .first(&mut conn) .await - .unwrap(); - assert_eq!(count, 1); + .expect("an approved transaction must leave its signature"); + + assert_eq!(r.len(), 32, "r must be a 32-byte scalar"); + assert_eq!(s.len(), 32, "s must be a 32-byte scalar"); + assert!(y_parity == 0 || y_parity == 1, "y_parity must be a bit"); } #[tokio::test] -- 2.49.1 From 71081b6ee7110c3c9760b52d3ef079701cc12312 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 13:42:14 +0200 Subject: [PATCH 44/66] refactor(proposal): use id newtypes across the governance path --- .../src/actors/proposal_manager.rs | 45 +++++++++++-------- server/crates/arbiter-server/src/db/models.rs | 22 ++++----- .../src/db/proposal/approve_sdk_client.rs | 11 +++-- .../src/db/proposal/grant_wallet_access.rs | 11 +++-- .../arbiter-server/src/db/proposal/mod.rs | 10 ++--- .../src/db/proposal/one_off_transaction.rs | 16 ++++--- .../src/db/proposal/persistent_grant.rs | 20 +++++---- .../src/db/proposal/replace_operator.rs | 15 +++++-- .../src/db/proposal/trigger_rekey.rs | 6 +-- .../src/grpc/operator/governance.rs | 13 +++--- .../src/peers/operator/session/handlers.rs | 11 ++--- .../crates/arbiter-server/tests/governance.rs | 31 +++++++------ 12 files changed, 127 insertions(+), 84 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index e7ab118..1be894d 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -8,7 +8,8 @@ use crate::{ self, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, - Proposal, ProposalStatus, SqliteTimestamp, + OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, + SqliteTimestamp, }, proposal::{ProposalKind, ProposalKindTag, one_off_transaction, persistent_grant}, schema, @@ -65,9 +66,9 @@ pub enum Error { #[derive(Debug)] pub struct ProposalSummary { - pub id: i32, + pub id: ProposalId, pub kind: ProposalKindTag, - pub initiator_id: i32, + pub initiator_id: OperatorIdentityId, pub expires_at: SqliteTimestamp, pub approve_count: i64, pub reject_count: i64, @@ -103,9 +104,9 @@ impl ProposalManager { pub async fn create_proposal( &mut self, kind: ProposalKind, - initiator_id: i32, + initiator_id: OperatorIdentityId, ttl_secs: Option, - ) -> Result { + ) -> Result { let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); if ttl > MAX_TTL_SECS { return Err(Error::TtlTooLong); @@ -113,12 +114,12 @@ impl ProposalManager { let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl))); - let id: i32 = self + let id: ProposalId = self .db .get() .await? .transaction(async |conn| { - let id: i32 = diesel::insert_into(schema::proposal::table) + let id: ProposalId = diesel::insert_into(schema::proposal::table) .values(&NewProposal { kind: kind.discriminant(), initiator_id, @@ -136,7 +137,7 @@ impl ProposalManager { } #[message] - pub async fn query_pending(&mut self, operator_id: i32) -> Vec { + pub async fn query_pending(&mut self, operator_id: OperatorIdentityId) -> Vec { #[expect( clippy::cast_possible_truncation, clippy::as_conversions, @@ -149,7 +150,7 @@ impl ProposalManager { return vec![]; }; - let voted_ids: Vec = schema::proposal_vote::table + let voted_ids: Vec = schema::proposal_vote::table .filter(schema::proposal_vote::operator_id.eq(operator_id)) .select(schema::proposal_vote::proposal_id) .load(&mut conn) @@ -195,8 +196,8 @@ impl ProposalManager { #[message] pub async fn cast_vote( &mut self, - proposal_id: i32, - operator_id: i32, + proposal_id: ProposalId, + operator_id: OperatorIdentityId, approve: bool, signature: Vec, ) -> Result { @@ -249,7 +250,7 @@ impl ProposalManager { // 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_be_bytes()); + 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()) @@ -358,7 +359,10 @@ impl ProposalManager { /// §3.6: Any ordinary operator may request recovery wake-up. /// Fails if a wake-up is already pending or active. #[message] - pub async fn request_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> { + pub async fn request_recovery_wakeup( + &mut self, + operator_id: OperatorIdentityId, + ) -> Result<(), Error> { let mut conn = self.db.get().await?; if Self::has_uncancelled_wakeup(&mut conn).await? { return Err(Error::WakeupAlreadyPending); @@ -375,7 +379,10 @@ impl ProposalManager { /// §3.6: Any ordinary operator may cancel a pending wake-up request. /// Fails if there is no uncancelled request. #[message] - pub async fn cancel_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> { + pub async fn cancel_recovery_wakeup( + &mut self, + operator_id: OperatorIdentityId, + ) -> Result<(), Error> { let mut conn = self.db.get().await?; let rows_updated = diesel::update(schema::recovery_wakeup_request::table) .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) @@ -396,8 +403,8 @@ impl ProposalManager { #[message] pub async fn cast_recovery_vote( &mut self, - proposal_id: i32, - recovery_operator_id: i32, + proposal_id: ProposalId, + recovery_operator_id: RecoveryOperatorIdentityId, approve: bool, signature: Vec, ) -> Result { @@ -454,7 +461,7 @@ impl ProposalManager { .map_err(|()| Error::InvalidSignature)?; let mut vote_msg = Vec::with_capacity(9); - vote_msg.extend_from_slice(&i64::from(proposal_id).to_be_bytes()); + 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()) @@ -617,7 +624,7 @@ impl ProposalManager { /// removes their old Shamir share, then begins a coordinated re-key (§3.3). async fn execute_replace_operator( &self, - old_operator_id: i32, + old_operator_id: OperatorIdentityId, new_pubkey: Vec, ) -> Result<(), Error> { let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; @@ -657,7 +664,7 @@ impl ProposalManager { async fn execute_approve_one_off_transaction( &self, - proposal_id: i32, + proposal_id: ProposalId, tx: one_off_transaction::Settings, ) -> Result<(), Error> { use crate::actors::evm::ClientSignTransaction; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index fd749d1..4eae389 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -143,6 +143,8 @@ pub mod types { declare_id!(TlsHistoryId); declare_id!(EvmWalletId); declare_id!(ClientId); + declare_id!(ProposalId); + declare_id!(RecoveryOperatorIdentityId); #[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)] #[diesel(sql_type = Text)] @@ -478,9 +480,9 @@ pub struct IntegrityEnvelope { #[derive(Debug, Queryable, Selectable, Identifiable)] #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] pub struct Proposal { - pub id: i32, + pub id: ProposalId, pub kind: ProposalKindTag, - pub initiator_id: i32, + pub initiator_id: OperatorIdentityId, pub created_at: SqliteTimestamp, pub expires_at: SqliteTimestamp, pub status: ProposalStatus, @@ -490,7 +492,7 @@ pub struct Proposal { #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] pub struct NewProposal { pub kind: ProposalKindTag, - pub initiator_id: i32, + pub initiator_id: OperatorIdentityId, // status defaults to 'pending' at the DB layer pub expires_at: SqliteTimestamp, } @@ -499,8 +501,8 @@ pub struct NewProposal { #[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))] pub struct ProposalVote { pub id: i32, - pub proposal_id: i32, - pub operator_id: i32, + pub proposal_id: ProposalId, + pub operator_id: OperatorIdentityId, pub approve: bool, pub signature: Vec, pub voted_at: SqliteTimestamp, @@ -509,8 +511,8 @@ pub struct ProposalVote { #[derive(Debug, Insertable)] #[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))] pub struct NewProposalVote { - pub proposal_id: i32, - pub operator_id: i32, + pub proposal_id: ProposalId, + pub operator_id: OperatorIdentityId, pub approve: bool, pub signature: Vec, } @@ -518,8 +520,8 @@ pub struct NewProposalVote { #[derive(Debug, Insertable)] #[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))] pub struct NewRecoveryProposalVote { - pub proposal_id: i32, - pub recovery_operator_id: i32, + pub proposal_id: ProposalId, + pub recovery_operator_id: RecoveryOperatorIdentityId, pub approve: bool, pub signature: Vec, } @@ -527,5 +529,5 @@ pub struct NewRecoveryProposalVote { #[derive(Debug, Insertable)] #[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))] pub struct NewRecoveryWakeupRequest { - pub requested_by: i32, + pub requested_by: OperatorIdentityId, } diff --git a/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs index d623010..cf4ef59 100644 --- a/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs +++ b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs @@ -1,7 +1,9 @@ //! Approving an SDK client so it may authenticate against the vault. use super::{Proposal, ProposalKindTag}; -use crate::db::{DatabaseConnection, schema::proposal_approve_sdk_client as table}; +use crate::db::{ + DatabaseConnection, models::ProposalId, schema::proposal_approve_sdk_client as table, +}; use diesel::{ ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, @@ -22,7 +24,7 @@ impl Proposal for ApproveSdkClient { type Settings = Settings; async fn insert( - proposal_id: i32, + proposal_id: ProposalId, settings: &Self::Settings, conn: &mut DatabaseConnection, ) -> QueryResult<()> { @@ -33,7 +35,10 @@ impl Proposal for ApproveSdkClient { .map(drop) } - async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { table::table .find(proposal_id) .select(Settings::as_select()) diff --git a/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs index 5156b9b..b54b1e3 100644 --- a/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs +++ b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs @@ -1,7 +1,9 @@ //! Granting an SDK client visibility of a wallet. use super::{Proposal, ProposalKindTag}; -use crate::db::{DatabaseConnection, schema::proposal_grant_wallet_access as table}; +use crate::db::{ + DatabaseConnection, models::ProposalId, schema::proposal_grant_wallet_access as table, +}; use diesel::{ ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, @@ -23,7 +25,7 @@ impl Proposal for GrantWalletAccess { type Settings = Settings; async fn insert( - proposal_id: i32, + proposal_id: ProposalId, settings: &Self::Settings, conn: &mut DatabaseConnection, ) -> QueryResult<()> { @@ -34,7 +36,10 @@ impl Proposal for GrantWalletAccess { .map(drop) } - async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { table::table .find(proposal_id) .select(Settings::as_select()) diff --git a/server/crates/arbiter-server/src/db/proposal/mod.rs b/server/crates/arbiter-server/src/db/proposal/mod.rs index b70ac73..a8e756a 100644 --- a/server/crates/arbiter-server/src/db/proposal/mod.rs +++ b/server/crates/arbiter-server/src/db/proposal/mod.rs @@ -6,7 +6,7 @@ //! new kind is a new module plus one arm in each dispatcher -- nothing else in the //! codebase has to learn about it. -use crate::db::DatabaseConnection; +use crate::db::{DatabaseConnection, models::ProposalId}; use diesel::{ QueryResult, backend::Backend, @@ -42,7 +42,7 @@ pub trait Proposal: Sized { /// Writes the child row carrying `settings`. fn insert( - proposal_id: i32, + proposal_id: ProposalId, settings: &Self::Settings, conn: &mut DatabaseConnection, ) -> impl Future> + Send; @@ -50,7 +50,7 @@ pub trait Proposal: Sized { /// Reads the child row back. A missing row surfaces as [`diesel::result::Error::NotFound`], /// which is what a proposal without its parameters is. fn load( - proposal_id: i32, + proposal_id: ProposalId, conn: &mut DatabaseConnection, ) -> impl Future> + Send; } @@ -126,7 +126,7 @@ const _: () = { /// to the implementation that owns the table. pub async fn insert_kind( conn: &mut DatabaseConnection, - proposal_id: i32, + proposal_id: ProposalId, kind: &ProposalKind, ) -> QueryResult<()> { match kind { @@ -146,7 +146,7 @@ pub async fn insert_kind( /// Reads the parameters back for a `proposal.kind` that is only known at runtime. pub async fn load_kind( conn: &mut DatabaseConnection, - proposal_id: i32, + proposal_id: ProposalId, tag: ProposalKindTag, ) -> QueryResult { Ok(match tag { diff --git a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs index 4f2ec97..4c694c6 100644 --- a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs +++ b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs @@ -3,6 +3,7 @@ use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; use crate::db::{ DatabaseConnection, + models::ProposalId, schema::{proposal_one_off_transaction, proposal_one_off_transaction_result}, }; use diesel::{ @@ -28,7 +29,7 @@ pub struct Settings { #[derive(Debug, Queryable, Selectable, Insertable)] #[diesel(table_name = proposal_one_off_transaction, check_for_backend(Sqlite))] struct Row { - proposal_id: i32, + proposal_id: ProposalId, client_id: i32, wallet_address: Vec, chain_id: i64, @@ -42,7 +43,7 @@ struct Row { } impl Row { - fn new(proposal_id: i32, settings: &Settings) -> QueryResult { + fn new(proposal_id: ProposalId, settings: &Settings) -> QueryResult { Ok(Self { proposal_id, client_id: settings.client_id, @@ -82,7 +83,7 @@ impl Proposal for OneOffTransaction { type Settings = Settings; async fn insert( - proposal_id: i32, + proposal_id: ProposalId, settings: &Self::Settings, conn: &mut DatabaseConnection, ) -> QueryResult<()> { @@ -93,7 +94,10 @@ impl Proposal for OneOffTransaction { .map(drop) } - async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { let row: Row = proposal_one_off_transaction::table .find(proposal_id) .select(Row::as_select()) @@ -108,7 +112,7 @@ impl Proposal for OneOffTransaction { #[derive(Debug, Insertable)] #[diesel(table_name = proposal_one_off_transaction_result, check_for_backend(Sqlite))] struct SignatureRow { - proposal_id: i32, + proposal_id: ProposalId, r: Vec, s: Vec, y_parity: i32, @@ -117,7 +121,7 @@ struct SignatureRow { /// Records the signature produced for an approved transaction, by component, so what /// came back is as readable as what was signed. pub async fn store_signature( - proposal_id: i32, + proposal_id: ProposalId, signature: &alloy::signers::Signature, conn: &mut DatabaseConnection, ) -> QueryResult<()> { diff --git a/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs index 495a349..57f3376 100644 --- a/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs +++ b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs @@ -2,6 +2,7 @@ use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; use crate::db::{ DatabaseConnection, + models::ProposalId, schema::{ proposal_persistent_grant, proposal_persistent_grant_ether, proposal_persistent_grant_ether_target, proposal_persistent_grant_token, @@ -55,7 +56,7 @@ pub enum Specific { #[derive(Debug, Queryable, Selectable, Insertable)] #[diesel(table_name = proposal_persistent_grant, check_for_backend(Sqlite))] struct BaseRow { - proposal_id: i32, + proposal_id: ProposalId, wallet_access_id: i32, chain_id: i64, valid_from: Option, @@ -69,7 +70,7 @@ struct BaseRow { #[derive(Debug, Queryable, Selectable, Insertable)] #[diesel(table_name = proposal_persistent_grant_ether, check_for_backend(Sqlite))] struct EtherRow { - proposal_id: i32, + proposal_id: ProposalId, window_secs: i64, max_volume: Vec, } @@ -77,14 +78,14 @@ struct EtherRow { #[derive(Debug, Insertable)] #[diesel(table_name = proposal_persistent_grant_ether_target, check_for_backend(Sqlite))] struct NewEtherTarget { - proposal_id: i32, + proposal_id: ProposalId, address: Vec, } #[derive(Debug, Queryable, Selectable, Insertable)] #[diesel(table_name = proposal_persistent_grant_token, check_for_backend(Sqlite))] struct TokenRow { - proposal_id: i32, + proposal_id: ProposalId, token_contract: Vec, receiver: Option>, } @@ -92,13 +93,13 @@ struct TokenRow { #[derive(Debug, Insertable)] #[diesel(table_name = proposal_persistent_grant_token_limit, check_for_backend(Sqlite))] struct NewTokenLimit { - proposal_id: i32, + proposal_id: ProposalId, window_secs: i64, max_volume: Vec, } impl BaseRow { - fn new(proposal_id: i32, settings: &Settings) -> QueryResult { + fn new(proposal_id: ProposalId, settings: &Settings) -> QueryResult { Ok(Self { proposal_id, wallet_access_id: settings.wallet_access_id, @@ -141,7 +142,7 @@ impl Proposal for PersistentGrant { type Settings = Settings; async fn insert( - proposal_id: i32, + proposal_id: ProposalId, settings: &Self::Settings, conn: &mut DatabaseConnection, ) -> QueryResult<()> { @@ -201,7 +202,10 @@ impl Proposal for PersistentGrant { Ok(()) } - async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { let base: BaseRow = proposal_persistent_grant::table .find(proposal_id) .select(BaseRow::as_select()) diff --git a/server/crates/arbiter-server/src/db/proposal/replace_operator.rs b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs index d9e448a..1074084 100644 --- a/server/crates/arbiter-server/src/db/proposal/replace_operator.rs +++ b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs @@ -1,7 +1,11 @@ //! Replacing an operator's key, which also triggers a Shamir re-key (§3.3). use super::{Proposal, ProposalKindTag}; -use crate::db::{DatabaseConnection, schema::proposal_replace_operator as table}; +use crate::db::{ + DatabaseConnection, + models::{OperatorIdentityId, ProposalId}, + schema::proposal_replace_operator as table, +}; use diesel::{ ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, @@ -11,7 +15,7 @@ use diesel_async::RunQueryDsl as _; #[derive(Debug, Clone, PartialEq, Eq, Queryable, Selectable, Insertable)] #[diesel(table_name = table, check_for_backend(Sqlite))] pub struct Settings { - pub old_operator_id: i32, + pub old_operator_id: OperatorIdentityId, pub new_pubkey: Vec, } @@ -23,7 +27,7 @@ impl Proposal for ReplaceOperator { type Settings = Settings; async fn insert( - proposal_id: i32, + proposal_id: ProposalId, settings: &Self::Settings, conn: &mut DatabaseConnection, ) -> QueryResult<()> { @@ -34,7 +38,10 @@ impl Proposal for ReplaceOperator { .map(drop) } - async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + async fn load( + proposal_id: ProposalId, + conn: &mut DatabaseConnection, + ) -> QueryResult { table::table .find(proposal_id) .select(Settings::as_select()) diff --git a/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs index 6cf23bc..b132f74 100644 --- a/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs +++ b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs @@ -1,7 +1,7 @@ //! A Shamir re-key over the current operator set (§3.3). use super::{Proposal, ProposalKindTag}; -use crate::db::DatabaseConnection; +use crate::db::{DatabaseConnection, models::ProposalId}; use diesel::QueryResult; pub struct TriggerRekey; @@ -12,7 +12,7 @@ impl Proposal for TriggerRekey { type Settings = (); async fn insert( - _proposal_id: i32, + _proposal_id: ProposalId, _settings: &Self::Settings, _conn: &mut DatabaseConnection, ) -> QueryResult<()> { @@ -20,7 +20,7 @@ impl Proposal for TriggerRekey { } async fn load( - _proposal_id: i32, + _proposal_id: ProposalId, _conn: &mut DatabaseConnection, ) -> QueryResult { Ok(()) diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 987ab94..f1f1475 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -1,5 +1,6 @@ use crate::{ actors::proposal_manager::{Error as ProposalError, VoteOutcome}, + db::models::{OperatorIdentityId, ProposalId}, db::proposal::{ ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, persistent_grant, replace_operator, @@ -62,7 +63,7 @@ async fn handle_create( } Some(ProtoKind::ReplaceOperator(p)) => { ProposalKind::ReplaceOperator(replace_operator::Settings { - old_operator_id: p.old_operator_id, + old_operator_id: OperatorIdentityId::from_raw(p.old_operator_id), new_pubkey: p.new_pubkey, }) } @@ -87,7 +88,9 @@ async fn handle_create( })?; Ok(Some(wrap(GovResponsePayload::Created( - proto_gov::CreateProposalResponse { proposal_id }, + proto_gov::CreateProposalResponse { + proposal_id: proposal_id.to_raw(), + }, )))) } @@ -195,7 +198,7 @@ async fn handle_vote( ) -> Result, Status> { let result = actor .ask(HandleCastVote { - proposal_id: req.proposal_id, + proposal_id: ProposalId::from_raw(req.proposal_id), approve: req.approve, signature: req.signature, }) @@ -235,9 +238,9 @@ async fn handle_query( let proposals = summaries .into_iter() .map(|s| proto_gov::ProposalSummary { - id: s.id, + id: s.id.to_raw(), kind: <&'static str>::from(s.kind).to_owned(), - initiator_id: s.initiator_id, + initiator_id: s.initiator_id.to_raw(), expires_at: s.expires_at.0.timestamp(), approve_count: s.approve_count, reject_count: s.reject_count, 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 bc83e85..258ea2c 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -1,4 +1,5 @@ use super::{Error, OperatorSession}; +use crate::db::models::{OperatorIdentityId, ProposalId}; use crate::{ actors::{ evm::{ @@ -287,9 +288,9 @@ impl OperatorSession { &mut self, kind: crate::db::proposal::ProposalKind, ttl_secs: Option, - ) -> Result { + ) -> Result { use crate::actors::proposal_manager::CreateProposal; - let initiator_id = self.credentials.id; + let initiator_id = OperatorIdentityId::from_raw(self.credentials.id); self.props .actors .proposal_manager @@ -304,12 +305,12 @@ impl OperatorSession { #[message] pub(crate) async fn handle_cast_vote( &mut self, - proposal_id: i32, + proposal_id: ProposalId, approve: bool, signature: Vec, ) -> Result { use crate::actors::proposal_manager::CastVote; - let operator_id = self.credentials.id; + let operator_id = OperatorIdentityId::from_raw(self.credentials.id); self.props .actors .proposal_manager @@ -326,7 +327,7 @@ impl OperatorSession { &mut self, ) -> Vec { use crate::actors::proposal_manager::QueryPending; - let operator_id = self.credentials.id; + let operator_id = OperatorIdentityId::from_raw(self.credentials.id); self.props .actors .proposal_manager diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 426af2d..55e2c51 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -10,6 +10,7 @@ use arbiter_server::{ crypto::KeyCell, db::{ self, + models::{OperatorIdentityId, ProposalId, RecoveryOperatorIdentityId}, proposal::{ ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, persistent_grant, replace_operator, @@ -24,41 +25,45 @@ use arbiter_server::db::schema::{ use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; -async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { +async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> OperatorIdentityId { let mut conn = db.get().await.unwrap(); insert_into(operator_identity::table) .values(operator_identity::public_key.eq(pubkey.to_bytes())) .returning(operator_identity::id) - .get_result::(&mut conn) + .get_result::(&mut conn) .await .unwrap() } -async fn register_recovery_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { +async fn register_recovery_operator( + db: &db::DatabasePool, + pubkey: &authn::PublicKey, +) -> RecoveryOperatorIdentityId { let mut conn = db.get().await.unwrap(); insert_into(recovery_operator_identity::table) .values(recovery_operator_identity::public_key.eq(pubkey.to_bytes())) .returning(recovery_operator_identity::id) - .get_result::(&mut conn) + .get_result::(&mut conn) .await .unwrap() } /// Backdates a wakeup request so it appears to have passed the 14-day window. -async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: i32) { +async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: OperatorIdentityId) { let mut conn = db.get().await.unwrap(); diesel::sql_query(format!( "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ - VALUES ({operator_id}, unixepoch('now') - 14*24*3600 - 1)" + VALUES ({}, unixepoch('now') - 14*24*3600 - 1)", + operator_id.to_raw() )) .execute(&mut conn) .await .unwrap(); } -fn make_vote_message(proposal_id: i32, approve: bool) -> Vec { +fn make_vote_message(proposal_id: ProposalId, approve: bool) -> Vec { let mut msg = Vec::with_capacity(9); - msg.extend_from_slice(&(proposal_id as i64).to_be_bytes()); + msg.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes()); msg.push(u8::from(approve)); msg } @@ -128,13 +133,13 @@ async fn create_proposal_returns_id() { .proposal_manager .ask(CreateProposal { kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 42 }), - initiator_id: 1, + initiator_id: OperatorIdentityId::from_raw(1), ttl_secs: None, }) .await .unwrap(); - assert!(proposal_id > 0); + assert!(proposal_id.to_raw() > 0); } #[tokio::test] @@ -935,7 +940,7 @@ async fn key_rotation_requires_full_quorum() { .proposal_manager .ask(CreateProposal { kind: ProposalKind::ReplaceOperator(replace_operator::Settings { - old_operator_id: 1, + old_operator_id: OperatorIdentityId::from_raw(1), new_pubkey, }), initiator_id: op1, @@ -983,7 +988,7 @@ async fn recovery_vote_rejected_when_sleeping() { .proposal_manager .ask(CreateProposal { kind: ProposalKind::ReplaceOperator(replace_operator::Settings { - old_operator_id: 1, + old_operator_id: OperatorIdentityId::from_raw(1), new_pubkey, }), initiator_id: op_id, @@ -1137,7 +1142,7 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { .proposal_manager .ask(CreateProposal { kind: ProposalKind::ReplaceOperator(replace_operator::Settings { - old_operator_id: 1, + old_operator_id: OperatorIdentityId::from_raw(1), new_pubkey, }), initiator_id: op_id, -- 2.49.1 From c0546fa17fffef7fe5753df0acf7d74d484c9e33 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 13:50:02 +0200 Subject: [PATCH 45/66] refactor(db): use diesel exists() instead of counting rows --- .../src/actors/proposal_manager.rs | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 1be894d..8f59928 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -16,7 +16,10 @@ use crate::{ }, }; use chrono::Utc; -use diesel::{ExpressionMethods as _, QueryDsl}; +use diesel::{ + ExpressionMethods as _, QueryDsl, + dsl::{exists, select}, +}; use diesel_async::{AsyncConnection as _, RunQueryDsl}; use kameo::{Actor, actor::ActorRef, messages}; use strum::IntoDiscriminant as _; @@ -216,13 +219,14 @@ impl ProposalManager { })?; // Check for duplicate vote before status check so AlreadyVoted takes priority - let existing: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::proposal_vote::operator_id.eq(operator_id)) - .count() - .get_result(&mut conn) - .await?; - if existing > 0 { + let already_voted: bool = select(exists( + schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) + .filter(schema::proposal_vote::operator_id.eq(operator_id)), + )) + .get_result(&mut conn) + .await?; + if already_voted { return Err(Error::AlreadyVoted); } @@ -429,13 +433,16 @@ impl ProposalManager { return Err(Error::RecoveryNotActive); } - let existing: i64 = schema::recovery_proposal_vote::table - .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id)) - .count() - .get_result(&mut conn) - .await?; - if existing > 0 { + let already_voted: bool = select(exists( + schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) + .filter( + schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id), + ), + )) + .get_result(&mut conn) + .await?; + if already_voted { return Err(Error::AlreadyVoted); } @@ -547,30 +554,29 @@ impl ProposalManager { /// Returns true when an uncancelled wakeup request has passed the 14-day dispute window. async fn is_recovery_active_conn(conn: &mut db::DatabaseConnection) -> Result { - let count: i64 = schema::recovery_wakeup_request::table - .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .filter( - schema::recovery_wakeup_request::requested_at.le(diesel::dsl::sql::< - diesel::sql_types::Integer, - >(&format!( - "unixepoch('now') - {}", - Self::WAKEUP_DELAY_SECS - ))), - ) - .count() - .get_result(conn) - .await?; - Ok(count > 0) + let cutoff = diesel::dsl::sql::(&format!( + "unixepoch('now') - {}", + Self::WAKEUP_DELAY_SECS + )); + + select(exists( + schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .filter(schema::recovery_wakeup_request::requested_at.le(cutoff)), + )) + .get_result(conn) + .await + .map_err(Error::from) } /// Returns true when there is any uncancelled wakeup request (pending or active). async fn has_uncancelled_wakeup(conn: &mut db::DatabaseConnection) -> Result { - let count: i64 = schema::recovery_wakeup_request::table - .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .count() - .get_result(conn) - .await?; - Ok(count > 0) + select(exists(schema::recovery_wakeup_request::table.filter( + schema::recovery_wakeup_request::cancelled_at.is_null(), + ))) + .get_result(conn) + .await + .map_err(Error::from) } async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { -- 2.49.1 From e9496da78c4981b4bb9907d908b8cd1f836a8bdf Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 14:00:14 +0200 Subject: [PATCH 46/66] perf(proposal): replace the per-proposal tally loop with one grouped query --- .../src/actors/proposal_manager.rs | 66 ++++++++----- .../crates/arbiter-server/tests/governance.rs | 92 +++++++++++++++++++ 2 files changed, 133 insertions(+), 25 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 8f59928..7945bf1 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -22,6 +22,7 @@ use diesel::{ }; use diesel_async::{AsyncConnection as _, RunQueryDsl}; use kameo::{Actor, actor::ActorRef, messages}; +use std::collections::HashMap; use strum::IntoDiscriminant as _; use tracing::{error, warn}; @@ -168,32 +169,47 @@ impl ProposalManager { .await .unwrap_or_default(); - let mut summaries = Vec::with_capacity(proposals.len()); - for p in proposals { - let approve_count: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(p.id)) - .filter(schema::proposal_vote::approve.eq(true)) - .count() - .get_result(&mut conn) - .await - .unwrap_or(0); - let reject_count: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(p.id)) - .filter(schema::proposal_vote::approve.eq(false)) - .count() - .get_result(&mut conn) - .await - .unwrap_or(0); - summaries.push(ProposalSummary { - id: p.id, - kind: p.kind, - initiator_id: p.initiator_id, - expires_at: p.expires_at, - approve_count, - reject_count, - }); + let ids: Vec = proposals.iter().map(|p| p.id).collect(); + let tallies: Vec<(ProposalId, bool, i64)> = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq_any(&ids)) + .group_by(( + schema::proposal_vote::proposal_id, + schema::proposal_vote::approve, + )) + .select(( + schema::proposal_vote::proposal_id, + schema::proposal_vote::approve, + diesel::dsl::count_star(), + )) + .load(&mut conn) + .await + .unwrap_or_default(); + + let mut by_proposal: HashMap = HashMap::new(); + for (proposal_id, approve, count) in tallies { + let entry = by_proposal.entry(proposal_id).or_insert((0, 0)); + if approve { + entry.0 += count; + } else { + entry.1 += count; + } } - summaries + + proposals + .into_iter() + .map(|p| { + let (approve_count, reject_count) = + by_proposal.get(&p.id).copied().unwrap_or((0, 0)); + ProposalSummary { + id: p.id, + kind: p.kind, + initiator_id: p.initiator_id, + expires_at: p.expires_at, + approve_count, + reject_count, + } + }) + .collect() } #[message] diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 55e2c51..226721a 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -374,6 +374,98 @@ async fn invalid_signature_rejected() { )); } +#[tokio::test] +async fn query_pending_reports_a_tally_per_proposal() { + 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(); + + // Three operators, so one vote stays below the 2-of-3 threshold and every + // proposal is still pending when it is queried. + let approver = authn::SigningKey::generate(); + let rejecter = authn::SigningKey::generate(); + let watcher = authn::SigningKey::generate(); + let approver_id = register_operator(&db, &approver.public_key()).await; + let rejecter_id = register_operator(&db, &rejecter.public_key()).await; + let watcher_id = register_operator(&db, &watcher.public_key()).await; + + let cast = async |proposal_id, voter_id, key: &authn::SigningKey, approve| { + let sig = key + .sign_message( + &make_vote_message(proposal_id, approve), + SigningContext::GovernanceVote, + ) + .unwrap(); + actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: voter_id, + approve, + signature: sig.to_bytes(), + }) + .await + .unwrap() + }; + + let mut ids = Vec::new(); + for client_id in 1..=3 { + let id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: watcher_id, + ttl_secs: None, + }) + .await + .unwrap(); + ids.push(id); + } + + // First proposal: one approval. Second: one rejection. Third: neither. + assert_eq!( + cast(ids[0], approver_id, &approver, true).await, + VoteOutcome::Pending + ); + assert_eq!( + cast(ids[1], rejecter_id, &rejecter, false).await, + VoteOutcome::Pending + ); + + let summaries = actors + .proposal_manager + .ask(QueryPending { + operator_id: watcher_id, + }) + .await + .unwrap(); + assert_eq!(summaries.len(), 3, "the watcher has voted on nothing"); + + for summary in summaries { + let (approve, reject) = match summary.id { + id if id == ids[0] => (1, 0), + id if id == ids[1] => (0, 1), + _ => (0, 0), + }; + assert_eq!( + summary.approve_count, approve, + "approvals of {:?}", + summary.id + ); + assert_eq!( + summary.reject_count, reject, + "rejections of {:?}", + summary.id + ); + } +} + #[tokio::test] async fn query_pending_excludes_already_voted() { let db = db::create_test_pool().await; -- 2.49.1 From 0d29d0d5324873299f6d03ee7f268ddbb6339a88 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 14:08:48 +0200 Subject: [PATCH 47/66] refactor(db): declare unixepoch instead of formatting a SQL fragment --- .../arbiter-server/src/actors/proposal_manager.rs | 11 +++++------ server/crates/arbiter-server/src/db/functions.rs | 12 ++++++++++++ server/crates/arbiter-server/src/db/mod.rs | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 server/crates/arbiter-server/src/db/functions.rs diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 7945bf1..08a8694 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -6,6 +6,7 @@ use crate::{ }, db::{ self, + functions::unixepoch, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, @@ -570,15 +571,13 @@ impl ProposalManager { /// Returns true when an uncancelled wakeup request has passed the 14-day dispute window. async fn is_recovery_active_conn(conn: &mut db::DatabaseConnection) -> Result { - let cutoff = diesel::dsl::sql::(&format!( - "unixepoch('now') - {}", - Self::WAKEUP_DELAY_SECS - )); - select(exists( schema::recovery_wakeup_request::table .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .filter(schema::recovery_wakeup_request::requested_at.le(cutoff)), + .filter( + schema::recovery_wakeup_request::requested_at + .le(unixepoch("now") - Self::WAKEUP_DELAY_SECS), + ), )) .get_result(conn) .await diff --git a/server/crates/arbiter-server/src/db/functions.rs b/server/crates/arbiter-server/src/db/functions.rs new file mode 100644 index 0000000..faf6465 --- /dev/null +++ b/server/crates/arbiter-server/src/db/functions.rs @@ -0,0 +1,12 @@ +//! Typed bindings for the SQLite scalar functions used in Diesel expressions. + +use diesel::sql_types::{Integer, Text}; + +diesel::define_sql_function! { + /// SQLite `unixepoch(modifier)` -- seconds since the Unix epoch. + /// + /// Declared so timestamp comparisons are built by the query DSL instead of by + /// `format!`-ing a SQL fragment: the argument becomes a bind parameter and the + /// result type is checked against the column it is compared with. + fn unixepoch(modifier: Text) -> Integer; +} diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index 1ade36f..b20d45f 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -8,6 +8,7 @@ use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use thiserror::Error; use tracing::info; +pub mod functions; pub mod models; pub mod proposal; pub mod schema; -- 2.49.1 From 9e9672a1b100032d3106254f8dee59d5de9ad041 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 16:44:22 +0200 Subject: [PATCH 48/66] refactor(crypto): extract governance vote message and verification helpers --- .../src/actors/proposal_manager.rs | 36 +------ .../arbiter-server/src/crypto/governance.rs | 99 +++++++++++++++++++ .../crates/arbiter-server/src/crypto/mod.rs | 1 + .../crates/arbiter-server/tests/governance.rs | 11 +-- 4 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 server/crates/arbiter-server/src/crypto/governance.rs 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) -- 2.49.1 From 2b2c225b35bb72343a8e2538738488bfcc5af18e Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 16:49:58 +0200 Subject: [PATCH 49/66] chore: normalize line endings to LF --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf -- 2.49.1 From fa2df36fbec464821e515782a4118adf1d9de65e Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 16:57:22 +0200 Subject: [PATCH 50/66] refactor(proposal): publish approved proposals on the bus instead of executing them --- .../arbiter-server/src/actors/evm/mod.rs | 153 +++++++++- .../crates/arbiter-server/src/actors/mod.rs | 29 +- .../src/actors/proposal_manager.rs | 281 ++---------------- .../src/actors/proposal_manager/events.rs | 11 + .../arbiter-server/src/actors/vault/mod.rs | 62 +++- .../src/actors/vault_coordinator/mod.rs | 65 +++- .../arbiter-server/src/crypto/integrity/v1.rs | 36 ++- .../src/grpc/operator/governance.rs | 3 + .../src/peers/operator/session/handlers.rs | 2 +- .../crates/arbiter-server/tests/governance.rs | 143 ++++++--- 10 files changed, 465 insertions(+), 320 deletions(-) create mode 100644 server/crates/arbiter-server/src/actors/proposal_manager/events.rs diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 481c9fa..77a0dd7 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -1,9 +1,13 @@ use crate::{ - actors::vault::{CreateNew, Decrypt, Vault}, + actors::{ + proposal_manager::events::ProposalApproved, + vault::{CreateNew, Decrypt, Vault}, + }, crypto::integrity, db::{ DatabaseError, DatabasePool, - models::{self, EvmWalletId}, + models::{self, EvmWalletId, ProposalId}, + proposal::{ProposalKind, grant_wallet_access, one_off_transaction, persistent_grant}, schema, }, evm::{ @@ -23,8 +27,9 @@ use diesel::{ ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, }; use diesel_async::RunQueryDsl; -use kameo::{Actor, actor::ActorRef, messages}; +use kameo::{Actor, actor::ActorRef, messages, prelude::Message}; use rand::{SeedableRng, rng, rngs::StdRng}; +use tracing::error; pub use crate::evm::safe_signer; @@ -62,6 +67,9 @@ pub enum Error { #[error("Integrity violation: {0}")] Integrity(#[from] integrity::Error), + + #[error("Signing error: {0}")] + Sign(#[from] SignTransactionError), } #[derive(Actor)] @@ -267,3 +275,142 @@ impl EvmActor { Ok(signer.sign_transaction_sync(&mut transaction)?) } } + +impl Message for EvmActor { + type Reply = (); + + /// Every subscriber sees every approval and acts only on the kinds it owns. + async fn handle( + &mut self, + msg: ProposalApproved, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + let result = match msg.kind { + ProposalKind::GrantWalletAccess(settings) => self.grant_wallet_access(&settings).await, + ProposalKind::ApprovePersistentGrant(settings) => { + self.create_persistent_grant(*settings).await + } + ProposalKind::ApproveOneOffTransaction(settings) => { + self.sign_one_off_transaction(msg.id, *settings).await + } + _ => return, + }; + + if let Err(error) = result { + error!( + ?error, + proposal_id = msg.id.to_raw(), + "Failed to execute an approved proposal" + ); + } + } +} + +impl EvmActor { + async fn grant_wallet_access( + &mut self, + settings: &grant_wallet_access::Settings, + ) -> Result<(), Error> { + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(settings.wallet_id)), + schema::evm_wallet_access::client_id.eq(settings.client_id), + )) + .execute(&mut conn) + .await + .map_err(DatabaseError::from)?; + + Ok(()) + } + + async fn create_persistent_grant( + &mut self, + grant: persistent_grant::Settings, + ) -> Result<(), Error> { + use crate::evm::policies::{ + TransactionRateLimit, VolumeRateLimit, ether_transfer, token_transfers, + }; + use alloy::primitives::U256; + use chrono::Duration; + + let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit { + max_volume: U256::from_be_bytes(limit.max_volume), + window: Duration::seconds(limit.window_secs), + }; + + let basic = SharedGrantSettings { + wallet_access_id: grant.wallet_access_id, + chain: grant.chain_id, + valid_from: grant + .valid_from_secs + .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + valid_until: grant + .valid_until_secs + .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes), + max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes), + rate_limit: grant.rate_limit.map(|r| TransactionRateLimit { + count: r.count, + window: Duration::seconds(r.window_secs), + }), + }; + + let specific = match grant.specific { + persistent_grant::Specific::EtherTransfer { targets, limit } => { + SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: targets.into_iter().map(Address::from).collect(), + limit: volume(limit), + }) + } + persistent_grant::Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract: Address::from(token_contract), + target: receiver.map(Address::from), + volume_limits: volume_limits.into_iter().map(volume).collect(), + }), + }; + + self.operator_create_grant(basic, specific).await?; + + Ok(()) + } + + async fn sign_one_off_transaction( + &mut self, + proposal_id: ProposalId, + tx: one_off_transaction::Settings, + ) -> Result<(), Error> { + use alloy::{ + eips::eip2930::AccessList, + primitives::{Bytes, TxKind, U256}, + }; + + let transaction = TxEip1559 { + chain_id: tx.chain_id, + nonce: tx.nonce, + gas_limit: tx.gas_limit, + max_fee_per_gas: tx.max_fee_per_gas, + max_priority_fee_per_gas: tx.max_priority_fee_per_gas, + to: TxKind::Call(Address::from(tx.to)), + value: U256::from_be_bytes(tx.value), + input: Bytes::from(tx.input), + access_list: AccessList::default(), + }; + + let signature = self + .client_sign_transaction(tx.client_id, Address::from(tx.wallet_address), transaction) + .await?; + + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + one_off_transaction::store_signature(proposal_id, &signature, &mut conn) + .await + .map_err(DatabaseError::from)?; + + Ok(()) + } +} diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index 0412374..812b1be 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -1,14 +1,21 @@ use crate::{ actors::{ - bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, - operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault, + bootstrap::Bootstrapper, + evm::EvmActor, + flow_coordinator::FlowCoordinator, + operator_registry::OperatorRegistry, + proposal_manager::{ProposalManager, events::ProposalApproved}, + vault::Vault, vault_coordinator::VaultCoordinator, }, db, }; use kameo::actor::{ActorRef, Spawn}; -use kameo_actors::{DeliveryStrategy, message_bus::MessageBus}; +use kameo_actors::{ + DeliveryStrategy, + message_bus::{MessageBus, Register}, +}; use thiserror::Error; pub mod bootstrap; @@ -55,14 +62,18 @@ impl GlobalActors { db.clone(), key_holder.clone(), )); + // Approved proposals are executed by whoever owns the kind, not by ProposalManager. + for recipient in [ + evm.clone().recipient::(), + vault_coordinator.clone().recipient::(), + key_holder.clone().recipient::(), + ] { + let _ = message_bus.tell(Register(recipient)).await; + } + Ok(Self { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - proposal_manager: ProposalManager::spawn(ProposalManager::new( - db, - key_holder.clone(), - evm.clone(), - vault_coordinator.clone(), - )), + proposal_manager: ProposalManager::spawn(ProposalManager::new(db, message_bus.clone())), vault: key_holder, vault_coordinator, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index ebaf81f..62b741a 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -1,9 +1,5 @@ use crate::{ - actors::{ - evm::EvmActor, - vault::Vault, - vault_coordinator::{StartRekey, VaultCoordinator}, - }, + actors::proposal_manager::events::ProposalApproved, crypto::governance, db::{ self, @@ -13,7 +9,7 @@ use crate::{ OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, SqliteTimestamp, }, - proposal::{ProposalKind, ProposalKindTag, one_off_transaction, persistent_grant}, + proposal::{ProposalKind, ProposalKindTag}, schema, }, }; @@ -24,9 +20,12 @@ use diesel::{ }; use diesel_async::{AsyncConnection as _, RunQueryDsl}; use kameo::{Actor, actor::ActorRef, messages}; +use kameo_actors::message_bus::{MessageBus, Publish}; use std::collections::HashMap; use strum::IntoDiscriminant as _; -use tracing::{error, warn}; +use tracing::warn; + +pub mod events; pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS; @@ -58,8 +57,8 @@ pub enum Error { DatabaseConnection(#[from] db::PoolError), #[error("Database query error: {0}")] DatabaseQuery(#[from] diesel::result::Error), - #[error("Execution failed: {0}")] - ExecutionFailed(String), + #[error("Proposal manager is unavailable")] + Unavailable, #[error("Recovery operators are sleeping")] RecoveryNotActive, #[error("Recovery operators may only vote on operator replacement")] @@ -83,24 +82,12 @@ pub struct ProposalSummary { #[derive(Actor)] pub struct ProposalManager { pub(crate) db: db::DatabasePool, - pub(crate) vault: ActorRef, - pub(crate) evm: ActorRef, - pub(crate) vault_coordinator: ActorRef, + pub(crate) events: ActorRef, } impl ProposalManager { - pub const fn new( - db: db::DatabasePool, - vault: ActorRef, - evm: ActorRef, - vault_coordinator: ActorRef, - ) -> Self { - Self { - db, - vault, - evm, - vault_coordinator, - } + pub const fn new(db: db::DatabasePool, events: ActorRef) -> Self { + Self { db, events } } } @@ -343,12 +330,7 @@ impl ProposalManager { let threshold_i64 = threshold as i64; if approve_count >= threshold_i64 { - diesel::update(schema::proposal::table.find(proposal_id)) - .set(schema::proposal::status.eq(ProposalStatus::Approved)) - .execute(&mut conn) - .await?; - drop(conn); // release connection before async execution - self.execute_proposal(&proposal).await?; + self.announce_approval(&mut conn, &proposal).await?; return Ok(VoteOutcome::Approved); } @@ -505,12 +487,7 @@ impl ProposalManager { let approve_count = ordinary_approve + recovery_approve; if approve_count >= threshold_i64 { - diesel::update(schema::proposal::table.find(proposal_id)) - .set(schema::proposal::status.eq(ProposalStatus::Approved)) - .execute(&mut conn) - .await?; - drop(conn); - self.execute_proposal(&proposal).await?; + self.announce_approval(&mut conn, &proposal).await?; return Ok(VoteOutcome::Approved); } @@ -568,222 +545,30 @@ impl ProposalManager { .map_err(Error::from) } - async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { - let mut conn = self.db.get().await?; - let kind = db::proposal::load_kind(&mut conn, proposal.id, proposal.kind).await?; - drop(conn); - - match kind { - ProposalKind::ApproveSdkClient(s) => self.execute_approve_sdk_client(s.client_id).await, - ProposalKind::GrantWalletAccess(s) => { - self.execute_grant_wallet_access(s.wallet_id, s.client_id) - .await - } - ProposalKind::ReplaceOperator(s) => { - self.execute_replace_operator(s.old_operator_id, s.new_pubkey) - .await - } - ProposalKind::TriggerRekey => self.execute_trigger_rekey().await, - ProposalKind::ApprovePersistentGrant(grant) => { - self.execute_approve_persistent_grant(*grant).await - } - ProposalKind::ApproveOneOffTransaction(tx) => { - self.execute_approve_one_off_transaction(proposal.id, *tx) - .await - } - } - } - - async fn execute_grant_wallet_access( + /// Marks the proposal approved and hands the outcome to whoever owns that kind. + /// + /// The outcome is published, not executed: this actor coordinates voting and nothing + /// else. Executors subscribe on the bus, so a vote is answered once the quorum is + /// recorded rather than once the effect has landed. + async fn announce_approval( &self, - wallet_id: i32, - client_id: i32, + conn: &mut db::DatabaseConnection, + proposal: &Proposal, ) -> Result<(), Error> { - use crate::db::models::EvmWalletId; + diesel::update(schema::proposal::table.find(proposal.id)) + .set(schema::proposal::status.eq(ProposalStatus::Approved)) + .execute(conn) + .await?; - let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; - - diesel::insert_into(schema::evm_wallet_access::table) - .values(( - schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(wallet_id)), - schema::evm_wallet_access::client_id.eq(client_id), - )) - .execute(&mut conn) - .await - .map_err(|e| Error::ExecutionFailed(format!("grant wallet access: {e}")))?; + let kind = db::proposal::load_kind(conn, proposal.id, proposal.kind).await?; + let _ = self + .events + .tell(Publish(ProposalApproved { + id: proposal.id, + kind, + })) + .await; Ok(()) } - - /// Updates the old operator's public key in-place (preserving their DB id and history), - /// removes their old Shamir share, then begins a coordinated re-key (§3.3). - async fn execute_replace_operator( - &self, - old_operator_id: OperatorIdentityId, - new_pubkey: Vec, - ) -> Result<(), Error> { - let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; - - diesel::update(schema::operator_identity::table) - .filter(schema::operator_identity::id.eq(old_operator_id)) - .set(schema::operator_identity::public_key.eq(&new_pubkey)) - .execute(&mut conn) - .await - .map_err(|e| Error::ExecutionFailed(format!("update operator pubkey: {e}")))?; - - // Remove the old Shamir share; finalize_rekey will store a fresh one. - diesel::delete(schema::operator::table) - .filter(schema::operator::id.eq(Some(old_operator_id))) - .execute(&mut conn) - .await - .map_err(|e| Error::ExecutionFailed(format!("remove old operator share: {e}")))?; - - drop(conn); - - self.vault_coordinator - .ask(StartRekey {}) - .await - .map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?; - - Ok(()) - } - - /// Triggers a Shamir re-key with the current operator set (§3.3). - async fn execute_trigger_rekey(&self) -> Result<(), Error> { - self.vault_coordinator - .ask(StartRekey {}) - .await - .map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?; - Ok(()) - } - - async fn execute_approve_one_off_transaction( - &self, - proposal_id: ProposalId, - tx: one_off_transaction::Settings, - ) -> Result<(), Error> { - use crate::actors::evm::ClientSignTransaction; - use alloy::{ - consensus::TxEip1559, - eips::eip2930::AccessList, - primitives::{Address, Bytes, TxKind, U256}, - }; - - let transaction = TxEip1559 { - chain_id: tx.chain_id, - nonce: tx.nonce, - gas_limit: tx.gas_limit, - max_fee_per_gas: tx.max_fee_per_gas, - max_priority_fee_per_gas: tx.max_priority_fee_per_gas, - to: TxKind::Call(Address::from(tx.to)), - value: U256::from_be_bytes(tx.value), - input: Bytes::from(tx.input), - access_list: AccessList::default(), - }; - - let sig = self - .evm - .ask(ClientSignTransaction { - client_id: tx.client_id, - wallet_address: Address::from(tx.wallet_address), - transaction, - }) - .await - .map_err(|e| Error::ExecutionFailed(format!("sign one-off tx: {e}")))?; - - let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; - one_off_transaction::store_signature(proposal_id, &sig, &mut conn) - .await - .map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?; - - Ok(()) - } - - async fn execute_approve_persistent_grant( - &self, - grant: persistent_grant::Settings, - ) -> Result<(), Error> { - use crate::{ - actors::evm::OperatorCreateGrant, - evm::policies::{ - SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, - ether_transfer, token_transfers, - }, - }; - use alloy::primitives::{Address, U256}; - use chrono::Duration; - - let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit { - max_volume: U256::from_be_bytes(limit.max_volume), - window: Duration::seconds(limit.window_secs), - }; - - let basic = SharedGrantSettings { - wallet_access_id: grant.wallet_access_id, - chain: grant.chain_id, - valid_from: grant - .valid_from_secs - .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - valid_until: grant - .valid_until_secs - .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes), - max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes), - rate_limit: grant.rate_limit.map(|r| TransactionRateLimit { - count: r.count, - window: Duration::seconds(r.window_secs), - }), - }; - - let grant = match grant.specific { - persistent_grant::Specific::EtherTransfer { targets, limit } => { - SpecificGrant::EtherTransfer(ether_transfer::Settings { - target: targets.into_iter().map(Address::from).collect(), - limit: volume(limit), - }) - } - persistent_grant::Specific::TokenTransfer { - token_contract, - receiver, - volume_limits, - } => SpecificGrant::TokenTransfer(token_transfers::Settings { - token_contract: Address::from(token_contract), - target: receiver.map(Address::from), - volume_limits: volume_limits.into_iter().map(volume).collect(), - }), - }; - - self.evm - .ask(OperatorCreateGrant { basic, grant }) - .await - .map_err(|e| Error::ExecutionFailed(format!("create grant: {e}")))?; - - Ok(()) - } - - async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { - use crate::{crypto::integrity, peers::client::ClientCredentials}; - use arbiter_crypto::authn; - - let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; - - let pubkey_bytes: Vec = schema::program_client::table - .find(client_id) - .select(schema::program_client::public_key) - .first(&mut conn) - .await - .map_err(|e| Error::ExecutionFailed(format!("client not found: {e}")))?; - - let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice()) - .map_err(|()| Error::ExecutionFailed("invalid client public key".to_owned()))?; - - let creds = ClientCredentials { pubkey }; - - integrity::sign_entity(&mut conn, &self.vault, &creds, client_id) - .await - .map_err(|e| { - error!(?e, "Failed to sign integrity envelope for client"); - Error::ExecutionFailed(e.to_string()) - }) - } } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/events.rs b/server/crates/arbiter-server/src/actors/proposal_manager/events.rs new file mode 100644 index 0000000..2e2cfd7 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager/events.rs @@ -0,0 +1,11 @@ +use crate::db::{models::ProposalId, proposal::ProposalKind}; + +/// Published once a proposal reaches its approval threshold. +/// +/// Executors subscribe on the global `MessageBus` and act on the kinds they own; +/// `ProposalManager` does not know who acts on an outcome, or whether anyone does. +#[derive(Debug, Clone)] +pub struct ProposalApproved { + pub id: ProposalId, + pub kind: ProposalKind, +} diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index ad9d721..14fde5d 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -1,12 +1,14 @@ use crate::{ + actors::proposal_manager::events::ProposalApproved, crypto::{ KeyCell, encryption::v1::{self, Nonce}, - integrity::v1::HmacSha256, + integrity::{self, v1::HmacSha256}, }, db::{ self, models::{self, RootKeyHistory, RootKeyHistoryId}, + proposal::ProposalKind, schema, }, }; @@ -19,7 +21,7 @@ use diesel::{ }; use diesel_async::{AsyncConnection, RunQueryDsl}; use hmac::{KeyInit as _, Mac as _}; -use kameo::{Actor, Reply, actor::ActorRef, messages}; +use kameo::{Actor, Reply, actor::ActorRef, messages, prelude::Message}; use kameo_actors::message_bus::{MessageBus, Publish}; use strum::{EnumDiscriminants, IntoDiscriminant}; use tracing::{error, info}; @@ -461,6 +463,62 @@ impl Vault { } } +impl Message for Vault { + type Reply = (); + + /// Every subscriber sees every approval and acts only on the kinds it owns. + async fn handle( + &mut self, + msg: ProposalApproved, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + let ProposalKind::ApproveSdkClient(settings) = msg.kind else { + return; + }; + + if let Err(error) = self.approve_sdk_client(settings.client_id).await { + error!( + ?error, + proposal_id = msg.id.to_raw(), + "Failed to execute an approved proposal" + ); + } + } +} + +impl Vault { + /// Attests an approved SDK client with the root key. + /// + /// Builds the envelope from its parts rather than calling `integrity::sign_entity`, + /// which would have this actor ask itself for a signature and deadlock. + async fn approve_sdk_client(&mut self, client_id: i32) -> Result<(), Error> { + use crate::peers::client::ClientCredentials; + use arbiter_crypto::authn; + + // Cloned so the connection does not hold a borrow of `self` across `sign_integrity`. + let db = self.db.clone(); + let mut conn = db.get().await?; + + let pubkey_bytes: Vec = schema::program_client::table + .find(client_id) + .select(schema::program_client::public_key) + .first(&mut conn) + .await?; + + let pubkey = + authn::PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|()| Error::InvalidKey)?; + let credentials = ClientCredentials { pubkey }; + + let (entity_id, mac_input) = integrity::envelope_input(&credentials, client_id); + let (key_version, mac) = self.sign_integrity(mac_input)?; + + integrity::store_envelope::(&mut conn, entity_id, key_version, mac) + .await?; + + Ok(()) + } +} + #[cfg(test)] mod tests { use crate::actors::GlobalActors; diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 1b723bd..ba403ca 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -3,14 +3,21 @@ use std::collections::HashMap; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; -use kameo::{Actor, actor::ActorRef, messages}; +use kameo::{Actor, actor::ActorRef, messages, prelude::Message}; use rand_core::{OsRng, RngCore as _}; use tracing::error; use crate::{ - actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault}, + actors::{ + proposal_manager::events::ProposalApproved, + vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault}, + }, crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold}, - db::{self, models, schema}, + db::{ + self, models, + proposal::{ProposalKind, replace_operator}, + schema, + }, }; #[derive(Debug, thiserror::Error)] @@ -719,3 +726,55 @@ impl VaultCoordinator { self.do_finalize_rekey().await } } + +impl Message for VaultCoordinator { + type Reply = (); + + /// Every subscriber sees every approval and acts only on the kinds it owns. + async fn handle( + &mut self, + msg: ProposalApproved, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + let result = match msg.kind { + ProposalKind::ReplaceOperator(settings) => self.replace_operator(&settings).await, + ProposalKind::TriggerRekey => self.start_rekey().await, + _ => return, + }; + + if let Err(error) = result { + error!( + ?error, + proposal_id = msg.id.to_raw(), + "Failed to execute an approved proposal" + ); + } + } +} + +impl VaultCoordinator { + /// Replaces the operator's public key in place, keeping their id and history, drops the + /// share that key no longer matches, then begins a coordinated re-key (§3.3). + async fn replace_operator( + &mut self, + settings: &replace_operator::Settings, + ) -> Result<(), Error> { + let mut conn = self.db.get().await?; + + diesel::update(schema::operator_identity::table) + .filter(schema::operator_identity::id.eq(settings.old_operator_id)) + .set(schema::operator_identity::public_key.eq(&settings.new_pubkey)) + .execute(&mut conn) + .await?; + + // Drop the stale Shamir share; finalize_rekey stores a fresh one. + diesel::delete(schema::operator::table) + .filter(schema::operator::id.eq(Some(settings.old_operator_id))) + .execute(&mut conn) + .await?; + + drop(conn); + + self.start_rekey().await + } +} diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index 9feb840..9154552 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -2,7 +2,7 @@ use crate::{ actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity}, db::{ self, - models::{IntegrityEnvelope, NewIntegrityEnvelope}, + models::{IntegrityEnvelope, NewIntegrityEnvelope, RootKeyHistoryId}, schema::integrity_envelope, }, }; @@ -109,11 +109,7 @@ pub async fn sign_entity( entity: &E, entity_id: impl IntoId, ) -> Result<(), Error> { - let payload_hash = payload_hash(&entity); - - let entity_id = entity_id.into_id(); - - let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); + let (entity_id, mac_input) = envelope_input::(entity, entity_id); let (key_version, mac) = vault @@ -124,6 +120,31 @@ pub async fn sign_entity( _ => Error::VaultSend, })?; + store_envelope::(conn, entity_id, key_version, mac) + .await + .map_err(db::DatabaseError::from)?; + + Ok(()) +} + +/// The entity id and the bytes the root key covers, as a pair. +/// +/// Split out of [`sign_entity`] so the `Vault` actor can build an envelope from inside a +/// message handler, where asking itself for a signature would deadlock. +pub fn envelope_input(entity: &E, entity_id: impl IntoId) -> (Vec, Vec) { + let payload_hash = payload_hash(entity); + let entity_id = entity_id.into_id(); + let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); + (entity_id, mac_input) +} + +/// Stores the integrity envelope for one entity, replacing any envelope it already has. +pub async fn store_envelope( + conn: &mut impl AsyncConnection, + entity_id: Vec, + key_version: RootKeyHistoryId, + mac: Vec, +) -> Result<(), diesel::result::Error> { insert_into(integrity_envelope::table) .values(NewIntegrityEnvelope { entity_kind: E::KIND.to_owned(), @@ -143,8 +164,7 @@ pub async fn sign_entity( integrity_envelope::mac.eq(mac), )) .execute(conn) - .await - .map_err(db::DatabaseError::from)?; + .await?; Ok(()) } diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index f1f1475..145f7ee 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -217,6 +217,9 @@ async fn handle_vote( Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => { return Err(Status::not_found("Proposal not found")); } + Err(kameo::error::SendError::HandlerError(ProposalError::Unavailable)) => { + return Err(Status::unavailable("Proposal manager is unavailable")); + } Err(e) => { warn!(?e, "cast_vote failed"); return Err(Status::internal("Failed to cast vote")); 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 258ea2c..6fc8d59 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -318,7 +318,7 @@ impl OperatorSession { .await .map_err(|err| match err { SendError::HandlerError(e) => e, - _ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()), + _ => crate::actors::proposal_manager::Error::Unavailable, }) } diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index a25dea9..1819809 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -26,6 +26,26 @@ use arbiter_server::db::schema::{ }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; +use std::future::Future; + +/// Retries `probe` until it yields a value, then returns it. +/// +/// Outcome execution is asynchronous: `CastVote` answers as soon as the quorum is +/// recorded, and the actor that owns the kind runs afterwards off the message bus. Tests +/// therefore wait for the effect instead of reading the database straight after the vote. +async fn eventually(what: &str, mut probe: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, +{ + for _ in 0..100 { + if let Some(value) = probe().await { + return value; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + panic!("{what} did not happen within 2s"); +} async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> OperatorIdentityId { let mut conn = db.get().await.unwrap(); @@ -628,14 +648,20 @@ async fn approve_sdk_client_writes_integrity_envelope() { assert_eq!(outcome, VoteOutcome::Approved); - let mut conn = db.get().await.unwrap(); - let count: i64 = integrity_envelope::table - .filter(integrity_envelope::entity_kind.eq("client_credentials")) - .count() - .get_result(&mut conn) - .await - .unwrap(); - assert_eq!(count, 1); + eventually("the client's integrity envelope", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let count: i64 = integrity_envelope::table + .filter(integrity_envelope::entity_kind.eq("client_credentials")) + .count() + .get_result(&mut conn) + .await + .unwrap(); + (count == 1).then_some(()) + } + }) + .await; } #[tokio::test] @@ -682,15 +708,21 @@ async fn grant_wallet_access_on_quorum_approval() { assert_eq!(outcome, VoteOutcome::Approved); - let mut conn = db.get().await.unwrap(); - let count: i64 = evm_wallet_access::table - .filter(evm_wallet_access::wallet_id.eq(wallet_id)) - .filter(evm_wallet_access::client_id.eq(client_id)) - .count() - .get_result(&mut conn) - .await - .unwrap(); - assert_eq!(count, 1); + eventually("the wallet access row", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_wallet_access::table + .filter(evm_wallet_access::wallet_id.eq(wallet_id)) + .filter(evm_wallet_access::client_id.eq(client_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + (count == 1).then_some(()) + } + }) + .await; } #[tokio::test] @@ -767,14 +799,20 @@ async fn approve_persistent_grant_creates_basic_grant_row() { assert_eq!(outcome, VoteOutcome::Approved); - let mut conn = db.get().await.unwrap(); - let count: i64 = evm_basic_grant::table - .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) - .count() - .get_result(&mut conn) - .await - .unwrap(); - assert_eq!(count, 1); + eventually("the basic grant row", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_basic_grant::table + .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + (count == 1).then_some(()) + } + }) + .await; } #[tokio::test] @@ -881,17 +919,23 @@ async fn approve_one_off_transaction_stores_result() { assert_eq!(outcome, VoteOutcome::Approved); - let mut conn = db.get().await.unwrap(); - let (r, s, y_parity): (Vec, Vec, i32) = proposal_one_off_transaction_result::table - .find(proposal_id) - .select(( - proposal_one_off_transaction_result::r, - proposal_one_off_transaction_result::s, - proposal_one_off_transaction_result::y_parity, - )) - .first(&mut conn) - .await - .expect("an approved transaction must leave its signature"); + let (r, s, y_parity): (Vec, Vec, i32) = eventually("the transaction signature", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + proposal_one_off_transaction_result::table + .find(proposal_id) + .select(( + proposal_one_off_transaction_result::r, + proposal_one_off_transaction_result::s, + proposal_one_off_transaction_result::y_parity, + )) + .first(&mut conn) + .await + .ok() + } + }) + .await; assert_eq!(r.len(), 32, "r must be a 32-byte scalar"); assert_eq!(s.len(), 32, "s must be a 32-byte scalar"); @@ -944,23 +988,30 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { assert_eq!(outcome, VoteOutcome::Approved); + eventually("the operator's public key to be replaced", || { + let db = db.clone(); + let new_pubkey = new_pubkey.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let stored: Vec = operator_identity::table + .filter(operator_identity::id.eq(op_id)) + .select(operator_identity::public_key) + .first(&mut conn) + .await + .unwrap(); + (stored == new_pubkey).then_some(()) + } + }) + .await; + + // The old identity row is updated in place, so no second operator appears. let mut conn = db.get().await.unwrap(); - // The old identity row is updated in-place; count stays the same. let count: i64 = operator_identity::table .count() .get_result(&mut conn) .await .unwrap(); assert_eq!(count, 1); - - // Verify the public key was updated to the new one. - let stored_pubkey: Vec = operator_identity::table - .filter(operator_identity::id.eq(op_id)) - .select(operator_identity::public_key) - .first(&mut conn) - .await - .unwrap(); - assert_eq!(stored_pubkey, new_pubkey.clone()); } #[tokio::test] -- 2.49.1 From f32da65467f9d037354679ac514a9ecb4cfae434 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 17:58:26 +0200 Subject: [PATCH 51/66] refactor(proposal): put DB access behind a mockable ProposalStore trait --- server/Cargo.lock | 80 + server/crates/arbiter-server/Cargo.toml | 1 + .../src/actors/proposal_manager.rs | 497 +-- .../src/actors/proposal_manager/store.rs | 409 +++ .../src/actors/proposal_manager/tests.rs | 222 ++ .../crates/arbiter-server/tests/governance.rs | 1 - useragent/rust/Cargo.lock | 3187 ++++++++++++++++- 7 files changed, 3934 insertions(+), 463 deletions(-) create mode 100644 server/crates/arbiter-server/src/actors/proposal_manager/store.rs create mode 100644 server/crates/arbiter-server/src/actors/proposal_manager/tests.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index b8ee5e4..d08434b 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -674,6 +674,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -767,6 +773,7 @@ dependencies = [ "kameo", "kameo_actors", "ml-dsa", + "mockall", "mutants", "pem", "proptest", @@ -1983,6 +1990,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "downcast-rs" version = "2.0.2" @@ -2235,6 +2248,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3352,6 +3374,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mockall" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "module-lattice" version = "0.2.2" @@ -3785,6 +3833,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5171,6 +5245,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-log" version = "0.2.20" diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index ee58227..b17e739 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -59,6 +59,7 @@ proptest = "1.11.0" rstest.workspace = true test-log = { version = "0.2", default-features = false, features = ["trace"] } ml-dsa.workspace = true +mockall = "0.15.0" [lib] doctest = false diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 62b741a..03a6938 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -1,35 +1,34 @@ use crate::{ - actors::proposal_manager::events::ProposalApproved, + actors::proposal_manager::{ + events::ProposalApproved, + store::{DieselProposalStore, ProposalStore, Tally}, + }, crypto::governance, db::{ self, - functions::unixepoch, models::{ - NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, - OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, - SqliteTimestamp, + NewProposalVote, NewRecoveryProposalVote, OperatorIdentityId, Proposal, ProposalId, + ProposalStatus, RecoveryOperatorIdentityId, SqliteTimestamp, }, proposal::{ProposalKind, ProposalKindTag}, - schema, }, }; use chrono::Utc; -use diesel::{ - ExpressionMethods as _, QueryDsl, - dsl::{exists, select}, -}; -use diesel_async::{AsyncConnection as _, RunQueryDsl}; use kameo::{Actor, actor::ActorRef, messages}; use kameo_actors::message_bus::{MessageBus, Publish}; -use std::collections::HashMap; -use strum::IntoDiscriminant as _; +use std::sync::Arc; use tracing::warn; pub mod events; +pub mod store; pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS; +/// Recovery operators stay asleep for this long after a wake-up is requested, so the other +/// operators have time to dispute it (§3.6). +const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; // 14 days + #[derive(Debug, Clone, PartialEq, Eq)] pub enum VoteOutcome { Pending, @@ -81,13 +80,21 @@ pub struct ProposalSummary { #[derive(Actor)] pub struct ProposalManager { - pub(crate) db: db::DatabasePool, + pub(crate) store: Arc, pub(crate) events: ActorRef, } impl ProposalManager { - pub const fn new(db: db::DatabasePool, events: ActorRef) -> Self { - Self { db, events } + pub fn new(db: db::DatabasePool, events: ActorRef) -> Self { + Self::with_store(Arc::new(DieselProposalStore::new(db)), events) + } + + /// Builds the actor over an arbitrary store, so tests can supply a mock. + pub(crate) const fn with_store( + store: Arc, + events: ActorRef, + ) -> Self { + Self { store, events } } } @@ -107,98 +114,18 @@ impl ProposalManager { let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl))); - let id: ProposalId = self - .db - .get() - .await? - .transaction(async |conn| { - let id: ProposalId = diesel::insert_into(schema::proposal::table) - .values(&NewProposal { - kind: kind.discriminant(), - initiator_id, - expires_at, - }) - .returning(schema::proposal::id) - .get_result(conn) - .await?; - db::proposal::insert_kind(conn, id, &kind).await?; - Ok::<_, diesel::result::Error>(id) - }) - .await?; - - Ok(id) + self.store.create(kind, initiator_id, expires_at).await } #[message] pub async fn query_pending(&mut self, operator_id: OperatorIdentityId) -> Vec { - #[expect( - clippy::cast_possible_truncation, - clippy::as_conversions, - reason = "fixme! #84; this will break in 2038" - )] - let now_ts = Utc::now().timestamp() as i32; - - let Ok(mut conn) = self.db.get().await else { - warn!("query_pending: failed to acquire DB connection"); - return vec![]; - }; - - let voted_ids: Vec = schema::proposal_vote::table - .filter(schema::proposal_vote::operator_id.eq(operator_id)) - .select(schema::proposal_vote::proposal_id) - .load(&mut conn) + self.store + .pending_for(operator_id) .await - .unwrap_or_default(); - - let proposals: Vec = schema::proposal::table - .filter(schema::proposal::status.eq(ProposalStatus::Pending)) - .filter(schema::proposal::expires_at.gt(now_ts)) - .filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids))) - .load(&mut conn) - .await - .unwrap_or_default(); - - let ids: Vec = proposals.iter().map(|p| p.id).collect(); - let tallies: Vec<(ProposalId, bool, i64)> = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq_any(&ids)) - .group_by(( - schema::proposal_vote::proposal_id, - schema::proposal_vote::approve, - )) - .select(( - schema::proposal_vote::proposal_id, - schema::proposal_vote::approve, - diesel::dsl::count_star(), - )) - .load(&mut conn) - .await - .unwrap_or_default(); - - let mut by_proposal: HashMap = HashMap::new(); - for (proposal_id, approve, count) in tallies { - let entry = by_proposal.entry(proposal_id).or_insert((0, 0)); - if approve { - entry.0 += count; - } else { - entry.1 += count; - } - } - - proposals - .into_iter() - .map(|p| { - let (approve_count, reject_count) = - by_proposal.get(&p.id).copied().unwrap_or((0, 0)); - ProposalSummary { - id: p.id, - kind: p.kind, - initiator_id: p.initiator_id, - expires_at: p.expires_at, - approve_count, - reject_count, - } + .unwrap_or_else(|e| { + warn!(?e, "query_pending failed"); + vec![] }) - .collect() } #[message] @@ -209,141 +136,35 @@ impl ProposalManager { approve: bool, signature: Vec, ) -> Result { - let mut conn = self.db.get().await?; + let proposal = self.store.load(proposal_id).await?; - // Load proposal — must exist - let proposal: Proposal = schema::proposal::table - .find(proposal_id) - .first(&mut conn) - .await - .map_err(|e| match e { - diesel::result::Error::NotFound => Error::ProposalNotFound, - other => Error::DatabaseQuery(other), - })?; - - // Check for duplicate vote before status check so AlreadyVoted takes priority - let already_voted: bool = select(exists( - schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::proposal_vote::operator_id.eq(operator_id)), - )) - .get_result(&mut conn) - .await?; - if already_voted { + // Checked before the status check so AlreadyVoted takes priority. + if self.store.has_voted(proposal_id, operator_id).await? { return Err(Error::AlreadyVoted); } - if proposal.status != ProposalStatus::Pending { - return Err(Error::ProposalNotPending); - } + Self::check_votable(&proposal)?; - if proposal.expires_at.0 <= Utc::now() { - return Err(Error::ProposalExpired); - } - - // Load operator public key from operator_identity - let pubkey_bytes: Vec = schema::operator_identity::table - .find(operator_id) - .select(schema::operator_identity::public_key) - .first(&mut conn) - .await - .map_err(|e| match e { - diesel::result::Error::NotFound => Error::OperatorNotFound, - other => Error::DatabaseQuery(other), - })?; - - governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature) + let public_key = self.store.operator_public_key(operator_id).await?; + governance::verify_vote(&public_key, proposal_id, approve, &signature) .map_err(|_| Error::InvalidSignature)?; - // Insert vote - diesel::insert_into(schema::proposal_vote::table) - .values(&NewProposalVote { + self.store + .record_vote(NewProposalVote { proposal_id, operator_id, approve, signature, }) - .execute(&mut conn) .await?; - // Quorum check - let total_operators: i64 = schema::operator_identity::table - .count() - .get_result(&mut conn) - .await?; - let recovery_active = Self::is_recovery_active_conn(&mut conn).await?; - let total_recovery: i64 = if recovery_active { - schema::recovery_operator_identity::table - .count() - .get_result(&mut conn) - .await? - } else { - 0 - }; - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::as_conversions, - reason = "operator count is always a small positive integer" - )] - let threshold = if proposal.kind.requires_full_quorum() { - // §3.3: key-rotation proposals require every eligible voter to approve - // §3.5: when recovery is active, recovery operators also vote on replace_operator - (total_operators + total_recovery) as usize - } else { - crate::crypto::shamir::shamir_threshold(total_operators as usize) - }; - - let ordinary_approve: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::proposal_vote::approve.eq(true)) - .count() - .get_result(&mut conn) - .await?; - let recovery_approve: i64 = schema::recovery_proposal_vote::table - .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::recovery_proposal_vote::approve.eq(true)) - .count() - .get_result(&mut conn) - .await?; - let approve_count = ordinary_approve + recovery_approve; - - let ordinary_reject: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::proposal_vote::approve.eq(false)) - .count() - .get_result(&mut conn) - .await?; - let recovery_reject: i64 = schema::recovery_proposal_vote::table - .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::recovery_proposal_vote::approve.eq(false)) - .count() - .get_result(&mut conn) - .await?; - let reject_count = ordinary_reject + recovery_reject; - - #[expect( - clippy::cast_possible_wrap, - clippy::as_conversions, - reason = "threshold is derived from operator count, always fits i64" - )] - let threshold_i64 = threshold as i64; - - if approve_count >= threshold_i64 { - self.announce_approval(&mut conn, &proposal).await?; - return Ok(VoteOutcome::Approved); + let mut tally = self.store.tally(proposal_id).await?; + // §3.5: recovery operators only join the electorate once they are awake. + if !self.store.is_recovery_active().await? { + tally.total_recovery = 0; } - let total_eligible = total_operators + total_recovery; - if reject_count > total_eligible - threshold_i64 { - diesel::update(schema::proposal::table.find(proposal_id)) - .set(schema::proposal::status.eq(ProposalStatus::Rejected)) - .execute(&mut conn) - .await?; - return Ok(VoteOutcome::Rejected); - } - - Ok(VoteOutcome::Pending) + self.settle(&proposal, &tally).await } /// §3.6: Any ordinary operator may request recovery wake-up. @@ -353,17 +174,10 @@ impl ProposalManager { &mut self, operator_id: OperatorIdentityId, ) -> Result<(), Error> { - let mut conn = self.db.get().await?; - if Self::has_uncancelled_wakeup(&mut conn).await? { + if self.store.has_uncancelled_wakeup().await? { return Err(Error::WakeupAlreadyPending); } - diesel::insert_into(schema::recovery_wakeup_request::table) - .values(&NewRecoveryWakeupRequest { - requested_by: operator_id, - }) - .execute(&mut conn) - .await?; - Ok(()) + self.store.request_wakeup(operator_id).await } /// §3.6: Any ordinary operator may cancel a pending wake-up request. @@ -373,19 +187,11 @@ impl ProposalManager { &mut self, operator_id: OperatorIdentityId, ) -> Result<(), Error> { - let mut conn = self.db.get().await?; - let rows_updated = diesel::update(schema::recovery_wakeup_request::table) - .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .set(( - schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)), - schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())), - )) - .execute(&mut conn) - .await?; - if rows_updated == 0 { - return Err(Error::NoActiveWakeup); + if self.store.cancel_wakeup(operator_id).await? { + Ok(()) + } else { + Err(Error::NoActiveWakeup) } - Ok(()) } /// §3.5: Recovery operators may only vote on operator replacement proposals. @@ -398,151 +204,106 @@ impl ProposalManager { approve: bool, signature: Vec, ) -> Result { - let mut conn = self.db.get().await?; - - let proposal: Proposal = schema::proposal::table - .find(proposal_id) - .first(&mut conn) - .await - .map_err(|e| match e { - diesel::result::Error::NotFound => Error::ProposalNotFound, - other => Error::DatabaseQuery(other), - })?; + let proposal = self.store.load(proposal_id).await?; if proposal.kind != ProposalKindTag::ReplaceOperator { return Err(Error::NotAllowedForRecoveryOperator); } - if !Self::is_recovery_active_conn(&mut conn).await? { + if !self.store.is_recovery_active().await? { return Err(Error::RecoveryNotActive); } - let already_voted: bool = select(exists( - schema::recovery_proposal_vote::table - .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) - .filter( - schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id), - ), - )) - .get_result(&mut conn) - .await?; - if already_voted { + if self + .store + .has_recovery_voted(proposal_id, recovery_operator_id) + .await? + { return Err(Error::AlreadyVoted); } - if proposal.status != ProposalStatus::Pending { - return Err(Error::ProposalNotPending); - } + Self::check_votable(&proposal)?; - if proposal.expires_at.0 <= Utc::now() { - return Err(Error::ProposalExpired); - } - - let pubkey_bytes: Vec = schema::recovery_operator_identity::table - .find(recovery_operator_id) - .select(schema::recovery_operator_identity::public_key) - .first(&mut conn) - .await - .map_err(|e| match e { - diesel::result::Error::NotFound => Error::OperatorNotFound, - other => Error::DatabaseQuery(other), - })?; - - governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature) + let public_key = self + .store + .recovery_operator_public_key(recovery_operator_id) + .await?; + governance::verify_vote(&public_key, proposal_id, approve, &signature) .map_err(|_| Error::InvalidSignature)?; - diesel::insert_into(schema::recovery_proposal_vote::table) - .values(&NewRecoveryProposalVote { + self.store + .record_recovery_vote(NewRecoveryProposalVote { proposal_id, recovery_operator_id, approve, signature, }) - .execute(&mut conn) .await?; - // Quorum: all ordinary + all recovery operators must approve (§3.3 + §3.5) - let total_ordinary: i64 = schema::operator_identity::table - .count() - .get_result(&mut conn) - .await?; - let total_recovery: i64 = schema::recovery_operator_identity::table - .count() - .get_result(&mut conn) - .await?; - let threshold_i64 = total_ordinary + total_recovery; - - let ordinary_approve: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::proposal_vote::approve.eq(true)) - .count() - .get_result(&mut conn) - .await?; - let recovery_approve: i64 = schema::recovery_proposal_vote::table - .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::recovery_proposal_vote::approve.eq(true)) - .count() - .get_result(&mut conn) - .await?; - let approve_count = ordinary_approve + recovery_approve; - - if approve_count >= threshold_i64 { - self.announce_approval(&mut conn, &proposal).await?; - return Ok(VoteOutcome::Approved); - } - - let recovery_reject: i64 = schema::recovery_proposal_vote::table - .filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::recovery_proposal_vote::approve.eq(false)) - .count() - .get_result(&mut conn) - .await?; - let ordinary_reject: i64 = schema::proposal_vote::table - .filter(schema::proposal_vote::proposal_id.eq(proposal_id)) - .filter(schema::proposal_vote::approve.eq(false)) - .count() - .get_result(&mut conn) - .await?; - let reject_count = ordinary_reject + recovery_reject; - - if reject_count > threshold_i64 - approve_count - reject_count { - diesel::update(schema::proposal::table.find(proposal_id)) - .set(schema::proposal::status.eq(ProposalStatus::Rejected)) - .execute(&mut conn) - .await?; - return Ok(VoteOutcome::Rejected); - } - - Ok(VoteOutcome::Pending) + let tally = self.store.tally(proposal_id).await?; + self.settle(&proposal, &tally).await } } impl ProposalManager { - const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; - - /// Returns true when an uncancelled wakeup request has passed the 14-day dispute window. - async fn is_recovery_active_conn(conn: &mut db::DatabaseConnection) -> Result { - select(exists( - schema::recovery_wakeup_request::table - .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .filter( - schema::recovery_wakeup_request::requested_at - .le(unixepoch("now") - Self::WAKEUP_DELAY_SECS), - ), - )) - .get_result(conn) - .await - .map_err(Error::from) + /// A vote only counts while the proposal is still open. + fn check_votable(proposal: &Proposal) -> Result<(), Error> { + if proposal.status != ProposalStatus::Pending { + return Err(Error::ProposalNotPending); + } + if proposal.expires_at.0 <= Utc::now() { + return Err(Error::ProposalExpired); + } + Ok(()) } - /// Returns true when there is any uncancelled wakeup request (pending or active). - async fn has_uncancelled_wakeup(conn: &mut db::DatabaseConnection) -> Result { - select(exists(schema::recovery_wakeup_request::table.filter( - schema::recovery_wakeup_request::cancelled_at.is_null(), - ))) - .get_result(conn) - .await - .map_err(Error::from) + /// Pure quorum arithmetic — no I/O, so the rules can be tested directly (§3.3). + /// + /// A proposal is rejected once approval has become unreachable: even if every voter + /// who has not spoken yet approved, the threshold could not be met. + #[must_use] + pub(crate) const fn evaluate_quorum(tally: &Tally, requires_full_quorum: bool) -> VoteOutcome { + let total_eligible = tally.total_ordinary + tally.total_recovery; + + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::as_conversions, + reason = "operator counts are always small positive integers" + )] + // §3.3: key-rotation proposals require every eligible voter to approve. + // §3.5: when recovery is active, recovery operators are eligible too. + let threshold: i64 = if requires_full_quorum { + total_eligible + } else { + crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) as i64 + }; + + if tally.approve >= threshold { + VoteOutcome::Approved + } else if tally.reject > total_eligible - threshold { + VoteOutcome::Rejected + } else { + VoteOutcome::Pending + } + } + + /// Applies the quorum rules to a fresh tally and records whatever they decide. + async fn settle(&self, proposal: &Proposal, tally: &Tally) -> Result { + let outcome = Self::evaluate_quorum(tally, proposal.kind.requires_full_quorum()); + + match outcome { + VoteOutcome::Approved => self.announce_approval(proposal).await?, + VoteOutcome::Rejected => { + self.store + .set_status(proposal.id, ProposalStatus::Rejected) + .await?; + } + VoteOutcome::Pending => {} + } + + Ok(outcome) } /// Marks the proposal approved and hands the outcome to whoever owns that kind. @@ -550,17 +311,12 @@ impl ProposalManager { /// The outcome is published, not executed: this actor coordinates voting and nothing /// else. Executors subscribe on the bus, so a vote is answered once the quorum is /// recorded rather than once the effect has landed. - async fn announce_approval( - &self, - conn: &mut db::DatabaseConnection, - proposal: &Proposal, - ) -> Result<(), Error> { - diesel::update(schema::proposal::table.find(proposal.id)) - .set(schema::proposal::status.eq(ProposalStatus::Approved)) - .execute(conn) + async fn announce_approval(&self, proposal: &Proposal) -> Result<(), Error> { + self.store + .set_status(proposal.id, ProposalStatus::Approved) .await?; - let kind = db::proposal::load_kind(conn, proposal.id, proposal.kind).await?; + let kind = self.store.load_kind(proposal.id, proposal.kind).await?; let _ = self .events .tell(Publish(ProposalApproved { @@ -572,3 +328,6 @@ impl ProposalManager { Ok(()) } } + +#[cfg(test)] +mod tests; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs new file mode 100644 index 0000000..f735a7e --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs @@ -0,0 +1,409 @@ +//! Database access for [`super::ProposalManager`], behind a trait. +//! +//! The actor holds a `dyn ProposalStore` rather than a connection pool, so the quorum +//! rules can be exercised against a mock instead of a live SQLite file. + +use super::{Error, ProposalSummary, WAKEUP_DELAY_SECS}; +use crate::db::{ + self, + functions::unixepoch, + models::{ + NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, + OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, + SqliteTimestamp, + }, + proposal::{ProposalKind, ProposalKindTag}, + schema, +}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{ + ExpressionMethods as _, QueryDsl, + dsl::{exists, select}, +}; +use diesel_async::{AsyncConnection as _, RunQueryDsl}; +use std::collections::HashMap; +use strum::IntoDiscriminant as _; + +/// Everything the quorum rules need to know about one proposal's votes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Tally { + pub approve: i64, + pub reject: i64, + pub total_ordinary: i64, + pub total_recovery: i64, +} + +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub trait ProposalStore: Send + Sync + 'static { + /// Writes the proposal and its kind-specific rows in one transaction. + async fn create( + &self, + kind: ProposalKind, + initiator_id: OperatorIdentityId, + expires_at: SqliteTimestamp, + ) -> Result; + + async fn load(&self, id: ProposalId) -> Result; + + async fn load_kind(&self, id: ProposalId, tag: ProposalKindTag) -> Result; + + async fn has_voted( + &self, + id: ProposalId, + operator_id: OperatorIdentityId, + ) -> Result; + + async fn has_recovery_voted( + &self, + id: ProposalId, + recovery_operator_id: RecoveryOperatorIdentityId, + ) -> Result; + + async fn operator_public_key(&self, id: OperatorIdentityId) -> Result, Error>; + + async fn recovery_operator_public_key( + &self, + id: RecoveryOperatorIdentityId, + ) -> Result, Error>; + + async fn record_vote(&self, vote: NewProposalVote) -> Result<(), Error>; + + async fn record_recovery_vote(&self, vote: NewRecoveryProposalVote) -> Result<(), Error>; + + /// Vote counts for one proposal, alongside the size of each electorate. + async fn tally(&self, id: ProposalId) -> Result; + + async fn set_status(&self, id: ProposalId, status: ProposalStatus) -> Result<(), Error>; + + /// Pending, unexpired proposals this operator has not voted on yet. + async fn pending_for( + &self, + operator_id: OperatorIdentityId, + ) -> Result, Error>; + + /// True once an uncancelled wake-up request has outlived the dispute window. + async fn is_recovery_active(&self) -> Result; + + /// True while any wake-up request stands, whether or not the window has elapsed. + async fn has_uncancelled_wakeup(&self) -> Result; + + async fn request_wakeup(&self, operator_id: OperatorIdentityId) -> Result<(), Error>; + + /// Returns false when there was no uncancelled request to cancel. + async fn cancel_wakeup(&self, operator_id: OperatorIdentityId) -> Result; +} + +pub struct DieselProposalStore { + db: db::DatabasePool, +} + +impl DieselProposalStore { + pub const fn new(db: db::DatabasePool) -> Self { + Self { db } + } +} + +/// `NotFound` means the row is absent, which every caller reports as its own error. +fn missing(absent: Error) -> impl FnOnce(diesel::result::Error) -> Error { + move |e| match e { + diesel::result::Error::NotFound => absent, + other => Error::DatabaseQuery(other), + } +} + +#[async_trait] +impl ProposalStore for DieselProposalStore { + async fn create( + &self, + kind: ProposalKind, + initiator_id: OperatorIdentityId, + expires_at: SqliteTimestamp, + ) -> Result { + let id = self + .db + .get() + .await? + .transaction(async |conn| { + let id: ProposalId = diesel::insert_into(schema::proposal::table) + .values(&NewProposal { + kind: kind.discriminant(), + initiator_id, + expires_at, + }) + .returning(schema::proposal::id) + .get_result(conn) + .await?; + db::proposal::insert_kind(conn, id, &kind).await?; + Ok::<_, diesel::result::Error>(id) + }) + .await?; + + Ok(id) + } + + async fn load(&self, id: ProposalId) -> Result { + let mut conn = self.db.get().await?; + schema::proposal::table + .find(id) + .first(&mut conn) + .await + .map_err(missing(Error::ProposalNotFound)) + } + + async fn load_kind(&self, id: ProposalId, tag: ProposalKindTag) -> Result { + let mut conn = self.db.get().await?; + db::proposal::load_kind(&mut conn, id, tag) + .await + .map_err(Error::from) + } + + async fn has_voted( + &self, + id: ProposalId, + operator_id: OperatorIdentityId, + ) -> Result { + let mut conn = self.db.get().await?; + select(exists( + schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(id)) + .filter(schema::proposal_vote::operator_id.eq(operator_id)), + )) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn has_recovery_voted( + &self, + id: ProposalId, + recovery_operator_id: RecoveryOperatorIdentityId, + ) -> Result { + let mut conn = self.db.get().await?; + select(exists( + schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(id)) + .filter( + schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id), + ), + )) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn operator_public_key(&self, id: OperatorIdentityId) -> Result, Error> { + let mut conn = self.db.get().await?; + schema::operator_identity::table + .find(id) + .select(schema::operator_identity::public_key) + .first(&mut conn) + .await + .map_err(missing(Error::OperatorNotFound)) + } + + async fn recovery_operator_public_key( + &self, + id: RecoveryOperatorIdentityId, + ) -> Result, Error> { + let mut conn = self.db.get().await?; + schema::recovery_operator_identity::table + .find(id) + .select(schema::recovery_operator_identity::public_key) + .first(&mut conn) + .await + .map_err(missing(Error::OperatorNotFound)) + } + + async fn record_vote(&self, vote: NewProposalVote) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::insert_into(schema::proposal_vote::table) + .values(&vote) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn record_recovery_vote(&self, vote: NewRecoveryProposalVote) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::insert_into(schema::recovery_proposal_vote::table) + .values(&vote) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn tally(&self, id: ProposalId) -> Result { + let mut conn = self.db.get().await?; + + let ordinary_approve: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(id)) + .filter(schema::proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + let recovery_approve: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(id)) + .filter(schema::recovery_proposal_vote::approve.eq(true)) + .count() + .get_result(&mut conn) + .await?; + + let ordinary_reject: i64 = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq(id)) + .filter(schema::proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + let recovery_reject: i64 = schema::recovery_proposal_vote::table + .filter(schema::recovery_proposal_vote::proposal_id.eq(id)) + .filter(schema::recovery_proposal_vote::approve.eq(false)) + .count() + .get_result(&mut conn) + .await?; + + let total_ordinary: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await?; + let total_recovery: i64 = schema::recovery_operator_identity::table + .count() + .get_result(&mut conn) + .await?; + + Ok(Tally { + approve: ordinary_approve + recovery_approve, + reject: ordinary_reject + recovery_reject, + total_ordinary, + total_recovery, + }) + } + + async fn set_status(&self, id: ProposalId, status: ProposalStatus) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::update(schema::proposal::table.find(id)) + .set(schema::proposal::status.eq(status)) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn pending_for( + &self, + operator_id: OperatorIdentityId, + ) -> Result, Error> { + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "fixme! #84; this will break in 2038" + )] + let now_ts = Utc::now().timestamp() as i32; + + let mut conn = self.db.get().await?; + + let voted_ids: Vec = schema::proposal_vote::table + .filter(schema::proposal_vote::operator_id.eq(operator_id)) + .select(schema::proposal_vote::proposal_id) + .load(&mut conn) + .await?; + + let proposals: Vec = schema::proposal::table + .filter(schema::proposal::status.eq(ProposalStatus::Pending)) + .filter(schema::proposal::expires_at.gt(now_ts)) + .filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids))) + .load(&mut conn) + .await?; + + let ids: Vec = proposals.iter().map(|p| p.id).collect(); + let tallies: Vec<(ProposalId, bool, i64)> = schema::proposal_vote::table + .filter(schema::proposal_vote::proposal_id.eq_any(&ids)) + .group_by(( + schema::proposal_vote::proposal_id, + schema::proposal_vote::approve, + )) + .select(( + schema::proposal_vote::proposal_id, + schema::proposal_vote::approve, + diesel::dsl::count_star(), + )) + .load(&mut conn) + .await?; + + let mut by_proposal: HashMap = HashMap::new(); + for (proposal_id, approve, count) in tallies { + let entry = by_proposal.entry(proposal_id).or_insert((0, 0)); + if approve { + entry.0 += count; + } else { + entry.1 += count; + } + } + + Ok(proposals + .into_iter() + .map(|p| { + let (approve_count, reject_count) = + by_proposal.get(&p.id).copied().unwrap_or((0, 0)); + ProposalSummary { + id: p.id, + kind: p.kind, + initiator_id: p.initiator_id, + expires_at: p.expires_at, + approve_count, + reject_count, + } + }) + .collect()) + } + + async fn is_recovery_active(&self) -> Result { + let mut conn = self.db.get().await?; + select(exists( + schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .filter( + schema::recovery_wakeup_request::requested_at + .le(unixepoch("now") - WAKEUP_DELAY_SECS), + ), + )) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn has_uncancelled_wakeup(&self) -> Result { + let mut conn = self.db.get().await?; + select(exists(schema::recovery_wakeup_request::table.filter( + schema::recovery_wakeup_request::cancelled_at.is_null(), + ))) + .get_result(&mut conn) + .await + .map_err(Error::from) + } + + async fn request_wakeup(&self, operator_id: OperatorIdentityId) -> Result<(), Error> { + let mut conn = self.db.get().await?; + diesel::insert_into(schema::recovery_wakeup_request::table) + .values(&NewRecoveryWakeupRequest { + requested_by: operator_id, + }) + .execute(&mut conn) + .await?; + Ok(()) + } + + async fn cancel_wakeup(&self, operator_id: OperatorIdentityId) -> Result { + let mut conn = self.db.get().await?; + let rows = diesel::update(schema::recovery_wakeup_request::table) + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .set(( + schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)), + schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())), + )) + .execute(&mut conn) + .await?; + Ok(rows > 0) + } +} diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs new file mode 100644 index 0000000..7a8a607 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs @@ -0,0 +1,222 @@ +//! The quorum rules, exercised without a database. +//! +//! These assertions are the point of [`super::store::ProposalStore`]: until the actor took +//! its data through a trait, checking that two of three operators carry an ordinary +//! proposal meant opening SQLite and registering operators first. + +use super::{ + ProposalManager, VoteOutcome, + store::{MockProposalStore, Tally}, +}; +use crate::{ + actors::GlobalActors, + crypto::governance::vote_message, + db::{ + models::{OperatorIdentityId, Proposal, ProposalId, ProposalStatus, SqliteTimestamp}, + proposal::ProposalKindTag, + }, +}; +use arbiter_crypto::authn::{SigningContext, SigningKey}; +use chrono::{Duration, Utc}; +use std::sync::Arc; + +const fn tally(approve: i64, reject: i64, ordinary: i64, recovery: i64) -> Tally { + Tally { + approve, + reject, + total_ordinary: ordinary, + total_recovery: recovery, + } +} + +#[test] +fn simple_majority_approves_at_two_of_three() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 0, 3, 0), false), + VoteOutcome::Approved + ); +} + +#[test] +fn one_of_three_is_not_yet_a_majority() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(1, 0, 3, 0), false), + VoteOutcome::Pending + ); +} + +#[test] +fn full_quorum_kind_needs_every_voter() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 0, 3, 0), true), + VoteOutcome::Pending, + "two of three must not carry a key-rotation proposal" + ); +} + +#[test] +fn recovery_voters_count_towards_full_quorum() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(3, 0, 2, 1), true), + VoteOutcome::Approved + ); + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 0, 2, 1), true), + VoteOutcome::Pending, + "the sleeping recovery operator still owes a vote" + ); +} + +#[test] +fn rejection_is_decided_once_approval_is_unreachable() { + // Threshold is 2 of 3, so two rejections leave at most one approval available. + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 2, 3, 0), false), + VoteOutcome::Rejected + ); + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 1, 3, 0), false), + VoteOutcome::Pending, + "one rejection still leaves two approvals reachable" + ); +} + +#[test] +fn a_single_rejection_sinks_a_full_quorum_proposal() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(2, 1, 3, 0), true), + VoteOutcome::Rejected + ); +} + +fn pending_proposal(id: ProposalId, kind: ProposalKindTag) -> Proposal { + let now = Utc::now(); + Proposal { + id, + kind, + initiator_id: OperatorIdentityId::from_raw(1), + created_at: SqliteTimestamp::from(now), + expires_at: SqliteTimestamp::from(now + Duration::days(1)), + status: ProposalStatus::Pending, + } +} + +/// The mock earns its keep here: reaching quorum must flip the stored status to +/// `Approved` exactly once. Signature verification stays real -- only the database is +/// stubbed out. +#[tokio::test] +async fn reaching_quorum_marks_the_proposal_approved() { + let id = ProposalId::from_raw(1); + let voter = OperatorIdentityId::from_raw(1); + let key = SigningKey::generate(); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .expect("signing a vote must succeed"); + let public_key = key.public_key().to_bytes(); + + let mut store = MockProposalStore::new(); + store + .expect_load() + .returning(move |id| Ok(pending_proposal(id, ProposalKindTag::TriggerRekey))); + store.expect_has_voted().returning(|_, _| Ok(false)); + store + .expect_operator_public_key() + .returning(move |_| Ok(public_key.clone())); + store.expect_record_vote().returning(|_| Ok(())); + store.expect_is_recovery_active().returning(|| Ok(false)); + store.expect_tally().returning(|_| Ok(tally(1, 0, 1, 0))); + store + .expect_set_status() + .withf(move |got, status| *got == id && *status == ProposalStatus::Approved) + .times(1) + .returning(|_, _| Ok(())); + store + .expect_load_kind() + .returning(|_, _| Ok(crate::db::proposal::ProposalKind::TriggerRekey)); + + let mut manager = + ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); + + let outcome = manager + .cast_vote(id, voter, true, signature.to_bytes()) + .await + .expect("a valid vote must be accepted"); + + assert_eq!(outcome, VoteOutcome::Approved); +} + +/// A vote that does not reach the threshold must leave the stored status alone. +#[tokio::test] +async fn a_vote_short_of_quorum_does_not_touch_the_status() { + let id = ProposalId::from_raw(7); + let voter = OperatorIdentityId::from_raw(2); + let key = SigningKey::generate(); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .expect("signing a vote must succeed"); + let public_key = key.public_key().to_bytes(); + + let mut store = MockProposalStore::new(); + store + .expect_load() + .returning(move |id| Ok(pending_proposal(id, ProposalKindTag::ApproveSdkClient))); + store.expect_has_voted().returning(|_, _| Ok(false)); + store + .expect_operator_public_key() + .returning(move |_| Ok(public_key.clone())); + store.expect_record_vote().returning(|_| Ok(())); + store.expect_is_recovery_active().returning(|| Ok(false)); + store.expect_tally().returning(|_| Ok(tally(1, 0, 3, 0))); + store.expect_set_status().never(); + + let mut manager = + ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); + + let outcome = manager + .cast_vote(id, voter, true, signature.to_bytes()) + .await + .expect("a valid vote must be accepted"); + + assert_eq!(outcome, VoteOutcome::Pending); +} + +/// A sleeping recovery electorate must not raise the bar for an ordinary proposal. +#[tokio::test] +async fn sleeping_recovery_operators_do_not_count_towards_quorum() { + let id = ProposalId::from_raw(9); + let voter = OperatorIdentityId::from_raw(3); + let key = SigningKey::generate(); + let signature = key + .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) + .expect("signing a vote must succeed"); + let public_key = key.public_key().to_bytes(); + + let mut store = MockProposalStore::new(); + store + .expect_load() + .returning(move |id| Ok(pending_proposal(id, ProposalKindTag::ApproveSdkClient))); + store.expect_has_voted().returning(|_, _| Ok(false)); + store + .expect_operator_public_key() + .returning(move |_| Ok(public_key.clone())); + store.expect_record_vote().returning(|_| Ok(())); + store.expect_is_recovery_active().returning(|| Ok(false)); + // Two recovery operators exist but are asleep, so the threshold stays at 1 of 1. + store.expect_tally().returning(|_| Ok(tally(1, 0, 1, 2))); + store.expect_set_status().times(1).returning(|_, _| Ok(())); + store.expect_load_kind().returning(|_, _| { + Ok(crate::db::proposal::ProposalKind::ApproveSdkClient( + crate::db::proposal::approve_sdk_client::Settings { client_id: 1 }, + )) + }); + + let mut manager = + ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); + + let outcome = manager + .cast_vote(id, voter, true, signature.to_bytes()) + .await + .expect("a valid vote must be accepted"); + + assert_eq!(outcome, VoteOutcome::Approved); +} diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 1819809..2c5ba2d 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -26,7 +26,6 @@ use arbiter_server::db::schema::{ }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; -use std::future::Future; /// Retries `probe` until it yields a value, then returns it. /// diff --git a/useragent/rust/Cargo.lock b/useragent/rust/Cargo.lock index 5442523..d89b5cb 100644 --- a/useragent/rust/Cargo.lock +++ b/useragent/rust/Cargo.lock @@ -36,7 +36,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -171,6 +171,649 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9867f85f660948ddbef071ae484027654c04b62c7decd5b61464a7ed1f8931a0" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-ens", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" +dependencies = [ + "alloy-primitives", + "num_enum", + "phf 0.14.0", +] + +[[package]] +name = "alloy-consensus" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a542fe1e6a48cfd010a9e8eba1235436de58ee0f1415e588c27d5d556e98df" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.8", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-consensus-any" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ac1559726a5e71d6dbab59cbd8ad0988c762105d2ae41ae4f3241a3166262d" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86722084d6d07822a6c67e39ae794d437fb9de12fa4127d4f9fd93f89651121" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtimer", +] + +[[package]] +name = "alloy-core" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88cf3d065edfb29a13278215b8521d3ef72a41e2432e019c1f0dd8e30649a5d" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f1a3f2206f2ba4206fdeeddce6640eed3e26b8a13ac41444adb66b76d8e650" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow 1.0.1", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb59cca9e58f5c624becc3cffb32772dc278f31eeae4606844a88592e8d2672" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eips" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40246adf7468b430fce87945ece8a54ef27fa760dd9f670f5162a412007bdb2" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", +] + +[[package]] +name = "alloy-ens" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9aa797694e7c55b007aa8f172daef76a83272cbedb8800bdc7038eed3bdb1a" +dependencies = [ + "alloy-contract", + "alloy-primitives", + "alloy-provider", + "alloy-sol-types", + "async-trait", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-genesis" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4951473e6ff25cdf82eb87b6a1a5fbb4af9c97528398a4a62fbf516198ec5028" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "alloy-trie", + "borsh", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-json-abi" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "208699c66c453fbb4c50d2e602f8ceff8a5f1fa48ac8b6ee3b6357fdc93da311" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e5722ea29a9a85ecdba2516c58f50d71e77cd25f8ce7e9f4ee8570ddeee6df1" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e4d278913f00ab611f5367448a3a800fed173ab791c3cc67b7a1b0d7afd305" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-network-primitives" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e52ae56d1acaa6b46e9d7015b8ae6c10f3d1c213a32ae70b17426a03ae6729" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c902f0ca3f8353c41e3e1ec3cf26be49412525bc48ab9d3c4710d7be4f01832" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "fixed-cache", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.13.1", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.5", + "rapidhash", + "ruint", + "rustc-hash 2.1.2", + "secp256k1 0.31.1", + "serde", + "sha3", +] + +[[package]] +name = "alloy-provider" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "386124639a5c386329b9f2ef2b42969badbf7bba40422b513e3e5739c516dc34" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap 6.1.0", + "either", + "futures", + "futures-utils-wasm", + "lru", + "parking_lot", + "pin-project", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-rpc-client" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfd3ebc1756426a6a9e17979d4467632aff01f359136c50e5320ac6e32d6b8d" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417dc664965e36fc43a4f9c8eb8b2d14067a29c430e23470d2c923c1c977bac7" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0a6cce3f97e5d9c0048081b5d173e0e406c2056c52c012e578a1d451ff0cdc" +dependencies = [ + "alloy-consensus-any", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb4c87dfdde83cd97e03a8c0c5e986b52e14075e02266e322ac6c7eb02e7399" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-serde" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff89f75b8a9d00f659750e9220c31b42d67c69ffc43b2ac8911dfd0506eb4a6" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e1f6fae3ade809daa4c32bee35a30a439bb307209d7b42f3995988b5cc74b8" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-signer-local" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "621cc39c34b85875858ef149598f3d5f9797dff9a3247f3606fcc06f9b20acc8" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.8", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdcbd48d60e029be4a325c3a2f1312761caea4ed249f18ba9e8ed24ca1bf01e6" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c9f7c535f99a7e7b64cc520968b09ed14cec3715572fcc277cfbff602808cd" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.13.1", + "proc-macro-error3", + "proc-macro2", + "quote", + "sha3", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1abd404fbc12f543823005146b73fd07621bdc0baaa950d26995c543a9d73811" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a7fd71864526bfeca8903010d5bb7fd28a0a4f5cc55818304c9cad8f0d63ab" +dependencies = [ + "serde", + "winnow 1.0.1", +] + +[[package]] +name = "alloy-sol-types" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adfc2ba3fb0e865de4934bcad6d37fc51e9ffcd5294be1322eab38e4494e051b" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06f4ca8484d6295d5165f67de8fdb00ae630ba585efb606f003171286b56441c" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8342aa5a7f8dee6d606307eebbc847d9799a823c7e4f37b9c1e32b08715f73c8" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "itertools 0.14.0", + "reqwest", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d0626c4f3b2028f7e8db32f53c22d9ecd5939f772317b7c4b6fe5219cb0589a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -232,10 +875,14 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "arbiter-crypto" version = "0.1.0" dependencies = [ + "alloy", "chrono", + "hmac 0.13.0", "memsafe", "ml-dsa", - "rand", + "rand 0.10.2", + "strum", + "thiserror 2.0.18", "x-wing", ] @@ -259,6 +906,269 @@ dependencies = [ "x11rb", ] +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -379,7 +1289,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -400,6 +1310,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -414,7 +1346,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -466,12 +1398,46 @@ dependencies = [ "zbus", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -487,6 +1453,18 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -508,6 +1486,42 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -520,6 +1534,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -569,6 +1595,51 @@ dependencies = [ "piper", ] +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "build-target" version = "0.4.0" @@ -581,6 +1652,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytemuck" version = "1.25.0" @@ -598,7 +1675,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -618,6 +1695,24 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] [[package]] name = "calloop" @@ -672,12 +1767,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.83" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ + "find-msvc-tools", "jobserver", "libc", + "shlex", ] [[package]] @@ -709,7 +1806,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -735,6 +1832,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.3" @@ -790,12 +1896,60 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -864,6 +2018,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -885,6 +2054,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -901,8 +2082,9 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ + "getrandom 0.4.2", "hybrid-array", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -930,7 +2112,7 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", - "rustc_version", + "rustc_version 0.4.1", "subtle", "zeroize", ] @@ -943,7 +2125,42 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", ] [[package]] @@ -968,6 +2185,51 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "delegate-attr" version = "0.3.0" @@ -976,7 +2238,17 @@ checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", ] [[package]] @@ -985,10 +2257,62 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", + "const-oid 0.10.2", "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -996,7 +2320,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.6", + "subtle", ] [[package]] @@ -1007,6 +2333,7 @@ checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1033,7 +2360,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1066,6 +2393,33 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecolor" version = "0.34.1" @@ -1076,6 +2430,18 @@ dependencies = [ "emath", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "eframe" version = "0.34.1" @@ -1189,6 +2555,35 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "emath" version = "0.34.1" @@ -1204,6 +2599,26 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1222,7 +2637,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1321,6 +2736,28 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + [[package]] name = "fax" version = "0.2.6" @@ -1338,7 +2775,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1359,12 +2796,50 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.8", + "rustc-hex", + "static_assertions", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1414,7 +2889,7 @@ dependencies = [ "md-5", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1456,7 +2931,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1474,6 +2949,18 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.29" @@ -1543,7 +3030,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1575,6 +3062,12 @@ dependencies = [ "slab", ] +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + [[package]] name = "generic-array" version = "0.14.7" @@ -1583,6 +3076,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1595,6 +3089,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1614,11 +3121,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1638,6 +3147,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.17.0" @@ -1750,6 +3265,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -1762,6 +3288,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1788,6 +3320,19 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "heck" version = "0.5.0" @@ -1812,12 +3357,87 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + [[package]] name = "hexf-parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.10" @@ -1829,6 +3449,64 @@ dependencies = [ "zeroize", ] +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1941,6 +3619,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1976,6 +3660,37 @@ dependencies = [ "tiff", ] +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1988,12 +3703,98 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2019,9 +3820,9 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -2049,7 +3850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2074,6 +3875,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + [[package]] name = "keccak" version = "0.2.0" @@ -2084,6 +3899,16 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + [[package]] name = "kem" version = "0.3.0" @@ -2091,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ "crypto-common 0.2.1", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -2111,6 +3936,21 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kurbo" version = "0.13.0" @@ -2213,6 +4053,32 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2277,19 +4143,30 @@ dependencies = [ ] [[package]] -name = "ml-dsa" -version = "0.1.0-rc.8" +name = "mio" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5b2bb0ad6fa2b40396775bd56f51345171490fef993f46f91a876ecdbdaea55" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ - "const-oid", + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.1", "ctutils", "hybrid-array", "module-lattice", - "pkcs8", - "rand_core", - "sha3", - "signature", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", "zeroize", ] @@ -2302,16 +4179,16 @@ dependencies = [ "hybrid-array", "kem", "module-lattice", - "rand_core", + "rand_core 0.10.0", "sha3", "zeroize", ] [[package]] name = "module-lattice" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "164eb3faeaecbd14b0b2a917c1b4d0c035097a9c559b0bed85c2cdd032bc8faa" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" dependencies = [ "ctutils", "hybrid-array", @@ -2344,7 +4221,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap", + "indexmap 2.13.1", "libm", "log", "num-traits", @@ -2391,6 +4268,31 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2430,7 +4332,21 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", ] [[package]] @@ -2757,6 +4673,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.51" @@ -2793,7 +4715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" dependencies = [ "cc", - "dashmap", + "dashmap 5.5.3", "log", ] @@ -2806,6 +4728,34 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -2835,6 +4785,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peniko" version = "0.6.0" @@ -2854,6 +4810,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + [[package]] name = "phf" version = "0.13.1" @@ -2861,10 +4827,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -2872,7 +4847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2882,10 +4857,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2897,6 +4872,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2914,7 +4898,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2923,6 +4907,12 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -2936,12 +4926,22 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.11.0-rc.11" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -3013,6 +5013,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -3026,7 +5041,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", ] [[package]] @@ -3038,6 +5064,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3053,6 +5101,21 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.11.0", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -3084,6 +5147,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3106,14 +5226,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand" -version = "0.10.0" +name = "radium" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", + "serde", ] [[package]] @@ -3122,12 +5310,39 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3183,6 +5398,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "regex" version = "1.10.2" @@ -3218,6 +5453,112 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.8", + "rand 0.9.5", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + [[package]] name = "rust_lib_arbiter" version = "0.1.0" @@ -3247,13 +5588,28 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.28", ] [[package]] @@ -3282,6 +5638,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3297,6 +5728,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3322,18 +5786,115 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.228" @@ -3361,7 +5922,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3385,7 +5946,61 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3398,6 +6013,33 @@ dependencies = [ "keccak", ] +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.2", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3410,12 +6052,22 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.10" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.2", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -3430,7 +6082,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "rustc_version", + "rustc_version 0.4.1", "simdutf8", ] @@ -3479,6 +6131,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -3552,6 +6207,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -3561,6 +6226,16 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -3568,9 +6243,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3589,12 +6270,50 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3606,6 +6325,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e452eb8cb83fc8b81597eb07c8d39f770d04905af9c5bffce8bea7213df29960" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3614,9 +6365,15 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -3665,7 +6422,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3676,7 +6433,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3702,6 +6459,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3738,14 +6525,79 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.34.0" +name = "tinyvec" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ - "backtrace", - "num_cpus", + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] @@ -3763,7 +6615,7 @@ version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ - "indexmap", + "indexmap 2.13.1", "toml_datetime", "toml_parser", "winnow 1.0.1", @@ -3778,6 +6630,51 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3798,7 +6695,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3810,6 +6707,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3831,6 +6734,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3842,6 +6751,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -3866,6 +6793,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3876,6 +6809,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3895,6 +6829,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vello_common" version = "0.0.6" @@ -3937,6 +6877,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -3997,7 +6952,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4027,7 +6982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -4040,8 +6995,22 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.13.1", + "semver 1.0.28", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", ] [[package]] @@ -4215,6 +7184,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4265,7 +7243,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.13.1", "log", "naga", "once_cell", @@ -4482,7 +7460,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4493,7 +7471,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4802,9 +7780,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.1", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4820,7 +7798,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4833,7 +7811,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -4852,9 +7830,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.1", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -4868,6 +7846,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x-wing" version = "0.1.0-rc.0" @@ -4876,7 +7863,7 @@ checksum = "e17d0d5f4d1f26b9b9e7477af1d3bef960e1d1fb64edab7912fde472a8a8432e" dependencies = [ "kem", "ml-kem", - "rand_core", + "rand_core 0.10.0", "sha3", "x25519-dalek", "zeroize", @@ -4921,7 +7908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d5d6ff67acd3945b933e592bfa7143db4fcbb2f871754b6b9fbd7847fc5aea" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.10.0", "zeroize", ] @@ -4975,7 +7962,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5032,7 +8019,7 @@ checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus-lockstep", "zbus_xml", "zvariant", @@ -5047,7 +8034,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -5093,7 +8080,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5113,7 +8100,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5122,6 +8109,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -5153,7 +8154,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5200,7 +8201,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -5213,6 +8214,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "winnow 0.7.15", ] -- 2.49.1 From d916997ef35cfc80caabd32db22efdefbc6c8dfd Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 13:03:57 +0200 Subject: [PATCH 52/66] fix(crypto): return None from shamir_threshold for an empty committee --- .../src/actors/proposal_manager.rs | 6 ++- .../src/actors/vault_coordinator/mod.rs | 15 +++++-- .../arbiter-server/src/crypto/shamir.rs | 31 +++++++++++--- .../arbiter-server/tests/vault/lifecycle.rs | 42 +++++++++++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 03a6938..c153d0b 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -277,7 +277,11 @@ impl ProposalManager { let threshold: i64 = if requires_full_quorum { total_eligible } else { - crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) as i64 + match crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) { + Some(threshold) => threshold as i64, + // No ordinary operators means no electorate: nothing can settle. + None => return VoteOutcome::Pending, + } }; if tally.approve >= threshold { diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index ba403ca..af967fd 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -52,6 +52,8 @@ pub enum Error { TwoOperatorsRequireRecovery, #[error("Broken database")] BrokenDatabase, + #[error("A committee must have at least one ordinary operator")] + EmptyCommittee, } // Passphrases stored as plain Vec (not SafeCell) so CoordinatorState is Sync. @@ -153,7 +155,7 @@ async fn finalize_bootstrap( let ordinary_count = ordinary_passphrases.len(); let recovery_count = recovery_passphrases.len(); let total = ordinary_count + recovery_count; - let threshold = shamir_threshold(ordinary_count); + let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?; let mut seal_key_bytes = [0u8; 32]; OsRng.fill_bytes(&mut seal_key_bytes); @@ -232,7 +234,8 @@ async fn finalize_unseal( .count() .get_result(&mut conn) .await?; - let threshold = shamir_threshold(ordinary_operator_count as usize); + let threshold = + shamir_threshold(ordinary_operator_count as usize).ok_or(Error::EmptyCommittee)?; let mut shares: Vec> = Vec::new(); @@ -314,7 +317,7 @@ async fn finalize_rekey( let ordinary_count = ordinary_passphrases.len(); let recovery_count = recovery_passphrases.len(); let total = ordinary_count + recovery_count; - let threshold = shamir_threshold(ordinary_count); + let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?; let mut new_seal_key_bytes = [0u8; 32]; OsRng.fill_bytes(&mut new_seal_key_bytes); @@ -398,6 +401,9 @@ impl VaultCoordinator { if !matches!(self.state, CoordinatorState::Idle) { return Err(Error::AlreadyBootstrapping); } + if declared_count == 0 { + return Err(Error::EmptyCommittee); + } if declared_count == 2 && recovery_count == 0 { return Err(Error::TwoOperatorsRequireRecovery); } @@ -584,7 +590,8 @@ impl VaultCoordinator { .count() .get_result(&mut conn) .await?; - let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()); + let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()) + .ok_or(Error::EmptyCommittee)?; self.state = CoordinatorState::Unsealing { threshold, ordinary_passphrases: HashMap::new(), diff --git a/server/crates/arbiter-server/src/crypto/shamir.rs b/server/crates/arbiter-server/src/crypto/shamir.rs index 61c59e1..4600f99 100644 --- a/server/crates/arbiter-server/src/crypto/shamir.rs +++ b/server/crates/arbiter-server/src/crypto/shamir.rs @@ -21,14 +21,14 @@ pub fn split_key( } /// Returns the minimum number of shares required to reconstruct the secret -/// for a committee of `n` operators. +/// for a committee of `n` operators, or `None` for an empty committee. #[must_use] -pub const fn shamir_threshold(n: usize) -> usize { +pub const fn shamir_threshold(n: usize) -> Option { match n { - 0 => panic!("No operators"), - 1 => 1, - 2 => 2, - n => n / 2 + 1, + 0 => None, + 1 => Some(1), + 2 => Some(2), + n => Some(n / 2 + 1), } } @@ -39,3 +39,22 @@ pub fn combine_shares(shares: &[Vec]) -> Result<[u8; 32], ShamirError> { <[u8; 32]>::try_from(bytes.as_slice()) .map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned())) } + +#[cfg(test)] +mod tests { + use super::shamir_threshold; + + #[test] + fn empty_committee_has_no_threshold() { + assert_eq!(shamir_threshold(0), None); + } + + #[test] + fn threshold_follows_the_ordinary_quorum() { + // ARCHITECTURE.md §3.1/§3.4: 1 decides alone, 2 need consensus, N needs N/2 + 1. + assert_eq!(shamir_threshold(1), Some(1)); + assert_eq!(shamir_threshold(2), Some(2)); + assert_eq!(shamir_threshold(3), Some(2)); + assert_eq!(shamir_threshold(4), Some(3)); + } +} diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index e11a27d..012e251 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -268,3 +268,45 @@ async fn recovery_share_stored_and_used_for_unseal() { let state = vault_ref2.ask(GetState {}).await.unwrap(); assert_eq!(state, VaultState::Unsealed); } + +/// A committee of zero ordinary operators used to reach `shamir_threshold(0)` and panic, +/// taking the global coordinator down with it. +#[tokio::test] +#[test_log::test] +async fn empty_committee_is_rejected_without_panicking() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref)); + + let err = coordinator + .ask(StartBootstrap { + operator_id: 1, + declared_count: 0, + recovery_count: 1, + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee) + ), + "expected EmptyCommittee, got {err:?}" + ); + + // The actor must still be alive to serve the next caller. + let err = coordinator + .ask(StartBootstrap { + operator_id: 1, + declared_count: 0, + recovery_count: 0, + }) + .await + .unwrap_err(); + assert!(matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee) + )); +} -- 2.49.1 From 39072416445c97c3146f62d040606f7eabb49a31 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 13:33:09 +0200 Subject: [PATCH 53/66] fix(vault): keep the root key row identity across a seal-key re-key --- .../arbiter-server/src/actors/vault/mod.rs | 125 ++++++++++++++---- 1 file changed, 98 insertions(+), 27 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index 14fde5d..de5c3b5 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -277,8 +277,10 @@ impl Vault { Ok(()) } - /// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row. - /// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal. + /// Re-encrypts the root key with `new_seal_key`, updating its `root_key_history` row in + /// place. Called after a Shamir re-key, so the old seal key is no longer sufficient to + /// unseal. The root key itself does not change, so its row identity (and the nonce counter + /// and integrity envelopes bound to it) must not change either. #[message] pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> { let Unsealed { @@ -298,34 +300,31 @@ impl Vault { }) })?; - let data_encryption_nonce = Nonce::default(); - let mut conn = self.db.get().await?; - let new_root_key_history_id: i32 = conn - .transaction(async |conn| { - let new_id = insert_into(schema::root_key_history::table) - .values(&models::NewRootKeyHistory { - ciphertext: new_ciphertext, - tag: v1::ROOT_KEY_TAG.to_vec(), - root_key_encryption_nonce: new_nonce.to_vec(), - data_encryption_nonce: data_encryption_nonce.to_vec(), - schema_version: 1, - salt: new_salt.to_vec(), - }) - .returning(schema::root_key_history::id) - .get_result::(&mut *conn) - .await?; - update(schema::arbiter_settings::table) - .set(schema::arbiter_settings::root_key_id.eq(new_id)) - .execute(&mut *conn) - .await?; - - Result::<_, diesel::result::Error>::Ok(new_id) - }) + // The root key is unchanged, so its row keeps its identity: `data_encryption_nonce` + // keeps counting up, and every integrity envelope stays bound to the same key version. + // Only the seal-key material is replaced, retiring the previous one. `tag` and + // `schema_version` are deliberately left untouched: the seal-key encryption scheme + // itself is unchanged by a re-key, so there is nothing new for them to describe. + let rows_updated = update(schema::root_key_history::table) + .filter(schema::root_key_history::id.eq(*root_key_history_id)) + .set(( + schema::root_key_history::ciphertext.eq(new_ciphertext), + schema::root_key_history::root_key_encryption_nonce.eq(new_nonce.to_vec()), + schema::root_key_history::salt.eq(new_salt.to_vec()), + )) + .execute(&mut conn) .await?; - *root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id); + if rows_updated == 0 { + error!( + "Broken database: rekey matched no root_key_history row id={:#?}", + root_key_history_id + ); + return Err(Error::BrokenDatabase); + } + info!("Vault root key rekeyed successfully"); Ok(()) } @@ -522,7 +521,6 @@ impl Vault { #[cfg(test)] mod tests { use crate::actors::GlobalActors; - use arbiter_crypto::safecell::SafeCellHandle as _; use super::*; @@ -578,4 +576,77 @@ mod tests { "next write must advance nonce" ); } + + #[tokio::test] + #[test_log::test] + async fn rekey_does_not_restart_the_data_nonce_counter() { + let db = db::create_test_pool().await; + let mut actor = bootstrapped_actor(&db).await; + + let before = actor + .create_new(SafeCell::new(b"before-rekey".to_vec())) + .await + .unwrap(); + + actor.rekey_root_key(KeyCell::from([7u8; 32])).await.unwrap(); + + let after = actor + .create_new(SafeCell::new(b"after-rekey".to_vec())) + .await + .unwrap(); + + let mut conn = db.get().await.unwrap(); + + // One root key, one row: the root key never changed, so its history did not fork. + let rows: i64 = schema::root_key_history::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(rows, 1, "a seal-key re-key must not append a root key row"); + + // Fetch each nonce by its own id, rather than `eq_any` (whose row order is + // unspecified), and assert the counter strictly advanced. A weaker `assert_ne!` would + // still pass if the counter reset, as long as the two nonces happened to differ. + let before_nonce: Vec = schema::aead_encrypted::table + .find(before) + .select(schema::aead_encrypted::current_nonce) + .first(&mut conn) + .await + .unwrap(); + let after_nonce: Vec = schema::aead_encrypted::table + .find(after) + .select(schema::aead_encrypted::current_nonce) + .first(&mut conn) + .await + .unwrap(); + assert!( + after_nonce > before_nonce, + "nonce counter must keep advancing across a rekey, not reset" + ); + } + + #[tokio::test] + #[test_log::test] + async fn rekey_invalidates_the_old_seal_key() { + let db = db::create_test_pool().await; + let mut actor = bootstrapped_actor(&db).await; + + actor.rekey_root_key(KeyCell::from([7u8; 32])).await.unwrap(); + actor.seal().await.unwrap(); + + // A no-op rekey would leave the old seal key working; it must not. + let err = actor + .try_unseal(KeyCell::from([0u8; 32])) + .await + .unwrap_err(); + assert!( + matches!(err, Error::InvalidKey), + "old seal key must no longer unseal after a rekey, got {err:?}" + ); + + // A failed unseal must leave the sealed state intact: the new seal key must still be + // able to unseal on the next attempt. + actor.try_unseal(KeyCell::from([7u8; 32])).await.unwrap(); + } } -- 2.49.1 From a9bc53312984b7ea826ef94ad536eef74b1eff15 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 13:50:24 +0200 Subject: [PATCH 54/66] test(vault): pin attestation validity across a re-key --- .../arbiter-server/src/actors/vault/mod.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index de5c3b5..b4050c7 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -649,4 +649,23 @@ mod tests { // able to unseal on the next attempt. actor.try_unseal(KeyCell::from([7u8; 32])).await.unwrap(); } + + #[tokio::test] + #[test_log::test] + async fn integrity_envelopes_survive_a_rekey() { + let db = db::create_test_pool().await; + let mut actor = bootstrapped_actor(&db).await; + + let mac_input = b"operator_credentials/1".to_vec(); + let (key_version, mac) = actor.sign_integrity(mac_input.clone()).unwrap(); + + actor.rekey_root_key(KeyCell::from([9u8; 32])).await.unwrap(); + + assert!( + actor + .verify_integrity(mac_input, mac, key_version) + .unwrap(), + "a seal-key re-key must not invalidate existing attestations" + ); + } } -- 2.49.1 From 5937ae8112131ea96f266faf0a4264c7b34ba84b Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 14:35:04 +0200 Subject: [PATCH 55/66] fix(operator): revoke wallet access by access id instead of wallet id --- .../src/peers/operator/session/handlers.rs | 144 ++++++++++++++++-- 1 file changed, 132 insertions(+), 12 deletions(-) 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 6fc8d59..299254a 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -200,18 +200,7 @@ impl OperatorSession { entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; - conn.transaction(async |conn| { - use crate::db::schema::evm_wallet_access; - for entry in entries { - diesel::delete(evm_wallet_access::table) - .filter(evm_wallet_access::wallet_id.eq(entry)) - .execute(&mut *conn) - .await?; - } - - Result::<_, Error>::Ok(()) - }) - .await?; + revoke_wallet_access(&mut conn, &entries).await?; Ok(()) } @@ -229,6 +218,20 @@ impl OperatorSession { } } +/// Deletes access rows by their own id. The wire carries `WalletAccessEntry.id` values, so +/// filtering by `wallet_id` here would revoke every client's access to that wallet. +pub(crate) async fn revoke_wallet_access( + conn: &mut crate::db::DatabaseConnection, + ids: &[i32], +) -> Result { + use crate::db::schema::evm_wallet_access; + + diesel::delete(evm_wallet_access::table) + .filter(evm_wallet_access::id.eq_any(ids)) + .execute(conn) + .await +} + #[messages] impl OperatorSession { #[message(ctx)] @@ -379,3 +382,120 @@ impl OperatorSession { .map_err(|_| Error::internal("VaultCoordinator unavailable")) } } + +#[cfg(test)] +mod tests { + use super::revoke_wallet_access; + use crate::db::{self, models, schema}; + + use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + + /// Two clients share one wallet. Revoking one access row must leave the other alone. + #[tokio::test] + async fn revoking_one_access_leaves_the_other_client_alone() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(&mut conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: chrono::Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(&mut conn) + .await + .unwrap(); + + let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(vec![0u8; 20]), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(&mut conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + + let first_client: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(vec![1u8; 32]), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(&mut conn) + .await + .unwrap(); + + let second_client: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(vec![2u8; 32]), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(&mut conn) + .await + .unwrap(); + + let first_access: i32 = insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(first_client), + )) + .returning(schema::evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + + let _second_access: i32 = insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(second_client), + )) + .returning(schema::evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + + let removed = revoke_wallet_access(&mut conn, &[first_access]) + .await + .unwrap(); + assert_eq!(removed, 1); + + let survivors: Vec = schema::evm_wallet_access::table + .select(schema::evm_wallet_access::client_id) + .load(&mut conn) + .await + .unwrap(); + assert_eq!( + survivors, + vec![second_client], + "revoking one access row removed another client's access" + ); + } +} -- 2.49.1 From 5d811f2ee9645f6a3a0ce0c146d6f1b47070388d Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 14:35:10 +0200 Subject: [PATCH 56/66] fix(evm): reject unrepresentable grant timestamps instead of dropping the bound --- .../arbiter-server/src/actors/evm/mod.rs | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 77a0dd7..9b2c073 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -70,6 +70,16 @@ pub enum Error { #[error("Signing error: {0}")] Sign(#[from] SignTransactionError), + + #[error("Grant timestamp {0} is outside the representable range")] + InvalidTimestamp(i64), +} + +/// Converts a grant boundary from Unix seconds. `None` in means "unbounded"; an +/// unrepresentable value is an error, never a silently unbounded grant. +fn grant_timestamp(secs: Option) -> Result>, Error> { + secs.map(|s| chrono::DateTime::from_timestamp(s, 0).ok_or(Error::InvalidTimestamp(s))) + .transpose() } #[derive(Actor)] @@ -343,12 +353,8 @@ impl EvmActor { let basic = SharedGrantSettings { wallet_access_id: grant.wallet_access_id, chain: grant.chain_id, - valid_from: grant - .valid_from_secs - .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - valid_until: grant - .valid_until_secs - .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + valid_from: grant_timestamp(grant.valid_from_secs)?, + valid_until: grant_timestamp(grant.valid_until_secs)?, max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes), max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes), rate_limit: grant.rate_limit.map(|r| TransactionRateLimit { @@ -414,3 +420,27 @@ impl EvmActor { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::{Error, grant_timestamp}; + + #[test] + fn absent_timestamp_stays_absent() { + assert!(grant_timestamp(None).unwrap().is_none()); + } + + #[test] + fn in_range_timestamp_is_converted() { + let converted = grant_timestamp(Some(1_800_000_000)).unwrap(); + assert_eq!(converted.unwrap().timestamp(), 1_800_000_000); + } + + /// An unrepresentable expiry must not silently become "no expiry": that would widen the + /// grant beyond what was voted on. + #[test] + fn out_of_range_timestamp_is_an_error() { + let err = grant_timestamp(Some(i64::MAX)).unwrap_err(); + assert!(matches!(err, Error::InvalidTimestamp(i64::MAX))); + } +} -- 2.49.1 From a37af6bc1ce8d46fcdb618364f1ca2705d37649e Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 14:40:34 +0200 Subject: [PATCH 57/66] fix(db): enable foreign key enforcement on pooled connections --- server/crates/arbiter-server/src/db/mod.rs | 34 ++++++ server/crates/arbiter-server/src/evm/mod.rs | 81 ++++++++++++- .../src/evm/policies/ether_transfer/tests.rs | 85 ++++++++++++- .../src/evm/policies/token_transfers/tests.rs | 114 ++++++++++++++++-- .../crates/arbiter-server/tests/governance.rs | 31 +++-- 5 files changed, 319 insertions(+), 26 deletions(-) diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index b20d45f..da564ba 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -123,6 +123,10 @@ pub async fn create_pool(url: Option<&str>) -> Result DatabasePool { .await .expect("Failed to create test database pool") } + +#[cfg(test)] +mod tests { + use super::*; + use diesel::{ExpressionMethods as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + + /// `operator.id` references `operator_identity(id)`. Without `PRAGMA foreign_keys = ON` + /// on the pooled connection, SQLite accepts a share row for an operator that does not exist. + #[tokio::test] + async fn pooled_connections_enforce_foreign_keys() { + let pool = create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let result = insert_into(schema::operator::table) + .values(( + schema::operator::id.eq(4242), + schema::operator::share.eq(vec![0u8; 32]), + schema::operator::share_nonce.eq(vec![0u8; 24]), + schema::operator::share_salt.eq(vec![0u8; 32]), + )) + .execute(&mut conn) + .await; + + assert!( + result.is_err(), + "insert with a dangling operator_identity reference was accepted" + ); + } +} diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 5d05ca4..9b34bc3 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -352,16 +352,17 @@ impl Engine { mod tests { use alloy::primitives::{Address, Bytes, U256, address}; use chrono::{Duration, Utc}; - use diesel::{SelectableHelper, insert_into}; + use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; use rstest::rstest; use crate::db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{ EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, + schema, schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::policies::{ @@ -403,10 +404,82 @@ mod tests { } } + /// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key + /// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and + /// returns the new access row's id. + async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() + } + async fn insert_basic_grant( conn: &mut DatabaseConnection, shared: &SharedGrantSettings, ) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + #[expect( clippy::cast_possible_truncation, clippy::cast_possible_wrap, @@ -415,7 +488,7 @@ mod tests { )] insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: shared.wallet_access_id, + wallet_access_id, chain_id: shared.chain.into(), valid_from: shared.valid_from.map(SqliteTimestamp), valid_until: shared.valid_until.map(SqliteTimestamp), @@ -579,7 +652,7 @@ mod tests { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id: basic_grant.id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic_grant.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index b9deb99..4d2cfa5 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -1,11 +1,12 @@ use super::{EtherTransfer, Settings}; use crate::{ db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{ EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, + schema, schema::{evm_basic_grant, evm_transaction_log}, }, evm::{ @@ -19,7 +20,7 @@ use crate::{ use alloy::primitives::{Address, Bytes, U256, address}; use chrono::{Duration, Utc}; -use diesel::{SelectableHelper, insert_into}; +use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; const WALLET_ACCESS_ID: i32 = 1; @@ -45,10 +46,82 @@ fn ctx(to: Address, value: U256) -> EvalContext { } } +/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key +/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns +/// the new access row's id. +async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() +} + async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id, chain_id: CHAIN_ID.into(), valid_from: None, valid_until: None, @@ -161,7 +234,7 @@ async fn evaluate_passes_when_volume_within_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -203,7 +276,7 @@ async fn evaluate_rejects_volume_over_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -246,7 +319,7 @@ async fn evaluate_passes_at_exactly_volume_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index f2c02b3..4f6afd0 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -1,8 +1,9 @@ use super::{Settings, TokenTransfer}; use crate::{ db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp}, + schema, schema::evm_basic_grant, }, evm::{ @@ -20,7 +21,7 @@ use alloy::{ sol_types::SolCall, }; use chrono::{Duration, Utc}; -use diesel::{SelectableHelper, insert_into}; +use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; // DAI on Ethereum mainnet — present in the static token registry @@ -58,10 +59,82 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { } } +/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key +/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns +/// the new access row's id. +async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() +} + async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id, chain_id: CHAIN_ID.into(), valid_from: None, valid_until: None, @@ -77,6 +150,27 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG .unwrap() } +/// `evm_token_transfer_log.log_id` references the shared `evm_transaction_log` table, so +/// tests recording a token transfer need a real row there to point at. +async fn insert_transaction_log( + conn: &mut DatabaseConnection, + basic: &EvmBasicGrant, + eth_value: U256, +) -> i32 { + insert_into(schema::evm_transaction_log::table) + .values(models::NewEvmTransactionLog { + grant_id: basic.id, + wallet_access_id: basic.wallet_access_id, + chain_id: CHAIN_ID.into(), + eth_value: utils::u256_to_bytes(eth_value).to_vec(), + signed_at: SqliteTimestamp(Utc::now()), + }) + .returning(schema::evm_transaction_log::id) + .get_result(conn) + .await + .unwrap() +} + fn make_settings(target: Option
, max_volume: Option) -> Settings { Settings { token_contract: DAI, @@ -241,10 +335,11 @@ async fn evaluate_passes_volume_at_exact_limit() { .unwrap(); // Record a past transfer of 900, with current transfer 100 => exactly 1000 limit - insert_into(db::schema::evm_token_transfer_log::table) - .values(db::models::NewEvmTokenTransferLog { + let log_id = insert_transaction_log(&mut conn, &basic, U256::from(900u64)).await; + insert_into(schema::evm_token_transfer_log::table) + .values(models::NewEvmTokenTransferLog { grant_id, - log_id: 0, + log_id, chain_id: CHAIN_ID.into(), token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), @@ -285,10 +380,11 @@ async fn evaluate_rejects_volume_over_limit() { .await .unwrap(); - insert_into(db::schema::evm_token_transfer_log::table) - .values(db::models::NewEvmTokenTransferLog { + let log_id = insert_transaction_log(&mut conn, &basic, U256::from(1_000u64)).await; + insert_into(schema::evm_token_transfer_log::table) + .values(models::NewEvmTokenTransferLog { grant_id, - log_id: 0, + log_id, chain_id: CHAIN_ID.into(), token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 2c5ba2d..7e4cb13 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -21,8 +21,8 @@ use arbiter_server::{ }; use arbiter_server::actors::vault::Bootstrap; use arbiter_server::db::schema::{ - aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, - proposal_one_off_transaction_result, recovery_operator_identity, + aead_encrypted, arbiter_settings, evm_basic_grant, evm_wallet, evm_wallet_access, + operator_identity, proposal_one_off_transaction_result, recovery_operator_identity, }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -82,14 +82,22 @@ async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: OperatorIdenti .unwrap(); } +/// Requires the vault to already be bootstrapped: it reads the root key row `Bootstrap` +/// creates so the aead-encrypted wallet secret has a real parent to reference. async fn insert_evm_wallet(db: &db::DatabasePool) -> i32 { let mut conn = db.get().await.unwrap(); + let root_key_id: i32 = arbiter_settings::table + .select(arbiter_settings::root_key_id) + .first::>(&mut conn) + .await + .unwrap() + .expect("vault must be bootstrapped before creating an aead-encrypted row"); let aead_id: i32 = insert_into(aead_encrypted::table) .values(( aead_encrypted::current_nonce.eq(vec![0u8; 4]), aead_encrypted::ciphertext.eq(vec![0u8; 32]), aead_encrypted::tag.eq(vec![0u8; 16]), - aead_encrypted::associated_root_key_id.eq(0i32), + aead_encrypted::associated_root_key_id.eq(root_key_id), )) .returning(aead_encrypted::id) .get_result::(&mut conn) @@ -143,11 +151,16 @@ async fn create_proposal_returns_id() { .await .unwrap(); + let key = authn::SigningKey::generate(); + let operator_id = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 42 }), - initiator_id: OperatorIdentityId::from_raw(1), + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: operator_id, ttl_secs: None, }) .await @@ -170,12 +183,14 @@ async fn create_proposal_caps_the_ttl() { let key = authn::SigningKey::generate(); let op = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; let create = async |ttl: u32| { actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 1 }), + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: Some(ttl), }) @@ -429,7 +444,9 @@ async fn query_pending_reports_a_tally_per_proposal() { }; let mut ids = Vec::new(); - for client_id in 1..=3 { + for _ in 1..=3 { + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; let id = actors .proposal_manager .ask(CreateProposal { -- 2.49.1 From 6e21505a7f8a6b07e7996ecae9d3eae5b1baf958 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 15:18:31 +0200 Subject: [PATCH 58/66] fix(operator): soft-revoke wallet access instead of deleting the row --- .../2026-02-14-171124-0000_init/up.sql | 1 + .../arbiter-server/src/actors/evm/mod.rs | 10 + server/crates/arbiter-server/src/db/mod.rs | 19 +- server/crates/arbiter-server/src/db/models.rs | 7 +- server/crates/arbiter-server/src/db/schema.rs | 1 + server/crates/arbiter-server/src/evm/mod.rs | 4 + .../src/evm/policies/ether_transfer/tests.rs | 1 + .../src/evm/policies/token_transfers/tests.rs | 1 + .../src/peers/operator/session/handlers.rs | 380 +++++++++++++++--- 9 files changed, 358 insertions(+), 66 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 36a5310..42c7756 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -109,6 +109,7 @@ create table if not exists evm_wallet_access ( id integer not null primary key, wallet_id integer not null references evm_wallet (id) on delete cascade, client_id integer not null references program_client (id) on delete cascade, + revoked_at integer, -- unix timestamp when revoked, null = still active created_at integer not null default(unixepoch ('now')) ) STRICT; diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 9b2c073..59d91c8 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -226,6 +226,7 @@ impl EvmActor { .select(models::EvmWalletAccess::as_select()) .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) .filter(schema::evm_wallet_access::client_id.eq(client_id)) + .filter(schema::evm_wallet_access::revoked_at.is_null()) .first(&mut conn) .await .optional() @@ -261,6 +262,7 @@ impl EvmActor { .select(models::EvmWalletAccess::as_select()) .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) .filter(schema::evm_wallet_access::client_id.eq(client_id)) + .filter(schema::evm_wallet_access::revoked_at.is_null()) .first(&mut conn) .await .optional() @@ -323,11 +325,19 @@ impl EvmActor { ) -> Result<(), Error> { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + // Revives a previously revoked row instead of conflicting on it forever: + // `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`. insert_into(schema::evm_wallet_access::table) .values(( schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(settings.wallet_id)), schema::evm_wallet_access::client_id.eq(settings.client_id), )) + .on_conflict(( + schema::evm_wallet_access::wallet_id, + schema::evm_wallet_access::client_id, + )) + .do_update() + .set(schema::evm_wallet_access::revoked_at.eq(None::)) .execute(&mut conn) .await .map_err(DatabaseError::from)?; diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index da564ba..01d1b87 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -164,7 +164,11 @@ pub async fn create_test_pool() -> DatabasePool { #[cfg(test)] mod tests { use super::*; - use diesel::{ExpressionMethods as _, dsl::insert_into}; + use diesel::{ + ExpressionMethods as _, + dsl::insert_into, + result::{DatabaseErrorKind, Error as DieselError}, + }; use diesel_async::RunQueryDsl; /// `operator.id` references `operator_identity(id)`. Without `PRAGMA foreign_keys = ON` @@ -184,9 +188,18 @@ mod tests { .execute(&mut conn) .await; + // Specifically a foreign-key violation, not any error: a `NOT NULL` failure or a + // renamed column would also make `result.is_err()` true without proving the pragma + // is what rejected the insert. assert!( - result.is_err(), - "insert with a dangling operator_identity reference was accepted" + matches!( + result, + Err(DieselError::DatabaseError( + DatabaseErrorKind::ForeignKeyViolation, + _ + )) + ), + "expected a foreign-key violation for a dangling operator_identity reference, got {result:?}" ); } } diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 4eae389..4bd004b 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -263,19 +263,22 @@ pub struct EvmWallet { #[view( NewEvmWalletAccess, derive(Insertable), - omit(id, created_at), + omit(id, created_at, revoked_at), attributes_with = "deriveless" )] #[view( CoreEvmWalletAccess, derive(Insertable), - omit(created_at), + omit(created_at, revoked_at), attributes_with = "deriveless" )] pub struct EvmWalletAccess { pub id: i32, pub wallet_id: EvmWalletId, pub client_id: i32, + // Grants, transaction logs, and persistent-grant proposals reference this row + // `on delete restrict`, so revocation cannot delete it -- it marks it revoked instead. + pub revoked_at: Option, pub created_at: SqliteTimestamp, } diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 8123290..c71717c 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -135,6 +135,7 @@ diesel::table! { id -> Integer, wallet_id -> Integer, client_id -> Integer, + revoked_at -> Nullable, created_at -> Integer, } } diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 9b34bc3..f557270 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -381,6 +381,7 @@ mod tests { id: WALLET_ACCESS_ID, wallet_id: EvmWalletId::from_raw(5), client_id: 20, + revoked_at: None, created_at: SqliteTimestamp(Utc::now()), }, chain: CHAIN_ID, @@ -478,6 +479,9 @@ mod tests { conn: &mut DatabaseConnection, shared: &SharedGrantSettings, ) -> EvmBasicGrant { + // The seeded id deliberately wins over `shared.wallet_access_id`: every other field + // below is read from `shared`, but a caller-supplied access id would almost never + // reference a row that actually exists under foreign-key enforcement. let wallet_access_id = seed_wallet_access(conn).await; #[expect( diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index 4d2cfa5..19ea8be 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -35,6 +35,7 @@ fn ctx(to: Address, value: U256) -> EvalContext { id: WALLET_ACCESS_ID, wallet_id: EvmWalletId::from_raw(10), client_id: 20, + revoked_at: None, created_at: SqliteTimestamp(Utc::now()), }, chain: CHAIN_ID, diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index 4f6afd0..a4a587e 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -48,6 +48,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { id: WALLET_ACCESS_ID, wallet_id: EvmWalletId::from_raw(10), client_id: 20, + revoked_at: None, created_at: SqliteTimestamp(Utc::now()), }, chain: CHAIN_ID, 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 299254a..60cc16e 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -177,20 +177,7 @@ impl OperatorSession { entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; - conn.transaction(async |conn| { - use crate::db::schema::evm_wallet_access; - - for entry in entries { - diesel::insert_into(evm_wallet_access::table) - .values(&entry) - .on_conflict_do_nothing() - .execute(&mut *conn) - .await?; - } - - Result::<_, Error>::Ok(()) - }) - .await?; + grant_wallet_access(&mut conn, entries).await?; Ok(()) } @@ -211,6 +198,7 @@ impl OperatorSession { use crate::db::schema::evm_wallet_access; let mut conn = self.props.db.get().await?; let access_entries = evm_wallet_access::table + .filter(evm_wallet_access::revoked_at.is_null()) .select(EvmWalletAccess::as_select()) .load::<_>(&mut conn) .await?; @@ -218,16 +206,47 @@ impl OperatorSession { } } -/// Deletes access rows by their own id. The wire carries `WalletAccessEntry.id` values, so -/// filtering by `wallet_id` here would revoke every client's access to that wallet. +/// Grants access, reviving a previously revoked row rather than leaving it shadowed: +/// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert +/// would conflict forever on a row that was revoked but never deleted. +pub(crate) async fn grant_wallet_access( + conn: &mut crate::db::DatabaseConnection, + entries: Vec, +) -> Result<(), diesel::result::Error> { + use crate::db::{models::SqliteTimestamp, schema::evm_wallet_access}; + + conn.transaction(async |conn| { + for entry in entries { + diesel::insert_into(evm_wallet_access::table) + .values(&entry) + .on_conflict((evm_wallet_access::wallet_id, evm_wallet_access::client_id)) + .do_update() + .set(evm_wallet_access::revoked_at.eq(None::)) + .execute(&mut *conn) + .await?; + } + + Ok(()) + }) + .await +} + +/// Marks access rows revoked by their own id rather than deleting them. The wire carries +/// `WalletAccessEntry.id` values, so filtering by `wallet_id` here would revoke every +/// client's access to that wallet. Deleting is not an option: `evm_basic_grant`, +/// `evm_transaction_log`, and `proposal_persistent_grant` all reference this row +/// `on delete restrict`, so an access that was ever granted, signed with, or proposed +/// against can never be deleted -- only marked revoked. pub(crate) async fn revoke_wallet_access( conn: &mut crate::db::DatabaseConnection, ids: &[i32], ) -> Result { - use crate::db::schema::evm_wallet_access; + use crate::db::{models::SqliteTimestamp, schema::evm_wallet_access}; - diesel::delete(evm_wallet_access::table) + diesel::update(evm_wallet_access::table) .filter(evm_wallet_access::id.eq_any(ids)) + .filter(evm_wallet_access::revoked_at.is_null()) + .set(evm_wallet_access::revoked_at.eq(SqliteTimestamp::now())) .execute(conn) .await } @@ -385,18 +404,15 @@ impl OperatorSession { #[cfg(test)] mod tests { - use super::revoke_wallet_access; + use super::{grant_wallet_access, revoke_wallet_access}; use crate::db::{self, models, schema}; use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; use diesel_async::RunQueryDsl; - /// Two clients share one wallet. Revoking one access row must leave the other alone. - #[tokio::test] - async fn revoking_one_access_leaves_the_other_client_alone() { - let pool = db::create_test_pool().await; - let mut conn = pool.get().await.unwrap(); - + /// Inserts a fresh root key, an aead-encrypted wallet secret, and the wallet itself. + /// Returns the wallet's id and its 20-byte address. + async fn seed_wallet(conn: &mut db::DatabaseConnection) -> (models::EvmWalletId, Vec) { let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) .values(&models::NewRootKeyHistory { ciphertext: vec![0u8; 32], @@ -407,7 +423,7 @@ mod tests { salt: vec![0u8; 16], }) .returning(schema::root_key_history::id) - .get_result(&mut conn) + .get_result(conn) .await .unwrap(); @@ -421,81 +437,323 @@ mod tests { created_at: chrono::Utc::now().into(), }) .returning(schema::aead_encrypted::id) - .get_result(&mut conn) + .get_result(conn) .await .unwrap(); + let address = rand::random::<[u8; 20]>().to_vec(); let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table) .values(( - schema::evm_wallet::address.eq(vec![0u8; 20]), + schema::evm_wallet::address.eq(address.clone()), schema::evm_wallet::aead_encrypted_id.eq(aead_id), )) .returning(schema::evm_wallet::id) - .get_result(&mut conn) + .get_result(conn) .await .unwrap(); - let metadata_id: i32 = insert_into(schema::client_metadata::table) + (wallet_id, address) + } + + async fn seed_client_metadata(conn: &mut db::DatabaseConnection) -> i32 { + insert_into(schema::client_metadata::table) .values(schema::client_metadata::name.eq("test")) .returning(schema::client_metadata::id) - .get_result(&mut conn) + .get_result(conn) .await - .unwrap(); + .unwrap() + } - let first_client: i32 = insert_into(schema::program_client::table) + /// Inserts a `program_client` row under the given `client_metadata` row, keyed by its + /// own random public key. + async fn seed_client(conn: &mut db::DatabaseConnection, metadata_id: i32) -> i32 { + insert_into(schema::program_client::table) .values(( - schema::program_client::public_key.eq(vec![1u8; 32]), + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), schema::program_client::metadata_id.eq(metadata_id), )) .returning(schema::program_client::id) - .get_result(&mut conn) + .get_result(conn) .await - .unwrap(); + .unwrap() + } - let second_client: i32 = insert_into(schema::program_client::table) - .values(( - schema::program_client::public_key.eq(vec![2u8; 32]), - schema::program_client::metadata_id.eq(metadata_id), - )) - .returning(schema::program_client::id) - .get_result(&mut conn) - .await - .unwrap(); - - let first_access: i32 = insert_into(schema::evm_wallet_access::table) + /// Inserts an access row directly, for fixtures that need one to already exist. + /// Production code grants access through [`grant_wallet_access`]. + async fn insert_wallet_access( + conn: &mut db::DatabaseConnection, + wallet_id: models::EvmWalletId, + client_id: i32, + ) -> i32 { + insert_into(schema::evm_wallet_access::table) .values(( schema::evm_wallet_access::wallet_id.eq(wallet_id), - schema::evm_wallet_access::client_id.eq(first_client), + schema::evm_wallet_access::client_id.eq(client_id), )) .returning(schema::evm_wallet_access::id) - .get_result(&mut conn) + .get_result(conn) .await - .unwrap(); + .unwrap() + } - let _second_access: i32 = insert_into(schema::evm_wallet_access::table) - .values(( - schema::evm_wallet_access::wallet_id.eq(wallet_id), - schema::evm_wallet_access::client_id.eq(second_client), - )) - .returning(schema::evm_wallet_access::id) - .get_result(&mut conn) - .await - .unwrap(); + /// Two clients share one wallet. Revoking one access row must leave the other alone, + /// and must mark the row revoked rather than deleting it. + #[tokio::test] + async fn revoking_one_access_leaves_the_other_client_alone() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let first_client = seed_client(&mut conn, metadata_id).await; + let second_client = seed_client(&mut conn, metadata_id).await; + + let first_access = insert_wallet_access(&mut conn, wallet_id, first_client).await; + let _second_access = insert_wallet_access(&mut conn, wallet_id, second_client).await; let removed = revoke_wallet_access(&mut conn, &[first_access]) .await .unwrap(); assert_eq!(removed, 1); - let survivors: Vec = schema::evm_wallet_access::table + let active: Vec = schema::evm_wallet_access::table + .filter(schema::evm_wallet_access::revoked_at.is_null()) .select(schema::evm_wallet_access::client_id) .load(&mut conn) .await .unwrap(); assert_eq!( - survivors, + active, vec![second_client], "revoking one access row removed another client's access" ); + + let total: i64 = schema::evm_wallet_access::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!( + total, 2, + "revoking an access row must mark it revoked, not delete it" + ); + } + + /// The bug this round fixes: once an access has been used for a grant, a signed + /// transaction, or a proposed persistent grant, three tables reference + /// `evm_wallet_access` `on delete restrict`, so deleting the row is no longer possible + /// once foreign keys are enforced. Revoking must still succeed by marking it revoked. + #[tokio::test] + async fn revoking_an_access_with_grant_log_and_proposal_succeeds() { + use crate::db::proposal::{Proposal as _, persistent_grant, persistent_grant::PersistentGrant}; + + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; + + // A grant against this access... + let grant_id: i32 = insert_into(schema::evm_basic_grant::table) + .values(models::NewEvmBasicGrant { + wallet_access_id: access_id, + chain_id: 1u64.into(), + valid_from: None, + valid_until: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit_count: None, + rate_limit_window_secs: None, + revoked_at: None, + }) + .returning(schema::evm_basic_grant::id) + .get_result(&mut conn) + .await + .unwrap(); + + // ...a signed transaction against that grant... + insert_into(schema::evm_transaction_log::table) + .values(models::NewEvmTransactionLog { + grant_id, + wallet_access_id: access_id, + chain_id: 1u64.into(), + eth_value: vec![0u8; 32], + signed_at: models::SqliteTimestamp(chrono::Utc::now()), + }) + .execute(&mut conn) + .await + .unwrap(); + + // ...and a persistent-grant proposal that named this access before it was voted on. + let operator_id: models::OperatorIdentityId = + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(rand::random::<[u8; 32]>().to_vec())) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + let proposal_id: models::ProposalId = insert_into(schema::proposal::table) + .values(&models::NewProposal { + kind: db::proposal::ProposalKindTag::ApprovePersistentGrant, + initiator_id: operator_id, + expires_at: models::SqliteTimestamp(chrono::Utc::now() + chrono::Duration::days(1)), + }) + .returning(schema::proposal::id) + .get_result(&mut conn) + .await + .unwrap(); + PersistentGrant::insert( + proposal_id, + &persistent_grant::Settings { + wallet_access_id: access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: [0u8; 32], + window_secs: 3600, + }, + }, + }, + &mut conn, + ) + .await + .unwrap(); + + // Before this round's fix, this would fail with a foreign-key violation. + let removed = revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + assert_eq!(removed, 1); + + let revoked_at: Option = schema::evm_wallet_access::table + .find(access_id) + .select(schema::evm_wallet_access::revoked_at) + .first(&mut conn) + .await + .unwrap(); + assert!( + revoked_at.is_some(), + "the row must be marked revoked, not deleted" + ); + } + + /// A revoked access must no longer resolve through the lookup `shared_analyze_transaction` + /// and `client_sign_transaction` share -- otherwise the SDK client keeps signing after + /// the operator believes it has been cut off. + #[tokio::test] + async fn revoked_access_no_longer_authorizes_signing() { + use crate::actors::{ + GlobalActors, + evm::{EvmActor, SignTransactionError}, + vault::Vault, + }; + use alloy::{ + consensus::TxEip1559, + eips::eip2930::AccessList, + primitives::{Address, Bytes, TxKind, U256}, + }; + use kameo::actor::Spawn as _; + + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; + + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + drop(conn); + + let vault = Vault::spawn( + Vault::new(pool.clone(), GlobalActors::spawn_message_bus()) + .await + .unwrap(), + ); + let mut evm_actor = EvmActor::new(vault, pool.clone()); + + let transaction = TxEip1559 { + chain_id: 1, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + to: TxKind::Call(Address::ZERO), + value: U256::ZERO, + input: Bytes::new(), + access_list: AccessList::default(), + }; + let wallet_address = Address::from_slice(&address); + + // Both lookups resolve access the same way; both must reject the revoked row before + // ever touching the vault (neither call bootstraps one). + let analyze_result = evm_actor + .shared_analyze_transaction(client_id, wallet_address, transaction.clone()) + .await; + assert!( + matches!(analyze_result, Err(SignTransactionError::WalletNotFound)), + "a revoked access must not authorize shared_analyze_transaction: {analyze_result:?}" + ); + + let sign_result = evm_actor + .client_sign_transaction(client_id, wallet_address, transaction) + .await; + assert!( + matches!(sign_result, Err(SignTransactionError::WalletNotFound)), + "a revoked access must not authorize client_sign_transaction: {sign_result:?}" + ); + } + + /// Re-granting a revoked access must restore it rather than silently doing nothing: + /// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert + /// would conflict on the revoked row forever. + #[tokio::test] + async fn regranting_a_revoked_access_restores_it() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; + + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + + grant_wallet_access( + &mut conn, + vec![models::NewEvmWalletAccess { + wallet_id, + client_id, + }], + ) + .await + .unwrap(); + + let revoked_at: Option = schema::evm_wallet_access::table + .find(access_id) + .select(schema::evm_wallet_access::revoked_at) + .first(&mut conn) + .await + .unwrap(); + assert!( + revoked_at.is_none(), + "re-granting a revoked access must clear revoked_at" + ); + + let total: i64 = schema::evm_wallet_access::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!( + total, 1, + "re-granting a revoked access must revive the existing row, not add a second one" + ); } } -- 2.49.1 From e10cc762d61a9d99b2aabdd2284a6860ef35c81d Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 15:51:43 +0200 Subject: [PATCH 59/66] fix(proposal): count recovery operators only in electorates they can vote in --- .../src/actors/proposal_manager.rs | 19 ++- .../src/actors/proposal_manager/tests.rs | 151 ++++++++++++++++-- .../arbiter-server/src/db/proposal/mod.rs | 6 + 3 files changed, 155 insertions(+), 21 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index c153d0b..8f8156e 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -159,10 +159,7 @@ impl ProposalManager { .await?; let mut tally = self.store.tally(proposal_id).await?; - // §3.5: recovery operators only join the electorate once they are awake. - if !self.store.is_recovery_active().await? { - tally.total_recovery = 0; - } + self.narrow_electorate(&proposal, &mut tally).await?; self.settle(&proposal, &tally).await } @@ -240,7 +237,9 @@ impl ProposalManager { }) .await?; - let tally = self.store.tally(proposal_id).await?; + let mut tally = self.store.tally(proposal_id).await?; + self.narrow_electorate(&proposal, &mut tally).await?; + self.settle(&proposal, &tally).await } } @@ -257,6 +256,16 @@ impl ProposalManager { Ok(()) } + /// §3.5/§3.6: recovery operators join the electorate only for the kinds they may vote on, + /// and only once the wake-up window has elapsed. Counting them anywhere else makes the + /// rejection threshold unreachable and, for full-quorum kinds, approval unreachable too. + async fn narrow_electorate(&self, proposal: &Proposal, tally: &mut Tally) -> Result<(), Error> { + if !proposal.kind.recovery_may_vote() || !self.store.is_recovery_active().await? { + tally.total_recovery = 0; + } + Ok(()) + } + /// Pure quorum arithmetic — no I/O, so the rules can be tested directly (§3.3). /// /// A proposal is rejected once approval has become unreachable: even if every voter diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs index 7a8a607..1b50763 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs @@ -180,11 +180,24 @@ async fn a_vote_short_of_quorum_does_not_touch_the_status() { assert_eq!(outcome, VoteOutcome::Pending); } -/// A sleeping recovery electorate must not raise the bar for an ordinary proposal. -#[tokio::test] -async fn sleeping_recovery_operators_do_not_count_towards_quorum() { - let id = ProposalId::from_raw(9); - let voter = OperatorIdentityId::from_raw(3); +/// Drives one `cast_vote` on a proposal of the given `kind` through a mocked store and +/// returns the outcome. `recovery_active` decides what `is_recovery_active` reports; +/// `expected_status` is the status a settled outcome must be persisted under. +/// +/// `set_status` carries an argument matcher but no `.times()`: whichever outcome a caller +/// asserts is either `Approved` or `Rejected` (never `Pending`), so the write must happen +/// with the right status if it happens at all, but leaving the count unconstrained means a +/// regression that turns the outcome into `Pending` still fails on the caller's own +/// `assert_eq!` -- a readable diff -- rather than on a mockall cardinality panic that hides +/// what the actor actually computed. +async fn settle_vote_with( + kind: ProposalKindTag, + tally: Tally, + recovery_active: bool, + expected_status: ProposalStatus, +) -> VoteOutcome { + let id = ProposalId::from_raw(11); + let voter = OperatorIdentityId::from_raw(1); let key = SigningKey::generate(); let signature = key .sign_message(&vote_message(id, true), SigningContext::GovernanceVote) @@ -194,29 +207,135 @@ async fn sleeping_recovery_operators_do_not_count_towards_quorum() { let mut store = MockProposalStore::new(); store .expect_load() - .returning(move |id| Ok(pending_proposal(id, ProposalKindTag::ApproveSdkClient))); + .returning(move |id| Ok(pending_proposal(id, kind))); store.expect_has_voted().returning(|_, _| Ok(false)); store .expect_operator_public_key() .returning(move |_| Ok(public_key.clone())); store.expect_record_vote().returning(|_| Ok(())); - store.expect_is_recovery_active().returning(|| Ok(false)); - // Two recovery operators exist but are asleep, so the threshold stays at 1 of 1. - store.expect_tally().returning(|_| Ok(tally(1, 0, 1, 2))); - store.expect_set_status().times(1).returning(|_, _| Ok(())); - store.expect_load_kind().returning(|_, _| { - Ok(crate::db::proposal::ProposalKind::ApproveSdkClient( - crate::db::proposal::approve_sdk_client::Settings { client_id: 1 }, - )) + store + .expect_is_recovery_active() + .returning(move || Ok(recovery_active)); + store.expect_tally().returning(move |_| Ok(tally)); + store + .expect_set_status() + .withf(move |_, status| *status == expected_status) + .returning(|_, _| Ok(())); + store.expect_load_kind().returning(move |_, _| { + Ok(match kind { + ProposalKindTag::TriggerRekey => crate::db::proposal::ProposalKind::TriggerRekey, + ProposalKindTag::ApproveSdkClient => { + crate::db::proposal::ProposalKind::ApproveSdkClient( + crate::db::proposal::approve_sdk_client::Settings { client_id: 1 }, + ) + } + ProposalKindTag::ReplaceOperator => crate::db::proposal::ProposalKind::ReplaceOperator( + crate::db::proposal::replace_operator::Settings { + old_operator_id: OperatorIdentityId::from_raw(1), + new_pubkey: vec![0u8; 32], + }, + ), + other => unreachable!("settle_vote_with has no load_kind fixture for {other:?}"), + }) }); let mut manager = ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus()); - let outcome = manager + manager .cast_vote(id, voter, true, signature.to_bytes()) .await - .expect("a valid vote must be accepted"); + .expect("a valid vote must be accepted") +} + +/// The full-quorum rejection path is insensitive to electorate size by construction: +/// `threshold == total_eligible` there, so `total_eligible - threshold` is always 0 and any +/// single rejection settles the proposal, whether or not recovery operators are (wrongly) +/// counted. This does not exercise the electorate-narrowing fix -- see +/// `unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake` +/// below for the test that does -- it just pins that `cast_vote` still writes `Rejected` +/// through `settle` for a full-quorum kind. +#[tokio::test] +async fn unanimous_rejection_settles_a_full_quorum_rekey_via_cast_vote() { + let outcome = settle_vote_with( + ProposalKindTag::TriggerRekey, + Tally { + approve: 0, + reject: 3, + total_ordinary: 3, + total_recovery: 2, + }, + /* recovery_active */ true, + ProposalStatus::Rejected, + ) + .await; + + assert_eq!(outcome, VoteOutcome::Rejected); +} + +/// §3.3 full quorum for a rekey means every *ordinary* operator, not every identity on file. +#[tokio::test] +async fn unanimous_ordinary_approval_approves_a_rekey_while_recovery_is_awake() { + let outcome = settle_vote_with( + ProposalKindTag::TriggerRekey, + Tally { + approve: 3, + reject: 0, + total_ordinary: 3, + total_recovery: 2, + }, + /* recovery_active */ true, + ProposalStatus::Approved, + ) + .await; + + assert_eq!(outcome, VoteOutcome::Approved); +} + +/// §3.5: recovery operators do not vote on `ApproveSdkClient`, so they must not inflate its +/// electorate. Before the fix, `total_eligible` counted them anyway (5, not 3), so the +/// rejection test `reject > total_eligible - threshold` became `3 > 5 - 2 = 3`, which is +/// false -- three unanimous rejections left the proposal `Pending` forever, since a fourth +/// vote could never arrive. After the fix, `total_eligible` is 3 and the same test becomes +/// `3 > 3 - 2 = 1`, which settles it. +#[tokio::test] +async fn unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake() { + let outcome = settle_vote_with( + ProposalKindTag::ApproveSdkClient, + Tally { + approve: 0, + reject: 3, + total_ordinary: 3, + total_recovery: 2, + }, + /* recovery_active */ true, + ProposalStatus::Rejected, + ) + .await; + + assert_eq!(outcome, VoteOutcome::Rejected); +} + +/// §3.5/§3.6: `ReplaceOperator` is the one kind recovery may vote on, so it is the only kind +/// where whether recovery is awake is observable at all -- for every other kind +/// `narrow_electorate` zeroes `total_recovery` regardless of `is_recovery_active`, short- +/// circuiting before that call. A sleeping recovery electorate must not raise the bar here: +/// with 1 ordinary operator and 2 (asleep) recovery operators, the lone ordinary approval +/// must already reach full quorum. +#[tokio::test] +async fn sleeping_recovery_operators_do_not_count_towards_quorum() { + let outcome = settle_vote_with( + ProposalKindTag::ReplaceOperator, + Tally { + approve: 1, + reject: 0, + total_ordinary: 1, + total_recovery: 2, + }, + /* recovery_active */ false, + ProposalStatus::Approved, + ) + .await; assert_eq!(outcome, VoteOutcome::Approved); } diff --git a/server/crates/arbiter-server/src/db/proposal/mod.rs b/server/crates/arbiter-server/src/db/proposal/mod.rs index a8e756a..b03dbbd 100644 --- a/server/crates/arbiter-server/src/db/proposal/mod.rs +++ b/server/crates/arbiter-server/src/db/proposal/mod.rs @@ -82,6 +82,12 @@ impl ProposalKindTag { pub const fn requires_full_quorum(self) -> bool { matches!(self, Self::ReplaceOperator | Self::TriggerRekey) } + + /// §3.5: recovery operators weigh in on operator replacement and nothing else. + #[must_use] + pub const fn recovery_may_vote(self) -> bool { + matches!(self, Self::ReplaceOperator) + } } /// Pins every implementation to the variant it is dispatched from. Without this a -- 2.49.1 From e80dc53b0ef6cbd27a740d2208e6c3a9282798d8 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 16:22:33 +0200 Subject: [PATCH 60/66] fix(vault): read the unseal threshold from the recorded split parameters --- .../2026-02-14-171124-0000_init/up.sql | 6 +- .../src/actors/vault_coordinator/mod.rs | 51 +++++--- server/crates/arbiter-server/src/db/schema.rs | 1 + .../arbiter-server/tests/vault/lifecycle.rs | 109 +++++++++++++++++- 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 42c7756..8db1fb0 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -37,7 +37,11 @@ create table if not exists tls_history ( create table if not exists arbiter_settings ( id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1 root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet - tls_id integer references tls_history (id) on delete RESTRICT + tls_id integer references tls_history (id) on delete RESTRICT, + -- Shamir threshold of the split that produced the stored shares. Null before bootstrap. + -- Recorded rather than recomputed: an aborted operator replacement leaves fewer share + -- rows than the split has shares, and a recomputed threshold would then be wrong. + shamir_threshold integer ) STRICT; insert into arbiter_settings (id) values (1) on conflict do nothing; diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index af967fd..524bd6e 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -142,6 +142,36 @@ fn decrypt_share( Ok(share_buffer.read().clone()) } +/// Records the threshold of the split that produced the stored shares. +async fn store_threshold(conn: &mut db::DatabaseConnection, threshold: usize) -> Result<(), Error> { + // A threshold that doesn't fit in the column is a bug, not a real empty-committee refusal. + let threshold = i32::try_from(threshold).map_err(|_| Error::BrokenDatabase)?; + let rows_updated = diesel::update(schema::arbiter_settings::table) + .set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold))) + .execute(conn) + .await?; + // The singleton row always exists (up.sql seeds it), so anything else means the update did + // not land -- bootstrap would then report success while the threshold stays NULL forever. + if rows_updated != 1 { + return Err(Error::BrokenDatabase); + } + Ok(()) +} + +/// Reads back the threshold recorded at bootstrap or re-key time. +async fn load_threshold(conn: &mut db::DatabaseConnection) -> Result { + let stored: Option = schema::arbiter_settings::table + .select(schema::arbiter_settings::shamir_threshold) + .first(conn) + .await?; + + // A missing or out-of-range value here means the recorded threshold is corrupt, not that the + // committee is genuinely empty -- `EmptyCommittee` is reserved for the real domain refusal. + stored + .and_then(|threshold| usize::try_from(threshold).ok()) + .ok_or(Error::BrokenDatabase) +} + /// §3.4: Split the seal key across ordinary + recovery operators. /// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery. /// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split, @@ -212,6 +242,8 @@ async fn finalize_bootstrap( .await?; } + store_threshold(&mut conn, threshold).await?; + vault.ask(Bootstrap { seal_key }).await.map_err(|err| { error!(?err, "Vault bootstrap failed"); Error::VaultError @@ -230,12 +262,7 @@ async fn finalize_unseal( let mut conn = db.get().await?; // Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2). - let ordinary_operator_count: i64 = schema::operator::table - .count() - .get_result(&mut conn) - .await?; - let threshold = - shamir_threshold(ordinary_operator_count as usize).ok_or(Error::EmptyCommittee)?; + let threshold = load_threshold(&mut conn).await?; let mut shares: Vec> = Vec::new(); @@ -372,6 +399,8 @@ async fn finalize_rekey( .await?; } + store_threshold(&mut conn, threshold).await?; + drop(conn); let new_seal_key = KeyCell::from(new_seal_key_bytes); @@ -582,16 +611,12 @@ impl VaultCoordinator { impl VaultCoordinator { /// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`. - /// Threshold is based on ordinary operator count only (§3.4). + /// Threshold comes from the recorded split parameters (§3.4), not from a live row count. async fn ensure_unsealing_state(&mut self) -> Result<(), Error> { if matches!(self.state, CoordinatorState::Idle) { let mut conn = self.db.get().await?; - let ordinary_count: i64 = schema::operator::table - .count() - .get_result(&mut conn) - .await?; - let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default()) - .ok_or(Error::EmptyCommittee)?; + let threshold = load_threshold(&mut conn).await?; + drop(conn); self.state = CoordinatorState::Unsealing { threshold, ordinary_passphrases: HashMap::new(), diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c71717c..9922733 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -17,6 +17,7 @@ diesel::table! { id -> Integer, root_key_id -> Nullable, tls_id -> Nullable, + shamir_threshold -> Nullable, } } diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 012e251..8551659 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -6,7 +6,7 @@ use arbiter_server::{ vault::{Error, GetState, Vault, VaultState}, vault_coordinator::{ ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal, - Error as CoordinatorError, StartBootstrap, VaultCoordinator, + ContributeUnseal, Error as CoordinatorError, StartBootstrap, VaultCoordinator, }, }, crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, @@ -310,3 +310,110 @@ async fn empty_committee_is_rejected_without_panicking() { kameo::error::SendError::HandlerError(CoordinatorError::EmptyCommittee) )); } + +/// An approved-but-unfinished operator replacement deletes a share row. The unseal threshold +/// must still describe the split that is actually stored, not the surviving row count. +/// +/// Four ordinary operators give a real 3-of-4 `vsss-rs` split (`shamir_threshold(4) == 3`). +/// Deleting one share row leaves 3 rows, and `shamir_threshold(3) == 2` -- a *different* number +/// from the recorded threshold. A recount-based unseal would therefore finalize one contribution +/// early, combine only 2 shares of a 3-of-4 split, and fail to reconstruct the seal key. +#[tokio::test] +#[test_log::test] +async fn unseal_threshold_survives_a_deleted_share_row() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref)); + + // Four ordinary operators: threshold is 3-of-4. + let mut ids = Vec::new(); + for n in 1..=4u8 { + let mut conn = db.get().await.unwrap(); + let id: i32 = insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![n; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + ids.push(id); + } + + coordinator + .ask(StartBootstrap { + operator_id: ids[0], + declared_count: 4, + recovery_count: 0, + }) + .await + .unwrap(); + for (n, id) in ids.iter().enumerate() { + coordinator + .ask(ContributeBootstrap { + operator_id: *id, + passphrase: SafeCell::new(format!("pass-{n}").into_bytes()), + }) + .await + .unwrap(); + } + + let stored: Option = { + let mut conn = db.get().await.unwrap(); + schema::arbiter_settings::table + .select(schema::arbiter_settings::shamir_threshold) + .first(&mut conn) + .await + .unwrap() + }; + assert_eq!(stored, Some(3), "bootstrap must record the split threshold"); + + // Simulate the aborted replacement: one share row is gone, leaving 3 of the 4 shares. + { + let mut conn = db.get().await.unwrap(); + diesel::delete(schema::operator::table) + .filter(schema::operator::id.eq(Some(ids[3]))) + .execute(&mut conn) + .await + .unwrap(); + } + + // Restart and unseal with the three surviving operators' original passphrases. + drop(coordinator); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + + let done = coordinator2 + .ask(ContributeUnseal { + operator_id: ids[0], + passphrase: SafeCell::new(b"pass-0".to_vec()), + }) + .await + .unwrap(); + assert!(!done, "one share must not be enough for a 3-of-4 split"); + + let done = coordinator2 + .ask(ContributeUnseal { + operator_id: ids[1], + passphrase: SafeCell::new(b"pass-1".to_vec()), + }) + .await + .unwrap(); + assert!( + !done, + "two shares must not be enough for a 3-of-4 split -- a recount would wrongly finalize here" + ); + + let done = coordinator2 + .ask(ContributeUnseal { + operator_id: ids[2], + passphrase: SafeCell::new(b"pass-2".to_vec()), + }) + .await + .unwrap(); + assert!(done, "three shares must reconstruct a 3-of-4 split"); + assert_eq!( + vault_ref2.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); +} -- 2.49.1 From 5ade05d475683a31984b409713215967d1ea25e2 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 17:00:43 +0200 Subject: [PATCH 61/66] fix(vault): require an elapsed wake-up before a recovery share can unseal --- .../src/actors/proposal_manager.rs | 4 - .../src/actors/proposal_manager/store.rs | 17 +-- .../src/actors/vault_coordinator/mod.rs | 9 ++ server/crates/arbiter-server/src/db/mod.rs | 1 + .../crates/arbiter-server/src/db/recovery.rs | 102 ++++++++++++++++++ .../arbiter-server/tests/vault/lifecycle.rs | 96 ++++++++++++++++- 6 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 server/crates/arbiter-server/src/db/recovery.rs diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 8f8156e..ae93685 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -25,10 +25,6 @@ pub mod store; pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS; -/// Recovery operators stay asleep for this long after a wake-up is requested, so the other -/// operators have time to dispute it (§3.6). -const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; // 14 days - #[derive(Debug, Clone, PartialEq, Eq)] pub enum VoteOutcome { Pending, diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs index f735a7e..749443b 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs @@ -3,10 +3,9 @@ //! The actor holds a `dyn ProposalStore` rather than a connection pool, so the quorum //! rules can be exercised against a mock instead of a live SQLite file. -use super::{Error, ProposalSummary, WAKEUP_DELAY_SECS}; +use super::{Error, ProposalSummary}; use crate::db::{ self, - functions::unixepoch, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId, @@ -360,17 +359,9 @@ impl ProposalStore for DieselProposalStore { async fn is_recovery_active(&self) -> Result { let mut conn = self.db.get().await?; - select(exists( - schema::recovery_wakeup_request::table - .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) - .filter( - schema::recovery_wakeup_request::requested_at - .le(unixepoch("now") - WAKEUP_DELAY_SECS), - ), - )) - .get_result(&mut conn) - .await - .map_err(Error::from) + db::recovery::is_active(&mut conn) + .await + .map_err(Error::from) } async fn has_uncancelled_wakeup(&self) -> Result { diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 524bd6e..326536e 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -54,6 +54,8 @@ pub enum Error { BrokenDatabase, #[error("A committee must have at least one ordinary operator")] EmptyCommittee, + #[error("Recovery operators are sleeping")] + RecoveryNotActive, } // Passphrases stored as plain Vec (not SafeCell) so CoordinatorState is Sync. @@ -583,6 +585,13 @@ impl VaultCoordinator { recovery_operator_id: i32, mut passphrase: SafeCell>, ) -> Result { + { + let mut conn = self.db.get().await?; + if !db::recovery::is_active(&mut conn).await? { + return Err(Error::RecoveryNotActive); + } + } + self.ensure_unsealing_state().await?; let CoordinatorState::Unsealing { diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index 01d1b87..3f8352c 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -11,6 +11,7 @@ use tracing::info; pub mod functions; pub mod models; pub mod proposal; +pub mod recovery; pub mod schema; pub type DatabaseConnection = SyncConnectionWrapper; diff --git a/server/crates/arbiter-server/src/db/recovery.rs b/server/crates/arbiter-server/src/db/recovery.rs new file mode 100644 index 0000000..638331d --- /dev/null +++ b/server/crates/arbiter-server/src/db/recovery.rs @@ -0,0 +1,102 @@ +//! Whether the recovery committee is awake. +//! +//! §3.6: a wake-up request opens a dispute window; recovery powers only become active once +//! that window has elapsed without cancellation. Both the proposal manager (for voting) and +//! the vault coordinator (for unsealing) gate on this, so the rule lives in one place. + +use crate::db::{functions::unixepoch, schema}; + +use diesel::{ + ExpressionMethods as _, QueryDsl as _, + dsl::{exists, select}, +}; +use diesel_async::RunQueryDsl; + +/// Recovery operators stay asleep for this long after a wake-up is requested, so the other +/// operators have time to dispute it (§3.6). +pub const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60; + +/// True when an uncancelled wake-up request is older than the dispute window. +pub async fn is_active( + conn: &mut crate::db::DatabaseConnection, +) -> Result { + select(exists( + schema::recovery_wakeup_request::table + .filter(schema::recovery_wakeup_request::cancelled_at.is_null()) + .filter( + schema::recovery_wakeup_request::requested_at + .le(unixepoch("now") - WAKEUP_DELAY_SECS), + ), + )) + .get_result(conn) + .await +} + +#[cfg(test)] +mod tests { + use super::{WAKEUP_DELAY_SECS, is_active}; + use crate::db::{self, schema}; + + use diesel::{ExpressionMethods as _, insert_into}; + use diesel_async::RunQueryDsl; + + /// `recovery_wakeup_request.requested_by` references `operator_identity(id)`, and pooled + /// connections enforce foreign keys, so every wake-up row needs a real identity behind it. + async fn insert_operator(pool: &db::DatabasePool) -> i32 { + let mut conn = pool.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![7u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + } + + /// Pins `.filter(requested_at.le(...))`: a wake-up requested moments ago must not be + /// active yet, even though nothing has cancelled it. Deleting that filter turns this + /// assertion false without touching any other test in the suite. + #[tokio::test] + async fn a_recent_wakeup_is_not_yet_active() { + let pool = db::create_test_pool().await; + let operator_id = insert_operator(&pool).await; + let mut conn = pool.get().await.unwrap(); + + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({operator_id}, unixepoch('now'))" + )) + .execute(&mut conn) + .await + .unwrap(); + + assert!( + !is_active(&mut conn).await.unwrap(), + "a wake-up requested moments ago must still be asleep" + ); + } + + /// Pins `.filter(cancelled_at.is_null())`: a cancelled wake-up must not count towards + /// activity even once its original request has outlived the dispute window. Deleting + /// that filter turns this assertion false without touching any other test in the suite. + #[tokio::test] + async fn a_cancelled_wakeup_is_not_active_even_past_the_window() { + let pool = db::create_test_pool().await; + let operator_id = insert_operator(&pool).await; + let mut conn = pool.get().await.unwrap(); + + diesel::sql_query(format!( + "INSERT INTO recovery_wakeup_request \ + (requested_by, requested_at, cancelled_by, cancelled_at) \ + VALUES ({operator_id}, unixepoch('now') - {WAKEUP_DELAY_SECS} - 1, \ + {operator_id}, unixepoch('now'))" + )) + .execute(&mut conn) + .await + .unwrap(); + + assert!( + !is_active(&mut conn).await.unwrap(), + "a cancelled wake-up must not activate recovery even past the window" + ); + } +} diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 8551659..8a655eb 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -13,7 +13,7 @@ use arbiter_server::{ db::{self, models, schema}, }; -use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into}; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into, sql_query}; use diesel_async::RunQueryDsl; use kameo::actor::Spawn as _; @@ -254,6 +254,19 @@ async fn recovery_share_stored_and_used_for_unseal() { let state = vault_ref2.ask(GetState {}).await.unwrap(); assert_eq!(state, VaultState::Sealed); + // §3.6: the recovery operator's share only counts once a wake-up request has stood + // uncancelled for the full dispute window, so back-date one here before unsealing. + { + let mut conn = db.get().await.unwrap(); + sql_query(format!( + "INSERT INTO recovery_wakeup_request (requested_by, requested_at) \ + VALUES ({ordinary_id}, unixepoch('now') - 14*24*3600 - 1)" + )) + .execute(&mut conn) + .await + .unwrap(); + } + // §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1). let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); let done = coordinator2 @@ -417,3 +430,84 @@ async fn unseal_threshold_survives_a_deleted_share_row() { VaultState::Unsealed ); } + +/// §3.6: recovery operators are asleep by default. Without a wake-up whose 14-day dispute +/// window has elapsed, their share must not count towards an unseal. +#[tokio::test] +#[test_log::test] +async fn sleeping_recovery_operator_cannot_contribute_to_unseal() { + let db = db::create_test_pool().await; + let bus = GlobalActors::spawn_message_bus(); + let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap()); + let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone())); + + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![1u8; 32])) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32])) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + coordinator + .ask(StartBootstrap { + operator_id: ordinary_id, + declared_count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + coordinator + .ask(ContributeRecoveryBootstrap { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap(); + coordinator + .ask(ContributeBootstrap { + operator_id: ordinary_id, + passphrase: SafeCell::new(b"ordinary-pass".to_vec()), + }) + .await + .unwrap(); + + // Restart so the vault comes up Sealed. + drop(coordinator); + drop(vault_ref); + let bus2 = GlobalActors::spawn_message_bus(); + let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap()); + let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone())); + + let err = coordinator2 + .ask(ContributeRecoveryUnseal { + recovery_operator_id: recovery_id, + passphrase: SafeCell::new(b"recovery-pass".to_vec()), + }) + .await + .unwrap_err(); + + assert!( + matches!( + err, + kameo::error::SendError::HandlerError(CoordinatorError::RecoveryNotActive) + ), + "expected RecoveryNotActive, got {err:?}" + ); + assert_eq!( + vault_ref2.ask(GetState {}).await.unwrap(), + VaultState::Sealed, + "a sleeping recovery operator unsealed the vault" + ); +} -- 2.49.1 From 2b02d4a9b1a2d8700c283d1e6bc78562e64ea582 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 17:40:15 +0200 Subject: [PATCH 62/66] fix(bootstrap): keep the token valid until the vault is bootstrapped --- server/Cargo.lock | 1 + server/crates/arbiter-server/Cargo.toml | 1 + .../arbiter-server/src/actors/bootstrap.rs | 301 +++++++++++++++--- .../crates/arbiter-server/src/actors/mod.rs | 40 ++- .../arbiter-server/src/actors/vault/mod.rs | 4 +- .../crates/arbiter-server/src/db/functions.rs | 2 +- server/crates/arbiter-server/src/db/mod.rs | 11 +- .../src/peers/operator/auth/state.rs | 68 ++-- .../arbiter-server/tests/client/auth.rs | 4 +- .../crates/arbiter-server/tests/common/mod.rs | 30 ++ .../crates/arbiter-server/tests/governance.rs | 52 +-- .../arbiter-server/tests/operator/auth.rs | 287 ++++++++++++++++- .../arbiter-server/tests/operator/unseal.rs | 8 +- 13 files changed, 700 insertions(+), 109 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index d08434b..c608b77 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -789,6 +789,7 @@ dependencies = [ "smlang", "strum 0.28.0", "subtle", + "tempfile", "test-log", "thiserror", "tokio", diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index b17e739..5bf6960 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -60,6 +60,7 @@ rstest.workspace = true test-log = { version = "0.2", default-features = false, features = ["trace"] } ml-dsa.workspace = true mockall = "0.15.0" +tempfile = "3.27.0" [lib] doctest = false diff --git a/server/crates/arbiter-server/src/actors/bootstrap.rs b/server/crates/arbiter-server/src/actors/bootstrap.rs index c1fe6af..ac2a5f9 100644 --- a/server/crates/arbiter-server/src/actors/bootstrap.rs +++ b/server/crates/arbiter-server/src/actors/bootstrap.rs @@ -4,28 +4,43 @@ use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use diesel::QueryDsl; use diesel_async::RunQueryDsl; use kameo::{Actor, messages}; -use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; +use rand::{ + distr::{Alphanumeric, SampleString as _}, + make_rng, + rngs::StdRng, +}; +use std::path::Path; use subtle::ConstantTimeEq as _; use thiserror::Error; const TOKEN_LENGTH: usize = 64; -pub async fn generate_token() -> Result { - let rng: StdRng = make_rng(); +pub async fn generate_token(home: &Path) -> Result { + let mut rng: StdRng = make_rng(); - let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold( - String::default(), - |mut accum, char| { - accum += char.to_string().as_str(); - accum - }, - ); + // `Alphanumeric` samples raw `u8` ASCII codes, not `char`s -- `SampleString::sample_string` + // is `rand`'s own documented way to turn that into an actual TOKEN_LENGTH-character string + // (see the "Passwords" example on `Alphanumeric`'s docs). A prior version of this function + // called `.to_string()` on the sampled `u8` directly, which stringifies the numeric byte + // value (e.g. `65` instead of `'A'`) rather than the character it represents, silently + // producing a variable-length, all-decimal-digit string instead of a real token. + let token = Alphanumeric.sample_string(&mut rng, TOKEN_LENGTH); - tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?; + tokio::fs::write(home.join(BOOTSTRAP_PATH), token.as_str()).await?; Ok(token) } +/// A token file is only trustworthy if it looks like something `generate_token` could have +/// produced. Anything else -- empty (a crash between the file's truncate and write), foreign +/// content, or a trailing newline added by an editor -- must not be adopted as a live +/// credential: an empty file would make every empty-string token verify, and mismatched +/// content would silently lock out every operator holding the real, already-printed token. +#[must_use] +fn is_valid_token(candidate: &str) -> bool { + candidate.len() == TOKEN_LENGTH && candidate.chars().all(|c| c.is_ascii_alphanumeric()) +} + #[derive(Error, Debug)] pub enum Error { #[error("Database error: {0}")] @@ -44,31 +59,77 @@ pub struct Bootstrapper { } impl Bootstrapper { + /// Production constructor: resolves the real `~/.arbiter` directory and delegates. pub async fn new(db: &DatabasePool) -> Result { - let row_count: i64 = { - let mut conn = db.get().await?; - - schema::operator::table - .count() - .get_result(&mut conn) - .await? - }; - - let token = if row_count == 0 { - let token = generate_token().await?; - Some(token) - } else { - None - }; - - Ok(Self { token }) + let home = home_path()?; + Self::new_in(db, &home).await } -} -#[messages] -impl Bootstrapper { - #[message] - pub fn is_correct_token(&self, token: String) -> bool { + /// Carries all of `new`'s logic, parameterized on the directory the token file lives in. + /// `new` resolves the real home directory and calls this; tests call it directly with a + /// throwaway temp directory so they never touch the real `~/.arbiter/bootstrap_token`. + pub async fn new_in(db: &DatabasePool, home: &Path) -> Result { + let mut conn = db.get().await?; + + let bootstrapped: bool = schema::arbiter_settings::table + .select(schema::arbiter_settings::root_key_id) + .first::>(&mut conn) + .await? + .is_some(); + + if bootstrapped { + return Ok(Self { token: None }); + } + + let any_operator_registered: bool = schema::operator_identity::table + .count() + .get_result::(&mut conn) + .await? + > 0; + + if !any_operator_registered { + // Nobody has used the current token yet, so there is nothing to preserve across a + // restart: generate a fresh one, exactly as on a first run. Reusing an old file + // here would let a token survive a database reset, silently reviving trust in + // whoever still held it. + return Ok(Self { + token: Some(generate_token(home).await?), + }); + } + + // At least one operator has already registered with the current token: every other + // declared operator still needs that same token, including across a restart, so an + // existing file is reused rather than replaced -- but only if it still looks like a + // real token. A truncated, foreign, or corrupted file must not become a live + // credential (see `is_valid_token`). + let path = home.join(BOOTSTRAP_PATH); + let token = match tokio::fs::read_to_string(&path).await { + Ok(existing) if is_valid_token(&existing) => existing, + Ok(_) => { + // Replacing the file invalidates whatever token the already-registered + // operators were handed, so it must not happen quietly. The content itself is + // a credential and stays out of the log; the path is enough to act on. + tracing::warn!( + ?path, + "Bootstrap token file is not a well-formed token; replacing it" + ); + generate_token(home).await? + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => generate_token(home).await?, + Err(err) => return Err(Error::Io(err)), + }; + + Ok(Self { token: Some(token) }) + } + + /// Drops the token from memory. Called once the vault is bootstrapped: from then on, + /// operators are added through governance rather than through the token. + pub(crate) fn forget_token(&mut self) { + self.token = None; + } + + #[must_use] + fn is_correct_token(&self, token: &str) -> bool { self.token.as_ref().is_some_and(|expected| { let expected_bytes = expected.as_bytes(); let token_bytes = token.as_bytes(); @@ -77,15 +138,28 @@ impl Bootstrapper { bool::from(choice) }) } +} +#[messages] +impl Bootstrapper { + /// Checks the token without retiring it: every operator in a declared committee + /// authenticates with the same token during bootstrap. #[message] - pub fn consume_token(&mut self, token: String) -> bool { - if self.is_correct_token(token) { - self.token = None; - true - } else { - false - } + #[must_use] + pub fn verify_token(&self, token: String) -> bool { + self.is_correct_token(&token) + } +} + +impl kameo::prelude::Message for Bootstrapper { + type Reply = (); + + async fn handle( + &mut self, + _msg: crate::actors::vault::events::Bootstrapped, + _ctx: &mut kameo::prelude::Context, + ) -> Self::Reply { + self.forget_token(); } } @@ -96,3 +170,150 @@ impl Bootstrapper { self.token.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + use diesel::{ExpressionMethods as _, insert_into, update}; + + /// A multi-operator committee registers every member with the same token, so verifying it + /// must not consume it. Only a completed bootstrap retires the token. + #[tokio::test] + async fn token_verifies_repeatedly_until_bootstrap_completes() { + let mut bootstrapper = Bootstrapper { + token: Some("test-token".to_owned()), + }; + + assert!(bootstrapper.verify_token("test-token".to_owned())); + assert!(bootstrapper.verify_token("test-token".to_owned())); + assert!(!bootstrapper.verify_token("wrong-token".to_owned())); + + bootstrapper.forget_token(); + + assert!(!bootstrapper.verify_token("test-token".to_owned())); + assert!(bootstrapper.get_token().is_none()); + } + + /// Once the vault is bootstrapped, `Bootstrapper::new_in` must return with no token -- and + /// it must do so from `arbiter_settings.root_key_id` alone, taking the early return before + /// `home` is ever consulted. Uses `new_in` with a throwaway temp directory (never the + /// production `new`, which unconditionally resolves the real home directory before this + /// method even runs) so this test cannot touch the real filesystem regardless of outcome. + #[tokio::test] + async fn new_returns_no_token_once_the_vault_is_bootstrapped() { + let db = db::create_test_pool().await; + let mut conn = db.get().await.unwrap(); + + let root_key_history_id: i32 = insert_into(schema::root_key_history::table) + .values(&db::models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(&mut conn) + .await + .unwrap(); + + update(schema::arbiter_settings::table) + .set(schema::arbiter_settings::root_key_id.eq(root_key_history_id)) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let home = tempfile::tempdir().unwrap(); + let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap(); + + assert!(bootstrapper.get_token().is_none()); + } + + /// The file-reuse path (an operator has already registered, so bootstrap is unfinished) + /// must not adopt a corrupted token file as a live credential: it must reject it and + /// generate a fresh one instead. This is the Critical from the review, now reachable + /// safely because `new_in` takes a throwaway temp directory instead of the real home. + #[tokio::test] + async fn new_in_rejects_and_replaces_a_corrupted_token_file() { + let db = db::create_test_pool().await; + let mut conn = db.get().await.unwrap(); + + // At least one operator must have registered, or `new_in` would regenerate + // unconditionally regardless of the file (Important 2) and never exercise validation. + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let home = tempfile::tempdir().unwrap(); + tokio::fs::write(home.path().join(BOOTSTRAP_PATH), "") + .await + .unwrap(); + + let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap(); + + let token = bootstrapper + .get_token() + .expect("a fresh token must be generated"); + assert!(is_valid_token(&token)); + + // The replacement must also have landed on disk, not just in memory, so a restart + // reads back the same (now valid) token rather than the corrupted one again. + let on_disk = tokio::fs::read_to_string(home.path().join(BOOTSTRAP_PATH)) + .await + .unwrap(); + assert_eq!(on_disk, token); + } + + /// The second defect the task exists to fix: a restart between the first registration and + /// the completed bootstrap must not invalidate the token the other declared operators were + /// already given. With an operator registered and a well-formed file on disk, `new_in` has + /// to hand back exactly what it read instead of generating a replacement. + #[tokio::test] + async fn new_in_reuses_a_valid_token_file_across_a_restart() { + let db = db::create_test_pool().await; + let mut conn = db.get().await.unwrap(); + + // Without a registered operator, `new_in` regenerates unconditionally (Important 2 of + // the round-2 review) and never reaches the reuse path this test is about. + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(vec![0u8; 32])) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let home = tempfile::tempdir().unwrap(); + // Stands in for the token a previous run wrote and printed to the console. + let handed_out = "Zq7Z2rXaB90kLmNpQwErTyUiOpAsDfGhJkLzXcVbNmQwErTyUiOpAsDfGhJkLzXc"; + assert!(is_valid_token(handed_out)); + tokio::fs::write(home.path().join(BOOTSTRAP_PATH), handed_out) + .await + .unwrap(); + + let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap(); + + assert_eq!(bootstrapper.get_token().as_deref(), Some(handed_out)); + } + + /// An empty file must not be adopted as a live credential: `is_correct_token`'s + /// `is_some_and` would enter its closure for `Some(String::new())`, and comparing two empty + /// byte slices is true, so an unauthenticated `Some("")` from the wire would otherwise + /// verify. Also pins the other corrupted-content shapes `is_valid_token` must reject. + #[test] + fn is_valid_token_rejects_anything_that_is_not_a_real_token() { + assert!(!is_valid_token("")); + assert!(!is_valid_token("too-short")); + assert!(!is_valid_token(&"a".repeat(TOKEN_LENGTH - 1))); + assert!(!is_valid_token(&"a".repeat(TOKEN_LENGTH + 1))); + // A trailing newline (e.g. from an editor) must not be silently accepted either. + assert!(!is_valid_token(&format!("{}\n", "a".repeat(TOKEN_LENGTH)))); + assert!(!is_valid_token(&"!".repeat(TOKEN_LENGTH))); + + assert!(is_valid_token(&"a".repeat(TOKEN_LENGTH))); + } +} diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index 812b1be..913ec0a 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -5,7 +5,7 @@ use crate::{ flow_coordinator::FlowCoordinator, operator_registry::OperatorRegistry, proposal_manager::{ProposalManager, events::ProposalApproved}, - vault::Vault, + vault::{Vault, events}, vault_coordinator::VaultCoordinator, }, db, @@ -17,6 +17,7 @@ use kameo_actors::{ message_bus::{MessageBus, Register}, }; use thiserror::Error; +use tracing::error; pub mod bootstrap; pub mod evm; @@ -54,6 +55,26 @@ impl GlobalActors { } pub async fn spawn(db: db::DatabasePool) -> Result { + let bootstrapper = Bootstrapper::new(&db).await?; + Self::spawn_with_bootstrapper(db, bootstrapper).await + } + + /// Test-facing: threads an explicit directory through to `Bootstrapper` instead of letting + /// it resolve the real home directory, so a test spawning a full `GlobalActors` can never + /// reach (let alone write to) the real `~/.arbiter/bootstrap_token`. Mirrors `spawn` + /// exactly, aside from where the token file lives. + pub async fn spawn_in( + db: db::DatabasePool, + home: &std::path::Path, + ) -> Result { + let bootstrapper = Bootstrapper::new_in(&db, home).await?; + Self::spawn_with_bootstrapper(db, bootstrapper).await + } + + async fn spawn_with_bootstrapper( + db: db::DatabasePool, + bootstrapper: Bootstrapper, + ) -> Result { let message_bus = Self::spawn_message_bus(); let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); @@ -62,6 +83,7 @@ impl GlobalActors { db.clone(), key_holder.clone(), )); + let bootstrapper = Bootstrapper::spawn(bootstrapper); // Approved proposals are executed by whoever owns the kind, not by ProposalManager. for recipient in [ evm.clone().recipient::(), @@ -70,9 +92,23 @@ impl GlobalActors { ] { let _ = message_bus.tell(Register(recipient)).await; } + // The token guards bootstrap only: once the vault reports success, it must be retired. + // A dropped registration would leave the token valid forever with nothing else to + // notice, so a failure here is logged rather than silently discarded. + if let Err(err) = message_bus + .tell(Register( + bootstrapper.clone().recipient::(), + )) + .await + { + error!( + ?err, + "Failed to register Bootstrapper for the Bootstrapped event" + ); + } Ok(Self { - bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), + bootstrapper, proposal_manager: ProposalManager::spawn(ProposalManager::new(db, message_bus.clone())), vault: key_holder, vault_coordinator, diff --git a/server/crates/arbiter-server/src/actors/vault/mod.rs b/server/crates/arbiter-server/src/actors/vault/mod.rs index b4050c7..f994f72 100644 --- a/server/crates/arbiter-server/src/actors/vault/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault/mod.rs @@ -222,7 +222,9 @@ impl Vault { }); info!("Vault bootstrapped successfully"); - let _ = self.events.tell(Publish(events::Bootstrapped)).await; + if let Err(err) = self.events.tell(Publish(events::Bootstrapped)).await { + error!(?err, "Failed to publish Bootstrapped event"); + } Ok(()) } diff --git a/server/crates/arbiter-server/src/db/functions.rs b/server/crates/arbiter-server/src/db/functions.rs index faf6465..3b5d119 100644 --- a/server/crates/arbiter-server/src/db/functions.rs +++ b/server/crates/arbiter-server/src/db/functions.rs @@ -1,6 +1,6 @@ //! Typed bindings for the SQLite scalar functions used in Diesel expressions. -use diesel::sql_types::{Integer, Text}; +use diesel::sql_types::Text; diesel::define_sql_function! { /// SQLite `unixepoch(modifier)` -- seconds since the Unix epoch. diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index 3f8352c..cffde4f 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -101,12 +101,17 @@ fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> { /// # Panics /// Panics if the database path is not valid UTF-8. pub async fn create_pool(url: Option<&str>) -> Result { - let database_url = url.map(String::from).unwrap_or( - database_path()? + // Matched rather than `unwrap_or`, whose argument is evaluated even when `url` is `Some`: + // `database_path` resolves the real home directory and creates `~/.arbiter` as a side + // effect, so an eager call reaches the developer's home from every test that passes an + // explicit temp path, and fails outright wherever no home directory is writable. + let database_url = match url { + Some(url) => url.to_owned(), + None => database_path()? .to_str() .expect("database path is not valid UTF-8") .to_owned(), - ); + }; initialize_database(&database_url)?; diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index ab74c3e..9f86547 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -3,15 +3,18 @@ use super::{ Error, }; use crate::{ - actors::bootstrap::ConsumeToken, - db::{DatabasePool, schema::operator_identity}, + actors::bootstrap::VerifyToken, + db::{ + DatabasePool, + schema::{arbiter_settings, operator_identity}, + }, peers::operator::auth::Outbound, }; use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; -use diesel_async::RunQueryDsl; +use diesel_async::{AsyncConnection as _, RunQueryDsl}; use tracing::error; pub(crate) struct ChallengeRequest { @@ -63,17 +66,32 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result>(&mut *conn) + .await? + .is_some(); - Ok(id) + if already_bootstrapped { + error!("Bootstrap token used to register after the vault was already bootstrapped"); + return Err(Error::InvalidBootstrapToken); + } + + let id: i32 = diesel::insert_into(operator_identity::table) + .values((operator_identity::public_key.eq(pubkey_bytes),)) + .returning(operator_identity::id) + .get_result(&mut *conn) + .await?; + + Ok(id) + }) + .await } pub(super) struct AuthContext<'a, T: ?Sized> { @@ -151,20 +169,20 @@ where return Err(Error::InvalidChallengeSolution); } - // Resolve client id: bootstrap (consume token + register) or lookup + // Resolve client id: bootstrap (verify token, then register) or lookup let id = match bootstrap_token { Some(token) => { let token_ok: bool = self .conn .actors .bootstrapper - .ask(ConsumeToken { + .ask(VerifyToken { token: token.clone(), }) .await .map_err(|e| { - error!(?e, "Failed to consume bootstrap token"); - Error::internal("Failed to consume bootstrap token") + error!(?e, "Failed to verify bootstrap token"); + Error::internal("Failed to verify bootstrap token") })?; if !token_ok { @@ -176,7 +194,21 @@ where return Err(Error::InvalidBootstrapToken); } - register_key(&self.conn.db, pubkey).await? + match register_key(&self.conn.db, pubkey).await { + Ok(id) => id, + // `register_key` refuses a token that verified here but lost the race + // against bootstrap. Reported to the peer exactly like the refusal above, + // so that operator sees a protocol error rather than a handshake that + // stops with nothing on the wire. + Err(Error::InvalidBootstrapToken) => { + self.transport + .send(Err(Error::InvalidBootstrapToken)) + .await + .map_err(|_| Error::Transport)?; + return Err(Error::InvalidBootstrapToken); + } + Err(err) => return Err(err), + } } None => get_client_id(&self.conn.db, pubkey) .await? diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 945e82b..4c1977a 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,4 +1,4 @@ -use super::common::ChannelTransport; +use super::common::{ChannelTransport, spawn_actors}; use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, @@ -93,7 +93,7 @@ async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) { async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors { insert_bootstrap_sentinel_operator(db).await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { diff --git a/server/crates/arbiter-server/tests/common/mod.rs b/server/crates/arbiter-server/tests/common/mod.rs index 598eee9..42fe3a3 100644 --- a/server/crates/arbiter-server/tests/common/mod.rs +++ b/server/crates/arbiter-server/tests/common/mod.rs @@ -24,6 +24,36 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { actor } +/// Spawns a full `GlobalActors` for a test, backing `Bootstrapper`'s token file with a +/// throwaway temp directory rather than the real `~/.arbiter` -- a test must never be able to +/// reach, let alone write to, the developer's real bootstrap token file. +pub(crate) async fn spawn_actors(db: db::DatabasePool) -> GlobalActors { + let home = tempfile::tempdir().expect("failed to create a temp home directory for a test"); + GlobalActors::spawn_in(db, home.path()) + .await + .expect("failed to spawn GlobalActors for a test") +} + +/// Retries `probe` until it yields a value, then returns it. +/// +/// Effects that travel over the message bus are not visible the moment the publishing call +/// returns: `Publish` only enqueues to the bus's mailbox, which then delivers to each +/// subscriber's mailbox in turn. A test observing such an effect waits for it instead of +/// reading straight after the call that triggered it. +pub(crate) async fn eventually(what: &str, mut probe: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, +{ + for _ in 0..100 { + if let Some(value) = probe().await { + return value; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + panic!("{what} did not happen within 2s"); +} + pub(crate) async fn root_key_history_id(db: &db::DatabasePool) -> i32 { let mut conn = db.get().await.unwrap(); let id = schema::arbiter_settings::table diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 7e4cb13..21c4c31 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -46,6 +46,16 @@ where panic!("{what} did not happen within 2s"); } +/// Spawns a full `GlobalActors` for a test, backing `Bootstrapper`'s token file with a +/// throwaway temp directory rather than the real `~/.arbiter` -- a test must never be able to +/// reach, let alone write to, the developer's real bootstrap token file. +async fn spawn_actors(db: db::DatabasePool) -> GlobalActors { + let home = tempfile::tempdir().expect("failed to create a temp home directory for a test"); + GlobalActors::spawn_in(db, home.path()) + .await + .expect("failed to spawn GlobalActors for a test") +} + async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> OperatorIdentityId { let mut conn = db.get().await.unwrap(); insert_into(operator_identity::table) @@ -142,7 +152,7 @@ async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicK #[tokio::test] async fn create_proposal_returns_id() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { @@ -172,7 +182,7 @@ async fn create_proposal_returns_id() { #[tokio::test] async fn create_proposal_caps_the_ttl() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { @@ -213,7 +223,7 @@ async fn create_proposal_caps_the_ttl() { #[tokio::test] async fn single_operator_vote_reaches_quorum() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -258,7 +268,7 @@ async fn single_operator_vote_reaches_quorum() { #[tokio::test] async fn two_operator_first_vote_is_pending() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -304,7 +314,7 @@ async fn two_operator_first_vote_is_pending() { #[tokio::test] async fn duplicate_vote_rejected() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -365,7 +375,7 @@ async fn duplicate_vote_rejected() { #[tokio::test] async fn invalid_signature_rejected() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -406,7 +416,7 @@ async fn invalid_signature_rejected() { #[tokio::test] async fn query_pending_reports_a_tally_per_proposal() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { @@ -500,7 +510,7 @@ async fn query_pending_reports_a_tally_per_proposal() { #[tokio::test] async fn query_pending_excludes_already_voted() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -566,7 +576,7 @@ async fn query_pending_excludes_already_voted() { #[tokio::test] async fn expired_proposal_is_hidden_and_unvotable() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -624,7 +634,7 @@ async fn approve_sdk_client_writes_integrity_envelope() { use arbiter_server::db::schema::integrity_envelope; let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -683,7 +693,7 @@ async fn approve_sdk_client_writes_integrity_envelope() { #[tokio::test] async fn grant_wallet_access_on_quorum_approval() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -744,7 +754,7 @@ async fn grant_wallet_access_on_quorum_approval() { #[tokio::test] async fn approve_persistent_grant_creates_basic_grant_row() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -841,7 +851,7 @@ async fn approve_one_off_transaction_stores_result() { use chrono::Duration; let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -961,7 +971,7 @@ async fn approve_one_off_transaction_stores_result() { #[tokio::test] async fn replace_operator_updates_pubkey_and_starts_rekey() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -1033,7 +1043,7 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { #[tokio::test] async fn trigger_rekey_reaches_quorum() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -1075,7 +1085,7 @@ async fn trigger_rekey_reaches_quorum() { async fn key_rotation_requires_full_quorum() { // §3.3: ReplaceOperator needs all 3 operators to approve, not just shamir_threshold(3)=2 let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) @@ -1129,7 +1139,7 @@ async fn key_rotation_requires_full_quorum() { #[tokio::test] async fn recovery_vote_rejected_when_sleeping() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); let op_key = authn::SigningKey::generate(); @@ -1175,7 +1185,7 @@ async fn recovery_vote_rejected_when_sleeping() { #[tokio::test] async fn recovery_vote_blocked_on_non_replace_proposal() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); let op_key = authn::SigningKey::generate(); @@ -1224,7 +1234,7 @@ async fn recovery_vote_blocked_on_non_replace_proposal() { #[tokio::test] async fn recovery_wakeup_can_be_cancelled() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); let key = authn::SigningKey::generate(); @@ -1253,7 +1263,7 @@ async fn recovery_wakeup_can_be_cancelled() { #[tokio::test] async fn recovery_wakeup_prevents_duplicate_request() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); let key = authn::SigningKey::generate(); @@ -1281,7 +1291,7 @@ async fn recovery_wakeup_prevents_duplicate_request() { async fn recovery_operator_vote_contributes_to_replace_quorum() { // 1 ordinary operator + 1 recovery operator; replace_operator needs both. let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap(); let op_key = authn::SigningKey::generate(); diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 5e12a9a..f5beb34 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -1,8 +1,11 @@ -use super::common::ChannelTransport; +use super::common::{ChannelTransport, bootstrapped_vault, eventually, spawn_actors}; use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_server::{ - actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, + actors::{ + bootstrap::GetToken, + vault::{self, Bootstrap}, + }, crypto::integrity, db::{self, schema}, peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate}, @@ -150,14 +153,7 @@ impl Sender for StartTestTransport { #[test_log::test] pub async fn bootstrap_token_auth() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); - actors - .vault - .ask(Bootstrap { - seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), - }) - .await - .unwrap(); + let actors = spawn_actors(db.clone()).await; let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let (mut server_transport, mut test_transport) = ChannelTransport::new(); @@ -211,11 +207,131 @@ pub async fn bootstrap_token_auth() { assert_eq!(stored_pubkey, verifying_key(&new_key).encode().0.to_vec()); } +/// A multi-operator committee must all register with the same bootstrap token before bootstrap +/// completes, so verifying the token must not consume it. This is the reachability bug fixed by +/// replacing `consume_token` with `verify_token`. #[tokio::test] #[test_log::test] -pub async fn bootstrap_invalid_token_auth() { +pub async fn bootstrap_token_registers_every_committee_member() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + for _ in 0..2 { + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let actors_for_task = actors.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors_for_task); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let new_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&new_key).into(), + bootstrap_token: Some(token.clone()), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&new_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive auth result"); + assert!(matches!(response, Ok(auth::Outbound::AuthSuccess))); + + task.await.unwrap().unwrap(); + } + + let mut conn = db.get().await.unwrap(); + let registered: i64 = schema::operator_identity::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(registered, 2); + + // Bootstrap has not completed: the token must still be valid. + assert_eq!( + actors.bootstrapper.ask(GetToken).await.unwrap(), + Some(token) + ); +} + +/// `GlobalActors` must subscribe `Bootstrapper` to `events::Bootstrapped` on the message bus. +/// Without that one registration the token stays valid forever in production, which is the +/// defect this task exists to fix, and no other test notices: the test below drives the event +/// handler directly, and the `challenge_auth` family never re-reads the token after +/// bootstrapping. This one goes the whole way round -- real `Vault::bootstrap`, real bus -- +/// and waits for the effect rather than reading straight after the call, because `Publish` +/// only enqueues to the bus's mailbox. +#[tokio::test] +#[test_log::test] +pub async fn bootstrapped_event_retires_the_token_through_the_message_bus() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + assert!( + actors.bootstrapper.ask(GetToken).await.unwrap().is_some(), + "the token must exist before the vault is bootstrapped" + ); + + actors + .vault + .ask(Bootstrap { + seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), + }) + .await + .unwrap(); + + eventually("the bootstrap token to be retired", || async { + actors + .bootstrapper + .ask(GetToken) + .await + .unwrap() + .is_none() + .then_some(()) + }) + .await; +} + +/// Once the vault reports `Bootstrapped`, the token is retired: further registrations must be +/// rejected even with a token that verified successfully moments earlier. +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_token_rejected_after_bootstrapped_event() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + // Drive the Bootstrapper's own event handler directly rather than through + // `actors.vault.ask(Bootstrap { .. })` + the message bus: bus delivery is fire-and-forget, + // so asserting on it would be racy. The handler under test is the same either way. + actors + .bootstrapper + .ask(vault::events::Bootstrapped) + .await + .unwrap(); + assert!(actors.bootstrapper.ask(GetToken).await.unwrap().is_none()); let (mut server_transport, mut test_transport) = ChannelTransport::new(); let db_for_task = db.clone(); @@ -228,7 +344,7 @@ pub async fn bootstrap_invalid_token_auth() { test_transport .send(auth::Inbound::AuthChallengeRequest { pubkey: verifying_key(&new_key).into(), - bootstrap_token: Some("invalid_token".to_owned()), + bootstrap_token: Some(token), }) .await .unwrap(); @@ -264,11 +380,150 @@ pub async fn bootstrap_invalid_token_auth() { assert_eq!(count, 0); } +/// `register_key`'s database gate must refuse a registration once `arbiter_settings.root_key_id` +/// is set, even when `Bootstrapper`'s own in-memory token has not yet been retired -- exactly +/// the two-mailbox-hop window between `Vault::bootstrap`'s commit and the `Bootstrapped` event +/// reaching `Bootstrapper` in production. "database bootstrapped, Bootstrapper not yet notified" +/// is reproduced deterministically by bootstrapping a throwaway `Vault` wired to its own message +/// bus: it commits `root_key_id` in the same database without ever publishing to the bus +/// `actors.bootstrapper` is registered on, so `actors.bootstrapper`'s token is left untouched. +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_token_rejected_once_the_database_is_bootstrapped() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); + + bootstrapped_vault(&db).await; + + // From Bootstrapper's point of view the token still verifies: it never received an event. + assert_eq!( + actors.bootstrapper.ask(GetToken).await.unwrap(), + Some(token.clone()) + ); + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let new_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&new_key).into(), + bootstrap_token: Some(token), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&new_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + // The refusal has to reach the peer, not just the task's return value: a registration + // refused after the database was bootstrapped must look like the refusal of a token that + // never verified, rather than a handshake that stops with nothing on the wire. + let refusal = test_transport + .recv() + .await + .expect("the refusal must be sent to the peer"); + assert!(matches!(refusal, Err(auth::Error::InvalidBootstrapToken))); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::InvalidBootstrapToken) + )); + + let mut conn = db.get().await.unwrap(); + let count: i64 = schema::operator_identity::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +#[test_log::test] +pub async fn bootstrap_invalid_token_auth() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let new_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&new_key).into(), + bootstrap_token: Some("invalid_token".to_owned()), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&new_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + // The reference behaviour the refusal above has to match: a token that never verified is + // reported to the peer. Pinned here so the two refusal paths cannot drift apart again. + let refusal = test_transport + .recv() + .await + .expect("the refusal must be sent to the peer"); + assert!(matches!(refusal, Err(auth::Error::InvalidBootstrapToken))); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::InvalidBootstrapToken) + )); + + let mut conn = db.get().await.unwrap(); + let count: i64 = schema::operator_identity::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); +} + #[tokio::test] #[test_log::test] pub async fn challenge_auth() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { @@ -353,7 +608,7 @@ pub async fn challenge_auth() { #[test_log::test] pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault @@ -427,7 +682,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() { #[test_log::test] pub async fn challenge_auth_rejects_invalid_signature() { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault .ask(Bootstrap { diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index 6acfdba..38f931d 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -1,9 +1,7 @@ +use super::common::spawn_actors; use arbiter_crypto::authn; use arbiter_server::{ - actors::{ - GlobalActors, - vault::{Bootstrap, Seal}, - }, + actors::vault::{Bootstrap, Seal}, db, peers::operator::{ Credentials, @@ -26,7 +24,7 @@ async fn setup_sealed_gate( oneshot::Receiver>, ) { let db = db::create_test_pool().await; - let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let actors = spawn_actors(db.clone()).await; actors .vault -- 2.49.1 From 8d25d6640ba8543ed123fe57133faaf2acb1bc01 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 7 Sep 2026 18:54:00 +0200 Subject: [PATCH 63/66] feat(operator): authenticate recovery operators as a distinct peer type --- .../src/peers/operator/auth/mod.rs | 4 +- .../src/peers/operator/auth/state.rs | 88 ++++-- .../arbiter-server/src/peers/operator/mod.rs | 42 ++- .../arbiter-server/tests/operator/auth.rs | 253 +++++++++++++++++- 4 files changed, 359 insertions(+), 28 deletions(-) diff --git a/server/crates/arbiter-server/src/peers/operator/auth/mod.rs b/server/crates/arbiter-server/src/peers/operator/auth/mod.rs index 8bea8a0..281c17e 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/mod.rs @@ -1,4 +1,4 @@ -use super::{Credentials, OperatorConnection}; +use super::{AuthenticatedOperator, OperatorConnection}; use arbiter_crypto::authn::{self, AuthChallenge}; use arbiter_proto::transport::Bi; @@ -71,7 +71,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents { pub async fn authenticate( props: &mut OperatorConnection, transport: &mut T, -) -> Result +) -> Result where T: Bi> + Send + ?Sized, { diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index 9f86547..add4c5f 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -1,12 +1,12 @@ use super::{ - super::{Credentials, OperatorConnection}, + super::{AuthenticatedOperator, Credentials, OperatorConnection, RecoveryCredentials}, Error, }; use crate::{ actors::bootstrap::VerifyToken, db::{ DatabasePool, - schema::{arbiter_settings, operator_identity}, + schema::{arbiter_settings, operator_identity, recovery_operator_identity}, }, peers::operator::auth::Outbound, }; @@ -37,7 +37,7 @@ smlang::statemachine!( custom_error: true, transitions: { *Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext), - SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials), + SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthenticatedOperator), } ); @@ -59,6 +59,27 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result Result, Error> { + let mut conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::internal("Database unavailable") + })?; + + recovery_operator_identity::table + .filter(recovery_operator_identity::public_key.eq(pubkey.to_bytes())) + .select(recovery_operator_identity::id) + .first::(&mut conn) + .await + .optional() + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::internal("Database operation failed") + }) +} + async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result { let pubkey_bytes = pubkey.to_bytes(); let mut conn = db.get().await.map_err(|e| { @@ -118,12 +139,14 @@ where bootstrap_token, }: ChallengeRequest, ) -> Result { - // Verify pubkey is registered (unless bootstrapping) - if bootstrap_token.is_none() { - let id = get_client_id(&self.conn.db, &pubkey).await?; - if id.is_none() { - return Err(Error::UnregisteredPublicKey); - } + // Verify pubkey is registered in either identity table (unless bootstrapping) + if bootstrap_token.is_none() + && get_client_id(&self.conn.db, &pubkey).await?.is_none() + && get_recovery_operator_id(&self.conn.db, &pubkey) + .await? + .is_none() + { + return Err(Error::UnregisteredPublicKey); } let challenge = AuthChallenge::generate(&mut rand::rng()); @@ -153,7 +176,7 @@ where bootstrap_token, }: &ChallengeContext, ChallengeSolution { solution }: ChallengeSolution, - ) -> Result { + ) -> Result { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| { error!("Failed to decode signature in challenge solution"); Error::InvalidChallengeSolution @@ -169,8 +192,9 @@ where return Err(Error::InvalidChallengeSolution); } - // Resolve client id: bootstrap (verify token, then register) or lookup - let id = match bootstrap_token { + // Resolve the peer's role: bootstrap (verify token, then register as an ordinary + // operator) or look the key up in whichever identity table holds it. + let authenticated = match bootstrap_token { Some(token) => { let token_ok: bool = self .conn @@ -194,7 +218,7 @@ where return Err(Error::InvalidBootstrapToken); } - match register_key(&self.conn.db, pubkey).await { + let id = match register_key(&self.conn.db, pubkey).await { Ok(id) => id, // `register_key` refuses a token that verified here but lost the race // against bootstrap. Reported to the peer exactly like the refusal above, @@ -208,11 +232,38 @@ where return Err(Error::InvalidBootstrapToken); } Err(err) => return Err(err), + }; + + AuthenticatedOperator::Ordinary(Credentials { + id, + pubkey: pubkey.clone(), + }) + } + None => { + if let Some(id) = get_client_id(&self.conn.db, pubkey).await? { + AuthenticatedOperator::Ordinary(Credentials { + id, + pubkey: pubkey.clone(), + }) + } else { + // `prepare_challenge` already found the key in one of the tables, so + // arriving here means it was removed mid-handshake. Reported to the peer + // for the same reason `InvalidBootstrapToken` is above: an operator that + // has sent its solution sees a protocol error rather than a handshake that + // stops with nothing on the wire. + let Some(id) = get_recovery_operator_id(&self.conn.db, pubkey).await? else { + self.transport + .send(Err(Error::UnregisteredPublicKey)) + .await + .map_err(|_| Error::Transport)?; + return Err(Error::UnregisteredPublicKey); + }; + AuthenticatedOperator::Recovery(RecoveryCredentials { + id, + pubkey: pubkey.clone(), + }) } } - None => get_client_id(&self.conn.db, pubkey) - .await? - .ok_or(Error::UnregisteredPublicKey)?, }; self.transport @@ -220,9 +271,6 @@ where .await .map_err(|_| Error::Transport)?; - Ok(Credentials { - id, - pubkey: pubkey.clone(), - }) + Ok(authenticated) } } diff --git a/server/crates/arbiter-server/src/peers/operator/mod.rs b/server/crates/arbiter-server/src/peers/operator/mod.rs index fe532bc..82c6444 100644 --- a/server/crates/arbiter-server/src/peers/operator/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/mod.rs @@ -33,6 +33,37 @@ impl Integrable for Credentials { const KIND: &'static str = "operator_credentials"; } +/// §3.5: recovery operators are a separate peer type with their own identity table. Their +/// attestation kind differs from an ordinary operator's so the two id spaces cannot collide. +#[derive(Debug, Clone, Hashable)] +pub struct RecoveryCredentials { + pub id: i32, + pub pubkey: authn::PublicKey, +} + +impl Integrable for RecoveryCredentials { + const KIND: &'static str = "recovery_operator_credentials"; +} + +/// The outcome of an operator handshake. The variant, not a field, decides what the peer may +/// do — so no call site can pass an unauthenticated recovery id. +#[derive(Debug, Clone)] +pub enum AuthenticatedOperator { + Ordinary(Credentials), + Recovery(RecoveryCredentials), +} + +impl AuthenticatedOperator { + /// The peer's id within its own identity table. + #[must_use] + pub const fn id(&self) -> i32 { + match self { + Self::Ordinary(credentials) => credentials.id, + Self::Recovery(credentials) => credentials.id, + } + } +} + // Messages, sent by operator to connection client without having a request #[derive(Debug)] pub enum OutOfBand { @@ -168,7 +199,16 @@ where T: Bi> + Send, T: Bi> + Send, { - let creds = authenticate(props, &mut transport).await?; + let authenticated = authenticate(props, &mut transport).await?; + + // A recovery operator has no session of its own yet: everything below this point is written + // against an ordinary operator's `Credentials`, so the handshake is refused rather than + // silently treated as an ordinary one. + let AuthenticatedOperator::Ordinary(creds) = authenticated else { + return Err(Error::Internal( + "recovery operators have no session yet".into(), + )); + }; // should run vault gate only if sealed / unbootstrapped if should_run_gate(&props.actors.vault).await? { diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index f5beb34..7444afb 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -8,7 +8,9 @@ use arbiter_server::{ }, crypto::integrity, db::{self, schema}, - peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate}, + peers::operator::{ + self, AuthenticatedOperator, Credentials, OperatorConnection, auth, vault_gate, + }, }; use async_trait::async_trait; @@ -196,15 +198,29 @@ pub async fn bootstrap_token_auth() { .expect("should receive auth result"); assert!(matches!(response, Ok(auth::Outbound::AuthSuccess))); - task.await.unwrap().unwrap(); + let authenticated = task.await.unwrap().unwrap(); let mut conn = db.get().await.unwrap(); - let stored_pubkey: Vec = schema::operator_identity::table - .select(schema::operator_identity::public_key) - .first::>(&mut conn) + let (stored_id, stored_pubkey): (i32, Vec) = schema::operator_identity::table + .select(( + schema::operator_identity::id, + schema::operator_identity::public_key, + )) + .first::<(i32, Vec)>(&mut conn) .await .unwrap(); assert_eq!(stored_pubkey, verifying_key(&new_key).encode().0.to_vec()); + + // A key registered through the bootstrap token is an ordinary operator, carrying the id its + // registration wrote. Asserted here because this is the file's only bootstrap-arm check on + // what `authenticate` actually returns. + match authenticated { + AuthenticatedOperator::Ordinary(creds) => assert_eq!(creds.id, stored_id), + AuthenticatedOperator::Recovery(creds) => panic!( + "expected the ordinary role, got a recovery operator with id {}", + creds.id + ), + } } /// A multi-operator committee must all register with the same bootstrap token before bootstrap @@ -758,3 +774,230 @@ pub async fn challenge_auth_rejects_invalid_signature() { Err(auth::Error::InvalidChallengeSolution) )); } + +/// §3.5: a recovery operator is a separate peer type. Its key resolves against +/// `recovery_operator_identity`, and authentication reports the recovery role. +/// +/// An ordinary operator is registered alongside it so the recovery key is not simply the only +/// key on file: the handshake has to reach the recovery table while `operator_identity` is +/// populated. Both tables autoincrement from 1, so the fixture also pushes the authenticating +/// recovery operator to id 2 -- with one row in each table an id taken from the wrong table +/// would still read as 1, and only the variant would be under test. +#[tokio::test] +#[test_log::test] +pub async fn recovery_operator_authenticates_with_its_own_identity() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let ordinary_key = MlDsa87::key_gen(&mut rand::rng()); + let other_recovery_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_pubkey_bytes = authn::PublicKey::from(verifying_key(&recovery_key)).to_bytes(); + + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values((schema::operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&ordinary_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values((schema::recovery_operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&other_recovery_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values((schema::recovery_operator_identity::public_key.eq(recovery_pubkey_bytes),)) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + assert_eq!( + recovery_id, 2, + "the fixture must give the authenticating recovery operator an id no ordinary \ + operator holds, or the id assertion below cannot discriminate" + ); + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&recovery_key).into(), + bootstrap_token: None, + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + let signature = sign_operator_challenge(&recovery_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive auth result"); + assert!(matches!(response, Ok(auth::Outbound::AuthSuccess))); + + let authenticated = task + .await + .unwrap() + .expect("recovery operator should authenticate"); + match authenticated { + AuthenticatedOperator::Recovery(creds) => assert_eq!(creds.id, recovery_id), + AuthenticatedOperator::Ordinary(creds) => panic!( + "expected the recovery role, got the ordinary operator with id {}", + creds.id + ), + } +} + +/// A key present in neither identity table is still rejected: accepting a key found in either +/// table must not degrade into accepting any key at all. Both tables hold a row so the refusal +/// cannot come from an empty lookup. +#[tokio::test] +#[test_log::test] +pub async fn unknown_key_is_rejected_when_both_tables_are_populated() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let ordinary_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_key = MlDsa87::key_gen(&mut rand::rng()); + { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values((schema::operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&ordinary_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values((schema::recovery_operator_identity::public_key + .eq(authn::PublicKey::from(verifying_key(&recovery_key)).to_bytes()),)) + .execute(&mut conn) + .await + .unwrap(); + } + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + let unknown_key = MlDsa87::key_gen(&mut rand::rng()); + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&unknown_key).into(), + bootstrap_token: None, + }) + .await + .unwrap(); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::UnregisteredPublicKey) + )); +} + +/// `verify_solution` resolves the recovery id only after the peer has sent its solution, so a +/// recovery row removed between challenge and solution reaches that refusal. It has to be sent +/// on the transport, like the `InvalidBootstrapToken` refusals in the arm above: an operator +/// that has answered the challenge sees a protocol error rather than a handshake that stops +/// with nothing on the wire. +#[tokio::test] +#[test_log::test] +pub async fn recovery_key_removed_mid_handshake_is_refused_on_the_wire() { + let db = db::create_test_pool().await; + let actors = spawn_actors(db.clone()).await; + + let recovery_key = MlDsa87::key_gen(&mut rand::rng()); + let recovery_pubkey_bytes = authn::PublicKey::from(verifying_key(&recovery_key)).to_bytes(); + { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(( + schema::recovery_operator_identity::public_key.eq(recovery_pubkey_bytes.clone()), + )) + .execute(&mut conn) + .await + .unwrap(); + } + + let (mut server_transport, mut test_transport) = ChannelTransport::new(); + let db_for_task = db.clone(); + let task = tokio::spawn(async move { + let mut props = OperatorConnection::new(db_for_task, actors); + auth::authenticate(&mut props, &mut server_transport).await + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: verifying_key(&recovery_key).into(), + bootstrap_token: None, + }) + .await + .unwrap(); + + let response = test_transport + .recv() + .await + .expect("should receive challenge"); + let challenge = match response { + Ok(auth::Outbound::AuthChallenge { challenge }) => challenge, + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + + // The challenge has been issued and the solution has not been sent, so the server cannot + // have read the table again yet: the row is gone by the time `verify_solution` looks. + { + let mut conn = db.get().await.unwrap(); + diesel::delete( + schema::recovery_operator_identity::table + .filter(schema::recovery_operator_identity::public_key.eq(recovery_pubkey_bytes)), + ) + .execute(&mut conn) + .await + .unwrap(); + } + + let signature = sign_operator_challenge(&recovery_key, &challenge); + test_transport + .send(auth::Inbound::AuthChallengeSolution { + signature: signature.to_bytes(), + }) + .await + .unwrap(); + + let refusal = test_transport + .recv() + .await + .expect("the refusal must be sent to the peer"); + assert!(matches!(refusal, Err(auth::Error::UnregisteredPublicKey))); + + assert!(matches!( + task.await.unwrap(), + Err(auth::Error::UnregisteredPublicKey) + )); +} -- 2.49.1 From 840269151436ab897183c3218e2113338e961d70 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 8 Sep 2026 10:27:58 +0200 Subject: [PATCH 64/66] fix(vault): derive the recovery operator id from the authenticated peer --- protobufs/operator/vault/bootstrap.proto | 3 +- protobufs/operator/vault/rekey.proto | 3 +- protobufs/operator/vault/unseal.proto | 3 +- .../arbiter-server/src/grpc/operator.rs | 23 +- .../arbiter-server/src/grpc/operator/vault.rs | 36 +- .../src/grpc/operator/vault_gate/inbound.rs | 2 - .../src/grpc/operator/vault_gate/outbound.rs | 20 ++ .../src/peers/operator/auth/state.rs | 7 + .../arbiter-server/src/peers/operator/mod.rs | 42 +-- .../src/peers/operator/session/handlers.rs | 25 +- .../src/peers/operator/session/mod.rs | 30 +- .../src/peers/operator/vault_gate/mod.rs | 91 ++++- .../arbiter-server/tests/operator/unseal.rs | 4 +- .../arbiter-server/tests/vault/lifecycle.rs | 311 +++++++++++++++++- 14 files changed, 522 insertions(+), 78 deletions(-) diff --git a/protobufs/operator/vault/bootstrap.proto b/protobufs/operator/vault/bootstrap.proto index fc0edf8..ab72c73 100644 --- a/protobufs/operator/vault/bootstrap.proto +++ b/protobufs/operator/vault/bootstrap.proto @@ -18,8 +18,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum BootstrapResult { diff --git a/protobufs/operator/vault/rekey.proto b/protobufs/operator/vault/rekey.proto index 5a1de2c..f6d1a63 100644 --- a/protobufs/operator/vault/rekey.proto +++ b/protobufs/operator/vault/rekey.proto @@ -7,8 +7,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum RekeyResult { diff --git a/protobufs/operator/vault/unseal.proto b/protobufs/operator/vault/unseal.proto index 5e770fd..ef981a5 100644 --- a/protobufs/operator/vault/unseal.proto +++ b/protobufs/operator/vault/unseal.proto @@ -20,8 +20,7 @@ message ContributePassphrase { } message ContributeRecoveryPassphrase { - int32 recovery_operator_id = 1; - bytes passphrase = 2; + bytes passphrase = 1; } enum UnsealResult { diff --git a/server/crates/arbiter-server/src/grpc/operator.rs b/server/crates/arbiter-server/src/grpc/operator.rs index aef89a6..16a4e8a 100644 --- a/server/crates/arbiter-server/src/grpc/operator.rs +++ b/server/crates/arbiter-server/src/grpc/operator.rs @@ -129,14 +129,23 @@ pub async fn start( let (oob_sender, oob_receiver) = mpsc::channel(16); let oob_adapter = OutOfBandAdapter(oob_sender); - let actor = { + let started = { let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); - match crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await { - Ok(actor) => actor, - Err(e) => { - warn!(error = ?e, "Operator connection failed"); - return; - } + crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await + }; + + let actor = match started { + Ok(actor) => actor, + // §3.5: a recovery operator is turned away from the session rather than failing. Say so + // on the stream, so it does not look like the server dropped the connection. + Err(e @ crate::peers::operator::Error::RecoveryOperatorHasNoSession) => { + info!("Recovery operator connection closed after the vault gate"); + let _ = bi.send(Err(Status::permission_denied(e.to_string()))).await; + return; + } + Err(e) => { + warn!(error = ?e, "Operator connection failed"); + return; } }; diff --git a/server/crates/arbiter-server/src/grpc/operator/vault.rs b/server/crates/arbiter-server/src/grpc/operator/vault.rs index 793e254..a49a306 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault.rs @@ -2,9 +2,12 @@ use crate::{ actors::vault::VaultState, peers::operator::{ OperatorSession, - session::handlers::{ - HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase, - HandleQueryVaultState, + session::{ + Error as SessionError, + handlers::{ + HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase, + HandleQueryVaultState, + }, }, }, }; @@ -21,7 +24,7 @@ use arbiter_proto::{ proto::shared::VaultState as ProtoVaultState, }; -use kameo::actor::ActorRef; +use kameo::{actor::ActorRef, error::SendError}; use tonic::Status; use tracing::warn; @@ -50,6 +53,20 @@ pub(super) async fn dispatch( } } +/// A re-key share belongs to exactly one role (§3.3), so a contribution from the wrong one is a +/// policy answer and must not reach the peer as an opaque `internal`. +fn rekey_status(err: SendError, context: &'static str) -> Status { + match err { + SendError::HandlerError(err @ SessionError::RoleNotPermitted) => { + Status::permission_denied(err.to_string()) + } + err => { + warn!(?err, "{context}"); + Status::internal(context) + } + } +} + async fn handle_rekey( actor: &ActorRef, req: proto_rekey::Request, @@ -66,20 +83,13 @@ async fn handle_rekey( passphrase: cp.passphrase, }) .await - .map_err(|e| { - warn!(?e, "rekey passphrase contribution failed"); - Status::internal("Rekey contribution failed") - })?, + .map_err(|e| rekey_status(e, "Rekey contribution failed"))?, RekeyPayload::ContributeRecoveryPassphrase(crp) => actor .ask(HandleContributeRecoveryRekeyPassphrase { - recovery_operator_id: crp.recovery_operator_id, passphrase: crp.passphrase, }) .await - .map_err(|e| { - warn!(?e, "rekey recovery passphrase contribution failed"); - Status::internal("Rekey recovery contribution failed") - })?, + .map_err(|e| rekey_status(e, "Rekey recovery contribution failed"))?, }; let proto_result = if done { diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs index 6e90a9c..919f0f1 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/inbound.rs @@ -89,7 +89,6 @@ impl TryConvert for UnsealRequestPayload { Self::ContributeRecoveryPassphrase(crp) => Ok( vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase( HandleContributeRecoveryUnsealPassphrase { - recovery_operator_id: crp.recovery_operator_id, passphrase: crp.passphrase, }, ), @@ -157,7 +156,6 @@ impl TryConvert for BootstrapRequestPayload { Self::ContributeRecoveryPassphrase(crp) => Ok( vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase( HandleContributeRecoveryBootstrapPassphrase { - recovery_operator_id: crp.recovery_operator_id, passphrase: crp.passphrase, }, ), diff --git a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs index fc8bf7f..7b5eb75 100644 --- a/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/operator/vault_gate/outbound.rs @@ -103,6 +103,9 @@ impl TryConvert for vault_gate::Outbound { Err(vault_gate::Error::AlreadyBootstrapped) => { ProtoBootstrapResult::AlreadyBootstrapped } + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "bootstrap failed"); return Err(Status::internal("Failed to bootstrap vault")); @@ -113,6 +116,11 @@ impl TryConvert for vault_gate::Outbound { Self::HandleDeclareCommittee(result) => { let proto_result = match result { Ok(()) => ProtoBootstrapResult::Success, + // A role refusal is a policy answer, not a server fault, so it leaves the + // gate as `PERMISSION_DENIED` rather than as an opaque `internal`. + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "declare committee failed"); return Err(Status::internal("Failed to declare committee")); @@ -124,6 +132,9 @@ impl TryConvert for vault_gate::Outbound { let proto_result = match result { Ok(true) => ProtoBootstrapResult::Success, Ok(false) => ProtoBootstrapResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "contribute bootstrap passphrase failed"); return Err(Status::internal("Failed to contribute bootstrap passphrase")); @@ -135,6 +146,9 @@ impl TryConvert for vault_gate::Outbound { let proto_result = match result { Ok(true) => ProtoBootstrapResult::Success, Ok(false) => ProtoBootstrapResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "contribute recovery bootstrap passphrase failed"); return Err(Status::internal( @@ -148,6 +162,9 @@ impl TryConvert for vault_gate::Outbound { let proto_result = match result { Ok(true) => ProtoUnsealResult::Success, Ok(false) => ProtoUnsealResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "contribute unseal passphrase failed"); return Err(Status::internal("Failed to contribute unseal passphrase")); @@ -161,6 +178,9 @@ impl TryConvert for vault_gate::Outbound { let proto_result = match result { Ok(true) => ProtoUnsealResult::Success, Ok(false) => ProtoUnsealResult::AwaitingContributions, + Err(err @ vault_gate::Error::RoleNotPermitted) => { + return Err(Status::permission_denied(err.to_string())); + } Err(err) => { warn!(?err, "contribute recovery unseal passphrase failed"); return Err(Status::internal( diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index add4c5f..a38a33a 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -240,6 +240,13 @@ where }) } None => { + // The tables are searched in this order, so a key registered in both resolves + // as `Ordinary` and could never submit its recovery share. Nothing enforces + // that the two sets are disjoint: `unique` is per table, and the only writer + // today is `register_key` above -- `recovery_operator_identity` has no + // registration path yet. Whoever builds one must refuse a key that + // `operator_identity` already holds, and vice versa, or §3.5's "separate peer + // type" holds only by convention. if let Some(id) = get_client_id(&self.conn.db, pubkey).await? { AuthenticatedOperator::Ordinary(Credentials { id, diff --git a/server/crates/arbiter-server/src/peers/operator/mod.rs b/server/crates/arbiter-server/src/peers/operator/mod.rs index 82c6444..f3a8759 100644 --- a/server/crates/arbiter-server/src/peers/operator/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/mod.rs @@ -53,17 +53,6 @@ pub enum AuthenticatedOperator { Recovery(RecoveryCredentials), } -impl AuthenticatedOperator { - /// The peer's id within its own identity table. - #[must_use] - pub const fn id(&self) -> i32 { - match self { - Self::Ordinary(credentials) => credentials.id, - Self::Recovery(credentials) => credentials.id, - } - } -} - // Messages, sent by operator to connection client without having a request #[derive(Debug)] pub enum OutOfBand { @@ -93,6 +82,12 @@ pub enum Error { Transport, #[error("database error: {0}")] Database(DatabaseError), + /// §3.5: a recovery operator's authority stops at the vault gate. It has no operator + /// session, because a session is the whole ordinary-governance surface -- wallets, grants, + /// SDK clients, proposals -- which §3.5 puts out of a recovery operator's reach. Named + /// rather than folded into `Internal`, so a policy refusal is not logged as a fault. + #[error("recovery operators do not have an operator session")] + RecoveryOperatorHasNoSession, #[error("internal: {0}")] Internal(String), } @@ -139,7 +134,7 @@ async fn should_run_gate(vault: &ActorRef) -> Result { async fn run_vault_gate( props: &OperatorConnection, transport: &mut T, - auth_creds: Credentials, + auth_creds: AuthenticatedOperator, ) -> Result<(), Error> where T: Bi> + Send + ?Sized, @@ -201,26 +196,25 @@ where { let authenticated = authenticate(props, &mut transport).await?; - // A recovery operator has no session of its own yet: everything below this point is written - // against an ordinary operator's `Credentials`, so the handshake is refused rather than - // silently treated as an ordinary one. - let AuthenticatedOperator::Ordinary(creds) = authenticated else { - return Err(Error::Internal( - "recovery operators have no session yet".into(), - )); - }; - // should run vault gate only if sealed / unbootstrapped if should_run_gate(&props.actors.vault).await? { - run_vault_gate(props, &mut transport, creds.clone()).await?; + // §3.5 lets a recovery operator take part in unsealing, and the gate is where that + // happens, so both roles run it. The gate decides per message which role may send it. + run_vault_gate(props, &mut transport, authenticated.clone()).await?; } + // Past the gate the connection turns into an ordinary operator session, which a recovery + // operator may not have. + let AuthenticatedOperator::Ordinary(creds) = &authenticated else { + return Err(Error::RecoveryOperatorHasNoSession); + }; + // checking the integrity - verify_integrity(&props.db, &props.actors.vault, &creds).await?; + verify_integrity(&props.db, &props.actors.vault, creds).await?; Ok(OperatorSession::spawn(OperatorSession::new( props.clone(), - creds.clone(), + authenticated.clone(), oob_sender, ))) } 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 60cc16e..a3c2173 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -312,7 +312,7 @@ impl OperatorSession { ttl_secs: Option, ) -> Result { use crate::actors::proposal_manager::CreateProposal; - let initiator_id = OperatorIdentityId::from_raw(self.credentials.id); + let initiator_id = OperatorIdentityId::from_raw(self.ordinary_id()?); self.props .actors .proposal_manager @@ -332,7 +332,10 @@ impl OperatorSession { signature: Vec, ) -> Result { use crate::actors::proposal_manager::CastVote; - let operator_id = OperatorIdentityId::from_raw(self.credentials.id); + let operator_id = OperatorIdentityId::from_raw( + self.ordinary_id() + .map_err(|_| crate::actors::proposal_manager::Error::NotAllowedForRecoveryOperator)?, + ); self.props .actors .proposal_manager @@ -349,7 +352,11 @@ impl OperatorSession { &mut self, ) -> Vec { use crate::actors::proposal_manager::QueryPending; - let operator_id = OperatorIdentityId::from_raw(self.credentials.id); + let Ok(id) = self.ordinary_id() else { + // The pending list is per ordinary operator; a recovery operator has no view of it. + return Vec::new(); + }; + let operator_id = OperatorIdentityId::from_raw(id); self.props .actors .proposal_manager @@ -369,7 +376,7 @@ impl OperatorSession { use crate::actors::vault_coordinator::ContributeRekey; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; - let operator_id = self.credentials.id; + let operator_id = self.ordinary_id()?; self.props .actors .vault_coordinator @@ -381,15 +388,23 @@ impl OperatorSession { .map_err(|_| Error::internal("VaultCoordinator unavailable")) } + /// §3.3: a re-key refreshes every share, recovery shares included, so a recovery operator + /// has one to contribute here. + /// + /// It cannot reach this handler yet: `peers::operator::start` refuses a recovery peer an + /// operator session, because a session carries the whole ordinary-governance surface that + /// §3.5 keeps out of a recovery operator's hands. Until a recovery-scoped session exists, + /// this refuses every caller -- which is the safe direction, and the id it would use comes + /// from the handshake either way. #[message] pub(crate) async fn handle_contribute_recovery_rekey_passphrase( &mut self, - recovery_operator_id: i32, passphrase: Vec, ) -> Result { use crate::actors::vault_coordinator::ContributeRecoveryRekey; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; + let recovery_operator_id = self.recovery_id()?; self.props .actors .vault_coordinator diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 083f106..9bf0777 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -1,4 +1,4 @@ -use super::{Credentials, OutOfBand, OperatorConnection}; +use super::{AuthenticatedOperator, OutOfBand, OperatorConnection}; use crate::{ actors::{ flow_coordinator::client_connect_approval::ClientApprovalController, @@ -19,6 +19,11 @@ pub enum Error { #[error("State transition failed")] State, + /// §3.5: the ordinary and recovery roles reach for different handlers here. A refusal is a + /// policy answer, so it is named rather than folded into `Internal` beside real faults. + #[error("This operator role may not perform that action")] + RoleNotPermitted, + #[error("Internal error: {message}")] Internal { message: Cow<'static, str> }, } @@ -51,7 +56,7 @@ pub struct PendingClientApproval { pub struct OperatorSession { props: OperatorConnection, - credentials: Credentials, + credentials: AuthenticatedOperator, sender: Box>, pending_client_approvals: HashMap, PendingClientApproval>, @@ -60,7 +65,7 @@ pub struct OperatorSession { pub mod handlers; impl OperatorSession { - pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box>) -> Self { + pub(crate) fn new(props: OperatorConnection, credentials: AuthenticatedOperator, sender: Box>) -> Self { Self { props, credentials, @@ -68,6 +73,25 @@ impl OperatorSession { pending_client_approvals: HashMap::default(), } } + + /// The id of the ordinary operator on the other end, or a refusal. + /// + /// Read from the handshake, never from a request body, so a peer cannot act under an id it + /// did not authenticate as. + const fn ordinary_id(&self) -> Result { + match &self.credentials { + AuthenticatedOperator::Ordinary(credentials) => Ok(credentials.id), + AuthenticatedOperator::Recovery(_) => Err(Error::RoleNotPermitted), + } + } + + /// The id of the recovery operator on the other end, or a refusal. See `ordinary_id`. + const fn recovery_id(&self) -> Result { + match &self.credentials { + AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id), + AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted), + } + } } #[messages] diff --git a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs index d416401..3c20e7b 100644 --- a/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/vault_gate/mod.rs @@ -1,4 +1,4 @@ -use super::Credentials; +use super::AuthenticatedOperator; use crate::{ actors::{ GlobalActors, @@ -36,6 +36,12 @@ pub enum Error { #[error("State transition failed")] State, + /// §3.5: ordinary and recovery operators hold different shares of the same split, so each + /// contribution belongs to exactly one of the two roles. A refusal here is a policy answer + /// and is kept out of `Internal`, which carries genuine faults. + #[error("This operator role may not perform that vault action")] + RoleNotPermitted, + #[error("Internal error: {0}")] Internal(String), } @@ -50,7 +56,7 @@ pub struct HandshakeResponse { } pub struct VaultGate { - pub auth_creds: Credentials, + pub auth_creds: AuthenticatedOperator, pub promotion_tx: Option>>, pub state: State, pub actors: GlobalActors, @@ -59,7 +65,7 @@ pub struct VaultGate { impl VaultGate { pub fn new( - auth_creds: Credentials, + auth_creds: AuthenticatedOperator, actors: GlobalActors, db: DatabasePool, promotion_tx: oneshot::Sender>, @@ -100,6 +106,25 @@ impl Actor for VaultGate { } impl VaultGate { + /// The id of the ordinary operator on the other end, or a refusal. + /// + /// The id is read from the handshake rather than from the request body, so a peer cannot + /// name an operator it did not authenticate as. + const fn ordinary_id(&self) -> Result { + match &self.auth_creds { + AuthenticatedOperator::Ordinary(credentials) => Ok(credentials.id), + AuthenticatedOperator::Recovery(_) => Err(Error::RoleNotPermitted), + } + } + + /// The id of the recovery operator on the other end, or a refusal. See `ordinary_id`. + const fn recovery_id(&self) -> Result { + match &self.auth_creds { + AuthenticatedOperator::Recovery(credentials) => Ok(credentials.id), + AuthenticatedOperator::Ordinary(_) => Err(Error::RoleNotPermitted), + } + } + fn decrypt_key( secret: &SharedSecret, nonce: &[u8], @@ -148,6 +173,13 @@ impl VaultGate { }) } + /// Deliberately open to both roles, unlike `handle_bootstrap_encrypted_key` below. + /// + /// Handing over the whole seal key to open a sealed vault is participating in unsealing, + /// which §3.5 grants a recovery operator, and the peer has to hold that key already -- it + /// gains nothing here it did not bring. Bootstrap is the opposite: it *chooses* the key for + /// a vault that has none, which is sole custody of the root key and belongs to no §3.5 + /// power. The reasoning that admits one does not admit the other. #[message] pub async fn handle_unseal_encrypted_key( &mut self, @@ -185,6 +217,10 @@ impl VaultGate { } } + /// §3.4/§3.5: bootstrapping picks the root key for a vault that has none, so whoever gets + /// here holds sole custody until the committee splits it. That is not one of a recovery + /// operator's two powers, and the check comes first because the vault commits before this + /// handler could refuse anything afterwards. #[message] pub async fn handle_bootstrap_encrypted_key( &mut self, @@ -192,6 +228,8 @@ impl VaultGate { ciphertext: Vec, associated_data: Vec, ) -> Result<(), Error> { + let _ = self.ordinary_id()?; + let State::ReadyForExchange { secret, .. } = &self.state else { return Err(Error::State); }; @@ -242,10 +280,12 @@ impl VaultGate { count: usize, recovery_count: usize, ) -> Result<(), Error> { + let operator_id = self.ordinary_id()?; + self.actors .vault_coordinator .ask(StartBootstrap { - operator_id: self.auth_creds.id, + operator_id, declared_count: count, recovery_count, }) @@ -258,11 +298,13 @@ impl VaultGate { &mut self, passphrase: Vec, ) -> Result { + let operator_id = self.ordinary_id()?; + let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator .ask(ContributeBootstrap { - operator_id: self.auth_creds.id, + operator_id, passphrase: passphrase_cell, }) .await @@ -272,9 +314,10 @@ impl VaultGate { #[message] pub async fn handle_contribute_recovery_bootstrap_passphrase( &mut self, - recovery_operator_id: i32, passphrase: Vec, ) -> Result { + let recovery_operator_id = self.recovery_id()?; + let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator @@ -291,11 +334,13 @@ impl VaultGate { &mut self, passphrase: Vec, ) -> Result { + let operator_id = self.ordinary_id()?; + let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator .ask(ContributeUnseal { - operator_id: self.auth_creds.id, + operator_id, passphrase: passphrase_cell, }) .await @@ -305,9 +350,10 @@ impl VaultGate { #[message] pub async fn handle_contribute_recovery_unseal_passphrase( &mut self, - recovery_operator_id: i32, passphrase: Vec, ) -> Result { + let recovery_operator_id = self.recovery_id()?; + let passphrase_cell = SafeCell::new(passphrase); self.actors .vault_coordinator @@ -334,13 +380,28 @@ impl Message for VaultGate { .get() .await .map_err(|_| Error::internal("DB unavailable"))?; - integrity::sign_entity( - &mut conn, - &self.actors.vault, - &self.auth_creds, - self.auth_creds.id, - ) - .await + // Each role signs under its own `Integrable::KIND`, so the two id spaces cannot + // collide in `integrity_envelope`. + match &self.auth_creds { + AuthenticatedOperator::Ordinary(credentials) => { + integrity::sign_entity( + &mut conn, + &self.actors.vault, + credentials, + credentials.id, + ) + .await + } + AuthenticatedOperator::Recovery(credentials) => { + integrity::sign_entity( + &mut conn, + &self.actors.vault, + credentials, + credentials.id, + ) + .await + } + } .map_err(|e| { error!(?e, "Failed to sign integrity envelope on bootstrap"); Error::internal("Integrity sign failed") diff --git a/server/crates/arbiter-server/tests/operator/unseal.rs b/server/crates/arbiter-server/tests/operator/unseal.rs index 38f931d..4d3b8cb 100644 --- a/server/crates/arbiter-server/tests/operator/unseal.rs +++ b/server/crates/arbiter-server/tests/operator/unseal.rs @@ -4,7 +4,7 @@ use arbiter_server::{ actors::vault::{Bootstrap, Seal}, db, peers::operator::{ - Credentials, + AuthenticatedOperator, Credentials, vault_gate::{ Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate, }, @@ -37,7 +37,7 @@ async fn setup_sealed_gate( let (promotion_tx, promotion_rx) = oneshot::channel(); let pubkey = authn::SigningKey::generate().public_key(); - let auth_creds = Credentials { id: 1, pubkey }; + let auth_creds = AuthenticatedOperator::Ordinary(Credentials { id: 1, pubkey }); let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx)); (db, gate, promotion_rx) diff --git a/server/crates/arbiter-server/tests/vault/lifecycle.rs b/server/crates/arbiter-server/tests/vault/lifecycle.rs index 8a655eb..42cc971 100644 --- a/server/crates/arbiter-server/tests/vault/lifecycle.rs +++ b/server/crates/arbiter-server/tests/vault/lifecycle.rs @@ -1,5 +1,8 @@ use crate::common; -use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use arbiter_crypto::{ + authn, + safecell::{SafeCell, SafeCellHandle as _}, +}; use arbiter_server::{ actors::{ GlobalActors, @@ -11,11 +14,23 @@ use arbiter_server::{ }, crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, db::{self, models, schema}, + peers::operator::{ + AuthenticatedOperator, Credentials, RecoveryCredentials, + vault_gate::{ + Error as VaultGateError, HandleBootstrapEncryptedKey, + HandleContributeBootstrapPassphrase, HandleContributeRecoveryBootstrapPassphrase, + HandleContributeRecoveryUnsealPassphrase, HandleContributeUnsealPassphrase, + HandleDeclareCommittee, HandleHandshake, VaultGate, + }, + }, }; +use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into, sql_query}; use diesel_async::RunQueryDsl; use kameo::actor::Spawn as _; +use tokio::sync::oneshot; +use x25519_dalek::{EphemeralSecret, PublicKey}; #[tokio::test] #[test_log::test] @@ -511,3 +526,297 @@ async fn sleeping_recovery_operator_cannot_contribute_to_unseal() { "a sleeping recovery operator unsealed the vault" ); } + +type PromotionRx = oneshot::Receiver>; + +/// One `VaultGate` per authenticated role against a shared `GlobalActors`, which is what +/// `peers::operator::start` builds for two connected peers. +struct RoleGates { + ordinary: kameo::actor::ActorRef, + recovery: kameo::actor::ActorRef, + ordinary_id: i32, + recovery_id: i32, + /// Held only so the gates' promotion channels stay open for the fixture's lifetime. + _promotions: (PromotionRx, PromotionRx), +} + +/// Registers one ordinary and one recovery identity, then spawns a gate for each. +async fn spawn_role_gates(db: &db::DatabasePool, actors: &GlobalActors) -> RoleGates { + let ordinary_pubkey = authn::SigningKey::generate().public_key(); + let recovery_pubkey = authn::SigningKey::generate().public_key(); + + let ordinary_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(ordinary_pubkey.to_bytes())) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + let recovery_id: i32 = { + let mut conn = db.get().await.unwrap(); + insert_into(schema::recovery_operator_identity::table) + .values(schema::recovery_operator_identity::public_key.eq(recovery_pubkey.to_bytes())) + .returning(schema::recovery_operator_identity::id) + .get_result(&mut conn) + .await + .unwrap() + }; + + let (ordinary_promotion_tx, ordinary_promotion_rx) = oneshot::channel(); + let ordinary = VaultGate::spawn(VaultGate::new( + AuthenticatedOperator::Ordinary(Credentials { + id: ordinary_id, + pubkey: ordinary_pubkey, + }), + actors.clone(), + db.clone(), + ordinary_promotion_tx, + )); + + let (recovery_promotion_tx, recovery_promotion_rx) = oneshot::channel(); + let recovery = VaultGate::spawn(VaultGate::new( + AuthenticatedOperator::Recovery(RecoveryCredentials { + id: recovery_id, + pubkey: recovery_pubkey, + }), + actors.clone(), + db.clone(), + recovery_promotion_tx, + )); + + RoleGates { + ordinary, + recovery, + ordinary_id, + recovery_id, + _promotions: (ordinary_promotion_rx, recovery_promotion_rx), + } +} + +/// Runs the gate's X25519 handshake and encrypts `seal_key` to the shared secret, producing the +/// message a peer would send to bootstrap the vault. Mirrors `tests/operator/unseal.rs`'s +/// `client_dh_encrypt`, which does the same for the unseal side. +async fn bootstrap_key_for( + gate: &kameo::actor::ActorRef, + seal_key: &[u8; 32], +) -> HandleBootstrapEncryptedKey { + let client_secret = EphemeralSecret::random(); + let client_public = PublicKey::from(&client_secret); + + let response = gate + .ask(HandleHandshake { + client_pubkey: client_public, + }) + .await + .unwrap(); + + let shared_secret = client_secret.diffie_hellman(&response.server_pubkey); + let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into()); + let nonce = XNonce::from([0u8; 24]); + let associated_data = b"bootstrap"; + let mut ciphertext = seal_key.to_vec(); + cipher + .encrypt_in_place(&nonce, associated_data, &mut ciphertext) + .unwrap(); + + HandleBootstrapEncryptedKey { + nonce: nonce.to_vec(), + ciphertext, + associated_data: associated_data.to_vec(), + } +} + +/// Asserts a gate turned a request down on the peer's role rather than on anything else -- +/// notably not on coordinator state, which is what an unguarded handler would have reported. +#[track_caller] +fn assert_role_refused( + what: &str, + result: Result>, +) { + match result { + Err(kameo::error::SendError::HandlerError(VaultGateError::RoleNotPermitted)) => {} + other => panic!("{what}: expected RoleNotPermitted, got {other:?}"), + } +} + +/// §3.5: which committee seat a passphrase fills is decided by the handshake, not by the +/// request, so neither role can spend the other's slot. +/// +/// Neither request carries an operator id, so the ordinary peer has nothing left to forge; the +/// point of running the bootstrap to completion afterwards is that its refusal left the +/// recovery seat empty rather than filling it under a chosen id. +#[tokio::test] +#[test_log::test] +async fn ordinary_operator_cannot_contribute_a_recovery_share() { + let db = db::create_test_pool().await; + let actors = common::spawn_actors(db.clone()).await; + let gates = spawn_role_gates(&db, &actors).await; + assert_eq!( + gates.ordinary_id, gates.recovery_id, + "the two ids must collide for the attestation check at the end to mean anything" + ); + + gates + .ordinary + .ask(HandleDeclareCommittee { + count: 1, + recovery_count: 1, + }) + .await + .unwrap(); + + assert_role_refused( + "an ordinary operator contributed a recovery share", + gates + .ordinary + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary share", + gates + .recovery + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + // The recovery seat is still empty: had the forged contribution landed, this one would come + // back as a duplicate instead of being accepted. + let done = gates + .recovery + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"recovery-pass".to_vec(), + }) + .await + .unwrap(); + assert!(!done, "the ordinary share is still outstanding"); + + let done = gates + .ordinary + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"ordinary-pass".to_vec(), + }) + .await + .unwrap(); + assert!(done, "both seats are filled, so bootstrap must finalize"); + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unsealed + ); + + // Both peers hold the same id in their own table (asserted above), so only the attestation + // kind tells the two envelopes apart. Two rows means the recovery peer signed as itself + // rather than overwriting the ordinary operator's attestation. + let kinds = common::eventually("both bootstrap attestations are written", || { + let db = db.clone(); + async move { + let mut conn = db.get().await.unwrap(); + let mut kinds: Vec = schema::integrity_envelope::table + .select(schema::integrity_envelope::entity_kind) + .load(&mut conn) + .await + .unwrap(); + kinds.sort(); + (kinds.len() == 2).then_some(kinds) + } + }) + .await; + assert_eq!( + kinds, + vec![ + "operator_credentials".to_owned(), + "recovery_operator_credentials".to_owned(), + ] + ); +} + +/// Every gate action that belongs to one role refuses the other, and refuses it before the +/// action takes effect. +/// +/// The vault is left unbootstrapped and the coordinator idle on purpose: an unguarded handler +/// would reach the vault or the coordinator and come back with `State`, `NotBootstrapping` or +/// `NotUnsealing`, so `RoleNotPermitted` can only come from the role check itself. +/// +/// §3.4/§3.5: `HandleBootstrapEncryptedKey` matters most here. It hands the vault a root key of +/// the peer's choosing, and the window it needs -- an unbootstrapped vault that already holds +/// recovery identity rows -- is exactly the state committee formation has to pass through. +#[tokio::test] +#[test_log::test] +async fn vault_gate_refuses_the_actions_of_the_other_role() { + let db = db::create_test_pool().await; + let actors = common::spawn_actors(db.clone()).await; + let gates = spawn_role_gates(&db, &actors).await; + + assert_role_refused( + "a recovery operator declared the committee", + gates + .recovery + .ask(HandleDeclareCommittee { + count: 1, + recovery_count: 1, + }) + .await, + ); + + // A key the vault would have accepted, negotiated through the gate's own handshake -- so + // the refusal comes from the role and not from a malformed request. + let seized_key = bootstrap_key_for(&gates.recovery, b"recovery-seized-32-byte-seal-key").await; + assert_role_refused( + "a recovery operator bootstrapped the vault", + gates.recovery.ask(seized_key).await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary bootstrap share", + gates + .recovery + .ask(HandleContributeBootstrapPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "an ordinary operator contributed a recovery bootstrap share", + gates + .ordinary + .ask(HandleContributeRecoveryBootstrapPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "a recovery operator contributed an ordinary unseal share", + gates + .recovery + .ask(HandleContributeUnsealPassphrase { + passphrase: b"forged-ordinary-pass".to_vec(), + }) + .await, + ); + + assert_role_refused( + "an ordinary operator contributed a recovery unseal share", + gates + .ordinary + .ask(HandleContributeRecoveryUnsealPassphrase { + passphrase: b"forged-recovery-pass".to_vec(), + }) + .await, + ); + + // Nothing above took effect: the refusals came before the vault and the coordinator. + assert_eq!( + actors.vault.ask(GetState {}).await.unwrap(), + VaultState::Unbootstrapped, + "a refused request still reached the vault" + ); +} -- 2.49.1 From 23183efd0cf5e90a049596ac895f60e07eb55933 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 8 Sep 2026 11:26:37 +0200 Subject: [PATCH 65/66] fix(evm): revoke dependent grants when wallet access is revoked --- .../arbiter-server/src/actors/evm/mod.rs | 252 +++++++++++++++++- .../src/actors/proposal_manager.rs | 19 +- .../src/actors/proposal_manager/store.rs | 39 ++- .../src/actors/proposal_manager/tests.rs | 134 +++++++--- .../src/actors/vault_coordinator/mod.rs | 108 +++++++- server/crates/arbiter-server/src/db/mod.rs | 85 ++++-- .../src/peers/operator/session/handlers.rs | 183 ++++++++++++- .../src/peers/operator/session/mod.rs | 6 + 8 files changed, 737 insertions(+), 89 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 59d91c8..41ab023 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -71,15 +71,51 @@ pub enum Error { #[error("Signing error: {0}")] Sign(#[from] SignTransactionError), - #[error("Grant timestamp {0} is outside the representable range")] + #[error( + "Grant timestamp {0} is outside the i32 range a grant boundary column can store \ + (Unix seconds, so no later than 2038-01-19T03:14:07Z)" + )] InvalidTimestamp(i64), + + #[error("Wallet access {0} is revoked or does not exist")] + AccessNotActive(i32), } -/// Converts a grant boundary from Unix seconds. `None` in means "unbounded"; an -/// unrepresentable value is an error, never a silently unbounded grant. +/// Converts a grant boundary from Unix seconds. `None` in means "unbounded"; a value the +/// boundary column cannot store is an error, never a silently different window. +/// +/// The range is `i32`, not `i64`, because that is what actually reaches the database: +/// `SqliteTimestamp::to_sql` narrows to `i32` (`fixme! #84`), so `3_000_000_000` -- a +/// `valid_from` in 2065 -- would wrap to 1902 and open the grant immediately instead of in +/// forty years. Accepting only what round-trips keeps the grant that gets written the grant +/// that was voted on. fn grant_timestamp(secs: Option) -> Result>, Error> { - secs.map(|s| chrono::DateTime::from_timestamp(s, 0).ok_or(Error::InvalidTimestamp(s))) - .transpose() + secs.map(|s| { + let storable = i32::try_from(s).map_err(|_| Error::InvalidTimestamp(s))?; + chrono::DateTime::from_timestamp(i64::from(storable), 0).ok_or(Error::InvalidTimestamp(s)) + }) + .transpose() +} + +/// Refuses an access id that is revoked or absent, so nothing hangs a grant off it. +async fn ensure_access_active( + conn: &mut crate::db::DatabaseConnection, + access_id: i32, +) -> Result<(), Error> { + let active: bool = diesel::select(diesel::dsl::exists( + schema::evm_wallet_access::table + .filter(schema::evm_wallet_access::id.eq(access_id)) + .filter(schema::evm_wallet_access::revoked_at.is_null()), + )) + .get_result(conn) + .await + .map_err(DatabaseError::from)?; + + if active { + Ok(()) + } else { + Err(Error::AccessNotActive(access_id)) + } } #[derive(Actor)] @@ -326,7 +362,10 @@ impl EvmActor { let mut conn = self.db.get().await.map_err(DatabaseError::from)?; // Revives a previously revoked row instead of conflicting on it forever: - // `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`. + // `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`. Visibility is + // all this restores -- revocation closes the grants that hung off the access, so a + // persistent grant needs its own vote again (§3.2). See + // `peers::operator::session::handlers::revoke_wallet_access`. insert_into(schema::evm_wallet_access::table) .values(( schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(settings.wallet_id)), @@ -355,6 +394,14 @@ impl EvmActor { use alloy::primitives::U256; use chrono::Duration; + // A persistent grant is only as good as the visibility it hangs off (§3.2, two + // separate votes). The proposal names the access id when it is created and can be + // approved much later, so the access may have been revoked in between; a grant + // against a revoked access would sit dormant and go live the moment anyone re-grants. + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; + ensure_access_active(&mut conn, grant.wallet_access_id).await?; + drop(conn); + let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit { max_volume: U256::from_be_bytes(limit.max_volume), window: Duration::seconds(limit.window_secs), @@ -433,7 +480,11 @@ impl EvmActor { #[cfg(test)] mod tests { - use super::{Error, grant_timestamp}; + use super::{Error, EvmActor, ensure_access_active, grant_timestamp}; + use crate::db::{self, models, schema}; + + use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; #[test] fn absent_timestamp_stays_absent() { @@ -453,4 +504,191 @@ mod tests { let err = grant_timestamp(Some(i64::MAX)).unwrap_err(); assert!(matches!(err, Error::InvalidTimestamp(i64::MAX))); } + + /// A `valid_from` past 2038 is representable as a `DateTime` but not as the `i32` the + /// boundary column stores: `3_000_000_000` (2065) wraps to a negative, which reads back as + /// 1902 and makes the grant active immediately. Refusing it is the only way the grant + /// that lands can match the window that was voted on. + #[test] + fn a_timestamp_past_2038_is_an_error() { + let past_2038 = 3_000_000_000_i64; + assert!( + chrono::DateTime::from_timestamp(past_2038, 0).is_some(), + "the fixture must be a date chrono accepts, or it proves nothing about storage" + ); + + let err = grant_timestamp(Some(past_2038)).unwrap_err(); + assert!( + matches!(err, Error::InvalidTimestamp(got) if got == past_2038), + "expected an out-of-range error, got {err:?}" + ); + } + + /// The last second the boundary column can hold must still be accepted: the range check + /// has to stop at what storage can take, not short of it. + #[test] + fn the_last_storable_timestamp_is_accepted() { + let converted = grant_timestamp(Some(i64::from(i32::MAX))).unwrap(); + assert_eq!(converted.unwrap().timestamp(), i64::from(i32::MAX)); + } + + /// Seeds a wallet, a client and one access row between them, and returns the access id. + async fn seed_access(conn: &mut db::DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: chrono::Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()), + schema::program_client::metadata_id.eq(metadata_id), + )) + .returning(schema::program_client::id) + .get_result(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() + } + + /// Both directions, so a guard that refused everything could not pass: a live access is + /// let through, a revoked one is not. + #[tokio::test] + async fn only_a_live_access_passes_the_grant_guard() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let access_id = seed_access(&mut conn).await; + ensure_access_active(&mut conn, access_id) + .await + .expect("a live access must pass"); + + diesel::update(schema::evm_wallet_access::table) + .filter(schema::evm_wallet_access::id.eq(access_id)) + .set(schema::evm_wallet_access::revoked_at.eq(models::SqliteTimestamp::now())) + .execute(&mut conn) + .await + .unwrap(); + + let err = ensure_access_active(&mut conn, access_id) + .await + .expect_err("a revoked access must be refused"); + assert!( + matches!(err, Error::AccessNotActive(got) if got == access_id), + "expected AccessNotActive, got {err:?}" + ); + } + + /// The guard has to be wired into the executor, not just exist: an approved persistent + /// grant whose access was revoked between proposal and approval must not create a grant + /// that would go live again the moment anyone re-grants that access (§3.2). + #[tokio::test] + async fn an_approved_persistent_grant_refuses_a_revoked_access() { + use crate::actors::{GlobalActors, vault::Vault}; + use crate::db::proposal::persistent_grant; + use kameo::actor::Spawn as _; + + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let access_id = seed_access(&mut conn).await; + diesel::update(schema::evm_wallet_access::table) + .filter(schema::evm_wallet_access::id.eq(access_id)) + .set(schema::evm_wallet_access::revoked_at.eq(models::SqliteTimestamp::now())) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let vault = Vault::spawn( + Vault::new(pool.clone(), GlobalActors::spawn_message_bus()) + .await + .unwrap(), + ); + let mut evm_actor = EvmActor::new(vault, pool.clone()); + + let err = evm_actor + .create_persistent_grant(persistent_grant::Settings { + wallet_access_id: access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: [0u8; 32], + window_secs: 3600, + }, + }, + }) + .await + .expect_err("a grant against a revoked access must be refused"); + assert!( + matches!(err, Error::AccessNotActive(got) if got == access_id), + "expected AccessNotActive, got {err:?}" + ); + + let grants: i64 = schema::evm_basic_grant::table + .filter(schema::evm_basic_grant::wallet_access_id.eq(access_id)) + .count() + .get_result(&mut pool.get().await.unwrap()) + .await + .unwrap(); + assert_eq!( + grants, 0, + "no grant row may be written for a revoked access" + ); + } } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index ae93685..76167c0 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -255,9 +255,15 @@ impl ProposalManager { /// §3.5/§3.6: recovery operators join the electorate only for the kinds they may vote on, /// and only once the wake-up window has elapsed. Counting them anywhere else makes the /// rejection threshold unreachable and, for full-quorum kinds, approval unreachable too. + /// + /// The votes go out with the voters. A wake-up can be cancelled after recovery operators + /// have already voted (`cancel_wakeup` cancels any uncancelled request, elapsed or not), + /// so a `ReplaceOperator` tally can hold recovery approvals at the moment the committee + /// stops being eligible. Keeping those while zeroing only the electorate size would let + /// them cover ordinary votes that were never cast. async fn narrow_electorate(&self, proposal: &Proposal, tally: &mut Tally) -> Result<(), Error> { if !proposal.kind.recovery_may_vote() || !self.store.is_recovery_active().await? { - tally.total_recovery = 0; + tally.drop_recovery(); } Ok(()) } @@ -270,6 +276,13 @@ impl ProposalManager { pub(crate) const fn evaluate_quorum(tally: &Tally, requires_full_quorum: bool) -> VoteOutcome { let total_eligible = tally.total_ordinary + tally.total_recovery; + // No electorate, nothing to settle. Guarded before the branch rather than inside it: + // the full-quorum arm would otherwise set `threshold` to 0 and read an empty tally as + // unanimous approval. + if total_eligible <= 0 { + return VoteOutcome::Pending; + } + #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, @@ -289,9 +302,9 @@ impl ProposalManager { } }; - if tally.approve >= threshold { + if tally.approve() >= threshold { VoteOutcome::Approved - } else if tally.reject > total_eligible - threshold { + } else if tally.reject() > total_eligible - threshold { VoteOutcome::Rejected } else { VoteOutcome::Pending diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs index 749443b..fd2c6d3 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager/store.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager/store.rs @@ -26,14 +26,43 @@ use std::collections::HashMap; use strum::IntoDiscriminant as _; /// Everything the quorum rules need to know about one proposal's votes. +/// +/// Votes are kept per electorate rather than pre-summed: an electorate can stop counting +/// between the vote and the tally (§3.6 -- recovery goes back to sleep the moment a wake-up +/// is cancelled), and the votes it already cast have to leave with it. A single `approve` +/// field would carry them past [`Tally::drop_recovery`] into a threshold computed for the +/// ordinary committee alone. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Tally { - pub approve: i64, - pub reject: i64, + pub ordinary_approve: i64, + pub ordinary_reject: i64, + pub recovery_approve: i64, + pub recovery_reject: i64, pub total_ordinary: i64, pub total_recovery: i64, } +impl Tally { + /// Approvals from every electorate that still counts. + pub const fn approve(&self) -> i64 { + self.ordinary_approve + self.recovery_approve + } + + /// Rejections from every electorate that still counts. + pub const fn reject(&self) -> i64 { + self.ordinary_reject + self.recovery_reject + } + + /// Takes the recovery committee out of the electorate, votes and all. The three numbers + /// go together: leaving the votes behind counts them against a threshold derived from an + /// electorate they are no longer part of. + pub const fn drop_recovery(&mut self) { + self.recovery_approve = 0; + self.recovery_reject = 0; + self.total_recovery = 0; + } +} + #[cfg_attr(test, mockall::automock)] #[async_trait] pub trait ProposalStore: Send + Sync + 'static { @@ -273,8 +302,10 @@ impl ProposalStore for DieselProposalStore { .await?; Ok(Tally { - approve: ordinary_approve + recovery_approve, - reject: ordinary_reject + recovery_reject, + ordinary_approve, + ordinary_reject, + recovery_approve, + recovery_reject, total_ordinary, total_recovery, }) diff --git a/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs index 1b50763..632f8eb 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager/tests.rs @@ -20,15 +20,37 @@ use arbiter_crypto::authn::{SigningContext, SigningKey}; use chrono::{Duration, Utc}; use std::sync::Arc; +/// A tally where every vote came from the ordinary committee. const fn tally(approve: i64, reject: i64, ordinary: i64, recovery: i64) -> Tally { Tally { - approve, - reject, + ordinary_approve: approve, + ordinary_reject: reject, + recovery_approve: 0, + recovery_reject: 0, total_ordinary: ordinary, total_recovery: recovery, } } +/// A tally with votes from both committees, in the order approve/reject per committee. +const fn mixed_tally( + ordinary_approve: i64, + ordinary_reject: i64, + recovery_approve: i64, + recovery_reject: i64, + total_ordinary: i64, + total_recovery: i64, +) -> Tally { + Tally { + ordinary_approve, + ordinary_reject, + recovery_approve, + recovery_reject, + total_ordinary, + total_recovery, + } +} + #[test] fn simple_majority_approves_at_two_of_three() { assert_eq!( @@ -57,16 +79,32 @@ fn full_quorum_kind_needs_every_voter() { #[test] fn recovery_voters_count_towards_full_quorum() { assert_eq!( - ProposalManager::evaluate_quorum(&tally(3, 0, 2, 1), true), + ProposalManager::evaluate_quorum(&mixed_tally(2, 0, 1, 0, 2, 1), true), VoteOutcome::Approved ); assert_eq!( - ProposalManager::evaluate_quorum(&tally(2, 0, 2, 1), true), + ProposalManager::evaluate_quorum(&mixed_tally(2, 0, 0, 0, 2, 1), true), VoteOutcome::Pending, "the sleeping recovery operator still owes a vote" ); } +/// An empty committee cannot approve anything. Both arms have to say so: the full-quorum arm +/// derives its threshold from the electorate, so with nobody eligible it would compare 0 +/// approvals against a threshold of 0 and call that unanimous. +#[test] +fn an_empty_electorate_settles_nothing() { + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 0, 0, 0), true), + VoteOutcome::Pending, + "a full-quorum proposal must not pass with no eligible voters" + ); + assert_eq!( + ProposalManager::evaluate_quorum(&tally(0, 0, 0, 0), false), + VoteOutcome::Pending + ); +} + #[test] fn rejection_is_decided_once_approval_is_unreachable() { // Threshold is 2 of 3, so two rejections leave at most one approval available. @@ -182,19 +220,18 @@ async fn a_vote_short_of_quorum_does_not_touch_the_status() { /// Drives one `cast_vote` on a proposal of the given `kind` through a mocked store and /// returns the outcome. `recovery_active` decides what `is_recovery_active` reports; -/// `expected_status` is the status a settled outcome must be persisted under. +/// `expected_status` is the status a settled outcome must be persisted under, or `None` for +/// a caller that expects the vote to leave the proposal pending. /// -/// `set_status` carries an argument matcher but no `.times()`: whichever outcome a caller -/// asserts is either `Approved` or `Rejected` (never `Pending`), so the write must happen -/// with the right status if it happens at all, but leaving the count unconstrained means a -/// regression that turns the outcome into `Pending` still fails on the caller's own -/// `assert_eq!` -- a readable diff -- rather than on a mockall cardinality panic that hides +/// `set_status` carries an argument matcher but no `.times()`, and `None` relaxes even the +/// matcher: the caller's own `assert_eq!` on the outcome is what pins the behaviour, so a +/// regression fails on a readable diff rather than on a mockall cardinality panic that hides /// what the actor actually computed. async fn settle_vote_with( kind: ProposalKindTag, tally: Tally, recovery_active: bool, - expected_status: ProposalStatus, + expected_status: Option, ) -> VoteOutcome { let id = ProposalId::from_raw(11); let voter = OperatorIdentityId::from_raw(1); @@ -219,7 +256,11 @@ async fn settle_vote_with( store.expect_tally().returning(move |_| Ok(tally)); store .expect_set_status() - .withf(move |_, status| *status == expected_status) + .withf(move |_, status| { + expected_status + .as_ref() + .is_none_or(|expected| status == expected) + }) .returning(|_, _| Ok(())); store.expect_load_kind().returning(move |_, _| { Ok(match kind { @@ -259,14 +300,9 @@ async fn settle_vote_with( async fn unanimous_rejection_settles_a_full_quorum_rekey_via_cast_vote() { let outcome = settle_vote_with( ProposalKindTag::TriggerRekey, - Tally { - approve: 0, - reject: 3, - total_ordinary: 3, - total_recovery: 2, - }, + tally(0, 3, 3, 2), /* recovery_active */ true, - ProposalStatus::Rejected, + Some(ProposalStatus::Rejected), ) .await; @@ -278,14 +314,9 @@ async fn unanimous_rejection_settles_a_full_quorum_rekey_via_cast_vote() { async fn unanimous_ordinary_approval_approves_a_rekey_while_recovery_is_awake() { let outcome = settle_vote_with( ProposalKindTag::TriggerRekey, - Tally { - approve: 3, - reject: 0, - total_ordinary: 3, - total_recovery: 2, - }, + tally(3, 0, 3, 2), /* recovery_active */ true, - ProposalStatus::Approved, + Some(ProposalStatus::Approved), ) .await; @@ -302,14 +333,9 @@ async fn unanimous_ordinary_approval_approves_a_rekey_while_recovery_is_awake() async fn unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake() { let outcome = settle_vote_with( ProposalKindTag::ApproveSdkClient, - Tally { - approve: 0, - reject: 3, - total_ordinary: 3, - total_recovery: 2, - }, + tally(0, 3, 3, 2), /* recovery_active */ true, - ProposalStatus::Rejected, + Some(ProposalStatus::Rejected), ) .await; @@ -326,16 +352,44 @@ async fn unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_r async fn sleeping_recovery_operators_do_not_count_towards_quorum() { let outcome = settle_vote_with( ProposalKindTag::ReplaceOperator, - Tally { - approve: 1, - reject: 0, - total_ordinary: 1, - total_recovery: 2, - }, + tally(1, 0, 1, 2), /* recovery_active */ false, - ProposalStatus::Approved, + Some(ProposalStatus::Approved), ) .await; assert_eq!(outcome, VoteOutcome::Approved); } + +/// The sequence the whole-branch review worked through, on a `ReplaceOperator` with 3 +/// ordinary and 2 recovery operators (§3.3: full quorum). Both recovery operators approve +/// while awake; one ordinary operator approves; another ordinary operator then cancels the +/// wake-up -- `cancel_wakeup` cancels an uncancelled request whether or not its window has +/// elapsed, so the committee goes straight back to sleep with its votes on the record; a +/// second ordinary operator approves. +/// +/// The store now reports 4 approvals, 2 of them from a committee that is no longer eligible. +/// Narrowing the electorate has to drop those votes along with the voters: what is left is 2 +/// of 3 ordinary approvals, and a full quorum needs all three. Counting the electorate down +/// to 3 while keeping all 4 votes would replace an operator on two ordinary approvals. +#[tokio::test] +async fn recovery_votes_leave_with_the_committee_that_cast_them() { + let outcome = settle_vote_with( + ProposalKindTag::ReplaceOperator, + mixed_tally( + /* ordinary_approve */ 2, /* ordinary_reject */ 0, + /* recovery_approve */ 2, /* recovery_reject */ 0, + /* total_ordinary */ 3, /* total_recovery */ 2, + ), + /* recovery_active */ false, + None, + ) + .await; + + assert_eq!( + outcome, + VoteOutcome::Pending, + "two of three ordinary approvals must not carry a full-quorum proposal, whatever a \ + sleeping recovery committee voted earlier" + ); +} diff --git a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs index 326536e..240d99b 100644 --- a/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs @@ -684,9 +684,7 @@ impl VaultCoordinator { /// then transitions to Rekeying state awaiting contributions from all of them. #[message] pub async fn start_rekey(&mut self) -> Result<(), Error> { - if !matches!(self.state, CoordinatorState::Idle) { - return Err(Error::AlreadyBootstrapping); - } + self.ensure_idle()?; let mut conn = self.db.get().await?; let ordinary_count: i64 = schema::operator_identity::table .count() @@ -794,12 +792,28 @@ impl Message for VaultCoordinator { } impl VaultCoordinator { + /// The coordinator runs one ceremony at a time; anything that starts a new one has to say + /// so before it changes any state the ceremony depends on. + const fn ensure_idle(&self) -> Result<(), Error> { + if matches!(self.state, CoordinatorState::Idle) { + Ok(()) + } else { + Err(Error::AlreadyBootstrapping) + } + } + /// Replaces the operator's public key in place, keeping their id and history, drops the /// share that key no longer matches, then begins a coordinated re-key (§3.3). async fn replace_operator( &mut self, settings: &replace_operator::Settings, ) -> Result<(), Error> { + // Checked before anything is written. The re-key is what gives the replaced operator + // a share they can use; if the coordinator is mid-ceremony, `start_rekey` refuses, and + // swapping the key and destroying the share first would leave that operator locked + // out with no re-key running and nothing to undo it -- the caller only logs the error. + self.ensure_idle()?; + let mut conn = self.db.get().await?; diesel::update(schema::operator_identity::table) @@ -819,3 +833,91 @@ impl VaultCoordinator { self.start_rekey().await } } + +#[cfg(test)] +mod tests { + use super::{CoordinatorState, Error, VaultCoordinator}; + use crate::{ + actors::{GlobalActors, vault::Vault}, + db::{self, models::OperatorIdentityId, proposal::replace_operator, schema}, + }; + + use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + use kameo::actor::Spawn as _; + use std::collections::HashMap; + + /// An approved `ReplaceOperator` that arrives while another ceremony is running must + /// change nothing. Swapping the public key and deleting the share are only safe because a + /// re-key follows and hands the operator a share for the new key; when `start_rekey` + /// refuses, the operator would otherwise be left holding a key with no share, and the + /// caller does nothing with the error but log it. + #[tokio::test] + async fn a_refused_rekey_leaves_the_operator_untouched() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let old_key = rand::random::<[u8; 32]>().to_vec(); + let operator_id: OperatorIdentityId = insert_into(schema::operator_identity::table) + .values(schema::operator_identity::public_key.eq(&old_key)) + .returning(schema::operator_identity::id) + .get_result(&mut conn) + .await + .unwrap(); + insert_into(schema::operator::table) + .values(( + schema::operator::id.eq(Some(operator_id)), + schema::operator::share.eq(vec![1u8; 32]), + schema::operator::share_nonce.eq(vec![2u8; 24]), + schema::operator::share_salt.eq(vec![3u8; 32]), + )) + .execute(&mut conn) + .await + .unwrap(); + drop(conn); + + let vault = Vault::spawn( + Vault::new(pool.clone(), GlobalActors::spawn_message_bus()) + .await + .unwrap(), + ); + let mut coordinator = VaultCoordinator::new(pool.clone(), vault); + coordinator.state = CoordinatorState::Rekeying { + ordinary_count: 2, + recovery_count: 0, + passphrases: HashMap::new(), + recovery_passphrases: HashMap::new(), + }; + + let result = coordinator + .replace_operator(&replace_operator::Settings { + old_operator_id: operator_id, + new_pubkey: vec![9u8; 32], + }) + .await; + assert!( + matches!(result, Err(Error::AlreadyBootstrapping)), + "a busy coordinator must refuse the replacement, got {result:?}" + ); + + let mut conn = pool.get().await.unwrap(); + let stored_key: Vec = schema::operator_identity::table + .find(operator_id) + .select(schema::operator_identity::public_key) + .first(&mut conn) + .await + .unwrap(); + assert_eq!( + stored_key, old_key, + "the public key must not be swapped when no re-key can follow" + ); + + let shares: i64 = schema::operator::table + .filter(schema::operator::id.eq(Some(operator_id))) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(shares, 1, "the operator's share must not be destroyed"); + } +} diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index cffde4f..10b0f38 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -59,25 +59,39 @@ fn database_path() -> Result { Ok(db_path) } +/// The pragmas `SQLite` scopes to one connection. They are defined once and run on every +/// connection that reaches the database -- the migration connection below and each pooled +/// connection in `create_pool` -- because a value set on one connection is invisible to the +/// next, and every real write happens on a pooled one. +const CONNECTION_PRAGMAS: &str = " + -- sleep if the database is busy; this corresponds to up to 9 seconds sleeping time. + -- see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/ + PRAGMA busy_timeout = 9000; + -- fsync only in critical moments + PRAGMA synchronous = NORMAL; + -- write WAL changes back every 1000 pages, for an in average 1MB WAL file. + -- May affect readers if number is increased + PRAGMA wal_autocheckpoint = 1000; + -- sqlite foreign keys are disabled by default, enable them for safety + PRAGMA foreign_keys = ON; + -- overwrite freed pages instead of leaving encrypted shares, nonces and salts + -- readable in the file + PRAGMA secure_delete = ON; +"; + #[tracing::instrument(level = "info", skip(conn))] fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> { - // fsync only in critical moments - conn.batch_execute("PRAGMA synchronous = NORMAL;")?; - // write WAL changes back every 1000 pages, for an in average 1MB WAL file. - // May affect readers if number is increased - conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?; + conn.batch_execute(CONNECTION_PRAGMAS)?; + + // The rest belong to the database file rather than the connection, so the one-shot + // migration connection is the right and only place for them. + // free some space by truncating possibly massive WAL files from the last run conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?; - // sqlite foreign keys are disabled by default, enable them for safety - conn.batch_execute("PRAGMA foreign_keys = ON;")?; - // better space reclamation conn.batch_execute("PRAGMA auto_vacuum = FULL;")?; - // secure delete, overwrite deleted content with zeros to prevent recovery - conn.batch_execute("PRAGMA secure_delete = ON;")?; - Ok(()) } @@ -120,17 +134,13 @@ pub async fn create_pool(url: Option<&str>) -> Result i32 { + diesel::sql_query(format!("select {name} as value from pragma_{name}()")) + .get_result::(conn) + .await + .unwrap() + .value + } + + /// `foreign_keys` had to be repeated on the pooled connection because `SQLite` scopes it + /// there; its siblings in `CONNECTION_PRAGMAS` are scoped the same way and were being + /// left behind on the migration connection. `secure_delete` is the one that matters in a + /// key-custody database: off by default, it leaves freed pages holding encrypted shares, + /// nonces and salts readable in the file. + #[tokio::test] + async fn pooled_connections_carry_the_shared_pragmas() { + let pool = create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + assert_eq!( + pragma(&mut conn, "secure_delete").await, + 1, + "freed pages must be overwritten on the connection that does the writing" + ); + assert_eq!( + pragma(&mut conn, "synchronous").await, + 1, + "synchronous must be NORMAL (1), not the default FULL (2)" + ); + assert_eq!(pragma(&mut conn, "foreign_keys").await, 1); + } } 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 a3c2173..451895f 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -181,13 +181,23 @@ impl OperatorSession { Ok(()) } + /// A revoke that matched fewer rows than it named did not do what the operator asked: + /// the id was never granted, or someone revoked it first. Answering `Ok` there tells the + /// operator access is cut off when nothing changed. The rows that did match stay revoked + /// -- rolling them back to report the shortfall would leave live access behind. #[message] pub(crate) async fn handle_revoke_evm_wallet_access( &mut self, entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; - revoke_wallet_access(&mut conn, &entries).await?; + let revoked = revoke_wallet_access(&mut conn, &entries).await?; + if revoked != entries.len() { + return Err(Error::PartialRevoke { + requested: entries.len(), + revoked, + }); + } Ok(()) } @@ -209,6 +219,9 @@ impl OperatorSession { /// Grants access, reviving a previously revoked row rather than leaving it shadowed: /// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert /// would conflict forever on a row that was revoked but never deleted. +/// +/// Reviving restores visibility and nothing else: [`revoke_wallet_access`] closes the grants +/// that hung off the access, so a persistent grant takes its own vote again (§3.2). pub(crate) async fn grant_wallet_access( conn: &mut crate::db::DatabaseConnection, entries: Vec, @@ -231,24 +244,51 @@ pub(crate) async fn grant_wallet_access( .await } -/// Marks access rows revoked by their own id rather than deleting them. The wire carries -/// `WalletAccessEntry.id` values, so filtering by `wallet_id` here would revoke every -/// client's access to that wallet. Deleting is not an option: `evm_basic_grant`, +/// Marks access rows revoked by their own id rather than deleting them, and revokes every +/// grant that hangs off them. Returns how many access rows this call revoked. +/// +/// The wire carries `WalletAccessEntry.id` values, so filtering by `wallet_id` here would +/// revoke every client's access to that wallet. Deleting is not an option: `evm_basic_grant`, /// `evm_transaction_log`, and `proposal_persistent_grant` all reference this row /// `on delete restrict`, so an access that was ever granted, signed with, or proposed /// against can never be deleted -- only marked revoked. +/// +/// The dependent grants have to go with it. `grant_wallet_access` revives a revoked row by +/// its id, and grant lookup keys on `wallet_access_id` alone, so leaving the grants live +/// would make a later re-grant restore every persistent grant the access ever held, with its +/// original volume and rate limits. §3.2 votes visibility and a persistent grant separately; +/// a committee that approves visibility must not silently hand back signing authority it did +/// not vote on. The filter names every requested id, not just the rows this call flipped, so +/// an access revoked before this fix has its orphaned grants closed too. pub(crate) async fn revoke_wallet_access( conn: &mut crate::db::DatabaseConnection, ids: &[i32], ) -> Result { - use crate::db::{models::SqliteTimestamp, schema::evm_wallet_access}; + use crate::db::{ + models::SqliteTimestamp, + schema::{evm_basic_grant, evm_wallet_access}, + }; - diesel::update(evm_wallet_access::table) - .filter(evm_wallet_access::id.eq_any(ids)) - .filter(evm_wallet_access::revoked_at.is_null()) - .set(evm_wallet_access::revoked_at.eq(SqliteTimestamp::now())) - .execute(conn) - .await + conn.transaction(async |conn| { + let now = SqliteTimestamp::now(); + + let revoked = diesel::update(evm_wallet_access::table) + .filter(evm_wallet_access::id.eq_any(ids)) + .filter(evm_wallet_access::revoked_at.is_null()) + .set(evm_wallet_access::revoked_at.eq(now.clone())) + .execute(&mut *conn) + .await?; + + diesel::update(evm_basic_grant::table) + .filter(evm_basic_grant::wallet_access_id.eq_any(ids)) + .filter(evm_basic_grant::revoked_at.is_null()) + .set(evm_basic_grant::revoked_at.eq(now)) + .execute(&mut *conn) + .await?; + + Ok(revoked) + }) + .await } #[messages] @@ -511,6 +551,18 @@ mod tests { .unwrap() } + /// The grants that grant lookup would treat as live for this access: the exact filter + /// `EtherTransfer::try_find_grant` and `TokenTransfer::try_find_grant` apply. + async fn live_grants_for(conn: &mut db::DatabaseConnection, access_id: i32) -> Vec { + schema::evm_basic_grant::table + .filter(schema::evm_basic_grant::wallet_access_id.eq(access_id)) + .filter(schema::evm_basic_grant::revoked_at.is_null()) + .select(schema::evm_basic_grant::id) + .load(conn) + .await + .unwrap() + } + /// Two clients share one wallet. Revoking one access row must leave the other alone, /// and must mark the row revoked rather than deleting it. #[tokio::test] @@ -552,6 +604,13 @@ mod tests { total, 2, "revoking an access row must mark it revoked, not delete it" ); + + // The count `handle_revoke_evm_wallet_access` answers on: a second revoke of the same + // id, like a revoke of an id that never existed, changes nothing and must say so. + let again = revoke_wallet_access(&mut conn, &[first_access]) + .await + .unwrap(); + assert_eq!(again, 0, "an already revoked access must report no rows"); } /// The bug this round fixes: once an access has been used for a grant, a signed @@ -682,8 +741,6 @@ mod tests { let metadata_id = seed_client_metadata(&mut conn).await; let client_id = seed_client(&mut conn, metadata_id).await; let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await; - - revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); drop(conn); let vault = Vault::spawn( @@ -706,6 +763,28 @@ mod tests { }; let wallet_address = Address::from_slice(&address); + // The paired positive case: while the access stands, both lookups resolve it and the + // calls fail further along (no grant, sealed vault) rather than at the access filter. + // Without this, a filter that rejected every row would pass the assertions below. + let live_analyze = evm_actor + .shared_analyze_transaction(client_id, wallet_address, transaction.clone()) + .await; + assert!( + !matches!(live_analyze, Err(SignTransactionError::WalletNotFound)), + "a live access must resolve through shared_analyze_transaction: {live_analyze:?}" + ); + let live_sign = evm_actor + .client_sign_transaction(client_id, wallet_address, transaction.clone()) + .await; + assert!( + !matches!(live_sign, Err(SignTransactionError::WalletNotFound)), + "a live access must resolve through client_sign_transaction: {live_sign:?}" + ); + + let mut conn = pool.get().await.unwrap(); + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + drop(conn); + // Both lookups resolve access the same way; both must reject the revoked row before // ever touching the vault (neither call bootstraps one). let analyze_result = evm_actor @@ -771,4 +850,82 @@ mod tests { "re-granting a revoked access must revive the existing row, not add a second one" ); } + + /// §3.2 puts wallet visibility and a persistent grant to two separate votes. Reviving a + /// revoked access restores visibility, and must restore nothing else: grant lookup keys on + /// `wallet_access_id` with `revoked_at is null`, so a grant left open when the access was + /// cut off would come back live -- with its original volume and rate limits -- the moment + /// the id revives. `EvmActor::grant_wallet_access` executes an approved `GrantWalletAccess` + /// proposal, so that would hand signing authority back to a committee that voted only on + /// visibility. + #[tokio::test] + async fn regranting_an_access_does_not_revive_its_grants() { + let pool = db::create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let (wallet_id, _address) = seed_wallet(&mut conn).await; + let metadata_id = seed_client_metadata(&mut conn).await; + let client_id = seed_client(&mut conn, metadata_id).await; + + let entry = || models::NewEvmWalletAccess { + wallet_id, + client_id, + }; + grant_wallet_access(&mut conn, vec![entry()]).await.unwrap(); + let access_id: i32 = schema::evm_wallet_access::table + .filter(schema::evm_wallet_access::wallet_id.eq(wallet_id)) + .filter(schema::evm_wallet_access::client_id.eq(client_id)) + .select(schema::evm_wallet_access::id) + .first(&mut conn) + .await + .unwrap(); + + // The row every persistent grant hangs off: the specific ether- or token-transfer + // rows reference it, so liveness is decided here. + let grant_id: i32 = insert_into(schema::evm_basic_grant::table) + .values(models::NewEvmBasicGrant { + wallet_access_id: access_id, + chain_id: 1u64.into(), + valid_from: None, + valid_until: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit_count: None, + rate_limit_window_secs: None, + revoked_at: None, + }) + .returning(schema::evm_basic_grant::id) + .get_result(&mut conn) + .await + .unwrap(); + + assert_eq!( + live_grants_for(&mut conn, access_id).await, + vec![grant_id], + "the seeded grant must start out live, or the assertions below prove nothing" + ); + + revoke_wallet_access(&mut conn, &[access_id]).await.unwrap(); + assert!( + live_grants_for(&mut conn, access_id).await.is_empty(), + "revoking an access must revoke the grants that hang off it" + ); + + grant_wallet_access(&mut conn, vec![entry()]).await.unwrap(); + + let revoked_at: Option = schema::evm_wallet_access::table + .find(access_id) + .select(schema::evm_wallet_access::revoked_at) + .first(&mut conn) + .await + .unwrap(); + assert!( + revoked_at.is_none(), + "re-granting must restore visibility for the access itself" + ); + assert!( + live_grants_for(&mut conn, access_id).await.is_empty(), + "re-granting an access must not revive the grants it held before revocation" + ); + } } diff --git a/server/crates/arbiter-server/src/peers/operator/session/mod.rs b/server/crates/arbiter-server/src/peers/operator/session/mod.rs index 9bf0777..3debd89 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/mod.rs @@ -24,6 +24,12 @@ pub enum Error { #[error("This operator role may not perform that action")] RoleNotPermitted, + /// Fewer access rows were revoked than the request named. Like `RoleNotPermitted` this is + /// an answer about the request, not a fault, so it is named rather than folded into + /// `Internal`. + #[error("Revoked {revoked} of {requested} wallet access entries")] + PartialRevoke { requested: usize, revoked: usize }, + #[error("Internal error: {message}")] Internal { message: Cow<'static, str> }, } -- 2.49.1 From a80cd39695b6741c3f97a545b41d99a273535a45 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Tue, 8 Sep 2026 12:40:09 +0200 Subject: [PATCH 66/66] fix(mise): migrate from unix only `asdf` backend to a universal one --- mise.lock | 49 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/mise.lock b/mise.lock index 346f56d..5cdc53d 100644 --- a/mise.lock +++ b/mise.lock @@ -7,6 +7,7 @@ backend = "aqua:ast-grep/ast-grep" [tools.ast-grep."platforms.linux-arm64"] checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388772218" [tools.ast-grep."platforms.linux-arm64-musl"] checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5" @@ -15,6 +16,7 @@ url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64 [tools.ast-grep."platforms.linux-x64"] checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388771275" [tools.ast-grep."platforms.linux-x64-musl"] checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657" @@ -23,14 +25,17 @@ url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64- [tools.ast-grep."platforms.macos-arm64"] checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388770234" [tools.ast-grep."platforms.macos-x64"] checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388770498" [tools.ast-grep."platforms.windows-x64"] checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388771363" [[tools."cargo:cargo-audit"]] version = "0.22.1" @@ -57,7 +62,7 @@ version = "0.9.133" backend = "cargo:cargo-nextest" [[tools."cargo:cargo-shear"]] -version = "1.11.2" +version = "1.13.4" backend = "cargo:cargo-shear" [[tools."cargo:cargo-vet"]] @@ -78,7 +83,27 @@ backend = "cargo:flutter_rust_bridge_codegen" [[tools.flutter]] version = "3.41.7-stable" -backend = "asdf:flutter" +backend = "http:flutter" + +[tools.flutter."platforms.linux-x64"] +checksum = "sha256:f344d5057db52abc2a63cd3a7c7370957b7685d1fca5e5fbe2ce4dfe74657a79" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.7-stable.tar.xz" + +[tools.flutter."platforms.linux-x64-musl"] +checksum = "sha256:f344d5057db52abc2a63cd3a7c7370957b7685d1fca5e5fbe2ce4dfe74657a79" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.7-stable.tar.xz" + +[tools.flutter."platforms.macos-arm64"] +checksum = "sha256:2e3e6af44d1adccf695deff52e5e4c8beb10e5625066b27ad082b38b83ef805e" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.41.7-stable.zip" + +[tools.flutter."platforms.macos-x64"] +checksum = "sha256:a0b9af49e6e1a6800f31a408b98c1d7bd51e98650a8b9ebcd77168b48c916ff0" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.41.7-stable.zip" + +[tools.flutter."platforms.windows-x64"] +checksum = "sha256:de17b513b740a931c5dbc3f96b5a659c1612dfe6b5e1f910c5ad954a8bac17ee" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.41.7-stable.zip" [[tools.protoc]] version = "29.6" @@ -87,30 +112,37 @@ backend = "aqua:protocolbuffers/protobuf/protoc" [tools.protoc."platforms.linux-arm64"] checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076" [tools.protoc."platforms.linux-arm64-musl"] checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076" [tools.protoc."platforms.linux-x64"] checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083" [tools.protoc."platforms.linux-x64-musl"] checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083" [tools.protoc."platforms.macos-arm64"] checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795082" [tools.protoc."platforms.macos-x64"] checksum = "sha256:312f04713946921cc0187ef34df80241ddca1bab6f564c636885fd2cc90d3f88" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795085" [tools.protoc."platforms.windows-x64"] checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f6f7" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795088" [[tools.python]] version = "3.14.4" @@ -122,8 +154,8 @@ url = "https://github.com/astral-sh/python-build-standalone/releases/download/20 provenance = "github-attestations" [tools.python."platforms.linux-arm64-musl"] -checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:a10687b226e0941632569836bc1d8fa6353a8e3e8424316467ca9cdf220b983d" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.linux-x64"] @@ -132,12 +164,12 @@ url = "https://github.com/astral-sh/python-build-standalone/releases/download/20 provenance = "github-attestations" [tools.python."platforms.linux-x64-musl"] -checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:d6005226cd24e780630626232c7a63243d4885fdf975dcf930a0758a0759ce14" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.macos-arm64"] -checksum = "blake3:0314ec66e0f33ec04959583b5900bc8edae371a396aa96b8874e750d1fe936e6" +checksum = "sha256:6f304f4ec30854611f23316578302235fb517cd970519ecdd11a8c4db87fd843" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-apple-darwin-install_only_stripped.tar.gz" provenance = "github-attestations" @@ -154,3 +186,6 @@ provenance = "github-attestations" [[tools.rust]] version = "1.95.0" backend = "core:rust" + +[tools.rust.options] +components = "clippy,rust-analyzer" -- 2.49.1