1. 项目概述:为什么我们需要在Node.js里搞懂RSA?

如果你正在用Node.js开发一个需要处理用户密码、传输敏感数据或者对接第三方支付接口的后端服务,那么“加密”和“签名”这两个词对你来说绝对不陌生。而RSA,作为非对称加密领域的“老大哥”,几乎是每个后端开发者绕不开的一道坎。我见过太多项目,加密模块写得稀里糊涂:有的把公钥私钥混着用,有的签名验签总是不通过,还有的因为性能问题在线上直接崩掉。这些问题,根源往往不是代码写错了,而是对RSA这套机制的理解还停留在“复制粘贴”的层面。

这个内容,就是要把Node.js环境下的RSA加密、解密、签名和验证给你彻底掰扯清楚。它不是一份简单的API调用手册,而是会深入到“为什么这么用”的层面。比如,为什么加密要用对方的公钥,而解密用自己的私钥?签名和加密的本质区别到底是什么?从生成密钥对开始,到如何安全地存储和读取密钥,再到用 crypto 模块一步步实现各种操作,最后处理那些让人头疼的异常和性能问题。无论你是要确保用户登录令牌的安全,还是要实现一个可靠的支付回调验证,这里面的坑和技巧,我都会结合我踩过的雷,给你讲明白。

2. RSA核心原理与在Node.js中的定位

2.1 非对称加密的“信箱”模型

要理解RSA,得先忘掉那些复杂的数学公式。咱们用一个生活化的例子来类比:想象一个带锁的信箱。

这个信箱有两把钥匙:一把是“公钥”,可以复制无数份,发给任何人。这把钥匙 只能锁上信箱 。另一把是“私钥”,只有信箱主人自己持有,这把钥匙 只能打开信箱

现在,张三想给主人李四寄一封密信。他会怎么做?他找到李四公布在外的“公钥”(锁信箱的钥匙),把信放进信箱,然后用这把公钥“咔哒”一声把信箱锁上。锁上之后,就连张三自己也无法再打开这个信箱了。这封信在传输过程中,即使被王五截获,他也打不开信箱,因为他没有李四的私钥。只有李四本人,用自己的“私钥”(开信箱的钥匙),才能打开信箱取出信件。

这个过程,就对应着 RSA加密和解密

  • 加密 :使用 接收方的公钥 对数据进行加密。加密后,只有接收方的私钥能解开。
  • 解密 :使用 接收方的私钥 对加密数据进行解密。

那么签名和验证又是什么呢?继续用信箱的例子。李四想发布一个公告,并证明这个公告确实是他发的,没有被篡改。他会先用一个公开的摘要算法(比如SHA256)把公告内容计算出一个很短的“指纹”(摘要)。然后,他用自己的“私钥”对这个“指纹”进行加密。这个加密后的“指纹”,附在公告后面,就叫做 数字签名

任何人拿到这份带签名的公告,都可以做两件事:1. 用同样的摘要算法计算公告内容的“指纹”。2. 用李四公开的“公钥”去解密那个签名,得到李四声称的原始“指纹”。如果两个“指纹”一模一样,就证明:第一,公告内容在传输过程中没有被篡改(内容一致);第二,这个签名一定是李四的私钥签的,因为只有他的公钥才能解开(身份可信)。

这个过程,就对应着 RSA签名和验证

  • 签名 :使用 发送方的私钥 对数据的摘要进行加密,生成签名。
  • 验证 :使用 发送方的公钥 对签名进行解密,得到摘要,再与重新计算的数据摘要进行比对。

2.2 Node.js crypto模块:你的瑞士军刀

Node.js内置的 crypto 模块,就是我们操作RSA的“瑞士军刀”。它底层基于OpenSSL,稳定且高效,无需安装任何第三方依赖。对于绝大多数应用场景, crypto 模块已经足够强大和可靠。

