国密算法是中国国家密码管理局制定的一系列密码标准,其中SM2负责椭圆曲线公钥加密与数字签名,SM3负责哈希摘要,SM4负责分组对称加密。在涉及金融支付、政务系统和等级保护测评的项目中,使用国密算法往往是硬性要求。Node.js开发者面对这类需求时,通常会发现原生Crypto模块的支持并不完整,需要结合第三方库来覆盖全部能力。本文将从原生支持情况讲起,逐步给出SM2、SM3、SM4的完整实现方案。

一、Node.js原生Crypto模块对国密算法的支持现状
首先需要明确一个前提:不同版本的Node.js对国密算法的支持差异很大。只有当Node.js编译时链接了OpenSSL 1.1.1及以上版本,SM3和SM4才会出现在可用算法列表中。Node.js 12之后的版本基本都满足这个条件,但更老的Node.js 10及以下版本则完全不支持。
可以通过一段简单代码检查当前环境是否支持国密算法。使用crypto.getHashes()查看是否包含sm3,使用crypto.getCiphers()查看是否包含sm4-ecb、sm4-cbc等算法。注意SM3在算法列表中显示为小写的sm3,而SM4相关算法在cipher列表中以sm4-开头。
const crypto = require('crypto');
// 检查SM3摘要算法是否可用
console.log(crypto.getHashes().includes('sm3')); // true 表示支持
// 检查SM4加密算法是否可用
const sm4Ciphers = crypto.getCiphers().filter(c => c.startsWith('sm4'));
console.log(sm4Ciphers);
// 可能输出: [ 'sm4-ecb', 'sm4-cbc', 'sm4-ctr', 'sm4-ofb' ]需要特别指出的是,原生Crypto模块并不支持SM2。SM2是基于特定椭圆曲线(国密曲线sm2p256v1)的公钥算法,而OpenSSL对SM2的支持在命令行工具层面比较完善,但Node.js的Crypto接口没有暴露对应的密钥生成入口。因此SM2的加解密和签名验签,必须依赖第三方库,比如node-sm-crypto或gm-crypt。这是很多开发者踩过的坑:以为升级Node版本就能用上全部国密算法,结果发现SM2根本无从下手。
二、SM3摘要计算与HMAC的实现
SM3是国产哈希算法,输出256位摘要,安全性对标SHA-256。它的使用方式和其他哈希算法完全一致,掌握了createHash的用法就能直接迁移。
基本用法如下,先创建一个sm3类型的hash实例,再更新数据并输出摘要。摘要默认输出Buffer,一般会转成十六进制字符串便于传输和比对。除了直接计算摘要,SM3还可以配合createHmac实现带密钥的消息认证码,用于接口签名验证等场景。
const crypto = require('crypto');
// 计算SM3摘要
const hash = crypto.createHash('sm3');
hash.update('你好,国密算法');
const digest = hash.digest('hex');
console.log('SM3摘要:', digest);
// 使用SM3实现HMAC
const hmac = crypto.createHmac('sm3', 'secret-key');
hmac.update('需要认证的消息');
console.log('HMAC-SM3:', hmac.digest('hex'));有一个细节值得注意:SM3摘要在不同实现之间可能存在编码差异。有些Java或Go的服务端在计算摘要前会先把字符串按UTF-8编码,再对字节数组做哈希,Node.js的update方法默认也是按UTF-8处理字符串,所以一般能够对齐。但如果对方系统使用GBK编码(国内老系统并不少见),就需要手动指定编码:hash.update(str, 'gbk'),否则双方算出的摘要会完全对不上,排查起来非常耗时。
另外,SM3常用于密码加盐存储。虽然专门的密码哈希算法(如scrypt、argon2)更适合这个场景,但在等保合规要求统一的国密体系下,SM3加随机盐迭代多次也是一种可接受的折中方案。
三、SM4对称加密:ECB与CBC模式的实践
SM4是分组长度和密钥长度均为128位的对称加密算法,可以理解为国密版AES。原生Crypto模块支持sm4-ecb、sm4-c4-cbc等多种模式,其中ECB模式最简单但安全性最弱,CBC模式配合随机IV是更推荐的用法。
先看ECB模式的写法。ECB不需要初始化向量,相同的明文块会加密成相同的密文块,容易被分析出数据模式,因此只建议用于加密很短的、随机的数据(比如已经过随机化的密钥)。代码中密钥必须是16字节,这一点和AES-128一致。
const crypto = require('crypto');
function sm4EcbEncrypt(plainText, key) {
const cipher = crypto.createCipheriv('sm4-ecb', Buffer.from(key, 'utf8'), null);
return Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]).toString('base64');
}
function sm4EcbDecrypt(cipherText, key) {
const decipher = crypto.createDecipheriv('sm4-ecb', Buffer.from(key, 'utf8'), null);
return Buffer.concat([
decipher.update(Buffer.from(cipherText, 'base64')),
decipher.final()
]).toString('utf8');
}
const key = '1234567890abcdef'; // 必须16字节
const enc = sm4EcbEncrypt('这是一段机密数据', key);
console.log('ECB加密结果:', enc);
console.log('ECB解密结果:', sm4EcbDecrypt(enc, key));CBC模式的写法稍微复杂一些,需要额外提供16字节的初始化向量IV。安全性上CBC优于ECB,因为相同的明文块配合不同IV会产生不同密文。实践中常见的做法是每次加密时随机生成IV,把IV拼在密文前面一起传输,解密方从密文头部取出IV再解密。
const crypto = require('crypto');
function sm4CbcEncrypt(plainText, key) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('sm4-cbc', Buffer.from(key, 'utf8'), iv);
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
// 将IV拼接在密文前面,格式:IV + 密文
return Buffer.concat([iv, encrypted]).toString('base64');
}
function sm4CbcDecrypt(cipherText, key) {
const data = Buffer.from(cipherText, 'base64');
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('sm4-cbc', Buffer.from(key, 'utf8'), iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
const result = sm4CbcEncrypt('CBC模式更安全', '1234567890abcdef');
console.log('CBC结果:', result);
console.log('还原:', sm4CbcDecrypt(result, '1234567890abcdef'));与Java端互通时要注意填充方式的问题。OpenSSL默认使用PKCS7填充,Java的Cipher.getInstance("SM4/CBC/PKCS5Padding")实际上也是PKCS7(在16字节分组下两者等价),所以直接互通没有问题。但如果对方采用的是无填充模式,就必须在Node侧显式禁用自动填充,调用cipher.setAutoPadding(false),并且保证明文长度恰好是16字节的整数倍。
四、SM2公钥算法:借助node-sm-crypto实现加解密与签名
SM2的实现是国密改造中最麻烦的一环,因为原生Crypto无能为力。社区里最常用的库是node-sm-crypto,安装命令为npm install node-sm-crypto --save。它基于纯JavaScript实现,不依赖OpenSSL,在Windows、Linux各个平台都能稳定运行。
SM2密钥对的生成非常直接,公钥是130位十六进制字符串(04前缀加两个坐标点),私钥是64位十六进制字符串。下面演示密钥生成、加密解密的完整流程。SM2加密时会内置一个随机数,因此同一段明文每次加密的结果都不同,这是正常现象,不要误以为加密出了问题。
const sm2 = require('node-sm-crypto');
// 生成SM2密钥对
const keypair = sm2.generateKeyPairHex();
const publicKey = keypair.publicKey; // 04开头的十六进制字符串
const privateKey = keypair.privateKey;
console.log('公钥:', publicKey);
console.log('私钥:', privateKey);
// 加密:cipherMode为1表示C1C3C2模式(新国标推荐)
const cipherText = sm2.doEncrypt('敏感信息', publicKey, 1);
console.log('SM2加密:', cipherText);
// 解密
const plainText = sm2.doDecrypt(cipherText, privateKey, 1);
console.log('SM2解密:', plainText);这里有个极易出错的点:密文排列顺序。国密标准最初定义的密文格式是C1C2C3,后来更新为C1C3C2,Java的hutool、BC库以及各类硬件加密机默认格式不一。如果Node端加密后Java端解密报错,优先排查两边的cipherMode参数是否一致,sm2.doEncrypt的第三个参数传0表示C1C2C3,传1表示C1C3C2。此外,有些系统传输密文时会带上04前缀,有些不带,node-sm-crypto输出的密文不带04前缀,对接时可能需要手动补上或去掉。
SM2签名验签同样简单,签名算法使用SM3作为摘要。签名结果默认输出为DER编码的hex字符串,如果对接方要求裸的r+s拼接格式,可以把hashData与输出格式参数调整一下,具体以库的文档为准。
const sm2 = require('node-sm-crypto');
const msg = '需要签名的数据';
const sig = sm2.doSignature(msg, privateKey);
console.log('签名:', sig);
// 验签:返回true表示验证通过
const valid = sm2.doVerifySignature(msg, sig, publicKey);
console.log('验签结果:', valid);
// 注意:如果数据已经做过SM3摘要,可传第三个参数指定{ hash: false, der: true }五、工程实践建议与常见问题排查
在真实项目中落地国密算法,光有代码还不够,有几个工程层面的建议值得参考。第一,密钥管理不要硬编码在代码里,应该放入环境变量、配置中心或KMS服务,尤其是SM4密钥和SM2私钥,一旦泄露整套加密体系就形同虚设。第二,建议把国密操作封装成独立的工具模块,统一处理hex与base64的编码转换、密文格式拼接,避免各个业务模块各自实现导致格式不一致。
第三,跨语言互通是国密项目最大的坑源。排查问题时建议用固定的测试向量对拍:让Java端和Node端加密同一段明文、同一个密钥、同一个IV(CBC模式下IV固定为全零进行测试),比对密文是否一致。一致说明算法层面没问题,不一致再逐步检查密钥编码、填充方式、密文格式这三个高频出错点。测试完成后再在生产代码中恢复随机IV。
最后补充性能方面的考量。node-sm-crypto是纯JS实现,SM2签名运算大约需要几毫秒,对于常规的接口签名验签完全够用,但如果要在网关层对海量请求做验签,建议通过N-API编写C++扩展,或者将国密运算下沉到独立的加密服务。SM3和SM4由于走的是OpenSSL原生实现,性能表现与SHA-256、AES处于同一量级,无需过度担心。
总结一下:SM3和SM4直接用Node.js原生Crypto模块即可,成本低、性能好;SM2则交给node-sm-crypto这类成熟库,重点盯住密文格式与签名编码的兼容性。三者组合起来,一套完整的国密安全体系在Node.js中就搭建完成了。