v0.5.2-pre.005
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/lib.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
//! Wallet boundary for local key storage and transaction signing.
|
||||
#![warn(missing_docs)]
|
||||
@@ -10,6 +10,7 @@ mod constants;
|
||||
mod manager;
|
||||
mod native;
|
||||
mod password;
|
||||
mod transfer;
|
||||
mod unlocked;
|
||||
mod wallet;
|
||||
|
||||
@@ -21,6 +22,8 @@ pub use self::manager::WalletFileHandle;
|
||||
pub use self::manager::WalletManager;
|
||||
/// Explicit non-clonable password supplied to native wallet operations.
|
||||
pub use self::password::WalletPassword;
|
||||
/// Explicit secret import/export format supported by the wallet boundary.
|
||||
pub use self::transfer::WalletTransferFormat;
|
||||
/// Authenticated signing capability for one unlocked persistent wallet.
|
||||
pub use self::unlocked::UnlockedWallet;
|
||||
/// Solana keypair kept private inside the wallet boundary.
|
||||
|
||||
665
ks-wallet/src/transfer.rs
Normal file
665
ks-wallet/src/transfer.rs
Normal file
@@ -0,0 +1,665 @@
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/unlocked.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Authenticated signing capability for one unlocked native wallet.
|
||||
|
||||
@@ -59,6 +59,11 @@ impl crate::UnlockedWallet {
|
||||
return &self.keypair;
|
||||
}
|
||||
|
||||
/// Copies the keypair into a zeroizing crate-private buffer for explicit secret export.
|
||||
pub(crate) fn keypair_bytes(&self) -> zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]> {
|
||||
return zeroize::Zeroizing::new(self.keypair.to_bytes());
|
||||
}
|
||||
|
||||
/// Signs arbitrary message bytes after authenticated unlock.
|
||||
pub fn sign_message(&self, message: &[u8]) -> ks_core::Result<std::string::String> {
|
||||
let signature = match self.keypair.try_sign_message(message) {
|
||||
|
||||
Reference in New Issue
Block a user