在前两篇文章——密码学和公钥基础设施简介 和 私钥如何在 PKI 和密码学中工作?——我以一般方式讨论了密码学和公钥基础设施 (PKI)。我谈到了称为证书的数字包如何存储公钥和识别信息。这些包包含很多复杂性,基本了解格式对于您需要深入了解内部结构时很有用。
抽象艺术
密钥、证书签名请求、证书和其他 PKI 工件在称为 抽象语法表示法一 (ASN.1) 的数据描述语言中定义自身。ASN.1 定义了一系列简单的数据类型(整数、字符串、日期等)以及一些结构化类型(序列、集合)。通过使用这些类型作为构建块,我们可以创建非常复杂的数据格式。
然而,ASN.1 包含许多对粗心大意者来说是陷阱的地方。例如,它有两种不同的日期表示方式:GeneralizedTime (ISO 8601 格式) 和 UTCTime(使用两位数年份)。字符串引入了更多的混乱。我们有用于 ASCII 字符串的 IA5String 和用于 Unicode 字符串的 UTF8String。ASN.1 还定义了其他几种字符串类型,从奇异的 T61String 和 TeletexString 到听起来更无害但可能不是您想要的 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 提供技术审查。
评论已关闭。