diff --git a/Cargo.toml b/Cargo.toml index 55169d3..0cb5861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.6-pre.16.fix.2" +version = "0.2.6-pre.17" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/ROADMAP.md b/ROADMAP.md index 5e860e6..a450b9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,7 +50,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U - [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants. - [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication. - [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. -- [/] `0.2.6` — `ksp-app-wallet-desk` + évolution `.kspwallet` V2 : surface Desk acquise ; `pre.014` polish et `pre.015` wire binaire V2 acquis ; `pre.016` matérialise APIs génériques/versionnées + runtime V2 ; prochain gate `pre.017` migration/canaris, puis `pre.018` candidate finale. Plan détaillé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`. +- [/] `0.2.6` — `ksp-app-wallet-desk` + évolution `.kspwallet` V2 : surface Desk acquise ; `pre.014` polish et `pre.015` wire binaire V2 acquis ; `pre.016` matérialise APIs génériques/versionnées + runtime V2 ; `pre.017` matérialise migration explicite V1 -> V2 et canaris ; prochain gate `pre.018` candidate finale. Plan détaillé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`. - [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe. - [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client. - [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle. diff --git a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs index fe7936d..5de4e15 100644 --- a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs @@ -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")); +} diff --git a/crates/ksp-wallet-lib/README.md b/crates/ksp-wallet-lib/README.md index cf7c65d..6771fcd 100644 --- a/crates/ksp-wallet-lib/README.md +++ b/crates/ksp-wallet-lib/README.md @@ -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. + diff --git a/crates/ksp-wallet-lib/USAGE.md b/crates/ksp-wallet-lib/USAGE.md index d980f09..6b88929 100644 --- a/crates/ksp-wallet-lib/USAGE.md +++ b/crates/ksp-wallet-lib/USAGE.md @@ -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. + diff --git a/crates/ksp-wallet-lib/src/error.rs b/crates/ksp-wallet-lib/src/error.rs index dc2f436..6cab562 100644 --- a/crates/ksp-wallet-lib/src/error.rs +++ b/crates/ksp-wallet-lib/src/error.rs @@ -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. diff --git a/crates/ksp-wallet-lib/src/lib.rs b/crates/ksp-wallet-lib/src/lib.rs index 628ed37..e53d0f8 100644 --- a/crates/ksp-wallet-lib/src/lib.rs +++ b/crates/ksp-wallet-lib/src/lib.rs @@ -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; diff --git a/crates/ksp-wallet-lib/src/migration.rs b/crates/ksp-wallet-lib/src/migration.rs new file mode 100644 index 0000000..64fd636 --- /dev/null +++ b/crates/ksp-wallet-lib/src/migration.rs @@ -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, +) -> 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; diff --git a/crates/ksp-wallet-lib/src/owner.rs b/crates/ksp-wallet-lib/src/owner.rs index 23b5754..537768b 100644 --- a/crates/ksp-wallet-lib/src/owner.rs +++ b/crates/ksp-wallet-lib/src/owner.rs @@ -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 { + 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. diff --git a/crates/ksp-wallet-lib/src/password.rs b/crates/ksp-wallet-lib/src/password.rs index 1454d06..2a1b1ae 100644 --- a/crates/ksp-wallet-lib/src/password.rs +++ b/crates/ksp-wallet-lib/src/password.rs @@ -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 { diff --git a/crates/ksp-wallet-lib/src/persistence.rs b/crates/ksp-wallet-lib/src/persistence.rs index 4516d12..c665a15 100644 --- a/crates/ksp-wallet-lib/src/persistence.rs +++ b/crates/ksp-wallet-lib/src/persistence.rs @@ -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> { +/// 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> { 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, diff --git a/crates/ksp-wallet-lib/src/wallet.rs b/crates/ksp-wallet-lib/src/wallet.rs index e4ba675..4986b05 100644 --- a/crates/ksp-wallet-lib/src/wallet.rs +++ b/crates/ksp-wallet-lib/src/wallet.rs @@ -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 { + 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> { let keypair = match self.solana_keypair.as_ref() { diff --git a/crates/ksp-wallet-lib/tests/public_api.rs b/crates/ksp-wallet-lib/tests/public_api.rs index a9320ea..4adc4d7 100644 --- a/crates/ksp-wallet-lib/tests/public_api.rs +++ b/crates/ksp-wallet-lib/tests/public_api.rs @@ -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); +} diff --git a/crates/ksp-wallet-lib/unit_tests/migration.rs b/crates/ksp-wallet-lib/unit_tests/migration.rs new file mode 100644 index 0000000..62562dc --- /dev/null +++ b/crates/ksp-wallet-lib/unit_tests/migration.rs @@ -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); +} diff --git a/deltas/0.2.6/pre.017.md b/deltas/0.2.6/pre.017.md new file mode 100644 index 0000000..5fcb3fd --- /dev/null +++ b/deltas/0.2.6/pre.017.md @@ -0,0 +1,263 @@ + + + +# Delta `0.2.6-pre.017` — migration explicite `.kspwallet` V1 -> V2 et canaris de persistence + +## Base requise + +```text +0.2.6-pre.016-fix.002 appliquée +workspace.package.version = 0.2.6-pre.16.fix.2 +``` + +Le checkpoint opérateur de cette base est intégralement vert : + +```text +cargo fmt --all OK +python3 scripts/audit_rust_workspace_rules.py clean +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test --workspace OK +ksp-wallet-lib 78 passed / 1 ignored +Wallet Desk desktop_contract 16/16 OK +Wallet Desk release_compliance 4/4 OK +smokes réseau / benchmark ignored comme prévu +``` + +`pre.016-fix.002` est donc accepté comme base de `pre.017`. + +## Signal technique + +Cette tranche ajoute des APIs/runtime/persistence de migration Wallet : + +```text +workspace.package.version = 0.2.6-pre.17 +commit = v0.2.6-pre.017 +``` + +Aucun tag prerelease. Aucun `package.json`/`tauri.conf.json` n'est modifié. + +## Objectif de la tranche + +`pre.017` ferme le chantier fonctionnel V1/V2 avant la candidate documentaire `pre.018` : + +```text +migration V1 -> V2 explicite +OWNER authentifie toujours la source +migration mémoire +copie filesystem no-clobber +remplacement in-place atomique et stale-protected +identité Solana conservée +metadata protégées conservées exactement +note IDs stables conservés +VIEW enabled/disabled conservé +credential VIEW cible explicite si VIEW est activé +aucune migration implicite lors d'un open/inspect +canaris tampering / no-clobber / stale-state +régression Wallet Desk non-migrante +``` + +## APIs publiques de migration + +### Snapshot mémoire + +```rust +migrate_wallet_v1_to_v2( + source, + owner_password, + target_view_password, +) +``` + +Le `source` doit être un V1 strict. La fonction : + +1. parse le V1 ; +2. authentifie OWNER ; +3. récupère en mémoire l'identité Solana autorisée et le payload metadata exact ; +4. construit un nouvel envelope V2 ; +5. restaure alias, notes et identifiants stables de notes dans le payload V2 ; +6. retourne un `WalletOwner` V2. + +Aucun octet source n'est modifié. + +### Migration vers une nouvelle destination + +```rust +migrate_wallet_file_v1_to_v2( + source, + destination, + owner_password, + target_view_password, +) +``` + +La source V1 est conservée. La destination V2 utilise la persistence no-clobber existante : une destination existante retourne `wallet.destination_exists` et reste inchangée. + +### Migration in-place + +```rust +migrate_wallet_file_v1_to_v2_in_place( + source, + owner_password, + target_view_password, +) +``` + +L'enveloppe V1 observée avant OWNER unlock devient l'état authentifié attendu. Après construction complète du V2, la publication passe par le remplacement atomique V1 existant : + +```text +relire source bornée +parse V1 strict +vérifier signature OWNER +comparer à expected V1 +écrire/sync temp +revérifier expected V1 juste avant publication +rename atomique +sync fichier / parent best-effort +``` + +Un autre état V1 valide publié entre-temps provoque `wallet.state_conflict` et n'est jamais écrasé. + +## Politique credential VIEW pendant migration + +V1 et V2 ont des domaines/AAD distincts. Le VIEW key-wrap V1 ne peut donc pas être réutilisé directement dans V2. + +Règle `pre.017` : + +```text +source V1 VIEW disabled -> target_view_password = None +source V1 VIEW enabled -> target_view_password = Some(...) +``` + +Lorsque VIEW est activé, le credential cible peut être : + +- le même mot de passe VIEW que V1 ; +- un nouveau mot de passe VIEW choisi pendant la migration. + +Cela ne constitue pas une élévation supplémentaire : OWNER possède déjà en V1/V2 l'autorité de rotation du credential VIEW sans connaître l'ancien mot de passe VIEW. + +La migration pure ne change toutefois pas la **forme de capability** : elle ne peut ni activer VIEW sur une source désactivée ni supprimer VIEW sur une source activée. + +Une incohérence de cette politique retourne le nouveau code : + +```text +wallet.migration_invalid +``` + +## Ce qui est préservé et ce qui est renouvelé + +Préservé : + +```text +Solana keypair / Pubkey +alias protégé +notes protégées +note IDs stables +état VIEW enabled/disabled +credential OWNER logique fourni par le caller +``` + +Renouvelé en V2 : + +```text +owner_root +metadata_key +secret_key +OWNER auth Ed25519 key +OWNER/VIEW slot IDs +Argon2 salts +XChaCha nonces +ciphertexts +state signature +AAD/transcripts V2 +``` + +La migration n'est donc jamais une transformation syntaxique JSON -> binaire des ciphertexts existants. Elle ouvre/authentifie V1 puis reconstruit cryptographiquement un document V2. + +## Secret lifetime interne + +`OwnerPassword` reste publiquement non-`Clone`. La migration doit toutefois dériver : + +```text +V1 OWNER KDF pour authentifier la source +V2 OWNER KDF pour créer la cible +``` + +`ksp-wallet-lib` ajoute donc uniquement un duplicate interne court-vivant du wrapper OWNER, non exposé aux consumers et toujours zéroïsé au drop. Aucun contrat public `Clone` n'est ajouté à `OwnerPassword` ou `ViewPassword`. + +## Canaris `ksp-wallet-lib` + +Nouveau module `unit_tests/migration.rs` : + +- migration V1 full-vector -> V2 ; +- identité Solana conservée ; +- alias/notes/note IDs conservés ; +- OWNER V2 rouvert avec le même credential OWNER ; +- VIEW V2 rouvert avec le credential cible ; +- ancien VIEW refusé lorsqu'un nouveau credential cible est choisi ; +- forme VIEW activée/désactivée obligatoirement conservée ; +- copie vers destination existante = no-clobber ; +- copie réussie laisse V1 source inchangé ; +- migration in-place produit un V2 ouvrable via APIs génériques ; +- tampering OWNER-authenticated V1 rejeté avant publication ; +- remplacement concurrent par un autre V1 valide = `wallet.state_conflict` ; +- état concurrent plus récent conservé. + +Le public API canary expose les trois fonctions de migration et `ERROR_CODE_MIGRATION_INVALID` depuis la racine de crate. + +## Régression Wallet Desk + +Wallet Desk ne reçoit volontairement **aucun bouton/commande de migration automatique** dans cette tranche. + +Il reste sur : + +```text +create/import génériques -> V2 +open OWNER/VIEW générique -> V1 ou V2 +inventory inspect générique -> V1 ou V2 +``` + +Un nouveau canari `pre_017_wallet_desk_open_paths_remain_non_migrating` interdit l'introduction d'un appel `migrate_wallet_*` dans les chemins normaux `app_state`/inventory. + +Cette séparation garantit qu'ouvrir un ancien V1 dans Wallet Desk ne modifie jamais le fichier à l'insu de l'utilisateur. + +## Documentation synchronisée + +Cette tranche met à jour : + +```text +docs/formats/KSPWALLET_V2.md +docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md +crates/ksp-wallet-lib/README.md +crates/ksp-wallet-lib/USAGE.md +prompts/011-V0_2_6_START_PROMPT.md +docs/000-README.md +docs/plans/000-README.md +ROADMAP.md état global uniquement +``` + +`CHANGELOG.md` reste réservé à `pre.018`/clôture de release conformément à la politique KSP. + +## Validation opérateur requise + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-wallet-lib +cargo test -p ksp-app-wallet-desk +cargo test --workspace +``` + +Aucun `cargo tauri build` dans cette tranche. + +Si ce checkpoint est vert, la prochaine tranche est : + +```text +0.2.6-pre.018 +``` + +avec documentation/candidate finale, fermeture CWD/resources du bundle, parcours Tauri final, prompt `0.2.7`, puis `cargo tauri build` comme toute dernière opération de validation. diff --git a/docs/000-README.md b/docs/000-README.md index 62b11b2..8876859 100644 --- a/docs/000-README.md +++ b/docs/000-README.md @@ -74,7 +74,7 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é ## Documents de planification -Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) a ouvert la release stable `0.2.2 — HTTP Accounts + Tokens + Cluster`. Son plan clôturé [`plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) conserve l'audit et l'implémentation des 22 wrappers typés, tandis que [`validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) enregistre les validations déterministes, les graphes Cargo et les deux smokes Devnet passés avant publication. Le prompt [`../prompts/008-V0_2_3_START_PROMPT.md`](../prompts/008-V0_2_3_START_PROMPT.md) a ouvert la release stable `0.2.3 — HTTP Transactions`. Son plan clôturé [`plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) conserve l'audit et l'implémentation des 11 wrappers ; le réaudit [`validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md`](validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md) confirme la complétude des 37 wrappers HTTP typés et [`validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](validation/006-V0_2_3_HTTP_TRANSACTIONS.md) enregistre les validations finales, graphes Cargo et deux smokes Devnet passés avant publication. Le prompt [`../prompts/009-V0_2_4_START_PROMPT.md`](../prompts/009-V0_2_4_START_PROMPT.md) a ouvert la release stable `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`. Son plan clôturé [`plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) conserve l’implémentation des 15 wrappers et la compliance `52/52 + 14/14`; la matrice finale [`validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) enregistre le réaudit SIMD/inventaire, les canaries globales et les preuves opérateur avant publication. Le prompt [`../prompts/010-V0_2_5_START_PROMPT.md`](../prompts/010-V0_2_5_START_PROMPT.md), finalisé par `0.2.4-pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` sur la base stable `v0.2.4`. Son plan historique clôturé [`plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) part du gate `pre.001` (héritage, threat model offline, VIEW/OWNER indépendants et niveau B read-only), puis matérialise la crate en `pre.002`, le wire/transcript en `pre.003`, les primitives Argon2id/XChaCha20-Poly1305 en `pre.004`, les payloads/create/open en `pre.005`, la persistence en `pre.006`, l'administration/signature en `pre.007` et les adapters transfer en `pre.008`. `pre.009` ferme l'audit adversarial/interoperability/compliance dans [`validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) avant la documentation finale `pre.010` ; `pre.010` finalise [`../crates/ksp-wallet-lib/README.md`](../crates/ksp-wallet-lib/README.md), [`../crates/ksp-wallet-lib/USAGE.md`](../crates/ksp-wallet-lib/USAGE.md), la spec, les graphes et la matrice ; `pre.010-fix.001`–`fix.003` ferment ensuite la mise à niveau Dalek et la normalisation Rust/audit structurel. `0.2.5-rel.001` publie la release stable et [`../prompts/011-V0_2_6_START_PROMPT.md`](../prompts/011-V0_2_6_START_PROMPT.md) ouvre `0.2.6 — Wallet Desk`. Le gate `0.2.6-pre.001` est matérialisé dans [`plans/013-V0_2_6_WALLET_DESK_PLAN.md`](plans/013-V0_2_6_WALLET_DESK_PLAN.md) : il réaudite Config Desk et les APIs finales, retient le gabarit Bootstrap/Font Awesome/DataTables/Select/SimpleBar/resize-observer-polyfill, fixe `std.wallet` avec root global + sous-répertoire de profil, création automatique des répertoires, secrets `KSP_SECRET_WALLET_PASS_*` via Config, inventory/path safety, lifecycle VIEW/OWNER, screen/command/DTO maps et balance HTTP. Le forecast a été étendu après `pre.014` : `pre.015` fige le wire binaire `.kspwallet` V2 et `pre.016` matérialise les APIs génériques/versionnées, le runtime V2 et la bascule version-neutral de Wallet Desk ; `pre.017` ferme migration/canaris/régression, puis `pre.018` finalise documentation, prompt `0.2.7` WebSocket et build Tauri. `0.2.6-pre.002` matérialise la crate `ksp-app-wallet-desk` et son shell Tauri splash/main avec Config + Logging bootstrap, ports `1432/1433`, Bootstrap, Font Awesome, DataTables/Select, SimpleBar, `resize-observer-polyfill`, TS-RS et bridge frontend Logging ; `0.2.6-pre.003` ajoute `cfg.std.wallet`/`schema.std.wallet`, le composite concret Wallet Desk, les adapters Config capables de préserver une sélection composite, les profils Wallet `default`/`temporary`/`tests` et la préparation automatique du répertoire global/effectif par l’application. L’inventory `.kspwallet` reste réservé à `pre.004`. +Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) a ouvert la release stable `0.2.2 — HTTP Accounts + Tokens + Cluster`. Son plan clôturé [`plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) conserve l'audit et l'implémentation des 22 wrappers typés, tandis que [`validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) enregistre les validations déterministes, les graphes Cargo et les deux smokes Devnet passés avant publication. Le prompt [`../prompts/008-V0_2_3_START_PROMPT.md`](../prompts/008-V0_2_3_START_PROMPT.md) a ouvert la release stable `0.2.3 — HTTP Transactions`. Son plan clôturé [`plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) conserve l'audit et l'implémentation des 11 wrappers ; le réaudit [`validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md`](validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md) confirme la complétude des 37 wrappers HTTP typés et [`validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](validation/006-V0_2_3_HTTP_TRANSACTIONS.md) enregistre les validations finales, graphes Cargo et deux smokes Devnet passés avant publication. Le prompt [`../prompts/009-V0_2_4_START_PROMPT.md`](../prompts/009-V0_2_4_START_PROMPT.md) a ouvert la release stable `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`. Son plan clôturé [`plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) conserve l’implémentation des 15 wrappers et la compliance `52/52 + 14/14`; la matrice finale [`validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) enregistre le réaudit SIMD/inventaire, les canaries globales et les preuves opérateur avant publication. Le prompt [`../prompts/010-V0_2_5_START_PROMPT.md`](../prompts/010-V0_2_5_START_PROMPT.md), finalisé par `0.2.4-pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` sur la base stable `v0.2.4`. Son plan historique clôturé [`plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) part du gate `pre.001` (héritage, threat model offline, VIEW/OWNER indépendants et niveau B read-only), puis matérialise la crate en `pre.002`, le wire/transcript en `pre.003`, les primitives Argon2id/XChaCha20-Poly1305 en `pre.004`, les payloads/create/open en `pre.005`, la persistence en `pre.006`, l'administration/signature en `pre.007` et les adapters transfer en `pre.008`. `pre.009` ferme l'audit adversarial/interoperability/compliance dans [`validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md`](validation/008-V0_2_5_WALLET_SECURITY_COMPLIANCE.md) avant la documentation finale `pre.010` ; `pre.010` finalise [`../crates/ksp-wallet-lib/README.md`](../crates/ksp-wallet-lib/README.md), [`../crates/ksp-wallet-lib/USAGE.md`](../crates/ksp-wallet-lib/USAGE.md), la spec, les graphes et la matrice ; `pre.010-fix.001`–`fix.003` ferment ensuite la mise à niveau Dalek et la normalisation Rust/audit structurel. `0.2.5-rel.001` publie la release stable et [`../prompts/011-V0_2_6_START_PROMPT.md`](../prompts/011-V0_2_6_START_PROMPT.md) ouvre `0.2.6 — Wallet Desk`. Le gate `0.2.6-pre.001` est matérialisé dans [`plans/013-V0_2_6_WALLET_DESK_PLAN.md`](plans/013-V0_2_6_WALLET_DESK_PLAN.md) : il réaudite Config Desk et les APIs finales, retient le gabarit Bootstrap/Font Awesome/DataTables/Select/SimpleBar/resize-observer-polyfill, fixe `std.wallet` avec root global + sous-répertoire de profil, création automatique des répertoires, secrets `KSP_SECRET_WALLET_PASS_*` via Config, inventory/path safety, lifecycle VIEW/OWNER, screen/command/DTO maps et balance HTTP. Le forecast a été étendu après `pre.014` : `pre.015` fige le wire binaire `.kspwallet` V2 et `pre.016` matérialise les APIs génériques/versionnées, le runtime V2 et la bascule version-neutral de Wallet Desk ; `pre.017` matérialise migration/canaris/régression, puis `pre.018` finalise documentation, prompt `0.2.7` WebSocket et build Tauri. `0.2.6-pre.002` matérialise la crate `ksp-app-wallet-desk` et son shell Tauri splash/main avec Config + Logging bootstrap, ports `1432/1433`, Bootstrap, Font Awesome, DataTables/Select, SimpleBar, `resize-observer-polyfill`, TS-RS et bridge frontend Logging ; `0.2.6-pre.003` ajoute `cfg.std.wallet`/`schema.std.wallet`, le composite concret Wallet Desk, les adapters Config capables de préserver une sélection composite, les profils Wallet `default`/`temporary`/`tests` et la préparation automatique du répertoire global/effectif par l’application. L’inventory `.kspwallet` reste réservé à `pre.004`. ## Spécifications de formats diff --git a/docs/formats/KSPWALLET_V2.md b/docs/formats/KSPWALLET_V2.md index 5427531..f2e1e0c 100644 --- a/docs/formats/KSPWALLET_V2.md +++ b/docs/formats/KSPWALLET_V2.md @@ -19,7 +19,7 @@ préserver les frontières cryptographiques Wallet Le caractère binaire **n'ajoute aucune propriété cryptographique**. La confidentialité et l'authenticité continuent de dépendre d'Argon2id, XChaCha20-Poly1305 et Ed25519, jamais de l'absence de JSON. -`0.2.6-pre.015` fige la grammaire binaire, les identifiants numériques, les bornes structurelles et les transcripts/AAD V2. `0.2.6-pre.016` matérialise la création/ouverture/persistence V2, l'auto-détection V1/V2, les APIs génériques/versionnées et le basculement de Wallet Desk vers la façade non versionnée. +`0.2.6-pre.015` fige la grammaire binaire, les identifiants numériques, les bornes structurelles et les transcripts/AAD V2. `0.2.6-pre.016` matérialise la création/ouverture/persistence V2, l'auto-détection V1/V2, les APIs génériques/versionnées et le basculement de Wallet Desk vers la façade non versionnée. `0.2.6-pre.017` ajoute la migration V1 -> V2 explicite et OWNER-authentifiée, en copie no-clobber ou remplacement atomique in-place. V1 reste un format stable supporté. V2 ne réinterprète jamais un document V1 avec sa propre grammaire. @@ -104,14 +104,14 @@ Une implémentation conforme doit vérifier les bornes **avant** toute allocatio Le début de fichier est strictement : -| Ordre | Champ | Taille | Valeur / règle | -|------:|---------------------------|-----------------:|------------------------------------------------| -| 1 | `magic` | 9 | ASCII exact `KSPWALLET` | -| 2 | `format_version` | 2 | `0x0002` | -| 3 | `document_length` | 4 | longueur totale exacte du fichier | -| 4 | `flags` | 2 | bit 0 = VIEW activé ; tous les autres bits = 0 | -| 5 | `owner_auth_public_key` | 32 | clé publique Ed25519 OWNER | -| 6 | `view_descriptor.slot_id` | 16 conditionnels | présent uniquement si `flags & 0x0001 != 0` | +| Ordre | Champ | Taille | Valeur / règle | +|---:|---|---:|---| +| 1 | `magic` | 9 | ASCII exact `KSPWALLET` | +| 2 | `format_version` | 2 | `0x0002` | +| 3 | `document_length` | 4 | longueur totale exacte du fichier | +| 4 | `flags` | 2 | bit 0 = VIEW activé ; tous les autres bits = 0 | +| 5 | `owner_auth_public_key` | 32 | clé publique Ed25519 OWNER | +| 6 | `view_descriptor.slot_id` | 16 conditionnels | présent uniquement si `flags & 0x0001 != 0` | Offsets fixes avant le descripteur conditionnel : @@ -154,21 +154,21 @@ Aucun compteur de slots ou de compartiments n'est nécessaire : leur cardinalit Chaque key slot est encodé ainsi : -| Champ | Taille | Valeur / règle | -|--------------------------|---------:|-----------------------------------------| -| `role` | 1 | `0x01` OWNER, `0x02` VIEW | -| `slot_id` | 16 | identifiant binaire exact | -| `kdf_algorithm` | 1 | `0x01` Argon2id | -| `kdf_version` | 4 | `19` | -| `memory_kib` | 4 | `1..1 048 576`, et `>= parallelism * 8` | -| `iterations` | 4 | `1..64` | -| `parallelism` | 4 | `1..64` | -| `salt_length` | 1 | `16..64` | -| `salt` | variable | exactement `salt_length` octets | -| `wrap_algorithm` | 1 | `0x01` XChaCha20-Poly1305 | -| `wrap_nonce` | 24 | nonce exact | -| `wrap_ciphertext_length` | 2 | `16..4096` | -| `wrap_ciphertext` | variable | exactement la longueur déclarée | +| Champ | Taille | Valeur / règle | +|---|---:|---| +| `role` | 1 | `0x01` OWNER, `0x02` VIEW | +| `slot_id` | 16 | identifiant binaire exact | +| `kdf_algorithm` | 1 | `0x01` Argon2id | +| `kdf_version` | 4 | `19` | +| `memory_kib` | 4 | `1..1 048 576`, et `>= parallelism * 8` | +| `iterations` | 4 | `1..64` | +| `parallelism` | 4 | `1..64` | +| `salt_length` | 1 | `16..64` | +| `salt` | variable | exactement `salt_length` octets | +| `wrap_algorithm` | 1 | `0x01` XChaCha20-Poly1305 | +| `wrap_nonce` | 24 | nonce exact | +| `wrap_ciphertext_length` | 2 | `16..4096` | +| `wrap_ciphertext` | variable | exactement la longueur déclarée | Ordre obligatoire : @@ -189,14 +189,14 @@ Toute divergence est invalide avant KDF/déchiffrement. Chaque compartiment est encodé : -| Champ | Taille | Valeur / règle | -|---------------------|---------:|------------------------------------------------------| -| `kind` | 1 | `0x01` OWNER-CONTROL, `0x02` METADATA, `0x03` SECRET | -| `payload_version` | 4 | `1` pour le profil initial V2 | -| `algorithm` | 1 | `0x01` XChaCha20-Poly1305 | -| `nonce` | 24 | nonce exact | -| `ciphertext_length` | 4 | longueur exacte | -| `ciphertext` | variable | ciphertext + tag Poly1305 | +| Champ | Taille | Valeur / règle | +|---|---:|---| +| `kind` | 1 | `0x01` OWNER-CONTROL, `0x02` METADATA, `0x03` SECRET | +| `payload_version` | 4 | `1` pour le profil initial V2 | +| `algorithm` | 1 | `0x01` XChaCha20-Poly1305 | +| `nonce` | 24 | nonce exact | +| `ciphertext_length` | 4 | longueur exacte | +| `ciphertext` | variable | ciphertext + tag Poly1305 | Bornes initiales : @@ -214,10 +214,10 @@ Les payloads plaintext V2 conservent le modèle fonctionnel établi en V1 pour c La fin du document est : -| Champ | Taille | Valeur / règle | -|-----------------------------|-------:|---------------------------| -| `state_signature.algorithm` | 1 | `0x01` Ed25519 | -| `state_signature.signature` | 64 | signature detached exacte | +| Champ | Taille | Valeur / règle | +|---|---:|---| +| `state_signature.algorithm` | 1 | `0x01` Ed25519 | +| `state_signature.signature` | 64 | signature detached exacte | Aucun octet ne peut suivre ces 65 octets. @@ -377,17 +377,17 @@ Le nonce/ciphertext n'est pas inclus dans son propre AAD. V1 et V2 sont deux formats explicites : -| Propriété | V1 | V2 | -|--------------------------|------------------------------------|---------------| -| enveloppe | JSON UTF-8 | binaire KSP | -| champs binaires | Base64url no-pad | bytes directs | -| version | `1` | `2` | -| canonicalité | sémantique JSON + Base64 canonique | byte-exact | -| taille fixture wire-only | 2037 octets | 628 octets | -| Argon2id | oui | oui | -| XChaCha20-Poly1305 | oui | oui | -| Ed25519 OWNER state | oui | oui | -| VIEW/OWNER | oui | oui | +| Propriété | V1 | V2 | +|---|---|---| +| enveloppe | JSON UTF-8 | binaire KSP | +| champs binaires | Base64url no-pad | bytes directs | +| version | `1` | `2` | +| canonicalité | sémantique JSON + Base64 canonique | byte-exact | +| taille fixture wire-only | 2037 octets | 628 octets | +| Argon2id | oui | oui | +| XChaCha20-Poly1305 | oui | oui | +| Ed25519 OWNER state | oui | oui | +| VIEW/OWNER | oui | oui | La réduction mesurée sur le fixture structurel de référence est : @@ -443,7 +443,21 @@ réécriture V1 -> V2 cachée non migration V1 -> V2 opération explicite ``` -Une migration doit authentifier le wallet source avec la capability requise, produire un nouveau document V2 valide et respecter la persistence no-clobber/atomique. Une simple transcodification non authentifiée des bytes V1 n'est pas suffisante puisque V2 possède ses propres domains/transcripts. +`pre.017` matérialise cette politique avec trois surfaces : + +```text +migrate_wallet_v1_to_v2(...) +migrate_wallet_file_v1_to_v2(...) +migrate_wallet_file_v1_to_v2_in_place(...) +``` + +La migration authentifie toujours le wallet source avec OWNER. Elle ne transcode pas les slots/ciphertexts V1 : elle ouvre le V1, conserve l'identité Solana et le payload metadata exact — y compris les identifiants de notes — puis construit un nouvel envelope V2 avec de nouveaux matériaux cryptographiques et les domains/transcripts V2. Le mot de passe OWNER fourni reste le credential OWNER cible. + +Lorsque VIEW est activé en V1, le caller doit fournir un mot de passe VIEW cible pour V2, car le key-wrap V1 ne peut pas être réutilisé sous l'AAD V2. OWNER peut fournir l'ancien credential ou en choisir un nouveau, conformément à son autorité existante de rotation VIEW. Une migration pure conserve en revanche la forme de capability : elle ne peut ni activer VIEW sur une source désactivée, ni supprimer VIEW d'une source activée. + +La migration fichier vers une autre destination est no-clobber et laisse V1 intact. La variante in-place compare l'état V1 authentifié attendu avant publication et utilise le remplacement atomique existant ; un changement concurrent retourne `wallet.state_conflict`. `wallet.migration_invalid` couvre une demande de migration incohérente. + +Une simple transcodification non authentifiée des bytes V1 n'est pas suffisante puisque V2 possède ses propres domains/transcripts. ## 15. Future V3 / second facteur diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index 0a74f84..85cd137 100644 --- a/docs/plans/000-README.md +++ b/docs/plans/000-README.md @@ -21,7 +21,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou - [`010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de la release stable `0.2.3 — HTTP Transactions`, ouvert par `pre.001`, exécuté jusqu'à `pre.009` puis publié par `rel.001`; il couvre les 11 méthodes, la classification `8 Read / 2 WriteSubmission / 1 Simulation`, `KSP-TRANSPORT-007`, le no-resend et la préparation de `0.2.4`. - [`011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan historique clôturé de la release stable `0.2.4`, ouvert par `pre.001`, exécuté jusqu’à `pre.009`, complété par le fix documentaire Wallet `pre.009-fix.001` puis publié par `rel.001`; il couvre les 10 Blocks + 5 Economics et la compliance finale `52/52 + 14/14` sous `KSP-TRANSPORT-007`. - [`012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.2.5 — Wallet foundation`, ouvert par `pre.001`, livré jusqu’à `pre.010`, renforcé par `pre.010-fix.001`–`fix.003` pour Dalek 3 et la normalisation Rust/audit structurel, puis publié par `rel.001`; il couvre `.kspwallet` V1, VIEW/OWNER, crypto, persistence, administration, transfer et compliance. -- [`013-V0_2_6_WALLET_DESK_PLAN.md`](013-V0_2_6_WALLET_DESK_PLAN.md) — plan actif de `0.2.6 — Wallet Desk`, ouvert par `pre.001`; `pre.002` matérialise le shell Tauri et `pre.003` la composition `std.wallet`/composite ainsi que la préparation des répertoires. Le plan fixe aussi secrets `KSP_SECRET_WALLET_PASS_*`, inventory DataTables/path safety, lifecycle VIEW/OWNER, screen/command/DTO maps, balance HTTP, administration retenue et forecast étendu jusqu’à `pre.018` : `pre.014` polish Bootstrap/splashscreen, `pre.015` wire binaire `.kspwallet` V2 et `pre.016` APIs génériques/versionnées + runtime V2 désormais matérialisés ; prochain gate `pre.017` migration/canaris, puis `pre.018` documentation candidate et build Tauri final. +- [`013-V0_2_6_WALLET_DESK_PLAN.md`](013-V0_2_6_WALLET_DESK_PLAN.md) — plan actif de `0.2.6 — Wallet Desk`, ouvert par `pre.001`; `pre.002` matérialise le shell Tauri et `pre.003` la composition `std.wallet`/composite ainsi que la préparation des répertoires. Le plan fixe aussi secrets `KSP_SECRET_WALLET_PASS_*`, inventory DataTables/path safety, lifecycle VIEW/OWNER, screen/command/DTO maps, balance HTTP, administration retenue et forecast étendu jusqu’à `pre.018` : `pre.014` polish Bootstrap/splashscreen, `pre.015` wire binaire `.kspwallet` V2 et `pre.016` APIs génériques/versionnées + runtime V2 et `pre.017` migration explicite V1 -> V2 désormais matérialisés ; prochain gate `pre.018` documentation candidate et build Tauri final. Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index 31a4220..2e2afe3 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -449,6 +449,8 @@ pre.018 documentation finale, validations, prompt 0.2.7 et cargo tauri build en rel.001 publication stable 0.2.6 ``` +`pre.017` est désormais matérialisé : la migration V1 -> V2 est explicite, OWNER-authentifiée et séparée de toute ouverture normale, avec copie no-clobber et remplacement in-place stale-protected. Le prochain gate est `pre.018`. + La tranche `pre.014` est réservée aux défauts visuels/templating observés en usage réel, notamment les scrollbars occasionnelles du splashscreen ; son contenu précis sera borné à partir du retour opérateur avant implémentation. `ROADMAP.md` reste synthétique et `CHANGELOG.md` n'est synchronisé qu'à la phase documentaire finale. ## `0.2.7` — WebSocket Solana standard diff --git a/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md b/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md index 5b5a3c0..08e22f8 100644 --- a/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md +++ b/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md @@ -770,32 +770,32 @@ outil de logging contrôlé ## 12. Command map -| Command | Entrée frontend | Sortie sûre | Délégation | -|----------------------------------------------|--------------------|-------------------------------------|-------------------------------------| -| `get_runtime_status` | — | `WalletRuntimeStatusDto` | Config/app state | -| `list_wallets` | — | `Vec` | app filesystem + Wallet inspect | -| `refresh_wallets` | — | inventory DTO | app filesystem + Wallet inspect | -| `select_wallet` | wallet id | `LockedWalletDto` | app resolver + Wallet inspect | -| `deselect_wallet` | — | session DTO | app state | -| `create_wallet` | create request | `WalletAuthorizedDto` OWNER | Wallet create file | -| `unlock_wallet_view` | password | authorized VIEW | Wallet open VIEW | -| `unlock_wallet_owner` | password | authorized OWNER | Wallet open OWNER | -| `unlock_wallet_view_with_configured_secret` | — | authorized VIEW / operation result | Config env + Wallet open VIEW | -| `unlock_wallet_owner_with_configured_secret` | — | authorized OWNER / operation result | Config env + Wallet open OWNER | -| `lock_wallet` | — | `LockedWalletDto` | drop handle + inspect | -| `refresh_wallet_balance` | — | `WalletBalanceDto` | Transport getBalance | -| `inspect_import_source` | format only | transfer inspection DTO / cancel | Rust native picker + Wallet inspect | -| `import_wallet` | import request | authorized OWNER | staged bytes + Wallet import | -| `update_wallet_alias` | alias mutation | authorized DTO | WalletOwner | -| `add_wallet_note` | note create | authorized DTO | WalletOwner | -| `update_wallet_note` | note update | authorized DTO | WalletOwner | -| `delete_wallet_note` | note id | authorized DTO | WalletOwner | -| `rotate_owner_password` | rotation request | operation result | WalletOwner | -| `rotate_view_password` | rotation request | operation result | WalletOwner | -| `disable_view` | confirmation | authorized DTO | WalletOwner | -| `recreate_view` | new VIEW password | authorized DTO | WalletOwner | -| `export_wallet_owner` | format + save path | operation result | Wallet export file | -| `emit_frontend_log` | redacted payload | `()` | Logging facade | +| Command | Entrée frontend | Sortie sûre | Délégation | +|----------------------------------------------|--------------------|-------------------------------------|---------------------------------| +| `get_runtime_status` | — | `WalletRuntimeStatusDto` | Config/app state | +| `list_wallets` | — | `Vec` | app filesystem + Wallet inspect | +| `refresh_wallets` | — | inventory DTO | app filesystem + Wallet inspect | +| `select_wallet` | wallet id | `LockedWalletDto` | app resolver + Wallet inspect | +| `deselect_wallet` | — | session DTO | app state | +| `create_wallet` | create request | `WalletAuthorizedDto` OWNER | Wallet create file | +| `unlock_wallet_view` | password | authorized VIEW | Wallet open VIEW | +| `unlock_wallet_owner` | password | authorized OWNER | Wallet open OWNER | +| `unlock_wallet_view_with_configured_secret` | — | authorized VIEW / operation result | Config env + Wallet open VIEW | +| `unlock_wallet_owner_with_configured_secret` | — | authorized OWNER / operation result | Config env + Wallet open OWNER | +| `lock_wallet` | — | `LockedWalletDto` | drop handle + inspect | +| `refresh_wallet_balance` | — | `WalletBalanceDto` | Transport getBalance | +| `inspect_import_source` | format only | transfer inspection DTO / cancel | Rust native picker + Wallet inspect | +| `import_wallet` | import request | authorized OWNER | staged bytes + Wallet import | +| `update_wallet_alias` | alias mutation | authorized DTO | WalletOwner | +| `add_wallet_note` | note create | authorized DTO | WalletOwner | +| `update_wallet_note` | note update | authorized DTO | WalletOwner | +| `delete_wallet_note` | note id | authorized DTO | WalletOwner | +| `rotate_owner_password` | rotation request | operation result | WalletOwner | +| `rotate_view_password` | rotation request | operation result | WalletOwner | +| `disable_view` | confirmation | authorized DTO | WalletOwner | +| `recreate_view` | new VIEW password | authorized DTO | WalletOwner | +| `export_wallet_owner` | format + save path | operation result | Wallet export file | +| `emit_frontend_log` | redacted payload | `()` | Logging facade | Aucune commande de signature arbitraire n'est ajoutée dans cette release. @@ -1741,7 +1741,29 @@ Une future API `_v3` pourra être ajoutée sans transformer automatiquement le d ### `pre.017` — migration V1 -> V2 + persistence/canaris -Ajouter la migration explicite et authentifiée V1 vers V2, les paths de remplacement/no-clobber nécessaires, les canaris adversariaux/interop et la régression complète Wallet Desk. Aucune migration silencieuse lors d'une simple ouverture. +**Statut : matérialisé par `0.2.6-pre.017`.** + +La migration est une opération explicite OWNER-authentifiée et reste séparée de toute ouverture normale : + +```text +migrate_wallet_v1_to_v2(...) snapshot mémoire V1 -> V2 +migrate_wallet_file_v1_to_v2(...) source V1 conservée + destination V2 no-clobber +migrate_wallet_file_v1_to_v2_in_place(...) remplacement atomique V1 -> V2 +``` + +Invariants retenus : + +- source obligatoirement V1 et authentifiée par OWNER avant conversion ; +- même identité Solana, alias, textes de notes et identifiants stables de notes après migration ; +- OWNER conserve le même mot de passe fourni à la migration ; +- VIEW reste activé ou désactivé comme dans V1 ; quand VIEW est activé, le caller fournit le mot de passe cible V2, qui peut être l'ancien ou un remplacement autorisé par OWNER ; +- aucun ciphertext/slot V1 n'est transcodé directement : V2 reçoit de nouvelles clés de contenu, nouveaux salts/nonces/slot IDs, un nouvel OWNER auth key et ses propres transcripts/AAD ; +- copy migration = destination no-clobber, source inchangée ; +- in-place migration = vérification de l'enveloppe V1 authentifiée attendue puis remplacement atomique ; un état concurrent/stale retourne `wallet.state_conflict` ; +- `wallet.migration_invalid` couvre les demandes incohérentes avec la forme VIEW source/cible ; +- une ouverture générique V1 continue de laisser le fichier en V1. + +Les canaris `pre.017` couvrent migration mémoire, conservation identité/metadata/note IDs, choix du credential VIEW cible, no-clobber, remplacement in-place, tampering, stale-state et exposition API publique. Wallet Desk reste version-neutral et non-migrant : aucune ouverture/inventory du Desk ne déclenche la migration. ### `pre.018` — documentation finale, candidate build et prompt suivant diff --git a/docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md b/docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md index 6e4f511..5f623f8 100644 --- a/docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md +++ b/docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md @@ -51,19 +51,19 @@ Le checkpoint opérateur `pre.012` du 21 août 2026 est vert : `cargo fmt`, audi ## 3. Matrice de frontières -| Frontière | Contrat `0.2.6` | Preuve durable | -|------------------------------|--------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| -| Frontend -> Config | aucun accès direct env/.env | `release_compliance::backend_keeps_config_wallet_transport_and_logging_ownership_boundaries` + ownership Config | -| Frontend -> Wallet secret | passwords uniquement request-only ; jamais de keypair en IPC | `desktop_security` + `release_compliance::response_dtos_keep_secret_key_material_out_of_ipc` | -| Frontend -> filesystem | aucun path arbitraire ; picker natif Rust | tests import/export + capability audit | -| Frontend -> réseau Solana | aucun `fetch`/WebSocket/RPC direct | `release_compliance::frontend_has_no_direct_filesystem_network_or_secret_persistence_surface` | -| Wallet Desk -> Solana crates | aucune dépendance protocole directe | `release_compliance::wallet_desk_dependency_firewall_and_tauri_capabilities_are_minimal` | -| Wallet Desk -> HTTP | `ksp-onchain-transport-lib` uniquement | manifest + smoke Devnet | -| Wallet Desk -> Wallet crypto | `ksp-wallet-lib` uniquement | manifest + feature canaries | -| Wallet Desk -> logging | façade `ksp-logging-lib` + adapter `tauri-plugin-tracing` | source audit + workspace logging canary | -| locked -> frontend | format/version/VIEW enabled seulement | `desktop_security` | -| VIEW/OWNER handles | Rust-only `AppState` | session/security tests | -| export | OWNER-only, file direct, no-clobber | `desktop_security` + runtime `pre.012` | +| Frontière | Contrat `0.2.6` | Preuve durable | +|---|---|---| +| Frontend -> Config | aucun accès direct env/.env | `release_compliance::backend_keeps_config_wallet_transport_and_logging_ownership_boundaries` + ownership Config | +| Frontend -> Wallet secret | passwords uniquement request-only ; jamais de keypair en IPC | `desktop_security` + `release_compliance::response_dtos_keep_secret_key_material_out_of_ipc` | +| Frontend -> filesystem | aucun path arbitraire ; picker natif Rust | tests import/export + capability audit | +| Frontend -> réseau Solana | aucun `fetch`/WebSocket/RPC direct | `release_compliance::frontend_has_no_direct_filesystem_network_or_secret_persistence_surface` | +| Wallet Desk -> Solana crates | aucune dépendance protocole directe | `release_compliance::wallet_desk_dependency_firewall_and_tauri_capabilities_are_minimal` | +| Wallet Desk -> HTTP | `ksp-onchain-transport-lib` uniquement | manifest + smoke Devnet | +| Wallet Desk -> Wallet crypto | `ksp-wallet-lib` uniquement | manifest + feature canaries | +| Wallet Desk -> logging | façade `ksp-logging-lib` + adapter `tauri-plugin-tracing` | source audit + workspace logging canary | +| locked -> frontend | format/version/VIEW enabled seulement | `desktop_security` | +| VIEW/OWNER handles | Rust-only `AppState` | session/security tests | +| export | OWNER-only, file direct, no-clobber | `desktop_security` + runtime `pre.012` | ## 4. Inventory / path adversarial @@ -255,4 +255,4 @@ V1 existant reste ouvrable sans migration V2 nouvellement créé/importé administration complète conservée en V2 ``` -Les canaris `pre.016` couvrent également le refus croisé des lecteurs explicitement versionnés et le maintien du format V2 après metadata/rotations/disable/recreate/self-rotation VIEW. La migration V1 -> V2 reste explicitement hors de cette tranche et appartient à `pre.017`. +Les canaris `pre.016` couvrent également le refus croisé des lecteurs explicitement versionnés et le maintien du format V2 après metadata/rotations/disable/recreate/self-rotation VIEW. `pre.017` matérialise ensuite la migration V1 -> V2 explicite : mémoire, copie no-clobber et remplacement in-place stale-protected, avec conservation identité/metadata/note IDs et canaris tampering/state-conflict. Wallet Desk reste non-migrant et continue à ouvrir/inspecter via les APIs génériques. diff --git a/prompts/011-V0_2_6_START_PROMPT.md b/prompts/011-V0_2_6_START_PROMPT.md index 0f76a62..cab5928 100644 --- a/prompts/011-V0_2_6_START_PROMPT.md +++ b/prompts/011-V0_2_6_START_PROMPT.md @@ -727,4 +727,4 @@ Après validation du polish `pre.014-fix.001`, la release est volontairement ét `pre.016` matérialise la politique figée en `pre.015` : `DEFAULT_WALLET_FORMAT = V2`, APIs génériques de création/import en V2, lecture/inspection V1/V2 auto-détectée, variantes `_v1`/`_v2` strictes et Wallet Desk consommant uniquement la façade non versionnée. Les handles OWNER/VIEW persistent leurs mutations dans le format natif ouvert ; aucune conversion implicite V1 -> V2 n'est effectuée. -La prochaine tranche est `pre.017` : migration explicite authentifiée V1 -> V2, canaris adversariaux/persistence et régression Wallet Desk. `pre.018` conserve la documentation/candidate finale et le build Tauri en dernière opération. +`pre.017` matérialise la migration explicite OWNER-authentifiée V1 -> V2 : conversion mémoire, copie filesystem no-clobber, remplacement in-place stale-protected, conservation identité/metadata/note IDs, cible VIEW explicite et canaris tampering/state-conflict. Wallet Desk reste version-neutral et aucune ouverture ne migre implicitement. La prochaine tranche est `pre.018`, dédiée à la documentation/candidate finale, au packaging/CWD et au build Tauri en toute dernière opération.