javascriptencryptioncryptojsrc4-cipher

How to encrypt any js file (in browser) using CryptoJS?


I have a task: Implement a program that encrypts a file using a strong symmetric cipher. After researching the requirements and features, I chose the RC4 algorithm and its implementation in the CryptoJS library.

https://jsfiddle.net/alexander_js_developer/nuevwrp0/

HTML part:

<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js" integrity="sha512-E8QSvWZ0eCLGk4km3hxSsNmGWbLtSCSUcewDQPQWZF6pEU8GlT8a5fF32wOl1i8ftdMhssTrF/OhyGWwonTcXA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<div>
  <h1>encrypt/decrypt file</h1>
    <ol>
        <li>Set password</li>
        <li>Pick a file</li>
        <li>Download decrypted/encrypted file</li>
    </ol>
  <div>
    <input type="text" id="pass" placeholder="pass">
    <button id="encrypt">encrypt file</button>
    <button id="decrypt">decrypt file</button>
  </div>
</div>

JavaScript part:

// support
const download = (data, filename, type) => {
  const file = new Blob([data], { type: type });
  const a = document.createElement('a');
  const url = URL.createObjectURL(file);

  a.href = url;
  a.download = filename;
  document.body.appendChild(a);

  a.click();

  setTimeout(function () {
    document.body.removeChild(a);
    window.URL.revokeObjectURL(url);
  }, 0);
}

const pickAFile = (getText = true) => {
  return new Promise((resolve, reject) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.onchange = (e) => {
      const file = e.target.files[0];
      const reader = new FileReader();

      if (!getText) {
        resolve(file);
      } else {
        reader.onload = (e) => resolve(e.target.result);
        reader.onerror = (e) => reject(e);

        reader.readAsText(file);
      }
    };

    input.click();
  });
};
// /support

function app () {
    const passNode = document.querySelector('input#pass');
    const encryptNode = document.querySelector('#encrypt');
    const decryptNode = document.querySelector('#decrypt');

    encryptNode.addEventListener('click', () => {
        if (!passNode.value) return alert('Password input is empty! Aborting.');
        const pass = CryptoJS.SHA3(passNode.value);

        pickAFile(false).then((file) => {
            const reader = new FileReader();
            
            reader.onload = (e) => {
                const encrypted = CryptoJS.RC4.encrypt(e.target.result, pass).toString();

                download(encrypted, `encrypted-${file.name}`, file.type);
            };

        reader.readAsText(file);
        });
    });

    decryptNode.addEventListener('click', () => {
        if (!passNode.value) return alert('Password input is empty! Aborting.');
        const pass = CryptoJS.SHA3(passNode.value);

        pickAFile(false).then((file) => {
        
            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    const decrypted = CryptoJS.RC4.decrypt(e.target.result, pass).toString(
                        CryptoJS.enc.Utf8
                    );

                    download(decrypted, `decrypted-${file.name}`, file.type);
                } catch (error) {
                    console.log('wrong password!');
                }
            };

            reader.readAsText(file);
        });
    });
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', app);
} else {
    app();
}

This code quickly and stably encrypts and decrypts files with utf-8 content (.txt, .js). But binary files (pictures, .exe, etc.) break. I suspect this is the place: reader.readAsText(file). The program reads the file as text and already at this stage the binary files get corrupted. I think that I need to convert the file into a bit stream and encrypt them already. But I still don't understand how to do it. I know about typed arrays, buffers and views, but I have very little experience with them.

How can I implement the following schema:

encrypt: file.ex => bytes => encrypt (CryptoJS) => bytes => file.ex

decrypt: file.ex => bytes => decrypt (CryptoJS) => bytes => file.ex

?

Metadata is important to keep. It would be nice if they were also encrypted, but this is not necessary. The speed of encryption / decryption and the weight of encrypted files are also critical.

Thank you!


