diff --git a/src/ssl/uvcpp_ssl_context.cpp b/src/ssl/uvcpp_ssl_context.cpp index 04694143..682c4c5b 100644 --- a/src/ssl/uvcpp_ssl_context.cpp +++ b/src/ssl/uvcpp_ssl_context.cpp @@ -77,6 +77,35 @@ void uvcpp_ssl_context::init_server() { status_ = TLS_CTX_READY; } +namespace { + +/** + * @brief `tls_version` 映射成 OpenSSL 的协议版本号(`TLS1_2_VERSION` 等)。 + * + * **为什么不能再用 `SSL_OP_NO_TLSv1*` 那一组。** 两个原因叠在一起: + * + * 1. `SSL_CTX_set_options(ctx, op)` 是「**加上** op 里的位」,它**不会清**没给 + * 的位 —— 想清必须另外调 `SSL_CTX_clear_options()`。原来那几处 + * `opts &= ~SSL_OP_NO_TLSv1_x` 之后再 `set_options(ctx, opts)`,那个清位 + * 是**白清的**:位从来没被去掉过。 + * 2. OpenSSL 3.0 起 `SSL_OP_NO_TLSv1*` 已被废弃,官方指定用 + * `SSL_CTX_set_min_proto_version()` / `set_max_proto_version()`。 + * + * 用版本号 API 之后,「设下限/设上限」是一次有明确语义的赋值,不再需要在 + * 位运算上辩方向。 + */ +int openssl_version_of(tls_version ver) { + switch (ver) { + case tls_version::TLS_1_0: return TLS1_VERSION; + case tls_version::TLS_1_1: return TLS1_1_VERSION; + case tls_version::TLS_1_2: return TLS1_2_VERSION; + case tls_version::TLS_1_3: return TLS1_3_VERSION; + } + return TLS1_2_VERSION; +} + +} // namespace + void uvcpp_ssl_context::set_default_verify() { // CLIENT 默认**校验**对端证书:一个不校验的 HTTPS 客户端会把任何中间人 // 当成正常服务端。回环自签用例必须显式调 set_verify_mode(NONE) 关掉它 —— @@ -88,15 +117,10 @@ void uvcpp_ssl_context::set_default_verify() { } else { SSL_CTX_set_verify(ctx_, SSL_VERIFY_NONE, nullptr); } - long opts = SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3; - // Disable TLS < min_version - if (static_cast(min_version_) > static_cast(tls_version::TLS_1_0)) - opts |= SSL_OP_NO_TLSv1; - if (static_cast(min_version_) > static_cast(tls_version::TLS_1_1)) - opts |= SSL_OP_NO_TLSv1_1; - if (static_cast(min_version_) > static_cast(tls_version::TLS_1_2)) - opts |= SSL_OP_NO_TLSv1_2; - SSL_CTX_set_options(ctx_, opts); + SSL_CTX_set_options(ctx_, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3); + // 构造时给的下限也走版本号 API —— 与 set_min_version() 用同一条路, + // 免得"构造时设的"和"运行中改的"是两套机制(其中一套还是无效的)。 + SSL_CTX_set_min_proto_version(ctx_, openssl_version_of(min_version_)); SSL_CTX_set_mode(ctx_, SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); } @@ -106,7 +130,22 @@ void uvcpp_ssl_context::set_default_verify() { bool uvcpp_ssl_context::load_certificate_file(const std::string& path) { if (!ctx_) return false; - if (SSL_CTX_use_certificate_file(ctx_, path.c_str(), SSL_FILETYPE_PEM) != 1) { + // 必须用 `_chain_file`,不能用 `_file`。 + // + // `SSL_CTX_use_certificate_file()` 只读 PEM 里的**第一张**证书 —— 对它来说 + // "证书链"就是一条多余的尾巴。结果是中间证书永远不会发给对端,而只信任根 CA + // 的客户端(浏览器与 curl 的默认状态)没法把链补全,握手直接失败: + // + // verify error:num=20: unable to get local issuer certificate + // Verify return code: 21 (unable to verify the first certificate) + // + // 而本 API 的文档承诺的正是"cert_file 是证书**链**"(见 uvcpp_web_app.h + // 的 enable_ssl 注释)。现实里几乎每张证书都挂在中间 CA 下面 + // (Let's Encrypt、DigiCert、企业 PKI),所以这个差别直接决定 HTTPS 能不能用。 + // + // 注:只有"叶子由根直接签"的两级链是例外 —— 那种情况服务端本来就不该发根 + // (客户端自己有),只发叶子是正确的,改动对它没有影响。 + if (SSL_CTX_use_certificate_chain_file(ctx_, path.c_str()) != 1) { clear_error(); return false; } return true; @@ -123,12 +162,43 @@ bool uvcpp_ssl_context::load_private_key_file(const std::string& path) { bool uvcpp_ssl_context::load_certificate_data(const std::string& pem) { if (!ctx_ || pem.empty()) return false; BIO* bio = BIO_new_mem_buf(pem.c_str(), static_cast(pem.size())); - X509* cert = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); + if (bio == nullptr) { clear_error(); return false; } + + // 与 load_certificate_file 是同一个问题:PEM 里可能不止一张证书。 + // 第一张是叶子,其余是中间证书 —— 后面那些必须显式挂到上下文上,否则 + // 客户端同样补不全链。`enable_ssl_pem()` 走的正是这条路径。 + // + // 读法照搬 OpenSSL 自己 process_chain() 的做法:先读叶子,循环读剩下的, + // 逐个 `SSL_CTX_add_extra_chain_cert()`(成功时所有权归 SSL_CTX)。 + // 读干净的 EOF 会在错误栈上留下 PEM_R_NO_START_LINE,那是正常结束不是错误, + // 所以最后统一清一次。 + X509* leaf = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); + if (leaf == nullptr) { + BIO_free(bio); + clear_error(); + return false; + } + const int rc = SSL_CTX_use_certificate(ctx_, leaf); + X509_free(leaf); + if (rc != 1) { + BIO_free(bio); + clear_error(); + return false; + } + + for (;;) { + X509* extra = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); + if (extra == nullptr) break; + if (SSL_CTX_add_extra_chain_cert(ctx_, extra) != 1) { + BIO_free(bio); + clear_error(); + return false; + } + } + BIO_free(bio); - if (!cert) { clear_error(); return false; } - int rc = SSL_CTX_use_certificate(ctx_, cert); - X509_free(cert); - return rc == 1; + ERR_clear_error(); // 清掉"读到文件尾"留下的 PEM_R_NO_START_LINE + return true; } bool uvcpp_ssl_context::load_private_key_data(const std::string& pem) { @@ -217,23 +287,13 @@ void uvcpp_ssl_context::set_verify_mode(tls_verify_mode mode) { void uvcpp_ssl_context::set_min_version(tls_version ver) { min_version_ = ver; if (!ctx_) return; - long opts = SSL_CTX_get_options(ctx_); - opts |= SSL_OP_NO_TLSv1; - if (static_cast(ver) <= static_cast(tls_version::TLS_1_0)) - opts &= ~SSL_OP_NO_TLSv1; - if (static_cast(ver) <= static_cast(tls_version::TLS_1_1)) - opts &= ~SSL_OP_NO_TLSv1_1; - if (static_cast(ver) <= static_cast(tls_version::TLS_1_2)) - opts &= ~SSL_OP_NO_TLSv1_2; - SSL_CTX_set_options(ctx_, opts); + // 赋值语义:设成什么就是什么,**双向都生效**。 + SSL_CTX_set_min_proto_version(ctx_, openssl_version_of(ver)); } void uvcpp_ssl_context::set_max_version(tls_version ver) { if (!ctx_) return; - if (static_cast(ver) < static_cast(tls_version::TLS_1_3)) - SSL_CTX_set_options(ctx_, SSL_CTX_get_options(ctx_) | SSL_OP_NO_TLSv1_3); - if (static_cast(ver) < static_cast(tls_version::TLS_1_2)) - SSL_CTX_set_options(ctx_, SSL_CTX_get_options(ctx_) | SSL_OP_NO_TLSv1_2); + SSL_CTX_set_max_proto_version(ctx_, openssl_version_of(ver)); } bool uvcpp_ssl_context::set_cipher_list(const std::string& ciphers) { diff --git a/tests/functional/web_ssl_func.cpp b/tests/functional/web_ssl_func.cpp index 91907d48..ce03af2f 100644 --- a/tests/functional/web_ssl_func.cpp +++ b/tests/functional/web_ssl_func.cpp @@ -12,6 +12,8 @@ #include #include #include // SSL_ERROR_* 常量:光看返回值分不出「还没完」和「出错」 +#include // PEM_write_bio_X509:回写证书,用于拼多张证书的 PEM +#include // std::remove using namespace uvcpp; // ========================================================================= @@ -356,6 +358,148 @@ static bool test_client_default_rejects_untrusted_cert() { return true; } +// ========================================================================= +// 回归:`load_certificate_file()` 必须把 PEM 里的**整条链**读进来 +// +// 原来的实现用的是 `SSL_CTX_use_certificate_file()`,它只读第一张证书 —— +// 于是中间证书永远发不到对端,只信任根 CA 的客户端(浏览器、curl 的默认状态) +// 补不全链,握手直接失败: +// verify error:num=20: unable to get local issuer certificate +// 现实中几乎每张证书都挂在中间 CA 下面,所以这个差别直接决定 HTTPS 能不能用。 +// +// 判据选在 **SSL_CTX 里挂了几张额外证书**,而不是"能不能握手":握手要真起 +// 一个 TCP 服务,而这里要验的是加载器读没读链上除叶子之外的部分,一个进程内 +// 就能问清楚。 +// ========================================================================= +static int extra_chain_count(uvcpp_ssl_context& ctx) { + STACK_OF(X509)* chain = nullptr; + SSL_CTX_get_extra_chain_certs(ctx.raw_ctx(), &chain); + return chain == nullptr ? 0 : sk_X509_num(chain); +} + +static bool write_cert_pem(const char* path, X509* first, X509* second) { + BIO* out = BIO_new_file(path, "w"); + if (out == nullptr) return false; + bool ok = PEM_write_bio_X509(out, first) == 1; + if (ok && second != nullptr) ok = PEM_write_bio_X509(out, second) == 1; + BIO_free(out); + return ok; +} + +static bool test_certificate_chain_loaded() { + // 造一张自签证书当作"叶子",再往同一个 PEM 里追加一份当作"中间证书"。 + // 真实世界里第二张由中间 CA 签,但这里要验的是**加载器读不读叶子之后的 + // 部分**,与两张证书之间实际的签发关系无关 —— 用同一张就够了。 + uvcpp_ssl_context gen(tls_mode::SERVER, tls_version::TLS_1_2); + if (!gen.generate_self_signed("chain.test", 2048)) { + std::cout << " 自签证书生成失败:" << gen.get_last_error() << "\n"; + return false; + } + X509* leaf = SSL_CTX_get0_certificate(gen.raw_ctx()); + if (leaf == nullptr) { std::cout << " 取不到叶子证书\n"; return false; } + + const char* kChainPath = "uvcpp_ssl_chain_test.pem"; + const char* kLeafPath = "uvcpp_ssl_leaf_test.pem"; + if (!write_cert_pem(kChainPath, leaf, leaf) || + !write_cert_pem(kLeafPath, leaf, nullptr)) { + std::cout << " 写临时 PEM 失败\n"; + return false; + } + + int chain_extra = -1, leaf_extra = -1; + { + uvcpp_ssl_context c(tls_mode::SERVER, tls_version::TLS_1_2); + if (!c.load_certificate_file(kChainPath)) { + std::cout << " 两张证书的 PEM 加载失败:" << c.get_last_error() << "\n"; + std::remove(kChainPath); std::remove(kLeafPath); + return false; + } + chain_extra = extra_chain_count(c); + } + { + uvcpp_ssl_context c(tls_mode::SERVER, tls_version::TLS_1_2); + if (!c.load_certificate_file(kLeafPath)) { + std::cout << " 单张证书的 PEM 加载失败:" << c.get_last_error() << "\n"; + std::remove(kChainPath); std::remove(kLeafPath); + return false; + } + leaf_extra = extra_chain_count(c); + } + std::remove(kChainPath); + std::remove(kLeafPath); + + if (chain_extra != 1) { + std::cout << " 两张证书的 PEM 只加载到 " << chain_extra + << " 张额外证书(应为 1)—— 链被截断了,中间证书不会发给对端\n"; + return false; + } + if (leaf_extra != 0) { + std::cout << " 单张证书的 PEM 却加载到 " << leaf_extra + << " 张额外证书(应为 0)\n"; + return false; + } + return true; +} + +// ========================================================================= +// 回归:`set_min_version()` / `set_max_version()` 必须**双向**生效 +// +// 原来的实现用 `SSL_OP_NO_TLSv1*` 这一组选项位,两个问题叠在一起: +// +// 1. `SSL_CTX_set_options(ctx, op)` 是「**加上** op 里的位」,**不清**没给的位 +// —— 想清必须另调 `SSL_CTX_clear_options()`。原来那几处 +// `opts &= ~SSL_OP_NO_TLSv1_x` 之后再 `set_options(ctx, opts)`,清位是白清的。 +// 于是 `set_min_version(TLS_1_3)` 想禁掉 1.2 的那一步**没有任何一行代码 +// 真正执行**,而构造时给的默认下限恰好就是 TLS_1_2 —— "抬到 1.3" 这个最 +// 常见的用法正是失效的那一个。 +// 2. OpenSSL 3.0 起这一组选项已废弃,官方指定用 proto version API。 +// +// 修法是改用 `SSL_CTX_set_min_proto_version()` / `set_max_proto_version()`, +// 断言也相应读 `SSL_CTX_get_min_proto_version()` / `get_max_proto_version()` +// —— 读版本号而不是读选项位,判据与实现用的是同一个量,不会各说各话。 +// +// 判据不起网络:只问上下文"你现在允许的区间是什么"。 +// ========================================================================= +static bool test_version_bounds_settable() { + uvcpp_ssl_context ctx(tls_mode::SERVER, tls_version::TLS_1_2); + + // 抬下限:TLS_1_3 → 1.2 必须被拒 + ctx.set_min_version(tls_version::TLS_1_3); + if (SSL_CTX_get_min_proto_version(ctx.raw_ctx()) != TLS1_3_VERSION) { + std::cout << " set_min_version(TLS_1_3) 之后下限不是 TLS 1.3(读到 " + << SSL_CTX_get_min_proto_version(ctx.raw_ctx()) << ")" << std::endl; + return false; + } + + // 放回来:TLS_1_2 → 1.2 必须重新可用(原实现这一步是失效的) + ctx.set_min_version(tls_version::TLS_1_2); + if (SSL_CTX_get_min_proto_version(ctx.raw_ctx()) != TLS1_2_VERSION) { + std::cout << " set_min_version(TLS_1_2) 之后下限不是 TLS 1.2 —— 下限只能抬、不能放" + << std::endl; + return false; + } + + // 压上限 + ctx.set_max_version(tls_version::TLS_1_2); + if (SSL_CTX_get_max_proto_version(ctx.raw_ctx()) != TLS1_2_VERSION) { + std::cout << " set_max_version(TLS_1_2) 之后上限不是 TLS 1.2" << std::endl; + return false; + } + + // 放宽上限(原实现同样做不到:位一旦置上就再也去不掉) + ctx.set_max_version(tls_version::TLS_1_3); + { + const int mx = SSL_CTX_get_max_proto_version(ctx.raw_ctx()); + // 0 = 不限,语义上等价于"最高到本端支持的最高版本" + if (mx != 0 && mx < TLS1_3_VERSION) { + std::cout << " set_max_version(TLS_1_3) 之后上限仍被压在 " << mx + << " —— 上限只能压、不能放" << std::endl; + return false; + } + } + return true; +} + int main() { bool ok = true; struct { const char* name; bool (*fn)(); } tests[] = { @@ -375,6 +519,9 @@ int main() { {"take_ciphertext_drains", test_take_ciphertext_drains}, {"client_default_rejects_untrusted_cert", test_client_default_rejects_untrusted_cert}, + // 下面两组是本次修复带的回归:链被截断 / 版本上下限只能单向生效 + {"certificate_chain_loaded", test_certificate_chain_loaded}, + {"version_bounds_settable", test_version_bounds_settable}, }; for (const auto& t : tests) { std::cout << "[web_ssl] " << t.name << "\n";