当我尝试自己实现TLS时,我遇到了这些问题

花费接近一周的业余时间,基本实现了基于 TLS 1.2 的通信过程。在这期间遇到了许多问题,在此记录。

实现的源码在: https://github.com/stefanJi/CNP/tree/master/TLSImpl

字节序问题

由于我代码使用基于 JVM 的语言写的,所以所有数字类型都是带符号的,也就是 signed 类型。但是 TLS 层的数据都是 unsigned 类型的数字,这就导致数据从 Socket 流走到 JVM 层之后会由于 JVM 的语言没有 unsigned 类型出现数字越界的问题。
比如一个 u_char, 值是 128,由于它大于了 127,走到 Java 这边之后,byte 的范围是 -128~127,就会被解析为 -128 。(128=11111111, bit位的首位在 signed 类型时表示符号位,首位为 1 表示这是一个负数,负数在 JVM 中又是以补码形式存储,所以 11111111 转化为原码之后就是 10000000 => 128,加上负数就变成 -128 了)。

由于上面的越界问题,所以从 Socket 中读数据都读出来存为 int,写数据时再转化为 u_char 写入。写入的过程就涉及字节序问题。

就比如发送每条 TLS 消息时,你都需要在消息体外面包裹一层 TLSPlaintText 结构,这个结构中必须写入一个 16 位的数字(length) 去表示数据的大小。16 位的就包含了2个字节,在这2个字节的写入上,我就遇到了问题。

我们知道 OutputStream 提供的 write 接口接收的数据单位是以字节为单位的,现在我想写入2个字节,这2个字节的写入顺序就要受字节序的影响了。

开始我是这样写的:

1
2
3
4
fun ByteBuffer.putU16(value: Int) = run {
put((value and 0xFF).toByte())
put((value shl 8).toByte())
}

因为我们写入时是以32位 int 写的,所以先取低8位写入第一个字节,再把32位数左移8位,剩下的写入第二个字节。这样就把32位数中的16位写入了2个字节中。
当我天真的以为这样就成功时,运行发现 Server 反馈我发送的数据有问题,通过 Wireshark 我发现,wireshark 解析出来的 length 尽然和我写入的length 值不相同。

原因是上面的写入顺序是 litte-endian 的,而网络协议一般都是按照 big-endian 的。
所以需要修改代码为:

1
2
3
4
fun ByteBuffer.putU16(value: Int) = run {
put((value shr 8).toByte())
put((value and 0xFF).toByte())
}

阅读 RFC 问题

RFC 作为计算机领域的协议标准,是非常值得仔细阅读的文档。但是由于自己英文水平不高,面对一些词句有时需要很久才能理解通顺。

我的经验是一定要仔细浏览目录,目录涵盖了 RFC 文档的脉络,而且最好两个 tab 对照着来看,一个看大纲,一个看内容,防止看内容时忘记当前的上下文。

理解 RFC 中对数据结构的描述

RFC 中对数据结构的描述也需要注意,其所描述的结构并就是代码对应的结构,也是因为 RFC 是语言无关的文档,所以 RFC 文档一般都会有一个 Presentation Language 章节来说明文档本身的表达规范,理解表达规范对理解 RFC 的主体章节及其重要。比如: https://tools.ietf.org/html/rfc5246#section-4 章节就是对应 TLS v1.2 RFC 中的表达语言规范。

理解 IO

理解 InputStream read 的含义

Socket 本质是操作系统向上层应用提供的一套访问操作系统网络栈实现的接口。网络数据从网卡到达之后,先是存在操作系统的网络栈实现的缓存中。通过 Socket 获取的 Input Output Stream 实际是读操作系统内核中的缓存数据的读写。read 读取数据时,操作系统会先判断当前缓存中是否还有数据可被读取,如果有就返回给应用;如果没有,则等待网卡数据到来之后再返回给应用,这也是 read 是一个阻塞调用的本质。

理解 BufferedInputStream 的特点

BufferedInputStream 是带缓存的流,带缓存的意思是 BufferedInputStream 中会维护数据读取的起始量和已读量,并维护一个字节数组。在从 BufferedInputStream 第一次取数据时,它会从原始流中一次性读取多个字节,填充到自己维护的字节数组中。后面再取数据,优先从自己维护的字节数据中获取,只有当获取的数据的长度超过了自己维护的字节数组时,才会触发第二次对原始流的读取。通过增加一层这样的缓存机制,就减少了对原始流的读写次数,也就减少了处理器在内核态和用户态的切换成本。

加密套件的含义

当客户端和服务器在 TLS 握手的 Client HelloServer Hello 会协商使用的加密套件。加密套件在传输的过程中来说,其实就是一个类型编码(Hex code),每个编码将对应一种公众协商好的加密套件。

套件的元信息 套件包含的信息
比如 0xC02B 对应的加密套件 该套件包含的信息

而加密套件本身代表着在 TLS 通信中将使用到的算法:

算法 作用
Key Exchange 在交互密钥过程中使用的算法
Authentication 在验证身份时使用的算法
Encryption 加密数据使用的算法
Hash 散列算法

查看一个加密套件代表的信息可以到 https://ciphersuite.info/ 搜索。

长长的名字代表啥

RFC 给常见的加密套件做了一套命名,它们的名字其实就代表着它们的算法组合。常见的加密套件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
TLS_ECDH_ECDSA_WITH_NULL_SHA           = { 0xC0, 0x01 }
TLS_ECDH_ECDSA_WITH_RC4_128_SHA = { 0xC0, 0x02 }
TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA = { 0xC0, 0x03 }
TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA = { 0xC0, 0x04 }
TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA = { 0xC0, 0x05 }

