v0.5.2-pre.005
This commit is contained in:
@@ -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 qu’un nouveau secret Ed25519 implique une nouvelle pubkey ;
|
||||
- rend obligatoires l’import **et** l’export 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 d’exemple 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 d’origine n’est 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 l’exclusion erronée de l’export privé contrôlé et supprime l’hypothèse d’un alias principal universel par profil ;
|
||||
- formalise l’extension native `.kswallet`, le wallet temporaire et l’obligation 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 d’atomicité/TOCTOU ;
|
||||
@@ -80,21 +106,6 @@
|
||||
- corrige README/USAGE/TODO afin d’aligner les objectifs sur ce contrat simplifié ;
|
||||
- ne modifie aucune API runtime, aucun fichier wallet et n’introduit 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 l’exclusion erronée de l’export privé contrôlé et supprime l’hypothèse d’un alias principal universel par profil ;
|
||||
- formalise l’extension native `.kswallet`, le wallet temporaire et l’obligation 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 qu’un nouveau secret Ed25519 implique une nouvelle pubkey ;
|
||||
- rend obligatoires l’import **et** l’export 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 d’exemple 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 d’origine n’est pas disponible.
|
||||
|
||||
## `0.5.1-pre.002`
|
||||
|
||||
- renomme `kb-wallet` en `ks-wallet` et `kb_wallet` en `ks_wallet` ;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 d’alias et de pubkey lors d’un import ;
|
||||
- publication d’export 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
665
ks-wallet/src/transfer.rs
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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
279
ks-wallet/tests/transfer.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user