v0.5.2-pre.004

This commit is contained in:
2026-08-10 16:43:19 +02:00
parent ef630e3123
commit b8e6751747
15 changed files with 1489 additions and 89 deletions

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml # file: Cargo.toml
# version: 58 # version: 61
[workspace] [workspace]
resolver = "3" resolver = "3"
@@ -18,7 +18,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.2-pre.3" version = "0.5.2-pre.4"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3" repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3"
@@ -26,7 +26,7 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
dotenvy = {version = "^0.15", features = ["clap", "cli"]} dotenvy = { version = "^0.15", features = ["clap", "cli"] }
argon2 = { version = "^0.5", features = ["std", "zeroize"] } argon2 = { version = "^0.5", features = ["std", "zeroize"] }
async-trait = { version = "^0.1", features = [] } async-trait = { version = "^0.1", features = [] }
base64 = { version = "^0.23", features = [] } base64 = { version = "^0.23", features = [] }
@@ -34,10 +34,11 @@ bytemuck = { version = "^1.25", features = ["derive"] }
borsh_0_10 = { package = "borsh", version = "^0.10" } borsh_0_10 = { package = "borsh", version = "^0.10" }
borsh = { version = "^1.7", features = ["ascii", "bson", "bytes", "default", "derive", "de_strict_order", "borsh-derive", "indexmap", "std", "rc"] } borsh = { version = "^1.7", features = ["ascii", "bson", "bytes", "default", "derive", "de_strict_order", "borsh-derive", "indexmap", "std", "rc"] }
bs58 = { version = "^0.5", features = ["cb58", "check", "default"] } bs58 = { version = "^0.5", features = ["cb58", "check", "default"] }
chacha20poly1305 = { version = "^0.11", features = ["std", "stream"] } chacha20poly1305 = { version = "^0.11", features = ["getrandom", "rand_core", "zeroize"] }
chrono = { version = "^0.4", features = ["serde"] } chrono = { version = "^0.4", features = ["serde"] }
fs2 = { version = "^0.4", features = [] } fs2 = { version = "^0.4", features = [] }
futures-util = { version = "^0.3", features = ["default", "futures-sink"] } futures-util = { version = "^0.3", features = ["default", "futures-sink"] }
getrandom = { version = "^0.4", features = ["std", "sys_rng"] }
jsonschema = { version = "^0.49", features = [] } jsonschema = { version = "^0.49", features = [] }
mpl-token-metadata = { version = "^5.1", features = ["serde"] } mpl-token-metadata = { version = "^5.1", features = ["serde"] }
rand = { version = "^0.10", features = ["chacha", "serde", "std", "sys_rng"] } rand = { version = "^0.10", features = ["chacha", "serde", "std", "sys_rng"] }

View File

