欢迎来到 ASN.1 和 DER

用丹麦语查看

用法语查看

切换到希伯来语

用日语查看

用乌克兰语查看

阅读简体中文页面

本文档提供了一个关于定义 HTTPS 中使用的证书的数据结构和格式的温和介绍。它应该对任何具有少量计算机科学经验和对证书有一定了解的人来说都是可理解的。

HTTPS 证书是一种文件类型,与其他文件类型类似。其内容遵循由RFC 5280定义的格式。这些定义是用 ASN.1 表达的,ASN.1 是一种用于定义文件格式或(等效地)数据结构的语言。例如,在 C 中,你可能会写

struct point {
  int x, y;
  char label[10];
};

在 Go 中,你可能会写

type point struct {
  x, y int
  label string
}

而在 ASN.1 中,你可能会写

Point ::= SEQUENCE {
  x INTEGER,
  y INTEGER,
  label UTF8String
}

使用 ASN.1 定义而不是 Go 或 C 定义的优点是,它们与语言无关。你可以在任何语言中实现 Point 的 ASN.1 定义,或者(最好)可以使用一个工具,该工具接受 ASN.1 定义并自动生成在你的首选语言中实现它的代码。一组 ASN.1 定义称为“模块”。

关于 ASN.1 的另一个重要方面是它带有多种序列化格式——将内存中的数据结构转换为字节序列(或文件)并反之亦然的方式。这允许由一台机器生成的证书被另一台机器读取,即使该机器使用不同的 CPU 和操作系统。

还有一些其他语言可以执行与 ASN.1 相同的操作。例如,Protocol Buffers 提供了语言用于定义类型,以及序列化格式用于编码已定义类型的对象。Thrift也同时具有语言和序列化格式。Protocol Buffers 或 Thrift 都可以轻松用于定义 HTTPS 证书的格式,但 ASN.1(1984 年)在证书(1988 年)和 HTTPS(1994 年)发明时已经存在,因此具有显著的优势。

ASN.1 经过多年多次修订,版本通常以其发布年份标识。本文档旨在教授足够多的 ASN.1 内容,以便清楚地理解 RFC 5280 和其他与 HTTPS 证书相关的标准,因此我们将主要讨论 1988 年版本,并对以后版本中添加的功能做一些说明。你可以直接从 ITU 下载各个版本,但要注意,有些版本只对 ITU 成员开放。相关标准是X.680(定义 ASN.1 语言)和X.690(定义序列化格式 DER 和 BER)。这些标准的早期版本分别是X.208X.209

ASN.1 的主要序列化格式是“区分编码规则”(DER)。它们是“基本编码规则”(BER)的变体,添加了规范化。例如,如果一个类型包含 SET OF,则成员必须在 DER 序列化中排序。

以 DER 表示的证书通常会进一步编码为 PEM,PEM 使用base64将任意字节编码为字母数字字符(以及“+”和“/”)并添加分隔线(“-----BEGIN CERTIFICATE-----”和“-----END CERTIFICATE-----”)。PEM 很有用,因为它更容易复制粘贴。

本文档将首先描述 ASN.1 使用的类型和符号,然后描述如何对使用 ASN.1 定义的对象进行编码。请随时在各节之间来回翻阅,特别是因为 ASN.1 语言的一些特性直接指定了编码细节。本文档更喜欢使用更熟悉的术语,因此使用“字节”代替“八位字节”,使用“值”代替“内容”。它使用“序列化”和“编码”来互换使用。

类型

INTEGER

好熟悉的老 INTEGER。它们可以是正数或负数。ASN.1 INTEGER 非常不同寻常的地方在于它们可以任意大。int64 不够大?没问题。这对于表示诸如 RSA 模数之类的东西特别有用,RSA 模数比 int64 大得多(例如 22048)。从技术上讲,DER 中存在最大整数,但它非常大:任何 DER 字段的长度都可以表示为最多 126 个字节的序列。因此,你在 DER 中可以表示的最大 INTEGER 是 256(2**1008)-1。对于真正无界的 INTEGER,你必须在 BER 中进行编码,BER 允许无限长的字段。

