Files
khadhroony-bot3/ks-wallet/src/transfer.rs
2026-08-10 19:12:13 +02:00

666 lines
27 KiB
Rust

// 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<std::path::Path>,
format: crate::WalletTransferFormat,
) -> ks_core::Result<crate::UnlockedWallet> {
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 `<alias>.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<crate::UnlockedWallet> {
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<std::path::Path>,
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<crate::UnlockedWallet> {
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<T>(
destination: std::path::PathBuf,
original_error: ks_core::Error,
) -> ks_core::Result<T> {
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<solana_keypair::Keypair> {
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<std::vec::Vec<u8>> {
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<solana_keypair::Keypair> {
let mut parsed = match serde_json::from_slice::<std::vec::Vec<u8>>(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<solana_keypair::Keypair> {
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<zeroize::Zeroizing<std::vec::Vec<u8>>> {
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<std::vec::Vec<u8>>,
) -> 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<std::vec::Vec<u8>>,
) -> 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",
};
}