v0.2.5-pre.006

This commit is contained in:
2026-08-19 17:31:29 +02:00
parent dcd23abb92
commit 6094d33120
12 changed files with 649 additions and 22 deletions

View File

@@ -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" }

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 49 -->
<!-- version: 50 -->
# 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, lautorité 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, lautorité 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.

View File

@@ -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]

View File

@@ -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.

View File

@@ -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<std::path::Path>,
owner_password: crate::OwnerPassword,
view_password: std::option::Option<crate::ViewPassword>,
metadata: crate::WalletCreateMetadataV1,
) -> ksp_core_lib::Result<crate::WalletOwner> {
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<std::path::Path>,
password: crate::ViewPassword,
) -> ksp_core_lib::Result<crate::WalletView> {
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<std::path::Path>,
password: crate::OwnerPassword,
) -> ksp_core_lib::Result<crate::WalletOwner> {
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<std::path::Path>) -> ksp_core_lib::Result<crate::LockedWalletInfo> {
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<std::vec::Vec<u8>> {
let task = tokio::task::spawn_blocking(move || return read_wallet_file_blocking(source.as_path()));
return match task.await {
std::result::Result::Ok(result) => result,
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<u8>) -> 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<std::vec::Vec<u8>> {
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<F>(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;

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -0,0 +1,130 @@
// file: crates/ksp-wallet-lib/unit_tests/persistence.rs
// version: 1
fn temp_directory() -> std::io::Result<tempfile::TempDir> {
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"));
}

155
deltas/0.2.5/pre.006.md Normal file
View File

@@ -0,0 +1,155 @@
<!-- file: deltas/0.2.5/pre.006.md -->
<!-- version: 1 -->
# 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
```

View File

@@ -1,5 +1,5 @@
<!-- file: docs/formats/KSPWALLET_V1.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# `.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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
<!-- version: 50 -->
<!-- version: 51 -->
# 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 dadministration + `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 lindé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 linterop 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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md -->
<!-- version: 8 -->
<!-- version: 9 -->
# 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 denveloppe/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 denveloppe/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.