v0.5.2-pre.005

This commit is contained in:
2026-08-10 19:12:13 +02:00
parent b8e6751747
commit e3acb8ceb1
13 changed files with 1276 additions and 90 deletions

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml
# version: 61
# version: 62
[workspace]
resolver = "3"
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.5.2-pre.4"
version = "0.5.2-pre.5"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3"

View File

@@ -1,5 +1,5 @@
<!-- file: docs/README.md -->
<!-- version: 34 -->
<!-- version: 35 -->
# Documentation active de Khadhroony Bot3
@@ -53,6 +53,7 @@ Les audits et rapports de travail `0.4.8-pre.*` sont archivés sous `../olddocs/
- [`MISSING_PROGRAM_IDLS.md`](MISSING_PROGRAM_IDLS.md) ;
- [`OPERATION_NAMING_CONVENTION.md`](OPERATION_NAMING_CONVENTION.md) ;
- [`NATIVE_FORMAT.md`](NATIVE_FORMAT.md) : format binaire natif versionné de `ks-wallet` ;
- [`WALLET_FORMAT_COMPATIBILITY.md`](WALLET_FORMAT_COMPATIBILITY.md) : matrice des formats dimport/export wallet Solana et décisions dadaptation ;
- [`IDEA_REMINDERS.md`](IDEA_REMINDERS.md).
Les idées ne rejoignent un `TODO.md` quaprès confirmation, attribution et reformulation en tâche vérifiable.

View File

@@ -0,0 +1,106 @@
<!-- file: docs/WALLET_FORMAT_COMPATIBILITY.md -->
<!-- version: 1 -->
# Compatibilité import/export des wallets Solana
## 1. Statut
Cette matrice est établie pour `khadhroony-bot3 0.5.2-pre.005` et borne les adaptateurs secrets de `ks-wallet`.
Elle ne prétend pas inventorier tous les wallets existants. Elle couvre les formats obligatoires du projet et les cibles explicitement retenues par le plan `0.5.2` : Solana CLI, Phantom, Solflare, Backpack, Trust Wallet et Base / Coinbase Wallet. Coinbase Developer Platform est distingué de Base app car son API de custody/import n'est pas le contrat utilisateur de Base app.
Règles transversales :
- tout export contenant le secret exige d'abord le mot de passe valide du `.kswallet` ;
- aucun adaptateur ne synthétise une recovery phrase à partir d'une keypair arbitraire ;
- un format n'est implémenté que si sa représentation est suffisamment précise pour permettre un round-trip et une validation de pubkey ;
- les exports secrets sont écrits dans un fichier privé, atomiquement et sans écrasement silencieux ;
- les sources d'import sont lues comme fichiers secrets privés et ne sont jamais loguées.
Sources vérifiées le **2026-08-10**.
## 2. Formats implémentés dans `0.5.2-pre.005`
| Format `ks-wallet` | Représentation | Import | Export | Cible de référence |
|--------------------------|-----------------------------------------------|:------:|:------:|---------------------------------------------|
| `SolanaCliJson` | tableau JSON contenant exactement 64 octets | oui | oui | Solana CLI / `solana-keygen` |
| `SolanaPrivateKeyBase58` | Base58 du keypair Solana complet de 64 octets | oui | oui | Phantom ; compatibilité Solflare documentée |
`SolanaPrivateKeyBase58` est volontairement un nom de format générique et non `PhantomPrivateKey` : le wire représente la keypair Solana, pas un conteneur propriétaire Phantom. Phantom sert de cible de référence parce que la documentation Solana identifie explicitement la private key copiée depuis Phantom comme **Base58**, et Phantom documente l'import d'une private key Solana. La référence officielle `@solana/web3.js` impose par ailleurs **64 octets** à `Keypair.fromSecretKey()` ; l'adaptateur `ks-wallet` encode donc exactement son keypair Solana complet de 64 octets en Base58 et refuse une seed brute de 32 octets.
Solflare documente de son côté le transfert d'une private key affichée par Phantom vers son propre écran `Import private key`. Cette compatibilité permet d'utiliser le même export Base58 sans créer un second codec de marque identique.
La version `pre.005` accepte uniquement la forme Base58 du **keypair complet de 64 octets**. Elle n'accepte pas une seed brute de 32 octets et ne dérive aucune keypair depuis une mnemonic.
## 3. Matrice des cibles étudiées
| Cible | Surface officielle observée | Encodage exploitable depuis une keypair arbitraire | Décision `0.5.2-pre.005` |
|-------------------------------|-------------------------------------------------------------------------|----------------------------------------------------|-----------------------------------------------------------------|
| Solana CLI / `solana-keygen` | fichier keypair JSON, tableau d'octets | oui, 64 octets | **implémenté import + export** |
| Phantom | import/export d'une private key Solana | oui, Base58 selon documentation Solana | **implémenté via `SolanaPrivateKeyBase58`** |
| Solflare | import d'une private key copiée depuis Phantom ; export de private key | oui par compatibilité documentée avec Phantom | même wire Base58 ; pas de codec de marque séparé |
| Solflare Keystore | import d'un fichier keystore protégé | format exact non suffisamment spécifié ici | TODO version ultérieure non déterminée |
| Backpack | import avancé par private key ou recovery phrase | représentation Solana exacte non spécifiée | TODO version ultérieure non déterminée |
| Trust Wallet | export de private keys ; restauration pouvant utiliser des private keys | représentation Solana exacte non spécifiée | TODO version ultérieure non déterminée |
| Base app / ex-Coinbase Wallet | restauration et backup principalement documentés par recovery phrase | pas de contrat arbitraire keypair -> même wallet | **aucune mnemonic synthétique ; pas d'adaptateur** |
| Coinbase Developer Platform | import Solana Base58 ou tableau brut 32 octets ; export API | oui, mais API de plateforme distincte de Base app | information de compatibilité ; pas un adaptateur wallet `0.5.2` |
## 4. Sources officielles
### Solana CLI JSON
La documentation Solana décrit le fichier keypair par défaut `~/.config/solana/id.json` comme un **JSON array of bytes**. Le format legacy déjà caractérisé dans `ks-wallet` fixe son wire actuel à 64 octets et `pre.005` conserve cette représentation en import/export.
- Solana — `Create a Token Account`: <https://solana.com/docs/tokens/extensions/confidential-transfer/create-token-account>
- Solana — `Deploying Programs`: <https://solana.com/docs/programs/deploying>
### Phantom / Base58
Phantom documente l'import d'une private key pour une adresse unique et l'export de la private key par réseau. Le template officiel Solana `ZK Compression Airdrop` précise que la private key obtenue par `Phantom -> Settings -> Show Private Key` est **Base58 encoded**.
- Phantom — `Sign in to or import an existing Phantom wallet`: <https://help.phantom.com/hc/en-us/articles/15079894392851-Sign-in-to-or-import-an-existing-Phantom-wallet>
- Phantom — `View your recovery phrase or private keys in Phantom`: <https://help.phantom.com/hc/en-us/articles/25334064171795-View-your-recovery-phrase-or-private-keys-in-Phantom>
- Solana — `ZK Compression Airdrop`: <https://solana.com/developers/templates/zk-compression-airdrop>
- Solana Foundation — `@solana/web3.js Keypair`: <https://solana-foundation.github.io/solana-web3.js/v1.x/classes/Keypair.html>
### Solflare
Solflare documente l'import d'une private key copiée depuis Phantom, ainsi que l'export de la private key d'un compte. Cette documentation établit la compatibilité du wire de private key entre les deux wallets, sans nécessiter un second format `ks-wallet`.
- Solflare — `How to Import a Wallet using a Private Key on Solflare`: <https://help.solflare.com/en/articles/6462529-how-to-import-a-wallet-using-a-private-key-on-solflare-desktop>
- Solflare — `How to Export Your Private Key on Solflare`: <https://help.solflare.com/en/articles/6150516-how-to-export-your-private-key-on-solflare-mobile>
- Solflare — `Importing a Keystore File into Your Solflare Wallet`: <https://help.solflare.com/en/articles/9351932-importing-a-keystore-file-into-your-solflare-wallet>
### Backpack
Backpack documente un import avancé par `Private key`, mais la page consultée ne fixe pas suffisamment l'encodage Solana du secret pour créer un codec testé sans hypothèse supplémentaire.
- Backpack — `Import/Recover Wallet`: <https://support.backpack.exchange/wallet/get-started/import-recover-wallet>
### Trust Wallet
Trust Wallet documente l'export de private keys et indique qu'une restauration peut utiliser des private keys stockées séparément. Les sources consultées ne fixent toutefois pas le wire Solana précis attendu par l'import utilisateur.
- Trust Wallet — `How to Backup Your Recovery Phrase and Export Private Keys in Trust Wallet`: <https://trustwallet.com/blog/product/how-to-backup-your-recovery-phrase-and-export-private-keys-in-trust-wallet>
- Trust Wallet — `Backing Up and Restoring Your Wallet: Step-by-Step Guide`: <https://trustwallet.com/blog/security/backing-up-and-restoring-your-wallet-step-by-step-guide>
### Base app et Coinbase Developer Platform
Base app documente principalement la recovery phrase pour backup/restauration. `ks-wallet` ne possède pas la mnemonic/seed d'origine d'une keypair arbitraire et ne doit donc pas en fabriquer une.
Coinbase Developer Platform possède un autre contrat : son API Solana accepte une private key Base58, explicitement citée comme pouvant venir de Phantom, ou un tableau brut de 32 octets. Ce contrat est utile pour confirmer l'existence du wire Base58 mais ne doit pas être confondu avec l'interface utilisateur Base app.
- Base / Coinbase Help — `Base wallet recovery phrase`: <https://help.coinbase.com/wallet/managing-account/wallet-recovery-phrase>
- Coinbase Developer Platform — `Import & Export`: <https://docs.cdp.coinbase.com/wallets/using-wallets/import-and-export>
## 5. Report après `0.5.2-pre.005`
Les adaptations suivantes sont volontairement reportées vers une version ultérieure non déterminée :
- vérifier et, si pertinent, implémenter le format exact `Private key` de Backpack pour Solana ;
- vérifier le wire d'import Solana de Trust Wallet avant tout codec ;
- caractériser le keystore Solflare uniquement si un format stable et suffisamment spécifié est disponible ;
- réévaluer Base app si une future documentation officielle expose un import de keypair arbitraire préservant directement la même pubkey ;
- ajouter d'autres wallets uniquement à partir d'un format privé explicitement documenté et testable.
Ces reports ne bloquent pas `0.5.2` : le format Solana CLI obligatoire et l'adaptateur tiers Base58 de référence sont couverts dans `pre.005`.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md -->
<!-- version: 7 -->
<!-- version: 8 -->
# Plan `0.5.2` — restructuration de `ks-wallet`
@@ -279,26 +279,28 @@ Pour chaque cible, la matrice doit distinguer :
- conservation garantie ou non de la même pubkey ;
- disponibilité d'une documentation/spécification officielle suffisamment précise pour écrire et tester l'adaptateur.
Constats documentaires initiaux au `2026-08-10` :
La matrice finalisée de `pre.005` se trouve dans [`docs/WALLET_FORMAT_COMPATIBILITY.md`](../WALLET_FORMAT_COMPATIBILITY.md). Les décisions retenues sont :
| Cible | Surface officiellement documentée | Statut pour `ks-wallet` |
|----------------------------|------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| Solana CLI | fichier keypair JSON standard | **obligatoire `0.5.2` en import + export** |
| Phantom | import/export de private key Solana et recovery phrase | candidat direct ; encodage exact à figer avant implémentation |
| Solflare | import par private key, recovery phrase et keystore | candidat direct ; format exact à figer avant implémentation |
| Backpack | import avancé par private key ou recovery phrase | candidat direct ; format exact à figer avant implémentation |
| Trust Wallet | restauration par recovery phrase et gestion/export de private keys | candidat à caractériser ; contrat Solana précis à vérifier |
| Coinbase Wallet / Base app | restauration principalement documentée par recovery phrase ; Solana supporté | **ne pas fabriquer une recovery phrase supposée équivalente** à partir d'une keypair arbitraire ; faisabilité à caractériser |
| Cible | Surface officiellement documentée | Décision `pre.005` |
|-------------------------------|-------------------------------------------------------------------------------|----------------------------------------------------------------|
| Solana CLI / `solana-keygen` | fichier keypair JSON standard | import + export `SolanaCliJson` |
| Phantom | import/export d'une private key Solana ; private key Base58 documentée Solana | adaptateur tiers de référence `SolanaPrivateKeyBase58` |
| Solflare | import d'une private key copiée depuis Phantom | compatible avec le même wire Base58 ; pas de codec séparé |
| Solflare Keystore | import de fichier keystore | TODO version ultérieure non déterminée |
| Backpack | import avancé par private key ou recovery phrase | TODO : wire Solana exact à caractériser |
| Trust Wallet | restauration/export de private keys | TODO : wire Solana exact à caractériser |
| Base app / ex-Coinbase Wallet | restauration principalement documentée par recovery phrase | aucun adaptateur ; aucune mnemonic synthétique |
| Coinbase Developer Platform | API d'import/export de clés Solana, distincte de Base app | information de compatibilité seulement, hors adaptateur wallet |
Une recovery phrase n'est pas interchangeable avec une keypair arbitraire. Si `ks-wallet` ne possède que la keypair finale et pas la mnemonic/seed d'origine avec son chemin de dérivation, il ne doit pas inventer une phrase qui prétend restaurer la même pubkey.
Dans `0.5.2`, après l'adaptateur Solana CLI obligatoire, **un seul adaptateur vers un wallet tiers** doit être implémenté comme exemple de la mécanique d'interopérabilité. Le choix doit aller au format le plus simple et le mieux spécifié au moment de `pre.005`. Les autres formats jugés faisables sont ajoutés au `TODO.md` pour une version ultérieure non déterminée.
Dans `0.5.2`, après l'adaptateur Solana CLI obligatoire, **un seul adaptateur vers un wallet tiers** est retenu comme exemple de la mécanique d'interopérabilité : `SolanaPrivateKeyBase58`, avec Phantom comme cible de référence documentée. Les autres formats jugés faisables sont ajoutés au `TODO.md` pour une version ultérieure non déterminée.
Tout adaptateur tiers reste soumis à la règle générale : un export contenant le secret est impossible sans validation du mot de passe du `.kswallet`.
## 7. Modèle public simple de `ks-wallet`
Le plan ne fixe pas encore les noms Rust définitifs, mais la responsabilité cible peut être représentée ainsi :
Les noms Rust principaux sont désormais stabilisés par les tranches exécutables ; la responsabilité peut être représentée ainsi :
```text
ks-wallet
@@ -523,15 +525,15 @@ La tranche `pre.003` reste structurelle ; le cycle de vie protégé est activé
### `0.5.2-pre.005` — migration, import et export
- import legacy/Solana CLI JSON vers `.kswallet` ;
- export `.kswallet` vers le format Solana CLI JSON ;
- rollback et conservation du legacy ;
- finalisation de la matrice des formats wallets tiers ;
- implémentation d'un adaptateur wallet tiers d'exemple, choisi selon simplicité et qualité de spécification ;
- report des autres adaptateurs faisables au TODO pour une version ultérieure non déterminée ;
- password obligatoire pour tout export secret ;
- collisions d'alias/pubkey ;
- tests de compatibilité.
- [x] import legacy/Solana CLI JSON vers `.kswallet` sans modification de la source ;
- [x] export `.kswallet` vers le format Solana CLI JSON après authentification du mot de passe ;
- [x] rollback d'une destination native nouvelle si la vérification post-publication échoue et conservation du legacy ;
- [x] matrice officielle finalisée dans `docs/WALLET_FORMAT_COMPATIBILITY.md` ;
- [x] adaptateur tiers `SolanaPrivateKeyBase58`, avec Phantom comme cible de référence et compatibilité Phantom -> Solflare documentée ;
- [x] report Backpack/Trust/Solflare Keystore/Base app au TODO pour une version ultérieure non déterminée ;
- [x] password obligatoire pour tout export secret ;
- [x] collisions d'alias et de pubkey refusées sans écrasement ;
- [x] tests de round-trip Solana CLI JSON/Base58, migration non destructive, permissions privées, collision et mauvais mot de passe.
### `0.5.2-pre.006` — configuration et consommateurs
@@ -607,8 +609,8 @@ Le plan est désormais centré sur les décisions suivantes :
12. la découverte persistante scanne le répertoire wallet résolu pour les `<alias>.kswallet`, sans faire confiance au seul nom de fichier ;
13. changer le password rechiffre la même keypair ; il n'existe pas de rotation du secret Ed25519 conservant la même pubkey ;
14. le format keypair JSON des binaires Solana est obligatoire en import et en export dans `0.5.2` ;
15. une matrice officielle des formats Phantom/Solflare/Backpack/Trust/Coinbase-Base et autres cibles pertinentes sera finalisée avant les adaptateurs ;
16. un seul adaptateur wallet tiers est implémenté dans `0.5.2` comme exemple, les autres formats faisables étant reportés au TODO sans version déterminée ;
15. la matrice officielle des formats Phantom/Solflare/Backpack/Trust/Coinbase-Base est finalisée dans `docs/WALLET_FORMAT_COMPATIBILITY.md` ;
16. `SolanaPrivateKeyBase58` est l'adaptateur tiers unique de `0.5.2`, avec Phantom comme référence ; les autres formats faisables sont reportés au TODO sans version déterminée ;
17. le format v1 utilise Argon2id v19 et XChaCha20-Poly1305 selon `docs/NATIVE_FORMAT.md`, et `pre.004` ferme le modèle runtime avec `WalletPassword` + `UnlockedWallet` ;
18. le scan automatique reste limité au répertoire configuré, tandis qu'un consommateur peut fournir explicitement un autre chemin `.kswallet` à `ks-wallet` sans modifier le store ni enregistrer ce fichier automatiquement.

