feat(wallet): validate safe import requests
test / workspace (push) Successful in 13m29s

This commit is contained in:
Tom You
2026-07-09 00:53:54 -05:00
parent f37ac6cbf3
commit 02ae7a0e6f
2 changed files with 241 additions and 0 deletions
+4
View File
@@ -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,
+237
View File
@@ -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"));
}
}