PKI 的位和字节

深入了解公钥基础设施的内部结构,以便更好地理解其格式。
106 位读者喜欢这篇文章。
How do we fix the state of technical documentation?

Victor 通过 Flickr。CC BY 2.0

在前两篇文章——密码学和公钥基础设施简介 和 私钥如何在 PKI 和密码学中工作?——我以一般方式讨论了密码学和公钥基础设施 (PKI)。我谈到了称为证书的数字包如何存储公钥和识别信息。这些包包含很多复杂性,基本了解格式对于您需要深入了解内部结构时很有用。

抽象艺术

密钥、证书签名请求、证书和其他 PKI 工件在称为 抽象语法表示法一 (ASN.1) 的数据描述语言中定义自身。ASN.1 定义了一系列简单的数据类型(整数、字符串、日期等)以及一些结构化类型(序列、集合)。通过使用这些类型作为构建块,我们可以创建非常复杂的数据格式。

然而,ASN.1 包含许多对粗心大意者来说是陷阱的地方。例如,它有两种不同的日期表示方式:GeneralizedTime (ISO 8601 格式) 和 UTCTime(使用两位数年份)。字符串引入了更多的混乱。我们有用于 ASCII 字符串的 IA5String 和用于 Unicode 字符串的 UTF8String。ASN.1 还定义了其他几种字符串类型,从奇异的 T61StringTeletexString 到听起来更无害但可能不是您想要的 PrintableString(仅 ASCII 的一个小子集)和 UniversalString(以 UTF-32 编码)。如果您正在编写或读取 ASN.1 数据,我建议参考规范

ASN.1 还有另一种值得特别提及的数据类型:对象标识符 (OID)。OID 是一系列整数。通常,它们以句点分隔显示。每个整数代表基本上是“事物树”中的一个节点。例如,1.3.6.1.4.1.2312 是我的雇主 Red Hat 的 OID,其中“1”是国际标准化组织 (ISO) 的节点,“3”是 ISO 识别的组织,“6”是美国国防部(由于历史原因,它是下一个节点的父节点),“1”是互联网,“4”是私有组织,“1”是企业,最后是“2312”,这是 Red Hat 自己的。

更常见的是,OID 通常用于识别 PKI 对象中的特定算法。如果您有数字签名,如果您不知道它是什么类型的签名,它就没什么用处。“sha256WithRSAEncryption”签名算法的 OID 为“1.2.840.113549.1.1.11”,例如。

ASN.1 的工作原理

假设我们拥有一家生产飞行扫帚的工厂,我们需要存储有关每把扫帚的一些数据。我们的扫帚有型号名称、序列号以及为确保飞行安全性而进行的一系列检查。我们可以使用如下所示的 ASN.1 存储此信息

BroomInfo ::= SEQUENCE {
  model UTF8String,
  serialNumber INTEGER,
  inspections SEQUENCE OF InspectionInfo
}

InspectionInfo ::= SEQUENCE {
  inspectorName UTF8String,
  inspectionDate GeneralizedTime
}

上面的示例将型号名称定义为 UTF8 编码的字符串,将序列号定义为整数,并将我们的检查定义为一系列 InspectionInfo 项。然后我们看到每个 InspectionInfo 项包含两部分数据:检查员姓名和检查时间。

BroomInfo 数据的实际实例在 ASN.1 的值分配语法中看起来像这样

broom BroomInfo ::= {
  model "Nimbus 2000",
  serialNumber 1066,
  inspections {
    {
      inspectorName "Harry",
      inspectionDate "201901011200Z"
    }
    {
      inspectorName "Hagrid",
      inspectionDate "201902011200Z"
    }
  }
}

不要太担心语法的细节;对于普通开发人员来说,基本掌握各个部分如何组合在一起就足够了。

现在让我们看一下 RFC 8017 中的一个真实示例,为了清晰起见,我对其进行了一些缩写

RSAPrivateKey ::= SEQUENCE {
  version           Version,
  modulus           INTEGER,  -- n
  publicExponent    INTEGER,  -- e
  privateExponent   INTEGER,  -- d
  prime1            INTEGER,  -- p
  prime2            INTEGER,  -- q
  exponent1         INTEGER,  -- d mod (p-1)
  exponent2         INTEGER,  -- d mod (q-1)
  coefficient       INTEGER,  -- (inverse of q) mod p
  otherPrimeInfos   OtherPrimeInfos OPTIONAL
}

Version ::= INTEGER { two-prime(0), multi(1) }
 (CONSTRAINED BY
 {-- version must be multi if otherPrimeInfos present --})

OtherPrimeInfos ::= SEQUENCE SIZE(1..MAX) OF OtherPrimeInfo

OtherPrimeInfo ::= SEQUENCE {
  prime             INTEGER,  -- ri
  exponent          INTEGER,  -- di
  coefficient       INTEGER   -- ti
}

