v0.2.5-pre.008
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/constants.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
//! Wallet-owned constants.
|
||||
|
||||
@@ -9,6 +9,10 @@ pub const KSPWALLET_MAGIC: &str = "KSPWALLET";
|
||||
pub const KSPWALLET_FORMAT_VERSION_V1: u32 = 1;
|
||||
/// Maximum accepted `.kspwallet` document size before JSON parsing.
|
||||
pub const KSPWALLET_MAX_FILE_BYTES: usize = 1024 * 1024;
|
||||
/// Maximum accepted Solana CLI keypair JSON transfer size before parsing.
|
||||
pub const KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES: usize = 1024;
|
||||
/// Maximum accepted canonical Base58 keypair transfer size before decoding.
|
||||
pub const KSPWALLET_TRANSFER_MAX_BASE58_BYTES: usize = 128;
|
||||
/// Maximum protected alias size in UTF-8 bytes for metadata V1.
|
||||
pub const KSPWALLET_V1_MAX_ALIAS_BYTES: usize = 256;
|
||||
/// Maximum number of protected notes in metadata V1.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/lib.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -12,7 +12,9 @@
|
||||
//! vectors. `0.2.5-pre.005` adds exact protected payloads, OWNER Ed25519 state authentication and async in-memory create/open flows for VIEW and OWNER.
|
||||
//! `0.2.5-pre.006` adds bounded async-first filesystem reads plus same-directory synchronized no-clobber publication for new native files. `0.2.5-pre.007`
|
||||
//! adds Solana message signing, protected metadata administration, password rotation and strong VIEW disable/recreate with capability-bound atomic
|
||||
//! replacement. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral observability uses only
|
||||
//! replacement. `0.2.5-pre.008` adds bounded Solana CLI JSON and canonical full-keypair Base58 import/export adapters with safe inspection and no-clobber
|
||||
//! native import/export publication. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral
|
||||
//! observability uses only
|
||||
//! `ksp-logging-lib` with the explicit crate target defined in `src/constants.rs`.
|
||||
|
||||
mod capability;
|
||||
@@ -25,6 +27,7 @@ mod password;
|
||||
mod payload;
|
||||
mod persistence;
|
||||
mod transcript;
|
||||
mod transfer;
|
||||
mod view;
|
||||
mod wallet;
|
||||
mod wire;
|
||||
@@ -39,6 +42,10 @@ pub use self::constants::KSPWALLET_MAGIC;
|
||||
pub use self::constants::KSPWALLET_MAX_FILE_BYTES;
|
||||
/// Byte length of every Solana Ed25519 signature returned by OWNER.
|
||||
pub use self::constants::KSPWALLET_SOLANA_SIGNATURE_BYTES;
|
||||
/// Maximum accepted Base58 keypair transfer size before decoding.
|
||||
pub use self::constants::KSPWALLET_TRANSFER_MAX_BASE58_BYTES;
|
||||
/// Maximum accepted Solana CLI JSON transfer size before parsing.
|
||||
pub use self::constants::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES;
|
||||
/// Byte length of the AEAD authentication tag appended to V1 ciphertexts.
|
||||
pub use self::constants::KSPWALLET_V1_AEAD_TAG_BYTES;
|
||||
/// Argon2 version serialized by `.kspwallet` V1 key slots.
|
||||
@@ -165,6 +172,18 @@ pub use self::persistence::inspect_locked_wallet_file_v1;
|
||||
pub use self::persistence::open_wallet_owner_file_v1;
|
||||
/// Opens a native `.kspwallet` V1 file with VIEW capability.
|
||||
pub use self::persistence::open_wallet_view_file_v1;
|
||||
/// Explicit secret-transfer format supported by Wallet.
|
||||
pub use self::transfer::WalletTransferFormat;
|
||||
/// Safe public identity projection of one validated secret-transfer source.
|
||||
pub use self::transfer::WalletTransferInspection;
|
||||
/// Imports one bounded external transfer file into a new no-clobber native Wallet V1.
|
||||
pub use self::transfer::import_wallet_transfer_file_v1;
|
||||
/// Imports one in-memory transfer payload into a new no-clobber native Wallet V1.
|
||||
pub use self::transfer::import_wallet_transfer_v1;
|
||||
/// Validates one in-memory transfer payload and exposes only its derived public identity.
|
||||
pub use self::transfer::inspect_wallet_transfer;
|
||||
/// Validates one bounded external transfer file and exposes only its derived public identity.
|
||||
pub use self::transfer::inspect_wallet_transfer_file;
|
||||
/// Authorized VIEW capability handle.
|
||||
pub use self::view::WalletView;
|
||||
/// Creates a new in-memory native Wallet V1.
|
||||
@@ -202,9 +221,15 @@ pub use self::wire::WalletViewDescriptorV1;
|
||||
|
||||
/// Wallet-owned tracing target used by the KSP logging facade.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Internal no-clobber native persistence path shared by transfer adapters.
|
||||
pub(crate) use self::persistence::persist_new_wallet_content_v1;
|
||||
/// Internal deterministic compartment-AAD codec shared by Wallet crypto layers.
|
||||
pub(crate) use self::transcript::compartment_aad;
|
||||
/// Internal deterministic key-slot-AAD codec shared by Wallet crypto layers.
|
||||
pub(crate) use self::transcript::slot_aad;
|
||||
/// Internal deterministic OWNER-state transcript codec shared by Wallet crypto layers.
|
||||
pub(crate) use self::transcript::state_transcript;
|
||||
/// Internal no-clobber transfer-file writer used only by OWNER export.
|
||||
pub(crate) use self::transfer::write_wallet_transfer_file_v1;
|
||||
/// Internal imported-keypair creation path shared by transfer adapters.
|
||||
pub(crate) use self::wallet::create_wallet_v1_from_keypair;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/owner.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Authorized OWNER capability handle.
|
||||
///
|
||||
@@ -51,6 +51,51 @@ impl WalletOwner {
|
||||
return &self.info;
|
||||
}
|
||||
|
||||
/// Exports the immutable Solana keypair through one explicitly selected transfer adapter.
|
||||
///
|
||||
/// The returned bytes contain secret key material and are owned by the caller. Callers should minimize their lifetime and zeroize the buffer after use.
|
||||
/// VIEW has no corresponding export API.
|
||||
pub fn export_transfer(&self, format: crate::WalletTransferFormat) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let encoded = self.state.export_transfer(format);
|
||||
if let std::result::Result::Ok(value) = encoded.as_ref() {
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_export_transfer",
|
||||
capability = "owner",
|
||||
transfer_format = format.code(),
|
||||
transfer_bytes = value.len(),
|
||||
"wallet secret encoded through explicit OWNER transfer adapter"
|
||||
);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/// Exports the immutable Solana keypair to a new no-clobber transfer file.
|
||||
///
|
||||
/// The destination parent must already exist. On Unix, Wallet attempts `0600` permissions as filesystem hygiene, but OS ACLs are not part of the
|
||||
/// `.kspwallet` cryptographic security guarantee. Existing destinations are never overwritten.
|
||||
pub async fn export_transfer_file(
|
||||
&self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
format: crate::WalletTransferFormat,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let encoded = match self.state.export_transfer(format) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = crate::write_wallet_transfer_file_v1(destination.as_ref().to_path_buf(), encoded, format).await;
|
||||
if result.is_ok() {
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_export_transfer_file",
|
||||
capability = "owner",
|
||||
transfer_format = format.code(),
|
||||
"wallet secret exported through explicit OWNER transfer adapter"
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Signs one caller-provided message with the Wallet's immutable Solana keypair.
|
||||
///
|
||||
/// The returned bytes are the 64-byte Ed25519 signature. The Solana secret key is never returned or logged.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/persistence.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Async-first native Wallet V1 filesystem persistence.
|
||||
|
||||
@@ -90,6 +90,10 @@ async fn read_wallet_file_async(source: std::path::PathBuf) -> ksp_core_lib::Res
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_new_wallet_content_v1(destination: std::path::PathBuf, content: std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
|
||||
return persist_new_wallet_async(destination, content).await;
|
||||
}
|
||||
|
||||
async fn persist_new_wallet_async(destination: std::path::PathBuf, content: std::vec::Vec<u8>) -> ksp_core_lib::Result<()> {
|
||||
let task = tokio::task::spawn_blocking(move || return persist_new_wallet_blocking(destination.as_path(), content.as_slice()));
|
||||
return match task.await {
|
||||
|
||||
397
crates/ksp-wallet-lib/src/transfer.rs
Normal file
397
crates/ksp-wallet-lib/src/transfer.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
// file: crates/ksp-wallet-lib/src/transfer.rs
|
||||
// version: 1
|
||||
|
||||
//! Explicit OWNER-only Solana keypair import/export adapters.
|
||||
|
||||
use std::io::{Read as _, Write as _};
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
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;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/wallet.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
//! In-memory native Wallet V1 create/open orchestration.
|
||||
|
||||
@@ -50,6 +50,30 @@ impl OwnerStateV1 {
|
||||
return;
|
||||
}
|
||||
|
||||
pub(crate) fn export_transfer(&self, format: crate::WalletTransferFormat) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let keypair = match self.solana_keypair.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(key_material_error()),
|
||||
};
|
||||
return match format {
|
||||
crate::WalletTransferFormat::SolanaCliJson => {
|
||||
let mut keypair_bytes = keypair.to_bytes();
|
||||
let encoded = serde_json::to_vec(keypair_bytes.as_slice());
|
||||
keypair_bytes.zeroize();
|
||||
match encoded {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(key_material_error()),
|
||||
}
|
||||
},
|
||||
crate::WalletTransferFormat::SolanaKeypairBase58 => {
|
||||
let mut encoded = keypair.to_base58_string();
|
||||
let output = encoded.as_bytes().to_vec();
|
||||
encoded.zeroize();
|
||||
std::result::Result::Ok(output)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn sign_message(&self, message: &[u8]) -> ksp_core_lib::Result<[u8; crate::KSPWALLET_SOLANA_SIGNATURE_BYTES]> {
|
||||
let keypair = match self.solana_keypair.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
@@ -395,6 +419,21 @@ pub async fn create_wallet_v1(
|
||||
owner_password: crate::OwnerPassword,
|
||||
view_password: std::option::Option<crate::ViewPassword>,
|
||||
metadata: crate::WalletCreateMetadataV1,
|
||||
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
||||
let mut solana_secret = match crate::crypto::random_bytes::<32>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let solana_keypair = solana_keypair::Keypair::new_from_array(solana_secret);
|
||||
solana_secret.zeroize();
|
||||
return crate::create_wallet_v1_from_keypair(owner_password, view_password, metadata, solana_keypair).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn create_wallet_v1_from_keypair(
|
||||
owner_password: crate::OwnerPassword,
|
||||
view_password: std::option::Option<crate::ViewPassword>,
|
||||
metadata: crate::WalletCreateMetadataV1,
|
||||
solana_keypair: solana_keypair::Keypair,
|
||||
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
||||
let owner_root = match crate::crypto::SecretKeyV1::random() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -416,15 +455,6 @@ pub async fn create_wallet_v1(
|
||||
let admin_signing_key = ed25519_dalek::SigningKey::from_bytes(&admin_secret);
|
||||
let owner_auth_public_key = admin_signing_key.verifying_key().to_bytes();
|
||||
|
||||
let mut solana_secret = match crate::crypto::random_bytes::<32>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
admin_secret.zeroize();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let solana_keypair = solana_keypair::Keypair::new_from_array(solana_secret);
|
||||
solana_secret.zeroize();
|
||||
let mut solana_keypair_bytes = solana_keypair.to_bytes();
|
||||
let pubkey_result = pubkey_from_keypair_bytes(&solana_keypair_bytes);
|
||||
let pubkey = match pubkey_result {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/tests/dependency_boundary.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! Wallet-specific dependency and ownership canaries.
|
||||
|
||||
@@ -60,6 +60,7 @@ fn wallet_manifest_preserves_dependency_firewall() -> std::io::Result<()> {
|
||||
"tauri",
|
||||
"tracing =",
|
||||
"solana-pubkey",
|
||||
"bs58",
|
||||
] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden direct Wallet dependency detected: {forbidden}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/tests/public_api.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
//! Public API canaries for the Wallet foundation.
|
||||
|
||||
@@ -122,3 +122,43 @@ fn public_pre_007_owner_signing_and_administration_surface_is_available() {
|
||||
let _ = sign_method;
|
||||
assert_eq!(ksp_wallet_lib::KSPWALLET_SOLANA_SIGNATURE_BYTES, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_008_transfer_adapters_are_available_from_crate_root() {
|
||||
let formats = ksp_wallet_lib::WalletTransferFormat::supported();
|
||||
assert_eq!(formats.len(), 2);
|
||||
assert_eq!(formats[0].code(), "solana_cli_json");
|
||||
assert_eq!(formats[1].code(), "solana_keypair_base58");
|
||||
assert_eq!(ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES, 1024);
|
||||
assert_eq!(ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES, 128);
|
||||
let export_method: fn(&ksp_wallet_lib::WalletOwner, ksp_wallet_lib::WalletTransferFormat) -> ksp_core_lib::Result<std::vec::Vec<u8>> =
|
||||
ksp_wallet_lib::WalletOwner::export_transfer;
|
||||
let _ = export_method;
|
||||
let inspect_method: fn(&[u8], ksp_wallet_lib::WalletTransferFormat) -> ksp_core_lib::Result<ksp_wallet_lib::WalletTransferInspection> =
|
||||
ksp_wallet_lib::inspect_wallet_transfer;
|
||||
let _ = inspect_method;
|
||||
|
||||
let path = std::path::Path::new("not-polled-transfer");
|
||||
let inspect_file_future = ksp_wallet_lib::inspect_wallet_transfer_file(path, formats[0]);
|
||||
drop(inspect_file_future);
|
||||
let owner_password = ksp_wallet_lib::OwnerPassword::new(std::string::String::from("public-pre008-owner-password"));
|
||||
let import_future = ksp_wallet_lib::import_wallet_transfer_v1(
|
||||
path,
|
||||
b"[1,2,3]",
|
||||
formats[0],
|
||||
owner_password,
|
||||
std::option::Option::None,
|
||||
ksp_wallet_lib::WalletCreateMetadataV1::default(),
|
||||
);
|
||||
drop(import_future);
|
||||
let owner_password = ksp_wallet_lib::OwnerPassword::new(std::string::String::from("public-pre008-owner-password"));
|
||||
let import_file_future = ksp_wallet_lib::import_wallet_transfer_file_v1(
|
||||
path,
|
||||
path,
|
||||
formats[0],
|
||||
owner_password,
|
||||
std::option::Option::None,
|
||||
ksp_wallet_lib::WalletCreateMetadataV1::default(),
|
||||
);
|
||||
drop(import_file_future);
|
||||
}
|
||||
|
||||
241
crates/ksp-wallet-lib/unit_tests/transfer.rs
Normal file
241
crates/ksp-wallet-lib/unit_tests/transfer.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
// file: crates/ksp-wallet-lib/unit_tests/transfer.rs
|
||||
// version: 1
|
||||
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
fn runtime() -> tokio::runtime::Runtime {
|
||||
return tokio::runtime::Builder::new_current_thread().build().expect("Wallet transfer test runtime must build");
|
||||
}
|
||||
|
||||
fn test_keypair() -> solana_keypair::Keypair {
|
||||
return solana_keypair::Keypair::new_from_array([7_u8; 32]);
|
||||
}
|
||||
|
||||
fn cli_json(keypair: &solana_keypair::Keypair) -> std::vec::Vec<u8> {
|
||||
let mut bytes = keypair.to_bytes();
|
||||
let encoded = serde_json::to_vec(bytes.as_slice()).expect("test keypair must encode as Solana CLI JSON");
|
||||
bytes.zeroize();
|
||||
return encoded;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_transfer_formats_have_stable_codes_labels_and_extensions() {
|
||||
let formats = crate::WalletTransferFormat::supported();
|
||||
assert_eq!(formats, [crate::WalletTransferFormat::SolanaCliJson, crate::WalletTransferFormat::SolanaKeypairBase58]);
|
||||
assert_eq!(formats[0].code(), "solana_cli_json");
|
||||
assert_eq!(formats[0].default_extension(), "json");
|
||||
assert!(formats[0].label().contains("64 bytes"));
|
||||
assert_eq!(formats[1].code(), "solana_keypair_base58");
|
||||
assert_eq!(formats[1].default_extension(), "txt");
|
||||
assert!(formats[1].label().contains("64 bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_inspection_accepts_cli_json_and_canonical_base58_without_exposing_secret() {
|
||||
let keypair = test_keypair();
|
||||
let mut json = cli_json(&keypair);
|
||||
let mut base58 = keypair.to_base58_string();
|
||||
let json_inspection =
|
||||
crate::inspect_wallet_transfer(json.as_slice(), crate::WalletTransferFormat::SolanaCliJson).expect("Solana CLI JSON inspection must succeed");
|
||||
let base58_inspection =
|
||||
crate::inspect_wallet_transfer(base58.as_bytes(), crate::WalletTransferFormat::SolanaKeypairBase58).expect("canonical Base58 inspection must succeed");
|
||||
|
||||
assert_eq!(json_inspection.pubkey(), base58_inspection.pubkey());
|
||||
assert_eq!(json_inspection.format(), crate::WalletTransferFormat::SolanaCliJson);
|
||||
assert_eq!(base58_inspection.format(), crate::WalletTransferFormat::SolanaKeypairBase58);
|
||||
assert!(!format!("{json_inspection:?}").contains(base58.as_str()));
|
||||
json.zeroize();
|
||||
base58.zeroize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_seed7_transfer_canary_matches_known_solana_keypair_encodings() {
|
||||
let expected_keypair_bytes = [
|
||||
7_u8, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 234, 74, 108, 99, 226, 156, 82, 10, 190, 245, 80,
|
||||
123, 19, 46, 197, 249, 149, 71, 118, 174, 190, 190, 123, 146, 66, 30, 234, 105, 20, 70, 210, 44,
|
||||
];
|
||||
let expected_pubkey = "GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB";
|
||||
let expected_base58 = "99eUso3aSbE9tqGSTXzo3TLfKb9RkMTURrHKQ1K7Zh3StnzFNUx8FKCPPPPpR479qsw5zv2WNBKmgiz7WqgAJfM";
|
||||
let keypair = test_keypair();
|
||||
let mut actual_keypair_bytes = keypair.to_bytes();
|
||||
assert_eq!(actual_keypair_bytes, expected_keypair_bytes);
|
||||
let mut actual_base58 = keypair.to_base58_string();
|
||||
assert_eq!(actual_base58, expected_base58);
|
||||
|
||||
let mut json = serde_json::to_vec(expected_keypair_bytes.as_slice()).expect("external keypair canary must encode as JSON");
|
||||
let json_inspection =
|
||||
crate::inspect_wallet_transfer(json.as_slice(), crate::WalletTransferFormat::SolanaCliJson).expect("external Solana CLI JSON canary must inspect");
|
||||
let base58_inspection = crate::inspect_wallet_transfer(expected_base58.as_bytes(), crate::WalletTransferFormat::SolanaKeypairBase58)
|
||||
.expect("external Base58 canary must inspect");
|
||||
assert_eq!(json_inspection.pubkey().to_string(), expected_pubkey);
|
||||
assert_eq!(base58_inspection.pubkey().to_string(), expected_pubkey);
|
||||
|
||||
json.zeroize();
|
||||
actual_base58.zeroize();
|
||||
actual_keypair_bytes.zeroize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_decoders_reject_short_inconsistent_or_noncanonical_sources() {
|
||||
let short_json = b"[1,2,3]";
|
||||
assert_eq!(
|
||||
crate::inspect_wallet_transfer(short_json, crate::WalletTransferFormat::SolanaCliJson).expect_err("short Solana CLI JSON must fail").code(),
|
||||
crate::ERROR_CODE_KEY_MATERIAL_INVALID
|
||||
);
|
||||
|
||||
let keypair = test_keypair();
|
||||
let mut bytes = keypair.to_bytes();
|
||||
bytes[63] ^= 0x01;
|
||||
let mut inconsistent_json = serde_json::to_vec(bytes.as_slice()).expect("inconsistent fixture must encode");
|
||||
assert_eq!(
|
||||
crate::inspect_wallet_transfer(inconsistent_json.as_slice(), crate::WalletTransferFormat::SolanaCliJson)
|
||||
.expect_err("inconsistent keypair bytes must fail")
|
||||
.code(),
|
||||
crate::ERROR_CODE_KEY_MATERIAL_INVALID
|
||||
);
|
||||
bytes.zeroize();
|
||||
inconsistent_json.zeroize();
|
||||
|
||||
assert_eq!(
|
||||
crate::inspect_wallet_transfer(b"not valid base58 !!!", crate::WalletTransferFormat::SolanaKeypairBase58)
|
||||
.expect_err("invalid Base58 must fail")
|
||||
.code(),
|
||||
crate::ERROR_CODE_KEY_MATERIAL_INVALID
|
||||
);
|
||||
let mut base58 = keypair.to_base58_string();
|
||||
let surrounded = format!(" {base58}");
|
||||
assert_eq!(
|
||||
crate::inspect_wallet_transfer(surrounded.as_bytes(), crate::WalletTransferFormat::SolanaKeypairBase58)
|
||||
.expect_err("surrounding whitespace must fail")
|
||||
.code(),
|
||||
crate::ERROR_CODE_KEY_MATERIAL_INVALID
|
||||
);
|
||||
base58.zeroize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_json_import_creates_new_no_clobber_wallet_with_imported_identity_and_metadata() {
|
||||
let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable");
|
||||
let destination = directory.path().join("imported.kspwallet");
|
||||
let keypair = test_keypair();
|
||||
let mut source = cli_json(&keypair);
|
||||
let expected = crate::inspect_wallet_transfer(source.as_slice(), crate::WalletTransferFormat::SolanaCliJson).expect("source inspection must succeed");
|
||||
let runtime = runtime();
|
||||
let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-owner-password"));
|
||||
let owner = runtime
|
||||
.block_on(crate::import_wallet_transfer_v1(
|
||||
destination.as_path(),
|
||||
source.as_slice(),
|
||||
crate::WalletTransferFormat::SolanaCliJson,
|
||||
owner_password,
|
||||
std::option::Option::None,
|
||||
crate::WalletCreateMetadataV1::new(std::option::Option::Some(std::string::String::from("imported-wallet")), std::vec::Vec::new()),
|
||||
))
|
||||
.expect("Solana CLI JSON import must succeed");
|
||||
assert_eq!(owner.pubkey(), expected.pubkey());
|
||||
assert_eq!(owner.alias(), std::option::Option::Some("imported-wallet"));
|
||||
|
||||
let second_password = crate::OwnerPassword::new(std::string::String::from("pre008-second-owner-password"));
|
||||
let second = runtime.block_on(crate::import_wallet_transfer_v1(
|
||||
destination.as_path(),
|
||||
source.as_slice(),
|
||||
crate::WalletTransferFormat::SolanaCliJson,
|
||||
second_password,
|
||||
std::option::Option::None,
|
||||
crate::WalletCreateMetadataV1::default(),
|
||||
));
|
||||
assert_eq!(second.expect_err("import must never overwrite an existing native Wallet").code(), crate::ERROR_CODE_DESTINATION_EXISTS);
|
||||
source.zeroize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_file_import_is_non_destructive_and_bounded() {
|
||||
let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable");
|
||||
let source_path = directory.path().join("source.json");
|
||||
let destination = directory.path().join("imported.kspwallet");
|
||||
let keypair = test_keypair();
|
||||
let mut source = cli_json(&keypair);
|
||||
std::fs::write(source_path.as_path(), source.as_slice()).expect("transfer fixture must be writable");
|
||||
let original = std::fs::read(source_path.as_path()).expect("transfer fixture must be readable");
|
||||
let runtime = runtime();
|
||||
let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-file-owner-password"));
|
||||
runtime
|
||||
.block_on(crate::import_wallet_transfer_file_v1(
|
||||
destination.as_path(),
|
||||
source_path.as_path(),
|
||||
crate::WalletTransferFormat::SolanaCliJson,
|
||||
owner_password,
|
||||
std::option::Option::None,
|
||||
crate::WalletCreateMetadataV1::default(),
|
||||
))
|
||||
.expect("transfer-file import must succeed");
|
||||
assert_eq!(std::fs::read(source_path.as_path()).expect("source must survive import"), original);
|
||||
|
||||
let oversized_path = directory.path().join("oversized.json");
|
||||
std::fs::write(oversized_path.as_path(), std::vec![b'1'; crate::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES + 1])
|
||||
.expect("oversized transfer fixture must be writable");
|
||||
let error = runtime
|
||||
.block_on(crate::inspect_wallet_transfer_file(oversized_path.as_path(), crate::WalletTransferFormat::SolanaCliJson))
|
||||
.expect_err("oversized transfer source must fail before parse");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_KEY_MATERIAL_INVALID);
|
||||
source.zeroize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_exports_cli_json_and_base58_that_roundtrip_to_the_same_imported_keypair() {
|
||||
let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable");
|
||||
let destination = directory.path().join("imported.kspwallet");
|
||||
let keypair = test_keypair();
|
||||
let mut source_base58 = keypair.to_base58_string();
|
||||
let mut expected_bytes = keypair.to_bytes();
|
||||
let runtime = runtime();
|
||||
let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-export-owner-password"));
|
||||
let owner = runtime
|
||||
.block_on(crate::import_wallet_transfer_v1(
|
||||
destination.as_path(),
|
||||
source_base58.as_bytes(),
|
||||
crate::WalletTransferFormat::SolanaKeypairBase58,
|
||||
owner_password,
|
||||
std::option::Option::None,
|
||||
crate::WalletCreateMetadataV1::default(),
|
||||
))
|
||||
.expect("Base58 import must succeed");
|
||||
|
||||
let mut exported_json = owner.export_transfer(crate::WalletTransferFormat::SolanaCliJson).expect("OWNER JSON export must succeed");
|
||||
let mut decoded_json: std::vec::Vec<u8> = serde_json::from_slice(exported_json.as_slice()).expect("OWNER JSON export must be valid JSON");
|
||||
assert_eq!(decoded_json.as_slice(), expected_bytes.as_slice());
|
||||
let mut exported_base58 = owner.export_transfer(crate::WalletTransferFormat::SolanaKeypairBase58).expect("OWNER Base58 export must succeed");
|
||||
assert_eq!(exported_base58.as_slice(), source_base58.as_bytes());
|
||||
|
||||
exported_json.zeroize();
|
||||
decoded_json.zeroize();
|
||||
exported_base58.zeroize();
|
||||
source_base58.zeroize();
|
||||
expected_bytes.zeroize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_transfer_file_export_is_no_clobber() {
|
||||
let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable");
|
||||
let wallet_path = directory.path().join("wallet.kspwallet");
|
||||
let export_path = directory.path().join("keypair.json");
|
||||
let runtime = runtime();
|
||||
let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-file-export-password"));
|
||||
let owner = runtime
|
||||
.block_on(crate::create_wallet_file_v1(wallet_path.as_path(), owner_password, std::option::Option::None, crate::WalletCreateMetadataV1::default()))
|
||||
.expect("native Wallet creation must succeed");
|
||||
runtime
|
||||
.block_on(owner.export_transfer_file(export_path.as_path(), crate::WalletTransferFormat::SolanaCliJson))
|
||||
.expect("first OWNER transfer-file export must succeed");
|
||||
let first = std::fs::read(export_path.as_path()).expect("exported transfer file must be readable");
|
||||
let second = runtime.block_on(owner.export_transfer_file(export_path.as_path(), crate::WalletTransferFormat::SolanaCliJson));
|
||||
assert_eq!(second.expect_err("OWNER transfer-file export must not overwrite").code(), crate::ERROR_CODE_DESTINATION_EXISTS);
|
||||
assert_eq!(std::fs::read(export_path.as_path()).expect("first export must remain intact"), first);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = std::fs::metadata(export_path.as_path()).expect("export metadata must be readable").permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user