Skip to content

Commit 1efe9d7

Browse files
committed
[guardian] signed committee handoff via UpdateCommittee RPC
The guardian's committee was set once at ProvisionerInit and could never change, so signature verification failed as soon as hashi rotated past the bootstrap epoch. This wires a chain-of-trust handoff: the outgoing committee N signs a CommitteeTransition{new_committee}, the leader collects threshold sigs and sends it to a new UpdateCommittee RPC, and the guardian verifies + atomically advances. Idempotent on duplicate sends; both success and rejection are logged to S3 for audit. Reuses HashiSigned<T> for the payload (epoch is already baked into signing_message). The new committee travels as move_types::Committee so the BCS-serialized payload matches Move's on-chain layout exactly. Hashi side: leader runs a reconcile task each tick that walks the guardian forward one epoch at a time, signing each step with the historical per-epoch BLS key already retained in db.signing_keys.
1 parent dcf3f91 commit 1efe9d7

18 files changed

Lines changed: 1150 additions & 4 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use crate::withdraw::verify_hashi_cert;
5+
use crate::Enclave;
6+
use hashi_types::committee::certificate_threshold;
7+
use hashi_types::guardian::CommitteeTransition;
8+
use hashi_types::guardian::CommitteeUpdateLogMessage;
9+
use hashi_types::guardian::GuardianError;
10+
use hashi_types::guardian::GuardianError::EnclaveUninitialized;
11+
use hashi_types::guardian::GuardianError::InvalidInputs;
12+
use hashi_types::guardian::GuardianResult;
13+
use hashi_types::guardian::HashiCommittee;
14+
use hashi_types::guardian::HashiSigned;
15+
use std::sync::Arc;
16+
use tracing::error;
17+
use tracing::info;
18+
19+
/// Advance the guardian's committee from the current epoch N to N+1 (or no-op
20+
/// if `signed.message().new_committee.epoch <= current_epoch`).
21+
///
22+
/// Verifies that the outgoing committee (the guardian's current one) signed
23+
/// the transition with sufficient weight, then swaps the in-memory committee
24+
/// atomically. Both successful and rejected attempts are logged to S3 for
25+
/// audit.
26+
///
27+
/// Returns the guardian's committee epoch *after* the call (which equals the
28+
/// proposed epoch on success, or the unchanged current epoch for a no-op).
29+
pub async fn update_committee(
30+
enclave: Arc<Enclave>,
31+
signed: HashiSigned<CommitteeTransition>,
32+
) -> GuardianResult<u64> {
33+
if !enclave.is_fully_initialized() {
34+
return Err(EnclaveUninitialized);
35+
}
36+
37+
let current = enclave.state.get_committee()?;
38+
let current_epoch = current.epoch();
39+
let proposed_epoch = signed.message().new_committee.epoch;
40+
41+
// Idempotency: silently accept already-applied or older transitions.
42+
// Lets the leader's catch-up loop retry without races.
43+
if proposed_epoch <= current_epoch {
44+
info!(
45+
current_epoch,
46+
proposed_epoch, "update_committee: no-op (already at or past proposed epoch)"
47+
);
48+
return Ok(current_epoch);
49+
}
50+
51+
// Strictly sequential transitions. Catch-up walks one epoch at a time.
52+
if proposed_epoch != current_epoch + 1 {
53+
let err = InvalidInputs(format!(
54+
"non-sequential committee transition: current {current_epoch} -> proposed {proposed_epoch}"
55+
));
56+
log_failure(&enclave, current_epoch, &signed, &err).await;
57+
return Err(err);
58+
}
59+
60+
// The outgoing committee's threshold is derived from its own weight, not
61+
// from `WithdrawalConfig.committee_threshold` — that field is genesis-only.
62+
let threshold = certificate_threshold(current.total_weight());
63+
if let Err(e) = verify_hashi_cert(current.clone(), threshold, &signed) {
64+
log_failure(&enclave, current_epoch, &signed, &e).await;
65+
return Err(e);
66+
}
67+
68+
// The transition carries a `move_types::Committee` (BCS-stable); convert
69+
// back to the in-memory form (which rebuilds the address index, etc.).
70+
let new_committee_move = signed.message().new_committee.clone();
71+
let new_committee: HashiCommittee = new_committee_move
72+
.clone()
73+
.try_into()
74+
.map_err(|e| InvalidInputs(format!("invalid new committee in transition: {e}")))?;
75+
76+
// Defensive: the BCS payload's epoch field must match the wire-level
77+
// proposed epoch we already checked. Disagreement would indicate a
78+
// proto-conversion bug; reject rather than silently re-bind.
79+
if new_committee.epoch() != proposed_epoch {
80+
let err = InvalidInputs(format!(
81+
"new committee epoch ({}) does not match transition epoch ({proposed_epoch})",
82+
new_committee.epoch()
83+
));
84+
log_failure(&enclave, current_epoch, &signed, &err).await;
85+
return Err(err);
86+
}
87+
88+
// Log success FIRST (immutable audit), then swap in memory. If the S3
89+
// write fails, the committee isn't advanced — caller retries.
90+
log_success(&enclave, current_epoch, &signed, &new_committee_move).await?;
91+
enclave.state.replace_committee(new_committee)?;
92+
93+
info!(
94+
from_epoch = current_epoch,
95+
to_epoch = proposed_epoch,
96+
"Committee updated"
97+
);
98+
Ok(proposed_epoch)
99+
}
100+
101+
async fn log_success(
102+
enclave: &Enclave,
103+
from_epoch: u64,
104+
signed: &HashiSigned<CommitteeTransition>,
105+
new_committee: &hashi_types::move_types::Committee,
106+
) -> GuardianResult<()> {
107+
let msg = CommitteeUpdateLogMessage::Success {
108+
from_epoch,
109+
to_epoch: new_committee.epoch,
110+
new_committee: new_committee.clone(),
111+
request_sign: signed.committee_signature().clone(),
112+
};
113+
enclave.log_committee_update(msg).await
114+
}
115+
116+
async fn log_failure(
117+
enclave: &Enclave,
118+
from_epoch: u64,
119+
signed: &HashiSigned<CommitteeTransition>,
120+
err: &GuardianError,
121+
) {
122+
let msg = CommitteeUpdateLogMessage::Failure {
123+
from_epoch,
124+
proposed_epoch: signed.message().new_committee.epoch,
125+
request_sign: signed.committee_signature().clone(),
126+
error: err.clone(),
127+
};
128+
if let Err(log_err) = enclave.log_committee_update(msg).await {
129+
error!(
130+
from_epoch,
131+
proposed_epoch = signed.message().new_committee.epoch,
132+
"failed to log committee update failure to S3: {log_err:?}"
133+
);
134+
}
135+
}
136+
137+
#[cfg(test)]
138+
mod tests {
139+
use super::*;
140+
use crate::test_utils::create_fully_initialized_enclave;
141+
use crate::test_utils::FullyInitializedArgs;
142+
use bitcoin::Network;
143+
use hashi_types::committee::Bls12381PrivateKey;
144+
use hashi_types::committee::BlsSignatureAggregator;
145+
use hashi_types::committee::EncryptionPublicKey;
146+
use hashi_types::committee::DEFAULT_MPC_MAX_FAULTY_IN_BASIS_POINTS;
147+
use hashi_types::committee::DEFAULT_MPC_THRESHOLD_IN_BASIS_POINTS;
148+
use hashi_types::committee::DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA;
149+
use hashi_types::guardian::test_utils::create_btc_keypair;
150+
use hashi_types::guardian::HashiCommitteeMember;
151+
use hashi_types::guardian::LimiterState;
152+
use hashi_types::guardian::WithdrawalConfig;
153+
use hashi_types::guardian::WithdrawalID as SuiAddress;
154+
155+
fn mock_signer_address() -> SuiAddress {
156+
SuiAddress::new([1u8; 32])
157+
}
158+
159+
fn mock_bls_sk() -> Bls12381PrivateKey {
160+
// Deterministic key derived from a fixed seed RNG so tests are
161+
// reproducible. We avoid sharing the in-crate `TEST_HASHI_BLS_SK_BYTES`
162+
// constant because it's private.
163+
use rand::SeedableRng;
164+
let mut rng = rand::rngs::StdRng::seed_from_u64(0x000C_0FFE_EBAD_F00D);
165+
Bls12381PrivateKey::generate(&mut rng)
166+
}
167+
168+
fn mock_encryption_pk() -> EncryptionPublicKey {
169+
use rand::SeedableRng;
170+
let mut rng = rand::rngs::StdRng::seed_from_u64(0xDEAD_BEEF);
171+
let sk = hashi_types::committee::EncryptionPrivateKey::new(&mut rng);
172+
EncryptionPublicKey::from_private_key(&sk)
173+
}
174+
175+
fn committee_at(epoch: u64) -> HashiCommittee {
176+
let pk = mock_bls_sk().public_key();
177+
let member = HashiCommitteeMember::new(mock_signer_address(), pk, mock_encryption_pk(), 10);
178+
HashiCommittee::new(
179+
vec![member],
180+
epoch,
181+
DEFAULT_MPC_THRESHOLD_IN_BASIS_POINTS,
182+
DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA,
183+
DEFAULT_MPC_MAX_FAULTY_IN_BASIS_POINTS,
184+
)
185+
}
186+
187+
/// Sign a transition with `signing_epoch` (which becomes the embedded
188+
/// epoch in the certificate) over the given `new_committee`.
189+
fn sign_transition_at(
190+
signing_epoch: u64,
191+
new_committee: HashiCommittee,
192+
) -> HashiSigned<CommitteeTransition> {
193+
let outgoing = committee_at(signing_epoch);
194+
let transition = CommitteeTransition {
195+
new_committee: hashi_types::move_types::Committee::from(&new_committee),
196+
};
197+
let sk = mock_bls_sk();
198+
let sig = sk.sign(signing_epoch, mock_signer_address(), &transition);
199+
let mut agg = BlsSignatureAggregator::new(&outgoing, transition);
200+
agg.add_signature(sig).expect("member sig should verify");
201+
agg.finish().expect("threshold should be met")
202+
}
203+
204+
async fn enclave_at_epoch(epoch: u64) -> Arc<Enclave> {
205+
let kp = create_btc_keypair(&[1u8; 32]);
206+
create_fully_initialized_enclave(FullyInitializedArgs {
207+
network: Network::Regtest,
208+
committee: committee_at(epoch),
209+
master_pubkey: kp.x_only_public_key().0,
210+
withdrawal_config: WithdrawalConfig {
211+
committee_threshold: 0,
212+
refill_rate_sats_per_sec: 0,
213+
max_bucket_capacity_sats: 1_000,
214+
},
215+
limiter_state: LimiterState {
216+
num_tokens_available: 1_000,
217+
last_updated_at: 0,
218+
next_seq: 0,
219+
},
220+
})
221+
.await
222+
}
223+
224+
#[tokio::test]
225+
async fn happy_path_advances_committee() {
226+
let enclave = enclave_at_epoch(5).await;
227+
let signed = sign_transition_at(5, committee_at(6));
228+
229+
let new_epoch = update_committee(enclave.clone(), signed).await.unwrap();
230+
assert_eq!(new_epoch, 6);
231+
assert_eq!(enclave.state.get_committee().unwrap().epoch(), 6);
232+
}
233+
234+
#[tokio::test]
235+
async fn already_applied_is_noop() {
236+
let enclave = enclave_at_epoch(5).await;
237+
// Try to "advance" to an epoch we're already at — must be a no-op.
238+
let signed = sign_transition_at(5, committee_at(5));
239+
240+
let new_epoch = update_committee(enclave.clone(), signed).await.unwrap();
241+
assert_eq!(new_epoch, 5);
242+
assert_eq!(enclave.state.get_committee().unwrap().epoch(), 5);
243+
}
244+
245+
#[tokio::test]
246+
async fn non_sequential_rejected() {
247+
let enclave = enclave_at_epoch(5).await;
248+
// Skipping ahead by 2 must be rejected — catch-up walks one epoch at a time.
249+
let signed = sign_transition_at(5, committee_at(7));
250+
251+
let err = update_committee(enclave.clone(), signed)
252+
.await
253+
.expect_err("non-sequential transition must error");
254+
assert!(
255+
matches!(err, GuardianError::InvalidInputs(_)),
256+
"expected InvalidInputs, got {err:?}"
257+
);
258+
// Committee unchanged.
259+
assert_eq!(enclave.state.get_committee().unwrap().epoch(), 5);
260+
}
261+
262+
#[tokio::test]
263+
async fn wrong_signing_epoch_rejected() {
264+
// Outgoing committee is at epoch 5, but the signature is made with
265+
// signing_epoch = 4 — the guardian must reject because the embedded
266+
// epoch doesn't match the current committee.
267+
let enclave = enclave_at_epoch(5).await;
268+
let signed = sign_transition_at(4, committee_at(6));
269+
270+
let err = update_committee(enclave.clone(), signed)
271+
.await
272+
.expect_err("mismatched signing epoch must error");
273+
assert!(
274+
matches!(err, GuardianError::InvalidInputs(_)),
275+
"expected InvalidInputs, got {err:?}"
276+
);
277+
assert_eq!(enclave.state.get_committee().unwrap().epoch(), 5);
278+
}
279+
}

