From 02ae7a0e6f8a82d4166266311bd1ff053ffb0490 Mon Sep 17 00:00:00 2001 From: Tom You Date: Thu, 9 Jul 2026 00:53:54 -0500 Subject: [PATCH] feat(wallet): validate safe import requests --- rabby-core/src/lib.rs | 4 + rabby-core/src/wallet_import/mod.rs | 237 ++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 rabby-core/src/wallet_import/mod.rs diff --git a/rabby-core/src/lib.rs b/rabby-core/src/lib.rs index 6f7fa97..02ca618 100644 --- a/rabby-core/src/lib.rs +++ b/rabby-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod shortcut; pub mod terminal_model; pub mod theme; pub mod transfer; +pub mod wallet_import; pub mod wallet_mvp; pub mod workspace; @@ -27,6 +28,9 @@ pub use secret::{SecretKind, SecretRef, SecretString}; pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence}; pub use theme::Theme; pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest}; +pub use wallet_import::{ + SensitiveMaterial, WalletImportError, WalletImportKind, WalletImportRequest, +}; pub use wallet_mvp::{ all_wallet_mvp_features, demo_dashboard, wallet_feature_summary, AccountSource, ActivityEntry, ApprovalRecord, ChainConfig, Contact, DappPermission, SendRequest, SigningRequest, SwapQuote, diff --git a/rabby-core/src/wallet_import/mod.rs b/rabby-core/src/wallet_import/mod.rs new file mode 100644 index 0000000..10ceead --- /dev/null +++ b/rabby-core/src/wallet_import/mod.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SensitiveMaterial(String); + +impl SensitiveMaterial { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(WalletImportError::Invalid( + "secret material is required".to_string(), + )); + } + Ok(Self(value)) + } + + pub fn expose_for_encryption(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SensitiveMaterial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SensitiveMaterial([REDACTED])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum WalletImportKind { + SeedPhrase, + PrivateKey, + JsonKeystore, + WatchOnly, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletImportRequest { + pub label: String, + pub kind: WalletImportKind, + pub material: SensitiveMaterial, + pub password: Option, +} + +impl fmt::Debug for WalletImportRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WalletImportRequest") + .field("label", &self.label) + .field("kind", &self.kind) + .field("material", &"[REDACTED]") + .field("password", &self.password.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalletImportError { + Invalid(String), +} + +impl WalletImportRequest { + pub fn seed_phrase( + label: impl Into, + phrase: impl Into, + ) -> Result { + Self::new(label, WalletImportKind::SeedPhrase, phrase, None) + } + + pub fn private_key( + label: impl Into, + key: impl Into, + ) -> Result { + Self::new(label, WalletImportKind::PrivateKey, key, None) + } + + pub fn watch_only( + label: impl Into, + address: impl Into, + ) -> Result { + Self::new(label, WalletImportKind::WatchOnly, address, None) + } + + pub fn json_keystore( + label: impl Into, + json: impl Into, + password: impl Into, + ) -> Result { + Self::new( + label, + WalletImportKind::JsonKeystore, + json, + Some(password.into()), + ) + } + + fn new( + label: impl Into, + kind: WalletImportKind, + material: impl Into, + password: Option, + ) -> Result { + let request = Self { + label: label.into(), + kind, + material: SensitiveMaterial::new(material)?, + password: password.map(SensitiveMaterial::new).transpose()?, + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), WalletImportError> { + if self.label.trim().is_empty() { + return Err(WalletImportError::Invalid( + "wallet label is required".to_string(), + )); + } + match self.kind { + WalletImportKind::SeedPhrase => { + validate_seed_phrase(self.material.expose_for_encryption()) + } + WalletImportKind::PrivateKey => { + validate_private_key(self.material.expose_for_encryption()) + } + WalletImportKind::JsonKeystore => validate_json_keystore( + self.material.expose_for_encryption(), + self.password.as_ref(), + ), + WalletImportKind::WatchOnly => validate_address(self.material.expose_for_encryption()), + } + } +} + +fn validate_seed_phrase(phrase: &str) -> Result<(), WalletImportError> { + let words: Vec<&str> = phrase.split_whitespace().collect(); + if !matches!(words.len(), 12 | 15 | 18 | 21 | 24) { + return Err(WalletImportError::Invalid( + "seed phrase must contain 12, 15, 18, 21, or 24 words".to_string(), + )); + } + if !words + .iter() + .all(|word| word.chars().all(|ch| ch.is_ascii_alphabetic())) + { + return Err(WalletImportError::Invalid( + "seed phrase words must be alphabetic".to_string(), + )); + } + Ok(()) +} + +fn validate_private_key(key: &str) -> Result<(), WalletImportError> { + let key = key.strip_prefix("0x").unwrap_or(key); + if key.len() != 64 || !key.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(WalletImportError::Invalid( + "private key must be 32-byte hex".to_string(), + )); + } + Ok(()) +} + +fn validate_json_keystore( + json: &str, + password: Option<&SensitiveMaterial>, +) -> Result<(), WalletImportError> { + if password.is_none() { + return Err(WalletImportError::Invalid( + "keystore password is required".to_string(), + )); + } + if !(json.contains("crypto") || json.contains("Crypto")) || !json.contains("address") { + return Err(WalletImportError::Invalid( + "keystore JSON must contain address and crypto fields".to_string(), + )); + } + Ok(()) +} + +fn validate_address(address: &str) -> Result<(), WalletImportError> { + let address = address.trim(); + if address.len() != 42 + || !address.starts_with("0x") + || !address.chars().skip(2).all(|ch| ch.is_ascii_hexdigit()) + { + return Err(WalletImportError::Invalid( + "watch address must be a 20-byte hex address".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_seed_private_key_keystore_and_watch_imports() { + let phrase = "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima"; + assert!(WalletImportRequest::seed_phrase("Seed", phrase).is_ok()); + assert!(WalletImportRequest::private_key( + "Key", + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + .is_ok()); + assert!(WalletImportRequest::json_keystore( + "JSON", + r#"{"address":"abc","crypto":{}}"#, + "password" + ) + .is_ok()); + assert!(WalletImportRequest::watch_only( + "Watch", + "0x1111111111111111111111111111111111111111" + ) + .is_ok()); + } + + #[test] + fn rejects_invalid_import_material() { + assert!(WalletImportRequest::seed_phrase("Seed", "too short").is_err()); + assert!(WalletImportRequest::private_key("Key", "0xdeadbeef").is_err()); + assert!(WalletImportRequest::json_keystore("JSON", "{}", "password").is_err()); + assert!(WalletImportRequest::watch_only("Watch", "not-address").is_err()); + } + + #[test] + fn import_request_debug_redacts_secret_material() { + let req = WalletImportRequest::private_key( + "Key", + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(); + let debug = format!("{req:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("aaaaaaaaaaaaaaaa")); + } +}