// file: ks-wallet/src/manager.rs // version: 6 //! Multi-wallet discovery, authenticated opening and password rotation. use solana_signer::Signer; // rust-rules: trait-import /// Opaque reference to one validated native wallet file. /// /// The local path is intentionally kept private and is not included in `Debug`. /// Consumers keep this handle and pass it back to `ks-wallet` for future open /// operations instead of reading the file directly. #[derive(Clone, Eq, PartialEq)] pub struct WalletFileHandle { alias: crate::WalletAlias, public_key: std::string::String, format_version: u16, path: std::path::PathBuf, } impl std::fmt::Debug for crate::WalletFileHandle { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return formatter .debug_struct("WalletFileHandle") .field("alias", &self.alias) .field("public_key", &self.public_key) .field("format_version", &self.format_version) .finish(); } } impl crate::WalletFileHandle { /// Returns the validated alias encoded by the native wallet and filename. pub fn alias(&self) -> &crate::WalletAlias { return &self.alias; } /// Returns the base58 public key declared by the native wallet header before unlock. pub fn public_key(&self) -> &str { return self.public_key.as_str(); } /// Returns the native wallet format version. pub fn format_version(&self) -> u16 { return self.format_version; } /// Returns the non-secret persistent identity declared before authenticated unlock. pub fn identity(&self) -> crate::WalletIdentity { return crate::WalletIdentity { alias: self.alias.clone(), public_key: self.public_key.clone(), persistence: crate::WalletPersistence::Persistent, }; } } /// Multi-wallet manager rooted at one configured wallet directory. /// /// Automatic discovery is limited to this configured directory. A caller may /// also inspect an explicitly selected `.kswallet` file outside the configured /// directory; doing so does not change the manager directory and does not add /// the external file to automatic discovery. pub struct WalletManager { directory: std::path::PathBuf, } impl crate::WalletManager { /// Creates a manager rooted at the supplied configured wallet directory. pub fn new(directory: impl std::convert::Into) -> ks_core::Result { let directory = directory.into(); if directory.as_os_str().is_empty() { return std::result::Result::Err(ks_core::Error::new( "wallet_directory_empty", "wallet directory must not be empty", )); } return std::result::Result::Ok(Self { directory }); } /// Returns the configured directory used for automatic wallet discovery. pub fn directory(&self) -> &std::path::Path { return self.directory.as_path(); } fn wallet_path(&self, alias: &crate::WalletAlias) -> std::path::PathBuf { return self.directory.join(format!( "{}.{}", alias.as_str(), crate::KSWALLET_FILE_EXTENSION )); } /// Discovers structurally valid native wallet containers in the configured directory. /// /// Discovery is non-recursive and ignores unrelated file extensions. Every /// `.kswallet` candidate must be a private regular file whose complete /// version-one container decodes strictly and whose filename alias matches /// the alias encoded by the native header. pub async fn scan(&self) -> ks_core::Result> { let directory_exists = match tokio::fs::try_exists(&self.directory).await { std::result::Result::Ok(exists) => exists, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_directory_exists_check_failed", error.to_string(), )); }, }; if !directory_exists { return std::result::Result::Ok(std::vec::Vec::new()); } match validate_wallet_directory(&self.directory).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let mut entries = match tokio::fs::read_dir(&self.directory).await { std::result::Result::Ok(entries) => entries, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_directory_read_failed", error.to_string(), )); }, }; let mut wallets = std::vec::Vec::new(); loop { let entry = match entries.next_entry().await { std::result::Result::Ok(std::option::Option::Some(entry)) => entry, std::result::Result::Ok(std::option::Option::None) => break, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_directory_entry_read_failed", error.to_string(), )); }, }; let path = entry.path(); if !has_native_wallet_extension(&path) { continue; } let wallet = match inspect_native_wallet_file(path).await { std::result::Result::Ok(wallet) => wallet, std::result::Result::Err(error) => return std::result::Result::Err(error), }; wallets.push(wallet); } wallets.sort_by(|left, right| return left.alias.cmp(&right.alias)); return std::result::Result::Ok(wallets); } /// Looks up one native wallet by alias in the configured directory. pub async fn lookup( &self, alias: &crate::WalletAlias, ) -> ks_core::Result> { let path = self.wallet_path(alias); let exists = match tokio::fs::try_exists(&path).await { std::result::Result::Ok(exists) => exists, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_file_exists_check_failed", error.to_string(), )); }, }; if !exists { return std::result::Result::Ok(std::option::Option::None); } let wallet = match inspect_native_wallet_file(path).await { std::result::Result::Ok(wallet) => wallet, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(std::option::Option::Some(wallet)); } /// Inspects an explicitly selected native wallet file at any local path. /// /// The selected path does not need to be inside the configured discovery /// directory. Successful inspection does not register, copy or move it. pub async fn inspect_file( &self, path: impl std::convert::AsRef, ) -> ks_core::Result { return inspect_native_wallet_file(path.as_ref().to_path_buf()).await; } /// Creates and atomically persists one new password-protected native wallet. pub async fn create( &self, alias: crate::WalletAlias, password: crate::WalletPassword, ) -> ks_core::Result { let path = self.wallet_path(&alias); let exists = match tokio::fs::try_exists(&path).await { std::result::Result::Ok(exists) => exists, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_file_exists_check_failed", error.to_string(), )); }, }; if exists { return std::result::Result::Err(ks_core::Error::new( "wallet_native_already_exists", "native wallet already exists for this alias", )); } let keypair = solana_keypair::Keypair::new(); let public_key = keypair.pubkey(); let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes()); let container = match crate::protect_native_wallet_keypair( alias.clone(), public_key, keypair_bytes, password, ) .await { std::result::Result::Ok(container) => container, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match crate::write_native_wallet_file_atomic(path, &container).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } tracing::info!( target: crate::TRACING_TARGET, action = "create_native_wallet", wallet_alias = alias.as_str(), public_key = %public_key, "created password-protected native wallet" ); return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair)); } /// Authenticates and unlocks one native wallet from the configured directory. pub async fn unlock( &self, alias: &crate::WalletAlias, password: crate::WalletPassword, ) -> ks_core::Result { let path = self.wallet_path(alias); let exists = match tokio::fs::try_exists(&path).await { std::result::Result::Ok(exists) => exists, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_file_exists_check_failed", error.to_string(), )); }, }; if !exists { return std::result::Result::Err(ks_core::Error::new( "wallet_native_not_found", "native wallet was not found for this alias", )); } return unlock_native_wallet_at_path( path, std::option::Option::Some(alias), std::option::Option::None, password, ) .await; } /// Authenticates an explicitly selected native wallet file outside or inside the store. /// /// The opaque handle is revalidated against the file before any signing /// capability is returned. The file is never registered in the configured store. pub async fn unlock_file( &self, handle: &crate::WalletFileHandle, password: crate::WalletPassword, ) -> ks_core::Result { return unlock_native_wallet_at_path( handle.path.clone(), std::option::Option::Some(&handle.alias), std::option::Option::Some(handle), password, ) .await; } /// Changes the protection password while preserving the exact keypair and public key. pub async fn change_password( &self, alias: &crate::WalletAlias, current_password: crate::WalletPassword, new_password: crate::WalletPassword, ) -> ks_core::Result { let path = self.wallet_path(alias); let container = match crate::read_native_wallet_container(&path).await { std::result::Result::Ok(container) => container, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if container.alias() != alias { return std::result::Result::Err(ks_core::Error::new( "wallet_native_alias_mismatch", "native wallet filename alias does not match the container alias", )); } let public_key = *container.public_key(); let keypair = match crate::unlock_native_wallet_keypair(container, current_password).await { std::result::Result::Ok(keypair) => keypair, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes()); let replacement = match crate::protect_native_wallet_keypair( alias.clone(), public_key, keypair_bytes, new_password, ) .await { std::result::Result::Ok(container) => container, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match crate::replace_native_wallet_file_atomic(path.clone(), &replacement).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } tracing::info!( target: crate::TRACING_TARGET, action = "change_native_wallet_password", wallet_alias = alias.as_str(), public_key = %public_key, "changed native wallet protection password" ); return std::result::Result::Ok(crate::WalletFileHandle { alias: alias.clone(), public_key: public_key.to_string(), format_version: crate::KSWALLET_FORMAT_VERSION, path, }); } } async fn unlock_native_wallet_at_path( path: std::path::PathBuf, expected_alias: std::option::Option<&crate::WalletAlias>, expected_handle: std::option::Option<&crate::WalletFileHandle>, password: crate::WalletPassword, ) -> ks_core::Result { let container = match crate::read_native_wallet_container(&path).await { std::result::Result::Ok(container) => container, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if let std::option::Option::Some(alias) = expected_alias { if container.alias() != alias { return std::result::Result::Err(ks_core::Error::new( "wallet_native_alias_mismatch", "native wallet alias does not match the requested identity", )); } } if let std::option::Option::Some(handle) = expected_handle { if handle.format_version != crate::KSWALLET_FORMAT_VERSION || container.alias() != &handle.alias || container.public_key().to_string() != handle.public_key { return std::result::Result::Err(ks_core::Error::new( "wallet_native_handle_stale", "native wallet file no longer matches the inspected handle", )); } } let alias = container.alias().clone(); let public_key = *container.public_key(); let keypair = match crate::unlock_native_wallet_keypair(container, password).await { std::result::Result::Ok(keypair) => keypair, std::result::Result::Err(error) => return std::result::Result::Err(error), }; tracing::debug!( target: crate::TRACING_TARGET, action = "unlock_native_wallet", wallet_alias = alias.as_str(), public_key = %public_key, "authenticated native wallet" ); return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair)); } fn has_native_wallet_extension(path: &std::path::Path) -> bool { return path.extension() == std::option::Option::Some(std::ffi::OsStr::new(crate::KSWALLET_FILE_EXTENSION)); } async fn inspect_native_wallet_file( path: std::path::PathBuf, ) -> ks_core::Result { if !has_native_wallet_extension(&path) { 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", )); }, }; let filename_alias = match crate::WalletAlias::parse(stem) { 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 filename contains an invalid wallet alias", )); }, }; match validate_native_wallet_file_metadata(&path).await { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let container = match crate::read_native_wallet_container(&path).await { std::result::Result::Ok(container) => container, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if container.alias() != &filename_alias { 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(crate::WalletFileHandle { alias: filename_alias, public_key: container.public_key().to_string(), format_version: crate::KSWALLET_FORMAT_VERSION, path, }); } async fn validate_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> { let metadata = match tokio::fs::symlink_metadata(directory).await { std::result::Result::Ok(metadata) => metadata, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "wallet_directory_metadata_failed", error.to_string(), )); }, }; if metadata.file_type().is_symlink() || !metadata.is_dir() { return std::result::Result::Err(ks_core::Error::new( "wallet_directory_type_invalid", "wallet directory must be a directory 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_directory_permissions_too_open", format!("wallet directory has mode {mode:o}; expected no group or other access"), )); } } 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(ks_core::Error::new( "wallet_file_metadata_failed", error.to_string(), )); }, }; 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(()); } #[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[8..10].copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes()); bytes[10..12].copy_from_slice(&(header_length as u16).to_le_bytes()); bytes[12..14].copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes()); bytes[14] = crate::KSWALLET_KDF_ARGON2ID; bytes[15] = crate::KSWALLET_ARGON2_VERSION; bytes[16] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305; bytes[17] = crate::KSWALLET_SALT_LENGTH as u8; bytes[18] = crate::KSWALLET_NONCE_LENGTH as u8; bytes[19] = alias_bytes.len() as u8; bytes[24..28].copy_from_slice(&65_536_u32.to_le_bytes()); bytes[28..32].copy_from_slice(&3_u32.to_le_bytes()); bytes[32..36].copy_from_slice(&4_u32.to_le_bytes()); bytes[36..40].copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes()); bytes[40..72].copy_from_slice(&[marker; 32]); 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 write_native_wallet(path: &std::path::Path, alias: &str, marker: u8) { let bytes = fixture_bytes(alias, marker); std::fs::write(path, bytes) .unwrap_or_else(|error| panic!("native fixture must be writable: {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!("native fixture permissions must be set: {error}")); } } fn make_directory_private(path: &std::path::Path) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap_or_else( |error| panic!("wallet fixture directory permissions must be set: {error}"), ); } } #[tokio::test] async fn scan_is_non_recursive_and_filters_by_native_extension() { let directory = tempfile::tempdir() .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); make_directory_private(directory.path()); let manager = crate::WalletManager::new(directory.path()) .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); write_native_wallet(&directory.path().join("alpha.kswallet"), "alpha", 3); std::fs::write(directory.path().join("legacy.json"), b"[]") .unwrap_or_else(|error| panic!("unrelated fixture must be writable: {error}")); let nested = directory.path().join("nested"); std::fs::create_dir(&nested) .unwrap_or_else(|error| panic!("nested directory must be created: {error}")); write_native_wallet(&nested.join("hidden.kswallet"), "hidden", 5); let wallets = manager .scan() .await .unwrap_or_else(|error| panic!("scan must succeed: {error}")); assert_eq!(wallets.len(), 1); assert_eq!(wallets[0].alias().as_str(), "alpha"); assert_eq!(wallets[0].format_version(), 1); assert_eq!(wallets[0].identity().persistence, crate::WalletPersistence::Persistent); } #[tokio::test] async fn lookup_uses_configured_directory_and_returns_opaque_handle() { let directory = tempfile::tempdir() .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); let manager = crate::WalletManager::new(directory.path()) .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); let alias = crate::WalletAlias::parse("lookup") .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); write_native_wallet(&manager.wallet_path(&alias), "lookup", 7); let handle = manager .lookup(&alias) .await .unwrap_or_else(|error| panic!("lookup must succeed: {error}")) .unwrap_or_else(|| panic!("wallet handle must exist")); assert_eq!(handle.alias(), &alias); assert_eq!(handle.path, manager.wallet_path(&alias)); assert_eq!(handle.public_key(), handle.identity().public_key); assert!(!format!("{handle:?}").contains(directory.path().to_string_lossy().as_ref())); } #[tokio::test] async fn explicit_file_inspection_accepts_path_outside_configured_directory() { let configured = tempfile::tempdir() .unwrap_or_else(|error| panic!("configured directory must exist: {error}")); make_directory_private(configured.path()); let external = tempfile::tempdir() .unwrap_or_else(|error| panic!("external directory must exist: {error}")); let manager = crate::WalletManager::new(configured.path()) .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); let external_path = external.path().join("external.kswallet"); write_native_wallet(&external_path, "external", 9); let handle = manager .inspect_file(&external_path) .await .unwrap_or_else(|error| panic!("explicit inspection must succeed: {error}")); assert_eq!(handle.alias().as_str(), "external"); assert_eq!(handle.path, external_path); assert!(!handle.public_key().is_empty()); let discovered = manager .scan() .await .unwrap_or_else(|error| panic!("configured scan must succeed: {error}")); assert!(discovered.is_empty()); } #[tokio::test] async fn prefix_only_native_file_is_rejected_by_complete_codec() { let directory = tempfile::tempdir() .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); let path = directory.path().join("short.kswallet"); let mut bytes = std::vec::Vec::new(); bytes.extend_from_slice(crate::KSWALLET_MAGIC); bytes.extend_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes()); std::fs::write(&path, bytes) .unwrap_or_else(|error| panic!("fixture must be writable: {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 set: {error}")); } let manager = crate::WalletManager::new(directory.path()) .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); let error = manager .inspect_file(&path) .await .err() .unwrap_or_else(|| panic!("prefix-only fixture must fail")); assert_eq!(error.code(), "wallet_native_file_length_invalid"); } #[tokio::test] async fn filename_and_header_alias_must_match() { let directory = tempfile::tempdir() .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); let path = directory.path().join("filename.kswallet"); write_native_wallet(&path, "header", 11); let manager = crate::WalletManager::new(directory.path()) .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); let error = manager .inspect_file(&path) .await .err() .unwrap_or_else(|| panic!("alias mismatch must fail")); assert_eq!(error.code(), "wallet_native_alias_mismatch"); } #[tokio::test] async fn codec_errors_do_not_disclose_external_path() { let configured = tempfile::tempdir() .unwrap_or_else(|error| panic!("configured directory must exist: {error}")); let external = tempfile::tempdir() .unwrap_or_else(|error| panic!("external directory must exist: {error}")); let manager = crate::WalletManager::new(configured.path()) .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); let path = external.path().join("invalid.kswallet"); write_native_wallet(&path, "invalid", 13); let mut bytes = std::fs::read(&path) .unwrap_or_else(|error| panic!("fixture must be readable: {error}")); bytes[0] = b'X'; std::fs::write(&path, bytes) .unwrap_or_else(|error| panic!("fixture must be rewritten: {error}")); let error = manager .inspect_file(&path) .await .err() .unwrap_or_else(|| panic!("invalid native wallet must fail")); assert_eq!(error.code(), "wallet_native_magic_invalid"); assert!(!error.to_string().contains(external.path().to_string_lossy().as_ref())); } }