Skip to main content

chains_sdk/ethereum/
keystore.rs

1//! **Web3 Secret Storage v3** — Encrypted JSON keystore for Ethereum wallets.
2//!
3//! Implements the standard keystore format used by MetaMask, Geth, and other
4//! Ethereum wallets for securely storing private keys.
5//!
6//! Uses **scrypt** for key derivation and **AES-128-CTR** for encryption,
7//! following the [Web3 Secret Storage Definition](https://ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage/).
8
9use crate::error::SignerError;
10use aes::cipher::{KeyIvInit, StreamCipher};
11use core::fmt;
12use zeroize::Zeroizing;
13
14/// AES-128-CTR cipher type alias.
15type Aes128Ctr = ctr::Ctr64BE<aes::Aes128>;
16
17/// Scrypt parameters for keystore encryption.
18#[derive(Debug, Clone)]
19pub struct ScryptParams {
20    /// N — CPU/memory cost parameter (must be power of 2).
21    pub n: u32,
22    /// r — block size.
23    pub r: u32,
24    /// p — parallelization.
25    pub p: u32,
26    /// Derived key length in bytes (default: 32).
27    pub dklen: u32,
28}
29
30impl Default for ScryptParams {
31    /// Default scrypt parameters matching Geth/MetaMask defaults.
32    fn default() -> Self {
33        Self {
34            n: 262144, // 2^18
35            r: 8,
36            p: 1,
37            dklen: 32,
38        }
39    }
40}
41
42impl ScryptParams {
43    /// Light scrypt parameters for faster encryption (testing/mobile).
44    #[must_use]
45    pub fn light() -> Self {
46        Self {
47            n: 4096, // 2^12
48            r: 8,
49            p: 6,
50            dklen: 32,
51        }
52    }
53}
54
55/// An encrypted Ethereum keystore (V3 format).
56///
57/// Fields correspond to the JSON keystore standard.
58#[derive(Clone)]
59pub struct Keystore {
60    /// UUID for this keystore.
61    pub id: String,
62    /// EIP-55 checksummed address.
63    pub address: String,
64    /// Scrypt parameters used.
65    scrypt_params: ScryptParams,
66    /// 32-byte random salt for scrypt.
67    salt: [u8; 32],
68    /// 16-byte IV for AES-128-CTR.
69    iv: [u8; 16],
70    /// Encrypted private key (ciphertext).
71    ciphertext: Vec<u8>,
72    /// MAC: keccak256(derived_key[16..32] || ciphertext).
73    mac: [u8; 32],
74}
75
76impl Keystore {
77    /// Encrypt a private key into a keystore.
78    ///
79    /// # Arguments
80    /// - `private_key` — 32-byte private key
81    /// - `password` — User password for encryption
82    /// - `params` — Scrypt parameters (use `ScryptParams::default()` for standard)
83    pub fn encrypt(
84        private_key: &[u8],
85        password: &[u8],
86        params: &ScryptParams,
87    ) -> Result<Self, SignerError> {
88        if private_key.len() != 32 {
89            return Err(SignerError::InvalidPrivateKey(
90                "key must be 32 bytes".into(),
91            ));
92        }
93
94        // Generate random salt and IV
95        let mut salt = [0u8; 32];
96        crate::security::secure_random(&mut salt)?;
97        let mut iv = [0u8; 16];
98        crate::security::secure_random(&mut iv)?;
99
100        // Derive key using scrypt
101        let derived = derive_scrypt_key(password, &salt, params)?;
102
103        // Encrypt with AES-128-CTR using first 16 bytes of derived key
104        let mut ciphertext = private_key.to_vec();
105        let mut cipher = Aes128Ctr::new(derived[..16].into(), iv.as_ref().into());
106        cipher.apply_keystream(&mut ciphertext);
107
108        // MAC: keccak256(derived_key[16..32] || ciphertext)
109        let mut mac_input = Vec::with_capacity(16 + ciphertext.len());
110        mac_input.extend_from_slice(&derived[16..32]);
111        mac_input.extend_from_slice(&ciphertext);
112        let mac = keccak256(&mac_input);
113
114        // Derive address for the keystore
115        use crate::traits::KeyPair;
116        let signer = super::EthereumSigner::from_bytes(private_key)?;
117        let address = signer.address_checksum();
118
119        // Generate UUID
120        let mut uuid_bytes = [0u8; 16];
121        crate::security::secure_random(&mut uuid_bytes)?;
122        // Set version 4 and variant bits
123        uuid_bytes[6] = (uuid_bytes[6] & 0x0F) | 0x40;
124        uuid_bytes[8] = (uuid_bytes[8] & 0x3F) | 0x80;
125        let id = format!(
126            "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
127            u32::from_be_bytes([uuid_bytes[0], uuid_bytes[1], uuid_bytes[2], uuid_bytes[3]]),
128            u16::from_be_bytes([uuid_bytes[4], uuid_bytes[5]]),
129            u16::from_be_bytes([uuid_bytes[6], uuid_bytes[7]]),
130            u16::from_be_bytes([uuid_bytes[8], uuid_bytes[9]]),
131            u64::from_be_bytes([
132                0,
133                0,
134                uuid_bytes[10],
135                uuid_bytes[11],
136                uuid_bytes[12],
137                uuid_bytes[13],
138                uuid_bytes[14],
139                uuid_bytes[15]
140            ]),
141        );
142
143        Ok(Self {
144            id,
145            address,
146            scrypt_params: params.clone(),
147            salt,
148            iv,
149            ciphertext,
150            mac,
151        })
152    }
153
154    /// Decrypt the private key from this keystore.
155    ///
156    /// Returns the 32-byte private key wrapped in `Zeroizing`.
157    pub fn decrypt(&self, password: &[u8]) -> Result<Zeroizing<Vec<u8>>, SignerError> {
158        // Derive key
159        let derived = derive_scrypt_key(password, &self.salt, &self.scrypt_params)?;
160
161        // Verify MAC
162        let mut mac_input = Vec::with_capacity(16 + self.ciphertext.len());
163        mac_input.extend_from_slice(&derived[16..32]);
164        mac_input.extend_from_slice(&self.ciphertext);
165        let computed_mac = keccak256(&mac_input);
166
167        use subtle::ConstantTimeEq;
168        if computed_mac.ct_eq(&self.mac).unwrap_u8() != 1 {
169            return Err(SignerError::InvalidSignature(
170                "keystore MAC verification failed (wrong password?)".into(),
171            ));
172        }
173
174        // Decrypt with AES-128-CTR
175        let mut plaintext = self.ciphertext.clone();
176        let mut cipher = Aes128Ctr::new(derived[..16].into(), self.iv.as_ref().into());
177        cipher.apply_keystream(&mut plaintext);
178
179        Ok(Zeroizing::new(plaintext))
180    }
181
182    /// Serialize the keystore to JSON string.
183    ///
184    /// Produces standard Web3 Secret Storage v3 format.
185    #[must_use]
186    pub fn to_json(&self) -> String {
187        format!(
188            r#"{{"version":3,"id":"{}","address":"{}","crypto":{{"cipher":"aes-128-ctr","cipherparams":{{"iv":"{}"}},"ciphertext":"{}","kdf":"scrypt","kdfparams":{{"dklen":{},"n":{},"r":{},"p":{},"salt":"{}"}},"mac":"{}"}}}}"#,
189            self.id,
190            self.address.trim_start_matches("0x").to_lowercase(),
191            hex::encode(self.iv),
192            hex::encode(&self.ciphertext),
193            self.scrypt_params.dklen,
194            self.scrypt_params.n,
195            self.scrypt_params.r,
196            self.scrypt_params.p,
197            hex::encode(self.salt),
198            hex::encode(self.mac),
199        )
200    }
201}
202
203impl fmt::Debug for Keystore {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.debug_struct("Keystore")
206            .field("id", &self.id)
207            .field("address", &self.address)
208            .field("ciphertext", &"[REDACTED]")
209            .field("mac", &"[REDACTED]")
210            .finish()
211    }
212}
213
214// ─── Internal Helpers ──────────────────────────────────────────────
215
216fn derive_scrypt_key(
217    password: &[u8],
218    salt: &[u8],
219    params: &ScryptParams,
220) -> Result<Zeroizing<Vec<u8>>, SignerError> {
221    use scrypt::scrypt;
222    let log_n = (params.n as f64).log2() as u8;
223    let scrypt_params = scrypt::Params::new(log_n, params.r, params.p, params.dklen as usize)
224        .map_err(|e| SignerError::EncodingError(format!("scrypt params: {e}")))?;
225    let mut derived = Zeroizing::new(vec![0u8; params.dklen as usize]);
226    scrypt(password, salt, &scrypt_params, &mut derived)
227        .map_err(|e| SignerError::EncodingError(format!("scrypt: {e}")))?;
228    Ok(derived)
229}
230
231fn keccak256(data: &[u8]) -> [u8; 32] {
232    super::keccak256(data)
233}
234
235// ─── Tests ─────────────────────────────────────────────────────────
236
237#[cfg(test)]
238#[allow(clippy::unwrap_used, clippy::expect_used)]
239mod tests {
240    use super::*;
241    use crate::traits::KeyPair;
242
243    fn light_params() -> ScryptParams {
244        ScryptParams::light()
245    }
246
247    #[test]
248    fn test_keystore_encrypt_decrypt_roundtrip() {
249        let signer = super::super::EthereumSigner::generate().unwrap();
250        let pk = signer.private_key_bytes();
251        let password = b"test-password-123";
252
253        let ks = Keystore::encrypt(&pk, password, &light_params()).unwrap();
254        let decrypted = ks.decrypt(password).unwrap();
255        assert_eq!(&*decrypted, &*pk);
256    }
257
258    #[test]
259    fn test_keystore_wrong_password_fails() {
260        let pk = [0x42u8; 32];
261        let ks = Keystore::encrypt(&pk, b"correct", &light_params()).unwrap();
262        let result = ks.decrypt(b"wrong");
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn test_keystore_address_matches() {
268        let signer = super::super::EthereumSigner::generate().unwrap();
269        let pk = signer.private_key_bytes();
270        let expected_addr = signer.address_checksum();
271
272        let ks = Keystore::encrypt(&pk, b"pw", &light_params()).unwrap();
273        assert_eq!(ks.address, expected_addr);
274    }
275
276    #[test]
277    fn test_keystore_to_json_format() {
278        let pk = [0x42u8; 32];
279        let ks = Keystore::encrypt(&pk, b"pw", &light_params()).unwrap();
280        let json = ks.to_json();
281        assert!(json.contains("\"version\":3"));
282        assert!(json.contains("\"cipher\":\"aes-128-ctr\""));
283        assert!(json.contains("\"kdf\":\"scrypt\""));
284        assert!(json.contains(&format!("\"n\":{}", light_params().n)));
285    }
286
287    #[test]
288    fn test_keystore_unique_salts() {
289        let pk = [0x42u8; 32];
290        let ks1 = Keystore::encrypt(&pk, b"pw", &light_params()).unwrap();
291        let ks2 = Keystore::encrypt(&pk, b"pw", &light_params()).unwrap();
292        assert_ne!(ks1.salt, ks2.salt, "salts should be unique");
293        assert_ne!(ks1.iv, ks2.iv, "IVs should be unique");
294    }
295
296    #[test]
297    fn test_keystore_invalid_key_length() {
298        assert!(Keystore::encrypt(&[0; 16], b"pw", &light_params()).is_err());
299    }
300}