《Java 服务端通信工程》10.1 TLS 与 mTLS

把 linkd-v3 的 9093 从明文换成 mTLS:用 keytool 生成 CA、服务端与客户端证书,再用 TlsServerCredentials 和 TlsChannelCredentials 接入 gRPC,讲清 PEM、SAN、主机名校验与握手失败的判读。

本节目标:把 9093 从明文 HTTP/2 换成双向 TLS,能自己签出一套开发证书,能把证书接到 gRPC 的 credentials 上,并读懂握手失败时状态码背后那一层 SSLHandshakeException。
适用版本:JDK 25(Temurin 25.0.5+8)、gRPC-Java 1.76.0(grpc-netty-shaded)、macOS 26.3。

承接的问题

9.3 把网关的错误与超时映射收口之后,linkd-v3 的调用链是通的,但整条链路仍然裸奔:9094 到 9093 是明文 HTTP/2,任何人都能在本机用 tcpdump 看到完整的 protobuf 消息和 metadata。10.1 先把传输层补上,10.2 再谈「谁在调用」,10.3 谈「他能做什么」。

传输安全要解决三件事:

威胁机制本节落在哪
链路上的窃听加密TLS,客户端校验服务端证书
连到假服务端身份认证服务端证书 + SAN 主机名校验
任意客户端都能连双向认证mTLS,服务端要求客户端证书

gRPC-Java 把这三件事都收在 ChannelCredentials 与 ServerCredentials 里。应用代码不碰 SSLEngine,也不该自己去读密钥;把证书路径交给 credentials,剩下的握手由底层完成。TLS 1.3 的密钥调度、会话恢复细节属于协议实现,本节不展开,也不假装能从 gRPC 层观察到这些内部状态。

核心机制一:证书链、PEM 与主机名

mTLS 的最小拓扑是一条两级链:

linkd-v3-dev-ca(自签 CA)
├── server:CN=linkd-v3,SAN=dns:localhost,ip:127.0.0.1
└── client:CN=linkd-gateway,EKU=clientAuth

服务端持有 server.pem 与 server-key.pem,并用 ca.pem 校验客户端证书;客户端持有 client.pem 与 client-key.pem,并用同一份 ca.pem 校验服务端。ca.pem 是双方共同的信任锚。

几个容易混淆的点:

  • gRPC 的 TlsServerCredentials / TlsChannelCredentials 吃的是 PEM 文件,不是 JKS 或 PKCS#12。keyManager(certChain, privateKey) 的第一个参数是证书链(叶子在前),第二个是 PKCS#8 私钥。
  • 服务端证书必须有 SAN。现代 TLS 客户端不再回退到 CN;证书里只有 CN=linkd-v3 而客户端连的是 localhost,校验一定失败。
  • 客户端证书不需要 SAN,因为服务端只用它做链校验与身份提取;但 EKU=clientAuth 要写对,部分实现会检查扩展用途。
  • 信任锚必须是签出叶子证书的那个 CA。把叶子证书自己当 trust manager,只在「自签叶子」时成立;本节是两级链,必须用 ca.pem。
  • 私钥不能带口令。gRPC 的 PEM key manager 不接受加密私钥;开发环境用 openssl pkcs12 -nodes 导出无口令 PKCS#8,生产环境靠文件权限与密钥管理服务保护,而不是靠一个写在脚本里的口令。

主机名校验由 gRPC 在握手后完成:它拿 channel 的 authority(默认 host:port 里的 host)去匹配服务端证书的 SAN。开发机连 localhost:9093,证书 SAN 里有 dns:localhost,通过;如果代码写 127.0.0.1,就需要 ip:127.0.0.1。这两个 SAN 都要写进开发证书,否则同一份代码换个写法就连不上。

核心机制二:用 keytool 签一套开发证书

JDK 自带的 keytool 能完成 CA、CSR、签发和导入,只有「把 PKCS#12 里的私钥导成 PEM」这一步需要 openssl。下面这段可以直接复制执行,产物全部落在 /tmp/linkd-tls:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p /tmp/linkd-tls && cd /tmp/linkd-tls
PASS=changeit

