使用 IaC 统一管理 PKI 的 Tonic 与 Jetpack Compose mTLS 通信架构


在构建需要客户端与服务器保持长期、安全通信的系统时,一个核心挑战是如何在零信任网络环境中建立可信的连接。传统的基于 Token 的认证机制在会话开始时验证用户身份,但无法保证通信信道本身不被中间人攻击。尤其对于桌面客户端这种长期运行的应用,我们需要一种更强的身份验证机制,不仅验证用户,更要验证通信的两端——客户端应用和服务端实例。

我们面临的场景是:一个部署在 Kotlin/Jetpack Compose 桌面客户端上的应用,需要与后端的 Rust/Tonic 服务进行实时、双向的数据流交换。这里的技术痛点很明确:如何确保只有我们签发的、合法的客户端实例才能连接到生产环境的服务,并且整个通信过程是端到端加密和防篡改的?

答案是双向TLS认证 (mTLS)。然而,手动管理一套完整的公钥基础设施 (PKI)——包括证书颁发机构 (CA)、服务器证书、客户端证书——是一个极其繁琐且容易出错的过程。在真实项目中,这种手动操作是不可持续的,尤其是在需要频繁轮换证书或管理多个环境时。

因此,我们的初步构想是将整个 PKI 的生命周期纳入基础设施即代码 (IaC) 的管理范畴。使用 Terraform,我们可以声明式地定义和管理 CA、证书和密钥,将其与应用基础设施的部署流程完全统一。这不仅实现了自动化,更重要的是,它将安全基础设施变成了可审计、可版本控制的代码。

第一步:用 Terraform 定义和生成我们的 PKI

在动手编写任何应用代码之前,我们首先要构建信任的基石。Terraform 的 tls provider 是完成此任务的完美工具。我们将创建以下资源:

  1. 一个自签名的根证书颁发机构 (CA)。在生产环境中,这可能会被一个受信任的内部或外部 CA 替代,但原理相同。
  2. 一个服务端证书,由该 CA 签名。
  3. 一个客户端证书,同样由该 CA 签名。

这种结构确保了只有持有由我们自己的 CA 签发的有效证书的客户端,才能被我们的服务器信任。

main.tf 文件定义了整个 PKI 结构:

# 使用 Terraform 管理本地 TLS 证书和密钥,用于 mTLS
terraform {
  required_providers {
    tls = {
      source  = "hashicorp/tls"
      version = "4.0.5"
    }
    local = {
      source  = "hashicorp/local"
      version = "2.4.0"
    }
  }
}

# 1. 创建私有的证书颁发机构 (CA)
# ------------------------------------
# 首先,生成 CA 的私钥
resource "tls_private_key" "ca_key" {
  algorithm = "ECDSA"
  ecdsa_curve = "P256"
}

# 接着,基于私钥创建自签名的 CA 证书
resource "tls_self_signed_cert" "ca_cert" {
  private_key_pem = tls_private_key.ca_key.private_key_pem

  subject {
    common_name  = "MyInternalApp CA"
    organization = "MyOrg Inc."
  }

  is_ca_certificate = true
  validity_period_hours = 8760 # 1 year
  allowed_uses = [
    "cert_signing",
    "crl_signing",
  ]
}

# 2. 为 Tonic 服务端创建证书
# ---------------------------------
# 生成服务器的私钥
resource "tls_private_key" "server_key" {
  algorithm = "ECDSA"
  ecdsa_curve = "P256"
}

# 创建一个证书签名请求 (CSR)
resource "tls_cert_request" "server_csr" {
  private_key_pem = tls_private_key.server_key.private_key_pem

  subject {
    common_name  = "server.internal.app"
    organization = "MyOrg Inc."
  }
  
  # 这里的 DNS 名称必须与客户端连接时使用的主机名匹配
  dns_names = ["localhost", "127.0.0.1"]
}

# 使用我们的 CA 签署服务器的 CSR,生成最终的服务器证书
resource "tls_locally_signed_cert" "server_cert" {
  cert_request_pem = tls_cert_request.server_csr.cert_request_pem
  ca_private_key_pem = tls_private_key.ca_key.private_key_pem
  ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem

  validity_period_hours = 720 # 30 days
  allowed_uses = [
    "key_encipherment",
    "digital_signature",
    "server_auth",
  ]
}


