// file: crates/ksp-wallet-lib/src/transfer.rs // version: 3 //! Explicit OWNER-only Solana keypair import/export adapters. use std::io::Read; // rust-rules: trait-import use std::io::Write; // rust-rules: trait-import #[cfg(unix)] use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import use zeroize::Zeroize; // rust-rules: trait-import /// Explicit secret-transfer formats supported by Wallet `0.2.5`. #[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WalletTransferFormat { /// Standard Solana CLI keypair JSON array containing exactly 64 byte values. SolanaCliJson, /// Canonical Base58 encoding of the complete 64-byte Solana keypair. SolanaKeypairBase58, } impl WalletTransferFormat { /// Returns every transfer format currently supported for both import and OWNER export. #[must_use] pub const fn supported() -> [Self; 2] { return [Self::SolanaCliJson, Self::SolanaKeypairBase58]; } /// Returns the stable machine-readable format code. #[must_use] pub const fn code(self) -> &'static str { return match self { Self::SolanaCliJson => "solana_cli_json", Self::SolanaKeypairBase58 => "solana_keypair_base58", }; } /// Returns a human-readable label that states the exact key material carried by the format. #[must_use] pub const fn label(self) -> &'static str { return match self { Self::SolanaCliJson => "Solana CLI keypair JSON (64 bytes)", Self::SolanaKeypairBase58 => "Solana keypair Base58 (64 bytes)", }; } /// Returns the conventional file extension for this transfer format. #[must_use] pub const fn default_extension(self) -> &'static str { return match self { Self::SolanaCliJson => "json", Self::SolanaKeypairBase58 => "txt", }; } const fn maximum_source_bytes(self) -> usize { return match self { Self::SolanaCliJson => crate::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES, Self::SolanaKeypairBase58 => crate::KSPWALLET_TRANSFER_MAX_BASE58_BYTES, }; } } /// Safe public projection produced after validating an external secret-transfer source. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct WalletTransferInspection { pubkey: ksp_core_lib::Pubkey, format: WalletTransferFormat, } impl WalletTransferInspection { /// Returns the Solana public key derived from the validated external keypair. #[must_use] pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey { return &self.pubkey; } /// Returns the transfer format used for validation. #[must_use] pub const fn format(&self) -> WalletTransferFormat { return self.format; } } /// Validates one in-memory transfer payload and exposes only its derived public identity. /// /// The caller retains ownership of `source`; if it contains secret key material, the caller is responsible for zeroizing that buffer when appropriate. pub fn inspect_wallet_transfer(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { let keypair = match decode_transfer_keypair(source, format) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let pubkey = keypair_pubkey(&keypair); return std::result::Result::Ok(WalletTransferInspection { pubkey, format }); } /// Reads and validates one bounded external transfer file and exposes only its derived public identity. pub async fn inspect_wallet_transfer_file( source: impl std::convert::AsRef, format: WalletTransferFormat, ) -> ksp_core_lib::Result { let bytes = match read_transfer_file_async(source.as_ref().to_path_buf(), format).await { std::result::Result::Ok(value) => zeroize::Zeroizing::new(value), std::result::Result::Err(error) => return std::result::Result::Err(error), }; return inspect_wallet_transfer(bytes.as_slice(), format); } /// Imports one in-memory Solana keypair transfer payload into a new native `.kspwallet` V1 file. /// /// Import always creates a fresh native Wallet envelope around the validated immutable Solana keypair and publishes with the same no-clobber semantics as /// native creation. An existing destination is never overwritten. The caller retains ownership of `source` and must zeroize it when appropriate. pub async fn import_wallet_transfer_v1( destination: impl std::convert::AsRef, source: &[u8], format: WalletTransferFormat, owner_password: crate::OwnerPassword, view_password: std::option::Option, metadata: crate::WalletCreateMetadataV1, ) -> ksp_core_lib::Result { let keypair = match decode_transfer_keypair(source, format) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let owner = match crate::create_wallet_v1_from_keypair(owner_password, view_password, metadata, keypair).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let serialized = match owner.to_json_bytes() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let persist_result = crate::persist_new_wallet_content_v1(destination.as_ref().to_path_buf(), serialized).await; if let std::result::Result::Err(error) = persist_result { return std::result::Result::Err(error); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, operation = "wallet_import_transfer", transfer_format = format.code(), "external Solana keypair imported into a new native wallet" ); return std::result::Result::Ok(owner); } /// Imports one bounded external transfer file into a new native `.kspwallet` V1 file. /// /// The source file is never modified and the destination remains no-clobber. pub async fn import_wallet_transfer_file_v1( destination: impl std::convert::AsRef, source: impl std::convert::AsRef, format: WalletTransferFormat, owner_password: crate::OwnerPassword, view_password: std::option::Option, metadata: crate::WalletCreateMetadataV1, ) -> ksp_core_lib::Result { let bytes = match read_transfer_file_async(source.as_ref().to_path_buf(), format).await { std::result::Result::Ok(value) => zeroize::Zeroizing::new(value), std::result::Result::Err(error) => return std::result::Result::Err(error), }; return import_wallet_transfer_v1(destination, bytes.as_slice(), format, owner_password, view_password, metadata).await; } /// Executes the crate-internal write wallet transfer file v1 operation for the owning module. pub(crate) async fn write_wallet_transfer_file_v1( destination: std::path::PathBuf, content: std::vec::Vec, format: WalletTransferFormat, ) -> ksp_core_lib::Result<()> { let content = zeroize::Zeroizing::new(content); if content.len() > format.maximum_source_bytes() { return std::result::Result::Err(key_material_error(format, "encoded transfer exceeds the supported format bound")); } let task = tokio::task::spawn_blocking(move || return write_transfer_file_blocking(destination.as_path(), content.as_slice())); return match task.await { std::result::Result::Ok(result) => result, std::result::Result::Err(error) => std::result::Result::Err(blocking_transfer_error("export_task", error)), }; } fn decode_transfer_keypair(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { if source.is_empty() || source.len() > format.maximum_source_bytes() { return std::result::Result::Err(key_material_error(format, "transfer source length is invalid")); } return match format { WalletTransferFormat::SolanaCliJson => decode_solana_cli_json(source, format), WalletTransferFormat::SolanaKeypairBase58 => decode_solana_keypair_base58(source, format), }; } fn decode_solana_cli_json(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { let decoded_result = serde_json::from_slice::>(source); let mut decoded = match decoded_result { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(key_material_error(format, "transfer source is not a Solana CLI byte array")), }; if decoded.len() != crate::KSPWALLET_V1_SECRET_PLAINTEXT_BYTES { decoded.zeroize(); return std::result::Result::Err(key_material_error(format, "Solana CLI keypair must contain exactly 64 bytes")); } let keypair_result = solana_keypair::Keypair::try_from(decoded.as_slice()); decoded.zeroize(); return match keypair_result { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(_) => std::result::Result::Err(key_material_error(format, "Solana CLI keypair bytes are inconsistent")), }; } fn decode_solana_keypair_base58(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { let source_text = match std::str::from_utf8(source) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(key_material_error(format, "Base58 transfer source is not UTF-8")), }; if source_text.trim() != source_text { return std::result::Result::Err(key_material_error(format, "Base58 transfer source must not contain surrounding whitespace")); } let keypair = match solana_keypair::Keypair::try_from_base58_string(source_text) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(key_material_error(format, "Base58 transfer source is not a valid 64-byte Solana keypair")); }, }; let mut canonical = keypair.to_base58_string(); let canonical_matches = canonical.as_str() == source_text; canonical.zeroize(); if !canonical_matches { return std::result::Result::Err(key_material_error(format, "Base58 transfer source is not canonical")); } return std::result::Result::Ok(keypair); } fn keypair_pubkey(keypair: &solana_keypair::Keypair) -> ksp_core_lib::Pubkey { let mut keypair_bytes = keypair.to_bytes(); let mut public_bytes = [0_u8; 32]; public_bytes.copy_from_slice(&keypair_bytes[32..64]); keypair_bytes.zeroize(); return ksp_core_lib::Pubkey::new_from_array(public_bytes); } async fn read_transfer_file_async(source: std::path::PathBuf, format: WalletTransferFormat) -> ksp_core_lib::Result> { let task = tokio::task::spawn_blocking(move || return read_transfer_file_blocking(source.as_path(), format)); return match task.await { std::result::Result::Ok(result) => result, std::result::Result::Err(error) => std::result::Result::Err(blocking_transfer_error("read_task", error)), }; } fn read_transfer_file_blocking(source: &std::path::Path, format: WalletTransferFormat) -> ksp_core_lib::Result> { let file = match std::fs::File::open(source) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(transfer_io_error("source_open", error)), }; let metadata = match file.metadata() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(transfer_io_error("source_metadata", error)), }; if !metadata.is_file() { return std::result::Result::Err(key_material_error(format, "transfer source must be a regular file")); } let maximum = format.maximum_source_bytes(); if metadata.len() > maximum as u64 { return std::result::Result::Err(key_material_error(format, "transfer source exceeds the supported format bound")); } let mut reader = file.take((maximum + 1) as u64); let mut bytes = std::vec::Vec::with_capacity(maximum.min(metadata.len() as usize)); if let std::result::Result::Err(error) = reader.read_to_end(&mut bytes) { bytes.zeroize(); return std::result::Result::Err(transfer_io_error("source_read", error)); } if bytes.len() > maximum { bytes.zeroize(); return std::result::Result::Err(key_material_error(format, "transfer source exceeds the supported format bound")); } return std::result::Result::Ok(bytes); } fn write_transfer_file_blocking(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { let parent = match destination_parent(destination) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if destination.file_name().is_none() { return std::result::Result::Err(transfer_atomic_error("destination", "transfer destination has no file name")); } let temporary_result = tempfile::Builder::new().prefix(".kspwallet-export-").suffix(".tmp").tempfile_in(parent); let mut temporary = match temporary_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(transfer_atomic_io_error("temporary_create", error)), }; set_private_permissions_best_effort(temporary.as_file()); if let std::result::Result::Err(error) = temporary.write_all(content) { return std::result::Result::Err(transfer_atomic_io_error("temporary_write", error)); } if let std::result::Result::Err(error) = temporary.as_file().sync_all() { return std::result::Result::Err(transfer_atomic_io_error("temporary_sync", error)); } let persist_result = temporary.persist_noclobber(destination); let persisted = match persist_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { if error.error.kind() == std::io::ErrorKind::AlreadyExists { return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_DESTINATION_EXISTS, "Wallet transfer destination already exists")); } return std::result::Result::Err(transfer_atomic_io_error("publish_noclobber", error.error)); }, }; if let std::result::Result::Err(error) = persisted.sync_all() { return std::result::Result::Err(transfer_atomic_io_error("published_file_sync", error)); } sync_parent_directory_best_effort(parent); return std::result::Result::Ok(()); } fn destination_parent(destination: &std::path::Path) -> ksp_core_lib::Result<&std::path::Path> { return match destination.parent() { std::option::Option::Some(parent) if !parent.as_os_str().is_empty() => std::result::Result::Ok(parent), std::option::Option::Some(_) => std::result::Result::Ok(std::path::Path::new(".")), std::option::Option::None => std::result::Result::Err(transfer_atomic_error("destination", "transfer destination has no parent directory")), }; } #[cfg(unix)] fn set_private_permissions_best_effort(file: &std::fs::File) { let permissions = std::fs::Permissions::from_mode(0o600); if let std::result::Result::Err(error) = file.set_permissions(permissions) { ksp_logging_lib::warn!( target: crate::TRACING_TARGET, operation = "wallet_transfer_permissions", io_kind = ?error.kind(), "wallet transfer export continues without the optional Unix 0600 hygiene hardening" ); } return; } #[cfg(not(unix))] fn set_private_permissions_best_effort(_file: &std::fs::File) { return; } #[cfg(unix)] fn sync_parent_directory_best_effort(parent: &std::path::Path) { let directory = match std::fs::File::open(parent) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { ksp_logging_lib::warn!( target: crate::TRACING_TARGET, operation = "wallet_transfer_parent_sync", io_kind = ?error.kind(), "wallet transfer export succeeded but parent-directory sync could not start" ); return; }, }; if let std::result::Result::Err(error) = directory.sync_all() { ksp_logging_lib::warn!( target: crate::TRACING_TARGET, operation = "wallet_transfer_parent_sync", io_kind = ?error.kind(), "wallet transfer export succeeded but parent-directory sync failed" ); } return; } #[cfg(not(unix))] fn sync_parent_directory_best_effort(_parent: &std::path::Path) { return; } fn key_material_error(format: WalletTransferFormat, message: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_KEY_MATERIAL_INVALID, message).with_context("transfer_format", format.code()); } fn transfer_io_error(operation: &'static str, source: std::io::Error) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Wallet transfer filesystem I/O failed") .with_context("operation", operation) .with_source(source); } fn transfer_atomic_error(operation: &'static str, message: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED, message).with_context("operation", operation); } fn transfer_atomic_io_error(operation: &'static str, source: std::io::Error) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED, "Atomic Wallet transfer export failed") .with_context("operation", operation) .with_source(source); } fn blocking_transfer_error(operation: &'static str, source: tokio::task::JoinError) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Wallet transfer filesystem task failed") .with_context("operation", operation) .with_source(source); } #[cfg(test)] #[path = "../unit_tests/transfer.rs"] mod tests;