作者:JOHN BALDWIN
FreeBSD 13.0 增加了对传输层安全性(TLS)套接字内核卸载的支持。TLS 卸载允许内核通过 TLS 发送和接收数据,其中 TLS 的封装和加密在内核中执行,而非用户空间。内核 TLS(KTLS)需要对用户空间的 SSL 库、内核的网络栈,以及在某些情况下的设备驱动程序进行修改。
TLS 是一款应用层协议,旨在为上层应用协议提供身份验证和隐私保护。TLS 以记录流(或帧)的形式构造,并在流协议上传输和接收。(虽然存在用于数据报协议的 TLS 版本,但 KTLS 仅支持基于 TCP 的 TLS。)每个 TLS 记录都包含一个带有消息类型和长度的头部。此记录层用于传输 TLS 协议定义的消息,以管理 TLS 连接,同时也用于传输来自上层应用协议(如 HTTP、SMTP 或 IMAP)的应用数据消息。
典型的 TLS 连接始于几个 TLS 协议消息,以建立 TLS 会话。这些消息用于验证连接对端的身份、选择一组加密和身份验证算法(称为密码套件),并建立一对临时会话密钥,以加密和验证后续的 TLS 记录。若连接建立,通信便切换为通过应用数据消息传输上层应用数据。在 TLS 连接中,这些应用数据消息占据了大部分通信流量。
应用数据消息由可变大小的应用数据块构成。应用数据经过加密,并添加头部和尾部,形成一个 TLS 记录(见图 1)。TLS 协议消息的构造方式类似,但 TLS 头部包含不同的消息类型,而 TLS 记录的有效负载数据来自 TLS 协议。TLS 头部和尾部的格式由会话所使用的具体密码套件决定。
图 1:构建 TLS 记录
通常情况下,我们会尽量避免将代码放入内核。用户态代码运行时权限较低,且与其他进程隔离。用户进程崩溃只会影响该进程本身,漏洞的影响范围也仅限于特定进程。而内核代码运行时权限更高,能够访问系统中的所有数据,因此内核中的错误往往会带来更严重的后果。那么,在这种情况下,为什么还要将 TLS 的额外复杂性引入内核呢?原因与大多数其他内核代码一样:性能。
KTLS 主要有两个理由。首先,它可以减少数据在内核与用户空间之间的额外拷贝。其次,它能够支持在网卡(NIC)中实现 TLS 卸载。
KTLS 的最初工作是为了恢复在使用 TLS 进行 HTTP 时,sendfile() 的零拷贝性能。在 sendfile() 出现之前,FTP 和 HTTP 服务器的典型工作流程要求通过 read() 将文件内容读入用户进程的缓冲区,然后通过调用 write() 将数据通过套接字发送出去(见图 2)。这会导致数据被拷贝三次:一次是在内核的缓冲区缓存(或虚拟内存页面缓存)中与文件关联,第二次是在用户空间的临时缓冲区中,第三次是在套接字缓冲区中。sendfile() 系统调用指示内核将文件的一部分内容通过套接字发送。在这个更高级的请求下,内核能够直接在套接字的发送缓冲区中重用缓冲区缓存中的文件数据,从而消除了额外的数据拷贝(见图 3)。
图 2:sendfile() 之前的 HTTP/FTP 工作流程
图 3:sendfile() HTTP/FTP 工作流程
在使用 TLS 的 HTTP 中,sendfile() 系统调用无法像之前那样直接使用。通过套接字发送的数据不再是文件内容的精确拷贝。相反,文件数据必须先进行加密,并封装在 TLS 帧中。由于 TLS 封装是在用户空间执行的,这就需要回到 sendfile() 之前的工作流程。数据从缓冲区缓存复制到用户空间中的临时缓冲区,在那里进行加密和封装。然后,这些修改过的数据再复制到内核的套接字缓冲区中(见图 4)。通过将 TLS 处理移入内核,我们可以避免这些额外的数据拷贝之一或两个。
图 4:带用户 TLS 的 HTTPS 工作流程
类似的问题也出现在一个较新的工作流程中:NFS over TLS。NFS 客户端和服务器运行在内核中。它们直接在缓冲区缓存和套接字缓冲区之间移动文件数据。尽管客户端和服务器确实在缓冲区缓存和套接字缓冲区之间拷贝数据,但在用户空间执行 TLS 会导致需要额外的拷贝进出用户空间。
最近几款以太网卡,包括 Chelsio T6 和 Mellanox ConnectX-6 Dx,支持对 TLS 记录的内联加密和解密。对于发送,这允许主机将未加密的数据发送到网卡(NIC),然后网卡会加密数据并将其拆分成多个 TCP 段,再通过网络传输数据。同样,在接收的情况下,网卡将多个 TCP 段组装成一个 TLS 记录,并将解密后的 TLS 记录提供给主机。驱动程序负责将会话密钥提供给网卡,以便执行内联加密和解密。
这些使用场景有类似的要求。sendfile() 要求内核控制 TLS 封装,包括序列号。由于序列号是 TLS 中用于身份验证算法的输入,这就要求内核管理所有套接字上的 TLS 帧加密操作,以支持 sendfile()。加密所有 TLS 帧要求用户空间将所有 TLS 记录的未加密数据提供给内核。NFS over TLS 同样要求内核对 TLS 封装进行完全控制。网卡中的 TLS 卸载需要对所有套接字上的 TLS 帧访问未加密数据。
简而言之,这些使用场景要求将 TLS 记录层移入内核。然而,这些使用场景不要求将 TLS 协议消息的处理(例如,用于建立 TLS 会话和协商会话密钥的消息)移入内核。因此,KTLS 将 TLS 记录层的处理移入内核,但将 TLS 协议消息的管理保留在现有的用户空间 SSL 库中。
这类似于 IPsec 的处理方式,其中密钥协商由用户空间守护进程执行,但单个数据包的加密和解密在内核中进行。对于 IPsec,密钥通过一个独立的带外连接进行协商,并在多个连接之间共享。对于 TLS,密钥是连接特有的,并通过 TLS 协议消息在连接内进行协商。此外,TLS 记录和 IPsec 数据包都由许多相同的算法进行加密和认证。
内核中的 KTLS 由三个主要组件组成。TLS 会话存储有关 TLS 连接单向传输的信息,包括使用的密码套件、会话密钥和用于加密或解密 TLS 记录的后端。KTLS 还与网络栈的发送和接收路径相连接。尽管发送和接收路径共享一些属性,但它们的实现方式有所不同。
TLS 会话管理 TLS 记录的加密和解密。TLS 连接的发送端和接收端是独立管理的,发送和接收各自有一个独立的 TLS 会话。每个会话由一个 struct tls_session
对象描述,包含会话的相关信息,例如 TLS 版本、密码套件、加密和认证密钥。会话还与一个后端相关联,该后端执行加密和解密操作。
支持三种不同类型的会话后端。软件后端在套接字缓冲区中存在时加密和解密 TLS 记录。它们不需要了解套接字层之外的 TLS 内容。具体来说,像 TCP 和设备驱动程序这样的协议不需要进行任何更改来支持软件后端。网卡 TLS 后端在网络卡中加密和解密 TLS 记录,作为发送或接收数据包的一部分。这需要与协议层合作,并且设备驱动程序需要显式支持。最后,TOE TLS 后端的工作方式类似于 NIC TLS,只不过它们利用 TOE 支持来管理 TCP 状态,例如重传。
KTLS 通常独立处理 TLS 连接的发送和接收两端。一个连接可能只卸载其中一端,而不是两端。此外,一个连接可能为每一端使用不同类型的后端。例如,连接可以在内核中卸载 TLS 发送,而在用户空间的 SSL 库中处理 TLS 接收,或者连接可以使用 网卡 TLS 来卸载 TLS 发送,并使用软件后端来处理 TLS 接收。
KTLS 的最初工作集中在卸载发送的 TLS 记录的加密操作。HTTPS 服务器的静态内容工作负载通常发送的数据量远大于接收的数据量。因此,对于这些工作负载,最大的性能提升来自于卸载发送数据的加密,而不是接收数据的解密。
当 TLS 发送被卸载到内核时,用户空间应用程序始终将未加密的数据提供给内核。通过像 write()
或 sendfile()
这样的系统调用发送的数据由内核拆分成独立的 TLS 记录。这些 TLS 记录始终使用应用数据消息类型。用户空间可以使用 sendmsg()
系统调用和 TLS_SET_RECORD_TYPE
控制消息发送具有自定义类型和大小的单个 TLS 记录。对于这些请求,消息头中的 scatter/gather 列表描述的数据内容会在一个 TLS 记录中发送,并使用控制消息中给定的消息类型。这由 SSL 库用于发送 TLS 协议消息,如握手消息和警报。
TLS 发送会话由 TCP_TXTLS_ENABLE
套接字选项创建。用户空间 SSL 库调用 setsockopt()
,并使用此选项提供 struct tls_enable
实例作为选项值。该结构包含指向会话密钥的指针,以及协商的密码套件和 TLS 协议版本的描述。内核中的套接字选项处理程序创建一个新的 TLS 会话对象,并探测后端。TOE TLS 和 网卡 TLS 后端通过调用与该连接关联的网卡(NIC)设备驱动程序来进行探测。如果网卡驱动程序不支持 TLS 或无法卸载该连接,处理程序会继续寻找软件后端。如果没有找到后端,setsockopt()
系统调用将失败,SSL 库将继续在用户空间执行 TLS 加密。如果找到了后端,新的 TLS 会话的引用将保存在套接字的发送缓冲区中。套接字发送缓冲区中的现有数据会原样传输,但所有随后写入套接字缓冲区的数据都会被封装为 TLS 记录并进行加密。
每个发送的 TLS 记录由一个单独的 struct mbuf
描述。TLS mbuf 使用 FreeBSD 13 中新增的外部页面 mbuf。外部页面 mbuf 不会在由 m_data
指向的虚拟连续缓冲区中存储有效负载数据。相反,这些 mbuf 包含一个物理地址指针数组,指向一个或多个内存中的页面。这意味着传统的访问 mbuf 数据的方法 mtod()
无法与这些 mbuf 一起使用。使用这些 mbuf 的内核代码路径已经进行了广泛的审计,确保没有使用 mtod()
。
这些 mbuf 最初是为了提高 sendfile()
的性能而添加的。之前,sendfile()
为文件中的每个页面使用一个单独的 mbuf。通过外部页面 mbuf,一个单一的 mbuf 可以描述多个页面。这使得较少数量的 mbuf 就能描述套接字缓冲区中相同数量的文件数据。较少的 mbuf 意味着在遍历套接字缓冲区中 mbuf 的链表时会减少缓存未命中的次数,从而提高性能。
网络接口设备驱动程序可以选择通过广告支持 NOMAP 功能来支持发送这些类型的 mbuf。对于使用现有 bus_dma
常规来映射 mbuf 的设备驱动程序,它们不需要进一步的更改,除非在其附加例程中设置 IFCAP_NOMAP
到 if_capabilities
和 if_capenable
。需要注意的是,设备驱动程序必须能够卸载包含这些 mbuf 的数据包的校验和,但校验和卸载是 NIC 广泛支持的功能。如果设备驱动程序不支持外部页面 mbuf 或不支持必要的校验和卸载,则网络栈会将外部页面 mbuf 转换为一系列常规 mbuf,再传递给设备驱动程序。
TLS mbuf 扩展了外部页面 mbuf,存储了 TLS 特定的信息,如 TLS 记录头和尾部,以及 TLS 会话的引用。支持外部页面 mbuf 的设备驱动程序必须也支持发送存储在 TLS 记录头和尾部字段中的数据。然而,对于大多数设备驱动程序来说,这不需要任何特定的更改,因为现有的 bus_dma
常规已经处理了这些字段。
ktls_frame()
函数设置 TLS mbuf 的 TLS 特定信息。该函数使用与套接字的发送缓冲区关联的 TLS 会话来构建记录。它将此 TLS 会话的引用存储在 mbuf 中,并填充记录的 TLS 头部,包括任何显式的 IV 或随机数。最后,函数计算 TLS 尾部的长度,并考虑任何需要的填充。TLS 尾部的内容在 TLS 记录加密之前不会被设置,但设置尾部长度确保 mbuf 为 TCP 序列号保留正确的空间。
当使用软件后端时,TLS 传输会在套接字缓冲区中加密 TLS 记录,然后将它们释放到传输协议中。在套接字缓冲区中,这依赖于现有的 M_NOTREADY
mbuf 标志。
M_NOTREADY
mbuf 标志用于标记套接字缓冲区中尚未包含有效数据的 mbuf。这些 mbuf 仍然在套接字缓冲区中保留空间,为生产数据的过程提供背压,但不能被套接字缓冲区的消费者使用。sendfile()
系统调用使用这些 mbuf 来为文件中尚未加载到内存中的区域保留空间。在此时,磁盘 I/O 请求会调度以填充这些缺失的页面。当 I/O 请求完成时,通过清除 M_NOTREADY
标志来标记 mbuf 为已准备好,协议会收到通知,表示套接字缓冲区中有新的数据可以发送。
软件 TLS 传输重用了这个框架来处理 TLS 记录,以区分未加密的 TLS 记录和加密的 TLS 记录。当通过如 write()
或 sendfile()
等系统调用将未加密的 TLS 记录排队到套接字缓冲区时,ktls_frame()
会在描述 TLS 记录的 mbuf 上设置 M_NOTREADY
标志。此外,mbuf 被放置在未加密 TLS 记录的队列中。一个线程池(每个 CPU 一个线程)会服务这个队列。工作线程调用软件后端加密每个 TLS 记录。一旦 TLS 记录被加密,关联的 mbuf 被标记为已准备好(见图 5)。此时,TLS mbuf 现在成为一个“常规”外部页面 mbuf。网络栈从协议层到设备驱动程序都无需执行任何额外的工作来支持 TLS(使用软件后端时)。关于外部页面 mbuf 被转换为常规 mbuf 链的警告仍然适用,但大多数设备驱动程序可以通过支持外部页面 mbuf 来轻松解决此问题。
图 5:软件 TLS 传输概览
网卡 TLS 后端除了软件 TLS 所需的更改外,还需要在网络栈和设备驱动程序中进行额外的更改。使用 网卡 TLS 时,未加密的 TLS 记录会通过网络栈传递到设备驱动程序。TLS 记录的有效负载在通过 DMA 从主机内存读取后,在传输到网络前由 NIC 加密(见图 6)。
图 6:网卡 TLS 传输概览
为了探测 网卡 TLS 后端,TCP_TXTLS_ENABLE
套接字选项的处理程序使用与套接字关联的路由来查找服务该套接字的网络接口。该处理程序尝试从接口分配一个 IF_SND_TAG_TLS
发送标签。此发送标签将设置在包含来自该会话的一个或多个 TLS 记录的 mbuf 链的第一个 mbuf 上。
为了将未加密的 TLS 记录传递给 NIC,ktls_frame()
函数在使用 网卡 TLS 后端时不会在 TLS mbuf 上设置 M_NOTREADY
标志。网卡 TLS mbuf 会在排队到套接字缓冲区时直接传递给传输协议。
网卡 TLS 确实需要对 TCP 和 IP 协议进行一些更改。首先,TCP 必须确保不会将“普通”数据与 TLS 记录混合到同一个数据包中。一般来说,TCP 允许构建跨越多个 mbuf 的数据包。在 KTLS 中,连接最初包含几个“普通”数据 mbuf。一旦启用了 TLS 传输,套接字缓冲区中的所有未来 mbuf 都将包含 TLS 记录。这意味着,在启用 TLS 传输后,套接字缓冲区将暂时包含两种类型的 mbuf。为了简化下层协议栈对 网卡 TLS 的支持,TCP 不会发送包含这两种类型 mbuf 的数据包。这在 tcp_mcopym()
函数中处理。其次,IP 和 IP6 输出必须在每个数据包开始时,在将数据包传递给设备驱动程序之前,设置数据包头部 mbuf 的发送标签。由于 TCP 不会混合不同类型的 mbuf,IP 输出可以检查数据包中的第一个数据 mbuf,看看它是否有 TLS 会话。如果有,它会读取 TLS 会话中的发送标签,并将其设置到数据包头部 mbuf 上。这在 ip_output_send()
和 ip6_output_send()
函数中实现。
最后,网卡 TLS 需要设备驱动程序的支持。设备驱动程序必须处理创建 TLS 发送标签的请求。驱动程序检查是否支持请求的 TLS 版本和密码套件。如果支持,它会分配一个新的发送标签,用于保存 TLS 会话所需的设备特定状态。此外,驱动程序必须识别指定了 TLS 发送标签的传入数据包,并安排对其进行分段和加密。这使得情况变得更加复杂,因为 TCP 可能会选择仅发送 TLS 记录的一部分。因此,驱动程序不能依赖于为每个请求发送完整的 TLS 记录。它可能需要发送 TLS 记录的开头、结尾或 TLS 记录中间的一部分。此外,由于 TLS 隐式支持 TCP 分段卸载(TSO),单个 TCP “数据包”可能跨越多个 TLS 记录。
TLS mbuf 的一个关键特性是每个 TLS 记录由一个单独的 mbuf 描述。这确保了网卡驱动程序在需要重新传输 TLS 记录的任何部分时,始终能够轻松访问整个 TLS 记录的内容。例如,NIC 可能需要整个记录的内容来计算存储在 TLS 记录尾部的身份验证代码,当传输 TLS 记录的结尾时。如果 TLS 记录分布在多个 mbuf 中,TCP 可能会释放持有已被远程端确认的 TLS 记录部分的 mbuf。此外,网卡 TLS 驱动程序还需要一种可靠的方法来查找属于同一 TLS 记录的其他 mbuf,例如在驱动程序中持有额外的 mbuf 引用,或者检查套接字缓冲区以定位属于同一 TLS 记录的其他 mbuf。使用单个 mbuf 来表示每个 TLS 记录消除了这种复杂性,并简化了 网卡 TLS 驱动程序。
TOE TLS 使用类似于 网卡 TLS 的数据流来传输 TLS 记录。与 网卡 TLS 一样,未加密的 TLS 记录直接通过套接字缓冲区传递给设备驱动程序。然而,TOE 驱动程序直接从套接字缓冲区读取数据,而不依赖于软件协议。这使得 TOE TLS 的实现相较于 网卡 TLS 更加简化。
为了探测 TOE TLS,套接字选项 TCP_TXTLS_ENABLE
的处理程序会检查当前套接字是否使用 TOE。如果套接字已被卸载,处理程序会调用附加的 TOE 驱动程序中的方法来分配一个 TLS 会话。
与 网卡 TLS 类似,ktls_frame()
在使用 TOE TLS 时不会在 TLS mbuf 上设置 M_NOTREADY
标志。TOE TLS mbuf 被插入到套接字缓冲区中以供立即消费。当数据可用时,TCP 调用 TOE 驱动程序的输出方法。该方法从套接字缓冲区读取数据,并将其发送到相关 NIC 的 TOE 队列以进行传输。由于 NIC 上的 TOE 引擎负责 TCP 分段和头部生成,TOE 方法将完整的 mbuf 从套接字缓冲区排队到 NIC。这意味着,对于 TOE TLS,该方法始终能够一次性发送完整的 TLS 记录,而无需像 网卡 TLS 那样处理部分记录传输的边缘情况。此外,由于 TOE 会检查套接字缓冲区中的 mbuf,因此 TOE TLS 不需要单独的发送标签,而是使用 TLS mbuf 中的 TLS 会话引用。
在 TLS 传输之后,内核 TLS 接收卸载是一个自然的下一步。虽然与 TLS 传输相比,Web 服务器负载从 TLS 接收卸载中受益较少,但其他使用 TLS 且流量较为平衡的负载可以从 TLS 接收卸载中受益。FreeBSD 不提供类似于 sendfile()(例如 recvfile())的用于从套接字读取数据的功能,因此接收卸载的主要理由是支持 TLS 上的 NFS 和在 NIC 中支持 TLS 接收卸载。
与 TLS 传输相比,内核 TLS 接收卸载在用户空间和内核的套接字层都进行了更深入的更改。TLS 传输允许应用程序数据作为较大的写操作直接写入,并由内核将其拆分为多个 TLS 记录。另一方面,TLS 接收每次系统调用返回一个单一的 TLS 记录。用户空间必须使用 recvmsg()
从套接字读取数据,使用 KTLS 接收时,每个 TLS 记录都会前置一个新的控制消息 TLS_GET_RECORD
,其中包含 TLS 记录头部。这允许用户空间的 SSL 库拦截并处理接收到的 TLS 协议消息。
使用 recvmsg()
的控制消息要求接收套接字缓冲区作为持有记录链表的 Datagram 套接字处理,而不是作为表示数据流的单个 mbuf 链表的流套接字。TCP 使用优化的钩子函数 soreceive_stream()
来返回接收的数据给用户空间,它假设套接字缓冲区是流套接字布局,并且不支持控制消息。对于 TLS 接收,soreceive_stream()
被修改为在套接字启用 TLS 时调用通用钩子函数 soreceive_generic()
。将解密后的 TLS 记录表示为数据报还允许任意 mbuf 存储 TLS 记录的有效负载数据,而不需要使用 TLS mbuf。
TLS 接收会话通过 TCP_RXTLS_ENABLE
套接字选项创建。此套接字选项使用与启用传输相同的 struct tls_enable
值。对于接收,结构体进行了扩展,新增了初始序列号字段。该字段用于处理一个边缘情况,即 SSL 客户端库可能在 TLS 协议消息完成密钥交换后从套接字中读取了额外的数据。在这种情况下,用户空间的库将在用户空间解密该挂起消息中的 TLS 记录。内核仅解密后续的 TLS 记录。然而,内核需要知道它解密的第一条消息的 TLS 序列号,因为序列号作为输入参与 TLS 记录分帧的身份验证阶段。对于传输,SSL 客户端库在发送任何加密的 TLS 记录之前调用 TCP_TXTLS_ENABLE
套接字选项,因此传输端的初始序列号始终为零。
与传输的情况类似,TCP_RXTLS_ENABLE
的处理程序会探测可用的后端。如果找到了后端,会创建一个 TLS 会话并将其与套接字的接收缓冲区关联。当前存在于套接字缓冲区中的任何数据都被视为加密的 TLS 记录。持有这些数据的 mbuf 被标记为未就绪(通过 M_NOTREADY
mbuf 标志),并被安排在用户空间读取之前进行解密。如果没有找到后端,setsockopt()
系统调用将失败,用户空间的 SSL 库将在用户空间解密 TLS 记录。
当前内核 TLS 接收支持 TOE 和软件 TLS 后端。尚不支持 网卡 TLS 后端。对于 TLS 传输,首先实现了软件 TLS,随后添加了 NIC 和 TOE TLS。对于 TLS 接收,TOE TLS 是第一个实现的后端,软件 TLS 是第二个实现的。
TOE TLS 接收使用简单的数据流(见图 7)。NIC 将解密和验证后的 TLS 记录交付给 TOE 设备驱动程序。TOE 设备驱动程序将这些解密后的 TLS 记录直接附加到接收套接字缓冲区中。
图 7:TOE TLS 接收概述
与 TOE TLS 传输一样,TCP_RXTLS_ENABLE
套接字选项的处理程序会检查当前套接字是否使用 TOE。如果套接字已卸载,处理程序会调用附加的 TOE 驱动程序中的一个方法来分配一个 TLS 会话。
对于 TOE TLS,附加解密后的 TLS 记录到套接字缓冲区是直接的。TOE 设备驱动程序分配一个控制消息 mbuf 来保存 TLS 头部。然后,驱动程序将此控制消息与包含 TLS 记录有效负载的 mbuf 链一起传递给 sbappendcontrol()
,将解密后的 TLS 记录作为数据报附加到套接字缓冲区。
软件 TLS 接收的操作类似于软件 TLS 传输,唯一不同的是数据流的方向相反。协议将包含加密 TLS 记录的 mbuf 排入套接字缓冲区。这些 mbuf 会被标记为 M_NOTREADY
,直到它们被解密,此时它们才可供用户空间应用程序读取(见图 8)。与 TLS 传输类似,TLS 记录的解密是由一个工作线程池异步执行的。然而,软件 TLS 接收与软件 TLS 传输相比有几个不同之处。
图 8:软件 TLS 接收概述
对于 TLS 传输,生成数据并存储到套接字缓冲区中的生产者知道它正在生成 TLS 记录。在这种情况下,生产者是像 write()
或 sendfile()
这样的系统调用。因此,每个传输的 TLS 记录都会作为单个 TLS mbuf 存储。这允许套接字的发送缓冲区使用流套接字缓冲区的正常布局,即使它包含一系列 TLS 记录。软件传输 TLS 还能够在加密 TLS 记录时就地修改 TLS mbufs 的内容,而无需修改套接字缓冲区中 mbufs 的链表。一旦加密完成,M_NOTREADY
标志会从 mbuf 中清除,使 TLS 记录可以通过协议层进行传输。
对于 TLS 接收,接收数据到 mbufs 中的生产者并不知道 TLS 的存在。设备驱动程序接收包含不同协议的多流数据包流。协议最终将这些 mbufs 交付给套接字缓冲区,作为一个字节流。在这种情况下,mbuf 边界与 TLS 记录边界之间没有定义的对齐方式。一个 TLS 记录可以跨越多个 mbuf,而一个 mbuf 可能包含来自多个 TLS 记录的数据。例如,一个 TLS 记录可能从一个 mbuf 的中间开始,并通过十个或更多的 mbuf 继续,直到结束。存储这类 mbuf 数据流的最自然方式是作为普通流套接字中的单一数据流。然而,存储在接收套接字缓冲区中的 TLS 数据是一个数据报列表,可以通过 recvmsg()
读取。
软件 TLS 传输和接收之间的另一个关键区别是套接字缓冲区中 mbufs 的生命周期。对于软件 TLS 的两个方向,TLS 记录的加密和解密由工作线程执行。这些工作线程必须确保在软件后端执行加密或解密时,不会释放正在被修改的 mbuf。当套接字的传输方向被 close()
或 shutdown()
关闭时,发送套接字缓冲区中的待处理数据不会立即释放,而是被保留在套接字缓冲区中,直到发送到远程客户端。然而,当套接字的接收方向被关闭时,接收套接字缓冲区中的待处理数据会立即释放。如果在关闭套接字接收方向时,某个工作线程正在解密 TLS 记录,TLS 接收必须确保在解密完成之前不会释放这些 mbuf。
为了处理这些差异,TLS 接收将接收套接字缓冲区中的数据分为三类。尽管套接字缓冲区在其统计中包括所有三种类型的数据,但每类数据的存储方式不同,且每类的统计细节有所不同。
在 TLS 接收套接字缓冲区中存储的第一类数据是已解密的 TLS 记录。这些数据作为数据报存储在由 sb_mb
指向的正常套接字缓冲区链中。与这些 TLS 记录相关的所有 mbufs 会像常规套接字缓冲区一样进行统计,包括 sb_ccnt
、sb_mcnt
和 sb_mbcnt
中的 mbufs 和 mbuf 聚集体的计数。
在 TLS 接收套接字缓冲区中存储的第二类数据是包含加密 TLS 记录的原始协议数据。这些 mbufs 被存储在一个单链表中,由新的 sb_mtls
成员指向。除了常规的套接字缓冲区统计之外,sb_tlscc
还统计存储在套接字缓冲区中的此类数据的字节数。
在 TLS 接收套接字缓冲区中存储的最后一类数据是“分离”的 TLS 记录。当工作线程将一个包含加密 TLS 记录的 mbuf 链组装完成时,它会将这些 mbufs 从 sb_mtls
链中分离出来。然后,它会通过分离的 TLS 记录的字节数递增新的 sb_dtlscc
成员。在记录被解密之后,工作线程检查 sb_dtlscc
是否已经通过调用 sbflush()
或 sbcut()
被清除。如果是,它会释放 mbuf 链。否则,它会将现在解密的 TLS 记录作为数据报附加到 sb_mb
链中。在 TLS 记录被分离期间,套接字缓冲区统计不会追踪 TLS 记录所使用的 mbufs 或聚集体。只有字节会在 sb_tlsdcc
和 sb_ccc
中进行统计。这仍然为 TCP 提供了准确的已用空间视图,以便计算广告给 TCP 连接远程端的接收窗口。
逻辑上,这三类数据在套接字缓冲区中的顺序为:sb_mb
链,接着是任何分离的 TLS 记录,然后是 sb_mtls
链。当 sbcut()
或 sbflush()
遍历套接字缓冲区以释放 mbufs 时,它们按这个顺序遍历类来释放数据。实际上,这些函数只会在 TCP 套接字的接收缓冲区上被调用一次,即当套接字的接收方向被关闭时,刷新整个缓冲区。分离记录的处理利用这一点,通过确保每次调用 sbcut()
或 sbflush()
时,要么释放所有分离的 TLS 记录,要么一个也不释放。
当协议层将数据附加到 TLS 接收套接字缓冲区时,sbappendstream()
会调用新的 sbappend_ktls_rx()
函数,将新的 mbufs 附加到 sb_mtls
链中。在 mbufs 被附加后,ktls_check_rx()
会检查 sb_mtls
链的头部。这个函数读取下一个 TLS 记录的 TLS 头部(如果完整的 TLS 头部可用),并提取长度字段。然后,它会检查 sb_mtls
链的长度是否与 TLS 头部中的长度一致。如果完整的 TLS 记录已经接收,ktls_check_rx()
会调度套接字由 KTLS 工作线程进行解密。
KTLS 工作线程从 sb_mtls
链中解密 TLS 记录。工作线程首先将 TLS 记录从接收套接字缓冲区中分离。这会将保存 TLS 记录数据的 mbufs 从 sb_mtls
链中移除,但会在 sb_tlsdcc
中对其数据进行统计。接着,它调用软件后端解密 TLS 记录。由于记录已经被分离,因此可以在不持有任何套接字缓冲区锁的情况下进行解密。解密完成后,工作线程锁定套接字缓冲区,检查在解密过程中是否调用了 sbcut()
或 sbflush()
。如果没有,工作线程会分配一个控制消息来保存 TLS 记录头部,修剪原始 TLS 记录 mbuf 链中的 TLS 头部和尾部,然后调用 sbappendcontrol()
将 TLS 记录作为数据报附加到 sb_mb
链中。
内核 TLS 是 FreeBSD 13.0 中一个令人兴奋但复杂的新特性。在撰写本文时,内核 TLS 传输的支持已被合并到 FreeBSD 的主分支中,包括对软件 TLS、网卡 TLS 和 TOE TLS 的支持。一个 ktls_ocf
内核模块为使用 TLS 1.2 和 1.3 协议的连接提供 AES-GCM 密码套件的软件 TLS 支持。由于该模块使用内核的 opencrypto 框架,它允许通过加密协处理器驱动程序而非仅依赖主机 CPU 上的软件来加密 TLS 记录。Chelsio 和 Mellanox 设备驱动程序都支持 NIC TLS,而 Chelsio 的 TOE 设备驱动程序支持 TOE TLS。在接收端,包括 TOE TLS 支持的核心 TLS 接收框架已合并到主分支。软件 TLS 接收仍在审查中,但应该会在 13.0 发布之前出现在主分支中。持续的工作还在进一步优化性能。
内核 TLS 还需要对用户空间 SSL 库进行更改。在撰写本文时,OpenSSL 的开发分支已包括对 FreeBSD 上 TLS 1.0-1.2 传输的支持。这一功能作为选项 KTLS 提供,位于 Port security/openssl-devel
中。此外,当前的 KTLS 补丁已被回移植到 OpenSSL 1.1.1 中,作为 Port security/openssl
的选项 KTLS 。对 TLS 1.3 的传输支持和对 TLS 1.1-1.2 的接收支持目前正在审查中,并应在 OpenSSL 3.0.0 发布之前加入。
Netflix 还开发了一个 nginx 扩展,以利用 OpenSSL 中的新 SSL_sendfile()
函数通过 TLS 使用 sendfile()
。目前,这一功能作为补丁提供,位于 Port www/nginx-devel
下的 KTLS Port 选项中。
最后,内核 TLS 是一个庞大的项目,由 FreeBSD 社区的多个成员共同努力。当前的工作成果是经过多年的迭代和改进。Scott Long 在 Netflix 工作时首次构想将 TLS 移入内核。他与 Randall Stewart(也在 Netflix)合作设计并实现了软件 TLS 传输的第一个版本。Drew Gallatin 引入了外部页面 mbufs 及其后续扩展为 TLS mbufs,并将 Netflix 早期的 KTLS 转换为使用 M_NOTREADY mbufs 进行软件 TLS 传输。Drew 还为软件 TLS 后端添加了可插拔接口。我与 Drew 一起添加了 网卡 TLS 传输的基础设施,并支持在 Chelsio T6 适配器上进行 网卡 TLS 传输。Hans Petter Selasky 为 Mellanox ConnectX-6 Dx 适配器添加了对 网卡 TLS 的支持。随后,我负责了 TOE TLS 和 TLS 接收的工作。Scott、Randall 和 Drew 的工作由 Netflix 提供资金支持,Hans Petter 的工作由 Mellanox 提供资金支持,我的工作则由 Chelsio 和 Netflix 提供资金支持。
JOHN BALDWIN 是名系统软件开发人员。在过去 20 年里,他直接为 FreeBSD 操作系统的各个部分(包括 x86 平台支持、SMP、各种设备驱动程序和虚拟内存子系统)以及用户空间程序提交了代码更改。除了编写代码外,John 还曾在 FreeBSD 核心团队和发布工程团队工作。他还为 GDB 调试器和 LLVM 作出了贡献。John 目前与妻子 Kimberly 及三名子女 Janelle、Evan 和 Bella 一起生活在加利福尼亚州的康科德市。