openmina_core/
encrypted_key.rs

1//! # Encrypted Secret Key Implementation
2//!
3//! This module provides a unified interface for encrypting and decrypting
4//! cryptographic secret keys used throughout the OpenMina node. It implements
5//! password-based encryption compatible with the Mina Protocol's key format.
6//!
7//! ## Usage
8//!
9//! This module is used by:
10//! - Block producer keys ([`AccountSecretKey`]) for signing blocks and transactions
11//! - P2P networking keys ([`SecretKey`]) for node identity and peer authentication
12//!
13//! [`AccountSecretKey`]: ../../../node/account/struct.AccountSecretKey.html
14//! [`SecretKey`]: ../../../p2p/identity/struct.SecretKey.html
15//!
16//! ## Encryption Algorithms
17//!
18//! The implementation uses industry-standard cryptographic algorithms:
19//!
20//! ### Key Derivation
21//! - **Argon2i**: Password-based key derivation function (PBKDF) with
22//!   configurable memory cost and time cost parameters
23//! - **Default parameters**: 128MB memory cost, 6 iterations
24//! - **Salt**: 32-byte random salt generated using OS entropy
25//!
26//! ### Symmetric Encryption
27//! - **XSalsa20Poly1305**: Authenticated encryption with associated data (AEAD)
28//! - **Key size**: 256-bit derived from password via Argon2i
29//! - **Nonce**: 192-bit random nonce generated per encryption
30//! - **Authentication**: Poly1305 MAC for ciphertext integrity
31//!
32//! ### Encoding
33//! - **Base58**: All encrypted data (nonce, salt, ciphertext) encoded in
34//!   Base58 with version bytes for format compatibility with Mina Protocol
35//! - **Version byte**: 2 for encryption data format compatibility
36//!
37//! ## File Format
38//!
39//! Encrypted keys are stored in JSON format with the following structure:
40//! ```json
41//! {
42//!   "box_primitive": "xsalsa20poly1305",
43//!   "pw_primitive": "argon2i",
44//!   "nonce": "base58-encoded-nonce",
45//!   "pwsalt": "base58-encoded-salt",
46//!   "pwdiff": [memory_cost_bytes, time_cost_iterations],
47//!   "ciphertext": "base58-encoded-encrypted-key"
48//! }
49//! ```
50//!
51//! This format ensures compatibility with existing Mina Protocol tooling and
52//! wallet implementations.
53//!
54//! ## Reference Implementation
55//!
56//! The encryption format is based on the OCaml implementation in the Mina
57//! repository:
58//! [`src/lib/secret_box`](https://github.com/MinaProtocol/mina/tree/develop/src/lib/secret_box)
59
60use std::{fs, path::Path};
61
62use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
63use base64::Engine;
64use crypto_secretbox::{
65    aead::{Aead, OsRng},
66    AeadCore, KeyInit, XSalsa20Poly1305,
67};
68use serde::{Deserialize, Serialize};
69
70#[derive(Serialize, Deserialize, Debug)]
71struct Base58String(String);
72
73impl Base58String {
74    pub fn new(raw: &[u8], version: u8) -> Self {
75        Base58String(bs58::encode(raw).with_check_version(version).into_string())
76    }
77
78    pub fn try_decode(&self, version: u8) -> Result<Vec<u8>, EncryptionError> {
79        let decoded = bs58::decode(&self.0).with_check(Some(version)).into_vec()?;
80        Ok(decoded[1..].to_vec())
81    }
82}
83
84#[derive(Debug, thiserror::Error)]
85pub enum EncryptionError {
86    #[error(transparent)]
87    SecretBox(#[from] crypto_secretbox::aead::Error),
88    #[error(transparent)]
89    ArgonError(#[from] argon2::Error),
90    #[error(transparent)]
91    PasswordHash(#[from] argon2::password_hash::Error),
92    #[error(transparent)]
93    Base58DecodeError(#[from] bs58::decode::Error),
94    #[error(transparent)]
95    CipherKeyInvalidLength(#[from] crypto_secretbox::cipher::InvalidLength),
96    #[error("Password hash missing after hash_password")]
97    HashMissing,
98    #[error(transparent)]
99    Io(#[from] std::io::Error),
100    #[error(transparent)]
101    SerdeJson(#[from] serde_json::Error),
102    #[error("Other: {0}")]
103    Other(String),
104}
105
106/// Represents the JSON structure of an encrypted secret key file.
107///
108/// This structure defines the format used to store encrypted secret keys on
109/// disk, compatible with the Mina Protocol's key file format. The file
110/// contains all necessary cryptographic parameters for decryption.
111///
112/// # JSON Format
113/// When serialized, this structure produces a JSON file with the following
114/// format:
115/// ```json
116/// {
117///   "box_primitive": "xsalsa20poly1305",
118///   "pw_primitive": "argon2i",
119///   "nonce": "base58-encoded-nonce-with-version-byte",
120///   "pwsalt": "base58-encoded-salt-with-version-byte",
121///   "pwdiff": [memory_cost_in_bytes, time_cost_iterations],
122///   "ciphertext": "base58-encoded-encrypted-key-with-version-byte"
123/// }
124/// ```
125///
126/// # Security Considerations
127/// - The `nonce` must be unique for each encryption operation
128/// - The `pwsalt` should be cryptographically random
129/// - The `pwdiff` parameters determine the computational cost of key
130///   derivation
131/// - All Base58-encoded fields include version bytes for format validation
132#[derive(Serialize, Deserialize, Debug)]
133pub struct EncryptedSecretKeyFile {
134    /// Symmetric encryption algorithm identifier.
135    /// Always "xsalsa20poly1305" for compatibility.
136    box_primitive: String,
137
138    /// Password-based key derivation function identifier.
139    /// Always "argon2i" for compatibility.
140    pw_primitive: String,
141
142    /// Encryption nonce encoded in Base58 with version byte.
143    /// Used once per encryption to ensure semantic security.
144    nonce: Base58String,
145
146    /// Argon2 salt encoded in Base58 with version byte.
147    /// Random value used in password-based key derivation.
148    pwsalt: Base58String,
149
150    /// Argon2 parameters as (memory_cost_bytes, time_cost_iterations).
151    /// Determines computational difficulty of key derivation.
152    pwdiff: (u32, u32),
153
154    /// Encrypted secret key encoded in Base58 with version byte.
155    /// Contains the actual encrypted key data with authentication tag.
156    ciphertext: Base58String,
157}
158
159impl EncryptedSecretKeyFile {
160    pub fn new(path: impl AsRef<Path>) -> Result<Self, EncryptionError> {
161        let file = fs::File::open(path)?;
162        Ok(serde_json::from_reader(file)?)
163    }
164}
165
166fn setup_argon(pwdiff: (u32, u32)) -> Result<Argon2<'static>, EncryptionError> {
167    let params = argon2::Params::new(
168        pwdiff.0 / 1024,
169        pwdiff.1,
170        argon2::Params::DEFAULT_P_COST,
171        None,
172    )?;
173
174    Ok(Argon2::new(
175        argon2::Algorithm::Argon2i,
176        Default::default(),
177        params,
178    ))
179}
180
181pub trait EncryptedSecretKey {
182    const ENCRYPTION_DATA_VERSION_BYTE: u8 = 2;
183    const SECRET_KEY_PREFIX_BYTE: u8 = 1;
184
185    // Based on the OCaml implementation at:
186    // https://github.com/MinaProtocol/mina/tree/develop/src/lib/secret_box
187    const BOX_PRIMITIVE: &'static str = "xsalsa20poly1305";
188    const PW_PRIMITIVE: &'static str = "argon2i";
189    // Note: Only used for encryption, for decryption use the pwdiff from the
190    // file
191    const PW_DIFF: (u32, u32) = (134217728, 6);
192
193    /// Decrypts an encrypted secret key file using the provided password.
194    ///
195    /// This method implements the decryption process compatible with Mina
196    /// Protocol's key format:
197    /// 1. Decodes Base58-encoded nonce, salt, and ciphertext from the file
198    /// 2. Derives encryption key from password using Argon2i with file's
199    ///    parameters
200    /// 3. Decrypts the ciphertext using XSalsa20Poly1305 AEAD
201    /// 4. Returns the raw secret key bytes (with prefix byte stripped)
202    ///
203    /// # Parameters
204    /// - `encrypted`: The encrypted key file structure containing all
205    ///   encryption metadata
206    /// - `password`: The password used to derive the decryption key
207    ///
208    /// # Returns
209    /// - `Ok(Vec<u8>)`: The raw secret key bytes on successful decryption
210    /// - `Err(EncryptionError)`: Various errors including wrong password,
211    ///   corrupted data, or format incompatibility
212    ///
213    /// # Errors
214    /// - `EncryptionError::SecretBox`: AEAD decryption failure (wrong
215    ///   password)
216    /// - `EncryptionError::Base58DecodeError`: Invalid Base58 encoding
217    /// - `EncryptionError::ArgonError`: Key derivation failure
218    fn try_decrypt(
219        encrypted: &EncryptedSecretKeyFile,
220        password: &str,
221    ) -> Result<Vec<u8>, EncryptionError> {
222        // prepare inputs to cipher
223        let password = password.as_bytes();
224        let pwsalt = encrypted
225            .pwsalt
226            .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?;
227        let nonce = encrypted
228            .nonce
229            .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?;
230        let ciphertext = encrypted
231            .ciphertext
232            .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?;
233
234        // The argon crate's SaltString can only be built from base64 string,
235        // but the OCaml Mina node encodes the salt in base58. So we decode it
236        // from base58 first, then convert to base64 and lastly to SaltString
237        let pwsalt_encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(pwsalt);
238        let salt = SaltString::from_b64(&pwsalt_encoded)?;
239
240        let argon2 = setup_argon(encrypted.pwdiff)?;
241        let password_hash = argon2
242            .hash_password(password, &salt)?
243            .hash
244            .ok_or(EncryptionError::HashMissing)?;
245        let password_bytes = password_hash.as_bytes();
246
247        // decrypt cipher
248        let cipher = XSalsa20Poly1305::new_from_slice(password_bytes)?;
249        let decrypted = cipher.decrypt(nonce.as_slice().into(), ciphertext.as_ref())?;
250
251        // strip the prefix and create keypair
252        Ok(decrypted)
253    }
254
255    /// Encrypts a secret key using password-based encryption.
256    ///
257    /// This method implements the encryption process compatible with Mina
258    /// Protocol's key format:
259    /// 1. Prefixes the key with a format version byte
260    /// 2. Generates a random salt and derives encryption key using Argon2i
261    /// 3. Encrypts the prefixed key using XSalsa20Poly1305 AEAD with a
262    ///    random nonce
263    /// 4. Encodes all components (nonce, salt, ciphertext) in Base58 format
264    /// 5. Returns the complete encrypted file structure
265    ///
266    /// # Parameters
267    /// - `key`: The raw secret key bytes to encrypt
268    /// - `password`: The password used to derive the encryption key
269    ///
270    /// # Returns
271    /// - `Ok(EncryptedSecretKeyFile)`: Complete encrypted file structure
272    ///   ready for JSON serialization
273    /// - `Err(EncryptionError)`: Encryption process failure
274    ///
275    /// # Errors
276    /// - `EncryptionError::ArgonError`: Key derivation failure
277    /// - `EncryptionError::SecretBox`: AEAD encryption failure
278    /// - `EncryptionError::HashMissing`: Argon2 hash generation failure
279    ///
280    /// # Security Notes
281    /// - Uses cryptographically secure random number generation for salt
282    ///   and nonce
283    /// - Default Argon2i parameters: 128MB memory cost, 6 iterations
284    /// - Each encryption produces unique salt and nonce for security
285    fn try_encrypt(key: &[u8], password: &str) -> Result<EncryptedSecretKeyFile, EncryptionError> {
286        let argon2 = setup_argon(Self::PW_DIFF)?;
287
288        // add the prefix byte to the key
289        let mut key_prefixed = vec![Self::SECRET_KEY_PREFIX_BYTE];
290        key_prefixed.extend(key);
291
292        let salt = SaltString::generate(&mut OsRng);
293        let password_hash = argon2
294            .hash_password(password.as_bytes(), &salt)?
295            .hash
296            .ok_or(EncryptionError::HashMissing)?;
297
298        let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng);
299        let cipher = XSalsa20Poly1305::new_from_slice(password_hash.as_bytes())?;
300
301        let ciphertext = cipher.encrypt(&nonce, key_prefixed.as_slice())?;
302
303        // Same reason as in decrypt, we need to decode the SaltString from
304        // base64 then encode it to base58 below
305        let mut salt_bytes = [0; 32];
306        let salt_portion = salt.decode_b64(&mut salt_bytes)?;
307
308        Ok(EncryptedSecretKeyFile {
309            box_primitive: Self::BOX_PRIMITIVE.to_string(),
310            pw_primitive: Self::PW_PRIMITIVE.to_string(),
311            nonce: Base58String::new(&nonce, Self::ENCRYPTION_DATA_VERSION_BYTE),
312            pwsalt: Base58String::new(salt_portion, Self::ENCRYPTION_DATA_VERSION_BYTE),
313            pwdiff: (argon2.params().m_cost() * 1024, argon2.params().t_cost()),
314            ciphertext: Base58String::new(&ciphertext, Self::ENCRYPTION_DATA_VERSION_BYTE),
315        })
316    }
317}