最近发现,使用外部开源的国密库https://github.com/duanhongyi/gmssl进行 SM2 加密之后无法在腾讯云 KMS 系统上做解密,于是笔者针对这个问题做了一些调研、分析,最后解决了这个问题,这篇文章用来记录解决此问题的一些关键步骤和分析思路。

SM2的公钥和密文格式

SM2 公钥加密产生的密文是一个字节串,它可以被分为三个主要部分:C1、C2、C3,其中C1是随机数计算出的椭圆曲线、C2是密文数据、C3是SM3杂凑值,C1固定为64字节,C2的长度与明文相同,C3的长度固定为32字节。

标准的 SM2 公钥长度一般为 65字节,其十六进制格式类似于04c24942bccd2fb8822282cd0aca657cd53e91577c1a76d5d030a8807d35ada743ed0d3cbefcf24475d53333201388fc95ea518c90e9cd7b763f7c8ba8795dbcfc

其中前导的字节04是固定的。

遇到的问题

腾讯云 KMS 上下载的 SM2 公钥格式形如:

1
2
3
4
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEwklCvM0vuIIigs0KymV81T6RV3wa
dtXQMKiAfTWtp0PtDTy+/PJEddUzMyATiPyV6lGMkOnNe3Y/fIuoeV28/A==
-----END PUBLIC KEY-----

腾讯云 KMS 在做 SM2 解密时,其对于密文的格式要求为:

img

因此最开始进行编码时,其逻辑流程为:

  • 先对 SM2 公钥做 base64 解码
  • 然后使用 CryptSM2 接口做加密,并且在加密时专门指定了其模式为 1(即 C1C3C2 模式),asn1=True(即需要做 ASN1 编码)
  • 最后对密文做 base64 编码
1
2
3
        sm2_crypt = sm2.CryptSM2(public_key=base64.b64decode(public_key).hex(), private_key="", mode=1, asn1=True)
        ciphertxt = sm2_crypt.encrypt(data.encode("utf-8"))
        encrypted_text = base64.b64encode(ciphertxt).decode(encoding='utf-8')

最后解密时得到了错误反馈:

img

问题分析与应对措施

解密失败的几种可能

常见的解密失败的原因无非:

  • 公钥与私钥不匹配
  • 错误的公钥数据
  • 密文数据错误

公钥私钥不匹配

在KMS上,SM2私钥用户是不可见的,用户只能通过控制台下载公钥,这里我们反复对下载的公钥做了确认,因此可以排除是公钥与私钥不匹配。

错误的公钥数据

从上述的操作步骤来看,在第一步,直接对 PEM 公钥做base64解码的动作,可能会存在问题。

因此接下来首先检查base64解码后的公钥数据是否为65字节:

1
2
    origin_pem_data = base64.b64decode(pem_data)
    print(origin_pem_data.hex(), len(origin_pem_data))

通过单独的 base64 解码并打印,我们发现公钥数据并不是标准的 65 字节,而是 91 字节:

1
3059301306072a8648ce3d020106082a811ccf5501822d03420004c24942bccd2fb8822282cd0aca657cd53e91577c1a76d5d030a8807d35ada743ed0d3cbefcf24475d53333201388fc95ea518c90e9cd7b763f7c8ba8795dbcfc

并且密钥数据的前置字节也并不是固定的 04,因此可以确定,公钥数据的确存在问题。

如何获取正确的公钥数据

通过向 KMS 侧咨询,我们了解到,从 KMS 平台下载的公钥,其格式是做过 ASN1 编码的,而 ASN1 编码有很大概率会导致数据膨胀,因此我们接下来需要做的,就是对公钥做 ASN1 解码。

通用 KMS 侧反馈的信息,我们了解到 SM2 公钥的 ASN1 结构类似于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- 最外层是一个SEQUENCE
SEQUENCE (
    -- 第一个元素是一个SEQUENCE
    SEQUENCE (
        -- OBJECT IDENTIFIER (1.2.840.10045.2.1)
        OBJECT IDENTIFIER 1.2.840.10045.2.1,
        -- OCTET STRING
        OCTET STRING 00049CCFEDCCDD18BDF39D2EAC6460BB87D0375650C3D56B
            A03A4F9265E601DEA5876FA06592399EB6D0E00348A4A1E5143
            7510C8C0A4D245B14BF67F6884094D4A
    ),
    -- 第二个元素是一个SEQUENCE
    SEQUENCE (
        -- OBJECT IDENTIFIER (1.2.156.10197.1.301)
        OBJECT IDENTIFIER 1.2.156.10197.1.301,
        -- BIT STRING
        BIT STRING
    )
)

