如何启用 TLS 双向认证?

解读

在国内互联网、金融、物联网及云原生场景下,双向 TLS(mTLS) 已成为“零信任”架构的标配:不仅服务端要向客户端出示证书,客户端也必须回传合法证书,否则连接直接终止。面试官问“如何启用”,既考察候选人是否理解 TLS 握手流程,也考察能否用 Rust 生态落地生产级配置,包括国密、证书热更新、SNI、ALPN、中间人降级防护等细节。回答时要把“为什么做”和“怎么做”拆开,先讲原理,再给代码骨架,最后补充踩坑点,体现“编译通过即正确”的 Rust 文化。

知识点

  1. TLS 1.3 握手与 CertificateRequest:服务端在 ServerHello 后发送 CertificateRequest,客户端必须回证书+签名。
  2. rustls 与 OpenSSL 的权衡:rustls 纯 Rust、FIPS 待审、无国密;OpenSSL 有国密、需 unsafe,国内合规项目常双栈支持。
  3. 证书链校验:系统根库、自定义 CA、CRL/OCSP、国密双证(签名+加密)。
  4. tokio-rustls / hyper-rustls / tonic:异步生态统一接口,ServerConfig::with_client_cert_verifier 是关键扩展点。
  5. 客户端证书选择回调:当客户端有多张证书时,需实现 ResolvesClientCert trait,按 SNI/算法/国密策略动态挑选。
  6. ALPN 协商:http/1.1、h2、grpc-exp,防止协议降级。
  7. 热更新:证书文件走 notify crate 监听 inotify,reload 后原子替换 Arc<ServerConfig>服务零中断
  8. 错误诊断:rustls 报错仅给 AlertDescription,需要开启 secretly extract peer cert chain 日志,结合 Wireshark 抓包,否则现场排障抓瞎。

答案

以下示例基于 tokio + rustls 0.21,覆盖“服务端强制校验客户端证书”与“客户端自动送证”两端,可直接放进简历项目

  1. 准备 CA 与双证
# 自建根 CA
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ca.key
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=example-ca"
# 服务端证书
openssl req -new -nodes -out server.csr -keyout server.key -subj "/CN=server.example"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365
# 客户端证书
openssl req -new -nodes -out client.csr -keyout client.key -subj "/CN=client.example"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365
  1. 服务端代码(强制 mTLS
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_rustls::{TlsAcceptor, server::TlsStream};
use rustls::{ServerConfig, RootCertStore, AllowAnyAuthenticatedClient};
use rustls_pemfile::{certs, pkcs8_private_keys};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. 加载 CA 根证书
    let mut root_store = RootCertStore::empty();
    let ca_file = &mut std::io::BufReader::new(std::fs::File::open("ca.crt")?);
    for cert in certs(ca_file)? {
        root_store.add(&rustls::Certificate(cert))?;
    }

    // 2. 加载服务端证书与私钥
    let cert_file = &mut std::io::BufReader::new(std::fs::File::open("server.crt")?);
    let key_file  = &mut std::io::BufReader::new(std::fs::File::open("server.key")?);
    let cert_chain = certs(cert_file)?.into_iter().map(rustls::Certificate).collect();
    let mut keys = pkcs8_private_keys(key_file)?;
    let key = rustls::PrivateKey(keys.remove(0));

    // 3. 构造 ServerConfig:强制客户端出示证书
    let config = ServerConfig::builder()
        .with_safe_defaults()
        .with_client_cert_verifier(AllowAnyAuthenticatedClient::new(root_store))
        .with_single_cert(cert_chain, key)?;

    let acceptor = TlsAcceptor::from(Arc::new(config));
    let listener = TcpListener::bind("0.0.0.0:443").await?;

    loop {
        let (stream, peer_addr) = listener.accept().await?;
        let acceptor = acceptor.clone();
        tokio::spawn(async move {
            match acceptor.accept(stream).await {
                Ok(tls) => handle(tls, peer_addr).await,
                Err(e) => eprintln!("TLS handshake failed from {}: {}", peer_addr, e),
            }
        });
    }
}

