use curve25519_dalek::{
constants::ED25519_BASEPOINT_POINT,
edwards::{CompressedEdwardsY, EdwardsPoint},
scalar::Scalar,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::types::{KeyShare, ThresholdConfig, ThresholdDecryptionContext, ThresholdPublicKey};
#[cfg(feature = "frost-dkg")]
use super::types::{SerializableKeyShare, SerializableThresholdContext};
use crate::crypto::error::CryptoError;
#[cfg(feature = "frost-dkg")]
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThresholdGatewayConfig {
#[serde(default)]
pub enabled: bool,
pub threshold: u32,
pub total: u32,
pub master_public_key: String,
#[serde(default)]
pub public_shares: HashMap<String, String>,
#[serde(default)]
pub keystore_path: Option<String>,
}
impl ThresholdGatewayConfig {
#[cfg(feature = "frost-dkg")]
pub fn load_threshold_context_with_keystore(
&self,
keystore_path: Option<&Path>,
keystore_password: Option<&[u8]>,
) -> Result<ThresholdDecryptionContext, CryptoError> {
if let (Some(path), Some(password)) = (keystore_path, keystore_password) {
if path.exists() {
let plaintext = crate::dkg::keystore::read_keystore(path, password)?;
let ctx: SerializableThresholdContext = serde_json::from_slice(&plaintext)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("deserialize threshold context: {e}")))?;
return ctx.to_threshold_context();
}
}
self.to_threshold_context()
}
pub fn to_threshold_context(&self) -> Result<ThresholdDecryptionContext, CryptoError> {
if !self.enabled {
return Err(CryptoError::ThresholdDecrypt("threshold not enabled".into()));
}
if self.threshold > self.total {
return Err(CryptoError::ThresholdDecrypt(format!(
"threshold ({}) exceeds total ({})",
self.threshold, self.total
)));
}
if self.public_shares.len() as u32 != self.total {
return Err(CryptoError::ThresholdDecrypt(format!(
"expected {} public shares, got {}",
self.total,
self.public_shares.len()
)));
}
let mpk_edwards = parse_edwards_hex(&self.master_public_key)?;
let mpk_montgomery = mpk_edwards.to_montgomery().to_bytes();
let mut public_shares = HashMap::with_capacity(self.public_shares.len());
for (idx_str, hex) in &self.public_shares {
let idx: u32 = idx_str
.parse()
.map_err(|e| CryptoError::ThresholdDecrypt(format!("invalid operator index '{}': {}", idx_str, e)))?;
let point = parse_edwards_hex(hex)?;
public_shares.insert(idx, point);
}
Ok(ThresholdDecryptionContext {
public_key: ThresholdPublicKey {
edwards: mpk_edwards,
hpke_public_key: mpk_montgomery,
},
public_shares,
config: ThresholdConfig {
threshold: self.threshold,
total: self.total,
},
})
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThresholdOperatorConfig {
#[serde(default)]
pub enabled: bool,
pub index: u32,
pub secret_share: String,
#[serde(default)]
pub keystore_path: Option<String>,
}
impl std::fmt::Debug for ThresholdOperatorConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ThresholdOperatorConfig")
.field("enabled", &self.enabled)
.field("index", &self.index)
.field("secret_share", &"[REDACTED]")
.field("keystore_path", &self.keystore_path)
.finish()
}
}
impl ThresholdOperatorConfig {
#[cfg(feature = "frost-dkg")]
pub fn load_key_share_with_keystore(
&self,
keystore_path: Option<&Path>,
keystore_password: Option<&[u8]>,
) -> Result<KeyShare, CryptoError> {
if let (Some(path), Some(password)) = (keystore_path, keystore_password) {
if path.exists() {
let plaintext = crate::dkg::keystore::read_keystore(path, password)?;
let serializable: SerializableKeyShare = serde_json::from_slice(&plaintext)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("deserialize key share: {e}")))?;
return serializable.to_key_share();
}
}
self.to_key_share()
}
pub fn to_key_share(&self) -> Result<KeyShare, CryptoError> {
if !self.enabled {
return Err(CryptoError::ThresholdDecrypt("threshold not enabled".into()));
}
if self.index == 0 {
return Err(CryptoError::ThresholdDecrypt(
"operator index must be >= 1 (1-based indexing)".into(),
));
}
let secret_share = parse_scalar_hex(&self.secret_share)?;
let public_share = secret_share * ED25519_BASEPOINT_POINT;
Ok(KeyShare {
index: self.index,
secret_share,
public_share,
})
}
}
fn parse_edwards_hex(hex: &str) -> Result<EdwardsPoint, CryptoError> {
let hex = hex.strip_prefix("0x").unwrap_or(hex);
let bytes =
hex::decode(hex).map_err(|e| CryptoError::ThresholdDecrypt(format!("invalid hex for Edwards point: {}", e)))?;
if bytes.len() != 32 {
return Err(CryptoError::ThresholdDecrypt(format!(
"Edwards point must be 32 bytes, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
CompressedEdwardsY(arr)
.decompress()
.ok_or_else(|| CryptoError::ThresholdDecrypt("invalid compressed Edwards Y point".into()))
}
fn parse_scalar_hex(hex: &str) -> Result<Scalar, CryptoError> {
let hex = hex.strip_prefix("0x").unwrap_or(hex);
let bytes =
hex::decode(hex).map_err(|e| CryptoError::ThresholdDecrypt(format!("invalid hex for Scalar: {}", e)))?;
if bytes.len() != 32 {
return Err(CryptoError::ThresholdDecrypt(format!(
"Scalar must be 32 bytes, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Option::from(Scalar::from_canonical_bytes(arr))
.ok_or_else(|| CryptoError::ThresholdDecrypt("scalar not in canonical form".into()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EpochConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_epoch_duration")]
pub duration_seconds: u64,
#[serde(default = "default_grace_period_epochs")]
pub grace_period_epochs: u32,
#[serde(default = "default_retry_interval")]
pub retry_interval_seconds: u64,
}
fn default_epoch_duration() -> u64 {
86400
}
fn default_grace_period_epochs() -> u32 {
2
}
fn default_retry_interval() -> u64 {
1800
}
impl Default for EpochConfig {
fn default() -> Self {
Self {
enabled: false,
duration_seconds: default_epoch_duration(),
grace_period_epochs: default_grace_period_epochs(),
retry_interval_seconds: default_retry_interval(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dkg::dealer;
#[cfg(feature = "frost-dkg")]
use crate::dkg::{keystore, types::SerializableKeyShare};
#[test]
fn gateway_config_roundtrip() {
let config = ThresholdConfig { threshold: 2, total: 3 };
let (tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let mpk_hex = hex::encode(tpk.edwards.compress().as_bytes());
let mut public_shares = HashMap::new();
for share in &shares {
public_shares.insert(
share.index.to_string(),
hex::encode(share.public_share.compress().as_bytes()),
);
}
let gw_config = ThresholdGatewayConfig {
enabled: true,
threshold: 2,
total: 3,
master_public_key: mpk_hex,
public_shares,
keystore_path: None,
};
let ctx = gw_config.to_threshold_context().unwrap();
assert_eq!(ctx.config.threshold, 2);
assert_eq!(ctx.config.total, 3);
assert_eq!(ctx.public_key.edwards.compress(), tpk.edwards.compress());
assert_eq!(ctx.public_shares.len(), 3);
for share in &shares {
let loaded = ctx.public_shares.get(&share.index).unwrap();
assert_eq!(loaded.compress(), share.public_share.compress());
}
}
#[test]
fn operator_config_roundtrip() {
let config = ThresholdConfig { threshold: 2, total: 3 };
let (_tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let share = &shares[0];
let secret_hex = hex::encode(share.secret_share.as_bytes());
let op_config = ThresholdOperatorConfig {
enabled: true,
index: share.index,
secret_share: secret_hex,
keystore_path: None,
};
let loaded = op_config.to_key_share().unwrap();
assert_eq!(loaded.index, share.index);
assert_eq!(loaded.secret_share, share.secret_share);
assert_eq!(loaded.public_share.compress(), share.public_share.compress());
}
#[test]
fn disabled_config_returns_error() {
let gw = ThresholdGatewayConfig {
enabled: false,
threshold: 2,
total: 3,
master_public_key: String::new(),
public_shares: HashMap::new(),
keystore_path: None,
};
assert!(gw.to_threshold_context().is_err());
let op = ThresholdOperatorConfig {
enabled: false,
index: 1,
secret_share: String::new(),
keystore_path: None,
};
assert!(op.to_key_share().is_err());
}
#[test]
fn invalid_hex_returns_error() {
let gw = ThresholdGatewayConfig {
enabled: true,
threshold: 2,
total: 3,
master_public_key: "not_hex".into(),
public_shares: HashMap::new(),
keystore_path: None,
};
assert!(gw.to_threshold_context().is_err());
}
#[test]
fn hex_with_0x_prefix_works() {
let config = ThresholdConfig { threshold: 2, total: 3 };
let (_tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let share = &shares[0];
let secret_hex = format!("0x{}", hex::encode(share.secret_share.as_bytes()));
let op_config = ThresholdOperatorConfig {
enabled: true,
index: share.index,
secret_share: secret_hex,
keystore_path: None,
};
let loaded = op_config.to_key_share().unwrap();
assert_eq!(loaded.secret_share, share.secret_share);
}
#[test]
fn serde_json_roundtrip() {
let config = ThresholdConfig { threshold: 2, total: 3 };
let (tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let mpk_hex = hex::encode(tpk.edwards.compress().as_bytes());
let mut public_shares = HashMap::new();
for share in &shares {
public_shares.insert(
share.index.to_string(),
hex::encode(share.public_share.compress().as_bytes()),
);
}
let gw_config = ThresholdGatewayConfig {
enabled: true,
threshold: 2,
total: 3,
master_public_key: mpk_hex,
public_shares,
keystore_path: None,
};
let json_str = serde_json::to_string(&gw_config).unwrap();
let deserialized: ThresholdGatewayConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(gw_config, deserialized);
}
#[cfg(feature = "frost-dkg")]
#[test]
fn load_key_share_prefers_keystore_over_toml() {
use tempfile::NamedTempFile;
let config = ThresholdConfig { threshold: 2, total: 3 };
let (_tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let key_share = &shares[0];
assert_eq!(key_share.index, 1);
let serializable = SerializableKeyShare::from_key_share(key_share);
let serialized = serde_json::to_vec(&serializable).unwrap();
let tmp = NamedTempFile::new().unwrap();
keystore::write_keystore(tmp.path(), "test-ceremony", &serialized, b"pass").unwrap();
let toml_config = ThresholdOperatorConfig {
enabled: true,
index: 99,
secret_share: hex::encode(curve25519_dalek::Scalar::from(42u64).to_bytes()),
keystore_path: None,
};
let loaded = toml_config
.load_key_share_with_keystore(Some(tmp.path()), Some(b"pass"))
.unwrap();
assert_eq!(loaded.index, 1);
assert_eq!(loaded.secret_share, key_share.secret_share);
assert_eq!(loaded.public_share.compress(), key_share.public_share.compress());
}
#[cfg(feature = "frost-dkg")]
#[test]
fn load_key_share_falls_back_to_toml_when_no_keystore() {
let config = ThresholdConfig { threshold: 2, total: 3 };
let (_tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let share = &shares[0];
let toml_config = ThresholdOperatorConfig {
enabled: true,
index: share.index,
secret_share: hex::encode(share.secret_share.as_bytes()),
keystore_path: None,
};
let loaded = toml_config.load_key_share_with_keystore(None, None).unwrap();
assert_eq!(loaded.index, share.index);
assert_eq!(loaded.secret_share, share.secret_share);
}
#[cfg(feature = "frost-dkg")]
#[test]
fn load_threshold_context_prefers_keystore_over_toml() {
use crate::dkg::types::SerializableThresholdContext;
use tempfile::NamedTempFile;
let config = ThresholdConfig { threshold: 2, total: 3 };
let (tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let public_shares: HashMap<u32, curve25519_dalek::edwards::EdwardsPoint> =
shares.iter().map(|s| (s.index, s.public_share)).collect();
let ctx = crate::dkg::types::ThresholdDecryptionContext {
public_key: tpk.clone(),
public_shares,
config,
};
let serializable = SerializableThresholdContext::from_threshold_context(&ctx);
let serialized = serde_json::to_vec(&serializable).unwrap();
let tmp = NamedTempFile::new().unwrap();
keystore::write_keystore(tmp.path(), "test-ceremony-gw", &serialized, b"gwpass").unwrap();
let wrong_mpk_hex = hex::encode(shares[0].public_share.compress().as_bytes());
let toml_config = ThresholdGatewayConfig {
enabled: true,
threshold: 1,
total: 1,
master_public_key: wrong_mpk_hex,
public_shares: std::collections::HashMap::from([(
"1".to_string(),
hex::encode(shares[0].public_share.compress().as_bytes()),
)]),
keystore_path: None,
};
let loaded = toml_config
.load_threshold_context_with_keystore(Some(tmp.path()), Some(b"gwpass"))
.unwrap();
assert_eq!(loaded.public_key.edwards.compress(), tpk.edwards.compress());
assert_eq!(loaded.config.threshold, 2);
assert_eq!(loaded.config.total, 3);
}
#[cfg(feature = "frost-dkg")]
#[test]
fn load_threshold_context_falls_back_to_toml_when_no_keystore() {
let config = ThresholdConfig { threshold: 2, total: 3 };
let (tpk, _commitment, shares) = dealer::generate_shares(config).unwrap();
let mpk_hex = hex::encode(tpk.edwards.compress().as_bytes());
let mut public_shares = HashMap::new();
for share in &shares {
public_shares.insert(
share.index.to_string(),
hex::encode(share.public_share.compress().as_bytes()),
);
}
let toml_config = ThresholdGatewayConfig {
enabled: true,
threshold: 2,
total: 3,
master_public_key: mpk_hex,
public_shares,
keystore_path: None,
};
let loaded = toml_config.load_threshold_context_with_keystore(None, None).unwrap();
assert_eq!(loaded.public_key.edwards.compress(), tpk.edwards.compress());
assert_eq!(loaded.config.threshold, 2);
}
}