# 1. 自签 CA:bc:c 表示 BasicConstraints CA=true
keytool -genkeypair -alias ca -keyalg EC -groupname secp256r1 \
  -dname "CN=linkd-v3-dev-ca, O=Plume, C=CN" -validity 3650 \
  -keystore ca.p12 -storetype PKCS12 -storepass "$PASS" -keypass "$PASS" \
  -ext bc:c
keytool -exportcert -alias ca -keystore ca.p12 -storepass "$PASS" \
  -rfc -file ca.pem

# 2. 服务端:生成密钥与 CSR,再由 CA 签发,SAN 同时覆盖 localhost 与 127.0.0.1
keytool -genkeypair -alias server -keyalg EC -groupname secp256r1 \
  -dname "CN=linkd-v3, O=Plume, C=CN" -validity 825 \
  -keystore server.p12 -storetype PKCS12 -storepass "$PASS" -keypass "$PASS"
keytool -certreq -alias server -keystore server.p12 -storepass "$PASS" \
  -file server.csr
keytool -gencert -alias ca -keystore ca.p12 -storepass "$PASS" \
  -infile server.csr -outfile server.pem -rfc -validity 825 \
  -ext san=dns:localhost,ip:127.0.0.1 -ext eku=serverAuth
keytool -importcert -alias ca -file ca.pem -keystore server.p12 \
  -storepass "$PASS" -noprompt
keytool -importcert -alias server -file server.pem -keystore server.p12 \
  -storepass "$PASS" -noprompt
openssl pkcs12 -in server.p12 -nocerts -nodes -passin pass:"$PASS" \
  -out server-key.pem

# 3. 客户端:EKU=clientAuth,其余步骤与服务端对称
keytool -genkeypair -alias client -keyalg EC -groupname secp256r1 \
  -dname "CN=linkd-gateway, O=Plume, C=CN" -validity 825 \
  -keystore client.p12 -storetype PKCS12 -storepass "$PASS" -keypass "$PASS"
keytool -certreq -alias client -keystore client.p12 -storepass "$PASS" \
  -file client.csr
keytool -gencert -alias ca -keystore ca.p12 -storepass "$PASS" \
  -infile client.csr -outfile client.pem -rfc -validity 825 \
  -ext eku=clientAuth
keytool -importcert -alias ca -file ca.pem -keystore client.p12 \
  -storepass "$PASS" -noprompt
keytool -importcert -alias client -file client.pem -keystore client.p12 \
  -storepass "$PASS" -noprompt
openssl pkcs12 -in client.p12 -nocerts -nodes -passin pass:"$PASS" \
  -out client-key.pem

验证服务端证书的扩展:

openssl x509 -in server.pem -noout -subject -issuer -ext subjectAltName

预期输出(本节未在本机执行这套命令,格式按 openssl 的常规输出推导):

subject=CN=linkd-v3, O=Plume, C=CN
issuer=CN=linkd-v3-dev-ca, O=Plume, C=CN
X509v3 Subject Alternative Name:
    DNS:localhost, IP Address:127.0.0.1

keytool -gencert 的 -ext 参数支持 san、eku 这类常见扩展,但不同 JDK 版本对扩展字符串的解析略有差异;执行后一定要用上面的 openssl x509 看一眼,别假设命令成功就等于扩展写对。私钥文件是 PKCS#8 PEM,openssl pkcs12 -nocerts -nodes 只是导出,不会额外加保护,所以 /tmp/linkd-tls 只能用于开发。

核心机制三:服务端接入 TlsServerCredentials

服务端要同时表达三件事:用哪张证书、信谁、要不要客户端证书。TlsServerCredentials.Builder 的 clientAuth(REQUIRE) 就是第三件:

package com.plume.linkd.v3.server;

import io.grpc.BindableService;
import io.grpc.Server;
import io.grpc.ServerCredentials;
import io.grpc.TlsServerCredentials;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;

import java.io.File;
import java.io.IOException;

public final class TlsServer {

    private TlsServer() {
    }

