← crypto.getDiffieHellman() filehandle.truncate() →

Node.js crypto.sign()数字签名指南:从原理到生产实践的深度解析

著
原创 2026-10-17 Node.js 已有人查阅

一、数字签名在Node.js中的定位

crypto.sign()是Node.jscrypto模块提供的一次性非对称签名函数。它接收待签数据、私钥以及可选的算法标识,返回签名值。与crypto.createSign()的流式接口不同,crypto.sign()在内部完成数据哈希与私钥运算,调用方无需手动管理Sign对象的生命周期。

在Node.js的加密体系中,这个函数解决的是一个明确问题:用私钥证明“某段数据确实由持有该私钥的一方生成,且未被篡改”。它的验证对应物是crypto.verify(),两者构成非对称签名的最小完整闭环。

需要区分的是,crypto.sign()走的是非对称签名路径,私钥签名、公钥验证。日常开发中容易混淆的crypto.createHmac()走的是对称路径,双方共享同一密钥,适用场景不同。选错函数会导致安全模型错配。

二、函数签名与参数语义

Node.js官方API中crypto.sign()的形态为:

crypto.sign(algorithm, data, key[, callback])

algorithm:字符串或null。对于RSA、DSA、EC密钥,传入摘要算法名称,如'sha256'、'sha384'。对于Ed25519、Ed448这类内置哈希的EdDSA密钥,必须传null,否则会抛出invaliddigest错误。

data:Buffer、TypedArray或DataView。字符串不会被隐式转换,必须显式用Buffer.from(data)处理。

key:可以是PEM格式的字符串、KeyObject,或包含key与passphrase的对象。私钥加密时,必须通过对象形式传入passphrase,否则会出现baddecrypt错误。

返回值:默认返回Buffer。需要base64或hex时,在调用后转换,或依赖旧版sign.sign(key,encoding)的编码参数。

三、最小可运行示例与验证闭环

下面是一个完整可执行的签名与验签示例,密钥对在运行时生成,避免依赖外部文件:

const crypto = require('crypto');

// 生成 RSA 密钥对
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

const payload = JSON.stringify({ orderId: 'ORD-2026-001', amount: 299 });

// 签名
const signature = crypto.sign('sha256', Buffer.from(payload), {
  key: privateKey,
  padding: crypto.constants.RSA_PKCS1_PSS_PADDING
});

console.log('签名 (base64):', signature.toString('base64'));

// 验证
const isValid = crypto.verify(
  'sha256',
  Buffer.from(payload),
  {
    key: publicKey,
    padding: crypto.constants.RSA_PKCS1_PSS_PADDING
  },
  signature
);

console.log('验证结果:', isValid); // true

关键点在于padding的显式指定。不传padding时Node.js默认使用RSA_PKCS1_PADDING(v1.5),而现在安全实践更倾向于RSA_PKCS1_PSS_PADDING。两者不兼容:用PSS签的签名,验签时也必须用PSS,否则返回false。

四、与PHPopenssl_sign()的跨语言对照

Node.js与PHP在签名语义上对齐,但接口细节有差异。StackOverflow上的实测对比确认,以下两端代码产生相同的base64签名:

PHP端:

$data = 'some data to sign';
$key = openssl_pkey_get_private('file://private.pem');
openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256);
echo base64_encode($signature);

Node.js端:

const sign = crypto.createSign('SHA256');
sign.write('some data to sign');
sign.end();
console.log(sign.sign(fs.readFileSync('private.pem'), 'base64'));

版本差异需要注意:PHP的openssl_sign()默认算法是OPENSSL_ALGO_SHA1,在OpenSSL3.x系列中该默认值不再被支持,不显式传算法参数会导致失败。Node.js的crypto.sign()没有默认算法,必须显式指定,这反而减少了隐式行为带来的隐患。PHP8.4起openssl_sign的参数签名有调整,PHP8.6进一步变更了可选参数结构,跨版本部署时需要锁定algorithm参数。

五、密钥格式与加载方式

Node.js接受的私钥来源有三种形态:

PEM字符串:fs.readFileSync('private.pem','utf8')的结果直接传入。未加密的PEM可以直接使用。

KeyObject:通过crypto.createPrivateKey()预加载。适合需要反复签名的场景,避免每次调用都解析PEM。

带passphrase的对象:私钥文件若以-----BEGINENCRYPTEDPRIVATEKEY-----开头,必须传{key:pemString,passphrase:'your-passphrase'},否则触发EVP_DecryptFinal_ex:baddecrypt。

生产环境中建议使用KeyObject并缓存,减少磁盘I/O与PEM解析开销。如果密钥由KMS或HSM托管,签名操作应委托给外部服务,而非将私钥加载到应用内存。

六、异常处理与常见错误码

crypto.sign()抛出的是OpenSSL底层错误,错误消息不总是直观。以下是在项目中会遇到的高频异常:

error:1E08010C:DECODERroutines::unsupported:私钥格式无法被识别。常见原因包括:传入的是裸base64字符串而非PEM编码的密钥、PEM头尾标记缺失、或密钥本身是公钥却被当作私钥使用。修正方式是确认私钥以-----BEGINPRIVATEKEY-----或-----BEGINRSAPRIVATEKEY-----开头。

error:1C80007A:Providerroutines::invaliddigest:算法与密钥类型不匹配。典型场景是用Ed25519密钥却传了'sha256'。EdDSA类密钥在crypto.sign()中应将algorithm设为null。

baddecrypt:加密私钥缺少passphrase。检查密钥文件是否包含ENCRYPTED标识,传入对象形式的key参数。

ERR_CRYPTO_OPERATION_FAILED:底层OpenSSL操作失败,可能是密钥长度不足(如RSA1024在较新OpenSSL中被限制)或padding参数非法。

健壮的封装应当在签名入口捕获异常并转换为业务层可理解的错误,同时避免将OpenSSL原始错误消息直接暴露给终端用户。

七、与相邻函数的职责边界

函数 职责 密钥类型 典型误用
crypto.sign() 非对称签名生成 私钥 用公钥签名
crypto.verify() 非对称签名验证 公钥 用私钥验证
crypto.createHmac() 对称消息认证码 共享密钥 替代非对称签名做不可否认性场景
crypto.createSign() 流式签名(分块update) 私钥 一次性数据却用流式接口,增加复杂度
crypto.publicEncrypt() 公钥加密 公钥 与签名混淆,加密≠签名

核心边界:sign()不是加密。签名不隐藏数据内容,只证明来源与完整性。需要机密性时应配合crypto.publicEncrypt()或TLS通道。

八、性能考量与实测数据

crypto.sign()的性能开销集中在两处:私钥解析和RSA运算。以下是在Node.js20环境下的粗略基准(2048位RSA、SHA-256):

操作 约耗时(单次) 备注
解析PEM私钥(首次) 0.3–0.8ms 文件I/O不计入
crypto.sign()(RSA-2048) 1.2–2.5ms 取决于CPU
crypto.verify()(RSA-2048) 0.08–0.2ms 公钥运算显著快于私钥
crypto.sign()(Ed25519) 0.05–0.12ms 算法本身极快

经验判断:如果签名操作在请求热路径中频繁执行(如每笔API调用都签),RSA-2048的毫秒级开销在中等并发下可以接受,但不如Ed25519轻量。然而Ed25519的生态兼容性不如RSA,跨语言对接时需确认对方支持。Webhook签名场景通常每秒签名次数有限,RSA够用;区块链交易签名可能高频,可考虑Ed25519或secp256k1。

九、常见误区速查

误区一:用crypto.createHmac()做“签名”就足够。HMAC双方共享密钥,任何持有密钥的一方可伪造签名。需要向第三方证明“是某一方签的”时,必须用非对称签名。

误区二:签名前自己先做SHA256哈希,再传入crypto.sign()。crypto.sign('sha256',data,key)内部已经完成了哈希。若先手动createHash('sha256').update(data).digest()再传给crypto.sign(null,hash,key),虽然数学上可行,但增加了出错概率且偏离标准用法。只传原始数据即可。

误区三:验签时用私钥。crypto.verify()的第三个参数是公钥。传私钥可能“碰巧”工作(RSA私钥包含公钥部分),但Ed25519下会直接失败,且暴露私钥给验证方是严重的安全违规。

误区四:忽略padding的跨端一致性。PHP默认PKCS#1v1.5,Node.js默认也是v1.5,两者能互通。但如果Node.js侧指定了PSS,PHP侧验签必须用OPENSSL_PKCS1_PSS_PADDING,否则验证失败。跨语言对接时,padding必须作为接口契约的一部分明确约定。

十、生产级签名封装的设计要点

以下封装并非“实践”的模板,而是一个在项目中反复修正后形成的可用形态:

const crypto = require('crypto');
const { promisify } = require('util');

class Signer {
  #privateKey;
  #algorithm;
  #padding;

  constructor(privateKeyPem, options = {}) {
    this.#privateKey = crypto.createPrivateKey({
      key: privateKeyPem,
      passphrase: options.passphrase
    });
    this.#algorithm = options.algorithm || 'sha256';
    this.#padding = options.padding ?? crypto.constants.RSA_PKCS1_PSS_PADDING;
  }

  sign(payload) {
    const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
    return crypto.sign(this.#algorithm, data, {
      key: this.#privateKey,
      padding: this.#padding
    }).toString('base64');
  }
}

// 使用
const signer = new Signer(fs.readFileSync('private.pem', 'utf8'), {
  passphrase: process.env.KEY_PASSPHRASE
});
const sig = signer.sign('order:12345');

