国密实战(一):SM2 密钥格式与 PFX 证书解析全指南
作者:林 | 系列:国密实战 | 适合读者:Java 后端/架构师
一、背景与问题
随着《密码法》的实施和信创(信息技术应用创新)要求的推进,越来越多的政府、央企、金融项目要求使用国密算法替代 RSA/ECDSA。SM2 作为国密体系中的非对称加密算法,对标 RSA-2048 和 ECDSA P-256,已广泛应用于数字证书、签名验签、密钥交换等场景。
然而,在实际项目中接入 SM2 时,第一个拦路虎往往并非算法本身,重点是,密钥格式和证书解析。不同厂商输出的密钥格式不一致(hex、DER、PEM),PFX 证书里封装的 SM2 密钥对解析方式与 RSA 也有差异,稍不注意就会踩坑。
本文将系统梳理 SM2 密钥格式,并给出从 PFX 证书中提取 SM2 公私钥的完整 Java 实现。
二、SM2 算法简介
SM2 是国家密码管理局发布的椭圆曲线公钥密码算法,标准编号 GM/T 0003,包含:
| 部分 | 内容 | 对标国际标准 |
|---|---|---|
| GM/T 0003.1 | 总则 | — |
| GM/T 0003.2 | 数字签名算法 | ECDSA |
| GM/T 0003.3 | 密钥交换协议 | ECDH |
| GM/T 0003.4 | 公钥加密算法 | ECIES |
| GM/T 0003.5 | 参数定义 | — |
SM2 使用的椭圆曲线参数为 sm2p256v1,其曲线方程为标准 Weierstrass 形式:y² = x³ + ax + b (mod p)。
其中参数为:
- p:256 位素数(定义有限域 Fp)
- a, b:曲线系数
- G:基点(生成元)
- n:基点 G 的阶(256 位素数)
密钥长度 256 位,安全性约等价于 RSA-2048。
SM2 vs RSA/ECDSA 参数对比
| 指标 | SM2 (256-bit) | RSA-2048 | ECDSA P-256 |
|---|---|---|---|
| 密钥长度 | 256 bit | 2048 bit | 256 bit |
| 签名长度 | 64 bytes | 256 bytes | 64 bytes |
| 安全强度 | 128 bit | 112 bit | 128 bit |
SM2 签名计算包含用户标识相关的 Z 值,调用双方必须统一用户标识与编码。性能与签名格式要在实际密码提供者、硬件和协议封装下测试。
三、SM2 密钥格式详解
3.1 私钥格式
SM2 私钥本质是一个 256 位整数 d,满足 1 ≤ d ≤ n-1。长度换算关系:256 bits = 32 bytes = 64 hex chars
常见表示方式:
① 64 字符 Hex 字符串(常见)
aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344有些密钥生成工具输出的 hex 可能不足 64 字符(省略了前导零)。在转为 BigInteger 时,必须补齐到 64 字符,否则密钥长度不对,后续所有操作都会失败。
// ❌ 错误写法:可能丢失前导零
BigInteger privKey = new BigInteger(hexKey, 16);
// ✅ 正确写法:补齐前导零到 64 字符
String padded = String.format("%064s", hexKey).replace(' ', '0');
BigInteger privKey = new BigInteger(padded, 16);② 32 字节原始二进制
直接存储私钥的大端序(Big-Endian)字节数组。
③ DER 编码(PKCS#8)
PFX/PEM 证书中通常采用 PKCS#8 格式封装私钥,结构为:
PrivateKeyInfo ::= SEQUENCE {
version Version,
privateKeyAlgorithm AlgorithmIdentifier, -- OID: 1.2.156.10197.1.301
privateKey OCTET STRING -- 内含 ECPrivateKey
}PKCS#8 DER 编码的私钥 Base64 后通常以 MIG2AgE 开头(SM2 算法标识)。
3.2 公钥格式
SM2 公钥是椭圆曲线上的一个点 P = (x, y),其中 x、y 各为 256 位整数。常见表示方式:
① 未压缩格式(Uncompressed)— 更常用
04 + x坐标(64字符) + y坐标(64字符)总长度 130 字符(hex)= 65 字节,以 04 开头:公钥长度 = 1 + 32 + 32 = 65 bytes = 130 hex chars
很多工具只输出 128 字符的公钥(省略了 04 前缀)。使用时需要手动拼上 04,否则 BouncyCastle 会报 Invalid point encoding 错误。
// 检查并补齐 04 前缀
if (!pubKeyHex.startsWith("04")) {
pubKeyHex = "04" + pubKeyHex;
}② 压缩格式(Compressed)
02 + x坐标 (当 y 为偶数)
03 + x坐标 (当 y 为奇数)总长度 66 字符(hex)= 33 字节。解压时需要通过椭圆曲线方程 y² = x³ + ax + b 反算 y。
③ DER 编码(SubjectPublicKeyInfo)
X.509 证书中公钥的标准封装格式:
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier, -- OID: 1.2.156.10197.1.301
subjectPublicKey BIT STRING -- 内含未压缩公钥点(65 字节)
}3.3 格式对照表
| 场景 | 私钥格式 | 公钥格式 |
|---|---|---|
| 业务接口对接 | 64 字符 hex | 130 字符 hex (04 开头) |
| PFX/P12 证书 | PKCS#8 DER | SubjectPublicKeyInfo DER |
| PEM 证书 | Base64(PKCS#8 DER) | Base64(SubjectPublicKeyInfo DER) |
| BouncyCastle Java 对象 | BCECPrivateKey | BCECPublicKey |
四、PFX 证书结构解析
PFX(PKCS#12)是一种二进制格式,用于打包证书和私钥,文件后缀通常是 .pfx 或 .p12。
4.1 PFX 证书内部结构
PFX (PKCS#12)
├── AuthenticatedSafe
│ ├── ContentInfo (加密的私钥包)
│ │ └── ShroudedKeyBag
│ │ ├── 私钥 (PKCS#8, 可能加密)
│ │ └── 属性 (friendlyName, localKeyId 等)
│ └── ContentInfo (证书包)
│ └── CertBag
│ └── X.509 证书 (含公钥)
└── MAC (完整性校验)关键点:
- 私钥被封装在
PKCS8ShroudedKeyBag中,使用密码加密保护 - 证书(含公钥)在
CertBag中 - 私钥和证书通过
localKeyId属性关联
4.2 SM2 PFX 的特殊性
国密 PFX 证书在解析时有以下特点:
- 算法 OID 不同:SM2 的 OID 为
1.2.156.10197.1.301 - 签名算法:证书签名使用 SM3withSM2(OID
1.2.156.10197.1.501) - 公钥封装:SubjectPublicKeyInfo 中的公钥为未压缩格式(65 字节)
- 私钥封装:PKCS#8 中的私钥为 32 字节原始值
4.3 ASN.1 DER 编码基础
ASN.1(Abstract Syntax Notation One)是描述数据结构的标准表示法,DER 是其二进制编码规则。SM2 相关的 ASN.1 结构中常用的类型:
| Tag (Hex) | 类型 | 说明 |
|---|---|---|
| 0x30 | SEQUENCE | 序列(容器) |
| 0x02 | INTEGER | 整数 |
| 0x04 | OCTET STRING | 字节串 |
| 0x06 | OBJECT IDENTIFIER | OID |
| 0x03 | BIT STRING | 位串 |
DER 编码的长度计算规则:如果长度 ≤ 127,用 1 字节表示;如果长度 > 127,用 1 + N 字节表示(N 为表示长度所需的下限字节数,首字节的高 bit 置 1,低 7 bit 表示 N)。
五、完整实现代码
5.1 Maven 依赖
- 推荐版本:
bcprov-jdk18on1.84+(支持 JDK 18+,向下兼容 JDK 8/11/17) jdk15on系列已在 1.71 后停止维护,请迁移到jdk18on- 如果项目仍使用 JDK 8,可以用
jdk15on1.70(末尾一个维护版本)
<dependencies>
<!-- BouncyCastle 国密支持(推荐 jdk18on) -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.84</version>
</dependency>
</dependencies>5.2 注册 BouncyCastle Provider
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.Security;
public class Sm2KeyUtils {
// 静态块中注册 BouncyCastle Provider(只需注册一次)
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
}建议在 Spring Boot 启动时统一注册,避免多处重复:
@Configuration
public class BouncyCastleConfig {
@PostConstruct
public void init() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
}5.3 从 PFX 证书提取 SM2 公私钥
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.ECParameterSpec;
import org.bouncycastle.jce.spec.ECPrivateKeySpec;
import org.bouncycastle.jce.spec.ECPublicKeySpec;
import org.bouncycastle.math.ec.ECPoint;
import java.io.FileInputStream;
import java.math.BigInteger;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
public class Sm2KeyExtractor {
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
/**
* 从 PFX 证书中提取 SM2 公钥和私钥
*
* @param pfxPath PFX/P12 证书文件路径
* @param password 证书密码
* @return KeyPair 包含 SM2 公私钥
*/
public static KeyPair extractKeyPair(String pfxPath, String password) throws Exception {
// 1. 加载 KeyStore(类型为 PKCS12,指定 BC Provider)
KeyStore keyStore = KeyStore.getInstance("PKCS12", "BC");
try (FileInputStream fis = new FileInputStream(pfxPath)) {
keyStore.load(fis, password.toCharArray());
}
// 2. 获取别名(通常只有一个)
String alias = keyStore.aliases().nextElement();
System.out.println("证书别名: " + alias);
// 3. 提取私钥
Key privateKey = keyStore.getKey(alias, password.toCharArray());
System.out.println("私钥算法: " + privateKey.getAlgorithm());
// 4. 提取证书和公钥
Certificate cert = keyStore.getCertificate(alias);
PublicKey publicKey = cert.getPublicKey();
System.out.println("公钥算法: " + publicKey.getAlgorithm());
// 5. 打印证书信息
if (cert instanceof X509Certificate) {
X509Certificate x509 = (X509Certificate) cert;
System.out.println("证书主题: " + x509.getSubjectDN());
System.out.println("证书颁发者: " + x509.getIssuerDN());
System.out.println("签名算法: " + x509.getSigAlgName());
System.out.println("有效期: " + x509.getNotBefore() + " ~ " + x509.getNotAfter());
}
return new KeyPair(publicKey, privateKey);
}
/**
* 获取 hex 格式的私钥(64 字符)
*
* @param keyPair 密钥对
* @return 64 字符 hex 私钥
*/
public static String getPrivateKeyHex(KeyPair keyPair) {
ECPrivateKey ecPrivateKey = (ECPrivateKey) keyPair.getPrivate();
BigInteger d = ecPrivateKey.getS();
// 补齐前导零,确保 64 字符
return String.format("%064x", d);
}
/**
* 获取 hex 格式的公钥(130 字符,04 开头)
*
* @param keyPair 密钥对
* @return 130 字符 hex 公钥(04 + x + y)
*/
public static String getPublicKeyHex(KeyPair keyPair) {
ECPublicKey ecPublicKey = (ECPublicKey) keyPair.getPublic();
ECPoint point = ecPublicKey.getW();
// x 和 y 各 32 字节,补齐前导零
String x = String.format("%064x", point.getAffineX());
String y = String.format("%064x", point.getAffineY());
return "04" + x + y;
}
/**
* 通过 hex 私钥构造 ECPrivateKey 对象
*
* @param hexKey 64 字符 hex 私钥
* @return ECPrivateKey 对象
*/
public static ECPrivateKey buildPrivateKey(String hexKey) {
// 补齐前导零到 64 字符
String padded = String.format("%064s", hexKey).replace(' ', '0');
BigInteger d = new BigInteger(padded, 16);
// 使用 sm2p256v1 曲线参数
ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
ECPrivateKeySpec privSpec = new ECPrivateKeySpec(d, ecSpec);
try {
KeyFactory kf = KeyFactory.getInstance("EC", "BC");
return (ECPrivateKey) kf.generatePrivate(privSpec);
} catch (Exception e) {
throw new RuntimeException("构建 SM2 私钥失败", e);
}
}
/**
* 通过 hex 公钥构造 ECPublicKey 对象
*
* @param hexKey 130 字符 hex 公钥(04 开头,可省略 04)
* @return ECPublicKey 对象
*/
public static ECPublicKey buildPublicKey(String hexKey) {
// 确保有 04 前缀(未压缩格式标识)
String fullHex = hexKey;
if (!fullHex.startsWith("04")) {
fullHex = "04" + fullHex;
}
ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("sm2p256v1");
// decodePoint 接受 byte[],将 hex 转为字节数组
ECPoint point = ecSpec.getCurve().decodePoint(hexToBytes(fullHex));
ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, ecSpec);
try {
KeyFactory kf = KeyFactory.getInstance("EC", "BC");
return (ECPublicKey) kf.generatePublic(pubSpec);
} catch (Exception e) {
throw new RuntimeException("构建 SM2 公钥失败", e);
}
}
/**
* hex 字符串转字节数组
*/
private static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
}5.4 测试代码
public class Sm2KeyExtractorTest {
public static void main(String[] args) throws Exception {
String pfxPath = "/path/to/your/sm2-cert.pfx";
String password = "your_password";
// 提取密钥对
KeyPair keyPair = Sm2KeyExtractor.extractKeyPair(pfxPath, password);
// 获取 hex 格式
String privKeyHex = Sm2KeyExtractor.getPrivateKeyHex(keyPair);
String pubKeyHex = Sm2KeyExtractor.getPublicKeyHex(keyPair);
System.out.println("\n=== 密钥信息 ===");
System.out.println("私钥 (hex, 64字符): " + privKeyHex);
System.out.println("私钥长度: " + privKeyHex.length());
System.out.println("公钥 (hex, 130字符): " + pubKeyHex);
System.out.println("公钥长度: " + pubKeyHex.length());
// 验证:从 hex 重建密钥对
ECPrivateKey rebuiltPriv = Sm2KeyExtractor.buildPrivateKey(privKeyHex);
ECPublicKey rebuiltPub = Sm2KeyExtractor.buildPublicKey(pubKeyHex);
System.out.println("\n=== 验证重建 ===");
System.out.println("私钥重建成功: " + (rebuiltPriv != null));
System.out.println("公钥重建成功: " + (rebuiltPub != null));
// 验证重建后的密钥与原始密钥一致
System.out.println("私钥一致性: " + privKeyHex.equals(
String.format("%064x", rebuiltPriv.getS())));
}
}六、SM2 密文格式:C1C3C2 vs C1C2C3
SM2 加密后的密文由三部分组成:
- C1:随机椭圆曲线点(临时公钥),65 字节(未压缩格式)
- C2:加密后的业务数据(与明文等长)
- C3:SM3 哈希值,32 字节
两种排列顺序:
| 格式 | 排列 | 说明 |
|---|---|---|
| C1C2C3 | C1 + C2 + C3 | 旧标准(GM/T 0003-2012 早期) |
| C1C3C2 | C1 + C3 + C2 | 新标准(推荐),ISO/IEC 18033-2 兼容 |
SM2 密文总长度(加密 16 字节密钥时):65(C1)+ 32(C3)+ 16(C2)= 113 bytes。
通用公式(明文长度 n 字节):密文长度 = 97 + n bytes。
如果加密方用 C1C3C2,解密方用 C1C2C3 解读,解密会失败(但不会报错,只是得到乱码)。前后端对接时务必确认格式统一。
BouncyCastle 默认使用 C1C3C2,这是推荐的格式。
6.1 DER 编码的 SM2 密文
在某些协议场景(如国密 TLS、S/MIME)中,SM2 密文会被封装为 ASN.1 DER 编码:
SM2Cipher ::= SEQUENCE {
XCoordinate INTEGER, -- C1 的 x 坐标
YCoordinate INTEGER, -- C1 的 y 坐标
HASH OCTET STRING (SIZE(32)), -- C3 (SM3 哈希)
CipherText OCTET STRING -- C2 (加密数据)
}对应的 DER 编码示意:
30 xx -- SEQUENCE (外层容器)
02 20 [32字节 x坐标] -- INTEGER X
02 20 [32字节 y坐标] -- INTEGER Y
04 20 [32字节 哈希] -- OCTET STRING C3
04 xx [N字节 密文] -- OCTET STRING C2七、踩坑记录与完整排查
坑 1:BigInteger 前导零丢失
现象:签名/加密结果与预期不一致,或报 Invalid key 异常。
原因:私钥 hex 以 0 开头时,BigInteger(hex, 16) 会丢弃前导零,导致密钥不足 32 字节。
// ❌ 错误:前导零丢失
BigInteger d = new BigInteger("0abbccdd...", 16); // 可能少于 32 字节
// ✅ 正确:始终补齐到 64 字符
String padded = String.format("%064s", hex).replace(' ', '0');
BigInteger d = new BigInteger(padded, 16);坑 2:公钥缺少 04 前缀
现象:Invalid point encoding 0x0a(或其他非 04/02/03 的值)
原因:某些工具输出 128 字符公钥(省略了 04 前缀),BouncyCastle 无法识别。
// ❌ 错误:缺少前缀
String pubHex = "aabb...ccdd"; // 128 字符
// ✅ 修复:补上 04 前缀
if (!pubHex.startsWith("04")) {
pubHex = "04" + pubHex; // 130 字符
}
ECPoint point = curve.decodePoint(Hex.decode(pubHex));坑 3:Invalid point encoding 0x30(完整排查)
现象:Invalid point encoding 0x30
排查步骤:
| 步骤 | 检查项 | 说明 |
|---|---|---|
| 1 | 确认输入类型 | 0x30 是 DER SEQUENCE 的标识,说明你传入的是 DER 编码数据,不是原始公钥 |
| 2 | 检查数据来源 | 可能把 DER 编码的密文当成了公钥,或把 DER 编码的证书内容当成了裸公钥 |
| 3 | 先做 ASN.1 解码 | 如果是 DER 密文,需要先解析提取 C1/C3/C2 |
| 4 | 检查 hex 长度 | 正常公钥应为 130 字符(含 04),不是 128 也不是更长 |
解决方案:DER 密文需要先进行 ASN.1 解码,提取出原始 SM2 密文后再处理。
坑 4:Provider 未注册
现象:java.security.NoSuchAlgorithmException: no such algorithm: EC for provider BC
解决方案:
// 确保在使用前注册
Security.addProvider(new BouncyCastleProvider());
// 验证注册成功
System.out.println(Security.getProvider("BC")); // 应输出版本信息坑 5:PFX 密码错误不报错
现象:KeyStore.load() 不报错,但后续 getKey() 返回 null 或报错。
原因:某些 PKCS12 实现在密码错误时不会在 load() 阶段抛异常。
解决方案:
KeyStore ks = KeyStore.getInstance("PKCS12", "BC");
ks.load(fis, password.toCharArray());
// load 后立即验证
String alias = ks.aliases().nextElement();
Key key = ks.getKey(alias, password.toCharArray());
if (key == null) {
throw new RuntimeException("密码错误或证书损坏");
}坑 6:BouncyCastle 版本冲突
现象:各种 NoSuchMethodError 或 ClassNotFoundException
原因:项目中同时存在 jdk15on 和 jdk18on 两个版本,或 BC 版本与其他库冲突。
排查命令:
# 检查依赖树中的 BC 版本
mvn dependency:tree | grep bouncycastle解决方案:统一使用 jdk18on,排除其他库传递的旧版本:
<dependency>
<groupId>com.some-library</groupId>
<artifactId>some-artifact</artifactId>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</exclusion>
</exclusions>
</dependency>八、安全注意事项
- 私钥存储:SM2 私钥应存储在 HSM(硬件安全模块)或加密的 KeyStore 中,禁止明文存储在配置文件或代码中
- 密钥传输:私钥传输必须通过安全通道(如 HTTPS + 双向认证)
- 密钥轮换:建议每 1-2 年轮换一次密钥对,证书到期前及时更新
- 随机数质量:密钥生成必须使用
SecureRandom,禁止使用Math.random()或new Random() - 日志脱敏:日志中打印密钥时只输出前 8 字符 +
...,或不打印
// 日志脱敏示例
String masked = privKeyHex.substring(0, 8) + "...";
log.info("使用私钥: {}", masked);九、总结与成熟实践
- 统一密钥格式:项目内部统一使用 hex 格式存储(私钥 64 字符、公钥 130 字符 04 开头),对外交互时按需转为 DER/PEM
- 前导零不可省略:私钥 hex 必须 64 字符,公钥 x/y 各 32 字节,不足时补齐前导零
- BouncyCastle 是首选库:Java 原生的
java.security对 SM2 支持有限,BouncyCastle 是更成熟的方案,推荐jdk18on1.84+ - 密文格式要约定:前后端、上下游统一使用 C1C3C2 格式(新标准推荐)
- Provider 注册时机:建议在应用启动时(如 Spring Boot 的
@PostConstruct)统一注册,避免重复注册 - BC 版本统一:避免
jdk15on和jdk18on混用,排除传递依赖中的旧版本
十、参考资料
- GM/T 0003-2012 SM2 椭圆曲线公钥密码算法
- GM/T 0006-2012 密码应用标识规范 — SM2 相关 OID 定义
- BouncyCastle 官方文档
- RFC 8998 - SM2 Encryption and Signature Algorithms
- ASN.1 编码入门