diff --git a/Cargo.toml b/Cargo.toml index 13928f1..4446e4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 152 +# version: 153 [workspace] resolver = "3" members = ["crates/ksp-app-config-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.5-pre.5.fix.1" +version = "0.2.5-pre.6" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -30,6 +30,7 @@ tracing = { version = "^0.1", default-features = false } tracing-subscriber = { version = "^0.3", default-features = false } tracing-appender = { version = "^0.2", default-features = false } tokio = { version = "^1.53", default-features = false } +tempfile = { version = "^3.27" } chrono = { version = "^0.4", default-features = false } tauri = { version = "^2.11" } tauri-build = { version = "^2.6" } diff --git a/ROADMAP.md b/ROADMAP.md index 2c8cc46..48d9b6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # Roadmap KSP @@ -49,7 +49,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U - [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables. - [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. -- [/] `0.2.5` — Wallet foundation : `pre.001` fixe le threat model et le format V1 autonome ; `pre.002` crée `ksp-wallet-lib` avec capabilities VIEW/OWNER et frontières ; `pre.003` fige le wire strict, key slots, transcript OWNER/AAD et la spécification externe ; `pre.004` matérialise Argon2id/XChaCha20-Poly1305/CSPRNG et le wrapping ; `pre.005` fixe le profil de création KSP issu du benchmark, les payloads owner-control/metadata/secret, l’autorité Ed25519 séparée et les flux in-memory create/open VIEW/OWNER avec vecteur complet interopérable. `Pubkey` reste consommée uniquement via `ksp-core-lib`; Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. `pre.006` porte ensuite la persistence atomique/no-clobber ; signature Solana publique, administration/rotations puis import/export restent répartis jusqu’à `pre.010` ; `WalletPolicy` reste exclu. +- [/] `0.2.5` — Wallet foundation : `pre.001` fixe le threat model et le format V1 autonome ; `pre.002` crée `ksp-wallet-lib` avec capabilities VIEW/OWNER et frontières ; `pre.003` fige le wire strict, key slots, transcript OWNER/AAD et la spécification externe ; `pre.004` matérialise Argon2id/XChaCha20-Poly1305/CSPRNG et le wrapping ; `pre.005` fixe le profil de création KSP issu du benchmark, les payloads owner-control/metadata/secret, l’autorité Ed25519 séparée et les flux in-memory create/open VIEW/OWNER avec vecteur complet interopérable. `Pubkey` reste consommée uniquement via `ksp-core-lib`; Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. `pre.006` ajoute maintenant la persistence async-first bornée, la création no-clobber par temp file same-directory synchronisé, les ouvertures VIEW/OWNER depuis fichier et les fault/concurrency canaries ; signature Solana publique, administration/rotations puis import/export restent répartis jusqu’à `pre.010` ; `WalletPolicy` reste exclu. - [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet. - [ ] `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. diff --git a/crates/ksp-wallet-lib/Cargo.toml b/crates/ksp-wallet-lib/Cargo.toml index c952cee..4c3816f 100644 --- a/crates/ksp-wallet-lib/Cargo.toml +++ b/crates/ksp-wallet-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-wallet-lib/Cargo.toml -# version: 5 +# version: 6 [package] name = "ksp-wallet-lib" @@ -19,6 +19,7 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true solana-keypair.workspace = true tokio = { workspace = true, features = ["rt"] } +tempfile.workspace = true zeroize.workspace = true [lints] diff --git a/crates/ksp-wallet-lib/src/lib.rs b/crates/ksp-wallet-lib/src/lib.rs index d83b199..4dd765f 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: 5 +// version: 6 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -10,7 +10,8 @@ //! error contract. `0.2.5-pre.003` freezes the strict V1 JSON envelope, canonical Base64url decoding, structural limits and deterministic //! state-transcript/AEAD-AAD byte codecs. `0.2.5-pre.004` adds the in-memory Argon2id/XChaCha20-Poly1305/CSPRNG primitives and deterministic crypto //! vectors. `0.2.5-pre.005` adds exact protected payloads, OWNER Ed25519 state authentication and async in-memory create/open flows for VIEW and OWNER. -//! Solana transaction signing and filesystem persistence remain outside this tranche. Public +//! `0.2.5-pre.006` adds bounded async-first filesystem reads plus same-directory synchronized no-clobber publication for new native files. Solana +//! transaction signing and OWNER/VIEW administration remain outside this tranche. 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`. @@ -22,6 +23,7 @@ mod metadata; mod owner; mod password; mod payload; +mod persistence; mod transcript; mod view; mod wallet; @@ -149,6 +151,14 @@ pub use self::owner::WalletOwner; pub use self::password::OwnerPassword; /// Owned VIEW password material with redacted diagnostics and drop-time zeroization. pub use self::password::ViewPassword; +/// Creates and no-clobber persists a new native `.kspwallet` V1 file. +pub use self::persistence::create_wallet_file_v1; +/// Reads and verifies a locked native `.kspwallet` V1 file. +pub use self::persistence::inspect_locked_wallet_file_v1; +/// Opens a native `.kspwallet` V1 file with OWNER capability. +pub use self::persistence::open_wallet_owner_file_v1; +/// Opens a native `.kspwallet` V1 file with VIEW capability. +pub use self::persistence::open_wallet_view_file_v1; /// Authorized VIEW capability handle. pub use self::view::WalletView; /// Creates a new in-memory native Wallet V1. diff --git a/crates/ksp-wallet-lib/src/persistence.rs b/crates/ksp-wallet-lib/src/persistence.rs new file mode 100644 index 0000000..4b592e9 --- /dev/null +++ b/crates/ksp-wallet-lib/src/persistence.rs @@ -0,0 +1,275 @@ +// file: crates/ksp-wallet-lib/src/persistence.rs +// version: 1 + +//! Async-first native Wallet V1 filesystem persistence. + +use std::io::{Read as _, Write as _}; + +/// Creates a new native `.kspwallet` V1 at `destination` without overwriting an existing path. +/// +/// The complete encrypted document is created in memory first, written and synchronized through a temporary file in the destination directory, then +/// published with no-clobber semantics. The destination directory must already exist; Wallet never discovers or creates a configured default directory. +pub async fn create_wallet_file_v1( + destination: impl std::convert::AsRef, + owner_password: crate::OwnerPassword, + view_password: std::option::Option, + metadata: crate::WalletCreateMetadataV1, +) -> ksp_core_lib::Result { + let destination = destination.as_ref().to_path_buf(); + let owner_result = crate::create_wallet_v1(owner_password, view_password, metadata).await; + let owner = match owner_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let serialized_result = owner.to_json_bytes(); + let serialized = match serialized_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let persist_result = persist_new_wallet_async(destination, serialized).await; + if let std::result::Result::Err(error) = persist_result { + return std::result::Result::Err(error); + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + operation = "wallet_create_file", + format_version = crate::KSPWALLET_FORMAT_VERSION_V1, + "native wallet persisted with no-clobber semantics" + ); + return std::result::Result::Ok(owner); +} + +/// Opens a native `.kspwallet` V1 from `source` with VIEW capability. +/// +/// The file is read through the bounded async persistence boundary before the normal strict parser, OWNER state-signature verification and VIEW KDF flow. +pub async fn open_wallet_view_file_v1( + source: impl std::convert::AsRef, + password: crate::ViewPassword, +) -> ksp_core_lib::Result { + let source = source.as_ref().to_path_buf(); + let bytes_result = read_wallet_file_async(source).await; + let bytes = match bytes_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::open_wallet_view_v1(bytes.as_slice(), password).await; +} + +/// Opens a native `.kspwallet` V1 from `source` with OWNER capability. +/// +/// The file is read through the bounded async persistence boundary before the normal strict parser, OWNER state-signature verification and OWNER KDF flow. +pub async fn open_wallet_owner_file_v1( + source: impl std::convert::AsRef, + password: crate::OwnerPassword, +) -> ksp_core_lib::Result { + let source = source.as_ref().to_path_buf(); + let bytes_result = read_wallet_file_async(source).await; + let bytes = match bytes_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::open_wallet_owner_v1(bytes.as_slice(), password).await; +} + +/// Reads and verifies the locked projection of a native `.kspwallet` V1 from `source` without running a password KDF. +pub async fn inspect_locked_wallet_file_v1(source: impl std::convert::AsRef) -> ksp_core_lib::Result { + let source = source.as_ref().to_path_buf(); + let bytes_result = read_wallet_file_async(source).await; + let bytes = match bytes_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::inspect_locked_wallet_v1(bytes.as_slice()); +} + +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, + std::result::Result::Err(error) => std::result::Result::Err(blocking_io_error("read_task", error)), + }; +} + +async fn persist_new_wallet_async(destination: std::path::PathBuf, content: std::vec::Vec) -> ksp_core_lib::Result<()> { + let task = tokio::task::spawn_blocking(move || return persist_new_wallet_blocking(destination.as_path(), content.as_slice())); + return match task.await { + std::result::Result::Ok(result) => result, + std::result::Result::Err(error) => std::result::Result::Err(blocking_atomic_error("create_task", error)), + }; +} + +fn read_wallet_file_blocking(source: &std::path::Path) -> ksp_core_lib::Result> { + let opened = std::fs::File::open(source); + let file = match opened { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(io_error("open", error)), + }; + let metadata_result = file.metadata(); + let metadata = match metadata_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(io_error("metadata", error)), + }; + if metadata.len() > crate::KSPWALLET_MAX_FILE_BYTES as u64 { + return std::result::Result::Err(oversized_document_error()); + } + + let capacity = std::cmp::min(metadata.len(), crate::KSPWALLET_MAX_FILE_BYTES as u64) as usize; + let mut bytes = std::vec::Vec::with_capacity(capacity); + let mut bounded = file.take((crate::KSPWALLET_MAX_FILE_BYTES + 1) as u64); + let read_result = bounded.read_to_end(&mut bytes); + if let std::result::Result::Err(error) = read_result { + return std::result::Result::Err(io_error("read", error)); + } + if bytes.len() > crate::KSPWALLET_MAX_FILE_BYTES { + return std::result::Result::Err(oversized_document_error()); + } + return std::result::Result::Ok(bytes); +} + +fn persist_new_wallet_blocking(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { + return persist_new_wallet_with_hook(destination, content, || return std::result::Result::Ok(())); +} + +fn persist_new_wallet_with_hook(destination: &std::path::Path, content: &[u8], before_publish: F) -> ksp_core_lib::Result<()> +where + F: std::ops::FnOnce() -> ksp_core_lib::Result<()>, +{ + if content.len() > crate::KSPWALLET_MAX_FILE_BYTES { + return std::result::Result::Err(oversized_document_error()); + } + let parent = destination_parent(destination); + let parent = match parent { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if destination.file_name().is_none() { + return std::result::Result::Err(atomic_error("destination", "Wallet destination has no file name")); + } + + let temporary_result = tempfile::Builder::new().prefix(".kspwallet-write-").suffix(".tmp").tempfile_in(parent); + let mut temporary = match temporary_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(atomic_io_error("temporary_create", error)), + }; + let write_result = temporary.write_all(content); + if let std::result::Result::Err(error) = write_result { + return std::result::Result::Err(atomic_io_error("temporary_write", error)); + } + let sync_result = temporary.as_file().sync_all(); + if let std::result::Result::Err(error) = sync_result { + return std::result::Result::Err(atomic_io_error("temporary_sync", error)); + } + + let hook_result = before_publish(); + if let std::result::Result::Err(error) = hook_result { + return std::result::Result::Err(error); + } + + let persist_result = temporary.persist_noclobber(destination); + let persisted = match persist_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + return std::result::Result::Err(destination_exists_error()); + } + return std::result::Result::Err(atomic_io_error("publish_noclobber", error.error)); + }, + }; + let final_sync_result = persisted.sync_all(); + if let std::result::Result::Err(error) = final_sync_result { + return std::result::Result::Err(atomic_io_error("published_file_sync", error)); + } + sync_parent_directory_best_effort(parent); + return std::result::Result::Ok(()); +} + +fn destination_parent(destination: &std::path::Path) -> ksp_core_lib::Result<&std::path::Path> { + return match destination.parent() { + std::option::Option::Some(parent) if !parent.as_os_str().is_empty() => std::result::Result::Ok(parent), + std::option::Option::Some(_) => std::result::Result::Ok(std::path::Path::new(".")), + std::option::Option::None => std::result::Result::Err(atomic_error("destination", "Wallet destination has no parent directory")), + }; +} + +#[cfg(unix)] +fn sync_parent_directory_best_effort(parent: &std::path::Path) { + let opened = std::fs::File::open(parent); + let directory = match opened { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + operation = "wallet_parent_directory_sync", + io_kind = ?error.kind(), + "wallet publication succeeded but parent-directory durability sync could not start" + ); + return; + }, + }; + if let std::result::Result::Err(error) = directory.sync_all() { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + operation = "wallet_parent_directory_sync", + io_kind = ?error.kind(), + "wallet publication succeeded but parent-directory durability sync failed" + ); + } + return; +} + +#[cfg(not(unix))] +fn sync_parent_directory_best_effort(_parent: &std::path::Path) { + return; +} + +fn oversized_document_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet document exceeds the V1 size limit").with_context("field", "document"); +} + +fn destination_exists_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_DESTINATION_EXISTS, "Wallet destination already exists"); +} + +fn io_error(operation: &'static str, source: std::io::Error) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Wallet filesystem I/O failed") + .with_context("operation", operation) + .with_source(source); +} + +fn atomic_error(operation: &'static str, message: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED, message).with_context("operation", operation); +} + +fn atomic_io_error(operation: &'static str, source: std::io::Error) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED, "Atomic Wallet persistence failed") + .with_context("operation", operation) + .with_source(source); +} + +fn blocking_io_error(operation: &'static str, source: tokio::task::JoinError) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Wallet filesystem task failed") + .with_context("operation", operation) + .with_source(source); +} + +fn blocking_atomic_error(operation: &'static str, source: tokio::task::JoinError) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED, "Atomic Wallet persistence task failed") + .with_context("operation", operation) + .with_source(source); +} + +#[cfg(test)] +pub(crate) fn persist_new_wallet_fault_before_publish(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { + return persist_new_wallet_with_hook(destination, content, || { + return std::result::Result::Err(atomic_error("fault_injection", "Injected Wallet persistence failure before publication")); + }); +} + +#[cfg(test)] +pub(crate) fn persist_new_wallet_for_test(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { + return persist_new_wallet_blocking(destination, content); +} + +#[cfg(test)] +#[path = "../unit_tests/persistence.rs"] +mod tests; diff --git a/crates/ksp-wallet-lib/tests/dependency_boundary.rs b/crates/ksp-wallet-lib/tests/dependency_boundary.rs index 15bea13..0042de8 100644 --- a/crates/ksp-wallet-lib/tests/dependency_boundary.rs +++ b/crates/ksp-wallet-lib/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/tests/dependency_boundary.rs -// version: 7 +// version: 8 //! Wallet-specific dependency and ownership canaries. @@ -49,6 +49,7 @@ fn wallet_manifest_preserves_dependency_firewall() -> std::io::Result<()> { assert!(manifest.contains("serde_json.workspace = true")); assert!(manifest.contains("solana-keypair.workspace = true")); assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }")); + assert!(manifest.contains("tempfile.workspace = true")); assert!(manifest.contains("zeroize.workspace = true")); for forbidden in [ "ksp-config-lib", diff --git a/crates/ksp-wallet-lib/tests/public_api.rs b/crates/ksp-wallet-lib/tests/public_api.rs index 51dae66..347e8bd 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: 4 +// version: 5 //! Public API canaries for the Wallet foundation. @@ -92,3 +92,23 @@ fn public_pre_005_create_open_and_calibrated_defaults_are_available_from_crate_r assert_eq!(ksp_wallet_lib::KSPWALLET_V1_DEFAULT_ARGON2_PARALLELISM, 1); assert_eq!(ksp_wallet_lib::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES, 32); } + +#[test] +fn public_pre_006_file_persistence_surface_is_available_from_crate_root() { + let path = std::path::Path::new("not-polled.kspwallet"); + let owner_password = ksp_wallet_lib::OwnerPassword::new("public-pre006-owner-password").expect("public test OWNER password must be valid"); + let create_future = + ksp_wallet_lib::create_wallet_file_v1(path, owner_password, std::option::Option::None, ksp_wallet_lib::WalletCreateMetadataV1::default()); + drop(create_future); + + let view_password = ksp_wallet_lib::ViewPassword::new("public-pre006-view-password").expect("public test VIEW password must be valid"); + let view_future = ksp_wallet_lib::open_wallet_view_file_v1(path, view_password); + drop(view_future); + + let owner_password = ksp_wallet_lib::OwnerPassword::new("public-pre006-owner-password").expect("public test OWNER password must be valid"); + let owner_future = ksp_wallet_lib::open_wallet_owner_file_v1(path, owner_password); + drop(owner_future); + + let inspect_future = ksp_wallet_lib::inspect_locked_wallet_file_v1(path); + drop(inspect_future); +} diff --git a/crates/ksp-wallet-lib/unit_tests/persistence.rs b/crates/ksp-wallet-lib/unit_tests/persistence.rs new file mode 100644 index 0000000..e425e0c --- /dev/null +++ b/crates/ksp-wallet-lib/unit_tests/persistence.rs @@ -0,0 +1,130 @@ +// file: crates/ksp-wallet-lib/unit_tests/persistence.rs +// version: 1 + +fn temp_directory() -> std::io::Result { + return tempfile::tempdir(); +} + +#[test] +fn no_clobber_create_keeps_the_first_published_document() { + let directory = temp_directory().expect("Wallet persistence test directory must be creatable"); + let destination = directory.path().join("wallet.kspwallet"); + let first = b"first-wallet"; + let second = b"second-wallet"; + + crate::persistence::persist_new_wallet_for_test(destination.as_path(), first).expect("first no-clobber publication must succeed"); + let second_result = crate::persistence::persist_new_wallet_for_test(destination.as_path(), second); + let error = second_result.expect_err("second publication must not overwrite an existing wallet"); + assert_eq!(error.code(), crate::ERROR_CODE_DESTINATION_EXISTS); + let persisted = std::fs::read(destination.as_path()).expect("published wallet must remain readable"); + assert_eq!(persisted, first); +} + +#[test] +fn injected_failure_before_publish_leaves_no_destination_or_partial_wallet() { + let directory = temp_directory().expect("Wallet persistence test directory must be creatable"); + let destination = directory.path().join("wallet.kspwallet"); + let result = crate::persistence::persist_new_wallet_fault_before_publish(destination.as_path(), b"candidate-wallet"); + let error = result.expect_err("fault injection must abort before publication"); + assert_eq!(error.code(), crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED); + assert!(!destination.exists()); + + let entries = std::fs::read_dir(directory.path()).expect("Wallet persistence test directory must remain readable"); + let count = entries.count(); + assert_eq!(count, 0, "temporary artifacts should be cleaned on ordinary error unwinding"); +} + +#[test] +fn concurrent_no_clobber_publish_has_exactly_one_winner() { + let directory = temp_directory().expect("Wallet persistence test directory must be creatable"); + let destination = directory.path().join("wallet.kspwallet"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + let mut handles = std::vec::Vec::new(); + + for index in 0_u8..8 { + let destination = destination.clone(); + let barrier = std::sync::Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + barrier.wait(); + let content = [index; 32]; + return crate::persistence::persist_new_wallet_for_test(destination.as_path(), content.as_slice()); + })); + } + + let mut success_count = 0_usize; + let mut exists_count = 0_usize; + let mut unexpected_code = std::option::Option::None; + for handle in handles { + let result = handle.join().expect("Wallet persistence test worker must not panic"); + match result { + std::result::Result::Ok(()) => success_count += 1, + std::result::Result::Err(error) if error.code() == crate::ERROR_CODE_DESTINATION_EXISTS => exists_count += 1, + std::result::Result::Err(error) => unexpected_code = std::option::Option::Some(error.code()), + } + } + assert_eq!(unexpected_code, std::option::Option::None); + assert_eq!(success_count, 1); + assert_eq!(exists_count, 7); + assert_eq!(std::fs::metadata(destination.as_path()).expect("winning wallet must exist").len(), 32); +} + +#[test] +fn bounded_reader_rejects_oversized_wallet_before_parser_allocation() { + let directory = temp_directory().expect("Wallet persistence test directory must be creatable"); + let destination = directory.path().join("oversized.kspwallet"); + let oversized = std::vec![b'x'; crate::KSPWALLET_MAX_FILE_BYTES + 1]; + std::fs::write(destination.as_path(), oversized).expect("oversized fixture must be writable"); + + let runtime = tokio::runtime::Builder::new_current_thread().build().expect("Wallet persistence test runtime must build"); + let result = runtime.block_on(crate::inspect_locked_wallet_file_v1(destination.as_path())); + let error = result.expect_err("oversized Wallet file must be rejected before parsing"); + assert_eq!(error.code(), crate::ERROR_CODE_FORMAT_INVALID); +} + +#[test] +fn public_file_create_and_locked_inspect_round_trip_without_revealing_identity() { + let directory = temp_directory().expect("Wallet persistence test directory must be creatable"); + let destination = directory.path().join("created.kspwallet"); + let runtime = tokio::runtime::Builder::new_current_thread().build().expect("Wallet persistence test runtime must build"); + let owner_password = crate::OwnerPassword::new("pre006-owner-password").expect("test OWNER password must be valid"); + let created = runtime + .block_on(crate::create_wallet_file_v1(destination.as_path(), owner_password, std::option::Option::None, crate::WalletCreateMetadataV1::default())) + .expect("native Wallet file creation must succeed"); + let locked = runtime.block_on(crate::inspect_locked_wallet_file_v1(destination.as_path())).expect("persisted Wallet must inspect successfully"); + + assert!(!locked.view_enabled()); + assert_eq!(created.capability(), crate::WalletCapability::Owner); + assert!(destination.exists()); + let second_password = crate::OwnerPassword::new("pre006-other-owner-password").expect("second test OWNER password must be valid"); + let second = runtime.block_on(crate::create_wallet_file_v1( + destination.as_path(), + second_password, + std::option::Option::None, + crate::WalletCreateMetadataV1::default(), + )); + assert_eq!(second.expect_err("create must remain no-clobber").code(), crate::ERROR_CODE_DESTINATION_EXISTS); +} + +#[test] +fn persisted_full_vector_opens_view_and_owner_through_file_apis() { + let directory = temp_directory().expect("Wallet persistence test directory must be creatable"); + let destination = directory.path().join("vector.kspwallet"); + let vector = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json"); + crate::persistence::persist_new_wallet_for_test(destination.as_path(), vector).expect("full vector must publish through no-clobber persistence"); + + let runtime = tokio::runtime::Builder::new_current_thread().build().expect("Wallet persistence test runtime must build"); + let view_password = crate::ViewPassword::new("pre005-view-password").expect("test VIEW password must be valid"); + let view = runtime + .block_on(crate::open_wallet_view_file_v1(destination.as_path(), view_password)) + .expect("persisted full vector must open through VIEW file API"); + let owner_password = crate::OwnerPassword::new("pre005-owner-password").expect("test OWNER password must be valid"); + let owner = runtime + .block_on(crate::open_wallet_owner_file_v1(destination.as_path(), owner_password)) + .expect("persisted full vector must open through OWNER file API"); + + assert_eq!(view.capability(), crate::WalletCapability::View); + assert_eq!(owner.capability(), crate::WalletCapability::Owner); + assert_eq!(view.pubkey(), owner.pubkey()); + assert_eq!(view.alias(), std::option::Option::Some("pre005-vector-wallet")); + assert_eq!(owner.alias(), std::option::Option::Some("pre005-vector-wallet")); +} diff --git a/deltas/0.2.5/pre.006.md b/deltas/0.2.5/pre.006.md new file mode 100644 index 0000000..3fcbce9 --- /dev/null +++ b/deltas/0.2.5/pre.006.md @@ -0,0 +1,155 @@ + + + +# Delta `0.2.5-pre.006` — persistence native async/no-clobber + +## Base + +```text +0.2.5-pre.5.fix.1 +``` + +Le checkpoint opérateur de cette base a validé `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-wallet-lib`, `cargo test --workspace` et l'audit Cargo tree. Une seule génération `ed25519-dalek 2.2.0` est utilisée et `solana-address 2.7.0` est unifié entre Core et `solana-keypair`. + +## Version technique + +```text +workspace.package.version = 0.2.5-pre.6 +``` + +## Objet + +Cette tranche matérialise la persistence native `.kspwallet` sans Config, environnement, Transport ou Tauri : + +```text +create_wallet_file_v1(path, ...) +open_wallet_view_file_v1(path, ...) +open_wallet_owner_file_v1(path, ...) +inspect_locked_wallet_file_v1(path) +``` + +Le chemin est toujours fourni explicitement par le caller. Wallet ne possède et ne découvre aucun répertoire par défaut. + +## Persistence retenue + +`tempfile ^3.27` est ajouté sous `[workspace.dependencies]` puis consommé par `ksp-wallet-lib` avec `tempfile.workspace = true`. + +La création native suit : + +```text +create crypto in-memory +-> serialize locked JSON +-> NamedTempFile dans le même répertoire que la destination +-> write_all +-> sync_all(temp) +-> persist_noclobber(destination) +-> sync_all(fichier publié) +-> parent-directory sync best-effort sur Unix +``` + +`create` ne propose aucun overwrite. Une destination existante retourne `wallet.destination_exists` même sous concurrence. + +La documentation upstream de `tempfile 3.27.0` précise que `persist_noclobber` ne peut pas être présenté comme atomique sur absolument toutes les plateformes/filesystems et peut laisser un hard-link temporaire dans certains scénarios de crash. KSP conserve donc une documentation explicite de cette limite : no-clobber est le contrat, tandis que la durabilité power-loss universelle n'est pas revendiquée. + +## Async / lecture bornée + +Les opérations filesystem bloquantes sont regroupées dans `tokio::task::spawn_blocking`; aucune feature Tokio filesystem supplémentaire n'est requise. + +La lecture : + +- ouvre le fichier dans la frontière blocking ; +- vérifie d'abord la taille metadata ; +- utilise ensuite une lecture bornée à `KSPWALLET_MAX_FILE_BYTES + 1` afin de couvrir aussi un fichier qui grossirait pendant la lecture ; +- rejette un document surdimensionné avant parsing/KDF. + +Les wrappers fichier délèguent ensuite aux flows `pre.005` déjà validés et n'introduisent aucune seconde implémentation crypto. + +## Fault / concurrence + +Nouveaux canaries déterministes : + +- première publication no-clobber conservée face à une seconde tentative ; +- fault injecté après `sync_all(temp)` mais avant publication : aucune destination partielle ; +- cleanup du temp lors d'un retour d'erreur ordinaire ; +- huit créateurs concurrents : exactement un succès et sept `destination_exists` ; +- rejet pré-parser d'un fichier supérieur à 1 MiB ; +- création publique vers fichier puis projection locked ; +- le vecteur complet `pre.005` peut être publié puis ouvert par les nouvelles API VIEW et OWNER depuis fichier. + +Un kill/power-loss brutal peut laisser un artifact temporaire selon l'OS/filesystem ; la tranche ne prétend pas simuler ni garantir le cleanup après arrêt non déroulé. + +## Frontières conservées + +```text +Wallet -> ksp-core-lib +Wallet -> ksp-logging-lib +Wallet -> primitives crypto/Solana retenues +Wallet -> tempfile + +Wallet -X-> Config +Wallet -X-> Transport +Wallet -X-> ExecutionPolicy +Wallet -X-> Store +Wallet -X-> Tauri +Wallet -X-> tracing direct +Wallet -X-> solana-pubkey direct +``` + +`Pubkey` reste consommée exclusivement via `ksp_core_lib::Pubkey`. `solana-keypair` reste propriété de Wallet parce qu'il encapsule du matériau secret et une capacité de signature ; aucune keypair brute n'est remontée dans Core. + +## Replace administratif + +`pre.006` n'expose volontairement pas une primitive publique générale d'overwrite/replace. Le remplacement atomique d'un wallet existant sera introduit en `pre.007` à travers les seules opérations autorisées : administration OWNER et self-rotation du password VIEW. Cela évite de donner à un consumer de niveau inférieur une primitive arbitraire `replace(path, bytes)`. + +## Fichiers ajoutés + +```text +crates/ksp-wallet-lib/src/persistence.rs +crates/ksp-wallet-lib/unit_tests/persistence.rs +deltas/0.2.5/pre.006.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +ROADMAP.md +crates/ksp-wallet-lib/Cargo.toml +crates/ksp-wallet-lib/src/lib.rs +crates/ksp-wallet-lib/tests/dependency_boundary.rs +crates/ksp-wallet-lib/tests/public_api.rs +docs/formats/KSPWALLET_V1.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md +``` + +## Validation opérateur requise + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-wallet-lib +cargo test --workspace + +cargo tree -p ksp-wallet-lib +cargo tree -p ksp-wallet-lib -d +cargo tree -i tempfile@3.27.0 +``` + +Points à contrôler dans le tree : pas de nouvelle dépendance Config/Transport/Tauri, `tempfile` uniquement par Wallet dans cette tranche et aucune duplication crypto nouvelle injustifiée. + +## Suite + +Après validation, `0.2.5-pre.007` porte : + +```text +signature Solana publique sans getter secret +alias/notes OWNER +rotation password VIEW par VIEW +rotation password VIEW par OWNER sans ancien password VIEW +rotation password OWNER +suppression/recréation VIEW +révocation forte VIEW avec rekey metadata +replace atomique lié aux mutations autorisées +``` diff --git a/docs/formats/KSPWALLET_V1.md b/docs/formats/KSPWALLET_V1.md index 8ce88a4..934966b 100644 --- a/docs/formats/KSPWALLET_V1.md +++ b/docs/formats/KSPWALLET_V1.md @@ -1,5 +1,5 @@ - + # `.kspwallet` V1 — spécification du format natif Wallet KSP @@ -764,7 +764,7 @@ Une création KSP V1 en mémoire suit conceptuellement : 10. vérifier immédiatement la signature produite avant de retourner le handle OWNER. ``` -Les opérations Argon2id sont exécutées hors du thread executor async par une frontière blocking dédiée. La création `pre.005` est **in-memory** : la persistence arrive en `pre.006`. +Les opérations Argon2id sont exécutées hors du thread executor async par une frontière blocking dédiée. La construction cryptographique reste entièrement en mémoire ; `pre.006` ajoute ensuite la publication filesystem no-clobber décrite en section 23. ### 21.2 Ouverture VIEW @@ -830,3 +830,22 @@ wallet JSON complet Le vecteur a été généré et revérifié indépendamment du code Rust avec Argon2id, XChaCha20-Poly1305 construit via HChaCha20 + ChaCha20-Poly1305 IETF et Ed25519. Une implémentation externe conforme doit pouvoir reproduire les mêmes dérivations, déchiffrements et vérifications à partir des deux fichiers sans dépendre d'un type Rust KSP. +## 23. Persistence native matérialisée par `pre.006` + +La persistence ne modifie aucun octet du wire V1. Elle définit uniquement comment un document V1 complet est publié et relu : + +```text +caller path explicite + -> temp file unique dans le même répertoire + -> écriture complète des bytes JSON verrouillés + -> sync_all(temp) + -> persist_noclobber(destination) + -> sync_all(fichier publié) + -> sync parent directory best-effort sur Unix +``` + +`create_wallet_file_v1` n'écrase jamais une destination existante. Une collision ou une course concurrente retourne `wallet.destination_exists`; il n'existe aucun mode overwrite pour create/import. Les ouvertures fichier sont bornées à `KSPWALLET_MAX_FILE_BYTES` avant parser/KDF et délèguent ensuite exactement aux procédures VIEW/OWNER des sections 21.2/21.3. + +La couche filesystem est async-first, mais les appels OS bloquants sont regroupés derrière `tokio::task::spawn_blocking`. KSP ne lit ni Config ni environnement pour choisir le chemin et ne journalise pas les chemins par défaut. + +La durabilité est décrite sans surpromesse : `persist_noclobber` ne garantit pas une atomicité universelle sur tous les filesystems et un crash brutal peut laisser un artefact ou hard-link temporaire complet. KSP garantit le no-clobber et l'absence de publication partielle dans le flux normal et dans les fault tests avant publication ; il ne garantit pas la suppression des temporaires après kill/power-loss ni une durabilité identique de l'entrée de répertoire sur tous les OS. Ces limites concernent la persistence et ne changent pas les garanties cryptographiques du format. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index a0d5f75..7d838cf 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -1,5 +1,5 @@ - + # Séquence des releases fonctionnelles KSP @@ -416,6 +416,8 @@ La release doit fournir `docs/formats/KSPWALLET_V1.md` comme spécification sép `0.2.5-pre.005` consomme ce benchmark (`64 MiB / 3 / 1 ≈ 1742 ms`, `128 MiB ≈ 3459 ms`, `256 MiB ≈ 6925 ms` sur la machine opérateur) et retient pour les créations KSP V1 `65 536 KiB / 3 / 1` avec salt 32 octets, tout en conservant les paramètres sérialisés comme autorité de lecture de chaque wallet. La tranche fixe les payloads `owner_control` (seed Ed25519 d’administration + `K_metadata` + `K_secret`), metadata strictes et secret Solana 64 octets, ajoute une autorité Ed25519 séparée de la keypair Solana et vérifie sa signature d’état avant tout KDF. Les API in-memory `create_wallet_v1`, `open_wallet_view_v1`, `open_wallet_owner_v1` et `inspect_locked_wallet_v1` matérialisent l’indépendance VIEW/OWNER : VIEW ne déchiffre jamais `owner_control`/secret, OWNER ne dépend pas de VIEW et la Pubkey reste un `ksp_core_lib::Pubkey`. Un vecteur `.kspwallet` complet test-only, revérifiable hors Rust, ferme l’interop de cette tranche. La persistence filesystem reste explicitement `pre.006`; signature Solana publique et mutations/rotations restent `pre.007+`. +`0.2.5-pre.006` ajoute la persistence native sans Config : `create_wallet_file_v1` reçoit un chemin explicite du caller et publie uniquement en no-clobber via un fichier temporaire créé dans le même répertoire, écrit puis `sync_all` avant `persist_noclobber`; `open_wallet_view_file_v1`, `open_wallet_owner_file_v1` et `inspect_locked_wallet_file_v1` effectuent une lecture bornée à la limite V1 avant de déléguer au parser/crypto acquis. Les opérations filesystem bloquantes sont isolées par `spawn_blocking`. Les tests couvrent destination existante, concurrence avec un seul gagnant, fault injection avant publication, cleanup ordinaire des temporaires et rejet d'un fichier surdimensionné. La synchronisation du répertoire parent est best-effort sur Unix et n'est pas transformée en garantie portable de crash-durability. Les ACL/permissions OS restent hors du modèle Wallet. + ## `0.2.6` — Wallet Desk Mission : valider Config composite + `.kspwallet` + transport HTTP dans une application Tauri mince. diff --git a/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md b/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md index 2404665..c646c46 100644 --- a/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md +++ b/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.5` — Wallet foundation @@ -432,14 +432,14 @@ L'absence d'une ligne « audited » dans ce document ne signifie donc pas « sû ### 8.7 Encodage et persistence -État au terme de `pre.005` : +État au terme de `pre.006` : ```text -base64 0.23.1 acquis depuis pre.003 pour Base64url sans padding des champs binaires JSON -tempfile 3.27.0 candidate pre.006 pour temp files same-directory + persist/persist_noclobber +base64 0.23.1 acquis depuis pre.003 pour Base64url sans padding des champs binaires JSON +tempfile 3.27.0 acquis en pre.006 pour temp files same-directory + publication no-clobber ``` -`tempfile` ne sera ajouté que si `pre.006` confirme sa sémantique portable et son besoin réel pour l'atomicité/no-clobber. +`pre.006` retient `NamedTempFile::new_in` et `persist_noclobber` parce que la publication reste sur le même filesystem et ne peut pas écraser une destination existante. La documentation upstream précise que `persist_noclobber` n'est pas atomique sur absolument toutes les plateformes/filesystems et peut laisser un hard-link temporaire après certains crashes : KSP documente cette limite au lieu de surpromettre une durabilité universelle. ## 9. Format natif `.kspwallet` V1 @@ -783,7 +783,7 @@ Pour créer/remplacer un `.kspwallet` : 6. synchroniser le répertoire parent lorsque la plateforme le permet ; 7. ne jamais considérer un fichier partiellement écrit comme nouveau wallet valide. -`tempfile::NamedTempFile`/`persist_noclobber`/`persist` est la candidate de `pre.006`, complétée par les fsync nécessaires. Sa sémantique exacte doit être testée par plateforme avant de revendiquer la durabilité crash-safe. +`pre.006` retient `tempfile::NamedTempFile` créé dans le même répertoire que la destination et `persist_noclobber` pour les créations. Le temp file est synchronisé avant publication puis le fichier publié est resynchronisé. Sur Unix, une synchronisation du répertoire parent est tentée en best-effort ; son échec est journalisé sans transformer une publication déjà réussie en échec ambigu. KSP ne revendique donc pas une durabilité power-loss/crash-safe universelle sur tous les OS/filesystems. ### 14.2 No-clobber @@ -803,7 +803,7 @@ L'import ne fournit aucun mode overwrite/replace d'un `.kspwallet` existant et n ### 14.4 Écriture interrompue -Un crash peut laisser un temp artifact complet à nettoyer ; l'invariant prioritaire est de ne pas transformer l'ancien wallet valide en fichier partiellement remplacé. Les ACL/permissions du système d'exploitation ne font pas partie des garanties de `ksp-wallet-lib`. +`pre.006` teste un fault injecté après écriture+`sync_all` du temp file mais avant publication : aucune destination partielle n'apparaît et le temp est nettoyé lors d'un retour d'erreur normal. Un crash brutal du processus peut néanmoins laisser un temp artifact/hard-link complet selon la plateforme ; KSP ne traite jamais ces noms temporaires comme le wallet destination et ne promet pas leur suppression après kill/power-loss. L'invariant prioritaire est que `create` ne publie jamais un contenu partiel et ne remplace jamais une destination existante. Les ACL/permissions du système d'exploitation ne font pas partie des garanties de `ksp-wallet-lib`. ## 15. Erreurs et non-oracle @@ -1049,7 +1049,20 @@ create/open KDF via spawn_blocking vecteur complet test-only généré et revérifié indépendamment du Rust ``` -La persistence, la signature Solana publique, les mutations metadata et les rotations restent volontairement absentes de cette tranche. +La signature Solana publique, les mutations metadata et les rotations restent volontairement absentes de cette tranche ; la persistence est désormais acquise par `pre.006`. + +### 19.8 État acquis après `pre.006` + +`pre.006` ajoute la frontière filesystem sans Config ni environnement : + +```text +create_wallet_file_v1(path, ...) -> create in-memory -> JSON verrouillé -> temp same-directory -> sync_all -> persist_noclobber +open_wallet_view_file_v1(path, ...) -> bounded read -> open VIEW acquis +open_wallet_owner_file_v1(path, ...) -> bounded read -> open OWNER acquis +inspect_locked_wallet_file_v1(path) -> bounded read -> signature-state verify sans KDF +``` + +La lecture est bornée à `KSPWALLET_MAX_FILE_BYTES` avant parsing, y compris si le fichier grossit entre metadata et lecture. Le filesystem blocking est exécuté via `tokio::task::spawn_blocking`. La création concurrente a exactement un gagnant ; les autres reçoivent `wallet.destination_exists`. Aucun répertoire n'est créé ou choisi par Wallet. Le replace administratif reste volontairement pour `pre.007`, où il pourra être lié à une capability OWNER ou à la self-rotation VIEW au lieu d'exposer une primitive générale d'overwrite. ## 20. Sizing @@ -1110,7 +1123,7 @@ Toutes les dépendances tierces communes restent centralisées sous `[workspace. Candidates restantes, à réauditer juste avant insertion : ```text -tempfile ^3.27 # pre.006 si la sémantique atomic/no-clobber est confirmée +tempfile ^3.27 # acquis pre.006 : temp same-directory + persist_noclobber solana-signer ^3.0 # seulement si un contrat public/impl l'exige réellement solana-signature ^3.5 # seulement si le type public l'exige ``` @@ -1218,4 +1231,4 @@ Une future `format_version >= 2` pourra réétudier des facteurs/ancrages extern ## 26. Suite immédiate -`0.2.5-pre.003` fige le codec JSON strict, les limites structurelles, `slot_id` 16 octets, le descripteur VIEW, les DTOs d’enveloppe/key slots, les TLV transcript/AAD et la première spécification `docs/formats/KSPWALLET_V1.md`. `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS et le wrapping de content keys. Le benchmark opérateur a ensuite permis à `pre.005` de retenir le profil initial `64 MiB / 3 / 1`, de figer les payloads `owner_control`/metadata/secret, d'introduire l'autorité Ed25519 OWNER distincte de la keypair Solana, de créer/ouvrir réellement VIEW et OWNER en mémoire et de publier un vecteur `.kspwallet` complet interopérable. **La suite immédiate est `pre.006` : persistence async/atomique/no-clobber**, sans déplacer de logique filesystem dans Config. +`0.2.5-pre.003` fige le codec JSON strict, les limites structurelles, `slot_id` 16 octets, le descripteur VIEW, les DTOs d’enveloppe/key slots, les TLV transcript/AAD et la première spécification `docs/formats/KSPWALLET_V1.md`. `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS et le wrapping de content keys. Le benchmark opérateur a ensuite permis à `pre.005` de retenir le profil initial `64 MiB / 3 / 1`, de figer les payloads `owner_control`/metadata/secret, d'introduire l'autorité Ed25519 OWNER distincte de la keypair Solana, de créer/ouvrir réellement VIEW et OWNER en mémoire et de publier un vecteur `.kspwallet` complet interopérable. **La suite immédiate est `pre.007` : signature Solana publique + administration metadata/passwords/VIEW**, sans déplacer de logique filesystem dans Config.