做国密 SM2 接入的同学应该都遇到过这种尴尬:自己手里拿到的密文是一长串十六进制,开头固定一个 04,后面跟着 65 字节的点坐标、32 字节摘要和一段密文,也就是标准 C1C3C2 拼接格式。结果对端接口偏偏只要 ASN.1 DER 编码的密文,或者反过来,从某个加密机导出的密文打开一看全是 30 02 04 开头的 TLV 结构,跟本地解析器完全对不上。这篇就把 SM2 密文从 C1C3C2 转成 ASN.1 DER 格式这件事讲透,包括两种格式的字节级构造、DER 编码的关键规则、可以直接抄的 Python 转换器,以及我在实际对接中踩过的几个坑。适合正在做国密改造、对接证书体系或自研密码模块的开发者参考。
1. 先从密文格式说起:C1C3C2 是怎么拼出来的
1.1 SM2 加密结果的三段式构造
SM2 加密算法输出的是一个三元组 C1、C2、C3,国密标准 GB/T 32918.1-2016 规定输出顺序是 C1 || C3 || C2。C1 是椭圆曲线点,由加密时的随机数 k 计算得到,标准做法是未压缩点格式,一个字节 0x04 前缀加 32 字节 X 坐标加 32 字节 Y 坐标,总共 65 字节。C3 是 SM3 杂凑值,固定 32 字节。C2 是密文数据,长度和明文长度相等。
所以一个完整密文的字节布局就是:
[0x04][X(32)][Y(32)][C3(32)][C2(N)]
对绝大多数实际场景来说,C1 的长度固定 65 字节,C3 固定 32 字节,只有 C2 跟随明文长度变化。拿到一条 0x04 开头的 hex 密文,第一反应是先量长度,减去 97 个字节之后才是 C2 的长度。我第一次接触这个格式时,对着 0x04 开头的那一长串数了很久,后来想明白:整条密文就是“点坐标 + 哈希 + 流密文”三段数据拼接,结构本身特别简单,难点全在后面的 DER 封装上。
1.2 为什么还会有 C1C2C3 这种老格式
只看标准文档,C1C3C2 没什么好讨论的。但实际对接第三方系统时,你会发现不少老设备、老驱动、甚至一些早期开源代码输出的是 C1C2C3:同样的三段数据,C3 被放到最后。原因很简单,标准在 2016 年修订前后有过一阵混乱期,不少实现在标准明确之前就按 C1C2C3 落地了,后续为了兼容就保留了下来。
这个坑必须先说清楚,因为你拿到的密文如果其实是 C1C2C3 顺序,按 C1C3C2 去切分,解析结果会完全错位:C3 那一块会被当成 C2 的头部。所以动手写转换前,先确认对接系统的输出顺序。判断手段通常有两个:一是看对方接口文档有没有明确写“C1C3C2 或 C1C2C3”;二是找一条已知明文的密文,解密验一把,哪个顺序能还原出明文就按哪个来。
我在一次老系统迁移中遇到过这种问题,对方文档根本没写顺序,最后是用一条已知明文样本试出来的。后来我在转换工具里直接加了一个模式参数,默认 C1C3C2,遇到老系统手动切到 C1C2C3,这样至少不会拿到密文一脸懵。
2. ASN.1 DER 里 SM2 密文的“标准长相”
2.1 TLV:看懂 DER 就靠这三件套
ASN.1 DER 本质上是一种二进制编码协议,任何数据都可以写成 Tag-Length-Value 三段,也就是 TLV。Tag 是类型标签,Length 是 Value 的字节长度,Value 是实际内容。SM2 密文转成 DER,归根结底就是按一套约定好的 TLV 结构把 C1 的 X、Y、C3、C2 重新组织起来。
最常见的三个标签码要记牢:0x30 是 SEQUENCE,表示一个结构体;0x02 是 INTEGER,整数;0x04 是 OCTET STRING,字节串。整个 SM2 密文的 DER 结构就是一个 SEQUENCE,里面装四个字段。DER 的 Length 编码规则比较直接:如果 Value 长度小于 128 字节,用短格式,Length 就一个字节,比如 0x73 表示 115 字节;如果大于等于 128 字节,用长格式,Length 的最高位置 1,低 7 位表示后面跟着几个长度字节。例如 0x81 0x85 表示长度 133 字节,0x82 0x01 0x23 表示 291 字节。实际 SM2 密文如果 C2 很长,最常见的长度字段就是 0x82 开头。这条规则实现起来不复杂,但容易写错,后面代码里会专门处理。
DER 还有一个重要特性叫“唯一编码”,意思是同一个值只有一种合法编码。这跟 BER 不一样,BER 允许同一长度写成多种形式,DER 要求必须是最短形式。很多密码库解析 DER 时会校验这一点,所以自己写编码器时务必严格按短格式优先的规则来。
2.2 SM2 密文的 DER 结构定义
在不少密码库和硬件设备里,SM2 密文对应的 DER 结构如下:
SEQUENCE { INTEGER X 坐标 INTEGER Y 坐标 OCTET STRING C3 哈希值 OCTET STRING C2 密文数据 }
也就是说,原本 C1C3C2 扁平拼接中的坐标被拆成了两个 INTEGER,C3 和 C2 被装进了两个 OCTET STRING。为什么这么设计?因为 X、Y 在数学上是两个大整数,用 INTEGER 表示最自然;C3 和 C2 本身就是字节串,用 OCTET STRING 保真。注意,坐标的 0x04 前缀在 DER 里是不存在的,因为 X 和 Y 已经各自独立成字段,不需要再靠一个标记字节说明“这是一个未压缩点”。
这个结构不是国密标准里硬性规定的,更多是 PKI 生态里为了统一封装而约定俗成的。有些实现第三字段也用 INTEGER 来装 C3,代码里需要兼容。如果你打开一个 DER 密文的 hex,看到 30 开头,后面跟着 02 02 04 之类的字节序列,那基本就是 x、y 两个整数加两个字节串的结构。反过来,想验证一段 DER 是不是 SM2 密文,最简单的方法是确认它是不是一个只有四个子字段的 SEQUENCE,且第一、第二个字段是长度在 32 字节左右的 INTEGER。
2.3 最容易翻车的两个编码细节
DER 里有两个细节非常容易坑人。第一是 INTEGER 的符号位问题。ASN.1 的 INTEGER 是有符号整数,如果 Value 的最高位是 1,编码器必须在前面补一个 0x00,否则解析端会把这个整数解读成负数。而 SM2 的 X、Y 坐标是随机数,最高位为 1 的概率正好是 50%,也就是说大概一半的密文在转换时都需要补这个前导 00。这就导致 X 字段的 TLV 长度不是固定的 34 字节,可能是 35 字节。很多转换代码没考虑这一点,导致 DER 长度计算全错。
第二是 DER 要求长度编码必须是最短形式。Value 长度 115 就写 0x73,不能写成 0x81 0x73。有些第三方库对非最短长度编码容忍,有些则直接拒绝。所以自己动手实现时,要严格按照“短格式优先,超长才用长格式”的规则来写。这两个坑都是我实际踩过的。第一次写转换器时没有处理符号位,转出来的 DER 在接口那边能解析,但解密结果完全不对,后来逐字段对比才发现是 INTEGER 被当成了负数。
3. C1C3C2 转 DER 的完整实现
3.1 转换流程拆解
整个转换可以拆成四步。
第一步,校验输入。输入是一条 hex 密文,先确认它以 0x04 开头,总长度至少 97 字节。第二步,切分。取出 X、Y、C3、C2 四段。第三步,编码四个子字段。X 和 Y 用 INTEGER 编码,C3 和 C2 用 OCTET STRING 编码。第四步,把四个子字段拼起来作为 SEQUENCE 的 Value,计算总长度,加上 0x30 标签,输出。
具体切分位置:C1 总共 65 字节,第 0 字节是 0x04,第 1 到 32 字节是 X,第 33 到 64 字节是 Y;C3 是紧接着的 32 字节,即第 65 到 96 字节;C2 是剩下所有字节,第 97 字节到结尾。注意这里一定要搞清楚 0x04 是 C1 的一部分,不是整个密文的前缀。有同事在实现时把它当成一个全局标记直接跳过,结果把 X 坐标截掉了 1 字节,导致对端解密失败。0x04 只在“描述椭圆曲线点”这个层面有意义,到了 DER 结构里它就消失了。
3.2 Python 转换器代码
直接给一份可以抄的 Python 实现。代码里包含长度编码、INTEGER 编码、OCTET STRING 编码,以及正反两个转换函数。不依赖第三方库,纯标准库就能跑。
def der_length(n: int) -> bytes: """DER 长度字段编码,短格式优先。""" if n < 0x80: return bytes([n]) length_bytes = n.to_bytes((n.bit_length() + 7) // 8, "big") return bytes([0x80 | len(length_bytes)]) + length_bytes def der_tlv(tag: int, value: bytes) -> bytes: return bytes([tag]) + der_length(len(value)) + value def der_integer(raw: bytes) -> bytes: # 去掉无意义的前导 00 i = 0 while i < len(raw) - 1 and raw[i] == 0x00: i += 1 body = raw[i:] # 最高位为 1 时补 0x00,保证是正数 if body and body[0] & 0x80: body = b"\x00" + body return der_tlv(0x02, body) def der_octet_string(data: bytes) -> bytes: return der_tlv(0x04, data) def parse_der_length(data: bytes, start: int): first = data[start] if not (first & 0x80): return first, start + 1 num_len_bytes = first & 0x7F if num_len_bytes == 0 or num_len_bytes > 8: raise ValueError("invalid DER length encoding") if start + 1 + num_len_bytes > len(data): raise ValueError("length field out of range") length = int.from_bytes(data[start + 1: start + 1 + num_len_bytes], "big") return length, start + 1 + num_len_bytes def int_to_fixed32(raw: bytes) -> bytes: i = 0 while i < len(raw) - 1 and raw[i] == 0x00: i += 1 raw = raw[i:] if len(raw) > 32: raise ValueError("integer too large for coordinate") return raw.rjust(32, b"\x00") def sm2_c1c3c2_to_der(cipher_hex: str) -> str: raw = bytes.fromhex(cipher_hex) if len(raw) < 97 or raw[0] != 0x04: raise ValueError("invalid SM2 C1C3C2 cipher text") x = raw[1:33] y = raw[33:65] c3 = raw[65:97] c2 = raw[97:] inner = der_integer(x) + der_integer(y) + der_octet_string(c3) + der_octet_string(c2) return der_tlv(0x30, inner).hex().upper() def sm2_der_to_c1c3c2(der_hex: str) -> str: der = bytes.fromhex(der_hex) if der[0] != 0x30: raise ValueError("not a SEQUENCE") seq_len, pos = parse_der_length(der, 1) seq_data = der[pos:pos + seq_len] if len(seq_data) != seq_len: raise ValueError("SEQUENCE length mismatch") pos = 0 fields = [] while pos < len(seq_data): tag = seq_data[pos] length, value_start = parse_der_length(seq_data, pos + 1) if value_start + length > len(seq_data): raise ValueError("field length out of SEQUENCE range") value = seq_data[value_start:value_start + length] fields.append((tag, value)) pos = value_start + length if len(fields) != 4: raise ValueError("expected 4 fields in SM2 DER cipher") tag_x, raw_x = fields[0] tag_y, raw_y = fields[1] tag_h, raw_h = fields[2] tag_c, raw_c = fields[3] if tag_x != 0x02 or tag_y != 0x02: raise ValueError("x/y should be INTEGER") if tag_c != 0x04: raise ValueError("cipher text should be OCTET STRING") x = int_to_fixed32(raw_x) y = int_to_fixed32(raw_y) if tag_h == 0x02: hash_val = int_to_fixed32(raw_h) else: hash_val = raw_h if len(hash_val) != 32: raise ValueError("hash field should be 32 bytes") result = b"\x04" + x + y + hash_val + raw_c return result.hex().upper()代码里几个设计点说明一下。der_integer 先去掉无意义前导 00,再判断是否需要补符号位,这是处理坐标最稳的方式。int_to_fixed32 是逆向操作,去掉符号位前导 00、左填充到 32 字节。parse_der_length 同时处理短格式和长格式,并做了长度字段越界检查,避免恶意或损坏数据导致切片异常。逆转换时对 SEQUENCE 内部字段长度也做了范围校验,所有越界情况都会直接抛异常而不是静默截断,这样问题能尽早暴露。
3.3 用一个构造密文跑通全流程
为了验证代码逻辑,我构造一段边界值密文,专门让 X 坐标最高位为 1,这样可以同时验证符号位补 00 的逻辑。密文内容是:0x04 前缀后面跟 32 字节的 0x80 00 00 ... 00 作为 X 坐标,32 字节的 0x02 02 ... 02 作为 Y 坐标,32 字节的 0x03 03 ... 03 作为 C3,10 字节的 00 11 22 33 44 55 66 77 88 99 作为 C2。
原始密文总长度是 65 + 32 + 10 = 107 字节。转 DER 时,X 由于最高位为 1,INTEGER 编码后变成 33 字节,对应的 TLV 是 02 21 00 80 ...;Y 对应 02 20 02 02 ...;C3 对应 04 20 03 03 ...;C2 对应 04 0A 00 11 22 33 44 55 66 77 88 99。
各个字段 TLV 长度推导如下:
- X INTEGER:1 + 1 + 33 = 35 字节
- Y INTEGER:1 + 1 + 32 = 34 字节
- C3 OCTET STRING:1 + 1 + 32 = 34 字节
- C2 OCTET STRING:1 + 1 + 10 = 12 字节
SEQUENCE 内部四个字段总长度是 35 + 34 + 34 + 12 = 115 字节,16 进制就是 0x73,所以整个 DER 以 30 73 开头,总长度 117 字节。读者可以自己跑一遍,看输出长度和这几个关键字节是不是一致。如果一致,说明符号位处理和长度计算都没问题。顺带一提,原始密文是 107 字节,DER 后变成 117 字节,多出来的 10 字节来自四层 TLV 外壳共 8 字节、SEQUENCE 外层 2 字节、X 符号位补 00 增加 1 字节,再减去原来 0x04 前缀消失的 1 字节,正好是 8 + 2 + 1 - 1 = 10。
4. DER 转回 C1C3C2:逆转换与容错
4.1 解析 DER 的注意点
逆转换的难点不在拼字节,而在解析。DER 解析的顺序是:读 0x30 标签,读 SEQUENCE 长度,然后在 SEQUENCE 的 Value 里循环读取四个 TLV。每一步都要实时更新游标位置,否则某个字段长度带个前导字节就会让整个解析偏移。
解析 INTEGER 时,需要把可能存在的符号位前导 00 去掉,再左填充到 32 字节。这里有个边界情况:如果坐标本身以 00 开头,比如 X = 00 01 23 45 ...,转 DER 时它会去掉一个前导 00 变成 31 字节的 INTEGER;逆转换时左填充回 32 字节即可恢复。但如果同时出现多个前导 00,DER 编码时会全部去掉,逆转换依然左填充回 32 字节,所以结果一致。唯一要注意的是,如果 INTEGER 补完前导 00 之后仍然超过 32 字节,说明原始数据有问题,应该报错而不是硬截断。代码里的 int_to_fixed32 统一处理这些情况:先剥掉所有前导 00,再判断长度是否超过 32,最后左填充。超过 32 字节直接抛出异常,至少不会静默产生一个错误坐标。
4.2 容错处理:识别字段顺序和标签变体
DER 里的字段顺序和标签在不同实现里不完全一致。最常见的是前文说的 SEQUENCE { INTEGER X, INTEGER Y, OCTET STRING C3, OCTET STRING C2 },但如果对方是用某些老版本库生成的,第三字段可能是 INTEGER C3。这时候如果只按 OCTET STRING 去解析,tag 对不上,代码应该给出明确报错,而不是硬把 bytes 当 hash。
更实际的一个问题是:有些实现干脆把 X、Y 也换成 OCTET STRING,整个结构变成四个 OCTET STRING。这种变体就不太好用标签自动判别了,建议在接口对接时先确认清楚对方的 ASN.1 定义。我的代码用 tag 判断:第二、第三个字段必须是 0x02,第四个字段必须是 0x04,第三字段允许 0x02 或 0x04。如果你的对接方用的结构完全不一样,基于这个代码改起来也很快。另外提醒一点:DER 解析器在读取 SEQUENCE 时,要检查 Value 的实际长度是否与 Length 字段一致。如果不一致,多半是长度编码写错或者数据被截断。不要信任 Length 字段之外的部分,也不要忽略 Value 末尾多余的数据。严格按 Length 切分,切完正好结束才是正常情况。
4.3 无 0x04 前缀的 C1:一个隐蔽的变种
有些厂商标注支持“C1C3C2”,但实际导出的密文开头根本没有 0x04,C1 直接就是 64 字节的 X || Y。这在 ASN.1 DER 视角下其实没关系,因为 DER 里本来就不要 0x04,但在 C1C3C2 的字节分割阶段很容易出问题:按 65 字节切 C1,会把 C3 的第一个字节当成 Y 坐标的最后一个字节,然后整条数据长度对不上。
处理办法是在转换函数里加一个可选参数,控制 C1 是否包含前缀。默认按 65 字节切,如果确认对方输出无前缀,改为 64 字节。或者更稳妥一点,在解析时先校验第一个字节:如果首字节是 0x04,按 65 切;不是 0x04,按 64 切。但这种方式在极端情况下可能误判,最好以接口文档为准。我在代码示例里保留的是标准 65 字节版本,实际使用时可以自行扩展这个参数。这个坑我第一次碰到是在调某个打印机的国密密文接口,对方返回的 hex 一共 64 + 32 + N 个字节,开头根本不是 04,我当时按标准格式解析直接报数组越界。后来看了一眼对方样例才明白,密文的第一部分只有坐标,没有前缀。
5. 实战中的常见问题速查
5.1 典型的报错与原因
我整理了一张排查表,覆盖实际接通过程中最常见的几类问题:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 转换后对端解析报错 | SEQUENCE 长度算错,或 INTEGER 符号位没补 00 | 先单测每个 TLV,核对短格式/长格式编码 |
| 解密结果乱码 | 密文顺序其实是 C1C2C3 | 确认对方输出顺序,用已知明文解密验证 |
| C2 长度对不上,总是差 1 | C1 实际无 0x04 前缀,按 65 切分 | 改用 64 字节切 C1,并在转换时自动补 04 |
| DER 解析越界 | 长度字段用了非最短编码,或游标没更新 | 用 parse_der_length 统一处理,加边界检查 |
| 坐标为正数但解析后全长 33 字节 | INTEGER 符号位补了 00,没剥掉 | 逆转换时先剥前导 00 再左填充到 32 |
| 第三个字段 tag 不是 04 | 对方用了 INTEGER 存 C3 | 解析时兼容 0x02 和 0x04 |
表格里的第二行特别值得多说一句。C1C2C3 和 C1C3C2 的差异,从字节层面很难自动判断,因为 C3 和 C2 都是一段字节。我建议在对接之前准备好一条明文和对应密钥,拿到对方密文先做一次解密验证:哪个顺序能解出明文,就按哪个来。工具层面可以做一个模式参数,两个方向都支持,这样切换成本最低。
5.2 我的排查思路清单
如果转换结果在某个环节还是不通,我一般按下面这个顺序排查。
第一步,打印原始 hex,确认长度。标准 C1C3C2 的总长度等于 65 + 32 + C2 长度。如果已知明文长度,这一步能立刻判断 C1 有没有前缀。第二步,打印每个 TLV。把 DER 按字段拆开,分别核对 X、Y、C3、C2 的长度和内容。X 和 Y 各 32 字节,C3 固定 32 字节,C2 长度应与明文一致。哪个字段长度异常,问题就锁定在哪一步。第三步,验证 DER 的 Length 字段。计算 SEQUENCE 内部所有字段的总长度,对照 Length 字段的值。这里尤其要检查长格式:如果内部总长度超过 127 字节,Length 字段必须是 0x81 或 0x82 开头,否则解析器不会认。第四步,用私钥解密一遍。不要只看格式对不对,最终要能解密出正确明文,整个链路才算通。如果解密失败,优先怀疑顺序问题,其次才是字段切分问题。第五步,如果还不行,就找一个参考实现做交叉验证。把自己生成的 DER 喂给标准库解析,再把标准库生成的 DER 喂回自己的解析器,对比两者字段是否一致。很多加密库都自带 SM2 的 DER 编解码函数,交叉验证能快速定位到底是编码端还是解析端的问题。
这个转换器我前后改了三版。第一版只处理标准 65 字节 C1,结果在真实对接中碰到无前缀密文直接翻车;第二版补了符号位和长格式处理,能正常转换大部分数据;第三版加了解析容错和顺序参数,才算真正在各种接口之间稳定下来。
最后分享一下我个人的使用习惯:生产环境里我不会直接在业务代码里写死 65 字节,而是先把密文长度和已知明文长度做一个交叉校验,再决定 C1 要不要按 64 字节处理。DER 这边也一样,统一用一个解析工具类,任何地方拿到 DER 密文都先规范化成 C1C3C2 内部结构,后续所有业务只认这一种中间格式。这样换对接方、换库,都只需要改入口适配层,核心逻辑一次写好永远不动。