This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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