1

I've successfully encrypted data with crypto API. Once it's done, I save the initialization-vector and the encrypted data as a single base64 string.

When decrypting, I revert these two information to Uint8Array that match the originals. But the decryption always fails with the following error:

error decrypt Error: OperationError

Here is the code:

// generate key
generateKey (){
  crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

// encrypt
  async encrypt(data, secretKey) {
    const initializationVector = crypto.getRandomValues(new Uint8Array(96));
    const encodedData = new TextEncoder().encode(JSON.stringify(data));

    const encryptedBuffer = await crypto.subtle.encrypt(
      {
        name: "AES-GCM",
        iv: initializationVector,
        tagLength: 128,
      },
      secretKey,
      encodedData
    );

    const encryptedDataBase64 = btoa(new Uint8Array(encryptedBuffer));
    const initializationVectorBase64 = btoa(initializationVector);
    return `${encryptedDataBase64}.${initializationVectorBase64}`;
  }

// convert base64 string to uint8array
  base64ToUint8Array(base64String) {
    return new Uint8Array(
      atob(base64String)
        .split(",")
        .map((n) => +n)
    );
  }

//decrypt
  async decrypt(encryptedData, secretKey) {
    const { 0: data, 1: iv } = encryptedData.split(".");
    const initializationVector = base64ToUint8Array(iv);
    const _data = base64ToUint8Array(data);
    const decryptedData = await crypto.subtle.decrypt(
      {
        name: "AES-GCM",
        iv: initializationVector,
        tagLength: 128,
      },
      secretKey,
      _data
    );
    return new TextDecoder().decode(decryptedData)
  }

I've checked the initialization-vector and the data Uint8Array during the encryption and during the decryption. They match their original versions. So I don't know where I'm doing something wrong here.

Thanks for your help!

DoneDeal0
  • 5,273
  • 13
  • 55
  • 114

1 Answers1

1

The conversion from ArrayBuffer to Base64 and vice versa is not correct. Also, when creating the IV or instantiating the Uint8Array, the length must be specified in bytes and not bits. A possible fix is:

(async () => {
    var key = await generateKey();
    
    var plaintext = {"data": "The quick brown fox jumps over the lazy dog"};
    var ciphertext = await encrypt(plaintext, key);
    console.log(ciphertext.replace(/(.{48})/g,'$1\n'));
    
    var decrypted = await decrypt(ciphertext, key);
    console.log(JSON.parse(decrypted));
})();

// generate key
function generateKey (){                                            
    return crypto.subtle.generateKey(                                   
        { name: "AES-GCM", length: 256 },
        false,
        ["encrypt", "decrypt"]
    );
}

// encrypt
async function encrypt(data, secretKey) {                                   
    const initializationVector = crypto.getRandomValues(new Uint8Array(12)); // Fix: length in bytes
    const encodedData = new TextEncoder().encode(JSON.stringify(data));

    const encryptedBuffer = await crypto.subtle.encrypt(
        {
            name: "AES-GCM",
            iv: initializationVector,
            tagLength: 128,
        },
        secretKey,
        encodedData
    );

    const encryptedDataBase64 = ab2b64(encryptedBuffer); // Fix: Apply proper ArrayBuffer to Base64 conversion
    const initializationVectorBase64 = ab2b64(initializationVector); // Fix: Apply proper ArrayBuffer to Base64 conversion 
    return `${encryptedDataBase64}.${initializationVectorBase64}`;
}

// decrypt
async function decrypt(encryptedData, secretKey) {                      
    const { 0: data, 1: iv } = encryptedData.split(".");
    const initializationVector = b642ab(iv); // Fix: Apply proper Base64 to ArrayBuffer conversion
    const _data = b642ab(data); // Fix: Apply proper Base64 to ArrayBuffer conversion
    const decryptedData = await crypto.subtle.decrypt(
        {
            name: "AES-GCM",
            iv: initializationVector,
            tagLength: 128,
        },
        secretKey,
        _data
    );
    return new TextDecoder().decode(decryptedData)
}

// https://stackoverflow.com/a/11562550/9014097 or https://stackoverflow.com/a/9458996/9014097
function ab2b64(arrayBuffer) {
      return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
}

// https://stackoverflow.com/a/41106346 or https://stackoverflow.com/a/21797381/9014097
function b642ab(base64string){
      return Uint8Array.from(atob(base64string), c => c.charCodeAt(0));
}
Topaco
  • 40,594
  • 4
  • 35
  • 62
  • Thank you, your corrections work! In your opinion, is this AES encryption secure enough to store data in local-storage? Or should I use a different algorithm, add a password in the additionalData entry, etc? – DoneDeal0 Jun 28 '21 at 11:06
  • @DoneDeal0 - A security concept (especially for JavaScript) cannot be answered in a comment and would also be beyond the scope of this question. There are many posts on this topic on the web. But this much can be said: AES-GCM is secure (if used correctly). The problem is rather to secure the key (especially in a JavaScript environment). One concept could be to store the CryptoKey (configured as not extractable) rather in the IndexedDB (than in localstorage), see e.g. [this post](https://stackoverflow.com/a/49479890/16317602). – Topaco Jun 28 '21 at 12:46
  • Yes that is what I did, the secret key is stored in indexedDB. i'll make sure to read more about this topic soon. Thanks again for your help, I appreciate it. – DoneDeal0 Jun 28 '21 at 13:24