内核 TLS

概述

传输层安全 (TLS) 是一种运行在 TCP 之上的上层协议 (ULP)。TLS 提供端到端的数据完整性和机密性。

用户接口

创建 TLS 连接

首先创建一个新的 TCP 套接字并设置 TLS ULP。

sock = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(sock, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));

设置 TLS ULP 允许我们设置/获取 TLS 套接字选项。目前,只有对称加密在内核中处理。在 TLS 握手完成后,我们拥有将数据路径移动到内核所需的所有参数。有一个单独的套接字选项用于将发送和接收移动到内核。

/* From linux/tls.h */
struct tls_crypto_info {
        unsigned short version;
        unsigned short cipher_type;
};

struct tls12_crypto_info_aes_gcm_128 {
        struct tls_crypto_info info;
        unsigned char iv[TLS_CIPHER_AES_GCM_128_IV_SIZE];
        unsigned char key[TLS_CIPHER_AES_GCM_128_KEY_SIZE];
        unsigned char salt[TLS_CIPHER_AES_GCM_128_SALT_SIZE];
        unsigned char rec_seq[TLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE];
};


struct tls12_crypto_info_aes_gcm_128 crypto_info;

crypto_info.info.version = TLS_1_2_VERSION;
crypto_info.info.cipher_type = TLS_CIPHER_AES_GCM_128;
memcpy(crypto_info.iv, iv_write, TLS_CIPHER_AES_GCM_128_IV_SIZE);
memcpy(crypto_info.rec_seq, seq_number_write,
                                      TLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE);
memcpy(crypto_info.key, cipher_key_write, TLS_CIPHER_AES_GCM_128_KEY_SIZE);
memcpy(crypto_info.salt, implicit_iv_write, TLS_CIPHER_AES_GCM_128_SALT_SIZE);

setsockopt(sock, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info));

发送和接收是分开设置的,但设置方式相同,使用 TLS_TX 或 TLS_RX。

发送 TLS 应用程序数据

在设置 TLS_TX 套接字选项后,通过此套接字发送的所有应用程序数据都将使用 TLS 以及套接字选项中提供的参数进行加密。例如,我们可以如下发送加密的 hello world 记录

const char *msg = "hello world\n";
send(sock, msg, strlen(msg));

如果可能,send() 数据将直接从用户空间提供的缓冲区加密到加密的内核发送缓冲区。

sendfile 系统调用将通过最大长度 (2^14) 的 TLS 记录发送文件的数据。

file = open(filename, O_RDONLY);
fstat(file, &stat);
sendfile(sock, file, &offset, stat.st_size);

TLS 记录在每次 send() 调用后创建并发送,除非传递了 MSG_MORE。MSG_MORE 将延迟记录的创建,直到没有传递 MSG_MORE 或达到最大记录大小。

内核将需要为加密数据分配缓冲区。此缓冲区在调用 send() 时分配,这样要么整个 send() 调用将返回 -ENOMEM(或阻塞等待内存),要么加密将始终成功。如果 send() 返回 -ENOMEM,并且来自先前使用 MSG_MORE 调用的某些数据保留在套接字缓冲区上,则 MSG_MORE 数据将保留在套接字缓冲区上。

接收 TLS 应用程序数据

在设置 TLS_RX 套接字选项后,所有 recv 系列套接字调用都将使用提供的 TLS 参数进行解密。必须先收到完整的 TLS 记录才能进行解密。

char buffer[16384];
recv(sock, buffer, 16384);

如果用户缓冲区足够大,则接收到的数据将直接解密到用户缓冲区中,并且不会发生额外的分配。如果用户空间缓冲区太小,则数据将在内核中解密并复制到用户空间。

如果接收到的消息中的 TLS 版本与 setsockopt 中传递的版本不匹配,则返回 EINVAL

如果接收到的消息太大,则返回 EMSGSIZE

如果由于任何其他原因导致解密失败,则返回 EBADMSG

发送 TLS 控制消息

除了应用程序数据外,TLS 还有控制消息,例如警报消息(记录类型 21)和握手消息(记录类型 22)等。可以通过 CMSG 提供 TLS 记录类型,在套接字上发送这些消息。例如,以下函数使用 @record_type 类型的记录发送 @length 字节的 @data。