上面的 ASN.1 定义了用于存储 RSA 密钥的 PKCS #1 格式。查看此内容,我们可以看到 RSAPrivateKey 序列以版本类型(0 或 1)开头,后跟一堆整数,然后是称为 OtherPrimeInfos 的可选类型。OtherPrimeInfos 序列包含一个或多个 OtherPrimeInfo 片段。每个 OtherPrimeInfo 只是一个整数序列。

让我们通过要求 OpenSSL 生成 RSA 密钥,然后将其管道传输到 asn1parse 来查看实际实例,这将以更人性化的格式将其打印出来。(顺便说一句,我在这里使用的 genrsa 命令已被 genpkey 取代;稍后我们将了解原因。)

% openssl genrsa 4096 2> /dev/null | openssl asn1parse
    0:d=0  hl=4 l=2344 cons: SEQUENCE
    4:d=1  hl=2 l=   1 prim: INTEGER :00
    7:d=1  hl=4 l= 513 prim: INTEGER :B80B0C2443...
  524:d=1  hl=2 l=   3 prim: INTEGER :010001
  529:d=1  hl=4 l= 512 prim: INTEGER :59C609C626...
 1045:d=1  hl=4 l= 257 prim: INTEGER :E8FC43002D...
 1306:d=1  hl=4 l= 257 prim: INTEGER :CA39222DD2...
 1567:d=1  hl=4 l= 256 prim: INTEGER :25F6CD181F...
 1827:d=1  hl=4 l= 256 prim: INTEGER :38CCE374CB...
 2087:d=1  hl=4 l= 257 prim: INTEGER :C80430E810...

回想一下,RSA 使用模数 n;公钥指数 e;和私钥指数 d。现在让我们看一下序列。首先,我们看到版本设置为 0,用于双素数 RSA 密钥(genrsa 生成的),模数 n 的整数,然后是公钥指数 e 的 0x010001。如果我们转换为十进制,我们将看到我们的公钥指数是 65537,这是一个 常用 作 RSA 公钥指数的数字。在公钥指数之后,我们看到私钥指数 e 的整数,然后是一些用于加速解密和签名的其他整数。解释这种优化如何工作超出了本文的范围,但如果您喜欢数学,则有一个关于该主题的精彩视频

输出左侧的其他内容呢?“h=4”和“l=513”是什么意思?我们稍后会介绍。

DER 错乱

我们已经看到了抽象语法表示法一的“抽象”部分,但是这些数据如何编码和存储?为此,我们转向 X.690 规范中定义的称为可分辨编码规则 (DER) 的二进制格式。DER 是其父级基本编码规则 (BER) 的更严格版本,因为对于任何给定数据,只有一种编码方式。如果我们要对数据进行数字签名,那么如果只有一种可能的编码需要签名,而不是数十种功能等效的表示形式,那么事情会容易得多。

DER 使用 标签-长度-值 (TLV) 结构。数据片段的编码以标识符八位字节开始,该八位字节定义数据的类型。(“八位字节”而不是“字节”,因为该标准非常古老,并且一些早期的架构没有为字节使用 8 位。)接下来是编码数据长度的八位字节,最后是数据。数据可以是另一个 TLV 系列。asn1parse 输出的左侧现在更有意义了。第一个数字表示从开头开始的绝对偏移量。“d=”告诉我们该项目在结构中的深度。第一行是一个序列,我们在下一行中下降到该序列中(深度 d 从 0 变为 1),此时 asn1parse 开始枚举该序列中的所有元素。“hl=”是标头长度(标识符和长度八位字节的总和),“l=”告诉我们该特定数据片段的长度。

标头长度是如何确定的?它是标识符字节和编码长度的字节的总和。在我们的示例中,顶部序列为 2344 个八位字节长。如果它小于 128 个八位字节,则长度将以“短格式”在单个八位字节中编码:位 8 将为零,位 7 到 1 将保存长度值(27-1=127)。值 2344 需要更多空间,因此使用“长”格式。第一个八位字节的位 8 设置为 1,位 7 到 1 包含长度的长度。在我们的例子中,值 2344 可以用两个八位字节 (0x0928) 编码。与第一个“长度的长度”八位字节结合,我们总共有三个八位字节。加上一个标识符八位字节,这给了我们总共四个的标头长度。

作为旁注练习,让我们考虑一下我们可以编码的最大值。我们已经看到我们最多有 127 个八位字节来编码长度。在每个八位字节 8 位的情况下,我们总共有 1008 位可以使用,因此我们可以容纳一个等于 21008-1 的数字。这将等同于 2.743062*10279 尧字节的内容长度,这比估计的宇宙中 1080 个原子还要多得惊人。如果您对所有细节感兴趣,我建议阅读“ASN.1、BER 和 DER 子集的简易指南。”

