密码学基础3:密钥文件格式完全解析

0.813字数 3894阅读 759

这是密码学笔记第三篇。之前两篇分析了 RSA 算法和椭圆曲线密码学的基本原理,从中可以知道 RSA 算法的本质是大整数质数因子分解,椭圆曲线密码学的本质是曲线上的打点,朴素的原理后面处处闪耀着数学之光。从理论到实践,这篇文章来解析下日常使用的密钥文件格式。

文章主要解决下面几个问题:

  • 使用 opensslssh-keygen 生成的 RSA 和 ECC 的密钥文件里面存了什么内容,用的什么编码规则?
  • 有些 RSA 私钥头部是 -----BEGIN RSA PRIVATE KEY-----,而有些又是 -----BEGIN PRIVATE KEY-----,它们存储的内容有什么区别?
  • 使用 openssl 生成的公钥跟 ssh-keygen 生成的密钥对中的公钥格式不一样,它们有什么区别?
  • opensslssh-keygen 都支持输入密码对私钥加密以增强安全性,它们加密方式分别是什么?有什么不同之处吗?

1 ASN.1

ASN.1(Abstract Syntax Notation One) 是一种数据描述语言,跟 protobuf 和 Thrift 这些接口描述语言类似,它通过模块(module) 来描述数据结构。ASN.1 最早用于电信领域,后来在计算机密码学中也有广泛应用。

ASN.1 定义了一些数据类型来描述数据结构,包括基础类型(如整数类型 INTEGER,布尔类型 BOOLEAN,字符串类型 OCTET STRING,BIT STRING)和结构化类型(如结构体类型 SEQUENCE,列表类型 SEQUENCE OF 等),完整类型列表见 [ASN.1 Types]。类型通常都有个类型标签,其中类型标签分为通用的、应用自定义的、上下文特定的、以及私有的 4 种类型。其中通用的类型标签如下:

Type Tag Number
INTEGER 0x02
BIT STRING 0x03
OCTET STRING 0x04
NULL 0x05
OBJECT IDENTIFIER 0x06
SEQUENCE and SEQUENCE OF 0x10
IA5String 0x16
UTCTime 0x17

下面用ASN.1定义了一个名为 FooProtocol 的模块,其中包含一个结构体类型字段 FooQuestion。结构体 FooQuestion 包括一个整形字段 id 和一个字符串类型字段 question(IA5String 是不包括控制字符的字符串类型)。

FooProtocol DEFINITIONS ::= BEGIN
    FooQuestion ::= SEQUENCE {
        id INTEGER,
        question       IA5String
    }
END

ASN.1 只是描述了数据结构,并没有指定怎么编码数据。因此,出现了多种编码规则以方便数据在网络上传输和不同终端间交互。比较常见的有 XER, JER, BER, DER等。如待编码的数据如下:

myQuestion FooQuestion ::= SEQUENCE {
    id 5,
    question "Anybody there?"
}

使用各编码规则编码结果如下,其中 XER 和 JER 不用多说,BER 和 DER 是最常见的密钥文件编码规则,下一节详细分析。

                    XER(XML Encoding Rules)
            <FooQuestion>
                <id>5</id>
                <question>Anybody there?</question>
            </FooQuestion>

                    JER(JSON Encoding Rules)
    { "id" : 5, "question" : "Anybody there?" }
        
                    BER(Basic Encoding Rules)
        30 13 02 01 05 16 0e 41 6e 79 62 6f 64 79 20 74 68 65 72 65 3f

                    DER(Distinguished Encoding Rules)
        30 13 02 01 05 16 0e 41 6e 79 62 6f 64 79 20 74 68 65 72 65 3f

2 编码规则

前文提到 ASN.1 只是定义了数据结构,并未规定具体的编码方式,与之对应的有多种编码规则。ASN.1 与特定的编码规则一起通过使用独立于计算机架构和编程语言的方法来描述数据结构,为结构化数据的表示、编码、传输、解码提供了便利。本节主要介绍密钥和证书中常见的编码规则 BER,DER,以及由 DER 衍生出的密钥文件格式 PEM。

