Skip to main content

zinc_core/
crypto.rs

1//! Encryption module for wallet seed protection
2//!
3//! Uses Argon2id for key derivation and AES-256-GCM for encryption.
4
5use aes_gcm::{
6    aead::{Aead, KeyInit},
7    Aes256Gcm, Nonce,
8};
9use argon2::{password_hash::SaltString, Argon2, Params};
10use rand::{rngs::OsRng, RngCore};
11use serde::{Deserialize, Serialize};
12use zeroize::Zeroizing;
13
14use crate::error::ZincError;
15
16/// Argon2 parameters for key derivation.
17/// Version 1: 64MB memory, 3 iterations - secure but slow for browser.
18const V1_M_COST: u32 = 65536; // 64 MB
19const V1_T_COST: u32 = 3;
20const V1_P_COST: u32 = 1;
21
22/// Version 2: 32MB memory, 1 iteration - ~3x faster, balanced for UX.
23const V2_M_COST: u32 = 32768; // 32 MB
24const V2_T_COST: u32 = 1;
25const V2_P_COST: u32 = 1;
26
27/// An encrypted wallet blob ready for storage.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct EncryptedWallet {
30    /// Salt for Argon2 (base64 encoded)
31    pub salt: String,
32    /// Nonce for AES-GCM (base64 encoded)
33    pub nonce: String,
34    /// Encrypted seed (base64 encoded)
35    pub ciphertext: String,
36    /// Version for future format changes
37    /// 1 = 64MB/3 iter, 2 = 32MB/1 iter
38    pub version: u8,
39}
40
41/// Encrypt a seed with a password using Argon2id + AES-256-GCM.
42pub fn encrypt_seed(seed: &[u8], password: &str) -> Result<EncryptedWallet, ZincError> {
43    // Generate random salt
44    let salt = SaltString::generate(&mut OsRng);
45
46    // Default to newest version (v2) for new encryptions
47    let version = 2;
48
49    // Derive key using Argon2id
50    let key = derive_key(password, salt.as_str(), version)?;
51
52    // Generate random nonce
53    let mut nonce_bytes = [0u8; 12];
54    OsRng.fill_bytes(&mut nonce_bytes);
55    let nonce = Nonce::from_slice(&nonce_bytes);
56
57    // Encrypt with AES-256-GCM
58    let cipher =
59        Aes256Gcm::new_from_slice(&*key).map_err(|e| ZincError::EncryptionError(e.to_string()))?;
60
61    let ciphertext = cipher
62        .encrypt(nonce, seed)
63        .map_err(|e| ZincError::EncryptionError(e.to_string()))?;
64
65    Ok(EncryptedWallet {
66        salt: salt.to_string(),
67        nonce: base64_encode(&nonce_bytes),
68        ciphertext: base64_encode(&ciphertext),
69        version,
70    })
71}
72
73/// Decrypt an encrypted wallet with a password.
74pub fn decrypt_seed(
75    encrypted: &EncryptedWallet,
76    password: &str,
77) -> Result<Zeroizing<Vec<u8>>, ZincError> {
78    // Derive key using Argon2id with version-specific parameters
79    let key = derive_key(password, &encrypted.salt, encrypted.version)?;
80
81    // Decode nonce and ciphertext
82    let nonce_bytes = base64_decode(&encrypted.nonce)?;
83    let ciphertext = base64_decode(&encrypted.ciphertext)?;
84
85    // `Nonce::from_slice` panics when length != 12. Treat malformed payloads as decryption failure.
86    if nonce_bytes.len() != 12 {
87        return Err(ZincError::DecryptionError);
88    }
89
90    let nonce = Nonce::from_slice(&nonce_bytes);
91
92    // Decrypt with AES-256-GCM
93    let cipher = Aes256Gcm::new_from_slice(&*key).map_err(|_| ZincError::DecryptionError)?;
94
95    let plaintext = cipher
96        .decrypt(nonce, ciphertext.as_slice())
97        .map_err(|_| ZincError::DecryptionError)?;
98
99    Ok(Zeroizing::new(plaintext))
100}
101
102/// Derive a 256-bit key from password using Argon2id.
103fn derive_key(password: &str, salt: &str, version: u8) -> Result<Zeroizing<[u8; 32]>, ZincError> {
104    let (m, t, p) = match version {
105        1 => (V1_M_COST, V1_T_COST, V1_P_COST),
106        2 => (V2_M_COST, V2_T_COST, V2_P_COST),
107        _ => {
108            return Err(ZincError::EncryptionError(format!(
109                "Unsupported wallet version: {}",
110                version
111            )))
112        }
113    };
114
115    let params =
116        Params::new(m, t, p, Some(32)).map_err(|e| ZincError::EncryptionError(e.to_string()))?;
117
118    let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
119
120    let mut key = Zeroizing::new([0u8; 32]);
121    argon2
122        .hash_password_into(password.as_bytes(), salt.as_bytes(), &mut *key)
123        .map_err(|e| ZincError::EncryptionError(e.to_string()))?;
124
125    Ok(key)
126}
127
128fn base64_encode(data: &[u8]) -> String {
129    use base64::{engine::general_purpose::STANDARD, Engine};
130    STANDARD.encode(data)
131}
132
133fn base64_decode(data: &str) -> Result<Vec<u8>, ZincError> {
134    use base64::{engine::general_purpose::STANDARD, Engine};
135    STANDARD
136        .decode(data)
137        .map_err(|e| ZincError::SerializationError(e.to_string()))
138}
139
140#[cfg(test)]
141#[allow(clippy::unwrap_used)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_encrypt_decrypt_roundtrip() {
147        let seed = b"this is a test seed for encryption";
148        let password = "secure_password_123!";
149
150        let encrypted = encrypt_seed(seed, password).unwrap();
151        let decrypted = decrypt_seed(&encrypted, password).unwrap();
152
153        assert_eq!(seed.as_slice(), decrypted.as_slice());
154    }
155
156    #[test]
157    fn test_wrong_password_fails() {
158        let seed = b"this is a test seed for encryption";
159        let password = "correct_password";
160        let wrong_password = "wrong_password";
161
162        let encrypted = encrypt_seed(seed, password).unwrap();
163        let result = decrypt_seed(&encrypted, wrong_password);
164
165        assert!(result.is_err());
166    }
167
168    #[test]
169    fn test_encrypted_wallet_serialization() {
170        let seed = b"test seed";
171        let password = "password";
172
173        let encrypted = encrypt_seed(seed, password).unwrap();
174        let json = serde_json::to_string(&encrypted).unwrap();
175        let parsed: EncryptedWallet = serde_json::from_str(&json).unwrap();
176
177        let decrypted = decrypt_seed(&parsed, password).unwrap();
178        assert_eq!(seed.as_slice(), decrypted.as_slice());
179    }
180
181    #[test]
182    fn test_malformed_nonce_length_fails_without_panic() {
183        let seed = b"test seed";
184        let password = "password";
185
186        let mut encrypted = encrypt_seed(seed, password).unwrap();
187        encrypted.nonce = base64_encode(&[0u8; 8]);
188
189        let result = decrypt_seed(&encrypted, password);
190        assert!(matches!(result, Err(ZincError::DecryptionError)));
191    }
192}