v0.2.5-pre.007
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 154
|
||||
# version: 155
|
||||
|
||||
[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.6.fix.1"
|
||||
version = "0.2.5-pre.7"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 50 -->
|
||||
<!-- version: 51 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -49,7 +49,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
|
||||
- [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants.
|
||||
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
|
||||
- [/] `0.2.5` — Wallet foundation : `pre.001` fixe le threat model et le format V1 autonome ; `pre.002` crée `ksp-wallet-lib` avec capabilities VIEW/OWNER et frontières ; `pre.003` fige le wire strict, key slots, transcript OWNER/AAD et la spécification externe ; `pre.004` matérialise Argon2id/XChaCha20-Poly1305/CSPRNG et le wrapping ; `pre.005` fixe le profil de création KSP issu du benchmark, les payloads owner-control/metadata/secret, l’autorité Ed25519 séparée et les flux in-memory create/open VIEW/OWNER avec vecteur complet interopérable. `Pubkey` reste consommée uniquement via `ksp-core-lib`; Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. `pre.006` 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.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.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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/constants.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Wallet-owned constants.
|
||||
|
||||
@@ -27,6 +27,8 @@ pub const KSPWALLET_V1_NOTE_ID_BYTES: usize = 16;
|
||||
pub const KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES: usize = 32;
|
||||
/// Byte length of an Ed25519 detached state signature.
|
||||
pub const KSPWALLET_V1_ED25519_SIGNATURE_BYTES: usize = 64;
|
||||
/// Byte length of every Solana Ed25519 message signature returned by Wallet OWNER.
|
||||
pub const KSPWALLET_SOLANA_SIGNATURE_BYTES: usize = 64;
|
||||
/// Byte length of an XChaCha20-Poly1305 nonce.
|
||||
pub const KSPWALLET_V1_XCHACHA_NONCE_BYTES: usize = 24;
|
||||
/// Byte length of the Poly1305 authentication tag appended to each ciphertext.
|
||||
@@ -67,7 +69,6 @@ pub const KSPWALLET_V1_MAX_METADATA_CIPHERTEXT_BYTES: usize = KSPWALLET_V1_MAX_M
|
||||
pub const KSPWALLET_V1_MAX_SECRET_CIPHERTEXT_BYTES: usize = 4096;
|
||||
/// Initial protected payload version used independently by control, metadata and secret compartments.
|
||||
pub const KSPWALLET_V1_INITIAL_PAYLOAD_VERSION: u32 = 1;
|
||||
|
||||
/// Domain separator for the OWNER state-signature transcript.
|
||||
pub const KSPWALLET_V1_STATE_TRANSCRIPT_DOMAIN: &[u8] = b"KSPWALLET-V1-STATE";
|
||||
/// Domain separator for OWNER key-slot wrapping AAD.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/error.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Error code used when a native Wallet structure is invalid.
|
||||
pub const ERROR_CODE_FORMAT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "format_invalid");
|
||||
@@ -31,3 +31,7 @@ pub const ERROR_CODE_TRANSFER_FORMAT_UNSUPPORTED: ksp_core_lib::ErrorCode = ksp_
|
||||
pub const ERROR_CODE_KEY_MATERIAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "key_material_invalid");
|
||||
/// Error code used when a Wallet signing operation fails.
|
||||
pub const ERROR_CODE_SIGNATURE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "signature_failed");
|
||||
/// Error code used when a protected Wallet note identifier is not present.
|
||||
pub const ERROR_CODE_NOTE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "note_not_found");
|
||||
/// Error code used when an administrative replacement targets a different or stale Wallet state.
|
||||
pub const ERROR_CODE_STATE_CONFLICT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "state_conflict");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/lib.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -10,9 +10,9 @@
|
||||
//! 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.
|
||||
//! `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
|
||||
//! `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
|
||||
//! `ksp-logging-lib` with the explicit crate target defined in `src/constants.rs`.
|
||||
|
||||
mod capability;
|
||||
@@ -37,6 +37,8 @@ pub use self::constants::KSPWALLET_FORMAT_VERSION_V1;
|
||||
pub use self::constants::KSPWALLET_MAGIC;
|
||||
/// Maximum accepted `.kspwallet` document size before parsing.
|
||||
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;
|
||||
/// 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.
|
||||
@@ -127,12 +129,16 @@ pub use self::error::ERROR_CODE_FORMAT_VERSION_UNSUPPORTED;
|
||||
pub use self::error::ERROR_CODE_IO_FAILED;
|
||||
/// Error code used when imported or decoded key material is invalid.
|
||||
pub use self::error::ERROR_CODE_KEY_MATERIAL_INVALID;
|
||||
/// Error code used when a protected Wallet note identifier is absent.
|
||||
pub use self::error::ERROR_CODE_NOTE_NOT_FOUND;
|
||||
/// Error code used when an OWNER unlock attempt fails without exposing a finer cryptographic oracle.
|
||||
pub use self::error::ERROR_CODE_OWNER_UNLOCK_FAILED;
|
||||
/// Error code used when the operating-system cryptographic random source cannot provide bytes.
|
||||
pub use self::error::ERROR_CODE_RANDOMNESS_FAILED;
|
||||
/// Error code used when a Wallet signing operation fails.
|
||||
pub use self::error::ERROR_CODE_SIGNATURE_FAILED;
|
||||
/// Error code used when an administrative replacement sees a different or stale Wallet state.
|
||||
pub use self::error::ERROR_CODE_STATE_CONFLICT;
|
||||
/// Error code used when an import/export transfer format is unsupported.
|
||||
pub use self::error::ERROR_CODE_TRANSFER_FORMAT_UNSUPPORTED;
|
||||
/// Error code used when a VIEW unlock attempt fails without exposing a finer cryptographic oracle.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// file: crates/ksp-wallet-lib/src/owner.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
/// Authorized OWNER capability handle.
|
||||
///
|
||||
/// OWNER exposes protected metadata and retains the authenticated in-memory state required by later signing and administration tranches without
|
||||
/// exposing a general-purpose secret-key getter.
|
||||
/// OWNER exposes protected metadata, Solana message signing and the authenticated administration operations defined by native Wallet V1. Secret key
|
||||
/// material remains encapsulated and is never exposed through a general-purpose getter.
|
||||
pub struct WalletOwner {
|
||||
info: crate::WalletInfo,
|
||||
state: crate::wallet::OwnerStateV1,
|
||||
@@ -51,10 +51,212 @@ impl WalletOwner {
|
||||
return &self.info;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn sign(&self, message: &[u8]) -> ksp_core_lib::Result<[u8; crate::KSPWALLET_SOLANA_SIGNATURE_BYTES]> {
|
||||
let signature = self.state.sign_message(message);
|
||||
if signature.is_ok() {
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_sign",
|
||||
capability = "owner",
|
||||
message_bytes = message.len(),
|
||||
"wallet message signed"
|
||||
);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
/// Updates or clears the protected internal alias and atomically replaces the existing wallet file.
|
||||
pub async fn update_alias(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
alias: std::option::Option<std::string::String>,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
|
||||
let mutation_result = payload.set_alias(alias);
|
||||
if let std::result::Result::Err(error) = mutation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.persist_metadata_payload(destination.as_ref().to_path_buf(), payload, "wallet_update_alias").await;
|
||||
}
|
||||
|
||||
/// Adds one protected note and returns its generated stable note identifier after successful persistence.
|
||||
pub async fn add_note(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
text: std::string::String,
|
||||
) -> ksp_core_lib::Result<std::string::String> {
|
||||
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
|
||||
let note_id = match payload.add_note(text) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = self.persist_metadata_payload(destination.as_ref().to_path_buf(), payload, "wallet_add_note").await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(note_id);
|
||||
}
|
||||
|
||||
/// Updates one protected note selected by its stable identifier.
|
||||
pub async fn update_note(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
note_id: &str,
|
||||
text: std::string::String,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
|
||||
let mutation_result = payload.update_note(note_id, text);
|
||||
if let std::result::Result::Err(error) = mutation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.persist_metadata_payload(destination.as_ref().to_path_buf(), payload, "wallet_update_note").await;
|
||||
}
|
||||
|
||||
/// Deletes one protected note selected by its stable identifier.
|
||||
pub async fn delete_note(&mut self, destination: impl std::convert::AsRef<std::path::Path>, note_id: &str) -> ksp_core_lib::Result<()> {
|
||||
let mut payload = crate::payload::MetadataPayloadV1::from_info(&self.info);
|
||||
let mutation_result = payload.delete_note(note_id);
|
||||
if let std::result::Result::Err(error) = mutation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.persist_metadata_payload(destination.as_ref().to_path_buf(), payload, "wallet_delete_note").await;
|
||||
}
|
||||
|
||||
/// Rotates the OWNER password while preserving the Wallet identity, metadata content key and Solana keypair.
|
||||
pub async fn rotate_owner_password(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
new_password: crate::OwnerPassword,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let envelope = match self.state.stage_owner_password_rotation(new_password).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = persist_staged(destination.as_ref().to_path_buf(), self.state.envelope(), &envelope).await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.state.apply_envelope(envelope);
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_rotate_owner_password",
|
||||
capability = "owner",
|
||||
"wallet OWNER password rotated"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Rotates the existing VIEW password without knowing the previous VIEW password.
|
||||
///
|
||||
/// This is a credential rotation only: it rewraps the same metadata content key in the same OWNER-signed VIEW slot descriptor.
|
||||
pub async fn rotate_view_password(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
new_password: crate::ViewPassword,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let envelope = match self.state.stage_view_password_rotation(new_password).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = persist_staged(destination.as_ref().to_path_buf(), self.state.envelope(), &envelope).await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.state.apply_envelope(envelope);
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_rotate_view_password",
|
||||
capability = "owner",
|
||||
"wallet VIEW password rotated by OWNER"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Strongly disables VIEW by generating a new metadata content key and removing the VIEW slot/descriptor.
|
||||
///
|
||||
/// Existing historical copies remain outside the revocation guarantee, but the old VIEW metadata key cannot decrypt future metadata states.
|
||||
pub async fn disable_view(&mut self, destination: impl std::convert::AsRef<std::path::Path>) -> ksp_core_lib::Result<()> {
|
||||
let (envelope, metadata_key) = match self.state.stage_disable_view() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = persist_staged(destination.as_ref().to_path_buf(), self.state.envelope(), &envelope).await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.state.apply_strong_view_state(envelope, metadata_key);
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_disable_view",
|
||||
capability = "owner",
|
||||
"wallet VIEW capability strongly disabled"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Strongly recreates VIEW with a fresh metadata content key, fresh slot identifier and new VIEW password.
|
||||
pub async fn recreate_view(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
new_password: crate::ViewPassword,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let (envelope, metadata_key) = match self.state.stage_recreate_view(new_password).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = persist_staged(destination.as_ref().to_path_buf(), self.state.envelope(), &envelope).await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.state.apply_strong_view_state(envelope, metadata_key);
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_recreate_view",
|
||||
capability = "owner",
|
||||
"wallet VIEW capability strongly recreated"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Serializes the complete locked `.kspwallet` V1 document without exposing any unlocked secret material.
|
||||
pub fn to_json_bytes(&self) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
return self.state.envelope().to_json_bytes();
|
||||
}
|
||||
|
||||
async fn persist_metadata_payload(
|
||||
&mut self,
|
||||
destination: std::path::PathBuf,
|
||||
payload: crate::payload::MetadataPayloadV1,
|
||||
operation: &'static str,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let (envelope, info) = match self.state.stage_metadata_payload(payload) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = persist_staged(destination, self.state.envelope(), &envelope).await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.state.apply_envelope(envelope);
|
||||
self.info = info;
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, operation = operation, capability = "owner", "wallet protected metadata updated");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_staged(
|
||||
destination: std::path::PathBuf,
|
||||
expected_current: &crate::KspWalletEnvelopeV1,
|
||||
envelope: &crate::KspWalletEnvelopeV1,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let serialized = match envelope.to_json_bytes() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return crate::persistence::replace_wallet_file_v1(destination, expected_current.clone(), serialized).await;
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WalletOwner {
|
||||
@@ -62,3 +264,7 @@ impl std::fmt::Debug for WalletOwner {
|
||||
return formatter.debug_struct("WalletOwner").field("info", &self.info).field("unlocked_state", &"<redacted>").finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/administration.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/payload.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Plaintext payload codecs protected inside native `.kspwallet` V1 compartments.
|
||||
|
||||
@@ -17,6 +17,99 @@ impl MetadataPayloadV1 {
|
||||
return &self.pubkey;
|
||||
}
|
||||
|
||||
pub(crate) fn from_info(info: &crate::WalletInfo) -> Self {
|
||||
let alias = info.alias().map(std::string::String::from);
|
||||
let notes = info.notes().to_vec();
|
||||
return Self { pubkey: *info.pubkey(), alias, notes };
|
||||
}
|
||||
|
||||
pub(crate) fn set_alias(&mut self, alias: std::option::Option<std::string::String>) -> ksp_core_lib::Result<()> {
|
||||
let validation_result = validate_alias(alias.as_deref());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.alias = alias;
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
pub(crate) fn add_note(&mut self, text: std::string::String) -> ksp_core_lib::Result<std::string::String> {
|
||||
if self.notes.len() >= crate::KSPWALLET_V1_MAX_NOTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes"));
|
||||
}
|
||||
let validation_result = validate_note_text(text.as_str());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let note_id_bytes = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_NOTE_ID_BYTES }>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let note_id = encode_base64url(note_id_bytes.as_slice());
|
||||
for note in &self.notes {
|
||||
if note.id() == note_id.as_str() {
|
||||
return std::result::Result::Err(random_note_identifier_error());
|
||||
}
|
||||
}
|
||||
self.notes.push(crate::WalletNote::new(note_id.clone(), text));
|
||||
return std::result::Result::Ok(note_id);
|
||||
}
|
||||
|
||||
pub(crate) fn update_note(&mut self, note_id: &str, text: std::string::String) -> ksp_core_lib::Result<()> {
|
||||
let validation_result = validate_note_text(text.as_str());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut index = 0_usize;
|
||||
while index < self.notes.len() {
|
||||
if self.notes[index].id() == note_id {
|
||||
self.notes[index] = crate::WalletNote::new(std::string::String::from(note_id), text);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return std::result::Result::Err(note_not_found_error());
|
||||
}
|
||||
|
||||
pub(crate) fn delete_note(&mut self, note_id: &str) -> ksp_core_lib::Result<()> {
|
||||
let mut index = 0_usize;
|
||||
while index < self.notes.len() {
|
||||
if self.notes[index].id() == note_id {
|
||||
self.notes.remove(index);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return std::result::Result::Err(note_not_found_error());
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let validation_result = validate_alias(self.alias.as_deref());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if self.notes.len() > crate::KSPWALLET_V1_MAX_NOTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes"));
|
||||
}
|
||||
let mut raw_notes = std::vec::Vec::with_capacity(self.notes.len());
|
||||
for note in &self.notes {
|
||||
let validation_result = validate_note_text(note.text());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
raw_notes.push(RawMetadataNoteV1 { id: std::string::String::from(note.id()), text: std::string::String::from(note.text()) });
|
||||
}
|
||||
let raw = RawMetadataPayloadV1 { pubkey: self.pubkey.to_string(), alias: self.alias.clone(), notes: raw_notes };
|
||||
let serialized_result = serde_json::to_vec(&raw);
|
||||
let serialized = match serialized_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata payload cannot be serialized", "metadata")),
|
||||
};
|
||||
if serialized.len() > crate::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet metadata payload exceeds the V1 plaintext limit", "metadata"));
|
||||
}
|
||||
return std::result::Result::Ok(serialized);
|
||||
}
|
||||
|
||||
pub(crate) fn into_info(self, capability: crate::WalletCapability) -> crate::WalletInfo {
|
||||
return crate::WalletInfo::new(capability, self.pubkey, self.alias, self.notes);
|
||||
}
|
||||
@@ -64,21 +157,20 @@ pub(crate) fn encode_initial_metadata_payload(
|
||||
metadata: crate::WalletCreateMetadataV1,
|
||||
) -> ksp_core_lib::Result<(std::vec::Vec<u8>, MetadataPayloadV1)> {
|
||||
let (alias, note_texts) = metadata.into_parts();
|
||||
if let std::option::Option::Some(alias_value) = alias.as_ref()
|
||||
&& alias_value.len() > crate::KSPWALLET_V1_MAX_ALIAS_BYTES
|
||||
{
|
||||
return std::result::Result::Err(metadata_error("Wallet alias exceeds the V1 UTF-8 byte limit", "metadata.alias"));
|
||||
let validation_result = validate_alias(alias.as_deref());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if note_texts.len() > crate::KSPWALLET_V1_MAX_NOTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes"));
|
||||
}
|
||||
|
||||
let mut raw_notes = std::vec::Vec::with_capacity(note_texts.len());
|
||||
let mut notes = std::vec::Vec::with_capacity(note_texts.len());
|
||||
let mut note_ids = std::collections::BTreeSet::new();
|
||||
for text in note_texts {
|
||||
if text.len() > crate::KSPWALLET_V1_MAX_NOTE_TEXT_BYTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note text exceeds the V1 UTF-8 byte limit", "metadata.notes.text"));
|
||||
let validation_result = validate_note_text(text.as_str());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let note_id_bytes = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_NOTE_ID_BYTES }>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -88,20 +180,15 @@ pub(crate) fn encode_initial_metadata_payload(
|
||||
if !note_ids.insert(note_id.clone()) {
|
||||
return std::result::Result::Err(random_note_identifier_error());
|
||||
}
|
||||
raw_notes.push(RawMetadataNoteV1 { id: note_id.clone(), text: text.clone() });
|
||||
notes.push(crate::WalletNote::new(note_id, text));
|
||||
}
|
||||
|
||||
let raw = RawMetadataPayloadV1 { pubkey: pubkey.to_string(), alias: alias.clone(), notes: raw_notes };
|
||||
let serialized_result = serde_json::to_vec(&raw);
|
||||
let serialized = match serialized_result {
|
||||
let payload = MetadataPayloadV1 { pubkey, alias, notes };
|
||||
let serialized = match payload.encode() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata payload cannot be serialized", "metadata")),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if serialized.len() > crate::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet metadata payload exceeds the V1 plaintext limit", "metadata"));
|
||||
}
|
||||
return std::result::Result::Ok((serialized, MetadataPayloadV1 { pubkey, alias, notes }));
|
||||
return std::result::Result::Ok((serialized, payload));
|
||||
}
|
||||
|
||||
pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<MetadataPayloadV1> {
|
||||
@@ -113,10 +200,9 @@ pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<Met
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata payload is invalid", "metadata")),
|
||||
};
|
||||
if let std::option::Option::Some(alias) = raw.alias.as_ref()
|
||||
&& alias.len() > crate::KSPWALLET_V1_MAX_ALIAS_BYTES
|
||||
{
|
||||
return std::result::Result::Err(metadata_error("Wallet alias exceeds the V1 UTF-8 byte limit", "metadata.alias"));
|
||||
let validation_result = validate_alias(raw.alias.as_deref());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if raw.notes.len() > crate::KSPWALLET_V1_MAX_NOTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes"));
|
||||
@@ -134,8 +220,9 @@ pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<Met
|
||||
let mut notes = std::vec::Vec::with_capacity(raw.notes.len());
|
||||
let mut note_ids = std::collections::BTreeSet::new();
|
||||
for raw_note in raw.notes {
|
||||
if raw_note.text.len() > crate::KSPWALLET_V1_MAX_NOTE_TEXT_BYTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note text exceeds the V1 UTF-8 byte limit", "metadata.notes.text"));
|
||||
let validation_result = validate_note_text(raw_note.text.as_str());
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let id_bytes = match decode_base64url(raw_note.id.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -181,6 +268,22 @@ pub(crate) fn decode_owner_control_payload(source: &[u8]) -> ksp_core_lib::Resul
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_alias(alias: std::option::Option<&str>) -> ksp_core_lib::Result<()> {
|
||||
if let std::option::Option::Some(alias_value) = alias
|
||||
&& alias_value.len() > crate::KSPWALLET_V1_MAX_ALIAS_BYTES
|
||||
{
|
||||
return std::result::Result::Err(metadata_error("Wallet alias exceeds the V1 UTF-8 byte limit", "metadata.alias"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_note_text(text: &str) -> ksp_core_lib::Result<()> {
|
||||
if text.len() > crate::KSPWALLET_V1_MAX_NOTE_TEXT_BYTES {
|
||||
return std::result::Result::Err(metadata_error("Wallet note text exceeds the V1 UTF-8 byte limit", "metadata.notes.text"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn encode_base64url(bytes: &[u8]) -> std::string::String {
|
||||
return base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
}
|
||||
@@ -198,6 +301,10 @@ fn random_note_identifier_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RANDOMNESS_FAILED, "Wallet generated a duplicate protected note identifier");
|
||||
}
|
||||
|
||||
fn note_not_found_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_NOTE_NOT_FOUND, "Wallet note identifier was not found");
|
||||
}
|
||||
|
||||
fn metadata_error(message: &'static str, field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, message).with_context("field", field);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/persistence.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Async-first native Wallet V1 filesystem persistence.
|
||||
|
||||
@@ -98,6 +98,20 @@ async fn persist_new_wallet_async(destination: std::path::PathBuf, content: std:
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_wallet_file_v1(
|
||||
destination: std::path::PathBuf,
|
||||
expected_current: crate::KspWalletEnvelopeV1,
|
||||
content: std::vec::Vec<u8>,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
return replace_wallet_file_checked_blocking(destination.as_path(), &expected_current, 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("replace_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 {
|
||||
@@ -130,6 +144,95 @@ fn persist_new_wallet_blocking(destination: &std::path::Path, content: &[u8]) ->
|
||||
return persist_new_wallet_with_hook(destination, content, || return std::result::Result::Ok(()));
|
||||
}
|
||||
|
||||
fn replace_wallet_file_checked_blocking(
|
||||
destination: &std::path::Path,
|
||||
expected_current: &crate::KspWalletEnvelopeV1,
|
||||
content: &[u8],
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let current_check = verify_expected_wallet_state(destination, expected_current);
|
||||
if let std::result::Result::Err(error) = current_check {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return replace_wallet_file_with_hook(destination, content, || return verify_expected_wallet_state(destination, expected_current));
|
||||
}
|
||||
|
||||
fn replace_wallet_file_blocking(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return replace_wallet_file_with_hook(destination, content, || return std::result::Result::Ok(()));
|
||||
}
|
||||
|
||||
fn verify_expected_wallet_state(destination: &std::path::Path, expected_current: &crate::KspWalletEnvelopeV1) -> ksp_core_lib::Result<()> {
|
||||
let current_bytes = match read_wallet_file_blocking(destination) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let current = match crate::KspWalletEnvelopeV1::parse_json(current_bytes.as_slice()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let verify_result = crate::wallet::verify_state_signature(¤t);
|
||||
if let std::result::Result::Err(error) = verify_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if ¤t != expected_current {
|
||||
return std::result::Result::Err(state_conflict_error());
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn replace_wallet_file_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 = 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(atomic_error("destination", "Wallet destination has no file name"));
|
||||
}
|
||||
let existing_metadata = std::fs::metadata(destination);
|
||||
let existing_metadata = match existing_metadata {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(io_error("replace_metadata", error)),
|
||||
};
|
||||
if !existing_metadata.is_file() {
|
||||
return std::result::Result::Err(atomic_error("replace_destination", "Wallet replacement destination is not a regular file"));
|
||||
}
|
||||
|
||||
let temporary_result = tempfile::Builder::new().prefix(".kspwallet-replace-").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("replacement_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("replacement_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("replacement_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(destination);
|
||||
let persisted = match persist_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(atomic_io_error("replacement_publish", 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("replacement_file_sync", error));
|
||||
}
|
||||
sync_parent_directory_best_effort(parent);
|
||||
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<()>,
|
||||
@@ -230,6 +333,10 @@ fn destination_exists_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_DESTINATION_EXISTS, "Wallet destination already exists");
|
||||
}
|
||||
|
||||
fn state_conflict_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_STATE_CONFLICT, "Wallet replacement source state does not match the authenticated handle");
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -270,6 +377,18 @@ pub(crate) fn persist_new_wallet_for_test(destination: &std::path::Path, content
|
||||
return persist_new_wallet_blocking(destination, content);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn replace_wallet_fault_before_publish(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return replace_wallet_file_with_hook(destination, content, || {
|
||||
return std::result::Result::Err(atomic_error("replace_fault_injection", "Injected Wallet replacement failure before publication"));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn replace_wallet_for_test(destination: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return replace_wallet_file_blocking(destination, content);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/persistence.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// file: crates/ksp-wallet-lib/src/view.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
/// Authorized VIEW capability handle.
|
||||
///
|
||||
/// VIEW exposes protected metadata and retains only the authenticated metadata capability needed for its future self-service password rotation. It never
|
||||
/// owns the Solana secret or OWNER administration material.
|
||||
/// VIEW exposes protected metadata and retains only the authenticated metadata capability required for self-service password rotation. It never owns the
|
||||
/// Solana secret, OWNER administration material or metadata-write authority.
|
||||
pub struct WalletView {
|
||||
info: crate::WalletInfo,
|
||||
state: crate::wallet::ViewStateV1,
|
||||
@@ -51,6 +51,37 @@ impl WalletView {
|
||||
return &self.info;
|
||||
}
|
||||
|
||||
/// Rotates only this VIEW credential and atomically replaces the existing wallet file.
|
||||
///
|
||||
/// The same metadata content key and OWNER-signed VIEW slot identifier are retained. Alias, notes, Pubkey, OWNER material, activation state and the
|
||||
/// Solana secret remain unchanged.
|
||||
pub async fn rotate_view_password(
|
||||
&mut self,
|
||||
destination: impl std::convert::AsRef<std::path::Path>,
|
||||
new_password: crate::ViewPassword,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let envelope = match self.state.stage_view_password_rotation(new_password).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let serialized = match envelope.to_json_bytes() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let persist_result = crate::persistence::replace_wallet_file_v1(destination.as_ref().to_path_buf(), self.state.envelope().clone(), serialized).await;
|
||||
if let std::result::Result::Err(error) = persist_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.state.apply_envelope(envelope);
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
operation = "wallet_rotate_view_password",
|
||||
capability = "view",
|
||||
"wallet VIEW password self-rotated"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Serializes the unchanged locked `.kspwallet` V1 document without exposing the metadata content key.
|
||||
pub fn to_json_bytes(&self) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
return self.state.envelope().to_json_bytes();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/wallet.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! In-memory native Wallet V1 create/open orchestration.
|
||||
|
||||
@@ -37,6 +37,283 @@ impl OwnerStateV1 {
|
||||
pub(crate) const fn envelope(&self) -> &crate::KspWalletEnvelopeV1 {
|
||||
return &self.envelope;
|
||||
}
|
||||
|
||||
pub(crate) fn apply_envelope(&mut self, envelope: crate::KspWalletEnvelopeV1) {
|
||||
self.envelope = envelope;
|
||||
return;
|
||||
}
|
||||
|
||||
pub(crate) fn apply_strong_view_state(&mut self, envelope: crate::KspWalletEnvelopeV1, metadata_key: crate::crypto::SecretKeyV1) {
|
||||
self.envelope = envelope;
|
||||
let previous = self.metadata_key.replace(metadata_key);
|
||||
drop(previous);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
std::option::Option::None => return std::result::Result::Err(signing_error()),
|
||||
};
|
||||
let mut keypair_bytes = keypair.to_bytes();
|
||||
let mut seed = [0_u8; crate::crypto::SECRET_KEY_BYTES];
|
||||
seed.copy_from_slice(&keypair_bytes[0..crate::crypto::SECRET_KEY_BYTES]);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
let signature = signing_key.sign(message).to_bytes();
|
||||
seed.zeroize();
|
||||
keypair_bytes.zeroize();
|
||||
return std::result::Result::Ok(signature);
|
||||
}
|
||||
|
||||
pub(crate) fn stage_metadata_payload(
|
||||
&self,
|
||||
payload: crate::payload::MetadataPayloadV1,
|
||||
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::WalletInfo)> {
|
||||
let metadata_key = match self.metadata_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let admin_signing_key = match self.admin_signing_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let mut plaintext = match payload.encode() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let nonce = match crate::crypto::random_nonce() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
plaintext.zeroize();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let encrypted = crate::crypto::encrypt_bytes(
|
||||
metadata_key,
|
||||
&nonce,
|
||||
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
|
||||
plaintext.as_slice(),
|
||||
);
|
||||
plaintext.zeroize();
|
||||
let ciphertext = match encrypted {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::Metadata, nonce, ciphertext);
|
||||
let envelope = resign_envelope(
|
||||
&self.envelope,
|
||||
*self.envelope.view_descriptor(),
|
||||
self.envelope.owner_slot().clone(),
|
||||
self.envelope.view_slot().cloned(),
|
||||
self.envelope.owner_control().clone(),
|
||||
metadata,
|
||||
self.envelope.secret().clone(),
|
||||
admin_signing_key,
|
||||
);
|
||||
let verify_result = verify_state_signature(&envelope);
|
||||
if let std::result::Result::Err(error) = verify_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok((envelope, payload.into_info(crate::WalletCapability::Owner)));
|
||||
}
|
||||
|
||||
pub(crate) async fn stage_owner_password_rotation(&self, new_password: crate::OwnerPassword) -> ksp_core_lib::Result<crate::KspWalletEnvelopeV1> {
|
||||
let owner_root = match self.owner_root.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let admin_signing_key = match self.admin_signing_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let owner_slot = match rewrap_slot(
|
||||
&self.envelope,
|
||||
*self.envelope.owner_slot().slot_id(),
|
||||
crate::WalletKeySlotRoleV1::Owner,
|
||||
owner_root,
|
||||
PasswordRotationV1::Owner(new_password),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let envelope = resign_envelope(
|
||||
&self.envelope,
|
||||
*self.envelope.view_descriptor(),
|
||||
owner_slot,
|
||||
self.envelope.view_slot().cloned(),
|
||||
self.envelope.owner_control().clone(),
|
||||
self.envelope.metadata().clone(),
|
||||
self.envelope.secret().clone(),
|
||||
admin_signing_key,
|
||||
);
|
||||
return verify_and_return(envelope);
|
||||
}
|
||||
|
||||
pub(crate) async fn stage_view_password_rotation(&self, new_password: crate::ViewPassword) -> ksp_core_lib::Result<crate::KspWalletEnvelopeV1> {
|
||||
let metadata_key = match self.metadata_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let view_slot = match self.envelope.view_slot() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
|
||||
};
|
||||
let new_slot =
|
||||
match rewrap_slot(&self.envelope, *view_slot.slot_id(), crate::WalletKeySlotRoleV1::View, metadata_key, PasswordRotationV1::View(new_password))
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let envelope = crate::KspWalletEnvelopeV1::new_internal(
|
||||
*self.envelope.owner_auth_public_key(),
|
||||
*self.envelope.view_descriptor(),
|
||||
self.envelope.owner_slot().clone(),
|
||||
std::option::Option::Some(new_slot),
|
||||
self.envelope.owner_control().clone(),
|
||||
self.envelope.metadata().clone(),
|
||||
self.envelope.secret().clone(),
|
||||
self.envelope.state_signature().clone(),
|
||||
);
|
||||
return verify_and_return(envelope);
|
||||
}
|
||||
|
||||
pub(crate) fn stage_disable_view(&self) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
|
||||
if !self.envelope.view_descriptor().enabled() {
|
||||
return std::result::Result::Err(capability_error("Wallet VIEW capability is already disabled"));
|
||||
}
|
||||
return self.stage_strong_view_change(std::option::Option::None);
|
||||
}
|
||||
|
||||
pub(crate) async fn stage_recreate_view(
|
||||
&self,
|
||||
new_password: crate::ViewPassword,
|
||||
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
|
||||
let slot_id = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let new_metadata_key = match crate::crypto::SecretKeyV1::random() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let view_slot =
|
||||
match rewrap_slot(&self.envelope, slot_id, crate::WalletKeySlotRoleV1::View, &new_metadata_key, PasswordRotationV1::View(new_password)).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self.stage_strong_view_change_with_key(std::option::Option::Some(view_slot), new_metadata_key);
|
||||
}
|
||||
|
||||
fn stage_strong_view_change(
|
||||
&self,
|
||||
view_slot: std::option::Option<crate::WalletKeySlotV1>,
|
||||
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
|
||||
let new_metadata_key = match crate::crypto::SecretKeyV1::random() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self.stage_strong_view_change_with_key(view_slot, new_metadata_key);
|
||||
}
|
||||
|
||||
fn stage_strong_view_change_with_key(
|
||||
&self,
|
||||
view_slot: std::option::Option<crate::WalletKeySlotV1>,
|
||||
new_metadata_key: crate::crypto::SecretKeyV1,
|
||||
) -> ksp_core_lib::Result<(crate::KspWalletEnvelopeV1, crate::crypto::SecretKeyV1)> {
|
||||
let current_metadata_key = match self.metadata_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let owner_root = match self.owner_root.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let secret_key = match self.secret_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let admin_signing_key = match self.admin_signing_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
|
||||
let current_plaintext_result = crate::crypto::decrypt_bytes(
|
||||
current_metadata_key,
|
||||
self.envelope.metadata().nonce(),
|
||||
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
|
||||
self.envelope.metadata().ciphertext(),
|
||||
);
|
||||
let mut metadata_plaintext = match current_plaintext_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
|
||||
};
|
||||
let metadata_nonce = match crate::crypto::random_nonce() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
metadata_plaintext.zeroize();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let metadata_ciphertext_result = crate::crypto::encrypt_bytes(
|
||||
&new_metadata_key,
|
||||
&metadata_nonce,
|
||||
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
|
||||
metadata_plaintext.as_slice(),
|
||||
);
|
||||
metadata_plaintext.zeroize();
|
||||
let metadata_ciphertext = match metadata_ciphertext_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::Metadata, metadata_nonce, metadata_ciphertext);
|
||||
|
||||
let mut admin_secret = admin_signing_key.to_bytes();
|
||||
let mut owner_control_plaintext = crate::payload::encode_owner_control_payload(&admin_secret, &new_metadata_key, secret_key);
|
||||
admin_secret.zeroize();
|
||||
let owner_control_nonce = match crate::crypto::random_nonce() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
owner_control_plaintext.zeroize();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let owner_control_ciphertext_result = crate::crypto::encrypt_bytes(
|
||||
owner_root,
|
||||
&owner_control_nonce,
|
||||
self.envelope.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
|
||||
owner_control_plaintext.as_slice(),
|
||||
);
|
||||
owner_control_plaintext.zeroize();
|
||||
let owner_control_ciphertext = match owner_control_ciphertext_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let owner_control =
|
||||
crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::OwnerControl, owner_control_nonce, owner_control_ciphertext);
|
||||
|
||||
let view_descriptor = match view_slot.as_ref() {
|
||||
std::option::Option::Some(slot) => crate::WalletViewDescriptorV1::enabled_for_slot(*slot.slot_id()),
|
||||
std::option::Option::None => crate::WalletViewDescriptorV1::disabled(),
|
||||
};
|
||||
let envelope = resign_envelope(
|
||||
&self.envelope,
|
||||
view_descriptor,
|
||||
self.envelope.owner_slot().clone(),
|
||||
view_slot,
|
||||
owner_control,
|
||||
metadata,
|
||||
self.envelope.secret().clone(),
|
||||
admin_signing_key,
|
||||
);
|
||||
let envelope = match verify_and_return(envelope) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok((envelope, new_metadata_key));
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Drop for OwnerStateV1 {
|
||||
@@ -67,6 +344,40 @@ impl ViewStateV1 {
|
||||
pub(crate) const fn envelope(&self) -> &crate::KspWalletEnvelopeV1 {
|
||||
return &self.envelope;
|
||||
}
|
||||
|
||||
pub(crate) fn apply_envelope(&mut self, envelope: crate::KspWalletEnvelopeV1) {
|
||||
self.envelope = envelope;
|
||||
return;
|
||||
}
|
||||
|
||||
pub(crate) async fn stage_view_password_rotation(&self, new_password: crate::ViewPassword) -> ksp_core_lib::Result<crate::KspWalletEnvelopeV1> {
|
||||
let metadata_key = match self.metadata_key.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crypto_operation_error()),
|
||||
};
|
||||
let view_slot = match self.envelope.view_slot() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
|
||||
};
|
||||
let new_slot =
|
||||
match rewrap_slot(&self.envelope, *view_slot.slot_id(), crate::WalletKeySlotRoleV1::View, metadata_key, PasswordRotationV1::View(new_password))
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let envelope = crate::KspWalletEnvelopeV1::new_internal(
|
||||
*self.envelope.owner_auth_public_key(),
|
||||
*self.envelope.view_descriptor(),
|
||||
self.envelope.owner_slot().clone(),
|
||||
std::option::Option::Some(new_slot),
|
||||
self.envelope.owner_control().clone(),
|
||||
self.envelope.metadata().clone(),
|
||||
self.envelope.secret().clone(),
|
||||
self.envelope.state_signature().clone(),
|
||||
);
|
||||
return verify_and_return(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Drop for ViewStateV1 {
|
||||
@@ -78,8 +389,8 @@ impl std::ops::Drop for ViewStateV1 {
|
||||
|
||||
/// Creates a new in-memory native `.kspwallet` V1 with a fresh Solana keypair.
|
||||
///
|
||||
/// The optional VIEW password creates an independent VIEW slot. This function performs no filesystem I/O; use the OWNER handle's
|
||||
/// `to_json_bytes()` projection for the complete locked document until `pre.006` adds atomic persistence.
|
||||
/// The optional VIEW password creates an independent VIEW slot. This function performs no filesystem I/O; use [`crate::create_wallet_file_v1`] when
|
||||
/// the caller also wants no-clobber native filesystem publication.
|
||||
pub async fn create_wallet_v1(
|
||||
owner_password: crate::OwnerPassword,
|
||||
view_password: std::option::Option<crate::ViewPassword>,
|
||||
@@ -500,6 +811,90 @@ pub(crate) fn verify_state_signature(envelope: &crate::KspWalletEnvelopeV1) -> k
|
||||
};
|
||||
}
|
||||
|
||||
enum PasswordRotationV1 {
|
||||
Owner(crate::OwnerPassword),
|
||||
View(crate::ViewPassword),
|
||||
}
|
||||
|
||||
async fn rewrap_slot(
|
||||
envelope: &crate::KspWalletEnvelopeV1,
|
||||
slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
|
||||
role: crate::WalletKeySlotRoleV1,
|
||||
capability_key: &crate::crypto::SecretKeyV1,
|
||||
password: PasswordRotationV1,
|
||||
) -> ksp_core_lib::Result<crate::WalletKeySlotV1> {
|
||||
let salt = match crate::crypto::random_bytes::<{ crate::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES }>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kdf = crate::WalletKdfParametersV1::new_creation(salt.to_vec());
|
||||
let nonce = match crate::crypto::random_nonce() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let derived_result = match password {
|
||||
PasswordRotationV1::Owner(value) => derive_owner_password_key_async(value, kdf.clone()).await,
|
||||
PasswordRotationV1::View(value) => derive_view_password_key_async(value, kdf.clone()).await,
|
||||
};
|
||||
let derived = match derived_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provisional = crate::WalletKeySlotV1::new(
|
||||
slot_id,
|
||||
role,
|
||||
kdf.clone(),
|
||||
crate::WalletKeyWrapV1::new(nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
|
||||
);
|
||||
let aad = crate::slot_aad(envelope, &provisional);
|
||||
let wrapped = match crate::crypto::wrap_key(&derived, capability_key, &nonce, aad.as_slice()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::WalletKeySlotV1::new(slot_id, role, kdf, crate::WalletKeyWrapV1::new(nonce, wrapped)));
|
||||
}
|
||||
|
||||
fn resign_envelope(
|
||||
base: &crate::KspWalletEnvelopeV1,
|
||||
view_descriptor: crate::WalletViewDescriptorV1,
|
||||
owner_slot: crate::WalletKeySlotV1,
|
||||
view_slot: std::option::Option<crate::WalletKeySlotV1>,
|
||||
owner_control: crate::WalletEncryptedCompartmentV1,
|
||||
metadata: crate::WalletEncryptedCompartmentV1,
|
||||
secret: crate::WalletEncryptedCompartmentV1,
|
||||
admin_signing_key: &ed25519_dalek::SigningKey,
|
||||
) -> crate::KspWalletEnvelopeV1 {
|
||||
let unsigned = crate::KspWalletEnvelopeV1::new_internal(
|
||||
*base.owner_auth_public_key(),
|
||||
view_descriptor,
|
||||
owner_slot.clone(),
|
||||
view_slot.clone(),
|
||||
owner_control.clone(),
|
||||
metadata.clone(),
|
||||
secret.clone(),
|
||||
crate::WalletStateSignatureV1::new([0_u8; crate::KSPWALLET_V1_ED25519_SIGNATURE_BYTES]),
|
||||
);
|
||||
let signature = admin_signing_key.sign(unsigned.state_transcript().as_slice()).to_bytes();
|
||||
return crate::KspWalletEnvelopeV1::new_internal(
|
||||
*base.owner_auth_public_key(),
|
||||
view_descriptor,
|
||||
owner_slot,
|
||||
view_slot,
|
||||
owner_control,
|
||||
metadata,
|
||||
secret,
|
||||
crate::WalletStateSignatureV1::new(signature),
|
||||
);
|
||||
}
|
||||
|
||||
fn verify_and_return(envelope: crate::KspWalletEnvelopeV1) -> ksp_core_lib::Result<crate::KspWalletEnvelopeV1> {
|
||||
let verify_result = verify_state_signature(&envelope);
|
||||
if let std::result::Result::Err(error) = verify_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(envelope);
|
||||
}
|
||||
|
||||
struct ViewCreationMaterialV1 {
|
||||
slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
|
||||
kdf: crate::WalletKdfParametersV1,
|
||||
@@ -673,6 +1068,10 @@ fn capability_error(message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_CAPABILITY_INSUFFICIENT, message);
|
||||
}
|
||||
|
||||
fn signing_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_SIGNATURE_FAILED, "Wallet Solana message signing failed");
|
||||
}
|
||||
|
||||
fn key_material_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_KEY_MATERIAL_INVALID, "Wallet Solana key material is invalid or inconsistent");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/tests/public_api.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Public API canaries for the Wallet foundation.
|
||||
|
||||
@@ -55,8 +55,10 @@ fn wallet_error_codes_are_available_from_crate_root() {
|
||||
ksp_wallet_lib::ERROR_CODE_TRANSFER_FORMAT_UNSUPPORTED,
|
||||
ksp_wallet_lib::ERROR_CODE_KEY_MATERIAL_INVALID,
|
||||
ksp_wallet_lib::ERROR_CODE_SIGNATURE_FAILED,
|
||||
ksp_wallet_lib::ERROR_CODE_NOTE_NOT_FOUND,
|
||||
ksp_wallet_lib::ERROR_CODE_STATE_CONFLICT,
|
||||
];
|
||||
assert_eq!(codes.len(), 15);
|
||||
assert_eq!(codes.len(), 17);
|
||||
for code in codes {
|
||||
assert_eq!(code.domain(), "wallet");
|
||||
}
|
||||
@@ -112,3 +114,11 @@ fn public_pre_006_file_persistence_surface_is_available_from_crate_root() {
|
||||
let inspect_future = ksp_wallet_lib::inspect_locked_wallet_file_v1(path);
|
||||
drop(inspect_future);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_007_owner_signing_and_administration_surface_is_available() {
|
||||
let sign_method: fn(&ksp_wallet_lib::WalletOwner, &[u8]) -> ksp_core_lib::Result<[u8; ksp_wallet_lib::KSPWALLET_SOLANA_SIGNATURE_BYTES]> =
|
||||
ksp_wallet_lib::WalletOwner::sign;
|
||||
let _ = sign_method;
|
||||
assert_eq!(ksp_wallet_lib::KSPWALLET_SOLANA_SIGNATURE_BYTES, 64);
|
||||
}
|
||||
|
||||
232
crates/ksp-wallet-lib/unit_tests/administration.rs
Normal file
232
crates/ksp-wallet-lib/unit_tests/administration.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
// file: crates/ksp-wallet-lib/unit_tests/administration.rs
|
||||
// version: 1
|
||||
|
||||
const FULL_VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json");
|
||||
|
||||
fn runtime() -> tokio::runtime::Runtime {
|
||||
return tokio::runtime::Builder::new_current_thread().build().expect("Wallet administration test runtime must build");
|
||||
}
|
||||
|
||||
fn published_vector(name: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let directory = tempfile::Builder::new().prefix("ksp-wallet-admin-").tempdir().expect("Wallet administration test directory must be creatable");
|
||||
let path = directory.path().join(name);
|
||||
crate::persistence::persist_new_wallet_for_test(path.as_path(), FULL_VECTOR).expect("full Wallet vector must publish for administration test");
|
||||
return (directory, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_signing_is_deterministic_and_owner_rotation_preserves_solana_identity() {
|
||||
let (_directory, path) = published_vector("owner-rotation.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("OWNER fixture must open");
|
||||
let pubkey = *owner.pubkey();
|
||||
let message = b"KSP pre.007 deterministic signing canary";
|
||||
let signature_before = owner.sign(message).expect("OWNER must sign");
|
||||
let signature_repeat = owner.sign(message).expect("OWNER repeated signature must succeed");
|
||||
assert_eq!(signature_before, signature_repeat);
|
||||
|
||||
runtime
|
||||
.block_on(owner.rotate_owner_password(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre007-new-owner-password"))))
|
||||
.expect("OWNER password rotation must persist");
|
||||
|
||||
let old = runtime.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))));
|
||||
assert_eq!(old.expect_err("old OWNER password must stop unlocking current file").code(), crate::ERROR_CODE_OWNER_UNLOCK_FAILED);
|
||||
|
||||
let reopened = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre007-new-owner-password"))))
|
||||
.expect("new OWNER password must unlock current file");
|
||||
assert_eq!(*reopened.pubkey(), pubkey);
|
||||
assert_eq!(reopened.sign(message).expect("rotated OWNER must still sign"), signature_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_self_rotation_changes_only_the_current_view_credential() {
|
||||
let (_directory, path) = published_vector("view-self-rotation.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut view = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))))
|
||||
.expect("VIEW fixture must open");
|
||||
let pubkey = *view.pubkey();
|
||||
let alias = view.alias().map(std::string::String::from);
|
||||
let notes = view.notes().to_vec();
|
||||
|
||||
runtime
|
||||
.block_on(view.rotate_view_password(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre007-new-view-password"))))
|
||||
.expect("VIEW self-rotation must persist");
|
||||
|
||||
let old = runtime.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))));
|
||||
assert_eq!(old.expect_err("old VIEW password must stop unlocking current file").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
|
||||
|
||||
let reopened = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre007-new-view-password"))))
|
||||
.expect("new VIEW password must unlock current file");
|
||||
assert_eq!(*reopened.pubkey(), pubkey);
|
||||
assert_eq!(reopened.alias(), alias.as_deref());
|
||||
assert_eq!(reopened.notes(), notes.as_slice());
|
||||
|
||||
let owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("VIEW self-rotation must not change OWNER credential");
|
||||
assert_eq!(*owner.pubkey(), pubkey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_rotates_view_without_old_view_password() {
|
||||
let (_directory, path) = published_vector("owner-view-rotation.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("OWNER fixture must open");
|
||||
let pubkey = *owner.pubkey();
|
||||
|
||||
runtime
|
||||
.block_on(owner.rotate_view_password(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre007-owner-set-view-password"))))
|
||||
.expect("OWNER must rotate VIEW without old VIEW password");
|
||||
|
||||
let old = runtime.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))));
|
||||
assert_eq!(old.expect_err("old VIEW password must stop unlocking current file").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
|
||||
let reopened = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre007-owner-set-view-password"))))
|
||||
.expect("OWNER-selected VIEW password must unlock");
|
||||
assert_eq!(*reopened.pubkey(), pubkey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_metadata_administration_is_visible_to_view_but_does_not_change_pubkey() {
|
||||
let (_directory, path) = published_vector("metadata-admin.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("OWNER fixture must open");
|
||||
let pubkey = *owner.pubkey();
|
||||
|
||||
runtime
|
||||
.block_on(owner.update_alias(path.as_path(), std::option::Option::Some(std::string::String::from("pre007-updated-alias"))))
|
||||
.expect("OWNER alias update must persist");
|
||||
let note_id = runtime.block_on(owner.add_note(path.as_path(), std::string::String::from("pre007-added-note"))).expect("OWNER note add must persist");
|
||||
runtime
|
||||
.block_on(owner.update_note(path.as_path(), note_id.as_str(), std::string::String::from("pre007-updated-note")))
|
||||
.expect("OWNER note update must persist");
|
||||
|
||||
let view = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))))
|
||||
.expect("VIEW must read OWNER-updated metadata");
|
||||
assert_eq!(*view.pubkey(), pubkey);
|
||||
assert_eq!(view.alias(), std::option::Option::Some("pre007-updated-alias"));
|
||||
assert!(view.notes().iter().any(|note| return note.id() == note_id.as_str() && note.text() == "pre007-updated-note"));
|
||||
|
||||
runtime.block_on(owner.delete_note(path.as_path(), note_id.as_str())).expect("OWNER note delete must persist");
|
||||
let view_after_delete = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))))
|
||||
.expect("VIEW must open after note deletion");
|
||||
assert!(!view_after_delete.notes().iter().any(|note| return note.id() == note_id.as_str()));
|
||||
assert_eq!(*view_after_delete.pubkey(), pubkey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_view_disable_and_recreate_change_current_view_authority_without_changing_solana_keypair() {
|
||||
let (_directory, path) = published_vector("strong-view-recreate.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("OWNER fixture must open");
|
||||
let pubkey = *owner.pubkey();
|
||||
let message = b"KSP pre.007 strong VIEW rekey canary";
|
||||
let signature = owner.sign(message).expect("OWNER must sign before VIEW rekey");
|
||||
let before_bytes = std::fs::read(path.as_path()).expect("current Wallet file must be readable");
|
||||
let before = crate::KspWalletEnvelopeV1::parse_json(before_bytes.as_slice()).expect("current Wallet must parse");
|
||||
let old_slot_id = *before.view_descriptor().slot_id().expect("fixture VIEW descriptor must have slot ID");
|
||||
let old_metadata_ciphertext = before.metadata().ciphertext().to_vec();
|
||||
|
||||
runtime.block_on(owner.disable_view(path.as_path())).expect("OWNER strong VIEW disable must persist");
|
||||
let locked_disabled = runtime.block_on(crate::inspect_locked_wallet_file_v1(path.as_path())).expect("disabled Wallet must inspect");
|
||||
assert!(!locked_disabled.view_enabled());
|
||||
let disabled_view =
|
||||
runtime.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))));
|
||||
assert_eq!(disabled_view.expect_err("disabled VIEW must not open").code(), crate::ERROR_CODE_CAPABILITY_INSUFFICIENT);
|
||||
|
||||
runtime
|
||||
.block_on(owner.recreate_view(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre007-recreated-view-password"))))
|
||||
.expect("OWNER strong VIEW recreate must persist");
|
||||
let recreated_bytes = std::fs::read(path.as_path()).expect("recreated Wallet file must be readable");
|
||||
let recreated = crate::KspWalletEnvelopeV1::parse_json(recreated_bytes.as_slice()).expect("recreated Wallet must parse");
|
||||
let new_slot_id = *recreated.view_descriptor().slot_id().expect("recreated VIEW descriptor must have slot ID");
|
||||
assert_ne!(new_slot_id, old_slot_id);
|
||||
assert_ne!(recreated.metadata().ciphertext(), old_metadata_ciphertext.as_slice());
|
||||
|
||||
let old = runtime.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))));
|
||||
assert_eq!(old.expect_err("historical VIEW password must not unlock recreated current state").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
|
||||
let recreated_view = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre007-recreated-view-password"))))
|
||||
.expect("recreated VIEW password must unlock current state");
|
||||
assert_eq!(*recreated_view.pubkey(), pubkey);
|
||||
assert_eq!(owner.sign(message).expect("OWNER must still sign after VIEW rekey"), signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_note_mutation_reports_missing_identifier_without_persisting() {
|
||||
let (_directory, path) = published_vector("missing-note.kspwallet");
|
||||
let runtime = runtime();
|
||||
let before = std::fs::read(path.as_path()).expect("Wallet bytes must be readable before missing-note canary");
|
||||
let mut owner = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("OWNER fixture must open");
|
||||
|
||||
let result = runtime.block_on(owner.update_note(path.as_path(), "AAAAAAAAAAAAAAAAAAAAAA", std::string::String::from("must-not-persist")));
|
||||
assert_eq!(result.expect_err("missing note identifier must be rejected").code(), crate::ERROR_CODE_NOTE_NOT_FOUND);
|
||||
assert_eq!(std::fs::read(path.as_path()).expect("Wallet bytes must remain readable"), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_owner_handle_cannot_overwrite_a_newer_authenticated_wallet_state() {
|
||||
let (_directory, path) = published_vector("stale-owner.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut first = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("first OWNER handle must open");
|
||||
let mut stale = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("second OWNER handle must open on the same state");
|
||||
|
||||
runtime
|
||||
.block_on(first.update_alias(path.as_path(), std::option::Option::Some(std::string::String::from("first-writer"))))
|
||||
.expect("first OWNER mutation must persist");
|
||||
let stale_result = runtime.block_on(stale.update_alias(path.as_path(), std::option::Option::Some(std::string::String::from("stale-writer"))));
|
||||
assert_eq!(stale_result.expect_err("stale OWNER handle must not overwrite newer state").code(), crate::ERROR_CODE_STATE_CONFLICT);
|
||||
|
||||
let current = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))))
|
||||
.expect("VIEW must open the first writer's state");
|
||||
assert_eq!(current.alias(), std::option::Option::Some("first-writer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_handle_cannot_replace_a_different_authenticated_wallet_state() {
|
||||
let (_first_directory, first_path) = published_vector("wrong-target-first.kspwallet");
|
||||
let (_second_directory, second_path) = published_vector("wrong-target-second.kspwallet");
|
||||
let runtime = runtime();
|
||||
let mut first = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(first_path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("first OWNER handle must open");
|
||||
let mut second = runtime
|
||||
.block_on(crate::open_wallet_owner_file_v1(second_path.as_path(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password"))))
|
||||
.expect("second OWNER handle must open");
|
||||
|
||||
runtime
|
||||
.block_on(second.update_alias(second_path.as_path(), std::option::Option::Some(std::string::String::from("different-authenticated-state"))))
|
||||
.expect("second Wallet must diverge before wrong-target canary");
|
||||
let wrong_target =
|
||||
runtime.block_on(first.update_alias(second_path.as_path(), std::option::Option::Some(std::string::String::from("must-not-overwrite-second-wallet"))));
|
||||
assert_eq!(wrong_target.expect_err("OWNER handle must not replace a different authenticated Wallet state").code(), crate::ERROR_CODE_STATE_CONFLICT);
|
||||
|
||||
let unchanged_first = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(first_path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))))
|
||||
.expect("first Wallet must remain unchanged");
|
||||
let unchanged_second = runtime
|
||||
.block_on(crate::open_wallet_view_file_v1(second_path.as_path(), crate::ViewPassword::new(std::string::String::from("pre005-view-password"))))
|
||||
.expect("second Wallet must remain on its own authenticated state");
|
||||
assert_eq!(unchanged_first.alias(), std::option::Option::Some("pre005-vector-wallet"));
|
||||
assert_eq!(unchanged_second.alias(), std::option::Option::Some("different-authenticated-state"));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/unit_tests/persistence.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
fn temp_directory() -> std::io::Result<tempfile::TempDir> {
|
||||
return tempfile::tempdir();
|
||||
@@ -128,3 +128,28 @@ fn persisted_full_vector_opens_view_and_owner_through_file_apis() {
|
||||
assert_eq!(view.alias(), std::option::Option::Some("pre005-vector-wallet"));
|
||||
assert_eq!(owner.alias(), std::option::Option::Some("pre005-vector-wallet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_fault_before_publish_preserves_the_previous_wallet_bytes() {
|
||||
let directory = temp_directory().expect("Wallet replacement test directory must be creatable");
|
||||
let destination = directory.path().join("replace-fault.kspwallet");
|
||||
let original = b"ORIGINAL-WALLET-CANARY";
|
||||
let replacement = b"REPLACEMENT-WALLET-CANARY";
|
||||
std::fs::write(destination.as_path(), original).expect("replacement test original must be writable");
|
||||
|
||||
let result = crate::persistence::replace_wallet_fault_before_publish(destination.as_path(), replacement);
|
||||
assert_eq!(result.expect_err("injected replacement fault must fail").code(), crate::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED);
|
||||
assert_eq!(std::fs::read(destination.as_path()).expect("original Wallet bytes must remain readable"), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_requires_an_existing_regular_file_and_replaces_complete_content() {
|
||||
let directory = temp_directory().expect("Wallet replacement test directory must be creatable");
|
||||
let destination = directory.path().join("replace.kspwallet");
|
||||
let missing = crate::persistence::replace_wallet_for_test(destination.as_path(), b"replacement");
|
||||
assert_eq!(missing.expect_err("replacement must not create a missing Wallet destination").code(), crate::ERROR_CODE_IO_FAILED);
|
||||
|
||||
std::fs::write(destination.as_path(), b"old").expect("replacement test original must be writable");
|
||||
crate::persistence::replace_wallet_for_test(destination.as_path(), b"new-complete-wallet").expect("existing Wallet replacement must succeed");
|
||||
assert_eq!(std::fs::read(destination.as_path()).expect("replaced Wallet must be readable"), b"new-complete-wallet");
|
||||
}
|
||||
|
||||
125
deltas/0.2.5/pre.007.md
Normal file
125
deltas/0.2.5/pre.007.md
Normal file
@@ -0,0 +1,125 @@
|
||||
<!-- file: deltas/0.2.5/pre.007.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.5-pre.007` — signing + administration + rotations VIEW/OWNER
|
||||
|
||||
## Base
|
||||
|
||||
```text
|
||||
0.2.5-pre.6.fix.1
|
||||
```
|
||||
|
||||
Version workspace cible :
|
||||
|
||||
```text
|
||||
0.2.5-pre.7
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Cette tranche complète l'administration native `.kspwallet` V1 au-dessus des primitives crypto `pre.004`, des capabilities in-memory `pre.005` et de la persistence `pre.006`, sans introduire Config, Transport, Tauri, Store, `WalletPolicy` ni secret externe.
|
||||
|
||||
## Implémentation
|
||||
|
||||
### Signature Solana OWNER
|
||||
|
||||
`WalletOwner::sign(message)` produit une signature Ed25519 Solana de 64 octets sans exposer seed/keypair brute. La keypair reste encapsulée dans l'état OWNER et n'est pas réexportée via `ksp-core-lib`.
|
||||
|
||||
### Metadata OWNER
|
||||
|
||||
OWNER peut :
|
||||
|
||||
```text
|
||||
update_alias
|
||||
add_note
|
||||
update_note
|
||||
delete_note
|
||||
```
|
||||
|
||||
La Pubkey reste immutable en V1. Chaque mutation rechiffre les metadata avec un nonce neuf et resign l'état OWNER. Un note ID absent retourne `wallet.note_not_found`.
|
||||
|
||||
### Rotations credentials
|
||||
|
||||
- VIEW peut changer uniquement son propre password VIEW ;
|
||||
- OWNER peut changer le password VIEW sans connaître l'ancien password VIEW ;
|
||||
- OWNER peut changer son propre password OWNER ;
|
||||
- une rotation VIEW simple conserve `slot_id` + `K_metadata` et laisse la `state_signature` inchangée conformément au transcript V1 ;
|
||||
- une rotation OWNER conserve `slot_id` + `K_owner_root`, puis resign l'état ;
|
||||
- aucune rotation de password ne change la keypair Solana.
|
||||
|
||||
### Révocation forte VIEW
|
||||
|
||||
OWNER peut `disable_view` ou `recreate_view`. Ces opérations génèrent un nouveau `K_metadata`, rechiffrent les metadata, réécrivent `owner_control`, modifient le descripteur/slot VIEW et resignent l'état. `recreate_view` utilise un nouveau `slot_id` VIEW.
|
||||
|
||||
### Persistence administrative capability-bound
|
||||
|
||||
Aucune API publique `replace(path, bytes)` n'est ajoutée. Les mutations stage le nouvel état en mémoire, persistent ensuite, puis mettent le handle à jour uniquement si la publication réussit.
|
||||
|
||||
Le replacement crate-private vérifie avant publication — puis de nouveau juste avant le replace — que la destination courante :
|
||||
|
||||
```text
|
||||
est lisible dans la borne V1
|
||||
se parse strictement
|
||||
possède une state_signature OWNER valide
|
||||
est sémantiquement égale à l'enveloppe authentifiée attendue par le handle
|
||||
```
|
||||
|
||||
Un mauvais chemin ou un handle stale retourne `wallet.state_conflict`. Ce garde-fou empêche les overwrites silencieux dans le flux KSP mais n'est pas présenté comme un CAS filesystem portable/linéarisable : une fenêtre TOCTOU reste possible entre la dernière vérification et le remplacement selon la plateforme/filesystem.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Aucune nouvelle dépendance tierce.
|
||||
|
||||
`tempfile 3.27.0` acquis en `pre.006` est réutilisé pour le temp file same-directory des remplacements administratifs. `solana-keypair` reste propriétaire de Wallet ; `ksp-core-lib` continue de réexporter uniquement `Pubkey` comme primitive Solana transversale.
|
||||
|
||||
## Canaris/tests ajoutés
|
||||
|
||||
La tranche couvre notamment :
|
||||
|
||||
```text
|
||||
signature Solana déterministe et stable à travers rotation OWNER
|
||||
rotation VIEW self-service : ancien password rejeté, nouveau accepté
|
||||
rotation VIEW par OWNER sans ancien password VIEW
|
||||
alias/notes OWNER visibles après réouverture VIEW
|
||||
Pubkey/keypair invariantes à travers mutations/rotations
|
||||
strong disable/recreate VIEW : nouveau K_metadata + nouveau slot VIEW
|
||||
ancien password VIEW rejeté après recreate
|
||||
fault avant replacement : ancien fichier conservé
|
||||
replacement exige une destination fichier existante
|
||||
handle OWNER stale -> wallet.state_conflict
|
||||
mauvais état cible -> wallet.state_conflict
|
||||
public API canary signature 64 octets
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Mise à jour :
|
||||
|
||||
```text
|
||||
docs/formats/KSPWALLET_V1.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md
|
||||
ROADMAP.md
|
||||
```
|
||||
|
||||
La spécification V1 documente désormais les procédures signing/admin/rotations/révocation forte et les limites du replacement capability-bound sans modifier `format_version = 1`.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Le sandbox de préparation ne dispose pas du toolchain Cargo/Rust : ces validations n'y ont pas été exécutées et doivent être réalisées sur le workspace opérateur avant commit.
|
||||
|
||||
## Commit attendu
|
||||
|
||||
```text
|
||||
v0.2.5-pre.007
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/formats/KSPWALLET_V1.md -->
|
||||
<!-- version: 4 -->
|
||||
<!-- version: 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 maintenant 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. La persistence filesystem, les mutations administratives, la signature Solana publique et 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. 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`.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -798,6 +798,87 @@ Une ouverture VIEW ne déchiffre **jamais** `owner_control` ni `secret` et ne ma
|
||||
|
||||
Aucun getter public de secret n'est introduit par cette procédure.
|
||||
|
||||
### 21.4 Signature Solana OWNER
|
||||
|
||||
La signature de message est une capacité OWNER. L'implémentation reconstruit/utilise la keypair Solana déjà authentifiée lors de l'ouverture OWNER et produit une signature Ed25519 de **64 octets** sur les octets exacts du message fourni :
|
||||
|
||||
```text
|
||||
OWNER + message bytes -> Ed25519(Solana keypair) -> signature[64]
|
||||
```
|
||||
|
||||
VIEW ne possède aucune primitive de signature. La surface publique n'expose ni getter de seed, ni getter de keypair brute, ni type Solana secret supplémentaire. Une rotation de password ou une mutation metadata ne change pas la keypair Solana ; signer le même message avec le même wallet produit donc la même signature Ed25519 déterministe.
|
||||
|
||||
### 21.5 Administration OWNER des metadata
|
||||
|
||||
OWNER peut modifier l'alias et les notes protégées. La Pubkey metadata reste **immutable** en V1. Une mutation metadata suit conceptuellement :
|
||||
|
||||
```text
|
||||
metadata authentifiées courantes
|
||||
-> mutation alias/note validée
|
||||
-> nouveau nonce metadata
|
||||
-> rechiffrement avec le même K_metadata
|
||||
-> reconstruction du transcript OWNER
|
||||
-> nouvelle state_signature OWNER
|
||||
```
|
||||
|
||||
Les notes conservent des identifiants 16 octets uniques. Une mise à jour ou suppression visant un identifiant absent retourne `wallet.note_not_found`. VIEW peut lire le nouvel état après réouverture mais ne dispose d'aucune API pour produire cette mutation.
|
||||
|
||||
### 21.6 Rotation simple du password VIEW
|
||||
|
||||
VIEW peut changer **uniquement son propre password VIEW**. OWNER peut effectuer la même rotation sans connaître l'ancien password VIEW. La rotation simple conserve :
|
||||
|
||||
```text
|
||||
slot_id VIEW
|
||||
K_metadata
|
||||
metadata ciphertext
|
||||
owner_control
|
||||
secret
|
||||
keypair Solana
|
||||
state_signature OWNER
|
||||
```
|
||||
|
||||
Elle génère de nouveaux paramètres de credential autorisés pour le slot VIEW : au minimum salt, KDF de création courant, wrap nonce et wrapped key. Le wrapping réencapsule le **même `K_metadata`** sous le nouveau password. La `state_signature` reste inchangée parce que les champs self-service rotatables du slot VIEW sont explicitement exclus du transcript OWNER V1, alors que son descripteur stable reste OWNER-signed.
|
||||
|
||||
Après publication réussie, l'ancien password n'ouvre plus le slot VIEW **courant**. Cette opération est une rotation de credential et non une révocation forte : un détenteur ayant conservé une ancienne copie du fichier et/ou `K_metadata` peut toujours lire l'état correspondant à cette ancienne copie.
|
||||
|
||||
### 21.7 Rotation du password OWNER
|
||||
|
||||
OWNER peut changer son password sans changer l'identité Solana. La rotation conserve `slot_id OWNER` et `K_owner_root`, génère de nouveaux KDF/salt/wrap nonce, rewrappe `K_owner_root`, reconstruit le transcript et produit une nouvelle `state_signature` avec la même autorité administrative.
|
||||
|
||||
Elle ne modifie ni `K_metadata`, ni `K_secret`, ni la keypair Solana, ni la Pubkey. Après publication réussie, l'ancien password OWNER n'ouvre plus le slot OWNER courant.
|
||||
|
||||
### 21.8 Disable/recreate VIEW et révocation forte
|
||||
|
||||
`disable_view` et `recreate_view` sont OWNER-only et constituent la primitive de révocation forte V1 pour les **futures metadata sous l'état courant**. OWNER génère un nouveau `K_metadata`, rechiffre les metadata, met à jour `owner_control`, puis :
|
||||
|
||||
```text
|
||||
disable_view -> aucun slot VIEW + view_descriptor disabled
|
||||
recreate_view -> nouveau slot_id VIEW + nouveau password VIEW + view_descriptor enabled
|
||||
```
|
||||
|
||||
Le nouvel état est resigné par OWNER. L'ancien password VIEW ne peut pas ouvrir le nouvel état, même s'il connaissait le wrapping précédent. Cette garantie n'efface pas les anciennes copies déjà possédées : V1 ne fournit ni anti-rollback externe ni ancre de confiance externe.
|
||||
|
||||
### 21.9 Remplacement persistant capability-bound
|
||||
|
||||
Les mutations `pre.007` ne publient aucun `replace(path, bytes)` générique. Elles partent d'un handle VIEW/OWNER déjà authentifié, construisent le nouvel état en mémoire, puis demandent à la persistence de remplacer **le chemin explicitement fourni par le caller**.
|
||||
|
||||
Avant publication, la persistence relit le fichier destination, le parse strictement, vérifie sa `state_signature` OWNER et compare son enveloppe sémantique à l'état authentifié attendu par le handle. Un mauvais chemin ou un handle devenu stale retourne `wallet.state_conflict` au lieu d'écraser silencieusement un autre état. Le contrôle est répété juste avant la publication du temp file.
|
||||
|
||||
```text
|
||||
expected authenticated envelope
|
||||
-> bounded read current destination
|
||||
-> strict parse + OWNER state signature verify
|
||||
-> semantic equality check
|
||||
-> temp same-directory + write + sync_all
|
||||
-> second expected-state check
|
||||
-> atomic/best-effort platform replace
|
||||
-> sync published file
|
||||
-> parent sync best-effort on Unix
|
||||
-> commit du nouvel état dans le handle seulement après succès
|
||||
```
|
||||
|
||||
Cette vérification protège les erreurs de cible et les mises à jour stale dans l'API KSP, mais **ne constitue pas un compare-and-swap filesystem portable et linéarisable** : il subsiste une fenêtre TOCTOU entre la dernière vérification et le remplacement sur les plateformes/filesystems sans primitive transactionnelle correspondante. Les ACL, verrous interprocess externes, anti-rollback et remplacement total hostile du fichier restent hors du contrat V1.
|
||||
|
||||
## 22. Vecteur complet interopérable `pre.005`
|
||||
|
||||
Le dépôt publie :
|
||||
@@ -846,6 +927,6 @@ caller path explicite
|
||||
|
||||
`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 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
|
||||
<!-- version: 51 -->
|
||||
<!-- version: 52 -->
|
||||
|
||||
# Séquence des releases fonctionnelles KSP
|
||||
|
||||
@@ -418,6 +418,8 @@ 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.6` — Wallet Desk
|
||||
|
||||
Mission : valider Config composite + `.kspwallet` + transport HTTP dans une application Tauri mince.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 10 -->
|
||||
|
||||
# Plan `0.2.5` — Wallet foundation
|
||||
|
||||
@@ -436,9 +436,11 @@ L'absence d'une ligne « audited » dans ce document ne signifie donc pas « sû
|
||||
|
||||
```text
|
||||
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 3.27.0 acquis en pre.006 pour temp files same-directory + publication no-clobber / replacement administratif contrôlé
|
||||
```
|
||||
|
||||
`pre.007` n'ajoute aucune dépendance tierce. Le remplacement administratif est lié aux capabilities VIEW/OWNER et réutilise `tempfile`; aucune primitive publique générale d'overwrite n'est introduite.
|
||||
|
||||
`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
|
||||
@@ -1049,7 +1051,7 @@ create/open KDF via spawn_blocking
|
||||
vecteur complet test-only généré et revérifié indépendamment du Rust
|
||||
```
|
||||
|
||||
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`.
|
||||
La signature Solana publique, les mutations metadata, les rotations OWNER/VIEW, disable/recreate VIEW avec rekey metadata fort et le remplacement administratif capability-bound sont désormais acquis par `pre.007`. La keypair Solana reste encapsulée dans OWNER et n'est jamais réexportée via Core ou un getter Wallet.
|
||||
|
||||
### 19.8 État acquis après `pre.006`
|
||||
|
||||
@@ -1062,7 +1064,26 @@ 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.
|
||||
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.
|
||||
|
||||
### 19.9 État acquis après `pre.007`
|
||||
|
||||
`pre.007` complète l'administration native sans ouvrir de surface de secret brut :
|
||||
|
||||
```text
|
||||
WalletOwner::sign(message) -> signature Ed25519 Solana 64 octets
|
||||
WalletOwner::update_alias(path, alias) -> metadata reencrypt + OWNER resign
|
||||
WalletOwner::add/update/delete_note(path, ...) -> metadata reencrypt + OWNER resign
|
||||
WalletView::rotate_view_password(path, new_pass) -> même slot_id + même K_metadata + state signature inchangée
|
||||
WalletOwner::rotate_view_password(path, new_pass) -> même rotation sans ancien VIEW password
|
||||
WalletOwner::rotate_owner_password(path, new_pass) -> même K_owner_root + nouveau credential + OWNER resign
|
||||
WalletOwner::disable_view(path) -> nouveau K_metadata + VIEW supprimé + OWNER resign
|
||||
WalletOwner::recreate_view(path, new_pass) -> nouveau K_metadata + nouveau slot_id VIEW + OWNER resign
|
||||
```
|
||||
|
||||
Toutes les mutations persistent un état staged puis ne mettent à jour le handle qu'après publication réussie. Le replacement est crate-private et vérifie que la destination courante correspond sémantiquement à l'enveloppe authentifiée attendue ; un mauvais chemin ou un handle stale retourne `wallet.state_conflict`. Deux handles OWNER ouverts sur le même état ne peuvent donc pas s'écraser silencieusement dans le flux KSP : après la première mutation publiée, le second est stale. Ce garde-fou n'est pas présenté comme un CAS filesystem portable : une fenêtre TOCTOU subsiste entre la dernière vérification et le remplacement, et V1 ne fournit ni verrou interprocess universel ni anti-rollback externe.
|
||||
|
||||
La rotation simple VIEW rewrappe le même `K_metadata` et n'est pas une révocation forte. `disable_view`/`recreate_view` génèrent au contraire un nouveau `K_metadata`, rechiffrent les metadata et réécrivent `owner_control`; elles constituent la révocation forte des futures metadata de l'état courant. Dans tous les cas, `format_version` et la keypair Solana restent inchangés.
|
||||
|
||||
## 20. Sizing
|
||||
|
||||
@@ -1105,7 +1126,7 @@ Une `fix` ou tranche supplémentaire est préférable à la suppression d'une ga
|
||||
|
||||
## 22. Dépendances par tranche
|
||||
|
||||
État réellement acquis au terme de `pre.005` :
|
||||
État réellement acquis au terme de `pre.007` :
|
||||
|
||||
```text
|
||||
pre.002 zeroize ^1.9
|
||||
@@ -1116,6 +1137,8 @@ pre.004 argon2 ^0.5
|
||||
pre.005 ed25519-dalek ^2.2
|
||||
solana-keypair ^3.1
|
||||
tokio déjà workspace, feature locale rt pour spawn_blocking
|
||||
pre.006 tempfile ^3.27
|
||||
pre.007 aucune nouvelle dépendance tierce
|
||||
```
|
||||
|
||||
Toutes les dépendances tierces communes restent centralisées sous `[workspace.dependencies]`; le membre Wallet active uniquement les features nécessaires. `ed25519-dalek ^2.2` est volontairement aligné avec la contrainte `^2.1.1` de `solana-keypair 3.1.2` afin de permettre une seule génération Dalek et d'activer `zeroize` sur la `SigningKey` partagée par résolution Cargo.
|
||||
@@ -1123,11 +1146,12 @@ Toutes les dépendances tierces communes restent centralisées sous `[workspace.
|
||||
Candidates restantes, à réauditer juste avant insertion :
|
||||
|
||||
```text
|
||||
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
|
||||
solana-signature ^3.5 # seulement si un type public futur l'exige réellement
|
||||
```
|
||||
|
||||
`pre.007` confirme qu'une signature Solana publique peut rester un `[u8; 64]` KSP sans ajouter `solana-signature` à la surface publique. `solana-keypair` reste propriétaire de Wallet : Core continue de réexporter uniquement la `Pubkey` transversale et ne devient pas propriétaire d'un secret/signing capability.
|
||||
|
||||
Déjà présents et réutilisés :
|
||||
|
||||
```text
|
||||
@@ -1231,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 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.
|
||||
`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.
|
||||
|
||||
Reference in New Issue
Block a user