async fn handle(stream: TlsStream<tokio::net::TcpStream>, peer: std::net::SocketAddr) {
    // 4. 提取客户端证书链,做二次业务鉴权
    let (_, session) = stream.get_ref();
    if let Some(chain) = session.peer_certificates() {
        println!("client cert count: {}", chain.len());
        // TODO: 解析 CN/O 做细粒度 RBAC
    }
    // 5. 业务逻辑
    use tokio::io::{AsyncWriteExt, AsyncReadExt};
    let (mut rd, mut wr) = tokio::io::split(stream);
    let n = rd.copy(&mut wr).await.unwrap();
    println!("echo {} bytes from {}", n, peer);
}
  1. 客户端代码(自动送证
use tokio::net::TcpStream;
use tokio_rustls::{TlsConnector, client::TlsStream};
use rustls::{ClientConfig, RootCertStore};
use rustls_pemfile::{certs, pkcs8_private_keys};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. 信任同一 CA
    let mut root_store = RootCertStore::empty();
    let ca_file = &mut std::io::BufReader::new(std::fs::File::open("ca.crt")?);
    for cert in certs(ca_file)? {
        root_store.add(&rustls::Certificate(cert))?;
    }

    // 2. 加载客户端证书与私钥
    let cert_file = &mut std::io::BufReader::new(std::fs::File::open("client.crt")?);
    let key_file  = &mut std::io::BufReader::new(std::fs::File::open("client.key")?);
    let cert_chain = certs(cert_file)?.into_iter().map(rustls::Certificate).collect();
    let mut keys = pkcs8_private_keys(key_file)?;
    let key = rustls::PrivateKey(keys.remove(0));

    let config = ClientConfig::builder()
        .with_safe_defaults()
        .with_root_certificates(root_store)
        .with_single_cert(cert_chain, key)?; // 关键:送客户端证书

    let connector = TlsConnector::from(Arc::new(config));
    let stream = TcpStream::connect("server.example:443").await?;
    let domain = rustls::ServerName::try_from("server.example")?;
    let mut tls = connector.connect(domain, stream).await?;

    tls.write_all(b"GET / HTTP/1.0\r\n\r\n").await?;
    let mut buf = vec![0; 1024];
    let n = tls.read(&mut buf).await?;
    println!("{}", String::from_utf8_lossy(&buf[..n]));
    Ok(())
}
  1. 验证
# 服务端日志应出现 “client cert count: 1”
# 若客户端未送证,rustls 会返回 AlertDescription::CertificateRequired,连接直接断开

拓展思考

  1. 国密场景:rustls 目前无 SM2/SM3/SM4,需回退到 openssl + tokio-openssl,并启用 GmSSL 引擎;同时注意双证体系,签名证书与加密证书需分开发送。
  2. 证书热更新:把 Arc<ServerConfig> 包进 ArcSwap,结合 notify 监听证书目录,原子替换无需重启进程;线上灰度时可通过 Envoy/NGINX Rust 插件 做软负载兜底。
  3. 多租户 SNI:在 ResolvesServerCert 中按 SNI 选择不同证书,实现 单端口万域名;客户端同样可在 ResolvesClientCert 里按服务端 SNI 回传不同证书链,防止证书滥发
  4. 性能调优:rustls 默认启用 TLS 1.3 0-RTT,但 0-RTT 有重放风险,金融支付类业务需关闭;可开启 boringssl 兼容的 SSLKEYLOGFILE 抓包,结合 eBPF 分析握手时延。
  5. 合规审计:国内等保 2.0 要求“通信完整性校验”,mTLS 满足该条款,但需把客户端证书序列号、算法、有效期落盘到日志,对接 ELK/ClickHouse 做事后溯源。