这里有一个非常重要的选择: 为什么优先推荐使用 crypto ,而不是 node-rsa ursa 等第三方库?

  1. 原生与稳定 crypto 是Node.js核心模块,与Node.js版本生命周期绑定,具有最好的兼容性和稳定性。第三方库可能存在维护滞后、安全更新不及时的风险。
  2. 性能 :作为内置模块, crypto 通常有更好的性能表现,尤其是在涉及大量加密解密操作时。
  3. 安全性 :由Node.js和OpenSSL团队共同维护,安全响应更及时。
  4. 减少依赖 :避免项目引入不必要的依赖,降低依赖冲突和供应链攻击的风险。

除非你有非常特殊的、 crypto 模块不支持的功能需求(这在RSA基础操作中极少见),否则坚持使用 crypto 是最佳实践。接下来所有的代码演示,都将基于 crypto 模块展开。

3. 密钥对生成、格式与安全存储实践

3.1 生成RSA密钥对

万事开头难,而生成密钥对就是第一步。在Node.js中,我们使用 crypto.generateKeyPair crypto.generateKeyPairSync 方法。

const crypto = require('crypto');

// 异步方式生成密钥对(推荐,不阻塞事件循环)
crypto.generateKeyPair('rsa', {
  modulusLength: 2048, // 密钥长度,2048是当前安全基准,4096更安全但更慢
  publicKeyEncoding: {
    type: 'spki', // 推荐的公钥格式
    format: 'pem'
  },
  privateKeyEncoding: {
    type: 'pkcs8', // 推荐的私钥格式
    format: 'pem',
    cipher: 'aes-256-cbc', // 可选:用密码加密私钥
    passphrase: 'your-strong-passphrase' // 加密密码
  }
}, (err, publicKey, privateKey) => {
  if (err) throw err;
  console.log('公钥:\n', publicKey);
  console.log('私钥:\n', privateKey);
  // 接下来需要将密钥保存到文件或环境变量
});

// 同步方式生成密钥对
try {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
  });
  console.log(publicKey, privateKey);
} catch (err) {
  console.error(err);
}

关键参数解析:

  • modulusLength : 密钥长度。 绝对不要使用1024位 ,它已被证明不安全。 2048位是当前Web应用的标准配置 ,在安全性和性能之间取得了良好平衡。对安全性要求极高的场景(如CA证书)可考虑4096位,但请注意加解密和签名验签速度会显著下降。
  • publicKeyEncoding.type : 'spki' (Subject Public Key Info)。这是X.509证书中使用的标准公钥格式,兼容性最好。
  • privateKeyEncoding.type : 'pkcs8' (Private-Key Information Syntax Standard)。这是存储私钥的推荐格式,比传统的 'pkcs1' 格式更安全、更通用。
  • cipher passphrase : 这是保护私钥的关键。如果你将私钥保存在代码仓库或配置文件中, 必须使用强密码进行加密 。否则,一旦源代码泄露,你的私钥也就拱手送人了。

3.2 密钥格式辨析:PEM、DER、JWK

生成和使用的密钥,最常见的是PEM格式,它是一种用ASCII文本表示的格式,以 -----BEGIN XXX----- -----END XXX----- 包裹。

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo
...
-----END PUBLIC KEY-----

-----BEGIN ENCRYPTED PRIVATE KEY----- // 如果加密了会显示 ENCRYPTED
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,...

...加密的私钥数据...
-----END ENCRYPTED PRIVATE KEY-----
  • PEM (Privacy-Enhanced Mail) :文本格式,便于阅读、复制和粘贴在配置文件中,是最常用的格式。
  • DER (Distinguished Encoding Rules) :二进制格式,体积更小,常用于程序内部处理或证书文件中( .der , .cer )。 crypto 模块可以方便地在PEM和DER之间转换。
  • JWK (JSON Web Key) :一种用JSON表示的密钥格式,常用于JWT和现代Web API(如OAuth 2.0)。如果你在做前后端分离的认证,可能会用到它。
