🐛 Correction du bug de conversion WordArray ↔ Uint8Array¶
Date: 11 novembre 2025 Statut: ✅ Corrigé et testé Priorité: 🔴 Critique Impact: Affecte tous les utilisateurs (keystores V2 et V3)
📋 Résumé¶
Correction d'un bug critique dans les conversions bidirectionnelles entre CryptoJS WordArray et Uint8Array qui causait :
- Déchiffrement V2 CBC : Retour de seulement 8 bytes au lieu de 32 bytes
- Chiffrement V3 GCM : Conversion incorrecte lors de la sérialisation en base64
🔍 Symptômes¶
Lorsqu'un utilisateur essayait d'ajouter un document avec un keystore V2 (legacy CBC), l'erreur suivante apparaissait :
WARN ⚠️ [CRYPTO] Master key length unexpected (8), regenerating V3 keystore...
ERROR ❌ Erreur traitement document: [Error: Master key invalid length after regeneration]
L'application tentait de régénérer le keystore V3 mais échouait car la master key déchiffrée faisait seulement 8 bytes au lieu des 32 bytes requis pour AES-256.
🔬 Analyse technique¶
Causes racines¶
Il y avait deux bugs dans les conversions CryptoJS ↔ Uint8Array :
Bug 1 : Déchiffrement V2 CBC (WordArray → Uint8Array)¶
Le problème se situait dans src/services/crypto.ts:236 :
// ❌ CODE BUGGÉ (ligne 236)
const arr = CryptoJS.enc.Base64.parse(dec.toString(CryptoJS.enc.Base64));
return new Uint8Array(arr.words as any);
Pourquoi ça ne fonctionnait pas ?
CryptoJS stocke les données dans un WordArray où chaque élément est un mot de 32 bits (4 bytes), pas un byte individuel :
- Pour 32 bytes (AES-256), un WordArray contient 8 words (8 × 4 = 32 bytes)
- Le code buggé utilisait
arr.wordsdirectement, créant unUint8Array(8)avec les 8 words (32-bit integers) au lieu des 32 bytes - Résultat : Une clé de 8 bytes au lieu de 32 bytes
Bug 2 : Chiffrement/Sérialisation (Uint8Array → WordArray)¶
Le problème se situait dans src/services/crypto.ts:82 :
// ❌ CODE BUGGÉ (ligne 82)
const words = CryptoJS.lib.WordArray.create(
buf instanceof Uint8Array ? buf : new Uint8Array(buf),
);
return CryptoJS.enc.Base64.stringify(words);
CryptoJS.lib.WordArray.create() avec un Uint8Array directement causait le même problème inverse : les bytes étaient interprétés comme des words.
Les corrections¶
Correction 1 : WordArray → Uint8Array (lignes 236-242)¶
// ✅ CODE CORRIGÉ
const sigBytes = dec.sigBytes;
const result = new Uint8Array(sigBytes);
for (let i = 0; i < sigBytes; i++) {
result[i] = (dec.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
}
return result;
Correction 2 : Uint8Array → WordArray (lignes 84-94)¶
// ✅ CODE CORRIGÉ
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
const words = [];
for (let i = 0; i < u8.length; i += 4) {
const word =
((u8[i] || 0) << 24) |
((u8[i + 1] || 0) << 16) |
((u8[i + 2] || 0) << 8) |
(u8[i + 3] || 0);
words.push(word);
}
const wordArray = CryptoJS.lib.WordArray.create(words, u8.length);
return CryptoJS.enc.Base64.stringify(wordArray);
Comment ça fonctionne ?
Les deux conversions sont maintenant symétriques :
WordArray → Uint8Array :
i >>> 2: Divise par 4 pour obtenir l'index du word24 - (i % 4) * 8: Calcule le décalage de bits pour le byte& 0xff: Masque pour extraire un seul byte
Uint8Array → WordArray :
- Grouper 4 bytes consécutifs
- Décaler chaque byte à sa position dans le word (24, 16, 8, 0 bits)
- Combiner avec OR binaire (
|) - Créer le WordArray avec la liste de words + longueur exacte
Exemple concret :
Bytes: [0x00, 0x01, 0x02, 0x03]
Uint8Array → WordArray:
word = (0x00 << 24) | (0x01 << 16) | (0x02 << 8) | 0x03
word = 0x00010203
WordArray → Uint8Array:
Byte 0: (word[0] >>> 24) & 0xff = 0x00
Byte 1: (word[0] >>> 16) & 0xff = 0x01
Byte 2: (word[0] >>> 8) & 0xff = 0x02
Byte 3: (word[0] >>> 0) & 0xff = 0x03
✅ Tests de régression¶
Trois tests spécifiques ont été créés pour garantir qu'aucun des deux bugs ne reviendra :
Test 1 : Déchiffrement V2 CBC complet¶
Fichier : src/__tests__/crypto.test.ts:132-204
it("should correctly decrypt V2 CBC keystore and return 32-byte master key (bug regression test)", async () => {
// Crée un vrai keystore V2 CBC avec une master key de 32 bytes
const realMasterKey = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
realMasterKey[i] = i; // 0, 1, 2, ..., 31
}
// Chiffre avec CryptoJS en V2 CBC
const encrypted = CryptoJS.AES.encrypt(masterKeyWA, passKey, { iv });
// Déchiffre via ensureMasterKey
const unlockedKey = await crypto.ensureMasterKey(password);
// ASSERTIONS CRITIQUES
expect(unlockedKey.length).toBe(32); // ✅ 32 bytes, PAS 8!
expect(Array.from(unlockedKey)).toEqual(Array.from(realMasterKey));
});
Test 2 : Conversion bidirectionnelle (32 bytes)¶
Fichier : src/__tests__/crypto.test.ts:310-348
it("should correctly convert Uint8Array to base64 and back (32 bytes - Bug regression test)", () => {
const originalBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
originalBytes[i] = i;
}
// Uint8Array → WordArray → base64 → WordArray → Uint8Array
// (round-trip conversion)
// CRITICAL ASSERTIONS
expect(bytesBack.length).toBe(32); // Must be 32 bytes, NOT 8!
expect(Array.from(bytesBack)).toEqual(Array.from(originalBytes));
});
Test 3 : Conversion avec tailles non multiples de 4¶
Fichier : src/__tests__/crypto.test.ts:382-413
it("should handle non-multiple-of-4 byte arrays (33 bytes)", () => {
const originalBytes = new Uint8Array(33);
// Round-trip conversion test
expect(bytesBack.length).toBe(33);
expect(Array.from(bytesBack)).toEqual(Array.from(originalBytes));
});
Résultats des tests¶
PASS src/__tests__/crypto.test.ts
✓ should correctly decrypt V2 CBC keystore and return 32-byte master key (2773 ms)
✓ should correctly convert Uint8Array to base64 and back (32 bytes - Bug regression test)
✓ should correctly convert arbitrary byte arrays (16 bytes)
✓ should handle non-multiple-of-4 byte arrays (33 bytes)
Test Suites: 1 passed
Tests: 4 passed (+ 29 autres tests crypto)
📊 Impact¶
Avant la correction¶
- ❌ Les utilisateurs avec keystores V2 ne pouvaient pas chiffrer de nouveaux documents
- ❌ La migration V2 → V3 échouait
- ❌ L'erreur forçait une régénération du keystore (perte potentielle de données)
Après la correction¶
- ✅ Déchiffrement V2 CBC fonctionne correctement (32 bytes)
- ✅ Migration V2 → V3 réussit automatiquement
- ✅ Les utilisateurs peuvent ajouter des documents sans erreur
- ✅ Test de régression garantit qu'il n'y aura pas de régression future
🧪 Vérification¶
Tests¶
npm run test:ci
# ✅ 164/164 tests passent (100%)
# ✅ Coverage: 34.02% statements
npm test -- crypto.test.ts --testNamePattern="V2 CBC"
# ✅ Test de régression V2 CBC passe
npm test -- crypto.test.ts --testNamePattern="conversion helpers"
# ✅ Tests de conversion bidirectionnelle passent (3 tests)
Qualité du code¶
📝 Fichiers modifiés¶
Code de production¶
src/services/crypto.ts:84-94- CorrectionbufToB64(): Uint8Array → WordArraysrc/services/crypto.ts:236-242- Correctionb64ToBuf()dans déchiffrement : WordArray → Uint8Arraysrc/services/crypto.ts:95-107- Correctionb64ToBuf()helper : WordArray → Uint8Array
Tests¶
src/__tests__/crypto.test.ts:132-204- Nouveau test de régression V2 CBC
Documentation¶
- BUG_FIX_V2_CBC_DECRYPTION.md - Ce fichier
🎯 Leçons apprises¶
Bonnes pratiques¶
- Comprendre les structures de données tierces : CryptoJS WordArray ≠ Array de bytes
- Tests de régression : Toujours créer un test qui reproduit le bug
- Migration incrémentale : Support V2 legacy + V3 moderne crucial pour les utilisateurs existants
- Vérifications de longueur : Le check
master.length !== 32à la ligne 363 a permis de détecter le bug
Points d'attention¶
- ⚠️ La conversion entre différentes représentations crypto (WordArray, Uint8Array, Buffer) nécessite une attention particulière
- ⚠️ Les tests unitaires doivent utiliser de vraies données crypto, pas seulement des mocks
- ⚠️ Les conversions binaires doivent être testées avec les longueurs exactes attendues
🚀 Prochaines étapes¶
- ✅ Déploiement : Le fix est prêt pour production
- ✅ Tests passés : Tous les tests incluant le test de régression
- 📱 Test utilisateur : Vérifier avec l'utilisateur que l'ajout de document fonctionne
- 📝 Suivi : Monitorer les logs pour s'assurer qu'aucune erreur "Master key length unexpected" n'apparaît
📚 Références¶
- Issue utilisateur : "Lorsque j'ajoute un document, j'ai cette erreur"
- CryptoJS WordArray : https://cryptojs.gitbook.io/docs/#the-cipher-algorithms
- AES-256 Key Length : 32 bytes (256 bits)
- Migration V2 → V3 : TESTS_CRYPTO_COMPLETE.md
Contact : support@probatiovault.com Documentation : https://probatiovault.com/docs