BER (Basic Encoding Rules)

BER 是基础编码规则,编码结构包括类型标志、长度,值以及结束符(可选),每个字段以 8bit 即字节进行分割。

----------------------------------------------------
| Identifier | Length | Contents | End-of-contents |
|   octets   | octets |  octets  |      octets     |
----------------------------------------------------

Identifier: 类型标志,就是ASN.1 规定的类型,只是除了标签号(tag number)外,还加了 3 位,第 7,8 位用于区分是通用的标签类型还是其他标签类型, 第 6 位 用于区分是基础类型还是结构化类型。Identifier 结构如下,后面我们会看到密钥中的结构体类型 SEQUENCE 的 Identifier 为 0x30,即是由这个格式而来(0011000)。

---------------------------------
| 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
---------------------------------
|Class  |P/C| Tag Number        |
---------------------------------
  • Class:如果是通用类型标签则为 00,应用自定义的类型标签则为 01,上下文特定类型标签 10,私有类型标签 11。
  • P/C:如果是基础类型,则为 0,结构化类型为 1。
  • Tag Number:就是 ASN.1 中定义的数据类型标签号。

Length: 分三种情况,

  • 1)数据长度 < 128:则 Length 的 8bit首位为0,其他7位表示数据长度。
  • 2)数据长度 >= 128:则 Length 的第一个8bit为 0x8?,其中 ? 是后面跟的是长度。比如 0x81 表示后面一个字节为长度,如果是 0x82 则表示后面两个字节为长度,以此类推。
  • 3)如果数据长度未知,则 Length=0x80,并增加 End-of-contents=00 00 结束标记。

Contents & End-of-contents: 数据内容。对于未知数据长度的数据类型,才有 End-of-contents,为00 00

实例:

  • 使用 OCTET STRING 编码字符串 Hello,为 04 05 48 65 6C 6C 6F,即类型为 04,长度为 05,内容为 0x48 65 6C 6C 6F,即 Hello 的 ASCII 码。
  • 使用 INTEGER 编码整数 129,为 02 81 81
  • 结构化类型是包含了多个数据类型的复合类型,后面详细分析。

DER (Distinguished Encoding Rules)

DER 是典型的 Tag-Length-Value(TLV) 编码方式,是 PKCS 密钥体系常用的编码。
DER 是 BER 的子集,编码规则几乎一样,不过去掉了 BER 的一些灵活性,多了几个限制:

  • 如果数据长度在 0-127 之间,则 Length 必须使用第 1 种编码方式。
  • 如果数据长度 >= 128,则 Length 必须使用第 2 种编码方式,且 Length 必须用最少的字节编码,如果能用 2 字节的则不能用 3 字节。
  • 数据要用明确长度的编码方式,不支持 Length 的第3种编码即未知数据长度+结束标记的方式。

注意:ASN.1 规定整型 INTEGER 需要支持正整数、负整数和零。BER/DER 使用大端模式存储 INTEGER,并通过最高位来编码正负数(最高位0为正数,1为负数)。 如果密钥参数值最高位为 1,则 BER/DER 编码会在参数前额外加 0x00 以表示正数,这也就是为什么有时候密钥参数前面会多出1字节 0x00 的原因。

PEM(Privacy Enhanced Mail): 因为 DER 编码是二进制数据,早期的 Email 不能发送附件,也不方便直接传输二进制数据([原因]),因此密钥文件通常会在 DER 编码基础上进行 Base64 编码,这就是经常看到的密钥文件格式 PEM。PEM 最早是用来增强邮件安全性的,不过没有被广泛接受,最后却是在密码学中得到了发扬光大,如 openssl 和 ssh-keygen 工具生成的公私钥文件默认都采用 PEM 格式。需要注意的是,PEM 本身不是 ASN.1 的编码规则,它只是 Base64-encoded DER

3 密钥格式解析