Solution

  • Your guess is right. reader.readAsText() applies a UTF8 encoding by default, which is correct for UTF8 encoded text files, but corrupts binary data files (images, .exe, etc.). Therefore more general reader.readAsArrayBuffer() must be used. The data is loaded into an ArrayBuffer, which must be converted to the CryptoJS internal WordArray type with CryptoJS.lib.WordArray.create() before further processing by CryptoJS.

    During encryption the ciphertext was Base64 encoded, i.e. stored as text. Therefore, when decrypting, the ciphertext can be loaded with reader.readAsText() as before. The decrypted data is again of the CryptoJS internal WordArray type, which can be converted to a e.g. Uint8Array (see below, function convertWordArrayToUint8Array()), that can be processed directly by the Blob constructor (see function download()).

    With these changes, encryption and decryption of arbitrary binary files work.


    Full code:

    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js" integrity="sha512-E8QSvWZ0eCLGk4km3hxSsNmGWbLtSCSUcewDQPQWZF6pEU8GlT8a5fF32wOl1i8ftdMhssTrF/OhyGWwonTcXA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    
    <script>
    // support
    const download = (data, filename, type) => {
      const file = new Blob([data], { type: type });
      const a = document.createElement('a');
      const url = URL.createObjectURL(file);
    
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
    
      a.click();
    
      setTimeout(function () {
        document.body.removeChild(a);
        window.URL.revokeObjectURL(url);
      }, 0);
    }
    
    const pickAFile = (getText = true) => {
        return new Promise((resolve, reject) => {
            const input = document.createElement('input');
            input.type = 'file';
            input.onchange = (e) => {
                const file = e.target.files[0];
                const reader = new FileReader();
                if (!getText) {
                    resolve(file);
                } else {
                    reader.onload = (e) => resolve(e.target.result);
                    reader.onerror = (e) => reject(e);
                    reader.readAsText(file);
                }
            };
            input.click();
        });
    };
    // /support
    
    function app () {
        const passNode = document.querySelector('input#pass');
        const encryptNode = document.querySelector('#encrypt');
        const decryptNode = document.querySelector('#decrypt');
    
        encryptNode.addEventListener('click', () => {
            if (!passNode.value) return alert('Password input is empty! Aborting.');
            const pass = CryptoJS.SHA3(passNode.value);
            pickAFile(false).then((file) => {
                const reader = new FileReader();            
                reader.onload = (e) => {
                    var wordArray = CryptoJS.lib.WordArray.create(e.target.result); // Fix 2a: Convert data to WordArray type
                    const encrypted = CryptoJS.RC4.encrypt(wordArray, pass).toString(); // Fix 2b: Pass the WordArray
                    download(encrypted, `encrypted-${file.name}`, file.type);
                };
                reader.readAsArrayBuffer(file); // Fix 1: replace readAsText() with readAsArrayBuffer()
            });
        });
    
        decryptNode.addEventListener('click', () => {
            if (!passNode.value) return alert('Password input is empty! Aborting.');
            const pass = CryptoJS.SHA3(passNode.value);
            pickAFile(false).then((file) => {       
                const reader = new FileReader();
                reader.onload = (e) => {
                    try {
                        const decrypted = CryptoJS.RC4.decrypt(e.target.result, pass); // Fix 3a: Return the decrypted data as WordArray
                        var typedArray = convertWordArrayToUint8Array(decrypted); // Fix 3b: Convert the WordArray into a Uint8Array
                        download(typedArray, `decrypted-${file.name}`, file.type); // Fix 3c: Pass the typed array
                    } catch (error) {
                        console.log('wrong password!');
                    }
                };
                reader.readAsText(file);
            });
        });
        
        // Fix 4: New function convertWordArrayToUint8Array
        function convertWordArrayToUint8Array(wordArray) {
            var arrayOfWords = wordArray.hasOwnProperty("words") ? wordArray.words : [];
            var length = wordArray.hasOwnProperty("sigBytes") ? wordArray.sigBytes : arrayOfWords.length * 4;
            var uInt8Array = new Uint8Array(length), index=0, word, i;
            for (i=0; i<length; i++) {
                word = arrayOfWords[i];
                uInt8Array[index++] = word >> 24;
                uInt8Array[index++] = (word >> 16) & 0xff;
                uInt8Array[index++] = (word >> 8) & 0xff;
                uInt8Array[index++] = word & 0xff;
            }
            return uInt8Array;
        }
    }
    
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', app);
    } else {
        app();
    }
    </script>
    
    <div>
        <h1>encrypt/decrypt file</h1>
        <ol>
            <li>Set password</li>
            <li>Pick a file</li>
            <li>Download decrypted/encrypted file</li>
        </ol>
        <div>
            <input type="text" id="pass" placeholder="pass">
            <button id="encrypt">encrypt file</button>
            <button id="decrypt">decrypt file</button>
        </div>
    </div>
    

    Keep in mind Peter's hint in the comments. Nowadays it is better to use AES instead of RC4