于是乎我们做了如下的公钥 ASN1 解码操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class SM2PubKeyASN1Sequence(univ.Sequence):
    componentType = namedtype.NamedTypes(
        namedtype.NamedType('field-0',
                            univ.Sequence(
                                componentType = namedtype.NamedTypes(
                                    namedtype.NamedType('oid-1', univ.ObjectIdentifier()),
                                    namedtype.NamedType('oid-2', univ.ObjectIdentifier())
                                )
                            )),
        namedtype.NamedType('field-1', univ.BitString())
    )

def get_pubkey_from_pem(pem_data: str) -> str:
    origin_pem_data = base64.b64decode(pem_data)
    print(origin_pem_data.hex(), len(origin_pem_data))
    decoded_data, _ = decoder.decode(base64.b64decode(pem_data), asn1Spec = SM2PubKeyASN1Sequence())
    # print(decoded_data)
    # 获取 field-1 的位串,并转换为十六进制字符串
    bit_string = decoded_data['field-1'].asOctets()
    hex_string = binascii.hexlify(bit_string).decode('utf-8')
    print("Hexadecimal string of field-1:", hex_string)
    return hex_string

最终我们成功获得了 65 字节的 SM2 公钥数据:

img

SM2 公钥数据

使用正确的公钥但是仍然解密失败

在使用正确的公钥加密后,我们发现仍然解密失败,因此我们怀疑是不是密文数据有问题。

我们检查了加密接口的参数设置:

img

CryptSM2 参数设置

img

可以确定的是,我们的参数设置是正确的,并没有问题。

但是为了保险,我们继续对 encrypt 函数做了检查:

img

我们发现 encrypt 函数的实现中,并没有做 ASN1 编码的动作,看来问题就出在这里,我们拿到的密文其实是 C1C3C2 模式的裸密文。

通过进一步检查,我们发现,它只在 verify 中有ASN1 相关的判断:

img

verify 函数中判断了 ASN1 编码。

对密文重新做 ASN1 编码

在确定了密文格式的确存在问题后,接下来我们只需要对密文做好 ASN1 编码即可。

通过查阅 SM2 相关的文档,我们找到了其 ASN1 的对象定义:

img

SM2 密文 ASN1 定义

基于这个定义,我们可以进行如下的编码,来实现对裸密文的 ASN1 格式编码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 定义SM2 Ciphertext结构
class SM2_C1C3C2_ASN1_Ciphertext(univ.Sequence):
    componentType = namedtype.NamedTypes(
        namedtype.NamedType('c1x', univ.Integer()),
        namedtype.NamedType('c1y', univ.Integer()),
        namedtype.NamedType('c3', univ.OctetString()),
        namedtype.NamedType('c2', univ.OctetString())
    )

def parse_C1C3C2(cipher_bytes: bytes) -> Tuple[bytes, bytes, bytes]:
    """
    解析密文,返回C1, C3, C2
    SM2密文主要由C1、C2、C3三部分构成,
    其中C1是随机数计算出的椭圆曲线、C2是密文数据、C3是SM3杂凑值,
    C1固定为64字节,C2的长度与明文相同,C3的长度固定为32字节,
    """
    c1 = cipher_bytes[0:64]
    c3 = cipher_bytes[64: 32 + 64]
    c2 = cipher_bytes[32 + 64:]
    return c1, c3, c2

def do_sm2_c1c3c2_asn1_encode(raw_cipher_bytes: bytes) -> bytes:
    c1, c3, c2 = parse_C1C3C2(raw_cipher_bytes)
    c1x = c1[:32]
    c1x_int = int.from_bytes(c1x, 'big')
    c1y = c1[32:]
    c1y_int = int.from_bytes(c1y, 'big')
    # print('c1 part:', c1.hex(), len(c1))
    # print('c2 part:', c2.hex(), len(c2))
    # print('c3 part:', c3.hex(), len(c3))
    ciphertext = SM2_C1C3C2_ASN1_Ciphertext()
    ciphertext.setComponentByName('c1x', c1x_int)
    ciphertext.setComponentByName('c1y', c1y_int)
    ciphertext.setComponentByName('c3', c3)
    ciphertext.setComponentByName('c2', c2)
    encoded_ciphertext = encoder.encode(ciphertext)
    return encoded_ciphertext

运行可以得到以下密文:

img

经过 ASN1 编码的密文

img

KMS解密成功

img

解密数据符合预期

完整的测试代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import base64
import binascii
import datetime
from typing import Tuple

from gmssl import sm2
from pyasn1.codec.der import decoder, encoder
from pyasn1.type import namedtype, univ


def sm2_encrypt(public_key_in_hex: str, data: bytes) -> Tuple[bytes, str]:
    sm2_crypt = sm2.CryptSM2(public_key = public_key_in_hex, private_key = "", mode = 1, asn1 = True)
    ciphertext = sm2_crypt.encrypt(data)
    # print('cipher in hex:', ciphertext.hex(), len(ciphertext))
    encrypted_text = base64.b64encode(ciphertext).decode(encoding = 'utf-8')
    # print("encrypted_text in base64:", encrypted_text)
    return ciphertext, encrypted_text


