v0.2.5-pre.007
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user