# 3. 为 Jetpack Compose 客户端创建证书
# --------------------------------------
# 生成客户端的私钥
resource "tls_private_key" "client_key" {
  algorithm = "ECDSA"
  ecdsa_curve = "P256"
}

# 创建客户端的 CSR
resource "tls_cert_request" "client_csr" {
  private_key_pem = tls_private_key.client_key.private_key_pem

  subject {
    common_name  = "desktop-client-v1"
    organization = "MyOrg Inc."
  }
}

# 使用 CA 签署客户端的 CSR
resource "tls_locally_signed_cert" "client_cert" {
  cert_request_pem = tls_cert_request.client_csr.cert_request_pem
  ca_private_key_pem = tls_private_key.ca_key.private_key_pem
  ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem

  validity_period_hours = 720 # 30 days
  allowed_uses = [
    "key_encipherment",
    "digital_signature",
    "client_auth",
  ]
}


# 4. 将生成的 PEM 文件输出到本地目录,以便应用程序使用
# -----------------------------------------------------------
resource "local_file" "ca_cert_pem" {
  content  = tls_self_signed_cert.ca_cert.cert_pem
  filename = "${path.module}/certs/ca.pem"
}

resource "local_file" "server_cert_pem" {
  content  = tls_locally_signed_cert.server_cert.cert_pem
  filename = "${path.module}/certs/server.pem"
}

resource "local_file" "server_key_pem" {
  content  = tls_private_key.server_key.private_key_pem
  filename = "${path.module}/certs/server.key"
}

resource "local_file" "client_cert_pem" {
  content  = tls_locally_signed_cert.client_cert.cert_pem
  filename = "${path.module}/certs/client.pem"
}

resource "local_file" "client_key_pem" {
  content  = tls_private_key.client_key.private_key_pem
  filename = "${path.module}/certs/client.key"
}

运行 terraform initterraform apply 后,我们会在 certs/ 目录下得到所有必需的文件:ca.pem, server.pem, server.key, client.pem, client.key。这一步的价值在于,整个PKI的定义是代码化的,任何变更都会被记录在版本控制中,且可以轻松地为不同环境(开发、测试、生产)生成不同的证书集。

第二步:构建启用了 mTLS 的 Tonic 服务端

现在我们有了证书,可以开始构建 Rust 服务端了。我们将使用 tonic 作为 gRPC 框架,并结合 tokio-websockets 来支持 WebSocket 协议,尽管 tonic 本身通过 HTTP/2 处理流式通信。为了简化,我们这里直接使用 Tonic 的双向流 gRPC,它在概念上和 WebSocket 的双向通信非常相似。

首先,定义我们的 .proto 文件,stream.proto

syntax = "proto3";

package stream;

// 流式服务定义
service Streamer {
    // 双向流式 RPC
    // 客户端和服务器可以异步地向对方发送消息流
    rpc BidirectionalStream(stream ClientRequest) returns (stream ServerResponse);
}

message ClientRequest {
    string message = 1;
}

message ServerResponse {
    string reply = 1;
    int64 timestamp = 2;
}

接下来,配置我们的 Cargo.toml:

[package]
name = "secure-tonic-server"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
tonic = { version = "0.10", features = ["tls", "tls-roots"] }
prost = "0.12"
tokio-stream = "0.1"
futures-core = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[build-dependencies]
tonic-build = "0.10"

服务端的核心代码在于配置 ServerTlsConfig。我们不仅要加载服务器自己的证书和密钥,还必须加载 CA 证书,并明确要求对客户端证书进行验证。

src/main.rs:

use std::pin::Pin;
use std::time::{SystemTime, UNIX_EPOCH};

use futures_core::Stream;
use tokio::sync::mpsc;
use tokio_stream::{wrappers::ReceiverStream, StreamExt};
use tonic::{
    transport::{Identity, Server, ServerTlsConfig, ClientAuth},
    Request, Response, Status, Streaming,
};
use tracing::info;

// 从 .proto 文件生成的代码
pub mod stream {
    tonic::include_proto!("stream");
}

use stream::streamer_server::{Streamer, StreamerServer};
use stream::{ClientRequest, ServerResponse};

