前端国密SM4加密实战:基于CryptoJS的ECB/CBC模式实现与跨平台联调指南

发布时间:2026/7/4 19:18:16
前端国密SM4加密实战:基于CryptoJS的ECB/CBC模式实现与跨平台联调指南 1. 项目概述为什么要在前端搞国密最近在做一个对接政务平台的项目客户明确要求所有敏感数据传输必须使用国密算法SM4进行加密。作为前端第一反应是“这活不是后端干的吗” 但需求很明确数据在离开浏览器之前就必须是密文。于是我开始在前端寻找SM4的实现方案。网上搜了一圈发现原生JavaScript实现SM4的轮子不多而且自己手搓一个加密算法不仅容易出错性能和安全审计也是大问题。这时候一个熟悉的名字跳了出来CryptoJS。我们常用它的AES、MD5但它能支持SM4吗答案是肯定的。经过一番折腾我成功用CryptoJS在前端实现了SM4的ECB和CBC模式加解密并且踩了不少坑。这篇文章我就把完整的实现过程、核心原理、尤其是那些官方文档不会告诉你的“坑点”和调试技巧从头到尾捋一遍。无论你是遇到了类似的合规性需求还是单纯对国密算法在前端的应用感兴趣这篇实战记录都能给你一份可直接“抄作业”的解决方案。我们会从CryptoJS的引入开始讲到SM4算法的基础再到两种常用模式的代码实现最后集中解决那些让人头疼的编码、填充和跨平台对齐问题。2. 环境准备与CryptoJS的“特别”引入2.1 为什么是CryptoJS首先得说在前端进行加密操作CryptoJS是一个久经沙场的库。它提供了丰富的哈希和加密算法实现。对于SM4CryptoJS的早期版本并没有内置支持但它的架构允许我们扩展新的算法。我们需要的是一个包含了SM4扩展的CryptoJS版本。这里有个关键点你直接从npm安装crypto-js这个官方包里面是没有SM4的。你需要寻找一个集成了SM4补丁的版本或者手动引入扩展文件。我在项目中采用的是后者——引入一个第三方提供的crypto-js-sm4.js扩展文件。这个文件通常是由社区开发者根据国密标准在CryptoJS框架下实现的SM4算法。注意务必从可信赖的来源获取这个扩展文件比如知名的开源项目或经过审计的代码仓库。因为加密算法实现上的细微偏差都可能导致严重的安全问题。2.2 项目中的引入方式如果你的项目使用Webpack、Vite等构建工具可以这样处理放置扩展文件将下载的crypto-js-sm4.js文件放入项目的src/utils/libs/目录下。引入核心库与扩展在你的工具类文件或具体的业务组件中先引入核心CryptoJS再引入SM4扩展。扩展文件会将其实现挂载到CryptoJS这个全局对象上。// 引入核心CryptoJS import CryptoJS from crypto-js; // 引入SM4扩展。注意路径根据你的项目结构调整。 import ./libs/crypto-js-sm4;引入后你就可以通过CryptoJS.SM4来调用相关加密解密方法了。如果是在传统HTML页面中直接通过script标签依次引入crypto-js.js和crypto-js-sm4.js即可。2.3 验证环境是否就绪在开始写业务代码前写个简单的测试脚本验证一下环境是很好的习惯。// test-sm4-env.js try { console.log(CryptoJS loaded:, typeof CryptoJS); console.log(SM4 algorithm available:, typeof CryptoJS.SM4); console.log(SM4 encrypt function available:, typeof CryptoJS.SM4.encrypt); } catch (error) { console.error(环境初始化失败:, error); }如果控制台能正确输出SM4 algorithm available: function恭喜你环境搭建成功了。如果遇到CryptoJS.SM4 is undefined那肯定是扩展文件没有正确引入或加载顺序有问题。3. SM4算法基础与模式选择3.1 SM4算法是个啥SM4是一种分组密码算法属于对称加密。简单来说对称加密加密和解密使用同一把密钥。就像你用同一把钥匙锁门和开门。分组密码它不会一个字节一个字节地加密而是把明文数据切分成固定长度的“块”Block然后一块一块地处理。SM4的块长度是128位16字节。这意味着无论你要加密的数据是“hello”还是“hello world this is a long text”算法内部都会把它们分成若干个16字节的块。如果最后一块不足16字节怎么办这就引出了“填充”Padding的概念后面会详细讲。3.2 加密模式ECB vs CBC选对加密模式至关重要它决定了块与块之间如何关联直接影响安全性和使用场景。3.2.1 ECB模式电子密码本模式这是最简单粗暴的模式。每个16字节的明文块都独立地用同一个密钥加密生成对应的密文块。优点简单易于并行计算因为块之间无关。致命缺点相同的明文块会生成相同的密文块。对于有规律的数据比如一张纯色图片ECB加密后的密文依然会保留明文的模式安全性很低。一般不推荐用于加密有意义的数据但在一些特定场景如加密固定格式的令牌可能被使用。3.2.2 CBC模式密码分组链接模式这是更常用、更安全的模式。它在加密当前明文块时会先与前一个密文块进行异或XOR操作然后再用密钥加密。对于第一个块由于没有“前一个密文块”就用一个叫“初始化向量”IV, Initialization Vector的随机数来代替。优点由于引入了IV和链式结构即使明文相同加密后的密文也会完全不同隐藏了数据的模式安全性高。缺点无法并行加密因为需要前一块的密文但解密可以并行。如何选择如果你的对接方规范明确要求了模式那就按规定来。如果没规定无脑选CBC模式就对了。ECB模式除非你非常清楚自己在做什么并且能接受其安全缺陷否则请避免使用。4. 实战SM4-ECB模式加密解密我们先从简单的ECB模式开始理解最基本的加解密流程。假设我们的密钥是0123456789abcdeffedcba987654321032个十六进制字符即128位。4.1 ECB加密实现/** * SM4-ECB模式加密 * param {string} plainText - 待加密的明文 * param {string} key - 密钥16进制字符串长度32 * returns {string} 加密后的密文16进制字符串 */ function sm4EncryptECB(plainText, key) { // 1. 将密钥转换为CryptoJS可识别的WordArray格式 const keyHex CryptoJS.enc.Hex.parse(key); // 2. 将明文转换为UTF-8编码的WordArray // 这里非常关键明文可能是中文必须明确指定编码为UTF-8 const srcs CryptoJS.enc.Utf8.parse(plainText); // 3. 调用SM4进行ECB加密 // 注意ECB模式不需要IV参数 const encrypted CryptoJS.SM4.encrypt(srcs, keyHex, { mode: CryptoJS.mode.ECB, // 指定ECB模式 padding: CryptoJS.pad.Pkcs7 // 指定PKCS#7填充 }); // 4. 将加密结果转换为16进制字符串输出 return encrypted.ciphertext.toString().toUpperCase(); } // 使用示例 const key 0123456789abcdeffedcba9876543210; const plaintext Hello世界SM4测试123; const ciphertext sm4EncryptECB(plaintext, key); console.log(ECB密文:, ciphertext); // 输出类似9E7F0B5A8C1D3E7F0B5A8C1D3E7F0B5A...代码解读与注意事项密钥格式key必须是32位的十六进制字符串0-9, a-f对应128位。这是SM4的标准密钥长度。如果你的密钥是别的格式比如Base64或纯文本需要先转换。编码是关键CryptoJS.enc.Utf8.parse(plainText)这一步绝不能省。它确保了无论明文是英文、中文还是特殊符号都能被正确转换为二进制数据流进行加密。如果直接传字符串CryptoJS可能会按默认的Latin1编码处理导致中文乱码进而使加密结果错误。填充方案我们选择了CryptoJS.pad.Pkcs7。这是最常用的填充方式。当数据长度不是16字节的倍数时PKCS#7会在末尾填充若干个字节每个字节的值等于填充的长度。例如如果缺5字节就填充5个0x05。输出格式encrypted.ciphertext.toString()默认输出十六进制小写字符串。我习惯用.toUpperCase()转为大写看起来更规整也方便与后端或其他系统对比。4.2 ECB解密实现解密是加密的逆过程。/** * SM4-ECB模式解密 * param {string} ciphertextHex - 密文16进制字符串 * param {string} key - 密钥16进制字符串长度32 * returns {string} 解密后的明文 */ function sm4DecryptECB(ciphertextHex, key) { // 1. 转换密钥 const keyHex CryptoJS.enc.Hex.parse(key); // 2. 将16进制密文转换为CryptoJS内部格式 // 注意这里需要先将hex字符串还原为WordArray格式的密文数据 const encryptedHexStr CryptoJS.enc.Hex.parse(ciphertextHex); // 创建一个“密文包装对象”这是CryptoJS解密函数需要的格式 const encryptedBase64Str CryptoJS.enc.Base64.stringify(encryptedHexStr); const cipherParams CryptoJS.lib.CipherParams.create({ ciphertext: encryptedHexStr }); // 3. 调用SM4进行ECB解密 const decrypted CryptoJS.SM4.decrypt(cipherParams, keyHex, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }); // 4. 将解密结果WordArray按UTF-8编码转回字符串 return decrypted.toString(CryptoJS.enc.Utf8); } // 使用示例接上面的加密 const decryptedText sm4DecryptECB(ciphertext, key); console.log(ECB解密结果:, decryptedText); // 应输出Hello世界SM4测试123解密过程中的大坑解密函数的第一个参数CryptoJS期望的是一个CipherParams对象而不是简单的十六进制字符串。直接传字符串会报错。所以我们需要手动构造这个对象用CryptoJS.enc.Hex.parse把十六进制字符串转成WordArray。用CryptoJS.lib.CipherParams.create({ ciphertext: wordArray })包装它。 这是很多初学者包括我第一次对接时最容易卡住的地方文档里往往一笔带过。5. 实战更安全的SM4-CBC模式加密解密CBC模式需要初始化向量IV。IV是一个随机生成的、长度为16字节128位的数据通常也用十六进制字符串表示。IV不需要保密但必须唯一且不可预测通常随密文一起传输。5.1 CBC加密实现/** * SM4-CBC模式加密 * param {string} plainText - 待加密的明文 * param {string} key - 密钥16进制字符串长度32 * param {string} iv - 初始化向量16进制字符串长度32 * returns {string} 加密后的密文16进制字符串 */ function sm4EncryptCBC(plainText, key, iv) { const keyHex CryptoJS.enc.Hex.parse(key); const ivHex CryptoJS.enc.Hex.parse(iv); // 解析IV const srcs CryptoJS.enc.Utf8.parse(plainText); const encrypted CryptoJS.SM4.encrypt(srcs, keyHex, { iv: ivHex, // 关键传入IV mode: CryptoJS.mode.CBC, // 指定CBC模式 padding: CryptoJS.pad.Pkcs7 }); return encrypted.ciphertext.toString().toUpperCase(); } // 使用示例 const key 0123456789abcdeffedcba9876543210; const iv 1234567890abcdeffedcba0987654321; // 示例IV实际应用中应随机生成 const plaintext 这是一段使用CBC模式加密的敏感数据; const ciphertextCBC sm4EncryptCBC(plaintext, key, iv); console.log(CBC密文:, ciphertextCBC);5.2 CBC解密实现/** * SM4-CBC模式解密 * param {string} ciphertextHex - 密文16进制字符串 * param {string} key - 密钥16进制字符串长度32 * param {string} iv - 初始化向量16进制字符串长度32 * returns {string} 解密后的明文 */ function sm4DecryptCBC(ciphertextHex, key, iv) { const keyHex CryptoJS.enc.Hex.parse(key); const ivHex CryptoJS.enc.Hex.parse(iv); const encryptedHexStr CryptoJS.enc.Hex.parse(ciphertextHex); const cipherParams CryptoJS.lib.CipherParams.create({ ciphertext: encryptedHexStr }); const decrypted CryptoJS.SM4.decrypt(cipherParams, keyHex, { iv: ivHex, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); } // 使用示例 const decryptedTextCBC sm4DecryptCBC(ciphertextCBC, key, iv); console.log(CBC解密结果:, decryptedTextCBC);关于IV的生成与传递在实际项目中IV应该由加密方随机生成例如使用CryptoJS.lib.WordArray.random(16)生成16字节随机数再转成hex。加密后需要将这个IV和密文一起传递给解密方。常见的做法是将IV拼接在密文前面或者作为单独的字段传输。解密方必须使用加密时用的同一个IV才能成功解密。6. 核心问题排查与跨平台对齐实战理论跑通只是第一步真正对接时问题才刚开始。最常见的问题是前端加密的结果后端Java/Python/PHP等解不出来或者反过来。6.1 问题一编码不一致导致“乱码”这是头号杀手。加密算法操作的是字节不是字符串。前端必须确保明文在加密前和解密后的输出编码一致。如前所述使用CryptoJS.enc.Utf8.parse()和toString(CryptoJS.enc.Utf8)。后端同样后端在接收密文解密前需要知道明文原本的编码通常是UTF-8解密后按此编码还原。排查技巧找一个双方都认可的纯英文短字符串如”test123”进行加解密测试。如果英文能通中文不通99%是编码问题。6.2 问题二填充模式不匹配SM4作为分组密码必须填充。PKCS#7也叫PKCS#5是标准。但有些老旧系统可能使用其他填充如ZeroPadding。必须与对接方确认填充方案。在CryptoJS中除了Pkcs7还有NoPadding要求数据长度正好是16的倍数、ZeroPadding等选项。如果后端用的是JavaJava的Cipher类默认通常是PKCS5Padding在分组为8字节时叫PKCS516字节时等同于PKCS7。6.3 问题三密钥、IV的格式和传递方式格式确认双方对密钥和IV的理解是“十六进制字符串”还是“Base64字符串”还是“原始字节数组”。我们的代码示例用的是十六进制字符串Hex。如果后端期望Base64你需要转换CryptoJS.enc.Base64.stringify(keyWordArray)。IV的传递如前所述CBC模式必须传递IV。要约定好IV是放在密文前N个字节还是作为单独参数传递。6.4 问题四CryptoJS输出的是“OpenSSL格式”这是一个深坑CryptoJS.SM4.encrypt()返回的对象直接调用toString()默认输出的是一个OpenSSL兼容的字符串格式它并不是纯密文。这个格式包含了盐、加密算法标识等信息。我们之前用的encrypted.ciphertext.toString()是直接提取了内部的纯密文WordArray再转Hex。如果你错误地使用了encrypted.toString()你会得到一个以”U2FsdGVkX1…”开头的Base64字符串后端用标准的SM4库是绝对解不开的。务必使用.ciphertext属性来获取纯密文数据。6.5 实战调试与联调检查清单当你和后端联调失败时请按以下清单逐项核对算法与模式双方都是SM4吗都是CBC或ECB吗密钥密钥的值和格式Hex/Base64/原始字节是否完全一致可以用一个在线Hex转换工具对比。IVCBC模式IV的值和格式是否一致是否传递了填充都是PKCS#7/PKCS#5填充吗数据编码加密前的明文、解密后的输出是否都明确使用UTF-8密文格式前端传递的是纯密文的Hex/Base64还是包含了其他信息的OpenSSL格式后端期望接收的是什么格式数据块用同一个简短的、长度小于16字节的明文如“123”在双方本地加密对比产生的密文Hex。如果从第一步就不同那问题出在加密环节。7. 封装成健壮的工具类将上面的函数封装成一个工具类方便在项目中调用并增加一些错误处理和兼容性代码。// sm4-utils.js import CryptoJS from crypto-js; import ./libs/crypto-js-sm4; // 引入扩展 class SM4Crypto { /** * 构造函数 * param {string} key - 16进制密钥字符串 (32字符) * param {string} mode - 加密模式ECB 或 CBC * param {string} iv - 16进制IV字符串 (32字符)CBC模式必填 */ constructor(key, mode CBC, iv null) { if (!/^[0-9a-fA-F]{32}$/.test(key)) { throw new Error(密钥必须是32位十六进制字符串); } this.key CryptoJS.enc.Hex.parse(key); this.mode mode.toUpperCase(); if (this.mode CBC) { if (!iv || !/^[0-9a-fA-F]{32}$/.test(iv)) { throw new Error(CBC模式必须提供32位十六进制IV字符串); } this.iv CryptoJS.enc.Hex.parse(iv); } else if (this.mode ! ECB) { throw new Error(加密模式只支持 ECB 或 CBC); } } /** * 加密 * param {string} plainText - 明文 * param {string} outputFormat - 输出格式hex 或 base64 * returns {string} 密文 */ encrypt(plainText, outputFormat hex) { try { const srcs CryptoJS.enc.Utf8.parse(plainText); const options { mode: this.mode CBC ? CryptoJS.mode.CBC : CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }; if (this.iv) { options.iv this.iv; } const encrypted CryptoJS.SM4.encrypt(srcs, this.key, options); if (outputFormat.toLowerCase() base64) { // 注意这里返回的是纯密文的Base64不是OpenSSL格式 return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); } else { // 默认返回十六进制大写 return encrypted.ciphertext.toString().toUpperCase(); } } catch (error) { console.error(SM4加密失败:, error); throw new Error(加密失败: ${error.message}); } } /** * 解密 * param {string} cipherText - 密文hex或base64字符串 * param {string} inputFormat - 输入密文的格式hex 或 base64 * returns {string} 明文 */ decrypt(cipherText, inputFormat hex) { try { let encryptedHexStr; if (inputFormat.toLowerCase() base64) { // 将Base64密文转换为WordArray const words CryptoJS.enc.Base64.parse(cipherText); encryptedHexStr CryptoJS.enc.Hex.parse(words.toString(CryptoJS.enc.Hex)); } else { // 将Hex密文转换为WordArray encryptedHexStr CryptoJS.enc.Hex.parse(cipherText); } const cipherParams CryptoJS.lib.CipherParams.create({ ciphertext: encryptedHexStr }); const options { mode: this.mode CBC ? CryptoJS.mode.CBC : CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }; if (this.iv) { options.iv this.iv; } const decrypted CryptoJS.SM4.decrypt(cipherParams, this.key, options); return decrypted.toString(CryptoJS.enc.Utf8); } catch (error) { console.error(SM4解密失败:, error); // 解密失败可能因为密钥错误、密文损坏、填充错误等 throw new Error(解密失败: ${error.message}); } } /** * 生成随机IV仅CBC模式有用 * returns {string} 32位随机十六进制IV字符串 */ static generateRandomIV() { const randomWordArray CryptoJS.lib.WordArray.random(16); // 16字节 128位 return randomWordArray.toString().toUpperCase(); } } export default SM4Crypto; // 使用示例 // const sm4 new SM4Crypto(0123456789ABCDEFFEDCBA9876543210, CBC, 1234567890ABCDEFFEDCBA0987654321); // const encrypted sm4.encrypt(敏感数据); // const decrypted sm4.decrypt(encrypted);这个工具类做了几件重要的事参数校验在构造函数中检查密钥和IV的格式。异常处理用try-catch包裹核心操作避免程序崩溃给出更友好的错误提示。格式支持同时支持Hex和Base64格式的输入输出方便对接不同要求的后端。静态方法提供了生成随机IV的便捷方法。8. 在真实项目中的集成与注意事项8.1 密钥管理前端加密的安全边界这是一个必须清醒认识的问题在前端用JavaScript进行加密密钥是暴露在代码中的。任何懂得打开浏览器开发者工具的人都能看到你的密钥。因此前端加密的主要目的不是防止窥探而是为了满足传输过程中的合规性要求如国密标准或者防止中间人直接看到明文。它不能替代HTTPS。在实际项目中密钥不应该硬编码在JS文件里。可以考虑以下方式动态获取在页面加载后通过一个安全的API接口本身受HTTPS保护临时获取本次会话的加密密钥。这个密钥可以由后端动态生成并有一定有效期。非对称加密配合更安全的做法是前端使用后端提供的RSA公钥对SM4密钥进行加密然后将加密后的SM4密钥和用该SM4密钥加密的数据一起传给后端。后端用私钥解密出SM4密钥再去解密数据。这样保证了SM4密钥本身的安全传输。8.2 性能考量SM4加密解密是计算密集型操作。对于大量数据比如上传整个文件在前端进行加密可能会造成页面卡顿。数据分块对于大文件可以将其分片例如每1MB一片逐片加密后再上传。Web Worker将加密解密操作放到Web Worker中避免阻塞主线程影响用户体验。性能测试在目标用户的主流设备上测试加密一段典型长度数据如1KB, 10KB, 100KB的耗时做到心中有数。8.3 与后端联调的终极验证脚本写一个简单的HTML页面包含固定的测试向量让前端和后端工程师分别运行对比输出。这是解决联调分歧最有效的方法。!DOCTYPE html html head titleSM4 联调测试页/title script srcpath/to/crypto-js.js/script script srcpath/to/crypto-js-sm4.js/script /head body script // 固定测试向量 (来自官方测试用例或与后端约定) const TEST_KEY 0123456789abcdeffedcba9876543210; const TEST_IV 1234567890abcdeffedcba0987654321; const TEST_PLAIN_TEXT This is a test message! 测试消息; console.log( SM4-CBC 模式联调测试 ); console.log(密钥(Hex):, TEST_KEY); console.log(IV(Hex):, TEST_IV); console.log(明文:, TEST_PLAIN_TEXT); // 加密 const keyHex CryptoJS.enc.Hex.parse(TEST_KEY); const ivHex CryptoJS.enc.Hex.parse(TEST_IV); const srcs CryptoJS.enc.Utf8.parse(TEST_PLAIN_TEXT); const encrypted CryptoJS.SM4.encrypt(srcs, keyHex, { iv: ivHex, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const cipherHex encrypted.ciphertext.toString().toUpperCase(); const cipherBase64 CryptoJS.enc.Base64.stringify(encrypted.ciphertext); console.log(前端加密结果 (Hex):, cipherHex); console.log(前端加密结果 (Base64):, cipherBase64); // 解密自解密验证 const encryptedHexStr CryptoJS.enc.Hex.parse(cipherHex); const cipherParams CryptoJS.lib.CipherParams.create({ ciphertext: encryptedHexStr }); const decrypted CryptoJS.SM4.decrypt(cipherParams, keyHex, { iv: ivHex, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); console.log(前端自解密结果:, decrypted.toString(CryptoJS.enc.Utf8)); console.log(\n请将【密钥】、【IV】、【明文】和【Hex密文】提供给后端同事。); console.log(后端应能使用相同的参数解密出相同的明文。); console.log(如果解密失败请逐项检查第6部分的“联调检查清单”。); /script /body /html把这个页面丢给后端让他们用同样的密钥、IV和明文在他们的环境下加密比对生成的Hex密文是否一致。如果不一致问题一定出在算法实现、编码、填充或模式这几个核心参数上。走完这一整套流程从前端环境搭建、算法理解、代码实现、问题排查到项目集成你应该能独立应对绝大多数与SM4前端加密相关的需求了。核心就是细心尤其是对数据编码和格式的转换保持高度敏感多写测试用例验证联调时耐心按清单排查。国密算法在未来各类应用中的渗透会越来越深掌握其在前端的实现无疑是一个实用的技能。