v0.5.2-pre.003
This commit is contained in:
@@ -1,8 +1,40 @@
|
||||
<!-- file: ks-wallet/CHANGELOG.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 14 -->
|
||||
|
||||
# CHANGELOG — ks-wallet
|
||||
|
||||
## `0.5.2-pre.003`
|
||||
|
||||
- fixe et documente dans `docs/NATIVE_FORMAT.md` le layout binaire strict `.kswallet` version `1` ;
|
||||
- sélectionne Argon2id v19 et XChaCha20-Poly1305 comme KDF/AEAD du format v1 sans encore traiter de mot de passe ;
|
||||
- retient comme profil KDF d'écriture par défaut 64 MiB, 3 passes, 4 lanes, sel 16 octets et sortie 32 octets, avec bornes anti-DoS ;
|
||||
- fixe le payload secret v1 à la keypair Solana brute de 64 octets et le ciphertext/tag à exactement 80 octets, pour une taille totale `.kswallet` de 193 à 256 octets ;
|
||||
- ajoute le décodage/validation internes stricts pour magic/version, flags, IDs crypto, paramètres KDF, pubkey, alias, sel, nonce et ciphertext ;
|
||||
- réserve header + alias + sel + nonce comme données authentifiées de l'AEAD pour `pre.004` ;
|
||||
- enrichit `WalletFileHandle` de la pubkey déclarée et de la version du format sans exposer le chemin ;
|
||||
- ajoute une lecture bornée avant allocation et rejette truncation, trailing bytes et croissance concurrente du fichier ;
|
||||
- ajoute les tests du décodage strict, des bornes KDF/ciphertext, de l'alias et de la réouverture bornée ;
|
||||
- laisse volontairement l'encodage de création, la publication atomique, le password, la dérivation Argon2id, l'appel AEAD et la capacité de signature ouverts à `pre.004`.
|
||||
|
||||
### `pre.003-delta-fix-001`
|
||||
|
||||
- déplace la spécification normative de `ks-wallet/NATIVE_FORMAT.md` vers `docs/NATIVE_FORMAT.md` et aligne son tableau de layout pour une lecture Markdown plus claire ;
|
||||
- corrige les tests `native.rs` pour respecter la règle `super::Item` sur les éléments privés du module parent ;
|
||||
- retire de `pre.003` le code d'encodage/publication et les defaults KDF préparés prématurément pour `pre.004`, afin de ne conserver aucun dead code ni import inutilisé ;
|
||||
- borne `pre.003` au décodage/validation stricts et à la lecture nécessaire au scan, sans changer le layout v1 documenté.
|
||||
|
||||
### `pre.003-delta-fix-002`
|
||||
|
||||
- corrige les fixtures de tests `WalletManager::scan()` afin que les répertoires temporaires utilisés sous Unix soient explicitement privés en mode `0700` ;
|
||||
- conserve inchangée la validation runtime qui refuse un répertoire wallet accessible au groupe ou aux autres utilisateurs ;
|
||||
- corrige les deux échecs `scan_is_non_recursive_and_filters_by_native_extension` et `explicit_file_inspection_accepts_path_outside_configured_directory` sans assouplir le contrat de sécurité.
|
||||
|
||||
### `pre.003-delta-fix-003`
|
||||
|
||||
- corrige la fixture du test externe `crate_root_exposes_multi_wallet_discovery_without_path_disclosure` afin que son répertoire temporaire respecte lui aussi le mode Unix `0700` exigé par `WalletManager::scan()` ;
|
||||
- ne modifie aucune validation runtime, aucun codec `.kswallet` et aucune surface publique ;
|
||||
- complète le correctif de permissions de `fix-002` sur le dernier test externe qui dépendait encore de l'umask de la machine.
|
||||
|
||||
## `0.5.2-pre.002`
|
||||
|
||||
- ajoute `WalletManager` pour la découverte non récursive des `.kswallet` dans le répertoire configuré et le lookup par alias ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: ks-wallet/Cargo.toml
|
||||
# version: 3
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "ks-wallet"
|
||||
@@ -12,6 +12,7 @@ publish.workspace = true
|
||||
ks-core = { path = "../ks-core" }
|
||||
serde_json.workspace = true
|
||||
solana-keypair.workspace = true
|
||||
solana-pubkey.workspace = true
|
||||
solana-signer.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ks-wallet/README.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# ks-wallet
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
|
||||
## État actuel
|
||||
|
||||
En `0.5.2-pre.002`, la crate possède désormais la première API multi-wallet sans encore créer ni déchiffrer de conteneur natif persistant. Le legacy Solana JSON reste utilisé par les consommateurs historiques pendant la transition.
|
||||
En `0.5.2-pre.003`, la crate possède désormais le décodage/validation stricts du format binaire `.kswallet` v1 nécessaires au scan. L'encodage de création, la publication atomique, le password et le chiffrement/déchiffrement réel restent pour `pre.004`; le legacy Solana JSON reste utilisé par les consommateurs historiques pendant la transition.
|
||||
|
||||
La crate fournit actuellement :
|
||||
|
||||
- alias validés ;
|
||||
- identité publique minimale `WalletIdentity` sans chemin local ;
|
||||
- `WalletManager` pour scanner/lookup les `.kswallet` du répertoire configuré ;
|
||||
- `WalletManager` pour scanner/lookup les `.kswallet` structurellement valides du répertoire configuré ;
|
||||
- inspection explicite d'un `.kswallet` sélectionné hors du store via un handle opaque ;
|
||||
- décodage v1 strict avec identité publique déclarée, paramètres KDF/AEAD bornés et payload keypair de taille exacte ;
|
||||
- wallet temporaire en mémoire ;
|
||||
- stockage legacy local JSON d'un keypair Solana ;
|
||||
- création exclusive avec refus d'écrasement et reprise des courses `load_or_create` ;
|
||||
@@ -42,7 +43,7 @@ La persistance legacy écrit encore directement le contenu dans le chemin final
|
||||
- importer et exporter obligatoirement le format keypair JSON des binaires Solana ;
|
||||
- documenter les formats compatibles des principaux wallets Solana, implémenter un adaptateur tiers d'exemple et reporter les autres au TODO.
|
||||
|
||||
Le format `.kswallet` sera propre à `ks-wallet`, mais sa protection cryptographique utilisera des primitives standards. `pre.002` ne fixe que son préambule d'identification minimal (`KSWALLET` + version `1`) afin de rendre scan et lookup exécutables ; le payload protégé n'existe pas encore. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet. Le détail du KDF, de l'AEAD et du reste du layout binaire sera fixé dans les tranches techniques après validation des dépendances réellement résolues.
|
||||
Le format `.kswallet` est propre à `ks-wallet`, mais sa protection cryptographique utilise des primitives établies : le format v1 fixe Argon2id v19 et XChaCha20-Poly1305. Le layout normatif est documenté dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). Le payload secret v1 est exactement la keypair Solana brute de 64 octets ; avec le tag AEAD, le ciphertext est fixé à 80 octets. `pre.003` ne réalise encore aucune dérivation ni encryption réelle. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet.
|
||||
|
||||
Le scan automatique reste strictement borné au répertoire fourni à `WalletManager`. Un programme peut néanmoins demander l'inspection d'un autre fichier `.kswallet` choisi explicitement, par exemple via un file browser desktop ; cette opération ne modifie pas le store et ne rend pas le chemin public.
|
||||
|
||||
@@ -56,6 +57,7 @@ Une application desktop doit projeter les informations autorisées dans ses prop
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Format natif `.kswallet`](../docs/NATIVE_FORMAT.md)
|
||||
- [USAGE.md](USAGE.md)
|
||||
- [TODO.md](TODO.md)
|
||||
- [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ks-wallet/TODO.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 10 -->
|
||||
|
||||
# TODO — ks-wallet
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
- [x] caractériser par tests externes le format legacy `<alias>.json`, ses erreurs et ses permissions.
|
||||
- [x] définir l'identité publique minimale d'un wallet et le lookup par alias.
|
||||
- [x] introduire le manager, le scan borné et le lookup des `.kswallet` identifiables dans le store résolu ; renforcer la validation avec le codec complet en `pre.003`.
|
||||
- [x] introduire le manager, le scan borné et le lookup des `.kswallet` structurellement valides dans le store résolu.
|
||||
- [x] conserver les wallets temporaires ou jetables purement en mémoire et leur frontière de signature actuelle.
|
||||
- [x] permettre l'inspection explicite d'un `.kswallet` hors store sans mutation de la configuration ni exposition publique du chemin.
|
||||
- [ ] spécifier puis implémenter le format natif binaire versionné `<alias>.kswallet`.
|
||||
- [ ] sélectionner et documenter KDF/AEAD/paramètres après vérification des dépendances résolues.
|
||||
- [x] spécifier le format natif binaire v1 `<alias>.kswallet` et implémenter son décodage/validation stricts pour le scan.
|
||||
- [ ] `0.5.2-pre.004` — implémenter l'encodage de création et la publication atomique/no-clobber du conteneur natif.
|
||||
- [x] sélectionner et documenter Argon2id v19 / XChaCha20-Poly1305 et leurs paramètres/bornes v1.
|
||||
- [ ] créer/ouvrir un wallet persistant avec mot de passe sans exposer les bytes privés.
|
||||
- [ ] permettre le changement de mot de passe en rechiffrant exactement la même keypair et donc en conservant la même pubkey.
|
||||
- [x] préserver la frontière `solana_signer::Signer` compatible avec les consommateurs sans dépendance de `ks-lib` vers `ks-wallet`.
|
||||
@@ -27,5 +28,5 @@
|
||||
- [ ] normaliser la sélection par alias dans `ks-config` sans secret.
|
||||
- [ ] adapter les consommateurs et le desktop uniquement via des surfaces non sensibles.
|
||||
- [ ] retirer secrets et chemins locaux inutiles des logs, erreurs, diagnostics et DTO.
|
||||
- [ ] tester permissions privées, atomicité, corruption, concurrence réellement utilisée et non-divulgation.
|
||||
- [ ] compléter les tests de permissions privées, atomicité, corruption authentifiée, concurrence réellement utilisée et non-divulgation lors des tranches password/migration.
|
||||
- [ ] produire le guide de sécurité et la documentation finale avant clôture de `0.5.2`.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: ks-wallet/USAGE.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Utilisation de ks-wallet
|
||||
|
||||
## Statut
|
||||
|
||||
En `0.5.2-pre.002`, l'API multi-wallet de découverte est introduite, tandis que le password, le codec complet et la création persistante `.kswallet` restent planifiés pour les prereleases suivantes.
|
||||
En `0.5.2-pre.003`, le manager valide désormais la structure complète du conteneur binaire `.kswallet` v1 avant de retourner un handle. Le password, le chiffrement/déchiffrement réel et la création persistante publique arrivent en `pre.004`.
|
||||
|
||||
## Valider un alias
|
||||
|
||||
@@ -144,7 +144,7 @@ println!("wallet={}", wallet.public_key());
|
||||
|
||||
Le contenu legacy est toutefois écrit directement dans le chemin final. La publication n'est pas encore crash-safe/atomique par fichier temporaire + renommage.
|
||||
|
||||
## Manager multi-wallet introduit en `pre.002`
|
||||
## Manager multi-wallet et codec v1
|
||||
|
||||
Le manager reçoit le répertoire déjà résolu par le consommateur/configuration. Son scan automatique ne sort jamais de cette racine :
|
||||
|
||||
@@ -166,7 +166,7 @@ let wallets = match manager.scan().await {
|
||||
};
|
||||
```
|
||||
|
||||
`scan()` est non récursif et ne traite que les `<alias>.kswallet`. En `pre.002`, l'identification vérifie le suffixe, l'alias, le type de fichier, les permissions Unix, le magic ASCII `KSWALLET` et la version `1` encodée en `u16` little-endian. La validation complète du payload arrivera avec le codec `pre.003`.
|
||||
`scan()` est non récursif et ne traite que les `<alias>.kswallet`. En `pre.003`, chaque candidat doit décoder entièrement selon le format v1 : magic/version, flags, IDs crypto, paramètres KDF, pubkey déclarée, alias, sel, nonce et ciphertext de taille exacte. Le nom du fichier et l'alias encodé doivent correspondre.
|
||||
|
||||
Le lookup par alias reste limité à la racine configurée :
|
||||
|
||||
@@ -190,9 +190,18 @@ let handle = match manager.inspect_file(selected_path).await {
|
||||
};
|
||||
|
||||
println!("alias={}", handle.alias().as_str());
|
||||
println!("pubkey={}", handle.public_key());
|
||||
println!("format={}", handle.format_version());
|
||||
```
|
||||
|
||||
`WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`. Le handle servira à l'ouverture par mot de passe dans la tranche correspondante.
|
||||
`WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. Sa pubkey est une identité **déclarée** avant unlock : elle ne deviendra authentifiée qu'après validation AEAD et comparaison avec la keypair déchiffrée en `pre.004`. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`.
|
||||
|
||||
|
||||
## Format binaire v1
|
||||
|
||||
Le layout exact, les bornes et la politique de publication cible sont documentés dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). `pre.003` implémente la lecture et le décodage stricts nécessaires au scan ; l'encodage de création et la publication atomique sont introduits en `pre.004`. Le payload secret v1 est strictement borné à une keypair Solana de 64 octets et produit un ciphertext/tag de 80 octets.
|
||||
|
||||
Le format encode Argon2id v19 avec un profil d'écriture par défaut de 64 MiB / 3 passes / 4 lanes et XChaCha20-Poly1305 avec nonce 24 octets. Ces paramètres sont seulement encodés/validés en `pre.003`; leur exécution cryptographique commence en `pre.004`.
|
||||
|
||||
## Cible persistante `0.5.2`
|
||||
|
||||
@@ -253,4 +262,4 @@ L'export vers Solana CLI utilise son tableau JSON de 64 octets. Pour Phantom, So
|
||||
- rejet des keypairs corrompus ;
|
||||
- vérification des permissions Unix privées.
|
||||
|
||||
`pre.002` ajoute la caractérisation legacy externalisée ainsi que les tests du manager, du scan, du lookup, de l'inspection hors store, du magic/version et de la non-divulgation du chemin par le handle. La suite de la matrice ajoutera le codec `.kswallet`, le password/changement de password, la migration, l'import/export, l'atomicité complète et les canaris de non-divulgation.
|
||||
`pre.003` ajoute le décodage/validation v1 stricts, les bornes KDF/fichier et les tests de corruption/réouverture. `pre.004` ajoute l'encodage de création, la publication atomique, le password/changement de password et le chiffrement effectif ; les tranches suivantes couvrent la migration, l'import/export et les canaris de non-divulgation.
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
// file: ks-wallet/tests/public_api.rs
|
||||
// version: 1
|
||||
// version: 3
|
||||
|
||||
//! External crate-root contract tests for the `ks-wallet` public API.
|
||||
|
||||
fn write_native_identification_fixture(path: &std::path::Path) {
|
||||
fn write_native_wallet_fixture(
|
||||
path: &std::path::Path,
|
||||
alias: &str,
|
||||
marker: u8,
|
||||
) -> std::string::String {
|
||||
let public_key = solana_pubkey::Pubkey::new_from_array([marker; 32]);
|
||||
let alias_bytes = alias.as_bytes();
|
||||
let alias_length =
|
||||
u8::try_from(alias_bytes.len()).unwrap_or_else(|_| panic!("fixture alias must fit u8"));
|
||||
let header_length = 72_u16 + u16::from(alias_length);
|
||||
let ciphertext = vec![marker.wrapping_add(3); 80];
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(b"KSWALLET");
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&header_length.to_le_bytes());
|
||||
bytes.extend_from_slice(&0_u16.to_le_bytes());
|
||||
bytes.push(1);
|
||||
bytes.push(0x13);
|
||||
bytes.push(1);
|
||||
bytes.push(16);
|
||||
bytes.push(24);
|
||||
bytes.push(alias_length);
|
||||
bytes.extend_from_slice(&[0_u8; 4]);
|
||||
bytes.extend_from_slice(&65_536_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&3_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&4_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&80_u32.to_le_bytes());
|
||||
let public_key_bytes = public_key.to_bytes();
|
||||
bytes.extend_from_slice(&public_key_bytes);
|
||||
bytes.extend_from_slice(alias_bytes);
|
||||
bytes.extend_from_slice(&[marker.wrapping_add(1); 16]);
|
||||
bytes.extend_from_slice(&[marker.wrapping_add(2); 24]);
|
||||
bytes.extend_from_slice(ciphertext.as_slice());
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be writable: {error}"));
|
||||
#[cfg(unix)]
|
||||
@@ -15,6 +44,7 @@ fn write_native_identification_fixture(path: &std::path::Path) {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.unwrap_or_else(|error| panic!("native fixture permissions must be set: {error}"));
|
||||
}
|
||||
return public_key.to_string();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -22,16 +52,27 @@ async fn crate_root_exposes_multi_wallet_discovery_without_path_disclosure() {
|
||||
assert_eq!(ks_wallet::KSWALLET_FILE_EXTENSION, "kswallet");
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("wallet fixture directory permissions must be set: {error}")
|
||||
});
|
||||
}
|
||||
let manager = ks_wallet::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = directory.path().join("public-api.kswallet");
|
||||
write_native_identification_fixture(&path);
|
||||
let expected_public_key = write_native_wallet_fixture(&path, "public-api", 37);
|
||||
let handles = manager
|
||||
.scan()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("scan must succeed: {error}"));
|
||||
assert_eq!(handles.len(), 1);
|
||||
assert_eq!(handles[0].alias().as_str(), "public-api");
|
||||
assert_eq!(handles[0].public_key(), expected_public_key);
|
||||
assert_eq!(handles[0].format_version(), 1);
|
||||
assert_eq!(handles[0].identity().persistence, ks_wallet::WalletPersistence::Persistent);
|
||||
assert!(!format!("{:?}", handles[0]).contains(directory.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user