You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

跨前端与Node.js中间件用WebCrypto解密时遇OperationError求助

问题描述

我正在用WebCrypto构建一个小型Astro站点,实现加密内容的解密功能(仅用于非严肃场景)。单页面版本能正常运行,但跨前端与Node.js中间件实现时,解密出现「Error: OperationError (in promise)」错误。我已经通过日志确认加密输出和解密输入的数据一致,相关代码如下:

核心加密工具代码
/**
 * 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;
};
前端组件代码
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);
  });
问题分析与解决

出现OperationError的核心原因有两个,对应修复如下:

1. 密钥派生硬编码固定字符串,未使用用户密码

getEncryptionOrDecryptionKey函数中,importKey时硬编码了"password"字符串,而非传入的用户密码,同时前端解密时还额外拼接了"password",导致加密和解密的密码完全不匹配。

修复代码:

const importedKey = await Subtle.importKey(
  "raw",
  new TextEncoder().encode(password), // 替换为传入的用户密码
  "PBKDF2",
  false,
  ["deriveKey"]
);

同时建议去掉前端代码中多余的密码拼接:

const providedPassword = document.querySelector('input[name="password"]').value;

2. 加密时生成两次随机盐,密钥派生盐与存储盐不一致

加密流程中,getEncryptionKey会生成一次盐用于密钥派生,之后又生成另一个盐存入加密字符串,导致解密时用存储的盐派生的密钥和加密时的密钥完全不同。

修复步骤:

  • 修改encrypt函数,只生成一次盐同时用于密钥派生和存储:
export const encrypt = async (content, password) => {
  if (password == undefined || password.trim() == "") {
    throw new Error("No password was provided.");
  }

  // 只生成一次盐
  const salt = getSalt();
  const initVector = getInitVect();
  // 将盐传入密钥生成函数
  const encryptionKey = await getEncryptionKey(password, salt);

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

  return ab2b64([...salt, ...initVector, ...encryptedContent]);
};
  • 修改密钥生成函数,支持传入盐参数:
export const getEncryptionKey = async (password, salt) =>
  getEncryptionOrDecryptionKey(password, ["encrypt"], salt);
export const getDecryptionKey = async (password, salt) =>
  getEncryptionOrDecryptionKey(password, ["decrypt"], salt);

const getEncryptionOrDecryptionKey = async (password, usages, salt) => {
  try {
    if (password == null || password == undefined || password.trim() == "") {
      throw new Error("No password was provided");
    }

    const importedKey = await Subtle.importKey(
      "raw",
      new TextEncoder().encode(password),
      "PBKDF2",
      false,
      ["deriveKey"]
    );
    const encryptionKey = await Subtle.deriveKey(
      {
        name: "PBKDF2",
        salt: salt || getSalt(), // 解密时传入存储的盐,加密时若未传则生成
        iterations: 250000,
        hash: "SHA-256",
      },
      importedKey,
      { name: "AES-GCM", length: 256 },
      false,
      usages
    );

    return encryptionKey;
  } catch (err) {
    throw err;
  }
};
  • 修改decrypt函数,传入存储的盐派生密钥,并将结果转为字符串:
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);

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

  // 将ArrayBuffer转为字符串返回
  return new TextDecoder().decode(decryptedContent);
};

内容的提问来源于stack exchange,提问作者Phillip Gooch

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 05:45:55