diff --git a/Cargo.lock b/Cargo.lock index f25b15c..12309ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +82,18 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "atk" version = "0.18.2" @@ -94,6 +141,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -124,6 +177,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -321,6 +383,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -421,6 +493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -463,6 +536,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.23.0" @@ -546,6 +628,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1021,6 +1104,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1466,6 +1559,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2010,6 +2112,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -2064,6 +2172,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2174,6 +2293,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2189,6 +2320,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2292,6 +2432,7 @@ name = "rabby" version = "0.1.0" dependencies = [ "rabby-core", + "rabby-runtime", "serde", "tauri", "tauri-build", @@ -2309,10 +2450,45 @@ dependencies = [ name = "rabby-runtime" version = "0.1.0" dependencies = [ + "aes-gcm", + "argon2", + "base64 0.22.1", "rabby-core", + "rand", + "serde", "serde_json", ] +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2831,6 +3007,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3575,6 +3757,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -4345,6 +4537,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/docs/rabbyhub-mvp-feature-inventory.md b/docs/rabbyhub-mvp-feature-inventory.md index 2c27029..516588f 100644 --- a/docs/rabbyhub-mvp-feature-inventory.md +++ b/docs/rabbyhub-mvp-feature-inventory.md @@ -61,3 +61,8 @@ The current app can open but is still a shell. The MVP work should prioritize: - Unsafe/incomplete wallet requests are rejected. - Dashboard totals and high-risk approval counts are computed. - Settings customization defaults validate. + + +## Implemented runtime slices + +- Encrypted local wallet dashboard vault: `rabby-runtime::wallet_vault::EncryptedWalletVault` writes AES-256-GCM encrypted JSON envelopes using Argon2id-derived keys, rejects blank/wrong passphrases, avoids plaintext wallet fields in storage, and is exposed to Tauri via `save_demo_wallet_vault` / `load_wallet_vault`. diff --git a/rabby-core/src/wallet_mvp/mod.rs b/rabby-core/src/wallet_mvp/mod.rs index 6e1afa5..eceba54 100644 --- a/rabby-core/src/wallet_mvp/mod.rs +++ b/rabby-core/src/wallet_mvp/mod.rs @@ -32,7 +32,7 @@ pub struct WalletFeatureSpec { } pub const RABBY_WALLET_MVP_FEATURES: &[WalletFeatureSpec] = &[ - WalletFeatureSpec { key: "onboarding-password", title: "Create/unlock wallet", area: WalletFeatureArea::Onboarding, rabby_reference: "Welcome, CreatePassword, Unlock, ForgotPassword", mvp_acceptance: "User can create a password, lock/unlock, and keep encrypted local state", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "onboarding-password", title: "Create/unlock wallet", area: WalletFeatureArea::Onboarding, rabby_reference: "Welcome, CreatePassword, Unlock, ForgotPassword", mvp_acceptance: "User can create a password, lock/unlock, and keep encrypted local state", status: WalletFeatureStatus::RuntimeImplemented }, WalletFeatureSpec { key: "import-create-accounts", title: "Import/create accounts", area: WalletFeatureArea::Accounts, rabby_reference: "CreateMnemonics, ImportMnemonics, ImportPrivateKey, ImportJson, watch address", mvp_acceptance: "Seed phrase/private key/JSON/watch-only account records validate and display", status: WalletFeatureStatus::Modeled }, WalletFeatureSpec { key: "hardware-wallets", title: "Hardware and institutional accounts", area: WalletFeatureArea::Accounts, rabby_reference: "Ledger, Trezor, Keystone, GridPlus, OneKey, BitBox02, Gnosis Safe, Coinbase, WalletConnect, Cobo Argus", mvp_acceptance: "Account source model covers hardware/safe/walletconnect/custody sources", status: WalletFeatureStatus::Modeled }, WalletFeatureSpec { key: "multi-chain", title: "Multi-chain network support", area: WalletFeatureArea::Portfolio, rabby_reference: "ChainList, CustomRPC, CustomTestnet, offline chain support", mvp_acceptance: "Chains have IDs, RPC URLs, testnet/custom/offline flags, and validation", status: WalletFeatureStatus::Modeled }, diff --git a/rabby-runtime/Cargo.toml b/rabby-runtime/Cargo.toml index 522842b..65dea3e 100644 --- a/rabby-runtime/Cargo.toml +++ b/rabby-runtime/Cargo.toml @@ -10,4 +10,9 @@ path = "src/lib.rs" [dependencies] rabby-core = { path = "../rabby-core" } +aes-gcm = "0.10" +argon2 = "0.5" +base64 = "0.22" +rand = "0.8" +serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rabby-runtime/src/lib.rs b/rabby-runtime/src/lib.rs index b62786f..06cb68d 100644 --- a/rabby-runtime/src/lib.rs +++ b/rabby-runtime/src/lib.rs @@ -1,2 +1,3 @@ pub mod config_store; pub mod pty; +pub mod wallet_vault; diff --git a/rabby-runtime/src/wallet_vault.rs b/rabby-runtime/src/wallet_vault.rs new file mode 100644 index 0000000..d1bfb15 --- /dev/null +++ b/rabby-runtime/src/wallet_vault.rs @@ -0,0 +1,280 @@ +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use argon2::Argon2; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use rabby_core::WalletDashboard; +use rand::rngs::OsRng; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::path::Path; + +const VAULT_VERSION: u8 = 1; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; +const CIPHER: &str = "aes-256-gcm"; +const KDF: &str = "argon2id"; + +#[derive(Debug)] +pub enum WalletVaultError { + Io(std::io::Error), + Json(serde_json::Error), + Crypto(String), + Invalid(String), +} + +impl fmt::Display for WalletVaultError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "wallet vault I/O error: {err}"), + Self::Json(err) => write!(f, "wallet vault JSON error: {err}"), + Self::Crypto(message) => write!(f, "wallet vault crypto error: {message}"), + Self::Invalid(message) => write!(f, "invalid wallet vault: {message}"), + } + } +} + +impl std::error::Error for WalletVaultError {} + +impl From for WalletVaultError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for WalletVaultError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VaultEnvelope { + pub version: u8, + pub kdf: String, + pub cipher: String, + pub salt_b64: String, + pub nonce_b64: String, + pub ciphertext_b64: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WalletVaultStatus { + pub path: String, + pub cipher: &'static str, + pub kdf: &'static str, + pub encrypted: bool, +} + +pub struct EncryptedWalletVault; + +impl EncryptedWalletVault { + pub fn save_dashboard( + path: impl AsRef, + dashboard: &WalletDashboard, + passphrase: &str, + ) -> Result { + validate_passphrase(passphrase)?; + dashboard.validate().map_err(WalletVaultError::Invalid)?; + + let mut salt = [0u8; SALT_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce); + + let key = derive_key(passphrase, &salt)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| { + WalletVaultError::Crypto("failed to create AES-256-GCM key".to_string()) + })?; + let plaintext = serde_json::to_vec(dashboard)?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref()) + .map_err(|_| { + WalletVaultError::Crypto("failed to encrypt wallet dashboard".to_string()) + })?; + + let envelope = VaultEnvelope { + version: VAULT_VERSION, + kdf: KDF.to_string(), + cipher: CIPHER.to_string(), + salt_b64: B64.encode(salt), + nonce_b64: B64.encode(nonce), + ciphertext_b64: B64.encode(ciphertext), + }; + write_envelope(path.as_ref(), &envelope)?; + Ok(WalletVaultStatus { + path: path.as_ref().display().to_string(), + cipher: CIPHER, + kdf: KDF, + encrypted: true, + }) + } + + pub fn load_dashboard( + path: impl AsRef, + passphrase: &str, + ) -> Result { + validate_passphrase(passphrase)?; + let raw = std::fs::read_to_string(path.as_ref())?; + let envelope: VaultEnvelope = serde_json::from_str(&raw)?; + envelope.validate()?; + + let salt = decode_fixed::(&envelope.salt_b64, "salt")?; + let nonce = decode_fixed::(&envelope.nonce_b64, "nonce")?; + let ciphertext = B64 + .decode(envelope.ciphertext_b64.as_bytes()) + .map_err(|_| WalletVaultError::Invalid("ciphertext is not valid base64".to_string()))?; + let key = derive_key(passphrase, &salt)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| { + WalletVaultError::Crypto("failed to create AES-256-GCM key".to_string()) + })?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref()) + .map_err(|_| { + WalletVaultError::Crypto( + "failed to decrypt wallet vault; check passphrase".to_string(), + ) + })?; + let dashboard: WalletDashboard = serde_json::from_slice(&plaintext)?; + dashboard.validate().map_err(WalletVaultError::Invalid)?; + Ok(dashboard) + } +} + +impl VaultEnvelope { + fn validate(&self) -> Result<(), WalletVaultError> { + if self.version != VAULT_VERSION { + return Err(WalletVaultError::Invalid(format!( + "unsupported vault version {}", + self.version + ))); + } + if self.kdf != KDF { + return Err(WalletVaultError::Invalid(format!( + "unsupported vault KDF {}", + self.kdf + ))); + } + if self.cipher != CIPHER { + return Err(WalletVaultError::Invalid(format!( + "unsupported vault cipher {}", + self.cipher + ))); + } + if self.ciphertext_b64.trim().is_empty() { + return Err(WalletVaultError::Invalid( + "ciphertext is required".to_string(), + )); + } + Ok(()) + } +} + +fn validate_passphrase(passphrase: &str) -> Result<(), WalletVaultError> { + if passphrase.trim().len() < 8 { + return Err(WalletVaultError::Invalid( + "passphrase must be at least 8 non-blank characters".to_string(), + )); + } + Ok(()) +} + +fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN], WalletVaultError> { + let mut key = [0u8; KEY_LEN]; + Argon2::default() + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|err| WalletVaultError::Crypto(format!("failed to derive key: {err}")))?; + Ok(key) +} + +fn decode_fixed(value: &str, label: &str) -> Result<[u8; N], WalletVaultError> { + let decoded = B64 + .decode(value.as_bytes()) + .map_err(|_| WalletVaultError::Invalid(format!("{label} is not valid base64")))?; + decoded + .try_into() + .map_err(|_| WalletVaultError::Invalid(format!("{label} has incorrect length"))) +} + +fn write_envelope(path: &Path, envelope: &VaultEnvelope) -> Result<(), WalletVaultError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(envelope)?; + std::fs::write(path, raw)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rabby_core::demo_dashboard; + use std::path::PathBuf; + + fn temp_path(name: &str) -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("rabby-vault-{name}-{unique}.json")) + } + + #[test] + fn encrypted_vault_round_trips_dashboard_with_passphrase() { + let path = temp_path("round-trip"); + let dashboard = demo_dashboard(); + + EncryptedWalletVault::save_dashboard(&path, &dashboard, "correct horse battery staple") + .unwrap(); + let restored = + EncryptedWalletVault::load_dashboard(&path, "correct horse battery staple").unwrap(); + + assert_eq!(restored, dashboard); + } + + #[test] + fn encrypted_vault_does_not_store_wallet_plaintext() { + let path = temp_path("opaque"); + let dashboard = demo_dashboard(); + + EncryptedWalletVault::save_dashboard(&path, &dashboard, "correct horse battery staple") + .unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + + assert!(raw.contains("aes-256-gcm")); + assert!(!raw.contains(&dashboard.active_account.address)); + assert!(!raw.contains("Main Wallet")); + } + + #[test] + fn encrypted_vault_rejects_wrong_passphrase() { + let path = temp_path("wrong-passphrase"); + let dashboard = demo_dashboard(); + + EncryptedWalletVault::save_dashboard(&path, &dashboard, "correct horse battery staple") + .unwrap(); + + let err = EncryptedWalletVault::load_dashboard(&path, "wrong password").unwrap_err(); + assert!( + err.to_string().to_lowercase().contains("decrypt") + || err.to_string().to_lowercase().contains("passphrase") + ); + } + + #[test] + fn encrypted_vault_rejects_blank_passphrase() { + let path = temp_path("blank-passphrase"); + let dashboard = demo_dashboard(); + + let err = EncryptedWalletVault::save_dashboard(&path, &dashboard, " ").unwrap_err(); + + assert!(err.to_string().to_lowercase().contains("passphrase")); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a1a806..d4551cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,5 +10,6 @@ tauri-build = { version = "2", features = [] } [dependencies] rabby-core = { path = "../rabby-core" } +rabby-runtime = { path = "../rabby-runtime" } serde = { version = "1", features = ["derive"] } tauri = { version = "2", features = [] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1addfeb..2d69bba 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,9 @@ use rabby_core::{ demo_dashboard, wallet_feature_summary, WalletDashboard, WalletFeatureStatus, Workspace, }; +use rabby_runtime::wallet_vault::{EncryptedWalletVault, WalletVaultStatus}; use serde::Serialize; +use std::path::PathBuf; #[derive(Serialize)] struct FeatureRow { @@ -41,13 +43,55 @@ fn wallet_dashboard() -> WalletDashboard { demo_dashboard() } +#[tauri::command] +fn save_demo_wallet_vault( + passphrase: String, + path: Option, +) -> Result { + let vault_path = resolve_vault_path(path); + EncryptedWalletVault::save_dashboard(&vault_path, &demo_dashboard(), &passphrase) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn load_wallet_vault(path: String, passphrase: String) -> Result { + EncryptedWalletVault::load_dashboard(path, &passphrase).map_err(|err| err.to_string()) +} + +fn resolve_vault_path(path: Option) -> PathBuf { + path.map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("rabby-demo-wallet-vault.json")) +} + fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ workspace_summary, wallet_mvp_summary, - wallet_dashboard + wallet_dashboard, + save_demo_wallet_vault, + load_wallet_vault ]) .run(tauri::generate_context!()) .expect("failed to run Rabby Tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_vault_path_has_stable_file_name() { + let path = resolve_vault_path(None); + assert_eq!( + path.file_name().and_then(|value| value.to_str()), + Some("rabby-demo-wallet-vault.json") + ); + } + + #[test] + fn explicit_vault_path_is_preserved() { + let path = resolve_vault_path(Some("/tmp/custom-rabby-vault.json".to_string())); + assert_eq!(path, PathBuf::from("/tmp/custom-rabby-vault.json")); + } +} diff --git a/ui/app.js b/ui/app.js index 1ec139d..e2879c3 100644 --- a/ui/app.js +++ b/ui/app.js @@ -28,7 +28,7 @@ const fallbackDashboard = { }; const fallbackFeatures = [ - ['Create/unlock wallet', 'Password, lock/unlock, encrypted local state', 'Modeled'], + ['Create/unlock wallet', 'Password, lock/unlock, encrypted local state', 'RuntimeImplemented'], ['Import/create accounts', 'Seed phrase, private key, JSON, watch-only', 'Modeled'], ['Multi-chain networks', 'Built-in, custom RPC, testnet, offline chain flags', 'Modeled'], ['Portfolio dashboard', 'Balances, NFTs, DeFi positions, activity', 'UiPrototype'], @@ -117,6 +117,25 @@ function renderFeatures(features) { }).join(''); } +async function saveDemoVault() { + const passphrase = document.getElementById('vault-passphrase').value; + const output = document.getElementById('vault-status'); + output.textContent = 'Encrypting demo vault…'; + try { + const tauri = window.__TAURI__?.core; + if (!tauri) throw new Error('Tauri bridge unavailable in browser preview'); + const status = await tauri.invoke('save_demo_wallet_vault', { passphrase, path: null }); + output.textContent = `Encrypted with ${status.cipher} + ${status.kdf}: ${status.path}`; + } catch (error) { + output.textContent = String(error); + } +} + +function wireVaultActions() { + const button = document.getElementById('save-vault'); + if (button) button.addEventListener('click', saveDemoVault); +} + async function boot() { const [dashboard, features] = await Promise.all([ invokeOrFallback('wallet_dashboard', fallbackDashboard), @@ -124,6 +143,7 @@ async function boot() { ]); renderDashboard(dashboard); renderFeatures(features); + wireVaultActions(); } boot(); diff --git a/ui/index.html b/ui/index.html index 74380eb..73fe45c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -120,6 +120,15 @@
+
+

Encrypted local state

+ +
+ + +
+ AES-256-GCM + Argon2id runtime wired through Tauri. +
diff --git a/ui/styles.css b/ui/styles.css index dd5f944..4fafd91 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -257,6 +257,34 @@ h3 { margin-bottom: 0; font-size: 18px; } } .settings-grid span { display: block; color: #7d89a5; font-size: 12px; } +.vault-form { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid #edf1fb; +} +.vault-form label { + display: block; + margin-bottom: 8px; + color: #4a5878; + font-weight: 800; +} +.vault-row { display: flex; gap: 8px; } +.vault-row input { + min-width: 0; + flex: 1; + border: 1px solid #dfe6f7; + border-radius: 14px; + padding: 10px 12px; + color: #24345f; + background: #f8faff; +} +.vault-form small { + display: block; + margin-top: 10px; + color: #7d89a5; + overflow-wrap: anywhere; +} + .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));