#[derive(Debug, Default)]
pub struct MyStreamer {}

// 为我们的服务实现双向流处理逻辑
#[tonic::async_trait]
impl Streamer for MyStreamer {
    type BidirectionalStreamStream =
        Pin<Box<dyn Stream<Item = Result<ServerResponse, Status>> + Send>>;

    async fn bidirectional_stream(
        &self,
        request: Request<Streaming<ClientRequest>>,
    ) -> Result<Response<Self::BidirectionalStreamStream>, Status> {
        info!("Client connected! Peer address: {:?}", request.remote_addr());
        
        let mut in_stream = request.into_inner();
        let (tx, rx) = mpsc::channel(128);

        // 创建一个任务来处理从客户端接收到的消息
        tokio::spawn(async move {
            while let Some(result) = in_stream.next().await {
                match result {
                    Ok(req) => {
                        info!("Received message from client: '{}'", req.message);
                        // 模拟处理并回应
                        let reply = format!("Server acknowledges: {}", req.message);
                        let response = ServerResponse {
                            reply,
                            timestamp: SystemTime::now()
                                .duration_since(UNIX_EPOCH)
                                .unwrap()
                                .as_secs() as i64,
                        };
                        if let Err(e) = tx.send(Ok(response)).await {
                            tracing::error!("Failed to send response to client: {}", e);
                            break;
                        }
                    }
                    Err(err) => {
                        tracing::error!("Error receiving from client stream: {}", err);
                        break;
                    }
                }
            }
            info!("Client stream closed.");
        });

        let out_stream = ReceiverStream::new(rx);
        Ok(Response::new(Box::pin(out_stream) as Self::BidirectionalStreamStream))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt::init();

    // 从 Terraform 生成的文件中异步读取证书和密钥
    // 这是一个常见的错误:在同步代码中使用阻塞IO。使用 tokio::fs 是正确的做法。
    let cert = tokio::fs::read("certs/server.pem").await?;
    let key = tokio::fs::read("certs/server.key").await?;
    let ca_cert = tokio::fs::read("certs/ca.pem").await?;
    
    // 创建服务器身份标识
    let identity = Identity::from_pem(&cert, &key);
    
    // 创建 CA 证书对象,用于验证客户端
    let ca = tonic::transport::Certificate::from_pem(&ca_cert);

    // 配置 TLS
    let tls_config = ServerTlsConfig::new()
        .identity(identity)
        // 关键:要求并验证客户端证书。
        // ClientAuth::RequireAndVerify 会强制客户端提供证书,并且该证书必须是由我们提供的 CA 签发的。
        // 如果没有这一行,就只是普通的单向 TLS。
        .client_ca_root(ca)
        .client_auth(ClientAuth::RequireAndVerify);

    let addr = "127.0.0.1:50051".parse()?;
    let streamer = MyStreamer::default();

    info!("Server listening on {} with mTLS enabled", addr);

    Server::builder()
        .tls_config(tls_config)?
        .add_service(StreamerServer::new(streamer))
        .serve(addr)
        .await?;

    Ok(())
}

这里的关键在于 .client_auth(ClientAuth::RequireAndVerify)。这行代码指示 tonic 服务器在 TLS 握手期间,必须向客户端请求证书,并且必须使用我们提供的 ca.pem 来验证该证书的有效性。任何没有提供证书,或者提供了无效证书(例如自签名的,或由其他 CA 签发的)的客户端,连接将被直接拒绝在 TLS 层,根本不会进入我们的应用逻辑。

第三步:构建启用了 mTLS 的 Jetpack Compose 客户端

客户端的实现相对复杂一些,因为它需要在 JVM 中配置 TLS 上下文,这通常比在 Rust 中要繁琐。我们将使用 gRPC-Kotlin 配合 OkHttp 作为传输层,因为 OkHttp 提供了强大的网络配置能力。

首先,配置 build.gradle.kts:

// build.gradle.kts
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
    kotlin("jvm")
    id("org.jetbrains.compose")
    id("com.google.protobuf") version "0.9.4"
}
//...
dependencies {
    implementation(compose.desktop.currentOs)
    // gRPC dependencies
    implementation("io.grpc:grpc-stub-ktx:1.3.0")
    implementation("io.grpc:grpc-protobuf:1.58.0")
    implementation("io.grpc:grpc-okhttp:1.58.0")
    // OkHttp 需要 netty-tcnative-boringssl-static 来支持 ALPN (HTTP/2)
    implementation("io.netty:netty-tcnative-boringssl-static:2.0.61.Final")
}

