# `.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` et publié stable avec KSP `0.2.6`. V2 remplace l'enveloppe JSON/Base64url de V1 par un framing binaire KSP canonique. L'objectif est : ```text 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 : ```text DEFAULT_WALLET_FORMAT LATEST_SUPPORTED_WALLET_FORMAT ``` À partir de l'intégration V2 : ```text 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 : ```text 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 : ```text 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 : ```text 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 : ```text 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 ```text 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 : ```text 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 : ```text premier slot = OWNER second slot = VIEW seulement si VIEW_ENABLED ``` Si VIEW est activé : ```text 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 : ```text 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 ```text 0x01 OWNER 0x02 VIEW ``` ### 9.2 Algorithmes ```text KDF 0x01 Argon2id AEAD 0x01 XChaCha20-Poly1305 state signature 0x01 Ed25519 ``` ### 9.3 Compartiments ```text 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 : ```text 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 : ```text 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 : ```text 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 : ```text 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 : ```text 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 : ```text 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 : ```text 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 : ```text 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` : ```text crates/ksp-wallet-lib/tests/fixtures/kspwallet_v2_wire_only.bin size = 628 sha256 = cbacb37326c76e6dde8d614601cd765a8c0fd9744f891d640b8a3cd71cf60a93 ``` Préfixe hexadécimal : ```text 4b535057414c4c4554 0002 00000274 0001 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f 202122232425262728292a2b2c2d2e2f ``` Lecture : ```text 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 : ```text 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 : ```text 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 : ```text 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.