Skip to content

Commit 4a399b9

Browse files
mskd12claude
andcommitted
[Guardian] Add rotate_kps: setup-mode KP rotation
Adds the rotate_kps gRPC handler. Each current KP submits an encrypted old share along with a state portion describing the rotation target (new KP pubkeys, new N, new T, current commitments, current seq). T-of-N digest-matched across submissions; the same digest is bound as HPKE AAD on the encrypted share. On reaching the current threshold the enclave: - reconstructs the BTC key in memory from the old shares, - re-splits it with fresh randomness for the new KP set using the new (n, t), - writes CurrentKeyState { seq = current_seq + 1, encrypted_shares, secret_sharing_config } to key_state/. Asymmetric rotation (new N/T differs from old) is supported. Cross-checks: the KP-supplied current_share_commitments must match what the enclave was given at operator_init (via SecretSharingConfig); each old share is verified against those commitments before reaching the digest-match step. Tests cover happy path (symmetric + asymmetric), dup share, state mismatch panic, share-not-matching-commitments, state commitments not matching enclave, wrong pubkey count, duplicate new pubkeys, and pre-operator-init rejection. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3d5c759 commit 4a399b9

15 files changed

Lines changed: 787 additions & 61 deletions

File tree

crates/hashi-guardian/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ Where:
1818
- `init_suffix` is a semantic label (`oi-attestation-unsigned`, `oi-guardian-info`, `pi-success-share-{share_id}`, `pi-enclave-fully-initialized`).
1919
- `counter` is a zero-padded decimal sequence number (used in heartbeats only).
2020
- `seq` (in `withdraw/`) is the zero-padded limiter sequence number consumed by the withdrawal.
21-
- `sharing_seq` (in `secret_sharing/`) is a zero-padded rotation counter — `setup_new_key` writes `0`; future key-provisioner rotations will append `prev+1`.
21+
- `sharing_seq` (in `secret_sharing/`) is a zero-padded rotation counter — `setup_new_key` writes `0`; each `rotate_kps` appends `prev+1`.
2222
- `rand8` is a random 8-hex suffix to avoid key collisions (failures only — successes are uniquely keyed by seq).
2323

2424
## Stream semantics
2525

2626
- `init` logs are per-session and deterministic by semantic message kind.
2727
- `heartbeat` logs are hour-partitioned and strictly ordered per session.
2828
- `withdraw` logs are hour-partitioned. Successes are seq-sorted within a bucket so the KP rotating in the next enclave can recover limiter state by reading the lexicographically last success key.
29-
- `secret_sharing` logs are flat (not date-partitioned). Each entry is a `SecretSharingLogMessage { encrypted_shares, secret_sharing_config }` written by `setup_new_key` (genesis, `sharing_seq=0`). KPs read the lexicographically last entry to learn the current authoritative commitments and to fetch their encrypted shares.
29+
- `secret_sharing` logs are flat (not date-partitioned). Each entry is a `SecretSharingLogMessage { encrypted_shares, secret_sharing_config }` written by `setup_new_key` (genesis, `sharing_seq=0`) or `rotate_kps` (each rotation, `sharing_seq=prev+1`). KPs read the lexicographically last entry to learn the current authoritative scheme (commitments + N + T) and to fetch their encrypted shares.
3030

3131
## Why this layout
3232