View File

@@ -1,14 +1,25 @@
<!-- file: ks-wallet/CHANGELOG.md -->
<!-- version: 16 -->
<!-- version: 18 -->
# CHANGELOG — ks-wallet
### `pre.004-delta-fix-001`
## `0.5.2-pre.005`
- aligne les features workspace de `argon2`, `chacha20poly1305` et `getrandom` sur les features réellement exposées par les versions résolues et validées par Cargo ;
- conserve `argon2` avec `std` et `zeroize`, `chacha20poly1305` avec `getrandom`, `rand_core` et `zeroize`, et `getrandom` avec `std` et `sys_rng` ;
- corrige le dernier warning Clippy de `WalletPassword::new()` en utilisant directement `String::len()`, qui conserve la même sémantique de longueur en octets UTF-8 ;
- ne modifie aucun contrat `.kswallet`, paramètre KDF/AEAD, cycle de vie password ou API publique.
- ajoute `WalletTransferFormat` avec `SolanaCliJson` et `SolanaPrivateKeyBase58` comme formats secrets explicites ;
- ajoute `WalletManager::migrate_legacy()` pour convertir `<alias>.json` vers `<alias>.kswallet` sans modifier ni supprimer la source legacy ;
- ajoute `WalletManager::import_file()` pour importer un keypair Solana depuis un fichier privé JSON CLI ou Base58 vers un nouveau conteneur natif protégé par mot de passe ;
- ajoute `WalletManager::export_file()` et impose l'authentification du mot de passe du `.kswallet` avant toute création d'un export secret ;
- refuse l'écrasement d'une destination native/export existante et les collisions de pubkey sous un autre alias natif ;
- publie les exports secrets atomiquement en fichier privé `0600` sous Unix, sans imposer que leur répertoire parent appartienne au store wallet configuré ;
- utilise un buffer crate-private zéroïsé pour l'unique copie des 64 octets du keypair nécessaire à un export secret, sans ajouter de getter public ;
- documente Phantom comme cible de référence du Base58 et la compatibilité Phantom -> Solflare, puis reporte Backpack, Trust Wallet, Solflare Keystore et Base app au TODO tant que leur contrat Solana exact n'est pas suffisamment spécifié ;
- ajoute `docs/WALLET_FORMAT_COMPATIBILITY.md` et des tests de migration non destructive, round-trip JSON/Base58, permissions, collision de pubkey et mauvais mot de passe.
## `pre.004-delta-fix-001`
- conserve les features workspace `argon2`, `chacha20poly1305` et `getrandom` validées par Cargo ;
- corrige le dernier warning Clippy de `WalletPassword::new()` en utilisant directement `String::len()` ;
- ne modifie aucun contrat fonctionnel ni wire `.kswallet`.
## `0.5.2-pre.004`
@@ -24,6 +35,25 @@
- ajoute `argon2`, `chacha20poly1305` et `getrandom` comme dépendances directes réellement utilisées par `ks-wallet`, et active `zeroize` sur `chacha20poly1305` au niveau workspace ;
- ajoute des tests externes de création, permissions privées, refus d'écrasement, signature, réouverture, ouverture externe, changement de mot de passe, rejet de l'ancien mot de passe, altération de l'AAD et canaris de non-divulgation.
## `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.
## `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-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é.
## `0.5.2-pre.003`
- fixe et documente dans `docs/NATIVE_FORMAT.md` le layout binaire strict `.kswallet` version `1` ;
@@ -37,25 +67,6 @@
- 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 ;
@@ -69,6 +80,21 @@
- ajoute `tests/public_api.rs` pour figer la façade crate-root multi-wallet et l'absence de chemin dans le `Debug` du handle ;
- ne crée encore aucun `.kswallet` réel et n'introduit aucun mot de passe ou chiffrement persistant.
## `pre.001-delta-fix-002`
- précise que `ks-wallet` découvrira les wallets persistants par scan borné des `<alias>.kswallet` dans son répertoire résolu, sans faire confiance au seul nom de fichier ;
- distingue explicitement changement de password et rotation de keypair : le password rechiffre la même keypair, tandis quun nouveau secret Ed25519 implique une nouvelle pubkey ;
- rend obligatoires limport **et** lexport du format keypair JSON standard utilisé par les binaires Solana ;
- ajoute une matrice de compatibilité à établir pour Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et les autres wallets Solana techniquement documentés ;
- borne `0.5.2` à un adaptateur wallet tiers dexemple en plus du format Solana CLI obligatoire, avec report des autres adaptateurs faisables au TODO pour une version ultérieure non déterminée ;
- interdit de synthétiser une recovery phrase supposée restaurer une keypair arbitraire lorsque la mnemonic/seed dorigine nest pas disponible.
## `pre.001-delta-fix-001`
- corrige la version Cargo de `0.5.2-pre.001` vers le SemVer valide `0.5.2-pre.1` ;
- simplifie profondément le plan initial, retire lexclusion erronée de lexport privé contrôlé et supprime lhypothèse dun alias principal universel par profil ;
- formalise lextension native `.kswallet`, le wallet temporaire et lobligation de password pour tout export secret.
## `0.5.2-pre.001`
- caractérise précisément le format legacy `<alias>.json`, ses permissions Unix, ses erreurs, sa publication directe dans le chemin final et ses limites datomicité/TOCTOU ;
@@ -80,21 +106,6 @@
- corrige README/USAGE/TODO afin daligner les objectifs sur ce contrat simplifié ;
- ne modifie aucune API runtime, aucun fichier wallet et nintroduit encore aucun nouveau format persistant.
### `pre.001-delta-fix-001`
- corrige la version Cargo de `0.5.2-pre.001` vers le SemVer valide `0.5.2-pre.1` ;
- simplifie profondément le plan initial, retire lexclusion erronée de lexport privé contrôlé et supprime lhypothèse dun alias principal universel par profil ;
- formalise lextension native `.kswallet`, le wallet temporaire et lobligation de password pour tout export secret.
### `pre.001-delta-fix-002`
- précise que `ks-wallet` découvrira les wallets persistants par scan borné des `<alias>.kswallet` dans son répertoire résolu, sans faire confiance au seul nom de fichier ;
- distingue explicitement changement de password et rotation de keypair : le password rechiffre la même keypair, tandis quun nouveau secret Ed25519 implique une nouvelle pubkey ;
- rend obligatoires limport **et** lexport du format keypair JSON standard utilisé par les binaires Solana ;
- ajoute une matrice de compatibilité à établir pour Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et les autres wallets Solana techniquement documentés ;
- borne `0.5.2` à un adaptateur wallet tiers dexemple en plus du format Solana CLI obligatoire, avec report des autres adaptateurs faisables au TODO pour une version ultérieure non déterminée ;
- interdit de synthétiser une recovery phrase supposée restaurer une keypair arbitraire lorsque la mnemonic/seed dorigine nest pas disponible.
## `0.5.1-pre.002`
- renomme `kb-wallet` en `ks-wallet` et `kb_wallet` en `ks_wallet` ;