“cons”和“prim”呢?这些指示值是否使用“构造”或“原始”编码进行编码。原始编码用于简单类型,如“INTEGER”或“BOOLEAN”,而构造编码用于结构化类型,如“SEQUENCE”或“SET”。两种编码方法之间的实际区别在于标识符八位字节中的位 6 是零还是 1。如果是 1,则解析器知道内容八位字节也是 DER 编码的,它可以下降。

PEM 好友

虽然在许多情况下很有用,但如果我们需要将数据显示为文本,二进制格式将无法通过测试。MIME 标准出现之前,附件支持是零星的。通常,如果您想附加数据,您会将其放在电子邮件正文中,并且由于 SMTP 仅支持 ASCII,这意味着将您的二进制数据(例如,您的公钥的 DER)转换为 ASCII 字符。

因此,PEM 格式应运而生。PEM 代表“隐私增强邮件”,是传输和存储 PKI 数据的早期标准。该标准从未流行起来,但它定义的存储格式却流行起来。PEM 编码的对象只是 base64 编码并在每行 64 个字符处换行的 DER 对象。为了描述对象类型,标头和页脚包围 base64 字符串。您会看到 -----BEGIN CERTIFICATE----------BEGIN PRIVATE KEY-----,例如。

您经常会看到带有“.pem”扩展名的文件。我发现这个后缀没有用。该文件可能包含证书、密钥、证书签名请求或其他几种可能性。想象一下去寿司店,看到菜单将每件商品描述为“鱼和米饭”!相反,我更喜欢更具信息性的扩展名,如“.crt”、“.key”和“.csr”。

PKCS 动物园

早些时候,我展示了一个 PKCS #1 格式的 RSA 密钥示例。正如您可能期望的那样,各种 IETF RFC 中也存在用于存储证书和签名请求的格式。例如,PKCS #8 可用于存储许多不同算法的私钥(包括 RSA!)。以下是 RFC 5208 中 PKCS #8 的一些 ASN.1。(RFC 5208 已被 RFC 5958 废弃,但我认为 RFC 5208 中的 ASN.1 更容易理解。)

PrivateKeyInfo ::= SEQUENCE {
  version                   Version,
  privateKeyAlgorithm       PrivateKeyAlgorithmIdentifier,
  privateKey                PrivateKey,
  attributes           [0]  IMPLICIT Attributes OPTIONAL }

Version ::= INTEGER

PrivateKeyAlgorithmIdentifier ::= AlgorithmIdentifier

PrivateKey ::= OCTET STRING

Attributes ::= SET OF Attribute

如果您将 RSA 私钥存储在 PKCS #8 中,则 PrivateKey 元素实际上将是 DER 编码的 PKCS #1!让我们证明一下。还记得我之前使用 genrsa 生成 PKCS #1 吗?OpenSSL 可以使用 genpkey 命令生成 PKCS #8,您可以指定 RSA 作为要使用的算法。

% openssl genpkey -algorithm RSA | openssl asn1parse
    0:d=0  hl=4 l= 629 cons: SEQUENCE
    4:d=1  hl=2 l=   1 prim: INTEGER      :00
    7:d=1  hl=2 l=  13 cons: SEQUENCE
    9:d=2  hl=2 l=   9 prim: OBJECT       :rsaEncryption
   20:d=2  hl=2 l=   0 prim: NULL
   22:d=1  hl=4 l= 607 prim: OCTET STRING [HEX DUMP]:3082025B...

您可能已经发现了输出中的“OBJECT”并猜测这与 OID 相关。您是对的。OID “1.2.840.113549.1.1.1”分配给 RSA 加密。OpenSSL 有一个内置的常用 OID 列表,并将它们转换为人类可读的形式供您使用。

% openssl genpkey -algorithm RSA | openssl asn1parse -strparse 22
    0:d=0  hl=4 l= 604 cons: SEQUENCE
    4:d=1  hl=2 l=   1 prim: INTEGER :00
    7:d=1  hl=3 l= 129 prim: INTEGER :CA6720E706...
  139:d=1  hl=2 l=   3 prim: INTEGER :010001
  144:d=1  hl=3 l= 128 prim: INTEGER :05D0BEBE44...
  275:d=1  hl=2 l=  65 prim: INTEGER :F215DC6B77...
  342:d=1  hl=2 l=  65 prim: INTEGER :D6095CED7E...
  409:d=1  hl=2 l=  64 prim: INTEGER :402C7562F3...
  475:d=1  hl=2 l=  64 prim: INTEGER :06D0097B2D...
  541:d=1  hl=2 l=  65 prim: INTEGER :AB266E8E51...

