1226 lines
52 KiB
Rust
1226 lines
52 KiB
Rust
// file: ks-wallet/src/native.rs
|
|
// version: 4
|
|
|
|
//! 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
|
|
|
|
const OFFSET_FORMAT_VERSION: usize = 8;
|
|
const OFFSET_HEADER_LENGTH: usize = 10;
|
|
const OFFSET_FLAGS: usize = 12;
|
|
const OFFSET_KDF_ID: usize = 14;
|
|
const OFFSET_KDF_VERSION: usize = 15;
|
|
const OFFSET_AEAD_ID: usize = 16;
|
|
const OFFSET_SALT_LENGTH: usize = 17;
|
|
const OFFSET_NONCE_LENGTH: usize = 18;
|
|
const OFFSET_ALIAS_LENGTH: usize = 19;
|
|
const OFFSET_RESERVED: usize = 20;
|
|
const OFFSET_MEMORY_KIB: usize = 24;
|
|
const OFFSET_ITERATIONS: usize = 28;
|
|
const OFFSET_PARALLELISM: usize = 32;
|
|
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,
|
|
}
|
|
|
|
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 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 {
|
|
/// Returns the validated logical alias declared by the native header.
|
|
pub(crate) fn alias(&self) -> &crate::WalletAlias {
|
|
return &self.alias;
|
|
}
|
|
|
|
/// Returns the public key declared by the native header before authenticated unlock.
|
|
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.
|
|
pub(crate) async fn read_native_wallet_container(
|
|
path: &std::path::Path,
|
|
) -> ks_core::Result<crate::NativeWalletContainer> {
|
|
match validate_native_wallet_file_metadata(path).await {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let mut file = match tokio::fs::File::open(path).await {
|
|
std::result::Result::Ok(file) => file,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(io_error("wallet_native_open_failed", error));
|
|
},
|
|
};
|
|
let metadata = match file.metadata().await {
|
|
std::result::Result::Ok(metadata) => metadata,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(io_error("wallet_native_metadata_failed", error));
|
|
},
|
|
};
|
|
let file_length = metadata.len();
|
|
if file_length < crate::KSWALLET_MIN_FILE_LENGTH as u64
|
|
|| file_length > crate::KSWALLET_MAX_FILE_LENGTH as u64
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_file_length_invalid",
|
|
format!(
|
|
"native wallet file length must be between {} and {} bytes",
|
|
crate::KSWALLET_MIN_FILE_LENGTH,
|
|
crate::KSWALLET_MAX_FILE_LENGTH
|
|
),
|
|
));
|
|
}
|
|
let length = match usize::try_from(file_length) {
|
|
std::result::Result::Ok(length) => length,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_file_length_invalid",
|
|
"native wallet file length cannot be represented on this platform",
|
|
));
|
|
},
|
|
};
|
|
let mut bytes = vec![0_u8; length];
|
|
if let std::result::Result::Err(error) = file.read_exact(bytes.as_mut_slice()).await {
|
|
bytes.zeroize();
|
|
return std::result::Result::Err(io_error("wallet_native_read_failed", error));
|
|
}
|
|
let mut trailing = [0_u8; 1];
|
|
match file.read(trailing.as_mut_slice()).await {
|
|
std::result::Result::Ok(0) => {},
|
|
std::result::Result::Ok(_) => {
|
|
bytes.zeroize();
|
|
trailing.zeroize();
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_file_length_changed",
|
|
"native wallet file changed while it was being read",
|
|
));
|
|
},
|
|
std::result::Result::Err(error) => {
|
|
bytes.zeroize();
|
|
trailing.zeroize();
|
|
return std::result::Result::Err(io_error("wallet_native_read_failed", error));
|
|
},
|
|
}
|
|
trailing.zeroize();
|
|
let decoded = decode_native_wallet_container(bytes.as_slice());
|
|
bytes.zeroize();
|
|
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
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_file_length_invalid",
|
|
"native wallet file length is outside the supported version-one bound",
|
|
));
|
|
}
|
|
if bytes[0..crate::KSWALLET_MAGIC.len()] != crate::KSWALLET_MAGIC[..] {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_magic_invalid",
|
|
"native wallet magic is invalid",
|
|
));
|
|
}
|
|
let format_version = read_u16(bytes, OFFSET_FORMAT_VERSION);
|
|
if format_version != crate::KSWALLET_FORMAT_VERSION {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_version_unsupported",
|
|
"native wallet format version is unsupported",
|
|
));
|
|
}
|
|
let alias_length = bytes[OFFSET_ALIAS_LENGTH] as usize;
|
|
if alias_length == 0 || alias_length > crate::KSWALLET_MAX_ALIAS_LENGTH {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_length_invalid",
|
|
"native wallet alias length is outside the supported bound",
|
|
));
|
|
}
|
|
let header_length = read_u16(bytes, OFFSET_HEADER_LENGTH) as usize;
|
|
let expected_header_length = match crate::KSWALLET_FIXED_HEADER_LENGTH.checked_add(alias_length)
|
|
{
|
|
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",
|
|
));
|
|
},
|
|
};
|
|
if header_length != expected_header_length {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_header_length_invalid",
|
|
"native wallet header length does not match the encoded alias length",
|
|
));
|
|
}
|
|
if read_u16(bytes, OFFSET_FLAGS) != crate::KSWALLET_FLAGS_NONE {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_flags_unsupported",
|
|
"native wallet flags are unsupported for format version one",
|
|
));
|
|
}
|
|
if bytes[OFFSET_KDF_ID] != crate::KSWALLET_KDF_ARGON2ID {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_unsupported",
|
|
"native wallet KDF identifier is unsupported",
|
|
));
|
|
}
|
|
if bytes[OFFSET_KDF_VERSION] != crate::KSWALLET_ARGON2_VERSION {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_version_unsupported",
|
|
"native wallet Argon2 version is unsupported",
|
|
));
|
|
}
|
|
if bytes[OFFSET_AEAD_ID] != crate::KSWALLET_AEAD_XCHACHA20_POLY1305 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_aead_unsupported",
|
|
"native wallet AEAD identifier is unsupported",
|
|
));
|
|
}
|
|
if bytes[OFFSET_SALT_LENGTH] as usize != crate::KSWALLET_SALT_LENGTH {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_salt_length_invalid",
|
|
"native wallet salt length is invalid",
|
|
));
|
|
}
|
|
if bytes[OFFSET_NONCE_LENGTH] as usize != crate::KSWALLET_NONCE_LENGTH {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_nonce_length_invalid",
|
|
"native wallet nonce length is invalid",
|
|
));
|
|
}
|
|
if bytes[OFFSET_RESERVED..OFFSET_RESERVED + RESERVED_LENGTH]
|
|
.iter()
|
|
.any(|byte| return *byte != 0)
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_reserved_nonzero",
|
|
"native wallet reserved header bytes must be zero",
|
|
));
|
|
}
|
|
let kdf_parameters = NativeWalletKdfParameters {
|
|
memory_kib: read_u32(bytes, OFFSET_MEMORY_KIB),
|
|
iterations: read_u32(bytes, OFFSET_ITERATIONS),
|
|
parallelism: read_u32(bytes, OFFSET_PARALLELISM),
|
|
};
|
|
match validate_kdf_parameters(&kdf_parameters) {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let ciphertext_length = read_u32(bytes, OFFSET_CIPHERTEXT_LENGTH) as usize;
|
|
match validate_ciphertext_length(ciphertext_length) {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let expected_length = match header_length
|
|
.checked_add(crate::KSWALLET_SALT_LENGTH)
|
|
.and_then(|length| return length.checked_add(crate::KSWALLET_NONCE_LENGTH))
|
|
.and_then(|length| return length.checked_add(ciphertext_length))
|
|
{
|
|
std::option::Option::Some(length) => length,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_file_length_invalid",
|
|
"native wallet lengths overflow the platform size",
|
|
));
|
|
},
|
|
};
|
|
if expected_length != bytes.len() {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_file_length_mismatch",
|
|
"native wallet declared lengths do not match the file length",
|
|
));
|
|
}
|
|
let alias_bytes = &bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length];
|
|
let alias_text = match std::str::from_utf8(alias_bytes) {
|
|
std::result::Result::Ok(alias) => alias,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_encoding_invalid",
|
|
"native wallet alias must be valid ASCII-compatible UTF-8",
|
|
));
|
|
},
|
|
};
|
|
let alias = match crate::WalletAlias::parse(alias_text) {
|
|
std::result::Result::Ok(alias) => alias,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_invalid",
|
|
"native wallet header contains an invalid wallet alias",
|
|
));
|
|
},
|
|
};
|
|
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,
|
|
});
|
|
}
|
|
|
|
fn validate_kdf_parameters(parameters: &NativeWalletKdfParameters) -> ks_core::Result<()> {
|
|
if parameters.memory_kib < crate::KSWALLET_ARGON2_MIN_MEMORY_KIB
|
|
|| parameters.memory_kib > crate::KSWALLET_ARGON2_MAX_MEMORY_KIB
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_memory_invalid",
|
|
"native wallet Argon2 memory parameter is outside the supported bound",
|
|
));
|
|
}
|
|
if parameters.iterations < crate::KSWALLET_ARGON2_MIN_ITERATIONS
|
|
|| parameters.iterations > crate::KSWALLET_ARGON2_MAX_ITERATIONS
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_iterations_invalid",
|
|
"native wallet Argon2 iteration parameter is outside the supported bound",
|
|
));
|
|
}
|
|
if parameters.parallelism < crate::KSWALLET_ARGON2_MIN_PARALLELISM
|
|
|| parameters.parallelism > crate::KSWALLET_ARGON2_MAX_PARALLELISM
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_parallelism_invalid",
|
|
"native wallet Argon2 parallelism parameter is outside the supported bound",
|
|
));
|
|
}
|
|
let minimum_memory = match 8_u32.checked_mul(parameters.parallelism) {
|
|
std::option::Option::Some(memory) => memory,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_memory_invalid",
|
|
"native wallet Argon2 memory relation is invalid",
|
|
));
|
|
},
|
|
};
|
|
let alignment = match 4_u32.checked_mul(parameters.parallelism) {
|
|
std::option::Option::Some(alignment) => alignment,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_memory_invalid",
|
|
"native wallet Argon2 memory alignment is invalid",
|
|
));
|
|
},
|
|
};
|
|
if parameters.memory_kib < minimum_memory || parameters.memory_kib % alignment != 0 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_kdf_memory_invalid",
|
|
"native wallet Argon2 memory must satisfy the encoded parallelism relation",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn validate_ciphertext_length(length: usize) -> ks_core::Result<()> {
|
|
if length != crate::KSWALLET_CIPHERTEXT_LENGTH {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_ciphertext_length_invalid",
|
|
format!(
|
|
"native wallet ciphertext length must be exactly {} bytes",
|
|
crate::KSWALLET_CIPHERTEXT_LENGTH
|
|
),
|
|
));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
async fn validate_native_wallet_file_metadata(path: &std::path::Path) -> ks_core::Result<()> {
|
|
let metadata = match tokio::fs::symlink_metadata(path).await {
|
|
std::result::Result::Ok(metadata) => metadata,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(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 io_error(code: &str, error: std::io::Error) -> ks_core::Error {
|
|
return ks_core::Error::new(code, error.to_string());
|
|
}
|
|
|
|
fn read_u16(bytes: &[u8], offset: usize) -> u16 {
|
|
return u16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
|
|
}
|
|
|
|
fn read_u32(bytes: &[u8], offset: usize) -> u32 {
|
|
return u32::from_le_bytes([
|
|
bytes[offset],
|
|
bytes[offset + 1],
|
|
bytes[offset + 2],
|
|
bytes[offset + 3],
|
|
]);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
fn fixture_bytes(alias: &str, marker: u8) -> std::vec::Vec<u8> {
|
|
let parsed_alias = crate::WalletAlias::parse(alias)
|
|
.unwrap_or_else(|error| panic!("fixture alias must be valid: {error}"));
|
|
let alias_bytes = parsed_alias.as_str().as_bytes();
|
|
let header_length = crate::KSWALLET_FIXED_HEADER_LENGTH + alias_bytes.len();
|
|
let total_length = header_length
|
|
+ crate::KSWALLET_SALT_LENGTH
|
|
+ crate::KSWALLET_NONCE_LENGTH
|
|
+ crate::KSWALLET_CIPHERTEXT_LENGTH;
|
|
let mut bytes = vec![0_u8; total_length];
|
|
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
|
bytes[super::OFFSET_FORMAT_VERSION..super::OFFSET_FORMAT_VERSION + 2]
|
|
.copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
|
bytes[super::OFFSET_HEADER_LENGTH..super::OFFSET_HEADER_LENGTH + 2]
|
|
.copy_from_slice(&(header_length as u16).to_le_bytes());
|
|
bytes[super::OFFSET_FLAGS..super::OFFSET_FLAGS + 2]
|
|
.copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
|
bytes[super::OFFSET_KDF_ID] = crate::KSWALLET_KDF_ARGON2ID;
|
|
bytes[super::OFFSET_KDF_VERSION] = crate::KSWALLET_ARGON2_VERSION;
|
|
bytes[super::OFFSET_AEAD_ID] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
|
bytes[super::OFFSET_SALT_LENGTH] = crate::KSWALLET_SALT_LENGTH as u8;
|
|
bytes[super::OFFSET_NONCE_LENGTH] = crate::KSWALLET_NONCE_LENGTH as u8;
|
|
bytes[super::OFFSET_ALIAS_LENGTH] = alias_bytes.len() as u8;
|
|
bytes[super::OFFSET_MEMORY_KIB..super::OFFSET_MEMORY_KIB + 4]
|
|
.copy_from_slice(&65_536_u32.to_le_bytes());
|
|
bytes[super::OFFSET_ITERATIONS..super::OFFSET_ITERATIONS + 4]
|
|
.copy_from_slice(&3_u32.to_le_bytes());
|
|
bytes[super::OFFSET_PARALLELISM..super::OFFSET_PARALLELISM + 4]
|
|
.copy_from_slice(&4_u32.to_le_bytes());
|
|
bytes[super::OFFSET_CIPHERTEXT_LENGTH..super::OFFSET_CIPHERTEXT_LENGTH + 4]
|
|
.copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
|
bytes[super::OFFSET_PUBLIC_KEY..super::OFFSET_PUBLIC_KEY + super::PUBLIC_KEY_LENGTH]
|
|
.copy_from_slice(&[marker; super::PUBLIC_KEY_LENGTH]);
|
|
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;
|
|
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
|
bytes[salt_offset..nonce_offset].fill(marker.wrapping_add(1));
|
|
bytes[nonce_offset..ciphertext_offset].fill(marker.wrapping_add(2));
|
|
bytes[ciphertext_offset..].fill(marker.wrapping_add(3));
|
|
return bytes;
|
|
}
|
|
|
|
fn mutate_u32(bytes: &mut [u8], offset: usize, value: u32) {
|
|
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn write_private_fixture(path: &std::path::Path, bytes: &[u8]) {
|
|
std::fs::write(path, bytes)
|
|
.unwrap_or_else(|error| panic!("fixture write must succeed: {error}"));
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
|
.unwrap_or_else(|error| panic!("fixture permissions must be private: {error}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn version_one_decoder_accepts_exact_header_and_declared_identity() {
|
|
let encoded = fixture_bytes("codec", 7);
|
|
assert_eq!(&encoded[0..8], b"KSWALLET");
|
|
assert_eq!(super::read_u16(encoded.as_slice(), super::OFFSET_FORMAT_VERSION), 1);
|
|
assert_eq!(super::read_u16(encoded.as_slice(), super::OFFSET_HEADER_LENGTH) as usize, 77);
|
|
assert_eq!(encoded[super::OFFSET_KDF_ID], crate::KSWALLET_KDF_ARGON2ID);
|
|
assert_eq!(encoded[super::OFFSET_KDF_VERSION], 0x13);
|
|
assert_eq!(encoded[super::OFFSET_AEAD_ID], crate::KSWALLET_AEAD_XCHACHA20_POLY1305);
|
|
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_MEMORY_KIB), 65_536);
|
|
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_ITERATIONS), 3);
|
|
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_PARALLELISM), 4);
|
|
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_CIPHERTEXT_LENGTH), 80);
|
|
let decoded = super::decode_native_wallet_container(encoded.as_slice())
|
|
.unwrap_or_else(|error| panic!("decoding must succeed: {error}"));
|
|
assert_eq!(decoded.alias().as_str(), "codec");
|
|
assert_eq!(decoded.public_key(), &solana_pubkey::Pubkey::new_from_array([7_u8; 32]));
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_rejects_unknown_algorithms_reserved_bytes_and_versions() {
|
|
let encoded = fixture_bytes("strict", 13);
|
|
for (offset, value, code) in [
|
|
(super::OFFSET_KDF_ID, 99_u8, "wallet_native_kdf_unsupported"),
|
|
(super::OFFSET_KDF_VERSION, 0x12_u8, "wallet_native_kdf_version_unsupported"),
|
|
(super::OFFSET_AEAD_ID, 99_u8, "wallet_native_aead_unsupported"),
|
|
(super::OFFSET_RESERVED, 1_u8, "wallet_native_reserved_nonzero"),
|
|
] {
|
|
let mut candidate = encoded.clone();
|
|
candidate[offset] = value;
|
|
let error = super::decode_native_wallet_container(candidate.as_slice())
|
|
.err()
|
|
.unwrap_or_else(|| panic!("mutation at offset {offset} must fail"));
|
|
assert_eq!(error.code(), code);
|
|
}
|
|
let mut version = encoded.clone();
|
|
version[super::OFFSET_FORMAT_VERSION..super::OFFSET_FORMAT_VERSION + 2]
|
|
.copy_from_slice(&2_u16.to_le_bytes());
|
|
let error = super::decode_native_wallet_container(version.as_slice())
|
|
.err()
|
|
.unwrap_or_else(|| panic!("unknown format version must fail"));
|
|
assert_eq!(error.code(), "wallet_native_version_unsupported");
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_rejects_truncation_and_trailing_bytes() {
|
|
let encoded = fixture_bytes("lengths", 17);
|
|
let truncated = &encoded[..encoded.len() - 1];
|
|
let truncated_error = super::decode_native_wallet_container(truncated)
|
|
.err()
|
|
.unwrap_or_else(|| panic!("truncation must fail"));
|
|
assert_eq!(truncated_error.code(), "wallet_native_file_length_mismatch");
|
|
let mut trailing = encoded.clone();
|
|
trailing.push(0);
|
|
let trailing_error = super::decode_native_wallet_container(trailing.as_slice())
|
|
.err()
|
|
.unwrap_or_else(|| panic!("trailing byte must fail"));
|
|
assert_eq!(trailing_error.code(), "wallet_native_file_length_mismatch");
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_rejects_kdf_parameters_outside_dos_bounds() {
|
|
let encoded = fixture_bytes("kdf", 19);
|
|
for (offset, value, code) in [
|
|
(super::OFFSET_MEMORY_KIB, 32_768_u32, "wallet_native_kdf_memory_invalid"),
|
|
(super::OFFSET_MEMORY_KIB, 524_288_u32, "wallet_native_kdf_memory_invalid"),
|
|
(super::OFFSET_ITERATIONS, 2_u32, "wallet_native_kdf_iterations_invalid"),
|
|
(super::OFFSET_ITERATIONS, 11_u32, "wallet_native_kdf_iterations_invalid"),
|
|
(super::OFFSET_PARALLELISM, 0_u32, "wallet_native_kdf_parallelism_invalid"),
|
|
(super::OFFSET_PARALLELISM, 9_u32, "wallet_native_kdf_parallelism_invalid"),
|
|
] {
|
|
let mut candidate = encoded.clone();
|
|
mutate_u32(candidate.as_mut_slice(), offset, value);
|
|
let error = super::decode_native_wallet_container(candidate.as_slice())
|
|
.err()
|
|
.unwrap_or_else(|| panic!("invalid KDF parameter must fail"));
|
|
assert_eq!(error.code(), code);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_requires_exact_keypair_ciphertext_length() {
|
|
let encoded = fixture_bytes("ciphertext", 21);
|
|
for length in [79_u32, 81_u32] {
|
|
let mut candidate = encoded.clone();
|
|
mutate_u32(candidate.as_mut_slice(), super::OFFSET_CIPHERTEXT_LENGTH, length);
|
|
let error = super::decode_native_wallet_container(candidate.as_slice())
|
|
.err()
|
|
.unwrap_or_else(|| panic!("non-keypair ciphertext length must fail"));
|
|
assert_eq!(error.code(), "wallet_native_ciphertext_length_invalid");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bounded_reader_reopens_private_structurally_valid_file() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
let path = directory.path().join("reopen.kswallet");
|
|
let encoded = fixture_bytes("reopen", 31);
|
|
write_private_fixture(&path, encoded.as_slice());
|
|
let reopened = crate::read_native_wallet_container(&path)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("reopen must succeed: {error}"));
|
|
assert_eq!(reopened.alias().as_str(), "reopen");
|
|
assert_eq!(reopened.public_key(), &solana_pubkey::Pubkey::new_from_array([31_u8; 32]));
|
|
}
|
|
}
|