v0.5.2-pre.003
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# file: Cargo.toml
|
||||
# version: 57
|
||||
# version: 58
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.2-pre.2"
|
||||
version = "0.5.2-pre.3"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3"
|
||||
|
||||
211
docs/NATIVE_FORMAT.md
Normal file
211
docs/NATIVE_FORMAT.md
Normal file
@@ -0,0 +1,211 @@
|
||||
<!-- file: docs/NATIVE_FORMAT.md -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# Format natif `.kswallet` — version 1
|
||||
|
||||
## Statut
|
||||
|
||||
Ce document fixe le codec binaire de la version `1` introduit par `0.5.2-pre.003`.
|
||||
|
||||
`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`.
|
||||
|
||||
Un fichier natif porte le nom :
|
||||
|
||||
```text
|
||||
<alias>.kswallet
|
||||
```
|
||||
|
||||
Tous les entiers multi-octets sont encodés en **little-endian**.
|
||||
|
||||
## Objectifs du format v1
|
||||
|
||||
Le conteneur doit :
|
||||
|
||||
- être identifiable sans heuristique ;
|
||||
- distinguer clairement sa version et ses algorithmes ;
|
||||
- exposer uniquement l'identité publique nécessaire avant unlock ;
|
||||
- borner toutes les longueurs et paramètres avant allocation ou KDF ;
|
||||
- authentifier avec l'AEAD toutes les métadonnées influençant identité et déchiffrement ;
|
||||
- permettre une publication atomique sans écrasement silencieux ;
|
||||
- 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`.
|
||||
|
||||
## Layout binaire v1
|
||||
|
||||
Le header fixe occupe **72 octets**.
|
||||
|
||||
| Offset | Taille | Champ | Contrat v1 |
|
||||
|--------------:|-----------:|----------------------|-------------------------------------------------|
|
||||
| `0` | `8` | magic | ASCII `KSWALLET` |
|
||||
| `8` | `2` | format version | `1` |
|
||||
| `10` | `2` | header length | `72 + alias_length` |
|
||||
| `12` | `2` | flags | `0` |
|
||||
| `14` | `1` | KDF id | `1` = Argon2id |
|
||||
| `15` | `1` | KDF version | `0x13` = Argon2 v19 |
|
||||
| `16` | `1` | AEAD id | `1` = XChaCha20-Poly1305 |
|
||||
| `17` | `1` | salt length | `16` |
|
||||
| `18` | `1` | nonce length | `24` |
|
||||
| `19` | `1` | alias length | `1..64` |
|
||||
| `20` | `4` | reserved | quatre octets `0` |
|
||||
| `24` | `4` | Argon2 memory KiB | borné, voir ci-dessous |
|
||||
| `28` | `4` | Argon2 passes | borné, voir ci-dessous |
|
||||
| `32` | `4` | Argon2 parallelism | borné, voir ci-dessous |
|
||||
| `36` | `4` | ciphertext length | `80` |
|
||||
| `40` | `32` | Solana pubkey | 32 octets bruts |
|
||||
| `72` | variable | alias | ASCII validé par `WalletAlias` |
|
||||
| après alias | `16` | salt | sel Argon2id |
|
||||
| après salt | `24` | nonce | nonce XChaCha20-Poly1305 |
|
||||
| après nonce | `80` | ciphertext | keypair 64 octets protégé + tag AEAD 16 octets |
|
||||
|
||||
Aucun octet supplémentaire n'est accepté après le ciphertext déclaré.
|
||||
|
||||
## Identité publique et payload secret
|
||||
|
||||
L'alias et la pubkey sont disponibles avant unlock afin de permettre :
|
||||
|
||||
- le scan et le lookup ;
|
||||
- l'affichage d'une identité non sensible ;
|
||||
- la détection des collisions ;
|
||||
- le choix du wallet par un consommateur.
|
||||
|
||||
Le secret Ed25519 n'est jamais placé dans ce header. Le plaintext protégé de la version `1` est exactement le keypair Solana brut de **64 octets** déjà caractérisé par le format CLI legacy. XChaCha20-Poly1305 ajoute un tag de 16 octets : le ciphertext v1 est donc exactement de **80 octets**. Toute évolution de la structure secrète nécessite une nouvelle version du format.
|
||||
|
||||
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.
|
||||
|
||||
## KDF v1
|
||||
|
||||
Le KDF sélectionné est **Argon2id version 19**.
|
||||
|
||||
Le profil d'écriture par défaut retenu est :
|
||||
|
||||
```text
|
||||
memory = 65536 KiB (64 MiB)
|
||||
passes = 3
|
||||
parallelism = 4 lanes
|
||||
salt = 16 bytes
|
||||
output key = 32 bytes
|
||||
```
|
||||
|
||||
Ce profil correspond au second profil recommandé par RFC 9106 pour les environnements à mémoire contrainte et produit la clé de 256 bits requise par l'AEAD choisi.
|
||||
|
||||
Le codec accepte uniquement des paramètres dans les bornes suivantes :
|
||||
|
||||
```text
|
||||
memory = 65536..262144 KiB
|
||||
passes = 3..10
|
||||
parallelism = 1..8
|
||||
```
|
||||
|
||||
La mémoire déclarée doit en plus respecter la relation Argon2 `m >= 8 * p` et être un multiple de `4 * p` afin que la valeur encodée corresponde sans ambiguïté au nombre de blocs réellement retenu.
|
||||
|
||||
Ces bornes servent deux objectifs distincts :
|
||||
|
||||
- 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`.
|
||||
|
||||
## AEAD v1
|
||||
|
||||
L'AEAD sélectionné est **XChaCha20-Poly1305** :
|
||||
|
||||
```text
|
||||
key = 32 bytes
|
||||
nonce = 24 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.
|
||||
|
||||
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.
|
||||
|
||||
## Données authentifiées
|
||||
|
||||
Le contrat v1 réserve comme **AEAD associated data** l'intégralité des octets précédant le ciphertext :
|
||||
|
||||
```text
|
||||
fixed header
|
||||
+ alias
|
||||
+ salt
|
||||
+ nonce
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Bornes du codec
|
||||
|
||||
Le codec v1 impose :
|
||||
|
||||
```text
|
||||
alias <= 64 bytes
|
||||
plaintext keypair = 64 bytes
|
||||
ciphertext + tag = 80 bytes
|
||||
native file total = 193..256 bytes
|
||||
```
|
||||
|
||||
La taille du fichier est vérifiée à partir des métadonnées du fichier ouvert **avant** l'allocation du buffer de lecture. Après lecture des octets attendus, le lecteur vérifie également qu'aucun octet supplémentaire n'est apparu pendant l'opération. La somme exacte des longueurs déclarées doit être égale à la taille réelle ; truncation, croissance concurrente et trailing bytes sont refusés.
|
||||
|
||||
Les flags inconnus, IDs KDF/AEAD inconnus, version Argon2 inconnue et octets reserved non nuls sont refusés.
|
||||
|
||||
## Permissions et publication
|
||||
|
||||
Sous Unix :
|
||||
|
||||
```text
|
||||
wallet directory : 0700
|
||||
.kswallet file : 0600
|
||||
```
|
||||
|
||||
La publication que `pre.004` devra implémenter pour produire un conteneur v1 suit cette séquence :
|
||||
|
||||
1. encoder entièrement le conteneur ;
|
||||
2. créer dans le même répertoire un fichier temporaire privé avec `create_new` ;
|
||||
3. écrire le contenu complet ;
|
||||
4. `sync_all` du fichier temporaire ;
|
||||
5. publier sans remplacement par hard-link vers `<alias>.kswallet` ;
|
||||
6. synchroniser le répertoire parent sous Unix ;
|
||||
7. supprimer le nom temporaire ;
|
||||
8. synchroniser à nouveau le répertoire parent sous Unix.
|
||||
|
||||
L'utilisation d'un hard-link de même répertoire garantit que la destination existante n'est jamais remplacée silencieusement. Si le filesystem ne permet pas cette publication, l'opération échoue au lieu de revenir à une écriture destructive.
|
||||
|
||||
## Non-divulgation
|
||||
|
||||
Les structures internes contenant sel, nonce ou ciphertext :
|
||||
|
||||
- ne sont pas une API crate-root publique ;
|
||||
- n'implémentent pas `Debug` automatiquement ;
|
||||
- ne sont pas sérialisées vers Tauri ;
|
||||
- ne doivent pas apparaître dans les erreurs ou logs.
|
||||
|
||||
`WalletFileHandle` ne projette que :
|
||||
|
||||
- alias ;
|
||||
- pubkey déclarée ;
|
||||
- version du format ;
|
||||
- identité persistante non sensible.
|
||||
|
||||
Le chemin local reste conservé en interne pour permettre l'ouverture ultérieure du fichier sélectionné.
|
||||
|
||||
## Suite en `pre.004`
|
||||
|
||||
`pre.004` devra compléter ce codec sans modifier silencieusement son layout pour :
|
||||
|
||||
- encoder le conteneur de création et publier le fichier atomiquement/no-clobber selon la séquence ci-dessus ;
|
||||
- dériver une clé avec Argon2id ;
|
||||
- chiffrer/déchiffrer les 64 octets du keypair avec XChaCha20-Poly1305 ;
|
||||
- vérifier que la pubkey dérivée du keypair déchiffré est exactement celle du header ;
|
||||
- créer une capacité de signature seulement après authentification réussie ;
|
||||
- changer le mot de passe en conservant la même keypair/pubkey ;
|
||||
- zéroïser les buffers plaintext et clés dérivées possédés par `ks-wallet`.
|
||||
|
||||
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
|
||||
|
||||
- RFC 9106 — Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications, notamment le second profil recommandé Argon2id `t=3`, `p=4`, `m=64 MiB`, sel 128 bits et sortie 256 bits.
|
||||
- Documentation RustCrypto `argon2` 0.5 — dérivation de clé Argon2id et paramètres explicites.
|
||||
- Documentation RustCrypto `chacha20poly1305` 0.11 — contrat `XChaCha20Poly1305`, clé 32 octets, nonce 24 octets et tag 16 octets, ainsi que la note de statut de spécification XChaCha.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/README.md -->
|
||||
<!-- version: 33 -->
|
||||
<!-- version: 34 -->
|
||||
|
||||
# Documentation active de Khadhroony Bot3
|
||||
|
||||
@@ -52,6 +52,7 @@ Les audits et rapports de travail `0.4.8-pre.*` sont archivés sous `../olddocs/
|
||||
- [`IDL_TO_KB_LIB_NOMENCLATURE.md`](IDL_TO_KB_LIB_NOMENCLATURE.md) ;
|
||||
- [`MISSING_PROGRAM_IDLS.md`](MISSING_PROGRAM_IDLS.md) ;
|
||||
- [`OPERATION_NAMING_CONVENTION.md`](OPERATION_NAMING_CONVENTION.md) ;
|
||||
- [`NATIVE_FORMAT.md`](NATIVE_FORMAT.md) : format binaire natif versionné de `ks-wallet` ;
|
||||
- [`IDEA_REMINDERS.md`](IDEA_REMINDERS.md).
|
||||
|
||||
Les idées ne rejoignent un `TODO.md` qu’après confirmation, attribution et reformulation en tâche vérifiable.
|
||||
@@ -105,7 +106,7 @@ Les preuves détaillées de `0.4.8-pre.*` restent accessibles sous `../olddocs/a
|
||||
|
||||
Les plans temporaires `0.5.0` et `0.5.1` sont clôturés et archivés sous `../olddocs/archivekbot3/docs/plans/`.
|
||||
|
||||
Le plan détaillé de `0.5.2` sera créé dans sa première prerelease conformément au cycle de développement ; aucun plan temporaire `0.5.2` n’est préécrit avant cet inventaire.
|
||||
Le plan temporaire détaillé de `0.5.2` est actif sous [`plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md`](plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md) et sera archivé lors de la dernière prerelease de `0.5.2`.
|
||||
|
||||
## 11. Prompt de reprise
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md -->
|
||||
<!-- version: 4 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# Plan `0.5.2` — restructuration de `ks-wallet`
|
||||
|
||||
@@ -132,9 +132,19 @@ Le format binaire devra au minimum permettre d'identifier sans ambiguïté :
|
||||
- le payload protégé ;
|
||||
- les informations d'intégrité/authentification nécessaires.
|
||||
|
||||
La disposition exacte des champs, l'algorithme AEAD, le KDF, les tailles de nonce/sel et leurs paramètres ne sont **pas** décidés dans `pre.001`. Ils doivent être choisis après vérification des versions réellement résolues des dépendances et des exigences de sécurité.
|
||||
`pre.003` ferme cette décision technique pour le format v1 après vérification des dépendances et des références cryptographiques :
|
||||
|
||||
`argon2`, `chacha20poly1305` et `zeroize` existent déjà dans les dépendances du workspace, mais leur présence ne suffit pas à fixer le contrat.
|
||||
- KDF : Argon2id version 19 ;
|
||||
- profil d'écriture par défaut : 64 MiB, 3 passes, 4 lanes, sel 16 octets, sortie 32 octets ;
|
||||
- AEAD : XChaCha20-Poly1305, clé 32 octets, nonce 24 octets, tag 16 octets ;
|
||||
- header fixe : 72 octets little-endian avant l'alias ;
|
||||
- plaintext v1 : exactement les 64 octets du keypair Solana ;
|
||||
- ciphertext v1 : exactement 80 octets avec le tag AEAD ;
|
||||
- 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 ;
|
||||
- données authentifiées réservées pour `pre.004` : 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`.
|
||||
|
||||
## 4. Caractérisation du legacy actuel
|
||||
|
||||
@@ -480,15 +490,21 @@ Le découpage reste borné mais peut être ajusté si une tranche devient trop l
|
||||
|
||||
Le préambule d'identification introduit ici ne définit pas encore le payload protégé : le codec complet, les bornes du conteneur, KDF et AEAD restent dans `pre.003`.
|
||||
|
||||
### `0.5.2-pre.003` — codec et stockage `.kswallet`
|
||||
### `0.5.2-pre.003` — format et décodage `.kswallet`
|
||||
|
||||
- spécification binaire versionnée ;
|
||||
- sélection et justification KDF/AEAD ;
|
||||
- encode/decode stricts ;
|
||||
- bornes de taille ;
|
||||
- permissions ;
|
||||
- écriture atomique ;
|
||||
- tests corruption/version/atomicité.
|
||||
- [x] spécification binaire v1 documentée dans `docs/NATIVE_FORMAT.md` ;
|
||||
- [x] Argon2id v19 + XChaCha20-Poly1305 retenus et identifiés explicitement dans le header ;
|
||||
- [x] profil KDF par défaut 64 MiB / 3 passes / 4 lanes et bornes anti-DoS ;
|
||||
- [x] décodage/validation stricts avec rejet des versions, flags, algorithmes, réserves et longueurs inconnus ;
|
||||
- [x] plaintext keypair v1 fixé à 64 octets, ciphertext/tag à 80 octets et fichier total à 193..256 octets ;
|
||||
- [x] lecture bornée avant allocation et détection d'une croissance concurrente ;
|
||||
- [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] validation des permissions privées à la lecture ;
|
||||
- [ ] encodage de création et publication atomique/no-clobber — `pre.004` ;
|
||||
- [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`.
|
||||
|
||||
### `0.5.2-pre.004` — password et cycle de vie persistant
|
||||
|
||||
@@ -587,7 +603,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` ;
|
||||
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 ;
|
||||
17. les détails KDF/AEAD et le modèle runtime d'un wallet ouvert seront décidés dans leurs tranches techniques, après tests et vérification des dépendances ;
|
||||
17. le format v1 utilise Argon2id v19 et XChaCha20-Poly1305 selon `docs/NATIVE_FORMAT.md`, tandis que le modèle runtime d’un wallet ouvert reste à fermer en `pre.004` ;
|
||||
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`.
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
<!-- file: ks-wallet/CHANGELOG.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 14 -->
|
||||
|
||||
# CHANGELOG — ks-wallet
|
||||
|
||||
## `0.5.2-pre.003`
|
||||
|
||||
- fixe et documente dans `docs/NATIVE_FORMAT.md` le layout binaire strict `.kswallet` version `1` ;
|
||||
- sélectionne Argon2id v19 et XChaCha20-Poly1305 comme KDF/AEAD du format v1 sans encore traiter de mot de passe ;
|
||||
- retient comme profil KDF d'écriture par défaut 64 MiB, 3 passes, 4 lanes, sel 16 octets et sortie 32 octets, avec bornes anti-DoS ;
|
||||
- fixe le payload secret v1 à la keypair Solana brute de 64 octets et le ciphertext/tag à exactement 80 octets, pour une taille totale `.kswallet` de 193 à 256 octets ;
|
||||
- ajoute le décodage/validation internes stricts pour magic/version, flags, IDs crypto, paramètres KDF, pubkey, alias, sel, nonce et ciphertext ;
|
||||
- réserve header + alias + sel + nonce comme données authentifiées de l'AEAD pour `pre.004` ;
|
||||
- enrichit `WalletFileHandle` de la pubkey déclarée et de la version du format sans exposer le chemin ;
|
||||
- ajoute une lecture bornée avant allocation et rejette truncation, trailing bytes et croissance concurrente du fichier ;
|
||||
- ajoute les tests du décodage strict, des bornes KDF/ciphertext, de l'alias et de la réouverture bornée ;
|
||||
- laisse volontairement l'encodage de création, la publication atomique, le password, la dérivation Argon2id, l'appel AEAD et la capacité de signature ouverts à `pre.004`.
|
||||
|
||||
### `pre.003-delta-fix-001`
|
||||
|
||||
- déplace la spécification normative de `ks-wallet/NATIVE_FORMAT.md` vers `docs/NATIVE_FORMAT.md` et aligne son tableau de layout pour une lecture Markdown plus claire ;
|
||||
- corrige les tests `native.rs` pour respecter la règle `super::Item` sur les éléments privés du module parent ;
|
||||
- retire de `pre.003` le code d'encodage/publication et les defaults KDF préparés prématurément pour `pre.004`, afin de ne conserver aucun dead code ni import inutilisé ;
|
||||
- borne `pre.003` au décodage/validation stricts et à la lecture nécessaire au scan, sans changer le layout v1 documenté.
|
||||
|
||||
### `pre.003-delta-fix-002`
|
||||
|
||||
- corrige les fixtures de tests `WalletManager::scan()` afin que les répertoires temporaires utilisés sous Unix soient explicitement privés en mode `0700` ;
|
||||
- conserve inchangée la validation runtime qui refuse un répertoire wallet accessible au groupe ou aux autres utilisateurs ;
|
||||
- corrige les deux échecs `scan_is_non_recursive_and_filters_by_native_extension` et `explicit_file_inspection_accepts_path_outside_configured_directory` sans assouplir le contrat de sécurité.
|
||||
|
||||
### `pre.003-delta-fix-003`
|
||||
|
||||
- corrige la fixture du test externe `crate_root_exposes_multi_wallet_discovery_without_path_disclosure` afin que son répertoire temporaire respecte lui aussi le mode Unix `0700` exigé par `WalletManager::scan()` ;
|
||||
- ne modifie aucune validation runtime, aucun codec `.kswallet` et aucune surface publique ;
|
||||
- complète le correctif de permissions de `fix-002` sur le dernier test externe qui dépendait encore de l'umask de la machine.
|
||||
|
||||
## `0.5.2-pre.002`
|
||||
|
||||
- ajoute `WalletManager` pour la découverte non récursive des `.kswallet` dans le répertoire configuré et le lookup par alias ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: ks-wallet/Cargo.toml
|
||||
# version: 3
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "ks-wallet"
|
||||
@@ -12,6 +12,7 @@ publish.workspace = true
|
||||
ks-core = { path = "../ks-core" }
|
||||
serde_json.workspace = true
|
||||
solana-keypair.workspace = true
|
||||
solana-pubkey.workspace = true
|
||||
solana-signer.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ks-wallet/README.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# ks-wallet
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
|
||||
## État actuel
|
||||
|
||||
En `0.5.2-pre.002`, la crate possède désormais la première API multi-wallet sans encore créer ni déchiffrer de conteneur natif persistant. Le legacy Solana JSON reste utilisé par les consommateurs historiques pendant la transition.
|
||||
En `0.5.2-pre.003`, la crate possède désormais le décodage/validation stricts du format binaire `.kswallet` v1 nécessaires au scan. L'encodage de création, la publication atomique, le password et le chiffrement/déchiffrement réel restent pour `pre.004`; le legacy Solana JSON reste utilisé par les consommateurs historiques pendant la transition.
|
||||
|
||||
La crate fournit actuellement :
|
||||
|
||||
- alias validés ;
|
||||
- identité publique minimale `WalletIdentity` sans chemin local ;
|
||||
- `WalletManager` pour scanner/lookup les `.kswallet` du répertoire configuré ;
|
||||
- `WalletManager` pour scanner/lookup les `.kswallet` structurellement valides du répertoire configuré ;
|
||||
- inspection explicite d'un `.kswallet` sélectionné hors du store via un handle opaque ;
|
||||
- décodage v1 strict avec identité publique déclarée, paramètres KDF/AEAD bornés et payload keypair de taille exacte ;
|
||||
- wallet temporaire en mémoire ;
|
||||
- stockage legacy local JSON d'un keypair Solana ;
|
||||
- création exclusive avec refus d'écrasement et reprise des courses `load_or_create` ;
|
||||
@@ -42,7 +43,7 @@ La persistance legacy écrit encore directement le contenu dans le chemin final
|
||||
- importer et exporter obligatoirement le format keypair JSON des binaires Solana ;
|
||||
- documenter les formats compatibles des principaux wallets Solana, implémenter un adaptateur tiers d'exemple et reporter les autres au TODO.
|
||||
|
||||
Le format `.kswallet` sera propre à `ks-wallet`, mais sa protection cryptographique utilisera des primitives standards. `pre.002` ne fixe que son préambule d'identification minimal (`KSWALLET` + version `1`) afin de rendre scan et lookup exécutables ; le payload protégé n'existe pas encore. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet. Le détail du KDF, de l'AEAD et du reste du layout binaire sera fixé dans les tranches techniques après validation des dépendances réellement résolues.
|
||||
Le format `.kswallet` est propre à `ks-wallet`, mais sa protection cryptographique utilise des primitives établies : le format v1 fixe Argon2id v19 et XChaCha20-Poly1305. Le layout normatif est documenté dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). Le payload secret v1 est exactement la keypair Solana brute de 64 octets ; avec le tag AEAD, le ciphertext est fixé à 80 octets. `pre.003` ne réalise encore aucune dérivation ni encryption réelle. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet.
|
||||
|
||||
Le scan automatique reste strictement borné au répertoire fourni à `WalletManager`. Un programme peut néanmoins demander l'inspection d'un autre fichier `.kswallet` choisi explicitement, par exemple via un file browser desktop ; cette opération ne modifie pas le store et ne rend pas le chemin public.
|
||||
|
||||
@@ -56,6 +57,7 @@ Une application desktop doit projeter les informations autorisées dans ses prop
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Format natif `.kswallet`](../docs/NATIVE_FORMAT.md)
|
||||
- [USAGE.md](USAGE.md)
|
||||
- [TODO.md](TODO.md)
|
||||
- [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ks-wallet/TODO.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 10 -->
|
||||
|
||||
# TODO — ks-wallet
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
- [x] caractériser par tests externes le format legacy `<alias>.json`, ses erreurs et ses permissions.
|
||||
- [x] définir l'identité publique minimale d'un wallet et le lookup par alias.
|
||||
- [x] introduire le manager, le scan borné et le lookup des `.kswallet` identifiables dans le store résolu ; renforcer la validation avec le codec complet en `pre.003`.
|
||||
- [x] introduire le manager, le scan borné et le lookup des `.kswallet` structurellement valides dans le store résolu.
|
||||
- [x] conserver les wallets temporaires ou jetables purement en mémoire et leur frontière de signature actuelle.
|
||||
- [x] permettre l'inspection explicite d'un `.kswallet` hors store sans mutation de la configuration ni exposition publique du chemin.
|
||||
- [ ] spécifier puis implémenter le format natif binaire versionné `<alias>.kswallet`.
|
||||
- [ ] sélectionner et documenter KDF/AEAD/paramètres après vérification des dépendances résolues.
|
||||
- [x] spécifier le format natif binaire v1 `<alias>.kswallet` et implémenter son décodage/validation stricts pour le scan.
|
||||
- [ ] `0.5.2-pre.004` — implémenter l'encodage de création et la publication atomique/no-clobber du conteneur natif.
|
||||
- [x] sélectionner et documenter Argon2id v19 / XChaCha20-Poly1305 et leurs paramètres/bornes v1.
|
||||
- [ ] créer/ouvrir un wallet persistant avec mot de passe sans exposer les bytes privés.
|
||||
- [ ] permettre le changement de mot de passe en rechiffrant exactement la même keypair et donc en conservant la même pubkey.
|
||||
- [x] préserver la frontière `solana_signer::Signer` compatible avec les consommateurs sans dépendance de `ks-lib` vers `ks-wallet`.
|
||||
@@ -27,5 +28,5 @@
|
||||
- [ ] normaliser la sélection par alias dans `ks-config` sans secret.
|
||||
- [ ] adapter les consommateurs et le desktop uniquement via des surfaces non sensibles.
|
||||
- [ ] retirer secrets et chemins locaux inutiles des logs, erreurs, diagnostics et DTO.
|
||||
- [ ] tester permissions privées, atomicité, corruption, concurrence réellement utilisée et non-divulgation.
|
||||
- [ ] compléter les tests de permissions privées, atomicité, corruption authentifiée, concurrence réellement utilisée et non-divulgation lors des tranches password/migration.
|
||||
- [ ] produire le guide de sécurité et la documentation finale avant clôture de `0.5.2`.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: ks-wallet/USAGE.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Utilisation de ks-wallet
|
||||
|
||||
## Statut
|
||||
|
||||
En `0.5.2-pre.002`, l'API multi-wallet de découverte est introduite, tandis que le password, le codec complet et la création persistante `.kswallet` restent planifiés pour les prereleases suivantes.
|
||||
En `0.5.2-pre.003`, le manager valide désormais la structure complète du conteneur binaire `.kswallet` v1 avant de retourner un handle. Le password, le chiffrement/déchiffrement réel et la création persistante publique arrivent en `pre.004`.
|
||||
|
||||
## Valider un alias
|
||||
|
||||
@@ -144,7 +144,7 @@ println!("wallet={}", wallet.public_key());
|
||||
|
||||
Le contenu legacy est toutefois écrit directement dans le chemin final. La publication n'est pas encore crash-safe/atomique par fichier temporaire + renommage.
|
||||
|
||||
## Manager multi-wallet introduit en `pre.002`
|
||||
## Manager multi-wallet et codec v1
|
||||
|
||||
Le manager reçoit le répertoire déjà résolu par le consommateur/configuration. Son scan automatique ne sort jamais de cette racine :
|
||||
|
||||
@@ -166,7 +166,7 @@ let wallets = match manager.scan().await {
|
||||
};
|
||||
```
|
||||
|
||||
`scan()` est non récursif et ne traite que les `<alias>.kswallet`. En `pre.002`, l'identification vérifie le suffixe, l'alias, le type de fichier, les permissions Unix, le magic ASCII `KSWALLET` et la version `1` encodée en `u16` little-endian. La validation complète du payload arrivera avec le codec `pre.003`.
|
||||
`scan()` est non récursif et ne traite que les `<alias>.kswallet`. En `pre.003`, chaque candidat doit décoder entièrement selon le format v1 : magic/version, flags, IDs crypto, paramètres KDF, pubkey déclarée, alias, sel, nonce et ciphertext de taille exacte. Le nom du fichier et l'alias encodé doivent correspondre.
|
||||
|
||||
Le lookup par alias reste limité à la racine configurée :
|
||||
|
||||
@@ -190,9 +190,18 @@ let handle = match manager.inspect_file(selected_path).await {
|
||||
};
|
||||
|
||||
println!("alias={}", handle.alias().as_str());
|
||||
println!("pubkey={}", handle.public_key());
|
||||
println!("format={}", handle.format_version());
|
||||
```
|
||||
|
||||
`WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`. Le handle servira à l'ouverture par mot de passe dans la tranche correspondante.
|
||||
`WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. Sa pubkey est une identité **déclarée** avant unlock : elle ne deviendra authentifiée qu'après validation AEAD et comparaison avec la keypair déchiffrée en `pre.004`. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`.
|
||||
|
||||
|
||||
## Format binaire v1
|
||||
|
||||
Le layout exact, les bornes et la politique de publication cible sont documentés dans [`../docs/NATIVE_FORMAT.md`](../docs/NATIVE_FORMAT.md). `pre.003` implémente la lecture et le décodage stricts nécessaires au scan ; l'encodage de création et la publication atomique sont introduits en `pre.004`. Le payload secret v1 est strictement borné à une keypair Solana de 64 octets et produit un ciphertext/tag de 80 octets.
|
||||
|
||||
Le format encode Argon2id v19 avec un profil d'écriture par défaut de 64 MiB / 3 passes / 4 lanes et XChaCha20-Poly1305 avec nonce 24 octets. Ces paramètres sont seulement encodés/validés en `pre.003`; leur exécution cryptographique commence en `pre.004`.
|
||||
|
||||
## Cible persistante `0.5.2`
|
||||
|
||||
@@ -253,4 +262,4 @@ L'export vers Solana CLI utilise son tableau JSON de 64 octets. Pour Phantom, So
|
||||
- rejet des keypairs corrompus ;
|
||||
- vérification des permissions Unix privées.
|
||||
|
||||
`pre.002` ajoute la caractérisation legacy externalisée ainsi que les tests du manager, du scan, du lookup, de l'inspection hors store, du magic/version et de la non-divulgation du chemin par le handle. La suite de la matrice ajoutera le codec `.kswallet`, le password/changement de password, la migration, l'import/export, l'atomicité complète et les canaris de non-divulgation.
|
||||
`pre.003` ajoute le décodage/validation v1 stricts, les bornes KDF/fichier et les tests de corruption/réouverture. `pre.004` ajoute l'encodage de création, la publication atomique, le password/changement de password et le chiffrement effectif ; les tranches suivantes couvrent la migration, l'import/export et les canaris de non-divulgation.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/constants.rs
|
||||
// version: 3
|
||||
// version: 5
|
||||
|
||||
//! Local constants for the `ks-wallet` crate.
|
||||
|
||||
@@ -9,9 +9,52 @@ pub(crate) const TRACING_TARGET: &str = "ks-wallet";
|
||||
pub const KSWALLET_FILE_EXTENSION: &str = "kswallet";
|
||||
/// Native wallet format identification magic.
|
||||
pub(crate) const KSWALLET_MAGIC: &[u8; 8] = b"KSWALLET";
|
||||
/// Native wallet format version used by the identification prefix.
|
||||
/// Native wallet format version.
|
||||
pub(crate) const KSWALLET_FORMAT_VERSION: u16 = 1;
|
||||
/// Number of bytes required to identify a native wallet file.
|
||||
pub(crate) const KSWALLET_IDENTIFICATION_LENGTH: usize = 10;
|
||||
/// Fixed version-one header length before the variable alias bytes.
|
||||
pub(crate) const KSWALLET_FIXED_HEADER_LENGTH: usize = 72;
|
||||
/// Maximum wallet alias length encoded by version one.
|
||||
pub(crate) const KSWALLET_MAX_ALIAS_LENGTH: usize = 64;
|
||||
/// Native version-one flag value when no optional feature is enabled.
|
||||
pub(crate) const KSWALLET_FLAGS_NONE: u16 = 0;
|
||||
/// Version-one KDF identifier for Argon2id.
|
||||
pub(crate) const KSWALLET_KDF_ARGON2ID: u8 = 1;
|
||||
/// Argon2 version 19 identifier required by version one.
|
||||
pub(crate) const KSWALLET_ARGON2_VERSION: u8 = 0x13;
|
||||
/// Version-one AEAD identifier for XChaCha20-Poly1305.
|
||||
pub(crate) const KSWALLET_AEAD_XCHACHA20_POLY1305: u8 = 1;
|
||||
/// Salt length used by the version-one Argon2id profile.
|
||||
pub(crate) const KSWALLET_SALT_LENGTH: usize = 16;
|
||||
/// Nonce length used by XChaCha20-Poly1305.
|
||||
pub(crate) const KSWALLET_NONCE_LENGTH: usize = 24;
|
||||
/// Authentication tag length appended by XChaCha20-Poly1305.
|
||||
const KSWALLET_AEAD_TAG_LENGTH: usize = 16;
|
||||
/// Minimum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_MEMORY_KIB: u32 = 65_536;
|
||||
/// Maximum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) const KSWALLET_ARGON2_MAX_MEMORY_KIB: u32 = 262_144;
|
||||
/// Minimum accepted Argon2id pass count.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_ITERATIONS: u32 = 3;
|
||||
/// Maximum accepted Argon2id pass count.
|
||||
pub(crate) const KSWALLET_ARGON2_MAX_ITERATIONS: u32 = 10;
|
||||
/// Minimum accepted Argon2id lane count.
|
||||
pub(crate) const KSWALLET_ARGON2_MIN_PARALLELISM: u32 = 1;
|
||||
/// Maximum accepted Argon2id lane count.
|
||||
pub(crate) const KSWALLET_ARGON2_MAX_PARALLELISM: u32 = 8;
|
||||
/// Number of bytes stored by the standard Solana keypair JSON format.
|
||||
pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64;
|
||||
/// Exact ciphertext length for one 64-byte keypair plus the AEAD tag.
|
||||
pub(crate) const KSWALLET_CIPHERTEXT_LENGTH: usize =
|
||||
SOLANA_KEYPAIR_LENGTH + KSWALLET_AEAD_TAG_LENGTH;
|
||||
/// Minimum complete version-one file length with a one-byte alias.
|
||||
pub(crate) const KSWALLET_MIN_FILE_LENGTH: usize = KSWALLET_FIXED_HEADER_LENGTH
|
||||
+ 1
|
||||
+ KSWALLET_SALT_LENGTH
|
||||
+ KSWALLET_NONCE_LENGTH
|
||||
+ KSWALLET_CIPHERTEXT_LENGTH;
|
||||
/// Maximum complete version-one file length with the longest supported alias.
|
||||
pub(crate) const KSWALLET_MAX_FILE_LENGTH: usize = KSWALLET_FIXED_HEADER_LENGTH
|
||||
+ KSWALLET_MAX_ALIAS_LENGTH
|
||||
+ KSWALLET_SALT_LENGTH
|
||||
+ KSWALLET_NONCE_LENGTH
|
||||
+ KSWALLET_CIPHERTEXT_LENGTH;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: ks-wallet/src/lib.rs
|
||||
// version: 4
|
||||
// version: 6
|
||||
|
||||
//! Wallet boundary for local key storage and transaction signing.
|
||||
#![warn(missing_docs)]
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
mod constants;
|
||||
mod manager;
|
||||
mod native;
|
||||
mod wallet;
|
||||
|
||||
/// Native wallet file extension without the leading dot.
|
||||
@@ -31,13 +32,49 @@ pub use self::wallet::WalletPolicy;
|
||||
/// Backward-compatible name for a non-secret wallet identity.
|
||||
pub use self::wallet::WalletSummary;
|
||||
|
||||
/// Native wallet format version used by the identification prefix.
|
||||
/// Version-one AEAD identifier for XChaCha20-Poly1305.
|
||||
pub(crate) use self::constants::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
/// Maximum accepted Argon2id pass count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_ITERATIONS;
|
||||
/// Maximum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_MEMORY_KIB;
|
||||
/// Maximum accepted Argon2id lane count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MAX_PARALLELISM;
|
||||
/// Minimum accepted Argon2id pass count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MIN_ITERATIONS;
|
||||
/// Minimum accepted Argon2id memory cost in KiB.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MIN_MEMORY_KIB;
|
||||
/// Minimum accepted Argon2id lane count.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_MIN_PARALLELISM;
|
||||
/// Argon2 version identifier required by native format version one.
|
||||
pub(crate) use self::constants::KSWALLET_ARGON2_VERSION;
|
||||
/// Exact ciphertext length for a version-one protected keypair.
|
||||
pub(crate) use self::constants::KSWALLET_CIPHERTEXT_LENGTH;
|
||||
/// Fixed version-one header length before variable alias bytes.
|
||||
pub(crate) use self::constants::KSWALLET_FIXED_HEADER_LENGTH;
|
||||
/// Version-one flag value when no optional feature is enabled.
|
||||
pub(crate) use self::constants::KSWALLET_FLAGS_NONE;
|
||||
/// Native wallet format version.
|
||||
pub(crate) use self::constants::KSWALLET_FORMAT_VERSION;
|
||||
/// Number of bytes required to identify a native wallet file.
|
||||
pub(crate) use self::constants::KSWALLET_IDENTIFICATION_LENGTH;
|
||||
/// Version-one KDF identifier for Argon2id.
|
||||
pub(crate) use self::constants::KSWALLET_KDF_ARGON2ID;
|
||||
/// Native wallet format identification magic.
|
||||
pub(crate) use self::constants::KSWALLET_MAGIC;
|
||||
/// Maximum wallet alias length encoded by version one.
|
||||
pub(crate) use self::constants::KSWALLET_MAX_ALIAS_LENGTH;
|
||||
/// Maximum complete native wallet file length.
|
||||
pub(crate) use self::constants::KSWALLET_MAX_FILE_LENGTH;
|
||||
/// Minimum complete native wallet file length.
|
||||
pub(crate) use self::constants::KSWALLET_MIN_FILE_LENGTH;
|
||||
/// Nonce length used by XChaCha20-Poly1305.
|
||||
pub(crate) use self::constants::KSWALLET_NONCE_LENGTH;
|
||||
/// Salt length used by the version-one Argon2id profile.
|
||||
pub(crate) use self::constants::KSWALLET_SALT_LENGTH;
|
||||
/// Number of bytes stored by the standard Solana keypair JSON format.
|
||||
pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Parsed version-one native wallet container.
|
||||
pub(crate) use self::native::NativeWalletContainer;
|
||||
/// Reads and strictly decodes one native wallet file.
|
||||
pub(crate) use self::native::read_native_wallet_container;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// file: ks-wallet/src/manager.rs
|
||||
// version: 1
|
||||
// version: 4
|
||||
|
||||
//! Multi-wallet discovery and native wallet file references.
|
||||
|
||||
use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
|
||||
/// Opaque reference to one identified native wallet file.
|
||||
/// Opaque reference to one validated native wallet file.
|
||||
///
|
||||
/// The local path is intentionally kept private and is not included in `Debug`.
|
||||
/// Consumers keep this handle and pass it back to `ks-wallet` for future open
|
||||
@@ -13,20 +11,46 @@ use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct WalletFileHandle {
|
||||
alias: crate::WalletAlias,
|
||||
public_key: std::string::String,
|
||||
format_version: u16,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WalletFileHandle {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("WalletFileHandle").field("alias", &self.alias).finish();
|
||||
return formatter
|
||||
.debug_struct("WalletFileHandle")
|
||||
.field("alias", &self.alias)
|
||||
.field("public_key", &self.public_key)
|
||||
.field("format_version", &self.format_version)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::WalletFileHandle {
|
||||
/// Returns the validated alias inferred from the native wallet filename.
|
||||
/// Returns the validated alias encoded by the native wallet and filename.
|
||||
pub fn alias(&self) -> &crate::WalletAlias {
|
||||
return &self.alias;
|
||||
}
|
||||
|
||||
/// Returns the base58 public key declared by the native wallet header before unlock.
|
||||
pub fn public_key(&self) -> &str {
|
||||
return self.public_key.as_str();
|
||||
}
|
||||
|
||||
/// Returns the native wallet format version.
|
||||
pub fn format_version(&self) -> u16 {
|
||||
return self.format_version;
|
||||
}
|
||||
|
||||
/// Returns the non-secret persistent identity declared before authenticated unlock.
|
||||
pub fn identity(&self) -> crate::WalletIdentity {
|
||||
return crate::WalletIdentity {
|
||||
alias: self.alias.clone(),
|
||||
public_key: self.public_key.clone(),
|
||||
persistence: crate::WalletPersistence::Persistent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-wallet manager rooted at one configured wallet directory.
|
||||
@@ -65,12 +89,12 @@ impl crate::WalletManager {
|
||||
));
|
||||
}
|
||||
|
||||
/// Discovers identified native wallets in the configured directory.
|
||||
/// Discovers structurally valid native wallet containers in the configured directory.
|
||||
///
|
||||
/// Discovery is non-recursive and ignores unrelated file extensions. Every
|
||||
/// `.kswallet` candidate must be a private regular file with a valid alias,
|
||||
/// magic and supported identification version. Full payload validation is
|
||||
/// added by the native codec tranche.
|
||||
/// `.kswallet` candidate must be a private regular file whose complete
|
||||
/// version-one container decodes strictly and whose filename alias matches
|
||||
/// the alias encoded by the native header.
|
||||
pub async fn scan(&self) -> ks_core::Result<std::vec::Vec<crate::WalletFileHandle>> {
|
||||
let directory_exists = match tokio::fs::try_exists(&self.directory).await {
|
||||
std::result::Result::Ok(exists) => exists,
|
||||
@@ -183,7 +207,7 @@ async fn inspect_native_wallet_file(
|
||||
));
|
||||
},
|
||||
};
|
||||
let alias = match crate::WalletAlias::parse(stem) {
|
||||
let filename_alias = match crate::WalletAlias::parse(stem) {
|
||||
std::result::Result::Ok(alias) => alias,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
@@ -196,11 +220,22 @@ async fn inspect_native_wallet_file(
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
match validate_native_wallet_identification(&path).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
let container = match crate::read_native_wallet_container(&path).await {
|
||||
std::result::Result::Ok(container) => container,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if container.alias() != &filename_alias {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_mismatch",
|
||||
"native wallet filename alias does not match the container alias",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(crate::WalletFileHandle { alias, path });
|
||||
return std::result::Result::Ok(crate::WalletFileHandle {
|
||||
alias: filename_alias,
|
||||
public_key: container.public_key().to_string(),
|
||||
format_version: crate::KSWALLET_FORMAT_VERSION,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
async fn validate_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> {
|
||||
@@ -219,6 +254,17 @@ async fn validate_wallet_directory(directory: &std::path::Path) -> ks_core::Resu
|
||||
"wallet directory must be a directory and not a symlink",
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_directory_permissions_too_open",
|
||||
format!("wallet directory has mode {mode:o}; expected no group or other access"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
@@ -252,56 +298,45 @@ async fn validate_native_wallet_file_metadata(path: &std::path::Path) -> ks_core
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn validate_native_wallet_identification(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
let mut file = match tokio::fs::File::open(path).await {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_open_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut identification = [0_u8; crate::KSWALLET_IDENTIFICATION_LENGTH];
|
||||
match file.read_exact(&mut identification).await {
|
||||
std::result::Result::Ok(_) => {},
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_identification_truncated",
|
||||
"native wallet identification prefix is truncated",
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_identification_read_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
}
|
||||
if identification[0..crate::KSWALLET_MAGIC.len()] != crate::KSWALLET_MAGIC[..] {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_magic_invalid",
|
||||
"native wallet magic is invalid",
|
||||
));
|
||||
}
|
||||
let version_offset = crate::KSWALLET_MAGIC.len();
|
||||
let version =
|
||||
u16::from_le_bytes([identification[version_offset], identification[version_offset + 1]]);
|
||||
if version != crate::KSWALLET_FORMAT_VERSION {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_version_unsupported",
|
||||
format!("native wallet version {version} is not supported"),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn write_identified_wallet(path: &std::path::Path, version: u16) {
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes.extend_from_slice(&version.to_le_bytes());
|
||||
fn fixture_bytes(alias: &str, marker: u8) -> std::vec::Vec<u8> {
|
||||
let parsed_alias = crate::WalletAlias::parse(alias)
|
||||
.unwrap_or_else(|error| panic!("fixture alias must be valid: {error}"));
|
||||
let alias_bytes = parsed_alias.as_str().as_bytes();
|
||||
let header_length = crate::KSWALLET_FIXED_HEADER_LENGTH + alias_bytes.len();
|
||||
let total_length = header_length
|
||||
+ crate::KSWALLET_SALT_LENGTH
|
||||
+ crate::KSWALLET_NONCE_LENGTH
|
||||
+ crate::KSWALLET_CIPHERTEXT_LENGTH;
|
||||
let mut bytes = vec![0_u8; total_length];
|
||||
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes[8..10].copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
bytes[10..12].copy_from_slice(&(header_length as u16).to_le_bytes());
|
||||
bytes[12..14].copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
||||
bytes[14] = crate::KSWALLET_KDF_ARGON2ID;
|
||||
bytes[15] = crate::KSWALLET_ARGON2_VERSION;
|
||||
bytes[16] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
bytes[17] = crate::KSWALLET_SALT_LENGTH as u8;
|
||||
bytes[18] = crate::KSWALLET_NONCE_LENGTH as u8;
|
||||
bytes[19] = alias_bytes.len() as u8;
|
||||
bytes[24..28].copy_from_slice(&65_536_u32.to_le_bytes());
|
||||
bytes[28..32].copy_from_slice(&3_u32.to_le_bytes());
|
||||
bytes[32..36].copy_from_slice(&4_u32.to_le_bytes());
|
||||
bytes[36..40].copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
||||
bytes[40..72].copy_from_slice(&[marker; 32]);
|
||||
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
|
||||
let salt_offset = header_length;
|
||||
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
||||
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
||||
bytes[salt_offset..nonce_offset].fill(marker.wrapping_add(1));
|
||||
bytes[nonce_offset..ciphertext_offset].fill(marker.wrapping_add(2));
|
||||
bytes[ciphertext_offset..].fill(marker.wrapping_add(3));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn write_native_wallet(path: &std::path::Path, alias: &str, marker: u8) {
|
||||
let bytes = fixture_bytes(alias, marker);
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be writable: {error}"));
|
||||
#[cfg(unix)]
|
||||
@@ -312,25 +347,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_directory_private(path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap_or_else(
|
||||
|error| panic!("wallet fixture directory permissions must be set: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_is_non_recursive_and_filters_by_native_extension() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
make_directory_private(directory.path());
|
||||
let manager = crate::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
write_identified_wallet(&directory.path().join("alpha.kswallet"), 1);
|
||||
write_native_wallet(&directory.path().join("alpha.kswallet"), "alpha", 3);
|
||||
std::fs::write(directory.path().join("legacy.json"), b"[]")
|
||||
.unwrap_or_else(|error| panic!("unrelated fixture must be writable: {error}"));
|
||||
let nested = directory.path().join("nested");
|
||||
std::fs::create_dir(&nested)
|
||||
.unwrap_or_else(|error| panic!("nested directory must be created: {error}"));
|
||||
write_identified_wallet(&nested.join("hidden.kswallet"), 1);
|
||||
write_native_wallet(&nested.join("hidden.kswallet"), "hidden", 5);
|
||||
let wallets = manager
|
||||
.scan()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("scan must succeed: {error}"));
|
||||
assert_eq!(wallets.len(), 1);
|
||||
assert_eq!(wallets[0].alias().as_str(), "alpha");
|
||||
assert_eq!(wallets[0].format_version(), 1);
|
||||
assert_eq!(wallets[0].identity().persistence, crate::WalletPersistence::Persistent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -341,7 +389,7 @@ mod tests {
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let alias = crate::WalletAlias::parse("lookup")
|
||||
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
||||
write_identified_wallet(&manager.wallet_path(&alias), 1);
|
||||
write_native_wallet(&manager.wallet_path(&alias), "lookup", 7);
|
||||
let handle = manager
|
||||
.lookup(&alias)
|
||||
.await
|
||||
@@ -349,6 +397,7 @@ mod tests {
|
||||
.unwrap_or_else(|| panic!("wallet handle must exist"));
|
||||
assert_eq!(handle.alias(), &alias);
|
||||
assert_eq!(handle.path, manager.wallet_path(&alias));
|
||||
assert_eq!(handle.public_key(), handle.identity().public_key);
|
||||
assert!(!format!("{handle:?}").contains(directory.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
@@ -356,60 +405,90 @@ mod tests {
|
||||
async fn explicit_file_inspection_accepts_path_outside_configured_directory() {
|
||||
let configured = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("configured directory must exist: {error}"));
|
||||
make_directory_private(configured.path());
|
||||
let external = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
|
||||
let manager = crate::WalletManager::new(configured.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = external.path().join("selected.kswallet");
|
||||
write_identified_wallet(&path, 1);
|
||||
let external_path = external.path().join("external.kswallet");
|
||||
write_native_wallet(&external_path, "external", 9);
|
||||
let handle = manager
|
||||
.inspect_file(&path)
|
||||
.inspect_file(&external_path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("explicit inspection must succeed: {error}"));
|
||||
assert_eq!(handle.alias().as_str(), "selected");
|
||||
assert_eq!(handle.path, path);
|
||||
let scanned = manager
|
||||
assert_eq!(handle.alias().as_str(), "external");
|
||||
assert_eq!(handle.path, external_path);
|
||||
assert!(!handle.public_key().is_empty());
|
||||
let discovered = manager
|
||||
.scan()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("configured scan must succeed: {error}"));
|
||||
assert!(scanned.is_empty());
|
||||
assert!(discovered.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_native_version_is_rejected_without_path_disclosure() {
|
||||
async fn prefix_only_native_file_is_rejected_by_complete_codec() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let path = directory.path().join("short.kswallet");
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes.extend_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
std::fs::write(&path, bytes)
|
||||
.unwrap_or_else(|error| panic!("fixture must be writable: {error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.unwrap_or_else(|error| panic!("fixture permissions must be set: {error}"));
|
||||
}
|
||||
let manager = crate::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = directory.path().join("future.kswallet");
|
||||
write_identified_wallet(&path, 999);
|
||||
let error = manager
|
||||
.inspect_file(&path)
|
||||
.await
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("unsupported version must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_version_unsupported");
|
||||
assert!(!error.to_string().contains(directory.path().to_string_lossy().as_ref()));
|
||||
.unwrap_or_else(|| panic!("prefix-only fixture must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_file_length_invalid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_native_magic_is_rejected() {
|
||||
async fn filename_and_header_alias_must_match() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let path = directory.path().join("filename.kswallet");
|
||||
write_native_wallet(&path, "header", 11);
|
||||
let manager = crate::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = directory.path().join("invalid.kswallet");
|
||||
write_identified_wallet(&path, 1);
|
||||
let error = manager
|
||||
.inspect_file(&path)
|
||||
.await
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("alias mismatch must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_alias_mismatch");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codec_errors_do_not_disclose_external_path() {
|
||||
let configured = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("configured directory must exist: {error}"));
|
||||
let external = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
|
||||
let manager = crate::WalletManager::new(configured.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = external.path().join("invalid.kswallet");
|
||||
write_native_wallet(&path, "invalid", 13);
|
||||
let mut bytes = std::fs::read(&path)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be readable: {error}"));
|
||||
.unwrap_or_else(|error| panic!("fixture must be readable: {error}"));
|
||||
bytes[0] = b'X';
|
||||
std::fs::write(&path, bytes)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be rewritable: {error}"));
|
||||
.unwrap_or_else(|error| panic!("fixture must be rewritten: {error}"));
|
||||
let error = manager
|
||||
.inspect_file(&path)
|
||||
.await
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("invalid magic must fail"));
|
||||
.unwrap_or_else(|| panic!("invalid native wallet must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_magic_invalid");
|
||||
assert!(!error.to_string().contains(external.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
}
|
||||
|
||||
552
ks-wallet/src/native.rs
Normal file
552
ks-wallet/src/native.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
// file: ks-wallet/src/native.rs
|
||||
// version: 2
|
||||
|
||||
//! Strict reader and validator for native `.kswallet` containers.
|
||||
|
||||
use tokio::io::AsyncReadExt; // rust-rules: trait-import
|
||||
use zeroize::Zeroize; // rust-rules: trait-import
|
||||
|
||||
const OFFSET_FORMAT_VERSION: usize = 8;
|
||||
const OFFSET_HEADER_LENGTH: usize = 10;
|
||||
const OFFSET_FLAGS: usize = 12;
|
||||
const OFFSET_KDF_ID: usize = 14;
|
||||
const OFFSET_KDF_VERSION: usize = 15;
|
||||
const OFFSET_AEAD_ID: usize = 16;
|
||||
const OFFSET_SALT_LENGTH: usize = 17;
|
||||
const OFFSET_NONCE_LENGTH: usize = 18;
|
||||
const OFFSET_ALIAS_LENGTH: usize = 19;
|
||||
const OFFSET_RESERVED: usize = 20;
|
||||
const OFFSET_MEMORY_KIB: usize = 24;
|
||||
const OFFSET_ITERATIONS: usize = 28;
|
||||
const OFFSET_PARALLELISM: usize = 32;
|
||||
const OFFSET_CIPHERTEXT_LENGTH: usize = 36;
|
||||
const OFFSET_PUBLIC_KEY: usize = 40;
|
||||
const RESERVED_LENGTH: usize = 4;
|
||||
const PUBLIC_KEY_LENGTH: usize = 32;
|
||||
|
||||
struct NativeWalletKdfParameters {
|
||||
memory_kib: u32,
|
||||
iterations: u32,
|
||||
parallelism: u32,
|
||||
}
|
||||
|
||||
/// Parsed non-secret identity from one structurally valid native wallet container.
|
||||
///
|
||||
/// The full file buffer, including salt, nonce and ciphertext, is zeroized after
|
||||
/// structural validation. This type therefore retains only the fields required
|
||||
/// by discovery before authenticated unlock.
|
||||
pub(crate) struct NativeWalletContainer {
|
||||
alias: crate::WalletAlias,
|
||||
public_key: solana_pubkey::Pubkey,
|
||||
}
|
||||
|
||||
impl crate::NativeWalletContainer {
|
||||
/// Returns the validated logical alias declared by the native header.
|
||||
pub(crate) fn alias(&self) -> &crate::WalletAlias {
|
||||
return &self.alias;
|
||||
}
|
||||
|
||||
/// Returns the public key declared by the native header before authenticated unlock.
|
||||
pub(crate) fn public_key(&self) -> &solana_pubkey::Pubkey {
|
||||
return &self.public_key;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and strictly validates one native wallet file with a pre-allocation size bound.
|
||||
pub(crate) async fn read_native_wallet_container(
|
||||
path: &std::path::Path,
|
||||
) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
match validate_native_wallet_file_metadata(path).await {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let mut file = match tokio::fs::File::open(path).await {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_native_open_failed", error));
|
||||
},
|
||||
};
|
||||
let metadata = match file.metadata().await {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_native_metadata_failed", error));
|
||||
},
|
||||
};
|
||||
let file_length = metadata.len();
|
||||
if file_length < crate::KSWALLET_MIN_FILE_LENGTH as u64
|
||||
|| file_length > crate::KSWALLET_MAX_FILE_LENGTH as u64
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
format!(
|
||||
"native wallet file length must be between {} and {} bytes",
|
||||
crate::KSWALLET_MIN_FILE_LENGTH,
|
||||
crate::KSWALLET_MAX_FILE_LENGTH
|
||||
),
|
||||
));
|
||||
}
|
||||
let length = match usize::try_from(file_length) {
|
||||
std::result::Result::Ok(length) => length,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
"native wallet file length cannot be represented on this platform",
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut bytes = vec![0_u8; length];
|
||||
if let std::result::Result::Err(error) = file.read_exact(bytes.as_mut_slice()).await {
|
||||
bytes.zeroize();
|
||||
return std::result::Result::Err(io_error("wallet_native_read_failed", error));
|
||||
}
|
||||
let mut trailing = [0_u8; 1];
|
||||
match file.read(trailing.as_mut_slice()).await {
|
||||
std::result::Result::Ok(0) => {},
|
||||
std::result::Result::Ok(_) => {
|
||||
bytes.zeroize();
|
||||
trailing.zeroize();
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_changed",
|
||||
"native wallet file changed while it was being read",
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
bytes.zeroize();
|
||||
trailing.zeroize();
|
||||
return std::result::Result::Err(io_error("wallet_native_read_failed", error));
|
||||
},
|
||||
}
|
||||
trailing.zeroize();
|
||||
let decoded = decode_native_wallet_container(bytes.as_slice());
|
||||
bytes.zeroize();
|
||||
return decoded;
|
||||
}
|
||||
|
||||
fn decode_native_wallet_container(bytes: &[u8]) -> ks_core::Result<crate::NativeWalletContainer> {
|
||||
if bytes.len() < crate::KSWALLET_MIN_FILE_LENGTH
|
||||
|| bytes.len() > crate::KSWALLET_MAX_FILE_LENGTH
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
"native wallet file length is outside the supported version-one bound",
|
||||
));
|
||||
}
|
||||
if bytes[0..crate::KSWALLET_MAGIC.len()] != crate::KSWALLET_MAGIC[..] {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_magic_invalid",
|
||||
"native wallet magic is invalid",
|
||||
));
|
||||
}
|
||||
let format_version = read_u16(bytes, OFFSET_FORMAT_VERSION);
|
||||
if format_version != crate::KSWALLET_FORMAT_VERSION {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_version_unsupported",
|
||||
"native wallet format version is unsupported",
|
||||
));
|
||||
}
|
||||
let alias_length = bytes[OFFSET_ALIAS_LENGTH] as usize;
|
||||
if alias_length == 0 || alias_length > crate::KSWALLET_MAX_ALIAS_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_length_invalid",
|
||||
"native wallet alias length is outside the supported bound",
|
||||
));
|
||||
}
|
||||
let header_length = read_u16(bytes, OFFSET_HEADER_LENGTH) as usize;
|
||||
let expected_header_length = match crate::KSWALLET_FIXED_HEADER_LENGTH.checked_add(alias_length)
|
||||
{
|
||||
std::option::Option::Some(length) => length,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_header_length_invalid",
|
||||
"native wallet header length overflowed the platform size",
|
||||
));
|
||||
},
|
||||
};
|
||||
if header_length != expected_header_length {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_header_length_invalid",
|
||||
"native wallet header length does not match the encoded alias length",
|
||||
));
|
||||
}
|
||||
if read_u16(bytes, OFFSET_FLAGS) != crate::KSWALLET_FLAGS_NONE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_flags_unsupported",
|
||||
"native wallet flags are unsupported for format version one",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_KDF_ID] != crate::KSWALLET_KDF_ARGON2ID {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_unsupported",
|
||||
"native wallet KDF identifier is unsupported",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_KDF_VERSION] != crate::KSWALLET_ARGON2_VERSION {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_version_unsupported",
|
||||
"native wallet Argon2 version is unsupported",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_AEAD_ID] != crate::KSWALLET_AEAD_XCHACHA20_POLY1305 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_aead_unsupported",
|
||||
"native wallet AEAD identifier is unsupported",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_SALT_LENGTH] as usize != crate::KSWALLET_SALT_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_salt_length_invalid",
|
||||
"native wallet salt length is invalid",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_NONCE_LENGTH] as usize != crate::KSWALLET_NONCE_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_nonce_length_invalid",
|
||||
"native wallet nonce length is invalid",
|
||||
));
|
||||
}
|
||||
if bytes[OFFSET_RESERVED..OFFSET_RESERVED + RESERVED_LENGTH]
|
||||
.iter()
|
||||
.any(|byte| return *byte != 0)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_reserved_nonzero",
|
||||
"native wallet reserved header bytes must be zero",
|
||||
));
|
||||
}
|
||||
let kdf_parameters = NativeWalletKdfParameters {
|
||||
memory_kib: read_u32(bytes, OFFSET_MEMORY_KIB),
|
||||
iterations: read_u32(bytes, OFFSET_ITERATIONS),
|
||||
parallelism: read_u32(bytes, OFFSET_PARALLELISM),
|
||||
};
|
||||
match validate_kdf_parameters(&kdf_parameters) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let ciphertext_length = read_u32(bytes, OFFSET_CIPHERTEXT_LENGTH) as usize;
|
||||
match validate_ciphertext_length(ciphertext_length) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let expected_length = match header_length
|
||||
.checked_add(crate::KSWALLET_SALT_LENGTH)
|
||||
.and_then(|length| return length.checked_add(crate::KSWALLET_NONCE_LENGTH))
|
||||
.and_then(|length| return length.checked_add(ciphertext_length))
|
||||
{
|
||||
std::option::Option::Some(length) => length,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_invalid",
|
||||
"native wallet lengths overflow the platform size",
|
||||
));
|
||||
},
|
||||
};
|
||||
if expected_length != bytes.len() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_file_length_mismatch",
|
||||
"native wallet declared lengths do not match the file length",
|
||||
));
|
||||
}
|
||||
let alias_bytes = &bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length];
|
||||
let alias_text = match std::str::from_utf8(alias_bytes) {
|
||||
std::result::Result::Ok(alias) => alias,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_encoding_invalid",
|
||||
"native wallet alias must be valid ASCII-compatible UTF-8",
|
||||
));
|
||||
},
|
||||
};
|
||||
let alias = match crate::WalletAlias::parse(alias_text) {
|
||||
std::result::Result::Ok(alias) => alias,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_alias_invalid",
|
||||
"native wallet header contains an invalid wallet alias",
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut public_key_bytes = [0_u8; PUBLIC_KEY_LENGTH];
|
||||
public_key_bytes
|
||||
.copy_from_slice(&bytes[OFFSET_PUBLIC_KEY..OFFSET_PUBLIC_KEY + PUBLIC_KEY_LENGTH]);
|
||||
return std::result::Result::Ok(crate::NativeWalletContainer {
|
||||
alias,
|
||||
public_key: solana_pubkey::Pubkey::new_from_array(public_key_bytes),
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_kdf_parameters(parameters: &NativeWalletKdfParameters) -> ks_core::Result<()> {
|
||||
if parameters.memory_kib < crate::KSWALLET_ARGON2_MIN_MEMORY_KIB
|
||||
|| parameters.memory_kib > crate::KSWALLET_ARGON2_MAX_MEMORY_KIB
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory parameter is outside the supported bound",
|
||||
));
|
||||
}
|
||||
if parameters.iterations < crate::KSWALLET_ARGON2_MIN_ITERATIONS
|
||||
|| parameters.iterations > crate::KSWALLET_ARGON2_MAX_ITERATIONS
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_iterations_invalid",
|
||||
"native wallet Argon2 iteration parameter is outside the supported bound",
|
||||
));
|
||||
}
|
||||
if parameters.parallelism < crate::KSWALLET_ARGON2_MIN_PARALLELISM
|
||||
|| parameters.parallelism > crate::KSWALLET_ARGON2_MAX_PARALLELISM
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_parallelism_invalid",
|
||||
"native wallet Argon2 parallelism parameter is outside the supported bound",
|
||||
));
|
||||
}
|
||||
let minimum_memory = match 8_u32.checked_mul(parameters.parallelism) {
|
||||
std::option::Option::Some(memory) => memory,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory relation is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
let alignment = match 4_u32.checked_mul(parameters.parallelism) {
|
||||
std::option::Option::Some(alignment) => alignment,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory alignment is invalid",
|
||||
));
|
||||
},
|
||||
};
|
||||
if parameters.memory_kib < minimum_memory || parameters.memory_kib % alignment != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_kdf_memory_invalid",
|
||||
"native wallet Argon2 memory must satisfy the encoded parallelism relation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_ciphertext_length(length: usize) -> ks_core::Result<()> {
|
||||
if length != crate::KSWALLET_CIPHERTEXT_LENGTH {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_native_ciphertext_length_invalid",
|
||||
format!(
|
||||
"native wallet ciphertext length must be exactly {} bytes",
|
||||
crate::KSWALLET_CIPHERTEXT_LENGTH
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn validate_native_wallet_file_metadata(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
let metadata = match tokio::fs::symlink_metadata(path).await {
|
||||
std::result::Result::Ok(metadata) => metadata,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(io_error("wallet_file_metadata_failed", error));
|
||||
},
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_type_invalid",
|
||||
"wallet file must be a regular file and not a symlink",
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"wallet_file_permissions_too_open",
|
||||
format!("wallet file has mode {mode:o}; expected no group or other access"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn io_error(code: &str, error: std::io::Error) -> ks_core::Error {
|
||||
return ks_core::Error::new(code, error.to_string());
|
||||
}
|
||||
|
||||
fn read_u16(bytes: &[u8], offset: usize) -> u16 {
|
||||
return u16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> u32 {
|
||||
return u32::from_le_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn fixture_bytes(alias: &str, marker: u8) -> std::vec::Vec<u8> {
|
||||
let parsed_alias = crate::WalletAlias::parse(alias)
|
||||
.unwrap_or_else(|error| panic!("fixture alias must be valid: {error}"));
|
||||
let alias_bytes = parsed_alias.as_str().as_bytes();
|
||||
let header_length = crate::KSWALLET_FIXED_HEADER_LENGTH + alias_bytes.len();
|
||||
let total_length = header_length
|
||||
+ crate::KSWALLET_SALT_LENGTH
|
||||
+ crate::KSWALLET_NONCE_LENGTH
|
||||
+ crate::KSWALLET_CIPHERTEXT_LENGTH;
|
||||
let mut bytes = vec![0_u8; total_length];
|
||||
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
||||
bytes[super::OFFSET_FORMAT_VERSION..super::OFFSET_FORMAT_VERSION + 2]
|
||||
.copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
||||
bytes[super::OFFSET_HEADER_LENGTH..super::OFFSET_HEADER_LENGTH + 2]
|
||||
.copy_from_slice(&(header_length as u16).to_le_bytes());
|
||||
bytes[super::OFFSET_FLAGS..super::OFFSET_FLAGS + 2]
|
||||
.copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
||||
bytes[super::OFFSET_KDF_ID] = crate::KSWALLET_KDF_ARGON2ID;
|
||||
bytes[super::OFFSET_KDF_VERSION] = crate::KSWALLET_ARGON2_VERSION;
|
||||
bytes[super::OFFSET_AEAD_ID] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
||||
bytes[super::OFFSET_SALT_LENGTH] = crate::KSWALLET_SALT_LENGTH as u8;
|
||||
bytes[super::OFFSET_NONCE_LENGTH] = crate::KSWALLET_NONCE_LENGTH as u8;
|
||||
bytes[super::OFFSET_ALIAS_LENGTH] = alias_bytes.len() as u8;
|
||||
bytes[super::OFFSET_MEMORY_KIB..super::OFFSET_MEMORY_KIB + 4]
|
||||
.copy_from_slice(&65_536_u32.to_le_bytes());
|
||||
bytes[super::OFFSET_ITERATIONS..super::OFFSET_ITERATIONS + 4]
|
||||
.copy_from_slice(&3_u32.to_le_bytes());
|
||||
bytes[super::OFFSET_PARALLELISM..super::OFFSET_PARALLELISM + 4]
|
||||
.copy_from_slice(&4_u32.to_le_bytes());
|
||||
bytes[super::OFFSET_CIPHERTEXT_LENGTH..super::OFFSET_CIPHERTEXT_LENGTH + 4]
|
||||
.copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
||||
bytes[super::OFFSET_PUBLIC_KEY..super::OFFSET_PUBLIC_KEY + super::PUBLIC_KEY_LENGTH]
|
||||
.copy_from_slice(&[marker; super::PUBLIC_KEY_LENGTH]);
|
||||
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
|
||||
let salt_offset = header_length;
|
||||
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
||||
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
||||
bytes[salt_offset..nonce_offset].fill(marker.wrapping_add(1));
|
||||
bytes[nonce_offset..ciphertext_offset].fill(marker.wrapping_add(2));
|
||||
bytes[ciphertext_offset..].fill(marker.wrapping_add(3));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn mutate_u32(bytes: &mut [u8], offset: usize, value: u32) {
|
||||
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_private_fixture(path: &std::path::Path, bytes: &[u8]) {
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|error| panic!("fixture write must succeed: {error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.unwrap_or_else(|error| panic!("fixture permissions must be private: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_decoder_accepts_exact_header_and_declared_identity() {
|
||||
let encoded = fixture_bytes("codec", 7);
|
||||
assert_eq!(&encoded[0..8], b"KSWALLET");
|
||||
assert_eq!(super::read_u16(encoded.as_slice(), super::OFFSET_FORMAT_VERSION), 1);
|
||||
assert_eq!(super::read_u16(encoded.as_slice(), super::OFFSET_HEADER_LENGTH) as usize, 77);
|
||||
assert_eq!(encoded[super::OFFSET_KDF_ID], crate::KSWALLET_KDF_ARGON2ID);
|
||||
assert_eq!(encoded[super::OFFSET_KDF_VERSION], 0x13);
|
||||
assert_eq!(encoded[super::OFFSET_AEAD_ID], crate::KSWALLET_AEAD_XCHACHA20_POLY1305);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_MEMORY_KIB), 65_536);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_ITERATIONS), 3);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_PARALLELISM), 4);
|
||||
assert_eq!(super::read_u32(encoded.as_slice(), super::OFFSET_CIPHERTEXT_LENGTH), 80);
|
||||
let decoded = super::decode_native_wallet_container(encoded.as_slice())
|
||||
.unwrap_or_else(|error| panic!("decoding must succeed: {error}"));
|
||||
assert_eq!(decoded.alias().as_str(), "codec");
|
||||
assert_eq!(decoded.public_key(), &solana_pubkey::Pubkey::new_from_array([7_u8; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_unknown_algorithms_reserved_bytes_and_versions() {
|
||||
let encoded = fixture_bytes("strict", 13);
|
||||
for (offset, value, code) in [
|
||||
(super::OFFSET_KDF_ID, 99_u8, "wallet_native_kdf_unsupported"),
|
||||
(super::OFFSET_KDF_VERSION, 0x12_u8, "wallet_native_kdf_version_unsupported"),
|
||||
(super::OFFSET_AEAD_ID, 99_u8, "wallet_native_aead_unsupported"),
|
||||
(super::OFFSET_RESERVED, 1_u8, "wallet_native_reserved_nonzero"),
|
||||
] {
|
||||
let mut candidate = encoded.clone();
|
||||
candidate[offset] = value;
|
||||
let error = super::decode_native_wallet_container(candidate.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("mutation at offset {offset} must fail"));
|
||||
assert_eq!(error.code(), code);
|
||||
}
|
||||
let mut version = encoded.clone();
|
||||
version[super::OFFSET_FORMAT_VERSION..super::OFFSET_FORMAT_VERSION + 2]
|
||||
.copy_from_slice(&2_u16.to_le_bytes());
|
||||
let error = super::decode_native_wallet_container(version.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("unknown format version must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_version_unsupported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_truncation_and_trailing_bytes() {
|
||||
let encoded = fixture_bytes("lengths", 17);
|
||||
let truncated = &encoded[..encoded.len() - 1];
|
||||
let truncated_error = super::decode_native_wallet_container(truncated)
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("truncation must fail"));
|
||||
assert_eq!(truncated_error.code(), "wallet_native_file_length_mismatch");
|
||||
let mut trailing = encoded.clone();
|
||||
trailing.push(0);
|
||||
let trailing_error = super::decode_native_wallet_container(trailing.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("trailing byte must fail"));
|
||||
assert_eq!(trailing_error.code(), "wallet_native_file_length_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_kdf_parameters_outside_dos_bounds() {
|
||||
let encoded = fixture_bytes("kdf", 19);
|
||||
for (offset, value, code) in [
|
||||
(super::OFFSET_MEMORY_KIB, 32_768_u32, "wallet_native_kdf_memory_invalid"),
|
||||
(super::OFFSET_MEMORY_KIB, 524_288_u32, "wallet_native_kdf_memory_invalid"),
|
||||
(super::OFFSET_ITERATIONS, 2_u32, "wallet_native_kdf_iterations_invalid"),
|
||||
(super::OFFSET_ITERATIONS, 11_u32, "wallet_native_kdf_iterations_invalid"),
|
||||
(super::OFFSET_PARALLELISM, 0_u32, "wallet_native_kdf_parallelism_invalid"),
|
||||
(super::OFFSET_PARALLELISM, 9_u32, "wallet_native_kdf_parallelism_invalid"),
|
||||
] {
|
||||
let mut candidate = encoded.clone();
|
||||
mutate_u32(candidate.as_mut_slice(), offset, value);
|
||||
let error = super::decode_native_wallet_container(candidate.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("invalid KDF parameter must fail"));
|
||||
assert_eq!(error.code(), code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_requires_exact_keypair_ciphertext_length() {
|
||||
let encoded = fixture_bytes("ciphertext", 21);
|
||||
for length in [79_u32, 81_u32] {
|
||||
let mut candidate = encoded.clone();
|
||||
mutate_u32(candidate.as_mut_slice(), super::OFFSET_CIPHERTEXT_LENGTH, length);
|
||||
let error = super::decode_native_wallet_container(candidate.as_slice())
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("non-keypair ciphertext length must fail"));
|
||||
assert_eq!(error.code(), "wallet_native_ciphertext_length_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_reader_reopens_private_structurally_valid_file() {
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
let path = directory.path().join("reopen.kswallet");
|
||||
let encoded = fixture_bytes("reopen", 31);
|
||||
write_private_fixture(&path, encoded.as_slice());
|
||||
let reopened = crate::read_native_wallet_container(&path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("reopen must succeed: {error}"));
|
||||
assert_eq!(reopened.alias().as_str(), "reopen");
|
||||
assert_eq!(reopened.public_key(), &solana_pubkey::Pubkey::new_from_array([31_u8; 32]));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
// file: ks-wallet/tests/public_api.rs
|
||||
// version: 1
|
||||
// version: 3
|
||||
|
||||
//! External crate-root contract tests for the `ks-wallet` public API.
|
||||
|
||||
fn write_native_identification_fixture(path: &std::path::Path) {
|
||||
fn write_native_wallet_fixture(
|
||||
path: &std::path::Path,
|
||||
alias: &str,
|
||||
marker: u8,
|
||||
) -> std::string::String {
|
||||
let public_key = solana_pubkey::Pubkey::new_from_array([marker; 32]);
|
||||
let alias_bytes = alias.as_bytes();
|
||||
let alias_length =
|
||||
u8::try_from(alias_bytes.len()).unwrap_or_else(|_| panic!("fixture alias must fit u8"));
|
||||
let header_length = 72_u16 + u16::from(alias_length);
|
||||
let ciphertext = vec![marker.wrapping_add(3); 80];
|
||||
let mut bytes = std::vec::Vec::new();
|
||||
bytes.extend_from_slice(b"KSWALLET");
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&header_length.to_le_bytes());
|
||||
bytes.extend_from_slice(&0_u16.to_le_bytes());
|
||||
bytes.push(1);
|
||||
bytes.push(0x13);
|
||||
bytes.push(1);
|
||||
bytes.push(16);
|
||||
bytes.push(24);
|
||||
bytes.push(alias_length);
|
||||
bytes.extend_from_slice(&[0_u8; 4]);
|
||||
bytes.extend_from_slice(&65_536_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&3_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&4_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&80_u32.to_le_bytes());
|
||||
let public_key_bytes = public_key.to_bytes();
|
||||
bytes.extend_from_slice(&public_key_bytes);
|
||||
bytes.extend_from_slice(alias_bytes);
|
||||
bytes.extend_from_slice(&[marker.wrapping_add(1); 16]);
|
||||
bytes.extend_from_slice(&[marker.wrapping_add(2); 24]);
|
||||
bytes.extend_from_slice(ciphertext.as_slice());
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|error| panic!("native fixture must be writable: {error}"));
|
||||
#[cfg(unix)]
|
||||
@@ -15,6 +44,7 @@ fn write_native_identification_fixture(path: &std::path::Path) {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.unwrap_or_else(|error| panic!("native fixture permissions must be set: {error}"));
|
||||
}
|
||||
return public_key.to_string();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -22,16 +52,27 @@ async fn crate_root_exposes_multi_wallet_discovery_without_path_disclosure() {
|
||||
assert_eq!(ks_wallet::KSWALLET_FILE_EXTENSION, "kswallet");
|
||||
let directory = tempfile::tempdir()
|
||||
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
||||
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("wallet fixture directory permissions must be set: {error}")
|
||||
});
|
||||
}
|
||||
let manager = ks_wallet::WalletManager::new(directory.path())
|
||||
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
||||
let path = directory.path().join("public-api.kswallet");
|
||||
write_native_identification_fixture(&path);
|
||||
let expected_public_key = write_native_wallet_fixture(&path, "public-api", 37);
|
||||
let handles = manager
|
||||
.scan()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("scan must succeed: {error}"));
|
||||
assert_eq!(handles.len(), 1);
|
||||
assert_eq!(handles[0].alias().as_str(), "public-api");
|
||||
assert_eq!(handles[0].public_key(), expected_public_key);
|
||||
assert_eq!(handles[0].format_version(), 1);
|
||||
assert_eq!(handles[0].identity().persistence, ks_wallet::WalletPersistence::Persistent);
|
||||
assert!(!format!("{:?}", handles[0]).contains(directory.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user