@@ -1,13 +1,13 @@
<!-- file: docs/NATIVE_FORMAT.md --> <!-- file: docs/NATIVE_FORMAT.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Format natif `.kswallet` — version 1 # Format natif `.kswallet` — version 1
## Statut ## Statut
Ce document fixe le codec binaire de la version `1` introduit par `0.5.2-pre.003`. Ce document fixe le codec binaire de la version `1` introduit par `0.5.2-pre.003` et activé pour les wallets persistants protégés dans `0.5.2-pre.004`.
`pre.003` fixe le layout et implémente le décodage/validation strict ainsi que la lecture bornée nécessaires au scan, mais **ne chiffre encore aucune vraie keypair avec un mot de passe**. L'encodage de création, la publication atomique, le raccordement password/KDF/AEAD et l'ouverture signante appartiennent à `pre.004`. `pre.004` conserve exactement ce layout et implémente l'encodage, la publication atomique, la dérivation Argon2id, le chiffrement/déchiffrement XChaCha20-Poly1305, l'ouverture authentifiée et le changement de mot de passe.
Un fichier natif porte le nom : Un fichier natif porte le nom :
@@ -29,14 +29,14 @@ Le conteneur doit :
- permettre une publication atomique sans écrasement silencieux ; - permettre une publication atomique sans écrasement silencieux ;
- rester incompatible par construction avec le JSON keypair Solana legacy. - rester incompatible par construction avec le JSON keypair Solana legacy.
Le caractère binaire du format ne constitue pas une protection cryptographique. La confidentialité du secret sera fournie par l'AEAD après dérivation d'une clé depuis le mot de passe en `pre.004`. Le caractère binaire du format ne constitue pas une protection cryptographique. La confidentialité du secret est fournie par l'AEAD après dérivation d'une clé depuis le mot de passe.
## Layout binaire v1 ## Layout binaire v1
Le header fixe occupe **72 octets**. Le header fixe occupe **72 octets**.
| Offset | Taille | Champ | Contrat v1 | | Offset | Taille | Champ | Contrat v1 |
|--------------:|-----------:|----------------------|-------------------------------------------------| |------------:|---------:|--------------------|------------------------------------------------|
| `0` | `8` | magic | ASCII `KSWALLET` | | `0` | `8` | magic | ASCII `KSWALLET` |
| `8` | `2` | format version | `1` | | `8` | `2` | format version | `1` |
| `10` | `2` | header length | `72 + alias_length` | | `10` | `2` | header length | `72 + alias_length` |
@@ -73,7 +73,7 @@ Le secret Ed25519 n'est jamais placé dans ce header. Le plaintext protégé de
Le nom `<alias>.kswallet` et l'alias encodé doivent correspondre exactement. Une divergence est une erreur et ne déclenche aucun fallback vers le legacy. Le nom `<alias>.kswallet` et l'alias encodé doivent correspondre exactement. Une divergence est une erreur et ne déclenche aucun fallback vers le legacy.
Avant déverrouillage, cette identité est **déclarative** : le codec peut vérifier sa structure, mais pas encore le tag AEAD. `pre.004` doit authentifier le conteneur puis vérifier que la pubkey dérivée du keypair déchiffré correspond exactement à cette pubkey déclarée avant de créer une capacité de signature. Avant déverrouillage, cette identité est **déclarative** : le codec peut vérifier sa structure, mais pas le tag AEAD sans mot de passe. `unlock()` et `unlock_file()` authentifient le conteneur puis vérifient que la pubkey dérivée du keypair déchiffré correspond exactement à cette pubkey déclarée avant de créer `UnlockedWallet`.
## KDF v1 ## KDF v1
@@ -104,7 +104,7 @@ La mémoire déclarée doit en plus respecter la relation Argon2 `m >= 8 * p` et
Ces bornes servent deux objectifs distincts : Ces bornes servent deux objectifs distincts :
- refuser un conteneur v1 plus faible que le plancher retenu pour `ks-wallet` ; - refuser un conteneur v1 plus faible que le plancher retenu pour `ks-wallet` ;
- empêcher qu'un fichier hostile impose des coûts KDF arbitrairement élevés lors de `pre.004`. - empêcher qu'un fichier hostile impose des coûts KDF arbitrairement élevés lors d'une ouverture authentifiée.
## AEAD v1 ## AEAD v1
@@ -116,7 +116,7 @@ nonce = 24 bytes
tag = 16 bytes tag = 16 bytes
``` ```
`pre.004` devra générer un nonce neuf pour chaque nouvelle protection du payload. Une modification de mot de passe devra donc produire au minimum un nouveau sel et un nouveau nonce. Chaque nouvelle protection du payload génère un sel et un nonce neufs depuis la source aléatoire système. Une modification de mot de passe produit donc un nouveau sel et un nouveau nonce avant rechiffrement de la même keypair.
Ce choix appartient au format natif `ks-wallet`, pas à un format Solana externe. La documentation RustCrypto signale qu'il n'existe pas de spécification XChaCha20-Poly1305 autoritative unique, tout en documentant des implémentations interopérables et l'audit de la crate. Le layout v1 fixe donc explicitement l'algorithme et ses tailles au lieu de dépendre d'une convention implicite. Ce choix appartient au format natif `ks-wallet`, pas à un format Solana externe. La documentation RustCrypto signale qu'il n'existe pas de spécification XChaCha20-Poly1305 autoritative unique, tout en documentant des implémentations interopérables et l'audit de la crate. Le layout v1 fixe donc explicitement l'algorithme et ses tailles au lieu de dépendre d'une convention implicite.
@@ -133,7 +133,7 @@ fixed header
Le champ `ciphertext_length` appartient donc lui aussi aux données authentifiées. Le champ `ciphertext_length` appartient donc lui aussi aux données authentifiées.
Ainsi, une altération de la version, des IDs crypto, des paramètres KDF, de la pubkey, de l'alias, du sel, du nonce ou de la longueur du ciphertext devra faire échouer l'ouverture authentifiée en `pre.004`. Ainsi, une altération de la version, des IDs crypto, des paramètres KDF, de la pubkey, de l'alias, du sel, du nonce ou de la longueur du ciphertext fait échouer l'ouverture authentifiée.
## Bornes du codec ## Bornes du codec
@@ -159,7 +159,7 @@ wallet directory : 0700
.kswallet file : 0600 .kswallet file : 0600
``` ```
La publication que `pre.004` devra implémenter pour produire un conteneur v1 suit cette séquence : La publication implémentée pour produire un conteneur v1 suit cette séquence :
1. encoder entièrement le conteneur ; 1. encoder entièrement le conteneur ;
2. créer dans le même répertoire un fichier temporaire privé avec `create_new` ; 2. créer dans le même répertoire un fichier temporaire privé avec `create_new` ;
@@ -190,19 +190,23 @@ Les structures internes contenant sel, nonce ou ciphertext :
Le chemin local reste conservé en interne pour permettre l'ouverture ultérieure du fichier sélectionné. Le chemin local reste conservé en interne pour permettre l'ouverture ultérieure du fichier sélectionné.
## Suite en `pre.004` ## Implémentation `pre.004`
`pre.004` devra compléter ce codec sans modifier silencieusement son layout pour : `pre.004` complète le codec v1 sans modifier son layout :
- encoder le conteneur de création et publier le fichier atomiquement/no-clobber selon la séquence ci-dessus ; - encodage et publication atomique/no-clobber des créations ;
- dériver une clé avec Argon2id ; - dérivation Argon2id dans une tâche bloquante dédiée ;
- chiffrer/déchiffrer les 64 octets du keypair avec XChaCha20-Poly1305 ; - chiffrement/déchiffrement des 64 octets du keypair avec XChaCha20-Poly1305 ;
- vérifier que la pubkey dérivée du keypair déchiffré est exactement celle du header ; - authentification du préfixe complet comme AAD ;
- créer une capacité de signature seulement après authentification réussie ; - vérification stricte de la pubkey après déchiffrement ;
- changer le mot de passe en conservant la même keypair/pubkey ; - création de `UnlockedWallet` seulement après authentification réussie ;
- zéroïser les buffers plaintext et clés dérivées possédés par `ks-wallet`. - changement de mot de passe avec nouvelle protection du même keypair ;
- zéroïsation des mots de passe, clés dérivées, plaintexts temporaires et buffers ciphertext possédés par `ks-wallet` lorsque leur durée de vie se termine ;
- activation du feature `zeroize` du cipher pour effacer son état interne à la destruction.
Toute modification incompatible du layout défini ici doit utiliser une nouvelle version de format plutôt qu'une heuristique de décodage. La keypair runtime est possédée par `solana_keypair::Keypair`; sa dépendance `ed25519-dalek` active par défaut la zéroïsation de `SigningKey`.
La migration et les adaptateurs d'import/export ne font pas partie du format wire v1 et restent la tranche suivante. Toute modification incompatible du layout défini ici doit utiliser une nouvelle version de format plutôt qu'une heuristique de décodage.
## Références de conception v1 ## Références de conception v1

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md --> <!-- file: docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md -->
<!-- version: 6 --> <!-- version: 7 -->
# Plan `0.5.2` — restructuration de `ks-wallet` # Plan `0.5.2` — restructuration de `ks-wallet`
@@ -142,9 +142,9 @@ Le format binaire devra au minimum permettre d'identifier sans ambiguïté :
- ciphertext v1 : exactement 80 octets avec le tag AEAD ; - ciphertext v1 : exactement 80 octets avec le tag AEAD ;
- taille totale v1 : 193 à 256 octets selon la longueur de l'alias ; - taille totale v1 : 193 à 256 octets selon la longueur de l'alias ;
- paramètres KDF acceptés : mémoire 64 à 256 MiB, 3 à 10 passes, 1 à 8 lanes, avec les relations Argon2 de mémoire validées ; - paramètres KDF acceptés : mémoire 64 à 256 MiB, 3 à 10 passes, 1 à 8 lanes, avec les relations Argon2 de mémoire validées ;
- données authentifiées réservées pour `pre.004` : header complet + alias + sel + nonce. - données authentifiées : header complet + alias + sel + nonce.
Le layout normatif exact est documenté dans `docs/NATIVE_FORMAT.md`. `pre.003` décode et valide strictement ces champs pour le scan mais n'encode encore aucun vrai wallet persistant et n'effectue aucune dérivation de clé ni aucun chiffrement réel avec un mot de passe ; la création/écriture reste strictement dans `pre.004`. Le layout normatif exact est documenté dans `docs/NATIVE_FORMAT.md`. `pre.003` a fermé la lecture/validation structurelle ; `pre.004` active l'encodage, la publication, la dérivation Argon2id, le chiffrement XChaCha20-Poly1305 et l'ouverture authentifiée sans modifier ce wire.
## 4. Caractérisation du legacy actuel ## 4. Caractérisation du legacy actuel
@@ -501,19 +501,25 @@ Le préambule d'identification introduit ici ne définit pas encore le payload p
- [x] alias du header vérifié contre le nom `<alias>.kswallet` ; - [x] alias du header vérifié contre le nom `<alias>.kswallet` ;
- [x] `WalletFileHandle` enrichi de la pubkey déclarée et de la version sans exposer le chemin ; - [x] `WalletFileHandle` enrichi de la pubkey déclarée et de la version sans exposer le chemin ;
- [x] validation des permissions privées à la lecture ; - [x] validation des permissions privées à la lecture ;
- [ ] encodage de création et publication atomique/no-clobber `pre.004` ; - [x] encodage de création et publication atomique/no-clobber explicitement reportés à `pre.004` ;
- [x] tests du wire v1, truncation, trailing bytes, KDF hors bornes, alias mismatch et réouverture bornée. - [x] tests du wire v1, truncation, trailing bytes, KDF hors bornes, alias mismatch et réouverture bornée.
Aucune vraie keypair n'est encore chiffrée par cette tranche : le password, la dérivation Argon2id, l'appel AEAD et la capacité de signature ouverte restent `pre.004`. La tranche `pre.003` reste structurelle ; le cycle de vie protégé est activé par `pre.004` ci-dessous.
### `0.5.2-pre.004` — password et cycle de vie persistant ### `0.5.2-pre.004` — password et cycle de vie persistant
- création persistante avec password ; - [x] `WalletPassword` possédé, non clonable, redacted et consommé par une opération ;
- ouverture/déverrouillage ; - [x] création persistante avec password et publication atomique/no-clobber ;
- fermeture/verrouillage selon le modèle runtime retenu ; - [x] dérivation Argon2id et XChaCha20-Poly1305 effectifs hors du runtime async ;
- changement de mot de passe ; - [x] ouverture/déverrouillage par alias ;
- signature après ouverture ; - [x] ouverture authentifiée d'un `WalletFileHandle` sélectionné hors store ;
- non-divulgation et zéroïsation. - [x] `UnlockedWallet` comme capacité de signature non clonable ;
- [x] fermeture/verrouillage par consommation explicite ou fin de portée de `UnlockedWallet` ;
- [x] changement de mot de passe après authentification de l'ancien, avec nouveau sel/nonce et même keypair/pubkey ;
- [x] contrat explicite : la rotation du mot de passe reprotège le fichier mais ne révoque pas une `UnlockedWallet` déjà remise à un consommateur ;
- [x] vérification de la pubkey déchiffrée contre le header authentifié avant exposition du signer ;
- [x] zéroïsation des buffers secrets possédés et activation de la zéroïsation du cipher ;
- [x] tests d'ancien/nouveau mot de passe, signature, ouverture externe, AAD altéré et canaris de non-divulgation.
### `0.5.2-pre.005` — migration, import et export ### `0.5.2-pre.005` — migration, import et export
@@ -603,7 +609,7 @@ Le plan est désormais centré sur les décisions suivantes :
14. le format keypair JSON des binaires Solana est obligatoire en import et en export dans `0.5.2` ; 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 ; 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 ; 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 ;
17. le format v1 utilise Argon2id v19 et XChaCha20-Poly1305 selon `docs/NATIVE_FORMAT.md`, tandis que le modèle runtime dun wallet ouvert reste à fermer en `pre.004` ; 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. 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.
Ces décisions ont permis d'ouvrir `0.5.2-pre.002`. Ces décisions ont permis d'ouvrir `0.5.2-pre.002`.

View File

@@ -1,8 +1,29 @@
<!-- file: ks-wallet/CHANGELOG.md --> <!-- file: ks-wallet/CHANGELOG.md -->
<!-- version: 14 --> <!-- version: 16 -->
# CHANGELOG — ks-wallet # CHANGELOG — ks-wallet
### `pre.004-delta-fix-001`
- 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.
## `0.5.2-pre.004`
- ajoute `WalletPassword`, type possédé, non clonable, non sérialisable et redacted qui zéroïse sa chaîne à la destruction, y compris lorsqu'une valeur invalide est rejetée ;
- ajoute `UnlockedWallet`, capacité authentifiée non clonable qui expose uniquement l'identité publique et les interfaces `Signer`/`Signer + Sync`, sans getter des bytes privés ;
- ajoute `WalletManager::create()`, `unlock()`, `unlock_file()` et `change_password()` pour le cycle de vie natif protégé ;
- exécute Argon2id v19 hors du runtime async via `spawn_blocking`, avec le profil v1 64 MiB / 3 passes / 4 lanes et les bornes du codec ;
- chiffre et authentifie les 64 octets du keypair avec XChaCha20-Poly1305 en utilisant header + alias + sel + nonce comme AAD ;
- génère un sel et un nonce neufs via la source aléatoire système à chaque création ou changement de mot de passe ;
- vérifie après déchiffrement que la pubkey du keypair correspond exactement à la pubkey authentifiée du header avant de produire `UnlockedWallet` ;
- implémente l'encodage v1 et la publication privée atomique/no-clobber d'un nouveau `.kswallet`, ainsi que le remplacement atomique après authentification de l'ancien mot de passe ;
- permet d'ouvrir avec mot de passe un `.kswallet` sélectionné hors du store via son `WalletFileHandle`, sans mutation de la configuration ni auto-enregistrement ;
- 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.
## `0.5.2-pre.003` ## `0.5.2-pre.003`
- fixe et documente dans `docs/NATIVE_FORMAT.md` le layout binaire strict `.kswallet` version `1` ; - fixe et documente dans `docs/NATIVE_FORMAT.md` le layout binaire strict `.kswallet` version `1` ;

View File

@@ -1,5 +1,5 @@
# file: ks-wallet/Cargo.toml # file: ks-wallet/Cargo.toml
# version: 4 # version: 5
[package] [package]
name = "ks-wallet" name = "ks-wallet"
@@ -9,6 +9,9 @@ license.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
argon2.workspace = true
chacha20poly1305.workspace = true
getrandom.workspace = true
ks-core = { path = "../ks-core" } ks-core = { path = "../ks-core" }
serde_json.workspace = true serde_json.workspace = true
solana-keypair.workspace = true solana-keypair.workspace = true

