webcrypto-api

Error: OperationError (in promise) when trying to decrypt content


I have been playing with WebCrypto, building a little Astro site with an encoded block that is decoded using it (I know this wouldn't be particularly secure, it's not going to be used for anything serious). I've gotten a version working all on one page but am having the above error when trying to do it across the front-end and the middleware (nodejs). I have the following file that handles most the crypto stuff on both the front and middleware.

/**
 * Prepared some variables and does a very basic sanity check.
 */

var Crypto, Subtle;
if (typeof window === "undefined") {
  Crypto = globalThis.crypto;
  Subtle = Crypto.subtle;
} else {
  Crypto = window.crypto;
  Subtle = Crypto.subtle || Crypto.webkitSubtle;
}
if (!Subtle) {
  throw new Error(`Web Crypto API not supported, you're outta luck pal.`);
}

/**
 * Return a base64 string from the provided buffer array. From https://stackoverflow.com/a/68161336
 * @param {Uint8Array} arrayBuffer
 * @returns {String}
 */
function ab2b64(arrayBuffer) {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
}
/**
 * Return an array from the provided buffer bae64 string. From https://stackoverflow.com/a/68161336
 * @param {String} base64string
 * @returns {Uint8Array}
 */
function b642ab(base64string) {
  return Uint8Array.from(atob(base64string), (c) => c.charCodeAt(0));
}

/**
 * Returns a initialization vector for the AES encryption
 * @returns {Uint8Array}
 */
export const getInitVect = () => {
  return Crypto.getRandomValues(new Uint8Array(12));
};
/**
 * Returns a salt to spice up encryption
 * @returns {Uint8Array}
 */
export const getSalt = () => {
  return Crypto.getRandomValues(new Uint8Array(16));
};

/**
 * Returns a key derived from provided password with the encryption usage
 * @param {string} password
 * @returns {CryptoKey}
 */
export const getEncryptionKey = async (password) =>
  getEncryptionOrDecryptionKey(password, ["encrypt"]);
/**
 * Returns a key derived from provided password with the decryption usage
 * @param {string} password
 * @returns {CryptoKey}
 */
export const getDecryptionKey = async (password) =>
  getEncryptionOrDecryptionKey(password, ["decrypt"]);

/**
 * Returns an encryptionKey derived from the provided password with the requested usages
 * @param {string} password
 * @returns {CryptoKey}
 */
const getEncryptionOrDecryptionKey = async (password, usages) => {
  // Failure is not only an option, it's a probability.
  try {
    // Check that a password was even provided.
    if (password == null || password == undefined || password.trim() == "") {
      throw new Error("No password was provided");
    }

    // Turn a regular boring password and turn it into a key
    const importedKey = await Subtle.importKey(
      "raw",
      new TextEncoder().encode("password"),
      "PBKDF2",
      false,
      ["deriveKey"]
    );
    const encryptionKey = await Subtle.deriveKey(
      {
        name: "PBKDF2",
        salt: getSalt(),
        iterations: 250000,
        hash: "SHA-256",
      },
      importedKey,
      { name: "AES-GCM", length: 256 },
      false,
      usages
    );

    return encryptionKey;
    // Handle those errors by simply throwing them away.
  } catch (err) {
    throw err;
  }
};

/**
 * Encrypts the provided content with the provided password and returns a base64 string for use.
 * @param {string} content
 * @param {string} password
 * @returns {string}
 */
export const encrypt = async (content, password) => {
  if (password == undefined || password.trim() == "") {
    throw new Error("No password was provided.");
  }

  // Get the bits we need to start encryption
  const encryptionKey = await getEncryptionKey(password);
  const initVector = await getInitVect();
  const salt = await getSalt();

  // Encrypt the data
  const encryptedContent = new Uint8Array(
    await Subtle.encrypt(
      {
        name: "AES-GCM",
        iv: initVector,
        tagLength: 128,
      },
      encryptionKey,
      new TextEncoder().encode(content)
    )
  );
  console.log("salt", salt);
  console.log("initVector", initVector);
  console.log("encryptedContent", encryptedContent);

  // Return the encoded string
  return ab2b64([...salt, ...initVector, ...encryptedContent]);
};

/**
 * Decrypts a provided base64 string created by encode with the provided password.
 * @param {string} encryptedContent
 * @param {string} password
 * @returns {string}
 */
