From ec56887295bf4412594744309817312432fc8773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 18:25:50 -0400 Subject: [PATCH 01/20] feat(validator): prepare Golden DKG ceremony Bind public DKG setup to the trusted genesis validator set and generate per-validator DKG identities.\n\nReview context: this is a hard cutoff; no backwards compatibility path is required. --- Cargo.lock | 2 + bin/validator/Cargo.toml | 4 +- bin/validator/src/commands/golden_dkg.rs | 519 +++++++++++++++++++++++ bin/validator/src/commands/mod.rs | 6 + 4 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 bin/validator/src/commands/golden_dkg.rs diff --git a/Cargo.lock b/Cargo.lock index 6315b3a49e..13629b68ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4786,10 +4786,12 @@ dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", "serde", + "sha2 0.10.9", "tempfile", "thiserror 2.0.19", "tokio", "tokio-stream", + "toml", "tonic", "tonic-reflection", "tower", diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index fe90c274d0..e4b7da3291 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -39,9 +39,12 @@ miden-protocol = { workspace = true } miden-tx = { features = ["concurrent"], workspace = true } rand_core_06 = { workspace = true } serde = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } tokio = { features = ["macros", "net", "rt-multi-thread"], workspace = true } tokio-stream = { features = ["net"], workspace = true } +toml = { workspace = true } tonic = { default-features = true, features = ["transport"], workspace = true } tonic-reflection = { workspace = true } tower-http = { features = ["util"], workspace = true } @@ -60,6 +63,5 @@ miden-testing = { workspace = true } miden-tx = { features = ["concurrent", "testing"], workspace = true } rand = { workspace = true } rand_chacha_03 = { workspace = true } -tempfile = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "sync"], workspace = true } tower = { features = ["util"], workspace = true } diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs new file mode 100644 index 0000000000..008e89fd2f --- /dev/null +++ b/bin/validator/src/commands/golden_dkg.rs @@ -0,0 +1,519 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, ensure}; +use golden_core::wire::to_wire_bytes; +use golden_core::{ + DkgConfig, + GoldenGroup, + GoldenScalar, + ParticipantIndex, + ParticipantRegistry, + SessionId, +}; +use golden_ehtdh1::derive_context_session_id; +use golden_halo2curves::golden_group::Secp256k1GoldenGroup; +use miden_node_store::genesis::GenesisBlock; +use miden_node_utils::genesis::read_genesis_block; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use rand_core_06::OsRng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +type StorageGroup = Secp256k1GoldenGroup; +type StorageScalar = ::Scalar; + +const REGISTRATION_VERSION: &str = "miden-golden-dkg-registration-v1"; +const MANIFEST_VERSION: &str = "miden-golden-dkg-manifest-v1"; +const IDENTITY_SECRET_MAGIC: &[u8] = b"miden-golden-dkg-identity-v1\0"; +const IDENTITY_SECRET_FILE: &str = "identity-secret.wire"; +const REGISTRATION_FILE: &str = "registration.toml"; +const MANIFEST_FILE: &str = "manifest.toml"; +const DECRYPTION_CONFIG_FILE: &str = "decryption-config.wire"; +const CONTEXT_CONFIG_FILE: &str = "context-config.wire"; + +/// Inputs for one Golden DKG ceremony command. +#[derive(clap::Args)] +pub struct GoldenDkgOptions { + #[command(subcommand)] + command: GoldenDkgCommand, +} + +/// Golden DKG ceremony commands. +#[derive(clap::Subcommand)] +enum GoldenDkgCommand { + /// Generates this validator's DKG identity and public registration. + Identity { + /// Hex-encoded validator signing public key committed by genesis. + #[arg(long, value_name = "HEX")] + validator_public_key: String, + + /// New directory that receives the identity and registration files. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, + }, + + /// Builds the public configurations for both Golden DKG rounds. + Prepare { + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Number of shares needed to decrypt a private record. + #[arg(long, value_name = "NUM")] + threshold: usize, + + /// Hex-encoded 32-byte storage-key epoch. + #[arg(long, value_name = "HEX")] + epoch: String, + + /// Public registration from one validator. Repeat once per genesis validator. + #[arg(long, required = true, value_name = "FILE")] + registration: Vec, + + /// New directory that receives the manifest and public DKG configurations. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, + }, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Registration { + version: String, + validator_public_key: String, + dkg_identity_public_key: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Manifest { + version: String, + genesis_commitment: String, + threshold: usize, + epoch: String, + beta: String, + decryption_session_id: String, + context_session_id: String, + decryption_config_sha256: String, + context_config_sha256: String, + participants: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ManifestParticipant { + participant_index: u32, + validator_public_key: String, + dkg_identity_public_key: String, +} + +/// Runs one Golden DKG ceremony command. +pub fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { + match options.command { + GoldenDkgCommand::Identity { validator_public_key, output_directory } => { + generate_identity(&validator_public_key, &output_directory) + }, + GoldenDkgCommand::Prepare { + genesis, + threshold, + epoch, + registration, + output_directory, + } => prepare(&genesis, threshold, &epoch, ®istration, &output_directory), + } +} + +/// Generates one validator's private DKG identity and public registration. +fn generate_identity(validator_public_key: &str, output_directory: &Path) -> anyhow::Result<()> { + let validator_public_key = decode_validator_public_key(validator_public_key)?; + let identity_secret = StorageScalar::random(&mut OsRng); + ensure!(!bool::from(identity_secret.is_zero()), "generated a zero DKG identity secret"); + let identity_public_key = StorageGroup::mul_generator(&identity_secret); + + let registration = Registration { + version: REGISTRATION_VERSION.to_owned(), + validator_public_key: hex::encode(validator_public_key.to_bytes()), + dkg_identity_public_key: hex::encode(StorageGroup::encode_element(&identity_public_key)), + }; + let registration = + toml::to_string_pretty(®istration).context("failed to encode DKG registration")?; + let secret = encode_identity_secret(&identity_secret); + + publish_directory(output_directory, |directory| { + write_new_file(&directory.join(IDENTITY_SECRET_FILE), &secret, true)?; + write_new_file(&directory.join(REGISTRATION_FILE), registration.as_bytes(), false) + })?; + + println!("Golden DKG identity written to {}.", output_directory.display()); + Ok(()) +} + +/// Builds the genesis-bound manifest and public configurations for both DKG rounds. +fn prepare( + genesis_path: &Path, + threshold: usize, + epoch: &str, + registration_paths: &[PathBuf], + output_directory: &Path, +) -> anyhow::Result<()> { + let epoch = decode_fixed_hex::<32>(epoch, "storage-key epoch")?; + let genesis = GenesisBlock::try_from(read_genesis_block(genesis_path)?) + .context("failed to validate genesis block")?; + let validator_keys = genesis.inner().header().validator_keys().as_keys(); + + ensure!( + registration_paths.len() == validator_keys.len(), + "expected {} registrations, got {}", + validator_keys.len(), + registration_paths.len(), + ); + + let mut registrations = BTreeMap::new(); + let mut identity_keys = BTreeSet::new(); + for path in registration_paths { + let registration = read_registration(path)?; + let validator_key = decode_validator_public_key(®istration.validator_public_key)?; + let validator_key_bytes = validator_key.to_bytes(); + let identity_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + let identity_key_bytes = StorageGroup::encode_element(&identity_key).as_ref().to_vec(); + + ensure!( + identity_keys.insert(identity_key_bytes), + "duplicate DKG identity public key in {}", + path.display(), + ); + ensure!( + registrations + .insert(validator_key_bytes, (registration, identity_key)) + .is_none(), + "duplicate validator registration in {}", + path.display(), + ); + } + + let mut registry_entries = Vec::with_capacity(validator_keys.len()); + let mut participants = Vec::with_capacity(validator_keys.len()); + for (offset, validator_key) in validator_keys.iter().enumerate() { + let validator_key_hex = hex::encode(validator_key.to_bytes()); + let (_, identity_key) = + registrations.remove(validator_key.to_bytes().as_slice()).with_context(|| { + format!("missing registration for genesis validator {validator_key_hex}") + })?; + let participant = ParticipantIndex::new( + u32::try_from(offset + 1).context("too many Golden DKG participants")?, + )?; + let identity_key_hex = hex::encode(StorageGroup::encode_element(&identity_key)); + + registry_entries.push((participant, identity_key)); + participants.push(ManifestParticipant { + participant_index: participant.get(), + validator_public_key: validator_key_hex, + dkg_identity_public_key: identity_key_hex, + }); + } + ensure!( + registrations.is_empty(), + "registration set contains a validator outside genesis" + ); + + let beta = StorageScalar::random(&mut OsRng); + ensure!(!bool::from(beta.is_zero()), "generated a zero DKG beta"); + let decryption_session_id = SessionId::random(&mut OsRng); + let context_session_id = derive_context_session_id(decryption_session_id); + let registry: ParticipantRegistry = ParticipantRegistry::new(registry_entries)?; + let decryption_config = + DkgConfig::new(threshold, decryption_session_id, beta, registry.clone())?; + let context_config = DkgConfig::new(threshold, context_session_id, beta, registry)?; + let decryption_config = to_wire_bytes(&decryption_config); + let context_config = to_wire_bytes(&context_config); + + let manifest = Manifest { + version: MANIFEST_VERSION.to_owned(), + genesis_commitment: hex::encode(genesis.inner().header().commitment().to_bytes()), + threshold, + epoch: hex::encode(epoch), + beta: hex::encode(beta.to_repr()), + decryption_session_id: hex::encode(decryption_session_id.0), + context_session_id: hex::encode(context_session_id.0), + decryption_config_sha256: sha256_hex(&decryption_config), + context_config_sha256: sha256_hex(&context_config), + participants, + }; + let manifest = toml::to_string_pretty(&manifest).context("failed to encode DKG manifest")?; + + publish_directory(output_directory, |directory| { + write_new_file(&directory.join(MANIFEST_FILE), manifest.as_bytes(), false)?; + write_new_file(&directory.join(DECRYPTION_CONFIG_FILE), &decryption_config, false)?; + write_new_file(&directory.join(CONTEXT_CONFIG_FILE), &context_config, false) + })?; + + println!("Golden DKG configuration written to {}.", output_directory.display()); + Ok(()) +} + +/// Reads and validates one public DKG registration. +fn read_registration(path: &Path) -> anyhow::Result { + let contents = fs_err::read_to_string(path) + .with_context(|| format!("failed to read registration {}", path.display()))?; + let registration: Registration = toml::from_str(&contents) + .with_context(|| format!("failed to decode registration {}", path.display()))?; + ensure!( + registration.version == REGISTRATION_VERSION, + "unsupported registration version in {}", + path.display(), + ); + Ok(registration) +} + +/// Parses a validator public key and requires its canonical hex form. +fn decode_validator_public_key(value: &str) -> anyhow::Result { + let bytes = decode_hex(value, "validator public key")?; + let public_key = PublicKey::read_from_bytes(&bytes).context("invalid validator public key")?; + ensure!(public_key.to_bytes() == bytes, "non-canonical validator public key"); + Ok(public_key) +} + +/// Parses a non-identity Golden DKG public key. +fn decode_identity_public_key( + value: &str, +) -> anyhow::Result<::Element> { + let bytes = decode_hex(value, "DKG identity public key")?; + let repr = ::ElementRepr::try_from(bytes) + .map_err(|_| anyhow::anyhow!("invalid DKG identity public key length"))?; + let public_key = + StorageGroup::decode_element(&repr).context("invalid DKG identity public key")?; + ensure!( + !bool::from(StorageGroup::is_identity(&public_key)), + "DKG identity public key is the identity" + ); + Ok(public_key) +} + +/// Encodes a private DKG identity with a fixed format marker. +fn encode_identity_secret(secret: &StorageScalar) -> Zeroizing> { + let mut encoded = + Zeroizing::new(Vec::with_capacity(IDENTITY_SECRET_MAGIC.len() + StorageScalar::REPR_BYTES)); + encoded.extend_from_slice(IDENTITY_SECRET_MAGIC); + encoded.extend_from_slice(secret.to_repr().as_ref()); + encoded +} + +/// Decodes a private DKG identity and rejects malformed or zero scalars. +#[cfg(test)] +fn decode_identity_secret(bytes: &[u8]) -> anyhow::Result { + let scalar_bytes = bytes + .strip_prefix(IDENTITY_SECRET_MAGIC) + .context("invalid DKG identity secret format")?; + ensure!( + scalar_bytes.len() == StorageScalar::REPR_BYTES, + "invalid DKG identity secret length", + ); + let repr = ::Repr::try_from(scalar_bytes.to_vec()) + .map_err(|_| anyhow::anyhow!("invalid DKG identity secret length"))?; + let secret = StorageScalar::from_repr(&repr).context("invalid DKG identity secret")?; + ensure!(!bool::from(secret.is_zero()), "DKG identity secret is zero"); + Ok(secret) +} + +/// Publishes a complete set of ceremony files under a new directory. +fn publish_directory( + output_directory: &Path, + write: impl FnOnce(&Path) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + ensure!(!output_directory.exists(), "output directory already exists"); + let parent = output_directory.parent().unwrap_or_else(|| Path::new(".")); + fs_err::create_dir_all(parent).context("failed to create output parent directory")?; + let temporary = tempfile::Builder::new() + .prefix(".golden-dkg-") + .tempdir_in(parent) + .context("failed to create temporary output directory")?; + write(temporary.path())?; + let temporary = temporary.keep(); + fs_err::rename(&temporary, output_directory).context("failed to publish output directory")?; + Ok(()) +} + +/// Creates one ceremony file without replacing an existing file. +fn write_new_file(path: &Path, bytes: &[u8], private: bool) -> anyhow::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all().with_context(|| format!("failed to sync {}", path.display()))?; + Ok(()) +} + +/// Parses canonical lowercase hex. +fn decode_hex(value: &str, name: &str) -> anyhow::Result> { + ensure!( + value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "{name} must use lowercase hex", + ); + let bytes = hex::decode(value).with_context(|| format!("invalid {name}"))?; + ensure!(hex::encode(&bytes) == value, "non-canonical {name}"); + Ok(bytes) +} + +/// Parses a fixed-size canonical hex value. +fn decode_fixed_hex(value: &str, name: &str) -> anyhow::Result<[u8; N]> { + decode_hex(value, name)? + .try_into() + .map_err(|_| anyhow::anyhow!("{name} must be {N} bytes")) +} + +/// Returns the SHA-256 digest of one public ceremony artifact. +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use golden_core::wire::from_wire_bytes; + use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + + use super::*; + + type TestResult = Result<(), Box>; + + /// Creates a genesis block for three validators and returns their canonical public keys. + fn write_genesis(root: &Path) -> TestResultWith> { + let signing_keys = [SigningKey::new(), SigningKey::new(), SigningKey::new()]; + let config = format!( + concat!( + "version = 1\n", + "timestamp = 1717344256\n", + "validators = [\"{}\", \"{}\", \"{}\"]\n", + "\n[fee_parameters]\n", + "verification_base_fee = 0\n", + ), + hex::encode(signing_keys[0].public_key().to_bytes()), + hex::encode(signing_keys[1].public_key().to_bytes()), + hex::encode(signing_keys[2].public_key().to_bytes()), + ); + let config_path = root.join("genesis.toml"); + fs_err::write(&config_path, config)?; + let genesis_directory = root.join("genesis"); + let accounts_directory = root.join("accounts"); + super::super::genesis::generate( + &genesis_directory, + &accounts_directory, + Some(&config_path), + )?; + let genesis = + GenesisBlock::try_from(read_genesis_block(&genesis_directory.join("genesis.dat"))?)?; + Ok(genesis.inner().header().validator_keys().as_keys().to_vec()) + } + + type TestResultWith = Result>; + + #[test] + fn identity_round_trip_matches_public_registration() -> TestResult { + let root = tempfile::tempdir()?; + let validator_key = SigningKey::new().public_key(); + let output = root.path().join("identity"); + + generate_identity(&hex::encode(validator_key.to_bytes()), &output)?; + + let registration = read_registration(&output.join(REGISTRATION_FILE))?; + let secret_bytes = Zeroizing::new(fs_err::read(output.join(IDENTITY_SECRET_FILE))?); + let secret = decode_identity_secret(&secret_bytes)?; + let public_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + assert_eq!(StorageGroup::mul_generator(&secret), public_key); + assert_eq!(registration.validator_public_key, hex::encode(validator_key.to_bytes())); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = + fs_err::metadata(output.join(IDENTITY_SECRET_FILE))?.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + Ok(()) + } + + #[test] + fn identity_secret_rejects_malformed_input() { + assert!(decode_identity_secret(IDENTITY_SECRET_MAGIC).is_err()); + let mut zero = IDENTITY_SECRET_MAGIC.to_vec(); + zero.extend_from_slice(&[0; StorageScalar::REPR_BYTES]); + assert!(decode_identity_secret(&zero).is_err()); + zero.push(0); + assert!(decode_identity_secret(&zero).is_err()); + } + + #[test] + fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { + let root = tempfile::tempdir()?; + let validator_keys = write_genesis(root.path())?; + let mut registrations = Vec::new(); + for (position, validator_key) in validator_keys.iter().rev().enumerate() { + let directory = root.path().join(format!("identity-{position}")); + generate_identity(&hex::encode(validator_key.to_bytes()), &directory)?; + registrations.push(directory.join(REGISTRATION_FILE)); + } + let output = root.path().join("ceremony"); + let genesis_path = root.path().join("genesis/genesis.dat"); + let epoch = "11".repeat(32); + + prepare(&genesis_path, 2, &epoch, ®istrations, &output)?; + + let manifest: Manifest = + toml::from_str(&fs_err::read_to_string(output.join(MANIFEST_FILE))?)?; + let decryption_bytes = fs_err::read(output.join(DECRYPTION_CONFIG_FILE))?; + let context_bytes = fs_err::read(output.join(CONTEXT_CONFIG_FILE))?; + let decryption: DkgConfig = from_wire_bytes(&decryption_bytes)?; + let context: DkgConfig = from_wire_bytes(&context_bytes)?; + + assert_eq!(manifest.threshold, 2); + assert_eq!(manifest.epoch, epoch); + assert_eq!(manifest.decryption_config_sha256, sha256_hex(&decryption_bytes)); + assert_eq!(manifest.context_config_sha256, sha256_hex(&context_bytes)); + assert_eq!(decryption.threshold, 2); + assert_eq!(context.threshold, 2); + assert_eq!(decryption.registry, context.registry); + assert_eq!(context.session_id, derive_context_session_id(decryption.session_id)); + for ((position, participant), validator_key) in + manifest.participants.iter().enumerate().zip(&validator_keys) + { + assert_eq!(participant.participant_index, u32::try_from(position + 1)?); + assert_eq!(participant.validator_public_key, hex::encode(validator_key.to_bytes())); + } + Ok(()) + } + + #[test] + fn prepare_rejects_registration_outside_genesis() -> TestResult { + let root = tempfile::tempdir()?; + let validator_keys = write_genesis(root.path())?; + let mut registrations = Vec::new(); + for (position, validator_key) in validator_keys.iter().take(2).enumerate() { + let directory = root.path().join(format!("identity-{position}")); + generate_identity(&hex::encode(validator_key.to_bytes()), &directory)?; + registrations.push(directory.join(REGISTRATION_FILE)); + } + let outsider = root.path().join("identity-outsider"); + generate_identity(&hex::encode(SigningKey::new().public_key().to_bytes()), &outsider)?; + registrations.push(outsider.join(REGISTRATION_FILE)); + + assert!( + prepare( + &root.path().join("genesis/genesis.dat"), + 2, + &"22".repeat(32), + ®istrations, + &root.path().join("ceremony"), + ) + .is_err(), + ); + Ok(()) + } +} diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index cb447a209f..6189db08c7 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -1,6 +1,7 @@ mod bootstrap; mod export_private_record; mod genesis; +mod golden_dkg; mod issue_private_record_share; mod start; @@ -158,6 +159,9 @@ pub enum ValidatorCommand { data_directory: PathBuf, }, + /// Runs the Golden storage-key setup ceremony. + GoldenDkg(golden_dkg::GoldenDkgOptions), + /// Issues this validator's Golden decryption share for one stored private record. IssuePrivateRecordShare(PrivateRecordShareOptions), @@ -290,6 +294,7 @@ impl ValidatorCommand { .context("failed to apply validator database migrations")?; Ok(()) }, + Self::GoldenDkg(options) => golden_dkg::run(options), Self::IssuePrivateRecordShare(options) => { issue_private_record_share::issue_from_options(options) }, @@ -352,6 +357,7 @@ impl ValidatorCommand { Self::Genesis { .. } | Self::Bootstrap { .. } | Self::Pubkey { .. } + | Self::GoldenDkg(_) | Self::ExportPrivateRecord(_) | Self::IssuePrivateRecordShare(_) | Self::Migrate { .. } => OpenTelemetry::Disabled, From c853bbb502f567c058e1a99402053ba822a8c34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 18:35:25 -0400 Subject: [PATCH 02/20] fix(validator): authenticate DKG registrations Bind each DKG identity to the trusted genesis commitment with the validator signer. Keep temporary ceremony outputs guarded until publication succeeds. --- bin/validator/src/commands/golden_dkg.rs | 279 +++++++++++++++++------ bin/validator/src/commands/mod.rs | 2 +- 2 files changed, 211 insertions(+), 70 deletions(-) diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index 008e89fd2f..fe4dd19c01 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -16,18 +16,24 @@ use golden_ehtdh1::derive_context_session_id; use golden_halo2curves::golden_group::Secp256k1GoldenGroup; use miden_node_store::genesis::GenesisBlock; use miden_node_utils::genesis::read_genesis_block; -use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey; +use miden_protocol::Word; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::crypto::hash::rpo::Rpo256; use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_validator::ValidatorSigner; use rand_core_06::OsRng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; +use super::ValidatorSigningKey; + type StorageGroup = Secp256k1GoldenGroup; type StorageScalar = ::Scalar; const REGISTRATION_VERSION: &str = "miden-golden-dkg-registration-v1"; const MANIFEST_VERSION: &str = "miden-golden-dkg-manifest-v1"; +const REGISTRATION_SIGNATURE_DOMAIN: &[u8] = b"miden-golden-dkg-registration-signature-v1"; const IDENTITY_SECRET_MAGIC: &[u8] = b"miden-golden-dkg-identity-v1\0"; const IDENTITY_SECRET_FILE: &str = "identity-secret.wire"; const REGISTRATION_FILE: &str = "registration.toml"; @@ -47,9 +53,13 @@ pub struct GoldenDkgOptions { enum GoldenDkgCommand { /// Generates this validator's DKG identity and public registration. Identity { - /// Hex-encoded validator signing public key committed by genesis. - #[arg(long, value_name = "HEX")] - validator_public_key: String, + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Validator signing key committed by genesis. + #[command(flatten)] + signing_key: ValidatorSigningKey, /// New directory that receives the identity and registration files. #[arg(long, value_name = "DIR")] @@ -83,8 +93,10 @@ enum GoldenDkgCommand { #[derive(Debug, Deserialize, Serialize)] struct Registration { version: String, + genesis_commitment: String, validator_public_key: String, dkg_identity_public_key: String, + validator_signature: String, } #[derive(Debug, Deserialize, Serialize)] @@ -109,10 +121,11 @@ struct ManifestParticipant { } /// Runs one Golden DKG ceremony command. -pub fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { +pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { match options.command { - GoldenDkgCommand::Identity { validator_public_key, output_directory } => { - generate_identity(&validator_public_key, &output_directory) + GoldenDkgCommand::Identity { genesis, signing_key, output_directory } => { + let signer = signing_key.into_signer().await?; + generate_identity(&genesis, &signer, &output_directory).await }, GoldenDkgCommand::Prepare { genesis, @@ -125,16 +138,42 @@ pub fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { } /// Generates one validator's private DKG identity and public registration. -fn generate_identity(validator_public_key: &str, output_directory: &Path) -> anyhow::Result<()> { - let validator_public_key = decode_validator_public_key(validator_public_key)?; +async fn generate_identity( + genesis_path: &Path, + signer: &ValidatorSigner, + output_directory: &Path, +) -> anyhow::Result<()> { + let genesis = read_trusted_genesis(genesis_path)?; + let genesis_commitment = genesis.inner().header().commitment(); + let validator_public_key = signer.public_key(); + ensure!( + genesis + .inner() + .header() + .validator_keys() + .as_keys() + .contains(&validator_public_key), + "validator signing key is not committed by genesis", + ); let identity_secret = StorageScalar::random(&mut OsRng); ensure!(!bool::from(identity_secret.is_zero()), "generated a zero DKG identity secret"); let identity_public_key = StorageGroup::mul_generator(&identity_secret); + let signature_commitment = registration_signature_commitment( + genesis_commitment, + &validator_public_key, + &identity_public_key, + ); + let validator_signature = signer + .sign_commitment(signature_commitment) + .await + .context("failed to sign DKG registration")?; let registration = Registration { version: REGISTRATION_VERSION.to_owned(), + genesis_commitment: hex::encode(genesis_commitment.to_bytes()), validator_public_key: hex::encode(validator_public_key.to_bytes()), dkg_identity_public_key: hex::encode(StorageGroup::encode_element(&identity_public_key)), + validator_signature: hex::encode(validator_signature.to_bytes()), }; let registration = toml::to_string_pretty(®istration).context("failed to encode DKG registration")?; @@ -158,8 +197,8 @@ fn prepare( output_directory: &Path, ) -> anyhow::Result<()> { let epoch = decode_fixed_hex::<32>(epoch, "storage-key epoch")?; - let genesis = GenesisBlock::try_from(read_genesis_block(genesis_path)?) - .context("failed to validate genesis block")?; + let genesis = read_trusted_genesis(genesis_path)?; + let genesis_commitment = genesis.inner().header().commitment(); let validator_keys = genesis.inner().header().validator_keys().as_keys(); ensure!( @@ -169,34 +208,13 @@ fn prepare( registration_paths.len(), ); - let mut registrations = BTreeMap::new(); - let mut identity_keys = BTreeSet::new(); - for path in registration_paths { - let registration = read_registration(path)?; - let validator_key = decode_validator_public_key(®istration.validator_public_key)?; - let validator_key_bytes = validator_key.to_bytes(); - let identity_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; - let identity_key_bytes = StorageGroup::encode_element(&identity_key).as_ref().to_vec(); - - ensure!( - identity_keys.insert(identity_key_bytes), - "duplicate DKG identity public key in {}", - path.display(), - ); - ensure!( - registrations - .insert(validator_key_bytes, (registration, identity_key)) - .is_none(), - "duplicate validator registration in {}", - path.display(), - ); - } + let mut registrations = read_validated_registrations(registration_paths, genesis_commitment)?; let mut registry_entries = Vec::with_capacity(validator_keys.len()); let mut participants = Vec::with_capacity(validator_keys.len()); for (offset, validator_key) in validator_keys.iter().enumerate() { let validator_key_hex = hex::encode(validator_key.to_bytes()); - let (_, identity_key) = + let identity_key = registrations.remove(validator_key.to_bytes().as_slice()).with_context(|| { format!("missing registration for genesis validator {validator_key_hex}") })?; @@ -230,7 +248,7 @@ fn prepare( let manifest = Manifest { version: MANIFEST_VERSION.to_owned(), - genesis_commitment: hex::encode(genesis.inner().header().commitment().to_bytes()), + genesis_commitment: hex::encode(genesis_commitment.to_bytes()), threshold, epoch: hex::encode(epoch), beta: hex::encode(beta.to_repr()), @@ -266,6 +284,74 @@ fn read_registration(path: &Path) -> anyhow::Result { Ok(registration) } +/// Reads registrations and verifies their genesis binding and validator signatures. +fn read_validated_registrations( + paths: &[PathBuf], + genesis_commitment: Word, +) -> anyhow::Result, ::Element>> { + let mut registrations = BTreeMap::new(); + let mut identity_keys = BTreeSet::new(); + for path in paths { + let registration = read_registration(path)?; + let validator_key = decode_validator_public_key(®istration.validator_public_key)?; + let identity_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + let signature = decode_validator_signature(®istration.validator_signature)?; + + ensure!( + registration.genesis_commitment == hex::encode(genesis_commitment.to_bytes()), + "registration in {} belongs to a different genesis block", + path.display(), + ); + ensure!( + validator_key.verify( + registration_signature_commitment( + genesis_commitment, + &validator_key, + &identity_key, + ), + &signature, + ), + "invalid validator signature in {}", + path.display(), + ); + ensure!( + identity_keys.insert(StorageGroup::encode_element(&identity_key).as_ref().to_vec()), + "duplicate DKG identity public key in {}", + path.display(), + ); + ensure!( + registrations.insert(validator_key.to_bytes(), identity_key).is_none(), + "duplicate validator registration in {}", + path.display(), + ); + } + Ok(registrations) +} + +/// Reads and validates the trusted genesis block used by the ceremony. +fn read_trusted_genesis(path: &Path) -> anyhow::Result { + GenesisBlock::try_from(read_genesis_block(path)?).context("failed to validate genesis block") +} + +/// Commits a validator signature to one genesis-bound DKG identity registration. +fn registration_signature_commitment( + genesis_commitment: Word, + validator_public_key: &PublicKey, + identity_public_key: &::Element, +) -> Word { + let mut bytes = Vec::with_capacity( + REGISTRATION_SIGNATURE_DOMAIN.len() + + Word::SERIALIZED_SIZE + + validator_public_key.to_bytes().len() + + StorageGroup::ELEMENT_REPR_BYTES, + ); + bytes.extend_from_slice(REGISTRATION_SIGNATURE_DOMAIN); + bytes.extend_from_slice(&genesis_commitment.to_bytes()); + bytes.extend_from_slice(&validator_public_key.to_bytes()); + bytes.extend_from_slice(StorageGroup::encode_element(identity_public_key).as_ref()); + Rpo256::hash(&bytes) +} + /// Parses a validator public key and requires its canonical hex form. fn decode_validator_public_key(value: &str) -> anyhow::Result { let bytes = decode_hex(value, "validator public key")?; @@ -274,6 +360,14 @@ fn decode_validator_public_key(value: &str) -> anyhow::Result { Ok(public_key) } +/// Parses a canonical validator registration signature. +fn decode_validator_signature(value: &str) -> anyhow::Result { + let bytes = decode_hex(value, "validator signature")?; + let signature = Signature::read_from_bytes(&bytes).context("invalid validator signature")?; + ensure!(signature.to_bytes() == bytes, "non-canonical validator signature"); + Ok(signature) +} + /// Parses a non-identity Golden DKG public key. fn decode_identity_public_key( value: &str, @@ -329,8 +423,8 @@ fn publish_directory( .tempdir_in(parent) .context("failed to create temporary output directory")?; write(temporary.path())?; - let temporary = temporary.keep(); - fs_err::rename(&temporary, output_directory).context("failed to publish output directory")?; + fs_err::rename(temporary.path(), output_directory) + .context("failed to publish output directory")?; Ok(()) } @@ -384,9 +478,15 @@ mod tests { type TestResult = Result<(), Box>; - /// Creates a genesis block for three validators and returns their canonical public keys. - fn write_genesis(root: &Path) -> TestResultWith> { - let signing_keys = [SigningKey::new(), SigningKey::new(), SigningKey::new()]; + struct TestGenesis { + path: PathBuf, + signing_keys: Vec, + validator_keys: Vec, + } + + /// Creates a genesis block for three validators. + fn write_genesis(root: &Path) -> TestResultWith { + let signing_keys = vec![SigningKey::new(), SigningKey::new(), SigningKey::new()]; let config = format!( concat!( "version = 1\n", @@ -410,25 +510,38 @@ mod tests { )?; let genesis = GenesisBlock::try_from(read_genesis_block(&genesis_directory.join("genesis.dat"))?)?; - Ok(genesis.inner().header().validator_keys().as_keys().to_vec()) + Ok(TestGenesis { + path: genesis_directory.join("genesis.dat"), + signing_keys, + validator_keys: genesis.inner().header().validator_keys().as_keys().to_vec(), + }) } type TestResultWith = Result>; - #[test] - fn identity_round_trip_matches_public_registration() -> TestResult { + #[tokio::test] + async fn identity_round_trip_matches_public_registration() -> TestResult { let root = tempfile::tempdir()?; - let validator_key = SigningKey::new().public_key(); + let genesis = write_genesis(root.path())?; + let signing_key = genesis.signing_keys[0].clone(); + let validator_key = signing_key.public_key(); + let signer = ValidatorSigner::new_local(signing_key); let output = root.path().join("identity"); - generate_identity(&hex::encode(validator_key.to_bytes()), &output)?; + generate_identity(&genesis.path, &signer, &output).await?; let registration = read_registration(&output.join(REGISTRATION_FILE))?; let secret_bytes = Zeroizing::new(fs_err::read(output.join(IDENTITY_SECRET_FILE))?); let secret = decode_identity_secret(&secret_bytes)?; let public_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + let signature = decode_validator_signature(®istration.validator_signature)?; + let genesis_commitment = read_trusted_genesis(&genesis.path)?.inner().header().commitment(); assert_eq!(StorageGroup::mul_generator(&secret), public_key); assert_eq!(registration.validator_public_key, hex::encode(validator_key.to_bytes())); + assert!(validator_key.verify( + registration_signature_commitment(genesis_commitment, &validator_key, &public_key), + &signature, + )); #[cfg(unix)] { @@ -450,21 +563,25 @@ mod tests { assert!(decode_identity_secret(&zero).is_err()); } - #[test] - fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { + #[tokio::test] + async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { let root = tempfile::tempdir()?; - let validator_keys = write_genesis(root.path())?; + let genesis = write_genesis(root.path())?; let mut registrations = Vec::new(); - for (position, validator_key) in validator_keys.iter().rev().enumerate() { + for (position, signing_key) in genesis.signing_keys.iter().rev().enumerate() { let directory = root.path().join(format!("identity-{position}")); - generate_identity(&hex::encode(validator_key.to_bytes()), &directory)?; + generate_identity( + &genesis.path, + &ValidatorSigner::new_local(signing_key.clone()), + &directory, + ) + .await?; registrations.push(directory.join(REGISTRATION_FILE)); } let output = root.path().join("ceremony"); - let genesis_path = root.path().join("genesis/genesis.dat"); let epoch = "11".repeat(32); - prepare(&genesis_path, 2, &epoch, ®istrations, &output)?; + prepare(&genesis.path, 2, &epoch, ®istrations, &output)?; let manifest: Manifest = toml::from_str(&fs_err::read_to_string(output.join(MANIFEST_FILE))?)?; @@ -482,7 +599,7 @@ mod tests { assert_eq!(decryption.registry, context.registry); assert_eq!(context.session_id, derive_context_session_id(decryption.session_id)); for ((position, participant), validator_key) in - manifest.participants.iter().enumerate().zip(&validator_keys) + manifest.participants.iter().enumerate().zip(&genesis.validator_keys) { assert_eq!(participant.participant_index, u32::try_from(position + 1)?); assert_eq!(participant.validator_public_key, hex::encode(validator_key.to_bytes())); @@ -490,29 +607,53 @@ mod tests { Ok(()) } - #[test] - fn prepare_rejects_registration_outside_genesis() -> TestResult { + #[tokio::test] + async fn identity_rejects_signer_outside_genesis() -> TestResult { let root = tempfile::tempdir()?; - let validator_keys = write_genesis(root.path())?; + let genesis = write_genesis(root.path())?; + let outsider = ValidatorSigner::new_local(SigningKey::new()); + + let error = generate_identity(&genesis.path, &outsider, &root.path().join("identity")) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("not committed by genesis")); + Ok(()) + } + + #[tokio::test] + async fn prepare_rejects_substituted_dkg_identity() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis(root.path())?; let mut registrations = Vec::new(); - for (position, validator_key) in validator_keys.iter().take(2).enumerate() { + for (position, signing_key) in genesis.signing_keys.iter().enumerate() { let directory = root.path().join(format!("identity-{position}")); - generate_identity(&hex::encode(validator_key.to_bytes()), &directory)?; + generate_identity( + &genesis.path, + &ValidatorSigner::new_local(signing_key.clone()), + &directory, + ) + .await?; registrations.push(directory.join(REGISTRATION_FILE)); } - let outsider = root.path().join("identity-outsider"); - generate_identity(&hex::encode(SigningKey::new().public_key().to_bytes()), &outsider)?; - registrations.push(outsider.join(REGISTRATION_FILE)); + let mut registration = read_registration(®istrations[0])?; + let replacement_secret = StorageScalar::random(&mut OsRng); + registration.dkg_identity_public_key = hex::encode(StorageGroup::encode_element( + &StorageGroup::mul_generator(&replacement_secret), + )); + fs_err::write(®istrations[0], toml::to_string_pretty(®istration)?)?; + + let error = prepare( + &genesis.path, + 2, + &"22".repeat(32), + ®istrations, + &root.path().join("ceremony"), + ) + .unwrap_err(); assert!( - prepare( - &root.path().join("genesis/genesis.dat"), - 2, - &"22".repeat(32), - ®istrations, - &root.path().join("ceremony"), - ) - .is_err(), + format!("{error:#}").contains("invalid validator signature"), + "unexpected error: {error:#}", ); Ok(()) } diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index 6189db08c7..dfc917dd49 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -294,7 +294,7 @@ impl ValidatorCommand { .context("failed to apply validator database migrations")?; Ok(()) }, - Self::GoldenDkg(options) => golden_dkg::run(options), + Self::GoldenDkg(options) => golden_dkg::run(options).await, Self::IssuePrivateRecordShare(options) => { issue_private_record_share::issue_from_options(options) }, From 618ca69e0be5d251c6f2a22041c39f8294788daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 18:38:38 -0400 Subject: [PATCH 03/20] refactor(validator): use signature verification form Use the same ECDSA verification call style as the existing validator code. --- bin/validator/src/commands/golden_dkg.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index fe4dd19c01..0c0fb3823a 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -303,13 +303,13 @@ fn read_validated_registrations( path.display(), ); ensure!( - validator_key.verify( + signature.verify( registration_signature_commitment( genesis_commitment, &validator_key, &identity_key, ), - &signature, + &validator_key, ), "invalid validator signature in {}", path.display(), @@ -538,9 +538,9 @@ mod tests { let genesis_commitment = read_trusted_genesis(&genesis.path)?.inner().header().commitment(); assert_eq!(StorageGroup::mul_generator(&secret), public_key); assert_eq!(registration.validator_public_key, hex::encode(validator_key.to_bytes())); - assert!(validator_key.verify( + assert!(signature.verify( registration_signature_commitment(genesis_commitment, &validator_key, &public_key), - &signature, + &validator_key, )); #[cfg(unix)] From d9d4884d6fbe6f507f128bf4a9f3866e4df46677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 18:50:07 -0400 Subject: [PATCH 04/20] feat(validator): complete Golden DKG ceremony Add deal, finalize, and validate stages using the Secp/Secq proof backend. Persist only local private shares, reject mixed transcripts, and publish validated startup bundles. This is a hard cutoff with no compatibility path. --- Cargo.lock | 19 + Cargo.toml | 1 + bin/validator/Cargo.toml | 1 + bin/validator/src/commands/golden_dkg.rs | 986 ++++++++++++++++++++++- 4 files changed, 996 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13629b68ba..cd2fa9cbc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2586,6 +2586,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "golden-evrf" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a46591e30b00cc93a3ca196943b2871d621b39b79672f80170a84bb6b5dd4575" +dependencies = [ + "bulletproofs-cycle", + "ff 0.13.1", + "golden-core", + "golden-halo2curves", + "group 0.13.0", + "halo2curves", + "merlin", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "sha2 0.10.9", +] + [[package]] name = "golden-halo2curves" version = "0.1.0" @@ -4772,6 +4790,7 @@ dependencies = [ "fs-err", "golden-core", "golden-ehtdh1", + "golden-evrf", "golden-halo2curves", "hex", "miden-node-db", diff --git a/Cargo.toml b/Cargo.toml index ef49f17e7f..0bd0c2427e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,7 @@ fs-err = { version = "3" } futures = { version = "0.3" } golden-core = { version = "0.1.0" } golden-ehtdh1 = { version = "0.1.0" } +golden-evrf = { features = ["halo2curves-secp256k1"], version = "0.1.0" } golden-halo2curves = { features = ["halo2curves-secp256k1"], version = "0.1.0" } hex = { version = "0.4" } http = { version = "1.3" } diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index e4b7da3291..2c7e7e40c1 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -28,6 +28,7 @@ clap = { features = ["env", "string"], workspace = true } fs-err = { workspace = true } golden-core = { workspace = true } golden-ehtdh1 = { workspace = true } +golden-evrf = { workspace = true } golden-halo2curves = { workspace = true } hex = { workspace = true } miden-node-db = { workspace = true } diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index 0c0fb3823a..c00fa4de79 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -1,18 +1,28 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::time::Instant; use anyhow::{Context, ensure}; -use golden_core::wire::to_wire_bytes; +use golden_core::wire::{WireMessage, from_wire_bytes as from_core_wire_bytes, to_wire_bytes}; use golden_core::{ + DealerMessage, DkgConfig, + DkgDealing, + EvrfProofBackend, GoldenGroup, GoldenScalar, ParticipantIndex, ParticipantRegistry, SessionId, + Share, + complete, + create_dealing, + create_dealing_with_secret, }; -use golden_ehtdh1::derive_context_session_id; +use golden_ehtdh1::wire::to_wire_bytes as to_ehtdh1_wire_bytes; +use golden_ehtdh1::{SetupContext, derive_context_session_id, material_from_dkg_outputs}; +use golden_evrf::paper::secp_secq::SecpSecqBackend; use golden_halo2curves::golden_group::Secp256k1GoldenGroup; use miden_node_store::genesis::GenesisBlock; use miden_node_utils::genesis::read_genesis_block; @@ -20,8 +30,8 @@ use miden_protocol::Word; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; use miden_protocol::crypto::hash::rpo::Rpo256; use miden_protocol::utils::serde::{Deserializable, Serializable}; -use miden_validator::ValidatorSigner; -use rand_core_06::OsRng; +use miden_validator::{EncodedGoldenOperatorKey, StorageKeyEpoch, ValidatorSigner}; +use rand_core_06::{CryptoRngCore, OsRng}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; @@ -40,6 +50,14 @@ const REGISTRATION_FILE: &str = "registration.toml"; const MANIFEST_FILE: &str = "manifest.toml"; const DECRYPTION_CONFIG_FILE: &str = "decryption-config.wire"; const CONTEXT_CONFIG_FILE: &str = "context-config.wire"; +const DECRYPTION_DEALING_FILE: &str = "decryption-dealing.wire"; +const CONTEXT_DEALING_FILE: &str = "context-dealing.wire"; +const PRIVATE_STATE_FILE: &str = "private-state.wire"; +const PRIVATE_STATE_MAGIC: &[u8] = b"miden-golden-dkg-local-state-v1\0"; +const EPOCH_FILE: &str = "epoch.hex"; +const SETUP_CONTEXT_FILE: &str = "setup-context.wire"; +const PUBLIC_KEY_SET_FILE: &str = "public-key-set.wire"; +const SECRET_SHARE_FILE: &str = "secret-share.wire"; /// Inputs for one Golden DKG ceremony command. #[derive(clap::Args)] @@ -88,6 +106,75 @@ enum GoldenDkgCommand { #[arg(long, value_name = "DIR")] output_directory: PathBuf, }, + + /// Creates this validator's public dealings and private local state. + Deal { + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Directory containing the shared ceremony manifest and configurations. + #[arg(long, value_name = "DIR")] + ceremony_directory: PathBuf, + + /// This validator's private DKG identity file. + #[arg(long, value_name = "FILE")] + identity_secret: PathBuf, + + /// New directory that receives public dealings and private local state. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, + }, + + /// Completes both DKG rounds and writes this validator's startup bundle. + Finalize { + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Directory containing the shared ceremony manifest and configurations. + #[arg(long, value_name = "DIR")] + ceremony_directory: PathBuf, + + /// This validator's private DKG identity file. + #[arg(long, value_name = "FILE")] + identity_secret: PathBuf, + + /// Private state produced by this validator's `deal` command. + #[arg(long, value_name = "FILE")] + private_state: PathBuf, + + /// Public decryption-round dealing. Repeat once per genesis validator. + #[arg(long, required = true, value_name = "FILE")] + decryption_dealing: Vec, + + /// Public context-round dealing. Repeat once per genesis validator. + #[arg(long, required = true, value_name = "FILE")] + context_dealing: Vec, + + /// New directory that receives this validator's startup bundle. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, + }, + + /// Checks one startup bundle against genesis and the ceremony manifest. + Validate { + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Directory containing the shared ceremony manifest and configurations. + #[arg(long, value_name = "DIR")] + ceremony_directory: PathBuf, + + /// Genesis validator public key that owns this bundle. + #[arg(long, value_name = "HEX")] + validator_public_key: String, + + /// Directory containing the final storage-key bundle. + #[arg(long, value_name = "DIR")] + bundle_directory: PathBuf, + }, } #[derive(Debug, Deserialize, Serialize)] @@ -120,6 +207,22 @@ struct ManifestParticipant { dkg_identity_public_key: String, } +struct Ceremony { + manifest: Manifest, + decryption_config: DkgConfig, + context_config: DkgConfig, +} + +struct PrivateState { + participant: ParticipantIndex, + decryption_session_id: SessionId, + context_session_id: SessionId, + decryption_message_sha256: [u8; 32], + context_message_sha256: [u8; 32], + decryption_private_share: StorageScalar, + context_private_share: StorageScalar, +} + /// Runs one Golden DKG ceremony command. pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { match options.command { @@ -134,6 +237,43 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { registration, output_directory, } => prepare(&genesis, threshold, &epoch, ®istration, &output_directory), + GoldenDkgCommand::Deal { + genesis, + ceremony_directory, + identity_secret, + output_directory, + } => deal::( + &genesis, + &ceremony_directory, + &identity_secret, + &output_directory, + &mut OsRng, + ), + GoldenDkgCommand::Finalize { + genesis, + ceremony_directory, + identity_secret, + private_state, + decryption_dealing, + context_dealing, + output_directory, + } => finalize::( + &genesis, + &ceremony_directory, + &identity_secret, + &private_state, + &decryption_dealing, + &context_dealing, + &output_directory, + ), + GoldenDkgCommand::Validate { + genesis, + ceremony_directory, + validator_public_key, + bundle_directory, + } => { + validate_bundle(&genesis, &ceremony_directory, &validator_public_key, &bundle_directory) + }, } } @@ -270,6 +410,231 @@ fn prepare( Ok(()) } +/// Creates this validator's two public dealings and private self shares. +fn deal( + genesis_path: &Path, + ceremony_directory: &Path, + identity_secret_path: &Path, + output_directory: &Path, + rng: &mut impl CryptoRngCore, +) -> anyhow::Result<()> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let ceremony = read_ceremony(genesis_path, ceremony_directory)?; + let identity_secret_bytes = + Zeroizing::new(fs_err::read(identity_secret_path).with_context(|| { + format!("failed to read DKG identity secret {}", identity_secret_path.display()) + })?); + let identity_secret = decode_identity_secret(&identity_secret_bytes)?; + let participant = participant_for_identity(&ceremony.manifest, &identity_secret)?; + + println!("Creating Golden decryption dealing for participant {}.", participant.get()); + let started = Instant::now(); + let decryption = create_dealing::( + participant, + &identity_secret, + &ceremony.decryption_config, + rng, + ) + .context("failed to create decryption dealing")?; + println!( + "Created Golden decryption dealing for participant {} in {:.1?}.", + participant.get(), + started.elapsed(), + ); + + println!("Creating Golden context dealing for participant {}.", participant.get()); + let started = Instant::now(); + let context = create_dealing_with_secret::( + participant, + &identity_secret, + StorageScalar::zero(), + &ceremony.context_config, + rng, + ) + .context("failed to create context dealing")?; + println!( + "Created Golden context dealing for participant {} in {:.1?}.", + participant.get(), + started.elapsed(), + ); + + let decryption_message = to_wire_bytes(&decryption.message); + let context_message = to_wire_bytes(&context.message); + let state = PrivateState { + participant, + decryption_session_id: ceremony.decryption_config.session_id, + context_session_id: ceremony.context_config.session_id, + decryption_message_sha256: sha256(&decryption_message), + context_message_sha256: sha256(&context_message), + decryption_private_share: decryption.private_share.value, + context_private_share: context.private_share.value, + }; + let state = encode_private_state(&state); + + publish_directory(output_directory, |directory| { + write_new_file(&directory.join(DECRYPTION_DEALING_FILE), &decryption_message, false)?; + write_new_file(&directory.join(CONTEXT_DEALING_FILE), &context_message, false)?; + write_new_file(&directory.join(PRIVATE_STATE_FILE), &state, true) + })?; + + println!("Golden DKG dealings written to {}.", output_directory.display()); + Ok(()) +} + +/// Completes both DKG rounds and publishes one validated operator bundle. +fn finalize( + genesis_path: &Path, + ceremony_directory: &Path, + identity_secret_path: &Path, + private_state_path: &Path, + decryption_dealing_paths: &[PathBuf], + context_dealing_paths: &[PathBuf], + output_directory: &Path, +) -> anyhow::Result<()> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let ceremony = read_ceremony(genesis_path, ceremony_directory)?; + let identity_secret_bytes = + Zeroizing::new(fs_err::read(identity_secret_path).with_context(|| { + format!("failed to read DKG identity secret {}", identity_secret_path.display()) + })?); + let identity_secret = decode_identity_secret(&identity_secret_bytes)?; + let participant = participant_for_identity(&ceremony.manifest, &identity_secret)?; + let private_state_bytes = + Zeroizing::new(fs_err::read(private_state_path).with_context(|| { + format!("failed to read private DKG state {}", private_state_path.display()) + })?); + let private_state = decode_private_state(&private_state_bytes)?; + validate_private_state(&private_state, participant, &ceremony)?; + + let decryption_dealings = + read_dealings::(decryption_dealing_paths, ceremony.manifest.participants.len())?; + let context_dealings = + read_dealings::(context_dealing_paths, ceremony.manifest.participants.len())?; + + println!( + "Completing Golden decryption round for participant {} with {} dealings.", + participant.get(), + decryption_dealings.len(), + ); + let started = Instant::now(); + let decryption_output = complete_round::( + participant, + &identity_secret, + &private_state.decryption_private_share, + private_state.decryption_message_sha256, + decryption_dealings, + &ceremony.decryption_config, + ) + .context("failed to complete decryption round")?; + println!( + "Completed Golden decryption round for participant {} in {:.1?}.", + participant.get(), + started.elapsed(), + ); + + println!( + "Completing Golden context round for participant {} with {} dealings.", + participant.get(), + context_dealings.len(), + ); + let started = Instant::now(); + let context_output = complete_round::( + participant, + &identity_secret, + &private_state.context_private_share, + private_state.context_message_sha256, + context_dealings, + &ceremony.context_config, + ) + .context("failed to complete context round")?; + println!( + "Completed Golden context round for participant {} in {:.1?}.", + participant.get(), + started.elapsed(), + ); + + let epoch = decode_fixed_hex::<32>(&ceremony.manifest.epoch, "storage-key epoch")?; + let material = material_from_dkg_outputs( + &ceremony.decryption_config, + &decryption_output, + &ceremony.context_config, + &context_output, + epoch, + ) + .context("failed to bridge DKG outputs to EHTDH1")?; + let setup_context = to_ehtdh1_wire_bytes(&material.setup_context); + let public_key_set = to_ehtdh1_wire_bytes(&material.public_key_set); + let secret_share = Zeroizing::new(to_ehtdh1_wire_bytes(&material.secret_share)); + EncodedGoldenOperatorKey::new( + StorageKeyEpoch::new(epoch), + setup_context.clone(), + public_key_set.clone(), + secret_share.to_vec(), + ) + .decode() + .context("generated invalid Golden operator key")?; + + publish_directory(output_directory, |directory| { + write_new_file(&directory.join(EPOCH_FILE), ceremony.manifest.epoch.as_bytes(), false)?; + write_new_file(&directory.join(SETUP_CONTEXT_FILE), &setup_context, false)?; + write_new_file(&directory.join(PUBLIC_KEY_SET_FILE), &public_key_set, false)?; + write_new_file(&directory.join(SECRET_SHARE_FILE), &secret_share, true) + })?; + println!("Golden storage key bundle written to {}.", output_directory.display()); + Ok(()) +} + +/// Validates one final operator bundle and its genesis owner binding. +fn validate_bundle( + genesis_path: &Path, + ceremony_directory: &Path, + validator_public_key: &str, + bundle_directory: &Path, +) -> anyhow::Result<()> { + let ceremony = read_ceremony(genesis_path, ceremony_directory)?; + let validator_public_key = decode_validator_public_key(validator_public_key)?; + let expected = ceremony + .manifest + .participants + .iter() + .find(|entry| entry.validator_public_key == hex::encode(validator_public_key.to_bytes())) + .context("validator public key is not part of this ceremony")?; + let expected_participant = ParticipantIndex::new(expected.participant_index)?; + let epoch_text = fs_err::read_to_string(bundle_directory.join(EPOCH_FILE)) + .context("failed to read storage-key epoch file")?; + ensure!( + epoch_text == ceremony.manifest.epoch, + "storage-key epoch does not match manifest" + ); + let epoch = decode_fixed_hex::<32>(&epoch_text, "storage-key epoch")?; + let operator_key = EncodedGoldenOperatorKey::new( + StorageKeyEpoch::new(epoch), + fs_err::read(bundle_directory.join(SETUP_CONTEXT_FILE))?, + fs_err::read(bundle_directory.join(PUBLIC_KEY_SET_FILE))?, + fs_err::read(bundle_directory.join(SECRET_SHARE_FILE))?, + ) + .decode() + .context("invalid Golden operator key bundle")?; + ensure!( + operator_key.participant() == expected_participant, + "bundle belongs to participant {}, expected {}", + operator_key.participant().get(), + expected_participant.get(), + ); + validate_setup_context(operator_key.setup_context(), &ceremony)?; + println!( + "Golden storage key bundle is valid for participant {}.", + expected_participant.get(), + ); + Ok(()) +} + /// Reads and validates one public DKG registration. fn read_registration(path: &Path) -> anyhow::Result { let contents = fs_err::read_to_string(path) @@ -328,6 +693,267 @@ fn read_validated_registrations( Ok(registrations) } +/// Reads a ceremony directory and checks every public value against genesis. +fn read_ceremony(genesis_path: &Path, directory: &Path) -> anyhow::Result { + let manifest_path = directory.join(MANIFEST_FILE); + let manifest_text = fs_err::read_to_string(&manifest_path) + .with_context(|| format!("failed to read DKG manifest {}", manifest_path.display()))?; + let manifest: Manifest = + toml::from_str(&manifest_text).context("failed to decode DKG manifest")?; + ensure!(manifest.version == MANIFEST_VERSION, "unsupported DKG manifest version"); + + let genesis = read_trusted_genesis(genesis_path)?; + let genesis_commitment = genesis.inner().header().commitment(); + ensure!( + manifest.genesis_commitment == hex::encode(genesis_commitment.to_bytes()), + "DKG manifest belongs to a different genesis block", + ); + decode_fixed_hex::<32>(&manifest.epoch, "storage-key epoch")?; + + let decryption_bytes = fs_err::read(directory.join(DECRYPTION_CONFIG_FILE)) + .context("failed to read decryption configuration")?; + let context_bytes = fs_err::read(directory.join(CONTEXT_CONFIG_FILE)) + .context("failed to read context configuration")?; + ensure!( + sha256_hex(&decryption_bytes) == manifest.decryption_config_sha256, + "decryption configuration digest does not match manifest", + ); + ensure!( + sha256_hex(&context_bytes) == manifest.context_config_sha256, + "context configuration digest does not match manifest", + ); + let decryption_config = from_core_wire_bytes::>(&decryption_bytes) + .context("invalid decryption configuration")?; + let context_config = from_core_wire_bytes::>(&context_bytes) + .context("invalid context configuration")?; + + ensure!(decryption_config.threshold == manifest.threshold, "threshold mismatch"); + ensure!(context_config.threshold == manifest.threshold, "context threshold mismatch"); + ensure!( + hex::encode(decryption_config.beta.to_repr()) == manifest.beta + && context_config.beta == decryption_config.beta, + "DKG beta mismatch", + ); + ensure!( + hex::encode(decryption_config.session_id.0) == manifest.decryption_session_id, + "decryption session mismatch", + ); + ensure!( + hex::encode(context_config.session_id.0) == manifest.context_session_id + && context_config.session_id == derive_context_session_id(decryption_config.session_id), + "context session mismatch", + ); + ensure!( + context_config.registry.root() == decryption_config.registry.root(), + "registry mismatch between DKG rounds", + ); + + let validator_keys = genesis.inner().header().validator_keys().as_keys(); + ensure!( + manifest.participants.len() == validator_keys.len(), + "manifest participant count does not match genesis", + ); + for (offset, (entry, validator_key)) in + manifest.participants.iter().zip(validator_keys).enumerate() + { + let participant = ParticipantIndex::new( + u32::try_from(offset + 1).context("too many Golden DKG participants")?, + )?; + ensure!(entry.participant_index == participant.get(), "non-canonical participant order"); + ensure!( + entry.validator_public_key == hex::encode(validator_key.to_bytes()), + "manifest validator order does not match genesis", + ); + let expected_identity = decode_identity_public_key(&entry.dkg_identity_public_key)?; + ensure!( + decryption_config.registry.public_key(participant)? == &expected_identity + && context_config.registry.public_key(participant)? == &expected_identity, + "manifest identity does not match DKG registry", + ); + } + + Ok(Ceremony { + manifest, + decryption_config, + context_config, + }) +} + +/// Returns the manifest participant whose public identity matches a secret. +fn participant_for_identity( + manifest: &Manifest, + identity_secret: &StorageScalar, +) -> anyhow::Result { + let public_key = StorageGroup::mul_generator(identity_secret); + let public_key = hex::encode(StorageGroup::encode_element(&public_key)); + let entry = manifest + .participants + .iter() + .find(|entry| entry.dkg_identity_public_key == public_key) + .context("DKG identity is not part of this ceremony")?; + Ok(ParticipantIndex::new(entry.participant_index)?) +} + +/// Reads exactly one public dealing from every ceremony participant. +fn read_dealings( + paths: &[PathBuf], + expected: usize, +) -> anyhow::Result>> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + ensure!(paths.len() == expected, "expected {expected} dealings, got {}", paths.len()); + let mut dealings = BTreeMap::new(); + for path in paths { + let bytes = fs_err::read(path) + .with_context(|| format!("failed to read dealing {}", path.display()))?; + let message = from_core_wire_bytes::>(&bytes) + .with_context(|| format!("invalid dealing {}", path.display()))?; + let dealer = message.dealer; + ensure!( + dealings.insert(dealer, message).is_none(), + "duplicate dealing from participant {}", + dealer.get(), + ); + } + ensure!(dealings.len() == expected, "dealing set is incomplete"); + Ok(dealings) +} + +/// Completes one DKG round from public messages and the local self share. +fn complete_round( + participant: ParticipantIndex, + identity_secret: &StorageScalar, + private_share: &StorageScalar, + expected_own_message_sha256: [u8; 32], + mut dealings: BTreeMap>, + config: &DkgConfig, +) -> anyhow::Result> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let own_message = dealings.remove(&participant).context("missing local dealing")?; + ensure!( + sha256(&to_wire_bytes(&own_message)) == expected_own_message_sha256, + "local dealing does not match private state", + ); + let own_dealing = DkgDealing { + message: own_message, + private_share: Share { participant, value: *private_share }, + }; + Ok(complete::( + participant, + identity_secret, + &own_dealing, + &dealings, + config, + )?) +} + +/// Checks a generated setup context against the public ceremony. +fn validate_setup_context(context: &SetupContext, ceremony: &Ceremony) -> anyhow::Result<()> { + ensure!(context.threshold == ceremony.manifest.threshold, "setup threshold mismatch"); + ensure!( + context.registry_root == ceremony.decryption_config.registry.root(), + "setup registry mismatch", + ); + ensure!( + context.decryption_session_id == ceremony.decryption_config.session_id + && context.context_session_id == ceremony.context_config.session_id, + "setup session mismatch", + ); + ensure!( + context.epoch == decode_fixed_hex::<32>(&ceremony.manifest.epoch, "epoch")?, + "setup epoch mismatch" + ); + let participants = ceremony + .manifest + .participants + .iter() + .map(|entry| ParticipantIndex::new(entry.participant_index)) + .collect::, _>>()?; + ensure!(context.participants == participants, "setup participant mismatch"); + Ok(()) +} + +/// Encodes private self shares with their participant, sessions, and public messages. +fn encode_private_state(state: &PrivateState) -> Zeroizing> { + let mut bytes = Zeroizing::new(Vec::with_capacity( + PRIVATE_STATE_MAGIC.len() + 4 + 4 * 32 + 2 * StorageScalar::REPR_BYTES, + )); + bytes.extend_from_slice(PRIVATE_STATE_MAGIC); + bytes.extend_from_slice(&state.participant.get().to_be_bytes()); + bytes.extend_from_slice(&state.decryption_session_id.0); + bytes.extend_from_slice(&state.context_session_id.0); + bytes.extend_from_slice(&state.decryption_message_sha256); + bytes.extend_from_slice(&state.context_message_sha256); + bytes.extend_from_slice(state.decryption_private_share.to_repr().as_ref()); + bytes.extend_from_slice(state.context_private_share.to_repr().as_ref()); + bytes +} + +/// Decodes private self shares and rejects trailing or non-canonical data. +fn decode_private_state(bytes: &[u8]) -> anyhow::Result { + let mut bytes = bytes + .strip_prefix(PRIVATE_STATE_MAGIC) + .context("invalid private DKG state format")?; + let expected = 4 + 4 * 32 + 2 * StorageScalar::REPR_BYTES; + ensure!(bytes.len() == expected, "invalid private DKG state length"); + let participant = ParticipantIndex::new(u32::from_be_bytes(take_array(&mut bytes)?))?; + let decryption_session_id = SessionId(take_array(&mut bytes)?); + let context_session_id = SessionId(take_array(&mut bytes)?); + let decryption_message_sha256 = take_array(&mut bytes)?; + let context_message_sha256 = take_array(&mut bytes)?; + let decryption_private_share = take_scalar(&mut bytes)?; + let context_private_share = take_scalar(&mut bytes)?; + ensure!(bytes.is_empty(), "trailing private DKG state bytes"); + Ok(PrivateState { + participant, + decryption_session_id, + context_session_id, + decryption_message_sha256, + context_message_sha256, + decryption_private_share, + context_private_share, + }) +} + +/// Checks that private state belongs to this participant and ceremony. +fn validate_private_state( + state: &PrivateState, + participant: ParticipantIndex, + ceremony: &Ceremony, +) -> anyhow::Result<()> { + ensure!( + state.participant == participant, + "private DKG state belongs to another participant" + ); + ensure!( + state.decryption_session_id == ceremony.decryption_config.session_id + && state.context_session_id == ceremony.context_config.session_id, + "private DKG state belongs to another ceremony", + ); + Ok(()) +} + +/// Removes and returns one fixed-size prefix. +fn take_array(bytes: &mut &[u8]) -> anyhow::Result<[u8; N]> { + ensure!(bytes.len() >= N, "truncated private DKG state"); + let (head, tail) = bytes.split_at(N); + *bytes = tail; + Ok(head.try_into().expect("fixed-size slice")) +} + +/// Removes and decodes one canonical scalar. +fn take_scalar(bytes: &mut &[u8]) -> anyhow::Result { + let scalar = take_array::<{ StorageScalar::REPR_BYTES }>(bytes)?; + let repr = ::Repr::try_from(scalar.to_vec()) + .map_err(|_| anyhow::anyhow!("invalid private DKG scalar length"))?; + StorageScalar::from_repr(&repr).context("invalid private DKG scalar") +} + /// Reads and validates the trusted genesis block used by the ceremony. fn read_trusted_genesis(path: &Path) -> anyhow::Result { GenesisBlock::try_from(read_genesis_block(path)?).context("failed to validate genesis block") @@ -394,7 +1020,6 @@ fn encode_identity_secret(secret: &StorageScalar) -> Zeroizing> { } /// Decodes a private DKG identity and rejects malformed or zero scalars. -#[cfg(test)] fn decode_identity_secret(bytes: &[u8]) -> anyhow::Result { let scalar_bytes = bytes .strip_prefix(IDENTITY_SECRET_MAGIC) @@ -466,13 +1091,30 @@ fn decode_fixed_hex(value: &str, name: &str) -> anyhow::Result<[ /// Returns the SHA-256 digest of one public ceremony artifact. fn sha256_hex(bytes: &[u8]) -> String { - hex::encode(Sha256::digest(bytes)) + hex::encode(sha256(bytes)) +} + +/// Returns the SHA-256 digest of one artifact. +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() } #[cfg(test)] mod tests { use golden_core::wire::from_wire_bytes; + use golden_ehtdh1::wire::from_wire_bytes as from_ehtdh1_wire_bytes; + use golden_ehtdh1::{ + Combiner, + PublicKeySet, + SealingKey, + SecretShare, + SetupContext, + UnsealingShare, + }; + use golden_evrf::prototype::ShareOpeningBackend; use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; + use rand_chacha_03::ChaCha20Rng; + use rand_chacha_03::rand_core::SeedableRng; use super::*; @@ -486,18 +1128,29 @@ mod tests { /// Creates a genesis block for three validators. fn write_genesis(root: &Path) -> TestResultWith { - let signing_keys = vec![SigningKey::new(), SigningKey::new(), SigningKey::new()]; + write_genesis_with_validator_count(root, 3) + } + + /// Creates a genesis block with the requested validator count. + fn write_genesis_with_validator_count( + root: &Path, + validator_count: usize, + ) -> TestResultWith { + let signing_keys = (0..validator_count).map(|_| SigningKey::new()).collect::>(); + let validators = signing_keys + .iter() + .map(|key| format!("\"{}\"", hex::encode(key.public_key().to_bytes()))) + .collect::>() + .join(", "); let config = format!( concat!( "version = 1\n", "timestamp = 1717344256\n", - "validators = [\"{}\", \"{}\", \"{}\"]\n", + "validators = [{validators}]\n", "\n[fee_parameters]\n", "verification_base_fee = 0\n", ), - hex::encode(signing_keys[0].public_key().to_bytes()), - hex::encode(signing_keys[1].public_key().to_bytes()), - hex::encode(signing_keys[2].public_key().to_bytes()), + validators = validators, ); let config_path = root.join("genesis.toml"); fs_err::write(&config_path, config)?; @@ -657,4 +1310,315 @@ mod tests { ); Ok(()) } + + struct TestCeremony { + genesis: TestGenesis, + ceremony: PathBuf, + identities: Vec, + } + + /// Creates signed identities and one shared ceremony directory. + async fn prepare_test_ceremony( + root: &Path, + validator_count: usize, + threshold: usize, + ) -> TestResultWith { + let genesis = write_genesis_with_validator_count(root, validator_count)?; + let mut registrations = Vec::new(); + let mut identities = Vec::new(); + for (position, signing_key) in genesis.signing_keys.iter().enumerate() { + let directory = root.join(format!("identity-{position}")); + generate_identity( + &genesis.path, + &ValidatorSigner::new_local(signing_key.clone()), + &directory, + ) + .await?; + registrations.push(directory.join(REGISTRATION_FILE)); + identities.push(directory); + } + let ceremony = root.join("ceremony"); + prepare(&genesis.path, threshold, &"33".repeat(32), ®istrations, &ceremony)?; + Ok(TestCeremony { genesis, ceremony, identities }) + } + + /// Creates both dealings for every validator with the fast proof backend. + fn deal_for_all(root: &Path, ceremony: &TestCeremony) -> TestResultWith> { + let mut rng = ChaCha20Rng::from_seed([41; 32]); + let mut outputs = Vec::new(); + for (position, identity) in ceremony.identities.iter().enumerate() { + let output = root.join(format!("deal-{position}")); + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &identity.join(IDENTITY_SECRET_FILE), + &output, + &mut rng, + )?; + outputs.push(output); + } + Ok(outputs) + } + + /// Returns one named dealing file from every participant directory. + fn dealing_paths(outputs: &[PathBuf], name: &str) -> Vec { + outputs.iter().map(|directory| directory.join(name)).collect() + } + + /// Completes one startup bundle with the fast proof backend. + fn finalize_test_bundle( + root: &Path, + ceremony: &TestCeremony, + dealings: &[PathBuf], + position: usize, + ) -> TestResultWith { + let output = root.join(format!("bundle-{position}")); + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[position].join(IDENTITY_SECRET_FILE), + &dealings[position].join(PRIVATE_STATE_FILE), + &dealing_paths(dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(dealings, CONTEXT_DEALING_FILE), + &output, + )?; + Ok(output) + } + + #[tokio::test] + async fn three_validators_complete_dkg_and_recover_with_any_two_shares() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let mut bundles = Vec::new(); + for position in 0..3 { + let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, position)?; + validate_bundle( + &ceremony.genesis.path, + &ceremony.ceremony, + &hex::encode(ceremony.genesis.signing_keys[position].public_key().to_bytes()), + &bundle, + )?; + bundles.push(bundle); + } + + let shared_setup = fs_err::read(bundles[0].join(SETUP_CONTEXT_FILE))?; + let shared_public_keys = fs_err::read(bundles[0].join(PUBLIC_KEY_SET_FILE))?; + let secret_shares = bundles + .iter() + .map(|bundle| fs_err::read(bundle.join(SECRET_SHARE_FILE))) + .collect::, _>>()?; + assert!(bundles.iter().all(|bundle| { + fs_err::read(bundle.join(SETUP_CONTEXT_FILE)).unwrap() == shared_setup + && fs_err::read(bundle.join(PUBLIC_KEY_SET_FILE)).unwrap() == shared_public_keys + })); + assert_ne!(secret_shares[0], secret_shares[1]); + assert_ne!(secret_shares[1], secret_shares[2]); + + let setup: SetupContext = from_ehtdh1_wire_bytes(&shared_setup)?; + let public_keys: PublicKeySet = from_ehtdh1_wire_bytes(&shared_public_keys)?; + let secret_shares = secret_shares + .iter() + .map(|bytes| from_ehtdh1_wire_bytes::>(bytes)) + .collect::, _>>()?; + let sealing_key = SealingKey::new(public_keys.joint_public_key)?; + let context = b"transaction-inputs/test"; + let content_key = [0x5a; 32]; + let mut rng = ChaCha20Rng::from_seed([42; 32]); + let ciphertext = + sealing_key.seal_bytes_with_associated_data(&mut rng, &content_key, context)?; + let shares = secret_shares + .iter() + .map(|secret| { + UnsealingShare::new(secret.clone()).decrypt_share_with_associated_data( + &mut rng, + &setup, + &ciphertext, + context, + context, + ) + }) + .collect::, _>>()?; + let combiner = Combiner::new(public_keys, setup)?; + for pair in [[0, 1], [0, 2], [1, 2]] { + let recovered = combiner.combine_exact_with_associated_data( + &ciphertext, + context, + context, + &[shares[pair[0]].clone(), shares[pair[1]].clone()], + )?; + assert_eq!(recovered, content_key); + } + Ok(()) + } + + #[tokio::test] + async fn finalize_rejects_incomplete_or_duplicate_dealings() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + let context = dealing_paths(&dealings, CONTEXT_DEALING_FILE); + let output = root.path().join("bundle"); + + assert!( + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &decryption[..2], + &context, + &output, + ) + .is_err() + ); + assert!(!output.exists()); + + let duplicate = vec![decryption[0].clone(), decryption[0].clone(), decryption[2].clone()]; + assert!( + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &duplicate, + &context, + &output, + ) + .is_err() + ); + assert!(!output.exists()); + Ok(()) + } + + #[tokio::test] + async fn finalize_rejects_tampered_dealing_without_partial_output() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let tampered = root.path().join("tampered.wire"); + let mut bytes = fs_err::read(dealings[1].join(DECRYPTION_DEALING_FILE))?; + let offset = bytes.len() / 2; + bytes[offset] ^= 1; + fs_err::write(&tampered, bytes)?; + let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + decryption[1] = tampered; + let output = root.path().join("bundle"); + + assert!( + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &decryption, + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &output, + ) + .is_err() + ); + assert!(!output.exists()); + Ok(()) + } + + #[tokio::test] + async fn private_state_cannot_cross_ceremonies() -> TestResult { + let root = tempfile::tempdir()?; + let first_root = root.path().join("first"); + let second_root = root.path().join("second"); + fs_err::create_dir_all(&first_root)?; + fs_err::create_dir_all(&second_root)?; + let first = prepare_test_ceremony(&first_root, 3, 2).await?; + let first_dealings = deal_for_all(&first_root, &first)?; + + let registrations = first + .identities + .iter() + .map(|identity| identity.join(REGISTRATION_FILE)) + .collect::>(); + let second_ceremony = second_root.join("ceremony"); + prepare(&first.genesis.path, 2, &"44".repeat(32), ®istrations, &second_ceremony)?; + let output = second_root.join("bundle"); + let error = finalize::( + &first.genesis.path, + &second_ceremony, + &first.identities[0].join(IDENTITY_SECRET_FILE), + &first_dealings[0].join(PRIVATE_STATE_FILE), + &dealing_paths(&first_dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&first_dealings, CONTEXT_DEALING_FILE), + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("another ceremony")); + assert!(!output.exists()); + Ok(()) + } + + #[tokio::test] + async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let outsider = root.path().join("outsider.wire"); + fs_err::write(&outsider, encode_identity_secret(&StorageScalar::random(&mut OsRng)))?; + let output = root.path().join("deal"); + let mut rng = ChaCha20Rng::from_seed([43; 32]); + assert!( + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &outsider, + &output, + &mut rng, + ) + .is_err() + ); + assert!(!output.exists()); + + fs_err::create_dir(&output)?; + assert!( + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &output, + &mut rng, + ) + .is_err() + ); + Ok(()) + } + + #[tokio::test] + #[ignore = "slow: runs the concrete Secp/Secq proof backend"] + async fn paper_backend_completes_two_round_ceremony() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 2, 2).await?; + let mut rng = ChaCha20Rng::from_seed([44; 32]); + let mut dealings = Vec::new(); + for (position, identity) in ceremony.identities.iter().enumerate() { + let output = root.path().join(format!("paper-deal-{position}")); + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &identity.join(IDENTITY_SECRET_FILE), + &output, + &mut rng, + )?; + dealings.push(output); + } + for position in 0..2 { + let output = root.path().join(format!("paper-bundle-{position}")); + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[position].join(IDENTITY_SECRET_FILE), + &dealings[position].join(PRIVATE_STATE_FILE), + &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &output, + )?; + } + Ok(()) + } } From 2c6b55a8cf30b50b43f3cabb3cf82bc5a911707a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 19:03:03 -0400 Subject: [PATCH 05/20] fix(validator): require DKG transcript agreement Bind the manifest and both dealing sets into one canonical transcript. Require every genesis validator to sign it before finalization, and retain the accepted transcript with each bundle. --- bin/validator/src/commands/golden_dkg.rs | 615 ++++++++++++++++++++++- 1 file changed, 608 insertions(+), 7 deletions(-) diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index c00fa4de79..71be6e3617 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -16,12 +16,18 @@ use golden_core::{ ParticipantRegistry, SessionId, Share, + TranscriptBuilder, complete, create_dealing, create_dealing_with_secret, }; use golden_ehtdh1::wire::to_wire_bytes as to_ehtdh1_wire_bytes; -use golden_ehtdh1::{SetupContext, derive_context_session_id, material_from_dkg_outputs}; +use golden_ehtdh1::{ + Ehtdh1Material, + SetupContext, + derive_context_session_id, + material_from_dkg_outputs, +}; use golden_evrf::paper::secp_secq::SecpSecqBackend; use golden_halo2curves::golden_group::Secp256k1GoldenGroup; use miden_node_store::genesis::GenesisBlock; @@ -58,6 +64,12 @@ const EPOCH_FILE: &str = "epoch.hex"; const SETUP_CONTEXT_FILE: &str = "setup-context.wire"; const PUBLIC_KEY_SET_FILE: &str = "public-key-set.wire"; const SECRET_SHARE_FILE: &str = "secret-share.wire"; +const TRANSCRIPT_VERSION: &str = "miden-golden-dkg-transcript-v1"; +const TRANSCRIPT_ACCEPTANCE_VERSION: &str = "miden-golden-dkg-transcript-acceptance-v1"; +const TRANSCRIPT_SIGNATURE_DOMAIN: &[u8] = b"miden-golden-dkg-transcript-signature-v1"; +const TRANSCRIPT_FILE: &str = "transcript.toml"; +const TRANSCRIPT_ACCEPTANCE_FILE: &str = "transcript-acceptance.toml"; +const TRANSCRIPT_ACCEPTANCES_FILE: &str = "transcript-acceptances.toml"; /// Inputs for one Golden DKG ceremony command. #[derive(clap::Args)] @@ -126,6 +138,33 @@ enum GoldenDkgCommand { output_directory: PathBuf, }, + /// Signs the common manifest and dealing transcript. + Accept { + /// Trusted genesis block for the network. + #[arg(long, value_name = "FILE")] + genesis: PathBuf, + + /// Directory containing the shared ceremony manifest and configurations. + #[arg(long, value_name = "DIR")] + ceremony_directory: PathBuf, + + /// Validator signing key committed by genesis. + #[command(flatten)] + signing_key: ValidatorSigningKey, + + /// Public decryption-round dealing. Repeat once per genesis validator. + #[arg(long, required = true, value_name = "FILE")] + decryption_dealing: Vec, + + /// Public context-round dealing. Repeat once per genesis validator. + #[arg(long, required = true, value_name = "FILE")] + context_dealing: Vec, + + /// New directory that receives the transcript and this validator's acceptance. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, + }, + /// Completes both DKG rounds and writes this validator's startup bundle. Finalize { /// Trusted genesis block for the network. @@ -152,6 +191,14 @@ enum GoldenDkgCommand { #[arg(long, required = true, value_name = "FILE")] context_dealing: Vec, + /// Canonical transcript accepted by every genesis validator. + #[arg(long, value_name = "FILE")] + transcript: PathBuf, + + /// Signed transcript acceptance. Repeat once per genesis validator. + #[arg(long, required = true, value_name = "FILE")] + transcript_acceptance: Vec, + /// New directory that receives this validator's startup bundle. #[arg(long, value_name = "DIR")] output_directory: PathBuf, @@ -209,6 +256,8 @@ struct ManifestParticipant { struct Ceremony { manifest: Manifest, + manifest_sha256: [u8; 32], + genesis_commitment: Word, decryption_config: DkgConfig, context_config: DkgConfig, } @@ -223,6 +272,35 @@ struct PrivateState { context_private_share: StorageScalar, } +#[derive(Debug, Deserialize, Serialize)] +struct CeremonyTranscript { + version: String, + manifest_sha256: String, + decryption_transcript_root: String, + context_transcript_root: String, + decryption_dealings: Vec, + context_dealings: Vec, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +struct TranscriptDealing { + participant_index: u32, + sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TranscriptAcceptance { + version: String, + validator_public_key: String, + transcript_sha256: String, + validator_signature: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TranscriptAcceptances { + acceptances: Vec, +} + /// Runs one Golden DKG ceremony command. pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { match options.command { @@ -249,6 +327,25 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { &output_directory, &mut OsRng, ), + GoldenDkgCommand::Accept { + genesis, + ceremony_directory, + signing_key, + decryption_dealing, + context_dealing, + output_directory, + } => { + let signer = signing_key.into_signer().await?; + accept_transcript::( + &genesis, + &ceremony_directory, + &signer, + &decryption_dealing, + &context_dealing, + &output_directory, + ) + .await + }, GoldenDkgCommand::Finalize { genesis, ceremony_directory, @@ -256,6 +353,8 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { private_state, decryption_dealing, context_dealing, + transcript, + transcript_acceptance, output_directory, } => finalize::( &genesis, @@ -264,6 +363,8 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { &private_state, &decryption_dealing, &context_dealing, + &transcript, + &transcript_acceptance, &output_directory, ), GoldenDkgCommand::Validate { @@ -484,7 +585,63 @@ where Ok(()) } +/// Signs the exact manifest and public dealings accepted by one validator. +async fn accept_transcript( + genesis_path: &Path, + ceremony_directory: &Path, + signer: &ValidatorSigner, + decryption_dealing_paths: &[PathBuf], + context_dealing_paths: &[PathBuf], + output_directory: &Path, +) -> anyhow::Result<()> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let ceremony = read_ceremony(genesis_path, ceremony_directory)?; + let validator_public_key = signer.public_key(); + ensure!( + ceremony + .manifest + .participants + .iter() + .any(|participant| participant.validator_public_key + == hex::encode(validator_public_key.to_bytes())), + "validator signing key is not part of this ceremony", + ); + let (transcript, transcript_bytes) = + build_transcript::(&ceremony, decryption_dealing_paths, context_dealing_paths)?; + let transcript_sha256 = sha256(&transcript_bytes); + let signature = signer + .sign_commitment(transcript_signature_commitment( + ceremony.genesis_commitment, + transcript_sha256, + )) + .await + .context("failed to sign DKG transcript")?; + let acceptance = TranscriptAcceptance { + version: TRANSCRIPT_ACCEPTANCE_VERSION.to_owned(), + validator_public_key: hex::encode(validator_public_key.to_bytes()), + transcript_sha256: hex::encode(transcript_sha256), + validator_signature: hex::encode(signature.to_bytes()), + }; + let acceptance = + toml::to_string_pretty(&acceptance).context("failed to encode transcript acceptance")?; + debug_assert_eq!(transcript.manifest_sha256, hex::encode(ceremony.manifest_sha256)); + + publish_directory(output_directory, |directory| { + write_new_file(&directory.join(TRANSCRIPT_FILE), &transcript_bytes, false)?; + write_new_file(&directory.join(TRANSCRIPT_ACCEPTANCE_FILE), acceptance.as_bytes(), false) + })?; + println!("Golden DKG transcript accepted in {}.", output_directory.display()); + Ok(()) +} + /// Completes both DKG rounds and publishes one validated operator bundle. +#[expect( + clippy::too_many_arguments, + reason = "the ceremony files stay explicit at the CLI boundary" +)] fn finalize( genesis_path: &Path, ceremony_directory: &Path, @@ -492,6 +649,8 @@ fn finalize( private_state_path: &Path, decryption_dealing_paths: &[PathBuf], context_dealing_paths: &[PathBuf], + transcript_path: &Path, + transcript_acceptance_paths: &[PathBuf], output_directory: &Path, ) -> anyhow::Result<()> where @@ -512,10 +671,29 @@ where let private_state = decode_private_state(&private_state_bytes)?; validate_private_state(&private_state, participant, &ceremony)?; + let (transcript, transcript_bytes) = read_transcript(transcript_path, &ceremony)?; + let acceptances = read_transcript_acceptances( + transcript_acceptance_paths, + &ceremony, + sha256(&transcript_bytes), + )?; + let decryption_dealings = read_dealings::(decryption_dealing_paths, ceremony.manifest.participants.len())?; let context_dealings = read_dealings::(context_dealing_paths, ceremony.manifest.participants.len())?; + validate_dealings_against_transcript::( + &decryption_dealings, + decryption_dealing_paths, + &transcript.decryption_dealings, + &transcript.decryption_transcript_root, + )?; + validate_dealings_against_transcript::( + &context_dealings, + context_dealing_paths, + &transcript.context_dealings, + &transcript.context_transcript_root, + )?; println!( "Completing Golden decryption round for participant {} with {} dealings.", @@ -568,6 +746,26 @@ where epoch, ) .context("failed to bridge DKG outputs to EHTDH1")?; + publish_operator_bundle( + &material, + &ceremony, + &transcript_bytes, + &acceptances, + output_directory, + )?; + println!("Golden storage key bundle written to {}.", output_directory.display()); + Ok(()) +} + +/// Validates and publishes one final Golden operator key bundle. +fn publish_operator_bundle( + material: &Ehtdh1Material, + ceremony: &Ceremony, + transcript_bytes: &[u8], + acceptances: &TranscriptAcceptances, + output_directory: &Path, +) -> anyhow::Result<()> { + let epoch = decode_fixed_hex::<32>(&ceremony.manifest.epoch, "storage-key epoch")?; let setup_context = to_ehtdh1_wire_bytes(&material.setup_context); let public_key_set = to_ehtdh1_wire_bytes(&material.public_key_set); let secret_share = Zeroizing::new(to_ehtdh1_wire_bytes(&material.secret_share)); @@ -584,9 +782,14 @@ where write_new_file(&directory.join(EPOCH_FILE), ceremony.manifest.epoch.as_bytes(), false)?; write_new_file(&directory.join(SETUP_CONTEXT_FILE), &setup_context, false)?; write_new_file(&directory.join(PUBLIC_KEY_SET_FILE), &public_key_set, false)?; - write_new_file(&directory.join(SECRET_SHARE_FILE), &secret_share, true) + write_new_file(&directory.join(SECRET_SHARE_FILE), &secret_share, true)?; + write_new_file(&directory.join(TRANSCRIPT_FILE), transcript_bytes, false)?; + write_new_file( + &directory.join(TRANSCRIPT_ACCEPTANCES_FILE), + toml::to_string_pretty(acceptances)?.as_bytes(), + false, + ) })?; - println!("Golden storage key bundle written to {}.", output_directory.display()); Ok(()) } @@ -598,6 +801,14 @@ fn validate_bundle( bundle_directory: &Path, ) -> anyhow::Result<()> { let ceremony = read_ceremony(genesis_path, ceremony_directory)?; + let transcript_path = bundle_directory.join(TRANSCRIPT_FILE); + let (transcript, transcript_bytes) = read_transcript(&transcript_path, &ceremony)?; + let acceptance_text = + fs_err::read_to_string(bundle_directory.join(TRANSCRIPT_ACCEPTANCES_FILE)) + .context("failed to read transcript acceptances")?; + let acceptances: TranscriptAcceptances = + toml::from_str(&acceptance_text).context("failed to decode transcript acceptances")?; + validate_transcript_acceptances(&acceptances, &ceremony, sha256(&transcript_bytes))?; let validator_public_key = decode_validator_public_key(validator_public_key)?; let expected = ceremony .manifest @@ -628,6 +839,19 @@ fn validate_bundle( expected_participant.get(), ); validate_setup_context(operator_key.setup_context(), &ceremony)?; + ensure!( + operator_key.setup_context().decryption_transcript_root + == decode_fixed_hex::<32>( + &transcript.decryption_transcript_root, + "decryption transcript root", + )? + && operator_key.setup_context().context_transcript_root + == decode_fixed_hex::<32>( + &transcript.context_transcript_root, + "context transcript root", + )?, + "bundle transcript roots do not match accepted transcript", + ); println!( "Golden storage key bundle is valid for participant {}.", expected_participant.get(), @@ -774,6 +998,8 @@ fn read_ceremony(genesis_path: &Path, directory: &Path) -> anyhow::Result( + ceremony: &Ceremony, + decryption_paths: &[PathBuf], + context_paths: &[PathBuf], +) -> anyhow::Result<(CeremonyTranscript, Vec)> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let expected = ceremony.manifest.participants.len(); + let decryption_dealings = read_dealings::(decryption_paths, expected)?; + let context_dealings = read_dealings::(context_paths, expected)?; + let transcript = CeremonyTranscript { + version: TRANSCRIPT_VERSION.to_owned(), + manifest_sha256: hex::encode(ceremony.manifest_sha256), + decryption_transcript_root: hex::encode(completion_root(&decryption_dealings)), + context_transcript_root: hex::encode(completion_root(&context_dealings)), + decryption_dealings: dealing_hashes::(decryption_paths, expected)?, + context_dealings: dealing_hashes::(context_paths, expected)?, + }; + let bytes = toml::to_string_pretty(&transcript) + .context("failed to encode DKG transcript")? + .into_bytes(); + Ok((transcript, bytes)) +} + +/// Reads one canonical transcript and checks its manifest binding. +fn read_transcript( + path: &Path, + ceremony: &Ceremony, +) -> anyhow::Result<(CeremonyTranscript, Vec)> { + let bytes = fs_err::read(path) + .with_context(|| format!("failed to read DKG transcript {}", path.display()))?; + let text = std::str::from_utf8(&bytes).context("DKG transcript is not UTF-8")?; + let transcript: CeremonyTranscript = + toml::from_str(text).context("failed to decode DKG transcript")?; + ensure!(transcript.version == TRANSCRIPT_VERSION, "unsupported DKG transcript version"); + ensure!( + transcript.manifest_sha256 == hex::encode(ceremony.manifest_sha256), + "DKG transcript belongs to another manifest", + ); + decode_fixed_hex::<32>(&transcript.decryption_transcript_root, "decryption transcript root")?; + decode_fixed_hex::<32>(&transcript.context_transcript_root, "context transcript root")?; + let canonical = + toml::to_string_pretty(&transcript).context("failed to encode DKG transcript")?; + ensure!(canonical.as_bytes() == bytes, "non-canonical DKG transcript"); + Ok((transcript, bytes)) +} + +/// Reads, sorts, and verifies every validator's transcript acceptance. +fn read_transcript_acceptances( + paths: &[PathBuf], + ceremony: &Ceremony, + transcript_sha256: [u8; 32], +) -> anyhow::Result { + let mut acceptances = Vec::with_capacity(paths.len()); + for path in paths { + let text = fs_err::read_to_string(path) + .with_context(|| format!("failed to read transcript acceptance {}", path.display()))?; + acceptances.push(toml::from_str(&text).with_context(|| { + format!("failed to decode transcript acceptance {}", path.display()) + })?); + } + let acceptances = TranscriptAcceptances { acceptances }; + validate_transcript_acceptances(&acceptances, ceremony, transcript_sha256)?; + + let by_key = acceptances + .acceptances + .into_iter() + .map(|acceptance| (acceptance.validator_public_key.clone(), acceptance)) + .collect::>(); + let mut ordered = Vec::with_capacity(by_key.len()); + for participant in &ceremony.manifest.participants { + ordered.push( + by_key + .get(&participant.validator_public_key) + .context("missing transcript acceptance")? + .to_owned(), + ); + } + Ok(TranscriptAcceptances { acceptances: ordered }) +} + +/// Verifies unanimous genesis-validator acceptance of one exact transcript. +fn validate_transcript_acceptances( + acceptances: &TranscriptAcceptances, + ceremony: &Ceremony, + transcript_sha256: [u8; 32], +) -> anyhow::Result<()> { + ensure!( + acceptances.acceptances.len() == ceremony.manifest.participants.len(), + "expected {} transcript acceptances, got {}", + ceremony.manifest.participants.len(), + acceptances.acceptances.len(), + ); + let expected_digest = hex::encode(transcript_sha256); + let commitment = + transcript_signature_commitment(ceremony.genesis_commitment, transcript_sha256); + let mut accepted = BTreeSet::new(); + for acceptance in &acceptances.acceptances { + ensure!( + acceptance.version == TRANSCRIPT_ACCEPTANCE_VERSION, + "unsupported transcript acceptance version", + ); + ensure!( + acceptance.transcript_sha256 == expected_digest, + "transcript acceptance belongs to another transcript", + ); + let validator_key = decode_validator_public_key(&acceptance.validator_public_key)?; + let signature = decode_validator_signature(&acceptance.validator_signature)?; + ensure!( + signature.verify(commitment, &validator_key), + "invalid transcript acceptance signature", + ); + ensure!( + accepted.insert(acceptance.validator_public_key.clone()), + "duplicate transcript acceptance", + ); + } + let expected = ceremony + .manifest + .participants + .iter() + .map(|participant| participant.validator_public_key.clone()) + .collect::>(); + ensure!(accepted == expected, "transcript acceptances do not match genesis validators"); + Ok(()) +} + +/// Recomputes one round's canonical dealing hashes and completion root. +fn validate_dealings_against_transcript( + dealings: &BTreeMap>, + paths: &[PathBuf], + expected_hashes: &[TranscriptDealing], + expected_root: &str, +) -> anyhow::Result<()> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + ensure!( + dealing_hashes::(paths, dealings.len())? == expected_hashes, + "dealings do not match accepted transcript", + ); + ensure!( + hex::encode(completion_root(dealings)) == expected_root, + "dealing roots do not match accepted transcript", + ); + Ok(()) +} + +/// Returns canonical hashes for dealing files sorted by participant. +fn dealing_hashes(paths: &[PathBuf], expected: usize) -> anyhow::Result> +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let mut hashes = BTreeMap::new(); + for path in paths { + let bytes = fs_err::read(path) + .with_context(|| format!("failed to read dealing {}", path.display()))?; + let message = from_core_wire_bytes::>(&bytes) + .with_context(|| format!("invalid dealing {}", path.display()))?; + ensure!( + hashes.insert(message.dealer, sha256_hex(&bytes)).is_none(), + "duplicate dealing from participant {}", + message.dealer.get(), + ); + } + ensure!(hashes.len() == expected, "dealing set is incomplete"); + Ok(hashes + .into_iter() + .map(|(participant, sha256)| TranscriptDealing { + participant_index: participant.get(), + sha256, + }) + .collect()) +} + +/// Reproduces Golden's completion transcript root from public dealings. +fn completion_root