View File

@@ -1,5 +1,5 @@
<!-- file: ks-wallet/README.md --> <!-- file: ks-wallet/README.md -->
<!-- version: 9 --> <!-- version: 10 -->
# ks-wallet # ks-wallet
@@ -7,7 +7,7 @@
## État actuel ## État actuel
En `0.5.2-pre.003`, la crate possède désormais le décodage/validation stricts du format binaire `.kswallet` v1 nécessaires au scan. L'encodage de création, la publication atomique, le password et le chiffrement/déchiffrement réel restent pour `pre.004`; le legacy Solana JSON reste utilisé par les consommateurs historiques pendant la transition. 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`.
La crate fournit actuellement : La crate fournit actuellement :
@@ -15,7 +15,13 @@ La crate fournit actuellement :
- identité publique minimale `WalletIdentity` sans chemin local ; - identité publique minimale `WalletIdentity` sans chemin local ;
- `WalletManager` pour scanner/lookup les `.kswallet` structurellement valides du répertoire configuré ; - `WalletManager` pour scanner/lookup les `.kswallet` structurellement valides du répertoire configuré ;
- inspection explicite d'un `.kswallet` sélectionné hors du store via un handle opaque ; - inspection explicite d'un `.kswallet` sélectionné hors du store via un handle opaque ;
- décodage v1 strict avec identité publique déclarée, paramètres KDF/AEAD bornés et payload keypair de taille exacte ; - décodage et encodage v1 stricts avec identité publique déclarée, paramètres KDF/AEAD bornés et payload keypair de taille exacte ;
- création persistante native avec `WalletManager::create()` et publication atomique/no-clobber ;
- 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 ;
- `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 ; - wallet temporaire en mémoire ;
- stockage legacy local JSON d'un keypair Solana ; - stockage legacy local JSON d'un keypair Solana ;
- création exclusive avec refus d'écrasement et reprise des courses `load_or_create` ; - création exclusive avec refus d'écrasement et reprise des courses `load_or_create` ;
@@ -43,9 +49,9 @@ La persistance legacy écrit encore directement le contenu dans le chemin final
- importer et exporter obligatoirement le format keypair JSON des binaires Solana ; - importer et exporter obligatoirement le format keypair JSON des binaires Solana ;
- documenter les formats compatibles des principaux wallets Solana, implémenter un adaptateur tiers d'exemple et reporter les autres au TODO. - documenter les formats compatibles des principaux wallets Solana, implémenter un adaptateur tiers d'exemple et reporter les autres au TODO.
Le format `.kswallet` est propre à `ks-wallet`, mais sa protection cryptographique utilise des primitives établies : le format v1 fixe Argon2id v19 et XChaCha20-Poly1305. Le layout normatif est documenté dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). Le payload secret v1 est exactement la keypair Solana brute de 64 octets ; avec le tag AEAD, le ciphertext est fixé à 80 octets. `pre.003` ne réalise encore aucune dérivation ni encryption réelle. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet. Le format `.kswallet` est propre à `ks-wallet`, mais sa protection cryptographique utilise des primitives établies : Argon2id v19 pour la dérivation depuis le mot de passe et XChaCha20-Poly1305 pour le chiffrement authentifié. Le layout normatif est documenté dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). Le payload secret v1 est exactement la keypair Solana brute de 64 octets ; avec le tag AEAD, le ciphertext est fixé à 80 octets. Le header, l'alias, le sel et le nonce sont authentifiés comme AAD avant qu'une capacité `UnlockedWallet` puisse être produite. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet. Le changement du mot de passe reprotège le fichier persistant ; il ne révoque pas une capacité `UnlockedWallet` déjà détenue par un consommateur, qui doit être explicitement `lock()`/dropée selon son propre cycle de vie.
Le scan automatique reste strictement borné au répertoire fourni à `WalletManager`. Un programme peut néanmoins demander l'inspection d'un autre fichier `.kswallet` choisi explicitement, par exemple via un file browser desktop ; cette opération ne modifie pas le store et ne rend pas le chemin public. Le scan automatique reste strictement borné au répertoire fourni à `WalletManager`. Un programme peut néanmoins demander l'inspection d'un autre fichier `.kswallet` choisi explicitement, par exemple via un file browser desktop, puis l'ouvrir avec `unlock_file()` et le mot de passe fourni. Ces opérations ne modifient pas le store et ne rendent pas le chemin public.
## Relations ## Relations

View File

@@ -1,5 +1,5 @@
<!-- file: ks-wallet/TODO.md --> <!-- file: ks-wallet/TODO.md -->
<!-- version: 10 --> <!-- version: 11 -->
# TODO — ks-wallet # TODO — ks-wallet
@@ -11,12 +11,12 @@
- [x] conserver les wallets temporaires ou jetables purement en mémoire et leur frontière de signature actuelle. - [x] conserver les wallets temporaires ou jetables purement en mémoire et leur frontière de signature actuelle.
- [x] permettre l'inspection explicite d'un `.kswallet` hors store sans mutation de la configuration ni exposition publique du chemin. - [x] permettre l'inspection explicite d'un `.kswallet` hors store sans mutation de la configuration ni exposition publique du chemin.
- [x] spécifier le format natif binaire v1 `<alias>.kswallet` et implémenter son décodage/validation stricts pour le scan. - [x] spécifier le format natif binaire v1 `<alias>.kswallet` et implémenter son décodage/validation stricts pour le scan.
- [ ] `0.5.2-pre.004` — implémenter l'encodage de création et la publication atomique/no-clobber du conteneur natif. - [x] `0.5.2-pre.004` — implémenter l'encodage de création et la publication atomique/no-clobber du conteneur natif.
- [x] sélectionner et documenter Argon2id v19 / XChaCha20-Poly1305 et leurs paramètres/bornes v1. - [x] sélectionner et documenter Argon2id v19 / XChaCha20-Poly1305 et leurs paramètres/bornes v1.
- [ ] créer/ouvrir un wallet persistant avec mot de passe sans exposer les bytes privés. - [x] créer/ouvrir un wallet persistant avec mot de passe sans exposer les bytes privés.
- [ ] permettre le changement de mot de passe en rechiffrant exactement la même keypair et donc en conservant la même pubkey. - [x] 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] préserver la frontière `solana_signer::Signer` compatible avec les consommateurs sans dépendance de `ks-lib` vers `ks-wallet`.
- [ ] fournir cette même capacité depuis un wallet natif ouvert par mot de passe. - [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 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. - [ ] 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. - [ ] produire une matrice documentée des formats Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et autres wallets Solana pertinents.
@@ -28,5 +28,5 @@
- [ ] normaliser la sélection par alias dans `ks-config` sans secret. - [ ] normaliser la sélection par alias dans `ks-config` sans secret.
- [ ] adapter les consommateurs et le desktop uniquement via des surfaces non sensibles. - [ ] adapter les consommateurs et le desktop uniquement via des surfaces non sensibles.
- [ ] retirer secrets et chemins locaux inutiles des logs, erreurs, diagnostics et DTO. - [ ] 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 password/migration. - [ ] 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.
- [ ] produire le guide de sécurité et la documentation finale avant clôture de `0.5.2`. - [ ] produire le guide de sécurité et la documentation finale avant clôture de `0.5.2`.

View File

@@ -1,11 +1,11 @@
<!-- file: ks-wallet/USAGE.md --> <!-- file: ks-wallet/USAGE.md -->
<!-- version: 9 --> <!-- version: 10 -->
# Utilisation de ks-wallet # Utilisation de ks-wallet
## Statut ## Statut
En `0.5.2-pre.003`, le manager valide désormais la structure complète du conteneur binaire `.kswallet` v1 avant de retourner un handle. Le password, le chiffrement/déchiffrement réel et la création persistante publique arrivent en `pre.004`. 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`.
## Valider un alias ## Valider un alias
@@ -166,7 +166,7 @@ let wallets = match manager.scan().await {
}; };
``` ```
`scan()` est non récursif et ne traite que les `<alias>.kswallet`. En `pre.003`, chaque candidat doit décoder entièrement selon le format v1 : magic/version, flags, IDs crypto, paramètres KDF, pubkey déclarée, alias, sel, nonce et ciphertext de taille exacte. Le nom du fichier et l'alias encodé doivent correspondre. `scan()` est non récursif et ne traite que les `<alias>.kswallet`. Chaque candidat doit décoder entièrement selon le format v1 : magic/version, flags, IDs crypto, paramètres KDF, pubkey déclarée, alias, sel, nonce et ciphertext de taille exacte. Le nom du fichier et l'alias encodé doivent correspondre.
Le lookup par alias reste limité à la racine configurée : Le lookup par alias reste limité à la racine configurée :
@@ -194,14 +194,125 @@ println!("pubkey={}", handle.public_key());
println!("format={}", handle.format_version()); println!("format={}", handle.format_version());
``` ```
`WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. Sa pubkey est une identité **déclarée** avant unlock : elle ne deviendra authentifiée qu'après validation AEAD et comparaison avec la keypair déchiffrée en `pre.004`. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`. `WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. Sa pubkey reste une identité **déclarée** tant que le fichier n'a pas été ouvert : `unlock()`/`unlock_file()` authentifient l'AEAD puis vérifient que la pubkey de la keypair déchiffrée correspond exactement au header avant de retourner `UnlockedWallet`. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`.
## Cycle de vie natif protégé par mot de passe
`WalletPassword` prend possession du mot de passe pour **une seule opération**. Il n'est ni clonable ni sérialisable et son `Debug` est toujours redacted. L'appelant construit donc une nouvelle valeur pour chaque création, ouverture ou changement de mot de passe.
### Créer un wallet persistant natif
```rust
let alias = match ks_wallet::WalletAlias::parse("operator") {
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.create(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());
```
`create()` génère une keypair Solana, la protège avec Argon2id + XChaCha20-Poly1305, publie `<alias>.kswallet` sans écraser une destination existante et retourne une capacité `UnlockedWallet`. Aucun getter public ne fournit les bytes privés.
### Ouvrir par alias
```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.unlock(&alias, password).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
let signature = match wallet.sign_message(b"authenticated operation") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
println!("signature={signature}");
wallet.lock();
```
Un mauvais mot de passe ou une altération des données authentifiées échoue avant la création de la capacité de signature. `lock()` consomme explicitement `UnlockedWallet`; laisser la valeur sortir de portée a le même effet de durée de vie.
### Ouvrir un fichier choisi hors du store
```rust
let handle = match manager.inspect_file(selected_path).await {
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.unlock_file(&handle, password).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
```
Le handle est revalidé contre le fichier avant l'ouverture authentifiée. Le fichier externe reste extérieur au scan/configuration du manager.
### Changer le mot de passe
```rust
let current_password = match ks_wallet::WalletPassword::new(current_password_string) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
let new_password = match ks_wallet::WalletPassword::new(new_password_string) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
let handle = match manager
.change_password(&alias, current_password, new_password)
.await
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(error);
},
};
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.
## Format binaire v1 ## Format binaire v1
Le layout exact, les bornes et la politique de publication cible sont documentés dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). `pre.003` implémente la lecture et le décodage stricts nécessaires au scan ; l'encodage de création et la publication atomique sont introduits en `pre.004`. Le payload secret v1 est strictement borné à une keypair Solana de 64 octets et produit un ciphertext/tag de 80 octets. Le 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.
Le format encode Argon2id v19 avec un profil d'écriture par défaut de 64 MiB / 3 passes / 4 lanes et XChaCha20-Poly1305 avec nonce 24 octets. Ces paramètres sont seulement encodés/validés en `pre.003`; leur exécution cryptographique commence en `pre.004`. Le format exécute Argon2id v19 avec un profil d'écriture par défaut de 64 MiB / 3 passes / 4 lanes et XChaCha20-Poly1305 avec nonce 24 octets. Les paramètres lus depuis un fichier restent bornés avant toute exécution du KDF.
## Cible persistante `0.5.2` ## Cible persistante `0.5.2`
@@ -211,7 +322,7 @@ Le nouveau stockage natif doit utiliser :
<alias>.kswallet <alias>.kswallet
``` ```
Le fichier `.kswallet` sera un conteneur binaire versionné protégé par le mot de passe propre au wallet. Le fichier `.kswallet` est un conteneur binaire versionné protégé par le mot de passe propre au wallet.
L'API cible doit permettre conceptuellement : L'API cible doit permettre conceptuellement :
@@ -229,7 +340,7 @@ Le store découvrira les wallets persistants en scannant son répertoire résolu
Le changement de mot de passe rechiffre la même keypair. Il n'existe pas de rotation normale du secret Ed25519 conservant la même pubkey : une nouvelle clé secrète signifie une nouvelle identité Solana. Le changement de mot de passe rechiffre la même keypair. Il n'existe pas de rotation normale du secret Ed25519 conservant la même pubkey : une nouvelle clé secrète signifie une nouvelle identité Solana.
Le modèle runtime exact du wallet ouvert sera décidé dans les prereleases d'implémentation. Les noms `WalletManager`, `WalletFileHandle`, `WalletIdentity` et `WalletPersistence` font désormais partie de l'API de base. 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 cible
@@ -262,4 +373,4 @@ L'export vers Solana CLI utilise son tableau JSON de 64 octets. Pour Phantom, So
- rejet des keypairs corrompus ; - rejet des keypairs corrompus ;
- vérification des permissions Unix privées. - vérification des permissions Unix privées.
`pre.003` ajoute le décodage/validation v1 stricts, les bornes KDF/fichier et les tests de corruption/réouverture. `pre.004` ajoute l'encodage de création, la publication atomique, le password/changement de password et le chiffrement effectif ; les tranches suivantes couvrent la migration, l'import/export et les canaris de non-divulgation. `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.

