feat(vault)!: implement full Shamir re-key flow and governance execution (§3.3–§3.5)
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

- 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
This commit is contained in:
CleverWild
2026-06-14 15:11:11 +02:00
parent aff87c13ca
commit 7ab47ec563
14 changed files with 505 additions and 91 deletions

View File

@@ -12,19 +12,20 @@ message Request {
message CreateProposalRequest { message CreateProposalRequest {
oneof kind { oneof kind {
ApproveSdkClientPayload approve_sdk_client = 1; ApproveSdkClientPayload approve_sdk_client = 1;
GrantWalletAccessPayload grant_wallet_access = 3; GrantWalletAccessPayload grant_wallet_access = 3;
ApproveServerUpdatePayload approve_server_update = 4; ApproveServerUpdatePayload approve_server_update = 4;
ReplaceOperatorPayload replace_operator = 5; ReplaceOperatorPayload replace_operator = 5;
UpdateShamirParametersPayload update_shamir_parameters = 6; UpdateShamirParametersPayload update_shamir_parameters = 6;
ApprovePersistentGrantPayload approve_persistent_grant = 7; ApprovePersistentGrantPayload approve_persistent_grant = 7;
ApproveOneOffTransactionPayload approve_one_off_transaction = 8; ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
} }
optional uint32 ttl_secs = 2; optional uint32 ttl_secs = 2;
} }
message ReplaceOperatorPayload { message ReplaceOperatorPayload {
bytes new_pubkey = 1; int32 old_operator_id = 1;
bytes new_pubkey = 2;
} }
message UpdateShamirParametersPayload { message UpdateShamirParametersPayload {
@@ -52,9 +53,9 @@ message QueryPendingRequest {}
message Response { message Response {
oneof payload { oneof payload {
CreateProposalResponse created = 1; CreateProposalResponse created = 1;
VoteResponse voted = 2; VoteResponse voted = 2;
QueryPendingResponse pending = 3; QueryPendingResponse pending = 3;
} }
} }
@@ -67,10 +68,10 @@ message VoteResponse {
} }
enum VoteOutcome { enum VoteOutcome {
VOTE_OUTCOME_UNSPECIFIED = 0; VOTE_OUTCOME_UNSPECIFIED = 0;
VOTE_OUTCOME_PENDING = 1; VOTE_OUTCOME_PENDING = 1;
VOTE_OUTCOME_APPROVED = 2; VOTE_OUTCOME_APPROVED = 2;
VOTE_OUTCOME_REJECTED = 3; VOTE_OUTCOME_REJECTED = 3;
} }
message ProposalSummary { message ProposalSummary {
@@ -97,39 +98,39 @@ message VolumeLimitProto {
} }
message EtherTransferSpecProto { message EtherTransferSpecProto {
repeated bytes targets = 1; repeated bytes targets = 1;
VolumeLimitProto limit = 2; VolumeLimitProto limit = 2;
} }
message TokenTransferSpecProto { message TokenTransferSpecProto {
bytes token_contract = 1; bytes token_contract = 1;
optional bytes target = 2; optional bytes target = 2;
repeated VolumeLimitProto volume_limits = 3; repeated VolumeLimitProto volume_limits = 3;
} }
message ApproveOneOffTransactionPayload { message ApproveOneOffTransactionPayload {
int32 client_id = 1; int32 client_id = 1;
bytes wallet_address = 2; bytes wallet_address = 2;
uint64 chain_id = 3; uint64 chain_id = 3;
uint64 nonce = 4; uint64 nonce = 4;
uint64 gas_limit = 5; uint64 gas_limit = 5;
bytes max_fee_per_gas = 6; bytes max_fee_per_gas = 6;
bytes max_priority_fee_per_gas = 7; bytes max_priority_fee_per_gas = 7;
bytes to = 8; bytes to = 8;
bytes value = 9; bytes value = 9;
bytes input = 10; bytes input = 10;
} }
message ApprovePersistentGrantPayload { message ApprovePersistentGrantPayload {
int32 wallet_access_id = 1; int32 wallet_access_id = 1;
uint64 chain_id = 2; uint64 chain_id = 2;
optional int64 valid_from_secs = 3; optional int64 valid_from_secs = 3;
optional int64 valid_until_secs = 4; optional int64 valid_until_secs = 4;
optional bytes max_gas_fee_per_gas = 5; optional bytes max_gas_fee_per_gas = 5;
optional bytes max_priority_fee_per_gas = 6; optional bytes max_priority_fee_per_gas = 6;
optional TransactionRateLimitProto rate_limit = 7; optional TransactionRateLimitProto rate_limit = 7;
oneof specific { oneof specific {
EtherTransferSpecProto ether_transfer = 8; EtherTransferSpecProto ether_transfer = 8;
TokenTransferSpecProto token_transfer = 9; TokenTransferSpecProto token_transfer = 9;
} }
} }

View File

@@ -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;
}

View File

@@ -5,20 +5,23 @@ package arbiter.operator.vault;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "shared/vault.proto"; import "shared/vault.proto";
import "operator/vault/bootstrap.proto"; import "operator/vault/bootstrap.proto";
import "operator/vault/rekey.proto";
import "operator/vault/unseal.proto"; import "operator/vault/unseal.proto";
message Request { message Request {
oneof payload { oneof payload {
google.protobuf.Empty query_state = 1; google.protobuf.Empty query_state = 1;
unseal.Request unseal = 2; unseal.Request unseal = 2;
bootstrap.Request bootstrap = 3; bootstrap.Request bootstrap = 3;
rekey.Request rekey = 4;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
arbiter.shared.VaultState state = 1; arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2; unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3; bootstrap.Response bootstrap = 3;
rekey.Response rekey = 4;
} }
} }

View File

@@ -26,3 +26,5 @@ trait-assoc-item-kinds-order = [
"type", "type",
"fn", "fn",
] # community tested standard ] # community tested standard
too-many-lines-threshold = 150

View File

@@ -38,6 +38,10 @@ pub mod proto {
tonic::include_proto!("arbiter.operator.vault.bootstrap"); tonic::include_proto!("arbiter.operator.vault.bootstrap");
} }
pub mod rekey {
tonic::include_proto!("arbiter.operator.vault.rekey");
}
pub mod unseal { pub mod unseal {
tonic::include_proto!("arbiter.operator.vault.unseal"); tonic::include_proto!("arbiter.operator.vault.unseal");
} }

View File

@@ -51,18 +51,20 @@ impl GlobalActors {
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())); 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 { Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
db.clone(),
key_holder.clone(),
)),
proposal_manager: ProposalManager::spawn(ProposalManager::new( proposal_manager: ProposalManager::spawn(ProposalManager::new(
db, db,
key_holder.clone(), key_holder.clone(),
evm.clone(), evm.clone(),
vault_coordinator.clone(),
)), )),
vault: key_holder, vault: key_holder,
vault_coordinator,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
operator_registry.clone(), operator_registry.clone(),
)), )),

