1. 为什么要让DN支持中文:场景与核心难点
只要公司注册名里带中文,做证书请求(CSR)就注定绕不开"中文DN"这个坎。用OpenSSL的命令行一把梭虽然简单,但到了C++工程化集成的场景,比如你要在服务端程序里自动生成证书请求、对接CA的API提交材料,就必须在代码层面把中文DN正确编码进PKCS#10结构里。这个需求最常见于企业OV/EV证书申请、内部PKI体系的身份证书、代码签名证书等场景,而标题里说的"带中文的DN生成证书请求",本质上不是OpenSSL不支持中文,而是你选的字符串编码类型不对,导致签出来的CSR要么乱码,要么被CA系统直接拒收。
我见过不少开发者在第一步就翻车:用X509_NAME_add_entry_by_txt往DN里塞中文,结果用openssl req -text一看,O字段变成了一串"æµè¥¿ç"之类的乱码,或者ASN.1解析出来是BMPSTRING类型,CA后台无法正确识别。这背后牵涉到X.509证书里ASN.1 DER编码的字符串类型选择,属于典型的"看起来不难、做起来全是细节"的活。这篇文章就把整个链路拆开讲清楚,从原理到完整可编译的C++代码,再到验证方法和踩坑记录,一次说透。
1.1 哪些场景必须用中文DN
先明确一下什么场景会真的需要中文DN,不然容易为了技术而技术。
第一类是企业SSL证书申请,尤其国内公司的OV/EV证书。CA机构要求组织名称(O字段)必须和营业执照上的中文名称完全一致,部门(OU)也经常是中文,比如"研发部""财务中心"。这种情况下DN里不可能全是ASCII字符。
第二类是内部PKI体系,比如公司自建的证书签发平台给员工签发客户端认证证书,CN字段直接填员工中文姓名,O填公司中文名,这样证书在客户端展示时一眼能认出归属。
第三类是代码签名证书和文档签名证书,申请者对外的身份信息通常也是中文名称,签发出来的证书里包含中文DN。
第四类是自动化签发链路,很多人以为命令行手工生成就够了,但一旦证书请求变成平台功能,需要从数据库里动态取出中文公司名去拼DN,命令行脚本就顶不住了,必须在C++代码里完成。
1.2 难点本质:ASN.1里的字符串类型
DN是一系列"属性=值"的集合,每个值在ASN.1 DER编码里必须选定一种字符串类型。X.509里常见的有PrintableString、TeletexString、UniversalString、BMPString和UTF8String。
PrintableString只允许ASCII可见字符,能覆盖大部分英文DN,但中文根本不在它的字符集里。UTF8String是RFC 5280明确要求支持的类型,也是现代CA系统最推荐的非ASCII承载类型。OpenSSL在通过API添加DN条目时,默认行为倾向于用PrintableString去编码纯ASCII值,遇到中文这种多字节字符,如果你不显式指定类型,它要么报错,要么按字节硬解析成其他类型,结果就是乱码。
所以要解决带中文的DN生成证书请求,核心就一句话:对每个可能含中文的DN字段,显式指定UTF8String编码。之后所有代码都在围绕这个原则展开。
2. 动手前必须搞清楚的三件事
2.1 X509_NAME里DN到底怎么存的
在OpenSSL里,DN不是简单一个字符串,而是X509_NAME对象。这个对象内部维护了一个有序的X509_NAME_ENTRY链表,每个entry对应一个"属性=值"对。编码成DER时,一个DN是SEQUENCE,里面每个属性是一个SET,每个SET里再放AttributeTypeAndValue(也就是OID和值)。
我建议你在脑海里建立这个结构:X509_NAME→ 多个Entry → 每个Entry有"字段OID + 字符串类型 + 字节数据"。字符串类型存在Entry里,和值本身是绑定的。也就是说,同样一个"某科技有限公司",你可以存成PrintableString(会失败)、BMPString(乱码风险高)或UTF8String(正确),区别全在添加条目时传入的类型参数。
添加中文DN最常用的函数是:
int X509_NAME_add_entry_by_txt(X509_NAME *name, const char *field, int type, const unsigned char *bytes, int len, int loc, int set);各参数含义:field是DN字段名,比如"CN""O""ST";type是字符串编码类型,核心参数;bytes是字段值,注意必须是UTF-8编码的字节序列;len传-1表示自动按strlen计算;loc传-1表示把新条目追加到末尾;set传0表示每个属性单独成一个SET,这是最常见的形式。
2.2 MBSTRING_* 参数怎么选
OpenSSL定义了几组宏来指定字符串编码方式,实际开发中你只需要关心三个:
| 宏 | 含义 | 适用场景 |
|---|---|---|
| MBSTRING_ASC | 按ASCII处理,纯ASCII字符时编码为PrintableString | 国家代码C、纯英文的CN、密码学意义上的ASCII字段 |
| MBSTRING_UTF8 | 按UTF-8处理,编码为UTF8String | 所有含中文的字段,ST、L、O、OU、CN,统一用它最稳 |
| MBSTRING_BMP | 按UTF-16处理,编码为BMPString | 基本不用,某些老系统才需要,建议避开 |
有一种说法是"传0让OpenSSL自动判断",我不推荐在DN上这么做。自动判断的规则很绕,它根据字段NID允许的字符串类型范围来决定,纯ASCII时大概率选PrintableString,没问题;一旦出现中文,不同OpenSSL版本的行为不完全一致,有的版本会硬转成BMPString导致后续工具链识别异常。与其赌自动判断,不如每个字段显式指定。我的习惯是:国家代码用MBSTRING_ASC,其余ST、L、O、OU、CN全部用MBSTRING_UTF8。
顺带提醒,RFC 5280规定C这个字段必须是两位字母的ISO国家代码,只能用PrintableString,不能用中文,这一点别踩。
2.3 编码环境准备
写代码之前先确认编译环境的字符编码策略,这一步能省掉后面一半的乱码问题。
中文DN的值在内存里必须是UTF-8字节序列。如果你在Linux上用GCC,源文件保存成UTF-8,字符串字面量就是UTF-8,没问题。Windows上则有两个坑:
第一,MSVC默认按本地代码页解析源文件,简体中文系统上通常是GBK,你要么把源文件存成带BOM的UTF-8,要么在编译选项里加/utf-8,让编译器把源文件按UTF-8解析、把执行字符集也设为UTF-8。
第二,C++17里可以用u8"广东省"这种UTF-8字面量,它能保证字符串常量在编译后是UTF-8字节序列,不依赖编译器的执行字符集设置。注意C++20里u8字面量类型变成char8_t,传给OpenSSL接口需要reinterpret_cast一下,比较啰嗦,老项目建议直接用const char*配合/utf-8。
3. C++完整实现:从裸密钥到标准CSR
3.1 整体流程与函数分工
生成一个合法的PKCS#10证书请求,链路很清晰,一共五步:
- 生成密钥对。我优先用
EVP_PKEY_CTX这套高层API,它兼容OpenSSL 1.1.x和3.x,不像老的RSA_generate_key_ex在3.0里被标记为废弃。 - 创建
X509_REQ对象,把版本设为0,对应PKCS#10 v1。 - 构造
X509_NAME,逐个添加DN条目,中文字段用MBSTRING_UTF8。 - 把公钥挂到请求对象上,用私钥对请求签名。签名算法用SHA-256,别再用SHA-1。
- 把CSR和私钥分别写成PEM文件,或者直接输出到内存。
顺序上有个硬性要求:必须先X509_REQ_set_pubkey再X509_REQ_sign,因为签名是对"包含公钥的请求内容"做的,公钥没设置就签名,出来的CSR在验证时直接失败。
3.2 完整源码
下面是一份我在实际项目里精简过的可用代码,直接复制就能编译运行:
#include <openssl/evp.h> #include <openssl/x509.h> #include <openssl/pem.h> #include <openssl/err.h> #include <iostream> #include <string> static bool build_subject(X509_REQ* req) { X509_NAME* name = X509_NAME_new(); if (!name) return false; const char* country = "CN"; const char* province = "广东省"; const char* city = "深圳市"; const char* org = "某科技有限公司"; const char* ou = "研发一部"; const char* cn = "www.example.org.cn"; auto add_entry = [&](const char* field, const char* value) -> bool { if (!X509_NAME_add_entry_by_txt(name, field, MBSTRING_UTF8, (const unsigned char*)value, -1, -1, 0)) { std::cerr << "add field " << field << " failed" << std::endl; return false; } return true; }; // 国家代码是两位ASCII字母,按PrintableString编码,符合RFC 5280 if (!X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (const unsigned char*)country, -1, -1, 0)) { return false; } if (!add_entry("ST", province)) return false; if (!add_entry("L", city)) return false; if (!add_entry("O", org)) return false; if (!add_entry("OU", ou)) return false; if (!add_entry("CN", cn)) return false; if (!X509_REQ_set_subject_name(req, name)) { X509_NAME_free(name); return false; } X509_NAME_free(name); return true; } static EVP_PKEY* generate_rsa_key(int bits) { EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr); if (!ctx) return nullptr; if (EVP_PKEY_keygen_init(ctx) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } if (EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } EVP_PKEY* pkey = nullptr; if (EVP_PKEY_keygen(ctx, &pkey) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } EVP_PKEY_CTX_free(ctx); return pkey; } int main() { EVP_PKEY* pkey = generate_rsa_key(2048); if (!pkey) { ERR_print_errors_fp(stderr); return 1; } X509_REQ* req = X509_REQ_new(); if (!req) { EVP_PKEY_free(pkey); ERR_print_errors_fp(stderr); return 1; } X509_REQ_set_version(req, 0); if (!build_subject(req)) { ERR_print_errors_fp(stderr); EVP_PKEY_free(pkey); X509_REQ_free(req); return 1; } if (X509_REQ_set_pubkey(req, pkey) != 1) { ERR_print_errors_fp(stderr); EVP_PKEY_free(pkey); X509_REQ_free(req); return 1; } if (X509_REQ_sign(req, pkey, EVP_sha256()) <= 0) { ERR_print_errors_fp(stderr); EVP_PKEY_free(pkey); X509_REQ_free(req); return 1; } BIO* bio = BIO_new_file("server.csr", "wb"); if (!bio || PEM_write_bio_X509_REQ(bio, req) != 1) { std::cerr << "write csr failed" << std::endl; ERR_print_errors_fp(stderr); return 1; } BIO_free(bio); BIO* key_bio = BIO_new_file("server.key", "wb"); if (!key_bio || PEM_write_bio_PrivateKey(key_bio, pkey, nullptr, nullptr, 0, nullptr, nullptr) != 1) { std::cerr << "write key failed" << std::endl; ERR_print_errors_fp(stderr); return 1; } BIO_free(key_bio); std::cout << "CSR generated: server.csr / server.key" << std::endl; EVP_PKEY_free(pkey); X509_REQ_free(req); return 0; }几个容易忽略的细节说一下。
X509_NAME_add_entry_by_txt的type传MBSTRING_UTF8后,OpenSSL会把输入字节当作UTF-8数据校验,然后以UTF8String类型存进Entry。C字段单独用MBSTRING_ASC,让它正确生成PrintableString。这里我没有把C也放进lambda,就是为了区别处理。
PEM_write_bio_PrivateKey在不指定加密算法时,对RSA私钥输出的是-----BEGIN PRIVATE KEY-----这种PKCS#8格式,比-----BEGIN RSA PRIVATE KEY-----的PKCS#1传统格式通用性好一些,大部分CA系统和运维工具都认。
错误处理这里全部打了ERR_print_errors_fp(stderr),调试时非常有用,它能打出OpenSSL内部的错误队列,很多"生成失败"的真相都在这里。
3.3 编译、运行与命令行验证
Linux上用GCC编译,假设OpenSSL开发库已装好:
g++ -std=c++17 -O2 -o gen_csr gen_csr.cpp -lssl -lcrypto ./gen_csrWindows上在Visual Studio里,除了链接libssl.lib和libcrypto.lib,务必给C/C++命令行加上/utf-8,否则源文件里的中文常量可能在编译期就被转成错误的字节序列,程序运行后生成的CSR依然是乱码,这个坑我帮你们先踩过了。
跑完会生成server.csr和server.key两个文件。第一步验证签名和内容:
openssl req -in server.csr -noout -text -verify重点看三处:verify OK表示CSR自签名有效;Subject:这一行的中文是否正常显示;Signature Algorithm是否为sha256WithRSAEncryption。正常输出大致是:
verify OK Certificate Request: Data: Version: 1 (0x0) Subject: C = CN, ST = 广东省, L = 深圳市, O = 某科技有限公司, OU = 研发一部, CN = www.example.org.cn Subject Public Key Info: Public Key Algorithm: rsaEncryption Public-Key: (2048 bit) Signature Algorithm: sha256WithRSAEncryption第二步用asn1parse看底层编码类型,这一步最能说明问题:
openssl asn1parse -in server.csr -i输出里能看到DN部分每个属性:
15:d=4 hl=2 l= 1 prim: PRINTABLESTRING :CN 20:d=4 hl=2 l= 16 prim: UTF8STRING :广东省 40:d=4 hl=2 l= 14 prim: UTF8STRING :深圳市 58:d=4 hl=2 l= 29 prim: UTF8STRING :某科技有限公司只要中文的字段明确写着UTF8STRING,就说明编码方向对了。如果你看到BMPSTRING或者一串十六进制乱码,回到代码里检查是不是没用MBSTRING_UTF8。
4. 常见问题与排查实录
4.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 生成的CSR里中文乱码 | 源文件不是UTF-8,或编译器执行字符集不对 | 加/utf-8,用u8字面量,确认值确实是UTF-8字节 |
| asn1parse看到BMPSTRING | 添加DN时type参数没传MBSTRING_UTF8 | 显式指定MBSTRING_UTF8 |
openssl req -verify不通过 | set_pubkey和sign顺序颠倒,或密钥与请求不匹配 | 先set_pubkey再sign,确保用同一把私钥 |
| CA系统拒收CSR | 编码类型非UTF8String;或C字段非法 | 检查asn1parse输出,C字段只用两位ASCII |
| 签名算法显示SHA-1 | 用了EVP_sha1() | 改用EVP_sha256() |
| Windows控制台输出乱码 | 终端代码页是GBK,无法显示UTF-8 | 控制台执行chcp 65001再查看 |
| 编译报错说RSA系列函数废弃 | OpenSSL 3.0废弃了低层RSA API | 改用EVP_PKEY_CTX生成密钥 |
4.2 乱码的三种来源与定位方法
中文乱码是最常见的故障,而且来源往往不只一处。我总结过三个独立环节,任何一个出问题都会乱码。
第一环是源文件编码。源码里写的"广东省"最终必须编译成UTF-8字节。Linux默认没问题,Windows上如果你没做任何处理,MSVC可能把字符串按GBK编译,程序生成CSR时OpenSSL以为这是UTF-8,校验UTF-8合法性很可能直接失败或产生错误序列。解决办法就是给编译器加/utf-8,或者用u8字面量。
第二环是OpenSSL添加DN时的类型参数。如果type传错,比如传入MBSTRING_ASC,OpenSSL会把UTF-8字节按单字节字符处理,编码结果必然乱。这一环的问题是逻辑层面的错,就算源文件编码正确也会乱。
第三环是展示终端。Windows控制台默认GBK代码页,即使CSR文件里是正确的UTF-8,你用openssl req -text输出到控制台也可能显示乱码。这只影响显示,不影响文件内容。定位方法很简单:重定向到文件再用支持UTF-8的编辑器打开,或者先执行chcp 65001。
4.3 编码类型错误与CA拒收
比乱码更隐蔽的是类型对但值不被CA认可。比如你用MBSTRING_BMP把"某科技有限公司"编码成BMPString,从OpenSSL自己的解析看可能是正常的,但某些CA后台只认UTF8String,提交后直接被拒。所以判断编码对不对,不能只看openssl req -text显示正常,要看asn1parse里的类型标记。
另外提一个实际遇到的细节:有些CA系统对DN字符串末尾的空格特别敏感,尤其是从数据库里读出来的公司名可能带不可见字符,建议在拼DN之前统一trim掉首尾空白。中文全角空格和普通空格都清理一遍,这个习惯能省不少沟通成本。
4.4 旧版API与OpenSSL 3.0的兼容问题
网上很多老教程用的是RSA_generate_key_ex、RSA_new、BN_new这一套,在OpenSSL 3.0里build时会报deprecated警告,如果发行版编译OpenSSL时加了no-deprecated,直接编译失败。我推荐用上面代码里的EVP_PKEY_CTX流程,它在1.1.x和3.x都稳定工作。
还有一点,OpenSSL 1.1.0以上版本不需要手动调用OpenSSL_add_all_algorithms()和SSL_library_init(),程序初始化时自动完成。如果你还兼容老版本1.0.2,保留这两行调用也无妨。判断版本用宏OPENSSL_VERSION_NUMBER,但大多数项目直接放弃1.0.2更省心,它已经停止维护很多年了。
5. 工程化扩展建议
5.1 把CSR直接输出到内存字符串
很多业务场景不需要落地成文件,比如你得把CSR内容POST给CA的接口。用内存BIO就能拿到字符串:
BIO* mem = BIO_new(BIO_s_mem()); if (!mem) return; if (PEM_write_bio_X509_REQ(mem, req) != 1) { BIO_free(mem); return; } BUF_MEM* buf = nullptr; BIO_get_mem_ptr(mem, &buf); std::string csr_text(buf->data, buf->length); BIO_free(mem);拿到csr_text后可以直接放进HTTP请求体。记得用BIO_s_mem()而不是BIO_new_file,后者是文件型BIO,语义完全不同。这个技巧同样适用于私钥、证书的输出。
5.2 生成EC密钥的CSR
如果你的场景更看重性能,或者CA明确要求支持国密之外的椭圆曲线,可以把密钥生成部分替换成EC:
static EVP_PKEY* generate_ec_key() { EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, nullptr); if (!ctx) return nullptr; if (EVP_PKEY_keygen_init(ctx) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } if (EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, NID_X9_62_prime256v1) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } if (EVP_PKEY_CTX_set_ec_param_enc(ctx, OPENSSL_EC_NAMED_CURVE) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } EVP_PKEY* pkey = nullptr; if (EVP_PKEY_keygen(ctx, &pkey) <= 0) { EVP_PKEY_CTX_free(ctx); return nullptr; } EVP_PKEY_CTX_free(ctx); return pkey; }生成CSR的后续代码完全不用改,X509_REQ_sign(req, pkey, EVP_sha256())会自动选择合适的曲线签名算法。注意OPENSSL_EC_NAMED_CURVE这个参数要带上,它决定参数编码是使用命名曲线OID还是显式参数,不带的话某些CA系统可能无法解析。
5.3 中文CN的个人证书与最后建议
如果项目面向的是个人证书或客户端认证证书,CN字段直接用中文姓名即可,比如CN=张三。这种场景下把add_entry("CN", cn)里的cn换成姓名就行,编码逻辑没有任何区别。但如果是服务器SSL证书,CN还是建议放域名,中文CN的服务器证书在浏览器兼容性上一直存在不确定性,没必要给自己找麻烦。
我个人在实际项目中还有一个习惯:生成CSR后不要立刻删除私钥,但也不要随便分发。私钥只在持有者本地保存,CSR可以随意传输。提交给CA后,无论审核结果如何,私钥和CSR要一起归档,后续做证书续期时可以直接复用同一把私钥,只需重新生成CSR提交即可。
最后再分享一个容易被忽略的验证技巧:拿到CA签发的正式证书后,用openssl x509 -in cert.pem -noout -text看一眼Subject里的中文是否和当初申请时一致。如果显示正常,说明从生成到签发整条链路都没问题;如果这里乱码,但CSR本身验证是好的,问题出在CA侧,需要联系对方确认DN的编码处理策略,这已经不是你C++代码能控制的范围了。整条链路走通一次之后,这类需求基本就是写个封装函数的事,核心记住"UTF-8 + UTF8String"这六个字就够了。