diff --git a/Cargo.lock b/Cargo.lock index 1c64543e16..5ea60274f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1794,29 +1794,23 @@ dependencies = [ name = "dusk-node-data" version = "1.3.1-alpha.1" dependencies = [ - "aes 0.7.5", - "aes-gcm", "anyhow", "async-channel", "base64 0.22.1", - "block-modes", "bs58", "chrono", "dusk-bytes", "dusk-core", "fake", "hex", - "pbkdf2 0.12.2", "rand 0.8.5", "serde", "serde_json", "serde_with", - "sha2 0.10.8", "sha3", - "tempfile", "thiserror", "tracing", - "zeroize", + "wallet-fs", ] [[package]] @@ -4493,7 +4487,6 @@ dependencies = [ "dirs", "dusk-bytes", "dusk-core", - "dusk-node-data", "dusk-wallet-core", "flume 0.10.14", "futures", @@ -4518,6 +4511,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "wallet-fs", "zeroize", ] @@ -5823,6 +5817,28 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wallet-fs" +version = "0.1.0-alpha" +dependencies = [ + "aes 0.7.5", + "aes-gcm", + "anyhow", + "block-modes", + "dusk-bytes", + "dusk-core", + "pbkdf2 0.12.2", + "rand 0.8.5", + "serde", + "serde_json", + "serde_with", + "sha2 0.10.8", + "tempfile", + "thiserror", + "tracing", + "zeroize", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index f88b2c2c7a..709c2c5f1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "consensus", "node", "rusk-wallet", + "wallet-fs", ] resolver = "2" @@ -52,6 +53,7 @@ rusk-prover = { version = "1.3.1-alpha.1", path = "./rusk-prover/" } rusk-recovery = { version = "1.3.1-alpha.1", path = "./rusk-recovery/" } # wallet-core = { version = "1.3.0", package = "dusk-wallet-core" } wallet-core = { version = "1.3.1-alpha.1", path = "./wallet-core/", package = "dusk-wallet-core" } +wallet-fs = { version = "0.1.0-alpha", path = "./wallet-fs/" } # dusk-data-driver = "0.1.0" dusk-data-driver = { version = "0.1.1-alpha.1", path = "./data-drivers/data-driver" } diff --git a/Makefile b/Makefile index c80ed14db2..84fbc7e94a 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ test: keys wasm ## Run the tests $(MAKE) -C ./wallet-core $@ $(MAKE) -C ./rusk/ $@ $(MAKE) -C ./rusk-wallet/ $@ + $(MAKE) -C ./wallet-fs $@ clippy: ## Run clippy $(MAKE) -C ./core/ $@ @@ -53,6 +54,7 @@ clippy: ## Run clippy $(MAKE) -C ./wallet-core $@ $(MAKE) -C ./rusk/ $@ $(MAKE) -C ./rusk-wallet/ $@ + $(MAKE) -C ./wallet-fs $@ doc: ## Run doc gen $(MAKE) -C ./core/ $@ @@ -66,6 +68,7 @@ doc: ## Run doc gen $(MAKE) -C ./rusk-prover/ $@ $(MAKE) -C ./rusk-recovery $@ $(MAKE) -C ./wallet-core/ $@ + $(MAKE) -C ./wallet-fs $@ bench: keys wasm ## Bench Rusk & node $(MAKE) -C ./node bench diff --git a/node-data/Cargo.toml b/node-data/Cargo.toml index b9e3ab4c0f..1fe8b62a8b 100644 --- a/node-data/Cargo.toml +++ b/node-data/Cargo.toml @@ -10,15 +10,10 @@ repository = "https://github.com/dusk-network/rusk" [dependencies] dusk-bytes = { workspace = true } sha3 = { workspace = true } -sha2 = { workspace = true } rand = { workspace = true, features = ["std_rng", "getrandom"] } hex = { workspace = true } dusk-core = { workspace = true, features = ["serde"] } -block-modes = { workspace = true } -aes = { workspace = true } -aes-gcm = { workspace = true, features = ["std"] } -pbkdf2 = { workspace = true } serde_json = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_with = { workspace = true, features = ["hex", "base64"] } @@ -29,14 +24,13 @@ bs58 = { workspace = true } tracing = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } -zeroize = { workspace = true } +wallet-fs = { workspace = true } # faker feature dependencies fake = { workspace = true, features = ['derive'], optional = true } [dev-dependencies] fake = { workspace = true, features = ['derive'] } -tempfile = { workspace = true } [features] faker = ["dep:fake"] diff --git a/node-data/src/bls.rs b/node-data/src/bls.rs index 75770e1c6f..a4c4868aac 100644 --- a/node-data/src/bls.rs +++ b/node-data/src/bls.rs @@ -6,27 +6,14 @@ use std::cmp::Ordering; use std::fmt::Debug; -use std::fs; -use std::path::{Path, PathBuf}; -use aes::Aes256; -use aes_gcm::aead::Aead; -use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit}; -use block_modes::block_padding::Pkcs7; -use block_modes::{BlockMode, BlockModeError, Cbc}; use dusk_bytes::{DeserializableSlice, Serializable}; use dusk_core::signatures::bls::{ PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, }; -use rand::rngs::{OsRng, StdRng}; -use rand::RngCore; +use rand::rngs::StdRng; use rand::SeedableRng; -use serde::{Deserialize, Serialize}; -use serde_with::base64::Base64; -use serde_with::serde_as; -use sha2::{Digest, Sha256}; -use tracing::info; -use zeroize::Zeroize; +use serde::Serialize; pub const PUBLIC_BLS_SIZE: usize = BlsPublicKey::SIZE; @@ -151,296 +138,12 @@ pub fn load_keys( path: String, pwd: String, ) -> anyhow::Result<(BlsSecretKey, PublicKey)> { - let path_buf = PathBuf::from(path); - let (pk, sk) = read_from_file(path_buf, &pwd)?; - - Ok((sk, PublicKey::new(pk))) -} - -/// Fetches BLS public and secret keys from an encrypted consensus keys file. -fn read_from_file( - path: PathBuf, - pwd: &str, -) -> anyhow::Result<(BlsPublicKey, BlsSecretKey)> { - let contents = fs::read(&path).map_err(|e| { - anyhow::anyhow!( - "{} should be valid consensus keys file {e}", - path.display() - ) - })?; - - let (bytes, file_format_is_old) = match serde_json::from_slice::< - ProvisionerFileContents, - >(&contents) - { - Ok(contents) => { - let aes_key = derive_aes_key(pwd, &contents.salt); - let bytes = decrypt(&contents.key_pair, &aes_key, &contents.iv).map_err( - |_| anyhow::anyhow!("Failed to decrypt: invalid consensus keys password or the file is corrupted"), - )?; - (bytes, false) - } - Err(_) => { - let aes_key = hash_sha256(pwd); - let bytes = decrypt_aes_cbc(&contents, &aes_key).map_err(|e| { - anyhow::anyhow!("Invalid consensus keys password {e}") - })?; - (bytes, true) - } - }; - - let keys: BlsKeyPair = serde_json::from_slice(&bytes) - .map_err(|e| anyhow::anyhow!("keys files should contain json {e}"))?; - - let sk = BlsSecretKey::from_slice(&keys.secret_key_bls) - .map_err(|e| anyhow::anyhow!("sk should be valid {e:?}"))?; - - let pk = BlsPublicKey::from_slice(&keys.public_key_bls) - .map_err(|e| anyhow::anyhow!("pk should be valid {e:?}"))?; - - if file_format_is_old { - info!("Your consensus keys are in the old format. Migrating to the new format and saving the old file as {}.old", path.display()); - save_old_file(&path)?; - let keys_filename = path - .file_name() - .expect("keys file should have a name") - .to_str() - .expect("keys file should be a valid string"); - let keys_file_dir = path - .parent() - .expect("keys file should have a parent directory"); - let temp_keys_name = format!("{}_new", keys_filename); - save_consensus_keys(keys_file_dir, &temp_keys_name, &pk, &sk, pwd)?; - fs::rename( - keys_file_dir.join(&temp_keys_name).with_extension("keys"), - &path, - )?; - fs::remove_file( - keys_file_dir.join(temp_keys_name).with_extension("cpk"), - ) - .expect("The new cpk file should be deleted"); - } - - Ok((pk, sk)) -} - -fn save_old_file(path: &Path) -> Result<(), ConsensusKeysError> { - let old_path = path.with_extension("keys.old"); - fs::copy(path, old_path)?; - Ok(()) -} - -pub fn save_consensus_keys( - path: &Path, - filename: &str, - pk: &BlsPublicKey, - sk: &BlsSecretKey, - pwd: &str, -) -> Result<(PathBuf, PathBuf), ConsensusKeysError> { - let path = path.join(filename); - let bytes = pk.to_bytes(); - fs::write(path.with_extension("cpk"), bytes)?; - - let iv = gen_iv(); - let salt = gen_salt(); - let mut bls = BlsKeyPair { - public_key_bls: pk.to_bytes().to_vec(), - secret_key_bls: sk.to_bytes().to_vec(), - }; - let key_pair_plain = serde_json::to_vec(&bls); - bls.secret_key_bls.zeroize(); - let mut key_pair_plain = key_pair_plain?; - - let mut aes_key = derive_aes_key(pwd, &salt); - let key_pair_enc = encrypt(&key_pair_plain, &aes_key, &iv); - aes_key.zeroize(); - key_pair_plain.zeroize(); - let contents = serde_json::to_vec(&ProvisionerFileContents { - salt, - iv, - key_pair: key_pair_enc?, - })?; - - fs::write(path.with_extension("keys"), contents)?; - - Ok((path.with_extension("keys"), path.with_extension("cpk"))) -} - -#[serde_as] -#[derive(Serialize, Deserialize)] -struct ProvisionerFileContents { - #[serde_as(as = "Base64")] - salt: [u8; SALT_SIZE], - #[serde_as(as = "Base64")] - iv: [u8; IV_SIZE], - key_pair: Vec, -} - -#[serde_as] -#[derive(Serialize, Deserialize)] -struct BlsKeyPair { - #[serde_as(as = "Base64")] - secret_key_bls: Vec, - #[serde_as(as = "Base64")] - public_key_bls: Vec, -} - -type Aes256Cbc = Cbc; - -fn encrypt( - plaintext: &[u8], - key: &[u8], - iv: &[u8], -) -> Result, aes_gcm::Error> { - let key = Key::::from_slice(key); - let cipher = Aes256Gcm::new(key); - let iv = aes_gcm::Nonce::from_slice(iv); - let ciphertext = cipher.encrypt(iv, plaintext)?; - Ok(ciphertext) -} - -fn decrypt_aes_cbc(data: &[u8], pwd: &[u8]) -> Result, BlockModeError> { - let iv = &data[..16]; - let enc = &data[16..]; - - let cipher = Aes256Cbc::new_from_slices(pwd, iv).expect("valid data"); - cipher.decrypt_vec(enc) -} - -pub(crate) fn decrypt( - ciphertext: &[u8], - key: &[u8], - iv: &[u8], -) -> Result, aes_gcm::Error> { - let key = Key::::from_slice(key); - let cipher = Aes256Gcm::new(key); - let iv = aes_gcm::Nonce::from_slice(iv); - let plaintext = cipher.decrypt(iv, ciphertext)?; - - Ok(plaintext) -} - -const SALT_SIZE: usize = 32; -const IV_SIZE: usize = 12; -const PBKDF2_ROUNDS: u32 = 10_000; - -fn derive_aes_key(pwd: &str, salt: &[u8]) -> Vec { - pbkdf2::pbkdf2_hmac_array::( - pwd.as_bytes(), - salt, - PBKDF2_ROUNDS, - ) - .to_vec() -} - -fn gen_iv() -> [u8; IV_SIZE] { - let iv = Aes256Gcm::generate_nonce(OsRng); - iv.into() -} - -fn gen_salt() -> [u8; SALT_SIZE] { - let mut salt = [0; SALT_SIZE]; - let mut rng = OsRng; - rng.fill_bytes(&mut salt); - salt -} - -fn hash_sha256(pwd: &str) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(pwd.as_bytes()); - hasher.finalize().to_vec() -} - -#[derive(Debug, thiserror::Error)] -pub enum ConsensusKeysError { - #[error(transparent)] - Json(#[from] serde_json::Error), - - #[error(transparent)] - Io(#[from] std::io::Error), - - #[error("Encryption error")] - Encryption(#[from] aes_gcm::Error), -} - -#[cfg(test)] -mod tests { - use anyhow::anyhow; - use tempfile::tempdir; - - use super::*; - - #[test] - fn test_save_load_consensus_keys() -> Result<(), Box> - { - let dir = tempdir()?; - - let mut rng = StdRng::seed_from_u64(64); - let sk = BlsSecretKey::random(&mut rng); - let pk = BlsPublicKey::from(&sk); - let pwd = "password"; - - save_consensus_keys(dir.path(), "consensus", &pk, &sk, pwd)?; - let keys_path = dir.path().join("consensus.keys"); - let (loaded_sk, loaded_pk) = load_keys( - keys_path - .to_str() - .ok_or(anyhow!("Failed to convert path to string"))? - .to_string(), - pwd.to_string(), - )?; - let pk_bytes = fs::read(dir.path().join("consensus.cpk"))?; - let pk_bytes: [u8; PUBLIC_BLS_SIZE] = pk_bytes - .try_into() - .map_err(|_| anyhow!("Invalid BlsPublicKey bytes"))?; - let loaded_cpk = BlsPublicKey::from_bytes(&pk_bytes) - .map_err(|err| anyhow!("{err:?}"))?; - - assert_eq!(loaded_sk, sk); - assert_eq!(loaded_pk.inner, pk); - assert_eq!(loaded_cpk, pk); - - Ok(()) - } - - #[test] - fn test_can_still_load_keys_saved_by_wallet_impl( - ) -> Result<(), Box> { - // test-data/wallet-generated-consensus-keys contains consensus keys - // exported by the former rusk-wallet implementation to save consensus - // keys. - // This test checks if what is saved by the former implementation - // is still loaded correctly. - let mut rng = StdRng::seed_from_u64(64); - let sk = BlsSecretKey::random(&mut rng); - let pk = BlsPublicKey::from(&sk); - - let pwd = "password".to_string(); - let wallet_gen_keys_path = get_wallet_gen_consensus_keys_path(); - let temp_dir = tempdir()?; - let keys_path = temp_dir.path().join("consensus.keys"); - fs::copy(&wallet_gen_keys_path, &keys_path)?; - - let (loaded_sk, loaded_pk) = - load_keys(keys_path.to_str().unwrap().to_string(), pwd)?; - - assert_eq!(loaded_sk, sk); - assert_eq!(loaded_pk.inner, pk); - - let old_keys_path = temp_dir.path().join("consensus.keys.old"); - assert!(old_keys_path.exists(), "Old keys path should exist"); - - Ok(()) - } - - fn get_wallet_gen_consensus_keys_path() -> PathBuf { - let mut path = PathBuf::from(file!()); - // Remove the filename - path.pop(); - // Remove the current directory - let path: PathBuf = path.components().skip(1).collect(); - path.join("test-data") - .join("wallet-generated-consensus-keys") - .join("consensus.keys") + match wallet_fs::provisioner::load_keys(&path, &pwd) { + Ok((sk, pk)) => Ok((sk, PublicKey::new(pk))), + Err(e) => match e { + wallet_fs::Error::CorruptedData => Err(anyhow::anyhow!("The provisioner keys file is corrupted.")), + wallet_fs::Error::EncryptDecryptFailure => Err(anyhow::anyhow!("Failed to decrypt: invalid consensus keys password or the file is corrupted")), + err => Err(anyhow::anyhow!("{err}")), + }, } } diff --git a/rusk-wallet/Cargo.toml b/rusk-wallet/Cargo.toml index 0cea6cce36..f89db8115e 100644 --- a/rusk-wallet/Cargo.toml +++ b/rusk-wallet/Cargo.toml @@ -41,10 +41,10 @@ flume = { workspace = true } reqwest = { workspace = true, features = ["stream"] } dusk-bytes = { workspace = true } blake2b_simd = { workspace = true } -node-data = { workspace = true } zeroize = { workspace = true, features = ["derive"] } wallet-core = { workspace = true } +wallet-fs = { workspace = true } dusk-core = { workspace = true, features = ["kzg"] } tracing = { workspace = true } diff --git a/rusk-wallet/src/error.rs b/rusk-wallet/src/error.rs index 56ab951184..40808cd41f 100644 --- a/rusk-wallet/src/error.rs +++ b/rusk-wallet/src/error.rs @@ -9,7 +9,6 @@ use std::str::Utf8Error; use hex::FromHexError; use inquire::InquireError; -use node_data::bls::ConsensusKeysError; use rand::Error as RngError; use crate::gql::GraphQLError; @@ -176,9 +175,9 @@ pub enum Error { /// Error while querying archival node #[error("Archive node query error: {0}")] ArchiveJsonError(String), - /// Consensus keys error - #[error("Error while saving consensus keys: {0}")] - ConsensusKeysError(ConsensusKeysError), + /// Wallet file error + #[error("Error carrying out file/keys operation: {0}")] + WalletFs(#[from] wallet_fs::Error), /// Trying to claim more reward than the person has #[error("Trying to claim more than existing reward")] NotEnoughReward, @@ -237,9 +236,3 @@ impl From for Error { Self::InquireError(e.to_string()) } } - -impl From for Error { - fn from(e: ConsensusKeysError) -> Self { - Self::ConsensusKeysError(e) - } -} diff --git a/rusk-wallet/src/wallet.rs b/rusk-wallet/src/wallet.rs index 727d6a74a5..a1ff1d2741 100644 --- a/rusk-wallet/src/wallet.rs +++ b/rusk-wallet/src/wallet.rs @@ -602,7 +602,7 @@ impl Wallet { let path = PathBuf::from(dir); let filename = filename.unwrap_or(profile_idx.to_string()); - let paths = node_data::bls::save_consensus_keys( + let paths = wallet_fs::provisioner::save_consensus_keys( &path, &filename, &pk, &sk, pwd, ); diff --git a/wallet-fs/Cargo.toml b/wallet-fs/Cargo.toml new file mode 100644 index 0000000000..801dc6c132 --- /dev/null +++ b/wallet-fs/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "wallet-fs" +version = "0.1.0-alpha" +edition = "2021" + +description = "Utilities for wallet file and key management operations" +license = "MPL-2.0" +repository = "https://github.com/dusk-network/rusk" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +aes = { workspace = true } +aes-gcm = { workspace = true } +block-modes = { workspace = true } +dusk-bytes = { workspace = true } +dusk-core = { workspace = true, features = ["serde"] } +pbkdf2 = { workspace = true } +rand = { workspace = true, features = ["std_rng", "getrandom"] } +sha2 = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_with = { workspace = true, features = ["hex", "base64"] } +thiserror = { workspace = true } +tracing = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +tempfile = { workspace = true } diff --git a/wallet-fs/Makefile b/wallet-fs/Makefile new file mode 100644 index 0000000000..473c0d9ab6 --- /dev/null +++ b/wallet-fs/Makefile @@ -0,0 +1,16 @@ +help: ## Display this help screen + @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +test: ## Run tests + @cargo test --release -- --nocapture + +clean: + @cargo clean + +clippy: ## Run clippy + @cargo clippy --all-features --release -- -D warnings + +doc: ## Run doc gen + @cargo doc --release + +.PHONY: test help clean diff --git a/wallet-fs/src/crypto.rs b/wallet-fs/src/crypto.rs new file mode 100644 index 0000000000..d07b6ecef9 --- /dev/null +++ b/wallet-fs/src/crypto.rs @@ -0,0 +1,140 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use aes::Aes256; +use aes_gcm::aead::{Aead, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit}; +use block_modes::block_padding::Pkcs7; +use block_modes::{BlockMode, Cbc}; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +use crate::Error; +use crate::{IV_SIZE, PBKDF2_ROUNDS, SALT_SIZE}; + +type Aes256Cbc = Cbc; + +/// Encrypts the plaintext using AES-GCM. +pub(crate) fn encrypt_aes_gcm( + plaintext: &[u8], + key: &[u8], + iv: &[u8], +) -> Result, Error> { + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let iv = aes_gcm::Nonce::from_slice(iv); + let ciphertext = cipher.encrypt(iv, plaintext)?; + Ok(ciphertext) +} + +/// Decrypts the ciphertext with AES-CBC. +pub(crate) fn decrypt_aes_cbc( + ciphertext: &[u8], + key: &[u8], +) -> Result, Error> { + const OLD_IV_SIZE: usize = 16; + let iv = &ciphertext[..OLD_IV_SIZE]; + let enc = &ciphertext[OLD_IV_SIZE..]; + + let cipher = Aes256Cbc::new_from_slices(key, iv)?; + let plaintext = cipher.decrypt_vec(enc)?; + + Ok(plaintext) +} + +/// Decrypts the ciphertext with AES-GCM. +pub(crate) fn decrypt_aes_gcm( + ciphertext: &[u8], + key: &[u8], + iv: &[u8], +) -> Result, Error> { + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let iv = aes_gcm::Nonce::from_slice(iv); + let plaintext = cipher.decrypt(iv, ciphertext)?; + + Ok(plaintext) +} + +pub(crate) fn derive_aes_key(pwd: &str, salt: &[u8]) -> Vec { + pbkdf2::pbkdf2_hmac_array::( + pwd.as_bytes(), + salt, + PBKDF2_ROUNDS, + ) + .to_vec() +} + +pub(crate) fn aes_gcm_gen_iv() -> [u8; IV_SIZE] { + let iv = Aes256Gcm::generate_nonce(OsRng); + iv.into() +} + +pub(crate) fn aes_gcm_gen_salt() -> [u8; SALT_SIZE] { + let mut salt = [0; SALT_SIZE]; + let mut rng = OsRng; + rng.fill_bytes(&mut salt); + salt +} + +pub(crate) fn hash_sha256(pwd: &str) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(pwd.as_bytes()); + hasher.finalize().to_vec() +} + +#[cfg(test)] +mod tests { + use rand::rngs::OsRng; + use rand::RngCore; + + use super::*; + + #[test] + fn encrypt_and_decrypt() { + let seed = + b"0001020304050607000102030405060700010203040506070001020304050607"; + let key = hash_sha256("greatpassword"); + let iv1 = aes_gcm_gen_iv(); + let iv2 = aes_gcm_gen_iv(); + + let enc_seed_cbc = + encrypt_aes_cbc(seed, &key).expect("seed to encrypt ok"); + let enc_seed_t_cbc = + encrypt_aes_cbc(seed, &key).expect("seed to encrypt ok"); + + let enc_seed_gcm = + encrypt_aes_gcm(seed, &key, &iv1).expect("seed to encrypt ok"); + let enc_seed_t_gcm = + encrypt_aes_gcm(seed, &key, &iv2).expect("seed to encrypt ok"); + + // check that random IV is correctly applied + assert_ne!(enc_seed_cbc, enc_seed_t_cbc); + assert_ne!(enc_seed_gcm, enc_seed_t_gcm); + + let dec_seed_cbc = + decrypt_aes_cbc(&enc_seed_cbc, &key).expect("seed to decrypt ok"); + let dec_seed_gcm = decrypt_aes_gcm(&enc_seed_gcm, &key, &iv1) + .expect("seed to decrypt ok"); + + // check that decryption matches original seed + assert_eq!(dec_seed_cbc, seed); + assert_eq!(dec_seed_gcm, seed); + } + + // Old `encrypt` implementation. + fn encrypt_aes_cbc(plaintext: &[u8], key: &[u8]) -> Result, Error> { + let mut iv = vec![0; 16]; + let mut rng = OsRng; + rng.fill_bytes(&mut iv); + + let cipher = Aes256Cbc::new_from_slices(key, &iv)?; + let enc = cipher.encrypt_vec(plaintext); + + let ciphertext = iv.into_iter().chain(enc).collect(); + Ok(ciphertext) + } +} diff --git a/wallet-fs/src/error.rs b/wallet-fs/src/error.rs new file mode 100644 index 0000000000..076212c6b3 --- /dev/null +++ b/wallet-fs/src/error.rs @@ -0,0 +1,42 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use block_modes::{BlockModeError, InvalidKeyIvLength}; + +/// Errors that occur during file & key management operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failure to encrypt or decrypt data. + #[error("Failed to encrypt/decrypt")] + EncryptDecryptFailure, + /// The data is not valid. + #[error("The data is corrupted")] + CorruptedData, + /// JSON serialization/deserialization failure. + #[error(transparent)] + Json(#[from] serde_json::Error), + /// IO error. + #[error(transparent)] + Io(#[from] std::io::Error), +} + +impl From for Error { + fn from(_err: aes_gcm::Error) -> Self { + Error::EncryptDecryptFailure + } +} + +impl From for Error { + fn from(_err: InvalidKeyIvLength) -> Self { + Error::CorruptedData + } +} + +impl From for Error { + fn from(_err: BlockModeError) -> Self { + Error::EncryptDecryptFailure + } +} diff --git a/wallet-fs/src/lib.rs b/wallet-fs/src/lib.rs new file mode 100644 index 0000000000..79d73389ad --- /dev/null +++ b/wallet-fs/src/lib.rs @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Wallet files and key management + +#![deny(missing_docs)] +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(clippy::pedantic)] +#![deny(unused_crate_dependencies)] +#![deny(unused_extern_crates)] + +mod crypto; +mod error; + +pub mod provisioner; +pub mod rusk_wallet; + +pub use error::Error; + +/// Size in bytes of the IV used to encrypt wallet data +pub(crate) const IV_SIZE: usize = 12; +/// Size in bytes of the salt used to encrypt wallet data +pub(crate) const SALT_SIZE: usize = 32; +/// Number of PBKDF2 rounds used to derive the key for encrypting wallet data +pub(crate) const PBKDF2_ROUNDS: u32 = 10_000; + +#[cfg(test)] +mod deps { + use anyhow as _; + use tempfile as _; +} diff --git a/wallet-fs/src/provisioner.rs b/wallet-fs/src/provisioner.rs new file mode 100644 index 0000000000..1bcbd12489 --- /dev/null +++ b/wallet-fs/src/provisioner.rs @@ -0,0 +1,183 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! File & key management for provisioner nodes. + +use std::fs; +use std::path::{Path, PathBuf}; + +use dusk_bytes::DeserializableSlice; +use dusk_bytes::Serializable; +use dusk_core::signatures::bls::{ + PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, +}; +use serde::{Deserialize, Serialize}; +use serde_with::base64::Base64; +use serde_with::serde_as; +use tracing::info; +use zeroize::Zeroize; + +use crate::crypto::{ + aes_gcm_gen_iv, aes_gcm_gen_salt, decrypt_aes_cbc, decrypt_aes_gcm, + derive_aes_key, encrypt_aes_gcm, hash_sha256, +}; +use crate::Error; +use crate::{IV_SIZE, SALT_SIZE}; + +#[serde_as] +#[derive(Serialize, Deserialize)] +struct ProvisionerFileContents { + #[serde_as(as = "Base64")] + salt: [u8; SALT_SIZE], + #[serde_as(as = "Base64")] + iv: [u8; IV_SIZE], + key_pair: Vec, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +struct BlsKeyPair { + #[serde_as(as = "Base64")] + secret_key_bls: Vec, + #[serde_as(as = "Base64")] + public_key_bls: Vec, +} + +/// Loads BLS consensus keys from an encrypted file. +/// +/// This function reads consensus keys from a file that was previously saved +/// using [`save_consensus_keys`]. The file is expected to be encrypted with +/// AES-GCM encryption using the provided password. +/// +/// The function also handles backward compatibility with older file formats. If +/// an old format is detected, it will automatically migrate the file to the new +/// format while preserving the original as a `.old` backup. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file cannot be read +/// - The password is incorrect +/// - The file is corrupted or has invalid key data +/// - File migration operations fail (for old format files) +pub fn load_keys( + path: &str, + pwd: &str, +) -> Result<(BlsSecretKey, BlsPublicKey), Error> { + let path_buf = PathBuf::from(path); + let (pk, sk) = read_from_file(&path_buf, pwd)?; + + Ok((sk, pk)) +} + +/// Fetches BLS public and secret keys from an encrypted consensus keys file. +fn read_from_file( + path: &Path, + pwd: &str, +) -> Result<(BlsPublicKey, BlsSecretKey), Error> { + let contents = fs::read(path)?; + + let (bytes, file_format_is_old) = if let Ok(contents) = + serde_json::from_slice::(&contents) + { + let aes_key = derive_aes_key(pwd, &contents.salt); + let bytes = + decrypt_aes_gcm(&contents.key_pair, &aes_key, &contents.iv)?; + (bytes, false) + } else { + let aes_key = hash_sha256(pwd); + let bytes = decrypt_aes_cbc(&contents, &aes_key)?; + (bytes, true) + }; + + let keys: BlsKeyPair = serde_json::from_slice(&bytes)?; + let sk = BlsSecretKey::from_slice(&keys.secret_key_bls) + .map_err(|_| Error::CorruptedData)?; + let pk = BlsPublicKey::from_slice(&keys.public_key_bls) + .map_err(|_| Error::CorruptedData)?; + + if file_format_is_old { + info!("Your consensus keys are in the old format. Migrating to the new format and saving the old file as {}.old", path.display()); + save_old_file(path)?; + let keys_filename = path + .file_name() + .expect("keys file should have a name") + .to_str() + .expect("keys file should be a valid string"); + let keys_file_dir = path + .parent() + .expect("keys file should have a parent directory"); + let temp_keys_name = format!("{keys_filename}_new"); + save_consensus_keys(keys_file_dir, &temp_keys_name, &pk, &sk, pwd)?; + fs::rename( + keys_file_dir.join(&temp_keys_name).with_extension("keys"), + path, + )?; + fs::remove_file( + keys_file_dir.join(temp_keys_name).with_extension("cpk"), + )?; + } + + Ok((pk, sk)) +} + +/// Saves the consensus keys to disk in encrypted format. +/// +/// This function saves both the BLS public key and secret key to separate +/// files: +/// - The public key is saved as a `.cpk` file in plain text +/// - The public and secret keys are saved, along with the IV and salt, in a +/// JSON `.keys` file, +/// with the public and secret keys encrypted using AES-GCM with the provided +/// password. +/// +/// # Errors +/// +/// Returns an error if: +/// - File system operations fail +/// - Encryption operations fail +/// - JSON serialization fails. +pub fn save_consensus_keys( + path: &Path, + filename: &str, + pk: &BlsPublicKey, + sk: &BlsSecretKey, + pwd: &str, +) -> Result<(PathBuf, PathBuf), Error> { + let path = path.join(filename); + let bytes = pk.to_bytes(); + fs::write(path.with_extension("cpk"), bytes)?; + + let iv = aes_gcm_gen_iv(); + let salt = aes_gcm_gen_salt(); + let mut bls = BlsKeyPair { + public_key_bls: pk.to_bytes().to_vec(), + secret_key_bls: sk.to_bytes().to_vec(), + }; + let key_pair_plain = serde_json::to_vec(&bls); + bls.secret_key_bls.zeroize(); + let mut key_pair_plain = key_pair_plain?; + + let mut aes_key = derive_aes_key(pwd, &salt); + let key_pair_enc = encrypt_aes_gcm(&key_pair_plain, &aes_key, &iv); + aes_key.zeroize(); + key_pair_plain.zeroize(); + let contents = serde_json::to_vec(&ProvisionerFileContents { + salt, + iv, + key_pair: key_pair_enc?, + })?; + + fs::write(path.with_extension("keys"), contents)?; + + Ok((path.with_extension("keys"), path.with_extension("cpk"))) +} + +fn save_old_file(path: &Path) -> Result<(), Error> { + let old_path = path.with_extension("keys.old"); + fs::copy(path, old_path)?; + Ok(()) +} diff --git a/wallet-fs/src/rusk_wallet.rs b/wallet-fs/src/rusk_wallet.rs new file mode 100644 index 0000000000..4400ad982d --- /dev/null +++ b/wallet-fs/src/rusk_wallet.rs @@ -0,0 +1,7 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! File & key management for the rusk wallet. diff --git a/wallet-fs/tests/provisioner.rs b/wallet-fs/tests/provisioner.rs new file mode 100644 index 0000000000..ccf3d51245 --- /dev/null +++ b/wallet-fs/tests/provisioner.rs @@ -0,0 +1,88 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::fs; +use std::path::PathBuf; + +use anyhow::anyhow; +use dusk_bytes::Serializable; +use dusk_core::signatures::bls::{ + PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, +}; +use rand::{rngs::StdRng, SeedableRng}; +use tempfile::tempdir; +use wallet_fs::provisioner::{load_keys, save_consensus_keys}; + +#[test] +fn test_save_load_consensus_keys() -> Result<(), Box> { + let dir = tempdir()?; + + let mut rng = StdRng::seed_from_u64(64); + let sk = BlsSecretKey::random(&mut rng); + let pk = BlsPublicKey::from(&sk); + let pwd = "password"; + + save_consensus_keys(dir.path(), "consensus", &pk, &sk, pwd)?; + let keys_path = dir.path().join("consensus.keys"); + let (loaded_sk, loaded_pk) = load_keys( + keys_path + .to_str() + .ok_or(anyhow!("Failed to convert path to string"))?, + &pwd, + )?; + let pk_bytes = fs::read(dir.path().join("consensus.cpk"))?; + let pk_bytes: [u8; BlsPublicKey::SIZE] = pk_bytes + .try_into() + .map_err(|_| anyhow!("Invalid BlsPublicKey bytes"))?; + let loaded_cpk = BlsPublicKey::from_bytes(&pk_bytes) + .map_err(|err| anyhow!("{err:?}"))?; + + assert_eq!(loaded_sk, sk); + assert_eq!(loaded_pk, pk); + assert_eq!(loaded_cpk, pk); + + Ok(()) +} + +#[test] +fn test_can_still_load_keys_saved_by_wallet_impl( +) -> Result<(), Box> { + // test-data/wallet-generated-consensus-keys contains consensus keys + // exported by the former rusk-wallet implementation to save consensus + // keys. + // This test checks if what is saved by the former implementation + // is still loaded correctly. + let mut rng = StdRng::seed_from_u64(64); + let sk = BlsSecretKey::random(&mut rng); + let pk = BlsPublicKey::from(&sk); + + let pwd = "password".to_string(); + let wallet_gen_keys_path = get_wallet_gen_consensus_keys_path(); + let temp_dir = tempdir()?; + let keys_path = temp_dir.path().join("consensus.keys"); + fs::copy(&wallet_gen_keys_path, &keys_path)?; + + let (loaded_sk, loaded_pk) = load_keys(keys_path.to_str().unwrap(), &pwd)?; + + assert_eq!(loaded_sk, sk); + assert_eq!(loaded_pk, pk); + + let old_keys_path = temp_dir.path().join("consensus.keys.old"); + assert!(old_keys_path.exists(), "Old keys path should exist"); + + Ok(()) +} + +fn get_wallet_gen_consensus_keys_path() -> PathBuf { + let mut path = PathBuf::from(file!()); + // Remove the filename + path.pop(); + // Remove the current directory + let path: PathBuf = path.components().skip(1).collect(); + path.join("test-data") + .join("wallet-generated-consensus-keys") + .join("consensus.keys") +} diff --git a/node-data/src/test-data/wallet-generated-consensus-keys/consensus.cpk b/wallet-fs/tests/test-data/wallet-generated-consensus-keys/consensus.cpk similarity index 100% rename from node-data/src/test-data/wallet-generated-consensus-keys/consensus.cpk rename to wallet-fs/tests/test-data/wallet-generated-consensus-keys/consensus.cpk diff --git a/node-data/src/test-data/wallet-generated-consensus-keys/consensus.keys b/wallet-fs/tests/test-data/wallet-generated-consensus-keys/consensus.keys similarity index 100% rename from node-data/src/test-data/wallet-generated-consensus-keys/consensus.keys rename to wallet-fs/tests/test-data/wallet-generated-consensus-keys/consensus.keys