v0.2.6-pre.017
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||
// version: 18
|
||||
// version: 19
|
||||
|
||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||
|
||||
@@ -405,3 +405,15 @@ fn pre_016_wallet_desk_uses_version_neutral_wallet_dispatch() {
|
||||
assert!(!state.contains("open_wallet_owner_file_v1"));
|
||||
assert!(!inventory.contains("inspect_locked_wallet_file_v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_017_wallet_desk_open_paths_remain_non_migrating() {
|
||||
let state = read_source("src/app_state.rs");
|
||||
let inventory = read_source("src/wallet_inventory.rs");
|
||||
assert!(!state.contains("migrate_wallet_v1_to_v2"));
|
||||
assert!(!state.contains("migrate_wallet_file_v1_to_v2"));
|
||||
assert!(!inventory.contains("migrate_wallet_v1_to_v2"));
|
||||
assert!(state.contains("ksp_wallet_lib::open_wallet_view_file"));
|
||||
assert!(state.contains("ksp_wallet_lib::open_wallet_owner_file"));
|
||||
assert!(inventory.contains("ksp_wallet_lib::inspect_locked_wallet_file"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
Statut : **stable depuis KSP `0.2.5`**.
|
||||
|
||||
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Depuis `0.2.6-pre.016`, les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée.
|
||||
`ksp-wallet-lib` est la bibliothèque KSP propriétaire du Wallet Solana natif. Elle possède le format autonome `.kspwallet` V1 et le format binaire V2 canonique, les capacités indépendantes VIEW/OWNER, la protection du secret Solana, la signature, l'administration des metadata, les rotations de credentials, la persistence native et les adapters d'import/export explicitement supportés. Depuis `0.2.6-pre.016`, les APIs non versionnées créent/importent en V2 par default explicite et lisent V1/V2 par détection bornée. `0.2.6-pre.017` ajoute la migration V1 -> V2 explicite et OWNER-authentifiée, sans migration à l'ouverture.
|
||||
|
||||
La crate est volontairement indépendante de Config, du réseau et de Tauri. Un consumer fournit les chemins, passwords et metadata ; Wallet ouvre, protège, signe et persiste sans décider d'une policy de dépense ni contacter un RPC.
|
||||
|
||||
@@ -195,7 +195,7 @@ Elles couvrent le wire, Argon2id/XChaCha20-Poly1305, l'ouverture VIEW/OWNER, la
|
||||
- [`../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](../../docs/validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) — matrice de sécurité/interoperabilité/compliance ;
|
||||
- [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_START_PROMPT.md) — reprise vers Wallet Desk après publication stable de `0.2.5`.
|
||||
|
||||
## V2 en `0.2.6-pre.015` / `pre.016`
|
||||
## V2 en `0.2.6-pre.015` / `pre.016` / `pre.017`
|
||||
|
||||
`pre.015` a figé le wire structurel V2, son codec et ses transcripts/AAD. `pre.016` matérialise le runtime V2 complet et la façade multi-version :
|
||||
|
||||
@@ -211,4 +211,14 @@ open/inspect génériques -> détection V1/V2
|
||||
open/inspect _v1/_v2 -> format forcé strict
|
||||
```
|
||||
|
||||
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. La migration authentifiée V1 -> V2 reste une opération séparée de `pre.017`.
|
||||
`WalletOwner` et `WalletView` conservent le format natif qu'ils ont ouvert : metadata, rotations OWNER/VIEW, disable/recreate VIEW, self-rotation VIEW, signature et export ne transcodent jamais implicitement le fichier. Le default est une décision explicite et ne suit pas automatiquement une future V3. `pre.017` matérialise la migration authentifiée V1 -> V2 comme opération séparée ; aucune lecture ou mutation ordinaire ne migre implicitement.
|
||||
### Migration explicite V1 -> V2
|
||||
|
||||
```text
|
||||
migrate_wallet_v1_to_v2(...) migration mémoire
|
||||
migrate_wallet_file_v1_to_v2(...) copie V2 no-clobber, V1 source conservée
|
||||
migrate_wallet_file_v1_to_v2_in_place(...) remplacement V1 atomique et stale-protected
|
||||
```
|
||||
|
||||
La migration exige OWNER. Elle conserve l'identité Solana et les metadata protégées exactes, y compris les identifiants stables de notes, mais reconstruit un nouvel envelope V2 avec ses propres matériaux cryptographiques. Si VIEW est activé, un mot de passe VIEW cible doit être fourni ; il peut être identique à l'ancien ou remplacé sous l'autorité OWNER. Une source VIEW désactivée reste désactivée.
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ ksp-onchain-transport-lib
|
||||
|
||||
Cette composition est le rôle de `0.2.6 — ksp-app-wallet-desk`, pas de `ksp-wallet-lib`.
|
||||
|
||||
## Wire/runtime V2 (`0.2.6-pre.015` / `pre.016`)
|
||||
## Wire/runtime V2 (`0.2.6-pre.015` / `pre.016` / `pre.017`)
|
||||
|
||||
Le codec structurel V2 reste disponible directement pour les outils qui travaillent explicitement au niveau wire :
|
||||
|
||||
@@ -301,3 +301,41 @@ inspect_locked_wallet_file(...) auto-détection V1/V2
|
||||
```
|
||||
|
||||
`DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont intentionnellement indépendants. L'arrivée d'un futur V3 ne changera pas automatiquement le default V2.
|
||||
## Migration V1 -> V2 explicite
|
||||
|
||||
Migration en mémoire :
|
||||
|
||||
```rust
|
||||
let migrated = ksp_wallet_lib::migrate_wallet_v1_to_v2(
|
||||
v1_bytes.as_slice(),
|
||||
ksp_wallet_lib::OwnerPassword::new(owner_password),
|
||||
Some(ksp_wallet_lib::ViewPassword::new(target_view_password)),
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
Migration fichier vers une nouvelle destination :
|
||||
|
||||
```rust
|
||||
let migrated = ksp_wallet_lib::migrate_wallet_file_v1_to_v2(
|
||||
source_v1,
|
||||
destination_v2,
|
||||
ksp_wallet_lib::OwnerPassword::new(owner_password),
|
||||
Some(ksp_wallet_lib::ViewPassword::new(target_view_password)),
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
`destination_v2` est no-clobber et `source_v1` reste inchangé. Pour remplacer le fichier courant après vérification stale-state :
|
||||
|
||||
```rust
|
||||
let migrated = ksp_wallet_lib::migrate_wallet_file_v1_to_v2_in_place(
|
||||
source_v1,
|
||||
ksp_wallet_lib::OwnerPassword::new(owner_password),
|
||||
Some(ksp_wallet_lib::ViewPassword::new(target_view_password)),
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
Ces exemples utilisent `?` uniquement comme écriture illustrative de consumer ; les règles internes KSP restent celles du workspace. Une source VIEW activée exige `Some(target_view_password)` ; une source VIEW désactivée exige `None`. Le credential VIEW cible peut être l'ancien mot de passe ou un nouveau mot de passe choisi sous autorité OWNER. Identité Solana, alias, notes et note IDs sont conservés. Aucun open générique ne migre le fichier.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/tests/public_api.rs
|
||||
// version: 11
|
||||
// version: 12
|
||||
|
||||
//! Public API canaries for the Wallet foundation.
|
||||
|
||||
@@ -54,11 +54,12 @@ fn wallet_error_codes_are_available_from_crate_root() {
|
||||
ksp_wallet_lib::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED,
|
||||
ksp_wallet_lib::ERROR_CODE_TRANSFER_FORMAT_UNSUPPORTED,
|
||||
ksp_wallet_lib::ERROR_CODE_KEY_MATERIAL_INVALID,
|
||||
ksp_wallet_lib::ERROR_CODE_MIGRATION_INVALID,
|
||||
ksp_wallet_lib::ERROR_CODE_SIGNATURE_FAILED,
|
||||
ksp_wallet_lib::ERROR_CODE_NOTE_NOT_FOUND,
|
||||
ksp_wallet_lib::ERROR_CODE_STATE_CONFLICT,
|
||||
];
|
||||
assert_eq!(codes.len(), 17);
|
||||
assert_eq!(codes.len(), 18);
|
||||
for code in codes {
|
||||
assert_eq!(code.domain(), "wallet");
|
||||
}
|
||||
@@ -205,3 +206,17 @@ fn public_pre_016_version_neutral_and_explicit_v2_surfaces_are_available_from_cr
|
||||
let inspect_v2_future = ksp_wallet_lib::inspect_locked_wallet_file_v2(path);
|
||||
drop(inspect_v2_future);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_017_explicit_v1_to_v2_migration_surface_is_available_from_crate_root() {
|
||||
assert_eq!(ksp_wallet_lib::ERROR_CODE_MIGRATION_INVALID.domain(), "wallet");
|
||||
let _ = ksp_wallet_lib::migrate_wallet_v1_to_v2;
|
||||
let path = std::path::Path::new("not-polled-pre017.kspwallet");
|
||||
let destination = std::path::Path::new("not-polled-pre017-v2.kspwallet");
|
||||
let owner_password = ksp_wallet_lib::OwnerPassword::new(std::string::String::from("public-pre017-owner-password"));
|
||||
let copy_future = ksp_wallet_lib::migrate_wallet_file_v1_to_v2(path, destination, owner_password, std::option::Option::None);
|
||||
drop(copy_future);
|
||||
let owner_password = ksp_wallet_lib::OwnerPassword::new(std::string::String::from("public-pre017-owner-password"));
|
||||
let replace_future = ksp_wallet_lib::migrate_wallet_file_v1_to_v2_in_place(path, owner_password, std::option::Option::None);
|
||||
drop(replace_future);
|
||||
}
|
||||
|
||||
186
crates/ksp-wallet-lib/unit_tests/migration.rs
Normal file
186
crates/ksp-wallet-lib/unit_tests/migration.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
// file: crates/ksp-wallet-lib/unit_tests/migration.rs
|
||||
// version: 1
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
|
||||
const OWNER_PASSWORD: &str = "pre005-owner-password";
|
||||
const V1_FULL_VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json");
|
||||
const VIEW_PASSWORD: &str = "pre005-view-password";
|
||||
|
||||
fn runtime() -> tokio::runtime::Runtime {
|
||||
return tokio::runtime::Builder::new_current_thread().build().expect("Wallet migration test runtime must build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_v1_to_v2_migration_preserves_identity_metadata_and_note_ids() {
|
||||
let runtime = runtime();
|
||||
let source_owner = runtime
|
||||
.block_on(crate::open_wallet_owner_v1(V1_FULL_VECTOR, crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD))))
|
||||
.expect("V1 fixture OWNER must open before migration");
|
||||
let source_pubkey = *source_owner.pubkey();
|
||||
let source_alias = source_owner.alias().map(std::string::String::from);
|
||||
let source_notes = source_owner.notes().to_vec();
|
||||
let migrated = runtime
|
||||
.block_on(crate::migrate_wallet_v1_to_v2(
|
||||
V1_FULL_VECTOR,
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from("pre017-target-view-password"))),
|
||||
))
|
||||
.expect("authenticated V1 snapshot must migrate to V2");
|
||||
assert_eq!(migrated.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
||||
assert_eq!(*migrated.pubkey(), source_pubkey);
|
||||
assert_eq!(migrated.alias(), source_alias.as_deref());
|
||||
assert_eq!(migrated.notes(), source_notes.as_slice());
|
||||
let bytes = migrated.to_native_bytes().expect("migrated V2 wallet must serialize");
|
||||
assert_eq!(crate::detect_wallet_format(bytes.as_slice()).expect("migrated framing must detect"), crate::WalletFormat::V2);
|
||||
let reopened_owner = runtime
|
||||
.block_on(crate::open_wallet_owner_v2(bytes.as_slice(), crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD))))
|
||||
.expect("migrated V2 OWNER must reuse the authenticated OWNER password");
|
||||
assert_eq!(*reopened_owner.pubkey(), source_pubkey);
|
||||
assert_eq!(reopened_owner.notes(), source_notes.as_slice());
|
||||
let reopened_view = runtime
|
||||
.block_on(crate::open_wallet_view_v2(bytes.as_slice(), crate::ViewPassword::new(std::string::String::from("pre017-target-view-password"))))
|
||||
.expect("migrated V2 VIEW must use the caller-selected target VIEW password");
|
||||
assert_eq!(*reopened_view.pubkey(), source_pubkey);
|
||||
assert_eq!(reopened_view.notes(), source_notes.as_slice());
|
||||
let old_view = runtime.block_on(crate::open_wallet_view_v2(bytes.as_slice(), crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))));
|
||||
assert_eq!(old_view.expect_err("replaced target VIEW credential must reject the historical VIEW password").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_requires_target_view_presence_to_match_the_v1_capability_shape() {
|
||||
let runtime = runtime();
|
||||
let missing_view = runtime.block_on(crate::migrate_wallet_v1_to_v2(
|
||||
V1_FULL_VECTOR,
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::None,
|
||||
));
|
||||
assert_eq!(missing_view.expect_err("enabled V1 VIEW requires one target VIEW credential").code(), crate::ERROR_CODE_MIGRATION_INVALID);
|
||||
let disabled_owner = runtime
|
||||
.block_on(crate::create_wallet_v1(
|
||||
crate::OwnerPassword::new(std::string::String::from("pre017-disabled-owner")),
|
||||
std::option::Option::None,
|
||||
crate::WalletCreateMetadataV1::default(),
|
||||
))
|
||||
.expect("disabled-VIEW V1 fixture creation must succeed");
|
||||
let disabled_bytes = disabled_owner.to_json_bytes().expect("disabled-VIEW V1 fixture must serialize");
|
||||
let unexpected_view = runtime.block_on(crate::migrate_wallet_v1_to_v2(
|
||||
disabled_bytes.as_slice(),
|
||||
crate::OwnerPassword::new(std::string::String::from("pre017-disabled-owner")),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from("pre017-unexpected-view"))),
|
||||
));
|
||||
let error = unexpected_view.expect_err("disabled V1 VIEW must not be silently enabled by pure format migration");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_MIGRATION_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_clobber_file_migration_preserves_source_and_rejects_existing_destination() {
|
||||
let runtime = runtime();
|
||||
let directory = tempfile::Builder::new().prefix("ksp-pre017-copy-").tempdir().expect("migration copy directory must be creatable");
|
||||
let source = directory.path().join("source-v1.kspwallet");
|
||||
let destination = directory.path().join("destination-v2.kspwallet");
|
||||
std::fs::write(source.as_path(), V1_FULL_VECTOR).expect("V1 migration source must be writable");
|
||||
std::fs::write(destination.as_path(), b"existing-destination").expect("migration destination canary must be writable");
|
||||
let result = runtime.block_on(crate::migrate_wallet_file_v1_to_v2(
|
||||
source.as_path(),
|
||||
destination.as_path(),
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
||||
));
|
||||
assert_eq!(result.expect_err("migration copy must remain no-clobber").code(), crate::ERROR_CODE_DESTINATION_EXISTS);
|
||||
assert_eq!(std::fs::read(source.as_path()).expect("V1 source must remain readable"), V1_FULL_VECTOR);
|
||||
assert_eq!(std::fs::read(destination.as_path()).expect("existing destination must remain readable"), b"existing-destination");
|
||||
std::fs::remove_file(destination.as_path()).expect("migration destination canary must be removable");
|
||||
let migrated = runtime
|
||||
.block_on(crate::migrate_wallet_file_v1_to_v2(
|
||||
source.as_path(),
|
||||
destination.as_path(),
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
||||
))
|
||||
.expect("migration copy must publish a fresh V2 destination");
|
||||
assert_eq!(migrated.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
||||
assert_eq!(std::fs::read(source.as_path()).expect("successful migration copy must preserve V1 source"), V1_FULL_VECTOR);
|
||||
let destination_bytes = std::fs::read(destination.as_path()).expect("successful migration destination must be readable");
|
||||
assert_eq!(crate::detect_wallet_format(destination_bytes.as_slice()).expect("migration destination framing must detect"), crate::WalletFormat::V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_place_file_migration_atomically_replaces_current_v1_with_v2() {
|
||||
let runtime = runtime();
|
||||
let directory = tempfile::Builder::new().prefix("ksp-pre017-in-place-").tempdir().expect("migration in-place directory must be creatable");
|
||||
let source = directory.path().join("wallet.kspwallet");
|
||||
std::fs::write(source.as_path(), V1_FULL_VECTOR).expect("V1 in-place migration source must be writable");
|
||||
let migrated = runtime
|
||||
.block_on(crate::migrate_wallet_file_v1_to_v2_in_place(
|
||||
source.as_path(),
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
||||
))
|
||||
.expect("current authenticated V1 source must migrate in place");
|
||||
assert_eq!(migrated.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
||||
let bytes = std::fs::read(source.as_path()).expect("migrated in-place file must remain readable");
|
||||
assert_eq!(crate::detect_wallet_format(bytes.as_slice()).expect("migrated file framing must detect"), crate::WalletFormat::V2);
|
||||
let owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file(source.as_path(), crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD))))
|
||||
.expect("generic OWNER file open must read migrated V2");
|
||||
let view = runtime
|
||||
.block_on(crate::open_wallet_view_file(source.as_path(), crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))))
|
||||
.expect("generic VIEW file open must read migrated V2");
|
||||
assert_eq!(owner.notes(), view.notes());
|
||||
assert_eq!(owner.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
||||
assert_eq!(view.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_v1_source_is_rejected_before_migration_publication() {
|
||||
let runtime = runtime();
|
||||
let mut value: serde_json::Value = serde_json::from_slice(V1_FULL_VECTOR).expect("migration tamper fixture must parse as JSON");
|
||||
let target = value.pointer_mut("/metadata/ciphertext").expect("migration tamper fixture metadata pointer must exist");
|
||||
let encoded = target.as_str().expect("migration tamper target must be Base64url text");
|
||||
let mut decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()).expect("migration tamper target must decode");
|
||||
let first = decoded.first_mut().expect("migration tamper target must not be empty");
|
||||
*first ^= 0x01;
|
||||
*target = serde_json::Value::String(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(decoded.as_slice()));
|
||||
zeroize::Zeroize::zeroize(decoded.as_mut_slice());
|
||||
let tampered = serde_json::to_vec(&value).expect("migration tamper fixture must serialize");
|
||||
let result = runtime.block_on(crate::migrate_wallet_v1_to_v2(
|
||||
tampered.as_slice(),
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
||||
));
|
||||
assert_eq!(result.expect_err("tampered OWNER-authenticated V1 state must not migrate").code(), crate::ERROR_CODE_AUTHENTICATION_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_in_place_source_is_rejected_without_overwriting_the_newer_v1_state() {
|
||||
let runtime = runtime();
|
||||
let directory = tempfile::Builder::new().prefix("ksp-pre017-stale-").tempdir().expect("migration stale directory must be creatable");
|
||||
let source = directory.path().join("wallet.kspwallet");
|
||||
std::fs::write(source.as_path(), V1_FULL_VECTOR).expect("stale migration source must be writable");
|
||||
let replacement_owner = runtime
|
||||
.block_on(crate::create_wallet_v1(
|
||||
crate::OwnerPassword::new(std::string::String::from("pre017-newer-owner")),
|
||||
std::option::Option::None,
|
||||
crate::WalletCreateMetadataV1::new(std::option::Option::Some(std::string::String::from("newer-state")), std::vec::Vec::new()),
|
||||
))
|
||||
.expect("newer V1 state fixture must be creatable");
|
||||
let replacement_bytes = replacement_owner.to_json_bytes().expect("newer V1 state fixture must serialize");
|
||||
let hook_source = source.clone();
|
||||
let hook_bytes = replacement_bytes.clone();
|
||||
let result = runtime.block_on(super::migrate_wallet_file_v1_to_v2_in_place_with_hook(
|
||||
source.clone(),
|
||||
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
||||
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
||||
move || {
|
||||
return match std::fs::write(hook_source.as_path(), hook_bytes.as_slice()) {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Migration stale-state test hook could not replace source").with_source(error),
|
||||
),
|
||||
};
|
||||
},
|
||||
));
|
||||
assert_eq!(result.expect_err("stale in-place migration must abort").code(), crate::ERROR_CODE_STATE_CONFLICT);
|
||||
assert_eq!(std::fs::read(source.as_path()).expect("newer V1 state must remain readable"), replacement_bytes);
|
||||
}
|
||||
Reference in New Issue
Block a user