还原 SM2 压缩公钥的几种方法
写这篇文章的起因是朋友让我帮忙解决一个与 SM2 算法加密相关的问题。由于我对 SM2 算法并不熟悉,因此在解决问题的过程中走了很多弯路,花了很多时间去了解 SM2 算法以及如何通过代码还原压缩公钥。随着越来越多的系统采用国密算法,我们在与其对接时难免会遇到类似的问题。然而,关于这方面的资料在网上相对较少。因此,趁周末有空,我决定将还原压缩公钥的方法记录下来,希望对你有所帮助。
在介绍几种还原 SM2 压缩公钥的方法之前,让我们先了解一下什么是 SM2 压缩公钥。
文章会持续修订,转载请注明来源地址:https://her-cat.com/posts/2023/06/19/several-ways-to-decompress-sm2-compressed-public-key/
什么是 SM2 压缩公钥?
在 SM2 算法中,公钥的大小为 64 字节,算上前缀 04 的话就是 65 字节。公钥由椭圆曲线上的坐标点(x, y)组成,即每个坐标点都是 32 字节的大数。为了节省存储空间,通常会对公钥进行压缩后使用,也就是压缩公钥。
压缩公钥分别由前缀和坐标点 x 一共 33 字节组成,当坐标点 y 是偶数时,使用 02 作为前缀,否则使用 03 作为前缀。使用 16 进制字符串表示时,字符串长度为 66 个字符。
还原压缩公钥的原理:先通过压缩公钥的前缀,确定坐标点 y 是奇数还是偶数,然后根据椭圆曲线的公式计算得到完整的公钥。
没看懂?没关系,下面我介绍几种还原压缩公钥的方法,让你不需要知道原理也能还原压缩公钥。
第一种方法:使用在线工具
下面是我找到的两个比较好用的在线工具:
第一个网站,只要将压缩公钥粘贴到输入框,点击「还原公钥」按钮就可以得到完整的公钥,该工具输出的结果还需要去除其中的空格才能使用。
第二个网站,除了支持压缩公钥以外,还支持 HEX、PEM 格式的公钥,先将公钥粘贴到输入框,网站会自动将其转换成 PEM 格式(sm2p256v1),然后借助下面这段代码就能得到完整的公钥。
$pemStr = 'PEM 格式的公钥';
$pemStr = str_replace(['-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----', PHP_EOL], '', $pemStr);
$uncompressedPublicKey = substr(bin2hex(base64_decode($pemStr)), -128);
echo "未压缩公钥:" . $uncompressedPublicKey;
第二种方法:使用 Java 的 Bouncy Castle 类库
虽然在线工具非常便捷,并且也能够达到我们想要的目的,但还是缺乏一些安全性,因为我们不清楚这些在线工具是否会收集信息,所以最好还是使用本地运行的代码来还原压缩公钥。然后我开始在网上搜索还原压缩公钥的相关资料,但找了很久都没有找到。于是我修改了搜索词,最后,我在某个使用 Java 基于 Bouncy Castle 封装 SM2 工具类的文章中找到了一些思路,也就有了 Java 版还原压缩公钥的代码。
import cn.hutool.core.util.HexUtil;
import org.bouncycastle.asn1.gm.GMNamedCurves;
import org.bouncycastle.asn1.x9.X9ECParameters;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.math.ec.ECPoint;
import org.bouncycastle.util.encoders.Hex;
public class Main {
public static void main(String[] args) {
String compressedPublicKey = "02 或 03 开头的压缩公钥";
// 获取一条SM2曲线参数
X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
// 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N
ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
//提取公钥点
ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(compressedPublicKey));
// 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04
ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters);
String uncompressedPublicKey = HexUtil.encodeHexStr(publicKeyParameters.getQ().getEncoded(false));
System.out.println("未压缩公钥:" + uncompressedPublicKey);
}
}
第三种方法:使用 PHP 的 lpilp/guomi 包
实际上,一开始就只有上面 Java 版本的代码,在准备写这篇文章的时候,突然想到 PHP 应该也能实现才对,然后我又去研究了下怎么在 PHP 中实现还原压缩公钥。
在 PHP 中对于国密算法相关的操作,一般都是使用 lpilp/guomi 这个包,它实现了 SM2、SM3、SM4 国密算法,其中 SM2 算法是基于 mdanter/ecc 这个包实现的。虽然 lpilp/guomi 支持 SM2 算法相关的操作,但是对外提供的方法都只支持未压缩公钥,对于压缩公钥只能我们自己想办法。
于是,我开始研究它对外提供的这几个方法,最后在 RtSm2::verifySignOutKey 方法中找到了一些蛛丝马迹。
public function verifySignOutKey( $document, $sign, $publickeyFile, $userId = null ) {
...
// Parse signature
$sigSerializer = new DerSignatureSerializer();
$sig = $sigSerializer->parse( $sigData );
// Parse public key
$keyData = file_get_contents( $publickeyFile );
$derSerializer = new DerPublicKeySerializer( $adapter );
$pemSerializer = new PemPublicKeySerializer( $derSerializer );
$key = $pemSerializer->parse( $keyData );
$pubKeyX = $this->decHex( $key->getPoint()->getX() );
$pubKeyY = $this->decHex( $key->getPoint()->getY() );
$hash = $this->_doS3Hash( $document, $pubKeyX, $pubKeyY, $generator, $userId );
$signer = new Sm2Signer( $adapter );
return $signer->verify( $key, $sig, $hash );
}
上面这段代码中的 $pubKeyY 不就是 SM2 算法中公钥的另一个坐标点 y 的值吗?将 $pubKeyX 和 $pubKeyY 拼接在一起就可以得到完整的公钥。经过一顿调试并将上面的代码进行简化后,就有了 PHP 版本的实现。
use Mdanter\Ecc\Serializer\Point\CompressedPointSerializer;
use Mdanter\Ecc\Serializer\Point\UncompressedPointSerializer;
use Rtgm\ecc\RtEccFactory;
$adapter = RtEccFactory::getAdapter();
$curve = RtEccFactory::getSmCurves()->curveSm2();
$compressedPublicKey = '02 或 03 开头的压缩公钥';
$compressedPointSerializer = new CompressedPointSerializer($adapter);
$point = $compressedPointSerializer->unserialize($curve, $key);
$uncompressedPointSerializer = new UncompressedPointSerializer();
$uncompressedPublicKey = $uncompressedPointSerializer->serialize($point);
echo "未压缩公钥:" . $uncompressedPublicKey;
总结
在本文中,我向你介绍了三种还原压缩公钥的方法。首先是使用在线工具,它们可以直接将压缩公钥转换为完整的公钥,使用起来比较方便,但缺乏了安全性。其次是使用 Java 的 Bouncy Castle 类库,通过编写代码来还原压缩公钥,保证了安全性和隐私性。最后是使用 PHP 的 lpilp/guomi 包,其效果与 Java 版本的一致。在实际使用中,你可以根据自己的需求来选择合适的方法。