v0.2.6-pre.017
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/error.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
/// Error code used when an atomic Wallet persistence operation cannot publish a valid replacement.
|
||||
pub const ERROR_CODE_ATOMIC_PERSISTENCE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "atomic_persistence_failed");
|
||||
@@ -21,6 +21,8 @@ pub const ERROR_CODE_FORMAT_VERSION_UNSUPPORTED: ksp_core_lib::ErrorCode = ksp_c
|
||||
pub const ERROR_CODE_IO_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "io_failed");
|
||||
/// Error code used when imported or decoded key material is invalid.
|
||||
pub const ERROR_CODE_KEY_MATERIAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "key_material_invalid");
|
||||
/// Error code used when an explicit native Wallet migration request is inconsistent with its source or target policy.
|
||||
pub const ERROR_CODE_MIGRATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "migration_invalid");
|
||||
/// Error code used when a protected Wallet note identifier is not present.
|
||||
pub const ERROR_CODE_NOTE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "note_not_found");
|
||||
/// Error code used when an OWNER unlock attempt fails without exposing a finer cryptographic oracle.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/lib.rs
|
||||
// version: 13
|
||||
// version: 14
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -17,7 +17,9 @@
|
||||
//! native import/export publication. `0.2.5-pre.009` adds adversarial security/compliance canaries and records the final dependency/interop audit before
|
||||
//! documentation closure. `0.2.6-pre.015` defines the strict binary `.kspwallet` V2 wire and its bounded canonical codec without yet changing the public
|
||||
//! persistence default or Wallet Desk dispatch. `0.2.6-pre.016` adds complete V2 create/open/administration, stable version-neutral dispatch, explicit V1/V2
|
||||
//! APIs and moves the default native creation/import format to V2 without coupling that default to the latest supported version. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral
|
||||
//! APIs and moves the default native creation/import format to V2 without coupling that default to the latest supported version. `0.2.6-pre.017` adds
|
||||
//! explicit authenticated V1 -> V2 migration with no-clobber copy and state-conflict protected in-place replacement; ordinary open remains non-migrating.
|
||||
//! Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral
|
||||
//! observability uses only
|
||||
//! `ksp-logging-lib` with the explicit crate target defined in `src/constants.rs`.
|
||||
|
||||
@@ -27,6 +29,7 @@ mod crypto;
|
||||
mod error;
|
||||
mod format;
|
||||
mod metadata;
|
||||
mod migration;
|
||||
mod owner;
|
||||
mod password;
|
||||
mod payload;
|
||||
@@ -205,6 +208,8 @@ pub use self::error::ERROR_CODE_FORMAT_VERSION_UNSUPPORTED;
|
||||
pub use self::error::ERROR_CODE_IO_FAILED;
|
||||
/// Error code used when imported or decoded key material is invalid.
|
||||
pub use self::error::ERROR_CODE_KEY_MATERIAL_INVALID;
|
||||
/// Error code used when an explicit Wallet migration request is inconsistent with migration policy.
|
||||
pub use self::error::ERROR_CODE_MIGRATION_INVALID;
|
||||
/// Error code used when a protected Wallet note identifier is absent.
|
||||
pub use self::error::ERROR_CODE_NOTE_NOT_FOUND;
|
||||
/// Error code used when an OWNER unlock attempt fails without exposing a finer cryptographic oracle.
|
||||
@@ -245,6 +250,12 @@ pub use self::metadata::WalletCreateMetadataV1;
|
||||
pub use self::metadata::WalletInfo;
|
||||
/// One protected Wallet note exposed only after authorization.
|
||||
pub use self::metadata::WalletNote;
|
||||
/// Migrates one V1 file into a new no-clobber V2 destination while preserving the source.
|
||||
pub use self::migration::migrate_wallet_file_v1_to_v2;
|
||||
/// Atomically replaces one authenticated current V1 file with its V2 migration.
|
||||
pub use self::migration::migrate_wallet_file_v1_to_v2_in_place;
|
||||
/// Converts one authenticated V1 document snapshot into a fresh V2 Wallet in memory.
|
||||
pub use self::migration::migrate_wallet_v1_to_v2;
|
||||
/// Authorized OWNER capability handle.
|
||||
pub use self::owner::WalletOwner;
|
||||
/// Owned OWNER password material with redacted diagnostics and drop-time zeroization.
|
||||
@@ -404,6 +415,8 @@ pub(crate) use self::persistence::persist_new_wallet_fault_before_publish;
|
||||
/// Persists new wallet for test.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::persistence::persist_new_wallet_for_test;
|
||||
/// Bounded native Wallet reader shared with authenticated migration.
|
||||
pub(crate) use self::persistence::read_wallet_file_async;
|
||||
/// Replaces wallet fault before publish.
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::persistence::replace_wallet_fault_before_publish;
|
||||
|
||||
166
crates/ksp-wallet-lib/src/migration.rs
Normal file
166
crates/ksp-wallet-lib/src/migration.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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<crate::ViewPassword>,
|
||||
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
||||
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<std::path::Path>,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
owner_password: crate::OwnerPassword,
|
||||
target_view_password: std::option::Option<crate::ViewPassword>,
|
||||
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
||||
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<std::path::Path>,
|
||||
owner_password: crate::OwnerPassword,
|
||||
target_view_password: std::option::Option<crate::ViewPassword>,
|
||||
) -> ksp_core_lib::Result<crate::WalletOwner> {
|
||||
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<F>(
|
||||
source_path: std::path::PathBuf,
|
||||
owner_password: crate::OwnerPassword,
|
||||
target_view_password: std::option::Option<crate::ViewPassword>,
|
||||
before_publish: F,
|
||||
) -> ksp_core_lib::Result<crate::WalletOwner>
|
||||
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;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/owner.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
/// Authorized OWNER capability handle.
|
||||
///
|
||||
@@ -58,6 +58,29 @@ impl WalletOwner {
|
||||
return &self.info;
|
||||
}
|
||||
|
||||
/// Returns one short-lived V1 Solana keypair copy for crate-internal authenticated migration.
|
||||
pub(crate) fn clone_v1_solana_keypair_for_migration(&self) -> ksp_core_lib::Result<solana_keypair::Keypair> {
|
||||
return match &self.state {
|
||||
crate::OwnerState::V1(state) => state.clone_solana_keypair_for_migration(),
|
||||
crate::OwnerState::V2(_) => {
|
||||
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_MIGRATION_INVALID, "Wallet migration source is not V1"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Replaces protected metadata exactly in memory during authenticated migration, preserving stable note identifiers.
|
||||
pub(crate) fn replace_metadata_for_migration(&mut self, payload: crate::MetadataPayloadV1) -> ksp_core_lib::Result<()> {
|
||||
let (envelope, info) = match self.state.stage_metadata_payload(payload) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = self.state.apply_envelope(envelope) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.info = info;
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Exports the immutable Solana keypair through one explicitly selected transfer adapter.
|
||||
///
|
||||
/// The returned bytes contain secret key material and are owned by the caller. Callers should minimize their lifetime and zeroize the buffer after use.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/password.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
/// Owned VIEW password material.
|
||||
///
|
||||
@@ -65,6 +65,11 @@ impl OwnerPassword {
|
||||
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||
return self.value.as_bytes();
|
||||
}
|
||||
|
||||
/// Creates one short-lived crate-internal duplicate for authenticated format migration while keeping the public type non-`Clone`.
|
||||
pub(crate) fn duplicate_for_internal_use(&self) -> Self {
|
||||
return Self::new(self.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OwnerPassword {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/persistence.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! Async-first native Wallet V1/V2 filesystem persistence and version-neutral dispatch.
|
||||
|
||||
@@ -232,7 +232,8 @@ pub(crate) async fn replace_wallet_file_v2(
|
||||
};
|
||||
}
|
||||
|
||||
async fn read_wallet_file_async(source: std::path::PathBuf) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
/// Reads one bounded native Wallet file for crate-internal persistence and migration flows.
|
||||
pub(crate) async fn read_wallet_file_async(source: std::path::PathBuf) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let task = tokio::task::spawn_blocking(move || return read_wallet_file_blocking(source.as_path()));
|
||||
return match task.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/wallet.rs
|
||||
// version: 9
|
||||
// version: 10
|
||||
|
||||
//! In-memory native Wallet V1 create/open orchestration.
|
||||
|
||||
@@ -55,6 +55,21 @@ impl OwnerStateV1 {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Reconstructs one short-lived Solana keypair copy for authenticated V1 -> V2 migration.
|
||||
pub(crate) fn clone_solana_keypair_for_migration(&self) -> ksp_core_lib::Result<solana_keypair::Keypair> {
|
||||
let keypair = match self.solana_keypair.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(key_material_error()),
|
||||
};
|
||||
let mut bytes = keypair.to_bytes();
|
||||
let cloned = solana_keypair::Keypair::try_from(bytes.as_slice());
|
||||
bytes.zeroize();
|
||||
return match cloned {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(key_material_error()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes the crate-internal export transfer operation for `OwnerStateV1`.
|
||||
pub(crate) fn export_transfer(&self, format: crate::WalletTransferFormat) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let keypair = match self.solana_keypair.as_ref() {
|
||||
|
||||
Reference in New Issue
Block a user