class SM2PubKeyASN1Sequence(univ.Sequence):
    componentType = namedtype.NamedTypes(
        namedtype.NamedType('field-0',
                            univ.Sequence(
                                componentType = namedtype.NamedTypes(
                                    namedtype.NamedType('oid-1', univ.ObjectIdentifier()),
                                    namedtype.NamedType('oid-2', univ.ObjectIdentifier())
                                )
                            )),
        namedtype.NamedType('field-1', univ.BitString())
    )


def get_pubkey_from_pem(pem_data: str) -> str:
    origin_pem_data = base64.b64decode(pem_data)
    # print(origin_pem_data.hex(), len(origin_pem_data))
    decoded_data, _ = decoder.decode(base64.b64decode(pem_data), asn1Spec = SM2PubKeyASN1Sequence())
    # print(decoded_data)
    # 获取 field-1 的位串,并转换为十六进制字符串
    bit_string = decoded_data['field-1'].asOctets()
    hex_string = binascii.hexlify(bit_string).decode('utf-8')
    # print("Hexadecimal string of field-1:", hex_string)
    return hex_string


def parse_C1C3C2(cipher_bytes: bytes) -> Tuple[bytes, bytes, bytes]:
    """
    解析密文,返回C1, C3, C2
    SM2密文主要由C1、C2、C3三部分构成,
    其中C1是随机数计算出的椭圆曲线、C2是密文数据、C3是SM3杂凑值,
    C1固定为64字节,C2的长度与明文相同,C3的长度固定为32字节,
    """
    c1 = cipher_bytes[0:64]
    c3 = cipher_bytes[64: 32 + 64]
    c2 = cipher_bytes[32 + 64:]
    return c1, c3, c2


# 定义SM2 Ciphertext结构
class SM2_C1C3C2_ASN1_Ciphertext(univ.Sequence):
    componentType = namedtype.NamedTypes(
        namedtype.NamedType('c1x', univ.Integer()),
        namedtype.NamedType('c1y', univ.Integer()),
        namedtype.NamedType('c3', univ.OctetString()),
        namedtype.NamedType('c2', univ.OctetString())
    )


def do_sm2_c1c3c2_asn1_encode(raw_cipher_bytes: bytes) -> bytes:
    c1, c3, c2 = parse_C1C3C2(raw_cipher_bytes)
    c1x = c1[:32]
    c1x_int = int.from_bytes(c1x, 'big')
    c1y = c1[32:]
    c1y_int = int.from_bytes(c1y, 'big')
    # print('c1 part:', c1.hex(), len(c1))
    # print('c2 part:', c2.hex(), len(c2))
    # print('c3 part:', c3.hex(), len(c3))
    ciphertext = SM2_C1C3C2_ASN1_Ciphertext()
    ciphertext.setComponentByName('c1x', c1x_int)
    ciphertext.setComponentByName('c1y', c1y_int)
    ciphertext.setComponentByName('c3', c3)
    ciphertext.setComponentByName('c2', c2)
    encoded_ciphertext = encoder.encode(ciphertext)
    return encoded_ciphertext


if __name__ == '__main__':
    # 客户的公钥
    public_key = """
MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEwklCvM0vuIIigs0KymV81T6RV3wa
dtXQMKiAfTWtp0PtDTy+/PJEddUzMyATiPyV6lGMkOnNe3Y/fIuoeV28/A==
"""

    # 我自己的公钥
    #     public_key = """
    # MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEnM/twM3Ri9850urGRgu4fQN1ZQw9
    # VroDpPkmXmAdHqWHb6BlkjmettDgA0ikHlFDdRDIwKTSRbFL9n9ohAlNSg==
    # """

    # 获取公钥的十六进制字符串
    hex_pub_key = get_pubkey_from_pem(public_key)
    print('hex_pub_key:', hex_pub_key)

    # 待加密数据
    data_bytes = datetime.datetime.now().isoformat(timespec = 'microseconds').encode('utf-8')
    print('data_bytes:', data_bytes.hex())

    # 使用外部开源库加密
    ret_cipher_bytes, cipher_base64 = sm2_encrypt(hex_pub_key, data_bytes)
    ret_asn1_cipher = do_sm2_c1c3c2_asn1_encode(ret_cipher_bytes)
    print('asn1_cipher:', ret_asn1_cipher.hex())
    print('asn1 base64 cipher:', base64.b64encode(ret_asn1_cipher).decode('utf-8'))
    tmp, _ = decoder.decode(ret_asn1_cipher, asn1Spec = SM2_C1C3C2_ASN1_Ciphertext())
    print('测试外部库生成的 C1C3C2 密文被自定义 ASN1编码后能否解密:', tmp)