    public static ServerCredentials credentials(File dir) {
        File ca = new File(dir, "ca.pem");
        File cert = new File(dir, "server.pem");
        File key = new File(dir, "server-key.pem");
        requireReadable(ca, cert, key);
        return TlsServerCredentials.newBuilder()
                .keyManager(cert, key)
                .trustManager(ca)
                .clientAuth(TlsServerCredentials.ClientAuth.REQUIRE)
                .build();
    }

    public static Server start(int port, File dir, BindableService... services)
            throws IOException {
        NettyServerBuilder builder = NettyServerBuilder
                .forPort(port, credentials(dir))
                .maxInboundMessageSize(4 * 1024 * 1024);
        for (BindableService service : services) {
            builder.addService(service);
        }
        return builder.build().start();
    }

    private static void requireReadable(File... files) {
        for (File file : files) {
            if (!file.isFile() || !file.canRead()) {
                throw new IllegalStateException("unreadable TLS material: " + file);
            }
        }
    }
}

几个决定值得写进注释:

  • forPort(port, credentials) 是 NettyServerBuilder 的重载,直接吃 ServerCredentials;不要先 forPort(port) 再想办法塞证书。
  • clientAuth(REQUIRE) 一旦打开,没有客户端证书的握手会在 TLS 层被拒,请求根本到不了 ServerInterceptor。所以「未认证」不会表现为 gRPC 的 UNAUTHENTICATED,而是 UNAVAILABLE。
  • 证书路径从外部传入,不写死在类里。生产用只读挂载,开发用 /tmp/linkd-tls;同一个类两边都能用。
  • maxInboundMessageSize 是 4 MiB,与全书默认一致。它与 TLS 无关,但初始化 server 时一起设置,避免后面再改一次构建链。

启动日志要把「用了哪种认证」打出来,否则线上只能看到端口起来了:

server started port=9093 tls=mTLS clientAuth=REQUIRE cert=server.pem

这是预期格式,不是本机输出。判读时重点看 clientAuth 与证书文件名,不要只看 started。

核心机制四:客户端接入 TlsChannelCredentials

客户端要提供信任锚和自己的证书。单向 TLS 时省略 keyManager,mTLS 时必须提供:

package com.plume.linkd.v3.client;

import io.grpc.ChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.TlsChannelCredentials;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;

import java.io.File;
import java.io.IOException;

public final class TlsClient {

    private TlsClient() {
    }

    public static ManagedChannel channel(String host, int port, File dir,
                                         String authority) throws IOException {
        ChannelCredentials credentials = TlsChannelCredentials.newBuilder()
                .trustManager(new File(dir, "ca.pem"))
                .keyManager(new File(dir, "client.pem"),
                        new File(dir, "client-key.pem"))
                .build();
        return NettyChannelBuilder.forAddress(host, port, credentials)
                .overrideAuthority(authority)
                .maxInboundMessageSize(4 * 1024 * 1024)
                .build();
    }
}

overrideAuthority 只在测试或服务发现给出的是 IP、而证书只签了 DNS 名时使用。它会同时改变 HTTP/2 的 :authority 和 TLS 的主机名校验目标;生产环境如果靠它绕过不匹配,等于关掉了主机名校验。正确做法是把客户端真正使用的名字签进 SAN。

客户端侧的失败通常长这样(预期形态,具体文本随握手阶段变化):

io.grpc.StatusRuntimeException: UNAVAILABLE: io exception
Caused by: javax.net.ssl.SSLHandshakeException: Received fatal alert: bad_certificate

bad_certificate 表示服务端不认客户端证书,优先查三件事:客户端证书是不是这个 CA 签的、client.pem 是不是完整的叶子证书、服务端 trustManager 是不是同一份 ca.pem。如果是客户端不认服务端,常见的是 PKIX path building failed,那说明信任锚不对或链不完整。

在 linkd-v3 里落地

落地时把证书材料收在一个目录,用环境变量指定,启动时做一次自检:

package com.plume.linkd.v3.server;

import io.grpc.Server;

import java.io.File;
import java.time.Duration;

public final class LinkdMain {

