// file: ks-wallet/src/transfer.rs // version: 1 //! Explicit migration and secret import/export adapters. use solana_signer::Signer; // rust-rules: trait-import use tokio::io::AsyncReadExt; // rust-rules: trait-import use zeroize::Zeroize; // rust-rules: trait-import const MAX_SOLANA_CLI_JSON_LENGTH: u64 = 1_024; const MAX_SOLANA_PRIVATE_KEY_BASE58_LENGTH: u64 = 128; const SECRET_EXPORT_TEMP_ATTEMPTS: u64 = 16; static SECRET_EXPORT_TEMP_FILE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Explicit secret transfer formats supported by `ks-wallet`. #[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WalletTransferFormat { /// Standard Solana CLI keypair JSON array containing exactly 64 bytes. SolanaCliJson, /// Base58 encoding of the complete 64-byte Solana keypair used by wallet private-key imports. SolanaPrivateKeyBase58, } impl crate::WalletManager { /// Imports one secret file into a new password-protected native wallet. /// /// The source file is never modified. The destination alias and public key /// must both be unique among native wallets already present in the configured /// store. The supplied password protects only the newly created `.kswallet`. pub async fn import_file( &self, alias: crate::WalletAlias, password: crate::WalletPassword, source_path: impl std::convert::AsRef, format: crate::WalletTransferFormat, ) -> ks_core::Result { let source_path = source_path.as_ref().to_path_buf(); let destination = native_path(self, &alias); match ensure_native_alias_available(&destination).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let keypair = match read_transfer_keypair(&source_path, format).await { std::result::Result::Ok(keypair) => keypair, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match ensure_public_key_available(self, &alias, &keypair.pubkey().to_string()).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } return persist_imported_keypair(self, alias, password, keypair, format, destination).await; } /// Migrates the configured legacy `.json` file into a new `.kswallet`. /// /// Migration is non-destructive: the legacy source remains byte-for-byte /// untouched so it can be used for rollback until the operator explicitly /// removes it after validation. pub async fn migrate_legacy( &self, alias: crate::WalletAlias, password: crate::WalletPassword, ) -> ks_core::Result { let source_path = self.directory().join(format!("{}.json", alias.as_str())); let destination = native_path(self, &alias); match ensure_native_alias_available(&destination).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let keypair = match read_transfer_keypair(&source_path, crate::WalletTransferFormat::SolanaCliJson) .await { std::result::Result::Ok(keypair) => keypair, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match ensure_public_key_available(self, &alias, &keypair.pubkey().to_string()).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let public_key = keypair.pubkey(); let wallet = match persist_imported_keypair( self, alias.clone(), password, keypair, crate::WalletTransferFormat::SolanaCliJson, destination, ) .await { std::result::Result::Ok(wallet) => wallet, std::result::Result::Err(error) => return std::result::Result::Err(error), }; tracing::info!( target: crate::TRACING_TARGET, action = "migrate_legacy_wallet", wallet_alias = alias.as_str(), public_key = %public_key, "migrated legacy Solana JSON wallet without modifying the source" ); return std::result::Result::Ok(wallet); } /// Exports one native wallet secret to an explicit external file format. /// /// A valid native-wallet password is mandatory before any secret bytes are /// encoded or any destination file is created. Export refuses to overwrite an /// existing destination and creates a private file (`0600` on Unix). pub async fn export_file( &self, alias: &crate::WalletAlias, password: crate::WalletPassword, destination_path: impl std::convert::AsRef, format: crate::WalletTransferFormat, ) -> ks_core::Result<()> { let destination_path = destination_path.as_ref().to_path_buf(); let wallet = match self.unlock(alias, password).await { std::result::Result::Ok(wallet) => wallet, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let public_key = wallet.public_key(); let keypair_bytes = wallet.keypair_bytes(); let encoded = match encode_transfer_keypair(keypair_bytes, format) { std::result::Result::Ok(encoded) => encoded, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match write_secret_export_file_atomic(destination_path, encoded).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } tracing::info!( target: crate::TRACING_TARGET, action = "export_wallet_secret", wallet_alias = alias.as_str(), public_key = public_key.as_str(), transfer_format = transfer_format_code(format), "exported wallet secret after password authentication" ); return std::result::Result::Ok(()); } } async fn persist_imported_keypair( manager: &crate::WalletManager, alias: crate::WalletAlias, password: crate::WalletPassword, keypair: solana_keypair::Keypair, format: crate::WalletTransferFormat, destination: std::path::PathBuf, ) -> ks_core::Result { let public_key = keypair.pubkey(); let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes()); let container = match crate::protect_native_wallet_keypair( alias.clone(), public_key, keypair_bytes, password, ) .await { std::result::Result::Ok(container) => container, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match crate::write_native_wallet_file_atomic(destination.clone(), &container).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let handle = match manager.inspect_file(&destination).await { std::result::Result::Ok(handle) => handle, std::result::Result::Err(error) => { return rollback_failed_import(destination, error).await; }, }; if handle.alias() != &alias || handle.public_key() != public_key.to_string() { return rollback_failed_import( destination, ks_core::Error::new( "wallet_import_verification_failed", "imported native wallet identity does not match the source keypair", ), ) .await; } tracing::info!( target: crate::TRACING_TARGET, action = "import_wallet_secret", wallet_alias = alias.as_str(), public_key = %public_key, transfer_format = transfer_format_code(format), "imported wallet secret into native protected storage" ); return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair)); } async fn rollback_failed_import( destination: std::path::PathBuf, original_error: ks_core::Error, ) -> ks_core::Result { return match tokio::fs::remove_file(destination).await { std::result::Result::Ok(()) => std::result::Result::Err(original_error), std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "wallet_import_rollback_failed", "import verification failed and the new native destination could not be removed", )), }; } async fn ensure_native_alias_available(path: &std::path::Path) -> ks_core::Result<()> { return match tokio::fs::try_exists(path).await { std::result::Result::Ok(false) => std::result::Result::Ok(()), std::result::Result::Ok(true) => std::result::Result::Err(ks_core::Error::new( "wallet_native_already_exists", "native wallet already exists for this alias", )), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "wallet_file_exists_check_failed", error.to_string(), )), }; } async fn ensure_public_key_available( manager: &crate::WalletManager, alias: &crate::WalletAlias, public_key: &str, ) -> ks_core::Result<()> { let wallets = match manager.scan().await { std::result::Result::Ok(wallets) => wallets, std::result::Result::Err(error) => return std::result::Result::Err(error), }; for wallet in wallets { if wallet.public_key() == public_key && wallet.alias() != alias { return std::result::Result::Err(ks_core::Error::new( "wallet_public_key_already_exists", "native wallet store already contains this public key under another alias", )); } } return std::result::Result::Ok(()); } async fn read_transfer_keypair( path: &std::path::Path, format: crate::WalletTransferFormat, ) -> ks_core::Result { let maximum_length = match format { crate::WalletTransferFormat::SolanaCliJson => MAX_SOLANA_CLI_JSON_LENGTH, crate::WalletTransferFormat::SolanaPrivateKeyBase58 => MAX_SOLANA_PRIVATE_KEY_BASE58_LENGTH, }; let mut encoded = match read_private_source_file(path, maximum_length).await { std::result::Result::Ok(encoded) => encoded, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let result = match format { crate::WalletTransferFormat::SolanaCliJson => decode_solana_cli_json(encoded.as_slice()), crate::WalletTransferFormat::SolanaPrivateKeyBase58 => { decode_solana_private_key_base58(encoded.as_slice()) }, }; encoded.zeroize(); return result; } async fn read_private_source_file( path: &std::path::Path, maximum_length: u64, ) -> ks_core::Result> { let metadata = match tokio::fs::symlink_metadata(path).await { std::result::Result::Ok(metadata) => metadata, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_metadata_failed", error.to_string(), )); }, }; if metadata.file_type().is_symlink() || !metadata.is_file() { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_type_invalid", "wallet import source must be a regular file and not a symlink", )); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import let mode = metadata.permissions().mode() & 0o777; if mode & 0o077 != 0 { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_permissions_too_open", format!( "wallet import source has mode {mode:o}; expected no group or other access" ), )); } } let file_length = metadata.len(); if file_length == 0 || file_length > maximum_length { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_length_invalid", "wallet import source length is outside the accepted bound", )); } let allocation_length = match usize::try_from(file_length) { std::result::Result::Ok(length) => length, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_length_invalid", "wallet import source length exceeds the platform address space", )); }, }; let mut file = match tokio::fs::File::open(path).await { std::result::Result::Ok(file) => file, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_open_failed", error.to_string(), )); }, }; let opened_metadata = match file.metadata().await { std::result::Result::Ok(opened_metadata) => opened_metadata, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_metadata_failed", error.to_string(), )); }, }; if !opened_metadata.is_file() || opened_metadata.len() != file_length { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_changed", "wallet import source changed before it could be read", )); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; // rust-rules: trait-import use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import if opened_metadata.dev() != metadata.dev() || opened_metadata.ino() != metadata.ino() { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_changed", "wallet import source changed before it could be read", )); } let opened_mode = opened_metadata.permissions().mode() & 0o777; if opened_mode & 0o077 != 0 { return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_permissions_too_open", format!( "wallet import source has mode {opened_mode:o}; expected no group or other access" ), )); } } let mut bytes = vec![0_u8; allocation_length]; if let std::result::Result::Err(error) = file.read_exact(bytes.as_mut_slice()).await { bytes.zeroize(); return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_read_failed", error.to_string(), )); } let mut trailing = [0_u8; 1]; match file.read(trailing.as_mut_slice()).await { std::result::Result::Ok(0) => {}, std::result::Result::Ok(_) => { bytes.zeroize(); trailing.zeroize(); return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_length_changed", "wallet import source changed while it was being read", )); }, std::result::Result::Err(error) => { bytes.zeroize(); trailing.zeroize(); return std::result::Result::Err(ks_core::Error::new( "wallet_import_source_read_failed", error.to_string(), )); }, } trailing.zeroize(); return std::result::Result::Ok(bytes); } fn decode_solana_cli_json(bytes: &[u8]) -> ks_core::Result { let mut parsed = match serde_json::from_slice::>(bytes) { std::result::Result::Ok(parsed) => parsed, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_solana_json_invalid", "Solana CLI keypair JSON is invalid", )); }, }; if parsed.len() != crate::SOLANA_KEYPAIR_LENGTH { parsed.zeroize(); return std::result::Result::Err(ks_core::Error::new( "wallet_import_keypair_length_invalid", "wallet import must contain exactly 64 keypair bytes", )); } let keypair = solana_keypair::Keypair::try_from(parsed.as_slice()); parsed.zeroize(); return match keypair { std::result::Result::Ok(keypair) => std::result::Result::Ok(keypair), std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "wallet_import_keypair_invalid", "wallet import does not contain a valid Solana keypair", )), }; } fn decode_solana_private_key_base58(bytes: &[u8]) -> ks_core::Result { let text = match std::str::from_utf8(bytes) { std::result::Result::Ok(text) => text.trim(), std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_base58_encoding_invalid", "Solana private key Base58 source must be UTF-8 text", )); }, }; if text.is_empty() { return std::result::Result::Err(ks_core::Error::new( "wallet_import_base58_empty", "Solana private key Base58 source must not be empty", )); } let mut keypair_bytes = match bs58::decode(text).into_vec() { std::result::Result::Ok(keypair_bytes) => keypair_bytes, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "wallet_import_base58_invalid", "Solana private key Base58 source is invalid", )); }, }; if keypair_bytes.len() != crate::SOLANA_KEYPAIR_LENGTH { keypair_bytes.zeroize(); return std::result::Result::Err(ks_core::Error::new( "wallet_import_keypair_length_invalid", "wallet import must decode to exactly 64 keypair bytes", )); } let keypair = solana_keypair::Keypair::try_from(keypair_bytes.as_slice()); keypair_bytes.zeroize(); return match keypair { std::result::Result::Ok(keypair) => std::result::Result::Ok(keypair), std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "wallet_import_keypair_invalid", "wallet import does not contain a valid Solana keypair", )), }; } fn encode_transfer_keypair( keypair_bytes: zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]>, format: crate::WalletTransferFormat, ) -> ks_core::Result>> { return match format { crate::WalletTransferFormat::SolanaCliJson => { match serde_json::to_vec(keypair_bytes.as_slice()) { std::result::Result::Ok(encoded) => { std::result::Result::Ok(zeroize::Zeroizing::new(encoded)) }, std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "wallet_export_solana_json_failed", "Solana CLI keypair JSON could not be encoded", )), } }, crate::WalletTransferFormat::SolanaPrivateKeyBase58 => { let encoded = bs58::encode(keypair_bytes.as_slice()).into_string(); std::result::Result::Ok(zeroize::Zeroizing::new(encoded.into_bytes())) }, }; } async fn write_secret_export_file_atomic( path: std::path::PathBuf, bytes: zeroize::Zeroizing>, ) -> ks_core::Result<()> { let task = tokio::task::spawn_blocking(move || { return write_secret_export_file_atomic_blocking(path, bytes); }); return match task.await { std::result::Result::Ok(result) => result, std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "wallet_export_task_failed", "wallet secret export task failed", )), }; } fn write_secret_export_file_atomic_blocking( path: std::path::PathBuf, bytes: zeroize::Zeroizing>, ) -> ks_core::Result<()> { let directory = match path.parent() { std::option::Option::Some(directory) if !directory.as_os_str().is_empty() => directory, std::option::Option::Some(_) => std::path::Path::new("."), std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "wallet_export_parent_missing", "wallet export destination must have a parent directory", )); }, }; let metadata = match std::fs::symlink_metadata(directory) { std::result::Result::Ok(metadata) => metadata, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_export_directory_metadata_failed", error.to_string(), )); }, }; if metadata.file_type().is_symlink() || !metadata.is_dir() { return std::result::Result::Err(ks_core::Error::new( "wallet_export_directory_type_invalid", "wallet export parent must be a directory and not a symlink", )); } if path.exists() { return std::result::Result::Err(ks_core::Error::new( "wallet_export_already_exists", "wallet export destination already exists", )); } let file_name = match path.file_name().and_then(std::ffi::OsStr::to_str) { std::option::Option::Some(file_name) if !file_name.is_empty() => file_name, _ => { return std::result::Result::Err(ks_core::Error::new( "wallet_export_filename_invalid", "wallet export destination filename is invalid", )); }, }; let (temporary_path, mut file) = match create_secret_export_temp_file(directory, file_name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; use std::io::Write; // rust-rules: trait-import if let std::result::Result::Err(error) = file.write_all(bytes.as_slice()) { let _ = std::fs::remove_file(&temporary_path); return std::result::Result::Err(ks_core::Error::new( "wallet_export_write_failed", error.to_string(), )); } if let std::result::Result::Err(error) = file.sync_all() { let _ = std::fs::remove_file(&temporary_path); return std::result::Result::Err(ks_core::Error::new( "wallet_export_sync_failed", error.to_string(), )); } std::mem::drop(file); if let std::result::Result::Err(error) = std::fs::hard_link(&temporary_path, &path) { let _ = std::fs::remove_file(&temporary_path); if error.kind() == std::io::ErrorKind::AlreadyExists { return std::result::Result::Err(ks_core::Error::new( "wallet_export_already_exists", "wallet export destination already exists", )); } return std::result::Result::Err(ks_core::Error::new( "wallet_export_publish_failed", error.to_string(), )); } if let std::result::Result::Err(error) = sync_export_directory(directory) { let _ = std::fs::remove_file(&temporary_path); return std::result::Result::Err(error); } if let std::result::Result::Err(error) = std::fs::remove_file(&temporary_path) { return std::result::Result::Err(ks_core::Error::new( "wallet_export_temp_remove_failed", error.to_string(), )); } return sync_export_directory(directory); } fn create_secret_export_temp_file( directory: &std::path::Path, file_name: &str, ) -> ks_core::Result<(std::path::PathBuf, std::fs::File)> { for _ in 0..SECRET_EXPORT_TEMP_ATTEMPTS { let sequence = SECRET_EXPORT_TEMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let temporary_path = directory .join(format!(".{file_name}.kswallet-export-tmp-{}-{sequence}", std::process::id())); let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; // rust-rules: trait-import options.mode(0o600); } match options.open(&temporary_path) { std::result::Result::Ok(file) => { return std::result::Result::Ok((temporary_path, file)); }, std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_export_temp_create_failed", error.to_string(), )); }, } } return std::result::Result::Err(ks_core::Error::new( "wallet_export_temp_create_failed", "wallet export temporary filename could not be reserved", )); } #[cfg(unix)] fn sync_export_directory(directory: &std::path::Path) -> ks_core::Result<()> { let file = match std::fs::File::open(directory) { std::result::Result::Ok(file) => file, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_export_directory_sync_failed", error.to_string(), )); }, }; return match file.sync_all() { std::result::Result::Ok(()) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "wallet_export_directory_sync_failed", error.to_string(), )), }; } #[cfg(not(unix))] fn sync_export_directory(_directory: &std::path::Path) -> ks_core::Result<()> { return std::result::Result::Ok(()); } fn native_path(manager: &crate::WalletManager, alias: &crate::WalletAlias) -> std::path::PathBuf { return manager.directory().join(format!( "{}.{}", alias.as_str(), crate::KSWALLET_FILE_EXTENSION )); } fn transfer_format_code(format: crate::WalletTransferFormat) -> &'static str { return match format { crate::WalletTransferFormat::SolanaCliJson => "solana_cli_json", crate::WalletTransferFormat::SolanaPrivateKeyBase58 => "solana_private_key_base58", }; }