From 1125ade4c156416eb21f3ff2ef06bcf0d986b7e3 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 19 Aug 2026 18:47:06 +0200 Subject: [PATCH] v0.2.5-pre.008 --- Cargo.toml | 4 +- ROADMAP.md | 2 +- crates/ksp-wallet-lib/src/constants.rs | 6 +- crates/ksp-wallet-lib/src/lib.rs | 29 +- crates/ksp-wallet-lib/src/owner.rs | 47 ++- crates/ksp-wallet-lib/src/persistence.rs | 6 +- crates/ksp-wallet-lib/src/transfer.rs | 397 ++++++++++++++++++ crates/ksp-wallet-lib/src/wallet.rs | 50 ++- .../tests/dependency_boundary.rs | 3 +- crates/ksp-wallet-lib/tests/public_api.rs | 42 +- crates/ksp-wallet-lib/unit_tests/transfer.rs | 241 +++++++++++ deltas/0.2.5/pre.008.md | 223 ++++++++++ docs/IDEAS.md | 6 +- docs/formats/KSPWALLET_V1.md | 72 +++- docs/plans/000-README.md | 4 +- docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md | 4 +- .../012-V0_2_5_WALLET_FOUNDATION_PLAN.md | 4 +- 17 files changed, 1104 insertions(+), 36 deletions(-) create mode 100644 crates/ksp-wallet-lib/src/transfer.rs create mode 100644 crates/ksp-wallet-lib/unit_tests/transfer.rs create mode 100644 deltas/0.2.5/pre.008.md diff --git a/Cargo.toml b/Cargo.toml index 141d61e..29af99f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 156 +# version: 157 [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.7.fix.1" +version = "0.2.5-pre.8" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/ROADMAP.md b/ROADMAP.md index 3d90cd3..8589d49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ; `pre.006` ajoute la persistence async-first bornée, la création no-clobber et les ouvertures fichier avec fault/concurrency canaries ; `pre.007` ajoute signature Solana OWNER, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW avec rekey metadata fort et replacement administratif capability-bound avec détection `wallet.state_conflict` des handles stale/mauvaises cibles. `Pubkey` reste consommée uniquement via `ksp-core-lib`, la keypair reste encapsulée par Wallet, Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet et `WalletPolicy` reste exclu. Prochaine tranche : `pre.008` import/export adapters + Solana CLI JSON + Base58 générique. +- [/] `0.2.5` — Wallet foundation : `pre.001` fixe threat model/format autonome ; `pre.002` crée la crate et les capabilities ; `pre.003` fige wire/transcript/AAD ; `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG ; `pre.005` fixe defaults benchmarkés, payloads, autorité Ed25519 et create/open ; `pre.006` ajoute persistence no-clobber ; `pre.007` ajoute signature Solana, administration, rotations et révocation forte VIEW ; `pre.008` ajoute maintenant inspection/import/export Solana CLI JSON + keypair Base58 complet, avec import natif no-clobber et export OWNER uniquement. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. `pre.009` = audit security/compliance ; `pre.010` = documentation/interop/prompt Wallet Desk. - [ ] `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/src/constants.rs b/crates/ksp-wallet-lib/src/constants.rs index 1485562..b556ec7 100644 --- a/crates/ksp-wallet-lib/src/constants.rs +++ b/crates/ksp-wallet-lib/src/constants.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/src/constants.rs -// version: 4 +// version: 5 //! Wallet-owned constants. @@ -9,6 +9,10 @@ pub const KSPWALLET_MAGIC: &str = "KSPWALLET"; pub const KSPWALLET_FORMAT_VERSION_V1: u32 = 1; /// Maximum accepted `.kspwallet` document size before JSON parsing. pub const KSPWALLET_MAX_FILE_BYTES: usize = 1024 * 1024; +/// Maximum accepted Solana CLI keypair JSON transfer size before parsing. +pub const KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES: usize = 1024; +/// Maximum accepted canonical Base58 keypair transfer size before decoding. +pub const KSPWALLET_TRANSFER_MAX_BASE58_BYTES: usize = 128; /// Maximum protected alias size in UTF-8 bytes for metadata V1. pub const KSPWALLET_V1_MAX_ALIAS_BYTES: usize = 256; /// Maximum number of protected notes in metadata V1. diff --git a/crates/ksp-wallet-lib/src/lib.rs b/crates/ksp-wallet-lib/src/lib.rs index c74a3b7..8b48e4b 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: 7 +// version: 8 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -12,7 +12,9 @@ //! 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. //! `0.2.5-pre.006` adds bounded async-first filesystem reads plus same-directory synchronized no-clobber publication for new native files. `0.2.5-pre.007` //! adds Solana message signing, protected metadata administration, password rotation and strong VIEW disable/recreate with capability-bound atomic -//! replacement. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral observability uses only +//! replacement. `0.2.5-pre.008` adds bounded Solana CLI JSON and canonical full-keypair Base58 import/export adapters with safe inspection and no-clobber +//! native import/export publication. 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`. mod capability; @@ -25,6 +27,7 @@ mod password; mod payload; mod persistence; mod transcript; +mod transfer; mod view; mod wallet; mod wire; @@ -39,6 +42,10 @@ pub use self::constants::KSPWALLET_MAGIC; pub use self::constants::KSPWALLET_MAX_FILE_BYTES; /// Byte length of every Solana Ed25519 signature returned by OWNER. pub use self::constants::KSPWALLET_SOLANA_SIGNATURE_BYTES; +/// Maximum accepted Base58 keypair transfer size before decoding. +pub use self::constants::KSPWALLET_TRANSFER_MAX_BASE58_BYTES; +/// Maximum accepted Solana CLI JSON transfer size before parsing. +pub use self::constants::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES; /// Byte length of the AEAD authentication tag appended to V1 ciphertexts. pub use self::constants::KSPWALLET_V1_AEAD_TAG_BYTES; /// Argon2 version serialized by `.kspwallet` V1 key slots. @@ -165,6 +172,18 @@ pub use self::persistence::inspect_locked_wallet_file_v1; 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; +/// Explicit secret-transfer format supported by Wallet. +pub use self::transfer::WalletTransferFormat; +/// Safe public identity projection of one validated secret-transfer source. +pub use self::transfer::WalletTransferInspection; +/// Imports one bounded external transfer file into a new no-clobber native Wallet V1. +pub use self::transfer::import_wallet_transfer_file_v1; +/// Imports one in-memory transfer payload into a new no-clobber native Wallet V1. +pub use self::transfer::import_wallet_transfer_v1; +/// Validates one in-memory transfer payload and exposes only its derived public identity. +pub use self::transfer::inspect_wallet_transfer; +/// Validates one bounded external transfer file and exposes only its derived public identity. +pub use self::transfer::inspect_wallet_transfer_file; /// Authorized VIEW capability handle. pub use self::view::WalletView; /// Creates a new in-memory native Wallet V1. @@ -202,9 +221,15 @@ pub use self::wire::WalletViewDescriptorV1; /// Wallet-owned tracing target used by the KSP logging facade. pub(crate) use self::constants::TRACING_TARGET; +/// Internal no-clobber native persistence path shared by transfer adapters. +pub(crate) use self::persistence::persist_new_wallet_content_v1; /// Internal deterministic compartment-AAD codec shared by Wallet crypto layers. pub(crate) use self::transcript::compartment_aad; /// Internal deterministic key-slot-AAD codec shared by Wallet crypto layers. pub(crate) use self::transcript::slot_aad; /// Internal deterministic OWNER-state transcript codec shared by Wallet crypto layers. pub(crate) use self::transcript::state_transcript; +/// Internal no-clobber transfer-file writer used only by OWNER export. +pub(crate) use self::transfer::write_wallet_transfer_file_v1; +/// Internal imported-keypair creation path shared by transfer adapters. +pub(crate) use self::wallet::create_wallet_v1_from_keypair; diff --git a/crates/ksp-wallet-lib/src/owner.rs b/crates/ksp-wallet-lib/src/owner.rs index c9aeeac..cd3e2ae 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: 3 +// version: 4 /// Authorized OWNER capability handle. /// @@ -51,6 +51,51 @@ impl WalletOwner { return &self.info; } + /// 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. + /// VIEW has no corresponding export API. + pub fn export_transfer(&self, format: crate::WalletTransferFormat) -> ksp_core_lib::Result> { + let encoded = self.state.export_transfer(format); + if let std::result::Result::Ok(value) = encoded.as_ref() { + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + operation = "wallet_export_transfer", + capability = "owner", + transfer_format = format.code(), + transfer_bytes = value.len(), + "wallet secret encoded through explicit OWNER transfer adapter" + ); + } + return encoded; + } + + /// Exports the immutable Solana keypair to a new no-clobber transfer file. + /// + /// The destination parent must already exist. On Unix, Wallet attempts `0600` permissions as filesystem hygiene, but OS ACLs are not part of the + /// `.kspwallet` cryptographic security guarantee. Existing destinations are never overwritten. + pub async fn export_transfer_file( + &self, + destination: impl std::convert::AsRef, + format: crate::WalletTransferFormat, + ) -> ksp_core_lib::Result<()> { + let encoded = match self.state.export_transfer(format) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = crate::write_wallet_transfer_file_v1(destination.as_ref().to_path_buf(), encoded, format).await; + if result.is_ok() { + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + operation = "wallet_export_transfer_file", + capability = "owner", + transfer_format = format.code(), + "wallet secret exported through explicit OWNER transfer adapter" + ); + } + return result; + } + /// Signs one caller-provided message with the Wallet's immutable Solana keypair. /// /// The returned bytes are the 64-byte Ed25519 signature. The Solana secret key is never returned or logged. diff --git a/crates/ksp-wallet-lib/src/persistence.rs b/crates/ksp-wallet-lib/src/persistence.rs index 83d0bbc..938c6bf 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: 3 +// version: 4 //! Async-first native Wallet V1 filesystem persistence. @@ -90,6 +90,10 @@ async fn read_wallet_file_async(source: std::path::PathBuf) -> ksp_core_lib::Res }; } +pub(crate) async fn persist_new_wallet_content_v1(destination: std::path::PathBuf, content: std::vec::Vec) -> ksp_core_lib::Result<()> { + return persist_new_wallet_async(destination, content).await; +} + 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 { diff --git a/crates/ksp-wallet-lib/src/transfer.rs b/crates/ksp-wallet-lib/src/transfer.rs new file mode 100644 index 0000000..7ee21da --- /dev/null +++ b/crates/ksp-wallet-lib/src/transfer.rs @@ -0,0 +1,397 @@ +// file: crates/ksp-wallet-lib/src/transfer.rs +// version: 1 + +//! Explicit OWNER-only Solana keypair import/export adapters. + +use std::io::{Read as _, Write as _}; +use zeroize::Zeroize as _; + +/// Explicit secret-transfer formats supported by Wallet `0.2.5`. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletTransferFormat { + /// Standard Solana CLI keypair JSON array containing exactly 64 byte values. + SolanaCliJson, + /// Canonical Base58 encoding of the complete 64-byte Solana keypair. + SolanaKeypairBase58, +} + +impl WalletTransferFormat { + /// Returns every transfer format currently supported for both import and OWNER export. + #[must_use] + pub const fn supported() -> [Self; 2] { + return [Self::SolanaCliJson, Self::SolanaKeypairBase58]; + } + + /// Returns the stable machine-readable format code. + #[must_use] + pub const fn code(self) -> &'static str { + return match self { + Self::SolanaCliJson => "solana_cli_json", + Self::SolanaKeypairBase58 => "solana_keypair_base58", + }; + } + + /// Returns a human-readable label that states the exact key material carried by the format. + #[must_use] + pub const fn label(self) -> &'static str { + return match self { + Self::SolanaCliJson => "Solana CLI keypair JSON (64 bytes)", + Self::SolanaKeypairBase58 => "Solana keypair Base58 (64 bytes)", + }; + } + + /// Returns the conventional file extension for this transfer format. + #[must_use] + pub const fn default_extension(self) -> &'static str { + return match self { + Self::SolanaCliJson => "json", + Self::SolanaKeypairBase58 => "txt", + }; + } + + const fn maximum_source_bytes(self) -> usize { + return match self { + Self::SolanaCliJson => crate::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES, + Self::SolanaKeypairBase58 => crate::KSPWALLET_TRANSFER_MAX_BASE58_BYTES, + }; + } +} + +/// Safe public projection produced after validating an external secret-transfer source. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WalletTransferInspection { + pubkey: ksp_core_lib::Pubkey, + format: WalletTransferFormat, +} + +impl WalletTransferInspection { + /// Returns the Solana public key derived from the validated external keypair. + #[must_use] + pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey { + return &self.pubkey; + } + + /// Returns the transfer format used for validation. + #[must_use] + pub const fn format(&self) -> WalletTransferFormat { + return self.format; + } +} + +/// Validates one in-memory transfer payload and exposes only its derived public identity. +/// +/// The caller retains ownership of `source`; if it contains secret key material, the caller is responsible for zeroizing that buffer when appropriate. +pub fn inspect_wallet_transfer(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { + let keypair = match decode_transfer_keypair(source, format) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pubkey = keypair_pubkey(&keypair); + return std::result::Result::Ok(WalletTransferInspection { pubkey, format }); +} + +/// Reads and validates one bounded external transfer file and exposes only its derived public identity. +pub async fn inspect_wallet_transfer_file( + source: impl std::convert::AsRef, + format: WalletTransferFormat, +) -> ksp_core_lib::Result { + let bytes = match read_transfer_file_async(source.as_ref().to_path_buf(), format).await { + std::result::Result::Ok(value) => zeroize::Zeroizing::new(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return inspect_wallet_transfer(bytes.as_slice(), format); +} + +/// Imports one in-memory Solana keypair transfer payload into a new native `.kspwallet` V1 file. +/// +/// Import always creates a fresh native Wallet envelope around the validated immutable Solana keypair and publishes with the same no-clobber semantics as +/// native creation. An existing destination is never overwritten. The caller retains ownership of `source` and must zeroize it when appropriate. +pub async fn import_wallet_transfer_v1( + destination: impl std::convert::AsRef, + source: &[u8], + format: WalletTransferFormat, + owner_password: crate::OwnerPassword, + view_password: std::option::Option, + metadata: crate::WalletCreateMetadataV1, +) -> ksp_core_lib::Result { + let keypair = match decode_transfer_keypair(source, format) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let owner = match crate::create_wallet_v1_from_keypair(owner_password, view_password, metadata, keypair).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let serialized = match owner.to_json_bytes() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let persist_result = crate::persist_new_wallet_content_v1(destination.as_ref().to_path_buf(), 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_import_transfer", + transfer_format = format.code(), + "external Solana keypair imported into a new native wallet" + ); + return std::result::Result::Ok(owner); +} + +/// Imports one bounded external transfer file into a new native `.kspwallet` V1 file. +/// +/// The source file is never modified and the destination remains no-clobber. +pub async fn import_wallet_transfer_file_v1( + destination: impl std::convert::AsRef, + source: impl std::convert::AsRef, + format: WalletTransferFormat, + owner_password: crate::OwnerPassword, + view_password: std::option::Option, + metadata: crate::WalletCreateMetadataV1, +) -> ksp_core_lib::Result { + let bytes = match read_transfer_file_async(source.as_ref().to_path_buf(), format).await { + std::result::Result::Ok(value) => zeroize::Zeroizing::new(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return import_wallet_transfer_v1(destination, bytes.as_slice(), format, owner_password, view_password, metadata).await; +} + +pub(crate) async fn write_wallet_transfer_file_v1( + destination: std::path::PathBuf, + content: std::vec::Vec, + format: WalletTransferFormat, +) -> ksp_core_lib::Result<()> { + let content = zeroize::Zeroizing::new(content); + if content.len() > format.maximum_source_bytes() { + return std::result::Result::Err(key_material_error(format, "encoded transfer exceeds the supported format bound")); + } + let task = tokio::task::spawn_blocking(move || return write_transfer_file_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_transfer_error("export_task", error)), + }; +} + +fn decode_transfer_keypair(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { + if source.is_empty() || source.len() > format.maximum_source_bytes() { + return std::result::Result::Err(key_material_error(format, "transfer source length is invalid")); + } + return match format { + WalletTransferFormat::SolanaCliJson => decode_solana_cli_json(source, format), + WalletTransferFormat::SolanaKeypairBase58 => decode_solana_keypair_base58(source, format), + }; +} + +fn decode_solana_cli_json(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { + let decoded_result = serde_json::from_slice::>(source); + let mut decoded = match decoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(key_material_error(format, "transfer source is not a Solana CLI byte array")), + }; + if decoded.len() != crate::KSPWALLET_V1_SECRET_PLAINTEXT_BYTES { + decoded.zeroize(); + return std::result::Result::Err(key_material_error(format, "Solana CLI keypair must contain exactly 64 bytes")); + } + let keypair_result = solana_keypair::Keypair::try_from(decoded.as_slice()); + decoded.zeroize(); + return match keypair_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(key_material_error(format, "Solana CLI keypair bytes are inconsistent")), + }; +} + +fn decode_solana_keypair_base58(source: &[u8], format: WalletTransferFormat) -> ksp_core_lib::Result { + let source_text = match std::str::from_utf8(source) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(key_material_error(format, "Base58 transfer source is not UTF-8")), + }; + if source_text.trim() != source_text { + return std::result::Result::Err(key_material_error(format, "Base58 transfer source must not contain surrounding whitespace")); + } + let keypair = match solana_keypair::Keypair::try_from_base58_string(source_text) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(key_material_error(format, "Base58 transfer source is not a valid 64-byte Solana keypair")); + }, + }; + let mut canonical = keypair.to_base58_string(); + let canonical_matches = canonical.as_str() == source_text; + canonical.zeroize(); + if !canonical_matches { + return std::result::Result::Err(key_material_error(format, "Base58 transfer source is not canonical")); + } + return std::result::Result::Ok(keypair); +} + +fn keypair_pubkey(keypair: &solana_keypair::Keypair) -> ksp_core_lib::Pubkey { + let mut keypair_bytes = keypair.to_bytes(); + let mut public_bytes = [0_u8; 32]; + public_bytes.copy_from_slice(&keypair_bytes[32..64]); + keypair_bytes.zeroize(); + return ksp_core_lib::Pubkey::new_from_array(public_bytes); +} + +async fn read_transfer_file_async(source: std::path::PathBuf, format: WalletTransferFormat) -> ksp_core_lib::Result> { + let task = tokio::task::spawn_blocking(move || return read_transfer_file_blocking(source.as_path(), format)); + return match task.await { + std::result::Result::Ok(result) => result, + std::result::Result::Err(error) => std::result::Result::Err(blocking_transfer_error("read_task", error)), + }; +} + +fn read_transfer_file_blocking(source: &std::path::Path, format: WalletTransferFormat) -> ksp_core_lib::Result> { + let file = match std::fs::File::open(source) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(transfer_io_error("source_open", error)), + }; + let metadata = match file.metadata() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(transfer_io_error("source_metadata", error)), + }; + if !metadata.is_file() { + return std::result::Result::Err(key_material_error(format, "transfer source must be a regular file")); + } + let maximum = format.maximum_source_bytes(); + if metadata.len() > maximum as u64 { + return std::result::Result::Err(key_material_error(format, "transfer source exceeds the supported format bound")); + } + let mut reader = file.take((maximum + 1) as u64); + let mut bytes = std::vec::Vec::with_capacity(maximum.min(metadata.len() as usize)); + if let std::result::Result::Err(error) = reader.read_to_end(&mut bytes) { + bytes.zeroize(); + return std::result::Result::Err(transfer_io_error("source_read", error)); + } + if bytes.len() > maximum { + bytes.zeroize(); + return std::result::Result::Err(key_material_error(format, "transfer source exceeds the supported format bound")); + } + return std::result::Result::Ok(bytes); +} + +fn write_transfer_file_blocking(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { + let parent = match destination_parent(destination) { + 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(transfer_atomic_error("destination", "transfer destination has no file name")); + } + let temporary_result = tempfile::Builder::new().prefix(".kspwallet-export-").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(transfer_atomic_io_error("temporary_create", error)), + }; + set_private_permissions_best_effort(temporary.as_file()); + if let std::result::Result::Err(error) = temporary.write_all(content) { + return std::result::Result::Err(transfer_atomic_io_error("temporary_write", error)); + } + if let std::result::Result::Err(error) = temporary.as_file().sync_all() { + return std::result::Result::Err(transfer_atomic_io_error("temporary_sync", 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(ksp_core_lib::Error::new(crate::ERROR_CODE_DESTINATION_EXISTS, "Wallet transfer destination already exists")); + } + return std::result::Result::Err(transfer_atomic_io_error("publish_noclobber", error.error)); + }, + }; + if let std::result::Result::Err(error) = persisted.sync_all() { + return std::result::Result::Err(transfer_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(transfer_atomic_error("destination", "transfer destination has no parent directory")), + }; +} + +#[cfg(unix)] +fn set_private_permissions_best_effort(file: &std::fs::File) { + use std::os::unix::fs::PermissionsExt as _; + + let permissions = std::fs::Permissions::from_mode(0o600); + if let std::result::Result::Err(error) = file.set_permissions(permissions) { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + operation = "wallet_transfer_permissions", + io_kind = ?error.kind(), + "wallet transfer export continues without the optional Unix 0600 hygiene hardening" + ); + } + return; +} + +#[cfg(not(unix))] +fn set_private_permissions_best_effort(_file: &std::fs::File) { + return; +} + +#[cfg(unix)] +fn sync_parent_directory_best_effort(parent: &std::path::Path) { + let directory = match std::fs::File::open(parent) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + operation = "wallet_transfer_parent_sync", + io_kind = ?error.kind(), + "wallet transfer export succeeded but parent-directory 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_transfer_parent_sync", + io_kind = ?error.kind(), + "wallet transfer export succeeded but parent-directory sync failed" + ); + } + return; +} + +#[cfg(not(unix))] +fn sync_parent_directory_best_effort(_parent: &std::path::Path) { + return; +} + +fn key_material_error(format: WalletTransferFormat, message: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_KEY_MATERIAL_INVALID, message).with_context("transfer_format", format.code()); +} + +fn transfer_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 transfer filesystem I/O failed") + .with_context("operation", operation) + .with_source(source); +} + +fn transfer_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 transfer_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 transfer export failed") + .with_context("operation", operation) + .with_source(source); +} + +fn blocking_transfer_error(operation: &'static str, source: tokio::task::JoinError) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Wallet transfer filesystem task failed") + .with_context("operation", operation) + .with_source(source); +} + +#[cfg(test)] +#[path = "../unit_tests/transfer.rs"] +mod tests; diff --git a/crates/ksp-wallet-lib/src/wallet.rs b/crates/ksp-wallet-lib/src/wallet.rs index 8489134..1593a0d 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: 4 +// version: 5 //! In-memory native Wallet V1 create/open orchestration. @@ -50,6 +50,30 @@ impl OwnerStateV1 { return; } + pub(crate) fn export_transfer(&self, format: crate::WalletTransferFormat) -> 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()), + }; + return match format { + crate::WalletTransferFormat::SolanaCliJson => { + let mut keypair_bytes = keypair.to_bytes(); + let encoded = serde_json::to_vec(keypair_bytes.as_slice()); + keypair_bytes.zeroize(); + match encoded { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(key_material_error()), + } + }, + crate::WalletTransferFormat::SolanaKeypairBase58 => { + let mut encoded = keypair.to_base58_string(); + let output = encoded.as_bytes().to_vec(); + encoded.zeroize(); + std::result::Result::Ok(output) + }, + }; + } + pub(crate) fn sign_message(&self, message: &[u8]) -> ksp_core_lib::Result<[u8; crate::KSPWALLET_SOLANA_SIGNATURE_BYTES]> { let keypair = match self.solana_keypair.as_ref() { std::option::Option::Some(value) => value, @@ -395,6 +419,21 @@ pub async fn create_wallet_v1( owner_password: crate::OwnerPassword, view_password: std::option::Option, metadata: crate::WalletCreateMetadataV1, +) -> ksp_core_lib::Result { + let mut solana_secret = match crate::crypto::random_bytes::<32>() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let solana_keypair = solana_keypair::Keypair::new_from_array(solana_secret); + solana_secret.zeroize(); + return crate::create_wallet_v1_from_keypair(owner_password, view_password, metadata, solana_keypair).await; +} + +pub(crate) async fn create_wallet_v1_from_keypair( + owner_password: crate::OwnerPassword, + view_password: std::option::Option, + metadata: crate::WalletCreateMetadataV1, + solana_keypair: solana_keypair::Keypair, ) -> ksp_core_lib::Result { let owner_root = match crate::crypto::SecretKeyV1::random() { std::result::Result::Ok(value) => value, @@ -416,15 +455,6 @@ pub async fn create_wallet_v1( let admin_signing_key = ed25519_dalek::SigningKey::from_bytes(&admin_secret); let owner_auth_public_key = admin_signing_key.verifying_key().to_bytes(); - let mut solana_secret = match crate::crypto::random_bytes::<32>() { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => { - admin_secret.zeroize(); - return std::result::Result::Err(error); - }, - }; - let solana_keypair = solana_keypair::Keypair::new_from_array(solana_secret); - solana_secret.zeroize(); let mut solana_keypair_bytes = solana_keypair.to_bytes(); let pubkey_result = pubkey_from_keypair_bytes(&solana_keypair_bytes); let pubkey = match pubkey_result { diff --git a/crates/ksp-wallet-lib/tests/dependency_boundary.rs b/crates/ksp-wallet-lib/tests/dependency_boundary.rs index 0042de8..581a649 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: 8 +// version: 9 //! Wallet-specific dependency and ownership canaries. @@ -60,6 +60,7 @@ fn wallet_manifest_preserves_dependency_firewall() -> std::io::Result<()> { "tauri", "tracing =", "solana-pubkey", + "bs58", ] { assert!(!manifest.contains(forbidden), "forbidden direct Wallet dependency detected: {forbidden}"); } diff --git a/crates/ksp-wallet-lib/tests/public_api.rs b/crates/ksp-wallet-lib/tests/public_api.rs index f14c3df..7de3805 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: 7 +// version: 8 //! Public API canaries for the Wallet foundation. @@ -122,3 +122,43 @@ fn public_pre_007_owner_signing_and_administration_surface_is_available() { let _ = sign_method; assert_eq!(ksp_wallet_lib::KSPWALLET_SOLANA_SIGNATURE_BYTES, 64); } + +#[test] +fn public_pre_008_transfer_adapters_are_available_from_crate_root() { + let formats = ksp_wallet_lib::WalletTransferFormat::supported(); + assert_eq!(formats.len(), 2); + assert_eq!(formats[0].code(), "solana_cli_json"); + assert_eq!(formats[1].code(), "solana_keypair_base58"); + assert_eq!(ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES, 1024); + assert_eq!(ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES, 128); + let export_method: fn(&ksp_wallet_lib::WalletOwner, ksp_wallet_lib::WalletTransferFormat) -> ksp_core_lib::Result> = + ksp_wallet_lib::WalletOwner::export_transfer; + let _ = export_method; + let inspect_method: fn(&[u8], ksp_wallet_lib::WalletTransferFormat) -> ksp_core_lib::Result = + ksp_wallet_lib::inspect_wallet_transfer; + let _ = inspect_method; + + let path = std::path::Path::new("not-polled-transfer"); + let inspect_file_future = ksp_wallet_lib::inspect_wallet_transfer_file(path, formats[0]); + drop(inspect_file_future); + let owner_password = ksp_wallet_lib::OwnerPassword::new(std::string::String::from("public-pre008-owner-password")); + let import_future = ksp_wallet_lib::import_wallet_transfer_v1( + path, + b"[1,2,3]", + formats[0], + owner_password, + std::option::Option::None, + ksp_wallet_lib::WalletCreateMetadataV1::default(), + ); + drop(import_future); + let owner_password = ksp_wallet_lib::OwnerPassword::new(std::string::String::from("public-pre008-owner-password")); + let import_file_future = ksp_wallet_lib::import_wallet_transfer_file_v1( + path, + path, + formats[0], + owner_password, + std::option::Option::None, + ksp_wallet_lib::WalletCreateMetadataV1::default(), + ); + drop(import_file_future); +} diff --git a/crates/ksp-wallet-lib/unit_tests/transfer.rs b/crates/ksp-wallet-lib/unit_tests/transfer.rs new file mode 100644 index 0000000..e8d19a8 --- /dev/null +++ b/crates/ksp-wallet-lib/unit_tests/transfer.rs @@ -0,0 +1,241 @@ +// file: crates/ksp-wallet-lib/unit_tests/transfer.rs +// version: 1 + +use zeroize::Zeroize as _; + +fn runtime() -> tokio::runtime::Runtime { + return tokio::runtime::Builder::new_current_thread().build().expect("Wallet transfer test runtime must build"); +} + +fn test_keypair() -> solana_keypair::Keypair { + return solana_keypair::Keypair::new_from_array([7_u8; 32]); +} + +fn cli_json(keypair: &solana_keypair::Keypair) -> std::vec::Vec { + let mut bytes = keypair.to_bytes(); + let encoded = serde_json::to_vec(bytes.as_slice()).expect("test keypair must encode as Solana CLI JSON"); + bytes.zeroize(); + return encoded; +} + +#[test] +fn supported_transfer_formats_have_stable_codes_labels_and_extensions() { + let formats = crate::WalletTransferFormat::supported(); + assert_eq!(formats, [crate::WalletTransferFormat::SolanaCliJson, crate::WalletTransferFormat::SolanaKeypairBase58]); + assert_eq!(formats[0].code(), "solana_cli_json"); + assert_eq!(formats[0].default_extension(), "json"); + assert!(formats[0].label().contains("64 bytes")); + assert_eq!(formats[1].code(), "solana_keypair_base58"); + assert_eq!(formats[1].default_extension(), "txt"); + assert!(formats[1].label().contains("64 bytes")); +} + +#[test] +fn in_memory_inspection_accepts_cli_json_and_canonical_base58_without_exposing_secret() { + let keypair = test_keypair(); + let mut json = cli_json(&keypair); + let mut base58 = keypair.to_base58_string(); + let json_inspection = + crate::inspect_wallet_transfer(json.as_slice(), crate::WalletTransferFormat::SolanaCliJson).expect("Solana CLI JSON inspection must succeed"); + let base58_inspection = + crate::inspect_wallet_transfer(base58.as_bytes(), crate::WalletTransferFormat::SolanaKeypairBase58).expect("canonical Base58 inspection must succeed"); + + assert_eq!(json_inspection.pubkey(), base58_inspection.pubkey()); + assert_eq!(json_inspection.format(), crate::WalletTransferFormat::SolanaCliJson); + assert_eq!(base58_inspection.format(), crate::WalletTransferFormat::SolanaKeypairBase58); + assert!(!format!("{json_inspection:?}").contains(base58.as_str())); + json.zeroize(); + base58.zeroize(); +} + +#[test] +fn external_seed7_transfer_canary_matches_known_solana_keypair_encodings() { + let expected_keypair_bytes = [ + 7_u8, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 234, 74, 108, 99, 226, 156, 82, 10, 190, 245, 80, + 123, 19, 46, 197, 249, 149, 71, 118, 174, 190, 190, 123, 146, 66, 30, 234, 105, 20, 70, 210, 44, + ]; + let expected_pubkey = "GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB"; + let expected_base58 = "99eUso3aSbE9tqGSTXzo3TLfKb9RkMTURrHKQ1K7Zh3StnzFNUx8FKCPPPPpR479qsw5zv2WNBKmgiz7WqgAJfM"; + let keypair = test_keypair(); + let mut actual_keypair_bytes = keypair.to_bytes(); + assert_eq!(actual_keypair_bytes, expected_keypair_bytes); + let mut actual_base58 = keypair.to_base58_string(); + assert_eq!(actual_base58, expected_base58); + + let mut json = serde_json::to_vec(expected_keypair_bytes.as_slice()).expect("external keypair canary must encode as JSON"); + let json_inspection = + crate::inspect_wallet_transfer(json.as_slice(), crate::WalletTransferFormat::SolanaCliJson).expect("external Solana CLI JSON canary must inspect"); + let base58_inspection = crate::inspect_wallet_transfer(expected_base58.as_bytes(), crate::WalletTransferFormat::SolanaKeypairBase58) + .expect("external Base58 canary must inspect"); + assert_eq!(json_inspection.pubkey().to_string(), expected_pubkey); + assert_eq!(base58_inspection.pubkey().to_string(), expected_pubkey); + + json.zeroize(); + actual_base58.zeroize(); + actual_keypair_bytes.zeroize(); +} + +#[test] +fn transfer_decoders_reject_short_inconsistent_or_noncanonical_sources() { + let short_json = b"[1,2,3]"; + assert_eq!( + crate::inspect_wallet_transfer(short_json, crate::WalletTransferFormat::SolanaCliJson).expect_err("short Solana CLI JSON must fail").code(), + crate::ERROR_CODE_KEY_MATERIAL_INVALID + ); + + let keypair = test_keypair(); + let mut bytes = keypair.to_bytes(); + bytes[63] ^= 0x01; + let mut inconsistent_json = serde_json::to_vec(bytes.as_slice()).expect("inconsistent fixture must encode"); + assert_eq!( + crate::inspect_wallet_transfer(inconsistent_json.as_slice(), crate::WalletTransferFormat::SolanaCliJson) + .expect_err("inconsistent keypair bytes must fail") + .code(), + crate::ERROR_CODE_KEY_MATERIAL_INVALID + ); + bytes.zeroize(); + inconsistent_json.zeroize(); + + assert_eq!( + crate::inspect_wallet_transfer(b"not valid base58 !!!", crate::WalletTransferFormat::SolanaKeypairBase58) + .expect_err("invalid Base58 must fail") + .code(), + crate::ERROR_CODE_KEY_MATERIAL_INVALID + ); + let mut base58 = keypair.to_base58_string(); + let surrounded = format!(" {base58}"); + assert_eq!( + crate::inspect_wallet_transfer(surrounded.as_bytes(), crate::WalletTransferFormat::SolanaKeypairBase58) + .expect_err("surrounding whitespace must fail") + .code(), + crate::ERROR_CODE_KEY_MATERIAL_INVALID + ); + base58.zeroize(); +} + +#[test] +fn cli_json_import_creates_new_no_clobber_wallet_with_imported_identity_and_metadata() { + let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable"); + let destination = directory.path().join("imported.kspwallet"); + let keypair = test_keypair(); + let mut source = cli_json(&keypair); + let expected = crate::inspect_wallet_transfer(source.as_slice(), crate::WalletTransferFormat::SolanaCliJson).expect("source inspection must succeed"); + let runtime = runtime(); + let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-owner-password")); + let owner = runtime + .block_on(crate::import_wallet_transfer_v1( + destination.as_path(), + source.as_slice(), + crate::WalletTransferFormat::SolanaCliJson, + owner_password, + std::option::Option::None, + crate::WalletCreateMetadataV1::new(std::option::Option::Some(std::string::String::from("imported-wallet")), std::vec::Vec::new()), + )) + .expect("Solana CLI JSON import must succeed"); + assert_eq!(owner.pubkey(), expected.pubkey()); + assert_eq!(owner.alias(), std::option::Option::Some("imported-wallet")); + + let second_password = crate::OwnerPassword::new(std::string::String::from("pre008-second-owner-password")); + let second = runtime.block_on(crate::import_wallet_transfer_v1( + destination.as_path(), + source.as_slice(), + crate::WalletTransferFormat::SolanaCliJson, + second_password, + std::option::Option::None, + crate::WalletCreateMetadataV1::default(), + )); + assert_eq!(second.expect_err("import must never overwrite an existing native Wallet").code(), crate::ERROR_CODE_DESTINATION_EXISTS); + source.zeroize(); +} + +#[test] +fn transfer_file_import_is_non_destructive_and_bounded() { + let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable"); + let source_path = directory.path().join("source.json"); + let destination = directory.path().join("imported.kspwallet"); + let keypair = test_keypair(); + let mut source = cli_json(&keypair); + std::fs::write(source_path.as_path(), source.as_slice()).expect("transfer fixture must be writable"); + let original = std::fs::read(source_path.as_path()).expect("transfer fixture must be readable"); + let runtime = runtime(); + let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-file-owner-password")); + runtime + .block_on(crate::import_wallet_transfer_file_v1( + destination.as_path(), + source_path.as_path(), + crate::WalletTransferFormat::SolanaCliJson, + owner_password, + std::option::Option::None, + crate::WalletCreateMetadataV1::default(), + )) + .expect("transfer-file import must succeed"); + assert_eq!(std::fs::read(source_path.as_path()).expect("source must survive import"), original); + + let oversized_path = directory.path().join("oversized.json"); + std::fs::write(oversized_path.as_path(), std::vec![b'1'; crate::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES + 1]) + .expect("oversized transfer fixture must be writable"); + let error = runtime + .block_on(crate::inspect_wallet_transfer_file(oversized_path.as_path(), crate::WalletTransferFormat::SolanaCliJson)) + .expect_err("oversized transfer source must fail before parse"); + assert_eq!(error.code(), crate::ERROR_CODE_KEY_MATERIAL_INVALID); + source.zeroize(); +} + +#[test] +fn owner_exports_cli_json_and_base58_that_roundtrip_to_the_same_imported_keypair() { + let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable"); + let destination = directory.path().join("imported.kspwallet"); + let keypair = test_keypair(); + let mut source_base58 = keypair.to_base58_string(); + let mut expected_bytes = keypair.to_bytes(); + let runtime = runtime(); + let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-export-owner-password")); + let owner = runtime + .block_on(crate::import_wallet_transfer_v1( + destination.as_path(), + source_base58.as_bytes(), + crate::WalletTransferFormat::SolanaKeypairBase58, + owner_password, + std::option::Option::None, + crate::WalletCreateMetadataV1::default(), + )) + .expect("Base58 import must succeed"); + + let mut exported_json = owner.export_transfer(crate::WalletTransferFormat::SolanaCliJson).expect("OWNER JSON export must succeed"); + let mut decoded_json: std::vec::Vec = serde_json::from_slice(exported_json.as_slice()).expect("OWNER JSON export must be valid JSON"); + assert_eq!(decoded_json.as_slice(), expected_bytes.as_slice()); + let mut exported_base58 = owner.export_transfer(crate::WalletTransferFormat::SolanaKeypairBase58).expect("OWNER Base58 export must succeed"); + assert_eq!(exported_base58.as_slice(), source_base58.as_bytes()); + + exported_json.zeroize(); + decoded_json.zeroize(); + exported_base58.zeroize(); + source_base58.zeroize(); + expected_bytes.zeroize(); +} + +#[test] +fn owner_transfer_file_export_is_no_clobber() { + let directory = tempfile::tempdir().expect("Wallet transfer test directory must be creatable"); + let wallet_path = directory.path().join("wallet.kspwallet"); + let export_path = directory.path().join("keypair.json"); + let runtime = runtime(); + let owner_password = crate::OwnerPassword::new(std::string::String::from("pre008-file-export-password")); + let owner = runtime + .block_on(crate::create_wallet_file_v1(wallet_path.as_path(), owner_password, std::option::Option::None, crate::WalletCreateMetadataV1::default())) + .expect("native Wallet creation must succeed"); + runtime + .block_on(owner.export_transfer_file(export_path.as_path(), crate::WalletTransferFormat::SolanaCliJson)) + .expect("first OWNER transfer-file export must succeed"); + let first = std::fs::read(export_path.as_path()).expect("exported transfer file must be readable"); + let second = runtime.block_on(owner.export_transfer_file(export_path.as_path(), crate::WalletTransferFormat::SolanaCliJson)); + assert_eq!(second.expect_err("OWNER transfer-file export must not overwrite").code(), crate::ERROR_CODE_DESTINATION_EXISTS); + assert_eq!(std::fs::read(export_path.as_path()).expect("first export must remain intact"), first); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(export_path.as_path()).expect("export metadata must be readable").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/deltas/0.2.5/pre.008.md b/deltas/0.2.5/pre.008.md new file mode 100644 index 0000000..02eb94a --- /dev/null +++ b/deltas/0.2.5/pre.008.md @@ -0,0 +1,223 @@ + + + +# Delta `0.2.5-pre.008` — import/export Solana CLI JSON + keypair Base58 + +## Base + +```text +0.2.5-pre.7.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` et `cargo test --workspace` sans warning. + +Version workspace cible : + +```text +0.2.5-pre.8 +``` + +## Objectif + +Cette tranche matérialise les premiers adapters explicites d'import/export de keypair Solana autour du format natif `.kspwallet` V1 : + +```text +Solana CLI JSON : tableau JSON de 64 octets +Solana keypair Base58 : Base58 canonique de la keypair complète de 64 octets +``` + +Elle ne crée aucun format Wallet alternatif, ne migre aucun ancien format KSP/bot2/bot3 et ne modifie pas `format_version = 1`. + +## Audit upstream `solana-keypair 3.1.2` + +Le réaudit de la source officielle actuelle confirme : + +- `Keypair::to_bytes()` retourne exactement 64 octets ; +- `Keypair::try_from(&[u8])` exige 64 octets et vérifie que la moitié publique correspond bien à la clé dérivée du secret ; +- `Keypair::try_from_base58_string` décode la représentation Base58 de 64 octets puis passe par cette validation stricte ; +- `Keypair::to_base58_string` encode la keypair complète de 64 octets ; +- `read_keypair` / `write_keypair` matérialisent le format JSON Solana sous forme de 64 valeurs octet. + +KSP réutilise donc `solana-keypair` déjà possédée par Wallet et n'ajoute pas de dépendance directe `bs58`. + +## API de transfert + +Nouveaux contrats publics : + +```text +WalletTransferFormat +WalletTransferInspection +inspect_wallet_transfer +inspect_wallet_transfer_file +import_wallet_transfer_v1 +import_wallet_transfer_file_v1 +WalletOwner::export_transfer +WalletOwner::export_transfer_file +``` + +`WalletTransferFormat` distingue explicitement : + +```text +solana_cli_json +solana_keypair_base58 +``` + +Le terme Base58 désigne ici la **keypair complète de 64 octets**, jamais un seed 32 octets ambigu. + +## Inspection sûre + +L'inspection exige un format explicite, valide réellement la keypair et ne retourne que : + +```text +Pubkey KSP Core +format de transfert reconnu +``` + +Aucun secret, seed, keypair brute ou contenu source n'est exposé par `WalletTransferInspection` ou son `Debug`. + +Bornes pré-parser : + +```text +Solana CLI JSON : 1024 octets +Base58 : 128 octets +``` + +Pour les APIs fichier, Wallet ouvre d'abord la source puis vérifie les metadata du fichier réellement ouvert avant la lecture bornée à `maximum + 1`. + +## Import + +Tout import : + +```text +valide la keypair externe +conserve exactement l'identité/keypair Solana importée +crée de nouveaux admin key/content keys/salts/nonces/key slots KSP +applique les metadata KSP fournies par le caller +crée éventuellement le slot VIEW demandé +sérialise un nouveau `.kspwallet` V1 +publie avec la sémantique native no-clobber +``` + +Une destination existante retourne `wallet.destination_exists`. Un import ne remplace jamais un `.kspwallet` existant. L'import depuis fichier ne modifie pas le fichier source. + +## Export OWNER-only + +Seule `WalletOwner` expose l'export secret. `WalletView` ne possède aucune méthode équivalente. + +L'export en mémoire retourne un buffer contenant le secret au caller ; sa documentation exige d'en minimiser la durée de vie et de le zeroizer quand approprié. + +L'export fichier : + +```text +temp file same-directory +write +sync temp +persist_noclobber +sync fichier publié +parent-directory sync best-effort sur Unix +``` + +Une destination existante n'est jamais écrasée. Sur Unix, Wallet tente `0600` comme hygiène filesystem ; cette permission n'est pas transformée en garantie cryptographique du format V1. + +## Canari externe déterministe + +Un vecteur indépendant du code Rust Wallet est figé pour le seed Ed25519 `[7; 32]` : + +```text +Pubkey Base58 +GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB + +Keypair complète Base58 +99eUso3aSbE9tqGSTXzo3TLfKb9RkMTURrHKQ1K7Zh3StnzFNUx8FKCPPPPpR479qsw5zv2WNBKmgiz7WqgAJfM +``` + +Le test fixe aussi les 64 octets exacts et vérifie que les adapters JSON/Base58 produisent la même identité publique. Ce canari évite de valider les adapters uniquement par un round-trip circulaire interne à `solana-keypair`. + +## Tests/canaris ajoutés + +La tranche couvre notamment : + +```text +inventaire stable des deux formats + codes/labels/extensions +inspection JSON + Base58 sans exposition du secret +vecteur externe déterministe seed [7; 32] +rejet JSON court +rejet keypair 64 octets dont la moitié publique est incohérente +rejet Base58 invalide/non canonique/avec whitespace périphérique +import JSON -> nouveau `.kspwallet` + identité/metadata conservées +second import même destination -> wallet.destination_exists +import fichier non destructif + source surdimensionnée rejetée +export OWNER JSON et Base58 -> mêmes 64 octets importés +export fichier OWNER no-clobber +permission 0600 vérifiée sur Unix +canari API publique pre.008 +canari de frontière interdisant une dépendance directe `bs58` +``` + +## Dépendances + +Aucune nouvelle dépendance tierce. + +La tranche réutilise exclusivement les dépendances Wallet déjà acquises, notamment `solana-keypair 3.1.2`, `serde_json`, `tempfile` et `zeroize`. + +Les frontières restent : + +```text +Wallet -X-> Config +Wallet -X-> Transport +Wallet -X-> ExecutionPolicy +Wallet -X-> Store +Wallet -X-> Tauri +Wallet -X-> tracing direct +Wallet -X-> solana-pubkey direct +Wallet -X-> bs58 direct +``` + +`Pubkey` reste exposée par `ksp-core-lib`; la keypair secrète reste encapsulée dans `ksp-wallet-lib`. + +## Documentation + +Mise à jour : + +```text +docs/formats/KSPWALLET_V1.md +docs/plans/000-README.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md +docs/IDEAS.md +ROADMAP.md +``` + +`pre.009` devient la prochaine tranche : sécurité/interoperabilité/compliance finale et audits de dépendances/arbres. + +## Validation statique de préparation + +Le sandbox de préparation ne possède pas Cargo/Rust. Les contrôles statiques ont vérifié : + +- pas de nouveau `unwrap`/`expect`/`panic`/`?` dans les chemins production ajoutés ; +- seuls des traits nécessaires sont importés avec `use` ; +- aucun accès Config/Transport/Tauri/env/`tracing` direct/`solana-pubkey` direct ; +- aucune dépendance `bs58` ajoutée ; +- incréments de versions de fichiers cohérents ; +- liens Markdown locaux des documents modifiés résolus ; +- archive delta limitée aux fichiers ajoutés/modifiés. + +## 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 solana-keypair@3.1.2 +``` + +## Commit attendu + +```text +v0.2.5-pre.008 +``` diff --git a/docs/IDEAS.md b/docs/IDEAS.md index 3e5605f..a9c13a9 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -1,5 +1,5 @@ - + # Idées à explorer @@ -188,8 +188,8 @@ Le format natif KSP est `.kspwallet`. L'architecture d'import/export doit rester Formats/cibles à inventorier et prioriser selon usage réel : -- Solana CLI keypair JSON ; -- keypair Base58 complet lorsque pertinent ; +- Solana CLI keypair JSON — **acquis dans `0.2.5-pre.008`** ; +- keypair Base58 complet — **acquis dans `0.2.5-pre.008`** ; - Phantom, en privilégiant le wire Solana générique réellement documenté plutôt qu'un codec de marque inutile ; - Solflare, y compris réévaluation du keystore protégé seulement si son format public devient suffisamment stable pour un round-trip testé ; - Backpack : caractériser le wire Solana exact de l'import `Private key` avant tout codec/alias dédié ; diff --git a/docs/formats/KSPWALLET_V1.md b/docs/formats/KSPWALLET_V1.md index fe024af..21e7768 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 @@ -20,7 +20,7 @@ AAD des compartiments owner-control / metadata / secret règles unknown-field / unknown-version ``` -`0.2.5-pre.004` ajoute les primitives KDF/AEAD normatives et un premier vecteur cryptographique public. `0.2.5-pre.005` fixe les payloads plaintext V1, l'autorité Ed25519 OWNER, les procédures de création et d'ouverture VIEW/OWNER, le profil de création KSP issu du benchmark opérateur et un vecteur `.kspwallet` complet généré indépendamment du code Rust. `0.2.5-pre.006` matérialise la persistence filesystem bornée et la création no-clobber. `0.2.5-pre.007` matérialise la signature Solana OWNER, l'administration des metadata, les rotations OWNER/VIEW, la révocation forte VIEW et leur remplacement filesystem capability-bound. Les adapters import/export restent dans les tranches suivantes. Toute évolution qui modifie un élément déjà déclaré **figé** par cette spécification exige une évolution explicitement tracée avant la release stable ; après publication de V1, une incompatibilité de wire exige un nouveau `format_version`. +`0.2.5-pre.004` ajoute les primitives KDF/AEAD normatives et un premier vecteur cryptographique public. `0.2.5-pre.005` fixe les payloads plaintext V1, l'autorité Ed25519 OWNER, les procédures de création et d'ouverture VIEW/OWNER, le profil de création KSP issu du benchmark opérateur et un vecteur `.kspwallet` complet généré indépendamment du code Rust. `0.2.5-pre.006` matérialise la persistence filesystem bornée et la création no-clobber. `0.2.5-pre.007` matérialise la signature Solana OWNER, l'administration des metadata, les rotations OWNER/VIEW, la révocation forte VIEW et leur remplacement filesystem capability-bound. `0.2.5-pre.008` matérialise les adapters Solana CLI JSON et Base58 complet, leur inspection sûre, l'import no-clobber vers un nouveau `.kspwallet` et l'export secret OWNER explicite. Toute évolution qui modifie un élément déjà déclaré **figé** par cette spécification exige une évolution explicitement tracée avant la release stable ; après publication de V1, une incompatibilité de wire exige un nouveau `format_version`. Le but final est qu'une implémentation indépendante en Rust, Python, Go, C/C++, Java ou autre puisse créer, parser, vérifier et ouvrir un `.kspwallet` sans lire le code source de `ksp-wallet-lib`. @@ -665,13 +665,13 @@ Le premier vecteur cryptographique public KDF+wrapping est ajouté par `pre.004` ## 19. Invariants encore à compléter sans modifier le wire figé -Après `pre.005`, les tranches restantes portent sur les opérations autour du format déjà défini : +Les opérations autour du wire V1 sont désormais matérialisées jusqu'aux adapters de transfert : ```text -pre.006 : persistence async/atomique/no-clobber -pre.007 : signature Solana, metadata admin, rotations OWNER/VIEW et révocation forte VIEW -pre.008 : import/export -pre.009+ : audit adversarial, compliance et documentation de clôture +pre.006 : persistence async/atomique/no-clobber acquis +pre.007 : signature Solana, metadata admin, rotations et révocation acquis +pre.008 : import/export Solana CLI JSON + Base58 complet acquis +pre.009+ : audit adversarial, compliance et documentation de clôture restant ``` Toute découverte imposant de modifier la grammaire, les payloads plaintext, les tags, l'ordre transcript ou les domain separators définis dans ce document doit être traitée explicitement avant la publication stable, jamais masquée par une tolérance du parseur. @@ -930,3 +930,61 @@ caller path explicite 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. Les mutations `pre.007` réutilisent la même stratégie temp/sync pour un remplacement **capability-bound** avec contrôle d'état attendu décrit en section 21.9 ; ce remplacement n'est jamais exposé comme primitive publique générique et ne modifie pas la règle create/import = no-clobber. 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. + +## 24. Adapters de transfert matérialisés par `pre.008` + +Les formats de transfert ne modifient pas le wire `.kspwallet` V1. Ils servent uniquement à importer/exporter la keypair Solana immuable de 64 octets. Deux formats sont retenus immédiatement : + +```text +solana_cli_json JSON array de exactement 64 entiers u8 +solana_keypair_base58 Base58 canonique de la keypair complète 64 octets +``` + +### 24.1 Solana CLI JSON + +Le format correspond au codec `solana-keypair` actuel : un tableau JSON contenant exactement les 64 octets de la keypair. KSP accepte les espaces JSON usuels mais exige exactement 64 valeurs `0..255`, puis reconstruit la keypair avec validation de cohérence secret/public. La taille du source est bornée à **1024 octets** avant parsing. + +### 24.2 Base58 de keypair complète + +Le format Base58 encode les **64 octets complets** de la keypair, et non seulement le secret seed de 32 octets. KSP utilise le codec maintenu par `solana-keypair`, refuse le whitespace périphérique et impose `decode -> re-encode == input` afin de n'accepter que la représentation canonique. La taille du source est bornée à **128 octets** avant décodage. Aucun `bs58` direct n'est ajouté à Wallet. + +### 24.3 Inspection sûre + +`inspect_wallet_transfer` et `inspect_wallet_transfer_file` exigent que le caller choisisse explicitement le format. KSP ne fait pas d'auto-détection heuristique. Après validation complète de la keypair, l'inspection expose seulement : + +```text +Pubkey Solana via ksp_core_lib::Pubkey +WalletTransferFormat +``` + +Aucun secret, seed, keypair brute, alias ou note n'est projeté. + +### 24.4 Import vers `.kspwallet` + +Un import valide : + +```text +source transfer validée +-> même keypair Solana 64 octets +-> nouvelles clés OWNER/admin/content KSP +-> nouveaux slots OWNER/VIEW selon passwords fournis +-> nouvelles metadata KSP fournies par le caller +-> nouveau document .kspwallet V1 +-> publication no-clobber +``` + +La source n'est jamais modifiée. Une destination `.kspwallet` existante retourne `wallet.destination_exists`. L'import n'est donc jamais une mutation ou un remplacement de keypair d'un wallet V1 existant. + +### 24.5 Export OWNER + +Seul `WalletOwner` expose l'export. `WalletView` ne possède aucune API correspondante. Deux formes existent : + +```text +export_transfer(format) +export_transfer_file(destination, format) +``` + +La première retourne au caller des octets contenant volontairement le secret et exige donc que le caller en limite la durée de vie et les zeroize lorsque pertinent. La seconde publie un nouveau fichier en no-clobber ; sur Unix, KSP tente `0600` comme hygiène filesystem, sans transformer cette permission en garantie cryptographique du format. Aucun chemin n'est découvert via Config ou environnement. + +Les exports Base58 et Solana CLI JSON doivent reconstruire exactement la même keypair et la même Pubkey que le wallet OWNER source. + diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index 6e466a1..fd2ec82 100644 --- a/docs/plans/000-README.md +++ b/docs/plans/000-README.md @@ -1,5 +1,5 @@ - + # Plans KSP @@ -20,7 +20,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou - [`009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — plan clôturé de la release stable `0.2.2`, établi par `pre.001`, corrigé après réaudit Agave v4.2.1 puis exécuté jusqu'à `pre.007-fix.002`; il couvre les 22 wrappers Accounts/Tokens/Cluster, le smoke Transport opt-in et la préparation de `0.2.3`. - [`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 actif de `0.2.5 — Wallet foundation`, ouvert par `pre.001`; `pre.002` matérialise la crate, `pre.003` fige le wire JSON V1/transcript/AAD, `pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG et `pre.005` fixe les payloads, le profil de création benchmarké, l’autorité Ed25519 séparée et les flux in-memory create/open VIEW/OWNER avec vecteur complet interopérable. `pre.006` porte la persistence atomique/no-clobber. +- [`012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan actif de `0.2.5 — Wallet foundation`, ouvert par `pre.001`; wire/crypto/capabilities sont acquis jusqu’à `pre.005`, persistence no-clobber en `pre.006`, administration/signature/rotations en `pre.007`, puis import/export Solana CLI JSON + Base58 complet et inspection sûre en `pre.008`. `pre.009` porte le gate security/compliance avant la clôture documentaire `pre.010`. 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 8294143..cb09b44 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 @@ -418,7 +418,7 @@ La release doit fournir `docs/formats/KSPWALLET_V1.md` comme spécification sép `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.5-pre.007` complète l'administration native capability-bound : OWNER signe des messages Solana sans getter secret, modifie alias/notes, change son password ou celui de VIEW et peut disable/recreate VIEW avec rekey metadata fort ; VIEW ne peut que tourner son propre credential en rewrappant le même `K_metadata`. Les mutations sont staged puis remplacent le fichier uniquement si la destination courante correspond encore à l'enveloppe authentifiée attendue ; un handle stale ou une mauvaise cible reçoit `wallet.state_conflict`. Ce garde-fou ne prétend pas fournir un CAS filesystem portable ni un anti-rollback externe. La keypair reste encapsulée dans Wallet et aucune nouvelle dépendance tierce n'est ajoutée. `pre.008` reprend les adapters import/export Solana CLI JSON et Base58 générique. +`0.2.5-pre.007` complète l'administration native capability-bound : OWNER signe des messages Solana sans getter secret, modifie alias/notes, change son password ou celui de VIEW et peut disable/recreate VIEW avec rekey metadata fort ; VIEW ne peut que tourner son propre credential en rewrappant le même `K_metadata`. Les mutations sont staged puis remplacent le fichier uniquement si la destination courante correspond encore à l'enveloppe authentifiée attendue ; un handle stale ou une mauvaise cible reçoit `wallet.state_conflict`. Ce garde-fou ne prétend pas fournir un CAS filesystem portable ni un anti-rollback externe. La keypair reste encapsulée dans Wallet et aucune nouvelle dépendance tierce n'est ajoutée. `pre.008` ajoute ensuite les adapters `solana_cli_json` et `solana_keypair_base58`, l’inspection sûre limitée à Pubkey+format, l’import no-clobber vers un nouveau `.kspwallet` et l’export OWNER en mémoire/fichier. La source d’import reste inchangée, VIEW n’exporte jamais, aucun `bs58` direct n’est ajouté puisque `solana-keypair 3.1.2` possède déjà le codec Base58 complet. `pre.009` devient le prochain gate security/compliance. ## `0.2.6` — Wallet Desk 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 35e0abf..c90c048 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 @@ -1255,4 +1255,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 permet à `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 et de publier un vecteur complet. `pre.006` ajoute la persistence no-clobber et les ouvertures fichier. `pre.007` ajoute maintenant signature Solana, administration metadata, rotations OWNER/VIEW, révocation forte VIEW et remplacement administratif contrôlé. **La suite immédiate est `pre.008` : adapters import/export, Solana CLI JSON, keypair Base58 générique et inspect des formats de transfert**, sans déplacer les secrets ni la logique Wallet 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 permet à `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 et de publier un vecteur complet. `pre.006` ajoute la persistence no-clobber et les ouvertures fichier. `pre.007` ajoute signature Solana, administration metadata, rotations OWNER/VIEW, révocation forte VIEW et remplacement administratif contrôlé. `pre.008` ajoute maintenant les adapters import/export Solana CLI JSON et keypair Base58 complet, l'inspection sûre, l'import vers un nouveau `.kspwallet` no-clobber et l'export OWNER explicite sans `bs58` direct. **La suite immédiate est `pre.009` : audit adversarial/security/compliance et graphes Cargo**, avant la documentation finale `pre.010`.