v0.2.5-pre.004
This commit is contained in:
199
crates/ksp-wallet-lib/src/crypto.rs
Normal file
199
crates/ksp-wallet-lib/src/crypto.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
// file: crates/ksp-wallet-lib/src/crypto.rs
|
||||
// version: 1
|
||||
|
||||
//! 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;
|
||||
|
||||
/// Owned 32-byte secret key with redacted diagnostics and drop-time zeroization.
|
||||
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 {
|
||||
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());
|
||||
}
|
||||
return std::result::Result::Ok(Self { bytes });
|
||||
}
|
||||
|
||||
/// Borrows secret bytes internally without allocating or cloning.
|
||||
const fn as_bytes(&self) -> &[u8; SECRET_KEY_BYTES] {
|
||||
return &self.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SecretKeyV1 {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("SecretKeyV1(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Drop for SecretKeyV1 {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
if fill_result.is_err() {
|
||||
zeroize::Zeroize::zeroize(&mut nonce);
|
||||
return std::result::Result::Err(randomness_error());
|
||||
}
|
||||
return std::result::Result::Ok(nonce);
|
||||
}
|
||||
|
||||
/// Derives one V1 password wrapping key from serialized Argon2id parameters.
|
||||
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(
|
||||
wrapping_key: &SecretKeyV1,
|
||||
key_to_wrap: &SecretKeyV1,
|
||||
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
|
||||
aad: &[u8],
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
return encrypt_bytes(wrapping_key, nonce, aad, key_to_wrap.as_bytes());
|
||||
}
|
||||
|
||||
/// Unwraps one 32-byte content key and maps every AEAD authentication failure to the generic Wallet authentication error.
|
||||
fn unwrap_key(
|
||||
wrapping_key: &SecretKeyV1,
|
||||
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
|
||||
aad: &[u8],
|
||||
ciphertext: &[u8],
|
||||
) -> ksp_core_lib::Result<SecretKeyV1> {
|
||||
let plaintext_result = decrypt_bytes(wrapping_key, nonce, aad, ciphertext);
|
||||
let mut plaintext = match plaintext_result {
|
||||
std::result::Result::Ok(plaintext) => plaintext,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if plaintext.len() != SECRET_KEY_BYTES {
|
||||
zeroize::Zeroize::zeroize(plaintext.as_mut_slice());
|
||||
return std::result::Result::Err(authentication_error());
|
||||
}
|
||||
let converted = <[u8; SECRET_KEY_BYTES]>::try_from(plaintext.as_slice());
|
||||
let key = match converted {
|
||||
std::result::Result::Ok(key) => SecretKeyV1::from_bytes(key),
|
||||
std::result::Result::Err(_) => {
|
||||
zeroize::Zeroize::zeroize(plaintext.as_mut_slice());
|
||||
return std::result::Result::Err(authentication_error());
|
||||
},
|
||||
};
|
||||
zeroize::Zeroize::zeroize(plaintext.as_mut_slice());
|
||||
return std::result::Result::Ok(key);
|
||||
}
|
||||
|
||||
/// Encrypts bounded plaintext bytes with XChaCha20-Poly1305 and caller-provided domain-separated AAD.
|
||||
fn encrypt_bytes(
|
||||
key: &SecretKeyV1,
|
||||
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
|
||||
aad: &[u8],
|
||||
plaintext: &[u8],
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let cipher_result = chacha20poly1305::XChaCha20Poly1305::new_from_slice(key.as_bytes());
|
||||
let cipher = match cipher_result {
|
||||
std::result::Result::Ok(cipher) => cipher,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crypto_parameter_error()),
|
||||
};
|
||||
let nonce_value = chacha20poly1305::XNonce::from(*nonce);
|
||||
let payload = chacha20poly1305::aead::Payload { msg: plaintext, aad };
|
||||
let encrypted = cipher.encrypt(&nonce_value, payload);
|
||||
return match encrypted {
|
||||
std::result::Result::Ok(ciphertext) => std::result::Result::Ok(ciphertext),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(crypto_parameter_error()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Decrypts authenticated ciphertext bytes and returns a generic authentication error on tag failure.
|
||||
fn decrypt_bytes(
|
||||
key: &SecretKeyV1,
|
||||
nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES],
|
||||
aad: &[u8],
|
||||
ciphertext: &[u8],
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<u8>> {
|
||||
let cipher_result = chacha20poly1305::XChaCha20Poly1305::new_from_slice(key.as_bytes());
|
||||
let cipher = match cipher_result {
|
||||
std::result::Result::Ok(cipher) => cipher,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crypto_parameter_error()),
|
||||
};
|
||||
let nonce_value = chacha20poly1305::XNonce::from(*nonce);
|
||||
let payload = chacha20poly1305::aead::Payload { msg: ciphertext, aad };
|
||||
let decrypted = cipher.decrypt(&nonce_value, payload);
|
||||
return match decrypted {
|
||||
std::result::Result::Ok(plaintext) => std::result::Result::Ok(plaintext),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(authentication_error()),
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if salt.len() < crate::KSPWALLET_V1_MIN_KDF_SALT_BYTES || salt.len() > crate::KSPWALLET_V1_MAX_KDF_SALT_BYTES {
|
||||
return std::result::Result::Err(crypto_parameter_error());
|
||||
}
|
||||
if memory_kib == 0
|
||||
|| memory_kib > crate::KSPWALLET_V1_MAX_ARGON2_MEMORY_KIB
|
||||
|| iterations == 0
|
||||
|| iterations > crate::KSPWALLET_V1_MAX_ARGON2_ITERATIONS
|
||||
|| parallelism == 0
|
||||
|| parallelism > crate::KSPWALLET_V1_MAX_ARGON2_PARALLELISM
|
||||
|| memory_kib < parallelism * 8
|
||||
{
|
||||
return std::result::Result::Err(crypto_parameter_error());
|
||||
}
|
||||
|
||||
let params_result = argon2::Params::new(memory_kib, iterations, parallelism, std::option::Option::Some(SECRET_KEY_BYTES));
|
||||
let params = match params_result {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(crypto_parameter_error()),
|
||||
};
|
||||
let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut output = [0_u8; SECRET_KEY_BYTES];
|
||||
let derive_result = argon2.hash_password_into(password, salt, output.as_mut_slice());
|
||||
if derive_result.is_err() {
|
||||
zeroize::Zeroize::zeroize(&mut output);
|
||||
return std::result::Result::Err(crypto_parameter_error());
|
||||
}
|
||||
return std::result::Result::Ok(SecretKeyV1::from_bytes(output));
|
||||
}
|
||||
|
||||
fn randomness_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RANDOMNESS_FAILED, "Wallet cryptographic randomness could not be obtained");
|
||||
}
|
||||
|
||||
fn crypto_parameter_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID, "Wallet cryptographic parameters are invalid or unsupported");
|
||||
}
|
||||
|
||||
fn authentication_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_AUTHENTICATION_FAILED, "Wallet cryptographic authentication failed");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/crypto.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/error.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
/// 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");
|
||||
@@ -7,6 +7,8 @@ pub const ERROR_CODE_FORMAT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::Err
|
||||
pub const ERROR_CODE_FORMAT_VERSION_UNSUPPORTED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "format_version_unsupported");
|
||||
/// Error code used when serialized cryptographic parameters are invalid or unsupported.
|
||||
pub const ERROR_CODE_CRYPTO_PARAMETERS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "crypto_parameters_invalid");
|
||||
/// Error code used when the operating-system cryptographic random source cannot provide bytes.
|
||||
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 a VIEW unlock attempt fails without exposing a finer cryptographic oracle.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/lib.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -7,13 +7,15 @@
|
||||
//! Autonomous KSP Wallet foundation.
|
||||
//!
|
||||
//! `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` additionally freezes the strict V1 JSON envelope, canonical Base64url decoding, structural limits and deterministic
|
||||
//! state-transcript/AEAD-AAD byte codecs. It still performs no KDF, encryption, decryption, state-signature verification, Solana signing or filesystem
|
||||
//! persistence. 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`.
|
||||
//! 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
|
||||
//! 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;
|
||||
mod constants;
|
||||
mod crypto;
|
||||
mod error;
|
||||
mod metadata;
|
||||
mod owner;
|
||||
@@ -106,6 +108,8 @@ pub use self::error::ERROR_CODE_IO_FAILED;
|
||||
pub use self::error::ERROR_CODE_KEY_MATERIAL_INVALID;
|
||||
/// 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 import/export transfer format is unsupported.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-wallet-lib/src/wire.rs
|
||||
// version: 1
|
||||
// version: 3
|
||||
|
||||
//! Strict native `.kspwallet` V1 wire envelope.
|
||||
|
||||
@@ -893,6 +893,12 @@ fn parse_kdf(raw: RawKdfV1) -> ksp_core_lib::Result<WalletKdfParametersV1> {
|
||||
if raw.parallelism == 0 || raw.parallelism > crate::KSPWALLET_V1_MAX_ARGON2_PARALLELISM {
|
||||
return std::result::Result::Err(crypto_parameter_error("Wallet Argon2 parallelism is outside the V1 structural range", "key_slots.kdf.parallelism"));
|
||||
}
|
||||
if raw.memory_kib < raw.parallelism * 8 {
|
||||
return std::result::Result::Err(crypto_parameter_error(
|
||||
"Wallet Argon2 memory_kib must provide at least eight 1-KiB blocks per lane",
|
||||
"key_slots.kdf.memory_kib",
|
||||
));
|
||||
}
|
||||
let salt = match decode_base64url(raw.salt.as_str(), "key_slots.kdf.salt") {
|
||||
std::result::Result::Ok(salt) => salt,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
|
||||
Reference in New Issue
Block a user