Aller au contenu

🐛 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 :

  1. Déchiffrement V2 CBC : Retour de seulement 8 bytes au lieu de 32 bytes
  2. 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.words directement, créant un Uint8Array(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 :

  1. i >>> 2 : Divise par 4 pour obtenir l'index du word
  2. 24 - (i % 4) * 8 : Calcule le décalage de bits pour le byte
  3. & 0xff : Masque pour extraire un seul byte

Uint8Array → WordArray :

  1. Grouper 4 bytes consécutifs
  2. Décaler chaque byte à sa position dans le word (24, 16, 8, 0 bits)
  3. Combiner avec OR binaire (|)
  4. 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

npm run type-check
# ✅ 0 erreur TypeScript

npm run lint
# ✅ 0 erreur ESLint

📝 Fichiers modifiés

Code de production

  • src/services/crypto.ts:84-94 - Correction bufToB64() : Uint8Array → WordArray
  • src/services/crypto.ts:236-242 - Correction b64ToBuf() dans déchiffrement : WordArray → Uint8Array
  • src/services/crypto.ts:95-107 - Correction b64ToBuf() helper : WordArray → Uint8Array

Tests

  • src/__tests__/crypto.test.ts:132-204 - Nouveau test de régression V2 CBC

Documentation


🎯 Leçons apprises

Bonnes pratiques

  1. Comprendre les structures de données tierces : CryptoJS WordArray ≠ Array de bytes
  2. Tests de régression : Toujours créer un test qui reproduit le bug
  3. Migration incrémentale : Support V2 legacy + V3 moderne crucial pour les utilisateurs existants
  4. 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

  1. Déploiement : Le fix est prêt pour production
  2. Tests passés : Tous les tests incluant le test de régression
  3. 📱 Test utilisateur : Vérifier avec l'utilisateur que l'ajout de document fonctionne
  4. 📝 Suivi : Monitorer les logs pour s'assurer qu'aucune erreur "Master key length unexpected" n'apparaît

📚 Références


Contact : support@probatiovault.com Documentation : https://probatiovault.com/docs