v0.5.2-pre.003
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/constants.rs
|
||||
// version: 3
|
||||
// version: 5
|
||||
|
||||
//! Local constants for the `ks-wallet` crate.
|
||||
|
||||
@@ -9,9 +9,52 @@ pub(crate) const TRACING_TARGET: &str = "ks-wallet";
|
||||
pub const KSWALLET_FILE_EXTENSION: &str = "kswallet";
|
||||
/// Native wallet format identification magic.
|
||||
pub(crate) const KSWALLET_MAGIC: &[u8; 8] = b"KSWALLET";
|
||||
/// Native wallet format version used by the identification prefix.
|
||||
/// Native wallet format version.
|
||||
pub(crate) const KSWALLET_FORMAT_VERSION: u16 = 1;
|
||||
/// Number of bytes required to identify a native wallet file.
|
||||
pub(crate) const KSWALLET_IDENTIFICATION_LENGTH: usize = 10;
|
||||
/// Fixed version-one header length before the variable alias bytes.
|
||||
pub(crate) const KSWALLET_FIXED_HEADER_LENGTH: usize = 72;
|
||||
/// Maximum wallet alias length encoded by version one.
|
||||
pub(crate) const KSWALLET_MAX_ALIAS_LENGTH: usize = 64;
|
||||
/// Native version-one flag value when no optional feature is enabled.
|
||||
pub(crate) const KSWALLET_FLAGS_NONE: u16 = 0;
|
||||
/// Version-one KDF identifier for Argon2id.
|
||||
pub(crate) const KSWALLET_KDF_ARGON2ID: u8 = 1;
|
||||
/// Argon2 version 19 identifier required by version one.
|
||||
pub(crate) const KSWALLET_ARGON2_VERSION: u8 = 0x13;
|
||||
/// Version-one AEAD identifier for XChaCha20-Poly1305.
|
||||
pub(crate) const KSWALLET_AEAD_XCHACHA20_POLY1305: u8 = 1;
|
||||
/// Salt length used by the version-one Argon2id profile.
|
||||
pub(crate) const KSWALLET_SALT_LENGTH: usize = 16;
|
||||
/// Nonce length used by XChaCha20-Poly1305.
|
||||
pub(crate) const KSWALLET_NONCE_LENGTH: usize = 24;
|
||||
/// Authentication tag length appended by XChaCha20-Poly1305.
|
||||
const KSWALLET_AEAD_TAG_LENGTH: usize = 16;
|
||||
/// Minimum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_MEMORY_KIB: u32 = 65_536;
|
||||
/// Maximum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) const KSWALLET_ARGON2_MAX_MEMORY_KIB: u32 = 262_144;
|
||||
/// Minimum accepted Argon2id pass count.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_ITERATIONS: u32 = 3;
|
||||
/// Maximum accepted Argon2id pass count.
|
||||
pub(crate) const KSWALLET_ARGON2_MAX_ITERATIONS: u32 = 10;
|
||||
/// Minimum accepted Argon2id lane count.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_PARALLELISM: u32 = 1;
|
||||
/// Maximum accepted Argon2id lane count.
|
||||
pub(crate) const KSWALLET_ARGON2_MAX_PARALLELISM: u32 = 8;
|
||||
/// Number of bytes stored by the standard Solana keypair JSON format.
|
||||
pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64;
|
||||
/// Exact ciphertext length for one 64-byte keypair plus the AEAD tag.
|
||||
pub(crate) const KSWALLET_CIPHERTEXT_LENGTH: usize =
|
||||
SOLANA_KEYPAIR_LENGTH + KSWALLET_AEAD_TAG_LENGTH;
|
||||
/// Minimum complete version-one file length with a one-byte alias.
|
||||
pub(crate) const KSWALLET_MIN_FILE_LENGTH: usize = KSWALLET_FIXED_HEADER_LENGTH
|
||||
+ 1
|
||||
+ KSWALLET_SALT_LENGTH
|
||||
+ KSWALLET_NONCE_LENGTH
|
||||
+ KSWALLET_CIPHERTEXT_LENGTH;
|
||||
/// Maximum complete version-one file length with the longest supported alias.
|
||||
pub(crate) const KSWALLET_MAX_FILE_LENGTH: usize = KSWALLET_FIXED_HEADER_LENGTH
|
||||
+ KSWALLET_MAX_ALIAS_LENGTH
|
||||
+ KSWALLET_SALT_LENGTH
|
||||
+ KSWALLET_NONCE_LENGTH
|
||||
+ KSWALLET_CIPHERTEXT_LENGTH;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/lib.rs
|
||||
// version: 4
|
||||
// version: 6
|
||||
|
||||
//! Wallet boundary for local key storage and transaction signing.
|
||||
#![warn(missing_docs)]
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
mod constants;
|
||||
mod manager;
|
||||
mod native;
|
||||
mod wallet;
|
||||
|
||||
/// Native wallet file extension without the leading dot.
|
||||
@@ -31,13 +32,49 @@ pub use self::wallet::WalletPolicy;
|
||||
/// Backward-compatible name for a non-secret wallet identity.
|
||||
pub use self::wallet::WalletSummary;
|
||||
|
||||
/// Native wallet format version used by the identification prefix.
|
||||
/// Version-one AEAD identifier for XChaCha20-Poly1305.
|
||||
pub(crate) use self::constants::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
/// Maximum accepted Argon2id pass count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_ITERATIONS;
|
||||
/// Maximum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_MEMORY_KIB;
|
||||
/// Maximum accepted Argon2id lane count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_PARALLELISM;
|
||||
/// Minimum accepted Argon2id pass count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MIN_ITERATIONS;
|
||||
/// Minimum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MIN_MEMORY_KIB;
|
||||
/// Minimum accepted Argon2id lane count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MIN_PARALLELISM;
|
||||
/// Argon2 version identifier required by native format version one.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_VERSION;
|
||||
/// Exact ciphertext length for a version-one protected keypair.
|
||||
pub(crate) use self::constants::KSWALLET_CIPHERTEXT_LENGTH;
|
||||
/// Fixed version-one header length before variable alias bytes.
|
||||
pub(crate) use self::constants::KSWALLET_FIXED_HEADER_LENGTH;
|
||||
/// Version-one flag value when no optional feature is enabled.
|
||||
pub(crate) use self::constants::KSWALLET_FLAGS_NONE;
|
||||
/// Native wallet format version.
|
||||
pub(crate) use self::constants::KSWALLET_FORMAT_VERSION;
|
||||
/// Number of bytes required to identify a native wallet file.
|
||||
pub(crate) use self::constants::KSWALLET_IDENTIFICATION_LENGTH;
|
||||
/// Version-one KDF identifier for Argon2id.
|
||||
pub(crate) use self::constants::KSWALLET_KDF_ARGON2ID;
|
||||
/// Native wallet format identification magic.
|
||||
pub(crate) use self::constants::KSWALLET_MAGIC;
|
||||
/// Maximum wallet alias length encoded by version one.
|
||||
pub(crate) use self::constants::KSWALLET_MAX_ALIAS_LENGTH;
|
||||
/// Maximum complete native wallet file length.
|
||||
pub(crate) use self::constants::KSWALLET_MAX_FILE_LENGTH;
|
||||
/// Minimum complete native wallet file length.
|
||||
pub(crate) use self::constants::KSWALLET_MIN_FILE_LENGTH;
|
||||
/// Nonce length used by XChaCha20-Poly1305.
|
||||
pub(crate) use self::constants::KSWALLET_NONCE_LENGTH;
|
||||
/// Salt length used by the version-one Argon2id profile.
|
||||
pub(crate) use self::constants::KSWALLET_SALT_LENGTH;
|
||||
/// Number of bytes stored by the standard Solana keypair JSON format.
|
||||
pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Parsed version-one native wallet container.
|
||||
pub(crate) use self::native::NativeWalletContainer;
|
||||
/// Reads and strictly decodes one native wallet file.
|
||||
pub(crate) use self::native::read_native_wallet_container;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// file: ks-wallet/src/manager.rs
|
||||
// version: 1
|
||||
// version: 4
|
||||
|
||||
//! Multi-wallet discovery and native wallet file references.
|
||||
|
||||
use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
|
||||
/// Opaque reference to one identified native wallet file.
|
||||
/// Opaque reference to one validated native wallet file.
|
||||
///
|
||||
/// The local path is intentionally kept private and is not included in `Debug`.
|
||||
/// Consumers keep this handle and pass it back to `ks-wallet` for future open
|
||||
@@ -13,20 +11,46 @@ use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct WalletFileHandle {
|
||||
alias: crate::WalletAlias,
|
||||
public_key: std::string::String,
|
||||
format_version: u16,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WalletFileHandle {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("WalletFileHandle").field("alias", &self.alias).finish();
|
||||
return formatter
|
||||
.debug_struct("WalletFileHandle")
|
||||
.field("alias", &self.alias)
|
||||
.field("public_key", &self.public_key)
|
||||
.field("format_version", &self.format_version)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WalletFileHandle {
|
||||
/// Returns the validated alias inferred from the native wallet filename.
|
||||
/// Returns the validated alias encoded by the native wallet and filename.
|
||||
pub fn alias(&self) -> &crate::WalletAlias {
|
||||
return &self.alias;
|
||||
}
|
||||
|
||||
/// Returns the base58 public key declared by the native wallet header before unlock.
|
||||
pub fn public_key(&self) -> &str {
|
||||
return self.public_key.as_str();
|
||||
}
|
||||
|
||||
/// Returns the native wallet format version.
|
||||
pub fn format_version(&self) -> u16 {
|
||||
return self.format_version;
|
||||
}
|
||||
|
||||
/// Returns the non-secret persistent identity declared before authenticated unlock.
|
||||
pub fn identity(&self) -> crate::WalletIdentity {
|
||||
return crate::WalletIdentity {
|
||||
alias: self.alias.clone(),
|
||||
public_key: self.public_key.clone(),
|
||||
persistence: crate::WalletPersistence::Persistent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-wallet manager rooted at one configured wallet directory.
|
||||
@@ -65,12 +89,12 @@ impl crate::WalletManager {
|
||||
));
|
||||
}
|
||||
|
||||
/// Discovers identified native wallets in the configured directory.
|
||||
/// Discovers structurally valid native wallet containers in the configured directory.
|
||||
///
|
||||
/// Discovery is non-recursive and ignores unrelated file extensions. Every
|
||||
/// `.kswallet` candidate must be a private regular file with a valid alias,
|
||||
/// magic and supported identification version. Full payload validation is
|
||||
/// added by the native codec tranche.
|
||||
/// `.kswallet` candidate must be a private regular file whose complete
|
||||
/// version-one container decodes strictly and whose filename alias matches
|
||||
/// the alias encoded by the native header.
|
||||
pub async fn scan(&self) -> ks_core::Result<std::vec::Vec<crate::WalletFileHandle>> {
|
||||
let directory_exists = match tokio::fs::try_exists(&self.directory).await {
|
||||
std::result::Result::Ok(exists) => exists,
|
||||
@@ -183,7 +207,7 @@ async fn inspect_native_wallet_file(
|
||||
));
|
||||
},
|
||||
};
|
||||
let alias = match crate::WalletAlias::parse(stem) {
|
||||
let filename_alias = match crate::WalletAlias::parse(stem) {
|
||||
std::result::Result::Ok(alias) => alias,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
@@ -196,11 +220,22 @@ async fn inspect_native_wallet_file(
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_native_wallet_identification(&path).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
let container = match crate::read_native_wallet_container(&path).await {
|
||||
std::result::Result::Ok(container) => container,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if container.alias() != &filename_alias {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_mismatch",
|
||||
"native wallet filename alias does not match the container alias",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(crate::WalletFileHandle { alias, path });
|
||||
return std::result::Result::Ok(crate::WalletFileHandle {
|
||||
alias: filename_alias,
|
||||
public_key: container.public_key().to_string(),
|
||||
format_version: crate::KSWALLET_FORMAT_VERSION,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
async fn validate_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> {
|
||||
@@ -219,6 +254,17 @@ async fn validate_wallet_directory(directory: &std::path::Path) -> ks_core::Resu
|
||||
"wallet directory must be a directory and not a symlink",
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_directory_permissions_too_open",
|
||||
format!("wallet directory has mode {mode:o}; expected no group or other access"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
@@ -252,56 +298,45 @@ async fn validate_native_wallet_file_metadata(path: &std::path::Path) -> ks_core
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn validate_native_wallet_identification(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
let mut file = match tokio::fs::File::open(path).await {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_open_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut identification = [0_u8; crate::KSWALLET_IDENTIFICATION_LENGTH];
|
||||
match file.read_exact(&mut identification).await {
|
||||
std::result::Result::Ok(_) => {},
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_identification_truncated",
|
||||
"native wallet identification prefix is truncated",
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_identification_read_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
}
|
||||
if identification[0..crate::KSWALLET_MAGIC.len()] != crate::KSWALLET_MAGIC[..] {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_magic_invalid",
|
||||
"native wallet magic is invalid",
|
||||
));
|
||||
}
|
||||
let version_offset = crate::KSWALLET_MAGIC.len();
|
||||
let version =
|
||||
u16::from_le_bytes([identification[version_offset], identification[version_offset + 1]]);
|
||||
if version != crate::KSWALLET_FORMAT_VERSION {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_version_unsupported",
|
||||
format!("native wallet version {version} is not supported"),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn write_identified_wallet(path: &std::path::Path, version: u16) {
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes.extend_from_slice(&version.to_le_bytes());
|
||||
fn fixture_bytes(alias: &str, marker: u8) -> std::vec::Vec<u8> {
|
||||
let parsed_alias = crate::WalletAlias::parse(alias)
|
||||
.unwrap_or_else(|error| panic!("fixture alias must be valid: {error}"));
|
||||
let alias_bytes = parsed_alias.as_str().as_bytes();
|
||||
let header_length = crate::KSWALLET_FIXED_HEADER_LENGTH + alias_bytes.len();
|
||||
let total_length = header_length
|
||||
+ crate::KSWALLET_SALT_LENGTH
|
||||
+ crate::KSWALLET_NONCE_LENGTH
|
||||
+ crate::KSWALLET_CIPHERTEXT_LENGTH;
|
||||
let mut bytes = vec![0_u8; total_length];
|
||||
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes[8..10].copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
bytes[10..12].copy_from_slice(&(header_length as u16).to_le_bytes());
|
||||
bytes[12..14].copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
||||
bytes[14] = crate::KSWALLET_KDF_ARGON2ID;
|
||||
bytes[15] = crate::KSWALLET_ARGON2_VERSION;
|
||||
bytes[16] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
bytes[17] = crate::KSWALLET_SALT_LENGTH as u8;
|
||||
bytes[18] = crate::KSWALLET_NONCE_LENGTH as u8;
|
||||
bytes[19] = alias_bytes.len() as u8;
|
||||
bytes[24..28].copy_from_slice(&65_536_u32.to_le_bytes());
|
||||
bytes[28..32].copy_from_slice(&3_u32.to_le_bytes());
|
||||
bytes[32..36].copy_from_slice(&4_u32.to_le_bytes());
|
||||
bytes[36..40].copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
||||
bytes[40..72].copy_from_slice(&[marker; 32]);
|
||||
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
|
||||
let salt_offset = header_length;
|
||||
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
||||
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
||||
bytes[salt_offset..nonce_offset].fill(marker.wrapping_add(1));
|
||||
bytes[nonce_offset..ciphertext_offset].fill(marker.wrapping_add(2));
|
||||
bytes[ciphertext_offset..].fill(marker.wrapping_add(3));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn write_native_wallet(path: &std::path::Path, alias: &str, marker: u8) {
|
||||
let bytes = fixture_bytes(alias, marker);
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be writable: {error}"));
|
||||
#[cfg(unix)]
|
||||
@@ -312,25 +347,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_directory_private(path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap_or_else(
|
||||
|error| panic!("wallet fixture directory permissions must be set: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_is_non_recursive_and_filters_by_native_extension() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
make_directory_private(directory.path());
|
||||
let manager = crate::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
write_identified_wallet(&directory.path().join("alpha.kswallet"), 1);
|
||||
write_native_wallet(&directory.path().join("alpha.kswallet"), "alpha", 3);
|
||||
std::fs::write(directory.path().join("legacy.json"), b"[]")
|
||||
.unwrap_or_else(|error| panic!("unrelated fixture must be writable: {error}"));
|
||||
let nested = directory.path().join("nested");
|
||||
std::fs::create_dir(&nested)
|
||||
.unwrap_or_else(|error| panic!("nested directory must be created: {error}"));
|
||||
write_identified_wallet(&nested.join("hidden.kswallet"), 1);
|
||||
write_native_wallet(&nested.join("hidden.kswallet"), "hidden", 5);
|
||||
let wallets = manager
|
||||
.scan()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("scan must succeed: {error}"));
|
||||
assert_eq!(wallets.len(), 1);
|
||||
assert_eq!(wallets[0].alias().as_str(), "alpha");
|
||||
assert_eq!(wallets[0].format_version(), 1);
|
||||
assert_eq!(wallets[0].identity().persistence, crate::WalletPersistence::Persistent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -341,7 +389,7 @@ mod tests {
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let alias = crate::WalletAlias::parse("lookup")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
write_identified_wallet(&manager.wallet_path(&alias), 1);
|
||||
write_native_wallet(&manager.wallet_path(&alias), "lookup", 7);
|
||||
let handle = manager
|
||||
.lookup(&alias)
|
||||
.await
|
||||
@@ -349,6 +397,7 @@ mod tests {
|
||||
.unwrap_or_else(|| panic!("wallet handle must exist"));
|
||||
assert_eq!(handle.alias(), &alias);
|
||||
assert_eq!(handle.path, manager.wallet_path(&alias));
|
||||
assert_eq!(handle.public_key(), handle.identity().public_key);
|
||||
assert!(!format!("{handle:?}").contains(directory.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
@@ -356,60 +405,90 @@ mod tests {
|
||||
async fn explicit_file_inspection_accepts_path_outside_configured_directory() {
|
||||
let configured = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("configured directory must exist: {error}"));
|
||||
make_directory_private(configured.path());
|
||||
let external = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
|
||||
let manager = crate::WalletManager::new(configured.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = external.path().join("selected.kswallet");
|
||||
write_identified_wallet(&path, 1);
|
||||
let external_path = external.path().join("external.kswallet");
|
||||
write_native_wallet(&external_path, "external", 9);
|
||||
let handle = manager
|
||||
.inspect_file(&path)
|
||||
.inspect_file(&external_path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("explicit inspection must succeed: {error}"));
|
||||
assert_eq!(handle.alias().as_str(), "selected");
|
||||
assert_eq!(handle.path, path);
|
||||
let scanned = manager
|
||||
assert_eq!(handle.alias().as_str(), "external");
|
||||
assert_eq!(handle.path, external_path);
|
||||
assert!(!handle.public_key().is_empty());
|
||||
let discovered = manager
|
||||
.scan()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("configured scan must succeed: {error}"));
|
||||
assert!(scanned.is_empty());
|
||||
assert!(discovered.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_native_version_is_rejected_without_path_disclosure() {
|
||||
async fn prefix_only_native_file_is_rejected_by_complete_codec() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let path = directory.path().join("short.kswallet");
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes.extend_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
std::fs::write(&path, bytes)
|
||||
.unwrap_or_else(|error| panic!("fixture must be writable: {error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.unwrap_or_else(|error| panic!("fixture permissions must be set: {error}"));
|
||||
}
|
||||
let manager = crate::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = directory.path().join("future.kswallet");
|
||||
write_identified_wallet(&path, 999);
|
||||
let error = manager
|
||||
.inspect_file(&path)
|
||||
.await
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("unsupported version must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_version_unsupported");
|
||||
assert!(!error.to_string().contains(directory.path().to_string_lossy().as_ref()));
|
||||
.unwrap_or_else(|| panic!("prefix-only fixture must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_file_length_invalid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_native_magic_is_rejected() {
|
||||
async fn filename_and_header_alias_must_match() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let path = directory.path().join("filename.kswallet");
|
||||
write_native_wallet(&path, "header", 11);
|
||||
let manager = crate::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = directory.path().join("invalid.kswallet");
|
||||
write_identified_wallet(&path, 1);
|
||||
let error = manager
|
||||
.inspect_file(&path)
|
||||
.await
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("alias mismatch must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_alias_mismatch");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codec_errors_do_not_disclose_external_path() {
|
||||
let configured = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("configured directory must exist: {error}"));
|
||||
let external = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
|
||||
let manager = crate::WalletManager::new(configured.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = external.path().join("invalid.kswallet");
|
||||
write_native_wallet(&path, "invalid", 13);
|
||||
let mut bytes = std::fs::read(&path)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be readable: {error}"));
|
||||
.unwrap_or_else(|error| panic!("fixture must be readable: {error}"));
|
||||
bytes[0] = b'X';
|
||||
std::fs::write(&path, bytes)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be rewritable: {error}"));
|
||||
.unwrap_or_else(|error| panic!("fixture must be rewritten: {error}"));
|
||||
let error = manager
|
||||
.inspect_file(&path)
|
||||
.await
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("invalid magic must fail"));
|
||||
.unwrap_or_else(|| panic!("invalid native wallet must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_magic_invalid");
|
||||
assert!(!error.to_string().contains(external.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
}
|
||||
|
||||
552
ks-wallet/src/native.rs
Normal file
552
ks-wallet/src/native.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
// file: ks-wallet/src/native.rs
|
||||
// version: 2
|
||||
|
||||
//! Strict reader and validator for native `.kswallet` containers.
|
||||
|
||||
use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
use zeroize::Zeroize; // rust-rules: trait-import
|
||||
|
||||
const OFFSET_FORMAT_VERSION: usize = 8;
|
||||
const OFFSET_HEADER_LENGTH: usize = 10;
|
||||
const OFFSET_FLAGS: usize = 12;
|
||||
const OFFSET_KDF_ID: usize = 14;
|
||||
const OFFSET_KDF_VERSION: usize = 15;
|
||||
const OFFSET_AEAD_ID: usize = 16;
|
||||
const OFFSET_SALT_LENGTH: usize = 17;
|
||||
const OFFSET_NONCE_LENGTH: usize = 18;
|
||||
const OFFSET_ALIAS_LENGTH: usize = 19;
|
||||
const OFFSET_RESERVED: usize = 20;
|
||||
const OFFSET_MEMORY_KIB: usize = 24;
|
||||
const OFFSET_ITERATIONS: usize = 28;
|
||||
const OFFSET_PARALLELISM: usize = 32;
|
||||
const OFFSET_CIPHERTEXT_LENGTH: usize = 36;
|
||||
const OFFSET_PUBLIC_KEY: usize = 40;
|
||||
const RESERVED_LENGTH: usize = 4;
|
||||
const PUBLIC_KEY_LENGTH: usize = 32;
|
||||
|
||||
struct NativeWalletKdfParameters {
|
||||
memory_kib: u32,
|
||||
iterations: u32,
|
||||
parallelism: u32,
|
||||
}
|
||||
|
||||
/// Parsed non-secret identity from one structurally valid native wallet container.
|
||||
///
|
||||
/// The full file buffer, including salt, nonce and ciphertext, is zeroized after
|
||||
/// structural validation. This type therefore retains only the fields required
|
||||
/// by discovery before authenticated unlock.
|
||||
pub(crate) struct NativeWalletContainer {
|
||||
alias: crate::WalletAlias,
|
||||
public_key: solana_pubkey::Pubkey,
|
||||
}
|
||||
|
||||
impl crate::NativeWalletContainer {
|
||||
/// Returns the validated logical alias declared by the native header.
|
||||
pub(crate) fn alias(&self) -> &crate::WalletAlias {
|
||||
return &self.alias;
|
||||
}
|
||||
|
||||
/// Returns the public key declared by the native header before authenticated unlock.
|
||||
pub(crate) fn public_key(&self) -> &solana_pubkey::Pubkey {
|
||||
return &self.public_key;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and strictly validates one native wallet file with a pre-allocation size bound.
|
||||
pub(crate) async fn read_native_wallet_container(
|
||||
path: &std::path::Path,
|
||||
) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
match validate_native_wallet_file_metadata(path).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let mut file = match tokio::fs::File::open(path).await {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_native_open_failed", error));
|
||||
},
|
||||
};
|
||||
let metadata = match file.metadata().await {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_native_metadata_failed", error));
|
||||
},
|
||||
};
|
||||
let file_length = metadata.len();
|
||||
if file_length < crate::KSWALLET_MIN_FILE_LENGTH as u64
|
||||
|| file_length > crate::KSWALLET_MAX_FILE_LENGTH as u64
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
format!(
|
||||
"native wallet file length must be between {} and {} bytes",
|
||||
crate::KSWALLET_MIN_FILE_LENGTH,
|
||||
crate::KSWALLET_MAX_FILE_LENGTH
|
||||
),
|
||||
));
|
||||
}
|
||||
let length = match usize::try_from(file_length) {
|
||||
std::result::Result::Ok(length) => length,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
"native wallet file length cannot be represented on this platform",
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut bytes = vec![0_u8; length];
|
||||
if let std::result::Result::Err(error) = file.read_exact(bytes.as_mut_slice()).await {
|
||||
bytes.zeroize();
|
||||
return std::result::Result::Err(io_error("wallet_native_read_failed", error));
|
||||
}
|
||||
let mut trailing = [0_u8; 1];
|
||||
match file.read(trailing.as_mut_slice()).await {
|
||||
std::result::Result::Ok(0) => {},
|
||||
std::result::Result::Ok(_) => {
|
||||
bytes.zeroize();
|
||||
trailing.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_changed",
|
||||
"native wallet file changed while it was being read",
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
bytes.zeroize();
|
||||
trailing.zeroize();
|
||||
return std::result::Result::Err(io_error("wallet_native_read_failed", error));
|
||||
},
|
||||
}
|
||||
trailing.zeroize();
|
||||
let decoded = decode_native_wallet_container(bytes.as_slice());
|
||||
bytes.zeroize();
|
||||
return decoded;
|
||||
}
|
||||
|
||||
fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
if bytes.len() < crate::KSWALLET_MIN_FILE_LENGTH
|
||||
|| bytes.len() > crate::KSWALLET_MAX_FILE_LENGTH
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
"native wallet file length is outside the supported version-one bound",
|
||||
));
|
||||
}
|
||||
if bytes[0..crate::KSWALLET_MAGIC.len()] != crate::KSWALLET_MAGIC[..] {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_magic_invalid",
|
||||
"native wallet magic is invalid",
|
||||
));
|
||||
}
|
||||
let format_version = read_u16(bytes, OFFSET_FORMAT_VERSION);
|
||||
if format_version != crate::KSWALLET_FORMAT_VERSION {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_version_unsupported",
|
||||
"native wallet format version is unsupported",
|
||||
));
|
||||
}
|
||||
let alias_length = bytes[OFFSET_ALIAS_LENGTH] as usize;
|
||||
if alias_length == 0 || alias_length > crate::KSWALLET_MAX_ALIAS_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_length_invalid",
|
||||
"native wallet alias length is outside the supported bound",
|
||||
));
|
||||
}
|
||||
let header_length = read_u16(bytes, OFFSET_HEADER_LENGTH) as usize;
|
||||
let expected_header_length = match crate::KSWALLET_FIXED_HEADER_LENGTH.checked_add(alias_length)
|
||||
{
|
||||
std::option::Option::Some(length) => length,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_header_length_invalid",
|
||||
"native wallet header length overflowed the platform size",
|
||||
));
|
||||
},
|
||||
};
|
||||
if header_length != expected_header_length {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_header_length_invalid",
|
||||
"native wallet header length does not match the encoded alias length",
|
||||
));
|
||||
}
|
||||
if read_u16(bytes, OFFSET_FLAGS) != crate::KSWALLET_FLAGS_NONE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_flags_unsupported",
|
||||
"native wallet flags are unsupported for format version one",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_KDF_ID] != crate::KSWALLET_KDF_ARGON2ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_unsupported",
|
||||
"native wallet KDF identifier is unsupported",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_KDF_VERSION] != crate::KSWALLET_ARGON2_VERSION {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_version_unsupported",
|
||||
"native wallet Argon2 version is unsupported",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_AEAD_ID] != crate::KSWALLET_AEAD_XCHACHA20_POLY1305 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_aead_unsupported",
|
||||
"native wallet AEAD identifier is unsupported",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_SALT_LENGTH] as usize != crate::KSWALLET_SALT_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_salt_length_invalid",
|
||||
"native wallet salt length is invalid",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_NONCE_LENGTH] as usize != crate::KSWALLET_NONCE_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_nonce_length_invalid",
|
||||
"native wallet nonce length is invalid",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_RESERVED..OFFSET_RESERVED + RESERVED_LENGTH]
|
||||
.iter()
|
||||
.any(|byte| return *byte != 0)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_reserved_nonzero",
|
||||
"native wallet reserved header bytes must be zero",
|
||||
));
|
||||
}
|
||||
let kdf_parameters = NativeWalletKdfParameters {
|
||||
memory_kib: read_u32(bytes, OFFSET_MEMORY_KIB),
|
||||
iterations: read_u32(bytes, OFFSET_ITERATIONS),
|
||||
parallelism: read_u32(bytes, OFFSET_PARALLELISM),
|
||||
};
|
||||
match validate_kdf_parameters(&kdf_parameters) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let ciphertext_length = read_u32(bytes, OFFSET_CIPHERTEXT_LENGTH) as usize;
|
||||
match validate_ciphertext_length(ciphertext_length) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let expected_length = match header_length
|
||||
.checked_add(crate::KSWALLET_SALT_LENGTH)
|
||||
.and_then(|length| return length.checked_add(crate::KSWALLET_NONCE_LENGTH))
|
||||
.and_then(|length| return length.checked_add(ciphertext_length))
|
||||
{
|
||||
std::option::Option::Some(length) => length,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
"native wallet lengths overflow the platform size",
|
||||
));
|
||||
},
|
||||
};
|
||||
if expected_length != bytes.len() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_mismatch",
|
||||
"native wallet declared lengths do not match the file length",
|
||||
));
|
||||
}
|
||||
let alias_bytes = &bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length];
|
||||
let alias_text = match std::str::from_utf8(alias_bytes) {
|
||||
std::result::Result::Ok(alias) => alias,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_encoding_invalid",
|
||||
"native wallet alias must be valid ASCII-compatible UTF-8",
|
||||
));
|
||||
},
|
||||
};
|
||||
let alias = match crate::WalletAlias::parse(alias_text) {
|
||||
std::result::Result::Ok(alias) => alias,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_invalid",
|
||||
"native wallet header contains an invalid wallet alias",
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut public_key_bytes = [0_u8; PUBLIC_KEY_LENGTH];
|
||||
public_key_bytes
|
||||
.copy_from_slice(&bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]);
|
||||
return std::result::Result::Ok(crate::NativeWalletContainer {
|
||||
alias,
|
||||
public_key: solana_pubkey::Pubkey::new_from_array(public_key_bytes),
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_kdf_parameters(parameters: &NativeWalletKdfParameters) -> ks_core::Result<()> {
|
||||
if parameters.memory_kib < crate::KSWALLET_ARGON2_MIN_MEMORY_KIB
|
||||
|| parameters.memory_kib > crate::KSWALLET_ARGON2_MAX_MEMORY_KIB
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory parameter is outside the supported bound",
|
||||
));
|
||||
}
|
||||
if parameters.iterations < crate::KSWALLET_ARGON2_MIN_ITERATIONS
|
||||
|| parameters.iterations > crate::KSWALLET_ARGON2_MAX_ITERATIONS
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_iterations_invalid",
|
||||
"native wallet Argon2 iteration parameter is outside the supported bound",
|
||||
));
|
||||
}
|
||||
if parameters.parallelism < crate::KSWALLET_ARGON2_MIN_PARALLELISM
|
||||
|| parameters.parallelism > crate::KSWALLET_ARGON2_MAX_PARALLELISM
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_parallelism_invalid",
|
||||
"native wallet Argon2 parallelism parameter is outside the supported bound",
|
||||
));
|
||||
}
|
||||
let minimum_memory = match 8_u32.checked_mul(parameters.parallelism) {
|
||||
std::option::Option::Some(memory) => memory,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory relation is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
let alignment = match 4_u32.checked_mul(parameters.parallelism) {
|
||||
std::option::Option::Some(alignment) => alignment,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory alignment is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
if parameters.memory_kib < minimum_memory || parameters.memory_kib % alignment != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory must satisfy the encoded parallelism relation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_ciphertext_length(length: usize) -> ks_core::Result<()> {
|
||||
if length != crate::KSWALLET_CIPHERTEXT_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_ciphertext_length_invalid",
|
||||
format!(
|
||||
"native wallet ciphertext length must be exactly {} bytes",
|
||||
crate::KSWALLET_CIPHERTEXT_LENGTH
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn validate_native_wallet_file_metadata(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
let metadata = match tokio::fs::symlink_metadata(path).await {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_file_metadata_failed", error));
|
||||
},
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_type_invalid",
|
||||
"wallet file must be a regular file and not a symlink",
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_permissions_too_open",
|
||||
format!("wallet file has mode {mode:o}; expected no group or other access"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn io_error(code: &str, error: std::io::Error) -> ks_core::Error {
|
||||
return ks_core::Error::new(code, error.to_string());
|
||||
}
|
||||
|
||||
fn read_u16(bytes: &[u8], offset: usize) -> u16 {
|
||||
return u16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> u32 {
|
||||
return u32::from_le_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn fixture_bytes(alias: &str, marker: u8) -> std::vec::Vec<u8> {
|
||||
let parsed_alias = crate::WalletAlias::parse(alias)
|
||||
.unwrap_or_else(|error| panic!("fixture alias must be valid: {error}"));
|
||||
let alias_bytes = parsed_alias.as_str().as_bytes();
|
||||
let header_length = crate::KSWALLET_FIXED_HEADER_LENGTH + alias_bytes.len();
|
||||
let total_length = header_length
|
||||
+ crate::KSWALLET_SALT_LENGTH
|
||||
+ crate::KSWALLET_NONCE_LENGTH
|
||||
+ crate::KSWALLET_CIPHERTEXT_LENGTH;
|
||||
let mut bytes = vec![0_u8; total_length];
|
||||
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes[super::OFFSET_FORMAT_VERSION..super::OFFSET_FORMAT_VERSION + 2]
|
||||
.copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
bytes[super::OFFSET_HEADER_LENGTH..super::OFFSET_HEADER_LENGTH + 2]
|
||||
.copy_from_slice(&(header_length as u16).to_le_bytes());
|
||||
bytes[super::OFFSET_FLAGS..super::OFFSET_FLAGS + 2]
|
||||
.copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
||||
bytes[super::OFFSET_KDF_ID] = crate::KSWALLET_KDF_ARGON2ID;
|
||||
bytes[super::OFFSET_KDF_VERSION] = crate::KSWALLET_ARGON2_VERSION;
|
||||
bytes[super::OFFSET_AEAD_ID] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
bytes[super::OFFSET_SALT_LENGTH] = crate::KSWALLET_SALT_LENGTH as u8;
|
||||
bytes[super::OFFSET_NONCE_LENGTH] = crate::KSWALLET_NONCE_LENGTH as u8;
|
||||
bytes[super::OFFSET_ALIAS_LENGTH] = alias_bytes.len() as u8;
|
||||
bytes[super::OFFSET_MEMORY_KIB..super::OFFSET_MEMORY_KIB + 4]
|
||||
.copy_from_slice(&65_536_u32.to_le_bytes());
|
||||
bytes[super::OFFSET_ITERATIONS..super::OFFSET_ITERATIONS + 4]
|
||||
.copy_from_slice(&3_u32.to_le_bytes());
|
||||
bytes[super::OFFSET_PARALLELISM..super::OFFSET_PARALLELISM + 4]
|
||||
.copy_from_slice(&4_u32.to_le_bytes());
|
||||
bytes[super::OFFSET_CIPHERTEXT_LENGTH..super::OFFSET_CIPHERTEXT_LENGTH + 4]
|
||||
.copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
||||
bytes[super::OFFSET_PUBLIC_KEY..super::OFFSET_PUBLIC_KEY + super::PUBLIC_KEY_LENGTH]
|
||||
.copy_from_slice(&[marker; super::PUBLIC_KEY_LENGTH]);
|
||||
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
|
||||
let salt_offset = header_length;
|
||||
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
||||
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
||||
bytes[salt_offset..nonce_offset].fill(marker.wrapping_add(1));
|
||||
bytes[nonce_offset..ciphertext_offset].fill(marker.wrapping_add(2));
|
||||
bytes[ciphertext_offset..].fill(marker.wrapping_add(3));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn mutate_u32(bytes: &mut [u8], offset: usize, value: u32) {
|
||||
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_private_fixture(path: &std::path::Path, bytes: &[u8]) {
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|error| panic!("fixture write must succeed: {error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.unwrap_or_else(|error| panic!("fixture permissions must be private: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_decoder_accepts_exact_header_and_declared_identity() {
|
||||
let encoded = fixture_bytes("codec", 7);
|
||||
assert_eq!(&encoded[0..8], b"KSWALLET");
|
||||
assert_eq!(super::read_u16(encoded.as_slice(), super::OFFSET_FORMAT_VERSION), 1);
|
||||
assert_eq!(super::read_u16(encoded.as_slice(), super::OFFSET_HEADER_LENGTH) as usize, 77);
|
||||
assert_eq!(encoded[super::OFFSET_KDF_ID], crate::KSWALLET_KDF_ARGON2ID);
|
||||
assert_eq!(encoded[super::OFFSET_KDF_VERSION], 0x13);
|
||||
assert_eq!(encoded[super::OFFSET_AEAD_ID], crate::KSWALLET_AEAD_XCHACHA20_POLY1305);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_MEMORY_KIB), 65_536);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_ITERATIONS), 3);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_PARALLELISM), 4);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_CIPHERTEXT_LENGTH), 80);
|
||||
let decoded = super::decode_native_wallet_container(encoded.as_slice())
|
||||
.unwrap_or_else(|error| panic!("decoding must succeed: {error}"));
|
||||
assert_eq!(decoded.alias().as_str(), "codec");
|
||||
assert_eq!(decoded.public_key(), &solana_pubkey::Pubkey::new_from_array([7_u8; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_unknown_algorithms_reserved_bytes_and_versions() {
|
||||
let encoded = fixture_bytes("strict", 13);
|
||||
for (offset, value, code) in [
|
||||
(super::OFFSET_KDF_ID, 99_u8, "wallet_native_kdf_unsupported"),
|
||||
(super::OFFSET_KDF_VERSION, 0x12_u8, "wallet_native_kdf_version_unsupported"),
|
||||
(super::OFFSET_AEAD_ID, 99_u8, "wallet_native_aead_unsupported"),
|
||||
(super::OFFSET_RESERVED, 1_u8, "wallet_native_reserved_nonzero"),
|
||||
] {
|
||||
let mut candidate = encoded.clone();
|
||||
candidate[offset] = value;
|
||||
let error = super::decode_native_wallet_container(candidate.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("mutation at offset {offset} must fail"));
|
||||
assert_eq!(error.code(), code);
|
||||
}
|
||||
let mut version = encoded.clone();
|
||||
version[super::OFFSET_FORMAT_VERSION..super::OFFSET_FORMAT_VERSION + 2]
|
||||
.copy_from_slice(&2_u16.to_le_bytes());
|
||||
let error = super::decode_native_wallet_container(version.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("unknown format version must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_version_unsupported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_truncation_and_trailing_bytes() {
|
||||
let encoded = fixture_bytes("lengths", 17);
|
||||
let truncated = &encoded[..encoded.len() - 1];
|
||||
let truncated_error = super::decode_native_wallet_container(truncated)
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("truncation must fail"));
|
||||
assert_eq!(truncated_error.code(), "wallet_native_file_length_mismatch");
|
||||
let mut trailing = encoded.clone();
|
||||
trailing.push(0);
|
||||
let trailing_error = super::decode_native_wallet_container(trailing.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("trailing byte must fail"));
|
||||
assert_eq!(trailing_error.code(), "wallet_native_file_length_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_kdf_parameters_outside_dos_bounds() {
|
||||
let encoded = fixture_bytes("kdf", 19);
|
||||
for (offset, value, code) in [
|
||||
(super::OFFSET_MEMORY_KIB, 32_768_u32, "wallet_native_kdf_memory_invalid"),
|
||||
(super::OFFSET_MEMORY_KIB, 524_288_u32, "wallet_native_kdf_memory_invalid"),
|
||||
(super::OFFSET_ITERATIONS, 2_u32, "wallet_native_kdf_iterations_invalid"),
|
||||
(super::OFFSET_ITERATIONS, 11_u32, "wallet_native_kdf_iterations_invalid"),
|
||||
(super::OFFSET_PARALLELISM, 0_u32, "wallet_native_kdf_parallelism_invalid"),
|
||||
(super::OFFSET_PARALLELISM, 9_u32, "wallet_native_kdf_parallelism_invalid"),
|
||||
] {
|
||||
let mut candidate = encoded.clone();
|
||||
mutate_u32(candidate.as_mut_slice(), offset, value);
|
||||
let error = super::decode_native_wallet_container(candidate.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("invalid KDF parameter must fail"));
|
||||
assert_eq!(error.code(), code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_requires_exact_keypair_ciphertext_length() {
|
||||
let encoded = fixture_bytes("ciphertext", 21);
|
||||
for length in [79_u32, 81_u32] {
|
||||
let mut candidate = encoded.clone();
|
||||
mutate_u32(candidate.as_mut_slice(), super::OFFSET_CIPHERTEXT_LENGTH, length);
|
||||
let error = super::decode_native_wallet_container(candidate.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("non-keypair ciphertext length must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_ciphertext_length_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_reader_reopens_private_structurally_valid_file() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let path = directory.path().join("reopen.kswallet");
|
||||
let encoded = fixture_bytes("reopen", 31);
|
||||
write_private_fixture(&path, encoded.as_slice());
|
||||
let reopened = crate::read_native_wallet_container(&path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("reopen must succeed: {error}"));
|
||||
assert_eq!(reopened.alias().as_str(), "reopen");
|
||||
assert_eq!(reopened.public_key(), &solana_pubkey::Pubkey::new_from_array([31_u8; 32]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user