Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 15 additions & 4 deletions crates/ciphernode-builder/src/ciphernode_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use e3_net::{
create_channel_bridge, setup_libp2p_keypair, setup_net, setup_net_interface,
NetRepositoryFactory,
};
use e3_request::E3CipherExtension;
use e3_request::E3LifecycleCoordinator;
use e3_request::E3Router;
use e3_slashing::{AccusationManagerExtension, CommitmentConsistencyCheckerExtension};
Expand Down Expand Up @@ -692,9 +693,13 @@ impl CiphernodeBuilder {
) -> Result<e3_request::E3RouterBuilder> {
let mut e3_builder = E3Router::builder(bus, store.clone());

// ── Per-E3 forward-secrecy cipher (must be first so later extensions can consume it) ──
info!("Setting up E3CipherExtension (forward secrecy)");
e3_builder = e3_builder.with(E3CipherExtension::create(&self.cipher));

// ── Threshold keyshare + ZK actors ──
if let Some(KeyshareKind::Threshold) = self.keyshare {
let _ = self.ensure_multithread(bus);
let _ = self.ensure_multithread(bus, &store);
let backend = self
.zk_backend
.as_ref()
Expand All @@ -716,7 +721,7 @@ impl CiphernodeBuilder {
e3_builder = e3_builder.with(FheExtension::create(bus, &self.rng));

info!("Setting up PublicKeyAggregationExtension");
let _ = self.ensure_multithread(bus);
let _ = self.ensure_multithread(bus, &store);
e3_builder = e3_builder.with(PublicKeyAggregatorExtension::create(bus));

if self.keyshare.is_none() {
Expand All @@ -733,7 +738,7 @@ impl CiphernodeBuilder {
// ── Threshold plaintext aggregation ──
if self.threshold_plaintext_agg {
info!("Setting up ThresholdPlaintextAggregatorExtension");
let _ = self.ensure_multithread(bus);
let _ = self.ensure_multithread(bus, &store);
e3_builder = e3_builder.with(ThresholdPlaintextAggregatorExtension::create(
bus, sortition,
));
Expand Down Expand Up @@ -796,7 +801,11 @@ impl CiphernodeBuilder {
}
}

fn ensure_multithread(&mut self, bus: &BusHandle) -> Addr<Multithread> {
fn ensure_multithread(
&mut self,
bus: &BusHandle,
store: &e3_data::DataStore,
) -> Addr<Multithread> {
if let Some(cached) = self.multithread_cache.clone() {
return cached;
}
Expand All @@ -816,6 +825,7 @@ impl CiphernodeBuilder {
bus,
self.rng.clone(),
self.cipher.clone(),
store.clone(),
task_pool,
self.multithread_report.clone(),
backend,
Expand All @@ -825,6 +835,7 @@ impl CiphernodeBuilder {
bus,
self.rng.clone(),
self.cipher.clone(),
store.clone(),
task_pool,
self.multithread_report.clone(),
)
Expand Down
24 changes: 24 additions & 0 deletions crates/crypto/src/cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ impl Cipher {
Ok(Self { key })
}

/// Create a `Cipher` from raw 32-byte key material (no KDF). Used for per-E3 ephemeral
/// keys that are already uniformly random (generated by `Cipher::generate()`).
pub fn from_key_bytes(key: impl Into<Vec<u8>>) -> Result<Self> {
let key = Zeroizing::new(key.into());
anyhow::ensure!(
key.len() == ARGON2_OUTPUT_LEN,
"key must be exactly 32 bytes"
);
Ok(Self { key })
}

/// Generate a fresh random 32-byte `Cipher`. Used to create per-E3 ephemeral keys.
pub fn generate() -> Result<Self> {
let mut raw = vec![0u8; ARGON2_OUTPUT_LEN];
rand::rng().fill_bytes(&mut raw);
Self::from_key_bytes(raw)
}

/// Export the raw key bytes so they can be encrypted and persisted.
/// The returned bytes are wrapped in `Zeroizing` and must be handled with care.
pub fn key_bytes(&self) -> &Zeroizing<Vec<u8>> {
&self.key
}

pub async fn from_password(value: &str) -> Result<Self> {
Self::new(InMemPasswordManager::from_str(value)).await
}
Expand Down
15 changes: 15 additions & 0 deletions crates/events/src/enclave_event/e3_failed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ pub struct E3Failed {
pub reason: FailureReason,
}

impl FailureReason {
/// Returns true when the failure was caused purely by a deadline expiring rather
/// than by a node acting maliciously. Timeout failures have no associated
/// accusation/slashing lifecycle, so their E3 context can be torn down immediately.
pub fn is_timeout(&self) -> bool {
matches!(
self,
Self::CommitteeFormationTimeout
| Self::DKGTimeout
| Self::ComputeTimeout
| Self::DecryptionTimeout
)
}
}

impl Display for E3Failed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
Expand Down
4 changes: 4 additions & 0 deletions crates/events/src/store_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ impl StoreKeys {
String::from("//sortition")
}

pub fn e3_key(e3_id: &E3id) -> String {
format!("//e3_keys/{e3_id}")
}

pub fn eth_private_key() -> String {
String::from("//eth_private_key")
}
Expand Down
72 changes: 64 additions & 8 deletions crates/keyshare/src/actors/threshold_keyshare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ impl AllThresholdSharesCollected {

pub struct ThresholdKeyshareParams {
pub bus: BusHandle,
/// Per-E3 forward-secrecy cipher. `SensitiveBytes` sent to the shared `Multithread` compute
/// actor are decrypted there with the same per-E3 cipher, which it resolves by `e3_id`.
pub cipher: Arc<Cipher>,
pub state: Persistable<ThresholdKeyshareState>,
pub share_enc_preset: BfvPreset,
Expand Down Expand Up @@ -873,7 +875,8 @@ impl ThresholdKeyshare {
bail!("Invalid state - expected GeneratingThresholdShare with all data");
};

// Decrypt our shares from local storage
// Decrypt our shares from local storage. sk_sss and esi_sss were produced by the
// Multithread compute actor under this round's per-E3 cipher.
let decrypted_sk_sss: SharedSecret = sk_sss.decrypt(&self.cipher)?;
let decrypted_esi_sss: Vec<SharedSecret> = esi_sss
.into_iter()
Expand Down Expand Up @@ -2047,7 +2050,7 @@ impl Handler<EncryptionKeyCollectionFailed> for ThresholdKeyshare {
self.bus.publish_without_context(E3Failed {
e3_id: msg.e3_id,
failed_at_stage: E3Stage::CommitteeFinalized,
reason: FailureReason::InsufficientCommitteeMembers,
reason: FailureReason::DKGTimeout,
})?;

// Stop this actor since we can't proceed without all encryption keys
Expand Down Expand Up @@ -2081,7 +2084,7 @@ impl Handler<ThresholdShareCollectionFailed> for ThresholdKeyshare {
self.bus.publish_without_context(E3Failed {
e3_id: msg.e3_id,
failed_at_stage: E3Stage::CommitteeFinalized,
reason: FailureReason::InsufficientCommitteeMembers,
reason: FailureReason::DKGTimeout,
})?;

ctx.stop();
Expand Down Expand Up @@ -2129,7 +2132,7 @@ impl Handler<DecryptionKeySharedCollectionFailed> for ThresholdKeyshare {
self.bus.publish_without_context(E3Failed {
e3_id: msg.e3_id.clone(),
failed_at_stage: E3Stage::CommitteeFinalized,
reason: FailureReason::InsufficientCommitteeMembers,
reason: FailureReason::DecryptionTimeout,
})?;

ctx.stop();
Expand Down Expand Up @@ -2217,9 +2220,10 @@ mod tests {
E3id,
)> {
let (bus, history) = test_bus();
let cipher = Arc::new(Cipher::from_password("test-password").await?);
let actor = ThresholdKeyshare::new(ThresholdKeyshareParams {
bus,
cipher: Arc::new(Cipher::from_password("test-password").await?),
cipher,
state: test_state(),
share_enc_preset: DEFAULT_BFV_PRESET,
})
Expand Down Expand Up @@ -2269,7 +2273,7 @@ mod tests {
EnclaveEventData::E3Failed(data)
if data.e3_id == failure.e3_id
&& data.failed_at_stage == E3Stage::CommitteeFinalized
&& data.reason == FailureReason::InsufficientCommitteeMembers
&& data.reason == FailureReason::DKGTimeout
));

Ok(())
Expand Down Expand Up @@ -2300,7 +2304,7 @@ mod tests {
EnclaveEventData::E3Failed(data)
if data.e3_id == failure.e3_id
&& data.failed_at_stage == E3Stage::CommitteeFinalized
&& data.reason == FailureReason::InsufficientCommitteeMembers
&& data.reason == FailureReason::DKGTimeout
));

Ok(())
Expand All @@ -2323,9 +2327,61 @@ mod tests {
EnclaveEventData::E3Failed(data)
if data.e3_id == failure.e3_id
&& data.failed_at_stage == E3Stage::CommitteeFinalized
&& data.reason == FailureReason::InsufficientCommitteeMembers
&& data.reason == FailureReason::DecryptionTimeout
));

Ok(())
}

// ── cipher boundary tests ────────────────────────────────────────────────
//
// Forward-secrecy contract: the keyshare actor encrypts ALL SensitiveBytes — both
// at-rest shares and data sent to the shared Multithread compute actor — with this
// round's per-E3 cipher. Multithread resolves the same per-E3 cipher by `e3_id`, so a
// single key must round-trip and any other key must fail. (Multithread holding a
// different key is exactly the cipher-mismatch class of bug these tests guard against.)

#[test]
fn per_e3_cipher_round_trips_compute_bound_shares() {
use e3_trbfv::shares::{Encrypted, SharedSecret};
use ndarray::Array2;

let per_e3 = Arc::new(Cipher::from_key_bytes(vec![0xAAu8; 32]).unwrap());
let other = Arc::new(Cipher::from_key_bytes(vec![0xBBu8; 32]).unwrap());

// sk_sss / esi_sss are encrypted by the actor with the per-E3 cipher and decrypted
// by Multithread with the per-E3 cipher it resolves for the same e3_id.
let secret = SharedSecret::new(vec![Array2::zeros((2, 4))]);
let encrypted = Encrypted::new(secret, &per_e3).unwrap();

assert!(
encrypted.clone().decrypt(&per_e3).is_ok(),
"the round's per-E3 cipher must decrypt compute-bound shares"
);
assert!(
encrypted.decrypt(&other).is_err(),
"a cipher for a different round must not decrypt these shares"
);
}

#[test]
fn per_e3_cipher_round_trips_own_shares() {
// own_sk_share_raw / own_esi_shares_raw are encrypted by the actor with the per-E3
// cipher (and later forwarded to Multithread C4 under the same key).
use e3_crypto::SensitiveBytes;

let per_e3 = Arc::new(Cipher::from_key_bytes(vec![0xAAu8; 32]).unwrap());
let other = Arc::new(Cipher::from_key_bytes(vec![0xBBu8; 32]).unwrap());

let own_share = SensitiveBytes::new(b"own share data".to_vec(), &per_e3).unwrap();

assert!(
own_share.clone().access(&per_e3).is_ok(),
"per-E3 cipher must decrypt own share data"
);
assert!(
own_share.access(&other).is_err(),
"a different cipher must not decrypt own share data"
);
}
}
4 changes: 3 additions & 1 deletion crates/keyshare/src/domain/share_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ pub(crate) struct SharesGeneratedPlan {
/// requests for this party's freshly generated DKG share material.
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_shares_generated_plan(
// Per-E3 forward-secrecy cipher. All `SensitiveBytes` here are either stored at rest or sent
// to the Multithread compute actor, which resolves the same per-E3 cipher by `e3_id`.
cipher: &Cipher,
share_enc_preset: BfvPreset,
party_id: u64,
Expand Down Expand Up @@ -117,7 +119,7 @@ pub(crate) fn build_shares_generated_plan(
)
})?;

// Serialize for C2a/C2b proof requests (encrypted at rest)
// Serialize for C2a/C2b proof requests (encrypted at rest, decrypted by Multithread).
let sk_sss_raw = SensitiveBytes::new(
bincode::serialize(&decrypted_sk_sss)
.map_err(|e| anyhow!("Failed to serialize sk_sss: {}", e))?,
Expand Down
22 changes: 17 additions & 5 deletions crates/keyshare/src/ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,34 @@ use async_trait::async_trait;
use e3_crypto::Cipher;
use e3_data::{AutoPersist, RepositoriesFactory};
use e3_events::{prelude::*, BusHandle, EType, EnclaveEvent, EnclaveEventData};
use e3_request::{E3Context, E3ContextSnapshot, E3Extension, META_KEY};
use e3_request::{E3Context, E3ContextSnapshot, E3Extension, E3_CIPHER_KEY, META_KEY};

use crate::KeyshareState;
use std::sync::Arc;

pub struct ThresholdKeyshareExtension {
bus: BusHandle,
cipher: Arc<Cipher>,
/// Fallback cipher used when no per-E3 cipher is present in the context.
/// Normally `E3CipherExtension` is registered first and provides a per-E3
/// cipher; this field exists for backward compatibility and testing.
master_cipher: Arc<Cipher>,
address: String,
}

impl ThresholdKeyshareExtension {
pub fn create(bus: &BusHandle, cipher: &Arc<Cipher>, address: &str) -> Box<Self> {
Box::new(Self {
bus: bus.clone(),
cipher: cipher.to_owned(),
master_cipher: cipher.to_owned(),
address: address.to_owned(),
})
}

/// Return the per-E3 cipher if available, otherwise fall back to the master cipher.
fn resolve_cipher<'a>(&'a self, ctx: &'a E3Context) -> &'a Arc<Cipher> {
ctx.get_dependency(E3_CIPHER_KEY)
.unwrap_or(&self.master_cipher)
}
}

const ERROR_KEYSHARE_META_MISSING: &str =
Expand All @@ -57,6 +66,7 @@ impl E3Extension for ThresholdKeyshareExtension {
.err(EType::KeyGeneration, anyhow!(ERROR_KEYSHARE_META_MISSING));
return;
};
let cipher = self.resolve_cipher(ctx).clone();
let repo = ctx.repositories().threshold_keyshare(&e3_id);
let container = repo.send(Some(ThresholdKeyshareState::new(
e3_id.clone(),
Expand All @@ -75,7 +85,7 @@ impl E3Extension for ThresholdKeyshareExtension {
Some(
ThresholdKeyshare::new(ThresholdKeyshareParams {
bus: self.bus.clone(),
cipher: self.cipher.clone(),
cipher,
state: container,
share_enc_preset: meta
.params_preset
Expand Down Expand Up @@ -114,10 +124,12 @@ impl E3Extension for ThresholdKeyshareExtension {
.dkg_counterpart()
.unwrap_or(meta.params_preset);

let cipher = self.resolve_cipher(ctx).clone();

// Construct from snapshot
let value = ThresholdKeyshare::new(ThresholdKeyshareParams {
bus: self.bus.clone(),
cipher: self.cipher.clone(),
cipher,
state,
share_enc_preset,
})
Expand Down
Loading
Loading