字符串

ASN.1 拥有大量字符串类型:BMPString、GeneralString、GraphicString、IA5String、ISO646String、NumericString、PrintableString、TeletexString、T61String、UniversalString、UTF8String、VideotexString 和 VisibleString。就 HTTPS 证书而言,你主要需要关心 PrintableString、UTF8String 和IA5String。特定字段的字符串类型由定义该字段的 ASN.1 模块定义。例如

CPSuri ::= IA5String

PrintableString 是 ASCII 的一个受限子集,允许字母数字、空格以及一小部分特定标点符号:' () + , - . / : = ?。值得注意的是,它不包含 *@。对于更严格的字符串类型,没有存储大小优势。

一些字段,例如RFC 5280 中的 DirectoryString,允许序列化代码在多种字符串类型之间进行选择。由于 DER 编码包含你正在使用的字符串类型,因此请确保当你将某些内容编码为 PrintableString 时,它真正满足 PrintableString 要求

IA5String 基于国际字母表 5 号,更宽松:它允许几乎所有 ASCII 字符,用于证书中的电子邮件地址、DNS 名称和 URL。请注意,有一些字节值,其中 IA5 对字节值的含义不同于 US-ASCII 对相同值的含义。

TeletexString、BMPString 和 UniversalString 已被弃用,不再用于 HTTPS 证书,但在解析较旧的 CA 证书时,你可能会看到它们,因为这些证书的使用寿命很长,可能早于弃用日期。

ASN.1 中的字符串不像 C 和 C++ 中的字符串那样以 null 结尾。事实上,包含嵌入式 null 字节是完全合法的。当两个系统对同一个 ASN.1 字符串进行不同的解释时,这可能会导致漏洞。例如,一些 CA 曾经能够被欺骗,根据对 evil.com 的所有权来颁发“example.com\0.evil.com”。当时的证书验证库将结果视为对“example.com”的有效验证。在 C 和 C++ 中处理 ASN.1 字符串时要非常小心,避免创建漏洞。

日期和时间

同样,还有很多时间类型:UTCTime、GeneralizedTime、DATE、TIME-OF-DAY、DATE-TIME 和 DURATION。对于 HTTPS 证书,你只需要关心 UTCTime 和 GeneralizedTime。

UTCTime 以 YYMMDDhhmm[ss] 的格式表示日期和时间,并带有可选的时区偏移量或“Z”来表示 Zulu(即 UTC,即 0 时区偏移量)。例如,UTCTimes 820102120000Z 和 820102070000-0500 都表示相同的时间:1982 年 1 月 2 日,纽约时间(UTC-5)上午 7 点,UTC 时间下午 12 点。

由于 UTCTime 在是 1900 年代还是 2000 年代方面存在歧义,RFC 5280 阐明它表示 1950 年到 2050 年之间的日期。RFC 5280 还要求必须使用“Z”时区,并且必须包含秒。

GeneralizedTime 通过简单地使用四位数字表示年份来支持 2050 年之后的日期。它还允许小数秒(奇怪的是,使用逗号或句号作为小数点分隔符)。RFC 5280 禁止使用小数秒,并要求使用“Z”。

OBJECT IDENTIFIER

对象标识符是全局唯一的,分层的标识符,由一系列整数组成。它们可以指代任何类型的“事物”,但通常用于标识标准、算法、证书扩展、组织或策略文档。例如:1.2.840.113549标识 RSA Security LLC。RSA 然后可以分配以该前缀开头的 OID,例如1.2.840.113549.1.1.11,它标识 sha256WithRSAEncryption,如RFC 8017中所定义。

类似地,1.3.6.1.4.1.11129标识 Google, Inc. Google 将1.3.6.1.4.1.11129.2.4.2分配给标识SCT 列表扩展,该扩展用于证书透明度(最初由 Google 开发),如RFC 6962中所定义。

