- 《GB/T 32918.1-2016 信息安全技术 SM2椭圆曲线公钥密码算法 第1部分:总则》
- 《GB/T 32918.2-2016 信息安全技术 SM2椭圆曲线公钥密码算法 第2部分:数字签名算法》
- 《GB/T 32918.3-2016 信息安全技术 SM2椭圆曲线公钥密码算法 第3部分:密钥交换协议》
- 《GB/T 32918.4-2016 信息安全技术 SM2椭圆曲线公钥密码算法 第4部分:公钥加密算法》
- 《GB/T 32918.5-2017 信息安全技术 SM2椭圆曲线公钥密码算法 第5部分:参数定义》
- 《GB/T 35276-2017 信息安全技术 SM2密码算法使用规范》
- 《GB/T 33560-2017 信息安全技术 密码应用标识规范》
- 《GB/T 35275-2017 信息安全技术 SM2密码算法加密签名消息语法规范》(对应PKCS#7)
您可以从国家标准全文公开系统在线阅读这些标准。
SM2既然是椭圆曲线公钥密码算法,它就和NIST P系列椭圆曲线公钥密码算法类似,特别是P-256。NIST P 系列椭圆曲线公钥密码算法主要用于数字签名和密钥交换,NIST没有定义基于椭圆曲线的公钥加密算法标准,SEC 1: Elliptic Curve Cryptography第五章定义了“Elliptic Curve Integrated Encryption Scheme (ECIES)”,不过应用不广。SM2公钥加密算法与其相似,只是MAC不同。感兴趣的同学可以进一步对比一下:
SM2 | SEC 1 |
---|---|
数字签名算法 | ECDSA |
密钥交换协议 | ECMQV |
公钥加密算法 | ECIES |
注:最新的阿里KMS支持ECIES,难道客户有这个需求? ECIES_DH_SHA_1_XOR_HMAC:遵循SEC 1: Elliptic Curve Cryptography, Version 2.0标准,密钥协商算法采用ECDH,密钥派生算法采用 KDF2 with SHA-1,MAC算法采用HMAC-SHA-1,对称加密算法采用XOR。
业界对RSA非对称加密的安全性担忧与日俱增:
- The Marvin Attack
- CVE-2023-45287 Detail
- Vulnerability Report: GO-2023-2375
- Seriously, stop using RSA
SM2公私钥对的话,要么是自己产生,要么是别的系统产生后通过某种方式传输给您的。
您可以通过调用sm2.GenerateKey
方法产生SM2公私钥对,SM2的私钥通过组合方式扩展了ecdsa.PrivateKey
,用于定义一些SM2特定的方法:
// PrivateKey represents an ECDSA SM2 private key.
// It implemented both crypto.Decrypter and crypto.Signer interfaces.
type PrivateKey struct {
ecdsa.PrivateKey
...
}
SM2的公钥类型沿用了ecdsa.PublicKey
结构。注意:Go从v1.20开始,ecdsa.PublicKey
增加了func (k *PublicKey) ECDH() (*ecdh.PublicKey, error)
方法,这个方法对SM2的公钥不适用,SM2公钥请使用func PublicKeyToECDH(k *ecdsa.PublicKey) (*ecdh.PublicKey, error)
。
通常情况下,公钥是通过PEM编码的文本传输的,您可以通过两步获得公钥:
- 获得PEM中的block
- 解析block中的公钥
func getPublicKey(pemContent []byte) (any, error) {
block, _ := pem.Decode(pemContent)
if block == nil {
return nil, errors.New("Failed to parse PEM block")
}
return smx509.ParsePKIXPublicKey(block.Bytes)
}
由于smx509.ParsePKIXPublicKey
返回any类型,您需要通过pub, ok := publicKey.(*ecdsa.PublicKey)
转型。
有些应用可能会直接存储公钥的曲线点X, Y 坐标值,这时候,您可以通过以下类似方法构造公钥(假设输入的是点的非压缩序列化字节数组):
func ExampleNewPublicKey() {
keypoints, _ := hex.DecodeString("048356e642a40ebd18d29ba3532fbd9f3bbee8f027c3f6f39a5ba2f870369f9988981f5efe55d1c5cdf6c0ef2b070847a14f7fdf4272a8df09c442f3058af94ba1")
pub, err := sm2.NewPublicKey(keypoints)
if err != nil {
log.Fatalf("fail to new public key %v", err)
}
fmt.Printf("%x\n", elliptic.Marshal(sm2.P256(), pub.X, pub.Y))
// Output: 048356e642a40ebd18d29ba3532fbd9f3bbee8f027c3f6f39a5ba2f870369f9988981f5efe55d1c5cdf6c0ef2b070847a14f7fdf4272a8df09c442f3058af94ba1
}
当然,您也可以使用ecdh包下的方法ecdh.P256().NewPublicKey
来构造,目前只支持非压缩方式。
私钥的封装格式主要有以下几种,相关讨论:
- RFC 5915 / SEC1 - http://www.secg.org/sec1-v2.pdf
- PKCS#12
- PKCS#8
- PKCS#7(《GB/T 35275-2017 信息安全技术 SM2密码算法加密签名消息语法规范》)
- CFCA自定义封装
- 《GB/T 35276-2017 信息安全技术 SM2密码算法使用规范》
(存在于智能密码钥匙中,符合《GB/T 35291-2017 信息安全技术 智能密码钥匙应用接口规范》的,不在这里说明。)
所以,当您拿到一个密钥文件,您需要知道它的封装格式,然后选用合适的方法。PEM编码的密钥文本通常第一行会有相关信息。如果您得到的是一个ASN.1编码,那可能需要通过ASN.1结构和一些其中的OID来判断了。私钥信息是非常关键的信息,通常密钥文件被加密保护。可能是标准落后于应用的原因,目前这一块的互操作性可能差一点。
封装格式 | 解析方法 |
---|---|
RFC 5915 / SEC1 | smx509.ParseSM2PrivateKey |
PKCS#12 | 使用 github.com/emmansun/go-pkcs12 解析 |
PKCS#8 | smx509.ParsePKCS8PrivateKey 可以处理未加密的;pkcs8.ParsePKCS8PrivateKeySM2 可以处理未加密的,也可以处理加密的 |
PKCS#7 | Cryptographic Message Syntax, 可以参考github.com/emmansun/pkcs7/sign_enveloped_test.go中的TestParseSignedEvnvelopedData ,测试数据来自 https://www.gmcert.org/ |
CFCA自定义封装 | 顾名思义,这个封装是CFCA特定的,修改自PKCS#12,使用cfca.ParseSM2 方法来解析 |
《GB/T 35276-2017 信息安全技术 SM2密码算法使用规范》 | 这个规范还比较新,使用sm2.ParseEnvelopedPrivateKey 解析。典型的应用场景是CA机构返回CSRResponse, 里面包含签名证书、CA生成的SM2加密私钥以及相应的SM2加密证书,其中SM2加密私钥就用该规范定义的方式加密封装。请参考《GM/T 0092-2020 基于SM2算法的证书申请语法规范》 |
有些系统可能会直接存储、得到私钥的字节数组,那么您可以使用如下方法来构造私钥:
func ExampleNewPrivateKey() {
keyBytes, _ := hex.DecodeString("6c5a0a0b2eed3cbec3e4f1252bfe0e28c504a1c6bf1999eebb0af9ef0f8e6c85")
priv, err := sm2.NewPrivateKey(keyBytes)
if err != nil {
log.Fatalf("fail to new private key %v", err)
}
fmt.Printf("%x\n", priv.D.Bytes())
// Output: 6c5a0a0b2eed3cbec3e4f1252bfe0e28c504a1c6bf1999eebb0af9ef0f8e6c85
}
func ExampleNewPrivateKeyFromInt() {
key := big.NewInt(0x123456)
priv, err := sm2.NewPrivateKeyFromInt(key)
if err != nil {
log.Fatalf("fail to new private key %v", err)
}
fmt.Printf("%x\n", priv.D.Bytes())
// Output: 123456
}
当然,你也可以使用ecdh包的方法ecdh.P256().NewPrivateKey
来构造私钥,您要确保输入的字节数组是256位(32字节)的,如果不是,请先自行处理。
这个规范就是RFC8018 PKCS#5 国密定制版,其中PBES/PBKDF/PBMAC使用了不同的OID,但是这些OID似乎没有注册过。而且表A.1 中id-hmacWithSM3的OID为没有注册过的1.2.156.10197.1.401.3.1,和我们常用的1.2.156.10197.1.401.2不一致,也与该文档本身附录C不一致。不知道哪个产品遵从了这个行业规范。
对象标识符OID | 对象标识符定义 |
---|---|
1.2.156.10197.6.1.4.1.5 | 基于口令的密钥派生规范 |
1.2.156.10197.6.1.4.1.5.1 | 基于口令的密钥派生函数 PBKDF (其实就是PBKDF2) |
1.2.156.10197.6.1.4.1.5.2 | 基于口令的加密方案PBES (其实就是PBES2) |
1.2.156.10197.6.1.4.1.5.3 | 基于口令的消息鉴别码PBMAC |
规范中让人困惑的地方:
- 附录 A.2 伪随机函数 引入了这个新的id-hmacWithSM3的OID1.2.156.10197.1.401.3.1
- 附录 A.4 基础消息鉴别方案,给出的实例片段中的OID为1.2.156.10197.1.401,让人怀疑这个实例抄的是PKCS12-MAC,而不是PBMAC1。
- 附录 B.2 PBES结构,PBES-Encs竟然给出了pbeWithSM3AndSM4-CBC OJBECTIDENTIFIER ::= {1.2.156.10197.6.1.4.1.12.1.1},难不成又要抄PBES1?
- 附录 C. ASN.1 结构定义,id-hmacWithSM3的OID又是1.2.156.10197.1.401.2。
您可以直接使用sm2私钥的签名方法Sign
:
// This is a reference method to force SM2 standard with SDK [crypto.Signer].
func ExamplePrivateKey_Sign_forceSM2() {
toSign := []byte("ShangMi SM2 Sign Standard")
// real private key should be from secret storage
privKey, _ := hex.DecodeString("6c5a0a0b2eed3cbec3e4f1252bfe0e28c504a1c6bf1999eebb0af9ef0f8e6c85")
testkey, err := sm2.NewPrivateKey(privKey)
if err != nil {
log.Fatalf("fail to new private key %v", err)
}
// force SM2 sign standard and use default UID
sig, err := testkey.Sign(rand.Reader, toSign, sm2.DefaultSM2SignerOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error from sign: %s\n", err)
return
}
// Since sign is a randomized function, signature will be
// different each time.
fmt.Printf("%x\n", sig)
}
我们通过SignerOpts
参数来指示toSign
已经是hash值,还是需要进行处理的原始信息。通常情况下,toSign
传入原始信息、SignerOpts
传入sm2.DefaultSM2SignerOpts
。如果将来标准支持自定义的uid,那么您可以通过调用sm2.NewSM2SignerOption
来构造一个自定义的SignerOpts
。
当然,您也可以通过调用SM2私钥的SignWithSM2
方法,区别在于,Sign
方法是crypto.Singer
接口中定义的方法,而SignWithSM2
方法是sm2.Signer
接口中定义的方法。
您可以使用sm2.VerifyASN1WithSM2
来校验SM2签名:
func ExampleVerifyASN1WithSM2() {
// real public key should be from cert or public key pem file
keypoints, _ := hex.DecodeString("048356e642a40ebd18d29ba3532fbd9f3bbee8f027c3f6f39a5ba2f870369f9988981f5efe55d1c5cdf6c0ef2b070847a14f7fdf4272a8df09c442f3058af94ba1")
testkey, err := sm2.NewPublicKey(keypoints)
if err != nil {
log.Fatalf("fail to new public key %v", err)
}
toSign := []byte("ShangMi SM2 Sign Standard")
signature, _ := hex.DecodeString("304402205b3a799bd94c9063120d7286769220af6b0fa127009af3e873c0e8742edc5f890220097968a4c8b040fd548d1456b33f470cabd8456bfea53e8a828f92f6d4bdcd77")
ok := sm2.VerifyASN1WithSM2(testkey, nil, toSign, signature)
fmt.Printf("%v\n", ok)
// Output: true
}
这里有两个实现,一个是传统实现,位于sm2包中;另外一个参考最新go语言的实现在ecdh包中。在这里不详细介绍使用方法,一般只有tls/tlcp才会用到,普通应用通常不会涉及这一块,感兴趣的话可以参考github.com/Trisia/gotlcp中的应用。
请牢记,非对称加密算法通常不用于加密大量数据,而是用来加密对称加密密钥,我们在tlcp以及信封加密机制中能找到这种用法。
SM2公钥加密算法支持的密文编码格式有两种:
- 简单串接方式: C1C3C2,曾经老的标准为 C1C2C3
- ASN.1格式
SM2公钥加密示例:
func ExampleEncryptASN1() {
// real public key should be from cert or public key pem file
keypoints, _ := hex.DecodeString("048356e642a40ebd18d29ba3532fbd9f3bbee8f027c3f6f39a5ba2f870369f9988981f5efe55d1c5cdf6c0ef2b070847a14f7fdf4272a8df09c442f3058af94ba1")
testkey, err := sm2.NewPublicKey(keypoints)
if err != nil {
log.Fatalf("fail to new public key %v", err)
}
secretMessage := []byte("send reinforcements, we're going to advance")
// crypto/rand.Reader is a good source of entropy for randomizing the
// encryption function.
rng := rand.Reader
ciphertext, err := sm2.EncryptASN1(rng, testkey, secretMessage)
if err != nil {
fmt.Fprintf(os.Stderr, "Error from encryption: %s\n", err)
return
}
// Since encryption is a randomized function, ciphertext will be
// different each time.
fmt.Printf("Ciphertext: %x\n", ciphertext)
}
如果您需要普通拼接编码输出,您可以调用sm2.Encrypt
方法,其中EncrypterOpts
类型参数可以传入nil,表示默认C1C3C2。
sm2包也提供了辅助方法用于密文输出编码格式转换:您可以通过sm2.ASN1Ciphertext2Plain
方法把ASN.1密文转换为简单拼接输出;反过来,您也可以通过sm2.PlainCiphertext2ASN1
将简单拼接密文输出转换为ASN.1密文。你还可以通过sm2.AdjustCiphertextSplicingOrder
方法来改变串接顺序。
SM2公钥加密算法解密示例:
func ExamplePrivateKey_Decrypt() {
ciphertext, _ := hex.DecodeString("308194022100bd31001ce8d39a4a0119ff96d71334cd12d8b75bbc780f5bfc6e1efab535e85a02201839c075ff8bf761dcbe185c9750816410517001d6a130f6ab97fb23337cce150420ea82bd58d6a5394eb468a769ab48b6a26870ca075377eb06663780c920ea5ee0042be22abcf48e56ae9d29ac770d9de0d6b7094a874a2f8d26c26e0b1daaf4ff50a484b88163d04785b04585bb")
// real private key should be from secret storage
privKey, _ := hex.DecodeString("6c5a0a0b2eed3cbec3e4f1252bfe0e28c504a1c6bf1999eebb0af9ef0f8e6c85")
testkey, err := sm2.NewPrivateKey(privKey)
if err != nil {
log.Fatalf("fail to new private key %v", err)
}
plaintext, err := testkey.Decrypt(nil, ciphertext, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error from decryption: %s\n", err)
return
}
fmt.Printf("Plaintext: %s\n", string(plaintext))
// Output: Plaintext: send reinforcements, we're going to advance
}
这个SM2私钥的解密方法Decrypt
,通常情况下,对crypto.DecrypterOpts
类型参数,您只需传入nil,系统会自己检测输入密文是ASN.1还是普通拼接,但是,如果密文是老旧的C1||C2||C3拼接,请传入相应的crypto.DecrypterOpts
类型参数,或者您可以先通过上面介绍的辅助函数转换一下。
具体API文档请参考:API Document
目前有据可查的是,国家密码管理局2010版SM2标准还是用C1C2C3格式,到了2012年标准就改用了C1C3C2,并延续至今。
其实C1C2C3是符合《SEC 1: Elliptic Curve Cryptography》(May 21, 2009 Version 2.0)Elliptic Curve Integrated Encryption Scheme 5.1.3中的密文输出描述:9. Output C = (
我没有找到到哪个文档写有固定64字节转换的。从国家密码管理局2010年版的《SM2椭圆曲线公钥密码算法》,到2012年的GM/T 0003-2012 SM2椭圆曲线公钥密码算法,再到GB/T 32918-2016 信息安全技术 SM2椭圆曲线公钥密码算法,都在第一部分第四章的“点到字节串的转换”有详细说明。这也符合《SEC 1: Elliptic Curve Cryptography》(May 21, 2009 Version 2.0)中2.3.3 Elliptic-Curve-Point-to-Octet-String Conversion的描述。
问: Why some crypto implementations of EC point to octet string conversion without form indicator?
答: In Elliptic Curve Cryptography (ECC), the conversion of an elliptic curve point to an octet string can be done in several ways, typically either in compressed or uncompressed form. The form indicator (also known as the prefix) is used to denote whether the point is represented in its compressed or uncompressed form.
However, some implementations might choose to omit the form indicator for a few reasons:
-
Simplicity: Omitting the form indicator simplifies the implementation, as there's no need to handle different forms of point representation.
-
Assumed Knowledge: If it's known in advance that all points will be in a certain form (e.g., all points are always uncompressed), the form indicator might be deemed unnecessary.
-
Compatibility: Not all systems or libraries support both forms of point representation. To ensure maximum compatibility, an implementation might choose to always use one form and thus omit the form indicator.
-
Security: In some cases, to prevent certain types of attacks that rely on the form of the point, the form indicator might be omitted.
Please note that these are general reasons and the specific reason can vary depending on the exact context and the specific cryptographic protocol or system in use.
问:Won't this cause interoperability issues?
答: Yes, you're correct. Omitting the form indicator in the conversion of an elliptic curve point to an octet string can indeed cause interoperability issues.
When systems need to interoperate, they must agree on a common format for data exchange. If one system encodes elliptic curve points without a form indicator and another system expects the form indicator to be present, they won't be able to correctly interpret each other's data.
This is why standards like those defined by the SEC (Standards for Efficient Cryptography) specify the use of a form indicator. For example, the SEC 1 standard specifies that the form indicator should be 0x04 for uncompressed points and 0x02 or 0x03 for compressed points.
So, while omitting the form indicator might simplify the implementation or provide other benefits within a closed system, it can hinder interoperability with other systems. It's generally best to follow established standards when encoding and decoding cryptographic data to ensure maximum compatibility.
从v0.27.0开始,对大数据量的加解密做了优化处理,尤其是KDF并行计算。详情请参考SM2加解密性能。
国内云服务商的KMS服务大都提供SM2密钥,我们一般调用其API进行签名和解密,而验签和加密操作,一般在本地用公钥即可完成。不过需要注意的是,KMS提供的签名通常需要您在本地进行hash操作,而sm2签名的hash又比较特殊,下面示例供参考(自版本v0.24.0开始,您可以直接使用函数sm2.CalculateSM2Hash
):
func calculateSM2Hash(pub *ecdsa.PublicKey, data, uid []byte) ([]byte, error) {
if len(uid) == 0 {
uid = defaultUID
}
za, err := sm2.CalculateZA(pub, uid)
if err != nil {
return nil, err
}
md := sm3.New()
md.Write(za)
md.Write(data)
return md.Sum(nil), nil
}
公钥加密就没啥特殊,只要确保输出密文的编码格式和KMS一致即可。
SM2的一些扩展应用,譬如从签名中恢复公钥、半同态加密、环签名等,大多尚处于POC状态,也无相关标准。其它扩展应用(但凡椭圆曲线公钥密码算法能用到的场合),包括但不限于:
- 确定性签名
- 可验证随机函数ECVRF
- 盲签名
- 群签名
- 门限签名
- Pederson承诺
ECDSA 签名由两个数字(整数)组成:r 和 s。以太坊还引入了额外的变量 v(恢复标识符)。签名可以表示成 {r, s, v}。SM2 签名也由两个数字(整数)组成:r 和 s。签名算法中都只取随机点的X坐标,并对N取模,所以只有签名r和s的情况下,可以恢复出多个公钥。
// RecoverPublicKeysFromSM2Signature recovers two or four SM2 public keys from a given signature and hash.
// It takes the hash and signature as input and returns the recovered public keys as []*ecdsa.PublicKey.
// If the signature or hash is invalid, it returns an error.
// The function follows the SM2 algorithm to recover the public keys.
func RecoverPublicKeysFromSM2Signature(hash, sig []byte) ([]*ecdsa.PublicKey, error)
返回的结果:
- 公钥0 - Rx = (r - e) mod N; Ry是偶数(compressFlag = 2)
- 公钥1 - Rx = (r - e) mod N; Ry是奇数(compressFlag = 3)
- 公钥2 - Rx = ((r - e) mod N) + N; Ry是偶数(compressFlag = 2)
- 公钥3 - Rx = ((r - e) mod N) + N; Ry是奇数(compressFlag = 3)
Rx, Ry代表随机点R的X,Y坐标值。绝大多数情况下,只会返回两个公钥,后两者只有当(r - e) mod N的值小于P-1-N时才可能。
EC-ElGamal with SM2的半同态加密(Partially Homomorphic Encryption, PHE), 支持uint32 或者 int32类型。Partially Homomorphic Encryption, EC-ElGamal with SM2.
Ring Signature Schemes Based on SM2 Digital Signature Algorithm.