synor/apps/desktop-wallet/src-tauri/src/crypto.rs
Gulshan Yadav f23e7928ea fix(desktop-wallet): update crypto APIs for bech32 v0.11 and bip39 v2
- Update bech32 encoding to use Hrp struct and segwit::encode
- Update bip39 to use from_entropy() instead of generate_in()
- Update Mnemonic::parse() instead of parse_in(Language)
- Fix HMAC ambiguity with explicit type annotation
- Add Debug and Clone derives to EncryptedWallet
- Use show_menu_on_left_click() (deprecation fix)
- Add placeholder icons for development builds
2026-01-10 07:08:47 +05:30

260 lines
8.3 KiB
Rust

//! Cryptographic operations for the Synor wallet
//!
//! Implements:
//! - BIP39 mnemonic generation and validation
//! - Argon2id password-based key derivation
//! - ChaCha20-Poly1305 authenticated encryption
//! - Ed25519 key derivation from seed
//! - Bech32m address encoding
use argon2::{
password_hash::SaltString,
Argon2, Params,
};
use bip39::Mnemonic;
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Nonce,
};
use hmac::Mac;
use rand::{rngs::OsRng, RngCore};
use sha2::Sha512;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::{Error, Result};
/// Type alias for HMAC-SHA512 to avoid ambiguity
type HmacSha512 = hmac::Hmac<Sha512>;
/// Encrypted wallet data stored on disk
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EncryptedWallet {
/// Argon2 salt (22 bytes, base64 encoded)
pub salt: String,
/// ChaCha20-Poly1305 nonce (12 bytes, hex encoded)
pub nonce: String,
/// Encrypted seed (ciphertext + 16-byte tag, hex encoded)
pub ciphertext: String,
/// Version for future compatibility
pub version: u32,
}
/// Sensitive seed data that auto-zeros on drop
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SeedData {
/// 64-byte seed derived from mnemonic
pub seed: [u8; 64],
}
/// Generate a new random 24-word BIP39 mnemonic
pub fn generate_mnemonic() -> Result<String> {
// Generate 256 bits of entropy for 24 words
let mut entropy = [0u8; 32]; // 256 bits for 24 words
OsRng.fill_bytes(&mut entropy);
let mnemonic = Mnemonic::from_entropy(&entropy)
.map_err(|e| Error::Crypto(format!("Failed to generate mnemonic: {}", e)))?;
Ok(mnemonic.to_string())
}
/// Validate a BIP39 mnemonic phrase
pub fn validate_mnemonic(phrase: &str) -> Result<()> {
Mnemonic::parse(phrase)
.map_err(|_| Error::InvalidMnemonic)?;
Ok(())
}
/// Derive a 64-byte seed from mnemonic using BIP39
/// The passphrase is optional (empty string if not used)
pub fn mnemonic_to_seed(mnemonic: &str, passphrase: &str) -> Result<SeedData> {
let mnemonic = Mnemonic::parse(mnemonic)
.map_err(|_| Error::InvalidMnemonic)?;
let seed = mnemonic.to_seed(passphrase);
let mut seed_array = [0u8; 64];
seed_array.copy_from_slice(&seed);
Ok(SeedData { seed: seed_array })
}
/// Derive an encryption key from password using Argon2id
fn derive_encryption_key(password: &str, salt: &SaltString) -> Result<[u8; 32]> {
// Use Argon2id with secure parameters
// Memory: 64 MB, Iterations: 3, Parallelism: 4
let params = Params::new(65536, 3, 4, Some(32))
.map_err(|e| Error::Crypto(format!("Argon2 params error: {}", e)))?;
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt.as_str().as_bytes(), &mut key)
.map_err(|e| Error::Crypto(format!("Key derivation failed: {}", e)))?;
Ok(key)
}
/// Encrypt the seed with password using Argon2id + ChaCha20-Poly1305
pub fn encrypt_seed(seed: &[u8; 64], password: &str) -> Result<EncryptedWallet> {
// Generate random salt for Argon2
let salt = SaltString::generate(&mut OsRng);
// Derive encryption key
let key = derive_encryption_key(password, &salt)?;
// Generate random nonce for ChaCha20-Poly1305
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
// Encrypt the seed
let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| Error::Crypto(format!("Cipher init failed: {}", e)))?;
let ciphertext = cipher
.encrypt(nonce, seed.as_ref())
.map_err(|e| Error::Crypto(format!("Encryption failed: {}", e)))?;
Ok(EncryptedWallet {
salt: salt.to_string(),
nonce: hex::encode(nonce_bytes),
ciphertext: hex::encode(ciphertext),
version: 1,
})
}
/// Decrypt the seed with password
pub fn decrypt_seed(wallet: &EncryptedWallet, password: &str) -> Result<SeedData> {
if wallet.version != 1 {
return Err(Error::Crypto(format!(
"Unsupported wallet version: {}",
wallet.version
)));
}
// Parse salt
let salt = SaltString::from_b64(&wallet.salt)
.map_err(|_| Error::Crypto("Invalid salt".to_string()))?;
// Derive encryption key
let key = derive_encryption_key(password, &salt)?;
// Parse nonce
let nonce_bytes = hex::decode(&wallet.nonce)
.map_err(|_| Error::Crypto("Invalid nonce".to_string()))?;
if nonce_bytes.len() != 12 {
return Err(Error::Crypto("Invalid nonce length".to_string()));
}
let nonce = Nonce::from_slice(&nonce_bytes);
// Parse ciphertext
let ciphertext = hex::decode(&wallet.ciphertext)
.map_err(|_| Error::Crypto("Invalid ciphertext".to_string()))?;
// Decrypt
let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| Error::Crypto(format!("Cipher init failed: {}", e)))?;
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|_| Error::InvalidPassword)?;
if plaintext.len() != 64 {
return Err(Error::Crypto("Invalid seed length".to_string()));
}
let mut seed = [0u8; 64];
seed.copy_from_slice(&plaintext);
Ok(SeedData { seed })
}
/// Derive an Ed25519 keypair from seed using BIP32-Ed25519 style derivation
/// Returns (private_key, public_key)
pub fn derive_ed25519_keypair(seed: &[u8; 64], index: u32) -> ([u8; 32], [u8; 32]) {
// Use HMAC-SHA512 for deterministic key derivation
// Path: m/44'/synor'/0'/0/index
let mut mac: HmacSha512 = Mac::new_from_slice(b"ed25519 seed").unwrap();
mac.update(seed);
let master = mac.finalize().into_bytes();
// Derive child key for index
let mut mac: HmacSha512 = Mac::new_from_slice(&master[32..]).unwrap();
mac.update(&master[..32]);
mac.update(&index.to_be_bytes());
let derived = mac.finalize().into_bytes();
let mut private_key = [0u8; 32];
let mut chain_code = [0u8; 32];
private_key.copy_from_slice(&derived[..32]);
chain_code.copy_from_slice(&derived[32..]);
// For Ed25519, the public key would normally be derived using the ed25519 library
// Here we return the chain_code as a placeholder for the public key
// In production, use: ed25519_dalek::SigningKey::from_bytes(&private_key).verifying_key()
(private_key, chain_code)
}
/// Generate a Synor address from a public key
/// Format: synor1<bech32m-encoded-pubkey-hash>
pub fn pubkey_to_address(pubkey: &[u8; 32], testnet: bool) -> Result<String> {
use bech32::Hrp;
use sha2::{Digest, Sha256};
// Hash the public key (SHA256 then take first 20 bytes like Bitcoin)
let hash = Sha256::digest(pubkey);
let pubkey_hash: &[u8] = &hash[..20];
let hrp_str = if testnet { "tsynor" } else { "synor" };
let hrp = Hrp::parse(hrp_str)
.map_err(|e| Error::Crypto(format!("Invalid HRP: {}", e)))?;
// Encode using bech32 segwit encoding (version 0)
bech32::segwit::encode(hrp, bech32::segwit::VERSION_0, pubkey_hash)
.map_err(|e| Error::Crypto(format!("Bech32 encoding failed: {}", e)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mnemonic_generation() {
let mnemonic = generate_mnemonic().unwrap();
let words: Vec<&str> = mnemonic.split_whitespace().collect();
assert_eq!(words.len(), 24);
assert!(validate_mnemonic(&mnemonic).is_ok());
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let seed = [42u8; 64];
let password = "test_password_123";
let encrypted = encrypt_seed(&seed, password).unwrap();
let decrypted = decrypt_seed(&encrypted, password).unwrap();
assert_eq!(seed, decrypted.seed);
}
#[test]
fn test_wrong_password_fails() {
let seed = [42u8; 64];
let encrypted = encrypt_seed(&seed, "correct_password").unwrap();
let result = decrypt_seed(&encrypted, "wrong_password");
assert!(result.is_err());
}
#[test]
fn test_address_generation() {
let pubkey = [1u8; 32];
let address = pubkey_to_address(&pubkey, false).unwrap();
assert!(address.starts_with("synor1"));
let testnet_address = pubkey_to_address(&pubkey, true).unwrap();
assert!(testnet_address.starts_with("tsynor1"));
}
}