ASN.1 编码规则只定义了数据编码方式,但是并没有赋予数据意义。公钥密码学标准 PKCS (Public Key Cryptography Standards) 和公钥基础设施 PKIX(Public-Key Infrastructure X.509) 等使用 ASN.1 的类型定义密钥和证书的格式和编码,以描述公私钥和证书属性。 需要注意,PKCS 虽然名字是公钥密码学标准,它其实也包括私钥格式标准。 这两个标准的内容浩瀚如烟,本节只分析常见几种密钥相关的部分。

3.1 PKCS #1

PKCS #1 是 RSA Cryptography Specifications,即 RSA 密码学规范,它在 [rfc8017] 中有详细说明,定义了 RSA 密钥文件的格式和编码方式,以及加解密、签名、填充的基础算法。

RSA 密钥格式

RSA 公私钥的 ASN.1 类型定义如下,根据类型定义和数据编码,就能解析出 RSA 公私钥中的参数了(参数含义请参考我之前的《RSA算法原理解析》一文)。


 RSAPublicKey ::= SEQUENCE {
        modulus           INTEGER,  -- n
        publicExponent    INTEGER   -- e
 }
         
 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
}

使用 openssl 生成的一对 RSA 公私钥 (示例为方便展示,用的 1024 位密钥,实际中请使用 2048 位以上)

$ openssl genrsa -out prikey.p1 1024
$ openssl rsa -in prikey.p1 -pubout -RSAPublicKey_out > pubkey.p1

PKCS#1 格式解析如下:公钥的 SEQUENCE 包括 RSA 公钥参数 n 和 e 两个属性。RSA 私钥则首先是版本号 0,然后是 RSA 私钥的 8 个参数。

PKCS#1 公钥
PKCS#1 私钥

加密私钥

可以对 PKCS#1 的私钥进行加密,如 ssh-keygen 可以指定 passphrase (测试的密码是 testtest)加密 RSA 私钥,加密后的私钥 enc_prikey.p1 格式如下:

-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,8C2A8D6593F411D7336B842037B5200B

EncryptedRSAPrivateKey
-----END RSA PRIVATE KEY-----

DEK-Info 里面指明了加密算法是 AES-128-CBC,IV 是 8C2A8D6593F411D7336B842037B5200B,AES加密的实际密码=md5(设定密码 + IV的前8个字节)。可以使用 openssl aes-128-cbc 验证加密结果是否与 EncryptedRSAPrivateKey 一致。

$ tail -n +2 prikey.p1 | grep -v 'END RSA' | base64 -d | 
openssl aes-128-cbc -e -iv 8C2A8D6593F411D7336B842037B5200B -K $(python -c "exec(\"import hashlib\\nprint hashlib.md5(bytearray('testtest') + bytearray.fromhex('8C2A8D6593F411D7')).hexdigest()\")") | base64

3.2 PKCS #8

PKCS#8 是 Private-Key Information Syntax Standard,即私钥格式相关的标准,它不像 PKCS#1 只支持 RSA,而是支持各种类型的私钥。PKCS#8 私钥文件格式中首尾并未说明私钥算法类型,算法类型在数据中标识。PKCS#8 中的私钥也支持加密。

未加密私钥格式

未加密私钥格式的 ASN.1 类型定义如下(参见 [rfc5958] ):

    OneAsymmetricKey ::= SEQUENCE {
        version                   Version,
        privateKeyAlgorithm       PrivateKeyAlgorithmIdentifier,
        privateKey                PrivateKey,
        attributes            [0] Attributes OPTIONAL,
       ...,
       [[2: publicKey        [1] PublicKey OPTIONAL ]],
       ...}

        Version ::= INTEGER  # 版本号。
    
        PrivateKeyAlgorithmIdentifier ::= SEQUENCE  { # 密钥算法标识
                algorithm               OBJECT IDENTIFIER,
                parameters              ANY DEFINED BY algorithm OPTIONAL  }
    
        PrivateKey ::= OCTET STRING # 不同类型的私钥格式不同,比如 RSA 的是 RSAPrivateKey类型,而 ECC 的是 ECPrivateKey 类型。
            
        Attributes ::= SET OF Attribute # 跟公钥相关的属性,比如证书什么的,在公私钥中通常为空。
        
        PublicKey ::= BIT STRING # 不同类型密钥包含的公钥内容也不同。