TLS_ECDHE_ECDSA_WITH_NULL_SHA = { 0xC0, 0x06 }
TLS_ECDHE_ECDSA_WITH_RC4_128_SHA = { 0xC0, 0x07 }
TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA = { 0xC0, 0x08 }
TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA = { 0xC0, 0x09 }
TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA = { 0xC0, 0x0A }

TLS_ECDH_RSA_WITH_NULL_SHA = { 0xC0, 0x0B }
TLS_ECDH_RSA_WITH_RC4_128_SHA = { 0xC0, 0x0C }
TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA = { 0xC0, 0x0D }
TLS_ECDH_RSA_WITH_AES_128_CBC_SHA = { 0xC0, 0x0E }
TLS_ECDH_RSA_WITH_AES_256_CBC_SHA = { 0xC0, 0x0F }

TLS_ECDHE_RSA_WITH_NULL_SHA = { 0xC0, 0x10 }
TLS_ECDHE_RSA_WITH_RC4_128_SHA = { 0xC0, 0x11 }
TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA = { 0xC0, 0x12 }
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA = { 0xC0, 0x13 }
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA = { 0xC0, 0x14 }

TLS_ECDH_anon_WITH_NULL_SHA = { 0xC0, 0x15 }
TLS_ECDH_anon_WITH_RC4_128_SHA = { 0xC0, 0x16 }
TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA = { 0xC0, 0x17 }
TLS_ECDH_anon_WITH_AES_128_CBC_SHA = { 0xC0, 0x18 }
TLS_ECDH_anon_WITH_AES_256_CBC_SHA = { 0xC0, 0x19 }

比如 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(0xc02f),通过名字我们能直观的知道,使用这个加密套件时将会用到的算法:

  • Key Exchange 使用 ECDHE(Elliptic Curve Diffie-Hellman Ephemeral 短暂的椭圆曲线迪菲-赫尔曼公钥交换) 算法
  • Authentication 使用 RAS(非对称加密) 算法
  • Encryption 使用 AES_128_GCM(Advanced Encryption Standard with 128bit key in Galois/Counter mode 高级加密标准) 算法
  • Hash 使用 SHA256 散列算法

密钥交换的过程

密钥交换即 Key exchange message 的收发,premaster_secret 的生成和交换其实就是利用 Key Exchange 算法交换一些算法需要的参数的过程。

Key Exchange 算法们

常见的 Key Exchange 算法有:

  • ECDH_ECDSA

表示两种算法的结合。ECDH(椭圆曲线迪菲-赫尔曼公钥交换算法) 和 ECDSA(椭圆曲线数字签名算法)

在使用 ECDH_ECDSA 作为 Key Exchange 算法时, 服务器的证书必须包含一个 ECDH 可用的公钥,并且证书要用 ECDSA 签名。同时这种情况下,ServerKeyExchange 将不会被发送(因为服务器的证书已经包含了客户端所需的所有必要的参数信息)。

客户端使用与服务器相同的曲线算法生成一个 ECDH 密钥对,密钥对中的公钥将作为和服务器通信的长期公共密钥。客户端需要在 ClientKeyExchange 消息中携带这个公共密钥。

客户端和服务器都执行 ECDH 操作,并将生成的共享密钥作为后续阶段的 premaster_secret

  • ECDHE_ECDSA

ECDHE: 算法的计算方法和 ECDH 相同, 最后的 E 是 ephemeral 的缩写,表示临时的

在使用 ECDHE_ECDSA 作为 Key Exchange 算法时, 服务器的证书必须包含一个 ECDSA 可用的公钥,并且证书要用 ECDSA 签名。

服务器必须在 ServerKeyExchange 消息中携带他的临时ECDH公钥和他生成这个公钥使用到的曲线算法类型。这些参数必须使用与服务器证书中的公钥相对应的私钥来进行 ECDSA 签名。

客户端使用与服务器相同的曲线算法生成一个 ECDH 密钥对,密钥对中的公钥将作为和服务器通信的临时公共密钥。客户端需要在 ClientKeyExchange 消息中携带这个公共密钥。

客户端和服务器都执行 ECDH 操作,并将生成的共享密钥作为后续阶段的 premaster_secret

  • ECDH_RSA

该密钥交换算法与 ECDH_ECDSA 相同,唯一的不同是:服务器的证书必须使用RSA签名而不是ECDSA。

  • ECDHE_RSA

该密钥交换算法与 ECDHE_ECDSA 相同,不同之处在于服务器的证书必须包含使用 RSA 算法签名的公钥,并且 ServerKeyExchange 消息中的签名必须使用相应的 RSA 私钥进行计算。服务器证书必须使用 RSA 算法进行签名。

  • ECDH_anon

anon 表示匿名

在 ECDH_anon 中,服务器的证书,证书请求,客户的证书,以及证书验证消息都不能发送。服务器必须在 ServerKeyExchange 消息中携带他的临时ECDH公钥和他生成这个公钥使用到的曲线算法类型。这些参数不能进行任何签名。

客户端使用与服务器相同的曲线算法生成一个 ECDH 密钥对,密钥对中的公钥将作为和服务器通信的临时公共密钥。客户端需要在 ClientKeyExchange 消息中携带这个公共密钥。

客户端和服务器都执行 ECDH 操作,并将生成的共享密钥作为后续阶段的 premaster_secret


请注意,尽管ECDH_ECDSA,ECDHE_ECDSA,ECDH_RSA和ECDHE_RSA密钥交换算法要求使用特定的签名方案对服务器的证书进行签名,但是此规范并未对证书链中其他地方使用的签名方案施加限制。所以证书链上的证书都需要使用根证书结构去验证其正确性。