/* send TLS control message using record_type */
static int klts_send_ctrl_message(int sock, unsigned char record_type,
                                  void *data, size_t length)
{
      struct msghdr msg = {0};
      int cmsg_len = sizeof(record_type);
      struct cmsghdr *cmsg;
      char buf[CMSG_SPACE(cmsg_len)];
      struct iovec msg_iov;   /* Vector of data to send/receive into.  */

      msg.msg_control = buf;
      msg.msg_controllen = sizeof(buf);
      cmsg = CMSG_FIRSTHDR(&msg);
      cmsg->cmsg_level = SOL_TLS;
      cmsg->cmsg_type = TLS_SET_RECORD_TYPE;
      cmsg->cmsg_len = CMSG_LEN(cmsg_len);
      *CMSG_DATA(cmsg) = record_type;
      msg.msg_controllen = cmsg->cmsg_len;

      msg_iov.iov_base = data;
      msg_iov.iov_len = length;
      msg.msg_iov = &msg_iov;
      msg.msg_iovlen = 1;

      return sendmsg(sock, &msg, 0);
}

控制消息数据应以未加密的方式提供,并由内核加密。

接收 TLS 控制消息

TLS 控制消息在用户空间缓冲区中传递,消息类型通过 cmsg 传递。如果没有提供 cmsg 缓冲区,则在收到控制消息时会返回错误。可以在不设置 cmsg 缓冲区的情况下接收数据消息。

char buffer[16384];
char cmsg[CMSG_SPACE(sizeof(unsigned char))];
struct msghdr msg = {0};
msg.msg_control = cmsg;
msg.msg_controllen = sizeof(cmsg);

struct iovec msg_iov;
msg_iov.iov_base = buffer;
msg_iov.iov_len = 16384;

msg.msg_iov = &msg_iov;
msg.msg_iovlen = 1;

int ret = recvmsg(sock, &msg, 0 /* flags */);

struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
if (cmsg->cmsg_level == SOL_TLS &&
    cmsg->cmsg_type == TLS_GET_RECORD_TYPE) {
    int record_type = *((unsigned char *)CMSG_DATA(cmsg));
    // Do something with record_type, and control message data in
    // buffer.
    //
    // Note that record_type may be == to application data (23).
} else {
    // Buffer contains application data.
}

recv 永远不会返回来自混合类型的 TLS 记录的数据。

集成到用户空间 TLS 库中

从高层次来看,内核 TLS ULP 是用户空间 TLS 库记录层的替代品。

使用 ktls 作为记录层的 OpenSSL 补丁集在这里

使用 gnutls 在握手后直接调用发送的示例。由于它没有实现完整的记录层,因此不支持控制消息。

可选优化

如果请求,TLS ULP 可以进行某些特定于条件的优化。这些优化要么不是普遍有益的,要么可能会影响正确性,因此它们需要选择加入。所有选项都通过 setsockopt() 进行按套接字设置,它们的状态可以使用 getsockopt() 和通过套接字诊断 (ss) 进行检查。

TLS_TX_ZEROCOPY_RO

仅用于设备卸载。允许 sendfile() 数据直接传输到 NIC,而无需进行内核内复制。当启用设备卸载时,这允许真正的零拷贝行为。

应用程序必须确保数据在提交和传输完成之间不会被修改。换句话说,这主要适用于通过 sendfile() 在套接字上发送的数据是只读的。

修改数据可能会导致不同版本的数据用于原始 TCP 传输和 TCP 重传。对于接收器来说,这将看起来像 TLS 记录已被篡改,并将导致记录身份验证失败。

TLS_RX_EXPECT_NO_PAD

仅限 TLS 1.3。期望发送方不填充记录。这允许使用 TLS 1.3 将数据直接解密到用户空间缓冲区。

只有在信任远程端的情况下,启用此优化才是安全的,否则它是一个使 TLS 处理成本加倍的攻击向量。

如果解密的记录被证明已填充或不是数据记录,它将再次解密到没有零拷贝的内核缓冲区中。此类事件将计入 TlsDecryptRetry 统计信息中。

统计信息

TLS 实现会公开以下每个命名空间的统计信息 (/proc/net/tls_stat)

  • TlsCurrTxSw, TlsCurrRxSw - 当前安装的主机处理加密的 TX 和 RX 会话数

  • TlsCurrTxDevice, TlsCurrRxDevice - 当前安装的 NIC 处理加密的 TX 和 RX 会话数

  • TlsTxSw, TlsRxSw - 使用主机加密打开的 TX 和 RX 会话数

  • TlsTxDevice, TlsRxDevice - 使用 NIC 加密打开的 TX 和 RX 会话数

  • TlsDecryptError - 记录解密失败(例如,由于不正确的身份验证标记)

  • TlsDeviceRxResync - 发送到处理加密的 NIC 的 RX 重新同步数

  • TlsDecryptRetry - 由于 TLS_RX_EXPECT_NO_PAD 误预测而必须重新解密的 RX 记录数。请注意,此计数器也会为非数据记录递增。

  • TlsRxNoPadViolation - 由于 TLS_RX_EXPECT_NO_PAD 误预测而必须重新解密的数据 RX 记录数。