资讯动态

RSA公钥的两种形式:PEM格式与模数指数对详解

发布时间:2026/8/7 11:14:39 来源:尧图企业网站定制
1. 从一次令人困惑的对接说起最近在做一个第三方支付平台的接口对接对方技术文档里给了一个RSA公钥让我用来验签。我按照常规操作把那段以-----BEGIN PUBLIC KEY-----开头的PEM格式字符串加载到我的代码里结果死活验签失败。调试了半天各种编码转换都试了还是不行。最后实在没辙把问题抛给了对方的技术支持。对方沉默了一会儿回了一句“你试试用这个公钥指数和模数自己拼一下公钥” 我这才恍然大悟原来他们给的不是标准的PEM公钥而是分成了模数n和指数e两个十六进制字符串。这个经历让我意识到很多开发者对RSA公钥的两种主流表现形式——PEM格式和模数指数对——其实并不完全清楚尤其是在跨平台、跨语言对接时这个问题尤为突出。RSA公钥本质上是一对数字一个巨大的合数作为模数n和一个与欧拉函数互质的数作为公钥指数e。但在实际存储、传输和使用时我们很少直接操作这两个裸数字。为了通用性和规范性它们被包装成了不同的格式。理解这两种形式不仅是掌握RSA应用的基础更是解决实际开发中各种“灵异”问题的关键。无论你是正在集成支付SDK、实现API签名验证还是仅仅想搞懂HTTPS证书里的公钥是怎么一回事弄明白PEM和模数指数对的区别与联系都能让你少走很多弯路。2. 核心RSA公钥的“内在”与“外在”在深入两种形式之前我们必须先统一对RSA公钥本质的认识。这能帮助我们理解为什么会有不同的“包装”方式。2.1 公钥的数学本质模数(n)与指数(e)RSA公钥的数学核心非常简单就两个部分模数 (n)这是一个非常大的合数通常是两个大质数p和q的乘积即n p * q。这个数字的位数决定了RSA密钥的强度比如2048位RSA指的就是n的二进制长度大约是2048位。n是公开的。公钥指数 (e)这是一个与φ(n)即(p-1)*(q-1)互质的正整数。为了计算高效最常用的值是655370x10001因为它二进制中1很少计算速度快且安全性足够。少数老旧系统可能使用3或17。所以从数学和程序内存的角度看一个RSA公钥对象就是(n, e)这个数对。任何RSA操作无论是加密还是验签最终都需要这两个值。我们后面讲的所有格式无论是PEM还是XML本质上都是(n, e)这对数据的不同编码和包装方式。2.2 为什么需要不同的表现形式既然本质就是(n, e)为什么不能直接传这两个数呢原因在于互操作性和自描述性。原始数据的缺陷n和e都是非常大的整数。直接传输它们的十进制或十六进制字符串缺乏标准编码模糊这是十六进制还是Base64是大端序还是小端序结构缺失哪一段是n哪一段是e它们的长度是多少类型不明这串数字代表的是RSA公钥还是DSA公钥或者别的什么标准化格式的优势为了解决以上问题业界制定了标准的格式如PEM、DER、PKCS#1。这些格式包含元数据明确标识这是“RSA公钥”。定义编码规则规定整数如何编码为字节流通常是ASN.1 DER编码。提供封装结构添加起始、结束标记便于识别和解析。广泛支持所有主流编程语言和密码学库都支持解析这些标准格式。因此(n, e)是“内在”的原材料而PEM等格式是“外在”的、包装好的、可直接使用的成品。在实际开发中我们拿到成品PEM的机会更多但偶尔也会遇到需要自己组装原材料n, e的情况。3. 第一种形式PEM格式——即开即用的标准包装PEM格式是我们日常开发中最常见、最“友好”的RSA公钥表现形式。3.1 PEM格式的解剖一个典型的RSA公钥PEM文件看起来是这样的-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo 4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u qKhbwKfBstIsbMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh kd3qqGElvW/VDL5AaWTgU n4V9L1VFRjYueJ8Kq1p7pK/5Q0N9F8qZxUZ5QK7w ... -----END PUBLIC KEY-----它包含几个关键部分首尾标签行-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----。这是PEM格式的显著特征用于标识其中包含的数据类型。注意对于RSA公钥也可能是-----BEGIN RSA PUBLIC KEY-----这代表了稍有不同的内部结构PKCS#1格式后文会详述。Base64编码的正文标签行之间的内容是经过Base64编码的二进制数据。这段Base64字符串解码后得到的是一个遵循ASN.1 DER编码规则的二进制序列。3.2 解码PEM从Base64到ASN.1 DERPEM的正文部分Base64解码后得到的是一个DER编码的数据结构。对于BEGIN PUBLIC KEY标签对应X.509 SubjectPublicKeyInfo标准其ASN.1结构大致如下SubjectPublicKeyInfo :: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } AlgorithmIdentifier :: SEQUENCE { algorithm OBJECT IDENTIFIER, parameters ANY DEFINED BY algorithm OPTIONAL }在这个结构里algorithm字段指明了这是RSA算法OID为1.2.840.113549.1.1.1。subjectPublicKey这个BIT STRING里存放的才是真正的RSA公钥数据而这个数据本身又是一个ASN.1 DER编码的序列其结构是PKCS#1定义的RSAPublicKeyRSAPublicKey :: SEQUENCE { modulus INTEGER, -- n publicExponent INTEGER -- e }所以一个标准的PEM公钥是经过了两层包装的外层是X.509的SubjectPublicKeyInfo结构说明算法内层是PKCS#1的RSAPublicKey结构存放核心的n和e。3.3 在代码中使用PEM公钥正因为PEM是标准格式所有现代语言库都提供了极其简便的加载方式。这里以Pythoncryptography库和Node.js为例Python示例from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend # 从PEM字符串加载公钥 pem_public_key -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo... -----END PUBLIC KEY----- public_key serialization.load_pem_public_key( pem_public_key.encode(), backenddefault_backend() ) # 现在可以使用public_key进行verify()等操作Node.js示例const crypto require(crypto); const pemPublicKey -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo... -----END PUBLIC KEY-----; const publicKey crypto.createPublicKey(pemPublicKey); // 现在可以使用publicKey.verify()等操作注意有些PEM文件以-----BEGIN RSA PUBLIC KEY-----开头。这表示它直接包含了PKCS#1格式的RSAPublicKey结构而省略了外层的SubjectPublicKeyInfo。不是所有库都直接支持加载这种格式。cryptography库的load_pem_public_key可以同时处理这两种格式但一些旧库可能不行。如果遇到问题可能需要先将PKCS#1格式转换为PKCS#8即标准PUBLIC KEY格式。4. 第二种形式模数(n)与指数(e)——直击本质的原始数据在某些场景下特别是系统间接口传输、前端JavaScript加密或一些特定的SDK中你会直接拿到模数n和公钥指数e的字符串表示而不是一个完整的PEM。4.1 数据的常见样貌通常你会得到两个字符串模数 (n)一个很长的十六进制字符串例如“AABBCCDDEEFF...长达512个字符对应2048位。有时也可能是Base64编码的。指数 (e)通常是一个较短的十六进制字符串最常见的是“010001”即十进制的65537。也可能是“03”或“11”。这些数据可能出现在JSON接口的响应中{ publicKey: { modulus: AABBCCDDEEFF..., exponent: 010001 } }也可能出现在HTML页面的JavaScript变量里。4.2 从(n, e)构建标准公钥对象你的任务就是将这两个原始整数构造成你所用编程语言密码库能够识别的公钥对象。关键在于理解库需要的输入形式。Python (cryptography库) 示例Python的cryptography库需要n和e作为Python整数。from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend import base64 # 假设从接口获取的n是Base64编码e是010001 n_base64 0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw e_hex 010001 # 1. 解码并转换为整数 n_bytes base64.urlsafe_b64decode(n_base64 ) # 补足可能的等号 n_int int.from_bytes(n_bytes, byteorderbig) # 大端序 e_int int(e_hex, 16) # 将十六进制字符串转为整数 # 2. 构建公钥对象 public_numbers rsa.RSAPublicNumbers(e_int, n_int) public_key public_numbers.public_key(default_backend()) # 现在可以使用public_key了Node.js (crypto模块) 示例Node.js的crypto.createPublicKey方法不能直接从n和e构建需要先组装成PEM或JWK格式。组装成PEM更通用const crypto require(crypto); const asn1 require(asn1.js); // 需要安装 asn1.js 库 // 定义PKCS#1 RSA公钥的ASN.1结构 const RSAPublicKey asn1.define(RSAPublicKey, function() { this.seq().obj( this.key(modulus).int(), this.key(publicExponent).int() ); }); // 假设n和e是十六进制字符串 const nHex AABBCCDDEEFF...; const eHex 010001; // 转换为Buffer (大端序) const nBuffer Buffer.from(nHex, hex); const eBuffer Buffer.from(eHex, hex); // 转换为ASN.1 DER编码 const derBuffer RSAPublicKey.encode({ modulus: nBuffer, publicExponent: eBuffer }, der); // 将DER转换为PEM格式 const pemPublicKey -----BEGIN RSA PUBLIC KEY-----\n${ derBuffer.toString(base64).match(/.{1,64}/g).join(\n) }\n-----END RSA PUBLIC KEY-----; // 使用PEM创建公钥对象 const publicKey crypto.createPublicKey(pemPublicKey);Java (java.security) 示例Java使用RSAPublicKeySpec来构建。import java.math.BigInteger; import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.RSAPublicKeySpec; import java.util.Base64; public class BuildPubKeyFromNE { public static PublicKey buildPublicKey(String modulusBase64, String exponentHex) throws Exception { // 解码Base64的n byte[] nBytes Base64.getDecoder().decode(modulusBase64); BigInteger n new BigInteger(1, nBytes); // 正数大端序 // 解码十六进制的e BigInteger e new BigInteger(exponentHex, 16); // 创建密钥规范 RSAPublicKeySpec keySpec new RSAPublicKeySpec(n, e); // 生成公钥对象 KeyFactory keyFactory KeyFactory.getInstance(RSA); return keyFactory.generatePublic(keySpec); } }4.3 关键细节与常见坑点编码与字节序这是最大的坑。n和e的字符串表示需要先转换成字节数组(byte[]/Buffer)再转换成大整数。转换时必须明确编码字符串是十六进制(Hex)还是Base64Base64是标准格式还是URL安全格式可能缺少/用-_代替字节序密码学中几乎总是使用大端序即最高有效字节在前。int.from_bytes(..., big)和BigInteger(1, bytes)中的1都是为了确保正数和大端序。整数与字节数组的转换n是一个极大的整数直接将其十进制字符串形式转为整数可能可行但更常见的是提供其字节流的编码Hex/Base64。务必使用语言提供的密码学标准方法进行转换避免自己处理填充和符号位。指数(e)的值务必确认e的值。如果是010001没问题。如果是03要意识到它很小值为3虽然数学上有效但在某些安全严格的场景下可能不被接受。最好与提供方确认。5. 两种形式的互转与实践场景理解了各自的结构互转就有了理论基础。5.1 从PEM中提取(n, e)有时你需要从已有的PEM公钥中提取出原始的n和e比如为了在前端JavaScript中使用JSEncrypt这样的库进行加密。Python提取示例from cryptography.hazmat.primitives import serialization # 加载PEM公钥 public_key load_pem_public_key(...) # 接上文加载代码 # 获取公钥数字 public_numbers public_key.public_numbers() n public_numbers.n # 这是一个Python整数 e public_numbers.e # 这也是一个Python整数 # 转换为前端常用的Base64 URL编码无填充 import base64 n_bytes n.to_bytes((n.bit_length() 7) // 8, big) n_b64url base64.urlsafe_b64encode(n_bytes).decode(utf-8).rstrip() print(f模数n (Base64Url): {n_b64url}) print(f指数e (十进制): {e} (十六进制: {hex(e)}))5.2 从(n, e)生成PEM这个过程就是4.2节中构建公钥对象的延续最后一步是将公钥对象序列化为PEM格式。Python生成PEM示例# 接4.2节已有public_key对象 pem_bytes public_key.public_bytes( encodingserialization.Encoding.PEM, formatserialization.PublicFormat.SubjectPublicKeyInfo # 标准格式 ) print(pem_bytes.decode())5.3 典型应用场景分析使用PEM格式的场景SSL/TLS证书服务器证书中的公钥就是PEM格式。配置文件将公钥保存在应用的配置文件中如id_rsa.pub。OpenSSL命令行工具生成、查看、转换密钥对。大多数后端API验证直接在代码中加载PEM字符串或文件进行签名验证。使用(n, e)对的场景前端JavaScript加密浏览器环境无法直接解析PEM通常需要后端提供n和e或JWK格式。某些移动端SDK为了减小数据包体积或简化解析SDK文档可能直接给出n和e。跨平台二进制协议自定义的二进制通信协议中可能会直接传输n和e的字节流。密钥分发接口一些密钥管理服务(KMS)或身份提供商(IdP)的API会返回JSON格式的密钥信息里面包含n和e。6. 进阶PKCS#1与PKCS#8以及JWK格式在格式的海洋里还有两个概念容易让人混淆PKCS#1和PKCS#8。这主要影响PEM的标签。PKCS#1格式定义RSA密钥本身的ASN.1结构即我们之前看到的RSAPublicKey :: SEQUENCE { modulus, exponent }。对应的PEM标签是-----BEGIN RSA PUBLIC KEY-----。PKCS#8格式定义了一个更通用的、包含算法标识的私钥信息语法。但对于公钥我们通常说的是X.509 SubjectPublicKeyInfo格式它被PKCS#8引用用于公钥。这就是我们最常见的-----BEGIN PUBLIC KEY-----格式它外层是算法标识内层包裹着PKCS#1结构。简单来说BEGIN PUBLIC KEY(PKCS#8/X.509) 是BEGIN RSA PUBLIC KEY(PKCS#1) 外面再加了一层“包装”说明了“这是RSA算法的公钥”。现代库通常更推荐使用标准的PUBLIC KEY格式因为它更具通用性。此外在Web领域JSON Web Key (JWK)格式越来越流行。它本质上也是n和e的JSON化表示但增加了密钥类型(kty)、密钥ID(kid)等元信息。{ kty: RSA, n: 0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4..., e: AQAB, // 这是Base64Url编码的65537 (AQAB 0x010001) kid: my-key-2024 }JWK非常适用于RESTful API和前端应用因为它天生就是JSON易于解析和传输。许多现代的认证协议如OAuth 2.0、OIDC都使用JWK Set来发布公钥。7. 实战排坑那些年我踩过的格式坑回顾文章开头的故事以及多年的开发经验我总结了几条血泪教训标签头不匹配最经典的问题。代码期望BEGIN PUBLIC KEY但提供的是BEGIN RSA PUBLIC KEY或者反之。解决方案使用像Pythoncryptography这样能自动处理两者的库或者用OpenSSL命令转换openssl rsa -pubin -in rsapubkey.pem -RSAPublicKey_out -out pubkey.pem。Base64编码错误PEM中的Base64内容可能被意外修改如换行符被去掉、空格被添加。解决方案确保Base64块是完整的并且每行通常为64字符非强制但常见。可以用在线Base64解码器先验证是否能正常解码。(n, e)的编码误解对方说“这是n的Base64”但其实是Base64 URL编码且去掉了填充()。或者给的十六进制字符串前面带了0x。解决方案沟通时明确编码细节。处理时先尝试Base64标准解码如果失败尝试补上再解码或尝试URL安全解码。十六进制字符串去掉0x前缀。字节序错误自己构造(n, e)时将字节数组转换成整数时用了小端序。解决方案牢记密码学中几乎总是大端序。使用‘big’参数或对应的大端序构造函数。混合格式有些文档或代码示例中会给出类似{“kty”:”RSA”, “n”:”…”, “e”:”…”}的JWK却要求你当成PEM用。解决方案明确你拿到的是什么格式。如果是JWK就用支持JWK的库如Python的jose库来加载或者将其转换为PEM/(n, e)对。当你再遇到RSA公钥相关的问题时不妨先问自己三个问题1. 我拿到的是什么格式2. 我的库需要什么格式3. 从格式A到格式B正确的转换路径是什么理清这三个问题大部分难题都能迎刃而解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价