View File

@@ -1,5 +1,5 @@
// file: ks-wallet/src/constants.rs // file: ks-wallet/src/constants.rs
// version: 5 // version: 6
//! Local constants for the `ks-wallet` crate. //! Local constants for the `ks-wallet` crate.
@@ -27,8 +27,16 @@ pub(crate) const KSWALLET_AEAD_XCHACHA20_POLY1305: u8 = 1;
pub(crate) const KSWALLET_SALT_LENGTH: usize = 16; pub(crate) const KSWALLET_SALT_LENGTH: usize = 16;
/// Nonce length used by XChaCha20-Poly1305. /// Nonce length used by XChaCha20-Poly1305.
pub(crate) const KSWALLET_NONCE_LENGTH: usize = 24; pub(crate) const KSWALLET_NONCE_LENGTH: usize = 24;
/// Encryption-key length required by XChaCha20-Poly1305.
pub(crate) const KSWALLET_AEAD_KEY_LENGTH: usize = 32;
/// Authentication tag length appended by XChaCha20-Poly1305. /// Authentication tag length appended by XChaCha20-Poly1305.
const KSWALLET_AEAD_TAG_LENGTH: usize = 16; const KSWALLET_AEAD_TAG_LENGTH: usize = 16;
/// Default Argon2id memory cost in KiB used for new native wallets.
pub(crate) const KSWALLET_ARGON2_DEFAULT_MEMORY_KIB: u32 = 65_536;
/// Default Argon2id pass count used for new native wallets.
pub(crate) const KSWALLET_ARGON2_DEFAULT_ITERATIONS: u32 = 3;
/// Default Argon2id lane count used for new native wallets.
pub(crate) const KSWALLET_ARGON2_DEFAULT_PARALLELISM: u32 = 4;
/// Minimum accepted Argon2id memory cost in KiB. /// Minimum accepted Argon2id memory cost in KiB.
pub(crate) const KSWALLET_ARGON2_MIN_MEMORY_KIB: u32 = 65_536; pub(crate) const KSWALLET_ARGON2_MIN_MEMORY_KIB: u32 = 65_536;
/// Maximum accepted Argon2id memory cost in KiB. /// Maximum accepted Argon2id memory cost in KiB.

View File

@@ -1,5 +1,5 @@
// file: ks-wallet/src/lib.rs // file: ks-wallet/src/lib.rs
// version: 6 // version: 7
//! Wallet boundary for local key storage and transaction signing. //! Wallet boundary for local key storage and transaction signing.
#![warn(missing_docs)] #![warn(missing_docs)]
@@ -9,6 +9,8 @@
mod constants; mod constants;
mod manager; mod manager;
mod native; mod native;
mod password;
mod unlocked;
mod wallet; mod wallet;
/// Native wallet file extension without the leading dot. /// Native wallet file extension without the leading dot.
@@ -17,6 +19,10 @@ pub use self::constants::KSWALLET_FILE_EXTENSION;
pub use self::manager::WalletFileHandle; pub use self::manager::WalletFileHandle;
/// Multi-wallet manager rooted at one configured wallet directory. /// Multi-wallet manager rooted at one configured wallet directory.
pub use self::manager::WalletManager; pub use self::manager::WalletManager;
/// Explicit non-clonable password supplied to native wallet operations.
pub use self::password::WalletPassword;
/// Authenticated signing capability for one unlocked persistent wallet.
pub use self::unlocked::UnlockedWallet;
/// Solana keypair kept private inside the wallet boundary. /// Solana keypair kept private inside the wallet boundary.
pub use self::wallet::TemporaryWallet; pub use self::wallet::TemporaryWallet;
/// Filesystem-backed store for legacy development and integration-test wallets. /// Filesystem-backed store for legacy development and integration-test wallets.
@@ -32,8 +38,16 @@ pub use self::wallet::WalletPolicy;
/// Backward-compatible name for a non-secret wallet identity. /// Backward-compatible name for a non-secret wallet identity.
pub use self::wallet::WalletSummary; pub use self::wallet::WalletSummary;
/// Encryption-key length required by XChaCha20-Poly1305.
pub(crate) use self::constants::KSWALLET_AEAD_KEY_LENGTH;
/// Version-one AEAD identifier for XChaCha20-Poly1305. /// Version-one AEAD identifier for XChaCha20-Poly1305.
pub(crate) use self::constants::KSWALLET_AEAD_XCHACHA20_POLY1305; pub(crate) use self::constants::KSWALLET_AEAD_XCHACHA20_POLY1305;
/// Default Argon2id pass count for newly protected wallets.
pub(crate) use self::constants::KSWALLET_ARGON2_DEFAULT_ITERATIONS;
/// Default Argon2id memory cost for newly protected wallets.
pub(crate) use self::constants::KSWALLET_ARGON2_DEFAULT_MEMORY_KIB;
/// Default Argon2id parallelism for newly protected wallets.
pub(crate) use self::constants::KSWALLET_ARGON2_DEFAULT_PARALLELISM;
/// Maximum accepted Argon2id pass count. /// Maximum accepted Argon2id pass count.
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_ITERATIONS; pub(crate) use self::constants::KSWALLET_ARGON2_MAX_ITERATIONS;
/// Maximum accepted Argon2id memory cost in KiB. /// Maximum accepted Argon2id memory cost in KiB.
@@ -76,5 +90,13 @@ pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
pub(crate) use self::constants::TRACING_TARGET; pub(crate) use self::constants::TRACING_TARGET;
/// Parsed version-one native wallet container. /// Parsed version-one native wallet container.
pub(crate) use self::native::NativeWalletContainer; pub(crate) use self::native::NativeWalletContainer;
/// Protects one exact keypair payload with the native password contract.
pub(crate) use self::native::protect_native_wallet_keypair;
/// Reads and strictly decodes one native wallet file. /// Reads and strictly decodes one native wallet file.
pub(crate) use self::native::read_native_wallet_container; pub(crate) use self::native::read_native_wallet_container;
/// Atomically replaces one native wallet after authenticated password rotation.
pub(crate) use self::native::replace_native_wallet_file_atomic;
/// Authenticates and decrypts one native wallet keypair.
pub(crate) use self::native::unlock_native_wallet_keypair;
/// Publishes one new native wallet atomically without overwrite.
pub(crate) use self::native::write_native_wallet_file_atomic;

