15 KiB
.kspwallet V2 — spécification du wire binaire natif KSP
1. Statut et objectif
Ce document est l'autorité normative du wire .kspwallet format_version = 2 introduit par 0.2.6-pre.015.
V2 remplace l'enveloppe JSON/Base64url de V1 par un framing binaire KSP canonique. L'objectif est :
réduire fortement la taille persistée
supprimer l'encodage Base64 des champs déjà binaires
éviter qu'un .kspwallet nouvellement créé soit un document JSON lisible comme tel
conserver un format documenté et implémentable hors Rust
préserver les frontières cryptographiques Wallet
Le caractère binaire n'ajoute aucune propriété cryptographique. La confidentialité et l'authenticité continuent de dépendre d'Argon2id, XChaCha20-Poly1305 et Ed25519, jamais de l'absence de JSON.
0.2.6-pre.015 fige la grammaire binaire, les identifiants numériques, les bornes structurelles et les transcripts/AAD V2. 0.2.6-pre.016 matérialise la création/ouverture/persistence V2, l'auto-détection V1/V2, les APIs génériques/versionnées et le basculement de Wallet Desk vers la façade non versionnée. 0.2.6-pre.017 ajoute la migration V1 -> V2 explicite et OWNER-authentifiée, en copie no-clobber ou remplacement atomique in-place.
V1 reste un format stable supporté. V2 ne réinterprète jamais un document V1 avec sa propre grammaire.
2. Politique de version et API
Les notions suivantes sont distinctes :
DEFAULT_WALLET_FORMAT
LATEST_SUPPORTED_WALLET_FORMAT
À partir de l'intégration V2 :
DEFAULT_WALLET_FORMAT = V2
LATEST_SUPPORTED_WALLET_FORMAT = V2
Si un futur V3 apparaît, par exemple pour un modèle d'autorisation avec second facteur, LATEST_SUPPORTED_WALLET_FORMAT pourra devenir V3 tandis que DEFAULT_WALLET_FORMAT pourra rester V2. Le default ne suit jamais automatiquement la dernière version.
La politique d'API retenue est :
create_wallet_file(...) -> format default explicitement choisi, V2
create_wallet_file_v1(...) -> force V1
create_wallet_file_v2(...) -> force V2
create_wallet_file_v3(...) -> force V3 si V3 existe un jour
open_wallet_*_file(...) -> détecte puis dispatch les versions supportées
open_wallet_*_file_v1(...) -> exige V1
open_wallet_*_file_v2(...) -> exige V2
open_wallet_*_file_v3(...) -> exige V3 si V3 existe un jour
Le même principe s'applique aux opérations dont la version de fichier est pertinente, notamment inspection et import natif.
2.1 Surface matérialisée en pre.016
La politique ci-dessus est désormais du code, pas uniquement une cible documentaire :
create_wallet(...) -> V2 default
create_wallet_v1(...) -> V1 forcé
create_wallet_v2(...) -> V2 forcé
create_wallet_file(...) -> V2 default
create_wallet_file_v1(...) -> V1 forcé
create_wallet_file_v2(...) -> V2 forcé
open_wallet_view/owner(...) -> détecte V1/V2
open_wallet_*_v1/_v2(...) -> exige exactement V1/V2
inspect_locked_wallet(...) -> détecte V1/V2
inspect_locked_wallet_v1/_v2 -> exige exactement V1/V2
import_wallet_transfer(...) -> crée le default V2
import_wallet_transfer_v1/_v2 -> force le format natif produit
WalletOwner et WalletView mémorisent le format natif authentifié. Les opérations d'administration et de rotation persistent dans ce même format ; aucune mutation ordinaire ne réalise de conversion V1/V2. to_native_bytes() sérialise le format courant, tandis que to_json_bytes() reste une compatibilité V1 et rejette V2.
3. Encodage général
Un document V2 est :
binaire
maximum 1 048 576 octets
entiers multi-octets = unsigned big-endian
ordre des champs = normatif
aucun padding implicite
aucune Base64
aucune compression
aucun trailing byte
Les longueurs sont exprimées en octets.
Une implémentation conforme doit vérifier les bornes avant toute allocation dépendant d'une longueur reçue. Le document entier est déjà borné par 1 048 576 octets avant parsing.
4. Header V2
Le début de fichier est strictement :
| Ordre | Champ | Taille | Valeur / règle |
|---|---|---|---|
| 1 | magic |
9 | ASCII exact KSPWALLET |
| 2 | format_version |
2 | 0x0002 |
| 3 | document_length |
4 | longueur totale exacte du fichier |
| 4 | flags |
2 | bit 0 = VIEW activé ; tous les autres bits = 0 |
| 5 | owner_auth_public_key |
32 | clé publique Ed25519 OWNER |
| 6 | view_descriptor.slot_id |
16 conditionnels | présent uniquement si flags & 0x0001 != 0 |
Offsets fixes avant le descripteur conditionnel :
0x0000..0x0008 magic
0x0009..0x000A format_version
0x000B..0x000E document_length
0x000F..0x0010 flags
0x0011..0x0030 owner_auth_public_key
Pour un wallet VIEW-enabled, view_descriptor.slot_id occupe ensuite 0x0031..0x0040.
4.1 Flags
0x0001 VIEW_ENABLED
0xFFFE réservé, doit être zéro
Tout bit réservé non nul rend le document invalide. V2 n'emploie pas les bits réservés comme mécanisme d'extension silencieuse ; une modification incompatible exige un nouveau format.
5. Ordre canonique du body
Après le header :
OWNER key slot obligatoire
VIEW key slot présent seulement si VIEW_ENABLED
OWNER-CONTROL encrypted compartment obligatoire
METADATA encrypted compartment obligatoire
SECRET encrypted compartment obligatoire
state signature obligatoire
EOF immédiat
Aucun compteur de slots ou de compartiments n'est nécessaire : leur cardinalité et leur ordre sont déterminés par V2.
6. Key slot V2
Chaque key slot est encodé ainsi :
| Champ | Taille | Valeur / règle |
|---|---|---|
role |
1 | 0x01 OWNER, 0x02 VIEW |
slot_id |
16 | identifiant binaire exact |
kdf_algorithm |
1 | 0x01 Argon2id |
kdf_version |
4 | 19 |
memory_kib |
4 | 1..1 048 576, et >= parallelism * 8 |
iterations |
4 | 1..64 |
parallelism |
4 | 1..64 |
salt_length |
1 | 16..64 |
salt |
variable | exactement salt_length octets |
wrap_algorithm |
1 | 0x01 XChaCha20-Poly1305 |
wrap_nonce |
24 | nonce exact |
wrap_ciphertext_length |
2 | 16..4096 |
wrap_ciphertext |
variable | exactement la longueur déclarée |
Ordre obligatoire :
premier slot = OWNER
second slot = VIEW seulement si VIEW_ENABLED
Si VIEW est activé :
header.view_descriptor.slot_id == VIEW key slot.slot_id
Toute divergence est invalide avant KDF/déchiffrement.
7. Compartiments V2
Chaque compartiment est encodé :
| Champ | Taille | Valeur / règle |
|---|---|---|
kind |
1 | 0x01 OWNER-CONTROL, 0x02 METADATA, 0x03 SECRET |
payload_version |
4 | 1 pour le profil initial V2 |
algorithm |
1 | 0x01 XChaCha20-Poly1305 |
nonce |
24 | nonce exact |
ciphertext_length |
4 | longueur exacte |
ciphertext |
variable | ciphertext + tag Poly1305 |
Bornes initiales :
OWNER-CONTROL : 16..4096 octets
METADATA : 16..65552 octets
SECRET : 16..4096 octets
L'ordre exact est OWNER-CONTROL, METADATA, SECRET. Le kind encodé doit correspondre à la position attendue ; il n'autorise pas un réordonnancement.
Les payloads plaintext V2 conservent le modèle fonctionnel établi en V1 pour cette évolution : owner-control, metadata et secret Solana restent des compartiments distincts. Une future modification incompatible de leur sémantique exige une version de format explicite.
8. Signature d'état
La fin du document est :
| Champ | Taille | Valeur / règle |
|---|---|---|
state_signature.algorithm |
1 | 0x01 Ed25519 |
state_signature.signature |
64 | signature detached exacte |
Aucun octet ne peut suivre ces 65 octets.
9. Identifiants numériques figés
9.1 Rôles
0x01 OWNER
0x02 VIEW
9.2 Algorithmes
KDF
0x01 Argon2id
AEAD
0x01 XChaCha20-Poly1305
state signature
0x01 Ed25519
9.3 Compartiments
0x01 OWNER-CONTROL
0x02 METADATA
0x03 SECRET
0x00 est invalide dans ces espaces. Les autres valeurs sont réservées et rejetées par V2 tant qu'elles ne sont pas explicitement normalisées par une évolution compatible documentée ; aucune valeur inconnue n'est devinée.
10. Canonicalité et rejet
Le parser V2 doit rejeter avant crypto :
fichier > 1 MiB
magic incorrect
format_version != 2
document_length différent de la taille reçue
flag réservé non nul
champ tronqué
longueur qui dépasse le reste du document
role/algorithm/kind inconnu
ordre OWNER/VIEW invalide
VIEW descriptor et VIEW slot incohérents
Argon2 hors bornes
salt hors bornes
ciphertext hors bornes
payload_version non supporté
trailing bytes
Il n'existe qu'un encodage canonique d'un même état sémantique V2 : mêmes champs, même ordre, mêmes largeurs, même endianness et aucune donnée ignorée.
11. Transcripts et AAD V2
V2 possède ses propres domaines ; V1 et V2 ne partagent jamais un domain separator :
KSPWALLET-V2-STATE
KSPWALLET-V2-AAD-OWNER-SLOT
KSPWALLET-V2-AAD-VIEW-SLOT
KSPWALLET-V2-AAD-OWNER-CONTROL
KSPWALLET-V2-AAD-METADATA
KSPWALLET-V2-AAD-SECRET
Le transcript/AAD V2 conserve la discipline TLV déterministe de V1 :
domain || 0x00
puis pour chaque champ :
tag:u16 big-endian
length:u64 big-endian
value:length bytes
Les tags restent alignés avec les familles V1 :
0x0001 magic
0x0002 format_version
0x0003 owner_auth_public_key
0x0010 view_enabled
0x0011 view_role
0x0012 view_slot_id
0x0100 slot_id
0x0101 slot_role
0x0102 kdf_algorithm
0x0103 kdf_version
0x0104 kdf_memory_kib
0x0105 kdf_iterations
0x0106 kdf_parallelism
0x0107 kdf_salt
0x0108 wrap_algorithm
0x0109 wrap_nonce
0x010A wrap_ciphertext
0x0200 compartment_kind
0x0201 compartment_version
0x0202 compartment_algorithm
0x0203 compartment_nonce
0x0204 compartment_ciphertext
0x0500 state_signature_algorithm
Différence normative V2 : les rôles/algorithmes/kinds sont transcriptés sous leur ID numérique d'un octet, et format_version = 2 est transcripté en u32 big-endian. Les domaines distincts empêchent qu'un transcript V1 et un transcript V2 soient interchangeables.
11.1 State transcript
Le state transcript contient :
common
view descriptor
OWNER slot avec wrap nonce+ciphertext
OWNER-CONTROL avec nonce+ciphertext
METADATA avec nonce+ciphertext
SECRET avec nonce+ciphertext
state signature algorithm
Le VIEW wrap mutable n'est pas ajouté au state transcript, selon le modèle d'autorisation VIEW déjà retenu : son identité stable reste liée par le descriptor signé tandis que son credential peut être self-rotaté sans OWNER.
11.2 Slot AAD
Le slot AAD contient :
common
slot_id
role
KDF algorithm/version/parameters/salt
wrap algorithm
Le wrap nonce/ciphertext n'est pas inclus dans son propre AAD.
11.3 Compartment AAD
Le compartment AAD contient :
common
kind
payload_version
algorithm
Le nonce/ciphertext n'est pas inclus dans son propre AAD.
12. V1 et V2
V1 et V2 sont deux formats explicites :
| Propriété | V1 | V2 |
|---|---|---|
| enveloppe | JSON UTF-8 | binaire KSP |
| champs binaires | Base64url no-pad | bytes directs |
| version | 1 |
2 |
| canonicalité | sémantique JSON + Base64 canonique | byte-exact |
| taille fixture wire-only | 2037 octets | 628 octets |
| Argon2id | oui | oui |
| XChaCha20-Poly1305 | oui | oui |
| Ed25519 OWNER state | oui | oui |
| VIEW/OWNER | oui | oui |
La réduction mesurée sur le fixture structurel de référence est :
2037 -> 628 octets
-1409 octets
~69,2 %
Cette mesure n'est pas une promesse de ratio constant : la taille dépend notamment des metadata chiffrées.
13. Fixture structurelle
Fixture canonique de pre.015 :
crates/ksp-wallet-lib/tests/fixtures/kspwallet_v2_wire_only.bin
size = 628
sha256 = cbacb37326c76e6dde8d614601cd765a8c0fd9744f891d640b8a3cd71cf60a93
Préfixe hexadécimal :
4b535057414c4c4554 0002 00000274 0001
000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
202122232425262728292a2b2c2d2e2f
Lecture :
4b535057414c4c4554 = "KSPWALLET"
0002 = format_version V2
00000274 = 628 octets
0001 = VIEW_ENABLED
La fixture wire_only vérifie uniquement framing/canonicalité et reste volontairement distincte des wallets V2 runtime produits depuis pre.016; elle n'est pas présentée comme une signature cryptographique valide.
14. Compatibilité et migration
V2 n'autorise aucune migration implicite à l'ouverture.
Politique prévue :
lecture générique V1/V2 oui
création générique V2
création V1 forcée oui
création V2 forcée oui
réécriture V1 -> V2 cachée non
migration V1 -> V2 opération explicite
pre.017 matérialise cette politique avec trois surfaces :
migrate_wallet_v1_to_v2(...)
migrate_wallet_file_v1_to_v2(...)
migrate_wallet_file_v1_to_v2_in_place(...)
La migration authentifie toujours le wallet source avec OWNER. Elle ne transcode pas les slots/ciphertexts V1 : elle ouvre le V1, conserve l'identité Solana et le payload metadata exact — y compris les identifiants de notes — puis construit un nouvel envelope V2 avec de nouveaux matériaux cryptographiques et les domains/transcripts V2. Le mot de passe OWNER fourni reste le credential OWNER cible.
Lorsque VIEW est activé en V1, le caller doit fournir un mot de passe VIEW cible pour V2, car le key-wrap V1 ne peut pas être réutilisé sous l'AAD V2. OWNER peut fournir l'ancien credential ou en choisir un nouveau, conformément à son autorité existante de rotation VIEW. Une migration pure conserve en revanche la forme de capability : elle ne peut ni activer VIEW sur une source désactivée, ni supprimer VIEW d'une source activée.
La migration fichier vers une autre destination est no-clobber et laisse V1 intact. La variante in-place compare l'état V1 authentifié attendu avant publication et utilise le remplacement atomique existant ; un changement concurrent retourne wallet.state_conflict. wallet.migration_invalid couvre une demande de migration incohérente.
Une simple transcodification non authentifiée des bytes V1 n'est pas suffisante puisque V2 possède ses propres domains/transcripts.
15. Future V3 / second facteur
V2 ne réalise aucun second facteur.
Si un futur V3 ajoute une autorisation dépendant d'un facteur externe :
ksp-wallet-lib possède la règle qui exige le facteur
ksp-wallet-lib possède le challenge et la validation cryptographique
aucun WalletOwner ne peut être obtenu si le facteur requis n'est pas satisfait
Wallet Desk ne décide jamais que le second facteur est valide
Une UI comme Wallet Desk peut néanmoins devoir évoluer pour orchestrer l'expérience : attente de confirmation, saisie OTP, enrollment, recovery, hardware/WebAuthn ou consentement externe.
Le réseau ou le fournisseur externe ne doit pas forcer ksp-wallet-lib à dépendre directement de Config/Transport/Tauri. Une abstraction/provider KSP séparée peut fournir la preuve à Wallet tandis que Wallet reste propriétaire de la politique d'autorisation.
L'arrivée d'un V3 ne change pas automatiquement le default : V2 peut rester DEFAULT_WALLET_FORMAT aussi longtemps que KSP le décide explicitement.