// 将PEM公钥转换为JWK格式
const pemPublicKey = `-----BEGIN PUBLIC KEY-----...`;
const keyObject = crypto.createPublicKey(pemPublicKey);
const jwk = keyObject.export({ format: 'jwk' });
console.log(jwk);
// 输出: { kty: 'RSA', n: '...', e: '...', ... }

3.3 密钥的安全存储策略

私钥的安全,就是系统的命门。以下是不同环境下的存储建议:

  1. 本地开发环境

    • 将密钥保存在项目根目录的 .env 文件中,并确保 .env 文件被添加到 .gitignore
    • 使用 dotenv 库加载环境变量。
    • 示例 .env 文件
      RSA_PRIVATE_KEY="-----BEGIN ENCRYPTED PRIVATE KEY-----\n..."
      RSA_PRIVATE_KEY_PASSPHRASE="your-dev-passphrase"
      RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..."
      
  2. 服务器环境(生产环境)

    • 首选 :使用云服务商提供的密钥管理服务,如AWS KMS、Azure Key Vault、Google Cloud KMS。这些服务提供硬件级安全、自动轮转和详细的访问日志。
    • 次选 :将加密后的私钥存储在环境变量中。大多数云平台和容器编排系统(如Docker, Kubernetes)都支持安全地注入环境变量。
    • 绝对禁止 :将私钥(尤其是未加密的私钥)硬编码在源代码中、提交到版本控制系统(如Git)、或存放在Web服务器可公开访问的目录下。
  3. 密钥轮转 :就像定期更换密码一样,密钥也需要定期更换(轮转)。制定一个计划,例如每年生成一套新的密钥对,并将旧密钥标记为过期但暂时保留,用于解密历史数据或验证旧签名,直至所有相关数据生命周期结束。

4. 加密与解密:确保数据的机密性

4.1 公钥加密与私钥解密实战

假设场景:前端需要安全地传输用户的信用卡号到后端。

步骤1:后端生成密钥对,并将公钥下发给前端。 步骤2:前端使用公钥加密数据。 步骤3:后端使用私钥解密数据。

以下是Node.js后端实现解密的部分:

const crypto = require('crypto');
const fs = require('fs');

// 假设私钥已从安全位置加载(如环境变量)
const privateKeyPem = process.env.RSA_PRIVATE_KEY;
const privateKeyPassphrase = process.env.RSA_PRIVATE_KEY_PASSPHRASE;

// 从前端接收到的加密数据(通常是Base64编码的字符串)
const encryptedDataBase64 = '前端传过来的长长加密字符串...';

/**
 * 使用私钥解密数据
 * @param {string} encryptedBase64 - Base64编码的加密数据
 * @returns {string} 解密后的原始字符串
 */
function rsaDecrypt(encryptedBase64) {
  try {
    // 1. 将Base64字符串转换回Buffer
    const encryptedBuffer = Buffer.from(encryptedBase64, 'base64');

    // 2. 创建私钥对象。如果私钥有密码,在这里传入。
    const privateKey = crypto.createPrivateKey({
      key: privateKeyPem,
      passphrase: privateKeyPassphrase, // 如果私钥加密了,需要密码
      format: 'pem',
      type: 'pkcs8'
    });

    // 3. 执行解密
    const decryptedBuffer = crypto.privateDecrypt(
      {
        key: privateKey,
        // padding: crypto.constants.RSA_PKCS1_OAEP_PADDING // 这是默认且推荐的填充方式
      },
      encryptedBuffer
    );

    // 4. 将解密后的Buffer转为字符串
    return decryptedBuffer.toString('utf8');
  } catch (error) {
    console.error('解密失败:', error.message);
    // 具体错误处理:可能是密钥错误、密码错误、数据被篡改、填充方式不匹配等
    throw new Error('数据解密失败,请检查密钥或传输数据。');
  }
}

