400 lines
18 KiB
Rust
400 lines
18 KiB
Rust
// 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<WalletTransferInspection> {
|
|
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<std::path::Path>,
|
|
format: WalletTransferFormat,
|
|
) -> ksp_core_lib::Result<WalletTransferInspection> {
|
|
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<std::path::Path>,
|
|
source: &[u8],
|
|
format: WalletTransferFormat,
|
|
owner_password: crate::OwnerPassword,
|
|
view_password: std::option::Option<crate::ViewPassword>,
|
|
metadata: crate::WalletCreateMetadataV1,
|
|
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
|
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<std::path::Path>,
|
|
source: impl std::convert::AsRef<std::path::Path>,
|
|
format: WalletTransferFormat,
|
|
owner_password: crate::OwnerPassword,
|
|
view_password: std::option::Option<crate::ViewPassword>,
|
|
metadata: crate::WalletCreateMetadataV1,
|
|
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
|
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<u8>,
|
|
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<solana_keypair::Keypair> {
|
|
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<solana_keypair::Keypair> {
|
|
let decoded_result = serde_json::from_slice::<std::vec::Vec<u8>>(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<solana_keypair::Keypair> {
|
|
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<std::vec::Vec<u8>> {
|
|
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<std::vec::Vec<u8>> {
|
|
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;
|