Skip to content

SM2国密加解密 #33

Description

@mirages

椭圆曲线公钥密码算法

SM2 是我国自主研发的 256 位椭圆曲线公钥密码算法(ECC),属于国密(商用密码)核心非对称算法,用于替代 RSA/ECDSA/ECDH,主打数字签名、密钥交换、公钥加密三大功能,安全强度等效 RSA-3072、速度远快于 RSA。

曲线方程

y^2=x^3+ax+b(modp)

曲线参数,都是 32 字节 长度(256位):

p=FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFFa=FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFCb=28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93n=FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123Gx=32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7Gy=BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0
  • p: 素数域的模
    • 公式里所有计算最后都需要 mod p
    • 决定了坐标 x,y 的取值范围 0 ~ p-1
  • a: 曲线系数
  • b: 曲线系数
  • n: 基点 G 的阶。表示:G 加自己 n 次会回到 “无穷远点”。
    • 限制了私钥 k 的范围:1 ≤ k ≤ n-2
  • G: G 是椭圆曲线上一个固定的点 (Gx, Gy)

简单来说:p, a, b 定义了曲线本身;(Gx, Gy) 定义起点 G;n 定义私钥能用多大。以上这些值都是在标准/规范中定义好的。

公钥计算

Q=k*G

注意:这里的 * 指的是椭圆曲线的点乘法。也就是从起点 G(Gx,Gy) 经过 k 次计算后得到的结果坐标Q(Qx,Qy) 仍然还在椭圆曲线上。这里的 k 就是私钥。

总结:椭圆曲线密码就是用椭圆曲线上的点做公钥和私钥。公钥可由私钥计算得出,但是不能通过公钥反推出私钥。

参考

SM2 加解密算法

SM2 加解密算法是基于国家密码管理局发布的《GM/T 0003.4-2012 椭圆曲线公钥密码算法 第 4 部分:公钥加密算法》标准。

椭圆曲线设计的目的是“密钥交换”,而不是“直接加密数据”
椭圆曲线设计的目的是“密钥交换”,而不是“直接加密数据”
椭圆曲线设计的目的是“密钥交换”,而不是“直接加密数据”

加密流程

  1. 生成随机数 k,计算临时公钥 C1 = k * G。由坐标 (x,y) 组成,也是 64(32 + 32) 字节长度
  2. 计算共享密钥 t
    1. 根据第一步中的随机数 k 和 公钥 Q 进行点乘,计算椭圆坐标点 S = k * Q。类似公钥计算规则
    2. 执行密钥派生函数 KDF: t = KDF(Sx||Sy, len(M))M 表示明文,生成和明文长度一样的密钥 t
  3. 计算密文 C2:使用上一步生成的共享密钥 t 对明文 M 进行异或操作,得到密文 C2
  4. 计算校验值 C3: 将椭圆坐标点 S 和 明文 M 的拼接串进行 hash 操作 C3 = SM3(Sx||M||Sy)
  5. 拼接最终密文C1||C2||C3SM2 有两种加密模式 C1C2C3C1C3C2