// 使用示例
try {
  const originalCardNumber = rsaDecrypt(encryptedDataBase64);
  console.log('解密后的卡号:', originalCardNumber);
  // 重要:处理完敏感信息后,尽快从内存中清除
  // originalCardNumber = null; // 在严格的安全场景下考虑
} catch (e) {
  // 处理解密异常
}

前端加密示例(使用Web Crypto API或类似库): 虽然这不是Node.js,但理解全流程很重要。前端应使用 RSA-OAEP 填充方案进行加密,这与Node.js crypto 的默认设置匹配。

// 伪代码,示意前端加密流程
async function encryptOnFrontend(publicKeyPem, data) {
  // 1. 将PEM格式公钥导入为CryptoKey
  // 2. 使用RSA-OAEP算法和SHA-256进行加密
  // 3. 将加密结果ArrayBuffer转换为Base64字符串
  // 4. 将Base64字符串发送给后端
}

4.2 填充方案的选择与“数据太长”错误

RSA算法本身不能直接加密任意长度的数据。它有一个“数据块长度”的限制,与密钥长度有关。对于2048位密钥,能加密的原始数据长度大约为 256字节 - 填充开销

当你尝试加密超过这个长度的数据时,就会遇到 Error: data too large for key size 错误。

解决方案是:混合加密系统。

  1. 生成一个随机的对称密钥 (如AES-256密钥)。
  2. 使用这个对称密钥加密你的大量数据 (如整个JSON对象、文件)。AES等对称加密算法速度极快,且适合加密大块数据。
  3. 使用RSA公钥加密上一步生成的对称密钥
  4. RSA加密后的对称密钥 AES加密后的数据 一起发送给接收方。
  5. 接收方先用RSA私钥解密出对称密钥,再用对称密钥解密出原始数据。

这种“RSA+AES”的组合,兼顾了非对称加密的安全密钥交换和对称加密的高效大数据处理能力,是实际应用中的标准做法。

const crypto = require('crypto');

function hybridEncrypt(publicKey, largeData) {
  // 1. 生成随机AES密钥和初始化向量(IV)
  const aesKey = crypto.randomBytes(32); // AES-256
  const iv = crypto.randomBytes(16); // AES块大小

  // 2. 使用AES加密数据
  const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
  let encryptedData = cipher.update(largeData, 'utf8', 'base64');
  encryptedData += cipher.final('base64');

  // 3. 使用RSA公钥加密AES密钥
  const encryptedAesKey = crypto.publicEncrypt(publicKey, aesKey);

  // 4. 返回组合结果 (通常将IV也一起返回,IV可以公开)
  return {
    encryptedKey: encryptedAesKey.toString('base64'),
    iv: iv.toString('base64'),
    encryptedData: encryptedData
  };
}

5. 签名与验证:确保数据的完整性与来源可信

5.1 私钥签名与公钥验证实战

假设场景:你的服务需要调用一个第三方支付接口,对方要求所有请求参数必须签名。

步骤1:你(作为发送方)拥有自己的RSA私钥。支付平台拥有你的公钥。 步骤2:你对请求参数(如订单号、金额、时间戳)按特定规则拼接成一个签名字符串。 步骤3:你用私钥对这个字符串的摘要进行签名,并将签名附在请求中。 步骤4:支付平台用你的公钥验证签名,通过则说明请求确实来自你,且参数未被篡改。

const crypto = require('crypto');

/**
 * 使用私钥对数据进行签名
 * @param {string|Buffer} data - 待签名的原始数据
 * @param {string|Object} privateKey - PEM格式私钥或私钥对象
 * @param {string} [passphrase] - 私钥密码(如果有)
 * @returns {string} Base64编码的签名
 */
function rsaSign(data, privateKey, passphrase) {
  // 1. 创建签名对象,指定算法(如RSA-SHA256)
  const sign = crypto.createSign('RSA-SHA256');

  // 2. 更新(传入)要签名的数据
  sign.update(data);
  sign.end(); // 表示数据更新完毕

  // 3. 进行签名,并输出为Base64格式
  const privateKeyObj = crypto.createPrivateKey({
    key: privateKey,
    passphrase: passphrase,
    format: 'pem'
  });
  const signature = sign.sign(privateKeyObj, 'base64');
  return signature;
}