RSA 私钥格式

可以使用 openssl 将 PKCS#1 格式的私钥 prikey.p1 转换成 PKCS#8 格式的 prikey.p8,如下:

$ openssl pkcs8 -in prikey.p1 -topk8 -out prikey.p8 -nocrypt

私钥格式解析如下:

PKCS#8 RSA私钥格式
  • version:版本号,目前值为 0。
  • privateKeyAlgorithm:私钥算法, rsaEncryptionOBJECT IDENTIFIER1.2.840.113549.1.1.1,具体含义参见 [这里]
  • privateKey:私钥,OCTET STRING 类型,里面其实封装了一个 RSAPrivateKey 类型,跟 PKCS#1 一样。
  • attributes 和 publicKey 为空。

ECC 私钥格式

椭圆曲线类型的私钥格式在 rfc5915 中定义如下:

ECPrivateKey ::= SEQUENCE {
     version        INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
     privateKey     OCTET STRING,
     parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
     publicKey  [1] BIT STRING OPTIONAL
}

使用 openssl 创建一个 PKCS#8 格式的 ecc 密钥,采用 prime256v1 曲线:

# 生成传统格式的 ECC 私钥,类似 PKCS#1 那样,只包含 privateKey,密钥类型在头部 -----BEGIN EC PRIVATE KEY----- 标识,椭圆曲线在 parameters 标识。
$ openssl ecparam -name prime256v1 -genkey -noout -out ecc_prikey.tradfile

# 转换为 PKCS#8 格式
$ openssl pkcs8 -topk8 -in ecc_prikey.tradfile -out ecc_prikey.p8 -nocrypt
$ cat ecc_prikey.p8
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMbahscIGpSZ6NULI
iQ/pTI9ZcvFdXKtjN1bAGO2bxvahRANCAATwq1k9rx/8neP8MqVR7UuJ98bLFsU5
jpueH0ougZNWrsKUki0cgKDGrb3C8Q2NMRO336ve22Xk674lk/ZDHkAV
-----END PRIVATE KEY-----

ASN.1 格式解析如下:

PKCS#8 ECC私钥格式
  • 前面部分是算法标识 1.2.840.10045.2.1(ecPublicKey) 和 1.2.840.10045.3.1.7 (prime256v1)
  • 后面是私钥信息,其中包括了版本号 1,OCTET STRING 类型私钥 31B6A1...F6,BIT STRING 类型的公钥 0000 0100 1111 0000...

当然通过 openssl 可以直接解析出公私钥和曲线类型,如下:

$ openssl ec -in ecc_prikey.p8 -noout -text
read EC key
Private-Key: (256 bit)
priv:
    31:b6:a1:b1:c2:06:a5:26:7a:35:42:c8:89:0f:e9:
    4c:8f:59:72:f1:5d:5c:ab:63:37:56:c0:18:ed:9b:
    c6:f6
pub:
    04:f0:ab:59:3d:af:1f:fc:9d:e3:fc:32:a5:51:ed:
    4b:89:f7:c6:cb:16:c5:39:8e:9b:9e:1f:4a:2e:81:
    93:56:ae:c2:94:92:2d:1c:80:a0:c6:ad:bd:c2:f1:
    0d:8d:31:13:b7:df:ab:de:db:65:e4:eb:be:25:93:
    f6:43:1e:40:15
ASN1 OID: prime256v1
NIST CURVE: P-256

在上一篇《椭圆曲线密码学原理分析》一文知道,椭圆曲线的密钥生成其实就是一个公式 P = nG,n 就是私钥,G 是基点,P 是公钥。注意到这里公钥的第一个字节 04 表示公钥格式是 uncompressed format,即非压缩格式,也就是把点的 X 和 Y 坐标合到一起作为公钥。压缩格式就是只用 X 坐标或者 Y 坐标中的一个,另一个坐标根据曲线方程可以求得([rfc5480] 有详细说明)。可以通过 libnum 库来验证下公私钥的准确性。

