← 返回

国密实战(一):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-2048ECDSA P-256
密钥长度256 bit2048 bit256 bit
签名长度64 bytes256 bytes64 bytes
安全强度128 bit112 bit128 bit

SM2 签名计算包含用户标识相关的 Z 值,调用双方必须统一用户标识与编码。性能与签名格式要在实际密码提供者、硬件和协议封装下测试。


三、SM2 密钥格式详解

3.1 私钥格式

SM2 私钥本质是一个 256 位整数 d,满足 1 ≤ d ≤ n-1。长度换算关系:256 bits = 32 bytes = 64 hex chars

常见表示方式:

① 64 字符 Hex 字符串(常见)

aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344
坑点 1:前导零问题

有些密钥生成工具输出的 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

坑点 2:04 前缀必须保留

很多工具只输出 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 字符 hex130 字符 hex (04 开头)
PFX/P12 证书PKCS#8 DERSubjectPublicKeyInfo DER
PEM 证书Base64(PKCS#8 DER)Base64(SubjectPublicKeyInfo DER)
BouncyCastle Java 对象BCECPrivateKeyBCECPublicKey

四、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 证书在解析时有以下特点:

  1. 算法 OID 不同:SM2 的 OID 为 1.2.156.10197.1.301
  2. 签名算法:证书签名使用 SM3withSM2(OID 1.2.156.10197.1.501
  3. 公钥封装:SubjectPublicKeyInfo 中的公钥为未压缩格式(65 字节)
  4. 私钥封装:PKCS#8 中的私钥为 32 字节原始值

4.3 ASN.1 DER 编码基础

ASN.1(Abstract Syntax Notation One)是描述数据结构的标准表示法,DER 是其二进制编码规则。SM2 相关的 ASN.1 结构中常用的类型:

Tag (Hex)类型说明
0x30SEQUENCE序列(容器)
0x02INTEGER整数
0x04OCTET STRING字节串
0x06OBJECT IDENTIFIEROID
0x03BIT STRING位串

DER 编码的长度计算规则:如果长度 ≤ 127,用 1 字节表示;如果长度 > 127,用 1 + N 字节表示(N 为表示长度所需的下限字节数,首字节的高 bit 置 1,低 7 bit 表示 N)。


五、完整实现代码

5.1 Maven 依赖

Bouncy Castle 版本说明
  • 推荐版本bcprov-jdk18on 1.84+(支持 JDK 18+,向下兼容 JDK 8/11/17)
  • jdk15on 系列已在 1.71 后停止维护,请迁移到 jdk18on
  • 如果项目仍使用 JDK 8,可以用 jdk15on 1.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 中的注册方式

建议在 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 字节

两种排列顺序:

格式排列说明
C1C2C3C1 + C2 + C3旧标准(GM/T 0003-2012 早期)
C1C3C2C1 + C3 + C2新标准(推荐),ISO/IEC 18033-2 兼容

SM2 密文总长度(加密 16 字节密钥时):65(C1)+ 32(C3)+ 16(C2)= 113 bytes。

通用公式(明文长度 n 字节):密文长度 = 97 + n bytes。

坑点 3:格式不一致导致解密失败

如果加密方用 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 版本冲突

现象:各种 NoSuchMethodErrorClassNotFoundException

原因:项目中同时存在 jdk15onjdk18on 两个版本,或 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>

八、安全注意事项

  1. 私钥存储:SM2 私钥应存储在 HSM(硬件安全模块)或加密的 KeyStore 中,禁止明文存储在配置文件或代码中
  2. 密钥传输:私钥传输必须通过安全通道(如 HTTPS + 双向认证)
  3. 密钥轮换:建议每 1-2 年轮换一次密钥对,证书到期前及时更新
  4. 随机数质量:密钥生成必须使用 SecureRandom,禁止使用 Math.random()new Random()
  5. 日志脱敏:日志中打印密钥时只输出前 8 字符 + ...,或不打印
// 日志脱敏示例
String masked = privKeyHex.substring(0, 8) + "...";
log.info("使用私钥: {}", masked);

九、总结与成熟实践

  1. 统一密钥格式:项目内部统一使用 hex 格式存储(私钥 64 字符、公钥 130 字符 04 开头),对外交互时按需转为 DER/PEM
  2. 前导零不可省略:私钥 hex 必须 64 字符,公钥 x/y 各 32 字节,不足时补齐前导零
  3. BouncyCastle 是首选库:Java 原生的 java.security 对 SM2 支持有限,BouncyCastle 是更成熟的方案,推荐 jdk18on 1.84+
  4. 密文格式要约定:前后端、上下游统一使用 C1C3C2 格式(新标准推荐)
  5. Provider 注册时机:建议在应用启动时(如 Spring Boot 的 @PostConstruct)统一注册,避免重复注册
  6. BC 版本统一:避免 jdk15onjdk18on 混用,排除传递依赖中的旧版本

十、参考资料


下一篇预告国密实战(二):SM2 签名验签前后端对接指南(Java + TypeScript)