protobuf {
    // ... protobuf config ...
}

客户端的核心逻辑是创建一个 SslContext,它包含了信任的 CA、客户端自己的证书和私钥。然后用这个 SslContext 来配置 OkHttpChannelBuilder

src/main/kotlin/Main.kt:

import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import io.grpc.ManagedChannel
import io.grpc.okhttp.OkHttpChannelBuilder
import io.netty.handler.ssl.SslContext
import io.netty.handler.ssl.SslContextBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import stream.ClientRequest
import stream.StreamerGrpcKt
import java.io.File
import java.net.InetSocketAddress
import javax.net.ssl.SSLException

object StreamClient {
    private val scope = CoroutineScope(Dispatchers.IO)
    private var channel: ManagedChannel? = null
    private var stub: StreamerGrpcKt.StreamerCoroutineStub? = null

    // 使用 StateFlow 来暴露连接状态和消息
    private val _messages = MutableStateFlow<List<String>>(emptyList())
    val messages = _messages.asStateFlow()

    private val _connectionStatus = MutableStateFlow("Disconnected")
    val connectionStatus = _connectionStatus.asStateFlow()

    fun connect() {
        if (channel != null) return
        _connectionStatus.value = "Connecting..."
        try {
            // 关键:构建 mTLS 的 SslContext
            val sslContext = buildSslContext()
            
            // 使用 OkHttp 作为 gRPC 的传输层,并配置 SSL
            val address = InetSocketAddress("localhost", 50051)
            channel = OkHttpChannelBuilder.forAddress(address.hostName, address.port)
                .sslSocketFactory(sslContext.newClientContext().socketFactory)
                .build()

            stub = StreamerGrpcKt.StreamerCoroutineStub(channel!!)
            _connectionStatus.value = "Connected"
            startStreaming()

        } catch (e: Exception) {
            val rootCause = if (e.cause is SSLException) e.cause?.message else e.message
            _connectionStatus.value = "Connection Failed: ${rootCause}"
            e.printStackTrace()
            disconnect()
        }
    }

    private fun buildSslContext(): SslContext {
        // 在真实项目中,这些文件应该被安全地打包到应用中或从安全位置获取
        // 这里的路径是相对于项目根目录的
        val trustCertCollection = File("certs/ca.pem")
        val clientCertChain = File("certs/client.pem")
        val clientPrivateKey = File("certs/client.key")

        if (!trustCertCollection.exists() || !clientCertChain.exists() || !clientPrivateKey.exists()) {
             throw IllegalStateException("Certificate files not found. Make sure to run 'terraform apply' first.")
        }
        
        return SslContextBuilder.forClient()
            .trustManager(trustCertCollection) // 信任我们的自定义 CA
            .keyManager(clientCertChain, clientPrivateKey) // 提供客户端自己的证书和私钥
            .build()
    }

    private fun startStreaming() {
        val currentStub = stub ?: return
        scope.launch {
            try {
                // 开启双向流
                val stream = currentStub.bidirectionalStream()
                
                // 启动一个协程来监听来自服务器的消息
                launch {
                    stream.responses.collect { response ->
                        val msg = "[Server]: ${response.reply} (at ${response.timestamp})"
                        _messages.value = _messages.value + msg
                    }
                }

                // 在当前协程中向服务器发送消息
                repeat(5) { i ->
                    val message = "Hello from Compose Client, message #${i + 1}"
                    _messages.value = _messages.value + "[Client]: $message"
                    stream.requests.send(ClientRequest.newBuilder().setMessage(message).build())
                    kotlinx.coroutines.delay(2000)
                }
                stream.requests.close()
            } catch (e: Exception) {
                _connectionStatus.value = "Stream Error: ${e.message}"
                disconnect()
            }
        }
    }
    
    fun disconnect() {
        channel?.shutdownNow()
        channel = null
        stub = null
        _connectionStatus.value = "Disconnected"
    }
}


