资讯详情

SM2密文格式转换:C1C3C2与ASN.1 DER互转实战解析

📅 2026/10/18 18:24:46 | 华诺云谱 👁 阅读
SM2密文格式转换:C1C3C2与ASN.1 DER互转实战解析
做国密 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 扁平拼接中的坐标被拆成了两个 INTEGERC3 和 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 字节是 YC3 是紧接着的 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 作为 C310 字节的 00 11 22 33 44 55 66 77 88 99 作为 C2。原始密文总长度是 65 32 10 107 字节。转 DER 时X 由于最高位为 1INTEGER 编码后变成 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 INTEGER1 1 33 35 字节Y INTEGER1 1 32 34 字节C3 OCTET STRING1 1 32 34 字节C2 OCTET STRING1 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 字节即可恢复。但如果同时出现多个前导 00DER 编码时会全部去掉逆转换依然左填充回 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”但实际导出的密文开头根本没有 0x04C1 直接就是 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 长度对不上总是差 1C1 实际无 0x04 前缀按 65 切分改用 64 字节切 C1并在转换时自动补 04DER 解析越界长度字段用了非最短编码或游标没更新用 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 内部结构后续所有业务只认这一种中间格式。这样换对接方、换库都只需要改入口适配层核心逻辑一次写好永远不动。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