可以在给定前缀下存在的子 OID 集称为“OID 弧”。由于较短 OID 的表示更小,因此较短弧下的 OID 分配被认为更有价值,特别是对于必须发送很多次该 OID 的格式。OID 弧2.5分配给“目录服务”,它是一系列规范,其中包括 X.509,HTTPS 证书基于此。证书中的许多字段都以该方便的短弧开头。例如,2.5.4.6表示“countryName”,而2.5.4.10表示“organizationName”。由于大多数证书都必须至少编码一次这些 OID,因此它们很短非常方便。

规范中的 OID 通常以人类可读的名称表示,以便于理解,并且可以通过与另一个 OID 连接来指定。例如,来自 RFC 8017

   pkcs-1    OBJECT IDENTIFIER ::= {
       iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1) 1
   }
   ...

   sha256WithRSAEncryption      OBJECT IDENTIFIER ::= { pkcs-1 11 }

NULL

NULL 就是 NULL,你知道的。

SEQUENCE 和 SEQUENCE OF

不要被名字迷惑:这两种类型截然不同。SEQUENCE 等同于大多数编程语言中的“struct”。它包含固定数量的不同类型字段。例如,请参见下面的 Certificate 示例

另一方面,SEQUENCE OF 包含任意数量的单一类型字段。这类似于编程语言中的数组或列表。例如

   RDNSequence ::= SEQUENCE OF RelativeDistinguishedName

它可以是 0、1 或 7,000 个 RelativeDistinguishedNames,以特定顺序排列。

事实证明,SEQUENCE 和 SEQUENCE OF 确实有一个共同点——它们都以相同的方式编码!更多内容请参见编码部分。

SET 和 SET OF

它们与 SEQUENCE 和 SEQUENCE OF 相当类似,只是它们刻意没有对元素的排序附加语义。但是,在编码形式中,它们必须进行排序。一个示例

RelativeDistinguishedName ::=
  SET SIZE (1..MAX) OF AttributeTypeAndValue

注意:此示例使用 SIZE 关键字来额外指定 RelativeDistinguishedName 必须至少包含一个成员,但通常 SET 或 SET OF 允许大小为零。

BIT STRING 和 OCTET STRING

这些分别包含任意位或字节。 它们可用于保存非结构化数据,例如随机数或哈希函数输出。 它们也可以像 C 中的空指针或 Go 中的空接口类型 (interface{}) 一样使用:一种保存确实具有结构的数据的方法,但这种结构是在类型系统之外被理解或定义的。 例如,证书上的签名被定义为一个 BIT STRING

Certificate  ::=  SEQUENCE  {
     tbsCertificate       TBSCertificate,
     signatureAlgorithm   AlgorithmIdentifier,
     signature            BIT STRING  }

ASN.1 语言的后续版本 允许更详细地指定 BIT STRING 内部的内容(OCTET STRINGs 也是如此)。

CHOICE 和 ANY

CHOICE 是一种可以包含其定义中列出的类型之一的类型。 例如,Time 可以包含 UTCTime 或 GeneralizedTime 之一。

Time ::= CHOICE {
     utcTime        UTCTime,
     generalTime    GeneralizedTime }

ANY 表示值可以是任何类型。 在实践中,它通常受 ASN.1 语法中无法完全表达的事物限制。 例如

   AttributeTypeAndValue ::= SEQUENCE {
     type     AttributeType,
     value    AttributeValue }

   AttributeType ::= OBJECT IDENTIFIER

   AttributeValue ::= ANY -- DEFINED BY AttributeType

这对于扩展特别有用,因为您希望为在发布主要规范后分别定义的附加字段留出空间,因此您有方法注册新类型(对象标识符)并允许这些类型的定义指定新字段的结构应该是什么。

请注意,ANY 是 1988 年 ASN.1 符号的遗留产物。 在 1994 年版 中,ANY 已被弃用并替换为信息对象类,信息对象类是指定人们希望从 ANY 获得的扩展行为的一种花哨的、正式化的方式。 这种改变现在已经很老了,以至于最新的 ASN.1 规范(来自 2015 年)甚至没有提及 ANY。 但是如果你查看 1994 年版本,你可以看到一些关于切换的讨论。 我在这里包含旧的语法,因为 RFC 5280 仍然使用它。 RFC 5912 使用 2002 年 ASN.1 语法来表达 RFC 5280 和几个相关规范中相同的类型。

