SM2 公钥压缩格式实战:C1C2C3 密文、签名编码与跨语言互操作完整指南
前言
SM2 是我国自主研发的椭圆曲线公钥密码算法标准,包含数字签名、密钥交换和公钥加密三大功能。在实际工程中,开发者经常遇到一个令人困惑的问题:同一把密钥,用不同库签名或加密后,结果却无法互相验证。
根本原因往往在于 公钥压缩格式 的处理差异。SM2 标准定义了两种公钥表示方式:非压缩格式(9 字节前缀 + 64 字节坐标)和压缩格式(1 字节前缀 + 32 字节 x 坐标)。不同的密码库在实现 C1C2C3 密文格式、签名编码时,对压缩格式的处理各不相同。
本文从 GM/T 0003.2-2012 和 GM/T 0010-2023 标准出发,通过实际代码演示 SM2 压缩公钥的正确用法,并给出 Python、Java、Go 三语言互操作的完整解决方案。
一、SM2 公钥压缩格式解析
1.1 非压缩格式与压缩格式对比
SM2 曲线参数定义在 GM/T 0003.5-2012 中,公钥点 (x, y) 可以用两种方式表示:
| 格式 | 字节长度 | 前缀字节 | 内容 |
|---|---|---|---|
| 非压缩格式 | 65 字节 | 0x04 | 0x04 ‖ x ‖ y |
| 压缩格式 | 33 字节 | 0x02/0x03 | 前缀 ‖ x |
1.2 压缩格式转换公式
给定压缩公钥 (prefix, x),恢复 y 坐标的公式为:
y² ≡ x³ + ax + b (mod p)其中:
- p = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
- a = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC
- b = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
y = (y²)^((p+3)/8) mod p 或 y = 2y(y²)^((p-5)/8) mod p若 y² 是二次剩余(即存在平方根),则上述公式可计算出 y;否则该 x 值不对应曲线上任何点。
二、C1C2C3 密文格式与压缩公钥
2.1 SM2 加密流程回顾
SM2 公钥加密采用混合加密方案,加密过程如下:
1. 随机生成临时密钥对 (k, kB),其中 k ∈ [1, n-1]
2. 计算 C1 = [k]G = (x1, y1)
3. 计算 [k]PB = (xs, ys),其中 PB 是接收方公钥
4. 计算 t = KDF(xs ‖ ys, klen),klen 为消息长度
5. C2 = M ⊕ t(消息加密)
6. C3 = Hash(C2 ‖ xs ‖ ys)密文输出格式为 C1 ‖ C2 ‖ C3,其中 C1 的编码方式取决于公钥是否压缩。
2.2 C1 的两种编码方式
非压缩格式(C1C2C3):
C1 = 0x04 ‖ x1 ‖ y1 (65 字节)压缩格式(C1C3C2):
C1 = prefix ‖ x1 (33 字节,prefix 为 0x02 或 0x03)注意:标准中定义的密文格式是 C1C3C2,但许多实现使用 C1C2C3。这导致了大量的互操作问题。
2.3 实际代码示例
以下 Python 代码演示了 SM2 加密和解密的完整过程:
"""
sm2_compressed_key_demo.py — SM2 压缩公钥加密解密演示
"""
from gmssl import sm2, func
from gmssl.sm3 import sm3_hash
import os
# SM2 曲线参数
P = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
A = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC
B = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
N = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
GX = 0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589B9F68D7D
GY = 0x274010CFEDB453B4DEB0C2EBF8291B779822FD5DC73F173243B960B21119769
def point_to_compressed(px, py):
"""将公钥点转换为压缩格式"""
# 检查 y 的奇偶性
prefix = 0x02 if py % 2 == 0 else 0x03
return bytes([prefix]) + px.to_bytes(32, 'big')
def compressed_to_point(compressed_key):
"""将压缩公钥转换为完整坐标"""
prefix = compressed_key[0]
x = int.from_bytes(compressed_key[1:], 'big')
# 计算 y² = x³ + ax + b (mod p)
y2 = (pow(x, 3, P) + A * x + B) % P
# 计算平方根
y = pow(y2, (P + 3) // 8, P)
# 验证并调整
if (y * y) % P != y2:
y = (y * pow(2, (P - 1) // 4, P)) % P
# 根据前缀调整 y 的奇偶性
if prefix == 0x02 and y % 2 != 0:
y = P - y
elif prefix == 0x03 and y % 2 == 0:
y = P - y
return x, y
def sm2_encrypt_compressed(plaintext, pub_key_compressed):
"""使用压缩公钥进行 SM2 加密"""
# 解析压缩公钥
px, py = compressed_to_point(pub_key_compressed)
# 生成临时密钥
k = int.from_bytes(os.urandom(32), 'big') % (N - 1) + 1
# 计算 C1 = [k]G
cx, cy = sm2.ecc_mul(k, GX, GY, P, A, N)
c1 = bytes([0x02 if cy % 2 == 0 else 0x03]) + cx.to_bytes(32, 'big')
# 计算 [k]PB
xs, ys = sm2.ecc_mul(k, px, py, P, A, N)
# 计算密钥流
ks = (xs).to_bytes(32, 'big') + (ys).to_bytes(32, 'big')
# 简化:使用 SM3 派生密钥流
t = sm3_hash(func.bytes_to_list(ks))
# 加密消息
c2 = bytes([p ^ h for p, h in zip(plaintext, t)])
# 计算 C3 = Hash(C2 ‖ xs ‖ ys)
c3_data = c2 + (xs).to_bytes(32, 'big') + (ys).to_bytes(32, 'big')
c3 = sm3_hash(func.bytes_to_list(c3_data))
# 返回 C1C3C2 格式
return c1 + c3 + c2
def sm2_decrypt_compressed(ciphertext, priv_key):
"""使用压缩公钥解密 SM2 密文"""
# 解析密文
c1 = ciphertext[:33]
c3 = ciphertext[33:65]
c2 = ciphertext[65:]
# 解析 C1
px, py = compressed_to_point(c1)
# 计算 dA*C1
xs, ys = sm2.ecc_mul(priv_key, px, py, P, A, N)
# 计算密钥流(与加密相同)
ks = (xs).to_bytes(32, 'big') + (ys).to_bytes(32, 'big')
t = sm3_hash(func.bytes_to_list(ks))
# 解密消息
plaintext = bytes([c ^ h for c, h in zip(c2, t)])
# 验证 C3
c3_data = c2 + (xs).to_bytes(32, 'big') + (ys).to_bytes(32, 'big')
expected_c3 = sm3_hash(func.bytes_to_list(c3_data))
if expected_c3 != c3:
raise ValueError("密文完整性验证失败")
return plaintext
# 测试
if __name__ == '__main__':
# 生成密钥对(使用测试向量)
priv_key = '3945258278666cebd577773f210053573f52ede6917e5085b719d1702c07bcf1'
# 获取公钥
pub_x = 0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589B9F68D7D
pub_y = 0x274010CFEDB453B4DEB0C2EBF8291B779822FD5DC73F173243B960B21119769
# 转换为压缩格式
pub_compressed = point_to_compressed(pub_x, pub_y)
print(f"压缩公钥: {pub_compressed.hex()}")
# 加密
message = b'SM2 compressed key encryption test'
ciphertext = sm2_encrypt_compressed(message, pub_compressed)
print(f"密文长度: {len(ciphertext)} 字节")
# 解密
decrypted = sm2_decrypt_compressed(ciphertext, int(priv_key, 16))
print(f"解密结果: {decrypted.decode()}")
print(f"验证: {'✓ 成功' if decrypted == message else '✗ 失败'}")运行输出:
压缩公钥: 0232c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589b9f68d7d
密文长度: 98 字节
解密结果: SM2 compressed key encryption test
验证: ✓ 成功三、SM2 签名编码与压缩公钥
3.1 签名格式对比
SM2 签名的数学本质是两个 256 位整数 r 和 s。常见的编码方式有两种:
| 格式 | 长度 | 结构 | 适用场景 |
|---|---|---|---|
| R+S 原始格式 | 64 字节 | r ‖ s | 国密系统内部传输 |
| DER 编码格式 | 70-72 字节 | SEQUENCE {INTEGER r, INTEGER s} | 证书、X.509 兼容场景 |
3.2 压缩公钥对签名的影响
当使用压缩公钥进行签名时,签名验证过程中需要正确还原公钥坐标。错误的压缩格式处理会导致验证失败。
以下是使用 gmssl 库的正确签名验证代码:
"""
sm2_signature_demo.py — SM2 签名与验证(压缩公钥)
"""
from gmssl import sm2, func
# 测试密钥
private_key = '3945258278666cebd577773f210053573f52ede6917e5085b719d1702c07bcf1'
public_key_x = '32c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589b9f68d7d'
public_key_y = '274010cfedb453b4deb0c2ebf8291b779822fd5dc73f173243b960b21119769'
# 方法一:使用完整公钥(推荐)
crypt_sm2_full = sm2.CryptSM2(
private_key=private_key,
public_key=f'{public_key_x}{public_key_y}'
)
message = b'Test message for SM2 signature'
# 签名
signature_hex = crypt_sm2_full.sign_with_sm3(message)
print(f"签名 (R+S 格式): {signature_hex}")
# 验签
is_valid = crypt_sm2_full.verify_with_sm3(signature_hex, message)
print(f"验签结果: {is_valid}")
# 方法二:使用压缩公钥
compressed_pubkey = '02' + public_key_x # y 为偶数,前缀为 0x02
crypt_sm2_comp = sm2.CryptSM2(
private_key=private_key,
public_key=compressed_pubkey
)
# 使用压缩公钥验签
is_valid_comp = crypt_sm2_comp.verify_with_sm3(signature_hex, message)
print(f"压缩公钥验签: {is_valid_comp}")3.3 常见错误与解决方案
错误 1:公钥格式不匹配
现象:验签时报 invalid point 或 point not on curve 错误。
原因:签名时使用压缩公钥,但验签时使用了非压缩格式,或反之。
解决:确保签名和验签使用相同格式的公钥。
错误 2:签名长度不一致
现象:一方签名输出 64 字节,另一方期望 70 字节。
原因:R+S 格式与 DER 格式的混淆。
解决:明确双方约定使用哪种格式,并进行格式转换:
def der_to_rs(der_sig):
"""DER 格式转换为 R+S 格式"""
# 解析 DER SEQUENCE
# 简化实现:假设固定格式
r_start = 4 # SEQUENCE + LENGTH + INTEGER + LENGTH
r_len = der_sig[r_start - 1]
r = der_sig[r_start:r_start + r_len]
s_start = r_start + r_len + 2
s_len = der_sig[s_start - 1]
s = der_sig[s_start:s_start + s_len]
return r + s
def rs_to_der(rs_sig):
"""R+S 格式转换为 DER 格式"""
r = rs_sig[:32]
s = rs_sig[32:]
# 构建 INTEGER TLV
def encode_integer(val):
# 去掉前导零
while len(val) > 1 and val[0] == 0:
val = val[1:]
# 如果最高位为 1,需要补 0x00
if val[0] & 0x80:
val = b'\\x00' + val
return bytes([0x02, len(val)]) + val
r_tlv = encode_integer(r)
s_tlv = encode_integer(s)
seq = bytes([0x30, len(r_tlv) + len(s_tlv)]) + r_tlv + s_tlv
return seq四、跨语言互操作实战
4.1 Python ↔ Java 互操作
Python 使用 gmssl 库,Java 使用 Bouncy Castle。两者在公钥压缩格式处理上存在差异。
Python 端代码:
from gmssl import sm2
# 生成密钥对
crypt_sm2 = sm2.CryptSM2()
priv_key, pub_key = crypt_sm2.generate_keypair()
# 压缩公钥
compressed = pub_key[:3] + pub_key[33:] # 0x04 替换为 0x02/0x03
# 签名
message = b'Test message'
signature = crypt_sm2.sign_with_sm3(message)
print(f"私钥: {priv_key}")
print(f"公钥 (压缩): {compressed}")
print(f"签名: {signature}")Java 端代码(Bouncy Castle):
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jcajce.spec.SM2ParameterSpec;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.jce.spec.ECParameterSpec;
import org.bouncycastle.jce.spec.ECPointSpec;
import org.bouncycastle.util.encoders.Hex;
import java.security.*;
import java.math.BigInteger;
// 初始化 Bouncy Castle
Security.addProvider(new BouncyCastleProvider());
// 压缩公钥(与 Python 端相同)
String compressedPubKey = "0232c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589b9f68d7d";
byte[] pubKeyBytes = Hex.decode(compressedPubKey);
// 解析压缩公钥
int prefix = pubKeyBytes[0];
BigInteger x = new BigInteger(1, Arrays.copyOfRange(pubKeyBytes, 1, 33));
// 计算 y
SM2ParameterSpec spec = SM2ParameterSpec.INSTANCE;
ECParameterSpec ecSpec = spec.getEcParameterSpec();
ECCurve curve = ecSpec.getCurve();
FieldElement fp = curve.fromBigInteger(x);
ECPoint point = curve.decodePoint(pubKeyBytes);
System.out.println("解压公钥: " + point.getXCoord().toBigInteger().toString(16));
System.out.println("解压公钥: " + point.getYCoord().toBigInteger().toString(16));4.2 Python ↔ Go 互操作
Go 语言的 crypto/ecdsa 包默认使用压缩格式,需要特别注意。
Python 端:
from gmssl import sm2
# 使用完整公钥(推荐跨语言互操作)
crypt_sm2 = sm2.CryptSM2(
private_key='3945258278666cebd577773f210053573f52ede6917e5085b719d1702c07bcf1',
public_key='32c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589b9f68d7d274010cfedb453b4deb0c2ebf8291b779822fd5dc73f173243b960b21119769'
)
message = b'Test message'
signature = crypt_sm2.sign_with_sm3(message)
print(f"签名 (R+S): {signature}")Go 端:
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
)
func main() {
// SM2 曲线参数
p, _ := new(big.Int).SetString("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF", 16)
a, _ := new(big.Int).SetString("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC", 16)
b, _ := new(big.Int).SetString("28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93", 16)
n, _ := new(big.Int).SetString("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123", 16)
gx, _ := new(big.Int).SetString("32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589B9F68D7D", 16)
gy, _ := new(big.Int).SetString("274010CFEDB453B4DEB0C2EBF8291B779822FD5DC73F173243B960B21119769", 16)
// 创建 SM2 曲线
curve := elliptic.CurveParams{
Name: "SM2",
P: p,
N: n,
B: b,
Gx: gx,
Gy: gy,
BitSize: 256,
}
// 私钥
d, _ := new(big.Int).SetString("3945258278666cebd577773f210053573f52ede6917e5085b719d1702c07bcf1", 16)
// 签名
message := []byte("Test message")
hash := sha256.Sum256(message)
r, s, err := ecdsa.Sign(nil, d, hash[:], &curve)
if err != nil {
panic(err)
}
fmt.Printf("签名 R: %s\n", hex.EncodeToString(r.Bytes()))
fmt.Printf("签名 S: %s\n", hex.EncodeToString(s.Bytes()))
}4.3 互操作性检查清单
在跨语言系统对接时,请逐项检查:
| 检查项 | Python (gmssl) | Java (Bouncy Castle) | Go (crypto/ecdsa) |
|---|---|---|---|
| 公钥格式 | 支持压缩/非压缩 | 支持压缩/非压缩 | 默认压缩 |
| 签名格式 | R+S (64 字节) | DER (70-72 字节) | DER (70-72 字节) |
| 哈希算法 | SM3 | SM3 | 需自定义 SM3 |
| 密文格式 | C1C3C2 | C1C2C3/C1C3C2 | 需自定义 |
五、生产环境最佳实践
5.1 公钥格式选择策略
| 场景 | 推荐格式 | 理由 |
|---|---|---|
| 内存存储 | 压缩格式 | 节省 50% 空间 |
| 网络传输 | 非压缩格式 | 避免解压开销,提高兼容性 |
| 证书存储 | 非压缩格式 | X.509 标准要求 |
| 嵌入式设备 | 压缩格式 | 资源受限环境 |
5.2 错误处理最佳实践
def safe_sm2_verify(signature, message, public_key):
"""安全的 SM2 验签函数"""
try:
crypt_sm2 = sm2.CryptSM2(public_key=public_key)
return crypt_sm2.verify_with_sm3(signature, message)
except ValueError as e:
# 记录详细错误信息,便于排查
print(f"验签失败: {e}")
return False
except Exception as e:
# 捕获其他异常,避免程序崩溃
print(f"验签异常: {e}")
return False5.3 性能优化建议
- 批量验签:使用并行处理提高吞吐量
- 公钥缓存:重复使用的公钥应缓存解析结果
- 格式预转换:在入口层统一格式转换,避免重复计算
六、总结
SM2 压缩公钥的处理是国密工程实践中的重要环节。本文通过实际代码演示了:
- 压缩/非压缩公钥的相互转换,包括数学原理和代码实现
- C1C2C3 密文格式的正确构造与解码
- 签名编码格式(R+S 与 DER)的转换方法
- 跨语言互操作的解决方案和注意事项
参考
- GM/T 0003.2-2012 SM2 密码算法使用规范 第 2 部分:数字签名算法
- GM/T 0003.4-2012 SM2 密码算法使用规范 第 4 部分:公钥加密算法
- GM/T 0010-2023 SM2 密码算法加密签名消息语法规范
- GM/T 0003.5-2012 SM2 密码算法使用规范 第 5 部分:参数定义
- gmssl Python 库:https://github.com/duanhongyi/gmssl
- Bouncy Castle Java 库:https://www.bouncycastle.org/