View File

@@ -1,5 +1,5 @@
# file: ks-wallet/Cargo.toml
# version: 5
# version: 6
[package]
name = "ks-wallet"
@@ -10,6 +10,7 @@ publish.workspace = true
[dependencies]
argon2.workspace = true
bs58.workspace = true
chacha20poly1305.workspace = true
getrandom.workspace = true
ks-core = { path = "../ks-core" }

View File

@@ -1,5 +1,5 @@
<!-- file: ks-wallet/README.md -->
<!-- version: 10 -->
<!-- version: 11 -->
# ks-wallet
@@ -7,7 +7,7 @@
## État actuel
En `0.5.2-pre.004`, la crate sait créer, publier, ouvrir et reprotéger un wallet natif `.kswallet` v1 avec un mot de passe. La dérivation Argon2id et le chiffrement authentifié XChaCha20-Poly1305 sont exécutés dans une tâche bloquante dédiée ; le legacy Solana JSON reste disponible pendant la transition et sa migration/importation est réservée à `pre.005`.
En `0.5.2-pre.005`, la crate sait en plus migrer sans destruction un legacy Solana JSON, importer/exporter le format Solana CLI JSON et transférer un keypair complet via le format privé Base58 utilisé comme adaptateur tiers de référence pour Phantom. La dérivation Argon2id et le chiffrement authentifié XChaCha20-Poly1305 restent la protection du conteneur natif `.kswallet`.
La crate fournit actuellement :
@@ -20,6 +20,11 @@ La crate fournit actuellement :
- ouverture authentifiée par alias avec `WalletManager::unlock()` ;
- ouverture d'un fichier explicitement sélectionné avec `WalletManager::unlock_file()` sans l'enregistrer dans le store ;
- changement du mot de passe avec `WalletManager::change_password()` en conservant exactement la même keypair/pubkey ;
- migration non destructive de `<alias>.json` vers `<alias>.kswallet` avec `WalletManager::migrate_legacy()` ;
- import de fichiers secrets avec `WalletManager::import_file()` en `SolanaCliJson` ou `SolanaPrivateKeyBase58` ;
- export de fichiers secrets avec `WalletManager::export_file()`, uniquement après authentification du mot de passe du `.kswallet` ;
- refus des collisions dalias et de pubkey lors dun import ;
- publication dexport privée, atomique et sans écrasement silencieux ;
- `WalletPassword`, frontière possédée, non clonable et redacted pour une opération ;
- `UnlockedWallet`, capacité de signature authentifiée non clonable et sans getter des bytes privés ;
- wallet temporaire en mémoire ;
@@ -64,6 +69,7 @@ Une application desktop doit projeter les informations autorisées dans ses prop
## Documentation
- [Format natif `.kswallet`](../docs/NATIVE_FORMAT.md)
- [Compatibilité import/export des wallets Solana](../docs/WALLET_FORMAT_COMPATIBILITY.md)
- [USAGE.md](USAGE.md)
- [TODO.md](TODO.md)
- [CHANGELOG.md](CHANGELOG.md)

View File

@@ -1,5 +1,5 @@
<!-- file: ks-wallet/TODO.md -->
<!-- version: 11 -->
<!-- version: 12 -->
# TODO — ks-wallet
@@ -17,16 +17,24 @@
- [x] 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`.
- [x] fournir cette même capacité depuis un wallet natif ouvert par mot de passe.
- [ ] importer le legacy Solana JSON vers `.kswallet` avec écriture atomique, rollback et vérification de pubkey.
- [ ] importer et exporter le format keypair JSON standard des binaires Solana.
- [ ] produire une matrice documentée des formats Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et autres wallets Solana pertinents.
- [ ] implémenter dans `0.5.2` un seul adaptateur wallet tiers d'exemple, choisi selon simplicité et qualité de la spécification officielle.
- [ ] reporter les autres adaptateurs tiers faisables vers une version ultérieure non déterminée après validation de la matrice.
- [ ] ne jamais synthétiser une recovery phrase supposée préserver une keypair arbitraire sans mnemonic/seed d'origine.
- [ ] exiger le mot de passe valide pour tout export contenant le secret.
- [ ] définir les collisions d'alias et de pubkey sans écrasement silencieux.
- [x] importer le legacy Solana JSON vers `.kswallet` avec écriture atomique, rollback et vérification de pubkey.
- [x] importer et exporter le format keypair JSON standard des binaires Solana.
- [x] produire une matrice documentée des formats Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et autres wallets Solana pertinents.
- [x] implémenter dans `0.5.2` un seul adaptateur wallet tiers d'exemple : Base58 du keypair Solana complet, avec Phantom comme cible de référence documentée.
- [x] reporter les autres adaptateurs tiers faisables vers une version ultérieure non déterminée après validation de la matrice.
- [x] ne jamais synthétiser une recovery phrase supposée préserver une keypair arbitraire sans mnemonic/seed d'origine.
- [x] exiger le mot de passe valide pour tout export contenant le secret.
- [x] définir les collisions d'alias et de pubkey sans écrasement silencieux.
- [ ] 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.
- [ ] compléter les tests de permissions privées, atomicité, corruption authentifiée, concurrence réellement utilisée et non-divulgation lors des tranches migration/import-export ; la tranche password couvre déjà création, réouverture, changement de mot de passe, AAD altéré et canaris de mot de passe.
- [x] compléter les tests `pre.005` de migration non destructive, round-trip Solana CLI JSON/Base58, permissions privées, refus d'écrasement, collision de pubkey et export refusé avec mauvais mot de passe ; les tests de concurrence restent à réconcilier dans la finalisation selon les surfaces réellement utilisées.
- [ ] produire le guide de sécurité et la documentation finale avant clôture de `0.5.2`.
## Version ultérieure non déterminée — adaptateurs de transfert
- [ ] caractériser le wire Solana exact accepté par l'import `Private key` de Backpack avant d'ajouter un codec de marque ou un alias de format.
- [ ] caractériser le wire d'import Solana de Trust Wallet depuis une documentation suffisamment précise avant implémentation.
- [ ] caractériser le format keystore Solflare et son mot de passe uniquement si son conteneur public est suffisamment stable et spécifié pour un round-trip testé.
- [ ] réévaluer Base app / ex-Coinbase Wallet si une documentation officielle expose un import direct de keypair Solana arbitraire ; ne jamais synthétiser une recovery phrase.
- [ ] ajouter d'autres adaptateurs wallets uniquement à partir d'un format secret officiellement documenté, strictement validable et testable.

View File

@@ -1,11 +1,11 @@
<!-- file: ks-wallet/USAGE.md -->
<!-- version: 10 -->
<!-- version: 11 -->
# Utilisation de ks-wallet
## Statut
En `0.5.2-pre.004`, le manager gère le premier cycle de vie natif complet : création `.kswallet`, ouverture authentifiée, ouverture d'un fichier explicitement sélectionné et changement de mot de passe. La migration legacy et l'import/export restent pour `pre.005`.
En `0.5.2-pre.005`, le manager couvre aussi la migration non destructive du legacy, l'import/export du keypair JSON Solana CLI et le transfert Base58 d'une keypair Solana complète. Tout export secret repart d'un `.kswallet` authentifié avec son mot de passe.
## Valider un alias
@@ -308,6 +308,107 @@ println!("pubkey={}", handle.public_key());
`change_password()` n'altère pas la keypair : il authentifie l'ancien conteneur, rechiffre exactement le même matériau avec un nouveau sel/nonce et remplace atomiquement le `.kswallet`. La pubkey reste identique. Cette opération ne peut pas révoquer rétroactivement une `UnlockedWallet` déjà remise à un consommateur ; cette capacité doit être `lock()`/dropée par son propriétaire.
## Migration et import/export secrets
`0.5.2-pre.005` expose deux formats de transfert explicites :
- `WalletTransferFormat::SolanaCliJson` : tableau JSON standard des 64 octets du keypair Solana ;
- `WalletTransferFormat::SolanaPrivateKeyBase58` : Base58 du keypair Solana complet de 64 octets, utilisé comme adaptateur tiers de référence pour Phantom et compatible avec le flux Phantom -> Solflare documenté.
La matrice des formats vérifiés et reportés est maintenue dans [`../docs/WALLET_FORMAT_COMPATIBILITY.md`](../docs/WALLET_FORMAT_COMPATIBILITY.md).
### Migrer un legacy `<alias>.json`
```rust
let password = match ks_wallet::WalletPassword::new(password_string) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
let wallet = match manager.migrate_legacy(alias.clone(), password).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
println!("pubkey={}", wallet.public_key());
```
`migrate_legacy()` lit `<store>/<alias>.json`, crée `<store>/<alias>.kswallet`, vérifie la pubkey et **ne modifie ni ne supprime le legacy**. Une destination native déjà existante est refusée. Le `.json` reste donc disponible pour rollback jusqu'à suppression explicite par l'opérateur.
### Importer un fichier Solana CLI JSON
```rust
let alias = match ks_wallet::WalletAlias::parse("imported-cli") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
let password = match ks_wallet::WalletPassword::new(password_string) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
let wallet = match manager
.import_file(
alias,
password,
source_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
```
Une source d'import secret doit être un fichier régulier non symlink et privé sous Unix. L'import refuse les collisions de destination et les pubkeys déjà présentes sous un autre alias natif.
### Importer une private key Base58
Le même appel utilise :
```rust
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58
```
La forme acceptée est strictement le Base58 du **keypair Solana complet de 64 octets**. Une seed de 32 octets ou une recovery phrase n'est pas interprétée implicitement.
### Exporter un secret
```rust
let password = match ks_wallet::WalletPassword::new(password_string) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
match manager
.export_file(
&alias,
password,
destination_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
{
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
}
```
L'export secret **exige toujours le mot de passe valide du `.kswallet`**. La destination n'est créée qu'après authentification, n'est jamais écrasée silencieusement et est publiée en fichier privé (`0600` sous Unix). Le répertoire parent peut être un répertoire explicitement choisi par le consommateur, par exemple via un file browser ; il n'est pas assimilé au store wallet configuré.
Pour produire le Base58 tiers, remplacer le format par `WalletTransferFormat::SolanaPrivateKeyBase58`.
## Format binaire v1
Le layout exact, les bornes et la politique de publication sont documentés dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). `pre.004` implémente désormais l'encodage, la lecture, la protection et la publication. Le payload secret v1 est strictement borné à une keypair Solana de 64 octets et produit un ciphertext/tag de 80 octets.
@@ -342,15 +443,13 @@ Le changement de mot de passe rechiffre la même keypair. Il n'existe pas de rot
Le modèle runtime ouvert est `UnlockedWallet`, capacité non clonable possédant le signer authentifié. `WalletManager`, `WalletFileHandle`, `WalletIdentity`, `WalletPersistence`, `WalletPassword` et `UnlockedWallet` font désormais partie de l'API de base.
## Import/export cible
## Import/export implémenté
`ks-wallet` doit obligatoirement importer **et exporter** le format keypair JSON standard des binaires Solana, en plus de son import legacy qui correspond actuellement à ce même wire format. Il doit aussi pouvoir convertir vers/depuis les autres formats explicitement supportés.
`ks-wallet` importe et exporte désormais le format keypair JSON standard des binaires Solana ainsi que le Base58 du keypair complet de 64 octets. La migration legacy réutilise explicitement le codec `SolanaCliJson`.
Un export public peut exposer les données autorisées telles que l'alias et la pubkey.
Un export public peut toujours exposer uniquement les données autorisées telles que l'alias et la pubkey. **Tout export contenant ou permettant de reconstruire la clé privée demande et valide le mot de passe du wallet.**
**Tout export contenant ou permettant de reconstruire la clé privée doit obligatoirement demander et valider le mot de passe du wallet.**
L'export vers Solana CLI utilise son tableau JSON de 64 octets. Pour Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et les autres wallets examinés, une matrice de compatibilité doit d'abord confirmer le contrat exact depuis des sources officielles. Un seul adaptateur wallet tiers sera implémenté dans `0.5.2` à titre d'exemple ; les autres formats techniquement faisables seront reportés au TODO. Aucun format universel externe n'est supposé, et une recovery phrase ne doit jamais être synthétisée en prétendant représenter une keypair arbitraire si la seed/mnemonic d'origine n'est pas disponible.
Phantom est la cible tierce de référence du Base58 et Solflare documente l'import direct de cette private key depuis Phantom. Backpack, Trust Wallet, le keystore Solflare et Base app restent reportés tant qu'un contrat Solana précis et testable n'est pas suffisamment documenté pour la surface considérée. Aucune recovery phrase n'est synthétisée à partir d'une keypair arbitraire.
## Invariants
@@ -373,4 +472,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.003` ajoute le décodage/validation v1 stricts et les bornes KDF/fichier. `pre.004` ajoute l'encodage, la publication atomique, le password, la dérivation/chiffrement effectifs, l'ouverture authentifiée, le changement de password et les tests de non-divulgation associés. Les tranches suivantes couvrent migration et import/export puis l'intégration des consommateurs.
`pre.003` ajoute le décodage/validation v1 stricts et les bornes KDF/fichier. `pre.004` ajoute l'encodage, la publication atomique, le password, la dérivation/chiffrement effectifs, l'ouverture authentifiée et le changement de password. `pre.005` ajoute la migration legacy, les deux formats de transfert testés, les collisions d'alias/pubkey et la matrice de compatibilité. La tranche suivante couvre la configuration et les consommateurs.

View File

@@ -1,5 +1,5 @@
// file: ks-wallet/src/lib.rs
// version: 7
// version: 8
//! Wallet boundary for local key storage and transaction signing.
#![warn(missing_docs)]
@@ -10,6 +10,7 @@ mod constants;
mod manager;
mod native;
mod password;
mod transfer;
mod unlocked;
mod wallet;
@@ -21,6 +22,8 @@ pub use self::manager::WalletFileHandle;
pub use self::manager::WalletManager;
/// Explicit non-clonable password supplied to native wallet operations.
pub use self::password::WalletPassword;
/// Explicit secret import/export format supported by the wallet boundary.
pub use self::transfer::WalletTransferFormat;
/// Authenticated signing capability for one unlocked persistent wallet.
pub use self::unlocked::UnlockedWallet;
/// Solana keypair kept private inside the wallet boundary.

665
ks-wallet/src/transfer.rs Normal file
View File

@@ -0,0 +1,665 @@
// file: ks-wallet/src/transfer.rs
// version: 1
//! Explicit migration and secret import/export adapters.
use solana_signer::Signer; // rust-rules: trait-import
use tokio::io::AsyncReadExt; // rust-rules: trait-import
use zeroize::Zeroize; // rust-rules: trait-import
const MAX_SOLANA_CLI_JSON_LENGTH: u64 = 1_024;
const MAX_SOLANA_PRIVATE_KEY_BASE58_LENGTH: u64 = 128;
const SECRET_EXPORT_TEMP_ATTEMPTS: u64 = 16;
static SECRET_EXPORT_TEMP_FILE_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1);
/// Explicit secret transfer formats supported by `ks-wallet`.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WalletTransferFormat {
/// Standard Solana CLI keypair JSON array containing exactly 64 bytes.
SolanaCliJson,
/// Base58 encoding of the complete 64-byte Solana keypair used by wallet private-key imports.
SolanaPrivateKeyBase58,
}
impl crate::WalletManager {
/// Imports one secret file into a new password-protected native wallet.
///
/// The source file is never modified. The destination alias and public key
/// must both be unique among native wallets already present in the configured
/// store. The supplied password protects only the newly created `.kswallet`.
pub async fn import_file(
&self,
alias: crate::WalletAlias,
password: crate::WalletPassword,
source_path: impl std::convert::AsRef<std::path::Path>,
format: crate::WalletTransferFormat,
) -> ks_core::Result<crate::UnlockedWallet> {
let source_path = source_path.as_ref().to_path_buf();
let destination = native_path(self, &alias);
match ensure_native_alias_available(&destination).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let keypair = match read_transfer_keypair(&source_path, format).await {
std::result::Result::Ok(keypair) => keypair,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
match ensure_public_key_available(self, &alias, &keypair.pubkey().to_string()).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return persist_imported_keypair(self, alias, password, keypair, format, destination).await;
}
/// Migrates the configured legacy `<alias>.json` file into a new `.kswallet`.
///
/// Migration is non-destructive: the legacy source remains byte-for-byte
/// untouched so it can be used for rollback until the operator explicitly
/// removes it after validation.
pub async fn migrate_legacy(
&self,
alias: crate::WalletAlias,
password: crate::WalletPassword,
) -> ks_core::Result<crate::UnlockedWallet> {
let source_path = self.directory().join(format!("{}.json", alias.as_str()));
let destination = native_path(self, &alias);
match ensure_native_alias_available(&destination).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let keypair =
match read_transfer_keypair(&source_path, crate::WalletTransferFormat::SolanaCliJson)
.await
{
std::result::Result::Ok(keypair) => keypair,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
match ensure_public_key_available(self, &alias, &keypair.pubkey().to_string()).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let public_key = keypair.pubkey();
let wallet = match persist_imported_keypair(
self,
alias.clone(),
password,
keypair,
crate::WalletTransferFormat::SolanaCliJson,
destination,
)
.await
{
std::result::Result::Ok(wallet) => wallet,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
tracing::info!(
target: crate::TRACING_TARGET,
action = "migrate_legacy_wallet",
wallet_alias = alias.as_str(),
public_key = %public_key,
"migrated legacy Solana JSON wallet without modifying the source"
);
return std::result::Result::Ok(wallet);
}
/// Exports one native wallet secret to an explicit external file format.
///
/// A valid native-wallet password is mandatory before any secret bytes are
/// encoded or any destination file is created. Export refuses to overwrite an
/// existing destination and creates a private file (`0600` on Unix).
pub async fn export_file(
&self,
alias: &crate::WalletAlias,
password: crate::WalletPassword,
destination_path: impl std::convert::AsRef<std::path::Path>,
format: crate::WalletTransferFormat,
) -> ks_core::Result<()> {
let destination_path = destination_path.as_ref().to_path_buf();
let wallet = match self.unlock(alias, password).await {
std::result::Result::Ok(wallet) => wallet,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let public_key = wallet.public_key();
let keypair_bytes = wallet.keypair_bytes();
let encoded = match encode_transfer_keypair(keypair_bytes, format) {
std::result::Result::Ok(encoded) => encoded,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
match write_secret_export_file_atomic(destination_path, encoded).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "export_wallet_secret",
wallet_alias = alias.as_str(),
public_key = public_key.as_str(),
transfer_format = transfer_format_code(format),
"exported wallet secret after password authentication"
);
return std::result::Result::Ok(());
}
}
async fn persist_imported_keypair(
manager: &crate::WalletManager,
alias: crate::WalletAlias,
password: crate::WalletPassword,
keypair: solana_keypair::Keypair,
format: crate::WalletTransferFormat,
destination: std::path::PathBuf,
) -> ks_core::Result<crate::UnlockedWallet> {
let public_key = keypair.pubkey();
let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes());
let container = match crate::protect_native_wallet_keypair(
alias.clone(),
public_key,
keypair_bytes,
password,
)
.await
{
std::result::Result::Ok(container) => container,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
match crate::write_native_wallet_file_atomic(destination.clone(), &container).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let handle = match manager.inspect_file(&destination).await {
std::result::Result::Ok(handle) => handle,
std::result::Result::Err(error) => {
return rollback_failed_import(destination, error).await;
},
};
if handle.alias() != &alias || handle.public_key() != public_key.to_string() {
return rollback_failed_import(
destination,
ks_core::Error::new(
"wallet_import_verification_failed",
"imported native wallet identity does not match the source keypair",
),
)
.await;
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "import_wallet_secret",
wallet_alias = alias.as_str(),
public_key = %public_key,
transfer_format = transfer_format_code(format),
"imported wallet secret into native protected storage"
);
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
}
async fn rollback_failed_import<T>(
destination: std::path::PathBuf,
original_error: ks_core::Error,
) -> ks_core::Result<T> {
return match tokio::fs::remove_file(destination).await {
std::result::Result::Ok(()) => std::result::Result::Err(original_error),
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_import_rollback_failed",
"import verification failed and the new native destination could not be removed",
)),
};
}
async fn ensure_native_alias_available(path: &std::path::Path) -> ks_core::Result<()> {
return match tokio::fs::try_exists(path).await {
std::result::Result::Ok(false) => std::result::Result::Ok(()),
std::result::Result::Ok(true) => std::result::Result::Err(ks_core::Error::new(
"wallet_native_already_exists",
"native wallet already exists for this alias",
)),
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
"wallet_file_exists_check_failed",
error.to_string(),
)),
};
}
async fn ensure_public_key_available(
manager: &crate::WalletManager,
alias: &crate::WalletAlias,
public_key: &str,
) -> ks_core::Result<()> {
let wallets = match manager.scan().await {
std::result::Result::Ok(wallets) => wallets,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for wallet in wallets {
if wallet.public_key() == public_key && wallet.alias() != alias {
return std::result::Result::Err(ks_core::Error::new(
"wallet_public_key_already_exists",
"native wallet store already contains this public key under another alias",
));
}
}
return std::result::Result::Ok(());
}
async fn read_transfer_keypair(
path: &std::path::Path,
format: crate::WalletTransferFormat,
) -> ks_core::Result<solana_keypair::Keypair> {
let maximum_length = match format {
crate::WalletTransferFormat::SolanaCliJson => MAX_SOLANA_CLI_JSON_LENGTH,
crate::WalletTransferFormat::SolanaPrivateKeyBase58 => MAX_SOLANA_PRIVATE_KEY_BASE58_LENGTH,
};
let mut encoded = match read_private_source_file(path, maximum_length).await {
std::result::Result::Ok(encoded) => encoded,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = match format {
crate::WalletTransferFormat::SolanaCliJson => decode_solana_cli_json(encoded.as_slice()),
crate::WalletTransferFormat::SolanaPrivateKeyBase58 => {
decode_solana_private_key_base58(encoded.as_slice())
},
};
encoded.zeroize();
return result;
}
async fn read_private_source_file(
path: &std::path::Path,
maximum_length: u64,
) -> ks_core::Result<std::vec::Vec<u8>> {
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(ks_core::Error::new(
"wallet_import_source_metadata_failed",
error.to_string(),
));
},
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_type_invalid",
"wallet import source 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_import_source_permissions_too_open",
format!(
"wallet import source has mode {mode:o}; expected no group or other access"
),
));
}
}
let file_length = metadata.len();
if file_length == 0 || file_length > maximum_length {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_length_invalid",
"wallet import source length is outside the accepted bound",
));
}
let allocation_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_import_source_length_invalid",
"wallet import source length exceeds the platform address space",
));
},
};
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_import_source_open_failed",
error.to_string(),
));
},
};
let opened_metadata = match file.metadata().await {
std::result::Result::Ok(opened_metadata) => opened_metadata,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_metadata_failed",
error.to_string(),
));
},
};
if !opened_metadata.is_file() || opened_metadata.len() != file_length {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_changed",
"wallet import source changed before it could be read",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt; // rust-rules: trait-import
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
if opened_metadata.dev() != metadata.dev() || opened_metadata.ino() != metadata.ino() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_changed",
"wallet import source changed before it could be read",
));
}
let opened_mode = opened_metadata.permissions().mode() & 0o777;
if opened_mode & 0o077 != 0 {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_permissions_too_open",
format!(
"wallet import source has mode {opened_mode:o}; expected no group or other access"
),
));
}
}
let mut bytes = vec![0_u8; allocation_length];
if let std::result::Result::Err(error) = file.read_exact(bytes.as_mut_slice()).await {
bytes.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_read_failed",
error.to_string(),
));
}
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_import_source_length_changed",
"wallet import source changed while it was being read",
));
},
std::result::Result::Err(error) => {
bytes.zeroize();
trailing.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_source_read_failed",
error.to_string(),
));
},
}
trailing.zeroize();
return std::result::Result::Ok(bytes);
}
fn decode_solana_cli_json(bytes: &[u8]) -> ks_core::Result<solana_keypair::Keypair> {
let mut parsed = match serde_json::from_slice::<std::vec::Vec<u8>>(bytes) {
std::result::Result::Ok(parsed) => parsed,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_solana_json_invalid",
"Solana CLI keypair JSON is invalid",
));
},
};
if parsed.len() != crate::SOLANA_KEYPAIR_LENGTH {
parsed.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_keypair_length_invalid",
"wallet import must contain exactly 64 keypair bytes",
));
}
let keypair = solana_keypair::Keypair::try_from(parsed.as_slice());
parsed.zeroize();
return match keypair {
std::result::Result::Ok(keypair) => std::result::Result::Ok(keypair),
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_import_keypair_invalid",
"wallet import does not contain a valid Solana keypair",
)),
};
}
fn decode_solana_private_key_base58(bytes: &[u8]) -> ks_core::Result<solana_keypair::Keypair> {
let text = match std::str::from_utf8(bytes) {
std::result::Result::Ok(text) => text.trim(),
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_base58_encoding_invalid",
"Solana private key Base58 source must be UTF-8 text",
));
},
};
if text.is_empty() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_base58_empty",
"Solana private key Base58 source must not be empty",
));
}
let mut keypair_bytes = match bs58::decode(text).into_vec() {
std::result::Result::Ok(keypair_bytes) => keypair_bytes,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_base58_invalid",
"Solana private key Base58 source is invalid",
));
},
};
if keypair_bytes.len() != crate::SOLANA_KEYPAIR_LENGTH {
keypair_bytes.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_import_keypair_length_invalid",
"wallet import must decode to exactly 64 keypair bytes",
));
}
let keypair = solana_keypair::Keypair::try_from(keypair_bytes.as_slice());
keypair_bytes.zeroize();
return match keypair {
std::result::Result::Ok(keypair) => std::result::Result::Ok(keypair),
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_import_keypair_invalid",
"wallet import does not contain a valid Solana keypair",
)),
};
}
fn encode_transfer_keypair(
keypair_bytes: zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]>,
format: crate::WalletTransferFormat,
) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
return match format {
crate::WalletTransferFormat::SolanaCliJson => {
match serde_json::to_vec(keypair_bytes.as_slice()) {
std::result::Result::Ok(encoded) => {
std::result::Result::Ok(zeroize::Zeroizing::new(encoded))
},
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_export_solana_json_failed",
"Solana CLI keypair JSON could not be encoded",
)),
}
},
crate::WalletTransferFormat::SolanaPrivateKeyBase58 => {
let encoded = bs58::encode(keypair_bytes.as_slice()).into_string();
std::result::Result::Ok(zeroize::Zeroizing::new(encoded.into_bytes()))
},
};
}
async fn write_secret_export_file_atomic(
path: std::path::PathBuf,
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
) -> ks_core::Result<()> {
let task = tokio::task::spawn_blocking(move || {
return write_secret_export_file_atomic_blocking(path, bytes);
});
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_export_task_failed",
"wallet secret export task failed",
)),
};
}
fn write_secret_export_file_atomic_blocking(
path: std::path::PathBuf,
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
) -> ks_core::Result<()> {
let directory = match path.parent() {
std::option::Option::Some(directory) if !directory.as_os_str().is_empty() => directory,
std::option::Option::Some(_) => std::path::Path::new("."),
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_parent_missing",
"wallet export destination must have a parent directory",
));
},
};
let metadata = match std::fs::symlink_metadata(directory) {
std::result::Result::Ok(metadata) => metadata,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_directory_metadata_failed",
error.to_string(),
));
},
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_directory_type_invalid",
"wallet export parent must be a directory and not a symlink",
));
}
if path.exists() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_already_exists",
"wallet export destination already exists",
));
}
let file_name = match path.file_name().and_then(std::ffi::OsStr::to_str) {
std::option::Option::Some(file_name) if !file_name.is_empty() => file_name,
_ => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_filename_invalid",
"wallet export destination filename is invalid",
));
},
};
let (temporary_path, mut file) = match create_secret_export_temp_file(directory, file_name) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
use std::io::Write; // rust-rules: trait-import
if let std::result::Result::Err(error) = file.write_all(bytes.as_slice()) {
let _ = std::fs::remove_file(&temporary_path);
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_write_failed",
error.to_string(),
));
}
if let std::result::Result::Err(error) = file.sync_all() {
let _ = std::fs::remove_file(&temporary_path);
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_sync_failed",
error.to_string(),
));
}
std::mem::drop(file);
if let std::result::Result::Err(error) = std::fs::hard_link(&temporary_path, &path) {
let _ = std::fs::remove_file(&temporary_path);
if error.kind() == std::io::ErrorKind::AlreadyExists {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_already_exists",
"wallet export destination already exists",
));
}
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_publish_failed",
error.to_string(),
));
}
if let std::result::Result::Err(error) = sync_export_directory(directory) {
let _ = std::fs::remove_file(&temporary_path);
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = std::fs::remove_file(&temporary_path) {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_temp_remove_failed",
error.to_string(),
));
}
return sync_export_directory(directory);
}
fn create_secret_export_temp_file(
directory: &std::path::Path,
file_name: &str,
) -> ks_core::Result<(std::path::PathBuf, std::fs::File)> {
for _ in 0..SECRET_EXPORT_TEMP_ATTEMPTS {
let sequence =
SECRET_EXPORT_TEMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let temporary_path = directory
.join(format!(".{file_name}.kswallet-export-tmp-{}-{sequence}", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt; // rust-rules: trait-import
options.mode(0o600);
}
match options.open(&temporary_path) {
std::result::Result::Ok(file) => {
return std::result::Result::Ok((temporary_path, file));
},
std::result::Result::Err(error)
if error.kind() == std::io::ErrorKind::AlreadyExists => {},
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_temp_create_failed",
error.to_string(),
));
},
}
}
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_temp_create_failed",
"wallet export temporary filename could not be reserved",
));
}
#[cfg(unix)]
fn sync_export_directory(directory: &std::path::Path) -> ks_core::Result<()> {
let file = match std::fs::File::open(directory) {
std::result::Result::Ok(file) => file,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_export_directory_sync_failed",
error.to_string(),
));
},
};
return match file.sync_all() {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
"wallet_export_directory_sync_failed",
error.to_string(),
)),
};
}
#[cfg(not(unix))]
fn sync_export_directory(_directory: &std::path::Path) -> ks_core::Result<()> {
return std::result::Result::Ok(());
}
fn native_path(manager: &crate::WalletManager, alias: &crate::WalletAlias) -> std::path::PathBuf {
return manager.directory().join(format!(
"{}.{}",
alias.as_str(),
crate::KSWALLET_FILE_EXTENSION
));
}
fn transfer_format_code(format: crate::WalletTransferFormat) -> &'static str {
return match format {
crate::WalletTransferFormat::SolanaCliJson => "solana_cli_json",
crate::WalletTransferFormat::SolanaPrivateKeyBase58 => "solana_private_key_base58",
};
}