View File

@@ -1,7 +1,9 @@
// file: ks-wallet/src/manager.rs // file: ks-wallet/src/manager.rs
// version: 4 // version: 6
//! Multi-wallet discovery and native wallet file references. //! Multi-wallet discovery, authenticated opening and password rotation.
use solana_signer::Signer; // rust-rules: trait-import
/// Opaque reference to one validated native wallet file. /// Opaque reference to one validated native wallet file.
/// ///
@@ -182,6 +184,204 @@ impl crate::WalletManager {
) -> ks_core::Result<crate::WalletFileHandle> { ) -> ks_core::Result<crate::WalletFileHandle> {
return inspect_native_wallet_file(path.as_ref().to_path_buf()).await; return inspect_native_wallet_file(path.as_ref().to_path_buf()).await;
} }
/// Creates and atomically persists one new password-protected native wallet.
pub async fn create(
&self,
alias: crate::WalletAlias,
password: crate::WalletPassword,
) -> ks_core::Result<crate::UnlockedWallet> {
let path = self.wallet_path(&alias);
let exists = match tokio::fs::try_exists(&path).await {
std::result::Result::Ok(exists) => exists,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_file_exists_check_failed",
error.to_string(),
));
},
};
if exists {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_already_exists",
"native wallet already exists for this alias",
));
}
let keypair = solana_keypair::Keypair::new();
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(path, &container).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "create_native_wallet",
wallet_alias = alias.as_str(),
public_key = %public_key,
"created password-protected native wallet"
);
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
}
/// Authenticates and unlocks one native wallet from the configured directory.
pub async fn unlock(
&self,
alias: &crate::WalletAlias,
password: crate::WalletPassword,
) -> ks_core::Result<crate::UnlockedWallet> {
let path = self.wallet_path(alias);
let exists = match tokio::fs::try_exists(&path).await {
std::result::Result::Ok(exists) => exists,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_file_exists_check_failed",
error.to_string(),
));
},
};
if !exists {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_not_found",
"native wallet was not found for this alias",
));
}
return unlock_native_wallet_at_path(
path,
std::option::Option::Some(alias),
std::option::Option::None,
password,
)
.await;
}
/// Authenticates an explicitly selected native wallet file outside or inside the store.
///
/// The opaque handle is revalidated against the file before any signing
/// capability is returned. The file is never registered in the configured store.
pub async fn unlock_file(
&self,
handle: &crate::WalletFileHandle,
password: crate::WalletPassword,
) -> ks_core::Result<crate::UnlockedWallet> {
return unlock_native_wallet_at_path(
handle.path.clone(),
std::option::Option::Some(&handle.alias),
std::option::Option::Some(handle),
password,
)
.await;
}
/// Changes the protection password while preserving the exact keypair and public key.
pub async fn change_password(
&self,
alias: &crate::WalletAlias,
current_password: crate::WalletPassword,
new_password: crate::WalletPassword,
) -> ks_core::Result<crate::WalletFileHandle> {
let path = self.wallet_path(alias);
let container = match crate::read_native_wallet_container(&path).await {
std::result::Result::Ok(container) => container,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if container.alias() != alias {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_alias_mismatch",
"native wallet filename alias does not match the container alias",
));
}
let public_key = *container.public_key();
let keypair = match crate::unlock_native_wallet_keypair(container, current_password).await {
std::result::Result::Ok(keypair) => keypair,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes());
let replacement = match crate::protect_native_wallet_keypair(
alias.clone(),
public_key,
keypair_bytes,
new_password,
)
.await
{
std::result::Result::Ok(container) => container,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
match crate::replace_native_wallet_file_atomic(path.clone(), &replacement).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "change_native_wallet_password",
wallet_alias = alias.as_str(),
public_key = %public_key,
"changed native wallet protection password"
);
return std::result::Result::Ok(crate::WalletFileHandle {
alias: alias.clone(),
public_key: public_key.to_string(),
format_version: crate::KSWALLET_FORMAT_VERSION,
path,
});
}
}
async fn unlock_native_wallet_at_path(
path: std::path::PathBuf,
expected_alias: std::option::Option<&crate::WalletAlias>,
expected_handle: std::option::Option<&crate::WalletFileHandle>,
password: crate::WalletPassword,
) -> ks_core::Result<crate::UnlockedWallet> {
let container = match crate::read_native_wallet_container(&path).await {
std::result::Result::Ok(container) => container,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if let std::option::Option::Some(alias) = expected_alias {
if container.alias() != alias {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_alias_mismatch",
"native wallet alias does not match the requested identity",
));
}
}
if let std::option::Option::Some(handle) = expected_handle {
if handle.format_version != crate::KSWALLET_FORMAT_VERSION
|| container.alias() != &handle.alias
|| container.public_key().to_string() != handle.public_key
{
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_handle_stale",
"native wallet file no longer matches the inspected handle",
));
}
}
let alias = container.alias().clone();
let public_key = *container.public_key();
let keypair = match crate::unlock_native_wallet_keypair(container, password).await {
std::result::Result::Ok(keypair) => keypair,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
tracing::debug!(
target: crate::TRACING_TARGET,
action = "unlock_native_wallet",
wallet_alias = alias.as_str(),
public_key = %public_key,
"authenticated native wallet"
);
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
} }
fn has_native_wallet_extension(path: &std::path::Path) -> bool { fn has_native_wallet_extension(path: &std::path::Path) -> bool {

View File

@@ -1,8 +1,11 @@
// file: ks-wallet/src/native.rs // file: ks-wallet/src/native.rs
// version: 2 // version: 4
//! Strict reader and validator for native `.kswallet` containers. //! Native `.kswallet` codec, password protection and atomic file publication.
use chacha20poly1305::aead::Aead; // rust-rules: trait-import
use chacha20poly1305::aead::KeyInit; // rust-rules: trait-import
use solana_signer::Signer; // rust-rules: trait-import
use tokio::io::AsyncReadExt; // rust-rules: trait-import use tokio::io::AsyncReadExt; // rust-rules: trait-import
use zeroize::Zeroize; // rust-rules: trait-import use zeroize::Zeroize; // rust-rules: trait-import
@@ -23,21 +26,38 @@ const OFFSET_CIPHERTEXT_LENGTH: usize = 36;
const OFFSET_PUBLIC_KEY: usize = 40; const OFFSET_PUBLIC_KEY: usize = 40;
const RESERVED_LENGTH: usize = 4; const RESERVED_LENGTH: usize = 4;
const PUBLIC_KEY_LENGTH: usize = 32; const PUBLIC_KEY_LENGTH: usize = 32;
const TEMP_CREATE_ATTEMPTS: u64 = 16;
static NATIVE_TEMP_FILE_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[derive(Clone, Copy)]
struct NativeWalletKdfParameters { struct NativeWalletKdfParameters {
memory_kib: u32, memory_kib: u32,
iterations: u32, iterations: u32,
parallelism: u32, parallelism: u32,
} }
/// Parsed non-secret identity from one structurally valid native wallet container. impl NativeWalletKdfParameters {
fn version_one_default() -> Self {
return Self {
memory_kib: crate::KSWALLET_ARGON2_DEFAULT_MEMORY_KIB,
iterations: crate::KSWALLET_ARGON2_DEFAULT_ITERATIONS,
parallelism: crate::KSWALLET_ARGON2_DEFAULT_PARALLELISM,
};
}
}
/// Parsed native wallet container kept strictly inside the wallet boundary.
/// ///
/// The full file buffer, including salt, nonce and ciphertext, is zeroized after /// The container is intentionally neither public nor `Debug`. Ciphertext is
/// structural validation. This type therefore retains only the fields required /// retained only so an inspected file can later be authenticated and unlocked.
/// by discovery before authenticated unlock.
pub(crate) struct NativeWalletContainer { pub(crate) struct NativeWalletContainer {
alias: crate::WalletAlias, alias: crate::WalletAlias,
public_key: solana_pubkey::Pubkey, public_key: solana_pubkey::Pubkey,
kdf_parameters: NativeWalletKdfParameters,
salt: [u8; crate::KSWALLET_SALT_LENGTH],
nonce: [u8; crate::KSWALLET_NONCE_LENGTH],
ciphertext: zeroize::Zeroizing<std::vec::Vec<u8>>,
} }
impl crate::NativeWalletContainer { impl crate::NativeWalletContainer {
@@ -50,6 +70,25 @@ impl crate::NativeWalletContainer {
pub(crate) fn public_key(&self) -> &solana_pubkey::Pubkey { pub(crate) fn public_key(&self) -> &solana_pubkey::Pubkey {
return &self.public_key; return &self.public_key;
} }
fn authenticated_prefix(&self) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
return encode_authenticated_prefix(
&self.alias,
&self.public_key,
self.kdf_parameters,
&self.salt,
&self.nonce,
);
}
fn encode(&self) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
let mut bytes = match self.authenticated_prefix() {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
bytes.extend_from_slice(self.ciphertext.as_slice());
return std::result::Result::Ok(bytes);
}
} }
/// Reads and strictly validates one native wallet file with a pre-allocation size bound. /// Reads and strictly validates one native wallet file with a pre-allocation size bound.
@@ -122,6 +161,628 @@ pub(crate) async fn read_native_wallet_container(
return decoded; return decoded;
} }
/// Protects one exact Solana keypair payload with a caller-owned password.
pub(crate) async fn protect_native_wallet_keypair(
alias: crate::WalletAlias,
public_key: solana_pubkey::Pubkey,
keypair_bytes: zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]>,
password: crate::WalletPassword,
) -> ks_core::Result<crate::NativeWalletContainer> {
let task = tokio::task::spawn_blocking(move || {
return protect_native_wallet_keypair_blocking(alias, public_key, keypair_bytes, password);
});
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_native_crypto_task_failed",
"native wallet cryptographic task failed",
)),
};
}
/// Authenticates and decrypts one native wallet into a signing keypair.
pub(crate) async fn unlock_native_wallet_keypair(
container: crate::NativeWalletContainer,
password: crate::WalletPassword,
) -> ks_core::Result<solana_keypair::Keypair> {
let task = tokio::task::spawn_blocking(move || {
return unlock_native_wallet_keypair_blocking(container, password);
});
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_native_crypto_task_failed",
"native wallet cryptographic task failed",
)),
};
}
/// Publishes one new native wallet atomically without replacing an existing destination.
pub(crate) async fn write_native_wallet_file_atomic(
path: std::path::PathBuf,
container: &crate::NativeWalletContainer,
) -> ks_core::Result<()> {
let bytes = match container.encode() {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let alias = container.alias().clone();
let task = tokio::task::spawn_blocking(move || {
return write_native_wallet_file_atomic_blocking(path, alias, bytes);
});
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_native_write_task_failed",
"native wallet file publication task failed",
)),
};
}
/// Atomically replaces one authenticated native wallet during password rotation.
pub(crate) async fn replace_native_wallet_file_atomic(
path: std::path::PathBuf,
container: &crate::NativeWalletContainer,
) -> ks_core::Result<()> {
let bytes = match container.encode() {
std::result::Result::Ok(bytes) => bytes,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let alias = container.alias().clone();
let task = tokio::task::spawn_blocking(move || {
return replace_native_wallet_file_atomic_blocking(path, alias, bytes);
});
return match task.await {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new(
"wallet_native_write_task_failed",
"native wallet file replacement task failed",
)),
};
}
fn protect_native_wallet_keypair_blocking(
alias: crate::WalletAlias,
public_key: solana_pubkey::Pubkey,
keypair_bytes: zeroize::Zeroizing<[u8; crate::SOLANA_KEYPAIR_LENGTH]>,
password: crate::WalletPassword,
) -> ks_core::Result<crate::NativeWalletContainer> {
let kdf_parameters = NativeWalletKdfParameters::version_one_default();
let mut salt = [0_u8; crate::KSWALLET_SALT_LENGTH];
let mut nonce = [0_u8; crate::KSWALLET_NONCE_LENGTH];
if getrandom::fill(&mut salt).is_err() {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_random_failed",
"secure random generation failed",
));
}
if getrandom::fill(&mut nonce).is_err() {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_random_failed",
"secure random generation failed",
));
}
let authenticated_prefix =
match encode_authenticated_prefix(&alias, &public_key, kdf_parameters, &salt, &nonce) {
std::result::Result::Ok(prefix) => prefix,
std::result::Result::Err(error) => {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(error);
},
};
let key = match derive_native_wallet_key(password.as_bytes(), &salt, kdf_parameters) {
std::result::Result::Ok(key) => key,
std::result::Result::Err(error) => {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(error);
},
};
let cipher = match chacha20poly1305::XChaCha20Poly1305::new_from_slice(&*key) {
std::result::Result::Ok(cipher) => cipher,
std::result::Result::Err(_) => {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_aead_key_invalid",
"native wallet AEAD key length is invalid",
));
},
};
let xnonce = chacha20poly1305::XNonce::from(nonce);
let ciphertext = match cipher.encrypt(
&xnonce,
chacha20poly1305::aead::Payload {
msg: &*keypair_bytes,
aad: authenticated_prefix.as_slice(),
},
) {
std::result::Result::Ok(ciphertext) => ciphertext,
std::result::Result::Err(_) => {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_encrypt_failed",
"native wallet encryption failed",
));
},
};
if ciphertext.len() != crate::KSWALLET_CIPHERTEXT_LENGTH {
salt.zeroize();
nonce.zeroize();
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_ciphertext_length_invalid",
"native wallet encryption produced an invalid ciphertext length",
));
}
return std::result::Result::Ok(crate::NativeWalletContainer {
alias,
public_key,
kdf_parameters,
salt,
nonce,
ciphertext: zeroize::Zeroizing::new(ciphertext),
});
}
fn unlock_native_wallet_keypair_blocking(
container: crate::NativeWalletContainer,
password: crate::WalletPassword,
) -> ks_core::Result<solana_keypair::Keypair> {
let authenticated_prefix = match container.authenticated_prefix() {
std::result::Result::Ok(prefix) => prefix,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let key = match derive_native_wallet_key(
password.as_bytes(),
&container.salt,
container.kdf_parameters,
) {
std::result::Result::Ok(key) => key,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let cipher = match chacha20poly1305::XChaCha20Poly1305::new_from_slice(&*key) {
std::result::Result::Ok(cipher) => cipher,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_aead_key_invalid",
"native wallet AEAD key length is invalid",
));
},
};
let xnonce = chacha20poly1305::XNonce::from(container.nonce);
let plaintext = match cipher.decrypt(
&xnonce,
chacha20poly1305::aead::Payload {
msg: container.ciphertext.as_slice(),
aad: authenticated_prefix.as_slice(),
},
) {
std::result::Result::Ok(plaintext) => zeroize::Zeroizing::new(plaintext),
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_authentication_failed",
"native wallet password or authenticated data is invalid",
));
},
};
if plaintext.len() != crate::SOLANA_KEYPAIR_LENGTH {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_keypair_length_invalid",
"decrypted native wallet keypair length is invalid",
));
}
let keypair = match solana_keypair::Keypair::try_from(plaintext.as_slice()) {
std::result::Result::Ok(keypair) => keypair,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_keypair_invalid",
"decrypted native wallet keypair is invalid",
));
},
};
if keypair.pubkey() != container.public_key {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_public_key_mismatch",
"decrypted native wallet public key does not match the authenticated header",
));
}
return std::result::Result::Ok(keypair);
}
fn derive_native_wallet_key(
password: &[u8],
salt: &[u8; crate::KSWALLET_SALT_LENGTH],
parameters: NativeWalletKdfParameters,
) -> ks_core::Result<zeroize::Zeroizing<[u8; crate::KSWALLET_AEAD_KEY_LENGTH]>> {
match validate_kdf_parameters(&parameters) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let params = match argon2::Params::new(
parameters.memory_kib,
parameters.iterations,
parameters.parallelism,
std::option::Option::Some(crate::KSWALLET_AEAD_KEY_LENGTH),
) {
std::result::Result::Ok(params) => params,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_kdf_parameters_invalid",
"native wallet Argon2id parameters are invalid",
));
},
};
let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = zeroize::Zeroizing::new([0_u8; crate::KSWALLET_AEAD_KEY_LENGTH]);
if argon2.hash_password_into(password, salt, &mut *key).is_err() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_kdf_failed",
"native wallet Argon2id derivation failed",
));
}
return std::result::Result::Ok(key);
}
fn encode_authenticated_prefix(
alias: &crate::WalletAlias,
public_key: &solana_pubkey::Pubkey,
kdf_parameters: NativeWalletKdfParameters,
salt: &[u8; crate::KSWALLET_SALT_LENGTH],
nonce: &[u8; crate::KSWALLET_NONCE_LENGTH],
) -> ks_core::Result<zeroize::Zeroizing<std::vec::Vec<u8>>> {
match validate_kdf_parameters(&kdf_parameters) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let alias_bytes = alias.as_str().as_bytes();
let header_length = match crate::KSWALLET_FIXED_HEADER_LENGTH.checked_add(alias_bytes.len()) {
std::option::Option::Some(length) => length,
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_header_length_invalid",
"native wallet header length overflowed the platform size",
));
},
};
let header_length_u16 = match u16::try_from(header_length) {
std::result::Result::Ok(length) => length,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_header_length_invalid",
"native wallet header length cannot be encoded",
));
},
};
let alias_length = match u8::try_from(alias_bytes.len()) {
std::result::Result::Ok(length) => length,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_alias_length_invalid",
"native wallet alias length cannot be encoded",
));
},
};
let prefix_length = header_length + crate::KSWALLET_SALT_LENGTH + crate::KSWALLET_NONCE_LENGTH;
let mut bytes = zeroize::Zeroizing::new(vec![0_u8; prefix_length]);
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
bytes[OFFSET_FORMAT_VERSION..OFFSET_FORMAT_VERSION + 2]
.copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
bytes[OFFSET_HEADER_LENGTH..OFFSET_HEADER_LENGTH + 2]
.copy_from_slice(&header_length_u16.to_le_bytes());
bytes[OFFSET_FLAGS..OFFSET_FLAGS + 2]
.copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
bytes[OFFSET_KDF_ID] = crate::KSWALLET_KDF_ARGON2ID;
bytes[OFFSET_KDF_VERSION] = crate::KSWALLET_ARGON2_VERSION;
bytes[OFFSET_AEAD_ID] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
bytes[OFFSET_SALT_LENGTH] = crate::KSWALLET_SALT_LENGTH as u8;
bytes[OFFSET_NONCE_LENGTH] = crate::KSWALLET_NONCE_LENGTH as u8;
bytes[OFFSET_ALIAS_LENGTH] = alias_length;
bytes[OFFSET_MEMORY_KIB..OFFSET_MEMORY_KIB + 4]
.copy_from_slice(&kdf_parameters.memory_kib.to_le_bytes());
bytes[OFFSET_ITERATIONS..OFFSET_ITERATIONS + 4]
.copy_from_slice(&kdf_parameters.iterations.to_le_bytes());
bytes[OFFSET_PARALLELISM..OFFSET_PARALLELISM + 4]
.copy_from_slice(&kdf_parameters.parallelism.to_le_bytes());
bytes[OFFSET_CIPHERTEXT_LENGTH..OFFSET_CIPHERTEXT_LENGTH + 4]
.copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]
.copy_from_slice(&public_key.to_bytes());
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
let salt_offset = header_length;
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
bytes[salt_offset..nonce_offset].copy_from_slice(salt);
bytes[nonce_offset..].copy_from_slice(nonce);
return std::result::Result::Ok(bytes);
}
fn write_native_wallet_file_atomic_blocking(
path: std::path::PathBuf,
alias: crate::WalletAlias,
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
) -> ks_core::Result<()> {
match validate_native_destination(&path, &alias) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let directory = match path.parent() {
std::option::Option::Some(directory) => directory,
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_parent_missing",
"native wallet destination must have a parent directory",
));
},
};
match prepare_native_wallet_directory(directory) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if path.exists() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_already_exists",
"native wallet already exists for this alias",
));
}
let (temporary_path, mut file) = match create_private_temp_file(directory, &path) {
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(io_error("wallet_native_temp_write_failed", error));
}
if let std::result::Result::Err(error) = file.sync_all() {
let _ = std::fs::remove_file(&temporary_path);
return std::result::Result::Err(io_error("wallet_native_temp_sync_failed", error));
}
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_native_already_exists",
"native wallet already exists for this alias",
));
}
return std::result::Result::Err(io_error("wallet_native_publish_failed", error));
}
if let std::result::Result::Err(error) = sync_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(io_error("wallet_native_temp_remove_failed", error));
}
return sync_directory(directory);
}
fn replace_native_wallet_file_atomic_blocking(
path: std::path::PathBuf,
alias: crate::WalletAlias,
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
) -> ks_core::Result<()> {
match validate_native_destination(&path, &alias) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let directory = match path.parent() {
std::option::Option::Some(directory) => directory,
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_parent_missing",
"native wallet destination must have a parent directory",
));
},
};
match prepare_native_wallet_directory(directory) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_native_wallet_file_metadata_blocking(&path) {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let (temporary_path, mut file) = match create_private_temp_file(directory, &path) {
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(io_error("wallet_native_temp_write_failed", error));
}
if let std::result::Result::Err(error) = file.sync_all() {
let _ = std::fs::remove_file(&temporary_path);
return std::result::Result::Err(io_error("wallet_native_temp_sync_failed", error));
}
std::mem::drop(file);
if let std::result::Result::Err(error) = std::fs::rename(&temporary_path, &path) {
let _ = std::fs::remove_file(&temporary_path);
return std::result::Result::Err(io_error("wallet_native_replace_failed", error));
}
return sync_directory(directory);
}
fn create_private_temp_file(
directory: &std::path::Path,
destination: &std::path::Path,
) -> ks_core::Result<(std::path::PathBuf, std::fs::File)> {
let file_name = match destination.file_name().and_then(std::ffi::OsStr::to_str) {
std::option::Option::Some(file_name) => file_name,
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_filename_invalid",
"native wallet destination filename is invalid",
));
},
};
for _ in 0..TEMP_CREATE_ATTEMPTS {
let sequence = NATIVE_TEMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let temporary_path =
directory.join(format!(".{file_name}.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(io_error(
"wallet_native_temp_create_failed",
error,
));
},
}
}
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_temp_create_failed",
"native wallet temporary file name could not be reserved",
));
}
fn prepare_native_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> {
if directory.exists() {
let metadata = match std::fs::symlink_metadata(directory) {
std::result::Result::Ok(metadata) => metadata,
std::result::Result::Err(error) => {
return std::result::Result::Err(io_error(
"wallet_directory_metadata_failed",
error,
));
},
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_directory_type_invalid",
"wallet directory must be a directory and not a symlink",
));
}
return validate_private_directory_permissions(&metadata);
}
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt; // rust-rules: trait-import
builder.mode(0o700);
}
if let std::result::Result::Err(error) = builder.create(directory) {
return std::result::Result::Err(io_error("wallet_directory_create_failed", error));
}
let metadata = match std::fs::symlink_metadata(directory) {
std::result::Result::Ok(metadata) => metadata,
std::result::Result::Err(error) => {
return std::result::Result::Err(io_error("wallet_directory_metadata_failed", error));
},
};
return validate_private_directory_permissions(&metadata);
}
fn validate_private_directory_permissions(metadata: &std::fs::Metadata) -> ks_core::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return std::result::Result::Err(ks_core::Error::new(
"wallet_directory_permissions_too_open",
format!("wallet directory has mode {mode:o}; expected no group or other access"),
));
}
}
return std::result::Result::Ok(());
}
fn validate_native_destination(
path: &std::path::Path,
alias: &crate::WalletAlias,
) -> ks_core::Result<()> {
if path.extension()
!= std::option::Option::Some(std::ffi::OsStr::new(crate::KSWALLET_FILE_EXTENSION))
{
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_extension_invalid",
"native wallet file must use the .kswallet extension",
));
}
let stem = match path.file_stem().and_then(std::ffi::OsStr::to_str) {
std::option::Option::Some(stem) => stem,
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_alias_encoding_invalid",
"native wallet filename must contain a UTF-8 wallet alias",
));
},
};
if stem != alias.as_str() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_alias_mismatch",
"native wallet filename alias does not match the container alias",
));
}
return std::result::Result::Ok(());
}
fn validate_native_wallet_file_metadata_blocking(path: &std::path::Path) -> ks_core::Result<()> {
let metadata = match std::fs::symlink_metadata(path) {
std::result::Result::Ok(metadata) => metadata,
std::result::Result::Err(error) => {
return std::result::Result::Err(io_error("wallet_file_metadata_failed", error));
},
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_file_type_invalid",
"wallet file must be a regular file and not a symlink",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return std::result::Result::Err(ks_core::Error::new(
"wallet_file_permissions_too_open",
format!("wallet file has mode {mode:o}; expected no group or other access"),
));
}
}
return std::result::Result::Ok(());
}
fn sync_directory(directory: &std::path::Path) -> ks_core::Result<()> {
#[cfg(unix)]
{
let file = match std::fs::File::open(directory) {
std::result::Result::Ok(file) => file,
std::result::Result::Err(error) => {
return std::result::Result::Err(io_error(
"wallet_directory_sync_open_failed",
error,
));
},
};
if let std::result::Result::Err(error) = file.sync_all() {
return std::result::Result::Err(io_error("wallet_directory_sync_failed", error));
}
}
return std::result::Result::Ok(());
}
fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::NativeWalletContainer> { fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::NativeWalletContainer> {
if bytes.len() < crate::KSWALLET_MIN_FILE_LENGTH if bytes.len() < crate::KSWALLET_MIN_FILE_LENGTH
|| bytes.len() > crate::KSWALLET_MAX_FILE_LENGTH || bytes.len() > crate::KSWALLET_MAX_FILE_LENGTH
@@ -268,9 +929,21 @@ fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::Native
let mut public_key_bytes = [0_u8; PUBLIC_KEY_LENGTH]; let mut public_key_bytes = [0_u8; PUBLIC_KEY_LENGTH];
public_key_bytes public_key_bytes
.copy_from_slice(&bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]); .copy_from_slice(&bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]);
let salt_offset = header_length;
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
let mut salt = [0_u8; crate::KSWALLET_SALT_LENGTH];
salt.copy_from_slice(&bytes[salt_offset..nonce_offset]);
let mut nonce = [0_u8; crate::KSWALLET_NONCE_LENGTH];
nonce.copy_from_slice(&bytes[nonce_offset..ciphertext_offset]);
let ciphertext = zeroize::Zeroizing::new(bytes[ciphertext_offset..].to_vec());
return std::result::Result::Ok(crate::NativeWalletContainer { return std::result::Result::Ok(crate::NativeWalletContainer {
alias, alias,
public_key: solana_pubkey::Pubkey::new_from_array(public_key_bytes), public_key: solana_pubkey::Pubkey::new_from_array(public_key_bytes),
kdf_parameters,
salt,
nonce,
ciphertext,
}); });
} }