/**
 * 使用公钥验证签名
 * @param {string|Buffer} data - 原始数据
 * @param {string} signatureBase64 - Base64编码的签名
 * @param {string|Object} publicKey - PEM格式公钥或公钥对象
 * @returns {boolean} 验证是否通过
 */
function rsaVerify(data, signatureBase64, publicKey) {
  // 1. 创建验证对象,算法必须与签名时一致
  const verify = crypto.createVerify('RSA-SHA256');

  // 2. 更新(传入)原始数据
  verify.update(data);
  verify.end();

  // 3. 进行验证
  const publicKeyObj = crypto.createPublicKey({ key: publicKey, format: 'pem' });
  const signatureBuffer = Buffer.from(signatureBase64, 'base64');
  const isValid = verify.verify(publicKeyObj, signatureBuffer);
  return isValid;
}

// ============ 实战示例 ============
const privateKey = `-----BEGIN ENCRYPTED PRIVATE KEY-----...`;
const publicKey = `-----BEGIN PUBLIC KEY-----...`;
const passphrase = 'my-secret';

// 构造待签名的数据(必须与验证方约定好格式,顺序不能错!)
const orderData = {
  orderId: '202310270001',
  amount: 10000,
  currency: 'CNY',
  timestamp: Date.now()
};
// 将对象转换为确定的字符串,例如按key排序后拼接
const signString = `orderId=${orderData.orderId}&amount=${orderData.amount}¤cy=${orderData.currency}×tamp=${orderData.timestamp}`;

console.log('待签名字符串:', signString);

// 发送方:签名
const signature = rsaSign(signString, privateKey, passphrase);
console.log('生成的签名:', signature);

// 模拟传输...

// 接收方(支付平台):验证
const isVerified = rsaVerify(signString, signature, publicKey);
console.log('签名验证结果:', isVerified ? '✅ 验证成功,数据可信' : '❌ 验证失败,数据可能被篡改或来源不可信');

5.2 摘要算法的选择与签名流程标准化

在签名过程中,我们并不是直接对原始数据签名,而是先对数据做“哈希”(摘要),再对哈希值签名。这有两个好处:1. 固定长度,方便处理;2. 即使数据很大,哈希计算也很快。

crypto.createSign('RSA-SHA256') 中的 SHA256 就是摘要算法。常见的选择有:

  • SHA256 :目前最广泛使用的安全哈希算法,是大多数场景下的默认选择。
  • SHA384/SHA512 :更长的哈希值,安全性更高,但计算稍慢,签名结果也更长。
  • (已废弃)SHA1, MD5 绝对不要在新项目中使用 ,它们已被证明存在碰撞漏洞,不安全。

签名流程标准化至关重要: 验证方必须能以 完全相同的方式 重构出签名字符串。这意味着双方必须提前约定好:

  1. 参数排序 :是按字母顺序(ASCII)排序,还是按固定字段顺序?
  2. 键值连接符 :是用 = : 还是其他符号?
  3. 参数分隔符 :是用 & , 还是分号 ;
  4. 字符编码与URL编码 :是否需要对所有参数值进行URL编码?空格如何处理?
  5. 是否包含空值参数 :值为 null 或空字符串的参数是否参与签名?

一个常见的约定是: 将所有非空参数按参数名ASCII码从小到大排序,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串,然后进行签名 。微信支付、支付宝等平台的签名方案都类似于此。

6. 性能优化、常见陷阱与问题排查

6.1 RSA操作的性能考量

