From 7ab47ec563a8c9bf64eeec6d5799fcef06bcc8e0 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sun, 14 Jun 2026 15:11:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(vault)!:=20implement=20full=20Shamir=20re-?= =?UTF-8?q?key=20flow=20and=20governance=20execution=20(=C2=A73.3=E2=80=93?= =?UTF-8?q?=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 --- protobufs/operator/governance.proto | 75 ++--- protobufs/operator/vault/rekey.proto | 30 ++ protobufs/operator/vault/vault.proto | 13 +- 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 +- 14 files changed, 505 insertions(+), 91 deletions(-) create mode 100644 protobufs/operator/vault/rekey.proto diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 64dd1e2..132a69f 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -12,19 +12,20 @@ message Request { 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; + 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; ApproveOneOffTransactionPayload approve_one_off_transaction = 8; } optional uint32 ttl_secs = 2; } message ReplaceOperatorPayload { - bytes new_pubkey = 1; + int32 old_operator_id = 1; + bytes new_pubkey = 2; } message UpdateShamirParametersPayload { @@ -52,9 +53,9 @@ message QueryPendingRequest {} message Response { oneof payload { - CreateProposalResponse created = 1; - VoteResponse voted = 2; - QueryPendingResponse pending = 3; + CreateProposalResponse created = 1; + VoteResponse voted = 2; + QueryPendingResponse pending = 3; } } @@ -67,10 +68,10 @@ message VoteResponse { } enum VoteOutcome { - VOTE_OUTCOME_UNSPECIFIED = 0; - VOTE_OUTCOME_PENDING = 1; - VOTE_OUTCOME_APPROVED = 2; - VOTE_OUTCOME_REJECTED = 3; + VOTE_OUTCOME_UNSPECIFIED = 0; + VOTE_OUTCOME_PENDING = 1; + VOTE_OUTCOME_APPROVED = 2; + VOTE_OUTCOME_REJECTED = 3; } message ProposalSummary { @@ -97,39 +98,39 @@ message VolumeLimitProto { } message EtherTransferSpecProto { - repeated bytes targets = 1; - VolumeLimitProto limit = 2; + repeated bytes targets = 1; + VolumeLimitProto limit = 2; } message TokenTransferSpecProto { - bytes token_contract = 1; - optional bytes target = 2; - repeated VolumeLimitProto volume_limits = 3; + bytes token_contract = 1; + optional bytes target = 2; + repeated VolumeLimitProto volume_limits = 3; } message ApproveOneOffTransactionPayload { - int32 client_id = 1; - bytes wallet_address = 2; - uint64 chain_id = 3; - uint64 nonce = 4; - uint64 gas_limit = 5; - bytes max_fee_per_gas = 6; + int32 client_id = 1; + bytes wallet_address = 2; + uint64 chain_id = 3; + uint64 nonce = 4; + uint64 gas_limit = 5; + bytes max_fee_per_gas = 6; bytes max_priority_fee_per_gas = 7; - bytes to = 8; - bytes value = 9; - bytes input = 10; + bytes to = 8; + bytes value = 9; + bytes input = 10; } message ApprovePersistentGrantPayload { - int32 wallet_access_id = 1; - uint64 chain_id = 2; - optional int64 valid_from_secs = 3; - optional int64 valid_until_secs = 4; - optional bytes max_gas_fee_per_gas = 5; - optional bytes max_priority_fee_per_gas = 6; - optional TransactionRateLimitProto rate_limit = 7; + int32 wallet_access_id = 1; + uint64 chain_id = 2; + optional int64 valid_from_secs = 3; + optional int64 valid_until_secs = 4; + optional bytes max_gas_fee_per_gas = 5; + optional bytes max_priority_fee_per_gas = 6; + optional TransactionRateLimitProto rate_limit = 7; oneof specific { - EtherTransferSpecProto ether_transfer = 8; - TokenTransferSpecProto token_transfer = 9; + EtherTransferSpecProto ether_transfer = 8; + TokenTransferSpecProto token_transfer = 9; } } diff --git a/protobufs/operator/vault/rekey.proto b/protobufs/operator/vault/rekey.proto new file mode 100644 index 0000000..5a1de2c --- /dev/null +++ b/protobufs/operator/vault/rekey.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package arbiter.operator.vault.rekey; + +message ContributePassphrase { + bytes passphrase = 1; +} + +message ContributeRecoveryPassphrase { + int32 recovery_operator_id = 1; + bytes passphrase = 2; +} + +enum RekeyResult { + REKEY_RESULT_UNSPECIFIED = 0; + REKEY_RESULT_SUCCESS = 1; + REKEY_RESULT_AWAITING_CONTRIBUTIONS = 2; + REKEY_RESULT_NOT_IN_PROGRESS = 3; +} + +message Request { + oneof payload { + ContributePassphrase contribute_passphrase = 1; + ContributeRecoveryPassphrase contribute_recovery_passphrase = 2; + } +} + +message Response { + RekeyResult result = 1; +} diff --git a/protobufs/operator/vault/vault.proto b/protobufs/operator/vault/vault.proto index 72bd940..6ab1dda 100644 --- a/protobufs/operator/vault/vault.proto +++ b/protobufs/operator/vault/vault.proto @@ -5,20 +5,23 @@ package arbiter.operator.vault; import "google/protobuf/empty.proto"; import "shared/vault.proto"; import "operator/vault/bootstrap.proto"; +import "operator/vault/rekey.proto"; import "operator/vault/unseal.proto"; message Request { oneof payload { google.protobuf.Empty query_state = 1; - unseal.Request unseal = 2; - bootstrap.Request bootstrap = 3; + unseal.Request unseal = 2; + bootstrap.Request bootstrap = 3; + rekey.Request rekey = 4; } } message Response { oneof payload { - arbiter.shared.VaultState state = 1; - unseal.Response unseal = 2; - bootstrap.Response bootstrap = 3; + arbiter.shared.VaultState state = 1; + unseal.Response unseal = 2; + bootstrap.Response bootstrap = 3; + rekey.Response rekey = 4; } } 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, })