密码学基础3:常见密钥格式完全解析

5,455 阅读12分钟
原文链接: www.jianshu.com

这是密码学笔记第三篇。之前两篇分别是分析 RSA 和 椭圆曲线密码的基本原理,本文分析下常见的密钥格式。如有错误,恳请指正。

1 ASN.1

ASN.1(Abstract Syntax Notation One) 是用于描述抽象数据类型的一种标记,它最早用于电信领域,后来在计算机密码学中也有广泛应用。ASN.1 跟 protobuf 和 Thrift 类似,可以看作是一种接口描述语言,通过定义 scheme 描述数据,当然它出现的要更早。

ASN.1 定义了一些数据类型来描述数据结构,包括基础类型(如整数,布尔值,字符串类型)和结构化类型(如结构体,列表类型),完整类型列表见 [ASN.1 Types]。除了 CHOICE 和 ANY 类型外,类型通常都有个类型标签。类型标签分为通用的、应用自定义的、上下文特定的、以及私有的类型标签 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定义了一个数据结构 FooQuestion。FooQuestion 包括两个字段 id (整形) 和 question(IA5String是不包括控制字符的 ASCII 字符串类型)。

FooQuestion ::= SEQUENCE {
    id INTEGER,
    question IA5String
}

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。

Basic Encoding Rules(BER)

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

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

Identifier 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 表示后面一个 8bit 为长度,如果是 0x82 则表示后面两个 8bit 为长度,以此类推。
  • 3)如果数据长度未知,则 Length=0x80,并增加 End-of-contents=00 00 结束标记。

Contents & End-of-contents: 数据内容 Contents 按 8bit 分组,类型和长度由前两个字段确定。对于未知数据长度的数据类型,才有 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 编码整数 3,为 02 01 03,原理同上。
  • 结构化类型就是包含了多个简单类型的复合类型,后面详细分析。

Distinguished Encoding Rules (DER)

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

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

因为 DER 编码是二进制数据,早期的 Email 不能发送附件,也不方便直接传输二进制数据([原因]),因此密钥文件通常会 在 DER 格式基础上进行 Base64 编码,这就是经常看到的密钥文件格式 PEM。

PEM(Privacy Enhanced Mail): 最早是用来增强邮件安全性的,不过没有被广泛接受,最后却是在密码学中得到了发扬光大,如 openssl 和 ssh-keygen 工具生成的公私钥文件默认都采用 PEM 格式。

3 密钥格式解析

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

3.1 PKCS #1

PKCS #1 是 RSA Cryptography Standard,即 RSA 密码学标准,它定义了 RSA 公私钥的格式和属性,以及加解密、签名、填充的基础算法。

RSA 密钥格式

RSA 公私钥的 ASN.1 scheme 在 [rfc8017] 定义如下,根据 scheme 和 PEM 编码的数据,就能解析出 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

如果要使用 python 模块实现 AES 加密,需要将 password 和 iv 都转换为 byte 类型,如下所示。

PWD = 'testtest'
IV = '8C2A8D6593F411D7336B842037B5200B'
b_iv = bytes(bytearray.fromhex(iv))
b_key = hashlib.md5((bytearray(pwd) + bytearray.fromhex(iv[:16]))).digest()

openssl 和 openssh 生成公钥格式区别

使用 ssh-keygen -t rsa 生成的 RSA 密钥对中虽然私钥格式跟 PKCS#1 相同(注:openssh 现在也支持一种新的专用的私钥格式,不兼容其他标准),但是公钥格式不一样。ssh-keygen 生成的公钥如下所示,这不是 PKCS#1 标准格式,而是 openssh 使用的一种专属格式:

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

如下面这样:

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

解析后可以发现内容就是 e 和 n 两个参数的 base64 编码。(注:ASN.1 中规定 INTEGER 类型如果是正数,则最高位须是 0,负数最高位为 1。因此密钥参数中如果有值最高位为 1,则需要在参数前额外加 00 以表示正数,参数 n 前多 00 就是这个原因)。

73 73 68 2d 72 73 61 # ssh-rsa

01 00 01 # e

00 be 80 28 80 ff fb d4 32  ef f6 75 88 fd a0 65 # n
29 63 64 c2 c3 9f c0 b6 47  c8 d9 c5 af 97 b8 4e
......

3.2 PKCS #8

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

未加密私钥格式

未加密私钥格式的 ASN.1 scheme 定义如下(参见 [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 是公钥。我们可以通过 libnum 库来验证公私钥的准确性。注意到这里公钥的第一个字节 04 表示公钥格式是 uncompressed format,即非压缩格式,也就是把点的 X 和 Y 坐标合到一起作为公钥。压缩格式就是只用 X 坐标或者 Y 坐标中的一个,另一个坐标根据曲线方程可以求得( [rfc5480] 有详细说明)。

$ 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])
vagrant@stretch:~$ 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 scheme 定义如下:

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公钥格式

参考资料