64
ks-wallet/src/password.rs Normal file
View File

@@ -0,0 +1,64 @@
// file: ks-wallet/src/password.rs
// version: 3
//! Non-clonable password boundary for native wallet protection.
const MAX_PASSWORD_LENGTH: usize = 1_024;
/// Password supplied explicitly for one native wallet operation.
///
/// The value is owned by a zeroizing wrapper, is neither clonable nor
/// serializable, and its `Debug` representation never exposes the password.
pub struct WalletPassword {
value: zeroize::Zeroizing<std::string::String>,
}
impl std::fmt::Debug for crate::WalletPassword {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("WalletPassword([REDACTED])");
}
}
impl crate::WalletPassword {
/// Takes ownership of a password string for one explicit wallet operation.
pub fn new(value: std::string::String) -> ks_core::Result<Self> {
let value = zeroize::Zeroizing::new(value);
let length = value.len();
if length == 0 || length > MAX_PASSWORD_LENGTH {
return std::result::Result::Err(ks_core::Error::new(
"wallet_password_length_invalid",
format!("wallet password length must be between 1 and {MAX_PASSWORD_LENGTH} bytes"),
));
}
return std::result::Result::Ok(Self { value });
}
/// Returns the password bytes only inside the `ks-wallet` boundary.
pub(crate) fn as_bytes(&self) -> &[u8] {
return self.value.as_bytes();
}
}
#[cfg(test)]
mod tests {
#[test]
fn debug_never_contains_password() {
let password = crate::WalletPassword::new("canary-wallet-password".to_owned())
.unwrap_or_else(|error| panic!("password must be accepted: {error}"));
let debug = format!("{password:?}");
assert!(!debug.contains("canary-wallet-password"));
assert!(debug.contains("REDACTED"));
}
#[test]
fn password_length_is_bounded() {
let empty = crate::WalletPassword::new(std::string::String::new())
.err()
.unwrap_or_else(|| panic!("empty password must fail"));
assert_eq!(empty.code(), "wallet_password_length_invalid");
let oversized = crate::WalletPassword::new("x".repeat(1_025))
.err()
.unwrap_or_else(|| panic!("oversized password must fail"));
assert_eq!(oversized.code(), "wallet_password_length_invalid");
}
}