( + dealings: &BTreeMap>, +) -> [u8; 32] { + let mut transcript = TranscriptBuilder::with_prefix(b"golden-core-v1", b"completion"); + transcript.bytes(b"backend", StorageGroup::BACKEND_ID.as_bytes()); + transcript.usize(b"dealings-len", dealings.len()); + for (dealer, message) in dealings { + transcript.participant(b"dealer", *dealer); + transcript.bytes(b"dealing-root", &message.transcript_root); + } + transcript.root() +} + +/// Commits a validator signature to one exact public ceremony transcript. +fn transcript_signature_commitment(genesis_commitment: Word, transcript_sha256: [u8; 32]) -> Word { + let mut bytes = Vec::with_capacity( + TRANSCRIPT_SIGNATURE_DOMAIN.len() + Word::SERIALIZED_SIZE + transcript_sha256.len(), + ); + bytes.extend_from_slice(TRANSCRIPT_SIGNATURE_DOMAIN); + bytes.extend_from_slice(&genesis_commitment.to_bytes()); + bytes.extend_from_slice(&transcript_sha256); + Rpo256::hash(&bytes) +} + /// Completes one DKG round from public messages and the local self share. fn complete_round( participant: ParticipantIndex, @@ -1120,6 +1551,7 @@ mod tests { type TestResult = Result<(), Box>; + #[derive(Clone)] struct TestGenesis { path: PathBuf, signing_keys: Vec, @@ -1311,6 +1743,7 @@ mod tests { Ok(()) } + #[derive(Clone)] struct TestCeremony { genesis: TestGenesis, ceremony: PathBuf, @@ -1365,11 +1798,57 @@ mod tests { outputs.iter().map(|directory| directory.join(name)).collect() } + struct AcceptedTranscript { + transcript: PathBuf, + acceptances: Vec, + } + + /// Has every genesis validator sign the same public transcript. + async fn accept_for_all( + root: &Path, + ceremony: &TestCeremony, + dealings: &[PathBuf], + ) -> TestResultWith + where + B: EvrfProofBackend, + B::Proof: WireMessage, + { + let mut outputs = Vec::new(); + for (position, signing_key) in ceremony.genesis.signing_keys.iter().enumerate() { + let output = root.join(format!("accept-{position}")); + accept_transcript::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ValidatorSigner::new_local(signing_key.clone()), + &dealing_paths(dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(dealings, CONTEXT_DEALING_FILE), + &output, + ) + .await?; + outputs.push(output); + } + let transcript = outputs[0].join(TRANSCRIPT_FILE); + let expected = fs_err::read(&transcript)?; + assert!( + outputs + .iter() + .all(|output| fs_err::read(output.join(TRANSCRIPT_FILE)).unwrap() == expected) + ); + Ok(AcceptedTranscript { + transcript, + acceptances: outputs + .iter() + .map(|output| output.join(TRANSCRIPT_ACCEPTANCE_FILE)) + .collect(), + }) + } + /// Completes one startup bundle with the fast proof backend. fn finalize_test_bundle( root: &Path, ceremony: &TestCeremony, dealings: &[PathBuf], + accepted: &AcceptedTranscript, position: usize, ) -> TestResultWith { let output = root.join(format!("bundle-{position}")); @@ -1380,6 +1859,8 @@ mod tests { &dealings[position].join(PRIVATE_STATE_FILE), &dealing_paths(dealings, DECRYPTION_DEALING_FILE), &dealing_paths(dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, &output, )?; Ok(output) @@ -1390,9 +1871,12 @@ mod tests { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; let mut bundles = Vec::new(); for position in 0..3 { - let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, position)?; + let bundle = + finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, position)?; validate_bundle( &ceremony.genesis.path, &ceremony.ceremony, @@ -1457,6 +1941,8 @@ mod tests { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; let decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); let context = dealing_paths(&dealings, CONTEXT_DEALING_FILE); let output = root.path().join("bundle"); @@ -1469,6 +1955,8 @@ mod tests { &dealings[0].join(PRIVATE_STATE_FILE), &decryption[..2], &context, + &accepted.transcript, + &accepted.acceptances, &output, ) .is_err() @@ -1484,6 +1972,8 @@ mod tests { &dealings[0].join(PRIVATE_STATE_FILE), &duplicate, &context, + &accepted.transcript, + &accepted.acceptances, &output, ) .is_err() @@ -1497,6 +1987,8 @@ mod tests { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; let tampered = root.path().join("tampered.wire"); let mut bytes = fs_err::read(dealings[1].join(DECRYPTION_DEALING_FILE))?; let offset = bytes.len() / 2; @@ -1514,6 +2006,8 @@ mod tests { &dealings[0].join(PRIVATE_STATE_FILE), &decryption, &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, &output, ) .is_err() @@ -1522,6 +2016,100 @@ mod tests { Ok(()) } + #[tokio::test] + async fn finalize_rejects_valid_dealer_equivocation() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; + + let alternate = root.path().join("alternate-deal"); + let mut rng = ChaCha20Rng::from_seed([99; 32]); + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[1].join(IDENTITY_SECRET_FILE), + &alternate, + &mut rng, + )?; + let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + decryption[1] = alternate.join(DECRYPTION_DEALING_FILE); + let output = root.path().join("bundle"); + + let error = finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &decryption, + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("accepted transcript")); + assert!(!output.exists()); + Ok(()) + } + + #[tokio::test] + async fn finalize_requires_every_transcript_acceptance() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; + let output = root.path().join("bundle"); + + let error = finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances[..2], + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("expected 3 transcript acceptances")); + assert!(!output.exists()); + Ok(()) + } + + #[tokio::test] + async fn finalize_rejects_manifest_changed_after_acceptance() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; + let manifest_path = ceremony.ceremony.join(MANIFEST_FILE); + let mut manifest: Manifest = toml::from_str(&fs_err::read_to_string(&manifest_path)?)?; + manifest.epoch = "55".repeat(32); + fs_err::write(&manifest_path, toml::to_string_pretty(&manifest)?)?; + let output = root.path().join("bundle"); + + let error = finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("another manifest")); + assert!(!output.exists()); + Ok(()) + } + #[tokio::test] async fn private_state_cannot_cross_ceremonies() -> TestResult { let root = tempfile::tempdir()?; @@ -1539,14 +2127,24 @@ mod tests { .collect::>(); let second_ceremony = second_root.join("ceremony"); prepare(&first.genesis.path, 2, &"44".repeat(32), ®istrations, &second_ceremony)?; + let second = TestCeremony { + genesis: first.genesis.clone(), + ceremony: second_ceremony, + identities: first.identities.clone(), + }; + let second_dealings = deal_for_all(&second_root, &second)?; + let accepted = + accept_for_all::(&second_root, &second, &second_dealings).await?; let output = second_root.join("bundle"); let error = finalize::( &first.genesis.path, - &second_ceremony, + &second.ceremony, &first.identities[0].join(IDENTITY_SECRET_FILE), &first_dealings[0].join(PRIVATE_STATE_FILE), - &dealing_paths(&first_dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(&first_dealings, CONTEXT_DEALING_FILE), + &dealing_paths(&second_dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&second_dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, &output, ) .unwrap_err(); @@ -1607,6 +2205,7 @@ mod tests { )?; dealings.push(output); } + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; for position in 0..2 { let output = root.path().join(format!("paper-bundle-{position}")); finalize::( @@ -1616,6 +2215,8 @@ mod tests { &dealings[position].join(PRIVATE_STATE_FILE), &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, &output, )?; } From c39ff0294f56c90ebb28c0d8eecf668efc857bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 19:09:35 -0400 Subject: [PATCH 06/20] fix(validator): bind DKG public key set Derive the EHTDH1 public key set from accepted Feldman commitments and include its canonical digest in the unanimous transcript. --- bin/validator/src/commands/golden_dkg.rs | 126 ++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index 71be6e3617..a11b1476d4 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -24,6 +24,8 @@ use golden_core::{ use golden_ehtdh1::wire::to_wire_bytes as to_ehtdh1_wire_bytes; use golden_ehtdh1::{ Ehtdh1Material, + PublicKeySet, + PublicShare, SetupContext, derive_context_session_id, material_from_dkg_outputs, @@ -46,6 +48,8 @@ use super::ValidatorSigningKey; type StorageGroup = Secp256k1GoldenGroup; type StorageScalar = ::Scalar; +type StorageElement = ::Element; +type PublicOutput = (StorageElement, BTreeMap); const REGISTRATION_VERSION: &str = "miden-golden-dkg-registration-v1"; const MANIFEST_VERSION: &str = "miden-golden-dkg-manifest-v1"; @@ -278,6 +282,7 @@ struct CeremonyTranscript { manifest_sha256: String, decryption_transcript_root: String, context_transcript_root: String, + public_key_set_sha256: String, decryption_dealings: Vec, context_dealings: Vec, } @@ -749,6 +754,7 @@ where publish_operator_bundle( &material, &ceremony, + &transcript, &transcript_bytes, &acceptances, output_directory, @@ -761,6 +767,7 @@ where fn publish_operator_bundle( material: &Ehtdh1Material, ceremony: &Ceremony, + transcript: &CeremonyTranscript, transcript_bytes: &[u8], acceptances: &TranscriptAcceptances, output_directory: &Path, @@ -768,6 +775,10 @@ fn publish_operator_bundle( let epoch = decode_fixed_hex::<32>(&ceremony.manifest.epoch, "storage-key epoch")?; let setup_context = to_ehtdh1_wire_bytes(&material.setup_context); let public_key_set = to_ehtdh1_wire_bytes(&material.public_key_set); + ensure!( + sha256_hex(&public_key_set) == transcript.public_key_set_sha256, + "generated public key set does not match accepted transcript", + ); let secret_share = Zeroizing::new(to_ehtdh1_wire_bytes(&material.secret_share)); EncodedGoldenOperatorKey::new( StorageKeyEpoch::new(epoch), @@ -824,10 +835,15 @@ fn validate_bundle( "storage-key epoch does not match manifest" ); let epoch = decode_fixed_hex::<32>(&epoch_text, "storage-key epoch")?; + let public_key_set = fs_err::read(bundle_directory.join(PUBLIC_KEY_SET_FILE))?; + ensure!( + sha256_hex(&public_key_set) == transcript.public_key_set_sha256, + "bundle public key set does not match accepted transcript", + ); let operator_key = EncodedGoldenOperatorKey::new( StorageKeyEpoch::new(epoch), fs_err::read(bundle_directory.join(SETUP_CONTEXT_FILE))?, - fs_err::read(bundle_directory.join(PUBLIC_KEY_SET_FILE))?, + public_key_set, fs_err::read(bundle_directory.join(SECRET_SHARE_FILE))?, ) .decode() @@ -1060,11 +1076,17 @@ where let expected = ceremony.manifest.participants.len(); let decryption_dealings = read_dealings::(decryption_paths, expected)?; let context_dealings = read_dealings::(context_paths, expected)?; + let public_key_set = public_key_set_from_dealings( + &decryption_dealings, + &context_dealings, + &ceremony.decryption_config, + )?; let transcript = CeremonyTranscript { version: TRANSCRIPT_VERSION.to_owned(), manifest_sha256: hex::encode(ceremony.manifest_sha256), decryption_transcript_root: hex::encode(completion_root(&decryption_dealings)), context_transcript_root: hex::encode(completion_root(&context_dealings)), + public_key_set_sha256: sha256_hex(&to_ehtdh1_wire_bytes(&public_key_set)), decryption_dealings: dealing_hashes::(decryption_paths, expected)?, context_dealings: dealing_hashes::(context_paths, expected)?, }; @@ -1091,6 +1113,7 @@ fn read_transcript( ); decode_fixed_hex::<32>(&transcript.decryption_transcript_root, "decryption transcript root")?; decode_fixed_hex::<32>(&transcript.context_transcript_root, "context transcript root")?; + decode_fixed_hex::<32>(&transcript.public_key_set_sha256, "public key set digest")?; let canonical = toml::to_string_pretty(&transcript).context("failed to encode DKG transcript")?; ensure!(canonical.as_bytes() == bytes, "non-canonical DKG transcript"); @@ -1227,6 +1250,59 @@ where .collect()) } +/// Derives the EHTDH1 public key set from the accepted Feldman commitments. +fn public_key_set_from_dealings( + decryption: &BTreeMap>, + context: &BTreeMap>, + config: &DkgConfig, +) -> anyhow::Result> { + let (joint_public_key, decryption_shares) = aggregate_public_output(decryption, config)?; + let (context_public_key, context_shares) = aggregate_public_output(context, config)?; + ensure!( + bool::from(StorageGroup::is_identity(&context_public_key)), + "context dealings do not share zero", + ); + let public_shares = config + .registry + .indexes() + .map(|participant| { + Ok(( + participant, + PublicShare { + decryption: *decryption_shares + .get(&participant) + .context("missing decryption public share")?, + context: *context_shares + .get(&participant) + .context("missing context public share")?, + }, + )) + }) + .collect::>>()?; + PublicKeySet::new(config.threshold, joint_public_key, public_shares) + .context("dealings produce an invalid public key set") +} + +/// Aggregates the public key and participant shares from one dealing round. +fn aggregate_public_output

