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,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;