其他符号

注释以 -- 开头。 SEQUENCE 或 SET 的字段可以标记为 OPTIONAL,也可以标记为 DEFAULT foo,这与 OPTIONAL 的含义相同,只是当字段不存在时,应将其视为包含“foo”。 具有长度的类型(字符串、八位字节和位字符串、集合和序列 OF 事物)可以被赋予一个 SIZE 参数,该参数限制它们的长度,使其等于精确长度或处于某个范围内。

可以通过在类型定义之后使用大括号来限制类型具有某些值。 此示例定义了 Version 字段可以具有三个值,并为这些值分配有意义的名称。

Version ::= INTEGER { v1(0), v2(1), v3(2) }

这通常也用于为特定的 OID 命名(注意这是一个单一值,没有逗号表示备选值)。 RFC 5280 中的示例

id-pkix  OBJECT IDENTIFIER  ::=
         { iso(1) identified-organization(3) dod(6) internet(1)
                    security(5) mechanisms(5) pkix(7) }

您还将看到 [number]、IMPLICIT、EXPLICIT、UNIVERSAL 和 APPLICATION。 这些定义了值应如何编码的细节,我们将在下面讨论。

编码

ASN.1 与许多编码相关联:BER、DER、PER、XER 等等。 基本编码规则 (BER) 非常灵活。 鉴别编码规则 (DER) 是 BER 的一个子集,具有 规范化 规则,因此只有一种方法可以表达给定的结构。 封装编码规则 (PER) 使用更少的字节来编码事物,因此当空间或传输时间是主要问题时它们很有用。 XML 编码规则 (XER) 在某些情况下使用 XML 时很有用。

HTTPS 证书通常以 DER 编码。 可以用 BER 对它们进行编码,但是由于签名值是在等效的 DER 编码上计算的,而不是证书中的确切字节,因此用 BER 编码证书会导致不必要的麻烦。 我将描述 BER,并在进行过程中解释 DER 提供的额外限制。

我鼓励你在另一个窗口中打开 实际证书的解码 在另一个窗口中打开。

类型-长度-值

BER 是一种类型-长度-值编码,就像 Protocol Buffers 和 Thrift 一样。 这意味着,当您读取使用 BER 编码的字节时,首先会遇到一个类型,在 ASN.1 中称为标记。 这是一个字节,或一系列字节,它告诉您编码的类型是什么:INTEGER、UTF8String、结构或其他任何内容。

类型 长度
02 03 01 00 01

接下来会遇到一个长度:一个数字,它告诉您需要读取多少个字节才能获得该值。 当然,接下来就是包含值本身的字节。 例如,十六进制字节 02 03 01 00 01 表示一个 INTEGER(标记 02 对应于 INTEGER 类型),长度为 03,一个三字节值为 01 00 01。

类型-长度-值与 JSON、CSV 或 XML 等分隔符编码不同,在分隔符编码中,您不会提前知道字段的长度,而是会读取字节,直到遇到预期的分隔符(例如 JSON 中的 } 或 XML 中的 </some-tag>)。

标记

标记通常是一个字节。 有一种方法可以使用多个字节来编码任意大的标记数字(“高标记数字”形式),但这通常不需要。

以下是一些示例标记

标记(十进制) 标记(十六进制) 类型
2 02 INTEGER
3 03 BIT STRING
4 04 OCTET STRING
5 05 NULL
6 06 OBJECT IDENTIFIER
12 0C UTF8String
16 10(和 30)* SEQUENCE 和 SEQUENCE OF
17 11(和 31)* SET 和 SET OF
19 13 PrintableString
22 16 IA5String
23 17 UTCTime
24 18 GeneralizedTime

这些,以及我跳过的几个无聊的,是“通用”标记,因为它们在核心 ASN.1 规范中指定,并且在所有 ASN.1 模块中含义相同。