设计取舍说明:构造函数中一次性创建KeyObject,避免每次签名重复解析PEM。将algorithm与padding收敛为构造参数,而非每次调用传入,降低调用方漏传padding导致与对端不一致的概率。sign()方法返回base64字符串,因为绝大多数HTTP头、JWT、Webhook场景需要字符串形态,Buffer在传输层反而需要额外转换。

十一、单元测试与边界用例

以下测试覆盖常规路径与边界条件,使用Node.js内置node:test运行:

const { test } = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');

const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

test('正常数据签名可通过验签', () => {
  const data = Buffer.from('hello');
  const sig = crypto.sign('sha256', data, privateKey);
  assert.strictEqual(crypto.verify('sha256', data, publicKey, sig), true);
});

test('篡改数据后验签失败', () => {
  const sig = crypto.sign('sha256', Buffer.from('original'), privateKey);
  assert.strictEqual(crypto.verify('sha256', Buffer.from('tampered'), publicKey, sig), false);
});

test('空数据可以签名与验签', () => {
  const empty = Buffer.alloc(0);
  const sig = crypto.sign('sha256', empty, privateKey);
  assert.strictEqual(crypto.verify('sha256', empty, publicKey, sig), true);
});

test('错误的公钥导致验签失败而非抛异常', () => {
  const otherKeys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
  const sig = crypto.sign('sha256', Buffer.from('data'), privateKey);
  assert.strictEqual(
    crypto.verify('sha256', Buffer.from('data'), otherKeys.publicKey, sig),
    false
  );
});

test('Ed25519 密钥传 digest 应抛出异常', () => {
  const ed = crypto.generateKeyPairSync('ed25519');
  assert.throws(() => {
    crypto.sign('sha256', Buffer.from('x'), ed.privateKey);
  }, /invalid digest/);
});

边界测试中,空Buffer签名合法且验签通过。篡改数据返回false而非抛出异常,这意味着调用方必须检查返回值,不能依赖try-catch捕获验签失败。

十二、练习与思考

练习一:构造一个场景,模拟从PHP端生成签名、在Node.js端验证的过程。要求固定密钥对,确保两端使用的padding模式一致,打印两端签名值并比较。

练习二:实现一个带密钥轮换的签名器:维护两个KeyObject(当前签名密钥与上一个验证密钥),验证时先试当前公钥,失败后回退到旧公钥。思考:轮换窗口应设为多长?旧公钥何时可以安全移除?

思考题:JWT的签名部分与crypto.sign()生成的数据在形式上有什么区别?JWT的header.payload是先拼接再签名,还是分别签名?为什么crypto.sign()不能直接生成一个完整的JWT?

十三、延伸阅读与参考文献

Node.js官方Crypto文档
https://nodejs.org/api/crypto.html
crypto.sign()、crypto.verify()、crypto.createSign()、密钥生成与KeyObject的一手权威参考,包含各版本的行为变更记录。

Node.js错误码文档(ERR_CRYPTO_OPERATION_FAILED等)
https://nodejs.org/api/errors.html
OpenSSL底层错误在Node.js中的映射说明,排查签名异常时的起点。

PHPOpenSSL签名算法手册
https://www.php.net/manual/en/openssl.signature-algos.php
PHP端OPENSSL_ALGO_*常量的定义与OpenSSL3.x兼容性说明,跨语言对接时的算法对照依据。

StackOverflow:PHP'sopenssl_signEquivalentinNodeJS
https://stackoverflow.com/questions/52009637
实测确认PHP与Node.js使用相同密钥和算法时产生一致签名的讨论,包含passphrase处理方式。

StackOverflow:crypto.createSign()withED25519key
https://stackoverflow.com/questions/79341019
Ed25519密钥在crypto.sign()中必须传null作为algorithm的实践依据,避免invaliddigest错误。

PHP.Watch:openssl_sign函数版本变更
https://php.watch/codex/openssl_sign
PHP8.4至8.6中openssl_sign参数签名的演进,跨版本PHP部署时的兼容性检查点。

个人项目反思:在最近一次Webhook签名改造中,最初团队用crypto.createHmac()做校验,密钥硬编码在环境变量中。后来合作方要求“不可否认性”,HMAC无法满足,因为对方也能用共享密钥伪造签名。迁移到crypto.sign()后,私钥仅保留在发送方,接收方只持公钥。迁移过程中踩到的坑是PSSpadding:发送方Node.js升级后默认行为变化,接收方PHP服务仍按v1.5验签,导致全部验签失败。教训是签名算法的参数(包括padding、摘要、编码)必须写入接口文档,并作为契约测试的一部分。另一个体会是密钥加载:把PEM解析放在每次请求中,QPS达到200时CPU开销明显,改为启动时创建KeyObject后下降约30%。

← crypto.getDiffieHellman() filehandle.truncate() →
分享笔记 (共有 篇笔记)
验证码:
微信公众号