( + dealings: &BTreeMap>, + config: &DkgConfig, +) -> anyhow::Result { + let mut public_key = StorageGroup::identity(); + for message in dealings.values() { + public_key = StorageGroup::add(&public_key, &message.commitment.public_key()); + } + let mut public_shares = BTreeMap::new(); + for participant in config.registry.indexes() { + let mut share = StorageGroup::identity(); + for message in dealings.values() { + share = StorageGroup::add(&share, &message.commitment.public_key_share(participant)?); + } + public_shares.insert(participant, share); + } + Ok((public_key, public_shares)) +} + /// Reproduces Golden's completion transcript root from public dealings. fn completion_root

( dealings: &BTreeMap>, @@ -1777,7 +1853,16 @@ mod tests { /// Creates both dealings for every validator with the fast proof backend. fn deal_for_all(root: &Path, ceremony: &TestCeremony) -> TestResultWith> { - let mut rng = ChaCha20Rng::from_seed([41; 32]); + deal_for_all_with_seed(root, ceremony, [41; 32]) + } + + /// Creates both dealings using one deterministic test seed. + fn deal_for_all_with_seed( + root: &Path, + ceremony: &TestCeremony, + seed: [u8; 32], + ) -> TestResultWith> { + let mut rng = ChaCha20Rng::from_seed(seed); let mut outputs = Vec::new(); for (position, identity) in ceremony.identities.iter().enumerate() { let output = root.join(format!("deal-{position}")); @@ -1936,6 +2021,43 @@ mod tests { Ok(()) } + #[tokio::test] + async fn validate_rejects_an_internally_consistent_substitute_key_set() -> TestResult { + let root = tempfile::tempdir()?; + let alternate_root = root.path().join("alternate"); + fs_err::create_dir(&alternate_root)?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = + accept_for_all::(root.path(), &ceremony, &dealings).await?; + let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, 0)?; + + let alternate_dealings = deal_for_all_with_seed(&alternate_root, &ceremony, [77; 32])?; + let alternate_accepted = + accept_for_all::(&alternate_root, &ceremony, &alternate_dealings) + .await?; + let alternate_bundle = finalize_test_bundle( + &alternate_root, + &ceremony, + &alternate_dealings, + &alternate_accepted, + 0, + )?; + fs_err::copy(alternate_bundle.join(PUBLIC_KEY_SET_FILE), bundle.join(PUBLIC_KEY_SET_FILE))?; + fs_err::copy(alternate_bundle.join(SECRET_SHARE_FILE), bundle.join(SECRET_SHARE_FILE))?; + + let error = validate_bundle( + &ceremony.genesis.path, + &ceremony.ceremony, + &hex::encode(ceremony.genesis.signing_keys[0].public_key().to_bytes()), + &bundle, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("public key set")); + Ok(()) + } + #[tokio::test] async fn finalize_rejects_incomplete_or_duplicate_dealings() -> TestResult { let root = tempfile::tempdir()?; From 422c8c752873cf15c7b056d4e3452220d2abecfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 19:16:36 -0400 Subject: [PATCH 07/20] feat(validator): operationalize Golden DKG bootstrap This is a hard cutoff; no backward compatibility path is included. --- .github/workflows/nightly.yml | 24 +++++ CHANGELOG.md | 1 + bin/validator/src/commands/golden_dkg.rs | 71 ++++++++++++++ compose/bootstrap.yml | 24 +++++ compose/validator.yml | 29 ++---- .../external/src/local-network-development.md | 9 ++ .../network-operator/bootstrap-and-genesis.md | 14 +++ .../src/network-operator/validator.md | 97 +++++++++++++++++++ scripts/run-node.sh | 7 +- .../insecure-golden-storage-key/README.md | 7 +- 10 files changed, 258 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9b62ebd1d1..80a94a2888 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,6 +12,30 @@ permissions: contents: read jobs: + golden-dkg: + name: Golden DKG production backend + runs-on: warp-ubuntu-latest-x64-8x + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: "next" + persist-credentials: false + - name: Cleanup large tools for build space + uses: ./.github/actions/cleanup-runner + - uses: ./.github/actions/install-rocksdb + - uses: ./.github/actions/install-protobuf-compiler + - name: Install rust + run: rustup toolchain install --no-self-update + - uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: nextest@0.9.122 + - name: Run the paper-backed ceremony + run: | + cargo nextest run -p miden-validator \ + paper_backend_completes_two_round_ceremony \ + --run-ignored only + # Run tests on the beta channel to provide feedback for Rust team. beta-test: name: test on beta channel diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e6457240..dedcb521d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added an offline, genesis-bound Golden DKG ceremony for validator storage keys ([#2426](https://github.com/0xMiden/node/issues/2426)). - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). ## v0.15.0 (2026-06-10) diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index a11b1476d4..fa36e03602 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -226,6 +226,17 @@ enum GoldenDkgCommand { #[arg(long, value_name = "DIR")] bundle_directory: PathBuf, }, + + /// Checks a committed local-development fixture against one participant index. + ValidateFixture { + /// Directory containing the four storage-key fixture files. + #[arg(long, value_name = "DIR")] + bundle_directory: PathBuf, + + /// Golden participant index that must own the secret share. + #[arg(long, value_name = "NUM")] + expected_participant: u32, + }, } #[derive(Debug, Deserialize, Serialize)] @@ -380,6 +391,9 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { } => { validate_bundle(&genesis, &ceremony_directory, &validator_public_key, &bundle_directory) }, + GoldenDkgCommand::ValidateFixture { bundle_directory, expected_participant } => { + validate_fixture_bundle(&bundle_directory, expected_participant) + }, } } @@ -875,6 +889,36 @@ fn validate_bundle( Ok(()) } +/// Validates the four-file bundle used by local development fixtures. +fn validate_fixture_bundle( + bundle_directory: &Path, + expected_participant: u32, +) -> anyhow::Result<()> { + let expected_participant = ParticipantIndex::new(expected_participant)?; + let epoch = fs_err::read_to_string(bundle_directory.join(EPOCH_FILE)) + .context("failed to read storage-key epoch file")?; + let epoch = decode_fixed_hex::<32>(&epoch, "storage-key epoch")?; + let operator_key = EncodedGoldenOperatorKey::new( + StorageKeyEpoch::new(epoch), + fs_err::read(bundle_directory.join(SETUP_CONTEXT_FILE))?, + fs_err::read(bundle_directory.join(PUBLIC_KEY_SET_FILE))?, + fs_err::read(bundle_directory.join(SECRET_SHARE_FILE))?, + ) + .decode() + .context("invalid Golden operator key fixture")?; + ensure!( + operator_key.participant() == expected_participant, + "fixture belongs to participant {}, expected {}", + operator_key.participant().get(), + expected_participant.get(), + ); + println!( + "Golden storage key fixture is valid for participant {}.", + expected_participant.get(), + ); + Ok(()) +} + /// Reads and validates one public DKG registration. fn read_registration(path: &Path) -> anyhow::Result { let contents = fs_err::read_to_string(path) @@ -1639,6 +1683,33 @@ mod tests { write_genesis_with_validator_count(root, 3) } + #[test] + fn committed_fixture_has_one_valid_share_per_participant() -> TestResult { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../scripts/testdata/insecure-golden-storage-key"); + let root = tempfile::tempdir()?; + let mut shares = Vec::new(); + + for participant in 1..=3 { + let bundle = root.path().join(format!("validator-{participant}")); + fs_err::create_dir(&bundle)?; + fs_err::write(bundle.join(EPOCH_FILE), "09".repeat(32))?; + fs_err::copy(fixture.join(SETUP_CONTEXT_FILE), bundle.join(SETUP_CONTEXT_FILE))?; + fs_err::copy(fixture.join(PUBLIC_KEY_SET_FILE), bundle.join(PUBLIC_KEY_SET_FILE))?; + fs_err::copy( + fixture.join(format!("validator-{participant}/{SECRET_SHARE_FILE}")), + bundle.join(SECRET_SHARE_FILE), + )?; + validate_fixture_bundle(&bundle, participant)?; + shares.push(fs_err::read(bundle.join(SECRET_SHARE_FILE))?); + } + + assert_ne!(shares[0], shares[1]); + assert_ne!(shares[1], shares[2]); + assert_ne!(shares[0], shares[2]); + Ok(()) + } + /// Creates a genesis block with the requested validator count. fn write_genesis_with_validator_count( root: &Path, diff --git a/compose/bootstrap.yml b/compose/bootstrap.yml index b4290821cd..057cb761c2 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -5,6 +5,18 @@ services: configs: - source: genesis target: /genesis.toml + - source: validator-storage-key-setup-context + target: /fixtures/storage-key/setup-context.wire + - source: validator-storage-key-public-set + target: /fixtures/storage-key/public-key-set.wire + - source: validator-1-storage-key-secret-share + target: /fixtures/storage-key/validator-1/secret-share.wire + - source: validator-2-storage-key-secret-share + target: /fixtures/storage-key/validator-2/secret-share.wire + - source: validator-3-storage-key-secret-share + target: /fixtures/storage-key/validator-3/secret-share.wire + environment: + MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: ${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH:-0909090909090909090909090909090909090909090909090909090909090909} volumes: - node-data:/data entrypoint: ["/bin/sh", "-c"] @@ -36,6 +48,18 @@ services: miden-validator bootstrap \ --data-directory "/data/validators/$${VALIDATOR}" \ --genesis /data/genesis/genesis.dat + + STORAGE_KEY="/data/validators/$${VALIDATOR}/storage-key" + mkdir -p "$${STORAGE_KEY}" + printf '%s' "$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" > "$${STORAGE_KEY}/epoch.hex" + cp /fixtures/storage-key/setup-context.wire "$${STORAGE_KEY}/setup-context.wire" + cp /fixtures/storage-key/public-key-set.wire "$${STORAGE_KEY}/public-key-set.wire" + cp "/fixtures/storage-key/validator-$${VALIDATOR}/secret-share.wire" \ + "$${STORAGE_KEY}/secret-share.wire" + chmod 600 "$${STORAGE_KEY}/secret-share.wire" + miden-validator golden-dkg validate-fixture \ + --bundle-directory "$${STORAGE_KEY}" \ + --expected-participant "$${VALIDATOR}" done touch /data/validators/.bootstrapped diff --git a/compose/validator.yml b/compose/validator.yml index 0d1b071395..1aa93454b2 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -3,17 +3,6 @@ x-validator: &validator pull_policy: missing volumes: - node-data:/data - configs: - - source: validator-storage-key-setup-context - target: /storage-key/setup-context.wire - - source: validator-storage-key-public-set - target: /storage-key/public-key-set.wire - - source: validator-1-storage-key-secret-share - target: /storage-key/validator-1/secret-share.wire - - source: validator-2-storage-key-secret-share - target: /storage-key/validator-2/secret-share.wire - - source: validator-3-storage-key-secret-share - target: /storage-key/validator-3/secret-share.wire depends_on: bootstrap-validator: condition: service_completed_successfully @@ -34,9 +23,9 @@ services: MIDEN_VALIDATOR_DATA_DIRECTORY: /data/validators/1 MIDEN_VALIDATOR_SIGNING_KEY: ${MIDEN_VALIDATOR_1_SIGNING_KEY:-0101010101010101010101010101010101010101010101010101010101010101} MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: ${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH:-0909090909090909090909090909090909090909090909090909090909090909} - MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: /storage-key/setup-context.wire - MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: /storage-key/public-key-set.wire - MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: /storage-key/validator-1/secret-share.wire + MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: /data/validators/1/storage-key/setup-context.wire + MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: /data/validators/1/storage-key/public-key-set.wire + MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: /data/validators/1/storage-key/secret-share.wire OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 OTEL_RESOURCE_ATTRIBUTES: service.instance.id=validator-1 @@ -48,9 +37,9 @@ services: MIDEN_VALIDATOR_DATA_DIRECTORY: /data/validators/2 MIDEN_VALIDATOR_SIGNING_KEY: ${MIDEN_VALIDATOR_2_SIGNING_KEY:-0303030303030303030303030303030303030303030303030303030303030303} MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: ${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH:-0909090909090909090909090909090909090909090909090909090909090909} - MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: /storage-key/setup-context.wire - MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: /storage-key/public-key-set.wire - MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: /storage-key/validator-2/secret-share.wire + MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: /data/validators/2/storage-key/setup-context.wire + MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: /data/validators/2/storage-key/public-key-set.wire + MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: /data/validators/2/storage-key/secret-share.wire OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 OTEL_RESOURCE_ATTRIBUTES: service.instance.id=validator-2 @@ -62,9 +51,9 @@ services: MIDEN_VALIDATOR_DATA_DIRECTORY: /data/validators/3 MIDEN_VALIDATOR_SIGNING_KEY: ${MIDEN_VALIDATOR_3_SIGNING_KEY:-0404040404040404040404040404040404040404040404040404040404040404} MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: ${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH:-0909090909090909090909090909090909090909090909090909090909090909} - MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: /storage-key/setup-context.wire - MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: /storage-key/public-key-set.wire - MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: /storage-key/validator-3/secret-share.wire + MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: /data/validators/3/storage-key/setup-context.wire + MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: /data/validators/3/storage-key/public-key-set.wire + MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: /data/validators/3/storage-key/secret-share.wire OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 OTEL_RESOURCE_ATTRIBUTES: service.instance.id=validator-3 diff --git a/docs/external/src/local-network-development.md b/docs/external/src/local-network-development.md index 626fd628f4..8a30b120da 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -228,6 +228,15 @@ chain data before starting with a different genesis configuration: make local-network-delete ``` +## Golden Storage Key Fixture + +The Compose bootstrap service stages a committed, insecure two-of-three Golden fixture in each validator data directory. +It gives each validator its own secret share and validates the expected participant index before marking bootstrap +complete. The running validators read only their staged paths; they do not run the production DKG ceremony. + +The fixture is public test data. Never use it outside local development. Run the +[Golden storage key ceremony](./network-operator/validator.md#golden-storage-key-setup) for a real network. + ## Check the RPC API The RPC server exposes gRPC reflection. With `grpcurl` installed, a basic status check looks like: diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index 45dc1289dc..ab067a6f33 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -149,6 +149,20 @@ miden-ntx-builder bootstrap \ The key each validator operator starts their validator with must match the public key committed for them in the genesis configuration's `validators` list. +## Golden Storage Key Ceremony + +After genesis is built, every listed validator must join one offline Golden DKG ceremony. The ceremony creates the +shared public storage key and one distinct secret share per validator. No coordinator can derive those shares. + +Each operator first registers a fresh DKG identity with the validator signing key committed in genesis. One coordinator +uses every signed registration to prepare the common ceremony. Every operator then creates two public dealings, checks +and signs the same full transcript, and completes both rounds locally. The DKG and database bootstrap may run in either +order, but both must finish before the validator starts. + +All listed validators must contribute to the ceremony even when the recovery threshold is lower. If any participant +drops out or any transcript differs, discard the incomplete ceremony and start a new one with fresh identities and +sessions. See [Golden storage key setup](./validator.md#golden-storage-key-setup) for the commands and file rules. + Bootstrap takes no transaction encryption key: that key is configured separately when the validator is started, and nothing cross-checks it against the genesis block. A validator started without one falls back to a publicly known insecure default, which after bootstrap means every submission on the network is encrypted to a key anyone can read. See diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 8248825eb4..dadfcd8f26 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -28,6 +28,103 @@ the block header, this next-key commitment is authenticated by the existing vali rotation safe: the network can verify that the next validator key was authorized by the validator that signed the current block. +## Golden Storage Key Setup + +The Golden DKG creates the storage key used to re-encrypt validated private inputs. Run one ceremony for the validator +set committed in genesis. Participant indexes follow the order of validator signing keys in the genesis block. + +First, each operator creates a DKG identity and sends `registration.toml` to the coordinator. The signing key must match +one key in genesis. Use `--signing-key.hex` instead of KMS only for local or private deployments. + +```bash +miden-validator golden-dkg identity \ + --genesis genesis.dat \ + --signing-key.kms-id \ + --output-directory identity +``` + +The coordinator collects every registration and prepares one common ceremony directory. + +```bash +miden-validator golden-dkg prepare \ + --genesis genesis.dat \ + --threshold 2 \ + --epoch <32-byte-hex-epoch> \ + --registration validator-1-registration.toml \ + --registration validator-2-registration.toml \ + --registration validator-3-registration.toml \ + --output-directory ceremony +``` + +Each operator checks the ceremony directory over the authenticated bootstrap channel, then creates its dealings. + +```bash +miden-validator golden-dkg deal \ + --genesis genesis.dat \ + --ceremony-directory ceremony \ + --identity-secret identity/identity-secret.wire \ + --output-directory dealing +``` + +After all dealings are exchanged, every operator signs the same transcript. Repeat both dealing options once per +validator. + +```bash +miden-validator golden-dkg accept \ + --genesis genesis.dat \ + --ceremony-directory ceremony \ + --signing-key.kms-id \ + --decryption-dealing validator-1-decryption-dealing.wire \ + --decryption-dealing validator-2-decryption-dealing.wire \ + --decryption-dealing validator-3-decryption-dealing.wire \ + --context-dealing validator-1-context-dealing.wire \ + --context-dealing validator-2-context-dealing.wire \ + --context-dealing validator-3-context-dealing.wire \ + --output-directory acceptance +``` + +Compare `transcript.toml` byte for byte across all operators. Collect one signed `transcript-acceptance.toml` from each +operator. Each operator can then create and validate its own startup bundle. + +```bash +miden-validator golden-dkg finalize \ + --genesis genesis.dat \ + --ceremony-directory ceremony \ + --identity-secret identity/identity-secret.wire \ + --private-state dealing/private-state.wire \ + --decryption-dealing validator-1-decryption-dealing.wire \ + --decryption-dealing validator-2-decryption-dealing.wire \ + --decryption-dealing validator-3-decryption-dealing.wire \ + --context-dealing validator-1-context-dealing.wire \ + --context-dealing validator-2-context-dealing.wire \ + --context-dealing validator-3-context-dealing.wire \ + --transcript transcript.toml \ + --transcript-acceptance validator-1-transcript-acceptance.toml \ + --transcript-acceptance validator-2-transcript-acceptance.toml \ + --transcript-acceptance validator-3-transcript-acceptance.toml \ + --output-directory storage-key + +miden-validator golden-dkg validate \ + --genesis genesis.dat \ + --ceremony-directory ceremony \ + --validator-public-key \ + --bundle-directory storage-key +``` + +The files have these handling rules: + +| Files | Handling | +| ---------------------------------------------------------------------- | ------------------------------------------------------------ | +| `registration.toml`, `manifest.toml`, and both DKG configuration files | Public; send through an authenticated channel. | +| Both dealing files, `transcript.toml`, and transcript acceptances | Public; send through an authenticated channel. | +| `identity-secret.wire` and `private-state.wire` | Private to one operator; never send. | +| `epoch.hex`, `setup-context.wire`, and `public-key-set.wire` | Public final output; all operators must get identical bytes. | +| `secret-share.wire` | Private final output; each operator gets a different share. | + +Every operator must confirm matching public output hashes before activation. Once the final bundle is secured, +`identity-secret.wire` and `private-state.wire` are no longer needed. A failed ceremony cannot resume with a partial or +changed participant set; start a new ceremony instead. + ## Start ```bash diff --git a/scripts/run-node.sh b/scripts/run-node.sh index 201ad9f394..a2c4d71f86 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -35,7 +35,8 @@ VALIDATOR_STORAGE_KEY_EPOCH="090909090909090909090909090909090909090909090909090 VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY="scripts/testdata/insecure-golden-storage-key" VALIDATOR_INSECURE_STORAGE_KEY_SETUP_CONTEXT="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/setup-context.wire" VALIDATOR_INSECURE_STORAGE_KEY_PUBLIC_KEY_SET="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/public-key-set.wire" -VALIDATOR_INSECURE_STORAGE_KEY_SECRET_SHARE="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/secret-share.wire" +VALIDATOR_1_INSECURE_STORAGE_KEY_SECRET_SHARE="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/validator-1/secret-share.wire" +VALIDATOR_2_INSECURE_STORAGE_KEY_SECRET_SHARE="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/validator-2/secret-share.wire" GENESIS_CONFIG="crates/store/src/genesis/config/samples/01-simple.toml" NODE_DIR="/tmp/node" @@ -194,7 +195,7 @@ echo "Starting validator 1..." --storage-key.epoch "$VALIDATOR_STORAGE_KEY_EPOCH" \ --storage-key.setup-context "$VALIDATOR_INSECURE_STORAGE_KEY_SETUP_CONTEXT" \ --storage-key.public-key-set "$VALIDATOR_INSECURE_STORAGE_KEY_PUBLIC_KEY_SET" \ - --storage-key.secret-share "$VALIDATOR_INSECURE_STORAGE_KEY_SECRET_SHARE" \ + --storage-key.secret-share "$VALIDATOR_1_INSECURE_STORAGE_KEY_SECRET_SHARE" \ $EXTRA_ARGS \ "${KMS_START_ARGS_1[@]}" & PIDS+=($!) @@ -205,7 +206,7 @@ echo "Starting validator 2..." --storage-key.epoch "$VALIDATOR_STORAGE_KEY_EPOCH" \ --storage-key.setup-context "$VALIDATOR_INSECURE_STORAGE_KEY_SETUP_CONTEXT" \ --storage-key.public-key-set "$VALIDATOR_INSECURE_STORAGE_KEY_PUBLIC_KEY_SET" \ - --storage-key.secret-share "$VALIDATOR_INSECURE_STORAGE_KEY_SECRET_SHARE" \ + --storage-key.secret-share "$VALIDATOR_2_INSECURE_STORAGE_KEY_SECRET_SHARE" \ $EXTRA_ARGS \ "${KMS_START_ARGS_2[@]}" & PIDS+=($!) diff --git a/scripts/testdata/insecure-golden-storage-key/README.md b/scripts/testdata/insecure-golden-storage-key/README.md index b912b95f90..4b01eeef23 100644 --- a/scripts/testdata/insecure-golden-storage-key/README.md +++ b/scripts/testdata/insecure-golden-storage-key/README.md @@ -7,8 +7,7 @@ Layout: - `setup-context.wire`, `public-key-set.wire` — the shared public setup, the same for every validator. - `validator-1/secret-share.wire`, `validator-2/secret-share.wire`, `validator-3/secret-share.wire` — each participant's - **distinct** secret share. `compose/validator.yml` mounts this directory into all three validators and points each at - its own share. + **distinct** secret share. The Compose bootstrap service stages only the matching share in each validator's bundle. - `secret-share.wire` — participant 1's share (identical to `validator-1/secret-share.wire`), kept at the top level so single-validator tooling such as the CI benchmark smoke test keeps working unchanged. @@ -18,6 +17,10 @@ impossible even though each validator stores encrypted records. This key is public and must not be used outside tests. +Compose checks each staged bundle with `miden-validator golden-dkg validate-fixture` before it marks the local network +as bootstrapped. This fixture-only check binds the secret share to its expected participant index. Production bundles +must use `miden-validator golden-dkg validate`, which also checks genesis, the ceremony manifest, and signed transcript. + ## Regenerating The fixture is derived deterministically by `bin/validator/src/storage_key.rs` (`tests::values_for`). Regenerate it From d6ab8a6d5fe9a0fde22cb3d1b80eaebf054ff152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 3 Aug 2026 19:20:11 -0400 Subject: [PATCH 08/20] fix(ci): run only the ignored Golden test This is a hard cutoff; no backward compatibility path is included. --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 80a94a2888..18fb5e0f70 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -34,7 +34,7 @@ jobs: run: | cargo nextest run -p miden-validator \ paper_backend_completes_two_round_ceremony \ - --run-ignored only + --run-ignored ignored-only # Run tests on the beta channel to provide feedback for Rust team. beta-test: From 8ec966794b1372b990179d55b44eb4843711aa41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 13:30:31 -0400 Subject: [PATCH 09/20] refactor(validator): split Golden DKG ceremony tests --- bin/validator/src/commands/golden_dkg.rs | 766 +----------------- .../src/commands/golden_dkg/tests.rs | 747 +++++++++++++++++ 2 files changed, 748 insertions(+), 765 deletions(-) create mode 100644 bin/validator/src/commands/golden_dkg/tests.rs diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/golden_dkg.rs index fa36e03602..b0ef8d5ee5 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/golden_dkg.rs @@ -1651,768 +1651,4 @@ fn sha256(bytes: &[u8]) -> [u8; 32] { } #[cfg(test)] -mod tests { - use golden_core::wire::from_wire_bytes; - use golden_ehtdh1::wire::from_wire_bytes as from_ehtdh1_wire_bytes; - use golden_ehtdh1::{ - Combiner, - PublicKeySet, - SealingKey, - SecretShare, - SetupContext, - UnsealingShare, - }; - use golden_evrf::prototype::ShareOpeningBackend; - use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - use rand_chacha_03::ChaCha20Rng; - use rand_chacha_03::rand_core::SeedableRng; - - use super::*; - - type TestResult = Result<(), Box>; - - #[derive(Clone)] - struct TestGenesis { - path: PathBuf, - signing_keys: Vec, - validator_keys: Vec, - } - - /// Creates a genesis block for three validators. - fn write_genesis(root: &Path) -> TestResultWith { - write_genesis_with_validator_count(root, 3) - } - - #[test] - fn committed_fixture_has_one_valid_share_per_participant() -> TestResult { - let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../scripts/testdata/insecure-golden-storage-key"); - let root = tempfile::tempdir()?; - let mut shares = Vec::new(); - - for participant in 1..=3 { - let bundle = root.path().join(format!("validator-{participant}")); - fs_err::create_dir(&bundle)?; - fs_err::write(bundle.join(EPOCH_FILE), "09".repeat(32))?; - fs_err::copy(fixture.join(SETUP_CONTEXT_FILE), bundle.join(SETUP_CONTEXT_FILE))?; - fs_err::copy(fixture.join(PUBLIC_KEY_SET_FILE), bundle.join(PUBLIC_KEY_SET_FILE))?; - fs_err::copy( - fixture.join(format!("validator-{participant}/{SECRET_SHARE_FILE}")), - bundle.join(SECRET_SHARE_FILE), - )?; - validate_fixture_bundle(&bundle, participant)?; - shares.push(fs_err::read(bundle.join(SECRET_SHARE_FILE))?); - } - - assert_ne!(shares[0], shares[1]); - assert_ne!(shares[1], shares[2]); - assert_ne!(shares[0], shares[2]); - Ok(()) - } - - /// Creates a genesis block with the requested validator count. - fn write_genesis_with_validator_count( - root: &Path, - validator_count: usize, - ) -> TestResultWith { - let signing_keys = (0..validator_count).map(|_| SigningKey::new()).collect::>(); - let validators = signing_keys - .iter() - .map(|key| format!("\"{}\"", hex::encode(key.public_key().to_bytes()))) - .collect::>() - .join(", "); - let config = format!( - concat!( - "version = 1\n", - "timestamp = 1717344256\n", - "validators = [{validators}]\n", - "\n[fee_parameters]\n", - "verification_base_fee = 0\n", - ), - validators = validators, - ); - let config_path = root.join("genesis.toml"); - fs_err::write(&config_path, config)?; - let genesis_directory = root.join("genesis"); - let accounts_directory = root.join("accounts"); - super::super::genesis::generate( - &genesis_directory, - &accounts_directory, - Some(&config_path), - )?; - let genesis = - GenesisBlock::try_from(read_genesis_block(&genesis_directory.join("genesis.dat"))?)?; - Ok(TestGenesis { - path: genesis_directory.join("genesis.dat"), - signing_keys, - validator_keys: genesis.inner().header().validator_keys().as_keys().to_vec(), - }) - } - - type TestResultWith = Result>; - - #[tokio::test] - async fn identity_round_trip_matches_public_registration() -> TestResult { - let root = tempfile::tempdir()?; - let genesis = write_genesis(root.path())?; - let signing_key = genesis.signing_keys[0].clone(); - let validator_key = signing_key.public_key(); - let signer = ValidatorSigner::new_local(signing_key); - let output = root.path().join("identity"); - - generate_identity(&genesis.path, &signer, &output).await?; - - let registration = read_registration(&output.join(REGISTRATION_FILE))?; - let secret_bytes = Zeroizing::new(fs_err::read(output.join(IDENTITY_SECRET_FILE))?); - let secret = decode_identity_secret(&secret_bytes)?; - let public_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; - let signature = decode_validator_signature(®istration.validator_signature)?; - let genesis_commitment = read_trusted_genesis(&genesis.path)?.inner().header().commitment(); - assert_eq!(StorageGroup::mul_generator(&secret), public_key); - assert_eq!(registration.validator_public_key, hex::encode(validator_key.to_bytes())); - assert!(signature.verify( - registration_signature_commitment(genesis_commitment, &validator_key, &public_key), - &validator_key, - )); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = - fs_err::metadata(output.join(IDENTITY_SECRET_FILE))?.permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - } - Ok(()) - } - - #[test] - fn identity_secret_rejects_malformed_input() { - assert!(decode_identity_secret(IDENTITY_SECRET_MAGIC).is_err()); - let mut zero = IDENTITY_SECRET_MAGIC.to_vec(); - zero.extend_from_slice(&[0; StorageScalar::REPR_BYTES]); - assert!(decode_identity_secret(&zero).is_err()); - zero.push(0); - assert!(decode_identity_secret(&zero).is_err()); - } - - #[tokio::test] - async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { - let root = tempfile::tempdir()?; - let genesis = write_genesis(root.path())?; - let mut registrations = Vec::new(); - for (position, signing_key) in genesis.signing_keys.iter().rev().enumerate() { - let directory = root.path().join(format!("identity-{position}")); - generate_identity( - &genesis.path, - &ValidatorSigner::new_local(signing_key.clone()), - &directory, - ) - .await?; - registrations.push(directory.join(REGISTRATION_FILE)); - } - let output = root.path().join("ceremony"); - let epoch = "11".repeat(32); - - prepare(&genesis.path, 2, &epoch, ®istrations, &output)?; - - let manifest: Manifest = - toml::from_str(&fs_err::read_to_string(output.join(MANIFEST_FILE))?)?; - let decryption_bytes = fs_err::read(output.join(DECRYPTION_CONFIG_FILE))?; - let context_bytes = fs_err::read(output.join(CONTEXT_CONFIG_FILE))?; - let decryption: DkgConfig = from_wire_bytes(&decryption_bytes)?; - let context: DkgConfig = from_wire_bytes(&context_bytes)?; - - assert_eq!(manifest.threshold, 2); - assert_eq!(manifest.epoch, epoch); - assert_eq!(manifest.decryption_config_sha256, sha256_hex(&decryption_bytes)); - assert_eq!(manifest.context_config_sha256, sha256_hex(&context_bytes)); - assert_eq!(decryption.threshold, 2); - assert_eq!(context.threshold, 2); - assert_eq!(decryption.registry, context.registry); - assert_eq!(context.session_id, derive_context_session_id(decryption.session_id)); - for ((position, participant), validator_key) in - manifest.participants.iter().enumerate().zip(&genesis.validator_keys) - { - assert_eq!(participant.participant_index, u32::try_from(position + 1)?); - assert_eq!(participant.validator_public_key, hex::encode(validator_key.to_bytes())); - } - Ok(()) - } - - #[tokio::test] - async fn identity_rejects_signer_outside_genesis() -> TestResult { - let root = tempfile::tempdir()?; - let genesis = write_genesis(root.path())?; - let outsider = ValidatorSigner::new_local(SigningKey::new()); - - let error = generate_identity(&genesis.path, &outsider, &root.path().join("identity")) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("not committed by genesis")); - Ok(()) - } - - #[tokio::test] - async fn prepare_rejects_substituted_dkg_identity() -> TestResult { - let root = tempfile::tempdir()?; - let genesis = write_genesis(root.path())?; - let mut registrations = Vec::new(); - for (position, signing_key) in genesis.signing_keys.iter().enumerate() { - let directory = root.path().join(format!("identity-{position}")); - generate_identity( - &genesis.path, - &ValidatorSigner::new_local(signing_key.clone()), - &directory, - ) - .await?; - registrations.push(directory.join(REGISTRATION_FILE)); - } - - let mut registration = read_registration(®istrations[0])?; - let replacement_secret = StorageScalar::random(&mut OsRng); - registration.dkg_identity_public_key = hex::encode(StorageGroup::encode_element( - &StorageGroup::mul_generator(&replacement_secret), - )); - fs_err::write(®istrations[0], toml::to_string_pretty(®istration)?)?; - - let error = prepare( - &genesis.path, - 2, - &"22".repeat(32), - ®istrations, - &root.path().join("ceremony"), - ) - .unwrap_err(); - assert!( - format!("{error:#}").contains("invalid validator signature"), - "unexpected error: {error:#}", - ); - Ok(()) - } - - #[derive(Clone)] - struct TestCeremony { - genesis: TestGenesis, - ceremony: PathBuf, - identities: Vec, - } - - /// Creates signed identities and one shared ceremony directory. - async fn prepare_test_ceremony( - root: &Path, - validator_count: usize, - threshold: usize, - ) -> TestResultWith { - let genesis = write_genesis_with_validator_count(root, validator_count)?; - let mut registrations = Vec::new(); - let mut identities = Vec::new(); - for (position, signing_key) in genesis.signing_keys.iter().enumerate() { - let directory = root.join(format!("identity-{position}")); - generate_identity( - &genesis.path, - &ValidatorSigner::new_local(signing_key.clone()), - &directory, - ) - .await?; - registrations.push(directory.join(REGISTRATION_FILE)); - identities.push(directory); - } - let ceremony = root.join("ceremony"); - prepare(&genesis.path, threshold, &"33".repeat(32), ®istrations, &ceremony)?; - Ok(TestCeremony { genesis, ceremony, identities }) - } - - /// Creates both dealings for every validator with the fast proof backend. - fn deal_for_all(root: &Path, ceremony: &TestCeremony) -> TestResultWith> { - deal_for_all_with_seed(root, ceremony, [41; 32]) - } - - /// Creates both dealings using one deterministic test seed. - fn deal_for_all_with_seed( - root: &Path, - ceremony: &TestCeremony, - seed: [u8; 32], - ) -> TestResultWith> { - let mut rng = ChaCha20Rng::from_seed(seed); - let mut outputs = Vec::new(); - for (position, identity) in ceremony.identities.iter().enumerate() { - let output = root.join(format!("deal-{position}")); - deal::( - &ceremony.genesis.path, - &ceremony.ceremony, - &identity.join(IDENTITY_SECRET_FILE), - &output, - &mut rng, - )?; - outputs.push(output); - } - Ok(outputs) - } - - /// Returns one named dealing file from every participant directory. - fn dealing_paths(outputs: &[PathBuf], name: &str) -> Vec { - outputs.iter().map(|directory| directory.join(name)).collect() - } - - struct AcceptedTranscript { - transcript: PathBuf, - acceptances: Vec, - } - - /// Has every genesis validator sign the same public transcript. - async fn accept_for_all( - root: &Path, - ceremony: &TestCeremony, - dealings: &[PathBuf], - ) -> TestResultWith - where - B: EvrfProofBackend, - B::Proof: WireMessage, - { - let mut outputs = Vec::new(); - for (position, signing_key) in ceremony.genesis.signing_keys.iter().enumerate() { - let output = root.join(format!("accept-{position}")); - accept_transcript::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ValidatorSigner::new_local(signing_key.clone()), - &dealing_paths(dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(dealings, CONTEXT_DEALING_FILE), - &output, - ) - .await?; - outputs.push(output); - } - let transcript = outputs[0].join(TRANSCRIPT_FILE); - let expected = fs_err::read(&transcript)?; - assert!( - outputs - .iter() - .all(|output| fs_err::read(output.join(TRANSCRIPT_FILE)).unwrap() == expected) - ); - Ok(AcceptedTranscript { - transcript, - acceptances: outputs - .iter() - .map(|output| output.join(TRANSCRIPT_ACCEPTANCE_FILE)) - .collect(), - }) - } - - /// Completes one startup bundle with the fast proof backend. - fn finalize_test_bundle( - root: &Path, - ceremony: &TestCeremony, - dealings: &[PathBuf], - accepted: &AcceptedTranscript, - position: usize, - ) -> TestResultWith { - let output = root.join(format!("bundle-{position}")); - finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[position].join(IDENTITY_SECRET_FILE), - &dealings[position].join(PRIVATE_STATE_FILE), - &dealing_paths(dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - )?; - Ok(output) - } - - #[tokio::test] - async fn three_validators_complete_dkg_and_recover_with_any_two_shares() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - let mut bundles = Vec::new(); - for position in 0..3 { - let bundle = - finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, position)?; - validate_bundle( - &ceremony.genesis.path, - &ceremony.ceremony, - &hex::encode(ceremony.genesis.signing_keys[position].public_key().to_bytes()), - &bundle, - )?; - bundles.push(bundle); - } - - let shared_setup = fs_err::read(bundles[0].join(SETUP_CONTEXT_FILE))?; - let shared_public_keys = fs_err::read(bundles[0].join(PUBLIC_KEY_SET_FILE))?; - let secret_shares = bundles - .iter() - .map(|bundle| fs_err::read(bundle.join(SECRET_SHARE_FILE))) - .collect::, _>>()?; - assert!(bundles.iter().all(|bundle| { - fs_err::read(bundle.join(SETUP_CONTEXT_FILE)).unwrap() == shared_setup - && fs_err::read(bundle.join(PUBLIC_KEY_SET_FILE)).unwrap() == shared_public_keys - })); - assert_ne!(secret_shares[0], secret_shares[1]); - assert_ne!(secret_shares[1], secret_shares[2]); - - let setup: SetupContext = from_ehtdh1_wire_bytes(&shared_setup)?; - let public_keys: PublicKeySet = from_ehtdh1_wire_bytes(&shared_public_keys)?; - let secret_shares = secret_shares - .iter() - .map(|bytes| from_ehtdh1_wire_bytes::>(bytes)) - .collect::, _>>()?; - let sealing_key = SealingKey::new(public_keys.joint_public_key)?; - let context = b"transaction-inputs/test"; - let content_key = [0x5a; 32]; - let mut rng = ChaCha20Rng::from_seed([42; 32]); - let ciphertext = - sealing_key.seal_bytes_with_associated_data(&mut rng, &content_key, context)?; - let shares = secret_shares - .iter() - .map(|secret| { - UnsealingShare::new(secret.clone()).decrypt_share_with_associated_data( - &mut rng, - &setup, - &ciphertext, - context, - context, - ) - }) - .collect::, _>>()?; - let combiner = Combiner::new(public_keys, setup)?; - for pair in [[0, 1], [0, 2], [1, 2]] { - let recovered = combiner.combine_exact_with_associated_data( - &ciphertext, - context, - context, - &[shares[pair[0]].clone(), shares[pair[1]].clone()], - )?; - assert_eq!(recovered, content_key); - } - Ok(()) - } - - #[tokio::test] - async fn validate_rejects_an_internally_consistent_substitute_key_set() -> TestResult { - let root = tempfile::tempdir()?; - let alternate_root = root.path().join("alternate"); - fs_err::create_dir(&alternate_root)?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, 0)?; - - let alternate_dealings = deal_for_all_with_seed(&alternate_root, &ceremony, [77; 32])?; - let alternate_accepted = - accept_for_all::(&alternate_root, &ceremony, &alternate_dealings) - .await?; - let alternate_bundle = finalize_test_bundle( - &alternate_root, - &ceremony, - &alternate_dealings, - &alternate_accepted, - 0, - )?; - fs_err::copy(alternate_bundle.join(PUBLIC_KEY_SET_FILE), bundle.join(PUBLIC_KEY_SET_FILE))?; - fs_err::copy(alternate_bundle.join(SECRET_SHARE_FILE), bundle.join(SECRET_SHARE_FILE))?; - - let error = validate_bundle( - &ceremony.genesis.path, - &ceremony.ceremony, - &hex::encode(ceremony.genesis.signing_keys[0].public_key().to_bytes()), - &bundle, - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("public key set")); - Ok(()) - } - - #[tokio::test] - async fn finalize_rejects_incomplete_or_duplicate_dealings() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - let decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); - let context = dealing_paths(&dealings, CONTEXT_DEALING_FILE); - let output = root.path().join("bundle"); - - assert!( - finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &dealings[0].join(PRIVATE_STATE_FILE), - &decryption[..2], - &context, - &accepted.transcript, - &accepted.acceptances, - &output, - ) - .is_err() - ); - assert!(!output.exists()); - - let duplicate = vec![decryption[0].clone(), decryption[0].clone(), decryption[2].clone()]; - assert!( - finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &dealings[0].join(PRIVATE_STATE_FILE), - &duplicate, - &context, - &accepted.transcript, - &accepted.acceptances, - &output, - ) - .is_err() - ); - assert!(!output.exists()); - Ok(()) - } - - #[tokio::test] - async fn finalize_rejects_tampered_dealing_without_partial_output() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - let tampered = root.path().join("tampered.wire"); - let mut bytes = fs_err::read(dealings[1].join(DECRYPTION_DEALING_FILE))?; - let offset = bytes.len() / 2; - bytes[offset] ^= 1; - fs_err::write(&tampered, bytes)?; - let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); - decryption[1] = tampered; - let output = root.path().join("bundle"); - - assert!( - finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &dealings[0].join(PRIVATE_STATE_FILE), - &decryption, - &dealing_paths(&dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - ) - .is_err() - ); - assert!(!output.exists()); - Ok(()) - } - - #[tokio::test] - async fn finalize_rejects_valid_dealer_equivocation() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - - let alternate = root.path().join("alternate-deal"); - let mut rng = ChaCha20Rng::from_seed([99; 32]); - deal::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[1].join(IDENTITY_SECRET_FILE), - &alternate, - &mut rng, - )?; - let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); - decryption[1] = alternate.join(DECRYPTION_DEALING_FILE); - let output = root.path().join("bundle"); - - let error = finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &dealings[0].join(PRIVATE_STATE_FILE), - &decryption, - &dealing_paths(&dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("accepted transcript")); - assert!(!output.exists()); - Ok(()) - } - - #[tokio::test] - async fn finalize_requires_every_transcript_acceptance() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - let output = root.path().join("bundle"); - - let error = finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &dealings[0].join(PRIVATE_STATE_FILE), - &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(&dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances[..2], - &output, - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("expected 3 transcript acceptances")); - assert!(!output.exists()); - Ok(()) - } - - #[tokio::test] - async fn finalize_rejects_manifest_changed_after_acceptance() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = - accept_for_all::(root.path(), &ceremony, &dealings).await?; - let manifest_path = ceremony.ceremony.join(MANIFEST_FILE); - let mut manifest: Manifest = toml::from_str(&fs_err::read_to_string(&manifest_path)?)?; - manifest.epoch = "55".repeat(32); - fs_err::write(&manifest_path, toml::to_string_pretty(&manifest)?)?; - let output = root.path().join("bundle"); - - let error = finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &dealings[0].join(PRIVATE_STATE_FILE), - &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(&dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("another manifest")); - assert!(!output.exists()); - Ok(()) - } - - #[tokio::test] - async fn private_state_cannot_cross_ceremonies() -> TestResult { - let root = tempfile::tempdir()?; - let first_root = root.path().join("first"); - let second_root = root.path().join("second"); - fs_err::create_dir_all(&first_root)?; - fs_err::create_dir_all(&second_root)?; - let first = prepare_test_ceremony(&first_root, 3, 2).await?; - let first_dealings = deal_for_all(&first_root, &first)?; - - let registrations = first - .identities - .iter() - .map(|identity| identity.join(REGISTRATION_FILE)) - .collect::>(); - let second_ceremony = second_root.join("ceremony"); - prepare(&first.genesis.path, 2, &"44".repeat(32), ®istrations, &second_ceremony)?; - let second = TestCeremony { - genesis: first.genesis.clone(), - ceremony: second_ceremony, - identities: first.identities.clone(), - }; - let second_dealings = deal_for_all(&second_root, &second)?; - let accepted = - accept_for_all::(&second_root, &second, &second_dealings).await?; - let output = second_root.join("bundle"); - let error = finalize::( - &first.genesis.path, - &second.ceremony, - &first.identities[0].join(IDENTITY_SECRET_FILE), - &first_dealings[0].join(PRIVATE_STATE_FILE), - &dealing_paths(&second_dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(&second_dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("another ceremony")); - assert!(!output.exists()); - Ok(()) - } - - #[tokio::test] - async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let outsider = root.path().join("outsider.wire"); - fs_err::write(&outsider, encode_identity_secret(&StorageScalar::random(&mut OsRng)))?; - let output = root.path().join("deal"); - let mut rng = ChaCha20Rng::from_seed([43; 32]); - assert!( - deal::( - &ceremony.genesis.path, - &ceremony.ceremony, - &outsider, - &output, - &mut rng, - ) - .is_err() - ); - assert!(!output.exists()); - - fs_err::create_dir(&output)?; - assert!( - deal::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[0].join(IDENTITY_SECRET_FILE), - &output, - &mut rng, - ) - .is_err() - ); - Ok(()) - } - - #[tokio::test] - #[ignore = "slow: runs the concrete Secp/Secq proof backend"] - async fn paper_backend_completes_two_round_ceremony() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 2, 2).await?; - let mut rng = ChaCha20Rng::from_seed([44; 32]); - let mut dealings = Vec::new(); - for (position, identity) in ceremony.identities.iter().enumerate() { - let output = root.path().join(format!("paper-deal-{position}")); - deal::( - &ceremony.genesis.path, - &ceremony.ceremony, - &identity.join(IDENTITY_SECRET_FILE), - &output, - &mut rng, - )?; - dealings.push(output); - } - let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; - for position in 0..2 { - let output = root.path().join(format!("paper-bundle-{position}")); - finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[position].join(IDENTITY_SECRET_FILE), - &dealings[position].join(PRIVATE_STATE_FILE), - &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(&dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - )?; - } - Ok(()) - } -} +mod tests; diff --git a/bin/validator/src/commands/golden_dkg/tests.rs b/bin/validator/src/commands/golden_dkg/tests.rs new file mode 100644 index 0000000000..84209e40c2 --- /dev/null +++ b/bin/validator/src/commands/golden_dkg/tests.rs @@ -0,0 +1,747 @@ +use golden_core::wire::from_wire_bytes; +use golden_ehtdh1::wire::from_wire_bytes as from_ehtdh1_wire_bytes; +use golden_ehtdh1::{ + Combiner, + PublicKeySet, + SealingKey, + SecretShare, + SetupContext, + UnsealingShare, +}; +use golden_evrf::prototype::ShareOpeningBackend; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use rand_chacha_03::ChaCha20Rng; +use rand_chacha_03::rand_core::SeedableRng; + +use super::*; + +type TestResult = Result<(), Box>; + +#[derive(Clone)] +struct TestGenesis { + path: PathBuf, + signing_keys: Vec, + validator_keys: Vec, +} + +/// Creates a genesis block for three validators. +fn write_genesis(root: &Path) -> TestResultWith { + write_genesis_with_validator_count(root, 3) +} + +#[test] +fn committed_fixture_has_one_valid_share_per_participant() -> TestResult { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../scripts/testdata/insecure-golden-storage-key"); + let root = tempfile::tempdir()?; + let mut shares = Vec::new(); + + for participant in 1..=3 { + let bundle = root.path().join(format!("validator-{participant}")); + fs_err::create_dir(&bundle)?; + fs_err::write(bundle.join(EPOCH_FILE), "09".repeat(32))?; + fs_err::copy(fixture.join(SETUP_CONTEXT_FILE), bundle.join(SETUP_CONTEXT_FILE))?; + fs_err::copy(fixture.join(PUBLIC_KEY_SET_FILE), bundle.join(PUBLIC_KEY_SET_FILE))?; + fs_err::copy( + fixture.join(format!("validator-{participant}/{SECRET_SHARE_FILE}")), + bundle.join(SECRET_SHARE_FILE), + )?; + validate_fixture_bundle(&bundle, participant)?; + shares.push(fs_err::read(bundle.join(SECRET_SHARE_FILE))?); + } + + assert_ne!(shares[0], shares[1]); + assert_ne!(shares[1], shares[2]); + assert_ne!(shares[0], shares[2]); + Ok(()) +} + +/// Creates a genesis block with the requested validator count. +fn write_genesis_with_validator_count( + root: &Path, + validator_count: usize, +) -> TestResultWith { + let signing_keys = (0..validator_count).map(|_| SigningKey::new()).collect::>(); + let validators = signing_keys + .iter() + .map(|key| format!("\"{}\"", hex::encode(key.public_key().to_bytes()))) + .collect::>() + .join(", "); + let config = format!( + concat!( + "version = 1\n", + "timestamp = 1717344256\n", + "validators = [{validators}]\n", + "\n[fee_parameters]\n", + "verification_base_fee = 0\n", + ), + validators = validators, + ); + let config_path = root.join("genesis.toml"); + fs_err::write(&config_path, config)?; + let genesis_directory = root.join("genesis"); + let accounts_directory = root.join("accounts"); + super::super::genesis::generate(&genesis_directory, &accounts_directory, Some(&config_path))?; + let genesis = + GenesisBlock::try_from(read_genesis_block(&genesis_directory.join("genesis.dat"))?)?; + Ok(TestGenesis { + path: genesis_directory.join("genesis.dat"), + signing_keys, + validator_keys: genesis.inner().header().validator_keys().as_keys().to_vec(), + }) +} + +type TestResultWith = Result>; + +#[tokio::test] +async fn identity_round_trip_matches_public_registration() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis(root.path())?; + let signing_key = genesis.signing_keys[0].clone(); + let validator_key = signing_key.public_key(); + let signer = ValidatorSigner::new_local(signing_key); + let output = root.path().join("identity"); + + generate_identity(&genesis.path, &signer, &output).await?; + + let registration = read_registration(&output.join(REGISTRATION_FILE))?; + let secret_bytes = Zeroizing::new(fs_err::read(output.join(IDENTITY_SECRET_FILE))?); + let secret = decode_identity_secret(&secret_bytes)?; + let public_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + let signature = decode_validator_signature(®istration.validator_signature)?; + let genesis_commitment = read_trusted_genesis(&genesis.path)?.inner().header().commitment(); + assert_eq!(StorageGroup::mul_generator(&secret), public_key); + assert_eq!(registration.validator_public_key, hex::encode(validator_key.to_bytes())); + assert!(signature.verify( + registration_signature_commitment(genesis_commitment, &validator_key, &public_key), + &validator_key, + )); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = + fs_err::metadata(output.join(IDENTITY_SECRET_FILE))?.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + Ok(()) +} + +#[test] +fn identity_secret_rejects_malformed_input() { + assert!(decode_identity_secret(IDENTITY_SECRET_MAGIC).is_err()); + let mut zero = IDENTITY_SECRET_MAGIC.to_vec(); + zero.extend_from_slice(&[0; StorageScalar::REPR_BYTES]); + assert!(decode_identity_secret(&zero).is_err()); + zero.push(0); + assert!(decode_identity_secret(&zero).is_err()); +} + +#[tokio::test] +async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis(root.path())?; + let mut registrations = Vec::new(); + for (position, signing_key) in genesis.signing_keys.iter().rev().enumerate() { + let directory = root.path().join(format!("identity-{position}")); + generate_identity( + &genesis.path, + &ValidatorSigner::new_local(signing_key.clone()), + &directory, + ) + .await?; + registrations.push(directory.join(REGISTRATION_FILE)); + } + let output = root.path().join("ceremony"); + let epoch = "11".repeat(32); + + prepare(&genesis.path, 2, &epoch, ®istrations, &output)?; + + let manifest: Manifest = toml::from_str(&fs_err::read_to_string(output.join(MANIFEST_FILE))?)?; + let decryption_bytes = fs_err::read(output.join(DECRYPTION_CONFIG_FILE))?; + let context_bytes = fs_err::read(output.join(CONTEXT_CONFIG_FILE))?; + let decryption: DkgConfig = from_wire_bytes(&decryption_bytes)?; + let context: DkgConfig = from_wire_bytes(&context_bytes)?; + + assert_eq!(manifest.threshold, 2); + assert_eq!(manifest.epoch, epoch); + assert_eq!(manifest.decryption_config_sha256, sha256_hex(&decryption_bytes)); + assert_eq!(manifest.context_config_sha256, sha256_hex(&context_bytes)); + assert_eq!(decryption.threshold, 2); + assert_eq!(context.threshold, 2); + assert_eq!(decryption.registry, context.registry); + assert_eq!(context.session_id, derive_context_session_id(decryption.session_id)); + for ((position, participant), validator_key) in + manifest.participants.iter().enumerate().zip(&genesis.validator_keys) + { + assert_eq!(participant.participant_index, u32::try_from(position + 1)?); + assert_eq!(participant.validator_public_key, hex::encode(validator_key.to_bytes())); + } + Ok(()) +} + +#[tokio::test] +async fn identity_rejects_signer_outside_genesis() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis(root.path())?; + let outsider = ValidatorSigner::new_local(SigningKey::new()); + + let error = generate_identity(&genesis.path, &outsider, &root.path().join("identity")) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("not committed by genesis")); + Ok(()) +} + +#[tokio::test] +async fn prepare_rejects_substituted_dkg_identity() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis(root.path())?; + let mut registrations = Vec::new(); + for (position, signing_key) in genesis.signing_keys.iter().enumerate() { + let directory = root.path().join(format!("identity-{position}")); + generate_identity( + &genesis.path, + &ValidatorSigner::new_local(signing_key.clone()), + &directory, + ) + .await?; + registrations.push(directory.join(REGISTRATION_FILE)); + } + + let mut registration = read_registration(®istrations[0])?; + let replacement_secret = StorageScalar::random(&mut OsRng); + registration.dkg_identity_public_key = hex::encode(StorageGroup::encode_element( + &StorageGroup::mul_generator(&replacement_secret), + )); + fs_err::write(®istrations[0], toml::to_string_pretty(®istration)?)?; + + let error = prepare( + &genesis.path, + 2, + &"22".repeat(32), + ®istrations, + &root.path().join("ceremony"), + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("invalid validator signature"), + "unexpected error: {error:#}", + ); + Ok(()) +} + +#[derive(Clone)] +struct TestCeremony { + genesis: TestGenesis, + ceremony: PathBuf, + identities: Vec, +} + +/// Creates signed identities and one shared ceremony directory. +async fn prepare_test_ceremony( + root: &Path, + validator_count: usize, + threshold: usize, +) -> TestResultWith { + let genesis = write_genesis_with_validator_count(root, validator_count)?; + let mut registrations = Vec::new(); + let mut identities = Vec::new(); + for (position, signing_key) in genesis.signing_keys.iter().enumerate() { + let directory = root.join(format!("identity-{position}")); + generate_identity( + &genesis.path, + &ValidatorSigner::new_local(signing_key.clone()), + &directory, + ) + .await?; + registrations.push(directory.join(REGISTRATION_FILE)); + identities.push(directory); + } + let ceremony = root.join("ceremony"); + prepare(&genesis.path, threshold, &"33".repeat(32), ®istrations, &ceremony)?; + Ok(TestCeremony { genesis, ceremony, identities }) +} + +/// Creates both dealings for every validator with the fast proof backend. +fn deal_for_all(root: &Path, ceremony: &TestCeremony) -> TestResultWith> { + deal_for_all_with_seed(root, ceremony, [41; 32]) +} + +/// Creates both dealings using one deterministic test seed. +fn deal_for_all_with_seed( + root: &Path, + ceremony: &TestCeremony, + seed: [u8; 32], +) -> TestResultWith> { + let mut rng = ChaCha20Rng::from_seed(seed); + let mut outputs = Vec::new(); + for (position, identity) in ceremony.identities.iter().enumerate() { + let output = root.join(format!("deal-{position}")); + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &identity.join(IDENTITY_SECRET_FILE), + &output, + &mut rng, + )?; + outputs.push(output); + } + Ok(outputs) +} + +/// Returns one named dealing file from every participant directory. +fn dealing_paths(outputs: &[PathBuf], name: &str) -> Vec { + outputs.iter().map(|directory| directory.join(name)).collect() +} + +struct AcceptedTranscript { + transcript: PathBuf, + acceptances: Vec, +} + +/// Has every genesis validator sign the same public transcript. +async fn accept_for_all( + root: &Path, + ceremony: &TestCeremony, + dealings: &[PathBuf], +) -> TestResultWith +where + B: EvrfProofBackend, + B::Proof: WireMessage, +{ + let mut outputs = Vec::new(); + for (position, signing_key) in ceremony.genesis.signing_keys.iter().enumerate() { + let output = root.join(format!("accept-{position}")); + accept_transcript::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ValidatorSigner::new_local(signing_key.clone()), + &dealing_paths(dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(dealings, CONTEXT_DEALING_FILE), + &output, + ) + .await?; + outputs.push(output); + } + let transcript = outputs[0].join(TRANSCRIPT_FILE); + let expected = fs_err::read(&transcript)?; + assert!( + outputs + .iter() + .all(|output| fs_err::read(output.join(TRANSCRIPT_FILE)).unwrap() == expected) + ); + Ok(AcceptedTranscript { + transcript, + acceptances: outputs.iter().map(|output| output.join(TRANSCRIPT_ACCEPTANCE_FILE)).collect(), + }) +} + +/// Completes one startup bundle with the fast proof backend. +fn finalize_test_bundle( + root: &Path, + ceremony: &TestCeremony, + dealings: &[PathBuf], + accepted: &AcceptedTranscript, + position: usize, +) -> TestResultWith { + let output = root.join(format!("bundle-{position}")); + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[position].join(IDENTITY_SECRET_FILE), + &dealings[position].join(PRIVATE_STATE_FILE), + &dealing_paths(dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + )?; + Ok(output) +} + +#[tokio::test] +async fn three_validators_complete_dkg_and_recover_with_any_two_shares() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let mut bundles = Vec::new(); + for position in 0..3 { + let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, position)?; + validate_bundle( + &ceremony.genesis.path, + &ceremony.ceremony, + &hex::encode(ceremony.genesis.signing_keys[position].public_key().to_bytes()), + &bundle, + )?; + bundles.push(bundle); + } + + let shared_setup = fs_err::read(bundles[0].join(SETUP_CONTEXT_FILE))?; + let shared_public_keys = fs_err::read(bundles[0].join(PUBLIC_KEY_SET_FILE))?; + let secret_shares = bundles + .iter() + .map(|bundle| fs_err::read(bundle.join(SECRET_SHARE_FILE))) + .collect::, _>>()?; + assert!(bundles.iter().all(|bundle| { + fs_err::read(bundle.join(SETUP_CONTEXT_FILE)).unwrap() == shared_setup + && fs_err::read(bundle.join(PUBLIC_KEY_SET_FILE)).unwrap() == shared_public_keys + })); + assert_ne!(secret_shares[0], secret_shares[1]); + assert_ne!(secret_shares[1], secret_shares[2]); + + let setup: SetupContext = from_ehtdh1_wire_bytes(&shared_setup)?; + let public_keys: PublicKeySet = from_ehtdh1_wire_bytes(&shared_public_keys)?; + let secret_shares = secret_shares + .iter() + .map(|bytes| from_ehtdh1_wire_bytes::>(bytes)) + .collect::, _>>()?; + let sealing_key = SealingKey::new(public_keys.joint_public_key)?; + let context = b"transaction-inputs/test"; + let content_key = [0x5a; 32]; + let mut rng = ChaCha20Rng::from_seed([42; 32]); + let ciphertext = + sealing_key.seal_bytes_with_associated_data(&mut rng, &content_key, context)?; + let shares = secret_shares + .iter() + .map(|secret| { + UnsealingShare::new(secret.clone()).decrypt_share_with_associated_data( + &mut rng, + &setup, + &ciphertext, + context, + context, + ) + }) + .collect::, _>>()?; + let combiner = Combiner::new(public_keys, setup)?; + for pair in [[0, 1], [0, 2], [1, 2]] { + let recovered = combiner.combine_exact_with_associated_data( + &ciphertext, + context, + context, + &[shares[pair[0]].clone(), shares[pair[1]].clone()], + )?; + assert_eq!(recovered, content_key); + } + Ok(()) +} + +#[tokio::test] +async fn validate_rejects_an_internally_consistent_substitute_key_set() -> TestResult { + let root = tempfile::tempdir()?; + let alternate_root = root.path().join("alternate"); + fs_err::create_dir(&alternate_root)?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, 0)?; + + let alternate_dealings = deal_for_all_with_seed(&alternate_root, &ceremony, [77; 32])?; + let alternate_accepted = + accept_for_all::(&alternate_root, &ceremony, &alternate_dealings) + .await?; + let alternate_bundle = finalize_test_bundle( + &alternate_root, + &ceremony, + &alternate_dealings, + &alternate_accepted, + 0, + )?; + fs_err::copy(alternate_bundle.join(PUBLIC_KEY_SET_FILE), bundle.join(PUBLIC_KEY_SET_FILE))?; + fs_err::copy(alternate_bundle.join(SECRET_SHARE_FILE), bundle.join(SECRET_SHARE_FILE))?; + + let error = validate_bundle( + &ceremony.genesis.path, + &ceremony.ceremony, + &hex::encode(ceremony.genesis.signing_keys[0].public_key().to_bytes()), + &bundle, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("public key set")); + Ok(()) +} + +#[tokio::test] +async fn finalize_rejects_incomplete_or_duplicate_dealings() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + let context = dealing_paths(&dealings, CONTEXT_DEALING_FILE); + let output = root.path().join("bundle"); + + assert!( + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &decryption[..2], + &context, + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .is_err() + ); + assert!(!output.exists()); + + let duplicate = vec![decryption[0].clone(), decryption[0].clone(), decryption[2].clone()]; + assert!( + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &duplicate, + &context, + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .is_err() + ); + assert!(!output.exists()); + Ok(()) +} + +#[tokio::test] +async fn finalize_rejects_tampered_dealing_without_partial_output() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let tampered = root.path().join("tampered.wire"); + let mut bytes = fs_err::read(dealings[1].join(DECRYPTION_DEALING_FILE))?; + let offset = bytes.len() / 2; + bytes[offset] ^= 1; + fs_err::write(&tampered, bytes)?; + let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + decryption[1] = tampered; + let output = root.path().join("bundle"); + + assert!( + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &decryption, + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .is_err() + ); + assert!(!output.exists()); + Ok(()) +} + +#[tokio::test] +async fn finalize_rejects_valid_dealer_equivocation() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + + let alternate = root.path().join("alternate-deal"); + let mut rng = ChaCha20Rng::from_seed([99; 32]); + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[1].join(IDENTITY_SECRET_FILE), + &alternate, + &mut rng, + )?; + let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + decryption[1] = alternate.join(DECRYPTION_DEALING_FILE); + let output = root.path().join("bundle"); + + let error = finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &decryption, + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("accepted transcript")); + assert!(!output.exists()); + Ok(()) +} + +#[tokio::test] +async fn finalize_requires_every_transcript_acceptance() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let output = root.path().join("bundle"); + + let error = finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances[..2], + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("expected 3 transcript acceptances")); + assert!(!output.exists()); + Ok(()) +} + +#[tokio::test] +async fn finalize_rejects_manifest_changed_after_acceptance() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let manifest_path = ceremony.ceremony.join(MANIFEST_FILE); + let mut manifest: Manifest = toml::from_str(&fs_err::read_to_string(&manifest_path)?)?; + manifest.epoch = "55".repeat(32); + fs_err::write(&manifest_path, toml::to_string_pretty(&manifest)?)?; + let output = root.path().join("bundle"); + + let error = finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &dealings[0].join(PRIVATE_STATE_FILE), + &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("another manifest")); + assert!(!output.exists()); + Ok(()) +} + +#[tokio::test] +async fn private_state_cannot_cross_ceremonies() -> TestResult { + let root = tempfile::tempdir()?; + let first_root = root.path().join("first"); + let second_root = root.path().join("second"); + fs_err::create_dir_all(&first_root)?; + fs_err::create_dir_all(&second_root)?; + let first = prepare_test_ceremony(&first_root, 3, 2).await?; + let first_dealings = deal_for_all(&first_root, &first)?; + + let registrations = first + .identities + .iter() + .map(|identity| identity.join(REGISTRATION_FILE)) + .collect::>(); + let second_ceremony = second_root.join("ceremony"); + prepare(&first.genesis.path, 2, &"44".repeat(32), ®istrations, &second_ceremony)?; + let second = TestCeremony { + genesis: first.genesis.clone(), + ceremony: second_ceremony, + identities: first.identities.clone(), + }; + let second_dealings = deal_for_all(&second_root, &second)?; + let accepted = + accept_for_all::(&second_root, &second, &second_dealings).await?; + let output = second_root.join("bundle"); + let error = finalize::( + &first.genesis.path, + &second.ceremony, + &first.identities[0].join(IDENTITY_SECRET_FILE), + &first_dealings[0].join(PRIVATE_STATE_FILE), + &dealing_paths(&second_dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&second_dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("another ceremony")); + assert!(!output.exists()); + Ok(()) +} + +#[tokio::test] +async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let outsider = root.path().join("outsider.wire"); + fs_err::write(&outsider, encode_identity_secret(&StorageScalar::random(&mut OsRng)))?; + let output = root.path().join("deal"); + let mut rng = ChaCha20Rng::from_seed([43; 32]); + assert!( + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &outsider, + &output, + &mut rng, + ) + .is_err() + ); + assert!(!output.exists()); + + fs_err::create_dir(&output)?; + assert!( + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[0].join(IDENTITY_SECRET_FILE), + &output, + &mut rng, + ) + .is_err() + ); + Ok(()) +} + +#[tokio::test] +#[ignore = "slow: runs the concrete Secp/Secq proof backend"] +async fn paper_backend_completes_two_round_ceremony() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 2, 2).await?; + let mut rng = ChaCha20Rng::from_seed([44; 32]); + let mut dealings = Vec::new(); + for (position, identity) in ceremony.identities.iter().enumerate() { + let output = root.path().join(format!("paper-deal-{position}")); + deal::( + &ceremony.genesis.path, + &ceremony.ceremony, + &identity.join(IDENTITY_SECRET_FILE), + &output, + &mut rng, + )?; + dealings.push(output); + } + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + for position in 0..2 { + let output = root.path().join(format!("paper-bundle-{position}")); + finalize::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ceremony.identities[position].join(IDENTITY_SECRET_FILE), + &dealings[position].join(PRIVATE_STATE_FILE), + &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &accepted.transcript, + &accepted.acceptances, + &output, + )?; + } + Ok(()) +} From 5ec93f3fdafae6ee8a374340086eb8a27d697cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Tue, 4 Aug 2026 13:45:17 -0400 Subject: [PATCH 10/20] docs: move Golden DKG changelog to PR metadata --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dedcb521d6..39e6457240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## Unreleased -- Added an offline, genesis-bound Golden DKG ceremony for validator storage keys ([#2426](https://github.com/0xMiden/node/issues/2426)). - [BREAKING] Updated `miden-protocol` dependencies to use the `next` branch (v0.16). Block and transaction account updates now use the absolute `AccountPatch` representation instead of the relative `AccountDelta`, and the `miden-tx-batch-prover` crate was renamed to `miden-tx-batch` ([#2282](https://github.com/0xMiden/node/pull/2282)). ## v0.15.0 (2026-06-10) From ecd301908c9f5b8120d858c94cd10fc57488f1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 08:52:42 -0400 Subject: [PATCH 11/20] refactor(validator): use storage DKG names --- .github/workflows/nightly.yml | 8 +- .../src/commands/{golden_dkg.rs => dkg.rs} | 102 ++++++++---------- .../src/commands/{golden_dkg => dkg}/tests.rs | 6 +- bin/validator/src/commands/mod.rs | 28 ++--- compose/bootstrap.yml | 2 +- .../external/src/local-network-development.md | 10 +- .../network-operator/bootstrap-and-genesis.md | 8 +- .../src/network-operator/validator.md | 27 ++--- scripts/run-node.sh | 2 +- .../README.md | 12 +-- .../public-key-set.wire | Bin .../secret-share.wire | Bin .../setup-context.wire | Bin .../validator-1/secret-share.wire | Bin .../validator-2/secret-share.wire | Bin .../validator-3/secret-share.wire | Bin 16 files changed, 99 insertions(+), 106 deletions(-) rename bin/validator/src/commands/{golden_dkg.rs => dkg.rs} (95%) rename bin/validator/src/commands/{golden_dkg => dkg}/tests.rs (99%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/README.md (67%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/public-key-set.wire (100%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/secret-share.wire (100%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/setup-context.wire (100%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/validator-1/secret-share.wire (100%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/validator-2/secret-share.wire (100%) rename scripts/testdata/{insecure-golden-storage-key => insecure-storage-key}/validator-3/secret-share.wire (100%) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 18fb5e0f70..18087190f4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,8 +12,8 @@ permissions: contents: read jobs: - golden-dkg: - name: Golden DKG production backend + dkg: + name: DKG production backend runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 45 steps: @@ -30,10 +30,10 @@ jobs: - uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 with: tool: nextest@0.9.122 - - name: Run the paper-backed ceremony + - name: Run the production ceremony run: | cargo nextest run -p miden-validator \ - paper_backend_completes_two_round_ceremony \ + production_backend_completes_two_round_ceremony \ --run-ignored ignored-only # Run tests on the beta channel to provide feedback for Rust team. diff --git a/bin/validator/src/commands/golden_dkg.rs b/bin/validator/src/commands/dkg.rs similarity index 95% rename from bin/validator/src/commands/golden_dkg.rs rename to bin/validator/src/commands/dkg.rs index b0ef8d5ee5..10019d854d 100644 --- a/bin/validator/src/commands/golden_dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -46,15 +46,18 @@ use zeroize::Zeroizing; use super::ValidatorSigningKey; +#[cfg(test)] +mod tests; + type StorageGroup = Secp256k1GoldenGroup; type StorageScalar = ::Scalar; type StorageElement = ::Element; type PublicOutput = (StorageElement, BTreeMap); -const REGISTRATION_VERSION: &str = "miden-golden-dkg-registration-v1"; -const MANIFEST_VERSION: &str = "miden-golden-dkg-manifest-v1"; -const REGISTRATION_SIGNATURE_DOMAIN: &[u8] = b"miden-golden-dkg-registration-signature-v1"; -const IDENTITY_SECRET_MAGIC: &[u8] = b"miden-golden-dkg-identity-v1\0"; +const REGISTRATION_VERSION: &str = "miden-storage-key-dkg-registration-v1"; +const MANIFEST_VERSION: &str = "miden-storage-key-dkg-manifest-v1"; +const REGISTRATION_SIGNATURE_DOMAIN: &[u8] = b"miden-storage-key-dkg-registration-signature-v1"; +const IDENTITY_SECRET_MAGIC: &[u8] = b"miden-storage-key-dkg-identity-v1\0"; const IDENTITY_SECRET_FILE: &str = "identity-secret.wire"; const REGISTRATION_FILE: &str = "registration.toml"; const MANIFEST_FILE: &str = "manifest.toml"; @@ -63,28 +66,28 @@ const CONTEXT_CONFIG_FILE: &str = "context-config.wire"; const DECRYPTION_DEALING_FILE: &str = "decryption-dealing.wire"; const CONTEXT_DEALING_FILE: &str = "context-dealing.wire"; const PRIVATE_STATE_FILE: &str = "private-state.wire"; -const PRIVATE_STATE_MAGIC: &[u8] = b"miden-golden-dkg-local-state-v1\0"; +const PRIVATE_STATE_MAGIC: &[u8] = b"miden-storage-key-dkg-local-state-v1\0"; const EPOCH_FILE: &str = "epoch.hex"; const SETUP_CONTEXT_FILE: &str = "setup-context.wire"; const PUBLIC_KEY_SET_FILE: &str = "public-key-set.wire"; const SECRET_SHARE_FILE: &str = "secret-share.wire"; -const TRANSCRIPT_VERSION: &str = "miden-golden-dkg-transcript-v1"; -const TRANSCRIPT_ACCEPTANCE_VERSION: &str = "miden-golden-dkg-transcript-acceptance-v1"; -const TRANSCRIPT_SIGNATURE_DOMAIN: &[u8] = b"miden-golden-dkg-transcript-signature-v1"; +const TRANSCRIPT_VERSION: &str = "miden-storage-key-dkg-transcript-v1"; +const TRANSCRIPT_ACCEPTANCE_VERSION: &str = "miden-storage-key-dkg-transcript-acceptance-v1"; +const TRANSCRIPT_SIGNATURE_DOMAIN: &[u8] = b"miden-storage-key-dkg-transcript-signature-v1"; const TRANSCRIPT_FILE: &str = "transcript.toml"; const TRANSCRIPT_ACCEPTANCE_FILE: &str = "transcript-acceptance.toml"; const TRANSCRIPT_ACCEPTANCES_FILE: &str = "transcript-acceptances.toml"; -/// Inputs for one Golden DKG ceremony command. +/// Inputs for one DKG ceremony command. #[derive(clap::Args)] -pub struct GoldenDkgOptions { +pub struct DkgOptions { #[command(subcommand)] - command: GoldenDkgCommand, + command: DkgCommand, } -/// Golden DKG ceremony commands. +/// DKG ceremony commands. #[derive(clap::Subcommand)] -enum GoldenDkgCommand { +enum DkgCommand { /// Generates this validator's DKG identity and public registration. Identity { /// Trusted genesis block for the network. @@ -100,7 +103,7 @@ enum GoldenDkgCommand { output_directory: PathBuf, }, - /// Builds the public configurations for both Golden DKG rounds. + /// Builds the public configurations for both DKG rounds. Prepare { /// Trusted genesis block for the network. #[arg(long, value_name = "FILE")] @@ -233,7 +236,7 @@ enum GoldenDkgCommand { #[arg(long, value_name = "DIR")] bundle_directory: PathBuf, - /// Golden participant index that must own the secret share. + /// DKG participant index that must own the secret share. #[arg(long, value_name = "NUM")] expected_participant: u32, }, @@ -317,21 +320,21 @@ struct TranscriptAcceptances { acceptances: Vec, } -/// Runs one Golden DKG ceremony command. -pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { +/// Runs one DKG ceremony command. +pub async fn run(options: DkgOptions) -> anyhow::Result<()> { match options.command { - GoldenDkgCommand::Identity { genesis, signing_key, output_directory } => { + DkgCommand::Identity { genesis, signing_key, output_directory } => { let signer = signing_key.into_signer().await?; generate_identity(&genesis, &signer, &output_directory).await }, - GoldenDkgCommand::Prepare { + DkgCommand::Prepare { genesis, threshold, epoch, registration, output_directory, } => prepare(&genesis, threshold, &epoch, ®istration, &output_directory), - GoldenDkgCommand::Deal { + DkgCommand::Deal { genesis, ceremony_directory, identity_secret, @@ -343,7 +346,7 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { &output_directory, &mut OsRng, ), - GoldenDkgCommand::Accept { + DkgCommand::Accept { genesis, ceremony_directory, signing_key, @@ -362,7 +365,7 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { ) .await }, - GoldenDkgCommand::Finalize { + DkgCommand::Finalize { genesis, ceremony_directory, identity_secret, @@ -383,7 +386,7 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { &transcript_acceptance, &output_directory, ), - GoldenDkgCommand::Validate { + DkgCommand::Validate { genesis, ceremony_directory, validator_public_key, @@ -391,7 +394,7 @@ pub async fn run(options: GoldenDkgOptions) -> anyhow::Result<()> { } => { validate_bundle(&genesis, &ceremony_directory, &validator_public_key, &bundle_directory) }, - GoldenDkgCommand::ValidateFixture { bundle_directory, expected_participant } => { + DkgCommand::ValidateFixture { bundle_directory, expected_participant } => { validate_fixture_bundle(&bundle_directory, expected_participant) }, } @@ -444,7 +447,7 @@ async fn generate_identity( write_new_file(&directory.join(REGISTRATION_FILE), registration.as_bytes(), false) })?; - println!("Golden DKG identity written to {}.", output_directory.display()); + println!("DKG identity written to {}.", output_directory.display()); Ok(()) } @@ -478,9 +481,8 @@ fn prepare( registrations.remove(validator_key.to_bytes().as_slice()).with_context(|| { format!("missing registration for genesis validator {validator_key_hex}") })?; - let participant = ParticipantIndex::new( - u32::try_from(offset + 1).context("too many Golden DKG participants")?, - )?; + let participant = + ParticipantIndex::new(u32::try_from(offset + 1).context("too many DKG participants")?)?; let identity_key_hex = hex::encode(StorageGroup::encode_element(&identity_key)); registry_entries.push((participant, identity_key)); @@ -526,7 +528,7 @@ fn prepare( write_new_file(&directory.join(CONTEXT_CONFIG_FILE), &context_config, false) })?; - println!("Golden DKG configuration written to {}.", output_directory.display()); + println!("DKG configuration written to {}.", output_directory.display()); Ok(()) } @@ -600,7 +602,7 @@ where write_new_file(&directory.join(PRIVATE_STATE_FILE), &state, true) })?; - println!("Golden DKG dealings written to {}.", output_directory.display()); + println!("DKG dealings written to {}.", output_directory.display()); Ok(()) } @@ -652,7 +654,7 @@ where write_new_file(&directory.join(TRANSCRIPT_FILE), &transcript_bytes, false)?; write_new_file(&directory.join(TRANSCRIPT_ACCEPTANCE_FILE), acceptance.as_bytes(), false) })?; - println!("Golden DKG transcript accepted in {}.", output_directory.display()); + println!("DKG transcript accepted in {}.", output_directory.display()); Ok(()) } @@ -773,11 +775,11 @@ where &acceptances, output_directory, )?; - println!("Golden storage key bundle written to {}.", output_directory.display()); + println!("Storage key bundle written to {}.", output_directory.display()); Ok(()) } -/// Validates and publishes one final Golden operator key bundle. +/// Validates and publishes one final storage key bundle. fn publish_operator_bundle( material: &Ehtdh1Material, ceremony: &Ceremony, @@ -801,7 +803,7 @@ fn publish_operator_bundle( secret_share.to_vec(), ) .decode() - .context("generated invalid Golden operator key")?; + .context("generated invalid storage key")?; publish_directory(output_directory, |directory| { write_new_file(&directory.join(EPOCH_FILE), ceremony.manifest.epoch.as_bytes(), false)?; @@ -861,7 +863,7 @@ fn validate_bundle( fs_err::read(bundle_directory.join(SECRET_SHARE_FILE))?, ) .decode() - .context("invalid Golden operator key bundle")?; + .context("invalid storage key bundle")?; ensure!( operator_key.participant() == expected_participant, "bundle belongs to participant {}, expected {}", @@ -882,10 +884,7 @@ fn validate_bundle( )?, "bundle transcript roots do not match accepted transcript", ); - println!( - "Golden storage key bundle is valid for participant {}.", - expected_participant.get(), - ); + println!("Storage key bundle is valid for participant {}.", expected_participant.get()); Ok(()) } @@ -905,17 +904,14 @@ fn validate_fixture_bundle( fs_err::read(bundle_directory.join(SECRET_SHARE_FILE))?, ) .decode() - .context("invalid Golden operator key fixture")?; + .context("invalid storage key fixture")?; ensure!( operator_key.participant() == expected_participant, "fixture belongs to participant {}, expected {}", operator_key.participant().get(), expected_participant.get(), ); - println!( - "Golden storage key fixture is valid for participant {}.", - expected_participant.get(), - ); + println!("Storage key fixture is valid for participant {}.", expected_participant.get()); Ok(()) } @@ -980,8 +976,8 @@ fn read_validated_registrations( /// Reads a ceremony directory and checks every public value against genesis. fn read_ceremony(genesis_path: &Path, directory: &Path) -> anyhow::Result { let manifest_path = directory.join(MANIFEST_FILE); - let manifest_text = fs_err::read_to_string(&manifest_path) - .with_context(|| format!("failed to read DKG manifest {}", manifest_path.display()))?; + let manifest_text = + fs_err::read_to_string(&manifest_path).context("failed to read DKG manifest")?; let manifest: Manifest = toml::from_str(&manifest_text).context("failed to decode DKG manifest")?; ensure!(manifest.version == MANIFEST_VERSION, "unsupported DKG manifest version"); @@ -1040,9 +1036,8 @@ fn read_ceremony(genesis_path: &Path, directory: &Path) -> anyhow::Result( Ok((public_key, public_shares)) } -/// Reproduces Golden's completion transcript root from public dealings. +/// Reproduces the completion transcript root from public dealings. fn completion_root

( dealings: &BTreeMap>, ) -> [u8; 32] { @@ -1545,7 +1540,7 @@ fn decode_validator_signature(value: &str) -> anyhow::Result { Ok(signature) } -/// Parses a non-identity Golden DKG public key. +/// Parses a non-identity DKG public key. fn decode_identity_public_key( value: &str, ) -> anyhow::Result<::Element> { @@ -1595,7 +1590,7 @@ fn publish_directory( let parent = output_directory.parent().unwrap_or_else(|| Path::new(".")); fs_err::create_dir_all(parent).context("failed to create output parent directory")?; let temporary = tempfile::Builder::new() - .prefix(".golden-dkg-") + .prefix(".storage-key-dkg-") .tempdir_in(parent) .context("failed to create temporary output directory")?; write(temporary.path())?; @@ -1649,6 +1644,3 @@ fn sha256_hex(bytes: &[u8]) -> String { fn sha256(bytes: &[u8]) -> [u8; 32] { Sha256::digest(bytes).into() } - -#[cfg(test)] -mod tests; diff --git a/bin/validator/src/commands/golden_dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs similarity index 99% rename from bin/validator/src/commands/golden_dkg/tests.rs rename to bin/validator/src/commands/dkg/tests.rs index 84209e40c2..73461cc5c1 100644 --- a/bin/validator/src/commands/golden_dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -31,8 +31,8 @@ fn write_genesis(root: &Path) -> TestResultWith { #[test] fn committed_fixture_has_one_valid_share_per_participant() -> TestResult { - let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../scripts/testdata/insecure-golden-storage-key"); + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/testdata/insecure-storage-key"); let root = tempfile::tempdir()?; let mut shares = Vec::new(); @@ -712,7 +712,7 @@ async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { #[tokio::test] #[ignore = "slow: runs the concrete Secp/Secq proof backend"] -async fn paper_backend_completes_two_round_ceremony() -> TestResult { +async fn production_backend_completes_two_round_ceremony() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 2, 2).await?; let mut rng = ChaCha20Rng::from_seed([44; 32]); diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index dfc917dd49..3c4f51d16d 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -1,7 +1,7 @@ mod bootstrap; +mod dkg; mod export_private_record; mod genesis; -mod golden_dkg; mod issue_private_record_share; mod start; @@ -51,7 +51,7 @@ pub(crate) const INSECURE_ENCRYPTION_KEY_HEX: &str = // VALIDATOR COMMAND // ================================================================================================ -/// Local inputs for issuing one Golden private-record share. +/// Local inputs for issuing one private-record share. #[derive(clap::Args)] pub struct PrivateRecordShareOptions { /// Canonical private-record bundle for which to issue a share. @@ -62,7 +62,7 @@ pub struct PrivateRecordShareOptions { #[arg(long, value_name = "FILE")] output: PathBuf, - /// Canonical Golden storage key material for this validator. + /// Canonical storage key material for this validator. #[command(flatten)] storage_key: ValidatorStorageKey, } @@ -159,10 +159,10 @@ pub enum ValidatorCommand { data_directory: PathBuf, }, - /// Runs the Golden storage-key setup ceremony. - GoldenDkg(golden_dkg::GoldenDkgOptions), + /// Runs the storage-key setup ceremony. + Dkg(dkg::DkgOptions), - /// Issues this validator's Golden decryption share for one stored private record. + /// Issues this validator's decryption share for one stored private record. IssuePrivateRecordShare(PrivateRecordShareOptions), /// Exports one validator-qualified private-record bundle. @@ -252,7 +252,7 @@ pub enum ValidatorCommand { )] encryption_key_kms_ciphertext: Option, - /// Canonical Golden storage key material provisioned after setup. + /// Canonical Storage key material provisioned after setup. #[command(flatten)] storage_key: ValidatorStorageKey, }, @@ -294,7 +294,7 @@ impl ValidatorCommand { .context("failed to apply validator database migrations")?; Ok(()) }, - Self::GoldenDkg(options) => golden_dkg::run(options).await, + Self::Dkg(options) => dkg::run(options).await, Self::IssuePrivateRecordShare(options) => { issue_private_record_share::issue_from_options(options) }, @@ -357,7 +357,7 @@ impl ValidatorCommand { Self::Genesis { .. } | Self::Bootstrap { .. } | Self::Pubkey { .. } - | Self::GoldenDkg(_) + | Self::Dkg(_) | Self::ExportPrivateRecord(_) | Self::IssuePrivateRecordShare(_) | Self::Migrate { .. } => OpenTelemetry::Disabled, @@ -398,7 +398,7 @@ async fn resolve_decrypter( Ok(Arc::new(LocalX25519TransactionInputDecrypter::new(encryption_key))) } -/// Canonical Golden files needed to restore one validator storage key share. +/// Canonical files needed to restore one validator storage key share. #[derive(clap::Args)] pub struct ValidatorStorageKey { /// Hex-encoded 32-byte storage key epoch. @@ -408,21 +408,21 @@ pub struct ValidatorStorageKey { value_name = "STORAGE_KEY_EPOCH" )] key_epoch: String, - /// File containing canonical Golden `SetupContext` bytes. + /// File containing canonical `SetupContext` bytes. #[arg( long = "storage-key.setup-context", env = ENV_STORAGE_KEY_SETUP_CONTEXT, value_name = "FILE" )] setup_context: PathBuf, - /// File containing canonical Golden `PublicKeySet` bytes. + /// File containing canonical `PublicKeySet` bytes. #[arg( long = "storage-key.public-key-set", env = ENV_STORAGE_KEY_PUBLIC_SET, value_name = "FILE" )] public_key_set: PathBuf, - /// File containing this operator's canonical Golden `SecretShare` bytes. + /// File containing this operator's canonical `SecretShare` bytes. #[arg( long = "storage-key.secret-share", env = ENV_STORAGE_KEY_SECRET_SHARE, @@ -460,7 +460,7 @@ impl ValidatorStorageKey { })?, ) .decode() - .context("failed to validate Golden storage key material")?; + .context("failed to validate storage key material")?; Ok(operator_key) } } diff --git a/compose/bootstrap.yml b/compose/bootstrap.yml index 057cb761c2..88b337be99 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -57,7 +57,7 @@ services: cp "/fixtures/storage-key/validator-$${VALIDATOR}/secret-share.wire" \ "$${STORAGE_KEY}/secret-share.wire" chmod 600 "$${STORAGE_KEY}/secret-share.wire" - miden-validator golden-dkg validate-fixture \ + miden-validator dkg validate-fixture \ --bundle-directory "$${STORAGE_KEY}" \ --expected-participant "$${VALIDATOR}" done diff --git a/docs/external/src/local-network-development.md b/docs/external/src/local-network-development.md index 8a30b120da..50f36049e6 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -228,14 +228,14 @@ chain data before starting with a different genesis configuration: make local-network-delete ``` -## Golden Storage Key Fixture +## Storage Key Fixture -The Compose bootstrap service stages a committed, insecure two-of-three Golden fixture in each validator data directory. -It gives each validator its own secret share and validates the expected participant index before marking bootstrap -complete. The running validators read only their staged paths; they do not run the production DKG ceremony. +The Compose bootstrap service stages a committed, insecure two-of-three storage-key fixture in each validator data +directory. It gives each validator its own secret share and validates the expected participant index before marking +bootstrap complete. The running validators read only their staged paths; they do not run the production DKG ceremony. The fixture is public test data. Never use it outside local development. Run the -[Golden storage key ceremony](./network-operator/validator.md#golden-storage-key-setup) for a real network. +[storage key ceremony](./network-operator/validator.md#storage-key-setup) for a real network. ## Check the RPC API diff --git a/docs/external/src/network-operator/bootstrap-and-genesis.md b/docs/external/src/network-operator/bootstrap-and-genesis.md index ab067a6f33..eeddd7f434 100644 --- a/docs/external/src/network-operator/bootstrap-and-genesis.md +++ b/docs/external/src/network-operator/bootstrap-and-genesis.md @@ -149,10 +149,10 @@ miden-ntx-builder bootstrap \ The key each validator operator starts their validator with must match the public key committed for them in the genesis configuration's `validators` list. -## Golden Storage Key Ceremony +## Storage Key Ceremony -After genesis is built, every listed validator must join one offline Golden DKG ceremony. The ceremony creates the -shared public storage key and one distinct secret share per validator. No coordinator can derive those shares. +After genesis is built, every listed validator must join one offline DKG ceremony. The ceremony creates the shared +public storage key and one distinct secret share per validator. No coordinator can derive those shares. Each operator first registers a fresh DKG identity with the validator signing key committed in genesis. One coordinator uses every signed registration to prepare the common ceremony. Every operator then creates two public dealings, checks @@ -161,7 +161,7 @@ order, but both must finish before the validator starts. All listed validators must contribute to the ceremony even when the recovery threshold is lower. If any participant drops out or any transcript differs, discard the incomplete ceremony and start a new one with fresh identities and -sessions. See [Golden storage key setup](./validator.md#golden-storage-key-setup) for the commands and file rules. +sessions. See [storage key setup](./validator.md#storage-key-setup) for the commands and file rules. Bootstrap takes no transaction encryption key: that key is configured separately when the validator is started, and nothing cross-checks it against the genesis block. A validator started without one falls back to a publicly known diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index dadfcd8f26..1056295688 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -28,16 +28,16 @@ the block header, this next-key commitment is authenticated by the existing vali rotation safe: the network can verify that the next validator key was authorized by the validator that signed the current block. -## Golden Storage Key Setup +## Storage Key Setup -The Golden DKG creates the storage key used to re-encrypt validated private inputs. Run one ceremony for the validator -set committed in genesis. Participant indexes follow the order of validator signing keys in the genesis block. +The DKG creates the storage key used to re-encrypt validated private inputs. Run one ceremony for the validator set +committed in genesis. Participant indexes follow the order of validator signing keys in the genesis block. First, each operator creates a DKG identity and sends `registration.toml` to the coordinator. The signing key must match one key in genesis. Use `--signing-key.hex` instead of KMS only for local or private deployments. ```bash -miden-validator golden-dkg identity \ +miden-validator dkg identity \ --genesis genesis.dat \ --signing-key.kms-id \ --output-directory identity @@ -46,7 +46,7 @@ miden-validator golden-dkg identity \ The coordinator collects every registration and prepares one common ceremony directory. ```bash -miden-validator golden-dkg prepare \ +miden-validator dkg prepare \ --genesis genesis.dat \ --threshold 2 \ --epoch <32-byte-hex-epoch> \ @@ -59,7 +59,7 @@ miden-validator golden-dkg prepare \ Each operator checks the ceremony directory over the authenticated bootstrap channel, then creates its dealings. ```bash -miden-validator golden-dkg deal \ +miden-validator dkg deal \ --genesis genesis.dat \ --ceremony-directory ceremony \ --identity-secret identity/identity-secret.wire \ @@ -70,7 +70,7 @@ After all dealings are exchanged, every operator signs the same transcript. Repe validator. ```bash -miden-validator golden-dkg accept \ +miden-validator dkg accept \ --genesis genesis.dat \ --ceremony-directory ceremony \ --signing-key.kms-id \ @@ -87,7 +87,7 @@ Compare `transcript.toml` byte for byte across all operators. Collect one signed operator. Each operator can then create and validate its own startup bundle. ```bash -miden-validator golden-dkg finalize \ +miden-validator dkg finalize \ --genesis genesis.dat \ --ceremony-directory ceremony \ --identity-secret identity/identity-secret.wire \ @@ -104,7 +104,7 @@ miden-validator golden-dkg finalize \ --transcript-acceptance validator-3-transcript-acceptance.toml \ --output-directory storage-key -miden-validator golden-dkg validate \ +miden-validator dkg validate \ --genesis genesis.dat \ --ceremony-directory ceremony \ --validator-public-key \ @@ -155,11 +155,12 @@ is the supported provisioning path. Each validator must run inside its trusted execution environment. If transaction proving uses a remote prover, that prover also receives the plaintext inputs and must run inside the same trusted boundary. -This version requires a fresh validator database. Phase 1 client ciphertext cannot be converted into Golden records. +This version requires a fresh validator database. Phase 1 client ciphertext cannot be converted into threshold-encrypted +records. -The files contain canonical Golden wire bytes. Every validator uses the same setup context and public key set, but uses -its own secret share. The validator will not start if any storage key option is missing or the key material is invalid. -After validation, it stores only the transaction ID and the Golden threshold record. It does not store the client +The files contain canonical wire bytes. Every validator uses the same setup context and public key set, but uses its own +secret share. The validator will not start if any storage key option is missing or the key material is invalid. After +validation, it stores only the transaction ID and the threshold-encrypted record. It does not store the client ciphertext. Use `miden-validator start --help` for the complete current option list. diff --git a/scripts/run-node.sh b/scripts/run-node.sh index a2c4d71f86..fdb99d27e5 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -32,7 +32,7 @@ VALIDATOR_2_KEY_HEX="02020202020202020202020202020202020202020202020202020202020 # Insecure, hard-coded local dev storage encryption setup. VALIDATOR_STORAGE_KEY_EPOCH="0909090909090909090909090909090909090909090909090909090909090909" -VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY="scripts/testdata/insecure-golden-storage-key" +VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY="scripts/testdata/insecure-storage-key" VALIDATOR_INSECURE_STORAGE_KEY_SETUP_CONTEXT="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/setup-context.wire" VALIDATOR_INSECURE_STORAGE_KEY_PUBLIC_KEY_SET="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/public-key-set.wire" VALIDATOR_1_INSECURE_STORAGE_KEY_SECRET_SHARE="${VALIDATOR_INSECURE_STORAGE_KEY_DIRECTORY}/validator-1/secret-share.wire" diff --git a/scripts/testdata/insecure-golden-storage-key/README.md b/scripts/testdata/insecure-storage-key/README.md similarity index 67% rename from scripts/testdata/insecure-golden-storage-key/README.md rename to scripts/testdata/insecure-storage-key/README.md index 4b01eeef23..93e5da2321 100644 --- a/scripts/testdata/insecure-golden-storage-key/README.md +++ b/scripts/testdata/insecure-storage-key/README.md @@ -1,7 +1,7 @@ -# Insecure Golden storage key +# Insecure storage key -These files hold a deterministic **two-of-three** Golden storage key used by the docker-compose network and the -benchmark smoke test to exercise threshold storage. +These files hold a deterministic **two-of-three** storage key used by the docker-compose network and the benchmark smoke +test to exercise threshold storage. Layout: @@ -17,9 +17,9 @@ impossible even though each validator stores encrypted records. This key is public and must not be used outside tests. -Compose checks each staged bundle with `miden-validator golden-dkg validate-fixture` before it marks the local network -as bootstrapped. This fixture-only check binds the secret share to its expected participant index. Production bundles -must use `miden-validator golden-dkg validate`, which also checks genesis, the ceremony manifest, and signed transcript. +Compose checks each staged bundle with `miden-validator dkg validate-fixture` before it marks the local network as +bootstrapped. This fixture-only check binds the secret share to its expected participant index. Production bundles must +use `miden-validator dkg validate`, which also checks genesis, the ceremony manifest, and signed transcript. ## Regenerating diff --git a/scripts/testdata/insecure-golden-storage-key/public-key-set.wire b/scripts/testdata/insecure-storage-key/public-key-set.wire similarity index 100% rename from scripts/testdata/insecure-golden-storage-key/public-key-set.wire rename to scripts/testdata/insecure-storage-key/public-key-set.wire diff --git a/scripts/testdata/insecure-golden-storage-key/secret-share.wire b/scripts/testdata/insecure-storage-key/secret-share.wire similarity index 100% rename from scripts/testdata/insecure-golden-storage-key/secret-share.wire rename to scripts/testdata/insecure-storage-key/secret-share.wire diff --git a/scripts/testdata/insecure-golden-storage-key/setup-context.wire b/scripts/testdata/insecure-storage-key/setup-context.wire similarity index 100% rename from scripts/testdata/insecure-golden-storage-key/setup-context.wire rename to scripts/testdata/insecure-storage-key/setup-context.wire diff --git a/scripts/testdata/insecure-golden-storage-key/validator-1/secret-share.wire b/scripts/testdata/insecure-storage-key/validator-1/secret-share.wire similarity index 100% rename from scripts/testdata/insecure-golden-storage-key/validator-1/secret-share.wire rename to scripts/testdata/insecure-storage-key/validator-1/secret-share.wire diff --git a/scripts/testdata/insecure-golden-storage-key/validator-2/secret-share.wire b/scripts/testdata/insecure-storage-key/validator-2/secret-share.wire similarity index 100% rename from scripts/testdata/insecure-golden-storage-key/validator-2/secret-share.wire rename to scripts/testdata/insecure-storage-key/validator-2/secret-share.wire diff --git a/scripts/testdata/insecure-golden-storage-key/validator-3/secret-share.wire b/scripts/testdata/insecure-storage-key/validator-3/secret-share.wire similarity index 100% rename from scripts/testdata/insecure-golden-storage-key/validator-3/secret-share.wire rename to scripts/testdata/insecure-storage-key/validator-3/secret-share.wire From 14d4251c96fffb37498e13bd45ffa48d85c9a9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 08:57:00 -0400 Subject: [PATCH 12/20] fix(ci): update storage key fixture paths --- .github/workflows/ci.yml | 6 +++--- bin/validator/src/storage_key.rs | 10 +++++----- scripts/testdata/insecure-storage-key/README.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55a1297911..5911ef9690 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,9 +301,9 @@ jobs: WAIT_BLOCKS: "30" RUN_DIR: ${{ runner.temp }}/bench-local-run MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: "0909090909090909090909090909090909090909090909090909090909090909" - MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: ${{ github.workspace }}/scripts/testdata/insecure-golden-storage-key/setup-context.wire - MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: ${{ github.workspace }}/scripts/testdata/insecure-golden-storage-key/public-key-set.wire - MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: ${{ github.workspace }}/scripts/testdata/insecure-golden-storage-key/secret-share.wire + MIDEN_VALIDATOR_STORAGE_KEY_SETUP_CONTEXT: ${{ github.workspace }}/scripts/testdata/insecure-storage-key/setup-context.wire + MIDEN_VALIDATOR_STORAGE_KEY_PUBLIC_SET: ${{ github.workspace }}/scripts/testdata/insecure-storage-key/public-key-set.wire + MIDEN_VALIDATOR_STORAGE_KEY_SECRET_SHARE: ${{ github.workspace }}/scripts/testdata/insecure-storage-key/secret-share.wire run: | export PATH="$PWD/target/release:$PATH" ./scripts/bench-local.sh diff --git a/bin/validator/src/storage_key.rs b/bin/validator/src/storage_key.rs index d2511a0f8a..8203401b37 100644 --- a/bin/validator/src/storage_key.rs +++ b/bin/validator/src/storage_key.rs @@ -397,8 +397,8 @@ pub(crate) mod tests { operator_keys().remove(0) } - /// Regenerates the committed insecure Golden storage-key fixture under - /// `scripts/testdata/insecure-golden-storage-key/`. + /// Regenerates the committed insecure storage-key fixture under + /// `scripts/testdata/insecure-storage-key/`. /// /// The fixture holds a full two-of-three setup: one shared /// `setup-context.wire` and `public-key-set.wire`, plus a *distinct* @@ -411,15 +411,15 @@ pub(crate) mod tests { /// Ignored by default so it never runs in CI; regenerate the fixture with: /// /// ```text - /// cargo test -p miden-validator --lib storage_key::tests::write_insecure_golden_fixture -- --ignored + /// cargo test -p miden-validator --lib storage_key::tests::write_insecure_storage_key_fixture -- --ignored /// ``` #[test] #[ignore = "writes fixture files; run explicitly to regenerate"] - fn write_insecure_golden_fixture() { + fn write_insecure_storage_key_fixture() { use std::path::Path; let dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../scripts/testdata/insecure-golden-storage-key"); + .join("../../scripts/testdata/insecure-storage-key"); fs_err::create_dir_all(&dir).unwrap(); let (setup_context, public_key_set, _) = values_for(participant(1)); diff --git a/scripts/testdata/insecure-storage-key/README.md b/scripts/testdata/insecure-storage-key/README.md index 93e5da2321..1389e373b2 100644 --- a/scripts/testdata/insecure-storage-key/README.md +++ b/scripts/testdata/insecure-storage-key/README.md @@ -28,5 +28,5 @@ with: ```sh cargo test -p miden-validator --lib \ - storage_key::tests::write_insecure_golden_fixture -- --ignored + storage_key::tests::write_insecure_storage_key_fixture -- --ignored ``` From 1449adf04c38df5ab3362fb42c4900aa598cb604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 09:27:48 -0400 Subject: [PATCH 13/20] feat(compose): run storage key DKG at bootstrap --- bin/validator/src/commands/dkg.rs | 16 +-- compose/bootstrap.yml | 120 ++++++++++++++++-- .../external/src/local-network-development.md | 12 +- 3 files changed, 121 insertions(+), 27 deletions(-) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 10019d854d..dbd1fe325a 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -552,7 +552,7 @@ where let identity_secret = decode_identity_secret(&identity_secret_bytes)?; let participant = participant_for_identity(&ceremony.manifest, &identity_secret)?; - println!("Creating Golden decryption dealing for participant {}.", participant.get()); + println!("Creating decryption dealing for participant {}.", participant.get()); let started = Instant::now(); let decryption = create_dealing::( participant, @@ -562,12 +562,12 @@ where ) .context("failed to create decryption dealing")?; println!( - "Created Golden decryption dealing for participant {} in {:.1?}.", + "Created decryption dealing for participant {} in {:.1?}.", participant.get(), started.elapsed(), ); - println!("Creating Golden context dealing for participant {}.", participant.get()); + println!("Creating context dealing for participant {}.", participant.get()); let started = Instant::now(); let context = create_dealing_with_secret::( participant, @@ -578,7 +578,7 @@ where ) .context("failed to create context dealing")?; println!( - "Created Golden context dealing for participant {} in {:.1?}.", + "Created context dealing for participant {} in {:.1?}.", participant.get(), started.elapsed(), ); @@ -717,7 +717,7 @@ where )?; println!( - "Completing Golden decryption round for participant {} with {} dealings.", + "Completing decryption round for participant {} with {} dealings.", participant.get(), decryption_dealings.len(), ); @@ -732,13 +732,13 @@ where ) .context("failed to complete decryption round")?; println!( - "Completed Golden decryption round for participant {} in {:.1?}.", + "Completed decryption round for participant {} in {:.1?}.", participant.get(), started.elapsed(), ); println!( - "Completing Golden context round for participant {} with {} dealings.", + "Completing context round for participant {} with {} dealings.", participant.get(), context_dealings.len(), ); @@ -753,7 +753,7 @@ where ) .context("failed to complete context round")?; println!( - "Completed Golden context round for participant {} in {:.1?}.", + "Completed context round for participant {} in {:.1?}.", participant.get(), started.elapsed(), ); diff --git a/compose/bootstrap.yml b/compose/bootstrap.yml index 88b337be99..1b79a4b5ee 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -17,6 +17,10 @@ services: target: /fixtures/storage-key/validator-3/secret-share.wire environment: MIDEN_VALIDATOR_STORAGE_KEY_EPOCH: ${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH:-0909090909090909090909090909090909090909090909090909090909090909} + MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE: ${MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE:-false} + MIDEN_VALIDATOR_1_SIGNING_KEY: ${MIDEN_VALIDATOR_1_SIGNING_KEY:-0101010101010101010101010101010101010101010101010101010101010101} + MIDEN_VALIDATOR_2_SIGNING_KEY: ${MIDEN_VALIDATOR_2_SIGNING_KEY:-0303030303030303030303030303030303030303030303030303030303030303} + MIDEN_VALIDATOR_3_SIGNING_KEY: ${MIDEN_VALIDATOR_3_SIGNING_KEY:-0404040404040404040404040404040404040404040404040404040404040404} volumes: - node-data:/data entrypoint: ["/bin/sh", "-c"] @@ -34,6 +38,7 @@ services: /data/genesis \ /data/node \ /data/ntx-builder \ + /data/storage-key-dkg \ /data/validators mkdir -p /data/genesis /data/validators /data/accounts @@ -48,20 +53,111 @@ services: miden-validator bootstrap \ --data-directory "/data/validators/$${VALIDATOR}" \ --genesis /data/genesis/genesis.dat - - STORAGE_KEY="/data/validators/$${VALIDATOR}/storage-key" - mkdir -p "$${STORAGE_KEY}" - printf '%s' "$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" > "$${STORAGE_KEY}/epoch.hex" - cp /fixtures/storage-key/setup-context.wire "$${STORAGE_KEY}/setup-context.wire" - cp /fixtures/storage-key/public-key-set.wire "$${STORAGE_KEY}/public-key-set.wire" - cp "/fixtures/storage-key/validator-$${VALIDATOR}/secret-share.wire" \ - "$${STORAGE_KEY}/secret-share.wire" - chmod 600 "$${STORAGE_KEY}/secret-share.wire" - miden-validator dkg validate-fixture \ - --bundle-directory "$${STORAGE_KEY}" \ - --expected-participant "$${VALIDATOR}" done + if [ "$${MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE}" = "true" ]; then + echo "Staging the insecure storage key fixture..." + for VALIDATOR in 1 2 3; do + STORAGE_KEY="/data/validators/$${VALIDATOR}/storage-key" + mkdir -p "$${STORAGE_KEY}" + printf '%s' "$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" > "$${STORAGE_KEY}/epoch.hex" + cp /fixtures/storage-key/setup-context.wire "$${STORAGE_KEY}/setup-context.wire" + cp /fixtures/storage-key/public-key-set.wire "$${STORAGE_KEY}/public-key-set.wire" + cp "/fixtures/storage-key/validator-$${VALIDATOR}/secret-share.wire" \ + "$${STORAGE_KEY}/secret-share.wire" + chmod 600 "$${STORAGE_KEY}/secret-share.wire" + miden-validator dkg validate-fixture \ + --bundle-directory "$${STORAGE_KEY}" \ + --expected-participant "$${VALIDATOR}" + done + else + echo "Running the storage key DKG ceremony..." + DKG=/data/storage-key-dkg + mkdir -p "$${DKG}/identity" "$${DKG}/dealings" "$${DKG}/acceptances" + + for VALIDATOR in 1 2 3; do + case "$${VALIDATOR}" in + 1) SIGNING_KEY="$${MIDEN_VALIDATOR_1_SIGNING_KEY}" ;; + 2) SIGNING_KEY="$${MIDEN_VALIDATOR_2_SIGNING_KEY}" ;; + 3) SIGNING_KEY="$${MIDEN_VALIDATOR_3_SIGNING_KEY}" ;; + esac + miden-validator dkg identity \ + --genesis /data/genesis/genesis.dat \ + --signing-key.hex "$${SIGNING_KEY}" \ + --output-directory "$${DKG}/identity/$${VALIDATOR}" + done + + miden-validator dkg prepare \ + --genesis /data/genesis/genesis.dat \ + --threshold 2 \ + --epoch "$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" \ + --registration "$${DKG}/identity/1/registration.toml" \ + --registration "$${DKG}/identity/2/registration.toml" \ + --registration "$${DKG}/identity/3/registration.toml" \ + --output-directory "$${DKG}/ceremony" + + for VALIDATOR in 1 2 3; do + miden-validator dkg deal \ + --genesis /data/genesis/genesis.dat \ + --ceremony-directory "$${DKG}/ceremony" \ + --identity-secret "$${DKG}/identity/$${VALIDATOR}/identity-secret.wire" \ + --output-directory "$${DKG}/dealings/$${VALIDATOR}" + done + + for VALIDATOR in 1 2 3; do + case "$${VALIDATOR}" in + 1) SIGNING_KEY="$${MIDEN_VALIDATOR_1_SIGNING_KEY}" ;; + 2) SIGNING_KEY="$${MIDEN_VALIDATOR_2_SIGNING_KEY}" ;; + 3) SIGNING_KEY="$${MIDEN_VALIDATOR_3_SIGNING_KEY}" ;; + esac + miden-validator dkg accept \ + --genesis /data/genesis/genesis.dat \ + --ceremony-directory "$${DKG}/ceremony" \ + --signing-key.hex "$${SIGNING_KEY}" \ + --decryption-dealing "$${DKG}/dealings/1/decryption-dealing.wire" \ + --decryption-dealing "$${DKG}/dealings/2/decryption-dealing.wire" \ + --decryption-dealing "$${DKG}/dealings/3/decryption-dealing.wire" \ + --context-dealing "$${DKG}/dealings/1/context-dealing.wire" \ + --context-dealing "$${DKG}/dealings/2/context-dealing.wire" \ + --context-dealing "$${DKG}/dealings/3/context-dealing.wire" \ + --output-directory "$${DKG}/acceptances/$${VALIDATOR}" + done + + for VALIDATOR in 1 2 3; do + case "$${VALIDATOR}" in + 1) SIGNING_KEY="$${MIDEN_VALIDATOR_1_SIGNING_KEY}" ;; + 2) SIGNING_KEY="$${MIDEN_VALIDATOR_2_SIGNING_KEY}" ;; + 3) SIGNING_KEY="$${MIDEN_VALIDATOR_3_SIGNING_KEY}" ;; + esac + STORAGE_KEY="/data/validators/$${VALIDATOR}/storage-key" + miden-validator dkg finalize \ + --genesis /data/genesis/genesis.dat \ + --ceremony-directory "$${DKG}/ceremony" \ + --identity-secret "$${DKG}/identity/$${VALIDATOR}/identity-secret.wire" \ + --private-state "$${DKG}/dealings/$${VALIDATOR}/private-state.wire" \ + --decryption-dealing "$${DKG}/dealings/1/decryption-dealing.wire" \ + --decryption-dealing "$${DKG}/dealings/2/decryption-dealing.wire" \ + --decryption-dealing "$${DKG}/dealings/3/decryption-dealing.wire" \ + --context-dealing "$${DKG}/dealings/1/context-dealing.wire" \ + --context-dealing "$${DKG}/dealings/2/context-dealing.wire" \ + --context-dealing "$${DKG}/dealings/3/context-dealing.wire" \ + --transcript "$${DKG}/acceptances/1/transcript.toml" \ + --transcript-acceptance "$${DKG}/acceptances/1/transcript-acceptance.toml" \ + --transcript-acceptance "$${DKG}/acceptances/2/transcript-acceptance.toml" \ + --transcript-acceptance "$${DKG}/acceptances/3/transcript-acceptance.toml" \ + --output-directory "$${STORAGE_KEY}" + + VALIDATOR_PUBLIC_KEY=$$(miden-validator pubkey --signing-key.hex "$${SIGNING_KEY}") + miden-validator dkg validate \ + --genesis /data/genesis/genesis.dat \ + --ceremony-directory "$${DKG}/ceremony" \ + --validator-public-key "$${VALIDATOR_PUBLIC_KEY}" \ + --bundle-directory "$${STORAGE_KEY}" + done + + rm -rf "$${DKG}" + fi + touch /data/validators/.bootstrapped bootstrap-node: diff --git a/docs/external/src/local-network-development.md b/docs/external/src/local-network-development.md index 50f36049e6..9cb29bcc9c 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -228,14 +228,12 @@ chain data before starting with a different genesis configuration: make local-network-delete ``` -## Storage Key Fixture +## Storage Key Setup -The Compose bootstrap service stages a committed, insecure two-of-three storage-key fixture in each validator data -directory. It gives each validator its own secret share and validates the expected participant index before marking -bootstrap complete. The running validators read only their staged paths; they do not run the production DKG ceremony. - -The fixture is public test data. Never use it outside local development. Run the -[storage key ceremony](./network-operator/validator.md#storage-key-setup) for a real network. +The Compose bootstrap service runs the two-of-three storage key ceremony and validates each validator's output before +starting the network. This can take several minutes. For a faster local start, set +`MIDEN_VALIDATOR_USE_STORAGE_KEY_FIXTURE=true` to use the committed insecure fixture instead. The fixture is public test +data and must never be used outside local development. ## Check the RPC API From f606ef689fb47eb8a3218ec527db0cdb5905a1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 19:15:19 -0400 Subject: [PATCH 14/20] docs(validator): remove unreleased migration note --- docs/external/src/network-operator/validator.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 1056295688..43c627fc1e 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -155,9 +155,6 @@ is the supported provisioning path. Each validator must run inside its trusted execution environment. If transaction proving uses a remote prover, that prover also receives the plaintext inputs and must run inside the same trusted boundary. -This version requires a fresh validator database. Phase 1 client ciphertext cannot be converted into threshold-encrypted -records. - The files contain canonical wire bytes. Every validator uses the same setup context and public key set, but uses its own secret share. The validator will not start if any storage key option is missing or the key material is invalid. After validation, it stores only the transaction ID and the threshold-encrypted record. It does not store the client From d814c2a7eade5c8f7693f5f5007846fdb337457b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 21:51:34 -0400 Subject: [PATCH 15/20] feat(validator): derive storage DKG setup --- bin/validator/src/commands/dkg.rs | 54 ++++++++++++++++++++++--- bin/validator/src/commands/dkg/tests.rs | 52 ++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index dbd1fe325a..150288e5af 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -57,6 +57,8 @@ type PublicOutput = (StorageElement, BTreeMap) const REGISTRATION_VERSION: &str = "miden-storage-key-dkg-registration-v1"; const MANIFEST_VERSION: &str = "miden-storage-key-dkg-manifest-v1"; const REGISTRATION_SIGNATURE_DOMAIN: &[u8] = b"miden-storage-key-dkg-registration-signature-v1"; +const SETUP_BETA_DOMAIN: &[u8] = b"miden-storage-key-dkg-beta-v1"; +const DECRYPTION_SESSION_DOMAIN: &[u8] = b"miden-storage-key-dkg-session-v1"; const IDENTITY_SECRET_MAGIC: &[u8] = b"miden-storage-key-dkg-identity-v1\0"; const IDENTITY_SECRET_FILE: &str = "identity-secret.wire"; const REGISTRATION_FILE: &str = "registration.toml"; @@ -497,9 +499,9 @@ fn prepare( "registration set contains a validator outside genesis" ); - let beta = StorageScalar::random(&mut OsRng); - ensure!(!bool::from(beta.is_zero()), "generated a zero DKG beta"); - let decryption_session_id = SessionId::random(&mut OsRng); + let beta = setup_beta()?; + let decryption_session_id = + derive_decryption_session_id(genesis_commitment, threshold, &epoch, &participants)?; let context_session_id = derive_context_session_id(decryption_session_id); let registry: ParticipantRegistry = ParticipantRegistry::new(registry_entries)?; let decryption_config = @@ -988,7 +990,7 @@ fn read_ceremony(genesis_path: &Path, directory: &Path) -> anyhow::Result(&manifest.epoch, "storage-key epoch")?; + let epoch = decode_fixed_hex::<32>(&manifest.epoch, "storage-key epoch")?; let decryption_bytes = fs_err::read(directory.join(DECRYPTION_CONFIG_FILE)) .context("failed to read decryption configuration")?; @@ -1009,13 +1011,22 @@ fn read_ceremony(genesis_path: &Path, directory: &Path) -> anyhow::Result anyhow::Result anyhow::Result { + StorageScalar::hash_to_scalar(SETUP_BETA_DOMAIN, StorageGroup::BACKEND_ID.as_bytes()) + .context("failed to derive storage key DKG beta") +} + +/// Derives one ceremony session from its agreed public policy and participant registry. +fn derive_decryption_session_id( + genesis_commitment: Word, + threshold: usize, + epoch: &[u8; 32], + participants: &[ManifestParticipant], +) -> anyhow::Result { + let mut digest = Sha256::new(); + digest.update(DECRYPTION_SESSION_DOMAIN); + digest.update(u64::try_from(StorageGroup::BACKEND_ID.len())?.to_be_bytes()); + digest.update(StorageGroup::BACKEND_ID.as_bytes()); + digest.update(genesis_commitment.to_bytes()); + digest.update(u64::try_from(threshold)?.to_be_bytes()); + digest.update(epoch); + digest.update(u64::try_from(participants.len())?.to_be_bytes()); + for participant in participants { + digest.update(participant.participant_index.to_be_bytes()); + let validator_key = decode_validator_public_key(&participant.validator_public_key)?; + let identity_key = decode_identity_public_key(&participant.dkg_identity_public_key)?; + digest.update(validator_key.to_bytes()); + digest.update(StorageGroup::encode_element(&identity_key).as_ref()); + } + Ok(SessionId(digest.finalize().into())) +} + /// Returns the manifest participant whose public identity matches a secret. fn participant_for_identity( manifest: &Manifest, diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 73461cc5c1..080a9e5829 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -153,9 +153,11 @@ async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { registrations.push(directory.join(REGISTRATION_FILE)); } let output = root.path().join("ceremony"); + let second_output = root.path().join("ceremony-copy"); let epoch = "11".repeat(32); prepare(&genesis.path, 2, &epoch, ®istrations, &output)?; + prepare(&genesis.path, 2, &epoch, ®istrations, &second_output)?; let manifest: Manifest = toml::from_str(&fs_err::read_to_string(output.join(MANIFEST_FILE))?)?; let decryption_bytes = fs_err::read(output.join(DECRYPTION_CONFIG_FILE))?; @@ -169,6 +171,7 @@ async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { assert_eq!(manifest.context_config_sha256, sha256_hex(&context_bytes)); assert_eq!(decryption.threshold, 2); assert_eq!(context.threshold, 2); + assert_eq!(decryption.beta, setup_beta()?); assert_eq!(decryption.registry, context.registry); assert_eq!(context.session_id, derive_context_session_id(decryption.session_id)); for ((position, participant), validator_key) in @@ -177,6 +180,49 @@ async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { assert_eq!(participant.participant_index, u32::try_from(position + 1)?); assert_eq!(participant.validator_public_key, hex::encode(validator_key.to_bytes())); } + for name in [MANIFEST_FILE, DECRYPTION_CONFIG_FILE, CONTEXT_CONFIG_FILE] { + assert_eq!(fs_err::read(output.join(name))?, fs_err::read(second_output.join(name))?); + } + Ok(()) +} + +#[tokio::test] +async fn ceremony_rejects_a_substituted_session_with_matching_config_digests() -> TestResult { + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let mut manifest: Manifest = + toml::from_str(&fs_err::read_to_string(ceremony.ceremony.join(MANIFEST_FILE))?)?; + let decryption_bytes = fs_err::read(ceremony.ceremony.join(DECRYPTION_CONFIG_FILE))?; + let decryption: DkgConfig = from_wire_bytes(&decryption_bytes)?; + let wrong_session = SessionId([0x55; 32]); + assert_ne!(wrong_session, decryption.session_id); + let beta = decryption.beta; + + let wrong_decryption = + DkgConfig::new(decryption.threshold, wrong_session, beta, decryption.registry.clone())?; + let wrong_context = DkgConfig::new( + decryption.threshold, + derive_context_session_id(wrong_session), + beta, + decryption.registry, + )?; + let wrong_decryption = to_wire_bytes(&wrong_decryption); + let wrong_context = to_wire_bytes(&wrong_context); + manifest.decryption_session_id = hex::encode(wrong_session.0); + manifest.context_session_id = hex::encode(derive_context_session_id(wrong_session).0); + manifest.decryption_config_sha256 = sha256_hex(&wrong_decryption); + manifest.context_config_sha256 = sha256_hex(&wrong_context); + fs_err::write(ceremony.ceremony.join(DECRYPTION_CONFIG_FILE), wrong_decryption)?; + fs_err::write(ceremony.ceremony.join(CONTEXT_CONFIG_FILE), wrong_context)?; + fs_err::write(ceremony.ceremony.join(MANIFEST_FILE), toml::to_string_pretty(&manifest)?)?; + + let Err(error) = read_ceremony(&ceremony.genesis.path, &ceremony.ceremony) else { + panic!("substituted session was accepted"); + }; + assert!( + format!("{error:#}").contains("decryption session mismatch"), + "unexpected error: {error:#}", + ); Ok(()) } @@ -611,9 +657,9 @@ async fn finalize_rejects_manifest_changed_after_acceptance() -> TestResult { let dealings = deal_for_all(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let manifest_path = ceremony.ceremony.join(MANIFEST_FILE); - let mut manifest: Manifest = toml::from_str(&fs_err::read_to_string(&manifest_path)?)?; - manifest.epoch = "55".repeat(32); - fs_err::write(&manifest_path, toml::to_string_pretty(&manifest)?)?; + let mut manifest = fs_err::read_to_string(&manifest_path)?; + manifest.push_str("# changed after transcript acceptance\n"); + fs_err::write(&manifest_path, manifest)?; let output = root.path().join("bundle"); let error = finalize::( From 740d6535fa6e7cd2bb8ea3ecef99b5d045ce6851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Wed, 5 Aug 2026 22:03:03 -0400 Subject: [PATCH 16/20] feat(validator): prove storage DKG identity ownership --- bin/validator/src/commands/dkg.rs | 175 ++++++++++++++++-- bin/validator/src/commands/dkg/tests.rs | 119 ++++++++++-- compose/bootstrap.yml | 1 + .../src/network-operator/validator.md | 10 +- 4 files changed, 275 insertions(+), 30 deletions(-) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 150288e5af..5e98f6dac0 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -54,9 +54,10 @@ type StorageScalar = ::Scalar; type StorageElement = ::Element; type PublicOutput = (StorageElement, BTreeMap); -const REGISTRATION_VERSION: &str = "miden-storage-key-dkg-registration-v1"; -const MANIFEST_VERSION: &str = "miden-storage-key-dkg-manifest-v1"; -const REGISTRATION_SIGNATURE_DOMAIN: &[u8] = b"miden-storage-key-dkg-registration-signature-v1"; +const REGISTRATION_VERSION: &str = "miden-storage-key-dkg-registration-v2"; +const MANIFEST_VERSION: &str = "miden-storage-key-dkg-manifest-v2"; +const REGISTRATION_SIGNATURE_DOMAIN: &[u8] = b"miden-storage-key-dkg-registration-signature-v2"; +const IDENTITY_PROOF_DOMAIN: &[u8] = b"miden-storage-key-dkg-identity-proof-v1"; const SETUP_BETA_DOMAIN: &[u8] = b"miden-storage-key-dkg-beta-v1"; const DECRYPTION_SESSION_DOMAIN: &[u8] = b"miden-storage-key-dkg-session-v1"; const IDENTITY_SECRET_MAGIC: &[u8] = b"miden-storage-key-dkg-identity-v1\0"; @@ -96,6 +97,10 @@ enum DkgCommand { #[arg(long, value_name = "FILE")] genesis: PathBuf, + /// Hex-encoded 32-byte storage-key epoch. + #[arg(long, value_name = "HEX")] + epoch: String, + /// Validator signing key committed by genesis. #[command(flatten)] signing_key: ValidatorSigningKey, @@ -248,8 +253,11 @@ enum DkgCommand { struct Registration { version: String, genesis_commitment: String, + epoch: String, validator_public_key: String, dkg_identity_public_key: String, + identity_proof_commitment: String, + identity_proof_response: String, validator_signature: String, } @@ -325,9 +333,14 @@ struct TranscriptAcceptances { /// Runs one DKG ceremony command. pub async fn run(options: DkgOptions) -> anyhow::Result<()> { match options.command { - DkgCommand::Identity { genesis, signing_key, output_directory } => { + DkgCommand::Identity { + genesis, + epoch, + signing_key, + output_directory, + } => { let signer = signing_key.into_signer().await?; - generate_identity(&genesis, &signer, &output_directory).await + generate_identity(&genesis, &epoch, &signer, &output_directory).await }, DkgCommand::Prepare { genesis, @@ -405,9 +418,11 @@ pub async fn run(options: DkgOptions) -> anyhow::Result<()> { /// Generates one validator's private DKG identity and public registration. async fn generate_identity( genesis_path: &Path, + epoch: &str, signer: &ValidatorSigner, output_directory: &Path, ) -> anyhow::Result<()> { + let epoch = decode_fixed_hex::<32>(epoch, "storage-key epoch")?; let genesis = read_trusted_genesis(genesis_path)?; let genesis_commitment = genesis.inner().header().commitment(); let validator_public_key = signer.public_key(); @@ -423,10 +438,20 @@ async fn generate_identity( let identity_secret = StorageScalar::random(&mut OsRng); ensure!(!bool::from(identity_secret.is_zero()), "generated a zero DKG identity secret"); let identity_public_key = StorageGroup::mul_generator(&identity_secret); + let (proof_commitment, proof_response) = create_identity_proof( + genesis_commitment, + &epoch, + &validator_public_key, + &identity_secret, + &mut OsRng, + )?; let signature_commitment = registration_signature_commitment( genesis_commitment, + &epoch, &validator_public_key, &identity_public_key, + &proof_commitment, + &proof_response, ); let validator_signature = signer .sign_commitment(signature_commitment) @@ -436,8 +461,11 @@ async fn generate_identity( let registration = Registration { version: REGISTRATION_VERSION.to_owned(), genesis_commitment: hex::encode(genesis_commitment.to_bytes()), + epoch: hex::encode(epoch), validator_public_key: hex::encode(validator_public_key.to_bytes()), dkg_identity_public_key: hex::encode(StorageGroup::encode_element(&identity_public_key)), + identity_proof_commitment: hex::encode(StorageGroup::encode_element(&proof_commitment)), + identity_proof_response: hex::encode(proof_response.to_repr()), validator_signature: hex::encode(validator_signature.to_bytes()), }; let registration = @@ -473,7 +501,8 @@ fn prepare( registration_paths.len(), ); - let mut registrations = read_validated_registrations(registration_paths, genesis_commitment)?; + let mut registrations = + read_validated_registrations(registration_paths, genesis_commitment, &epoch)?; let mut registry_entries = Vec::with_capacity(validator_keys.len()); let mut participants = Vec::with_capacity(validator_keys.len()); @@ -935,6 +964,7 @@ fn read_registration(path: &Path) -> anyhow::Result { fn read_validated_registrations( paths: &[PathBuf], genesis_commitment: Word, + expected_epoch: &[u8; 32], ) -> anyhow::Result, ::Element>> { let mut registrations = BTreeMap::new(); let mut identity_keys = BTreeSet::new(); @@ -942,6 +972,10 @@ fn read_validated_registrations( let registration = read_registration(path)?; let validator_key = decode_validator_public_key(®istration.validator_public_key)?; let identity_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + let proof_commitment = + decode_non_identity_element(®istration.identity_proof_commitment, "identity proof")?; + let proof_response = + decode_scalar(®istration.identity_proof_response, "identity proof response")?; let signature = decode_validator_signature(®istration.validator_signature)?; ensure!( @@ -949,18 +983,38 @@ fn read_validated_registrations( "registration in {} belongs to a different genesis block", path.display(), ); + ensure!( + registration.epoch == hex::encode(expected_epoch), + "registration in {} belongs to a different storage-key epoch", + path.display(), + ); ensure!( signature.verify( registration_signature_commitment( genesis_commitment, + expected_epoch, &validator_key, &identity_key, + &proof_commitment, + &proof_response, ), &validator_key, ), "invalid validator signature in {}", path.display(), ); + ensure!( + verify_identity_proof( + genesis_commitment, + expected_epoch, + &validator_key, + &identity_key, + &proof_commitment, + &proof_response, + )?, + "invalid DKG identity proof in {}", + path.display(), + ); ensure!( identity_keys.insert(StorageGroup::encode_element(&identity_key).as_ref().to_vec()), "duplicate DKG identity public key in {}", @@ -1550,22 +1604,105 @@ fn read_trusted_genesis(path: &Path) -> anyhow::Result { /// Commits a validator signature to one genesis-bound DKG identity registration. fn registration_signature_commitment( genesis_commitment: Word, + epoch: &[u8; 32], validator_public_key: &PublicKey, identity_public_key: &::Element, + proof_commitment: &::Element, + proof_response: &StorageScalar, ) -> Word { let mut bytes = Vec::with_capacity( REGISTRATION_SIGNATURE_DOMAIN.len() + Word::SERIALIZED_SIZE + + epoch.len() + validator_public_key.to_bytes().len() - + StorageGroup::ELEMENT_REPR_BYTES, + + StorageGroup::ELEMENT_REPR_BYTES * 2 + + StorageScalar::REPR_BYTES, ); bytes.extend_from_slice(REGISTRATION_SIGNATURE_DOMAIN); bytes.extend_from_slice(&genesis_commitment.to_bytes()); + bytes.extend_from_slice(epoch); bytes.extend_from_slice(&validator_public_key.to_bytes()); bytes.extend_from_slice(StorageGroup::encode_element(identity_public_key).as_ref()); + bytes.extend_from_slice(StorageGroup::encode_element(proof_commitment).as_ref()); + bytes.extend_from_slice(proof_response.to_repr().as_ref()); Rpo256::hash(&bytes) } +/// Creates a proof that the registering validator knows its DKG identity secret. +fn create_identity_proof( + genesis_commitment: Word, + epoch: &[u8; 32], + validator_public_key: &PublicKey, + identity_secret: &StorageScalar, + rng: &mut impl CryptoRngCore, +) -> anyhow::Result<(StorageElement, StorageScalar)> { + let identity_public_key = StorageGroup::mul_generator(identity_secret); + let nonce = loop { + let nonce = StorageScalar::random(rng); + if !bool::from(nonce.is_zero()) { + break nonce; + } + }; + let commitment = StorageGroup::mul_generator(&nonce); + let challenge = identity_proof_challenge( + genesis_commitment, + epoch, + validator_public_key, + &identity_public_key, + &commitment, + )?; + let response = nonce.add(&challenge.mul(identity_secret)); + Ok((commitment, response)) +} + +/// Checks a proof that the registering validator knows its DKG identity secret. +fn verify_identity_proof( + genesis_commitment: Word, + epoch: &[u8; 32], + validator_public_key: &PublicKey, + identity_public_key: &StorageElement, + commitment: &StorageElement, + response: &StorageScalar, +) -> anyhow::Result { + let challenge = identity_proof_challenge( + genesis_commitment, + epoch, + validator_public_key, + identity_public_key, + commitment, + )?; + let expected = + StorageGroup::add(commitment, &StorageGroup::mul(identity_public_key, &challenge)); + Ok(StorageGroup::mul_generator(response) == expected) +} + +/// Derives the Fiat-Shamir challenge for one DKG identity proof. +fn identity_proof_challenge( + genesis_commitment: Word, + epoch: &[u8; 32], + validator_public_key: &PublicKey, + identity_public_key: &StorageElement, + commitment: &StorageElement, +) -> anyhow::Result { + let mut message = Vec::with_capacity( + std::mem::size_of::() + + StorageGroup::BACKEND_ID.len() + + Word::SERIALIZED_SIZE + + epoch.len() + + validator_public_key.to_bytes().len() + + StorageGroup::ELEMENT_REPR_BYTES * 2, + ); + message.extend_from_slice(&u64::try_from(StorageGroup::BACKEND_ID.len())?.to_be_bytes()); + message.extend_from_slice(StorageGroup::BACKEND_ID.as_bytes()); + message.extend_from_slice(&genesis_commitment.to_bytes()); + message.extend_from_slice(epoch); + message.extend_from_slice(&validator_public_key.to_bytes()); + message.extend_from_slice(StorageGroup::encode_element(identity_public_key).as_ref()); + message.extend_from_slice(StorageGroup::encode_element(commitment).as_ref()); + StorageScalar::hash_to_scalar(IDENTITY_PROOF_DOMAIN, &message) + .context("failed to derive DKG identity proof challenge") +} + /// Parses a validator public key and requires its canonical hex form. fn decode_validator_public_key(value: &str) -> anyhow::Result { let bytes = decode_hex(value, "validator public key")?; @@ -1586,18 +1723,28 @@ fn decode_validator_signature(value: &str) -> anyhow::Result { fn decode_identity_public_key( value: &str, ) -> anyhow::Result<::Element> { - let bytes = decode_hex(value, "DKG identity public key")?; + decode_non_identity_element(value, "DKG identity public key") +} + +/// Parses a canonical non-identity group element. +fn decode_non_identity_element(value: &str, name: &str) -> anyhow::Result { + let bytes = decode_hex(value, name)?; let repr = ::ElementRepr::try_from(bytes) - .map_err(|_| anyhow::anyhow!("invalid DKG identity public key length"))?; + .map_err(|_| anyhow::anyhow!("invalid {name} length"))?; let public_key = - StorageGroup::decode_element(&repr).context("invalid DKG identity public key")?; - ensure!( - !bool::from(StorageGroup::is_identity(&public_key)), - "DKG identity public key is the identity" - ); + StorageGroup::decode_element(&repr).with_context(|| format!("invalid {name}"))?; + ensure!(!bool::from(StorageGroup::is_identity(&public_key)), "{name} is the identity"); Ok(public_key) } +/// Parses a canonical scalar. +fn decode_scalar(value: &str, name: &str) -> anyhow::Result { + let bytes = decode_hex(value, name)?; + let repr = ::Repr::try_from(bytes) + .map_err(|_| anyhow::anyhow!("invalid {name} length"))?; + StorageScalar::from_repr(&repr).with_context(|| format!("invalid {name}")) +} + /// Encodes a private DKG identity with a fixed format marker. fn encode_identity_secret(secret: &StorageScalar) -> Zeroizing> { let mut encoded = diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 080a9e5829..59f5942766 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -101,19 +101,39 @@ async fn identity_round_trip_matches_public_registration() -> TestResult { let validator_key = signing_key.public_key(); let signer = ValidatorSigner::new_local(signing_key); let output = root.path().join("identity"); + let epoch = "10".repeat(32); - generate_identity(&genesis.path, &signer, &output).await?; + generate_identity(&genesis.path, &epoch, &signer, &output).await?; let registration = read_registration(&output.join(REGISTRATION_FILE))?; let secret_bytes = Zeroizing::new(fs_err::read(output.join(IDENTITY_SECRET_FILE))?); let secret = decode_identity_secret(&secret_bytes)?; let public_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; let signature = decode_validator_signature(®istration.validator_signature)?; + let proof_commitment = + decode_non_identity_element(®istration.identity_proof_commitment, "identity proof")?; + let proof_response = decode_scalar(®istration.identity_proof_response, "proof response")?; let genesis_commitment = read_trusted_genesis(&genesis.path)?.inner().header().commitment(); + let epoch = decode_fixed_hex::<32>(&epoch, "storage-key epoch")?; assert_eq!(StorageGroup::mul_generator(&secret), public_key); assert_eq!(registration.validator_public_key, hex::encode(validator_key.to_bytes())); + assert!(verify_identity_proof( + genesis_commitment, + &epoch, + &validator_key, + &public_key, + &proof_commitment, + &proof_response, + )?); assert!(signature.verify( - registration_signature_commitment(genesis_commitment, &validator_key, &public_key), + registration_signature_commitment( + genesis_commitment, + &epoch, + &validator_key, + &public_key, + &proof_commitment, + &proof_response, + ), &validator_key, )); @@ -141,11 +161,13 @@ fn identity_secret_rejects_malformed_input() { async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { let root = tempfile::tempdir()?; let genesis = write_genesis(root.path())?; + let epoch = "11".repeat(32); let mut registrations = Vec::new(); for (position, signing_key) in genesis.signing_keys.iter().rev().enumerate() { let directory = root.path().join(format!("identity-{position}")); generate_identity( &genesis.path, + &epoch, &ValidatorSigner::new_local(signing_key.clone()), &directory, ) @@ -154,8 +176,6 @@ async fn prepare_binds_configs_to_canonical_genesis_order() -> TestResult { } let output = root.path().join("ceremony"); let second_output = root.path().join("ceremony-copy"); - let epoch = "11".repeat(32); - prepare(&genesis.path, 2, &epoch, ®istrations, &output)?; prepare(&genesis.path, 2, &epoch, ®istrations, &second_output)?; @@ -232,9 +252,14 @@ async fn identity_rejects_signer_outside_genesis() -> TestResult { let genesis = write_genesis(root.path())?; let outsider = ValidatorSigner::new_local(SigningKey::new()); - let error = generate_identity(&genesis.path, &outsider, &root.path().join("identity")) - .await - .unwrap_err(); + let error = generate_identity( + &genesis.path, + &"12".repeat(32), + &outsider, + &root.path().join("identity"), + ) + .await + .unwrap_err(); assert!(format!("{error:#}").contains("not committed by genesis")); Ok(()) } @@ -243,11 +268,13 @@ async fn identity_rejects_signer_outside_genesis() -> TestResult { async fn prepare_rejects_substituted_dkg_identity() -> TestResult { let root = tempfile::tempdir()?; let genesis = write_genesis(root.path())?; + let epoch = "22".repeat(32); let mut registrations = Vec::new(); for (position, signing_key) in genesis.signing_keys.iter().enumerate() { let directory = root.path().join(format!("identity-{position}")); generate_identity( &genesis.path, + &epoch, &ValidatorSigner::new_local(signing_key.clone()), &directory, ) @@ -262,16 +289,80 @@ async fn prepare_rejects_substituted_dkg_identity() -> TestResult { )); fs_err::write(®istrations[0], toml::to_string_pretty(®istration)?)?; + let error = prepare(&genesis.path, 2, &epoch, ®istrations, &root.path().join("ceremony")) + .unwrap_err(); + assert!( + format!("{error:#}").contains("invalid validator signature"), + "unexpected error: {error:#}", + ); + Ok(()) +} + +#[tokio::test] +async fn prepare_rejects_a_signed_registration_without_a_valid_identity_proof() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis_with_validator_count(root.path(), 1)?; + let epoch = "23".repeat(32); + let epoch_bytes = decode_fixed_hex::<32>(&epoch, "storage-key epoch")?; + let signing_key = genesis.signing_keys[0].clone(); + let signer = ValidatorSigner::new_local(signing_key.clone()); + let identity = root.path().join("identity"); + generate_identity(&genesis.path, &epoch, &signer, &identity).await?; + let registration_path = identity.join(REGISTRATION_FILE); + let mut registration = read_registration(®istration_path)?; + let identity_key = decode_identity_public_key(®istration.dkg_identity_public_key)?; + let proof_commitment = + decode_non_identity_element(®istration.identity_proof_commitment, "identity proof")?; + let bad_response = StorageScalar::zero(); + registration.identity_proof_response = hex::encode(bad_response.to_repr()); + let genesis_commitment = read_trusted_genesis(&genesis.path)?.inner().header().commitment(); + let validator_key = signing_key.public_key(); + let signature = signer + .sign_commitment(registration_signature_commitment( + genesis_commitment, + &epoch_bytes, + &validator_key, + &identity_key, + &proof_commitment, + &bad_response, + )) + .await?; + registration.validator_signature = hex::encode(signature.to_bytes()); + fs_err::write(®istration_path, toml::to_string_pretty(®istration)?)?; + + let error = + prepare(&genesis.path, 1, &epoch, &[registration_path], &root.path().join("ceremony")) + .unwrap_err(); + assert!( + format!("{error:#}").contains("invalid DKG identity proof"), + "unexpected error: {error:#}", + ); + Ok(()) +} + +#[tokio::test] +async fn prepare_rejects_a_registration_from_another_epoch() -> TestResult { + let root = tempfile::tempdir()?; + let genesis = write_genesis_with_validator_count(root.path(), 1)?; + let identity = root.path().join("identity"); + generate_identity( + &genesis.path, + &"24".repeat(32), + &ValidatorSigner::new_local(genesis.signing_keys[0].clone()), + &identity, + ) + .await?; + let error = prepare( &genesis.path, - 2, - &"22".repeat(32), - ®istrations, + 1, + &"25".repeat(32), + &[identity.join(REGISTRATION_FILE)], &root.path().join("ceremony"), ) .unwrap_err(); assert!( - format!("{error:#}").contains("invalid validator signature"), + format!("{error:#}").contains("different storage-key epoch"), "unexpected error: {error:#}", ); Ok(()) @@ -291,12 +382,14 @@ async fn prepare_test_ceremony( threshold: usize, ) -> TestResultWith { let genesis = write_genesis_with_validator_count(root, validator_count)?; + let epoch = "33".repeat(32); let mut registrations = Vec::new(); let mut identities = Vec::new(); for (position, signing_key) in genesis.signing_keys.iter().enumerate() { let directory = root.join(format!("identity-{position}")); generate_identity( &genesis.path, + &epoch, &ValidatorSigner::new_local(signing_key.clone()), &directory, ) @@ -305,7 +398,7 @@ async fn prepare_test_ceremony( identities.push(directory); } let ceremony = root.join("ceremony"); - prepare(&genesis.path, threshold, &"33".repeat(32), ®istrations, &ceremony)?; + prepare(&genesis.path, threshold, &epoch, ®istrations, &ceremony)?; Ok(TestCeremony { genesis, ceremony, identities }) } @@ -695,7 +788,7 @@ async fn private_state_cannot_cross_ceremonies() -> TestResult { .map(|identity| identity.join(REGISTRATION_FILE)) .collect::>(); let second_ceremony = second_root.join("ceremony"); - prepare(&first.genesis.path, 2, &"44".repeat(32), ®istrations, &second_ceremony)?; + prepare(&first.genesis.path, 3, &"33".repeat(32), ®istrations, &second_ceremony)?; let second = TestCeremony { genesis: first.genesis.clone(), ceremony: second_ceremony, diff --git a/compose/bootstrap.yml b/compose/bootstrap.yml index 1b79a4b5ee..9603cac7f8 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -83,6 +83,7 @@ services: esac miden-validator dkg identity \ --genesis /data/genesis/genesis.dat \ + --epoch "$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" \ --signing-key.hex "$${SIGNING_KEY}" \ --output-directory "$${DKG}/identity/$${VALIDATOR}" done diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 43c627fc1e..3250208bc3 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -33,17 +33,21 @@ current block. The DKG creates the storage key used to re-encrypt validated private inputs. Run one ceremony for the validator set committed in genesis. Participant indexes follow the order of validator signing keys in the genesis block. -First, each operator creates a DKG identity and sends `registration.toml` to the coordinator. The signing key must match -one key in genesis. Use `--signing-key.hex` instead of KMS only for local or private deployments. +First, each operator creates a DKG identity for the agreed storage-key epoch and sends `registration.toml` to the +coordinator. The registration proves ownership of the DKG identity secret. The signing key must match one key in +genesis. Use `--signing-key.hex` instead of KMS only for local or private deployments. ```bash miden-validator dkg identity \ --genesis genesis.dat \ + --epoch <32-byte-hex-epoch> \ --signing-key.kms-id \ --output-directory identity ``` -The coordinator collects every registration and prepares one common ceremony directory. +The coordinator collects every registration and prepares one common ceremony directory. The setup coefficient is fixed +by the validator backend. The session ID is derived from genesis, the epoch, the threshold, and the ordered +registrations, so every operator can reproduce the same files. ```bash miden-validator dkg prepare \ From 399625bc574c651741b43aa0dea3d5a377b39325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Thu, 6 Aug 2026 11:55:42 -0400 Subject: [PATCH 17/20] fix(validator): verify DKG dealings before acceptance --- bin/validator/src/commands/dkg.rs | 9 +++++ bin/validator/src/commands/dkg/tests.rs | 34 +++++++++++++++++++ .../src/network-operator/validator.md | 7 ++++ 3 files changed, 50 insertions(+) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 5e98f6dac0..992c404856 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -20,6 +20,7 @@ use golden_core::{ complete, create_dealing, create_dealing_with_secret, + verify_dealing, }; use golden_ehtdh1::wire::to_wire_bytes as to_ehtdh1_wire_bytes; use golden_ehtdh1::{ @@ -1211,6 +1212,14 @@ where let expected = ceremony.manifest.participants.len(); let decryption_dealings = read_dealings::(decryption_paths, expected)?; let context_dealings = read_dealings::(context_paths, expected)?; + for message in decryption_dealings.values() { + verify_dealing::(message, &ceremony.decryption_config) + .context("invalid decryption dealing")?; + } + for message in context_dealings.values() { + verify_dealing::(message, &ceremony.context_config) + .context("invalid context dealing")?; + } let public_key_set = public_key_set_from_dealings( &decryption_dealings, &context_dealings, diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 59f5942766..0157118dc1 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -681,6 +681,40 @@ async fn finalize_rejects_tampered_dealing_without_partial_output() -> TestResul Ok(()) } +#[tokio::test] +async fn accept_rejects_a_dealing_from_another_session() -> TestResult { + type FastDealerMessage = + DealerMessage>::Proof>; + + let root = tempfile::tempdir()?; + let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; + let dealings = deal_for_all(root.path(), &ceremony)?; + let substituted = root.path().join("wrong-session.wire"); + let mut message = from_wire_bytes::(&fs_err::read( + dealings[1].join(DECRYPTION_DEALING_FILE), + )?)?; + message.session_id = SessionId([0x55; 32]); + fs_err::write(&substituted, to_wire_bytes(&message))?; + let mut decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); + decryption[1] = substituted; + let output = root.path().join("acceptance"); + + let error = accept_transcript::( + &ceremony.genesis.path, + &ceremony.ceremony, + &ValidatorSigner::new_local(ceremony.genesis.signing_keys[0].clone()), + &decryption, + &dealing_paths(&dealings, CONTEXT_DEALING_FILE), + &output, + ) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("session mismatch")); + assert!(!output.exists()); + Ok(()) +} + #[tokio::test] async fn finalize_rejects_valid_dealer_equivocation() -> TestResult { let root = tempfile::tempdir()?; diff --git a/docs/external/src/network-operator/validator.md b/docs/external/src/network-operator/validator.md index 3250208bc3..a1f93c0cbc 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -33,6 +33,13 @@ current block. The DKG creates the storage key used to re-encrypt validated private inputs. Run one ceremony for the validator set committed in genesis. Participant indexes follow the order of validator signing keys in the genesis block. +The threshold is network policy. A threshold of `t` lets any `t` validators decrypt a stored record; fewer validators +cannot. Choose it from the network's confidentiality and availability needs before the ceremony starts. + +This flow supports initial storage-key bootstrap only. The validator loads one storage-key epoch. Rotation, creating new +shares, and validator-set changes are not yet supported. Keep each operator bundle available for as long as records from +its epoch may need to be decrypted. + First, each operator creates a DKG identity for the agreed storage-key epoch and sends `registration.toml` to the coordinator. The registration proves ownership of the DKG identity secret. The signing key must match one key in genesis. Use `--signing-key.hex` instead of KMS only for local or private deployments. From dda3c4cfff78b05471c6c2288b0bab0fa87fa25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Thu, 6 Aug 2026 12:01:02 -0400 Subject: [PATCH 18/20] fix(validator): bind accepted DKG hashes to verified bytes --- bin/validator/src/commands/dkg.rs | 97 ++++++++++++------------------- 1 file changed, 38 insertions(+), 59 deletions(-) diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 992c404856..4dd24e0df4 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -331,6 +331,11 @@ struct TranscriptAcceptances { acceptances: Vec, } +struct DealingSet

{ + messages: BTreeMap>, + hashes: Vec, +} + /// Runs one DKG ceremony command. pub async fn run(options: DkgOptions) -> anyhow::Result<()> { match options.command { @@ -731,19 +736,18 @@ where sha256(&transcript_bytes), )?; - let decryption_dealings = + let decryption = read_dealings::(decryption_dealing_paths, ceremony.manifest.participants.len())?; - let context_dealings = - read_dealings::(context_dealing_paths, ceremony.manifest.participants.len())?; + let context = read_dealings::(context_dealing_paths, ceremony.manifest.participants.len())?; validate_dealings_against_transcript::( - &decryption_dealings, - decryption_dealing_paths, + &decryption.messages, + &decryption.hashes, &transcript.decryption_dealings, &transcript.decryption_transcript_root, )?; validate_dealings_against_transcript::( - &context_dealings, - context_dealing_paths, + &context.messages, + &context.hashes, &transcript.context_dealings, &transcript.context_transcript_root, )?; @@ -751,7 +755,7 @@ where println!( "Completing decryption round for participant {} with {} dealings.", participant.get(), - decryption_dealings.len(), + decryption.messages.len(), ); let started = Instant::now(); let decryption_output = complete_round::( @@ -759,7 +763,7 @@ where &identity_secret, &private_state.decryption_private_share, private_state.decryption_message_sha256, - decryption_dealings, + decryption.messages, &ceremony.decryption_config, ) .context("failed to complete decryption round")?; @@ -772,7 +776,7 @@ where println!( "Completing context round for participant {} with {} dealings.", participant.get(), - context_dealings.len(), + context.messages.len(), ); let started = Instant::now(); let context_output = complete_round::( @@ -780,7 +784,7 @@ where &identity_secret, &private_state.context_private_share, private_state.context_message_sha256, - context_dealings, + context.messages, &ceremony.context_config, ) .context("failed to complete context round")?; @@ -1173,16 +1177,14 @@ fn participant_for_identity( } /// Reads exactly one public dealing from every ceremony participant. -fn read_dealings( - paths: &[PathBuf], - expected: usize, -) -> anyhow::Result>> +fn read_dealings(paths: &[PathBuf], expected: usize) -> anyhow::Result> where B: EvrfProofBackend, B::Proof: WireMessage, { ensure!(paths.len() == expected, "expected {expected} dealings, got {}", paths.len()); let mut dealings = BTreeMap::new(); + let mut hashes = BTreeMap::new(); for path in paths { let bytes = fs_err::read(path) .with_context(|| format!("failed to read dealing {}", path.display()))?; @@ -1194,9 +1196,17 @@ where "duplicate dealing from participant {}", dealer.get(), ); + hashes.insert(dealer, sha256_hex(&bytes)); } ensure!(dealings.len() == expected, "dealing set is incomplete"); - Ok(dealings) + let hashes = hashes + .into_iter() + .map(|(participant, sha256)| TranscriptDealing { + participant_index: participant.get(), + sha256, + }) + .collect(); + Ok(DealingSet { messages: dealings, hashes }) } /// Builds the canonical transcript over one manifest and both dealing rounds. @@ -1210,29 +1220,29 @@ where B::Proof: WireMessage, { let expected = ceremony.manifest.participants.len(); - let decryption_dealings = read_dealings::(decryption_paths, expected)?; - let context_dealings = read_dealings::(context_paths, expected)?; - for message in decryption_dealings.values() { + let decryption = read_dealings::(decryption_paths, expected)?; + let context = read_dealings::(context_paths, expected)?; + for message in decryption.messages.values() { verify_dealing::(message, &ceremony.decryption_config) .context("invalid decryption dealing")?; } - for message in context_dealings.values() { + for message in context.messages.values() { verify_dealing::(message, &ceremony.context_config) .context("invalid context dealing")?; } let public_key_set = public_key_set_from_dealings( - &decryption_dealings, - &context_dealings, + &decryption.messages, + &context.messages, &ceremony.decryption_config, )?; let transcript = CeremonyTranscript { version: TRANSCRIPT_VERSION.to_owned(), manifest_sha256: hex::encode(ceremony.manifest_sha256), - decryption_transcript_root: hex::encode(completion_root(&decryption_dealings)), - context_transcript_root: hex::encode(completion_root(&context_dealings)), + decryption_transcript_root: hex::encode(completion_root(&decryption.messages)), + context_transcript_root: hex::encode(completion_root(&context.messages)), public_key_set_sha256: sha256_hex(&to_ehtdh1_wire_bytes(&public_key_set)), - decryption_dealings: dealing_hashes::(decryption_paths, expected)?, - context_dealings: dealing_hashes::(context_paths, expected)?, + decryption_dealings: decryption.hashes, + context_dealings: context.hashes, }; let bytes = toml::to_string_pretty(&transcript) .context("failed to encode DKG transcript")? @@ -1347,7 +1357,7 @@ fn validate_transcript_acceptances( /// Recomputes one round's canonical dealing hashes and completion root. fn validate_dealings_against_transcript( dealings: &BTreeMap>, - paths: &[PathBuf], + actual_hashes: &[TranscriptDealing], expected_hashes: &[TranscriptDealing], expected_root: &str, ) -> anyhow::Result<()> @@ -1355,10 +1365,7 @@ where B: EvrfProofBackend, B::Proof: WireMessage, { - ensure!( - dealing_hashes::(paths, dealings.len())? == expected_hashes, - "dealings do not match accepted transcript", - ); + ensure!(actual_hashes == expected_hashes, "dealings do not match accepted transcript"); ensure!( hex::encode(completion_root(dealings)) == expected_root, "dealing roots do not match accepted transcript", @@ -1366,34 +1373,6 @@ where Ok(()) } -/// Returns canonical hashes for dealing files sorted by participant. -fn dealing_hashes(paths: &[PathBuf], expected: usize) -> anyhow::Result> -where - B: EvrfProofBackend, - B::Proof: WireMessage, -{ - let mut hashes = BTreeMap::new(); - for path in paths { - let bytes = fs_err::read(path) - .with_context(|| format!("failed to read dealing {}", path.display()))?; - let message = from_core_wire_bytes::>(&bytes) - .with_context(|| format!("invalid dealing {}", path.display()))?; - ensure!( - hashes.insert(message.dealer, sha256_hex(&bytes)).is_none(), - "duplicate dealing from participant {}", - message.dealer.get(), - ); - } - ensure!(hashes.len() == expected, "dealing set is incomplete"); - Ok(hashes - .into_iter() - .map(|(participant, sha256)| TranscriptDealing { - participant_index: participant.get(), - sha256, - }) - .collect()) -} - /// Derives the EHTDH1 public key set from the accepted Feldman commitments. fn public_key_set_from_dealings( decryption: &BTreeMap>, From 2876a1955e4a7950bf49e1684da9353e3ed50873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 10 Aug 2026 07:44:56 -0400 Subject: [PATCH 19/20] chore(validator): upgrade Golden crates to 0.2 --- Cargo.lock | 21 ++++----- Cargo.toml | 8 ++-- bin/validator/src/commands/dkg.rs | 58 +++++++++---------------- bin/validator/src/commands/dkg/tests.rs | 4 +- 4 files changed, 37 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd2fa9cbc5..e94dcca4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,14 +1197,15 @@ dependencies = [ [[package]] name = "bulletproofs-cycle" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea602e35423dd56bc7909d70a3dfb8557b48e198c7a84ae64851069361144263" +checksum = "5b6f62956005907a28773784fc65460508de73a9f7eb11d27ec14ee30ffeae2c" dependencies = [ "digest 0.10.7", "ff 0.13.1", "group 0.13.0", "merlin", + "p3-maybe-rayon", "rand_core 0.6.4", "sha3 0.10.9", "subtle", @@ -2560,9 +2561,9 @@ dependencies = [ [[package]] name = "golden-core" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d80c0ffd303bf122e8854c95cd95c5c13df50d553b68cfddc50a79774da19f40" +checksum = "7b83486ad130ff01a0c83c7ca8d503d1468826810fab620dc553097a3dd81d87" dependencies = [ "rand_core 0.6.4", "sha2 0.10.9", @@ -2573,9 +2574,9 @@ dependencies = [ [[package]] name = "golden-ehtdh1" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6859be92f6a85610b3e5e457e65472a604c17a30d390f608c72d7143c142b4" +checksum = "bd8abcb44311d30ad55c8401e607ba7ca1c8831f60dc2f8e07307e91274e8657" dependencies = [ "chacha20 0.9.1", "golden-core", @@ -2588,9 +2589,9 @@ dependencies = [ [[package]] name = "golden-evrf" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a46591e30b00cc93a3ca196943b2871d621b39b79672f80170a84bb6b5dd4575" +checksum = "2f1a41bbbec835df64b1c6f7d4b91ac4cb141345a60e6257cfdb1576d74c40a7" dependencies = [ "bulletproofs-cycle", "ff 0.13.1", @@ -2606,9 +2607,9 @@ dependencies = [ [[package]] name = "golden-halo2curves" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1661fb98a5e44df46c68e71a3ca961c96d7ac29fdba694372e655dcb4345853f" +checksum = "b48128de1cbe48ce42829291277eb2e92cdaeec103301b23061c2954e746983c" dependencies = [ "bulletproofs-cycle", "ff 0.13.1", diff --git a/Cargo.toml b/Cargo.toml index 0bd0c2427e..84139473d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,10 +80,10 @@ deadpool-sync = { default-features = false, version = "0.1" } diesel = { version = "2.3" } fs-err = { version = "3" } futures = { version = "0.3" } -golden-core = { version = "0.1.0" } -golden-ehtdh1 = { version = "0.1.0" } -golden-evrf = { features = ["halo2curves-secp256k1"], version = "0.1.0" } -golden-halo2curves = { features = ["halo2curves-secp256k1"], version = "0.1.0" } +golden-core = { version = "0.2.0" } +golden-ehtdh1 = { version = "0.2.0" } +golden-evrf = { features = ["halo2curves-secp256k1"], version = "0.2.0" } +golden-halo2curves = { features = ["halo2curves-secp256k1"], version = "0.2.0" } hex = { version = "0.4" } http = { version = "1.3" } humantime = { version = "2.2" } diff --git a/bin/validator/src/commands/dkg.rs b/bin/validator/src/commands/dkg.rs index 4dd24e0df4..7714049397 100644 --- a/bin/validator/src/commands/dkg.rs +++ b/bin/validator/src/commands/dkg.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use anyhow::{Context, ensure}; -use golden_core::wire::{WireMessage, from_wire_bytes as from_core_wire_bytes, to_wire_bytes}; +use golden_core::wire::{from_wire_bytes as from_core_wire_bytes, to_wire_bytes}; use golden_core::{ DealerMessage, DkgConfig, @@ -331,8 +331,8 @@ struct TranscriptAcceptances { acceptances: Vec, } -struct DealingSet

{ - messages: BTreeMap>, +struct DealingSet { + messages: BTreeMap>, hashes: Vec, } @@ -579,7 +579,6 @@ fn deal( ) -> anyhow::Result<()> where B: EvrfProofBackend, - B::Proof: WireMessage, { let ceremony = read_ceremony(genesis_path, ceremony_directory)?; let identity_secret_bytes = @@ -654,7 +653,6 @@ async fn accept_transcript( ) -> anyhow::Result<()> where B: EvrfProofBackend, - B::Proof: WireMessage, { let ceremony = read_ceremony(genesis_path, ceremony_directory)?; let validator_public_key = signer.public_key(); @@ -713,7 +711,6 @@ fn finalize( ) -> anyhow::Result<()> where B: EvrfProofBackend, - B::Proof: WireMessage, { let ceremony = read_ceremony(genesis_path, ceremony_directory)?; let identity_secret_bytes = @@ -736,16 +733,15 @@ where sha256(&transcript_bytes), )?; - let decryption = - read_dealings::(decryption_dealing_paths, ceremony.manifest.participants.len())?; - let context = read_dealings::(context_dealing_paths, ceremony.manifest.participants.len())?; - validate_dealings_against_transcript::( + let decryption = read_dealings(decryption_dealing_paths, ceremony.manifest.participants.len())?; + let context = read_dealings(context_dealing_paths, ceremony.manifest.participants.len())?; + validate_dealings_against_transcript( &decryption.messages, &decryption.hashes, &transcript.decryption_dealings, &transcript.decryption_transcript_root, )?; - validate_dealings_against_transcript::( + validate_dealings_against_transcript( &context.messages, &context.hashes, &transcript.context_dealings, @@ -1177,18 +1173,14 @@ fn participant_for_identity( } /// Reads exactly one public dealing from every ceremony participant. -fn read_dealings(paths: &[PathBuf], expected: usize) -> anyhow::Result> -where - B: EvrfProofBackend, - B::Proof: WireMessage, -{ +fn read_dealings(paths: &[PathBuf], expected: usize) -> anyhow::Result { ensure!(paths.len() == expected, "expected {expected} dealings, got {}", paths.len()); let mut dealings = BTreeMap::new(); let mut hashes = BTreeMap::new(); for path in paths { let bytes = fs_err::read(path) .with_context(|| format!("failed to read dealing {}", path.display()))?; - let message = from_core_wire_bytes::>(&bytes) + let message = from_core_wire_bytes::>(&bytes) .with_context(|| format!("invalid dealing {}", path.display()))?; let dealer = message.dealer; ensure!( @@ -1217,11 +1209,10 @@ fn build_transcript( ) -> anyhow::Result<(CeremonyTranscript, Vec)> where B: EvrfProofBackend, - B::Proof: WireMessage, { let expected = ceremony.manifest.participants.len(); - let decryption = read_dealings::(decryption_paths, expected)?; - let context = read_dealings::(context_paths, expected)?; + let decryption = read_dealings(decryption_paths, expected)?; + let context = read_dealings(context_paths, expected)?; for message in decryption.messages.values() { verify_dealing::(message, &ceremony.decryption_config) .context("invalid decryption dealing")?; @@ -1355,16 +1346,12 @@ fn validate_transcript_acceptances( } /// Recomputes one round's canonical dealing hashes and completion root. -fn validate_dealings_against_transcript( - dealings: &BTreeMap>, +fn validate_dealings_against_transcript( + dealings: &BTreeMap>, actual_hashes: &[TranscriptDealing], expected_hashes: &[TranscriptDealing], expected_root: &str, -) -> anyhow::Result<()> -where - B: EvrfProofBackend, - B::Proof: WireMessage, -{ +) -> anyhow::Result<()> { ensure!(actual_hashes == expected_hashes, "dealings do not match accepted transcript"); ensure!( hex::encode(completion_root(dealings)) == expected_root, @@ -1374,9 +1361,9 @@ where } /// Derives the EHTDH1 public key set from the accepted Feldman commitments. -fn public_key_set_from_dealings( - decryption: &BTreeMap>, - context: &BTreeMap>, +fn public_key_set_from_dealings( + decryption: &BTreeMap>, + context: &BTreeMap>, config: &DkgConfig, ) -> anyhow::Result> { let (joint_public_key, decryption_shares) = aggregate_public_output(decryption, config)?; @@ -1407,8 +1394,8 @@ fn public_key_set_from_dealings( } /// Aggregates the public key and participant shares from one dealing round. -fn aggregate_public_output

( - dealings: &BTreeMap>, +fn aggregate_public_output( + dealings: &BTreeMap>, config: &DkgConfig, ) -> anyhow::Result { let mut public_key = StorageGroup::identity(); @@ -1427,9 +1414,7 @@ fn aggregate_public_output

( } /// Reproduces the completion transcript root from public dealings. -fn completion_root

( - dealings: &BTreeMap>, -) -> [u8; 32] { +fn completion_root(dealings: &BTreeMap>) -> [u8; 32] { let mut transcript = TranscriptBuilder::with_prefix(b"golden-core-v1", b"completion"); transcript.bytes(b"backend", StorageGroup::BACKEND_ID.as_bytes()); transcript.usize(b"dealings-len", dealings.len()); @@ -1457,12 +1442,11 @@ fn complete_round( identity_secret: &StorageScalar, private_share: &StorageScalar, expected_own_message_sha256: [u8; 32], - mut dealings: BTreeMap>, + mut dealings: BTreeMap>, config: &DkgConfig, ) -> anyhow::Result> where B: EvrfProofBackend, - B::Proof: WireMessage, { let own_message = dealings.remove(&participant).context("missing local dealing")?; ensure!( diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 0157118dc1..3ca3e7dc46 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -447,7 +447,6 @@ async fn accept_for_all( ) -> TestResultWith where B: EvrfProofBackend, - B::Proof: WireMessage, { let mut outputs = Vec::new(); for (position, signing_key) in ceremony.genesis.signing_keys.iter().enumerate() { @@ -683,8 +682,7 @@ async fn finalize_rejects_tampered_dealing_without_partial_output() -> TestResul #[tokio::test] async fn accept_rejects_a_dealing_from_another_session() -> TestResult { - type FastDealerMessage = - DealerMessage>::Proof>; + type FastDealerMessage = DealerMessage; let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; From 50eafe0d4adef3e54df23a1c0dd5b26cbdb2c42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Mon, 10 Aug 2026 10:47:22 -0400 Subject: [PATCH 20/20] test(validator): run production DKG by default --- .github/workflows/nightly.yml | 24 ------ bin/validator/src/commands/dkg/tests.rs | 108 +++++++++++------------- 2 files changed, 47 insertions(+), 85 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 18087190f4..9b62ebd1d1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,30 +12,6 @@ permissions: contents: read jobs: - dkg: - name: DKG production backend - runs-on: warp-ubuntu-latest-x64-8x - timeout-minutes: 45 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: "next" - persist-credentials: false - - name: Cleanup large tools for build space - uses: ./.github/actions/cleanup-runner - - uses: ./.github/actions/install-rocksdb - - uses: ./.github/actions/install-protobuf-compiler - - name: Install rust - run: rustup toolchain install --no-self-update - - uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 - with: - tool: nextest@0.9.122 - - name: Run the production ceremony - run: | - cargo nextest run -p miden-validator \ - production_backend_completes_two_round_ceremony \ - --run-ignored ignored-only - # Run tests on the beta channel to provide feedback for Rust team. beta-test: name: test on beta channel diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs index 3ca3e7dc46..f871c71ced 100644 --- a/bin/validator/src/commands/dkg/tests.rs +++ b/bin/validator/src/commands/dkg/tests.rs @@ -402,22 +402,28 @@ async fn prepare_test_ceremony( Ok(TestCeremony { genesis, ceremony, identities }) } -/// Creates both dealings for every validator with the fast proof backend. -fn deal_for_all(root: &Path, ceremony: &TestCeremony) -> TestResultWith> { - deal_for_all_with_seed(root, ceremony, [41; 32]) +/// Creates both dealings for every validator with the selected proof backend. +fn deal_for_all(root: &Path, ceremony: &TestCeremony) -> TestResultWith> +where + B: EvrfProofBackend, +{ + deal_for_all_with_seed::(root, ceremony, [41; 32]) } /// Creates both dealings using one deterministic test seed. -fn deal_for_all_with_seed( +fn deal_for_all_with_seed( root: &Path, ceremony: &TestCeremony, seed: [u8; 32], -) -> TestResultWith> { +) -> TestResultWith> +where + B: EvrfProofBackend, +{ let mut rng = ChaCha20Rng::from_seed(seed); let mut outputs = Vec::new(); for (position, identity) in ceremony.identities.iter().enumerate() { let output = root.join(format!("deal-{position}")); - deal::( + deal::( &ceremony.genesis.path, &ceremony.ceremony, &identity.join(IDENTITY_SECRET_FILE), @@ -475,16 +481,19 @@ where }) } -/// Completes one startup bundle with the fast proof backend. -fn finalize_test_bundle( +/// Completes one startup bundle with the selected proof backend. +fn finalize_test_bundle( root: &Path, ceremony: &TestCeremony, dealings: &[PathBuf], accepted: &AcceptedTranscript, position: usize, -) -> TestResultWith { +) -> TestResultWith +where + B: EvrfProofBackend, +{ let output = root.join(format!("bundle-{position}")); - finalize::( + finalize::( &ceremony.genesis.path, &ceremony.ceremony, &ceremony.identities[position].join(IDENTITY_SECRET_FILE), @@ -502,11 +511,17 @@ fn finalize_test_bundle( async fn three_validators_complete_dkg_and_recover_with_any_two_shares() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; - let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; + let dealings = deal_for_all::(root.path(), &ceremony)?; + let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let mut bundles = Vec::new(); for position in 0..3 { - let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, position)?; + let bundle = finalize_test_bundle::( + root.path(), + &ceremony, + &dealings, + &accepted, + position, + )?; validate_bundle( &ceremony.genesis.path, &ceremony.ceremony, @@ -573,15 +588,22 @@ async fn validate_rejects_an_internally_consistent_substitute_key_set() -> TestR fs_err::create_dir(&alternate_root)?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; - let bundle = finalize_test_bundle(root.path(), &ceremony, &dealings, &accepted, 0)?; + let bundle = finalize_test_bundle::( + root.path(), + &ceremony, + &dealings, + &accepted, + 0, + )?; - let alternate_dealings = deal_for_all_with_seed(&alternate_root, &ceremony, [77; 32])?; + let alternate_dealings = + deal_for_all_with_seed::(&alternate_root, &ceremony, [77; 32])?; let alternate_accepted = accept_for_all::(&alternate_root, &ceremony, &alternate_dealings) .await?; - let alternate_bundle = finalize_test_bundle( + let alternate_bundle = finalize_test_bundle::( &alternate_root, &ceremony, &alternate_dealings, @@ -606,7 +628,7 @@ async fn validate_rejects_an_internally_consistent_substitute_key_set() -> TestR async fn finalize_rejects_incomplete_or_duplicate_dealings() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let decryption = dealing_paths(&dealings, DECRYPTION_DEALING_FILE); let context = dealing_paths(&dealings, CONTEXT_DEALING_FILE); @@ -651,7 +673,7 @@ async fn finalize_rejects_incomplete_or_duplicate_dealings() -> TestResult { async fn finalize_rejects_tampered_dealing_without_partial_output() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let tampered = root.path().join("tampered.wire"); let mut bytes = fs_err::read(dealings[1].join(DECRYPTION_DEALING_FILE))?; @@ -686,7 +708,7 @@ async fn accept_rejects_a_dealing_from_another_session() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let substituted = root.path().join("wrong-session.wire"); let mut message = from_wire_bytes::(&fs_err::read( dealings[1].join(DECRYPTION_DEALING_FILE), @@ -717,7 +739,7 @@ async fn accept_rejects_a_dealing_from_another_session() -> TestResult { async fn finalize_rejects_valid_dealer_equivocation() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let alternate = root.path().join("alternate-deal"); @@ -754,7 +776,7 @@ async fn finalize_rejects_valid_dealer_equivocation() -> TestResult { async fn finalize_requires_every_transcript_acceptance() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let output = root.path().join("bundle"); @@ -779,7 +801,7 @@ async fn finalize_requires_every_transcript_acceptance() -> TestResult { async fn finalize_rejects_manifest_changed_after_acceptance() -> TestResult { let root = tempfile::tempdir()?; let ceremony = prepare_test_ceremony(root.path(), 3, 2).await?; - let dealings = deal_for_all(root.path(), &ceremony)?; + let dealings = deal_for_all::(root.path(), &ceremony)?; let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; let manifest_path = ceremony.ceremony.join(MANIFEST_FILE); let mut manifest = fs_err::read_to_string(&manifest_path)?; @@ -812,7 +834,7 @@ async fn private_state_cannot_cross_ceremonies() -> TestResult { fs_err::create_dir_all(&first_root)?; fs_err::create_dir_all(&second_root)?; let first = prepare_test_ceremony(&first_root, 3, 2).await?; - let first_dealings = deal_for_all(&first_root, &first)?; + let first_dealings = deal_for_all::(&first_root, &first)?; let registrations = first .identities @@ -826,7 +848,7 @@ async fn private_state_cannot_cross_ceremonies() -> TestResult { ceremony: second_ceremony, identities: first.identities.clone(), }; - let second_dealings = deal_for_all(&second_root, &second)?; + let second_dealings = deal_for_all::(&second_root, &second)?; let accepted = accept_for_all::(&second_root, &second, &second_dealings).await?; let output = second_root.join("bundle"); @@ -880,39 +902,3 @@ async fn deal_rejects_unknown_identity_and_existing_output() -> TestResult { ); Ok(()) } - -#[tokio::test] -#[ignore = "slow: runs the concrete Secp/Secq proof backend"] -async fn production_backend_completes_two_round_ceremony() -> TestResult { - let root = tempfile::tempdir()?; - let ceremony = prepare_test_ceremony(root.path(), 2, 2).await?; - let mut rng = ChaCha20Rng::from_seed([44; 32]); - let mut dealings = Vec::new(); - for (position, identity) in ceremony.identities.iter().enumerate() { - let output = root.path().join(format!("paper-deal-{position}")); - deal::( - &ceremony.genesis.path, - &ceremony.ceremony, - &identity.join(IDENTITY_SECRET_FILE), - &output, - &mut rng, - )?; - dealings.push(output); - } - let accepted = accept_for_all::(root.path(), &ceremony, &dealings).await?; - for position in 0..2 { - let output = root.path().join(format!("paper-bundle-{position}")); - finalize::( - &ceremony.genesis.path, - &ceremony.ceremony, - &ceremony.identities[position].join(IDENTITY_SECRET_FILE), - &dealings[position].join(PRIVATE_STATE_FILE), - &dealing_paths(&dealings, DECRYPTION_DEALING_FILE), - &dealing_paths(&dealings, CONTEXT_DEALING_FILE), - &accepted.transcript, - &accepted.acceptances, - &output, - )?; - } - Ok(()) -}