diff --git a/.github/workflows/doc-commands.yml b/.github/workflows/doc-commands.yml index 633b0efe23..e2abe75495 100644 --- a/.github/workflows/doc-commands.yml +++ b/.github/workflows/doc-commands.yml @@ -32,7 +32,8 @@ jobs: - name: Setup Rust uses: ./.github/actions/setup-rust with: - toolchain: nightly + components: rustfmt + toolchain: 1.84 cache-prefix: test-doc-commands-v0 - name: Download circuits files @@ -71,7 +72,7 @@ jobs: echo "Testing that generated key can be used by run-block-producer target..." # Run with --help to avoid actually starting the producer but verify # key validation passes - timeout 30s make run-block-producer-devnet COINBASE_RECEIVER="test" || { + timeout 30s make run-block-producer NETWORK=devnet COINBASE_RECEIVER=$(cat ./openmina-workdir/producer-key.pub) || { EXIT_CODE=$? if [ $EXIT_CODE -eq 124 ]; then echo "✅ Command started successfully (timed out as expected)" @@ -83,6 +84,61 @@ jobs: fi } + - name: Test generate-block-producer-key with custom filename + run: | + echo "Testing generate-block-producer-key with custom PRODUCER_KEY_FILENAME..." + make generate-block-producer-key PRODUCER_KEY_FILENAME=./openmina-workdir/custom-producer-key + + # Verify custom private key file exists + if [ ! -f "./openmina-workdir/custom-producer-key" ]; then + echo "❌ Custom producer key file was not generated" + exit 1 + fi + + # Verify custom public key file exists + if [ ! -f "./openmina-workdir/custom-producer-key.pub" ]; then + echo "❌ Custom producer public key file was not generated" + exit 1 + fi + + # Check file permissions (should be 600 for private key) + PERMS=$(stat -c "%a" "./openmina-workdir/custom-producer-key") + if [ "$PERMS" != "600" ]; then + echo "❌ Custom producer key file has incorrect permissions: $PERMS (expected: 600)" + exit 1 + fi + + # Check both files are not empty + if [ ! -s "./openmina-workdir/custom-producer-key" ]; then + echo "❌ Custom producer key file is empty" + exit 1 + fi + + if [ ! -s "./openmina-workdir/custom-producer-key.pub" ]; then + echo "❌ Custom producer public key file is empty" + exit 1 + fi + + echo "✅ Custom producer key pair generated successfully" + + - name: Test generate-block-producer-key failure when keys exist + run: | + echo "Testing that generate-block-producer-key fails when keys already exist..." + + # Try to generate keys again with default filename (should fail) + if make generate-block-producer-key 2>/dev/null; then + echo "❌ Command should have failed when keys already exist" + exit 1 + fi + + # Try to generate keys again with custom filename (should fail) + if make generate-block-producer-key PRODUCER_KEY_FILENAME=./openmina-workdir/custom-producer-key 2>/dev/null; then + echo "❌ Command should have failed when custom keys already exist" + exit 1 + fi + + echo "✅ Command correctly fails when keys already exist" + - name: Test other documented make targets exist run: | echo "Testing that documented make targets exist..." diff --git a/CHANGELOG.md b/CHANGELOG.md index 4751772e11..0e4c0fab14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#1221](https://github.com/o1-labs/openmina/pull/1221)). - **Documentation**: add section regarding peers setup and seeds ([#1295](https://github.com/o1-labs/openmina/pull/1295)) +- **Node**: add `openmina misc mina-encrypted-key` to generate a new encrypted + key with password, as the OCaml node provides + ([#1284](https://github.com/o1-labs/openmina/pull/1284/)). ### Changed diff --git a/Makefile b/Makefile index 7329b60cf1..7f6ca39d48 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ PG_HOST ?= localhost PG_PORT ?= 5432 # Block producer configuration -PRODUCER_KEY ?= ./openmina-workdir/producer-key +PRODUCER_KEY_FILENAME ?= ./openmina-workdir/producer-key COINBASE_RECEIVER ?= OPENMINA_LIBP2P_EXTERNAL_IP ?= OPENMINA_LIBP2P_PORT ?= 8302 @@ -313,44 +313,45 @@ run-archive: build-release ## Run an archive node with local storage .PHONY: run-block-producer run-block-producer: build-release ## Run a block producer node on $(NETWORK) network - @if [ ! -f "$(PRODUCER_KEY)" ]; then \ - echo "Error: Producer key not found at $(PRODUCER_KEY)"; \ - echo "Please place your producer private key at $(PRODUCER_KEY)"; \ + @if [ ! -f "$(PRODUCER_KEY_FILENAME)" ]; then \ + echo "Error: Producer key not found at $(PRODUCER_KEY_FILENAME)"; \ + echo "Please place your producer private key at $(PRODUCER_KEY_FILENAME)"; \ exit 1; \ fi - MINA_PRIVKEY_PASS="$(MINA_PRIVKEY_PASS)" \ - cargo run --bin openmina \ + cargo run \ + --bin openmina \ + --package=cli \ --release -- \ node \ - --producer-key $(PRODUCER_KEY) \ + --producer-key $(PRODUCER_KEY_FILENAME) \ $(if $(COINBASE_RECEIVER),--coinbase-receiver $(COINBASE_RECEIVER)) \ $(if $(OPENMINA_LIBP2P_EXTERNAL_IP),--libp2p-external-ip $(OPENMINA_LIBP2P_EXTERNAL_IP)) \ $(if $(OPENMINA_LIBP2P_PORT),--libp2p-port $(OPENMINA_LIBP2P_PORT)) \ --network $(NETWORK) -.PHONY: run-block-producer-devnet -run-block-producer-devnet: ## Run a block producer node on devnet - $(MAKE) run-block-producer NETWORK=devnet - -.PHONY: run-block-producer-mainnet -run-block-producer-mainnet: ## Run a block producer node on mainnet - $(MAKE) run-block-producer NETWORK=mainnet .PHONY: generate-block-producer-key -generate-block-producer-key: build-release ## Generate a new block producer key pair +generate-block-producer-key: build-release ## Generate a new block producer key pair (fails if keys exist, use PRODUCER_KEY_FILENAME to customize, MINA_PRIVKEY_PASS for password) + @if [ -f "$(PRODUCER_KEY_FILENAME)" ] || [ -f "$(PRODUCER_KEY_FILENAME).pub" ]; then \ + echo "Error: Producer key already exists at $(PRODUCER_KEY_FILENAME) or public key exists at $(PRODUCER_KEY_FILENAME).pub"; \ + echo ""; \ + echo "To generate a key with a different filename, set PRODUCER_KEY_FILENAME:"; \ + echo " make generate-block-producer-key PRODUCER_KEY_FILENAME=./path/to/new-key"; \ + echo ""; \ + echo "Or remove the existing key first to regenerate it."; \ + exit 1; \ + fi @mkdir -p openmina-workdir - @echo "Generating new block producer key pair..." - @OUTPUT=$$(cargo run --release --package=cli --bin openmina -- misc mina-key-pair); \ - SECRET_KEY=$$(echo "$$OUTPUT" | grep "secret key:" | cut -d' ' -f3); \ + @echo "Generating new encrypted block producer key..." + @OUTPUT=$$($(if $(MINA_PRIVKEY_PASS),MINA_PRIVKEY_PASS="$(MINA_PRIVKEY_PASS)") cargo run --release --package=cli --bin openmina -- misc mina-encrypted-key --file $(PRODUCER_KEY_FILENAME)); \ PUBLIC_KEY=$$(echo "$$OUTPUT" | grep "public key:" | cut -d' ' -f3); \ - echo "$$SECRET_KEY" > $(PRODUCER_KEY); \ - chmod 600 $(PRODUCER_KEY); \ + chmod 600 $(PRODUCER_KEY_FILENAME); \ echo ""; \ - echo "✓ Generated new producer key pair:"; \ - echo " Secret key saved to: $(PRODUCER_KEY)"; \ - echo " Public key: $$PUBLIC_KEY"; \ + echo "✓ Generated new encrypted producer key:"; \ + echo " Encrypted key saved to: $(PRODUCER_KEY_FILENAME)"; \ + echo " Public key: $$PUBLIC_KEY, saved to $(PRODUCER_KEY_FILENAME).pub"; \ echo ""; \ - echo "⚠️ IMPORTANT: Keep your secret key secure and backed up!" + echo "⚠️ IMPORTANT: Keep your encrypted key file and password secure and backed up!" .PHONY: postgres-clean postgres-clean: diff --git a/cli/src/commands/misc.rs b/cli/src/commands/misc.rs index c64ed28396..b7c84b9e5e 100644 --- a/cli/src/commands/misc.rs +++ b/cli/src/commands/misc.rs @@ -1,5 +1,6 @@ use libp2p_identity::PeerId; use node::{account::AccountSecretKey, p2p::identity::SecretKey}; +use std::{fs::File, io::Write}; #[derive(Debug, clap::Args)] pub struct Misc { @@ -10,16 +11,18 @@ pub struct Misc { impl Misc { pub fn run(self) -> anyhow::Result<()> { match self.command { - MiscCommand::P2PKeyPair(command) => command.run(), + MiscCommand::MinaEncryptedKey(command) => command.run(), MiscCommand::MinaKeyPair(command) => command.run(), + MiscCommand::P2PKeyPair(command) => command.run(), } } } #[derive(Clone, Debug, clap::Subcommand)] pub enum MiscCommand { - P2PKeyPair(P2PKeyPair), + MinaEncryptedKey(MinaEncryptedKey), MinaKeyPair(MinaKeyPair), + P2PKeyPair(P2PKeyPair), } #[derive(Debug, Clone, clap::Args)] @@ -59,3 +62,283 @@ impl MinaKeyPair { Ok(()) } } + +/// Generate a new Mina key pair and save it as an encrypted JSON file +/// +/// This command generates a new random Mina key pair (or uses a provided secret key) +/// and saves it to an encrypted JSON file format compatible with key generation +/// from the OCaml implementation. +/// The encrypted file can be used as a producer key for block production. +/// +/// This command replicates the secret box functionality from `src/lib/secret_box` +/// in the OCaml implementation, providing compatible encrypted key storage. +/// +/// # Examples +/// +/// Generate a new encrypted key with password: +/// ```bash +/// openmina misc mina-encrypted-key --password mypassword --file producer-key +/// ``` +/// +/// Generate a new encrypted key using environment variable for password: +/// ```bash +/// MINA_PRIVKEY_PASS=mypassword openmina misc mina-encrypted-key --file producer-key +/// ``` +/// +/// Use an existing secret key: +/// ```bash +/// openmina misc mina-encrypted-key --secret-key EKE... --password mypassword +/// ``` +#[derive(Debug, Clone, clap::Args)] +pub struct MinaEncryptedKey { + /// Optional existing secret key to encrypt. If not provided, generates a + /// new random key + #[arg(long, short = 's', env = "OPENMINA_ENC_KEY")] + secret_key: Option, + + /// Password to encrypt the key file with. Can be provided via + /// MINA_PRIVKEY_PASS environment variable + #[arg(env = "MINA_PRIVKEY_PASS", default_value = "")] + password: String, + + /// Output file path for the encrypted key (default: mina_encrypted_key.json) + #[arg(long, short = 'f', default_value = "mina_encrypted_key.json")] + file: String, +} + +impl MinaEncryptedKey { + /// Execute the mina-encrypted-key command + /// + /// Generates a new Mina key pair (or uses provided secret key) and saves it + /// as an encrypted JSON file that can be used for block production. + /// + /// It will also save the public key to the filename suffixed with `.pub`. + /// + /// # Returns + /// + /// * `Ok(())` - On successful key generation and file creation + /// * `Err(anyhow::Error)` - If key encryption or file writing fails + /// + /// # Output + /// + /// Prints the secret key and public key to stdout, and creates an encrypted + /// JSON file at the specified path. + pub fn run(self) -> anyhow::Result<()> { + let secret_key = self.secret_key.unwrap_or_else(AccountSecretKey::rand); + let public_key = secret_key.public_key(); + + // Save the public key to a separate file + let public_key_file = format!("{}.pub", self.file); + + if File::open(&public_key_file).is_ok() { + return Err(anyhow::anyhow!( + "Public key file '{}' already exists. Please choose a different file name.", + public_key_file + )); + } + + secret_key + .to_encrypted_file(&self.file, &self.password) + .map_err(|e| { + anyhow::anyhow!("Failed to encrypt key: {} into path '{}'", e, self.file,) + })?; + // Write the public key to the file + let mut public_key_file = File::create(public_key_file) + .map_err(|e| anyhow::anyhow!("Failed to create public key file: {}", e))?; + public_key_file + .write_all(public_key.to_string().as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to write public key: {}", e))?; + + println!("secret key: {secret_key}"); + println!("public key: {public_key}"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_mina_encrypted_key_generates_random_key() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_key.json"); + let file_path_str = file_path.to_str().unwrap().to_string(); + + let cmd = MinaEncryptedKey { + secret_key: None, + password: "test_password".to_string(), + file: file_path_str.clone(), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + + // Verify the file was created + assert!(file_path.exists()); + + // Verify the file contains encrypted data (should be JSON) + let file_content = fs::read_to_string(&file_path).unwrap(); + assert!(file_content.starts_with('{')); + assert!(file_content.ends_with('}')); + } + + #[test] + fn test_mina_encrypted_key_with_provided_secret_key() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_key_provided.json"); + let file_path_str = file_path.to_str().unwrap().to_string(); + + let secret_key = AccountSecretKey::rand(); + let expected_public_key = secret_key.public_key(); + + let cmd = MinaEncryptedKey { + secret_key: Some(secret_key), + password: "test_password".to_string(), + file: file_path_str.clone(), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + + // Verify the file was created + assert!(file_path.exists()); + + // Verify we can load the key back and it matches + let loaded_key = AccountSecretKey::from_encrypted_file(&file_path_str, "test_password"); + assert!(loaded_key.is_ok()); + let loaded_key = loaded_key.unwrap(); + assert_eq!(loaded_key.public_key(), expected_public_key); + } + + #[test] + fn test_mina_encrypted_key_with_empty_password() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_key_no_pass.json"); + let file_path_str = file_path.to_str().unwrap().to_string(); + + let cmd = MinaEncryptedKey { + secret_key: None, + password: "".to_string(), + file: file_path_str.clone(), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + + // Verify the file was created + assert!(file_path.exists()); + + // Verify we can load the key back with empty password + let loaded_key = AccountSecretKey::from_encrypted_file(&file_path_str, ""); + assert!(loaded_key.is_ok()); + } + + #[test] + fn test_mina_encrypted_key_wrong_password_fails() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_key_wrong_pass.json"); + let file_path_str = file_path.to_str().unwrap().to_string(); + + let cmd = MinaEncryptedKey { + secret_key: None, + password: "correct_password".to_string(), + file: file_path_str.clone(), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + + // Verify loading with wrong password fails + let loaded_key = AccountSecretKey::from_encrypted_file(&file_path_str, "wrong_password"); + assert!(loaded_key.is_err()); + } + + #[test] + fn test_mina_encrypted_key_invalid_file_path_fails() { + let cmd = MinaEncryptedKey { + secret_key: None, + password: "test_password".to_string(), + file: "/invalid/path/that/does/not/exist/key.json".to_string(), + }; + + let result = cmd.run(); + assert!(result.is_err()); + } + + #[test] + fn test_mina_encrypted_key_roundtrip_compatibility() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_roundtrip.json"); + let file_path_str = file_path.to_str().unwrap().to_string(); + + // Generate a key with our command + let original_secret_key = AccountSecretKey::rand(); + let original_public_key = original_secret_key.public_key(); + let password = "roundtrip_test_password"; + + let cmd = MinaEncryptedKey { + secret_key: Some(original_secret_key.clone()), + password: password.to_string(), + file: file_path_str.clone(), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + + // Load the key back using the secret key methods directly + let loaded_secret_key = AccountSecretKey::from_encrypted_file(&file_path_str, password); + assert!(loaded_secret_key.is_ok()); + let loaded_secret_key = loaded_secret_key.unwrap(); + let loaded_public_key = loaded_secret_key.public_key(); + + // Verify the keys match exactly + assert_eq!(original_public_key, loaded_public_key); + assert_eq!( + original_secret_key.to_string(), + loaded_secret_key.to_string() + ); + } + + #[test] + fn test_mina_key_pair_generates_random_key() { + let cmd = MinaKeyPair { secret_key: None }; + + let result = cmd.run(); + assert!(result.is_ok()); + } + + #[test] + fn test_mina_key_pair_with_provided_secret_key() { + let secret_key = AccountSecretKey::rand(); + let cmd = MinaKeyPair { + secret_key: Some(secret_key), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + } + + #[test] + fn test_p2p_key_pair_generates_random_key() { + let cmd = P2PKeyPair { + p2p_secret_key: None, + }; + + let result = cmd.run(); + assert!(result.is_ok()); + } + + #[test] + fn test_p2p_key_pair_with_provided_secret_key() { + let secret_key = SecretKey::rand(); + let cmd = P2PKeyPair { + p2p_secret_key: Some(secret_key), + }; + + let result = cmd.run(); + assert!(result.is_ok()); + } +} diff --git a/core/src/encrypted_key.rs b/core/src/encrypted_key.rs index 0b481dd93e..56947e19ac 100644 --- a/core/src/encrypted_key.rs +++ b/core/src/encrypted_key.rs @@ -1,3 +1,62 @@ +//! # Encrypted Secret Key Implementation +//! +//! This module provides a unified interface for encrypting and decrypting +//! cryptographic secret keys used throughout the OpenMina node. It implements +//! password-based encryption compatible with the Mina Protocol's key format. +//! +//! ## Usage +//! +//! This module is used by: +//! - Block producer keys ([`AccountSecretKey`]) for signing blocks and transactions +//! - P2P networking keys ([`SecretKey`]) for node identity and peer authentication +//! +//! [`AccountSecretKey`]: ../../../node/account/struct.AccountSecretKey.html +//! [`SecretKey`]: ../../../p2p/identity/struct.SecretKey.html +//! +//! ## Encryption Algorithms +//! +//! The implementation uses industry-standard cryptographic algorithms: +//! +//! ### Key Derivation +//! - **Argon2i**: Password-based key derivation function (PBKDF) with +//! configurable memory cost and time cost parameters +//! - **Default parameters**: 128MB memory cost, 6 iterations +//! - **Salt**: 32-byte random salt generated using OS entropy +//! +//! ### Symmetric Encryption +//! - **XSalsa20Poly1305**: Authenticated encryption with associated data (AEAD) +//! - **Key size**: 256-bit derived from password via Argon2i +//! - **Nonce**: 192-bit random nonce generated per encryption +//! - **Authentication**: Poly1305 MAC for ciphertext integrity +//! +//! ### Encoding +//! - **Base58**: All encrypted data (nonce, salt, ciphertext) encoded in +//! Base58 with version bytes for format compatibility with Mina Protocol +//! - **Version byte**: 2 for encryption data format compatibility +//! +//! ## File Format +//! +//! Encrypted keys are stored in JSON format with the following structure: +//! ```json +//! { +//! "box_primitive": "xsalsa20poly1305", +//! "pw_primitive": "argon2i", +//! "nonce": "base58-encoded-nonce", +//! "pwsalt": "base58-encoded-salt", +//! "pwdiff": [memory_cost_bytes, time_cost_iterations], +//! "ciphertext": "base58-encoded-encrypted-key" +//! } +//! ``` +//! +//! This format ensures compatibility with existing Mina Protocol tooling and +//! wallet implementations. +//! +//! ## Reference Implementation +//! +//! The encryption format is based on the OCaml implementation in the Mina +//! repository: +//! [`src/lib/secret_box`](https://github.com/MinaProtocol/mina/tree/develop/src/lib/secret_box) + use std::{fs, path::Path}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; @@ -44,13 +103,56 @@ pub enum EncryptionError { Other(String), } +/// Represents the JSON structure of an encrypted secret key file. +/// +/// This structure defines the format used to store encrypted secret keys on +/// disk, compatible with the Mina Protocol's key file format. The file +/// contains all necessary cryptographic parameters for decryption. +/// +/// # JSON Format +/// When serialized, this structure produces a JSON file with the following +/// format: +/// ```json +/// { +/// "box_primitive": "xsalsa20poly1305", +/// "pw_primitive": "argon2i", +/// "nonce": "base58-encoded-nonce-with-version-byte", +/// "pwsalt": "base58-encoded-salt-with-version-byte", +/// "pwdiff": [memory_cost_in_bytes, time_cost_iterations], +/// "ciphertext": "base58-encoded-encrypted-key-with-version-byte" +/// } +/// ``` +/// +/// # Security Considerations +/// - The `nonce` must be unique for each encryption operation +/// - The `pwsalt` should be cryptographically random +/// - The `pwdiff` parameters determine the computational cost of key +/// derivation +/// - All Base58-encoded fields include version bytes for format validation #[derive(Serialize, Deserialize, Debug)] pub struct EncryptedSecretKeyFile { + /// Symmetric encryption algorithm identifier. + /// Always "xsalsa20poly1305" for compatibility. box_primitive: String, + + /// Password-based key derivation function identifier. + /// Always "argon2i" for compatibility. pw_primitive: String, + + /// Encryption nonce encoded in Base58 with version byte. + /// Used once per encryption to ensure semantic security. nonce: Base58String, + + /// Argon2 salt encoded in Base58 with version byte. + /// Random value used in password-based key derivation. pwsalt: Base58String, + + /// Argon2 parameters as (memory_cost_bytes, time_cost_iterations). + /// Determines computational difficulty of key derivation. pwdiff: (u32, u32), + + /// Encrypted secret key encoded in Base58 with version byte. + /// Contains the actual encrypted key data with authentication tag. ciphertext: Base58String, } @@ -80,12 +182,39 @@ pub trait EncryptedSecretKey { const ENCRYPTION_DATA_VERSION_BYTE: u8 = 2; const SECRET_KEY_PREFIX_BYTE: u8 = 1; - // Based on the ocaml implementation + // Based on the OCaml implementation at: + // https://github.com/MinaProtocol/mina/tree/develop/src/lib/secret_box const BOX_PRIMITIVE: &'static str = "xsalsa20poly1305"; const PW_PRIMITIVE: &'static str = "argon2i"; - // Note: Only used for enryption, for decryption use the pwdiff from the file + // Note: Only used for encryption, for decryption use the pwdiff from the + // file const PW_DIFF: (u32, u32) = (134217728, 6); + /// Decrypts an encrypted secret key file using the provided password. + /// + /// This method implements the decryption process compatible with Mina + /// Protocol's key format: + /// 1. Decodes Base58-encoded nonce, salt, and ciphertext from the file + /// 2. Derives encryption key from password using Argon2i with file's + /// parameters + /// 3. Decrypts the ciphertext using XSalsa20Poly1305 AEAD + /// 4. Returns the raw secret key bytes (with prefix byte stripped) + /// + /// # Parameters + /// - `encrypted`: The encrypted key file structure containing all + /// encryption metadata + /// - `password`: The password used to derive the decryption key + /// + /// # Returns + /// - `Ok(Vec)`: The raw secret key bytes on successful decryption + /// - `Err(EncryptionError)`: Various errors including wrong password, + /// corrupted data, or format incompatibility + /// + /// # Errors + /// - `EncryptionError::SecretBox`: AEAD decryption failure (wrong + /// password) + /// - `EncryptionError::Base58DecodeError`: Invalid Base58 encoding + /// - `EncryptionError::ArgonError`: Key derivation failure fn try_decrypt( encrypted: &EncryptedSecretKeyFile, password: &str, @@ -102,8 +231,9 @@ pub trait EncryptedSecretKey { .ciphertext .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?; - // The argon crate's SaltString can only be built from base64 string, ocaml node encodes the salt in base58 - // So we decoded it from base58 first, then convert to base64 and lastly to SaltString + // The argon crate's SaltString can only be built from base64 string, + // but the OCaml Mina node encodes the salt in base58. So we decode it + // from base58 first, then convert to base64 and lastly to SaltString let pwsalt_encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(pwsalt); let salt = SaltString::from_b64(&pwsalt_encoded)?; @@ -121,10 +251,41 @@ pub trait EncryptedSecretKey { // strip the prefix and create keypair Ok(decrypted) } + + /// Encrypts a secret key using password-based encryption. + /// + /// This method implements the encryption process compatible with Mina + /// Protocol's key format: + /// 1. Prefixes the key with a format version byte + /// 2. Generates a random salt and derives encryption key using Argon2i + /// 3. Encrypts the prefixed key using XSalsa20Poly1305 AEAD with a + /// random nonce + /// 4. Encodes all components (nonce, salt, ciphertext) in Base58 format + /// 5. Returns the complete encrypted file structure + /// + /// # Parameters + /// - `key`: The raw secret key bytes to encrypt + /// - `password`: The password used to derive the encryption key + /// + /// # Returns + /// - `Ok(EncryptedSecretKeyFile)`: Complete encrypted file structure + /// ready for JSON serialization + /// - `Err(EncryptionError)`: Encryption process failure + /// + /// # Errors + /// - `EncryptionError::ArgonError`: Key derivation failure + /// - `EncryptionError::SecretBox`: AEAD encryption failure + /// - `EncryptionError::HashMissing`: Argon2 hash generation failure + /// + /// # Security Notes + /// - Uses cryptographically secure random number generation for salt + /// and nonce + /// - Default Argon2i parameters: 128MB memory cost, 6 iterations + /// - Each encryption produces unique salt and nonce for security fn try_encrypt(key: &[u8], password: &str) -> Result { let argon2 = setup_argon(Self::PW_DIFF)?; - // add the prefix byt to the key + // add the prefix byte to the key let mut key_prefixed = vec![Self::SECRET_KEY_PREFIX_BYTE]; key_prefixed.extend(key); @@ -139,7 +300,8 @@ pub trait EncryptedSecretKey { let ciphertext = cipher.encrypt(&nonce, key_prefixed.as_slice())?; - // Same reason as in decrypt, we ned to decode the SaltString from base64 then encode it to base58 bellow + // Same reason as in decrypt, we need to decode the SaltString from + // base64 then encode it to base58 below let mut salt_bytes = [0; 32]; let salt_portion = salt.decode_b64(&mut salt_bytes)?; diff --git a/node/account/src/secret_key.rs b/node/account/src/secret_key.rs index b530bc4731..8fa2a25746 100644 --- a/node/account/src/secret_key.rs +++ b/node/account/src/secret_key.rs @@ -237,7 +237,7 @@ mod tests { // load and decrypt let decrypted = AccountSecretKey::from_encrypted_file(&tmp_path, password) - .expect("Failed to decrypt secret key file"); + .unwrap_or_else(|_| panic!("Failed to decrypt secret key file: {}", tmp_path)); assert_eq!( new_key.public_key(), @@ -252,7 +252,7 @@ mod tests { let key_path = "../tests/files/accounts/test-key-1"; let expected_public_key = "B62qmg7n4XqU3SFwx9KD9B7gxsKwxJP5GmxtBpHp1uxyN3grujii9a1"; let decrypted = AccountSecretKey::from_encrypted_file(key_path, password) - .expect("Failed to decrypt secret key file"); + .unwrap_or_else(|_| panic!("Failed to decrypt secret key file: {}", key_path)); assert_eq!( expected_public_key.to_string(), diff --git a/node/native/src/node/builder.rs b/node/native/src/node/builder.rs index 40546f2612..eec812882f 100644 --- a/node/native/src/node/builder.rs +++ b/node/native/src/node/builder.rs @@ -213,8 +213,12 @@ impl NodeBuilder { password: &str, provers: Option, ) -> anyhow::Result<&mut Self> { - let key = AccountSecretKey::from_encrypted_file(path, password) - .context("Failed to decrypt secret key file")?; + let key = AccountSecretKey::from_encrypted_file(&path, password).with_context(|| { + format!( + "Failed to decrypt secret key file: {}", + path.as_ref().display() + ) + })?; Ok(self.block_producer(key, provers)) } diff --git a/p2p/src/identity/secret_key.rs b/p2p/src/identity/secret_key.rs index 43617726d1..5cd53a0d79 100644 --- a/p2p/src/identity/secret_key.rs +++ b/p2p/src/identity/secret_key.rs @@ -295,7 +295,7 @@ mod tests { let expected_peer_id = "12D3KooWDxyuJKSsVEwNR13UVwf4PEfs4yHkk3ecZipBPv3Y3Sac"; let decrypted = SecretKey::from_encrypted_file(key_path, password) - .expect("Failed to decrypt secret key file"); + .unwrap_or_else(|_| panic!("Failed to decrypt secret key file: {}", key_path)); let peer_id = decrypted.public_key().peer_id().to_libp2p_string(); assert_eq!(expected_peer_id, peer_id); diff --git a/website/docs/node-runners/block-producer.md b/website/docs/node-runners/block-producer.md index 0807392fbb..0ec9cfd1e8 100644 --- a/website/docs/node-runners/block-producer.md +++ b/website/docs/node-runners/block-producer.md @@ -85,7 +85,32 @@ using the Makefile target. This method requires building from source. ``` This will create a new key pair and save the private key to - `openmina-workdir/producer-key`. + `openmina-workdir/producer-key` and the public key to + `openmina-workdir/producer-key.pub`. The command will fail if keys already + exist to prevent accidental overwriting. + + To generate keys with a password: + + ```bash + make generate-block-producer-key MINA_PRIVKEY_PASS="YourPassword" + ``` + + To generate keys with a custom filename: + + ```bash + make generate-block-producer-key PRODUCER_KEY_FILENAME=./path/to/custom-key + ``` + + This will create `./path/to/custom-key` (private) and + `./path/to/custom-key.pub` (public). + + You can combine both options: + + ```bash + make generate-block-producer-key \ + PRODUCER_KEY_FILENAME=./path/to/custom-key \ + MINA_PRIVKEY_PASS="YourPassword" + ``` **Option B: Use an existing key** @@ -99,34 +124,32 @@ using the Makefile target. This method requires building from source. For devnet (default): ```bash - make run-block-producer-devnet COINBASE_RECEIVER="YourWalletAddress" \ - MINA_PRIVKEY_PASS="YourPassword" - ``` - - Or explicitly specify devnet: - - ```bash - make run-block-producer NETWORK=devnet COINBASE_RECEIVER="YourWalletAddress" \ - MINA_PRIVKEY_PASS="YourPassword" + make run-block-producer \ + MINA_PRIVKEY_PASS="YourPassword" \ + NETWORK=devnet \ + COINBASE_RECEIVER="YourWalletAddress" ``` For mainnet (when supported): ```bash - make run-block-producer-mainnet COINBASE_RECEIVER="YourWalletAddress" \ - MINA_PRIVKEY_PASS="YourPassword" + make run-block-producer \ + COINBASE_RECEIVER="YourWalletAddress" \ + MINA_PRIVKEY_PASS="YourPassword" \ + NETWORK=mainnet ``` Optional parameters: - `OPENMINA_LIBP2P_EXTERNAL_IP` - Sets external IP address - `OPENMINA_LIBP2P_PORT` - Sets libp2p communication port - - `PRODUCER_KEY` - Path to producer key (default: + - `PRODUCER_KEY_FILENAME` - Path to producer key (default: `./openmina-workdir/producer-key`) Example with all options: ```bash - make run-block-producer-devnet \ + make run-block-producer \ + NETWORK=devnet \ COINBASE_RECEIVER="YourWalletAddress" \ MINA_PRIVKEY_PASS="YourPassword" \ OPENMINA_LIBP2P_EXTERNAL_IP="1.2.3.4" \