跨前端与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
相关产品推荐
相关产品推荐

