This commit is contained in:
@@ -12,6 +12,7 @@ pub mod shortcut;
|
|||||||
pub mod terminal_model;
|
pub mod terminal_model;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
|
pub mod wallet_import;
|
||||||
pub mod wallet_mvp;
|
pub mod wallet_mvp;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ pub use secret::{SecretKind, SecretRef, SecretString};
|
|||||||
pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
|
pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
|
||||||
pub use theme::Theme;
|
pub use theme::Theme;
|
||||||
pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest};
|
pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest};
|
||||||
|
pub use wallet_import::{
|
||||||
|
SensitiveMaterial, WalletImportError, WalletImportKind, WalletImportRequest,
|
||||||
|
};
|
||||||
pub use wallet_mvp::{
|
pub use wallet_mvp::{
|
||||||
all_wallet_mvp_features, demo_dashboard, wallet_feature_summary, AccountSource, ActivityEntry,
|
all_wallet_mvp_features, demo_dashboard, wallet_feature_summary, AccountSource, ActivityEntry,
|
||||||
ApprovalRecord, ChainConfig, Contact, DappPermission, SendRequest, SigningRequest, SwapQuote,
|
ApprovalRecord, ChainConfig, Contact, DappPermission, SendRequest, SigningRequest, SwapQuote,
|
||||||
|
|||||||
@@ -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<String>) -> Result<Self, WalletImportError> {
|
||||||
|
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<SensitiveMaterial>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
phrase: impl Into<String>,
|
||||||
|
) -> Result<Self, WalletImportError> {
|
||||||
|
Self::new(label, WalletImportKind::SeedPhrase, phrase, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn private_key(
|
||||||
|
label: impl Into<String>,
|
||||||
|
key: impl Into<String>,
|
||||||
|
) -> Result<Self, WalletImportError> {
|
||||||
|
Self::new(label, WalletImportKind::PrivateKey, key, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn watch_only(
|
||||||
|
label: impl Into<String>,
|
||||||
|
address: impl Into<String>,
|
||||||
|
) -> Result<Self, WalletImportError> {
|
||||||
|
Self::new(label, WalletImportKind::WatchOnly, address, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json_keystore(
|
||||||
|
label: impl Into<String>,
|
||||||
|
json: impl Into<String>,
|
||||||
|
password: impl Into<String>,
|
||||||
|
) -> Result<Self, WalletImportError> {
|
||||||
|
Self::new(
|
||||||
|
label,
|
||||||
|
WalletImportKind::JsonKeystore,
|
||||||
|
json,
|
||||||
|
Some(password.into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
label: impl Into<String>,
|
||||||
|
kind: WalletImportKind,
|
||||||
|
material: impl Into<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
) -> Result<Self, WalletImportError> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user