diff --git a/AGENTS.md b/AGENTS.md index c1947d5..f6ab6b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Guidelines ## Project Structure & Module Organization -Mostro Core is a Rust library with `src/lib.rs` orchestrating modules that mirror domain entities. Core data models and logic live in files such as `src/order.rs`, `src/message.rs`, `src/user.rs`, while cryptographic helpers sit in `src/crypto.rs` and dispute flows in `src/dispute.rs`. Shared exports intended for consumers are re-exported through `src/prelude.rs`. Inline unit tests reside beside their modules under `#[cfg(test)]`. Enable optional capabilities through Cargo features: `wasm` ships by default and `sqlx` adds persistence helpers. +Mostro Core is a Rust library with `src/lib.rs` orchestrating modules that mirror domain entities. Core data models and logic live in files such as `src/order.rs`, `src/message.rs`, `src/user.rs`, while dispute flows live in `src/dispute.rs`. Shared exports intended for consumers are re-exported through `src/prelude.rs`. Inline unit tests reside beside their modules under `#[cfg(test)]`. Enable optional capabilities through Cargo features: `wasm` ships by default and `sqlx` adds persistence helpers. ## Build, Test, and Development Commands - `cargo check` — fast type checking before committing. @@ -14,7 +14,7 @@ Mostro Core is a Rust library with `src/lib.rs` orchestrating modules that mirro Code targets Rust 1.86.0 (2021 edition). Favor clear module boundaries and avoid leaking internals outside the prelude unless necessary. Use `snake_case` for functions and modules, `PascalCase` for types and enums, and `SCREAMING_SNAKE_CASE` for constants. Document public APIs with `///` comments and keep error enums exhaustive. Always run `cargo fmt` before pushing and address clippy warnings immediately. ## Testing Guidelines -Place new unit tests in the same module inside `#[cfg(test)] mod tests` blocks with descriptive names like `test_signature_roundtrip`. Cover failure paths around invalid currencies, dispute resolution, and crypto edge cases. Reuse existing builders or helpers instead of duplicating fixtures. Run `cargo test --all-features` before opening a PR to ensure feature parity. +Place new unit tests in the same module inside `#[cfg(test)] mod tests` blocks with descriptive names like `test_signature_roundtrip`. Cover failure paths around invalid currencies, dispute resolution, and other edge cases. Reuse existing builders or helpers instead of duplicating fixtures. Run `cargo test --all-features` before opening a PR to ensure feature parity. ## Commit & Pull Request Guidelines Follow Conventional Commits (e.g., `feat:`, `fix:`, `chore:`) and sign commits when possible. Keep commits focused and squash noisy iterations prior to review. Pull requests should summarise scope, list affected modules or features, and explain validation steps (tests, docs, screenshots for API changes). Link related issues and request review in Telegram if the change is time-sensitive. diff --git a/Cargo.toml b/Cargo.toml index 41bebc9..b43d23e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mostro-core" -version = "0.7.0" +version = "0.7.1" edition = "2021" license = "MIT" authors = ["Francisco Calderón "] @@ -57,14 +57,6 @@ sqlx-crud = { version = "0.4.0", features = [ wasm-bindgen = { version = "0.2.92", optional = true } nostr-sdk = "0.43.0" bitcoin = "0.32.7" -bitcoin_hashes = "0.16.0" -rand = "0.9.2" -argon2 = "0.5" -chacha20poly1305 = "0.10" -base64 = "0.22.1" -secrecy = "0.10.3" -zeroize = "1.8.1" -blake3 = "1.8.2" [features] default = ["wasm"] diff --git a/src/crypto.rs b/src/crypto.rs deleted file mode 100644 index 0701714..0000000 --- a/src/crypto.rs +++ /dev/null @@ -1,283 +0,0 @@ -// In a new file like src/crypto.rs or src/utils/crypto.rs -use crate::prelude::*; -use argon2::{ - password_hash::{rand_core::OsRng, Salt, SaltString}, - Algorithm, Argon2, Params, PasswordHasher, Version, -}; -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use chacha20poly1305::{ - aead::{Aead, KeyInit}, - AeadCore, ChaCha20Poly1305, -}; -use secrecy::*; -use std::collections::{HashMap, VecDeque}; -use std::sync::{LazyLock, RwLock}; -use zeroize::Zeroize; - -// 🔐 Cache: global static or pass it explicitly -static KEY_CACHE: LazyLock>> = - LazyLock::new(|| RwLock::new(SecretBox::new(Box::new(SimpleCache::new())))); - -// Constants for the crypto utils -// Derived key length with Argon2 -const DERIVED_KEY_LENGTH: usize = 32; -// Salt size and nonce size -const SALT_SIZE: usize = 16; -const NONCE_SIZE: usize = 12; -// ----- SIMPLE FIXED-SIZE CACHE ----- -const MAX_CACHE_SIZE: usize = 50; - -// blake3 hash for cache key -type CacheKey = blake3::Hash; // 256-bit - -struct SimpleCache { - map: HashMap, - order: VecDeque, -} - -impl SimpleCache { - fn new() -> Self { - Self { - map: HashMap::new(), - order: VecDeque::new(), - } - } - - fn get(&mut self, key: CacheKey) -> Option<[u8; 32]> { - if let Some(value) = self.map.get(&key) { - self.order.retain(|&k| k != key); - self.order.push_back(key); - Some(*value) - } else { - None - } - } - - fn put(&mut self, key: CacheKey, value: [u8; 32]) { - if !self.map.contains_key(&key) && self.map.len() >= MAX_CACHE_SIZE { - if let Some(oldest_key) = self.order.pop_front() { - self.map.remove(&oldest_key); - } - } - self.order.retain(|&k| k != key); - self.order.push_back(key); - self.map.insert(key, value); - } -} - -// Implementation of zeroize required by secretbox -impl Zeroize for SimpleCache { - fn zeroize(&mut self) { - for value in self.map.values_mut() { - value.zeroize(); - } - self.map.clear(); - self.order.clear(); - } -} - -// On drop, zeroize the cache -impl Drop for SimpleCache { - fn drop(&mut self) { - self.zeroize(); - } -} - -// make blake3 hash for cache key from password and salt -fn make_cache_key(password: &str, salt: &[u8]) -> CacheKey { - blake3::hash([password.as_bytes(), salt].concat().as_slice()) -} - -pub struct CryptoUtils; - -impl CryptoUtils { - /// Derive a key from password and salt with Argon2 - pub fn derive_key(password: &str, salt: &SaltString) -> Result, ServiceError> { - // Common key derivation logic - let params = Params::new( - Params::DEFAULT_M_COST, - Params::DEFAULT_T_COST, - Params::DEFAULT_P_COST * 2, - Some(Params::DEFAULT_OUTPUT_LEN), - ) - .map_err(|_| ServiceError::EncryptionError("Error creating params".to_string()))?; - - let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - let password_hash = argon2 - .hash_password(password.as_bytes(), salt) - .map_err(|_| ServiceError::EncryptionError("Error hashing password".to_string()))?; - - let key = password_hash - .hash - .ok_or_else(|| ServiceError::EncryptionError("Error getting hash".to_string()))?; - let key_bytes = key.as_bytes(); - if key_bytes.len() != DERIVED_KEY_LENGTH { - return Err(ServiceError::EncryptionError( - "Key length is not 32 bytes".to_string(), - )); - } - Ok(key_bytes.to_vec()) - } - - /// Encrypt data with the provided key and return a base64 encoded string to store in the database - pub fn encrypt(data: &[u8], key: &[u8], salt: &[u8]) -> Result { - // Encryption logic - // Check key length - if key.len() != DERIVED_KEY_LENGTH { - return Err(ServiceError::EncryptionError( - "Key length is not 32 bytes".to_string(), - )); - } - // Check salt length - if salt.len() != SALT_SIZE { - return Err(ServiceError::EncryptionError( - "Salt length is not 16 bytes".to_string(), - )); - } - // Create cipher - let cipher = ChaCha20Poly1305::new(key.into()); - // Generate nonce - let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); // 96-bits; unique per message - - // Encrypt data - let ciphertext = cipher - .encrypt(&nonce, data) - .map_err(|e| ServiceError::EncryptionError(e.to_string()))?; - - // Combine nonce and ciphertext - let mut encrypted = Vec::with_capacity(NONCE_SIZE + SALT_SIZE + ciphertext.len()); - encrypted.extend_from_slice(&nonce); - encrypted.extend_from_slice(salt); - encrypted.extend_from_slice(&ciphertext); - - // --- Encoding to String --- - // Encode the binary ciphertext into a Base64 String - let ciphertext_base64 = BASE64_STANDARD.encode(&encrypted); - Ok(ciphertext_base64) - } - - /// Decrypt data with the provided key - /// In case of cached values return the cached value to speed up search - fn decrypt(data: Vec, password: &str) -> Result, ServiceError> { - // Split the encrypted data into nonce and data - let (nonce, data) = data.split_at(NONCE_SIZE); - - let nonce: [u8; NONCE_SIZE] = nonce - .try_into() - .map_err(|e| ServiceError::DecryptionError(format!("Error converting nonce: {}", e)))?; - - let (salt, ciphertext) = data.split_at(SALT_SIZE); - - // Enecode salt from base64 to bytes - let salt = SaltString::encode_b64(salt) - .map_err(|e| ServiceError::DecryptionError(format!("Error decoding salt: {}", e)))?; - - // get hash value from salt and password - let cache_key = make_cache_key(password, salt.as_str().as_bytes()); - - let mut cache = KEY_CACHE - .write() - .map_err(|_| ServiceError::DecryptionError("Error in key cache".to_string()))?; - // Check if the key is already in the cache - // If the key is in the cache, use it - let key_bytes = if let Some(cached_key) = cache.expose_secret_mut().get(cache_key) { - cached_key - } else { - // Key not cached, derive it - let key_bytes = CryptoUtils::derive_key(password, &salt) - .map_err(|_| ServiceError::DecryptionError("Error deriving key".to_string()))?; - let mut key_array = [0u8; 32]; - key_array.copy_from_slice(&key_bytes); - cache.expose_secret_mut().put(cache_key, key_array); - key_array - }; - - // Create cipher - let cipher = ChaCha20Poly1305::new(&key_bytes.into()); - - // Decrypt the data - let decrypted = cipher - .decrypt(&nonce.into(), ciphertext) - .map_err(|e| ServiceError::DecryptionError(e.to_string()))?; - - Ok(decrypted) - } - - /// Decrypt an identity key from the database - pub fn decrypt_data( - data: String, - password: Option<&SecretString>, - ) -> Result { - // If password is not provided, return data as it is - let password = match password { - Some(password) => password, - None => return Ok(data), - }; - // Decode the encrypted data from base64 to bytes - let encrypted_bytes = BASE64_STANDARD.decode(&data).map_err(|_| { - ServiceError::DecryptionError("Error decoding encrypted data".to_string()) - })?; - - // Validate input length before processing - if encrypted_bytes.len() < NONCE_SIZE + SALT_SIZE { - return Err(ServiceError::DecryptionError( - "Invalid encrypted data: too short for nonce and salt".to_string(), - )); - } - - // Extract key bytes, salt and ciphered text - let decrypted_data = CryptoUtils::decrypt(encrypted_bytes, password.expose_secret())?; - - // Convert the decrypted data to a string and return it - String::from_utf8(decrypted_data).map_err(|_| { - ServiceError::DecryptionError("Error converting encrypted data to string".to_string()) - }) - } - - /// Encrypt a string to save it in the database - /// - /// # Parameters - /// * `idkey` - The string data to be encrypted - /// * `password` - Optional password used for encryption. If None, returns the data unencrypted - /// * `fixed_salt` - Optional fixed salt for encryption. If None, generates a random salt. - /// This parameter is primarily used for unit testing to ensure consistent encryption results. - /// - /// # Returns - /// Returns a Result containing either: - /// * Ok(String) - The encrypted data encoded in base64 - /// * Err(ServiceError) - If encryption fails - pub fn store_encrypted( - idkey: &str, - password: Option<&SecretString>, - fixed_salt: Option, - ) -> Result { - // If password is not provided, return data as it is - let password = match password { - Some(password) => password, - None => return Ok(idkey.to_string()), - }; - - // Salt generation - let salt = match fixed_salt { - Some(salt) => salt, - None => SaltString::generate(&mut OsRng), - }; - - // Buffer to decode salt - let buf = &mut [0u8; Salt::RECOMMENDED_LENGTH]; - // Decode salt from base64 to bytes - let salt_decoded = salt - .decode_b64(buf) - .map_err(|e| ServiceError::EncryptionError(format!("Error decoding salt: {}", e)))?; - - // Derive key as bytes - let key_bytes = CryptoUtils::derive_key(password.expose_secret(), &salt) - .map_err(|e| ServiceError::EncryptionError(format!("Error deriving key: {}", e)))?; - - // Encrypt data and return base64 encoded string - let ciphertext_base64 = CryptoUtils::encrypt(idkey.as_bytes(), &key_bytes, salt_decoded) - .map_err(|e| ServiceError::EncryptionError(format!("Error encrypting data: {}", e)))?; - - Ok(ciphertext_base64) - } -} diff --git a/src/lib.rs b/src/lib.rs index db43fba..bf958da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,3 @@ -pub mod crypto; pub mod dispute; pub mod error; pub mod message; diff --git a/src/message.rs b/src/message.rs index 38d1c9e..6e4f496 100644 --- a/src/message.rs +++ b/src/message.rs @@ -264,7 +264,6 @@ pub struct RestoredOrderHelper { } /// Information about the dispute to be restored in the new client. -/// Helper struct to decrypt the dispute information in case of encrypted database. /// Note: field names are chosen to match expected SQL SELECT aliases in mostrod (e.g. `status` aliased as `dispute_status`). #[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))] #[derive(Debug, Deserialize, Serialize, Clone)] @@ -277,10 +276,10 @@ pub struct RestoredDisputeHelper { pub trade_index_buyer: Option, pub trade_index_seller: Option, /// Indicates whether the buyer has initiated a dispute for this order. - /// Used in conjunction with `seller_dispute` to derive the `initiator` field in `RestoredDisputesInfo` after decryption. + /// Used in conjunction with `seller_dispute` to derive the `initiator` field in `RestoredDisputesInfo`. pub buyer_dispute: bool, /// Indicates whether the seller has initiated a dispute for this order. - /// Used in conjunction with `buyer_dispute` to derive the `initiator` field in `RestoredDisputesInfo` after decryption. + /// Used in conjunction with `buyer_dispute` to derive the `initiator` field in `RestoredDisputesInfo`. pub seller_dispute: bool, } diff --git a/src/order.rs b/src/order.rs index 574f9d8..fc41825 100644 --- a/src/order.rs +++ b/src/order.rs @@ -1,6 +1,5 @@ use crate::prelude::*; use nostr_sdk::{PublicKey, Timestamp}; -use secrecy::SecretString; use serde::{Deserialize, Serialize}; #[cfg(feature = "sqlx")] use sqlx::FromRow; @@ -301,23 +300,17 @@ impl Order { } } /// Get the master buyer pubkey - pub fn get_master_buyer_pubkey( - &self, - password: Option<&SecretString>, - ) -> Result { + pub fn get_master_buyer_pubkey(&self) -> Result { if let Some(pk) = self.master_buyer_pubkey.as_ref() { - CryptoUtils::decrypt_data(pk.clone(), password).map_err(|_| ServiceError::InvalidPubkey) + PublicKey::from_str(pk).map_err(|_| ServiceError::InvalidPubkey) } else { Err(ServiceError::InvalidPubkey) } } /// Get the master seller pubkey - pub fn get_master_seller_pubkey( - &self, - password: Option<&SecretString>, - ) -> Result { + pub fn get_master_seller_pubkey(&self) -> Result { if let Some(pk) = self.master_seller_pubkey.as_ref() { - CryptoUtils::decrypt_data(pk.clone(), password).map_err(|_| ServiceError::InvalidPubkey) + PublicKey::from_str(pk).map_err(|_| ServiceError::InvalidPubkey) } else { Err(ServiceError::InvalidPubkey) } @@ -350,24 +343,21 @@ impl Order { /// Check if a user is creating a full privacy order so he doesn't to have reputation /// compare master keys with the order keys if they are the same the user is in full privacy mode /// otherwise the user is not in normal mode and has a reputation - pub fn is_full_privacy_order( - &self, - password: Option<&SecretString>, - ) -> Result<(Option, Option), ServiceError> { + pub fn is_full_privacy_order(&self) -> Result<(Option, Option), ServiceError> { let (mut normal_buyer_idkey, mut normal_seller_idkey) = (None, None); // Get master pubkeys to get users data from db - let master_buyer_pubkey = self.get_master_buyer_pubkey(password).ok(); - let master_seller_pubkey = self.get_master_seller_pubkey(password).ok(); + let master_buyer_pubkey = self.get_master_buyer_pubkey().ok(); + let master_seller_pubkey = self.get_master_seller_pubkey().ok(); // Check if the buyer is in full privacy mode - if self.buyer_pubkey.as_ref() != master_buyer_pubkey.as_ref() { - normal_buyer_idkey = master_buyer_pubkey; + if self.buyer_pubkey != master_buyer_pubkey.map(|pk| pk.to_string()) { + normal_buyer_idkey = master_buyer_pubkey.map(|pk| pk.to_string()); } // Check if the seller is in full privacy mode - if self.seller_pubkey.as_ref() != master_seller_pubkey.as_ref() { - normal_seller_idkey = master_seller_pubkey; + if self.seller_pubkey != master_seller_pubkey.map(|pk| pk.to_string()) { + normal_seller_idkey = master_seller_pubkey.map(|pk| pk.to_string()); } Ok((normal_buyer_idkey, normal_seller_idkey)) diff --git a/src/prelude.rs b/src/prelude.rs index 498419a..b11ccf5 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -3,7 +3,6 @@ // //! Prelude -pub use crate::crypto::*; pub use crate::dispute::{Dispute, SolverDisputeInfo, Status as DisputeStatus}; pub use crate::error::{CantDoReason, MostroError, ServiceError}; pub use crate::message::{