RSA的加解密和签名验签都是CPU密集型操作,尤其是密钥长度较大时。以下是一些性能数据和优化建议:

  • 基准数据(仅供参考,随CPU变化) :在主流服务器CPU上,使用2048位RSA密钥,每秒大约能进行:
    • 私钥解密(或签名):数百到一千次。
    • 公钥加密(或验证):数千次。
  • 对高并发API的影响 :如果一个登录接口,每次都用RSA私钥解密前端传过来的密码,在每秒数千次请求的峰值下,CPU可能会成为瓶颈。
  • 优化策略
    1. 使用混合加密 :如前所述,对于传输大量数据,只用RSA加密一个随机的对称密钥。
    2. 缓存公钥对象 crypto.createPublicKey() crypto.createPrivateKey() 解析PEM字符串是有开销的。对于频繁使用的密钥(如验证JWT签名用的公钥),应该在服务启动时创建一次密钥对象并缓存起来,而不是每次请求都重新解析。
    3. 考虑更快的算法 :对于纯签名场景,可以考虑 ECDSA(椭圆曲线数字签名算法) 。在相同安全强度下,ECDSA的密钥更短,签名更快,签名结果也更小。但Node.js的 crypto 模块对它的支持同样完善。
    4. 异步操作 :使用 crypto 的异步方法(如 crypto.publicEncrypt 的回调形式,或利用 util.promisify )可以避免在极高并发下阻塞事件循环,但对于单次操作,性能提升不明显。

6.2 高频错误与排查指南

在调试RSA相关代码时,你几乎一定会遇到下面这些错误。这里给你一个速查表:

错误信息 可能原因 排查步骤
Error: error:06065064:digital envelope routines:EVP_DecryptFinal_ex:bad decrypt 1. 私钥密码错误。
2. 私钥格式不对(如用 pkcs1 格式去解析 pkcs8 的密钥)。
3. 加密数据在传输过程中被损坏。
1. 确认 passphrase 是否正确。
2. 检查生成和读取密钥时使用的 type 是否一致( pkcs1 vs pkcs8 )。
3. 确认加密数据的Base64编码是否正确,无换行或空格。
Error: error:04099079:rsa routines:RSA_padding_check_PKCS1_OAEP:oaep decoding error 1. 填充方案不匹配。比如加密用了 PKCS1_OAEP ,解密却用了 PKCS1_v1_5 (或不指定,默认是 OAEP )。
2. 密钥不配对(用A的公钥加密,却试图用B的私钥解密)。
3. 加密数据被篡改。
1. 确保加解密双方使用相同的填充方案。在 crypto.publicEncrypt/privateDecrypt options 参数中显式指定 padding: crypto.constants.RSA_PKCS1_OAEP_PADDING
2. 确认使用的公私钥是配对的一对。
Error: error:0D0680A8:asn1 encoding routines:asn1_check_tlen:wrong tag 密钥字符串格式错误。可能是PEM格式不完整(缺少头尾标记),或者字符串中包含非法字符、多余的空格或换行。 1. 打印出密钥字符串,肉眼检查 -----BEGIN XXX----- -----END XXX----- 是否完整。
2. 如果密钥是从环境变量读取的,确保换行符 \n 被正确保留(有时需要手动替换)。
Error: data too large for key size 尝试用RSA加密的数据超过了密钥长度限制。 改用“混合加密”方案,用RSA加密对称密钥,用对称加密算法(如AES)加密实际数据。
签名验证总是返回 false 1. 签名算法不匹配(签名用 SHA256 ,验证用 SHA1 )。
2. 待签名的原始数据在双方不一致 (这是最常见的原因)。
3. 签名字符串的Base64编码/解码出错。
1. 检查 createSign createVerify 使用的算法字符串是否完全一致。
2. 在签名和验证方分别打印出待处理的原始字符串( signString ),进行逐字符比对 。特别注意空格、不可见字符和排序规则。
3. 确保签名在传输过程中没有被修改。

6.3 密钥与证书的过期管理