crates/hashi-guardian/src/enclave.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,27 @@ impl EnclaveState {
289289
Ok(())
290290
}
291291

292+
/// Atomically replace the committee with a new one. Used by the
293+
/// `UpdateCommittee` handler after verifying the outgoing committee's
294+
/// threshold signature on the transition. Fails if the committee has
295+
/// never been initialized (use `set_committee` from `init` for that).
296+
pub fn replace_committee(&self, committee: HashiCommittee) -> GuardianResult<()> {
297+
info!(
298+
"Replacing committee with new committee for epoch {}.",
299+
committee.epoch()
300+
);
301+
302+
let mut guard = self
303+
.committee
304+
.write()
305+
.expect("rwlock should never throw an error");
306+
if guard.is_none() {
307+
return Err(InvalidInputs("committee not initialized".into()));
308+
}
309+
*guard = Some(Arc::new(committee));
310+
Ok(())
311+
}
312+
292313
// ========================================================================
293314
// Rate Limiter Management
294315
// ========================================================================
@@ -459,6 +480,11 @@ impl Enclave {
459480
self.write_log(LogMessage::Withdrawal(Box::new(msg))).await
460481
}
461482

483+
pub async fn log_committee_update(&self, msg: CommitteeUpdateLogMessage) -> GuardianResult<()> {
484+
self.write_log(LogMessage::CommitteeUpdate(Box::new(msg)))
485+
.await
486+
}
487+
462488
pub async fn log_heartbeat(&self, seq: u64) -> GuardianResult<()> {
463489
self.write_log(LogMessage::Heartbeat { seq }).await
464490
}