export const decrypt = async (content, password) => {
  if (password == undefined || password.trim() == "") {
    throw new Error("No password was provided.");
  }

  const encryptedData = b642ab(content);
  const Salt = encryptedData.slice(0, 16);
  const InitVect = encryptedData.slice(16, 16 + 12);
  const encryptedContent = encryptedData.slice(16 + 12);

  const encryptionKey = await getDecryptionKey(password);

  console.log("Salt", Salt);
  console.log("InitVect", InitVect);
  console.log("encryptedContent", encryptedContent);

  // Decrypt
  const decryptedContent = await Subtle.decrypt(
    {
      name: "AES-GCM",
      iv: InitVect,
      tagLength: 128,
    },
    encryptionKey,
    encryptedContent
  );

  // Return decrypted content
  return decryptedContent;
};

And the code in the component looks like this:

  document
    .querySelector("form.password-box")
    .addEventListener("submit", async (e) => {
      e.preventDefault();

      const providedPassword =
        document.querySelector('input[name="password"]').value + "password";
      const encryptedData = document.querySelector(
        "section[data-encryptedContent]"
      ).innerHTML;

      const decrypted = await decrypt(encryptedData, providedPassword);
    });

Using the console.logs in the first code block I've confirmed that the data coming from the encrypt and the data going into the decrypt are the same, and the code was working on the single page.