View File

@@ -60,7 +60,7 @@ pub enum ProposalKind {
} }
impl ProposalKind { impl ProposalKind {
pub fn tag(&self) -> ProposalKindTag { pub const fn tag(&self) -> ProposalKindTag {
match self { match self {
Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient, Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient,
Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess, Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess,

View File

@@ -275,6 +275,59 @@ impl Vault {
Ok(()) 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<u8> = 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::<i32>(&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] #[message]
pub async fn seal(&mut self) -> Result<(), Error> { pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed { let Unsealed {

View File

@@ -8,7 +8,7 @@ use rand_core::{OsRng, RngCore as _};
use tracing::error; use tracing::error;
use crate::{ use crate::{
actors::vault::{Bootstrap, TryUnseal, Vault}, actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold}, crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
db::{self, models, schema}, db::{self, models, schema},
}; };
@@ -19,6 +19,8 @@ pub enum Error {
AlreadyBootstrapping, AlreadyBootstrapping,
#[error("Already coordinating an unseal")] #[error("Already coordinating an unseal")]
AlreadyUnsealing, AlreadyUnsealing,
#[error("Rekey not in progress")]
NotRekeying,
#[error("Bootstrap not in progress")] #[error("Bootstrap not in progress")]
NotBootstrapping, NotBootstrapping,
#[error("Unseal not in progress")] #[error("Unseal not in progress")]
@@ -60,6 +62,15 @@ enum CoordinatorState {
ordinary_passphrases: HashMap<i32, Vec<u8>>, ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>, recovery_passphrases: HashMap<i32, Vec<u8>>,
}, },
/// 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<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
} }
#[derive(Actor)] #[derive(Actor)]
@@ -102,17 +113,17 @@ fn encrypt_share(
fn decrypt_share( fn decrypt_share(
passphrase_bytes: Vec<u8>, passphrase_bytes: Vec<u8>,
encrypted_share: Vec<u8>, encrypted_share: Vec<u8>,
share_nonce_bytes: Vec<u8>, share_nonce_bytes: &[u8],
share_salt: Vec<u8>, share_salt: &[u8],
operator_id: i32, operator_id: i32,
) -> Result<Vec<u8>, Error> { ) -> Result<Vec<u8>, 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!(operator_id, "Invalid nonce in DB");
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut passphrase_cell = SafeCell::new(passphrase_bytes); 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); let mut share_buffer = SafeCell::new(encrypted_share);
share_seal_key share_seal_key
@@ -123,8 +134,8 @@ fn decrypt_share(
} }
/// §3.4: Split the seal key across ordinary + recovery operators. /// §3.4: Split the seal key across ordinary + recovery operators.
/// Threshold = shamir_threshold(ordinary_count); total shares = ordinary + recovery. /// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery.
/// When ordinary_count == 1 (threshold = 1), vsss-rs does not support a proper split, /// 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. /// so each share is the seal key itself — any single participant can reconstruct.
async fn finalize_bootstrap( async fn finalize_bootstrap(
db: db::DatabasePool, db: db::DatabasePool,
@@ -146,7 +157,7 @@ async fn finalize_bootstrap(
shamir::split_key(threshold, total, &seal_key_bytes, OsRng) shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
.map_err(|e| Error::Shamir(e.to_string()))? .map_err(|e| Error::Shamir(e.to_string()))?
} else { } 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); let seal_key = KeyCell::from(seal_key_bytes);
@@ -155,9 +166,10 @@ async fn finalize_bootstrap(
let mut shares_iter = shares.into_iter(); let mut shares_iter = shares.into_iter();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases { for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let share = shares_iter.next().expect("split_key returned enough shares"); let share = shares_iter
let (encrypted_share, nonce_bytes, share_salt) = .next()
encrypt_share(passphrase_bytes, &share)?; .expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::operator::table) diesel::replace_into(schema::operator::table)
.values(( .values((
@@ -173,9 +185,10 @@ async fn finalize_bootstrap(
} }
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases { for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let share = shares_iter.next().expect("split_key returned enough shares"); let share = shares_iter
let (encrypted_share, nonce_bytes, share_salt) = .next()
encrypt_share(passphrase_bytes, &share)?; .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) diesel::replace_into(schema::recovery_operator::table)
.values(( .values((
@@ -190,13 +203,10 @@ async fn finalize_bootstrap(
.await?; .await?;
} }
vault vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
.ask(Bootstrap { seal_key }) error!(?err, "Vault bootstrap failed");
.await Error::VaultError
.map_err(|err| { })?;
error!(?err, "Vault bootstrap failed");
Error::VaultError
})?;
Ok(()) Ok(())
} }
@@ -235,8 +245,8 @@ async fn finalize_unseal(
shares.push(decrypt_share( shares.push(decrypt_share(
passphrase_bytes, passphrase_bytes,
encrypted_share, encrypted_share,
share_nonce_bytes, &share_nonce_bytes,
share_salt, &share_salt,
operator_id_raw, operator_id_raw,
)?); )?);
} }
@@ -257,8 +267,8 @@ async fn finalize_unseal(
shares.push(decrypt_share( shares.push(decrypt_share(
passphrase_bytes, passphrase_bytes,
encrypted_share, encrypted_share,
share_nonce_bytes, &share_nonce_bytes,
share_salt, &share_salt,
recovery_id_raw, 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). // 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. // Any single decrypted share is the key itself.
let seal_key_bytes: [u8; 32] = if threshold <= 1 { let seal_key_bytes: [u8; 32] = if threshold <= 1 {
let raw = shares.into_iter().next().ok_or_else(|| Error::Shamir("No shares available".into()))?; let raw = shares
raw.try_into().map_err(|_| Error::Shamir("Invalid share length".into()))? .into_iter()
.next()
.ok_or_else(|| Error::Shamir("No shares available".into()))?;
raw.try_into()
.map_err(|_| Error::Shamir("Invalid share length".into()))?
} else { } else {
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))? shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
}; };
let seal_key = KeyCell::from(seal_key_bytes); 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<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> 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<Vec<u8>> = 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 vault
.ask(TryUnseal { seal_key }) .ask(RekeyRootKey { new_seal_key })
.await .await
.map_err(|err| { .map_err(|err| {
error!(?err, "Vault unseal failed"); error!(?err, "Vault rekey failed");
Error::VaultError Error::VaultError
})?; })?;
@@ -486,8 +577,7 @@ impl VaultCoordinator {
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?;
let threshold = let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
self.state = CoordinatorState::Unsealing { self.state = CoordinatorState::Unsealing {
threshold, threshold,
ordinary_passphrases: HashMap::new(), ordinary_passphrases: HashMap::new(),
@@ -517,4 +607,115 @@ impl VaultCoordinator {
.await?; .await?;
Ok(true) Ok(true)
} }
async fn do_finalize_rekey(&mut self) -> Result<bool, Error> {
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<Vec<u8>>,
) -> Result<bool, Error> {
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<Vec<u8>>,
) -> Result<bool, Error> {
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
}
} }

View File

@@ -54,20 +54,28 @@ async fn handle_create(
}, },
Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate,
Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator {
new_pubkey: p.new_pubkey.try_into() old_operator_id: p.old_operator_id,
.map_err(|_| Status::invalid_argument("replace_operator: pubkey must be 32 bytes"))?, new_pubkey: p.new_pubkey,
}, },
Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters { 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, new_n: p.new_n as u8,
}, },
Some(ProtoKind::ApprovePersistentGrant(p)) => { Some(ProtoKind::ApprovePersistentGrant(p)) => {
use prost::Message as _; use prost::Message as _;
ProposalKind::ApprovePersistentGrant { payload_bytes: p.encode_to_vec() } ProposalKind::ApprovePersistentGrant {
payload_bytes: p.encode_to_vec(),
}
} }
Some(ProtoKind::ApproveOneOffTransaction(p)) => { Some(ProtoKind::ApproveOneOffTransaction(p)) => {
use prost::Message as _; 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")), None => return Err(Status::invalid_argument("Missing proposal kind")),
}; };

View File

@@ -1,12 +1,20 @@
use crate::{ use crate::{
actors::vault::VaultState, actors::vault::VaultState,
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, peers::operator::{
OperatorSession,
session::handlers::{
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
HandleQueryVaultState,
},
},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::operator::{ proto::operator::{
operator_response::Payload as OperatorResponsePayload, operator_response::Payload as OperatorResponsePayload,
vault::{ 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, response::Payload as VaultResponsePayload,
}, },
}, },
@@ -33,6 +41,7 @@ pub(super) async fn dispatch(
match payload { match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await, VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
Err(Status::permission_denied( Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session", "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<OperatorSession>,
req: proto_rekey::Request,
) -> Result<Option<OperatorResponsePayload>, 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( async fn handle_query_vault_state(
actor: &ActorRef<OperatorSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {

View File

@@ -53,6 +53,9 @@ impl TryConvert for VaultRequestPayload {
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState), Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(), Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(), Self::Bootstrap(req) => req.try_convert(),
Self::Rekey(_) => Err(Status::permission_denied(
"Rekey requires an authenticated session",
)),
} }
} }
} }

View File

@@ -335,3 +335,46 @@ impl OperatorSession {
.unwrap_or_default() .unwrap_or_default()
} }
} }
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_contribute_rekey_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
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<u8>,
) -> Result<bool, Error> {
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"))
}
}

View File

@@ -733,7 +733,7 @@ async fn approve_one_off_transaction_stores_result() {
} }
#[tokio::test] #[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 db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors actors
@@ -751,7 +751,7 @@ async fn replace_operator_inserts_identity_row() {
let proposal_id = actors let proposal_id = actors
.proposal_manager .proposal_manager
.ask(CreateProposal { .ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { new_pubkey }, kind: ProposalKind::ReplaceOperator { old_operator_id: op_id, new_pubkey: new_pubkey.clone() },
initiator_id: op_id, initiator_id: op_id,
ttl_secs: None, ttl_secs: None,
}) })
@@ -774,12 +774,22 @@ async fn replace_operator_inserts_identity_row() {
assert_eq!(outcome, VoteOutcome::QuorumApproved); assert_eq!(outcome, VoteOutcome::QuorumApproved);
let mut conn = db.get().await.unwrap(); 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 let count: i64 = operator_identity::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .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<u8> = 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] #[tokio::test]
@@ -843,7 +853,7 @@ async fn key_rotation_requires_full_quorum() {
let proposal_id = actors let proposal_id = actors
.proposal_manager .proposal_manager
.ask(CreateProposal { .ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { new_pubkey }, kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey },
initiator_id: op1, initiator_id: op1,
ttl_secs: None, ttl_secs: None,
}) })
@@ -925,7 +935,7 @@ async fn recovery_vote_rejected_when_sleeping() {
let proposal_id = actors let proposal_id = actors
.proposal_manager .proposal_manager
.ask(CreateProposal { .ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { new_pubkey }, kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey },
initiator_id: op_id, initiator_id: op_id,
ttl_secs: None, ttl_secs: None,
}) })
@@ -1072,7 +1082,7 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() {
let proposal_id = actors let proposal_id = actors
.proposal_manager .proposal_manager
.ask(CreateProposal { .ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { new_pubkey }, kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey },
initiator_id: op_id, initiator_id: op_id,
ttl_secs: None, ttl_secs: None,
}) })