在第二个命令中,我通过 -strparse 参数告诉 asn1parse 移动到八位字节 22 并开始将那里的内容八位字节解析为 ASN.1 对象。我们可以清楚地看到 PKCS #8 的 PrivateKey 看起来就像我们之前检查过的 PKCS #1。

您应该倾向于使用 genpkey 命令。PKCS #8 具有 PKCS #1 不具备的一些功能:PKCS #8 可以存储多种不同算法的私钥(PKCS #1 是 RSA 特定的),并且它提供了一种使用密码和对称密码加密私钥的机制。

加密的 PKCS #8 对象使用我不想深入研究的不同 ASN.1 语法,但让我们看一下实际示例,看看是否有任何突出的地方。使用 genpkey 加密私钥需要您指定要使用的对称加密算法。在此示例中,我将使用 AES-256-CBC 和密码“hello”(“pass:”前缀是告诉 OpenSSL 密码来自命令行的方式)。

% openssl genpkey -algorithm RSA -aes-256-cbc -pass pass:hello | openssl asn1parse
    0:d=0  hl=4 l= 733 cons: SEQUENCE
    4:d=1  hl=2 l=  87 cons: SEQUENCE
    6:d=2  hl=2 l=   9 prim: OBJECT            :PBES2
   17:d=2  hl=2 l=  74 cons: SEQUENCE
   19:d=3  hl=2 l=  41 cons: SEQUENCE
   21:d=4  hl=2 l=   9 prim: OBJECT            :PBKDF2
   32:d=4  hl=2 l=  28 cons: SEQUENCE
   34:d=5  hl=2 l=   8 prim: OCTET STRING      [HEX DUMP]:17E6FE554E85810A
   44:d=5  hl=2 l=   2 prim: INTEGER           :0800
   48:d=5  hl=2 l=  12 cons: SEQUENCE
   50:d=6  hl=2 l=   8 prim: OBJECT            :hmacWithSHA256
   60:d=6  hl=2 l=   0 prim: NULL
   62:d=3  hl=2 l=  29 cons: SEQUENCE
   64:d=4  hl=2 l=   9 prim: OBJECT            :aes-256-cbc
   75:d=4  hl=2 l=  16 prim: OCTET STRING      [HEX DUMP]:91E9536C39...
   93:d=1  hl=4 l= 640 prim: OCTET STRING      [HEX DUMP]:98007B264F...

% openssl genpkey -algorithm RSA -aes-256-cbc -pass pass:hello | head -n 1
-----BEGIN ENCRYPTED PRIVATE KEY-----

这里有几个有趣的项目。我们看到我们的加密算法记录在八位字节 64 开始的 OID 中。有一个用于“PBES2”(基于密码的加密方案 2)的 OID,它定义了加密和解密的标准过程,以及一个用于“PBKDF2”(基于密码的密钥派生函数 2)的 OID,它定义了从密码创建加密密钥的标准过程。有帮助的是,OpenSSL 在 PEM 输出中使用标头“ENCRYPTED PRIVATE KEY”。

OpenSSL 将允许您加密 PKCS #1,但它是通过插入到 PEM 中的一系列非标准标头以非标准方式完成的

% openssl genrsa -aes256 -passout pass:hello 4096
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,5B2C64DC05B7C0471A278C76562FD776
...

总结

您需要了解最后一个 PKCS 格式:PKCS #12。PKCS #12 格式允许将多个对象存储在一个文件中。如果您有证书及其对应的密钥或证书链,您可以将它们一起存储在一个 PKCS #12 文件中。文件中的各个条目可以使用基于密码的加密进行保护。

除了 PKCS 格式之外,还有其他存储方法,例如 Java 特定的 JKS 格式和 Mozilla 的 NSS 库,它使用基于文件的数据库(SQLite 或 Berkeley DB,具体取决于版本)。幸运的是,PKCS 格式是一种通用语言,如果您需要处理其他格式,它可以作为起点或参考。

如果这一切看起来令人困惑,那是因为它确实如此。不幸的是,PKI 生态系统有很多尖锐的边缘,从生成神秘错误消息的工具(看着你,OpenSSL)到过去 35 年来不断发展壮大的标准。如果您正在进行任何将通过 SSL/TLS 访问的应用程序开发,那么基本了解 PKI 对象的存储方式至关重要。

我希望本文能对这个问题有所启发,并可能使您免于在 PKI 荒野中花费徒劳的时间。


作者感谢 Hubert Kario 提供技术审查。

User profile image.
自 2004 年以来,我一直在 Red Hat 担任开发人员。目前我在 Satellite 6 上工作,大部分时间都在 Java、Python 或 Ruby 中度过。我的技术兴趣包括计算机安全、密码学和 Web 技术。我的项目和各种实验都在 GitHub 上。我的其他兴趣是视频游戏、棋盘游戏和历史。

评论已关闭。

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 获得许可。
© . All rights reserved.