- 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
260 lines
8.3 KiB
Rust
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"));
|
|
}
|
|
}
|