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/Cargo.lock b/Cargo.lock index 6315b3a49e..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", @@ -2586,11 +2587,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "golden-evrf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f1a41bbbec835df64b1c6f7d4b91ac4cb141345a60e6257cfdb1576d74c40a7" +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" +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", @@ -4772,6 +4791,7 @@ dependencies = [ "fs-err", "golden-core", "golden-ehtdh1", + "golden-evrf", "golden-halo2curves", "hex", "miden-node-db", @@ -4786,10 +4806,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/Cargo.toml b/Cargo.toml index ef49f17e7f..84139473d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,9 +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-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/Cargo.toml b/bin/validator/Cargo.toml index fe90c274d0..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 } @@ -39,9 +40,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 +64,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/dkg.rs b/bin/validator/src/commands/dkg.rs new file mode 100644 index 0000000000..7714049397 --- /dev/null +++ b/bin/validator/src/commands/dkg.rs @@ -0,0 +1,1807 @@ +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::{from_wire_bytes as from_core_wire_bytes, to_wire_bytes}; +use golden_core::{ + DealerMessage, + DkgConfig, + DkgDealing, + EvrfProofBackend, + GoldenGroup, + GoldenScalar, + ParticipantIndex, + ParticipantRegistry, + SessionId, + Share, + TranscriptBuilder, + complete, + create_dealing, + create_dealing_with_secret, + verify_dealing, +}; +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, +}; +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; +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::{EncodedGoldenOperatorKey, StorageKeyEpoch, ValidatorSigner}; +use rand_core_06::{CryptoRngCore, OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +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-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"; +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"; +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-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-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 DKG ceremony command. +#[derive(clap::Args)] +pub struct DkgOptions { + #[command(subcommand)] + command: DkgCommand, +} + +/// DKG ceremony commands. +#[derive(clap::Subcommand)] +enum DkgCommand { + /// Generates this validator's DKG identity and public registration. + Identity { + /// Trusted genesis block for the network. + #[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, + + /// New directory that receives the identity and registration files. + #[arg(long, value_name = "DIR")] + output_directory: PathBuf, + }, + + /// Builds the public configurations for both 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, + }, + + /// 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, + }, + + /// 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. + #[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, + + /// 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, + }, + + /// 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, + }, + + /// 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, + + /// DKG participant index that must own the secret share. + #[arg(long, value_name = "NUM")] + expected_participant: u32, + }, +} + +#[derive(Debug, Deserialize, Serialize)] +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, +} + +#[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, +} + +struct Ceremony { + manifest: Manifest, + manifest_sha256: [u8; 32], + genesis_commitment: Word, + 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, +} + +#[derive(Debug, Deserialize, Serialize)] +struct CeremonyTranscript { + version: String, + manifest_sha256: String, + decryption_transcript_root: String, + context_transcript_root: String, + public_key_set_sha256: 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, +} + +struct DealingSet { + messages: BTreeMap>, + hashes: Vec, +} + +/// Runs one DKG ceremony command. +pub async fn run(options: DkgOptions) -> anyhow::Result<()> { + match options.command { + DkgCommand::Identity { + genesis, + epoch, + signing_key, + output_directory, + } => { + let signer = signing_key.into_signer().await?; + generate_identity(&genesis, &epoch, &signer, &output_directory).await + }, + DkgCommand::Prepare { + genesis, + threshold, + epoch, + registration, + output_directory, + } => prepare(&genesis, threshold, &epoch, ®istration, &output_directory), + DkgCommand::Deal { + genesis, + ceremony_directory, + identity_secret, + output_directory, + } => deal::( + &genesis, + &ceremony_directory, + &identity_secret, + &output_directory, + &mut OsRng, + ), + DkgCommand::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 + }, + DkgCommand::Finalize { + genesis, + ceremony_directory, + identity_secret, + private_state, + decryption_dealing, + context_dealing, + transcript, + transcript_acceptance, + output_directory, + } => finalize::( + &genesis, + &ceremony_directory, + &identity_secret, + &private_state, + &decryption_dealing, + &context_dealing, + &transcript, + &transcript_acceptance, + &output_directory, + ), + DkgCommand::Validate { + genesis, + ceremony_directory, + validator_public_key, + bundle_directory, + } => { + validate_bundle(&genesis, &ceremony_directory, &validator_public_key, &bundle_directory) + }, + DkgCommand::ValidateFixture { bundle_directory, expected_participant } => { + validate_fixture_bundle(&bundle_directory, expected_participant) + }, + } +} + +/// 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(); + 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 (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) + .await + .context("failed to sign DKG registration")?; + + 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 = + 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!("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 = read_trusted_genesis(genesis_path)?; + let genesis_commitment = genesis.inner().header().commitment(); + 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 = + 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()); + 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 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 = 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 = + 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_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!("DKG configuration written to {}.", output_directory.display()); + 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, +{ + 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 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 decryption dealing for participant {} in {:.1?}.", + participant.get(), + started.elapsed(), + ); + + println!("Creating 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 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!("DKG dealings written to {}.", output_directory.display()); + 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, +{ + 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!("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, + identity_secret_path: &Path, + 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 + B: EvrfProofBackend, +{ + 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 (transcript, transcript_bytes) = read_transcript(transcript_path, &ceremony)?; + let acceptances = read_transcript_acceptances( + transcript_acceptance_paths, + &ceremony, + 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( + &decryption.messages, + &decryption.hashes, + &transcript.decryption_dealings, + &transcript.decryption_transcript_root, + )?; + validate_dealings_against_transcript( + &context.messages, + &context.hashes, + &transcript.context_dealings, + &transcript.context_transcript_root, + )?; + + println!( + "Completing decryption round for participant {} with {} dealings.", + participant.get(), + decryption.messages.len(), + ); + let started = Instant::now(); + let decryption_output = complete_round::( + participant, + &identity_secret, + &private_state.decryption_private_share, + private_state.decryption_message_sha256, + decryption.messages, + &ceremony.decryption_config, + ) + .context("failed to complete decryption round")?; + println!( + "Completed decryption round for participant {} in {:.1?}.", + participant.get(), + started.elapsed(), + ); + + println!( + "Completing context round for participant {} with {} dealings.", + participant.get(), + context.messages.len(), + ); + let started = Instant::now(); + let context_output = complete_round::( + participant, + &identity_secret, + &private_state.context_private_share, + private_state.context_message_sha256, + context.messages, + &ceremony.context_config, + ) + .context("failed to complete context round")?; + println!( + "Completed 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")?; + publish_operator_bundle( + &material, + &ceremony, + &transcript, + &transcript_bytes, + &acceptances, + output_directory, + )?; + println!("Storage key bundle written to {}.", output_directory.display()); + Ok(()) +} + +/// Validates and publishes one final storage key bundle. +fn publish_operator_bundle( + material: &Ehtdh1Material, + ceremony: &Ceremony, + transcript: &CeremonyTranscript, + 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); + 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), + setup_context.clone(), + public_key_set.clone(), + secret_share.to_vec(), + ) + .decode() + .context("generated invalid storage 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)?; + 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, + ) + })?; + 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 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 + .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 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))?, + public_key_set, + fs_err::read(bundle_directory.join(SECRET_SHARE_FILE))?, + ) + .decode() + .context("invalid storage 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)?; + 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!("Storage key bundle is valid for participant {}.", expected_participant.get()); + 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 storage key fixture")?; + ensure!( + operator_key.participant() == expected_participant, + "fixture belongs to participant {}, expected {}", + operator_key.participant().get(), + expected_participant.get(), + ); + println!("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) + .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) +} + +/// Reads registrations and verifies their genesis binding and validator signatures. +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(); + 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 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!( + registration.genesis_commitment == hex::encode(genesis_commitment.to_bytes()), + "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 {}", + path.display(), + ); + ensure!( + registrations.insert(validator_key.to_bytes(), identity_key).is_none(), + "duplicate validator registration in {}", + path.display(), + ); + } + 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).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"); + + 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", + ); + 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")?; + 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"); + let expected_beta = setup_beta()?; + ensure!( + decryption_config.beta == expected_beta + && hex::encode(expected_beta.to_repr()) == manifest.beta + && context_config.beta == decryption_config.beta, + "DKG beta mismatch", + ); + let expected_session = derive_decryption_session_id( + genesis_commitment, + manifest.threshold, + &epoch, + &manifest.participants, + )?; + ensure!( + decryption_config.session_id == expected_session + && hex::encode(expected_session.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 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, + manifest_sha256: sha256(manifest_text.as_bytes()), + genesis_commitment, + decryption_config, + context_config, + }) +} + +/// Returns the fixed public eVRF setup coefficient for this storage DKG backend. +fn setup_beta() -> 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, + 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 { + 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) + .with_context(|| format!("invalid dealing {}", path.display()))?; + let dealer = message.dealer; + ensure!( + dealings.insert(dealer, message).is_none(), + "duplicate dealing from participant {}", + dealer.get(), + ); + hashes.insert(dealer, sha256_hex(&bytes)); + } + ensure!(dealings.len() == expected, "dealing set is incomplete"); + 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. +fn build_transcript( + ceremony: &Ceremony, + decryption_paths: &[PathBuf], + context_paths: &[PathBuf], +) -> anyhow::Result<(CeremonyTranscript, Vec)> +where + B: EvrfProofBackend, +{ + let expected = ceremony.manifest.participants.len(); + 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.messages.values() { + verify_dealing::(message, &ceremony.context_config) + .context("invalid context dealing")?; + } + let public_key_set = public_key_set_from_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.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: decryption.hashes, + context_dealings: context.hashes, + }; + 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")?; + 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"); + 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>, + actual_hashes: &[TranscriptDealing], + expected_hashes: &[TranscriptDealing], + expected_root: &str, +) -> anyhow::Result<()> { + 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", + ); + Ok(()) +} + +/// 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 the 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, + identity_secret: &StorageScalar, + private_share: &StorageScalar, + expected_own_message_sha256: [u8; 32], + mut dealings: BTreeMap>, + config: &DkgConfig, +) -> anyhow::Result> +where + B: EvrfProofBackend, +{ + 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") +} + +/// 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 * 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")?; + 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 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 DKG public key. +fn decode_identity_public_key( + value: &str, +) -> anyhow::Result<::Element> { + 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 {name} length"))?; + let public_key = + 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 = + 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. +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(".storage-key-dkg-") + .tempdir_in(parent) + .context("failed to create temporary output directory")?; + write(temporary.path())?; + fs_err::rename(temporary.path(), 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(bytes)) +} + +/// Returns the SHA-256 digest of one artifact. +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} diff --git a/bin/validator/src/commands/dkg/tests.rs b/bin/validator/src/commands/dkg/tests.rs new file mode 100644 index 0000000000..f871c71ced --- /dev/null +++ b/bin/validator/src/commands/dkg/tests.rs @@ -0,0 +1,904 @@ +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-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"); + let epoch = "10".repeat(32); + + 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, + &epoch, + &validator_key, + &public_key, + &proof_commitment, + &proof_response, + ), + &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 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, + ) + .await?; + registrations.push(directory.join(REGISTRATION_FILE)); + } + let output = root.path().join("ceremony"); + let second_output = root.path().join("ceremony-copy"); + 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))?; + 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.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 + 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())); + } + 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(()) +} + +#[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, + &"12".repeat(32), + &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 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, + ) + .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, &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, + 1, + &"25".repeat(32), + &[identity.join(REGISTRATION_FILE)], + &root.path().join("ceremony"), + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("different storage-key epoch"), + "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 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, + ) + .await?; + registrations.push(directory.join(REGISTRATION_FILE)); + identities.push(directory); + } + let ceremony = root.join("ceremony"); + prepare(&genesis.path, threshold, &epoch, ®istrations, &ceremony)?; + Ok(TestCeremony { genesis, ceremony, identities }) +} + +/// 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( + root: &Path, + ceremony: &TestCeremony, + seed: [u8; 32], +) -> 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::( + &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, +{ + 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 selected proof backend. +fn finalize_test_bundle( + root: &Path, + ceremony: &TestCeremony, + dealings: &[PathBuf], + accepted: &AcceptedTranscript, + position: usize, +) -> TestResultWith +where + B: EvrfProofBackend, +{ + 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 accept_rejects_a_dealing_from_another_session() -> TestResult { + type FastDealerMessage = DealerMessage; + + 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()?; + 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 = 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::( + &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, 3, &"33".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(()) +} diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index cb447a209f..3c4f51d16d 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -1,4 +1,5 @@ mod bootstrap; +mod dkg; mod export_private_record; mod genesis; mod issue_private_record_share; @@ -50,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. @@ -61,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, } @@ -158,7 +159,10 @@ pub enum ValidatorCommand { data_directory: PathBuf, }, - /// Issues this validator's Golden decryption share for one stored private record. + /// Runs the storage-key setup ceremony. + Dkg(dkg::DkgOptions), + + /// Issues this validator's decryption share for one stored private record. IssuePrivateRecordShare(PrivateRecordShareOptions), /// Exports one validator-qualified private-record bundle. @@ -248,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, }, @@ -290,6 +294,7 @@ impl ValidatorCommand { .context("failed to apply validator database migrations")?; Ok(()) }, + Self::Dkg(options) => dkg::run(options).await, Self::IssuePrivateRecordShare(options) => { issue_private_record_share::issue_from_options(options) }, @@ -352,6 +357,7 @@ impl ValidatorCommand { Self::Genesis { .. } | Self::Bootstrap { .. } | Self::Pubkey { .. } + | Self::Dkg(_) | Self::ExportPrivateRecord(_) | Self::IssuePrivateRecordShare(_) | Self::Migrate { .. } => OpenTelemetry::Disabled, @@ -392,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. @@ -402,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, @@ -454,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/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/compose/bootstrap.yml b/compose/bootstrap.yml index b4290821cd..9603cac7f8 100644 --- a/compose/bootstrap.yml +++ b/compose/bootstrap.yml @@ -5,6 +5,22 @@ 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} + 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"] @@ -22,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 @@ -38,6 +55,110 @@ services: --genesis /data/genesis/genesis.dat 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 \ + --epoch "$${MIDEN_VALIDATOR_STORAGE_KEY_EPOCH}" \ + --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/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..9cb29bcc9c 100644 --- a/docs/external/src/local-network-development.md +++ b/docs/external/src/local-network-development.md @@ -228,6 +228,13 @@ chain data before starting with a different genesis configuration: make local-network-delete ``` +## Storage Key Setup + +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 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..eeddd7f434 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. +## Storage Key Ceremony + +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 +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 [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 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..a1f93c0cbc 100644 --- a/docs/external/src/network-operator/validator.md +++ b/docs/external/src/network-operator/validator.md @@ -28,6 +28,114 @@ 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. +## Storage Key Setup + +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. + +```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 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 \ + --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 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 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 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 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 @@ -58,11 +166,9 @@ 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. - -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 201ad9f394..fdb99d27e5 100755 --- a/scripts/run-node.sh +++ b/scripts/run-node.sh @@ -32,10 +32,11 @@ 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_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-storage-key/README.md similarity index 58% rename from scripts/testdata/insecure-golden-storage-key/README.md rename to scripts/testdata/insecure-storage-key/README.md index b912b95f90..1389e373b2 100644 --- a/scripts/testdata/insecure-golden-storage-key/README.md +++ b/scripts/testdata/insecure-storage-key/README.md @@ -1,14 +1,13 @@ -# 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: - `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 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 The fixture is derived deterministically by `bin/validator/src/storage_key.rs` (`tests::values_for`). Regenerate it @@ -25,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 ``` 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