View File

@@ -1,5 +1,5 @@
// file: ks-wallet/src/unlocked.rs
// version: 2
// version: 3
//! Authenticated signing capability for one unlocked native wallet.
@@ -59,6 +59,11 @@ impl crate::UnlockedWallet {
return &self.keypair;
}
/// Copies the keypair into a zeroizing crate-private buffer for explicit secret export.
pub(crate) fn keypair_bytes(&self) -> zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]> {
return zeroize::Zeroizing::new(self.keypair.to_bytes());
}
/// Signs arbitrary message bytes after authenticated unlock.
pub fn sign_message(&self, message: &[u8]) -> ks_core::Result<std::string::String> {
let signature = match self.keypair.try_sign_message(message) {

279
ks-wallet/tests/transfer.rs Normal file
View File

@@ -0,0 +1,279 @@
// file: ks-wallet/tests/transfer.rs
// version: 1
//! External migration and secret import/export contract tests.
use solana_signer::Signer; // rust-rules: trait-import
use zeroize::Zeroize; // rust-rules: trait-import
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!("fixture directory permissions must be set: {error}"));
}
}
fn write_private_file(path: &std::path::Path, bytes: &[u8]) {
use std::io::Write; // rust-rules: trait-import
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt; // rust-rules: trait-import
options.mode(0o600);
}
let mut file = options
.open(path)
.unwrap_or_else(|error| panic!("private fixture must be created: {error}"));
file.write_all(bytes)
.unwrap_or_else(|error| panic!("private fixture must be written: {error}"));
file.sync_all()
.unwrap_or_else(|error| panic!("private fixture must be synchronized: {error}"));
}
fn password(value: &str) -> ks_wallet::WalletPassword {
return ks_wallet::WalletPassword::new(value.to_owned())
.unwrap_or_else(|error| panic!("test password must be accepted: {error}"));
}
fn alias(value: &str) -> ks_wallet::WalletAlias {
return ks_wallet::WalletAlias::parse(value)
.unwrap_or_else(|error| panic!("test alias must be accepted: {error}"));
}
#[tokio::test]
async fn solana_cli_json_import_export_roundtrip_preserves_exact_keypair() {
let directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
make_directory_private(directory.path());
let manager = ks_wallet::WalletManager::new(directory.path())
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
let keypair = solana_keypair::Keypair::new();
let expected_public_key = keypair.pubkey().to_string();
let mut source_keypair_bytes = keypair.to_bytes();
let mut source_json = serde_json::to_vec(source_keypair_bytes.as_slice())
.unwrap_or_else(|error| panic!("Solana JSON fixture must encode: {error}"));
let source_path = directory.path().join("solana-cli-source.json");
write_private_file(&source_path, source_json.as_slice());
source_json.zeroize();
let imported = manager
.import_file(
alias("cli-import"),
password("cli-import-password"),
&source_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.unwrap_or_else(|error| panic!("Solana CLI import must succeed: {error}"));
assert_eq!(imported.public_key(), expected_public_key);
imported.lock();
let export_path = directory.path().join("solana-cli-export.json");
manager
.export_file(
&alias("cli-import"),
password("cli-import-password"),
&export_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.unwrap_or_else(|error| panic!("Solana CLI export must succeed: {error}"));
let mut exported = std::fs::read(&export_path)
.unwrap_or_else(|error| panic!("Solana CLI export must be readable: {error}"));
let mut decoded = serde_json::from_slice::<std::vec::Vec<u8>>(exported.as_slice())
.unwrap_or_else(|error| panic!("Solana CLI export must decode: {error}"));
exported.zeroize();
assert_eq!(decoded.as_slice(), source_keypair_bytes.as_slice());
decoded.zeroize();
source_keypair_bytes.zeroize();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
let metadata = std::fs::metadata(&export_path)
.unwrap_or_else(|error| panic!("export metadata must be readable: {error}"));
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
}
let overwrite_error = manager
.export_file(
&alias("cli-import"),
password("cli-import-password"),
&export_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.err()
.unwrap_or_else(|| panic!("secret export must refuse an existing destination"));
assert_eq!(overwrite_error.code(), "wallet_export_already_exists");
}
#[tokio::test]
async fn base58_private_key_adapter_roundtrips_phantom_style_keypair() {
let directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
make_directory_private(directory.path());
let manager = ks_wallet::WalletManager::new(directory.path())
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
let keypair = solana_keypair::Keypair::new();
let expected_public_key = keypair.pubkey().to_string();
let mut keypair_bytes = keypair.to_bytes();
let mut base58_private_key = bs58::encode(keypair_bytes.as_slice()).into_string();
let source_path = directory.path().join("phantom-private-key.txt");
write_private_file(&source_path, base58_private_key.as_bytes());
let imported = manager
.import_file(
alias("phantom-import"),
password("phantom-import-password"),
&source_path,
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
)
.await
.unwrap_or_else(|error| panic!("Base58 private-key import must succeed: {error}"));
assert_eq!(imported.public_key(), expected_public_key);
imported.lock();
let export_path = directory.path().join("phantom-private-key-export.txt");
manager
.export_file(
&alias("phantom-import"),
password("phantom-import-password"),
&export_path,
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
)
.await
.unwrap_or_else(|error| panic!("Base58 private-key export must succeed: {error}"));
let mut exported = std::fs::read_to_string(&export_path)
.unwrap_or_else(|error| panic!("Base58 export must be readable: {error}"));
assert_eq!(exported, base58_private_key);
exported.zeroize();
base58_private_key.zeroize();
keypair_bytes.zeroize();
}
#[tokio::test]
async fn legacy_migration_is_non_destructive_and_refuses_second_destination() {
let directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
make_directory_private(directory.path());
let manager = ks_wallet::WalletManager::new(directory.path())
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
let keypair = solana_keypair::Keypair::new();
let expected_public_key = keypair.pubkey().to_string();
let mut keypair_bytes = keypair.to_bytes();
let mut legacy_json = serde_json::to_vec(keypair_bytes.as_slice())
.unwrap_or_else(|error| panic!("legacy fixture must encode: {error}"));
let source_path = directory.path().join("legacy-migrate.json");
write_private_file(&source_path, legacy_json.as_slice());
let mut original_source = std::fs::read(&source_path)
.unwrap_or_else(|error| panic!("legacy fixture must be readable: {error}"));
let migrated = manager
.migrate_legacy(alias("legacy-migrate"), password("migration-password"))
.await
.unwrap_or_else(|error| panic!("legacy migration must succeed: {error}"));
assert_eq!(migrated.public_key(), expected_public_key);
migrated.lock();
let mut source_after = std::fs::read(&source_path)
.unwrap_or_else(|error| panic!("legacy source must remain readable: {error}"));
assert_eq!(source_after, original_source);
let error = manager
.migrate_legacy(alias("legacy-migrate"), password("second-migration-password"))
.await
.err()
.unwrap_or_else(|| panic!("second migration must refuse the native alias collision"));
assert_eq!(error.code(), "wallet_native_already_exists");
let mut source_after_error = std::fs::read(&source_path)
.unwrap_or_else(|read_error| panic!("legacy source must survive collision: {read_error}"));
assert_eq!(source_after_error, original_source);
source_after_error.zeroize();
source_after.zeroize();
original_source.zeroize();
legacy_json.zeroize();
keypair_bytes.zeroize();
}
#[tokio::test]
async fn import_rejects_duplicate_pubkey_and_export_requires_valid_password() {
let directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
make_directory_private(directory.path());
let manager = ks_wallet::WalletManager::new(directory.path())
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
let keypair = solana_keypair::Keypair::new();
let mut keypair_bytes = keypair.to_bytes();
let mut source_json = serde_json::to_vec(keypair_bytes.as_slice())
.unwrap_or_else(|error| panic!("duplicate fixture must encode: {error}"));
let source_path = directory.path().join("duplicate-source.json");
write_private_file(&source_path, source_json.as_slice());
source_json.zeroize();
let first = manager
.import_file(
alias("duplicate-a"),
password("duplicate-password"),
&source_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.unwrap_or_else(|error| panic!("first import must succeed: {error}"));
first.lock();
let duplicate_error = manager
.import_file(
alias("duplicate-b"),
password("unused-duplicate-password"),
&source_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.err()
.unwrap_or_else(|| panic!("duplicate public key must be rejected"));
assert_eq!(duplicate_error.code(), "wallet_public_key_already_exists");
let export_path = directory.path().join("wrong-password-export.json");
let password_error = manager
.export_file(
&alias("duplicate-a"),
password("wrong-export-password"),
&export_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.err()
.unwrap_or_else(|| panic!("wrong export password must fail"));
assert_eq!(password_error.code(), "wallet_native_authentication_failed");
assert!(!export_path.exists());
keypair_bytes.zeroize();
}
#[tokio::test]
async fn import_rejects_invalid_sources_without_creating_native_destination() {
let directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
make_directory_private(directory.path());
let manager = ks_wallet::WalletManager::new(directory.path())
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
let invalid_json_path = directory.path().join("invalid-source.json");
write_private_file(&invalid_json_path, b"[1,2,3]");
let json_error = manager
.import_file(
alias("invalid-json"),
password("invalid-json-password"),
&invalid_json_path,
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.await
.err()
.unwrap_or_else(|| panic!("short Solana JSON import must fail"));
assert_eq!(json_error.code(), "wallet_import_keypair_length_invalid");
assert!(!directory.path().join("invalid-json.kswallet").exists());
let invalid_base58_path = directory.path().join("invalid-base58.txt");
write_private_file(&invalid_base58_path, b"not valid base58 !!!");
let base58_error = manager
.import_file(
alias("invalid-base58"),
password("invalid-base58-password"),
&invalid_base58_path,
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
)
.await
.err()
.unwrap_or_else(|| panic!("invalid Base58 import must fail"));
assert_eq!(base58_error.code(), "wallet_import_base58_invalid");
assert!(!directory.path().join("invalid-base58.kswallet").exists());
}