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}