Solution

  • Decryption fails because different salts are used for key derivation during encryption and decryption and therefore different keys are used.

    Although the salt is extracted in the decrypt() function (const Salt = encryptedData.slice(0, 16)), this salt is not used for key derivation.
    Instead, a new random IV is generated, as getSalt() (which returns a random IV) is called in getEncryptionOrDecryptionKey() for decryption as well.

    Therefore, it is actually impossible for this code to work, even if encryption and decryption are performed within the same call, see the following example, which additionally outputs the (different) keys:

    (async () => {
    
    /**
     * Prepared some variables and does a very basic sanity check.
     */
    
    var Crypto, Subtle;
    if (typeof window === "undefined") {
      Crypto = globalThis.crypto;
      Subtle = Crypto.subtle;
    } else {
      Crypto = window.crypto;
      Subtle = Crypto.subtle || Crypto.webkitSubtle;
    }
    if (!Subtle) {
      throw new Error(`Web Crypto API not supported, you're outta luck pal.`);
    }
    
    /**
     * Return a base64 string from the provided buffer array. From https://stackoverflow.com/a/68161336
     * @param {Uint8Array} arrayBuffer
     * @returns {String}
     */
    function ab2b64(arrayBuffer) {
      return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
    }
    /**
     * Return an array from the provided buffer bae64 string. From https://stackoverflow.com/a/68161336
     * @param {String} base64string
     * @returns {Uint8Array}
     */
    function b642ab(base64string) {
      return Uint8Array.from(atob(base64string), (c) => c.charCodeAt(0));
    }
    
    /**
     * Returns a initialization vector for the AES encryption
     * @returns {Uint8Array}
     */
    /*export*/ const getInitVect = () => {
      return Crypto.getRandomValues(new Uint8Array(12));
    };
    /**
     * Returns a salt to spice up encryption
     * @returns {Uint8Array}
     */
    /*export*/ const getSalt = () => {
      return Crypto.getRandomValues(new Uint8Array(16));
    };
    
    /**
     * Returns a key derived from provided password with the encryption usage
     * @param {string} password
     * @returns {CryptoKey}
     */
    /*export*/ const getEncryptionKey = async (password) =>
      getEncryptionOrDecryptionKey(password, ["encrypt"]);
    /**
     * Returns a key derived from provided password with the decryption usage
     * @param {string} password
     * @returns {CryptoKey}
     */
    /*export*/ const getDecryptionKey = async (password) =>
      getEncryptionOrDecryptionKey(password, ["decrypt"]);
    
    /**
     * Returns an encryptionKey derived from the provided password with the requested usages
     * @param {string} password
     * @returns {CryptoKey}
     */
    const getEncryptionOrDecryptionKey = async (password, usages) => {
      // Failure is not only an option, it's a probability.
      try {
        // Check that a password was even provided.
        if (password == null || password == undefined || password.trim() == "") {
          throw new Error("No password was provided");
        }
    
        // Turn a regular boring password and turn it into a key
        const importedKey = await Subtle.importKey(
          "raw",
          new TextEncoder().encode("password"),
          "PBKDF2",
          false,
          ["deriveKey"]
        );
        const encryptionKey = await Subtle.deriveKey(
          {
            name: "PBKDF2",
            salt: getSalt(),
            iterations: 250000,
            hash: "SHA-256",
          },
          importedKey,
          { name: "AES-GCM", length: 256 },
          true, // false,  // for testing
          usages
        );
    
        return encryptionKey;
        // Handle those errors by simply throwing them away.
      } catch (err) {
        throw err;
      }
    };
    
    /**
     * Encrypts the provided content with the provided password and returns a base64 string for use.
     * @param {string} content
     * @param {string} password
     * @returns {string}
     */
    /*export*/ const encrypt = async (content, password) => {
      if (password == undefined || password.trim() == "") {
        throw new Error("No password was provided.");
      }
    
      // Get the bits we need to start encryption
      const encryptionKey = await getEncryptionKey(password);
      console.log("Key", ab2b64(await Subtle.exportKey('raw', encryptionKey)));
      const initVector = await getInitVect();
      const salt = await getSalt();
    
      // Encrypt the data
      const encryptedContent = new Uint8Array(
        await Subtle.encrypt(
          {
            name: "AES-GCM",
            iv: initVector,
            tagLength: 128,
          },
          encryptionKey,
          new TextEncoder().encode(content)
        )
      );
      //console.log("salt", salt);
      //console.log("initVector", initVector);
      //console.log("encryptedContent", encryptedContent);
    
      // Return the encoded string
      return ab2b64([...salt, ...initVector, ...encryptedContent]);
    };
    
    /**
     * Decrypts a provided base64 string created by encode with the provided password.
     * @param {string} encryptedContent
     * @param {string} password
     * @returns {string}
     */
    /*export*/ const decrypt = async (content, password) => {
      if (password == undefined || password.trim() == "") {
        throw new Error("No password was provided.");
      }
    
      const encryptedData = b642ab(content);
      const Salt = encryptedData.slice(0, 16);
      const InitVect = encryptedData.slice(16, 16 + 12);
      const encryptedContent = encryptedData.slice(16 + 12);
    
      const encryptionKey = await getDecryptionKey(password);
      console.log("Key", ab2b64(await Subtle.exportKey('raw', encryptionKey)));
    
      //console.log("Salt", Salt);
      //console.log("InitVect", InitVect);
      //console.log("encryptedContent", encryptedContent);
    
      // Decrypt
      const decryptedContent = await Subtle.decrypt(
        {
          name: "AES-GCM",
          iv: InitVect,
          tagLength: 128,
        },
        encryptionKey,
        encryptedContent
      );
    
      // Return decrypted content
      return decryptedContent;
    };
    
    var ct = await encrypt('The quick brown fox jumps over the lazy dog', 'my password');
    console.log('Ciphertext:', ct);
    try {
    var dt = await decrypt(ct, 'my password');
    console.log('Decrypted:', new TextDecoder().decode(dt));
    } catch (err) {
      console.log(err.name, 'Decryption failed...');
    }
    
    })();

    So the fix is to use the salt generated during encryption when decrypting, e.g. as follows:

    (async () => {
    
    /**
     * Prepared some variables and does a very basic sanity check.
     */
    
    var Crypto, Subtle;
    if (typeof window === "undefined") {
      Crypto = globalThis.crypto;
      Subtle = Crypto.subtle;
    } else {
      Crypto = window.crypto;
      Subtle = Crypto.subtle || Crypto.webkitSubtle;
    }
    if (!Subtle) {
      throw new Error(`Web Crypto API not supported, you're outta luck pal.`);
    }
    
    /**
     * Return a base64 string from the provided buffer array. From https://stackoverflow.com/a/68161336
     * @param {Uint8Array} arrayBuffer
     * @returns {String}
     */
    function ab2b64(arrayBuffer) {
      return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
    }
    /**
     * Return an array from the provided buffer bae64 string. From https://stackoverflow.com/a/68161336
     * @param {String} base64string
     * @returns {Uint8Array}
     */
    function b642ab(base64string) {
      return Uint8Array.from(atob(base64string), (c) => c.charCodeAt(0));
    }
    
    /**
     * Returns a initialization vector for the AES encryption
     * @returns {Uint8Array}
     */
    /*export*/ const getInitVect = () => {
      return Crypto.getRandomValues(new Uint8Array(12));
    };
    /**
     * Returns a salt to spice up encryption
     * @returns {Uint8Array}
     */
    /*export*/ const getSalt = () => {
      return Crypto.getRandomValues(new Uint8Array(16));
    };
    
    /**
     * Returns a key derived from provided password with the encryption usage
     * @param {string} password
     * @returns {CryptoKey}
     */
    /*export*/ const getEncryptionKey = async (password, salt) =>   // Fix 1/7: pass salt as 2nd parameter
      getEncryptionOrDecryptionKey(password, salt, ["encrypt"]);
    /**
     * Returns a key derived from provided password with the decryption usage
     * @param {string} password
     * @returns {CryptoKey}
     */
    /*export*/ const getDecryptionKey = async (password, salt) =>  // Fix 2/7: pass salt as 2nd parameter
      getEncryptionOrDecryptionKey(password, salt, ["decrypt"]);
    
    /**
     * Returns an encryptionKey derived from the provided password with the requested usages
     * @param {string} password
     * @returns {CryptoKey}
     */
    const getEncryptionOrDecryptionKey = async (password, salt, usages) => {  // Fix 3/7: pass salt as 2nd parameter
      // Failure is not only an option, it's a probability.
      try {
        // Check that a password was even provided.
        if (password == null || password == undefined || password.trim() == "") {
          throw new Error("No password was provided");
        }
    
        // Turn a regular boring password and turn it into a key
        const importedKey = await Subtle.importKey(
          "raw",
          new TextEncoder().encode("password"),
          "PBKDF2",
          false,
          ["deriveKey"]
        );
        const encryptionKey = await Subtle.deriveKey(
          {
            name: "PBKDF2",
            salt: salt,  // Fix 4/7: apply passed salt
            iterations: 250000,
            hash: "SHA-256",
          },
          importedKey,
          { name: "AES-GCM", length: 256 },
          true, // false,  // for testing
          usages
        );
    
        return encryptionKey;
        // Handle those errors by simply throwing them away.
      } catch (err) {
        throw err;
      }
    };
    
    /**
     * Encrypts the provided content with the provided password and returns a base64 string for use.
     * @param {string} content
     * @param {string} password
     * @returns {string}
     */
    /*export*/ const encrypt = async (content, password) => {
      if (password == undefined || password.trim() == "") {
        throw new Error("No password was provided.");
      }
    
      // Get the bits we need to start encryption
      const salt = await getSalt(); // Fix 5/7: generate salt
      const encryptionKey = await getEncryptionKey(password, salt); // Fix 6/7: apply generated salt for key derivation
      console.log("Key", ab2b64(await Subtle.exportKey('raw', encryptionKey)));
      const initVector = await getInitVect();
    
      // Encrypt the data
      const encryptedContent = new Uint8Array(
        await Subtle.encrypt(
          {
            name: "AES-GCM",
            iv: initVector,
            tagLength: 128,
          },
          encryptionKey,
          new TextEncoder().encode(content)
        )
      );
      //console.log("salt", salt);
      //console.log("initVector", initVector);
      //console.log("encryptedContent", encryptedContent);
    
      // Return the encoded string
      return ab2b64([...salt, ...initVector, ...encryptedContent]);
    };
    
    /**
     * Decrypts a provided base64 string created by encode with the provided password.
     * @param {string} encryptedContent
     * @param {string} password
     * @returns {string}
     */
    /*export*/ const decrypt = async (content, password) => {
      if (password == undefined || password.trim() == "") {
        throw new Error("No password was provided.");
      }
    
      const encryptedData = b642ab(content);
      const salt = encryptedData.slice(0, 16);
      const InitVect = encryptedData.slice(16, 16 + 12);
      const encryptedContent = encryptedData.slice(16 + 12);
    
      const encryptionKey = await getDecryptionKey(password, salt); // Fix 7/7: apply separated salt for key derivation
      console.log("Key", ab2b64(await Subtle.exportKey('raw', encryptionKey)));
    
      //console.log("Salt", Salt);
      //console.log("InitVect", InitVect);
      //console.log("encryptedContent", encryptedContent);
    
      // Decrypt
      const decryptedContent = await Subtle.decrypt(
        {
          name: "AES-GCM",
          iv: InitVect,
          tagLength: 128,
        },
        encryptionKey,
        encryptedContent
      );
    
      // Return decrypted content
      return decryptedContent;
    };
    
    var ct = await encrypt('The quick brown fox jumps over the lazy dog', 'my password');
    console.log('Ciphertext:', ct);
    try {
      var dt = await decrypt(ct, 'my password');
      console.log('Decrypted:', new TextDecoder().decode(dt));
    } catch (err) {
      console.log(err.name, 'Decryption failed...');
    }
    
    })();

    Now decryption is successful (for encryptions and decryptions in the same or different calls).