@Composable
@Preview
fun App() {
    val messages by StreamClient.messages.collectAsState()
    val status by StreamClient.connectionStatus.collectAsState()

    MaterialTheme {
        Column(Modifier.fillMaxSize().padding(16.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Button(onClick = { StreamClient.connect() }, enabled = status == "Disconnected") {
                    Text("Connect")
                }
                Spacer(Modifier.width(8.dp))
                Button(onClick = { StreamClient.disconnect() }, enabled = status.startsWith("Connected")) {
                    Text("Disconnect")
                }
                Spacer(Modifier.width(16.dp))
                Text("Status: $status")
            }
            Spacer(Modifier.height(16.dp))
            Divider()
            LazyColumn(modifier = Modifier.weight(1f)) {
                items(messages.size) { index ->
                    Text(messages[index], modifier = Modifier.padding(vertical = 4.dp))
                }
            }
        }
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication, title = "Secure Stream Client") {
        App()
    }
}

这个客户端应用启动后,点击 “Connect”,它会尝试使用 certs/ 目录下的证书与服务器建立 mTLS 连接。如果服务器的证书不是由 ca.pem 签发的,或者客户端自己提供的证书 (client.pem) 无效,连接就会在 TLS 握手阶段失败,并显示 Connection Failed。只有当双方证书都通过了 CA 的验证,连接才能成功建立,并开始双向的数据交换。

sequenceDiagram
    participant IaC (Terraform)
    participant Server (Tonic)
    participant Client (Compose)

    IaC ->> IaC: Generate CA, Server Cert/Key, Client Cert/Key
    IaC -->> Server: Provides server.pem, server.key, ca.pem
    IaC -->> Client: Provides client.pem, client.key, ca.pem
    
    Server ->> Server: Start listening with ServerTlsConfig(mTLS)
    
    Client ->> Server: Attempt to connect (ClientHello)
    Server ->> Client: Respond with its certificate (ServerHello, Certificate)
    
    Client ->> Client: Verify server cert against ca.pem
    Note right of Client: If verification fails, connection is aborted.
    
    Client ->> Server: Send its own certificate (Certificate)
    Server ->> Server: Verify client cert against ca.pem
    Note left of Server: If verification fails, connection is aborted.
    
    Server ->> Client: TLS Handshake complete
    Client ->> Server: TLS Handshake complete
    
    Note over Client, Server: Secure gRPC Channel Established
    
    Client ->> Server: Send ClientRequest stream
    Server ->> Client: Send ServerResponse stream

局限性与未来迭代路径

这套架构成功地解决了一对一安全通信和 PKI 自动化管理的问题,但在生产环境中,它还存在一些需要解决的挑战。

首先,证书分发与安全存储。当前方案假设客户端可以从文件系统直接读取密钥和证书。在真实的分发场景中,这是一个巨大的安全风险。如何安全地将 client.key 分发给每一个合法的客户端实例,并防止其被提取,是一个复杂的问题。可能的解决方案包括使用硬件安全模块 (HSM),或在应用首次启动时通过一个安全的、一次性的注册流程来获取证书。

其次,证书轮换与吊销。我们的 Terraform 脚本能够生成有效期为30天的证书,但没有实现自动轮换。一个完整的生产系统需要一套无缝的证书轮换机制。此外,如果某个客户端的密钥泄露,我们需要一种方式来吊销它的证书。这通常需要维护一个证书吊销列表 (CRL) 或使用在线证书状态协议 (OCSP),tonicOkHttp 都需要额外配置来支持这些。

最后,可扩展性。当服务需要水平扩展到多个实例时,如何管理负载均衡器与 mTLS 的关系变得至关重要。是让负载均衡器(如 Nginx, HAProxy)进行 TLS 终结并验证客户端证书,还是使用 TCP 直通模式让后端服务自己处理 TLS 握手?每种选择都有其架构上的权衡。使用服务网格 (Service Mesh) 如 Istio 或 Linkerd 可以自动化处理集群内部的 mTLS,但这又引入了新的复杂性。

尽管存在这些待解决的问题,通过 IaC 来统一管理 PKI,并结合 Tonic 和 Jetpack Compose 实现端到端的 mTLS 认证,为构建高安全性的 C/S 架构提供了一个坚实且可自动化的起点。


  目录