这些标记都恰好小于 31(0x1F),这是有充分理由的:位 8、7 和 6(标记字节的高位)用于编码额外信息,因此任何大于 31 的通用标记数字都需要使用“高标记数字”形式,这需要额外的字节。 有一些通用标记大于 31,但它们非常罕见。

* 标记的两个标记始终编码为 0x30 或 0x31,因为位 6 用于指示字段是构造的还是原始的。 这些标记始终是构造的,因此它们的编码将位 6 设置为 1。 有关详细信息,请参阅 构造的与原始的 部分。

标记类

仅仅因为通用类用尽了所有“好的”标记数字,并不意味着我们没有机会定义自己的标记。 还有“应用程序”、“私有”和“上下文特定”类。 这些由位 8 和 7 区分

位 8 位 7
通用 0 0
应用程序 0 1
上下文特定 1 0
私有 1 1

规范主要使用通用类中的标记,因为它们提供了最重要的构建块。 例如,证书中的序列号以普通 INTEGER 编码,标记号为 0x02。 但有时规范需要在上下文特定类中定义标记,以区分定义可选条目的 SET 或 SEQUENCE 中的条目,或者区分具有相同类型的多个条目的 CHOICE。 例如,考虑以下定义

Point ::= SEQUENCE {
  x INTEGER OPTIONAL,
  y INTEGER OPTIONAL
}

由于 OPTIONAL 字段在不存在时完全从编码中省略,因此无法区分只有一个 x 坐标的 Point 和只有一个 y 坐标的 Point。 例如,您将只包含 x 坐标为 9 的 Point 编码如下(30 在此处表示 SEQUENCE)

30 03 02 01 09

这是一个长度为 3(字节)的 SEQUENCE,包含一个长度为 1 的 INTEGER,其值为 9。 但您也会以完全相同的方式编码一个 y 坐标为 9 的 Point,因此存在歧义。

编码指令

为了解决这种歧义,规范需要提供编码指令,为每个条目分配一个唯一的标记。 由于我们不允许践踏 UNIVERSAL 标记,因此我们必须使用其他标记之一,例如 APPLICATION

Point ::= SEQUENCE {
  x [APPLICATION 0] INTEGER OPTIONAL,
  y [APPLICATION 1] INTEGER OPTIONAL
}

虽然对于这种用例,使用上下文特定类实际上更常见,上下文特定类由方括号中的数字本身表示

Point ::= SEQUENCE {
  x [0] INTEGER OPTIONAL,
  y [1] INTEGER OPTIONAL
}

因此现在,要编码一个只有 x 坐标为 9 的 Point,而不是将 x 编码为 UNIVERSAL INTEGER,您将设置编码标记的位 8 和 7 为 (1, 0) 以指示上下文特定类,并将低位设置为 0,得到以下编码

30 03 80 01 09

要表示一个只有 y 坐标为 9 的 Point,您将执行相同的操作,只是将低位设置为 1

30 03 81 01 09

或者,您可以表示一个 x 和 y 坐标都等于 9 的 Point

30 06 80 01 09 81 01 09

长度

标记-长度-值元组中的长度始终表示对象中的字节总数,包括所有子对象。 因此,包含一个字段的 SEQUENCE 的长度不为 1;它的长度为该字段的编码形式占用多少个字节。

长度的编码可以采用两种形式:短形式或长形式。 短形式是一个字节,介于 0 和 127 之间。

长形式至少有两个字节,并且第一个字节的位 8 设置为 1。 第一个字节的位 7-1 指示长度字段本身有多少个字节。 然后,其余字节指定长度本身,作为多字节整数。

正如您所想象的,这允许非常长的值。 最长的可能长度将以字节 254 开头(255 的长度字节保留用于将来的扩展),指定长度字段本身将跟随 126 个字节。 如果这 126 个字节中的每一个都是 255,那么这将表示值字段中将跟随 21008-1 个字节。

长形式允许您以多种方式编码相同的长度 - 例如,使用两个字节来表达可以在一个字节中容纳的长度,或者使用长形式来表达可以在短形式中容纳的长度。 DER 指示始终使用最小的可能长度表示。

