v0.2.5-pre.005

This commit is contained in:
2026-08-19 13:09:16 +02:00
parent 3c069347b7
commit 36b98e0abc
27 changed files with 2305 additions and 113 deletions

View File

@@ -21,6 +21,8 @@ pub const KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES: usize = 64 * 1024;
pub const KSPWALLET_V1_MAX_PASSWORD_BYTES: usize = 1024;
/// Byte length of every V1 key-slot identifier.
pub const KSPWALLET_V1_SLOT_ID_BYTES: usize = 16;
/// Byte length of every protected metadata note identifier.
pub const KSPWALLET_V1_NOTE_ID_BYTES: usize = 16;
/// Byte length of an Ed25519 public key used as Wallet format authority.
pub const KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES: usize = 32;
/// Byte length of an Ed25519 detached state signature.
@@ -31,6 +33,18 @@ pub const KSPWALLET_V1_XCHACHA_NONCE_BYTES: usize = 24;
pub const KSPWALLET_V1_AEAD_TAG_BYTES: usize = 16;
/// Argon2 version serialized by V1 key slots.
pub const KSPWALLET_V1_ARGON2_VERSION: u32 = 19;
/// Default Argon2id memory cost for newly created V1 key slots, calibrated on the 2026-08-19 operator benchmark.
pub const KSPWALLET_V1_DEFAULT_ARGON2_MEMORY_KIB: u32 = 65_536;
/// Default Argon2id iteration count for newly created V1 key slots.
pub const KSPWALLET_V1_DEFAULT_ARGON2_ITERATIONS: u32 = 3;
/// Default Argon2id parallelism for newly created V1 key slots.
pub const KSPWALLET_V1_DEFAULT_ARGON2_PARALLELISM: u32 = 1;
/// Salt size generated independently for every newly created V1 key slot.
pub const KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES: usize = 32;
/// Exact plaintext size of the V1 OWNER-control compartment.
pub const KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES: usize = 96;
/// Exact plaintext size of the V1 Solana secret compartment.
pub const KSPWALLET_V1_SECRET_PLAINTEXT_BYTES: usize = 64;
/// Minimum accepted Argon2 salt size in bytes.
pub const KSPWALLET_V1_MIN_KDF_SALT_BYTES: usize = 16;
/// Maximum accepted Argon2 salt size in bytes.
@@ -53,6 +67,7 @@ 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.

View File