解密流程

  1. 解析密文为 C1C2C3
  2. 计算共享密钥 t':
    1. 根据 C1 和 私钥 d 计算椭圆坐标点 S' = d * C1 该点和加密时计算得到的 S 相同,因为 d * C1 = d * (k * G) = k * (d * G) = k * Q
    2. 同样执行密钥派生函数 KDF: t = KDF(Sx||Sy, len(C2)),由于 C2 长度和明文长度相同,这里生成的 t' 和加密时生成的 t 也相同
  3. 计算明文 M': 使用上一步生成的密钥 t' 对密文 C2 进行异或操作得到明文 M'
  4. 计算校验值 C3' : 将椭圆坐标点 S' 和 明文 M' 的拼接串进行 hash 操作 C3' = SM3(Sx'||M'||Sy')
  5. 比较 C3'C3:如果解密得到的 C3' 和接收到的 C3 相同,说明 明文 M' 有效,没有被篡改;否则解密结果就是无效的。

这也就解释了为什么说 椭圆曲线设计的目的是“密钥交换”,而不是“直接加密数据”,加密和解密过程中都能计算得到相同的密钥 t,加密端仅需要将生成的临时公钥给到解密端就i完成了 “密钥交换”,全程没有传输密钥,而实际加密过程是使用的 异或 这种对称的加密处理。

公钥压缩

通过 k * G 计算出来的公钥包含 x 坐标和 y 坐标的完整信息总共 64(32 + 32) 字节长度,但是某些场景需要占用空间更小的公钥数据格式,因此会对公钥信息进行压缩,因此在原本的坐标信息下在开头增加一个字节的标志位:

  • 04: 表示完整的公钥信息,总共 65(1 + 32 + 32) 字节
  • 03: 公钥压缩格式,y 坐标是奇数,总共 33(1 + 32) 字节
  • 02: 公钥压缩格式,y 坐标是偶数,总共 33(1 + 32) 字节

为什么能压缩?根据椭圆曲线方程 y^2=x^3 + ax + b (mod p) 可知,根据 x 就能计算出 y 的值,但是可以得出两个 y 值,因此使用 0203 标志位加以区分。

因此一个完整的 SM2 公钥信息是应该包括标志位信息的,在处理 SM2 公钥数据时,要根据对应的工具库的处理方式,特别是非压缩格式时,有没有特殊说明需要补充或者截取 04 标志位

密钥封装

通常在加解密工程中:d 值指的就是原始的 密钥 数据,而 q 值指的原始的 公钥 数据。为了让公私钥数据在不同的系统或语言中能够通用,通常会在*原始数据**之上封装一层格式,增加了算法类型等元数据信息,常见封装格式:

格式数据格式封装对象核心用途典型特征常见算法
X.509ASN.1公钥(仅公钥)公钥的标准化传输 / 存储Base64 编码,带-----BEGIN PUBLIC KEY-----SM2/RSA/ECDSA
PKCS#1ASN.1RSA 私钥(仅 RSA)RSA 私钥的传统封装Base64 编码,带-----BEGIN RSA PRIVATE KEY-----仅 RSA
PKCS#8ASN.1任意私钥(通用)私钥的通用标准化封装Base64 编码,带-----BEGIN PRIVATE KEY-----SM2/RSA/ECDSA

像后端 Hutool 这种通用工具类,生成的 密钥对 都是带格式封装的,为了通用性;而像前端 sm-crypto.js 这种专门针对 SM2 的加解密工具,它所使用的都是原始的密钥数据。因此,在使用不同的加解密工具时,需要注意数据格式的处理和转换。

后端工具

Hutool SmUtil 使用详解:

无参实例化 SM2 工具

import cn.hutool.crypto.SmUtil;
import cn.hutool.crypto.asymmetric.SM2;
// 初始化 SM2 实例,无参构造,会自动创建新的密钥对
SM2 sm2 = SmUtil.sm2();
// 设置加密模式:默认是 C1C3C2
sm2.setMode(SM2Engine.Mode.C1C3C2);
// 直接获取公钥和私钥信息
String privateKey = sm2.getPrivateKeyBase64();
String publicKey = sm2.getPublicKeyBase64();
// 获取原始密钥对信息
byte[] dBytes = sm2.getD();
byte[] qBytes = sm2.getQ(false); // false 表示无压缩公钥信息

有参实例化 SM2 工具

import cn.hutool.crypto.SmUtil;
import cn.hutool.crypto.asymmetric.SM2;
// 缓存中获取公钥和私钥信息
String privateKeyStr = "xxxxxxx";
String publicKeyStr = "xxxxxxx";
// 初始化 SM2 实例,有参构造,使用已有密钥数据
// 格式可以是 HexStr 或 Base64 形式的字符串
// 也可以单独传入一个,只用来进行加密或解密
SM2 sm2 = SmUtil.sm2(privateKeyStr, publicKeyStr);
// 设置加密模式:默认是 C1C3C2
sm2.setMode(SM2Engine.Mode.C1C3C2);
// 获取原始密钥对信息
byte[] dBytes = sm2.getD();
byte[] qBytes = sm2.getQ(false); // false 表示无压缩公钥信息

加密数据

String publicKeyStr = "xxxx"; // 公钥数据
String plainText = "xxxxxx"; // 待加密数据
// 初始化 SM2,加密端通常不具有私钥信息,因此私钥可以为空
SM2 sm2 = SmUtil.sm2(null, publicKeyStr);
// 设置加密模式:默认是 C1C3C2
sm2.setMode(SM2Engine.Mode.C1C3C2);
try {
byte[] plainBytes = plainText.getBytes(StandardCharsets.UTF_8);
byte[] cipherBytes = sm2.encrypt(bytes, KeyType.PublicKey);
String cipherText = Base64.encode(cipherBytes);
} catch (Exception e) {
// 加密失败
}

解密数据

String publicKeyStr = "xxxx"; // 公钥数据
String privateKeyStr = "xxxx"; // 私钥数据
String cipherText = "xxxxxx"; // 加密数据,假如 base64 格式
// 初始化 SM2
SM2 sm2 = SmUtil.sm2(privateKeyStr, publicKeyStr);
// 设置加密模式:默认是 C1C3C2
sm2.setMode(SM2Engine.Mode.C1C3C2);
try {
byte[] cipherBytes = Base64.decode(cipherText);
byte[] plainBytes = sm2.decrypt(cipherBytes, KeyType.PrivateKey);
String plainText = new String(plainBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
// 解密失败
}

注意:通过 sm2.getXXXKeyBase64() 获取到的是 base64 编码,且带有 X.509 或 PKCS#8 封装格式的,前端 sm-crypto.js 需要的是原始的 公钥数据(不带封装格式的 Hex 字符串),因此可以直接使用 String pubkey = HexUtil.encodeHexStr(sm2.getQ(false)); 获取原始公钥数据的 Hex 字符串。

前端工具

sm-crypto.js 使用详解:

生成密钥对

import{sm2}from'sm-crypto'constkeypair=sm2.generateKeyPairHex()constpublicKey=keypair.publicKey// 公钥,Hex 字符串constprivateKey=keypair.privateKey// 私钥,Hex 字符串

加密数据

import{sm2}from'sm-crypto'constcipherMode=1;// 加密模式:1 - C1C3C2,0 - C1C2C3,默认为1constpublicKey="xxxxx";// web 作为加密端,通常公钥是从后台获取的,不需要本地生成constplainText="xxxx";// 待加密数据// 注意这里加密生成的 C1 没有 04 前缀,如果给 Hutool 工具使用,最好手动添加 04,否则可能服务端解密失败consthexStr="04"+sm2.doEncrypt(plainText,publicKey,cipherMode);// 另外 base64 字符串比 Hex 字符串更节省空间,通常会进行转换constcipherText=hex2Base64(hexStr);// hex2Base64 是工具函数

解密数据

import{sm2}from'sm-crypto'// 如果需要服务端加密,客户端解密。则需要客户端生成密钥,// 客户端请求接口时需将 publicKey 放置在 header 中给到服务端// 服务端响应数据时,则使用客户端传递的 publicKey 进行加密// 客户端收到响应之后,再使用本地的 privateKey 进行解密constkeypair=sm2.generateKeyPairHex()constcipherMode=1;// 加密模式:1 - C1C3C2,0 - C1C2C3,默认为1constpublicKey=keypair.publicKey// 公钥,Hex 字符串。请求接口时需要给到后端constprivateKey=keypair.privateKey// 私钥,Hex 字符串constcipherText="xxxx";// 待解密数据,假设为 base64 字符串constcipherBytes=base64ToBytes(cipherText);// base64ToBytes 是 base64 转 Uint8Array 工具函数consthexStr=bytesToHex(cipherBytes);// bytesToHex 是 Uint8Array 转 hex 字符串工具// 解密服务端数据// 这里将 hexStr 进行了截取,去掉了头两个字符串,因为 sm-crypto.js 在解密时会自动添加 04 前缀// 而服务端 Hutool 工具生成的密文包含 04 前缀,因此这里需要手动截取掉constplainText=sm2.doDecrypt(hexStr.slice(2),privateKey,cipherMode)

服务端客户端加解密交互时需要注意以下两点:

  • 加密模式 cipherMode 要保持一致:都是 C1C3C2 或者 C1C2C3 模式。
  • C1 临时公钥格式处理:是否需要手动添加或截取开头的 04 前缀。
  • 公钥 publicKey 格式确认:是否包含 X.509 格式封装,如果各端公钥格式要求不一样,则需要手动转换。
  • 数据 base64 和 hex 字符串处理:确认各端 sm2 需要的数据格式,看是否需要进行转换。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    javajses5 es6springSpring, Spring Boot

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions