javascriptcryptographywebcrypto-api

The operation failed for an operation-specific reason: Cipher job failed


I have the following code that should encrypt a string with a password in nodeJS. You can find an example here: https://jsfiddle.net/ujr4gev3/1/

I have also tried the approach here: https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a and this results in the same error.

const b64 = (u: Uint8Array<ArrayBuffer>) => btoa(String.fromCharCode(...u));
const fromB64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const utf8Encode = (s: string | undefined) => new TextEncoder().encode(s);
const utf8Decode = (u: AllowSharedBufferSource | undefined) => new TextDecoder().decode(u);

// Derive an AES-GCM 256-bit key from a password and salt
export async function deriveKeyFromPassword(password: string, salt: Uint8Array<ArrayBuffer>, iterations = 200_000) {
  const passKey = await crypto.subtle.importKey(
    "raw",
    utf8Encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveKey"]
  );
  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations,
      hash: "SHA-256"
    },
    passKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

// Encrypt plaintext (string) with password (string). Returns base64 strings.
export async function encryptWithPassword(password: string, plaintext: string) {
  const salt = crypto.getRandomValues(new Uint8Array(16)); // 128-bit salt
  const iv = crypto.getRandomValues(new Uint8Array(12));   // 96-bit IV for AES-GCM
  const key = await deriveKeyFromPassword(password, salt);
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv, tagLength: 128 },
    key,
    utf8Encode(plaintext)
  );
  return {
    ciphertext: b64(new Uint8Array(ct)),
    iv: b64(iv),
    salt: b64(salt),
    iterations: 200_000,
    algo: "AES-GCM"
  };
}

// Decrypt using password and the stored base64 values
export async function decryptWithPassword(password: string, b64ciphertext: string, b64iv: string, b64salt: string, iterations: number) {
  const salt = fromB64(b64salt);
  const iv = fromB64(b64iv);
  const ciphertext = fromB64(b64ciphertext);
  const key = await deriveKeyFromPassword(password, salt, iterations);
  const pt = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv, tagLength: 128 },
    key,
    ciphertext
  );
  return utf8Decode(new Uint8Array(pt));
}

export async function decryptFromCipherStructWithPassword(cipherStruct: any, password: string) {
    return decryptWithPassword(password, cipherStruct.ciphertext, cipherStruct.iv, cipherStruct.salt, cipherStruct.iterations);
}

It is tested with the follwing piece of code, which results in the error mentioned in the title (I am using node.js v20.19.2)

 test("test encryption", async () => {
        const encrypted = await encryptWithPassword("text", "password");
        console.log(encrypted)
        expect(encrypted).not.toBeNull();
        const decrypted = await decryptFromCipherStructWithPassword(encrypted, "password");
        expect(decrypted).toBe("text")
    });

Solution

  • Decryption fails because the arguments in the call to encryptWithPassword() have been swapped.


    One reason that may have contributed to this bug is that in decryptFromCipherStructWithPassword(), the password is passed as second argument, while in encryptWithPassword() and decryptWithPassword(), it is passed as first argument.

    An API should be designed to behave as intuitively as possible for the user (Principle of Least Surprise aka Principle of Least Astonishment (POLA)).

    Here, it would be most intuitive if the password were passed in the same position for all three methods, e.g., at the beginning.

    const b64 = (u) => btoa(String.fromCharCode(...u));
    const fromB64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
    const utf8Encode = (s) => new TextEncoder().encode(s);
    const utf8Decode = (u) => new TextDecoder().decode(u);
    
    // Derive an AES-GCM 256-bit key from a password and salt
    async function deriveKeyFromPassword(password, salt, iterations = 200_000) {
      const passKey = await crypto.subtle.importKey(
        "raw",
        utf8Encode(password),
        { name: "PBKDF2" },
        false,
        ["deriveKey"]
      );
      return crypto.subtle.deriveKey(
        {
          name: "PBKDF2",
          salt,
          iterations,
          hash: "SHA-256"
        },
        passKey,
        { name: "AES-GCM", length: 256 },
        false,
        ["encrypt", "decrypt"]
      );
    }
    
    // Encrypt plaintext (string) with password (string). Returns base64 strings.
    async function encryptWithPassword(password, plaintext) {
      const salt = crypto.getRandomValues(new Uint8Array(16)); // 128-bit salt
      const iv = crypto.getRandomValues(new Uint8Array(12));   // 96-bit IV for AES-GCM
      const key = await deriveKeyFromPassword(password, salt);
      const ct = await crypto.subtle.encrypt(
        { name: "AES-GCM", iv, tagLength: 128 },
        key,
        utf8Encode(plaintext)
      );
      return {
        ciphertext: b64(new Uint8Array(ct)),
        iv: b64(iv),
        salt: b64(salt),
        iterations: 200_000,
        algo: "AES-GCM"
      };
    }
    
    // Decrypt using password and the stored base64 values
    async function decryptWithPassword(password, b64ciphertext, b64iv, b64salt, iterations) {
      const salt = fromB64(b64salt);
      const iv = fromB64(b64iv);
      const ciphertext = fromB64(b64ciphertext);
      const key = await deriveKeyFromPassword(password, salt, iterations);
      const pt = await crypto.subtle.decrypt(
        { name: "AES-GCM", iv, tagLength: 128 },
        key,
        ciphertext
      );
      return utf8Decode(new Uint8Array(pt));
    }
    
    async function decryptFromCipherStructWithPassword(password, cipherStruct) {
        return decryptWithPassword(password, cipherStruct.ciphertext, cipherStruct.iv, cipherStruct.salt, cipherStruct.iterations);
    }
    
    encryptWithPassword("password", "text").then((x) => {
        decryptFromCipherStructWithPassword("password", x).then((x) => {
            console.log(x);
        }).catch(x => console.error(x))
    }).catch((x) => console.error(x));