v0.5.2-pre.003

This commit is contained in:
2026-08-10 16:23:18 +02:00
parent eaa24e6f11
commit ef630e3123
14 changed files with 1153 additions and 128 deletions

View File

@@ -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()));
}
}