安全警告:不要完全信任您解码的长度值! 例如,检查编码的长度是否小于从正在解码的流中获取的数据量。

不定长度

在 BER 中,还可以编码一个字符串、SEQUENCE、SEQUENCE OF、SET 或 SET OF,而您事先不知道长度(例如,在流式输出时)。 为此,您将长度编码为一个值为 80 的字节,并将值编码为一系列串联在一起的编码对象,以两个字节 00 00(可以视为标记为 0 的零长度对象)表示结束。 因此,例如,UTF8String 的不定长度编码将是串联在一起的一个或多个 UTF8String 的编码,最后与 00 00 串联在一起。

不定性可以任意嵌套! 因此,例如,您串联在一起以形成不定长度 UTF8String 的 UTF8String 本身可以编码为定长或不定长。

长度字节 80 是独特的,因为它不是有效的短形式或长形式长度。由于位 8 被设置为 1,这通常会被解释为长形式,但剩余的位应该表示构成长度的额外字节数。由于位 7-1 全部为 0,这意味着一个长形式编码,其中包含 0 个字节构成长度,这是不允许的。

DER 禁止使用不确定长度编码。您必须使用确定长度编码(即在开头指定长度)。

构造型与原始型

第一个标签字节的位 6 用于指示值是以原始形式还是构造形式编码的。原始编码直接表示值 - 例如,在 UTF8String 中,值将仅包含字符串本身,以 UTF-8 字节表示。构造编码表示值为其他编码值的串联。例如,如“不确定长度”部分所述,构造编码中的 UTF8String 将由多个编码的 UTF8String(每个都有标签和长度)组成,并联在一起。整个 UTF8String 的长度将是所有这些串联编码值的所有字节的总长度。构造编码可以使用确定长度或不确定长度。原始编码始终使用确定长度,因为没有办法在不使用构造编码的情况下表达不确定长度。

INTEGER、OBJECT IDENTIFIER 和 NULL 必须使用原始编码。SEQUENCE、SEQUENCE OF、SET 和 SET OF 必须使用构造编码(因为它们本质上是多个值的串联)。BIT STRING、OCTET STRING、UTCTime、GeneralizedTime 和各种字符串类型可以使用原始编码或构造编码,由发送方决定 - 在 BER 中。但是,在 DER 中,所有在原始编码和构造编码之间有编码选择类型的类型都必须使用原始编码。

显式与隐式

上面描述的 编码指令,例如 [1][APPLICATION 8],也可以包含关键字 EXPLICIT 或 IMPLICIT (RFC 5280 中的示例)

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 始终与特定标签号一起使用)。IMPLICIT 使用与底层类型相同的标签号和类进行编码,但使用 ASN.1 模块中提供的标签号和类。EXPLICIT 将字段编码为底层类型,然后将其包装在外部编码中。外部编码具有来自 ASN.1 模块的标签号和类,并且还设置了 构造位

以下是一个使用 IMPLICIT 的 ASN.1 编码指令的示例

[5] IMPLICIT UTF8String

这将把“hi”编码为

85 02 68 69

与使用 EXPLICIT 的此 ASN.1 编码指令进行比较

[5] EXPLICIT UTF8String

这将把“hi”编码为

A5 04 0C 02 68 69

当没有 IMPLICIT 或 EXPLICIT 关键字时,默认值为 EXPLICIT,除非模块在顶部使用“EXPLICIT TAGS”、“IMPLICIT TAGS”或“AUTOMATIC TAGS”设置不同的默认值。例如,RFC 5280 定义了两个模块,一个模块 EXPLICIT 标签为默认值,另一个模块导入第一个模块,并且 IMPLICIT 标签为默认值。隐式编码比显式编码使用更少的字节。

AUTOMATIC TAGS 与 IMPLICIT TAGS 相同,但具有附加属性,即标签号([0][1] 等)在需要它们的地方(如具有可选字段的 SEQUENCE)自动分配。

特定类型的编码