108
ks-wallet/src/unlocked.rs Normal file
View File

@@ -0,0 +1,108 @@
// file: ks-wallet/src/unlocked.rs
// version: 2
//! Authenticated signing capability for one unlocked native wallet.
use solana_signer::Signer; // rust-rules: trait-import
/// Authenticated native wallet capability holding one Solana keypair in memory.
///
/// The type is intentionally non-clonable and does not expose private bytes.
/// Dropping or explicitly locking the value releases the in-memory keypair.
pub struct UnlockedWallet {
alias: crate::WalletAlias,
keypair: solana_keypair::Keypair,
}
impl std::fmt::Debug for crate::UnlockedWallet {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("UnlockedWallet")
.field("alias", &self.alias)
.field("public_key", &self.public_key())
.finish();
}
}
impl crate::UnlockedWallet {
/// Builds an authenticated signing capability from an already validated keypair.
pub(crate) fn new(alias: crate::WalletAlias, keypair: solana_keypair::Keypair) -> Self {
return Self { alias, keypair };
}
/// Returns the wallet alias.
pub fn alias(&self) -> &crate::WalletAlias {
return &self.alias;
}
/// Returns the wallet public key in base58 form.
pub fn public_key(&self) -> std::string::String {
return self.keypair.pubkey().to_string();
}
/// Returns the authenticated non-secret wallet identity.
pub fn identity(&self) -> crate::WalletIdentity {
return crate::WalletIdentity {
alias: self.alias.clone(),
public_key: self.public_key(),
persistence: crate::WalletPersistence::Persistent,
};
}
/// Returns the signer interface without exposing keypair bytes.
pub fn as_signer(&self) -> &dyn solana_signer::Signer {
return &self.keypair;
}
/// Returns the signer interface with the `Sync` auto-trait preserved.
pub fn as_sync_signer(&self) -> &(dyn solana_signer::Signer + std::marker::Sync) {
return &self.keypair;
}
/// 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) {
std::result::Result::Ok(signature) => signature,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_message_sign_failed",
error.to_string(),
));
},
};
tracing::debug!(
target: crate::TRACING_TARGET,
action = "sign_message",
wallet_alias = self.alias.as_str(),
public_key = %self.keypair.pubkey(),
message_length = message.len(),
"signed message with authenticated native wallet"
);
return std::result::Result::Ok(signature.to_string());
}
/// Explicitly consumes and locks this signing capability.
pub fn lock(self) {
return;
}
}
#[cfg(test)]
mod tests {
fn assert_send_sync<T: std::marker::Send + std::marker::Sync>() {}
#[test]
fn authenticated_wallet_is_send_and_sync() {
assert_send_sync::<crate::UnlockedWallet>();
}
#[test]
fn debug_exposes_only_non_secret_identity() {
let alias = crate::WalletAlias::parse("unlocked-debug")
.unwrap_or_else(|error| panic!("alias must be valid: {error}"));
let wallet = crate::UnlockedWallet::new(alias, solana_keypair::Keypair::new());
let debug = format!("{wallet:?}");
assert!(debug.contains("unlocked-debug"));
assert!(!debug.contains("keypair"));
}
}

