
给一台内网 API 加访问控制,最省事的做法是发一串密钥,写进请求头。三个月后你会发现密钥被复制到了三个仓库、两个聊天窗口和一份离职同事的笔记里。双向 TLS 的价值不在于它更难破解,而在于它把「凭据」变成了一件不能靠复制传播的东西。
下面这套配置在本机 nginx 1.26.2(stable)配 OpenSSL 3.0.13 上验过,Debian 12 与 Ubuntu 24.04 的软件源版本可直接照抄。先跑一次 nginx -V 确认你的编译参数里有 --with-http_ssl_module。版本差异主要落在证书校验的错误码上,配置语法这两年没变。
一、先把目录和角色分清楚
双向认证里一共有三个角色:一个签发者(CA),一个服务端,若干个客户端。签发者的私钥是整套体系的根,它签谁谁就能进门。CA 私钥一旦泄露,整套证书全部作废,所以它不该和 nginx 放在同一台机器上。
/etc/nginx/tls/
├── ca.crt # 根证书,公开,客户端要拿它验服务端
├── server.crt # 服务端证书
├── server.key # 服务端私钥,权限 600
├── client-ca-bundle.crt # nginx 用的信任库:根 CA + 中间 CA
└── crl.pem # 可选,吊销列表
/root/ca/ # 只在这一步用,签完就离线
├── ca.key # 根私钥,权限 600
└── ca.srl # 序列号文件,务必保留
把签发用的私钥和线上用的证书分开存,是为了让「谁能发证」和「谁能用证」变成两件事。很多内网事故不是证书被盗,是签发能力被顺手复制走了。
二、建一个只干一件事的 CA
下面这条命令生成一个十年有效、4096 位的自签根证书。-nodes 表示私钥不加密码,适合自动化场景;如果你打算长期离线保存,去掉它并设一个密码更稳妥。-nodes 省下的是每天输一次密码的时间,赌上的是这台机器本身的边界。
mkdir -p /root/ca && cd /root/ca
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout ca.key -out ca.crt \
-subj "/C=CN/O=fhsj-internal/CN=fhsj-internal-ca" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
chmod 600 ca.key
pathlen:0 这一行限制了这张根证书只能签终端证书,不能再签出二级 CA。少一层中间 CA,就少一个将来没人说得清来历的信任来源。
三、签服务端与客户端证书
服务端证书的 CN 写域名,但真正被校验的是 SAN(Subject Alternative Name)。现代客户端已经不看 CN 了,浏览器、curl、Go 都一样。只写 CN 不写 SAN 的证书,会在握手完成后被客户端一句「名称不匹配」直接丢掉。
cd /root/ca
# 服务端:注意 SAN 里有 DNS 也有 IP
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \
-subj "/CN=api.internal"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAserial ca.srl \
-days 825 -sha256 -out server.crt \
-extfile <(printf "subjectAltName=DNS:api.internal,IP:10.0.8.11\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n")
# 客户端:每一个设备一张,CN 用来区分身份
openssl req -newkey rsa:2048 -nodes -keyout device-001.key -out device-001.csr \
-subj "/CN=device-001"
openssl x509 -req -in device-001.csr -CA ca.crt -CAkey ca.key -CAserial ca.srl \
-days 365 -sha256 -out device-001.crt \
-extfile <(printf "extendedKeyUsage=clientAuth\nkeyUsage=digitalSignature\n")
客户端证书里 extendedKeyUsage=clientAuth 这一行不能省。缺了它,Java 的 HttpClient 和部分版本的 Chrome 会拒绝使用这张证书,报的错还和证书无关,查起来很费时间。证书的用途不是注释,是握手时的硬性约束。
首次执行时 ca.srl 不存在,openssl 会报错。手工创建一次即可:echo 1000 > ca.srl。不要用 -CAcreateserial 让它自动生成,原因在后面的坑里。
四、nginx 侧的完整配置
核心只有四行:告诉 nginx 用哪张证书、用哪个信任库、要不要强制校验、校验到第几层。把这个文件当成唯一真源,不要在其他 include 里再写一遍同名指令。
server {
listen 443 ssl;
http2 on;
server_name api.internal;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_trusted_certificate /etc/nginx/tls/client-ca-bundle.crt;
# 信任库:客户端证书必须由这里的某个 CA 签发
ssl_client_certificate /etc/nginx/tls/client-ca-bundle.crt;
ssl_verify_client on;
ssl_verify_depth 2;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:MTLS:10m;
ssl_session_timeout 10m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
proxy_set_header X-Real-IP $remote_addr;
}
}
ssl_verify_depth 2 的意思是:客户端证书之后最多再往上两级(中间 CA、根 CA)。只放根 CA 的信任库配这个值,链稍长就会校验失败。深度不是越大越好,它决定了你能接受多长的证书链,也决定了攻击面。
后端拿到 X-Client-DN 就能知道是谁,比如 /CN=device-001。这个头只能由 nginx 注入,应用侧必须拒绝外部直接传入的同名头。否则整套双向认证会被一个请求头绕过,这是最常见的整体失效方式。
五、验收:三个正向,两个负向
正向第一条,带证书访问应该拿到 200,并把 DN 回显出来。这条只能证明「进得去」,证明不了「进的是对的门」。
curl -sS --cert device-001.crt --key device-001.key \
--cacert ca.crt https://api.internal/health -v 2>&1 | grep -E "SSL certificate|HTTP/"
正向第二条,用 openssl s_client 看握手细节,重点确认服务端确实发出了 CertificateRequest、客户端证书被接受。curl 只告诉你结果,s_client 才告诉你握手到底走到哪一步。
openssl s_client -connect api.internal:443 -state \
-cert device-001.crt -key device-001.key -CAfile ca.crt </dev/null 2>&1 \
| grep -E "Acceptable client certificate CA names|Verify return code"
负向测试更能说明问题。不带证书应当被拒,带一张别家 CA 签的证书也应当被拒。两条都要手工跑一遍,因为「不带证书被拒」可能只是防火墙挡住了,和被拒长得一样。能区分「拒绝的原因」和「拒绝的结果」,才算真的验过。
六、四个必然踩到的坑
第一个坑是序列号。用 -CAcreateserial 签第二张证书时,openssl 会重新生成 ca.srl 并从 1 开始编号,于是两张不同设备的证书拿到了同一个序列号。序列号是吊销和审计的唯一依据,重复意味着吊销一张等于吊销一批。
第二个坑是信任链。很多教程让你把中间 CA 也塞进 ssl_client_certificate,理由是「客户端的链可能不全」;但更常见的失败是反过来的:信任库里只放了根 CA,而客户端只发送自己的证书、不发送中间证书,nginx 走到一半找不到签发者,返回 400,日志里只有一行 client sent no required SSL certificate,和真实原因完全不搭边。解决办法是把中间 CA 追加进信任库文件,而不是把 ssl_verify_depth 调大。
第三个坑是按路径区分。想把 /public 放开、其余强制校验,直觉上会在 location 里写 ssl_verify_client optional。这在 HTTP/1.1 下勉强能用,在 HTTP/2 下必然出问题,因为 h2 禁止 TLS 重协商,一次连接只握手一次。要按路径分权,正确做法是拆成两个 server 块或两个端口,而不是在 location 里做条件判断。
第四个坑最隐蔽:撤销不等于断开。mTLS 只在握手阶段验证一次,之后的长连接不再检查证书状态。一张证书在 10 点被吊销,9 点建立的连接可以一直用到它自己断开。把 ssl_session_timeout 从默认的 5 小时压到 10 分钟,只能缩小窗口,不能消除它。
要真正兜住这一点,得在应用层做两件事:校验 X-Client-Serial 是否在有效清单里,以及给长连接设一个绝对生存期。传输层的身份和工作层的授权,是两件不能互相替代的事。
写在最后
双向认证最容易被误解的地方,是把它当成一道门锁。它更像一张门禁卡:卡本身不能复制粘贴,丢了要正式注销,谁在什么时候刷过都有记录。它真正的收益不是拦住陌生人,是让「谁进来过」这件事第一次变得可回答。
代价也很清楚:每台设备一张证书、每个季度一次轮换、每次轮换一次发版。这些工作量不会因为自动化消失,只会因为自动化变得可以计算。一个安全方案能不能长期活着,取决于它的运维成本,而不是它的强度。
所以真正该问的不是「要不要上 mTLS」,而是「有没有一个地方,值得为它维护一套签发流程」。如果答案是「只有一个后台」,那 API key 加 IP 白名单就够了;如果答案是「几十台设备、每年换人」,那密钥方案迟早会以一次泄露结束。
你手上那套内网服务,现在是用什么方式确认「对面是谁」的?换过证书之后,有没有遇到过只报「400」不报原因的握手失败?

评论 (0)