$ cat ecc.py 
from libnum.ecc import Curve
curve = Curve(
        a=0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc,
        b=0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b,
        p=0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff,
        g = (0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296,
            0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)
)

pri = 0x31b6a1b1c206a5267a3542c8890fe94c8f5972f15d5cab633756c018ed9bc6f6
pub = curve.power(curve.g, pri)
print hex(pub[0]), hex(pub[1])
$ python ecc.py 
('0xf0ab593daf1ffc9de3fc32a551ed4b89f7c6cb16c5398e9b9e1f4a2e819356aeL', 
'0xc294922d1c80a0c6adbdc2f10d8d3113b7dfabdedb65e4ebbe2593f6431e4015L')

加密私钥格式

PKCS#8 里面对私钥加密提供了 PBES2(Password-Based Encryption Scheme 2)加密模式支持。通过 PBKDF2(Password-Based Key Derivation Function 2) 对原始密码进行多次哈希处理作为加密密码以增强破解难度,然后用对称加密算法 AES 或者 DES 对私钥进行加密。

PBKDF2 是一种 CPU 密集型算法,但是如果使用 GPU 阵列或者 FPGA 来破解还是相对容易。在密码存储中现在更倾向于用 Bcrypt,它不仅是 CPU 运算密集,而且是内存密集,破解难度会更高一些。不过总的来说, PBKDF2 比 ssh-keygen 的 md5 方式生存密码安全性会高很多。

PKCS#8 加密类型私钥的 ASN.1 类型定义如下:

EncryptedPrivateKeyInfo ::= SEQUENCE {
     encryptionAlgorithm  EncryptionAlgorithmIdentifier,
     encryptedData        EncryptedData }

     EncryptionAlgorithmIdentifier ::= AlgorithmIdentifier
                                        { CONTENT-ENCRYPTION,
                                          { KeyEncryptionAlgorithms } }

     EncryptedData ::= OCTET STRING

使用 openssl 对 PKCS#8 格式的密钥加密是很方便的,默认就支持(生成密钥时不加 -nocrypt 参数即可)。加密后的 ecc_prikey.p8 格式解析如下:

加密的 PKCS#8 ECC私钥格式
  • 其中加密模式是 pkcs5PBES2,密钥生成算法是 PBKDF2,参数 salt= E20EED9A112B7BFA,iteration=2048,哈希算法是 hmacWithSHA256
  • 对称加密算法是 aes256-cbc,参数 iv=27579581D081AEDA083889370232AD1A
  • 最后一行 11E9C5C2.... 就是加密私钥 encryptedData。

加密过程解析:

  • 先使用密钥生成算法 PBKDF2 生成加密密码,python 可以用 backports.pbkdf2 模块。
import os, binascii
from backports.pbkdf2 import pbkdf2_hmac

salt = binascii.unhexlify('E20EED9A112B7BFA')
passwd = b"testtest"
key = pbkdf2_hmac("sha256", passwd, salt, 2048, 32)
print("Derived key:", binascii.hexlify(key))

# 输出: ('Derived key:', 'bf48084fd98fcbacd8e024166efb7232c897282fe7e4ff836db3f3d81e32ede9')
  • 然后使用 openssl 的 aes256-cbc 加密原私钥,可以验证跟 encryptedData 是一样的。
$ tail -n +2 ecc_prikey.p8 | grep -v 'END '| base64 -d | 
openssl aes-256-cbc -e -iv 27579581D081AEDA083889370232AD1A -K bf48084fd98fcbacd8e024166efb7232c897282fe7e4ff836db3f3d81e32ede9 | hexdump -C