在本节中,我们将讨论每种类型的值的编码方式,并提供示例。

INTEGER 编码

整数被编码为一个或多个字节,以二进制补码形式表示,最左侧字节的高位(位 8)作为符号位。正如 BER 规范所说

二进制补码的数值是通过对内容字节中的位进行编号来获得的,从最后一个字节的位 1 作为位 0 开始,到第一个字节的位 8 结束。每个位被分配一个 2N 的数值,其中 N 是它在上述编号序列中的位置。二进制补码的值是通过将分配给每个设置为 1 的位的数值(不包括第一个字节的位 8)相加,然后从该值中减去分配给第一个字节的位 8 的数值(如果该位设置为 1)来获得的。

例如,此一位字节值(以二进制表示)编码十进制 50

00110010 (== 十进制 50)

此一位字节值(以二进制表示)编码十进制 -100

10011100 (== 十进制 -100)

此五位字节值(以二进制表示)编码十进制 -549755813887(即 -239 + 1)

10000000 00000000 00000000 00000000 00000001 (== 十进制 -549755813887)

BER 和 DER 都要求整数以最短形式表示。这是通过以下规则强制执行的

... the bits of the first octet and bit 8 of the second octet:

1.  shall not all be ones; and
2.  shall not all be zero.

规则 (2) 大致意味着:如果编码中存在前导零字节,你可以直接省略它们,而不会影响数值。第二个字节的位 8 在这里也很重要,因为如果你想要表示某些值,你必须使用前导零字节。例如,十进制 255 编码为两个字节

00000000 11111111

这是因为 11111111 的单字节编码本身表示 -1(位 8 被视为符号位)。

规则 (1) 最好通过一个例子来解释。十进制 -128 编码为

10000000 (== 十进制 -128)

但是,它也可以编码为

11111111 10000000 (== 十进制 -128,但无效编码)

展开后,它就是 -215 + 214 + 213 + 212 + 211 + 210 + 29 + 28 + 27 == -27 == -128。请注意,“10000000”中的 1 在单字节编码中是符号位,但在双字节编码中表示 27 。

这是一个通用的转换:对于任何以 BER(或 DER)编码的负数,你可以在它前面加上 11111111,并将得到相同的数值。这被称为 符号扩展。或者等效地,如果有一个负数,其值的编码以 11111111 开头,你可以删除该字节,仍然得到相同的数值。因此 BER 和 DER 要求使用最短编码。

INTEGER 的二进制补码编码对 证书签发有实际影响:RFC 5280 要求序列号为正数。由于第一个位始终是符号位,这意味着以 DER 编码的 8 字节序列号最多可以有 63 位长。编码 64 位正序列号需要一个 9 字节编码值(第一个字节为零)。

以下是一个值为 263+1 的 INTEGER 的编码(恰好是一个 64 位正数)

02 09 00 80 00 00 00 00 00 00 01

字符串编码

字符串被编码为它们字面上的字节。由于 IA5String 和 PrintableString 只定义了可接受字符的不同子集,因此它们的编码只在标签上有所不同。

包含“hi”的 PrintableString

13 02 68 69

包含“hi”的 IA5String

16 02 68 69

UTF8String 也是一样的,但可以编码更多种字符。例如,这是包含 U+1F60E 笑脸戴墨镜(😎)的 UTF8String 的编码

0c 04 f0 9f 98 8e

日期和时间编码

UTCTime 和 GeneralizedTime 实际上是像字符串一样编码的,令人惊讶!如上所述的“类型”部分,UTCTime 以 YYMMDDhhmmss 的格式表示日期。GeneralizedTime 使用四位年份 YYYY 代替 YY。两者都具有可选的时区偏移量或“Z”(Zulu)来表示没有时区偏移量,与 UTC 相同。

例如,2019 年 12 月 15 日 19:02:10 PST 时区(UTC-8)在 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”形式表示为 UTC。

上面的日期将以 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。因此,前两个组件被明确地表示为 40*X+Y,其中 X 是第一个组件,Y 是第二个组件。