RSA密钥本身没有内置过期时间,但基于它签发的证书(如HTTPS用的SSL证书)有。在Node.js中直接使用密钥对时,你需要自己管理密钥的生命周期。

  1. 设置密钥有效期 :在心理上和制度上为密钥设定一个有效期(如1年或2年)。
  2. 实现密钥轮转
    • 在旧密钥过期前,生成一套新密钥对。
    • 将新公钥分发给所有通信方。
    • 在一段重叠期内,同时支持用新旧两套密钥验证签名或解密数据。
    • 重叠期结束后,停用旧密钥,并安全地归档或销毁它(从所有服务器、配置文件中删除)。
  3. 监控与告警 :在配置中记录密钥的生成日期和计划过期日期,设置监控,在密钥即将过期前发出告警。

7. 进阶应用:在Web开发中的典型场景

7.1 JWT(JSON Web Token)的签名与验证

JWT是RSA签名的一个完美应用场景。一个JWT通常由三部分组成:Header.Payload.Signature。其中的Signature部分,就可以使用RSA私钥对 Base64Url(Header).Base64Url(Payload) 进行签名生成。

const crypto = require('crypto');
const base64url = require('base64url'); // 需要安装: npm install base64url

function signJWT(payload, privateKey) {
  const header = { alg: 'RS256', typ: 'JWT' }; // RS256 即 RSA-SHA256
  const encodedHeader = base64url(JSON.stringify(header));
  const encodedPayload = base64url(JSON.stringify(payload));

  const signString = `${encodedHeader}.${encodedPayload}`;
  const signature = rsaSign(signString, privateKey); // 使用前面定义的rsaSign函数
  const encodedSignature = base64url.fromBase64(signature); // JWT要求是Base64Url

  return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
}

function verifyJWT(token, publicKey) {
  const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');
  const signString = `${encodedHeader}.${encodedPayload}`;
  const signature = base64url.toBase64(encodedSignature); // 转换回标准Base64

  return rsaVerify(signString, signature, publicKey); // 使用前面定义的rsaVerify函数
}

许多流行的JWT库(如 jsonwebtoken )底层就是调用 crypto 模块来完成这些操作的。理解了这个原理,你就能更从容地处理JWT密钥配置、算法选择等问题。

7.2 与前端、第三方API的加密交互

场景一:前端密码加密传输 这是RSA加密的经典用法。后端提供一个接口返回公钥(可以定期更换)。前端登录时,用此公钥加密密码后再传输。这样,即使请求被拦截,攻击者没有私钥也无法解密出明文密码。后端收到后,用私钥解密再进行密码比对。

场景二:支付回调验证 支付宝、微信支付等平台在回调你的服务器通知支付结果时,会携带一个签名。他们会提供自己的公钥(或通过平台证书获取)。你的服务器需要用他们的公钥来验证这个签名,以确保回调通知确实来自支付平台,而不是伪造的。这个过程就是“验签”。

// 模拟支付宝回调验签
const alipayPublicKey = `-----BEGIN PUBLIC KEY-----\n...来自支付宝平台的公钥...`;
const callbackData = req.body; // 回调参数
const signFromAlipay = req.query.sign; // 支付宝传来的签名

// 1. 按照支付宝文档的规则,组装待验签字符串
const signContent = buildSignString(callbackData); // 需要自己实现,严格按照文档

// 2. 验证签名
if (rsaVerify(signContent, signFromAlipay, alipayPublicKey)) {
  // 验签成功,处理业务逻辑
  console.log('支付成功,订单完结');
} else {
  // 验签失败,记录日志并拒绝请求
  console.error('可疑回调,验签失败!');
  res.status(403).send('Invalid Signature');
}

一个至关重要的实操心得:在处理第三方API时,签名验证逻辑的代码一定要有完善的日志记录。 记录下接收到的所有参数、你自己组装出的待签名字符串、以及验签的结果。当出现纠纷或调试时,这些日志是唯一能帮你定位问题是出在对方、网络传输还是你自己代码上的证据。我曾因为一个空格字符的差异,花了半天时间排查支付回调失败的问题,正是详细的日志让我最终找到了症结所在。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