crates/hashi-guardian/src/enclave.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pub struct Scratchpad {
7272
pub shares: tokio::sync::Mutex<Vec<Share>>,
7373
/// Secret-sharing scheme (commitments + N + T) set by operator_init.
7474
pub secret_sharing_config: OnceLock<SecretSharingConfig>,
75-
/// Hash of the state in ProvisionerInitRequest
75+
/// Hash of the state in ProvisionerInitRequest (non-setup mode) (or) RotateKps (setup mode)
7676
pub state_hash: OnceLock<[u8; 32]>,
7777
/// Set once operator_init has successfully written all logs to S3.
7878
/// This prevents heartbeats from being emitted before operator_init logs.
@@ -490,14 +490,24 @@ impl Enclave {
490490
.map_err(|_| InvalidInputs("Secret sharing config already set".into()))
491491
}
492492

493-
pub fn state_hash(&self) -> Option<&[u8; 32]> {
494-
self.scratchpad.state_hash.get()
495-
}
496-
497-
pub fn set_state_hash(&self, hash: [u8; 32]) -> GuardianResult<()> {
498-
self.scratchpad
499-
.state_hash
500-
.set(hash)
501-
.map_err(|_| InvalidInputs("State hash already set".into()))
493+
/// Match `state_hash` against the previously-stored hash from earlier
494+
/// submissions in the same multi-KP flow. Sets it on the first call;
495+
/// panics on mismatch (any divergence between KPs means at least one is
496+
/// malicious or misconfigured — refuse to proceed).
497+
pub fn check_or_set_state_hash(&self, state_hash: [u8; 32]) -> GuardianResult<()> {
498+
match self.scratchpad.state_hash.get() {
499+
Some(existing) if *existing != state_hash => {
500+
panic!("State hash mismatch")
501+
}
502+
Some(_) => info!("State hash matches existing."),
503+
None => {
504+
self.scratchpad
505+
.state_hash
506+
.set(state_hash)
507+
.map_err(|_| InvalidInputs("State hash already set".into()))?;
508+
info!("State hash set.");
509+
}
510+
}
511+
Ok(())
502512
}
503513
}

crates/hashi-guardian/src/init.rs

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use crate::getters::get_attestation;
55
use crate::Enclave;
66
use crate::S3Logger;
77
use hashi_types::guardian::crypto::combine_shares;
8-
use hashi_types::guardian::crypto::commit_share;
98
use hashi_types::guardian::crypto::decrypt_share;
9+
use hashi_types::guardian::crypto::k256_sk_to_btc_keypair;
1010
use hashi_types::guardian::crypto::Share;
1111
use hashi_types::guardian::InitLogMessage::OIAttestationUnsigned;
1212
use hashi_types::guardian::InitLogMessage::OIGuardianInfo;
@@ -138,21 +138,11 @@ pub async fn provisioner_init(
138138
let ssc = enclave
139139
.secret_sharing_config()
140140
.expect("secret sharing config should be set after operator_init");
141-
verify_share(&share, ssc.commitments())?;
141+
ssc.commitments().verify_share(&share)?;
142142
info!("Share verified.");
143143

144144
// 3) Set state_hash OR make sure whatever was previously set matches. Panics upon mismatch.
145-
info!("Checking state hash.");
146-
match enclave.state_hash() {
147-
Some(existing_state_hash) if *existing_state_hash != state_hash => {
148-
panic!("State hash mismatch")
149-
}
150-
Some(_) => info!("State hash matches existing."),
151-
None => {
152-
enclave.set_state_hash(state_hash)?;
153-
info!("State hash set.");
154-
}
155-
}
145+
enclave.check_or_set_state_hash(state_hash)?;
156146

157147
// MILESTONE: At this point, we are sure it is a legitimate payload (both share & config)
158148

@@ -206,7 +196,8 @@ async fn finalize_init(
206196
incoming_state: ProvisionerInitState,
207197
) {
208198
info!("Threshold reached, combining shares.");
209-
let enclave_btc_keypair = combine_shares(shares, threshold).expect("Unable to combine shares");
199+
let enclave_k256_sk = combine_shares(shares, threshold).expect("Unable to combine shares");
200+
let enclave_btc_keypair = k256_sk_to_btc_keypair(&enclave_k256_sk);
210201

211202
info!("Setting enclave keypair.");
212203
enclave
@@ -235,13 +226,6 @@ async fn finalize_init(
235226
info!("Enclave initialization complete.");
236227
}
237228

238-
fn verify_share(share: &Share, commitments: &ShareCommitments) -> GuardianResult<()> {
239-
commitments
240-
.contains(&commit_share(share))
241-
.then_some(())
242-
.ok_or_else(|| InvalidInputs("No matching share found".into()))
243-
}
244-
245229
#[cfg(test)]
246230
mod tests {
247231
use super::*;

crates/hashi-guardian/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod enclave;
1212
pub mod getters;
1313
pub mod heartbeat;
1414
pub mod init;
15+
pub mod rotate;
1516
pub mod rpc;
1617
pub mod s3_logger; // used by the monitor
1718
pub mod setup;
@@ -30,6 +31,8 @@ pub use test_utils::create_operator_initialized_enclave;
3031
#[cfg(any(test, feature = "test-utils"))]
3132
pub use test_utils::mock_logger;
3233
#[cfg(any(test, feature = "test-utils"))]
34+
pub use test_utils::mock_logger_capturing;
35+
#[cfg(any(test, feature = "test-utils"))]
3336
pub use test_utils::mock_logger_with_layout;
3437
#[cfg(any(test, feature = "test-utils"))]
3538
pub use test_utils::FullyInitializedArgs;

crates/hashi-guardian/src/main.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ use tonic_health::server::health_reporter;
1717
use tracing::info;
1818

1919
/// Enclave initialization.
20-
/// `setup_new_key` is gated to SETUP_MODE=true; `provisioner_init` and
21-
/// `standard_withdrawal` are gated to SETUP_MODE=false. Everything else
22-
/// (operator_init, get_guardian_info, …) is available in both modes. See the
23-
/// per-route gates in `rpc.rs`.
20+
/// `setup_new_key` and `rotate_kps` are gated to SETUP_MODE=true;
21+
/// `provisioner_init` and `standard_withdrawal` are gated to SETUP_MODE=false.
22+
/// Everything else (operator_init, get_guardian_info, …) is available in
23+
/// both modes. See the per-route gates in `rpc.rs`.
2424
#[tokio::main]
2525
async fn main() -> Result<()> {
2626
hashi_types::telemetry::TelemetryConfig::new()
@@ -35,9 +35,9 @@ async fn main() -> Result<()> {
3535
.unwrap_or(false);
3636

3737
if setup_mode {
38-
info!("Setup mode: setup_new_key enabled; provisioner_init/standard_withdrawal disabled.");
38+
info!("Setup mode: setup_new_key/rotate_kps enabled; provisioner_init/standard_withdrawal disabled.");
3939
} else {
40-
info!("Normal mode: provisioner_init/standard_withdrawal enabled; setup_new_key disabled.");
40+
info!("Normal mode: provisioner_init/standard_withdrawal enabled; setup_new_key/rotate_kps disabled.");
4141
}
4242

4343
let signing_keys = GuardianSignKeyPair::new(rand::thread_rng());

0 commit comments

Comments
 (0)