View File

@@ -0,0 +1,173 @@
// file: ks-wallet/tests/native_password.rs
// version: 1
//! External password-protected native wallet lifecycle tests.
fn make_directory_private(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap_or_else(
|error| panic!("wallet fixture directory permissions must be set: {error}"),
);
}
}
fn make_file_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(0o600))
.unwrap_or_else(|error| panic!("wallet fixture permissions must be set: {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}"));
}
#[tokio::test]
async fn native_wallet_password_lifecycle_preserves_keypair_and_supports_external_open() {
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 alias = ks_wallet::WalletAlias::parse("protected")
.unwrap_or_else(|error| panic!("alias must be valid: {error}"));
let created = manager
.create(alias.clone(), password("initial-password"))
.await
.unwrap_or_else(|error| panic!("native wallet creation must succeed: {error}"));
let public_key = created.public_key();
let signature = created
.sign_message(b"ks-wallet password lifecycle")
.unwrap_or_else(|error| panic!("unlocked wallet must sign: {error}"));
assert!(!signature.is_empty());
created.lock();
let handle = manager
.lookup(&alias)
.await
.unwrap_or_else(|error| panic!("lookup must succeed: {error}"))
.unwrap_or_else(|| panic!("created wallet must be discoverable"));
assert_eq!(handle.public_key(), public_key);
let external_directory = tempfile::tempdir()
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
let external_path = external_directory.path().join("protected.kswallet");
std::fs::copy(directory.path().join("protected.kswallet"), &external_path)
.unwrap_or_else(|error| panic!("external fixture copy must succeed: {error}"));
make_file_private(&external_path);
let external_handle = manager
.inspect_file(&external_path)
.await
.unwrap_or_else(|error| panic!("external inspection must succeed: {error}"));
let external = manager
.unlock_file(&external_handle, password("initial-password"))
.await
.unwrap_or_else(|error| panic!("external wallet unlock must succeed: {error}"));
assert_eq!(external.public_key(), public_key);
external.lock();
let changed = manager
.change_password(&alias, password("initial-password"), password("replacement-password"))
.await
.unwrap_or_else(|error| panic!("password change must succeed: {error}"));
assert_eq!(changed.public_key(), public_key);
let old_password_error = manager
.unlock(&alias, password("initial-password"))
.await
.err()
.unwrap_or_else(|| panic!("old password must no longer unlock the wallet"));
assert_eq!(old_password_error.code(), "wallet_native_authentication_failed");
assert!(!old_password_error.to_string().contains("initial-password"));
let reopened = manager
.unlock(&alias, password("replacement-password"))
.await
.unwrap_or_else(|error| panic!("replacement password must unlock: {error}"));
assert_eq!(reopened.public_key(), public_key);
reopened.lock();
let bytes = std::fs::read(directory.path().join("protected.kswallet"))
.unwrap_or_else(|error| panic!("native wallet must be readable for canary check: {error}"));
assert!(
!bytes
.windows("initial-password".len())
.any(|window| return window == b"initial-password")
);
assert!(
!bytes
.windows("replacement-password".len())
.any(|window| return window == b"replacement-password")
);
}
#[tokio::test]
async fn native_creation_is_private_and_refuses_overwrite() {
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 alias = ks_wallet::WalletAlias::parse("no-overwrite")
.unwrap_or_else(|error| panic!("alias must be valid: {error}"));
let created = manager
.create(alias.clone(), password("first-password"))
.await
.unwrap_or_else(|error| panic!("native wallet creation must succeed: {error}"));
let public_key = created.public_key();
created.lock();
let path = directory.path().join("no-overwrite.kswallet");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
let metadata = std::fs::metadata(&path)
.unwrap_or_else(|error| panic!("native wallet metadata must be readable: {error}"));
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
}
let error = manager
.create(alias.clone(), password("second-password"))
.await
.err()
.unwrap_or_else(|| panic!("duplicate native wallet creation must fail"));
assert_eq!(error.code(), "wallet_native_already_exists");
let handle = manager
.lookup(&alias)
.await
.unwrap_or_else(|error| panic!("existing native wallet lookup must succeed: {error}"))
.unwrap_or_else(|| panic!("existing native wallet must remain discoverable"));
assert_eq!(handle.public_key(), public_key);
}
#[tokio::test]
async fn authenticated_header_tampering_fails_before_signing_capability() {
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 alias = ks_wallet::WalletAlias::parse("tamper")
.unwrap_or_else(|error| panic!("alias must be valid: {error}"));
let created = manager
.create(alias.clone(), password("tamper-password"))
.await
.unwrap_or_else(|error| panic!("native wallet creation must succeed: {error}"));
created.lock();
let path = directory.path().join("tamper.kswallet");
let mut bytes = std::fs::read(&path)
.unwrap_or_else(|error| panic!("native wallet fixture must be readable: {error}"));
bytes[40] ^= 0x01;
std::fs::write(&path, bytes)
.unwrap_or_else(|error| panic!("tampered fixture must be writable: {error}"));
make_file_private(&path);
let handle = manager
.lookup(&alias)
.await
.unwrap_or_else(|error| panic!("structural lookup must succeed: {error}"))
.unwrap_or_else(|| panic!("tampered wallet must remain structurally discoverable"));
let error = manager
.unlock_file(&handle, password("tamper-password"))
.await
.err()
.unwrap_or_else(|| panic!("authenticated header tampering must fail"));
assert_eq!(error.code(), "wallet_native_authentication_failed");
assert!(!error.to_string().contains("tamper-password"));
}