Kernel 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);
除非传递了 MSG_MORE,否则在每次 send() 调用后都会创建并发送 TLS 记录。 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 1.3 密钥更新¶
在 TLS 1.3 中,KeyUpdate 握手消息表示发送者正在更新其 TX 密钥。 在 KeyUpdate 之后发送的任何消息都将使用新密钥加密。 用户空间库可以使用 TLS_TX 和 TLS_RX 套接字选项将新密钥传递给内核,就像初始密钥一样。 TLS 版本和密码不能更改。
为了防止尝试使用错误的密钥解密传入记录,当内核接收到 KeyUpdate 消息时,解密将暂停,直到使用 TLS_RX 套接字选项提供新密钥。 在读取 KeyUpdate 之后和提供新密钥之前的任何读取都将失败并显示 EKEYEXPIRED。 在提供新密钥之前,poll() 不会报告来自套接字的任何读取事件。 发送端没有暂停。
用户空间应确保 crypto_info 已正确设置。 特别是,内核不会检查密钥/nonce 重用。
成功和失败的密钥更新的数量在 TlsTxRekeyOk
, TlsRxRekeyOk
, TlsTxRekeyError
, TlsRxRekeyError
统计信息中跟踪。 TlsRxRekeyReceived
统计信息计算已接收的 KeyUpdate 握手消息的数量。
集成到用户空间 TLS 库中¶
在高层次上,内核 TLS ULP 是用户空间 TLS 库记录层的替代品。
使用 ktls 作为记录层的 OpenSSL 补丁集是 here。
一个例子 展示了使用 gnutls 在握手后直接调用 send。 由于它没有实现完整的记录层,因此不支持控制消息。
可选优化¶
如果请求,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 数据记录数。TlsTxRekeyOk
,TlsRxRekeyOk
- 现有会话上 TX 和 RX 的成功重新密钥数TlsTxRekeyError
,TlsRxRekeyError
- 现有会话上 TX 和 RX 的失败重新密钥数TlsRxRekeyReceived
- 已接收的 KeyUpdate 握手消息数,需要用户空间提供新的 RX 密钥