// file: crates/ksp-wallet-lib/src/migration.rs // version: 1 //! Explicit authenticated native Wallet migration operations. /// Converts one authenticated V1 document snapshot into a fresh V2 binary Wallet in memory. /// /// The OWNER password authenticates the V1 source and is reused for the V2 OWNER credential. Because V1 and V2 slot AAD are domain-separated, an enabled /// V1 VIEW slot cannot be copied byte-for-byte: the caller must provide the target VIEW password used to rewrap the migrated V2 metadata key. That value /// may be the current VIEW password or a replacement chosen under OWNER authority. A disabled source VIEW must remain disabled during pure format migration. /// No source bytes are modified by this function. pub async fn migrate_wallet_v1_to_v2( source: &[u8], owner_password: crate::OwnerPassword, target_view_password: std::option::Option, ) -> ksp_core_lib::Result { let source_envelope = match crate::KspWalletEnvelopeV1::parse_json(source) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let source_owner_password = owner_password.duplicate_for_internal_use(); let source_owner = match crate::open_wallet_owner_v1(source, source_owner_password).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if let std::result::Result::Err(error) = validate_view_migration_shape(source_envelope.view_descriptor().enabled(), target_view_password.is_some()) { return std::result::Result::Err(error); } let metadata_payload = crate::MetadataPayloadV1::from_info(source_owner.info()); let solana_keypair = match source_owner.clone_v1_solana_keypair_for_migration() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let mut migrated = match crate::create_wallet_v2_from_keypair(owner_password, target_view_password, crate::WalletCreateMetadata::default(), solana_keypair).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if let std::result::Result::Err(error) = migrated.replace_metadata_for_migration(metadata_payload) { return std::result::Result::Err(error); } if migrated.pubkey() != source_owner.pubkey() { return std::result::Result::Err(migration_error("Migrated Wallet public identity changed unexpectedly")); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, operation = "wallet_migrate_v1_to_v2", source_format_version = crate::KSPWALLET_FORMAT_VERSION_V1, target_format_version = crate::KSPWALLET_FORMAT_VERSION_V2, view_enabled = source_envelope.view_descriptor().enabled(), "authenticated native wallet migrated in memory" ); return std::result::Result::Ok(migrated); } /// Migrates one V1 file snapshot into a new no-clobber V2 destination while preserving the V1 source file. /// /// The source is read through the bounded Wallet persistence boundary. Existing destinations are never overwritten. This operation never performs an /// implicit migration when a Wallet is merely opened. pub async fn migrate_wallet_file_v1_to_v2( source: impl std::convert::AsRef, destination: impl std::convert::AsRef, owner_password: crate::OwnerPassword, target_view_password: std::option::Option, ) -> ksp_core_lib::Result { let source_path = source.as_ref().to_path_buf(); let destination_path = destination.as_ref().to_path_buf(); let source_bytes = match crate::read_wallet_file_async(source_path).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let migrated = match migrate_wallet_v1_to_v2(source_bytes.as_slice(), owner_password, target_view_password).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let serialized = match migrated.to_native_bytes() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if let std::result::Result::Err(error) = crate::persist_new_wallet_content(destination_path, serialized).await { return std::result::Result::Err(error); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, operation = "wallet_migrate_v1_to_v2_file", source_format_version = crate::KSPWALLET_FORMAT_VERSION_V1, target_format_version = crate::KSPWALLET_FORMAT_VERSION_V2, publication = "no_clobber", "authenticated native wallet migration published to a new destination" ); return std::result::Result::Ok(migrated); } /// Atomically replaces one authenticated V1 file with its V2 migration when the source state is still current. /// /// The V1 envelope observed before OWNER authentication is retained as the expected state. Publication uses the existing state-conflict protected atomic /// replacement boundary, so concurrent or stale changes abort rather than overwriting a newer Wallet. Failure before publication leaves the V1 bytes intact. pub async fn migrate_wallet_file_v1_to_v2_in_place( source: impl std::convert::AsRef, owner_password: crate::OwnerPassword, target_view_password: std::option::Option, ) -> ksp_core_lib::Result { return migrate_wallet_file_v1_to_v2_in_place_with_hook(source.as_ref().to_path_buf(), owner_password, target_view_password, || { return std::result::Result::Ok(()); }) .await; } async fn migrate_wallet_file_v1_to_v2_in_place_with_hook( source_path: std::path::PathBuf, owner_password: crate::OwnerPassword, target_view_password: std::option::Option, before_publish: F, ) -> ksp_core_lib::Result where F: std::ops::FnOnce() -> ksp_core_lib::Result<()>, { let source_bytes = match crate::read_wallet_file_async(source_path.clone()).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let expected_v1 = match crate::KspWalletEnvelopeV1::parse_json(source_bytes.as_slice()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let migrated = match migrate_wallet_v1_to_v2(source_bytes.as_slice(), owner_password, target_view_password).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let serialized = match migrated.to_native_bytes() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if let std::result::Result::Err(error) = before_publish() { return std::result::Result::Err(error); } if let std::result::Result::Err(error) = crate::replace_wallet_file_v1(source_path, expected_v1, serialized).await { return std::result::Result::Err(error); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, operation = "wallet_migrate_v1_to_v2_file", source_format_version = crate::KSPWALLET_FORMAT_VERSION_V1, target_format_version = crate::KSPWALLET_FORMAT_VERSION_V2, publication = "atomic_replace", "authenticated native wallet migration replaced the current V1 source" ); return std::result::Result::Ok(migrated); } fn validate_view_migration_shape(source_view_enabled: bool, target_view_present: bool) -> ksp_core_lib::Result<()> { if source_view_enabled != target_view_present { return std::result::Result::Err(migration_error( "V1 -> V2 migration must preserve whether VIEW capability is enabled; provide a target VIEW password exactly when the source VIEW is enabled", )); } return std::result::Result::Ok(()); } fn migration_error(message: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_MIGRATION_INVALID, message); } #[cfg(test)] #[path = "../unit_tests/migration.rs"] mod tests;