v0.5.2-pre.004
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/constants.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Local constants for the `ks-wallet` crate.
|
||||
|
||||
@@ -27,8 +27,16 @@ pub(crate) const KSWALLET_AEAD_XCHACHA20_POLY1305: u8 = 1;
|
||||
pub(crate) const KSWALLET_SALT_LENGTH: usize = 16;
|
||||
/// Nonce length used by XChaCha20-Poly1305.
|
||||
pub(crate) const KSWALLET_NONCE_LENGTH: usize = 24;
|
||||
/// Encryption-key length required by XChaCha20-Poly1305.
|
||||
pub(crate) const KSWALLET_AEAD_KEY_LENGTH: usize = 32;
|
||||
/// Authentication tag length appended by XChaCha20-Poly1305.
|
||||
const KSWALLET_AEAD_TAG_LENGTH: usize = 16;
|
||||
/// Default Argon2id memory cost in KiB used for new native wallets.
|
||||
pub(crate) const KSWALLET_ARGON2_DEFAULT_MEMORY_KIB: u32 = 65_536;
|
||||
/// Default Argon2id pass count used for new native wallets.
|
||||
pub(crate) const KSWALLET_ARGON2_DEFAULT_ITERATIONS: u32 = 3;
|
||||
/// Default Argon2id lane count used for new native wallets.
|
||||
pub(crate) const KSWALLET_ARGON2_DEFAULT_PARALLELISM: u32 = 4;
|
||||
/// Minimum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_MEMORY_KIB: u32 = 65_536;
|
||||
/// Maximum accepted Argon2id memory cost in KiB.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/lib.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Wallet boundary for local key storage and transaction signing.
|
||||
#![warn(missing_docs)]
|
||||
@@ -9,6 +9,8 @@
|
||||
mod constants;
|
||||
mod manager;
|
||||
mod native;
|
||||
mod password;
|
||||
mod unlocked;
|
||||
mod wallet;
|
||||
|
||||
/// Native wallet file extension without the leading dot.
|
||||
@@ -17,6 +19,10 @@ pub use self::constants::KSWALLET_FILE_EXTENSION;
|
||||
pub use self::manager::WalletFileHandle;
|
||||
/// Multi-wallet manager rooted at one configured wallet directory.
|
||||
pub use self::manager::WalletManager;
|
||||
/// Explicit non-clonable password supplied to native wallet operations.
|
||||
pub use self::password::WalletPassword;
|
||||
/// Authenticated signing capability for one unlocked persistent wallet.
|
||||
pub use self::unlocked::UnlockedWallet;
|
||||
/// Solana keypair kept private inside the wallet boundary.
|
||||
pub use self::wallet::TemporaryWallet;
|
||||
/// Filesystem-backed store for legacy development and integration-test wallets.
|
||||
@@ -32,8 +38,16 @@ pub use self::wallet::WalletPolicy;
|
||||
/// Backward-compatible name for a non-secret wallet identity.
|
||||
pub use self::wallet::WalletSummary;
|
||||
|
||||
/// Encryption-key length required by XChaCha20-Poly1305.
|
||||
pub(crate) use self::constants::KSWALLET_AEAD_KEY_LENGTH;
|
||||
/// Version-one AEAD identifier for XChaCha20-Poly1305.
|
||||
pub(crate) use self::constants::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
/// Default Argon2id pass count for newly protected wallets.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_DEFAULT_ITERATIONS;
|
||||
/// Default Argon2id memory cost for newly protected wallets.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_DEFAULT_MEMORY_KIB;
|
||||
/// Default Argon2id parallelism for newly protected wallets.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_DEFAULT_PARALLELISM;
|
||||
/// Maximum accepted Argon2id pass count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_ITERATIONS;
|
||||
/// Maximum accepted Argon2id memory cost in KiB.
|
||||
@@ -76,5 +90,13 @@ pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Parsed version-one native wallet container.
|
||||
pub(crate) use self::native::NativeWalletContainer;
|
||||
/// Protects one exact keypair payload with the native password contract.
|
||||
pub(crate) use self::native::protect_native_wallet_keypair;
|
||||
/// Reads and strictly decodes one native wallet file.
|
||||
pub(crate) use self::native::read_native_wallet_container;
|
||||
/// Atomically replaces one native wallet after authenticated password rotation.
|
||||
pub(crate) use self::native::replace_native_wallet_file_atomic;
|
||||
/// Authenticates and decrypts one native wallet keypair.
|
||||
pub(crate) use self::native::unlock_native_wallet_keypair;
|
||||
/// Publishes one new native wallet atomically without overwrite.
|
||||
pub(crate) use self::native::write_native_wallet_file_atomic;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// file: ks-wallet/src/manager.rs
|
||||
// version: 4
|
||||
// version: 6
|
||||
|
||||
//! Multi-wallet discovery and native wallet file references.
|
||||
//! Multi-wallet discovery, authenticated opening and password rotation.
|
||||
|
||||
use solana_signer::Signer; // rust-rules: trait-import
|
||||
|
||||
/// Opaque reference to one validated native wallet file.
|
||||
///
|
||||
@@ -182,6 +184,204 @@ impl crate::WalletManager {
|
||||
) -> ks_core::Result<crate::WalletFileHandle> {
|
||||
return inspect_native_wallet_file(path.as_ref().to_path_buf()).await;
|
||||
}
|
||||
|
||||
/// Creates and atomically persists one new password-protected native wallet.
|
||||
pub async fn create(
|
||||
&self,
|
||||
alias: crate::WalletAlias,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::UnlockedWallet> {
|
||||
let path = self.wallet_path(&alias);
|
||||
let exists = match tokio::fs::try_exists(&path).await {
|
||||
std::result::Result::Ok(exists) => exists,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_exists_check_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
if exists {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_already_exists",
|
||||
"native wallet already exists for this alias",
|
||||
));
|
||||
}
|
||||
let keypair = solana_keypair::Keypair::new();
|
||||
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(path, &container).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "create_native_wallet",
|
||||
wallet_alias = alias.as_str(),
|
||||
public_key = %public_key,
|
||||
"created password-protected native wallet"
|
||||
);
|
||||
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
|
||||
}
|
||||
|
||||
/// Authenticates and unlocks one native wallet from the configured directory.
|
||||
pub async fn unlock(
|
||||
&self,
|
||||
alias: &crate::WalletAlias,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::UnlockedWallet> {
|
||||
let path = self.wallet_path(alias);
|
||||
let exists = match tokio::fs::try_exists(&path).await {
|
||||
std::result::Result::Ok(exists) => exists,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_exists_check_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !exists {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_not_found",
|
||||
"native wallet was not found for this alias",
|
||||
));
|
||||
}
|
||||
return unlock_native_wallet_at_path(
|
||||
path,
|
||||
std::option::Option::Some(alias),
|
||||
std::option::Option::None,
|
||||
password,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Authenticates an explicitly selected native wallet file outside or inside the store.
|
||||
///
|
||||
/// The opaque handle is revalidated against the file before any signing
|
||||
/// capability is returned. The file is never registered in the configured store.
|
||||
pub async fn unlock_file(
|
||||
&self,
|
||||
handle: &crate::WalletFileHandle,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::UnlockedWallet> {
|
||||
return unlock_native_wallet_at_path(
|
||||
handle.path.clone(),
|
||||
std::option::Option::Some(&handle.alias),
|
||||
std::option::Option::Some(handle),
|
||||
password,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Changes the protection password while preserving the exact keypair and public key.
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
alias: &crate::WalletAlias,
|
||||
current_password: crate::WalletPassword,
|
||||
new_password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::WalletFileHandle> {
|
||||
let path = self.wallet_path(alias);
|
||||
let container = match crate::read_native_wallet_container(&path).await {
|
||||
std::result::Result::Ok(container) => container,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if container.alias() != alias {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_mismatch",
|
||||
"native wallet filename alias does not match the container alias",
|
||||
));
|
||||
}
|
||||
let public_key = *container.public_key();
|
||||
let keypair = match crate::unlock_native_wallet_keypair(container, current_password).await {
|
||||
std::result::Result::Ok(keypair) => keypair,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes());
|
||||
let replacement = match crate::protect_native_wallet_keypair(
|
||||
alias.clone(),
|
||||
public_key,
|
||||
keypair_bytes,
|
||||
new_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(container) => container,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
match crate::replace_native_wallet_file_atomic(path.clone(), &replacement).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "change_native_wallet_password",
|
||||
wallet_alias = alias.as_str(),
|
||||
public_key = %public_key,
|
||||
"changed native wallet protection password"
|
||||
);
|
||||
return std::result::Result::Ok(crate::WalletFileHandle {
|
||||
alias: alias.clone(),
|
||||
public_key: public_key.to_string(),
|
||||
format_version: crate::KSWALLET_FORMAT_VERSION,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_native_wallet_at_path(
|
||||
path: std::path::PathBuf,
|
||||
expected_alias: std::option::Option<&crate::WalletAlias>,
|
||||
expected_handle: std::option::Option<&crate::WalletFileHandle>,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::UnlockedWallet> {
|
||||
let container = match crate::read_native_wallet_container(&path).await {
|
||||
std::result::Result::Ok(container) => container,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::option::Option::Some(alias) = expected_alias {
|
||||
if container.alias() != alias {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_mismatch",
|
||||
"native wallet alias does not match the requested identity",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(handle) = expected_handle {
|
||||
if handle.format_version != crate::KSWALLET_FORMAT_VERSION
|
||||
|| container.alias() != &handle.alias
|
||||
|| container.public_key().to_string() != handle.public_key
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_handle_stale",
|
||||
"native wallet file no longer matches the inspected handle",
|
||||
));
|
||||
}
|
||||
}
|
||||
let alias = container.alias().clone();
|
||||
let public_key = *container.public_key();
|
||||
let keypair = match crate::unlock_native_wallet_keypair(container, password).await {
|
||||
std::result::Result::Ok(keypair) => keypair,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "unlock_native_wallet",
|
||||
wallet_alias = alias.as_str(),
|
||||
public_key = %public_key,
|
||||
"authenticated native wallet"
|
||||
);
|
||||
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
|
||||
}
|
||||
|
||||
fn has_native_wallet_extension(path: &std::path::Path) -> bool {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// file: ks-wallet/src/native.rs
|
||||
// version: 2
|
||||
// version: 4
|
||||
|
||||
//! Strict reader and validator for native `.kswallet` containers.
|
||||
//! Native `.kswallet` codec, password protection and atomic file publication.
|
||||
|
||||
use chacha20poly1305::aead::Aead; // rust-rules: trait-import
|
||||
use chacha20poly1305::aead::KeyInit; // rust-rules: trait-import
|
||||
use solana_signer::Signer; // rust-rules: trait-import
|
||||
use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
use zeroize::Zeroize; // rust-rules: trait-import
|
||||
|
||||
@@ -23,21 +26,38 @@ const OFFSET_CIPHERTEXT_LENGTH: usize = 36;
|
||||
const OFFSET_PUBLIC_KEY: usize = 40;
|
||||
const RESERVED_LENGTH: usize = 4;
|
||||
const PUBLIC_KEY_LENGTH: usize = 32;
|
||||
const TEMP_CREATE_ATTEMPTS: u64 = 16;
|
||||
static NATIVE_TEMP_FILE_COUNTER: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NativeWalletKdfParameters {
|
||||
memory_kib: u32,
|
||||
iterations: u32,
|
||||
parallelism: u32,
|
||||
}
|
||||
|
||||
/// Parsed non-secret identity from one structurally valid native wallet container.
|
||||
impl NativeWalletKdfParameters {
|
||||
fn version_one_default() -> Self {
|
||||
return Self {
|
||||
memory_kib: crate::KSWALLET_ARGON2_DEFAULT_MEMORY_KIB,
|
||||
iterations: crate::KSWALLET_ARGON2_DEFAULT_ITERATIONS,
|
||||
parallelism: crate::KSWALLET_ARGON2_DEFAULT_PARALLELISM,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed native wallet container kept strictly inside the wallet boundary.
|
||||
///
|
||||
/// The full file buffer, including salt, nonce and ciphertext, is zeroized after
|
||||
/// structural validation. This type therefore retains only the fields required
|
||||
/// by discovery before authenticated unlock.
|
||||
/// The container is intentionally neither public nor `Debug`. Ciphertext is
|
||||
/// retained only so an inspected file can later be authenticated and unlocked.
|
||||
pub(crate) struct NativeWalletContainer {
|
||||
alias: crate::WalletAlias,
|
||||
public_key: solana_pubkey::Pubkey,
|
||||
kdf_parameters: NativeWalletKdfParameters,
|
||||
salt: [u8; crate::KSWALLET_SALT_LENGTH],
|
||||
nonce: [u8; crate::KSWALLET_NONCE_LENGTH],
|
||||
ciphertext: zeroize::Zeroizing<std::vec::Vec<u8>>,
|
||||
}
|
||||
|
||||
impl crate::NativeWalletContainer {
|
||||
@@ -50,6 +70,25 @@ impl crate::NativeWalletContainer {
|
||||
pub(crate) fn public_key(&self) -> &solana_pubkey::Pubkey {
|
||||
return &self.public_key;
|
||||
}
|
||||
|
||||
fn authenticated_prefix(&self) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
|
||||
return encode_authenticated_prefix(
|
||||
&self.alias,
|
||||
&self.public_key,
|
||||
self.kdf_parameters,
|
||||
&self.salt,
|
||||
&self.nonce,
|
||||
);
|
||||
}
|
||||
|
||||
fn encode(&self) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
|
||||
let mut bytes = match self.authenticated_prefix() {
|
||||
std::result::Result::Ok(bytes) => bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
bytes.extend_from_slice(self.ciphertext.as_slice());
|
||||
return std::result::Result::Ok(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and strictly validates one native wallet file with a pre-allocation size bound.
|
||||
@@ -122,6 +161,628 @@ pub(crate) async fn read_native_wallet_container(
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/// Protects one exact Solana keypair payload with a caller-owned password.
|
||||
pub(crate) async fn protect_native_wallet_keypair(
|
||||
alias: crate::WalletAlias,
|
||||
public_key: solana_pubkey::Pubkey,
|
||||
keypair_bytes: zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]>,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
return protect_native_wallet_keypair_blocking(alias, public_key, keypair_bytes, password);
|
||||
});
|
||||
return match task.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_crypto_task_failed",
|
||||
"native wallet cryptographic task failed",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Authenticates and decrypts one native wallet into a signing keypair.
|
||||
pub(crate) async fn unlock_native_wallet_keypair(
|
||||
container: crate::NativeWalletContainer,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<solana_keypair::Keypair> {
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
return unlock_native_wallet_keypair_blocking(container, password);
|
||||
});
|
||||
return match task.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_crypto_task_failed",
|
||||
"native wallet cryptographic task failed",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Publishes one new native wallet atomically without replacing an existing destination.
|
||||
pub(crate) async fn write_native_wallet_file_atomic(
|
||||
path: std::path::PathBuf,
|
||||
container: &crate::NativeWalletContainer,
|
||||
) -> ks_core::Result<()> {
|
||||
let bytes = match container.encode() {
|
||||
std::result::Result::Ok(bytes) => bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let alias = container.alias().clone();
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
return write_native_wallet_file_atomic_blocking(path, alias, bytes);
|
||||
});
|
||||
return match task.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_write_task_failed",
|
||||
"native wallet file publication task failed",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Atomically replaces one authenticated native wallet during password rotation.
|
||||
pub(crate) async fn replace_native_wallet_file_atomic(
|
||||
path: std::path::PathBuf,
|
||||
container: &crate::NativeWalletContainer,
|
||||
) -> ks_core::Result<()> {
|
||||
let bytes = match container.encode() {
|
||||
std::result::Result::Ok(bytes) => bytes,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let alias = container.alias().clone();
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
return replace_native_wallet_file_atomic_blocking(path, alias, bytes);
|
||||
});
|
||||
return match task.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_write_task_failed",
|
||||
"native wallet file replacement task failed",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn protect_native_wallet_keypair_blocking(
|
||||
alias: crate::WalletAlias,
|
||||
public_key: solana_pubkey::Pubkey,
|
||||
keypair_bytes: zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]>,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
let kdf_parameters = NativeWalletKdfParameters::version_one_default();
|
||||
let mut salt = [0_u8; crate::KSWALLET_SALT_LENGTH];
|
||||
let mut nonce = [0_u8; crate::KSWALLET_NONCE_LENGTH];
|
||||
if getrandom::fill(&mut salt).is_err() {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_random_failed",
|
||||
"secure random generation failed",
|
||||
));
|
||||
}
|
||||
if getrandom::fill(&mut nonce).is_err() {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_random_failed",
|
||||
"secure random generation failed",
|
||||
));
|
||||
}
|
||||
let authenticated_prefix =
|
||||
match encode_authenticated_prefix(&alias, &public_key, kdf_parameters, &salt, &nonce) {
|
||||
std::result::Result::Ok(prefix) => prefix,
|
||||
std::result::Result::Err(error) => {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let key = match derive_native_wallet_key(password.as_bytes(), &salt, kdf_parameters) {
|
||||
std::result::Result::Ok(key) => key,
|
||||
std::result::Result::Err(error) => {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let cipher = match chacha20poly1305::XChaCha20Poly1305::new_from_slice(&*key) {
|
||||
std::result::Result::Ok(cipher) => cipher,
|
||||
std::result::Result::Err(_) => {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_aead_key_invalid",
|
||||
"native wallet AEAD key length is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
let xnonce = chacha20poly1305::XNonce::from(nonce);
|
||||
let ciphertext = match cipher.encrypt(
|
||||
&xnonce,
|
||||
chacha20poly1305::aead::Payload {
|
||||
msg: &*keypair_bytes,
|
||||
aad: authenticated_prefix.as_slice(),
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(ciphertext) => ciphertext,
|
||||
std::result::Result::Err(_) => {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_encrypt_failed",
|
||||
"native wallet encryption failed",
|
||||
));
|
||||
},
|
||||
};
|
||||
if ciphertext.len() != crate::KSWALLET_CIPHERTEXT_LENGTH {
|
||||
salt.zeroize();
|
||||
nonce.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_ciphertext_length_invalid",
|
||||
"native wallet encryption produced an invalid ciphertext length",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(crate::NativeWalletContainer {
|
||||
alias,
|
||||
public_key,
|
||||
kdf_parameters,
|
||||
salt,
|
||||
nonce,
|
||||
ciphertext: zeroize::Zeroizing::new(ciphertext),
|
||||
});
|
||||
}
|
||||
|
||||
fn unlock_native_wallet_keypair_blocking(
|
||||
container: crate::NativeWalletContainer,
|
||||
password: crate::WalletPassword,
|
||||
) -> ks_core::Result<solana_keypair::Keypair> {
|
||||
let authenticated_prefix = match container.authenticated_prefix() {
|
||||
std::result::Result::Ok(prefix) => prefix,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let key = match derive_native_wallet_key(
|
||||
password.as_bytes(),
|
||||
&container.salt,
|
||||
container.kdf_parameters,
|
||||
) {
|
||||
std::result::Result::Ok(key) => key,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let cipher = match chacha20poly1305::XChaCha20Poly1305::new_from_slice(&*key) {
|
||||
std::result::Result::Ok(cipher) => cipher,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_aead_key_invalid",
|
||||
"native wallet AEAD key length is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
let xnonce = chacha20poly1305::XNonce::from(container.nonce);
|
||||
let plaintext = match cipher.decrypt(
|
||||
&xnonce,
|
||||
chacha20poly1305::aead::Payload {
|
||||
msg: container.ciphertext.as_slice(),
|
||||
aad: authenticated_prefix.as_slice(),
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(plaintext) => zeroize::Zeroizing::new(plaintext),
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_authentication_failed",
|
||||
"native wallet password or authenticated data is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
if plaintext.len() != crate::SOLANA_KEYPAIR_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_keypair_length_invalid",
|
||||
"decrypted native wallet keypair length is invalid",
|
||||
));
|
||||
}
|
||||
let keypair = match solana_keypair::Keypair::try_from(plaintext.as_slice()) {
|
||||
std::result::Result::Ok(keypair) => keypair,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_keypair_invalid",
|
||||
"decrypted native wallet keypair is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
if keypair.pubkey() != container.public_key {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_public_key_mismatch",
|
||||
"decrypted native wallet public key does not match the authenticated header",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(keypair);
|
||||
}
|
||||
|
||||
fn derive_native_wallet_key(
|
||||
password: &[u8],
|
||||
salt: &[u8; crate::KSWALLET_SALT_LENGTH],
|
||||
parameters: NativeWalletKdfParameters,
|
||||
) -> ks_core::Result<zeroize::Zeroizing<[u8; crate::KSWALLET_AEAD_KEY_LENGTH]>> {
|
||||
match validate_kdf_parameters(¶meters) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let params = match argon2::Params::new(
|
||||
parameters.memory_kib,
|
||||
parameters.iterations,
|
||||
parameters.parallelism,
|
||||
std::option::Option::Some(crate::KSWALLET_AEAD_KEY_LENGTH),
|
||||
) {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_parameters_invalid",
|
||||
"native wallet Argon2id parameters are invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = zeroize::Zeroizing::new([0_u8; crate::KSWALLET_AEAD_KEY_LENGTH]);
|
||||
if argon2.hash_password_into(password, salt, &mut *key).is_err() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_failed",
|
||||
"native wallet Argon2id derivation failed",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(key);
|
||||
}
|
||||
|
||||
fn encode_authenticated_prefix(
|
||||
alias: &crate::WalletAlias,
|
||||
public_key: &solana_pubkey::Pubkey,
|
||||
kdf_parameters: NativeWalletKdfParameters,
|
||||
salt: &[u8; crate::KSWALLET_SALT_LENGTH],
|
||||
nonce: &[u8; crate::KSWALLET_NONCE_LENGTH],
|
||||
) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
|
||||
match validate_kdf_parameters(&kdf_parameters) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let alias_bytes = alias.as_str().as_bytes();
|
||||
let header_length = match crate::KSWALLET_FIXED_HEADER_LENGTH.checked_add(alias_bytes.len()) {
|
||||
std::option::Option::Some(length) => length,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_header_length_invalid",
|
||||
"native wallet header length overflowed the platform size",
|
||||
));
|
||||
},
|
||||
};
|
||||
let header_length_u16 = match u16::try_from(header_length) {
|
||||
std::result::Result::Ok(length) => length,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_header_length_invalid",
|
||||
"native wallet header length cannot be encoded",
|
||||
));
|
||||
},
|
||||
};
|
||||
let alias_length = match u8::try_from(alias_bytes.len()) {
|
||||
std::result::Result::Ok(length) => length,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_length_invalid",
|
||||
"native wallet alias length cannot be encoded",
|
||||
));
|
||||
},
|
||||
};
|
||||
let prefix_length = header_length + crate::KSWALLET_SALT_LENGTH + crate::KSWALLET_NONCE_LENGTH;
|
||||
let mut bytes = zeroize::Zeroizing::new(vec![0_u8; prefix_length]);
|
||||
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes[OFFSET_FORMAT_VERSION..OFFSET_FORMAT_VERSION + 2]
|
||||
.copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
bytes[OFFSET_HEADER_LENGTH..OFFSET_HEADER_LENGTH + 2]
|
||||
.copy_from_slice(&header_length_u16.to_le_bytes());
|
||||
bytes[OFFSET_FLAGS..OFFSET_FLAGS + 2]
|
||||
.copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
||||
bytes[OFFSET_KDF_ID] = crate::KSWALLET_KDF_ARGON2ID;
|
||||
bytes[OFFSET_KDF_VERSION] = crate::KSWALLET_ARGON2_VERSION;
|
||||
bytes[OFFSET_AEAD_ID] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
bytes[OFFSET_SALT_LENGTH] = crate::KSWALLET_SALT_LENGTH as u8;
|
||||
bytes[OFFSET_NONCE_LENGTH] = crate::KSWALLET_NONCE_LENGTH as u8;
|
||||
bytes[OFFSET_ALIAS_LENGTH] = alias_length;
|
||||
bytes[OFFSET_MEMORY_KIB..OFFSET_MEMORY_KIB + 4]
|
||||
.copy_from_slice(&kdf_parameters.memory_kib.to_le_bytes());
|
||||
bytes[OFFSET_ITERATIONS..OFFSET_ITERATIONS + 4]
|
||||
.copy_from_slice(&kdf_parameters.iterations.to_le_bytes());
|
||||
bytes[OFFSET_PARALLELISM..OFFSET_PARALLELISM + 4]
|
||||
.copy_from_slice(&kdf_parameters.parallelism.to_le_bytes());
|
||||
bytes[OFFSET_CIPHERTEXT_LENGTH..OFFSET_CIPHERTEXT_LENGTH + 4]
|
||||
.copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
||||
bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]
|
||||
.copy_from_slice(&public_key.to_bytes());
|
||||
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
|
||||
let salt_offset = header_length;
|
||||
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
||||
bytes[salt_offset..nonce_offset].copy_from_slice(salt);
|
||||
bytes[nonce_offset..].copy_from_slice(nonce);
|
||||
return std::result::Result::Ok(bytes);
|
||||
}
|
||||
|
||||
fn write_native_wallet_file_atomic_blocking(
|
||||
path: std::path::PathBuf,
|
||||
alias: crate::WalletAlias,
|
||||
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
|
||||
) -> ks_core::Result<()> {
|
||||
match validate_native_destination(&path, &alias) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let directory = match path.parent() {
|
||||
std::option::Option::Some(directory) => directory,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_parent_missing",
|
||||
"native wallet destination must have a parent directory",
|
||||
));
|
||||
},
|
||||
};
|
||||
match prepare_native_wallet_directory(directory) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
if path.exists() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_already_exists",
|
||||
"native wallet already exists for this alias",
|
||||
));
|
||||
}
|
||||
let (temporary_path, mut file) = match create_private_temp_file(directory, &path) {
|
||||
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(io_error("wallet_native_temp_write_failed", error));
|
||||
}
|
||||
if let std::result::Result::Err(error) = file.sync_all() {
|
||||
let _ = std::fs::remove_file(&temporary_path);
|
||||
return std::result::Result::Err(io_error("wallet_native_temp_sync_failed", error));
|
||||
}
|
||||
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_native_already_exists",
|
||||
"native wallet already exists for this alias",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Err(io_error("wallet_native_publish_failed", error));
|
||||
}
|
||||
if let std::result::Result::Err(error) = sync_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(io_error("wallet_native_temp_remove_failed", error));
|
||||
}
|
||||
return sync_directory(directory);
|
||||
}
|
||||
|
||||
fn replace_native_wallet_file_atomic_blocking(
|
||||
path: std::path::PathBuf,
|
||||
alias: crate::WalletAlias,
|
||||
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
|
||||
) -> ks_core::Result<()> {
|
||||
match validate_native_destination(&path, &alias) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let directory = match path.parent() {
|
||||
std::option::Option::Some(directory) => directory,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_parent_missing",
|
||||
"native wallet destination must have a parent directory",
|
||||
));
|
||||
},
|
||||
};
|
||||
match prepare_native_wallet_directory(directory) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_native_wallet_file_metadata_blocking(&path) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let (temporary_path, mut file) = match create_private_temp_file(directory, &path) {
|
||||
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(io_error("wallet_native_temp_write_failed", error));
|
||||
}
|
||||
if let std::result::Result::Err(error) = file.sync_all() {
|
||||
let _ = std::fs::remove_file(&temporary_path);
|
||||
return std::result::Result::Err(io_error("wallet_native_temp_sync_failed", error));
|
||||
}
|
||||
std::mem::drop(file);
|
||||
if let std::result::Result::Err(error) = std::fs::rename(&temporary_path, &path) {
|
||||
let _ = std::fs::remove_file(&temporary_path);
|
||||
return std::result::Result::Err(io_error("wallet_native_replace_failed", error));
|
||||
}
|
||||
return sync_directory(directory);
|
||||
}
|
||||
|
||||
fn create_private_temp_file(
|
||||
directory: &std::path::Path,
|
||||
destination: &std::path::Path,
|
||||
) -> ks_core::Result<(std::path::PathBuf, std::fs::File)> {
|
||||
let file_name = match destination.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||
std::option::Option::Some(file_name) => file_name,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_filename_invalid",
|
||||
"native wallet destination filename is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
for _ in 0..TEMP_CREATE_ATTEMPTS {
|
||||
let sequence = NATIVE_TEMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let temporary_path =
|
||||
directory.join(format!(".{file_name}.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(io_error(
|
||||
"wallet_native_temp_create_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_temp_create_failed",
|
||||
"native wallet temporary file name could not be reserved",
|
||||
));
|
||||
}
|
||||
|
||||
fn prepare_native_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> {
|
||||
if directory.exists() {
|
||||
let metadata = match std::fs::symlink_metadata(directory) {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error(
|
||||
"wallet_directory_metadata_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_directory_type_invalid",
|
||||
"wallet directory must be a directory and not a symlink",
|
||||
));
|
||||
}
|
||||
return validate_private_directory_permissions(&metadata);
|
||||
}
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt; // rust-rules: trait-import
|
||||
builder.mode(0o700);
|
||||
}
|
||||
if let std::result::Result::Err(error) = builder.create(directory) {
|
||||
return std::result::Result::Err(io_error("wallet_directory_create_failed", error));
|
||||
}
|
||||
let metadata = match std::fs::symlink_metadata(directory) {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_directory_metadata_failed", error));
|
||||
},
|
||||
};
|
||||
return validate_private_directory_permissions(&metadata);
|
||||
}
|
||||
|
||||
fn validate_private_directory_permissions(metadata: &std::fs::Metadata) -> ks_core::Result<()> {
|
||||
#[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_directory_permissions_too_open",
|
||||
format!("wallet directory has mode {mode:o}; expected no group or other access"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_native_destination(
|
||||
path: &std::path::Path,
|
||||
alias: &crate::WalletAlias,
|
||||
) -> ks_core::Result<()> {
|
||||
if path.extension()
|
||||
!= std::option::Option::Some(std::ffi::OsStr::new(crate::KSWALLET_FILE_EXTENSION))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_extension_invalid",
|
||||
"native wallet file must use the .kswallet extension",
|
||||
));
|
||||
}
|
||||
let stem = match path.file_stem().and_then(std::ffi::OsStr::to_str) {
|
||||
std::option::Option::Some(stem) => stem,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_encoding_invalid",
|
||||
"native wallet filename must contain a UTF-8 wallet alias",
|
||||
));
|
||||
},
|
||||
};
|
||||
if stem != alias.as_str() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_mismatch",
|
||||
"native wallet filename alias does not match the container alias",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_native_wallet_file_metadata_blocking(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
let metadata = match std::fs::symlink_metadata(path) {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_file_metadata_failed", error));
|
||||
},
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_type_invalid",
|
||||
"wallet file 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_file_permissions_too_open",
|
||||
format!("wallet file has mode {mode:o}; expected no group or other access"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn sync_directory(directory: &std::path::Path) -> ks_core::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let file = match std::fs::File::open(directory) {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error(
|
||||
"wallet_directory_sync_open_failed",
|
||||
error,
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = file.sync_all() {
|
||||
return std::result::Result::Err(io_error("wallet_directory_sync_failed", error));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
if bytes.len() < crate::KSWALLET_MIN_FILE_LENGTH
|
||||
|| bytes.len() > crate::KSWALLET_MAX_FILE_LENGTH
|
||||
@@ -268,9 +929,21 @@ fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::Native
|
||||
let mut public_key_bytes = [0_u8; PUBLIC_KEY_LENGTH];
|
||||
public_key_bytes
|
||||
.copy_from_slice(&bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]);
|
||||
let salt_offset = header_length;
|
||||
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
||||
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
||||
let mut salt = [0_u8; crate::KSWALLET_SALT_LENGTH];
|
||||
salt.copy_from_slice(&bytes[salt_offset..nonce_offset]);
|
||||
let mut nonce = [0_u8; crate::KSWALLET_NONCE_LENGTH];
|
||||
nonce.copy_from_slice(&bytes[nonce_offset..ciphertext_offset]);
|
||||
let ciphertext = zeroize::Zeroizing::new(bytes[ciphertext_offset..].to_vec());
|
||||
return std::result::Result::Ok(crate::NativeWalletContainer {
|
||||
alias,
|
||||
public_key: solana_pubkey::Pubkey::new_from_array(public_key_bytes),
|
||||
kdf_parameters,
|
||||
salt,
|
||||
nonce,
|
||||
ciphertext,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
64
ks-wallet/src/password.rs
Normal file
64
ks-wallet/src/password.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
// file: ks-wallet/src/password.rs
|
||||
// version: 3
|
||||
|
||||
//! Non-clonable password boundary for native wallet protection.
|
||||
|
||||
const MAX_PASSWORD_LENGTH: usize = 1_024;
|
||||
|
||||
/// Password supplied explicitly for one native wallet operation.
|
||||
///
|
||||
/// The value is owned by a zeroizing wrapper, is neither clonable nor
|
||||
/// serializable, and its `Debug` representation never exposes the password.
|
||||
pub struct WalletPassword {
|
||||
value: zeroize::Zeroizing<std::string::String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WalletPassword {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("WalletPassword([REDACTED])");
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WalletPassword {
|
||||
/// Takes ownership of a password string for one explicit wallet operation.
|
||||
pub fn new(value: std::string::String) -> ks_core::Result<Self> {
|
||||
let value = zeroize::Zeroizing::new(value);
|
||||
let length = value.len();
|
||||
if length == 0 || length > MAX_PASSWORD_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_password_length_invalid",
|
||||
format!("wallet password length must be between 1 and {MAX_PASSWORD_LENGTH} bytes"),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(Self { value });
|
||||
}
|
||||
|
||||
/// Returns the password bytes only inside the `ks-wallet` boundary.
|
||||
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||
return self.value.as_bytes();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn debug_never_contains_password() {
|
||||
let password = crate::WalletPassword::new("canary-wallet-password".to_owned())
|
||||
.unwrap_or_else(|error| panic!("password must be accepted: {error}"));
|
||||
let debug = format!("{password:?}");
|
||||
assert!(!debug.contains("canary-wallet-password"));
|
||||
assert!(debug.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_length_is_bounded() {
|
||||
let empty = crate::WalletPassword::new(std::string::String::new())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("empty password must fail"));
|
||||
assert_eq!(empty.code(), "wallet_password_length_invalid");
|
||||
let oversized = crate::WalletPassword::new("x".repeat(1_025))
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("oversized password must fail"));
|
||||
assert_eq!(oversized.code(), "wallet_password_length_invalid");
|
||||
}
|
||||
}
|
||||
108
ks-wallet/src/unlocked.rs
Normal file
108
ks-wallet/src/unlocked.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// file: ks-wallet/src/unlocked.rs
|
||||
// version: 2
|
||||
|
||||
//! Authenticated signing capability for one unlocked native wallet.
|
||||
|
||||
use solana_signer::Signer; // rust-rules: trait-import
|
||||
|
||||
/// Authenticated native wallet capability holding one Solana keypair in memory.
|
||||
///
|
||||
/// The type is intentionally non-clonable and does not expose private bytes.
|
||||
/// Dropping or explicitly locking the value releases the in-memory keypair.
|
||||
pub struct UnlockedWallet {
|
||||
alias: crate::WalletAlias,
|
||||
keypair: solana_keypair::Keypair,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::UnlockedWallet {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("UnlockedWallet")
|
||||
.field("alias", &self.alias)
|
||||
.field("public_key", &self.public_key())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::UnlockedWallet {
|
||||
/// Builds an authenticated signing capability from an already validated keypair.
|
||||
pub(crate) fn new(alias: crate::WalletAlias, keypair: solana_keypair::Keypair) -> Self {
|
||||
return Self { alias, keypair };
|
||||
}
|
||||
|
||||
/// Returns the wallet alias.
|
||||
pub fn alias(&self) -> &crate::WalletAlias {
|
||||
return &self.alias;
|
||||
}
|
||||
|
||||
/// Returns the wallet public key in base58 form.
|
||||
pub fn public_key(&self) -> std::string::String {
|
||||
return self.keypair.pubkey().to_string();
|
||||
}
|
||||
|
||||
/// Returns the authenticated non-secret wallet identity.
|
||||
pub fn identity(&self) -> crate::WalletIdentity {
|
||||
return crate::WalletIdentity {
|
||||
alias: self.alias.clone(),
|
||||
public_key: self.public_key(),
|
||||
persistence: crate::WalletPersistence::Persistent,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the signer interface without exposing keypair bytes.
|
||||
pub fn as_signer(&self) -> &dyn solana_signer::Signer {
|
||||
return &self.keypair;
|
||||
}
|
||||
|
||||
/// Returns the signer interface with the `Sync` auto-trait preserved.
|
||||
pub fn as_sync_signer(&self) -> &(dyn solana_signer::Signer + std::marker::Sync) {
|
||||
return &self.keypair;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
std::result::Result::Ok(signature) => signature,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_message_sign_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "sign_message",
|
||||
wallet_alias = self.alias.as_str(),
|
||||
public_key = %self.keypair.pubkey(),
|
||||
message_length = message.len(),
|
||||
"signed message with authenticated native wallet"
|
||||
);
|
||||
return std::result::Result::Ok(signature.to_string());
|
||||
}
|
||||
|
||||
/// Explicitly consumes and locks this signing capability.
|
||||
pub fn lock(self) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn assert_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
|
||||
#[test]
|
||||
fn authenticated_wallet_is_send_and_sync() {
|
||||
assert_send_sync::<crate::UnlockedWallet>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_exposes_only_non_secret_identity() {
|
||||
let alias = crate::WalletAlias::parse("unlocked-debug")
|
||||
.unwrap_or_else(|error| panic!("alias must be valid: {error}"));
|
||||
let wallet = crate::UnlockedWallet::new(alias, solana_keypair::Keypair::new());
|
||||
let debug = format!("{wallet:?}");
|
||||
assert!(debug.contains("unlocked-debug"));
|
||||
assert!(!debug.contains("keypair"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user