30 06 80 01 09 81 01 09
类型–长度–数据中的长度指的一定是数据的字节数,数据中嵌套的所有字段也包含在内。 因此,只含有一个元素的 SEQUENCE 长度也不是 1,而是该元素编码后有多少个字节。
长度也有两种编码方式:短编码和长编码。 短编码就是一个字节,取值范围是 0 到 127。
长编码则至少有两个字节。第一个字节的第 8 位必须为 1, 其余 7 位表示这个长度字段还有几个字节。 接下来就是一个多字节整数,给出了长度的具体数值。
可想而知,这样得到的长度值可以非常大。 长度最大时第一个字节是 254(255 是保留值,供将来扩展使用),表示这个长度字段内还有足足 126 个字节。 如果这 126 个字节都是 255,那么实际数据的长度会达到 21008−1 字节(超过 10294 GB)。
此外,同一长度值的长编码并不唯一,比如一个字节的数字可以拿两个字节表示,短编码就能表示的数字也可以用长编码。 所以 DER 规定必须采用最短的编码方式。
安全警示:不要轻信长度字段的值! 举例来说,待解码的数据流究竟有没有该字段显示的那么长还是需要核实的。
不定长编码
在 BER 中,字符串、SEQUENCE、SEQUENCE OF、SET 和 SET OF 类型的字段即使事先不知道长度也可以直接编码,通过数据流输出就可以采用这种方式。 具体方法是将长度字段设为一个字节 0x80,数据中连续存放若干个编码后的字段,最后以两个字节 00 00(可以认为是一个标签和长度都为 0 的字段)结尾。 例如,UTF8String 的不定长编码就是若干个 UTF8String 拼接在一起,然后在末尾加上 00 00。
不定长编码还可以任意嵌套。 例如,在 UTF8String 的不定长编码中,待拼接的每一段 UTF8String 本身也可以选用定长或不定长编码。
作为长度的 0x80 字节并不存在歧义,因为它既不是短编码,也不是长编码。 它的第 8 位是 1,按理说应该是长编码,其余 7 位表示还有多少个字节。 但这 7 位都是 0,说明长度要用 0 个字节来表示,这是不允许的。
DER 禁止使用不定长编码, 所以必须使用定长编码,提前写明数据的实际长度。
单一字段与复合字段
标签中第一个字节的第 6 位表示该字段是单一 (primitive) 字段还是复合 (constructed) 字段。 单一字段中存储的就是数据本身,比如 UTF8String 的数据就是经过 UTF-8 编码的字符串。 复合字段存储的则是若干其他字段,经过编码后拼接在一起。 例如“不定长编码”一节中提到的不定长 UTF8String 就会有多个 UTF8String 字段(各有标签和长度)编码后连在一起,形成复合字段。 复合字段的长度便是拼接后各字段的总字节数。 复合字段可以采用定长或不定长编码, 而单一字段只能用定长编码,因为其数据中没有其他字段,也就无法表明数据该在哪里结束。
INTEGER、OBJECT IDENTIFIER 和 NULL 类型必须是单一字段, 而 SEQUENCE、SEQUENCE OF、SET 和 SET OF 类型必须是复合字段(因为它们的作用本来就是存放多个元素)。 BIT STRING、OCTET STRING、UTCTime、GeneralizedTime 还有各种字符串类型既可以是单一字段,也可以是复合字段,在 BER 中编码者可以自行决定, 但在 DER 中凡是单一、复合均可的类型都必须用单一字段。
EXPLICIT 与 IMPLICIT
前面提到的 [1]、[APPLICATION 8] 等编码指令还可以加上 EXPLICIT 或 IMPLICIT 关键字。以 RFC5280 为例:
TBSCertificate ::= SEQUENCE {
version [0] Version DEFAULT v1,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
validity Validity,
subject Name,
subjectPublicKeyInfo SubjectPublicKeyInfo,
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
extensions [3] Extensions OPTIONAL
-- If present, version MUST be v3 -- }
这两个关键字定义的是标签的编码方式,与标签值是明确写出还是自动分配无关。事实上使用这两个关键字时都必须在方括号中写明标签的值。 IMPLICIT 字段的编码方式与其原始类型相同,只是标签的值和类别由方括号中的内容决定。 EXPLICIT 字段则需要先将数据按原始类型编码,再将编码结果套入另一字段中。 外层字段的标签值和类别再由方括号中的内容决定,而且一定是复合字段。
例如,若在 ASN.1 的编码指令中加上 IMPLICIT:
[5] IMPLICIT UTF8String
那么字符串“hi”会被编码为:
85 02 68 69
但如果在 ASN.1 的编码指令中加上 EXPLICIT:
[5] EXPLICIT UTF8String
则字符串“hi”会被编码为:
A5 04 0C 02 68 69
如果 IMPLICIT 和 EXPLICIT 关键字都没有出现,默认编码为 EXPLICIT,除非该模块开头指定了“EXPLICIT TAGS”、“IMPLICIT TAGS”或“AUTOMATIC TAGS”。 例如 RFC 5280 定义了两个模块,一个默认编码为 EXPLICIT,另一个则导入了前一模块,并将默认编码设为 IMPLICIT。 IMPLICIT 编码比 EXPLICIT 编码更短。
AUTOMATIC TAGS 的作用与 IMPLICIT TAGS 相同,只是标签的数值([0]、[1] 等等)会在必要时自动分配,比如 SEQUENCE 中存在可省略元素的情况。
各种类型的具体编码
我们接下来结合具体示例看看各种类型究竟是如何编码的。
INTEGER 的编码
整数的编码由一个或多个字节组成,采用补码格式,最左侧字节的最高位(即第 8 位)为符号位。 BER 标准中是这样写的:
计算二进制补码的数值时,首先将其各字节的每一位标号,最后一个字节的最低位序号为 0,从这一位开始到第一个字节的最高位序号依次递增, 第 N 位表示的数值为 2N。 整个二进制补码的值即为所有等于 1 的二进制位表示的数值的总和(除了最高位),再减去最高位表示的数值(如果最高位为 1)。
例如,这个字节(以二进制表示)等于十进制数字 50:
00110010(即十进制的 25 + 24 + 21 = 50)
这个字节(以二进制表示)则等于十进制数字 −100:
10011100(即十进制的 24 + 23 + 22 − 27 = −100)
这五个字节(以二进制表示)等于十进制数字 −549755813887:
10000000 00000000 00000000 00000000 00000001(即十进制的 20 − 239)
BER 和 DER 都规定整数必须以最短的方式编码。 这一规则是通过以下条件体现的:
……第一个字节的每一位和第二个字节的最高位:
1. 既不能同时为 1;
2. 也不能同时为 0。
第二项条件的大意就是说,如果编码开头存在零字节,那么把它们删去也不会影响数值。 第二个字节的最高位也很重要,因为有些数值的编码开头必须有零字节。 例如,十进制的 255 需要用两个字节编码:
00000000 11111111
因为一个字节 11111111 表示的是 −1(最高位是符号位)。
第一项条件则最好结合实例来看。 比如十进制数字 −128 的编码为:
10000000(即十进制的 −27 = −128)
但是似乎也可以这么编码:
11111111 10000000(还是十进制的 −128,不过是错误的编码)
不难验证,−215 + 214 + 213 + 212 + 211 + 210 + 29 + 28 + 27 = −27 = −128。 注意,1 在单字节的 10000000 中是符号位,但在第二个字节中只代表 27。
这种变换具有普遍性:BER(或 DER)编码的任何一个负数都可以在开头加上 11111111 并保持数值不变。 这称为符号扩展。 反过来说,如果一个负数的编码开头是 11111111,将该字节删去同样不会影响数值。 这就是 BER 与 DER 要求使用最短编码的原因。
INTEGER 采用补码表示对于证书发行影响重大。RFC 5280 规定证书序列号必须是正数, 但最高位必然是符号位,所以 DER 中 8 个字节只能编码 63 位的序列号, 64 位的正数需要 9 个字节才能编码(尽管第一个字节为 0)。
值为 263+1(恰好是 64 位的正数)的 INTEGER 编码如下:
02 09 00 80 00 00 00 00 00 00 01
字符串的编码
字符串的编码就是其字节序列。 IA5String 和 PrintableString 因为都是 ASCII 的子集,只是范围有所不同,所以它们的编码除了标签以外完全一样。
PrintableString 的“hi”编码为:
13 02 68 69
而 IA5String 的“hi”编码为:
16 02 68 69
UTF8String 也类似,但它能表示的字符更多。 例如,表情“😎” (U+1F60E Smiling Face With Sunglasses) 的编码是:
0c 04 f0 9f 98 8e
日期和时间的编码
出人意料的是,UTCTime 和 GeneralizedTime 的编码方式其实和字符串一样。 正如上文“类型”一章所述,UTCTime 表示时间的格式是 YYMMDDhhmmss, GeneralizedTime 则在其基础上将 YY 改成了四位年份 YYYY。 二者最后都可以加上时区,或者加一个 Z 表示协调世界时 (UTC)。
例如,太平洋标准时间(PST,即 UTC−8)的 2019 年 12 月 15 日 19:02:10 用 UTCTime 表示为 191215190210-0800, 用 BER 编码就是:
17 11 31 39 31 32 31 35 31 39 30 32 31 30 2d 30 38 30 30
BER 编码中,UTCTime 和 GeneralizedTime 的秒数都可以省略,并且可以使用各种时区。 但在 DER 编码(及 RFC 5280)中,秒数则不能省略,且不能出现小数,时区也只能用 Z,表示协调世界时。
上述时间如果用 DER 编码则是:
17 0d 31 39 31 32 31 36 30 33 30 32 31 30 5a
OBJECT IDENTIFIER 的编码
如上文所述,OID 的实质就是一串整数, 而且至少由两个整数组成。 第一个数必须是 0、1、2 三者之一, 如果是 0 或 1,则第二个数必须小于 40。 因此,前两个数 X 和 Y 可以直接用 40×X+Y 来表示,不会产生歧义。
以 2.999.3 的编码为例,首先要将前两个数合并成 1079(即 40×2+999),得到 1079.3。
完成合并后再用 Base 128 编码,左侧为高位字节。 也就是说,每个字节的最高位设为 1,但最后一个字节最高位设为 0,表示一个整数到此结束。各字节的其余七位从高到低依次相连表示数值。 例如数字 3 就用一个字节 0x03 表示, 而 129 则需要两个字节 0x81 0x01。 每个数字都如此转换成字节后,拼接在一起就形成了 OID 的编码。
无论是在 BER 还是 DER 中,OID 都必须用最短的方式编码。 所以其中每个数字编码时开头都不能出现 0x80 字节。
例如,OID 1.2.840.113549.1.1.11(代表 sha256WithRSAEncryption)的编码是:
06 09 2a 86 48 86 f7 0d 01 01 0b
NULL 的编码
NULL 字段的长度永远为 0,所以它的编码只有标签和长度:
05 00
SEQUENCE 的编码
首先需要注意的是,SEQUENCE 必然是复合字段,因为它的作用就是容纳其他对象。 换句话说,SEQUENCE 中的数据就是其元素各自编码后按定义中的顺序拼接在一起组成的。 因此,SEQUENCE 标签的第 6 位(单一/复合字段位)一定是 1。 虽然 SEQUENCE 本来的标签是 0x10,但在编码中一定会以 0x30 的形式出现。
SEQUENCE 的定义中标为 OPTIONAL 的字段可以省略,省略后就会直接从编码中消失。 读取 SEQUENCE 的内容时,解码器可以根据标签和已读入的元素确定正在读取的是哪个字段。 如果存在歧义,比如有同类型的元素,则在 ASN.1 模块中必须借助编码指令为这些元素分配不同的标签。
标有 DEFAULT 的字段与 OPTIONAL 类似。 如果该字段取默认值,在 BER 编码中就可以予以省略, 而在 DER 编码中则必须省略。
例如,RFC 5280 中的 AlgorithmIdentifier 就是 SEQUENCE 类型:
AlgorithmIdentifier ::= SEQUENCE {
algorithm OBJECT IDENTIFIER,
parameters ANY DEFINED BY algorithm OPTIONAL }
algorithm 为 1.2.840.113549.1.1.11 的 AlgorithmIdentifier 编码如下。 RFC 8017 建议这种算法对应的 parameters 字段应为 NULL。
30 0d 06 09 2a 86 48 86 f7 0d 01 01 0b 05 00
SEQUENCE OF 的编码
SEQUENCE OF 的编码方式与 SEQUENCE 完全相同, 甚至连标签都一样! 解码时也只有查阅 ASN.1 模块才能确定一个字段究竟是 SEQUENCE 还是 SEQUENCE OF。
例如以下是一个 SEQUENCE OF 对象的编码,其中包含 7、8、9 三个 INTEGER。
30 09 02 01 07 02 01 08 02 01 09
SET 的编码
与 SEQUENCE 一样,SET 也一定是复合字段,其数据总是由若干个字段的编码组成的。 它的标签是 0x11, 但因为单一/复合字段位(第 6 位)必须为 1,所以在编码中实际上是 0x31。
SET 的编码方式也和 SEQUENCE 类似,OPTIONAL 和 DEFAULT 字段如果省略或取默认值就不会出现, 任何由类型相同导致的歧义都需要在 ASN.1 模块中解决,并且取默认值的 DEFAULT 字段在 DER 编码中必须省略。
BER 编码对 SET 中元素的顺序没有要求, 但在 DER 中各元素必须按标签值升序排列。
SET OF 的编码
SET OF 的编码方式和 SET 一样,标签也是 0x31。 DER 编码同样规定 SET OF 中的字段要按升序排列。 因为 SET OF 中所有元素的类型都一样,仅靠标签排序是不够的。 所以 SET OF 中的元素以编码后的字节序为准,编码较短的就先在最后补齐零字节再作排序。
BIT STRING 的编码
一个 N 位的 BIT STRING 用 N/8 个字节(向上取整)来编码,开头还有一个字节的前缀,当 N 不是 8 的倍数时可以指明最后有几个二进制位是无用的。 例如,编码 18 个二进制位 011011100101110111 至少需要三个字节, 但三个字节其实足以存放 24 个二进制位, 有 6 位是用不上的。 这 6 位就放在末尾,于是最终编码为:
03 04 06 6e 5d c0
在 BER 中这些无用的二进制位可以取任意值,所以最后一个字节也可以是 c1、c2、c3 等等。 DER 则要求无用的位都必须为 0。
OCTET STRING 的编码
OCTET STRING 的编码就是其中包含的所有字节。 例如,包含四个字节 03 02 06 A0 的 OCTET STRING 的编码为:
04 04 03 02 06 A0
CHOICE 和 ANY 的编码
CHOICE 和 ANY 的编码与其最终表示的实际类型一致,但也可以通过编码指令更改。 假如一份 ASN.1 相关规范要求某一个 CHOICE 字段必须是 INTEGER 或 UTCTime,而现在该字段恰好属于 INTEGER,那就按 INTEGER 编码即可。
但实际应用中 CHOICE 往往都有编码指令。 比如在 RFC 5280 的下列定义中,rfc822Name 和 dNSName 的类型都是 IA5String,只有靠编码指令才能区分。
GeneralName ::= CHOICE {
otherName [0] OtherName,
rfc822Name [1] IA5String,
dNSName [2] IA5String,
x400Address [3] ORAddress,
directoryName [4] Name,
ediPartyName [5] EDIPartyName,
uniformResourceIdentifier [6] IA5String,
iPAddress [7] OCTET STRING,
registeredID [8] OBJECT IDENTIFIER }
举例来说,如果一个 GeneralName 里包含的是 rfc822Name,值为 a@example.com,则编码如下(回忆一下,[1] 表示标签值为 1,类别为特定语境标签,即第 8 位为 1,并且标签编码方式为 IMPLICIT):
81 0d 61 40 65 78 61 6d 70 6c 65 2e 63 6f 6d
如果 GeneralName 里包含的是 dNSName,值为“example.com”,编码则是:
82 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d
解码 BER 和 DER 格式时必须格外小心,尤其是在 C、C++ 等非内存安全的编程语言中。 各种解码器的安全漏洞可谓罄竹难书, 而解析用户输入本身就是安全问题的一大来源。 ASN.1 编码与漏洞近乎如影随形, 毕竟这种格式颇为复杂,各种不定长度的字段不计其数, 连长度字段本身都没有固定长度! 另一方面,ASN.1 格式的数据又往往来自潜在的攻击者, 所以如果要靠数字证书验证用户身份,不能光考虑如何解码正确的证书,还得应对五花八门的恶意输入,以免 ASN.1 代码中存在漏洞而被攻陷。
面对这些问题,最好的解决办法就是尽可能使用内存安全的编程语言, 并且无论能否使用这类语言,都应当借助现成的 ASN.1 编译器生成解析程序,而非闭门造车,自行编写解码器。
首先我要向 A Layman’s Guide to a Subset of ASN.1, DER, and BER 致以诚挚的敬意,本文中的大部分知识我都是从这份材料中学到的。 我还要感谢另一篇佳作 A warm welcome to DNS 的作者,其文风也奠定了本文的笔法基调。
一点题外话
你可曾注意到 PEM 编码的证书开头都是“MII”? 例如:
-----BEGIN CERTIFICATE-----
MIIFajCCBFKgAwIBAgISA6HJW9qjaoJoMn8iU8vTuiQ2MA0GCSqGSIb3DQEBCwUA
现在你已经能解释原因了! 证书本身是一个 SEQUENCE 结构,所以第一个字节是 0x30。 接下来是长度字段, 绝大多数证书都不止 127 个字节,所以需要采用长编码, 也就是说第一个字节是 0x80 + N,意味着后面还有 N 个字节表示长度。 N 一般都是 2,因为大多数证书的长度都在 128 至 65535 个字节之间,只需要两个字节表示。
于是我们可以得知 DER 格式的证书前两个字节是 0x30 0x82。 但 PEM 采用了 Base64 编码,将每 3 个二进制字节转换成 4 个 ASCII 字符。 换句话说,Base64 是将 24 个二进制位写成 4 个 ASCII 字符,每个字符表示 6 个二进制位。 我们已经知道证书的前 16 位是什么了, 要证明(几乎)所有证书开头都是“MII”,还需确定接下来的两个二进制位。 这两位正是长度字节的最高位, 它们会是 1 吗? 那这个证书就非得超过 16383 个字节不可! 由此我们可以断定 PEM 证书开头的几个字符都是相同的。 你不妨也试试看:
xxd -r -p <<<308200 | base64