    public static void main(String[] args) throws Exception {
        File tlsDir = new File(System.getenv().getOrDefault(
                "LINKD_TLS_DIR", "/tmp/linkd-tls"));
        Server server = TlsServer.start(9093, tlsDir,
                new LibraryServiceImpl(), new HealthService());
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            server.shutdown();
            try {
                server.awaitTermination(Duration.ofSeconds(5).toNanos(),
                        java.util.concurrent.TimeUnit.NANOSECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }));
        System.out.println("server started port=9093 tls=mTLS clientAuth=REQUIRE");
    }
}

awaitTermination 的签名是 (long timeout, TimeUnit unit),这里把 Duration 转成纳秒再传,避免在调用点猜单位。网关侧把 TlsClient.channel("localhost", 9093, tlsDir, "localhost") 得到的 channel 交给 LibraryClient,9094 到 9093 这一段就有了双向认证。

证书轮换不要在请求路径上做。开发环境每次重启重新生成即可;生产环境的做法是给服务端配置「当前证书 + 下一张证书」,先让所有客户端信任新 CA,再切服务端证书,最后撤旧 CA。gRPC 的 TlsServerCredentials 只接受一组 key manager,做平滑轮换要么重启,要么在更外层做 SNI 分流;这一点要在 15.3 的运行手册里写清楚。

常见错误与边界

错误一:把 JKS 直接传给 keyManager。 TlsServerCredentials.keyManager 要的是 PEM。传 .jks 不会在编译期报错,握手时才失败。用 file 命令或看文件头 -----BEGIN 确认格式。

错误二:服务端证书只写 CN,不写 SAN。 浏览器时代已经淘汰 CN 回退,gRPC 同样只认 SAN。开发证书里至少要有 dns:localhost 与 ip:127.0.0.1。

错误三:客户端只配 trustManager,却开了 clientAuth(REQUIRE)。 握手会以 bad_certificate 结束。mTLS 是双向的,客户端也要有证书和私钥。

错误四:把私钥提交进仓库。 server-key.pem、client-key.pem 只能存在于密钥管理或只读挂载里。开发脚本生成的 /tmp 目录用完即弃,不要 git add。

错误五:用 overrideAuthority 掩盖证书问题。 它会把「证书签错名字」变成「能连上」。测试可以临时用,生产必须让 SAN 与真实 authority 一致。

边界一:TLS 1.3 的细节不在本节范围。 gRPC 的 credentials 只负责把证书和信任锚交给底层;会话恢复、0-RTT、密钥更新由 TLS 实现决定。不要在应用层假设「一定用了 1.3」,也不要从 gRPC 的 API 去读密钥材料。

边界二:证书校验通过不等于授权通过。 mTLS 证明「这个客户端持有某张证书」,不证明「它能读这本书」。客户端证书的 CN 只适合做服务身份,最终的用户身份仍然来自 10.2 的 token。

边界三:时钟。 证书的 notBefore / notAfter 按本机时钟判断。开发机时间漂移会让一张刚签发的证书立刻失效;容器里没有正确时间同步时,握手失败会表现得像证书损坏。

小结

  • 9093 的 mTLS 由三份材料组成:CA 证书、服务端证书与私钥、客户端证书与私钥;gRPC 的 credentials 吃 PEM,不吃 JKS。
  • keytool 能完成 CA、CSR、签发与导入;私钥导成 PEM 这一步用 openssl pkcs12 -nocerts -nodes,产物只适合开发环境。
  • 服务端用 TlsServerCredentials.newBuilder().keyManager(...).trustManager(...).clientAuth(REQUIRE),客户端用 TlsChannelCredentials.newBuilder().trustManager(...).keyManager(...)。
  • 主机名校验看 SAN,不看 CN;overrideAuthority 只用于测试,不能拿来掩盖证书名字错误。
  • 握手失败在 gRPC 层是 UNAVAILABLE,根因在 cause 链里的 SSLHandshakeException;先查信任锚、证书链和 SAN。
  • mTLS 只解决传输与客户端身份;用户身份、租户和权限分别在 10.2、10.3 处理。

传输层加密之后,下一个问题是「这条加密连接上的人是谁」。10.2 把 authorization metadata、Bearer token 的结构校验与签名校验接进来,并明确哪些事情不该由一个教学实现承担。

阅读导航:上一节 9.3 错误与超时映射 | 下一节 10.2 Token 传播与校验

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收