OpenSSL 编程简介,第二部分中的第一部分
保护基于 TCP 的网络应用程序的最快速和最简单的方法是使用 SSL。如果您使用 C 语言,那么最好的选择可能是使用 OpenSSL (http://www.openssl.org/)。OpenSSL 是基于 Eric Young 的 SSLeay 包的 SSL/TLS 的免费(BSD 风格许可证)实现。不幸的是,OpenSSL 附带的文档和示例代码有待改进。在存在手册页的地方,手册页相当不错,但它们通常会忽略全局,因为手册页旨在作为参考,而不是教程。
OpenSSL API 非常庞大且复杂,因此我们不会尝试在此处提供任何类似完整的覆盖范围。相反,我们的想法是教您足以有效地使用手册页的知识。在本文(共两篇中的第一篇)中,我们将构建一个简单的 Web 客户端和服务器对,以演示 OpenSSL 的基本功能。在第二篇文章中,我们将介绍许多高级功能,例如会话恢复和客户端身份验证。
我假设您至少在概念层面上已经熟悉 SSL 和 HTTP。如果您不熟悉,一个好的起点是 RFC(请参阅“资源”)。
由于篇幅原因,本文仅包含源代码的摘录。完整的源代码以机器可读格式从作者的网站 http://www.rtfm.com/openssl-examples/ 获取。
我们的客户端是一个简单的 HTTPS(参见 RFC 2818)客户端。它启动与服务器的 SSL 连接,然后通过该连接传输 HTTP 请求。然后,它等待服务器的响应并将其打印到屏幕上。这是在 fetch 和 cURL 等程序中找到的功能的极大简化版本。
服务器程序是一个简单的 HTTPS 服务器。它等待来自客户端的 TCP 连接。当它接受一个连接时,它会协商 SSL 连接。连接协商完成后,它会读取客户端的 HTTP 请求。然后,它将 HTTP 响应传输到客户端。响应传输完成后,它会关闭连接。
我们的首要任务是设置上下文对象 (SSL_CTX)。然后,此上下文对象用于为每个新的 SSL 连接创建新的连接对象。这些连接对象用于执行 SSL 握手、读取和写入。
这种方法有两个优点。首先,上下文对象允许许多结构仅初始化一次,从而提高性能。在大多数应用程序中,每个 SSL 连接都将使用相同的密钥材料、证书颁发机构 (CA) 列表等。我们不是为每个连接重新加载此材料,而是在程序启动时将其加载到上下文对象中。当我们希望创建新连接时,我们可以简单地将该连接指向上下文对象。拥有单个上下文对象的第二个优点是,它允许多个 SSL 连接共享数据,例如用于会话恢复的 SSL 会话缓存。上下文初始化包括四个主要任务,所有任务都由 initialize_ctx() 函数执行,如清单 1 所示。
在 OpenSSL 可以用于任何用途之前,必须初始化整个库。这通过 SSL_library_init() 完成,它主要加载 OpenSSL 将使用的算法。如果我们想要良好的错误报告,我们还需要使用 SSL_load_error_strings() 加载错误字符串。否则,我们将无法将 OpenSSL 错误映射到字符串。
我们还创建一个对象用作错误打印上下文。OpenSSL 使用一种称为 BIO 对象 的抽象概念进行输入和输出。这允许程序员对不同类型的 I/O 通道(套接字、终端、内存缓冲区等)使用相同的函数,只需使用不同类型的 BIO 对象即可。在本例中,我们创建一个附加到 stderr 的 BIO 对象,用于打印错误。
如果您正在编写能够执行客户端身份验证的服务器或客户端,则需要加载您自己的公钥/私钥对和关联的证书。证书以明文形式存储,并与 CA 证书一起使用 SSL_CTX_use_certificate_chain_file() 加载,形成证书链。SSL_CTX_use_PrivateKey_file() 用于加载私钥。出于安全原因,私钥通常使用密码加密。如果是这样,将调用密码回调(使用 SSL_CTX_set_default_passwd_cb() 设置)以获取密码。
如果您要验证您连接到的主机的身份,OpenSSL 需要知道您信任哪些 CA。SSL_CTX_load_verify_locations() 调用用于加载 CA。
为了获得良好的安全性,SSL 需要良好的强随机数来源。通常,应用程序有责任为随机数生成器 (RNG) 提供一些种子材料。但是,如果 /dev/urandom 可用,OpenSSL 会自动使用 /dev/urandom 为 RNG 播种。由于 /dev/urandom 是 Linux 上的标准配置,因此我们不必为此做任何事情,这很方便,因为收集随机数很棘手且容易搞砸。请注意,如果您使用的系统不是 Linux,您可能会在某个时候遇到错误,因为随机数生成器未播种。OpenSSL 的 rand(3) 手册页提供了更多信息。
客户端初始化 SSL 上下文后,就可以连接到服务器了。OpenSSL 要求我们自己在客户端和服务器之间创建 TCP 连接,然后使用 TCP 套接字创建 SSL 套接字。为了方便起见,我们将 TCP 连接的创建隔离到 tcp_connect() 函数(此处未显示,但在可下载的源代码中可用)。
创建 TCP 连接后,我们创建一个 SSL 对象来处理连接。此对象需要附加到套接字。请注意,我们没有直接将 SSL 对象附加到套接字。相反,我们使用套接字创建一个 BIO 对象,然后将 SSL 对象附加到 BIO。
此抽象层允许您通过套接字以外的通道使用 OpenSSL,前提是您具有合适的 BIO。例如,OpenSSL 测试程序之一纯粹通过内存缓冲区连接 SSL 客户端和服务器。更实际的用途是支持某些无法通过套接字访问的协议。例如,您可以在串行线上运行 SSL。
SSL 连接的第一步是执行 SSL 握手。握手验证服务器(以及可选的客户端)的身份,并建立将用于保护其余流量的密钥材料。SSL_connect() 调用用于执行 SSL 握手。由于我们使用阻塞套接字,因此 SSL_connect() 在握手完成或检测到错误之前不会返回。SSL_connect() 成功时返回 1,错误时返回 0 或负数。此调用如下所示
/* Connect the TCP socket*/ sock=tcp_connect(host,port); /* Connect the SSL socket */ ssl=SSL_new(ctx); sbio=BIO_new_socket(sock,BIO_NOCLOSE); SSL_set_bio(ssl,sbio,sbio); if(SSL_connect(ssl)<=0) berr_exit("SSL connect error"); if(require_server_auth) check_cert(ssl,host);
当我们启动与服务器的 SSL 连接时,我们需要检查服务器的证书链。OpenSSL 为我们执行了一些检查,但不幸的是,其他检查是特定于应用程序的,因此我们必须自己执行这些检查。我们的示例应用程序执行的主要测试是检查服务器身份。此检查由 check_cert 函数执行,如清单 2 所示。
一旦您确定服务器的证书链有效,您需要验证您正在查看的证书是否与您期望服务器拥有的身份匹配。在大多数情况下,这意味着服务器的 DNS 名称出现在证书中,要么在“主题名称”的“通用名称”字段中,要么在证书扩展中。虽然每个协议在验证服务器身份方面都有略微不同的规则,但 RFC 2818 包含 HTTP over SSL/TLS 的规则。除非您有明确的理由不这样做,否则通常最好遵循 RFC 2818。
由于大多数证书仍然在“通用名称”字段而不是扩展中包含域名,因此我们仅显示“通用名称”检查。我们只需使用 SSL_get_peer_certificate() 提取服务器的证书,然后将通用名称与我们连接到的主机名进行比较。如果它们不匹配,则说明出现问题,我们退出。
在 0.9.5 版本之前,OpenSSL 容易受到证书扩展攻击。为了理解这一点,请考虑服务器使用 Bob 签名的证书进行身份验证的情况,如图 1 所示。Bob 不是您的 CA 之一,但他的证书由您信任的 CA 签名。

图 1. 扩展证书链
如果您接受此证书,您将遇到很多麻烦。CA 签署 Bob 的证书这一事实意味着 CA 相信它已经验证了 Bob 的身份,而不是 Bob 可以被信任。如果您知道您想与 Bob 做生意,那很好,但如果您想与 Alice 做生意,而 Bob(您从未听说过的人)为 Alice 担保,则这没什么用处。
最初,保护自己免受此类攻击的唯一方法是限制证书链的长度,以便您知道您正在查看的证书是由 CA 签名的。X.509 版本 3 包含一种 CA 将某些证书标记为其他 CA 的方法。这允许 CA 拥有一个根,然后认证一堆子 CA。
现代版本的 OpenSSL(0.9.5 及更高版本)会检查这些扩展,因此无论您是否检查链长度,您都会自动受到保护,免受扩展攻击。0.9.5 之前的版本根本不检查扩展,因此如果您使用旧版本,则必须强制执行链长度。0.9.5 在检查方面存在一些问题,因此如果您使用 0.9.5,则应升级。initialize_ctx() 中的 #ifdef 代码提供了旧版本的链长度检查。我们使用 SSL_CTX_set_verify_depth() 强制 OpenSSL 检查链长度。总而言之,强烈建议升级到 0.9.6,尤其是因为更长(但结构正确)的链正变得越来越流行。OpenSSL 的绝对最新(也是最佳)版本是 .0.9.66。
我们使用清单 3 中所示的代码编写 HTTP 请求。出于演示目的,我们使用在 REQUEST_TEMPLATE 变量中找到的或多或少硬编码的 HTTP 请求。由于我们连接的机器可能会更改,因此我们需要填写 Host 标头。这是使用 snprintf() 完成的。然后,我们使用 SSL_write() 将数据发送到服务器。SSL_write() 的 API 或多或少与 write() 相同,只是我们传入 SSL 对象而不是文件描述符。
经验丰富的 TCP 程序员会注意到,如果我们尝试写入的值不等于返回值,我们会抛出一个错误,而不是围绕写入循环。在阻塞模式下,SSL_write() 语义是全有或全无;调用将不会返回,直到所有数据都写入或发生错误为止,而 write() 可能只写入部分数据。SSL_MODE_ENABLE_PARTIAL_WRITE 标志(此处未使用)启用部分写入,在这种情况下,您需要循环。
在旧式 HTTP/1.0 中,服务器传输其响应,然后关闭连接。在更高版本中,引入了允许在同一连接上进行多个顺序事务的持久连接。为了方便和简单起见,我们将不使用持久连接。我们省略了允许它们的标头,导致服务器使用连接关闭来表示响应结束。在操作上,这意味着我们可以一直读取到文件结束,这大大简化了事情。
OpenSSL 使用 SSL_read() API 调用来读取数据,如清单 4 所示。与 read() 一样,我们只需选择一个适当大小的缓冲区并将其传递给 SSL_read()。请注意,缓冲区大小在这里并不是那么重要。SSL_read() 的语义(如 read() 的语义)是它返回可用数据,即使它小于请求量。另一方面,如果没有可用数据,则对 read 的调用会阻塞。
然后,BUFSIZZ 的选择基本上是一种性能权衡。这里的权衡与我们仅从普通套接字读取时的情况截然不同。在那种情况下,每次对 read() 的调用都需要上下文切换到内核。由于上下文切换开销很大,程序员尝试使用大缓冲区来减少上下文切换。但是,当我们使用 SSL 时,对 read() 的调用次数(以及因此的上下文切换次数)在很大程度上取决于数据写入的记录数,而不是对 SSL_read() 的调用次数。
例如,如果客户端写入一个 1000 字节的记录,而我们以 1 字节的块调用 SSL_read(),则对 SSL_read() 的第一次调用将导致记录被读取,而其余调用将仅从 SSL 缓冲区中读取它。因此,当我们使用 SSL 时,缓冲区大小的选择不如使用普通套接字时那么重要。如果数据是在一系列小记录中写入的,您可能希望使用对 read() 的单个调用一次读取所有记录。OpenSSL 提供了一个标志 SSL_CTRL_SET_READ_AHEAD,用于启用此行为。
请注意对 SSL_get_error() 返回值使用 switch。普通套接字的约定是任何负数(通常为 -1)都表示失败,然后检查 errno 以确定实际发生了什么。显然 errno 在这里不起作用,因为它只显示系统错误,并且我们希望能够对 SSL 错误采取措施。此外,errno 需要仔细编程才能实现线程安全。
OpenSSL 提供 SSL_get_error() 调用来代替 errno。此调用使我们可以检查返回值并确定是否发生错误以及错误是什么。如果返回值是正数,我们已经读取了一些数据,我们只需将其写入屏幕。真正的客户端会解析 HTTP 响应,然后显示数据(例如,网页)或将其保存到磁盘。但是,就 OpenSSL 而言,这些都不是有趣的,因此我们不会在此处显示任何内容。
如果返回值为零,则并不意味着没有可用数据。在这种情况下,我们将被阻塞,如上所述。相反,它意味着套接字已关闭,并且永远不会有任何数据可供读取。因此,我们退出循环。
如果返回值是负数,则发生某种错误。我们关心两种类型的错误:普通错误和过早关闭。我们使用 SSL_get_error() 调用来确定我们遇到哪种类型的错误。我们的客户端中的错误处理非常原始,因此对于大多数错误,我们只是调用 berr_exit() 来打印错误消息并退出。过早关闭必须特殊处理。
TCP 使用 FIN 段来指示发送方已发送所有数据。SSL 版本 2 只是允许任何一方发送 TCP FIN 来终止 SSL 连接。这允许截断攻击;攻击者只需伪造 TCP FIN 就可以使消息看起来比实际短。除非受害者有其他方法知道要期望的消息长度,否则他或她只会相信长度是正确的。
为了防止此安全问题,SSLv3 引入了“close_notify”警报。close_notify 是一条 SSL 消息(因此是安全的),但不属于数据流本身,因此应用程序看不到它。在发送 close_notify 后,可能不会传输任何数据。
因此,当 SSL_read() 返回 0 以指示套接字已关闭时,这实际上意味着已收到 close_notify。如果客户端在收到 close_notify 之前收到 FIN,则 SSL_read() 将返回错误。这种情况称为过早关闭。
一个幼稚的客户端可能会决定在每次收到过早关闭时报告错误并退出。这是 SSLv3 规范暗示的行为。不幸的是,发送过早关闭是一个相当常见的错误,尤其是在客户端中很常见。因此,除非您想一直报告错误,否则您通常必须忽略过早关闭。我们的代码区分了差异。它在 stderr 上报告过早关闭,但不以错误退出。
如果我们读取响应时没有任何错误,那么我们需要向服务器发送我们自己的 close_notify。这是使用 SSL_shutdown() API 调用完成的。当我们讨论服务器时,我们将更全面地介绍 SSL_shutdown(),但总体思路很简单:它为完整关闭返回 1,为不完整关闭返回 0,为错误返回 -1。由于我们已经收到了服务器的 close_notify,因此唯一可能出错的事情是我们发送我们的 close_notify 时遇到麻烦。否则,SSL_shutdown() 将成功(返回 1)。
最后,我们需要销毁我们分配的各种对象。由于此程序即将退出,从而释放对象,因此这并非绝对必要,但在更通用的程序中是必要的。
我们的 Web 服务器主要是客户端的镜像,但有一些变化。首先,我们使用 fork() 以便服务器可以处理多个客户端。其次,我们使用 OpenSSL 的 BIO API 一次读取客户端的请求一行,以及对客户端执行缓冲写入。最后,服务器关闭序列更加复杂。
在 Linux 上,编写可以处理多个客户端的服务器的最简单方法是为每个连接的客户端创建一个新的服务器进程。我们在 accept() 返回后通过调用 fork() 来做到这一点。每个新进程独立执行,并在完成为客户端服务后退出。虽然这种方法在繁忙的 Web 服务器上可能非常慢,但在这里是完全可以接受的。主服务器接受循环如清单 5 所示。
在 fork 并创建 SSL 对象后,服务器调用 SSL_accept(),这会导致 OpenSSL 执行 SSL 握手的服务器端。与 SSL_connect() 一样,由于我们使用阻塞套接字,因此 SSL_accept() 将阻塞,直到整个握手完成。因此,SSL_accept() 将返回的唯一情况是握手已完成或检测到错误。SSL_accept() 成功时返回 1,错误时返回 0 或负数。
OpenSSL 的 BIO 对象在某种程度上是可堆叠的。因此,我们可以将 SSL 对象包装在 BIO 中(ssl_bio 对象),然后将该 BIO 包装在缓冲 BIO 对象中,如下所示
io=BIO_new(BIO_f_buffer()); ssl_bio=BIO_new(BIO_f_ssl()); BIO_set_ssl(ssl_bio,ssl,BIO_CLOSE); BIO_push(io,ssl_bio);
这允许我们通过在新 io 对象上使用 BIO_* 函数来对 SSL 连接执行缓冲读取和写入。此时您可能会问,这有什么好处?主要这是一个编程便利问题。它允许程序员以自然单位(行和字符)而不是 SSL 记录工作。
HTTP 请求由请求行、后跟一堆标头行和可选的正文组成。标头行的末尾用空行(即一对 CRLF,尽管有时损坏的客户端会发送一对 LF 代替)表示。读取请求行和标头最方便的方法是一次读取一行,直到看到空行。我们可以使用 OpenSSL_BIO_gets() 调用来执行此操作,如清单 6 所示。
BIO_gets() 调用的行为类似于 stdio fgets() 调用。它采用任意大小的缓冲区和长度,并将 SSL 连接中的一行读取到该缓冲区中。结果始终以 null 结尾(但包括终止 LF)。因此,我们只需一次读取一行,直到获得仅由 LF 或 CRLF 组成的行。
由于我们使用固定长度的缓冲区,因此可能会发生(尽管不太可能)我们获得的行太长。在这种情况下,长行将被拆分到两行中。在极不可能发生拆分正好发生在 CRLF 之前的情况下,我们读取的下一行将仅包含上一行中的 CRLF。在这种情况下,我们将被愚弄,认为标头已过早结束。真正的 Web 服务器会检查这种情况,但在这里不值得这样做。请注意,无论传入的行长度是多少,都不会发生缓冲区溢出。可能发生的一切都是我们将错误地解析标头。
请注意,我们实际上并没有对 HTTP 请求做任何事情。我们只是读取并丢弃它。真正的实现会读取请求行和标头,确定是否存在正文,并也读取正文。
下一步是写入 HTTP 响应并关闭连接
if((r=BIO_puts (io,"HTTP/1.0 200 OK\\r\\n"))<0) err_exit("Write error"); if((r=BIO_puts (io,"Server: EKRServer\\r\\n\\r\\n"))<0) err_exit("Write error"); if((r=BIO_puts (io,"Server test page\\r\\n"))<0) err_exit("Write error"); if((r=BIO_flush(io))<0) err_exit("Error flushing BIO");
请注意,我们使用的是 BIO_puts() 而不是 SSL_write()。这允许我们一次写入一行响应,但将整个响应作为单个 SSL 记录写入。这很重要,因为准备用于传输的 SSL 记录(计算完整性检查并对其进行加密)的成本非常高。因此,最好使记录尽可能大。
值得注意的是关于使用这种缓冲写入的几个微妙之处。首先,您需要在关闭之前刷新缓冲区。SSL 对象不知道您已在其顶部分层了一个 BIO,因此如果您销毁 SSL 连接,您只会将最后一块数据留在缓冲区中。BIO_flush() 调用负责处理此问题。此外,默认情况下,OpenSSL 对缓冲 BIO 使用 1024 字节的缓冲区大小。由于 SSL 记录最长可达 16KB,因此使用 1024 字节的缓冲区可能会导致过度碎片化(从而降低性能)。您可以使用 BIO_ctrl() API 来增加缓冲区大小。
完成传输响应后,我们需要发送我们的 close_notify。与之前一样,这是使用 SSL_shutdown() 完成的。不幸的是,当服务器先关闭时,事情会变得有点棘手。我们对 SSL_shutdown() 的第一次调用发送 close_notify,但不查找另一端的 close_notify。因此,它会立即返回,但返回值为 0,表示关闭序列尚未完成。然后,应用程序有责任再次调用 SSL_shutdown()。
这里可能有两种态度。我们可以决定我们已经看到了我们关心的所有 HTTP 请求。我们对其他任何事情都不感兴趣。因此,我们不关心客户端是否发送 close_notify。或者,我们严格遵守协议并期望其他人也这样做。因此,我们需要 close_notify。
如果我们采取第一种态度,那么生活很简单。我们调用 SSL_shutdown() 发送我们的 close_notify,然后立即退出,而不管客户端是否已发送 close_notify。如果我们采取第二种态度(我们的示例服务器就是这样做的),那么生活会更加复杂,因为客户端通常行为不正确。
我们面临的第一个问题是客户端通常根本不发送 close_notify。事实上,一些客户端在读取整个 HTTP 响应后立即关闭连接(某些版本的 IE 就是这样做的)。当我们发送我们的 close_notify 时,另一端可能会发送 TCP RST 段,在这种情况下,程序将捕获 SIGPIPE。我们在 initialize_ctx() 中安装了一个虚拟 SIGPIPE 处理程序来防止此问题。
我们面临的第二个问题是客户端可能不会立即响应我们的 close_notify 发送 close_notify。某些版本的 Netscape 要求您先发送 TCP FIN。因此,我们在第二次调用 SSL_shutdown() 之前调用 shutdown(s,1)。当使用“how”参数 1 调用时,shutdown() 发送 FIN,但使套接字保持打开状态以进行读取。用于执行服务器关闭的代码如清单 7 所示。
在本文中,我们仅触及了使用 OpenSSL 所涉及问题的表面。以下是其他问题(非详尽列表)。
针对服务器主机名检查服务器证书的更复杂方法是使用 X.509 subjectAltName 扩展。为了进行此检查,您需要从证书中提取此扩展,然后针对主机名进行检查。此外,如果能够根据证书中的通配符名称检查主机名,那就太好了。
请注意,这些应用程序仅通过退出并显示错误来处理错误。当然,真正的应用程序将能够识别错误并将其信号通知给用户或某些审核日志,而不是仅仅退出。
在下一篇文章中,我们将讨论许多高级 OpenSSL 功能,包括会话恢复、多路复用和非阻塞 I/O 以及客户端身份验证。
