// 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>, } 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>> { return encode_authenticated_prefix( &self.alias, &self.public_key, self.kdf_parameters, &self.salt, &self.nonce, ); } fn encode(&self) -> ks_core::Result>> { 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 { 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 { 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 { 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 { 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 { 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> { 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>> { 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>, ) -> 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>, ) -> 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 { 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 { 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])); } }