00000000  11 e9 c5 c2 4c c3 2d bb  fa 84 b9 fb db f1 d1 ff  |....L.-.........|
00000010  f0 6a 5b fa c3 a6 88 cd  02 4c ac 52 84 f4 cb c1  |.j[......L.R....|
......
00000080  8f 72 96 7a 58 aa 1f 5a  6f c1 bf dc 43 1a 46 26  |.r.zX..Zo...C.F&|

3.3 PKIX

前面提到 PKCS#8 定义了通用的私钥格式支持各类私钥,在 PKIX ([rfc5280]) 中也定义了通用的公钥格式。其中包括算法标识和公钥内容,算法标识 AlgorithmIdentifier 与前面私钥中的 PrivateAlgorithmIdentifier 是类似的。

SubjectPublicKeyInfo  ::=  SEQUENCE  {
        algorithm         AlgorithmIdentifier,
        subjectPublicKey  BIT STRING }

        AlgorithmIdentifier  ::=  SEQUENCE  {
                algorithm               OBJECT IDENTIFIER,
                parameters              ANY DEFINED BY algorithm OPTIONAL  
        }

将之前的 PKCS#1 格式的 RSA 公钥转换成 PKIX 的格式:

$ openssl rsa -RSAPublicKey_in -in ../pk1/pubkey.p1 -pubout > pubkey.pkix

PKIX 格式的公钥解析如下,包括公钥算法 rsaEncryption 和 RSA 公钥参数 n 和 e。

PKIX公钥格式

3.4 openssl 和 ssh-keygen 生成公钥格式区别

相信大家会发现 ssh-keygen -t rsa 生成的 RSA 密钥对中公钥格式跟 PKCS#1 和 PKIX 中的都不一样。ssh-keygen 生成的公钥 id_rsa.pub 如下所示:

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+gCiA//vUMu/2dYj9oGUpY2TCw5/AtkfI2cWvl7hOkliQd7uI61gE9BV5w+Ib+HnjAB9lFYS4A8rlpRlkH9a+mCN2K/Oh5dhoonxat4qeHB5XDvmImUfdOGayT5l176KWP4ftGJt+8ygRpo05zcbuBrd/KxFZ7KDiQyXRvRv9mw== vagrant@stretch

这是 openssh 使用的一种专属格式:

[type-name] [base64-encoded-ssh-public-key] [comment]

其中 base64-encoded-ssh-public-key 并没有使用 ASN.1 的数据类型定义和 DER 编码,而是使用的 SSH 协议定义的格式(见 [rfc4251]),分为 3 部分:

string    "ssh-rsa"
mpint     e
mpint     n

这里的 string 类型和 mpint 类型在 rfc4251 中定义,其中 mpint是使用字符串的方式来存储整数,它们都会在数据前先用4字节存储数据长度。id_rsa.pub 解析后格式如下所示:

00 00 00 07  73 73 68 2d 72 73 61  | 长度 7,值 ssh-rsa|
00 00 00 03  01 00 01  | e 长度 3,值 0x010001 |
00 00 00 81  00 be 80 28 80 ff fb d4 |n 长度 127,值 0x00 be 80 28 80...|
......

也可以将 ssh-keygen 生成的公钥转换成 PKCS#1 的格式:

$ ssh-keygen -f id_rsa.pub -e -m pem > pubkey.p1

4 总结

PKCS 和 PKIX 使用 ASN.1 定义了密钥的数据结构,并使用 DER 编码规则编码密钥,最终使用 PEM (Base64-encoded DER) 格式将密钥数据存储在密钥文件中。

PKCS#1 首部会标识密钥算法类型,PKCS#8 则是在密钥数据中有字段专门存储密钥算法。openssh 使用的公钥格式是 SSH 协议中定义的,虽然参数值一样,但是编码方式与 PKCS 和 PKIX 标准都不同。

ssh-keygen 是对原始密码和初始向量经过一个简单规则 md5 后生成加密密码,然后使用 aes128-cbc 对称加密。而 openssl 则是对原始密码采用 PBKDF2 算法生成加密密码,然后使用 aes256-cbc 对称加密,openssl 的加密方式更安全一些。

参考资料

推荐阅读更多精彩内容