openmina_core/
encrypted_key.rs

1use std::{fs, path::Path};
2
3use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
4use base64::Engine;
5use crypto_secretbox::{
6    aead::{Aead, OsRng},
7    AeadCore, KeyInit, XSalsa20Poly1305,
8};
9use serde::{Deserialize, Serialize};
10
11#[derive(Serialize, Deserialize, Debug)]
12struct Base58String(String);
13
14impl Base58String {
15    pub fn new(raw: &[u8], version: u8) -> Self {
16        Base58String(bs58::encode(raw).with_check_version(version).into_string())
17    }
18
19    pub fn try_decode(&self, version: u8) -> Result<Vec<u8>, EncryptionError> {
20        let decoded = bs58::decode(&self.0).with_check(Some(version)).into_vec()?;
21        Ok(decoded[1..].to_vec())
22    }
23}
24
25#[derive(Debug, thiserror::Error)]
26pub enum EncryptionError {
27    #[error(transparent)]
28    SecretBox(#[from] crypto_secretbox::aead::Error),
29    #[error(transparent)]
30    ArgonError(#[from] argon2::Error),
31    #[error(transparent)]
32    PasswordHash(#[from] argon2::password_hash::Error),
33    #[error(transparent)]
34    Base58DecodeError(#[from] bs58::decode::Error),
35    #[error(transparent)]
36    CipherKeyInvalidLength(#[from] crypto_secretbox::cipher::InvalidLength),
37    #[error("Password hash missing after hash_password")]
38    HashMissing,
39    #[error(transparent)]
40    Io(#[from] std::io::Error),
41    #[error(transparent)]
42    SerdeJson(#[from] serde_json::Error),
43    #[error("Other: {0}")]
44    Other(String),
45}
46
47#[derive(Serialize, Deserialize, Debug)]
48pub struct EncryptedSecretKeyFile {
49    box_primitive: String,
50    pw_primitive: String,
51    nonce: Base58String,
52    pwsalt: Base58String,
53    pwdiff: (u32, u32),
54    ciphertext: Base58String,
55}
56
57impl EncryptedSecretKeyFile {
58    pub fn new(path: impl AsRef<Path>) -> Result<Self, EncryptionError> {
59        let file = fs::File::open(path)?;
60        Ok(serde_json::from_reader(file)?)
61    }
62}
63
64fn setup_argon(pwdiff: (u32, u32)) -> Result<Argon2<'static>, EncryptionError> {
65    let params = argon2::Params::new(
66        pwdiff.0 / 1024,
67        pwdiff.1,
68        argon2::Params::DEFAULT_P_COST,
69        None,
70    )?;
71
72    Ok(Argon2::new(
73        argon2::Algorithm::Argon2i,
74        Default::default(),
75        params,
76    ))
77}
78
79pub trait EncryptedSecretKey {
80    const ENCRYPTION_DATA_VERSION_BYTE: u8 = 2;
81    const SECRET_KEY_PREFIX_BYTE: u8 = 1;
82
83    // Based on the ocaml implementation
84    const BOX_PRIMITIVE: &'static str = "xsalsa20poly1305";
85    const PW_PRIMITIVE: &'static str = "argon2i";
86    // Note: Only used for enryption, for decryption use the pwdiff from the file
87    const PW_DIFF: (u32, u32) = (134217728, 6);
88
89    fn try_decrypt(
90        encrypted: &EncryptedSecretKeyFile,
91        password: &str,
92    ) -> Result<Vec<u8>, EncryptionError> {
93        // prepare inputs to cipher
94        let password = password.as_bytes();
95        let pwsalt = encrypted
96            .pwsalt
97            .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?;
98        let nonce = encrypted
99            .nonce
100            .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?;
101        let ciphertext = encrypted
102            .ciphertext
103            .try_decode(Self::ENCRYPTION_DATA_VERSION_BYTE)?;
104
105        // The argon crate's SaltString can only be built from base64 string, ocaml node encodes the salt in base58
106        // So we decoded it from base58 first, then convert to base64 and lastly to SaltString
107        let pwsalt_encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(pwsalt);
108        let salt = SaltString::from_b64(&pwsalt_encoded)?;
109
110        let argon2 = setup_argon(encrypted.pwdiff)?;
111        let password_hash = argon2
112            .hash_password(password, &salt)?
113            .hash
114            .ok_or(EncryptionError::HashMissing)?;
115        let password_bytes = password_hash.as_bytes();
116
117        // decrypt cipher
118        let cipher = XSalsa20Poly1305::new_from_slice(password_bytes)?;
119        let decrypted = cipher.decrypt(nonce.as_slice().into(), ciphertext.as_ref())?;
120
121        // strip the prefix and create keypair
122        Ok(decrypted)
123    }
124    fn try_encrypt(key: &[u8], password: &str) -> Result<EncryptedSecretKeyFile, EncryptionError> {
125        let argon2 = setup_argon(Self::PW_DIFF)?;
126
127        // add the prefix byt to the key
128        let mut key_prefixed = vec![Self::SECRET_KEY_PREFIX_BYTE];
129        key_prefixed.extend(key);
130
131        let salt = SaltString::generate(&mut OsRng);
132        let password_hash = argon2
133            .hash_password(password.as_bytes(), &salt)?
134            .hash
135            .ok_or(EncryptionError::HashMissing)?;
136
137        let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng);
138        let cipher = XSalsa20Poly1305::new_from_slice(password_hash.as_bytes())?;
139
140        let ciphertext = cipher.encrypt(&nonce, key_prefixed.as_slice())?;
141
142        // Same reason as in decrypt, we ned to decode the SaltString from base64 then encode it to base58 bellow
143        let mut salt_bytes = [0; 32];
144        let salt_portion = salt.decode_b64(&mut salt_bytes)?;
145
146        Ok(EncryptedSecretKeyFile {
147            box_primitive: Self::BOX_PRIMITIVE.to_string(),
148            pw_primitive: Self::PW_PRIMITIVE.to_string(),
149            nonce: Base58String::new(&nonce, Self::ENCRYPTION_DATA_VERSION_BYTE),
150            pwsalt: Base58String::new(salt_portion, Self::ENCRYPTION_DATA_VERSION_BYTE),
151            pwdiff: (argon2.params().m_cost() * 1024, argon2.params().t_cost()),
152            ciphertext: Base58String::new(&ciphertext, Self::ENCRYPTION_DATA_VERSION_BYTE),
153        })
154    }
155}