Skip to content

Commit 7334237

Browse files
authored
[Guardian] KP-rotation prep: N/T runtime + SecretSharingConfig (#591)
Prep for KP rotation. No new RPC surface. Moves the secret-sharing scheme from compile-time constants (`THRESHOLD = 3`, `NUM_OF_SHARES = 5`) to runtime-configurable operator-supplied state. N and T now flow through `OperatorInitRequest.secret_sharing_config`, bundled with the share commitments and a new `sharing_seq` (versions instances: setup writes 0, future rotations append prev+1). Adds a new flat `secret_sharing/` S3 log stream — entries are `SecretSharingLogMessage { encrypted_shares, secret_sharing_config }` written by `setup_new_key` today and by `rotate_kps` in the follow-up. KPs read the lex-last entry to learn the current authoritative scheme and fetch their encrypted shares. `GuardianInfo` returns the full `SecretSharingConfig` so KPs can cross-check the enclave's stored state against S3 off-enclave. Crypto tests parameterized over (2,2), (3,2), (5,3), (10,7). Object locks unified at 7 days; session IDs truncated to 16 hex chars in S3 keys. Stacked PR #592 adds `rotate_kps` on top.
1 parent b4abe6d commit 7334237

15 files changed

Lines changed: 571 additions & 296 deletions

File tree

crates/hashi-guardian/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,28 @@ Canonical key layout:
1010
- `heartbeat/{yyyy}/{mm}/{dd}/{hh}/{session_id}-{counter:020}.json`
1111
- `withdraw/{yyyy}/{mm}/{dd}/{hh}/success-{seq:020}-{session_id}-wid{wid}.json`
1212
- `withdraw/{yyyy}/{mm}/{dd}/{hh}/failure-{session_id}-wid{wid}-{rand8}.json`
13+
- `secret_sharing/{sharing_seq:020}-{session_id}.json`
1314

1415
Where:
1516

16-
- `session_id` is the enclave ephemeral signing pubkey bytes encoded as lowercase hex.
17-
- `init_suffix` is a semantic label (`oi-attestation-unsigned`, `oi-guardian-info`, `setup-new-key-success`, `pi-success-share-{share_id}`, `pi-enclave-fully-initialized`).
17+
- `session_id` is the first 16 hex chars of the enclave ephemeral signing pubkey (lowercase). Acts as a short per-session tag in keys; full pubkey verification still happens via the signed log payload (`SESSION_ID_HEX_LEN` in `hashi-types`).
18+
- `init_suffix` is a semantic label (`oi-attestation-unsigned`, `oi-guardian-info`, `pi-success-share-{share_id}`, `pi-enclave-fully-initialized`).
1819
- `counter` is a zero-padded decimal sequence number (used in heartbeats only).
19-
- `seq` is the limiter sequence number consumed by this withdrawal; zero-padded so lexicographic order within an hour bucket equals seq order.
20+
- `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`.
2022
- `rand8` is a random 8-hex suffix to avoid key collisions (failures only — successes are uniquely keyed by seq).
2123

2224
## Stream semantics
2325

2426
- `init` logs are per-session and deterministic by semantic message kind.
2527
- `heartbeat` logs are hour-partitioned and strictly ordered per session.
2628
- `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.
2730

2831
## Why this layout
2932

3033
- `init/{session_id}-...` keeps init logs session-addressable.
3134
- `heartbeat/...` and `withdraw/...` date partitions support efficient hour-based polling.
32-
- Prefixes (`init`, `heartbeat`, `withdraw`) allow independent S3 deletion policies.
35+
- `secret_sharing/` is flat because the consumer always wants "latest"; a lex sort over the whole prefix is cheap and gives that directly.
36+
- Zero-padding (`{seq:020}` in `withdraw/`, `{sharing_seq:020}` in `secret_sharing/`) makes lexicographic order over the keys equal seq order. The signed log payload embeds the same value, so a fetched object's filename and content can be cross-checked.
37+
- Prefixes (`init`, `heartbeat`, `withdraw`, `secret_sharing`) allow independent S3 deletion policies.

crates/hashi-guardian/src/enclave.rs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ pub struct Scratchpad {
7070
/// The received shares
7171
/// TODO: Investigate if it can be moved to std::sync::Mutex
7272
pub shares: tokio::sync::Mutex<Vec<Share>>,
73-
/// The share commitments
74-
pub share_commitments: OnceLock<ShareCommitments>,
73+
/// Secret-sharing scheme (commitments + N + T) set by operator_init.
74+
pub secret_sharing_config: OnceLock<SecretSharingConfig>,
7575
/// Hash of the state in ProvisionerInitRequest
7676
pub state_hash: OnceLock<[u8; 32]>,
7777
/// Set once operator_init has successfully written all logs to S3.
@@ -372,7 +372,7 @@ impl Enclave {
372372

373373
pub fn is_operator_init_complete(&self) -> bool {
374374
self.config.is_operator_init_complete()
375-
&& self.scratchpad.share_commitments.get().is_some()
375+
&& self.scratchpad.secret_sharing_config.get().is_some()
376376
&& self
377377
.scratchpad
378378
.operator_init_logging_complete
@@ -382,7 +382,7 @@ impl Enclave {
382382

383383
pub fn is_operator_init_partially_complete(&self) -> bool {
384384
self.config.is_operator_init_partially_complete()
385-
|| self.scratchpad.share_commitments.get().is_some()
385+
|| self.scratchpad.secret_sharing_config.get().is_some()
386386
}
387387

388388
pub fn is_fully_initialized(&self) -> bool {
@@ -420,7 +420,7 @@ impl Enclave {
420420

421421
pub fn info(&self) -> GuardianInfo {
422422
GuardianInfo {
423-
share_commitments: self.share_commitments().ok().cloned(),
423+
secret_sharing_config: self.secret_sharing_config().ok().cloned(),
424424
bucket_info: self
425425
.config
426426
.s3_logger()
@@ -463,6 +463,11 @@ impl Enclave {
463463
self.write_log(LogMessage::Heartbeat { seq }).await
464464
}
465465

466+
pub async fn log_secret_sharing(&self, state: SecretSharingLogMessage) -> GuardianResult<()> {
467+
self.write_log(LogMessage::SecretSharing(Box::new(state)))
468+
.await
469+
}
470+
466471
// ========================================================================
467472
// Scratchpad (Initialization-only data)
468473
// ========================================================================
@@ -471,18 +476,18 @@ impl Enclave {
471476
&self.scratchpad.shares
472477
}
473478

474-
pub fn share_commitments(&self) -> GuardianResult<&ShareCommitments> {
479+
pub fn secret_sharing_config(&self) -> GuardianResult<&SecretSharingConfig> {
475480
self.scratchpad
476-
.share_commitments
481+
.secret_sharing_config
477482
.get()
478-
.ok_or(InvalidInputs("Share commitments not set".into()))
483+
.ok_or(InvalidInputs("Secret sharing config not set".into()))
479484
}
480485

481-
pub fn set_share_commitments(&self, commitments: ShareCommitments) -> GuardianResult<()> {
486+
pub fn set_secret_sharing_config(&self, cfg: SecretSharingConfig) -> GuardianResult<()> {
482487
self.scratchpad
483-
.share_commitments
484-
.set(commitments)
485-
.map_err(|_| InvalidInputs("Share commitments already set".into()))
488+
.secret_sharing_config
489+
.set(cfg)
490+
.map_err(|_| InvalidInputs("Secret sharing config already set".into()))
486491
}
487492

488493
pub fn state_hash(&self) -> Option<&[u8; 32]> {

crates/hashi-guardian/src/init.rs

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub async fn operator_init(
3535
}
3636
info!("Enclave state validated.");
3737

38-
let (config, commitments, network) = request.into_parts();
38+
let (config, secret_sharing_config, network) = request.into_parts();
3939
let logger = S3Logger::new_checked(&config).await?;
4040
info!("S3 connectivity check complete.");
4141

@@ -51,16 +51,21 @@ pub async fn operator_init(
5151
.set_bitcoin_network(network)
5252
.expect("Unable to set network");
5353

54-
info!("Storing {} share commitments.", commitments.len());
55-
for (i, share_commitment) in commitments.iter().enumerate() {
54+
info!(
55+
"Storing secret-sharing config: n={}, t={}, {} commitments.",
56+
secret_sharing_config.num_shares(),
57+
secret_sharing_config.threshold(),
58+
secret_sharing_config.commitments().len()
59+
);
60+
for (i, share_commitment) in secret_sharing_config.commitments().iter().enumerate() {
5661
info!(
5762
"Share {}: ID {} Digest {:x?}.",
5863
i, share_commitment.id, share_commitment.digest
5964
);
6065
}
6166
enclave
62-
.set_share_commitments(commitments)
63-
.expect("Unable to set share commitments");
67+
.set_secret_sharing_config(secret_sharing_config)
68+
.expect("Unable to set secret sharing config");
6469

6570
// Log to S3!
6671
// 1) Attestation and pub key help authenticate all subsequent enclave-signed messages.
@@ -130,10 +135,10 @@ pub async fn provisioner_init(
130135

131136
// 2) Verify the share against the commitment
132137
info!("Verifying share against commitment.");
133-
let share_commitments = enclave
134-
.share_commitments()
135-
.expect("share commitments should be set after operator_init");
136-
verify_share(&share, share_commitments)?;
138+
let ssc = enclave
139+
.secret_sharing_config()
140+
.expect("secret sharing config should be set after operator_init");
141+
verify_share(&share, ssc.commitments())?;
137142
info!("Share verified.");
138143

139144
// 3) Set state_hash OR make sure whatever was previously set matches. Panics upon mismatch.
@@ -160,10 +165,8 @@ pub async fn provisioner_init(
160165
}
161166
received_shares.push(share);
162167
let current_share_count = received_shares.len();
163-
info!(
164-
"Total shares received: {}/{}.",
165-
current_share_count, THRESHOLD
166-
);
168+
let threshold = ssc.threshold();
169+
info!("Total shares received: {current_share_count}/{threshold}.");
167170

168171
// Note: This S3 log does not serve any security purpose.
169172
enclave
@@ -175,7 +178,7 @@ pub async fn provisioner_init(
175178
.expect("Unable to log ProvisionerInitSuccess");
176179

177180
// 5) If we have enough shares, finish initialization: combine shares & set config
178-
if current_share_count >= THRESHOLD {
181+
if current_share_count >= threshold {
179182
let shares_vec: Vec<Share> = received_shares.iter().cloned().collect();
180183
finalize_init(&shares_vec, &enclave, request.into_state()).await;
181184
// Log to S3 indicating that withdrawals can be expected henceforth
@@ -202,7 +205,11 @@ async fn finalize_init(
202205
incoming_state: ProvisionerInitState,
203206
) {
204207
info!("Threshold reached, combining shares.");
205-
let enclave_btc_keypair = combine_shares(shares).expect("Unable to combine shares");
208+
let threshold = enclave
209+
.secret_sharing_config()
210+
.expect("secret sharing config set during operator_init")
211+
.threshold();
212+
let enclave_btc_keypair = combine_shares(shares, threshold).expect("Unable to combine shares");
206213

207214
info!("Setting enclave keypair.");
208215
enclave
@@ -242,15 +249,17 @@ fn verify_share(share: &Share, commitments: &ShareCommitments) -> GuardianResult
242249
mod tests {
243250
use super::*;
244251
use crate::OperatorInitTestArgs;
245-
use hashi_types::guardian::crypto::NUM_OF_SHARES;
246252
use hashi_types::guardian::test_utils::create_btc_keypair;
247253
use k256::SecretKey;
248254

255+
const TEST_N: usize = 5;
256+
const TEST_T: usize = 3;
257+
249258
/// Helper: Generate test shares and initialized enclave
250259
/// Returns (shares, enclave)
251260
async fn setup_test_shares_and_enclave() -> (Vec<Share>, Arc<Enclave>) {
252261
let sk = SecretKey::random(&mut rand::thread_rng());
253-
let shares = split_secret(&sk, &mut rand::thread_rng());
262+
let shares = split_secret(&sk, TEST_N, TEST_T, &mut rand::thread_rng()).unwrap();
254263
let share_commitments = ShareCommitments::from_shares(&shares).unwrap();
255264
let enclave = Enclave::create_operator_initialized_with(
256265
OperatorInitTestArgs::default().with_commitments(share_commitments),
@@ -264,8 +273,8 @@ mod tests {
264273
let (shares, enclave) = setup_test_shares_and_enclave().await;
265274
let init_state = ProvisionerInitState::mock_for_testing(None);
266275

267-
// Simulate THRESHOLD KPs calling provisioner_init
268-
for (i, share) in shares.iter().enumerate().take(NUM_OF_SHARES) {
276+
// Simulate KPs calling provisioner_init
277+
for (i, share) in shares.iter().enumerate().take(TEST_N) {
269278
let request = ProvisionerInitRequest::build_from_share_and_state(
270279
share,
271280
enclave.encryption_public_key(),
@@ -276,12 +285,11 @@ mod tests {
276285
let result = provisioner_init(enclave.clone(), request).await;
277286

278287
// Check behavior based on whether we've reached/exceeded threshold
279-
if i == THRESHOLD - 1 {
288+
if i == TEST_T - 1 {
280289
// At exactly threshold (first time), call should succeed
281290
assert!(
282291
result.is_ok(),
283-
"Should succeed at threshold (iteration {})",
284-
i
292+
"Should succeed at threshold (iteration {i})"
285293
);
286294
assert!(
287295
enclave.config.is_enclave_btc_keypair_set(),
@@ -291,14 +299,9 @@ mod tests {
291299
enclave.config.is_hashi_btc_master_pubkey_set(),
292300
"Hashi BTC key should be set after threshold"
293301
);
294-
} else if i >= THRESHOLD {
302+
} else if i >= TEST_T {
295303
// After threshold, subsequent init calls should fail
296-
assert!(
297-
result.is_err(),
298-
"Should fail at iteration {}: {:?}",
299-
i,
300-
result
301-
);
304+
assert!(result.is_err(), "Should fail at iteration {i}: {result:?}");
302305
assert!(
303306
enclave.config.is_enclave_btc_keypair_set(),
304307
"Bitcoin key should still be set"
@@ -317,7 +320,7 @@ mod tests {
317320
}
318321
}
319322

320-
println!("Successfully initialized enclave with {} shares", THRESHOLD);
323+
println!("Successfully initialized enclave with {TEST_T} shares");
321324
}
322325

323326
#[tokio::test]

crates/hashi-guardian/src/main.rs

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

1919
/// Enclave initialization.
20-
/// SETUP_MODE=true: only get_attestation, operator_init and setup_new_key are enabled.
21-
/// SETUP_MODE=false: all endpoints except setup_new_key are enabled.
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`.
2224
#[tokio::main]
2325
async fn main() -> Result<()> {
2426
hashi_types::telemetry::TelemetryConfig::new()
@@ -33,9 +35,9 @@ async fn main() -> Result<()> {
3335
.unwrap_or(false);
3436

3537
if setup_mode {
36-
info!("Setup mode: setup_new_key route available, provisioner_init disabled.");
38+
info!("Setup mode: setup_new_key enabled; provisioner_init/standard_withdrawal disabled.");
3739
} else {
38-
info!("Normal mode: provisioner_init route available, setup_new_key disabled.");
40+
info!("Normal mode: provisioner_init/standard_withdrawal enabled; setup_new_key disabled.");
3941
}
4042

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

0 commit comments

Comments
 (0)