因此,例如,要编码 2.999.3,您将前两个组件组合成十进制 1079(40*2 + 999),这将为您提供“1079.3”。

应用此转换后,每个组件都以 128 为基数进行编码,最高有效字节在前。除了一个组件中的最后一个字节以外,位 8 都被设置为“1”;这就是您知道一个组件何时结束以及下一个组件何时开始的方式。因此,组件“3”将被简单地表示为字节 0x03。组件“129”将被表示为字节 0x81 0x01。编码后,OID 的所有组件都连接在一起,形成 OID 的编码值。

OID 必须以最少的字节数表示,无论是在 BER 还是 DER 中。因此,组件不能以字节 0x80 开头。

例如,OID 1.2.840.113549.1.1.11(表示 sha256WithRSAEncryption)编码如下

06 09 2a 86 48 86 f7 0d 01 01 0b

NULL 编码

包含 NULL 的对象的价值始终为零长度,因此 NULL 的编码始终只是标签和长度字段为零

05 00

SEQUENCE 编码

关于 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  }

以下是如何编码包含 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 完全相同。它甚至使用相同的标签!如果您正在解码,唯一能够区分 SEQUENCE 和 SEQUENCE OF 的方法是参考 ASN.1 模块。

以下是如何编码一个包含数字 7、8 和 9 的 SEQUENCE OF INTEGER

30 09 02 01 07 02 01 08 02 01 09

SET 编码

与 SEQUENCE 一样,SET 也是 Contructed,这意味着它的值字节是其编码字段的串联。它的标签号是 0x11。由于 构造型与原始型 位(位 6)始终设置为 1,这意味着它以标签字节 0x31 进行编码。

与 SEQUENCE 相似,SET 的编码会省略缺失或具有默认值的 OPTIONAL 和 DEFAULT 字段。由相同类型字段导致的任何歧义必须由 ASN.1 模块解决,并且如果 DEFAULT 字段具有默认值,则必须从 DER 编码中省略它们。

在 BER 中,SET 可以以任何顺序编码。在 DER 中,SET 必须按照每个元素的序列化值的升序进行编码。

SET OF 编码

SET OF 项目的编码方式与 SET 相同,包括 0x31 的标签字节。对于 DER 编码,有一个类似的要求,即 SET OF 必须按升序编码。因为 SET OF 中的所有元素具有相同的类型,所以仅按标签排序是不够的。因此,SET OF 的元素按其编码值排序,较短的值被视为在右侧填充了零。

BIT STRING 编码

N 位的 BIT STRING 编码为 N/8 字节(向上取整),并带有一个字节前缀,其中包含“未使用的位数”,以便在位数不是 8 的倍数时清楚起见。例如,编码位字符串 011011100101110111(18 位)时,我们至少需要三个字节。但这有点多余:它为我们提供了总共 24 位的容量。其中六位将未使用。这六位写在位字符串的最右端,因此编码为

03 04 06 6e 5d c0

在 BER 中,未使用的位可以具有任何值,因此该编码的最后一个字节也可以是 c1、c2、c3 等。在 DER 中,未使用的位必须全部为零。

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 }

以下是一个包含 rfc822Name a@example.com 的 GeneralName 的编码示例(回想一下,[1] 表示使用标签号 1,在标签类“上下文特定”(第 8 位设置为 1)中,使用 IMPLICIT 标签编码方法)

81 0d 61 40 65 78 61 6d 70 6c 65 2e 63 6f 6d

以下是一个包含 dNSName “example.com” 的 GeneralName 的编码示例

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 编译器 生成解析代码,而不是从头编写代码。

致谢

我对ASN.1、DER 和 BER 子集的门外汉指南 负有很大的债务,这是我学习这些主题的主要方式。我还想感谢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”,我们需要查看接下来的 2 位。这些将是两个长度字节中最重要字节的最高有效位。这些位会被设置为 1 吗?除非证书超过 16,383 字节,否则不会!因此我们可以预测 PEM 证书的第一个字符将始终相同。自己试试吧

xxd -r -p <<<308200 | base64