crates/hashi-guardian/src/getters.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ pub async fn get_guardian_info(enclave: Arc<Enclave>) -> GuardianResult<GetGuard
2929
let attestation = get_attestation(&signing_pub_key)?;
3030
let limiter_state = enclave.state.limiter_state().await;
3131
let limiter_config = enclave.state.limiter_config().await;
32+
let current_committee_epoch = enclave.state.get_committee().ok().map(|c| c.epoch());
3233
Ok(GetGuardianInfoResponse {
3334
attestation,
3435
signing_pub_key,
3536
signed_info: enclave.sign(enclave.info()),
3637
limiter_state,
3738
limiter_config,
39+
current_committee_epoch,
3840
})
3941
}
4042

crates/hashi-guardian/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub const HEARTBEAT_INTERVAL: Duration = Duration::from_mins(1);
88
pub const HEARTBEAT_RETRY_INTERVAL: Duration = Duration::from_secs(10);
99
pub const MAX_HEARTBEAT_FAILURES_INTERVAL: Duration = Duration::from_mins(5);
1010

11+
pub mod committee_update;
1112
pub mod enclave;
1213
pub mod getters;
1314
pub mod heartbeat;

crates/hashi-guardian/src/rpc.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
// Copyright (c) Mysten Labs, Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
use crate::committee_update;
45
use crate::getters;
56
use crate::init;
67
use crate::setup;
78
use crate::withdraw;
89
use crate::Enclave;
910
use hashi_types::guardian::proto_conversions;
11+
use hashi_types::guardian::proto_conversions::pb_to_signed_committee_transition;
1012
use hashi_types::guardian::proto_conversions::pb_to_signed_standard_withdrawal_request_wire;
1113
use hashi_types::guardian::AddressValidation;
1214
use hashi_types::guardian::GuardianError;
@@ -135,4 +137,25 @@ impl proto::guardian_service_server::GuardianService for GuardianGrpc {
135137
let resp_pb = proto_conversions::standard_withdrawal_response_signed_to_pb(response);
136138
Ok(Response::new(resp_pb))
137139
}
140+
141+
async fn update_committee(
142+
&self,
143+
request: Request<proto::SignedCommitteeTransition>,
144+
) -> Result<Response<proto::UpdateCommitteeResponse>, Status> {
145+
if self.setup_mode {
146+
return Err(Status::failed_precondition(
147+
"update_committee is disabled when SETUP_MODE=true",
148+
));
149+
}
150+
151+
let signed = pb_to_signed_committee_transition(request.into_inner()).map_err(to_status)?;
152+
let current_committee_epoch =
153+
committee_update::update_committee(self.enclave.clone(), signed)
154+
.await
155+
.map_err(to_status)?;
156+
157+
Ok(Response::new(proto::UpdateCommitteeResponse {
158+
current_committee_epoch: Some(current_committee_epoch),
159+
}))
160+
}
138161
}

0 commit comments

Comments
 (0)