@@ -1,42 +1,36 @@
// file: crates/ksp-wallet-lib/src/crypto.rs
// version: 1
// version: 2
//! In-memory cryptographic primitives for native `.kspwallet` V1.
//!
//! `pre.004` intentionally lands these crate-private primitives one tranche before `pre.005` wires them into create/open capability flows.
#![allow(dead_code, reason = "pre.004 stages private Wallet crypto primitives before their pre.005 production callers")]
use chacha20poly1305::KeyInit as _;
use chacha20poly1305::aead::Aead as _;
/// Exact V1 content-key and password-derived-key size in bytes.
const SECRET_KEY_BYTES: usize = 32;
pub(crate) const SECRET_KEY_BYTES: usize = 32;
/// Owned 32-byte secret key with redacted diagnostics and drop-time zeroization.
struct SecretKeyV1 {
pub(crate) struct SecretKeyV1 {
bytes: [u8; SECRET_KEY_BYTES],
}
impl SecretKeyV1 {
/// Takes ownership of exact 32-byte secret material.
const fn from_bytes(bytes: [u8; SECRET_KEY_BYTES]) -> Self {
pub(crate) const fn from_bytes(bytes: [u8; SECRET_KEY_BYTES]) -> Self {
return Self { bytes };
}
/// Generates a fresh secret key from the operating-system CSPRNG.
fn random() -> ksp_core_lib::Result<Self> {
let mut bytes = [0_u8; SECRET_KEY_BYTES];
let fill_result = getrandom::fill(bytes.as_mut_slice());
if fill_result.is_err() {
zeroize::Zeroize::zeroize(&mut bytes);
return std::result::Result::Err(randomness_error());
}
pub(crate) fn random() -> ksp_core_lib::Result<Self> {
let bytes = match random_bytes::<SECRET_KEY_BYTES>() {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { bytes });
}
/// Borrows secret bytes internally without allocating or cloning.
const fn as_bytes(&self) -> &[u8; SECRET_KEY_BYTES] {
pub(crate) const fn as_bytes(&self) -> &[u8; SECRET_KEY_BYTES] {
return &self.bytes;
}
}
@@ -53,24 +47,29 @@ impl std::ops::Drop for SecretKeyV1 {
}
}
/// Generates a fresh XChaCha20-Poly1305 nonce from the operating-system CSPRNG.
fn random_nonce() -> ksp_core_lib::Result<[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES]> {
let mut nonce = [0_u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES];
let fill_result = getrandom::fill(nonce.as_mut_slice());
/// Generates a fresh fixed-size byte array from the operating-system CSPRNG.
pub(crate) fn random_bytes<const LENGTH: usize>() -> ksp_core_lib::Result<[u8; LENGTH]> {
let mut bytes = [0_u8; LENGTH];
let fill_result = getrandom::fill(bytes.as_mut_slice());
if fill_result.is_err() {
zeroize::Zeroize::zeroize(&mut nonce);
zeroize::Zeroize::zeroize(&mut bytes);
return std::result::Result::Err(randomness_error());
}
return std::result::Result::Ok(nonce);
return std::result::Result::Ok(bytes);
}
/// Generates a fresh XChaCha20-Poly1305 nonce from the operating-system CSPRNG.
pub(crate) fn random_nonce() -> ksp_core_lib::Result<[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES]> {
return random_bytes::<{ crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES }>();
}
/// Derives one V1 password wrapping key from serialized Argon2id parameters.
fn derive_password_key(password: &[u8], kdf: &crate::WalletKdfParametersV1) -> ksp_core_lib::Result<SecretKeyV1> {
pub(crate) fn derive_password_key(password: &[u8], kdf: &crate::WalletKdfParametersV1) -> ksp_core_lib::Result<SecretKeyV1> {
return derive_argon2id(password, kdf.salt(), kdf.memory_kib(), kdf.iterations(), kdf.parallelism());
}
/// Wraps one 32-byte content key with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
fn wrap_key(
pub(crate) fn wrap_key(
wrapping_key: &SecretKeyV1,
key_to_wrap: &SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
@@ -80,7 +79,7 @@ fn wrap_key(
}
/// Unwraps one 32-byte content key and maps every AEAD authentication failure to the generic Wallet authentication error.
fn unwrap_key(
pub(crate) fn unwrap_key(
wrapping_key: &SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
@@ -108,7 +107,7 @@ fn unwrap_key(
}
/// Encrypts bounded plaintext bytes with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
fn encrypt_bytes(
pub(crate) fn encrypt_bytes(
key: &SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
@@ -129,7 +128,7 @@ fn encrypt_bytes(
}
/// Decrypts authenticated ciphertext bytes and returns a generic authentication error on tag failure.
fn decrypt_bytes(
pub(crate) fn decrypt_bytes(
key: &SecretKeyV1,
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
aad: &[u8],
@@ -149,7 +148,7 @@ fn decrypt_bytes(
};
}
fn derive_argon2id(password: &[u8], salt: &[u8], memory_kib: u32, iterations: u32, parallelism: u32) -> ksp_core_lib::Result<SecretKeyV1> {
pub(crate) fn derive_argon2id(password: &[u8], salt: &[u8], memory_kib: u32, iterations: u32, parallelism: u32) -> ksp_core_lib::Result<SecretKeyV1> {
if password.is_empty() || password.len() > crate::KSPWALLET_V1_MAX_PASSWORD_BYTES {
return std::result::Result::Err(crypto_parameter_error());
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/src/error.rs
// version: 2
// version: 3
/// 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");
@@ -11,6 +11,8 @@ pub const ERROR_CODE_CRYPTO_PARAMETERS_INVALID: ksp_core_lib::ErrorCode = ksp_co
pub const ERROR_CODE_RANDOMNESS_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "randomness_failed");
/// Error code used when an authenticated Wallet structure cannot be verified.
pub const ERROR_CODE_AUTHENTICATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "authentication_failed");
/// Error code used when an internal blocking cryptographic operation cannot complete.
pub const ERROR_CODE_CRYPTO_OPERATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "crypto_operation_failed");
/// Error code used when a VIEW unlock attempt fails without exposing a finer cryptographic oracle.
pub const ERROR_CODE_VIEW_UNLOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "view_unlock_failed");
/// Error code used when an OWNER unlock attempt fails without exposing a finer cryptographic oracle.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/src/lib.rs
// version: 4
// version: 5
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -9,7 +9,8 @@
//! `ksp-wallet-lib` owns the native `.kspwallet` domain, VIEW/OWNER capability model, protected metadata projection, password-secret wrappers and Wallet
//! 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 used by later create/open operations. It still performs no state-signature verification, Solana signing or filesystem persistence. Public
//! vectors. `0.2.5-pre.005` adds exact protected payloads, OWNER Ed25519 state authentication and async in-memory create/open flows for VIEW and OWNER.
//! Solana transaction signing and filesystem persistence remain outside this tranche. Public
//! 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`.
@@ -20,8 +21,10 @@ mod error;
mod metadata;
mod owner;
mod password;
mod payload;
mod transcript;
mod view;
mod wallet;
mod wire;
/// Authorized capability represented by an unlocked Wallet handle.
@@ -36,6 +39,14 @@ pub use self::constants::KSPWALLET_MAX_FILE_BYTES;
pub use self::constants::KSPWALLET_V1_AEAD_TAG_BYTES;
/// Argon2 version serialized by `.kspwallet` V1 key slots.
pub use self::constants::KSPWALLET_V1_ARGON2_VERSION;
/// Default Argon2id iteration count for newly created V1 slots.
pub use self::constants::KSPWALLET_V1_DEFAULT_ARGON2_ITERATIONS;
/// Default Argon2id memory cost for newly created V1 slots.
pub use self::constants::KSPWALLET_V1_DEFAULT_ARGON2_MEMORY_KIB;
/// Default Argon2id parallelism for newly created V1 slots.
pub use self::constants::KSPWALLET_V1_DEFAULT_ARGON2_PARALLELISM;
/// Default KDF salt size generated for newly created V1 slots.
pub use self::constants::KSPWALLET_V1_DEFAULT_KDF_SALT_BYTES;
/// Byte length of the Ed25519 format-authority public key.
pub use self::constants::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES;
/// Byte length of the Ed25519 detached state signature.
@@ -74,12 +85,18 @@ pub use self::constants::KSPWALLET_V1_MAX_SECRET_CIPHERTEXT_BYTES;
pub use self::constants::KSPWALLET_V1_METADATA_AAD_DOMAIN;
/// Minimum Argon2 salt size accepted by V1.
pub use self::constants::KSPWALLET_V1_MIN_KDF_SALT_BYTES;
/// Byte length of every protected metadata note identifier.
pub use self::constants::KSPWALLET_V1_NOTE_ID_BYTES;
/// Domain separator for OWNER-control compartment AEAD AAD.
pub use self::constants::KSPWALLET_V1_OWNER_CONTROL_AAD_DOMAIN;
/// Exact OWNER-control plaintext size fixed by V1.
pub use self::constants::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES;
/// Domain separator for OWNER key-slot wrapping AAD.
pub use self::constants::KSPWALLET_V1_OWNER_SLOT_AAD_DOMAIN;
/// Domain separator for OWNER-only secret compartment AEAD AAD.
pub use self::constants::KSPWALLET_V1_SECRET_AAD_DOMAIN;
/// Exact Solana keypair plaintext size fixed by V1.
pub use self::constants::KSPWALLET_V1_SECRET_PLAINTEXT_BYTES;
/// Byte length of every V1 key-slot identifier.
pub use self::constants::KSPWALLET_V1_SLOT_ID_BYTES;
/// Domain separator for the OWNER state-signature transcript.
@@ -94,6 +111,8 @@ pub use self::error::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED;
pub use self::error::ERROR_CODE_AUTHENTICATION_FAILED;
/// Error code used when an operation requires a capability that the caller does not own.
pub use self::error::ERROR_CODE_CAPABILITY_INSUFFICIENT;
/// Error code used when a blocking cryptographic task cannot complete.
pub use self::error::ERROR_CODE_CRYPTO_OPERATION_FAILED;
/// Error code used when serialized cryptographic parameters are invalid or unsupported.
pub use self::error::ERROR_CODE_CRYPTO_PARAMETERS_INVALID;
/// Error code used when a no-clobber create or import destination already exists.
@@ -118,6 +137,8 @@ pub use self::error::ERROR_CODE_TRANSFER_FORMAT_UNSUPPORTED;
pub use self::error::ERROR_CODE_VIEW_UNLOCK_FAILED;
/// Minimal non-secret information available while a native Wallet remains locked.
pub use self::metadata::LockedWalletInfo;
/// Protected initial metadata supplied to native Wallet creation.
pub use self::metadata::WalletCreateMetadataV1;
/// Safe metadata projection produced after VIEW or OWNER authorization.
pub use self::metadata::WalletInfo;
/// One protected Wallet note exposed only after authorization.
@@ -130,6 +151,14 @@ pub use self::password::OwnerPassword;
pub use self::password::ViewPassword;
/// Authorized VIEW capability handle.
pub use self::view::WalletView;
/// Creates a new in-memory native Wallet V1.
pub use self::wallet::create_wallet_v1;
/// Parses and verifies locked native Wallet state without unlocking protected metadata.
pub use self::wallet::inspect_locked_wallet_v1;
/// Opens the OWNER capability from a native Wallet V1 document.
pub use self::wallet::open_wallet_owner_v1;
/// Opens the VIEW capability from a native Wallet V1 document.
pub use self::wallet::open_wallet_view_v1;
/// Strict semantic representation of one parsed native `.kspwallet` V1 envelope.
pub use self::wire::KspWalletEnvelopeV1;
/// Authenticated-encryption algorithm fixed by native Wallet V1.

View File

@@ -1,5 +1,36 @@
// file: crates/ksp-wallet-lib/src/metadata.rs
// version: 2
// version: 3
/// Protected metadata requested when creating a new native Wallet.
///
/// Alias and note texts are never rendered by `Debug`. Wallet generates stable random note identifiers when the metadata is sealed into the V1 payload.
#[derive(Default, Eq, PartialEq)]
pub struct WalletCreateMetadataV1 {
alias: std::option::Option<std::string::String>,
note_texts: std::vec::Vec<std::string::String>,
}
impl WalletCreateMetadataV1 {
/// Creates protected initial Wallet metadata.
#[must_use]
pub fn new(alias: std::option::Option<std::string::String>, note_texts: std::vec::Vec<std::string::String>) -> Self {
return Self { alias, note_texts };
}
pub(crate) fn into_parts(self) -> (std::option::Option<std::string::String>, std::vec::Vec<std::string::String>) {
return (self.alias, self.note_texts);
}
}
impl std::fmt::Debug for WalletCreateMetadataV1 {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("WalletCreateMetadataV1")
.field("alias", &self.alias.as_ref().map(|_| return "<redacted>"))
.field("note_count", &self.note_texts.len())
.finish();
}
}
/// One protected Wallet note exposed only after VIEW or OWNER authorization.
#[derive(Clone, Eq, PartialEq)]
@@ -9,6 +40,10 @@ pub struct WalletNote {
}
impl WalletNote {
pub(crate) fn new(id: std::string::String, text: std::string::String) -> Self {
return Self { id, text };
}
/// Returns the stable note identifier used by Wallet administration operations.
#[must_use]
pub fn id(&self) -> &str {
@@ -39,6 +74,15 @@ pub struct WalletInfo {
}
impl WalletInfo {
pub(crate) fn new(
capability: crate::WalletCapability,
pubkey: ksp_core_lib::Pubkey,
alias: std::option::Option<std::string::String>,
notes: std::vec::Vec<crate::WalletNote>,
) -> Self {
return Self { format_version: crate::KSPWALLET_FORMAT_VERSION_V1, capability, pubkey, alias, notes };
}
/// Returns the native Wallet format version parsed for this projection.
#[must_use]
pub const fn format_version(&self) -> u32 {
@@ -91,6 +135,10 @@ pub struct LockedWalletInfo {
}
impl LockedWalletInfo {
pub(crate) const fn new(view_enabled: bool) -> Self {
return Self { format_version: crate::KSPWALLET_FORMAT_VERSION_V1, view_enabled };
}
/// Returns the native Wallet format version.
#[must_use]
pub const fn format_version(&self) -> u32 {

View File

@@ -1,15 +1,20 @@
// file: crates/ksp-wallet-lib/src/owner.rs
// version: 1
// version: 2
/// Authorized OWNER capability handle.
///
/// OWNER exposes all authorized metadata and will receive signing plus Wallet administration operations in later `0.2.5` tranches without exposing a
/// general-purpose secret-key getter.
/// 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.
pub struct WalletOwner {
info: crate::WalletInfo,
state: crate::wallet::OwnerStateV1,
}
impl WalletOwner {
pub(crate) fn from_unlocked(info: crate::WalletInfo, state: crate::wallet::OwnerStateV1) -> Self {
return Self { info, state };
}
/// Returns the authorization capability represented by this handle.
#[must_use]
pub const fn capability(&self) -> crate::WalletCapability {
@@ -45,10 +50,15 @@ impl WalletOwner {
);
return &self.info;
}
/// 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();
}
}
impl std::fmt::Debug for WalletOwner {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("WalletOwner").field("info", &self.info).finish();
return formatter.debug_struct("WalletOwner").field("info", &self.info).field("unlocked_state", &"<redacted>").finish();
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/src/password.rs
// version: 1
// version: 2
/// Owned VIEW password material.
///
@@ -21,6 +21,11 @@ impl ViewPassword {
pub fn new(value: std::string::String) -> Self {
return Self { value };
}
/// Borrows exact UTF-8 password bytes inside Wallet cryptographic operations.
pub(crate) fn as_bytes(&self) -> &[u8] {
return self.value.as_bytes();
}
}
impl std::fmt::Debug for ViewPassword {
@@ -55,6 +60,11 @@ impl OwnerPassword {
pub fn new(value: std::string::String) -> Self {
return Self { value };
}
/// Borrows exact UTF-8 password bytes inside Wallet cryptographic operations.
pub(crate) fn as_bytes(&self) -> &[u8] {
return self.value.as_bytes();
}
}
impl std::fmt::Debug for OwnerPassword {

View File

@@ -0,0 +1,207 @@
// file: crates/ksp-wallet-lib/src/payload.rs
// version: 2
//! Plaintext payload codecs protected inside native `.kspwallet` V1 compartments.
use base64::Engine as _;
use std::str::FromStr as _;
pub(crate) struct MetadataPayloadV1 {
pubkey: ksp_core_lib::Pubkey,
alias: std::option::Option<std::string::String>,
notes: std::vec::Vec<crate::WalletNote>,
}
impl MetadataPayloadV1 {
pub(crate) const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
pub(crate) fn into_info(self, capability: crate::WalletCapability) -> crate::WalletInfo {
return crate::WalletInfo::new(capability, self.pubkey, self.alias, self.notes);
}
}
pub(crate) struct OwnerControlMaterialV1 {
admin_signing_secret: [u8; crate::crypto::SECRET_KEY_BYTES],
metadata_key: crate::crypto::SecretKeyV1,
secret_key: crate::crypto::SecretKeyV1,
}
impl OwnerControlMaterialV1 {
pub(crate) fn into_parts(mut self) -> ([u8; crate::crypto::SECRET_KEY_BYTES], crate::crypto::SecretKeyV1, crate::crypto::SecretKeyV1) {
let mut admin_signing_secret = [0_u8; crate::crypto::SECRET_KEY_BYTES];
std::mem::swap(&mut admin_signing_secret, &mut self.admin_signing_secret);
let metadata_key = std::mem::replace(&mut self.metadata_key, crate::crypto::SecretKeyV1::from_bytes([0_u8; crate::crypto::SECRET_KEY_BYTES]));
let secret_key = std::mem::replace(&mut self.secret_key, crate::crypto::SecretKeyV1::from_bytes([0_u8; crate::crypto::SECRET_KEY_BYTES]));
return (admin_signing_secret, metadata_key, secret_key);
}
}
impl std::ops::Drop for OwnerControlMaterialV1 {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.admin_signing_secret);
}
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct RawMetadataPayloadV1 {
pubkey: std::string::String,
alias: std::option::Option<std::string::String>,
notes: std::vec::Vec<RawMetadataNoteV1>,
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct RawMetadataNoteV1 {
id: std::string::String,
text: std::string::String,
}
pub(crate) fn encode_initial_metadata_payload(
pubkey: ksp_core_lib::Pubkey,
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"));
}
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 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());
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 {
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, MetadataPayloadV1 { pubkey, alias, notes }));
}
pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result<MetadataPayloadV1> {
if source.len() > crate::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES {
return std::result::Result::Err(metadata_error("Wallet metadata payload exceeds the V1 plaintext limit", "metadata"));
}
let raw_result = serde_json::from_slice::<RawMetadataPayloadV1>(source);
let raw = match raw_result {
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"));
}
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"));
}
let pubkey_result = ksp_core_lib::Pubkey::from_str(raw.pubkey.as_str());
let pubkey = match pubkey_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata Pubkey is invalid", "metadata.pubkey")),
};
if pubkey.to_string() != raw.pubkey {
return std::result::Result::Err(metadata_error("Wallet metadata Pubkey is not canonical Base58", "metadata.pubkey"));
}
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 id_bytes = match decode_base64url(raw_note.id.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if id_bytes.len() != crate::KSPWALLET_V1_NOTE_ID_BYTES || encode_base64url(id_bytes.as_slice()) != raw_note.id {
return std::result::Result::Err(metadata_error("Wallet note identifier is not canonical V1 Base64url", "metadata.notes.id"));
}
if !note_ids.insert(raw_note.id.clone()) {
return std::result::Result::Err(metadata_error("Wallet note identifiers must be unique", "metadata.notes.id"));
}
notes.push(crate::WalletNote::new(raw_note.id, raw_note.text));
}
return std::result::Result::Ok(MetadataPayloadV1 { pubkey, alias: raw.alias, notes });
}
pub(crate) fn encode_owner_control_payload(
admin_signing_secret: &[u8; crate::crypto::SECRET_KEY_BYTES],
metadata_key: &crate::crypto::SecretKeyV1,
secret_key: &crate::crypto::SecretKeyV1,
) -> [u8; crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES] {
let mut output = [0_u8; crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES];
output[0..32].copy_from_slice(admin_signing_secret);
output[32..64].copy_from_slice(metadata_key.as_bytes());
output[64..96].copy_from_slice(secret_key.as_bytes());
return output;
}
pub(crate) fn decode_owner_control_payload(source: &[u8]) -> ksp_core_lib::Result<OwnerControlMaterialV1> {
if source.len() != crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES {
return std::result::Result::Err(metadata_error("Wallet OWNER-control payload length is invalid", "owner_control"));
}
let mut admin_signing_secret = [0_u8; crate::crypto::SECRET_KEY_BYTES];
admin_signing_secret.copy_from_slice(&source[0..32]);
let mut metadata_key = [0_u8; crate::crypto::SECRET_KEY_BYTES];
metadata_key.copy_from_slice(&source[32..64]);
let mut secret_key = [0_u8; crate::crypto::SECRET_KEY_BYTES];
secret_key.copy_from_slice(&source[64..96]);
return std::result::Result::Ok(OwnerControlMaterialV1 {
admin_signing_secret,
metadata_key: crate::crypto::SecretKeyV1::from_bytes(metadata_key),
secret_key: crate::crypto::SecretKeyV1::from_bytes(secret_key),
});
}
fn encode_base64url(bytes: &[u8]) -> std::string::String {
return base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
}
fn decode_base64url(encoded: &str) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes());
let value = match decoded {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata Base64url field is invalid", "metadata.notes.id")),
};
return std::result::Result::Ok(value);
}
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 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);
}
#[cfg(test)]
#[path = "../unit_tests/payload.rs"]
mod tests;

View File

@@ -1,15 +1,20 @@
// file: crates/ksp-wallet-lib/src/view.rs
// version: 1
// version: 2
/// Authorized VIEW capability handle.
///
/// VIEW exposes protected metadata and, in later `0.2.5` tranches, will expose only self-rotation of its VIEW password. It never owns the Solana secret or
/// OWNER administration material.
/// 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.
pub struct WalletView {
info: crate::WalletInfo,
state: crate::wallet::ViewStateV1,
}
impl WalletView {
pub(crate) fn from_unlocked(info: crate::WalletInfo, state: crate::wallet::ViewStateV1) -> Self {
return Self { info, state };
}
/// Returns the authorization capability represented by this handle.
#[must_use]
pub const fn capability(&self) -> crate::WalletCapability {
@@ -45,10 +50,15 @@ impl WalletView {
);
return &self.info;
}
/// 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();
}
}
impl std::fmt::Debug for WalletView {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("WalletView").field("info", &self.info).finish();
return formatter.debug_struct("WalletView").field("info", &self.info).field("unlocked_state", &"<redacted>").finish();
}
}

View File

@@ -0,0 +1,682 @@
// file: crates/ksp-wallet-lib/src/wallet.rs
// version: 2
//! In-memory native Wallet V1 create/open orchestration.
use ed25519_dalek::Signer as _;
use zeroize::Zeroize as _;
pub(crate) struct OwnerStateV1 {
envelope: crate::KspWalletEnvelopeV1,
owner_root: std::option::Option<crate::crypto::SecretKeyV1>,
metadata_key: std::option::Option<crate::crypto::SecretKeyV1>,
secret_key: std::option::Option<crate::crypto::SecretKeyV1>,
admin_signing_key: std::option::Option<ed25519_dalek::SigningKey>,
solana_keypair: std::option::Option<solana_keypair::Keypair>,
}
impl OwnerStateV1 {
pub(crate) fn new(
envelope: crate::KspWalletEnvelopeV1,
owner_root: crate::crypto::SecretKeyV1,
metadata_key: crate::crypto::SecretKeyV1,
secret_key: crate::crypto::SecretKeyV1,
admin_signing_key: ed25519_dalek::SigningKey,
solana_keypair: solana_keypair::Keypair,
) -> Self {
return Self {
envelope,
owner_root: std::option::Option::Some(owner_root),
metadata_key: std::option::Option::Some(metadata_key),
secret_key: std::option::Option::Some(secret_key),
admin_signing_key: std::option::Option::Some(admin_signing_key),
solana_keypair: std::option::Option::Some(solana_keypair),
};
}
pub(crate) const fn envelope(&self) -> &crate::KspWalletEnvelopeV1 {
return &self.envelope;
}
}
impl std::ops::Drop for OwnerStateV1 {
fn drop(&mut self) {
let owner_root = self.owner_root.take();
let metadata_key = self.metadata_key.take();
let secret_key = self.secret_key.take();
let admin_signing_key = self.admin_signing_key.take();
let solana_keypair = self.solana_keypair.take();
drop(owner_root);
drop(metadata_key);
drop(secret_key);
drop(admin_signing_key);
drop(solana_keypair);
}
}
pub(crate) struct ViewStateV1 {
envelope: crate::KspWalletEnvelopeV1,
metadata_key: std::option::Option<crate::crypto::SecretKeyV1>,
}
impl ViewStateV1 {
pub(crate) fn new(envelope: crate::KspWalletEnvelopeV1, metadata_key: crate::crypto::SecretKeyV1) -> Self {
return Self { envelope, metadata_key: std::option::Option::Some(metadata_key) };
}
pub(crate) const fn envelope(&self) -> &crate::KspWalletEnvelopeV1 {
return &self.envelope;
}
}
impl std::ops::Drop for ViewStateV1 {
fn drop(&mut self) {
let metadata_key = self.metadata_key.take();
drop(metadata_key);
}
}
/// 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.
pub async fn create_wallet_v1(
owner_password: crate::OwnerPassword,
view_password: std::option::Option<crate::ViewPassword>,
metadata: crate::WalletCreateMetadataV1,
) -> ksp_core_lib::Result<crate::WalletOwner> {
let owner_root = match crate::crypto::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let 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 secret_key = match crate::crypto::SecretKeyV1::random() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut admin_secret = match crate::crypto::random_bytes::<{ crate::crypto::SECRET_KEY_BYTES }>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let admin_signing_key = ed25519_dalek::SigningKey::from_bytes(&admin_secret);
let owner_auth_public_key = admin_signing_key.verifying_key().to_bytes();
let mut solana_secret = match crate::crypto::random_bytes::<32>() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
admin_secret.zeroize();
return std::result::Result::Err(error);
},
};
let solana_keypair = solana_keypair::Keypair::new_from_array(solana_secret);
solana_secret.zeroize();
let mut solana_keypair_bytes = solana_keypair.to_bytes();
let pubkey_result = pubkey_from_keypair_bytes(&solana_keypair_bytes);
let pubkey = match pubkey_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
admin_secret.zeroize();
solana_keypair_bytes.zeroize();
return std::result::Result::Err(error);
},
};
let metadata_encoded_result = crate::payload::encode_initial_metadata_payload(pubkey, metadata);
let (mut metadata_plaintext, metadata_payload) = match metadata_encoded_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
admin_secret.zeroize();
solana_keypair_bytes.zeroize();
return std::result::Result::Err(error);
},
};
let mut owner_control_plaintext = crate::payload::encode_owner_control_payload(&admin_secret, &metadata_key, &secret_key);
admin_secret.zeroize();
let owner_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 cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_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 cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_kdf = crate::WalletKdfParametersV1::new_creation(owner_salt.to_vec());
let owner_wrap_nonce = match crate::crypto::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let view_material_result = prepare_view_creation(view_password);
let view_material = match view_material_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_control_nonce = match crate::crypto::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let metadata_nonce = match crate::crypto::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let secret_nonce = match crate::crypto::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let provisional = provisional_envelope(
owner_auth_public_key,
owner_slot_id,
owner_kdf.clone(),
owner_wrap_nonce,
view_material.as_ref(),
owner_control_nonce,
metadata_nonce,
secret_nonce,
);
let owner_derived_result = derive_owner_password_key_async(owner_password, owner_kdf.clone()).await;
let owner_derived = match owner_derived_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_wrapped_result = crate::crypto::wrap_key(&owner_derived, &owner_root, &owner_wrap_nonce, provisional.owner_slot_aad().as_slice());
let owner_wrapped = match owner_wrapped_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let view_slot_result = seal_view_slot(&provisional, view_material, &metadata_key).await;
let view_slot = match view_slot_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
let owner_control_ciphertext_result = crate::crypto::encrypt_bytes(
&owner_root,
&owner_control_nonce,
provisional.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
owner_control_plaintext.as_slice(),
);
let owner_control_ciphertext = match owner_control_ciphertext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
owner_control_plaintext.zeroize();
let metadata_ciphertext_result = crate::crypto::encrypt_bytes(
&metadata_key,
&metadata_nonce,
provisional.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
metadata_plaintext.as_slice(),
);
let metadata_ciphertext = match metadata_ciphertext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
metadata_plaintext.zeroize();
let secret_ciphertext_result = crate::crypto::encrypt_bytes(
&secret_key,
&secret_nonce,
provisional.compartment_aad(crate::WalletCompartmentKindV1::Secret).as_slice(),
solana_keypair_bytes.as_slice(),
);
let secret_ciphertext = match secret_ciphertext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return cleanup_create_error(error, &mut owner_control_plaintext, &mut metadata_plaintext, &mut solana_keypair_bytes);
},
};
solana_keypair_bytes.zeroize();
let owner_slot =
crate::WalletKeySlotV1::new(owner_slot_id, crate::WalletKeySlotRoleV1::Owner, owner_kdf, crate::WalletKeyWrapV1::new(owner_wrap_nonce, owner_wrapped));
let view_descriptor = match view_slot.as_ref() {
std::option::Option::Some(slot) => crate::WalletViewDescriptorV1::enabled(*slot.slot_id()),
std::option::Option::None => crate::WalletViewDescriptorV1::disabled(),
};
let owner_control = crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::OwnerControl, owner_control_nonce, owner_control_ciphertext);
let metadata_compartment = crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::Metadata, metadata_nonce, metadata_ciphertext);
let secret_compartment = crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::Secret, secret_nonce, secret_ciphertext);
let unsigned = crate::KspWalletEnvelopeV1::new_internal(
owner_auth_public_key,
view_descriptor,
owner_slot.clone(),
view_slot.clone(),
owner_control.clone(),
metadata_compartment.clone(),
secret_compartment.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();
let envelope = crate::KspWalletEnvelopeV1::new_internal(
owner_auth_public_key,
view_descriptor,
owner_slot,
view_slot,
owner_control,
metadata_compartment,
secret_compartment,
crate::WalletStateSignatureV1::new(signature),
);
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let info = metadata_payload.into_info(crate::WalletCapability::Owner);
let state = OwnerStateV1::new(envelope, owner_root, metadata_key, secret_key, admin_signing_key, solana_keypair);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_create",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
view_enabled = state.envelope().view_descriptor().enabled(),
"native wallet created in memory"
);
return std::result::Result::Ok(crate::WalletOwner::from_unlocked(info, state));
}
/// Opens the VIEW capability from a native `.kspwallet` V1 JSON document.
pub async fn open_wallet_view_v1(source: &[u8], password: crate::ViewPassword) -> ksp_core_lib::Result<crate::WalletView> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let view_slot = match 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 derived_result = derive_view_password_key_async(password, view_slot.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 aad = envelope.view_slot_aad();
let view_aad = match aad {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(capability_error("Wallet VIEW capability is disabled")),
};
let metadata_key_result = crate::crypto::unwrap_key(&derived, view_slot.wrap().nonce(), view_aad.as_slice(), view_slot.wrap().ciphertext());
let metadata_key = match metadata_key_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(view_unlock_error()),
};
let metadata_plaintext_result = crate::crypto::decrypt_bytes(
&metadata_key,
envelope.metadata().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
envelope.metadata().ciphertext(),
);
let mut metadata_plaintext = match metadata_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(view_unlock_error()),
};
let metadata_payload_result = crate::payload::decode_metadata_payload(metadata_plaintext.as_slice());
metadata_plaintext.zeroize();
let metadata_payload = match metadata_payload_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let info = metadata_payload.into_info(crate::WalletCapability::View);
let state = ViewStateV1::new(envelope, metadata_key);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_open_view",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
capability = "view",
"native wallet VIEW capability opened"
);
return std::result::Result::Ok(crate::WalletView::from_unlocked(info, state));
}
/// Opens the OWNER capability from a native `.kspwallet` V1 JSON document.
pub async fn open_wallet_owner_v1(source: &[u8], password: crate::OwnerPassword) -> ksp_core_lib::Result<crate::WalletOwner> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let verify_result = verify_state_signature(&envelope);
if let std::result::Result::Err(error) = verify_result {
return std::result::Result::Err(error);
}
let owner_slot = envelope.owner_slot();
let derived_result = derive_owner_password_key_async(password, owner_slot.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 owner_root_result =
crate::crypto::unwrap_key(&derived, owner_slot.wrap().nonce(), envelope.owner_slot_aad().as_slice(), owner_slot.wrap().ciphertext());
let owner_root = match owner_root_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(owner_unlock_error()),
};
let owner_control_plaintext_result = crate::crypto::decrypt_bytes(
&owner_root,
envelope.owner_control().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl).as_slice(),
envelope.owner_control().ciphertext(),
);
let mut owner_control_plaintext = match owner_control_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(owner_unlock_error()),
};
let control_result = crate::payload::decode_owner_control_payload(owner_control_plaintext.as_slice());
owner_control_plaintext.zeroize();
let control = match control_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let (mut admin_secret, metadata_key, secret_key) = control.into_parts();
let admin_signing_key = ed25519_dalek::SigningKey::from_bytes(&admin_secret);
admin_secret.zeroize();
if admin_signing_key.verifying_key().to_bytes() != *envelope.owner_auth_public_key() {
return std::result::Result::Err(authentication_error());
}
let metadata_plaintext_result = crate::crypto::decrypt_bytes(
&metadata_key,
envelope.metadata().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata).as_slice(),
envelope.metadata().ciphertext(),
);
let mut metadata_plaintext = match metadata_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
let metadata_payload_result = crate::payload::decode_metadata_payload(metadata_plaintext.as_slice());
metadata_plaintext.zeroize();
let metadata_payload = match metadata_payload_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let secret_plaintext_result = crate::crypto::decrypt_bytes(
&secret_key,
envelope.secret().nonce(),
envelope.compartment_aad(crate::WalletCompartmentKindV1::Secret).as_slice(),
envelope.secret().ciphertext(),
);
let mut secret_plaintext = match secret_plaintext_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
if secret_plaintext.len() != crate::KSPWALLET_V1_SECRET_PLAINTEXT_BYTES {
secret_plaintext.zeroize();
return std::result::Result::Err(key_material_error());
}
let keypair_result = solana_keypair::Keypair::try_from(secret_plaintext.as_slice());
let solana_keypair = match keypair_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
secret_plaintext.zeroize();
return std::result::Result::Err(key_material_error());
},
};
let secret_pubkey_result = pubkey_from_keypair_bytes(secret_plaintext.as_slice());
let secret_pubkey = match secret_pubkey_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
secret_plaintext.zeroize();
return std::result::Result::Err(error);
},
};
secret_plaintext.zeroize();
if &secret_pubkey != metadata_payload.pubkey() {
return std::result::Result::Err(key_material_error());
}
let info = metadata_payload.into_info(crate::WalletCapability::Owner);
let state = OwnerStateV1::new(envelope, owner_root, metadata_key, secret_key, admin_signing_key, solana_keypair);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
operation = "wallet_open_owner",
format_version = crate::KSPWALLET_FORMAT_VERSION_V1,
capability = "owner",
"native wallet OWNER capability opened"
);
return std::result::Result::Ok(crate::WalletOwner::from_unlocked(info, state));
}
/// Parses and verifies the OWNER-authenticated locked state without unlocking metadata or secret material.
pub fn inspect_locked_wallet_v1(source: &[u8]) -> ksp_core_lib::Result<crate::LockedWalletInfo> {
let envelope = match crate::KspWalletEnvelopeV1::parse_json(source) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
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(crate::LockedWalletInfo::new(envelope.view_descriptor().enabled()));
}
pub(crate) fn verify_state_signature(envelope: &crate::KspWalletEnvelopeV1) -> ksp_core_lib::Result<()> {
let verifying_key_result = ed25519_dalek::VerifyingKey::from_bytes(envelope.owner_auth_public_key());
let verifying_key = match verifying_key_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(authentication_error()),
};
let signature = ed25519_dalek::Signature::from_bytes(envelope.state_signature().signature());
let verify_result = verifying_key.verify_strict(envelope.state_transcript().as_slice(), &signature);
return match verify_result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(_) => std::result::Result::Err(authentication_error()),
};
}
struct ViewCreationMaterialV1 {
slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
kdf: crate::WalletKdfParametersV1,
wrap_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
password: crate::ViewPassword,
}
fn prepare_view_creation(password: std::option::Option<crate::ViewPassword>) -> ksp_core_lib::Result<std::option::Option<ViewCreationMaterialV1>> {
let password = match password {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
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 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 wrap_nonce = match crate::crypto::random_nonce() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::option::Option::Some(ViewCreationMaterialV1 {
slot_id,
kdf: crate::WalletKdfParametersV1::new_creation(salt.to_vec()),
wrap_nonce,
password,
}));
}
async fn seal_view_slot(
envelope: &crate::KspWalletEnvelopeV1,
material: std::option::Option<ViewCreationMaterialV1>,
metadata_key: &crate::crypto::SecretKeyV1,
) -> ksp_core_lib::Result<std::option::Option<crate::WalletKeySlotV1>> {
let material = match material {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
let derived_result = derive_view_password_key_async(material.password, material.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_slot = crate::WalletKeySlotV1::new(
material.slot_id,
crate::WalletKeySlotRoleV1::View,
material.kdf.clone(),
crate::WalletKeyWrapV1::new(material.wrap_nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
);
let aad = crate::slot_aad(envelope, &provisional_slot);
let wrapped_result = crate::crypto::wrap_key(&derived, metadata_key, &material.wrap_nonce, aad.as_slice());
let wrapped = match wrapped_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::option::Option::Some(crate::WalletKeySlotV1::new(
material.slot_id,
crate::WalletKeySlotRoleV1::View,
material.kdf,
crate::WalletKeyWrapV1::new(material.wrap_nonce, wrapped),
)));
}
fn provisional_envelope(
owner_auth_public_key: [u8; crate::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES],
owner_slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES],
owner_kdf: crate::WalletKdfParametersV1,
owner_wrap_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
view_material: std::option::Option<&ViewCreationMaterialV1>,
owner_control_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
metadata_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
secret_nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
) -> crate::KspWalletEnvelopeV1 {
let owner_slot = crate::WalletKeySlotV1::new(
owner_slot_id,
crate::WalletKeySlotRoleV1::Owner,
owner_kdf,
crate::WalletKeyWrapV1::new(owner_wrap_nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
);
let (view_descriptor, view_slot) = match view_material {
std::option::Option::Some(material) => (
crate::WalletViewDescriptorV1::enabled(material.slot_id),
std::option::Option::Some(crate::WalletKeySlotV1::new(
material.slot_id,
crate::WalletKeySlotRoleV1::View,
material.kdf.clone(),
crate::WalletKeyWrapV1::new(material.wrap_nonce, std::vec![0_u8; crate::crypto::SECRET_KEY_BYTES + crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
)),
),
std::option::Option::None => (crate::WalletViewDescriptorV1::disabled(), std::option::Option::None),
};
return crate::KspWalletEnvelopeV1::new_internal(
owner_auth_public_key,
view_descriptor,
owner_slot,
view_slot,
crate::WalletEncryptedCompartmentV1::new(
crate::WalletCompartmentKindV1::OwnerControl,
owner_control_nonce,
std::vec![0_u8; crate::KSPWALLET_V1_AEAD_TAG_BYTES],
),
crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::Metadata, metadata_nonce, std::vec![0_u8; crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
crate::WalletEncryptedCompartmentV1::new(crate::WalletCompartmentKindV1::Secret, secret_nonce, std::vec![0_u8; crate::KSPWALLET_V1_AEAD_TAG_BYTES]),
crate::WalletStateSignatureV1::new([0_u8; crate::KSPWALLET_V1_ED25519_SIGNATURE_BYTES]),
);
}
async fn derive_owner_password_key_async(
password: crate::OwnerPassword,
kdf: crate::WalletKdfParametersV1,
) -> ksp_core_lib::Result<crate::crypto::SecretKeyV1> {
let task = tokio::task::spawn_blocking(move || return crate::crypto::derive_password_key(password.as_bytes(), &kdf));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(crypto_operation_error()),
};
}
async fn derive_view_password_key_async(password: crate::ViewPassword, kdf: crate::WalletKdfParametersV1) -> ksp_core_lib::Result<crate::crypto::SecretKeyV1> {
let task = tokio::task::spawn_blocking(move || return crate::crypto::derive_password_key(password.as_bytes(), &kdf));
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(crypto_operation_error()),
};
}
fn pubkey_from_keypair_bytes(bytes: &[u8]) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let public_slice = match bytes.get(32..64) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(key_material_error()),
};
let public_array = match <[u8; 32]>::try_from(public_slice) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(key_material_error()),
};
return std::result::Result::Ok(ksp_core_lib::Pubkey::new_from_array(public_array));
}
fn cleanup_create_error<T>(
error: ksp_core_lib::Error,
owner_control_plaintext: &mut [u8],
metadata_plaintext: &mut [u8],
solana_keypair_bytes: &mut [u8],
) -> ksp_core_lib::Result<T> {
owner_control_plaintext.zeroize();
metadata_plaintext.zeroize();
solana_keypair_bytes.zeroize();
return std::result::Result::Err(error);
}
fn authentication_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_AUTHENTICATION_FAILED, "Wallet OWNER-authenticated state verification failed");
}
fn crypto_operation_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_CRYPTO_OPERATION_FAILED, "Wallet blocking cryptographic operation could not complete");
}
fn view_unlock_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_VIEW_UNLOCK_FAILED, "Wallet VIEW capability could not be unlocked");
}
fn owner_unlock_error() -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_OWNER_UNLOCK_FAILED, "Wallet OWNER capability could not be unlocked");
}
fn capability_error(message: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_CAPABILITY_INSUFFICIENT, message);
}
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");
}
#[cfg(test)]
#[path = "../unit_tests/wallet.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-wallet-lib/src/wire.rs
// version: 3
// version: 4
//! Strict native `.kspwallet` V1 wire envelope.
@@ -105,6 +105,17 @@ pub struct WalletKdfParametersV1 {
}
impl WalletKdfParametersV1 {
pub(crate) fn new_creation(salt: std::vec::Vec<u8>) -> Self {
return Self {
algorithm: WalletKdfAlgorithmV1::Argon2id,
version: crate::KSPWALLET_V1_ARGON2_VERSION,
memory_kib: crate::KSPWALLET_V1_DEFAULT_ARGON2_MEMORY_KIB,
iterations: crate::KSPWALLET_V1_DEFAULT_ARGON2_ITERATIONS,
parallelism: crate::KSPWALLET_V1_DEFAULT_ARGON2_PARALLELISM,
salt,
};
}
/// Returns the KDF algorithm.
#[must_use]
pub const fn algorithm(&self) -> WalletKdfAlgorithmV1 {
@@ -165,6 +176,10 @@ pub struct WalletKeyWrapV1 {
}
impl WalletKeyWrapV1 {
pub(crate) fn new(nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], ciphertext: std::vec::Vec<u8>) -> Self {
return Self { algorithm: WalletAeadAlgorithmV1::XChaCha20Poly1305, nonce, ciphertext };
}
/// Returns the wrapping AEAD algorithm.
#[must_use]
pub const fn algorithm(&self) -> WalletAeadAlgorithmV1 {
@@ -205,6 +220,10 @@ pub struct WalletKeySlotV1 {
}
impl WalletKeySlotV1 {
pub(crate) fn new(slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES], role: WalletKeySlotRoleV1, kdf: WalletKdfParametersV1, wrap: WalletKeyWrapV1) -> Self {
return Self { slot_id, role, kdf, wrap };
}
/// Returns the stable 16-byte slot identifier.
#[must_use]
pub const fn slot_id(&self) -> &[u8; crate::KSPWALLET_V1_SLOT_ID_BYTES] {
@@ -250,6 +269,14 @@ pub struct WalletViewDescriptorV1 {
}
impl WalletViewDescriptorV1 {
pub(crate) const fn disabled() -> Self {
return Self { enabled: false, slot_id: std::option::Option::None };
}
pub(crate) const fn enabled(slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES]) -> Self {
return Self { enabled: true, slot_id: std::option::Option::Some(slot_id) };
}
/// Reports whether a VIEW slot is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
@@ -274,6 +301,16 @@ pub struct WalletEncryptedCompartmentV1 {
}
impl WalletEncryptedCompartmentV1 {
pub(crate) fn new(kind: WalletCompartmentKindV1, nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], ciphertext: std::vec::Vec<u8>) -> Self {
return Self {
kind,
payload_version: crate::KSPWALLET_V1_INITIAL_PAYLOAD_VERSION,
algorithm: WalletAeadAlgorithmV1::XChaCha20Poly1305,
nonce,
ciphertext,
};
}
/// Returns the compartment kind.
#[must_use]
pub const fn kind(&self) -> WalletCompartmentKindV1 {
@@ -326,6 +363,10 @@ pub struct WalletStateSignatureV1 {
}
impl WalletStateSignatureV1 {
pub(crate) const fn new(signature: [u8; crate::KSPWALLET_V1_ED25519_SIGNATURE_BYTES]) -> Self {
return Self { algorithm: WalletStateSignatureAlgorithmV1::Ed25519, signature };
}
/// Returns the state-signature algorithm.
#[must_use]
pub const fn algorithm(&self) -> WalletStateSignatureAlgorithmV1 {
@@ -363,6 +404,28 @@ pub struct KspWalletEnvelopeV1 {
}
impl KspWalletEnvelopeV1 {
pub(crate) fn new_internal(
owner_auth_public_key: [u8; crate::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES],
view_descriptor: WalletViewDescriptorV1,
owner_slot: WalletKeySlotV1,
view_slot: std::option::Option<WalletKeySlotV1>,
owner_control: WalletEncryptedCompartmentV1,
metadata: WalletEncryptedCompartmentV1,
secret: WalletEncryptedCompartmentV1,
state_signature: WalletStateSignatureV1,
) -> Self {
return Self {
owner_auth_public_key,
view_descriptor,
owner_slot,
view_slot,
owner_control,
metadata,
secret,
state_signature,
};
}
/// Parses and semantically validates one complete `.kspwallet` V1 JSON document.
///
/// This function validates only the V1 wire grammar, identifiers, canonical Base64url representation, structural bounds and key-slot invariants.