为多云 Serverless 函数构建基于 Phoenix 的动态 mTLS 证书颁发与测试体系


一个棘手的架构问题摆在面前:如何在一个混合云环境中,确保一个部署在传统服务器上的 Elixir/Phoenix 核心服务与多个部署在 Vercel 和 Google Cloud 上的 Serverless 函数之间的通信是绝对安全的。这些函数是短暂的、按需启动的,并且分布在不同的云提供商生态中。

传统的 API 密钥或 JWT 方案在这里显得力不从心。它们解决了应用层的认证与授权,但无法在传输层提供双向的、加密的身份验证。任何一个环节的网络配置失误或中间人攻击都可能导致灾难。我们需要一种更底层的、基于零信任原则的方案:双向 TLS 认证(mTLS)。

方案权衡:为何静态证书与云原生 IAM 均非最优解

在生产环境中,我们首先排除了两种看似可行的方案。

方案A:静态长周期证书

这是最直接的想法:为每个 Serverless 函数生成一个客户端证书,将其作为环境变量或 Secret 注入到函数运行时中。

  • 优点: 实现简单,逻辑清晰。
  • 缺点: 这在运维上是一场灾难。证书轮换(rotation)几乎无法自动化。一旦某个函数的私钥泄露,撤销过程将非常痛苦,且存在巨大的安全窗口。在拥有成百上千个函数的系统中,手动管理这些证书的生命周期是不可想象的。这违背了 Serverless 架构所追求的弹性与自动化精神。

方案B:云提供商原生 IAM

另一种思路是利用各云平台自身的身份认证机制。例如,Google Cloud Functions 可以被授予一个服务账户(Service Account),并能生成 OIDC 令牌来证明自己的身份。Vercel 也有类似的环境变量注入机制。

  • 优点: 在单一云生态内,这是非常安全且推荐的做法。
  • 缺点: 我们的场景是跨云的。让 Phoenix 服务去验证一个 Google Cloud 的 OIDC 令牌,同时又要验证来自 Vercel 的某种身份凭证,将导致认证逻辑变得极其复杂和脆弱。每增加一个新的云提供商,认证代码都需要重构。这种方案破坏了架构的统一性和可移植性,将身份验证的复杂性推给了应用层,而不是在传输层统一解决。

最终选择:基于 Phoenix 的动态短周期证书颁发中心

我们最终采纳的架构是,将 Phoenix 应用本身提升为一个轻量级的证书颁发机构(CA)代理。它负责为每一个冷启动的 Serverless 函数动态签发一个生命周期极短(例如,仅有几小时)的客户端证书。

这个架构的核心流程如下:

  1. 引导信任: 每个 Serverless 函数在部署时,被注入一个唯一的、高熵的引导令牌(Bootstrap Token)。这个令牌是一次性的,或者有严格的访问控制。
  2. 证书请求: 函数实例冷启动时,它使用引导令牌向 Phoenix CA 服务的一个特定端点发起请求,申请客户端证书。
  3. 签发与分发: Phoenix CA 服务验证引导令牌,验证通过后,动态生成一对密钥和证书签名请求(CSR),并使用其根 CA 私钥进行签名,最后将生成的客户端证书、私钥和 CA 证书链返回给函数。
  4. 建立mTLS连接: 函数在内存中加载这些凭证,并用它们来初始化其 HTTP 客户端。之后,所有对 Phoenix 服务或其他需要 mTLS 保护的服务的调用,都将使用这个短周期证书进行双向认证。
sequenceDiagram
    participant Vercel/GCF as Serverless Function
    participant PhoenixCA as Phoenix CA Service
    participant ProtectedSvc as Protected Phoenix Service

    Vercel/GCF->>+PhoenixCA: POST /issue-certificate (携带 Bootstrap Token)
    Note right of PhoenixCA: 1. 验证 Token 
2. 生成密钥对与 CSR
3. 使用根 CA 签名 PhoenixCA-->>-Vercel/GCF: 200 OK (返回短周期证书, 私钥, CA链) Vercel/GCF->>Vercel/GCF: 在内存中加载证书和私钥 Note over Vercel/GCF, ProtectedSvc: 建立 mTLS 连接 Vercel/GCF->>+ProtectedSvc: GET /api/secure-data (使用客户端证书) ProtectedSvc->>ProtectedSvc: 验证客户端证书是否由信任的 CA 签发 ProtectedSvc-->>-Vercel/GCF: 200 OK (返回安全数据)

这个方案的优势是显而易见的:

  • 安全性: 证书生命周期极短,大大缩减了密钥泄露的风险窗口。
  • 自动化: 整个过程完全自动化,无需人工干预证书的生命周期管理。
  • 平台无关: mTLS 是开放标准,此模式可应用于任何计算环境,无论是 Serverless、容器还是虚拟机。

核心实现:Phoenix CA 服务

我们将使用 Elixir 的 :public_keyx509 库来实现 CA 的核心功能。首先,我们需要一个根 CA 的证书和私钥。在生产环境中,这必须存储在 HSM 或 Vault 等安全系统中。为了演示,我们假设它们被安全地加载到应用配置中。

1. 路由与控制器

我们需要一个端点来处理证书签发请求。

# lib/my_app_web/router.ex
scope "/internal/pki", MyAppWeb do
  pipe_through [:api]
  post "/issue-certificate", CertificateController, :issue
end

# lib/my_app_web/controllers/certificate_controller.ex
defmodule MyAppWeb.CertificateController do
  use MyAppWeb, :controller

  # 假设我们有一个服务模块来处理引导令牌的验证
  alias MyApp.Auth.BootstrapTokenService
  # 证书颁发的核心逻辑
  alias MyApp.PKI.CertificateAuthority

  def issue(conn, %{"bootstrap_token" => token}) do
    case BootstrapTokenService.validate_and_get_identity(token) do
      {:ok, identity} ->
        # identity 可以包含函数名、环境等信息,用于生成证书的 Subject
        case CertificateAuthority.issue_short_lived_cert(identity) do
          {:ok, cert_data} ->
            json(conn, cert_data)
          {:error, reason} ->
            conn
            |> put_status(:internal_server_error)
            |> json(%{error: "certificate_issuance_failed", reason: reason})
        end

      {:error, :not_found} ->
        conn
        |> put_status(:unauthorized)
        |> json(%{error: "invalid_bootstrap_token"})
    end
  end

  def issue(conn, _params) do
    conn
    |> put_status(:bad_request)
    |> json(%{error: "missing_bootstrap_token"})
  end
end

2. 证书颁发核心逻辑

这是整个方案的心脏。CertificateAuthority 模块负责所有加密操作。

# lib/my_app/pki/certificate_authority.ex
defmodule MyApp.PKI.CertificateAuthority do
  @moduledoc """
  负责动态生成和签发短周期客户端证书
  """

  # 在真实项目中,这些配置应来自加密的配置源
  @root_ca_cert_pem Application.compile_env!(:my_app, [__MODULE__, :root_ca_cert])
  @root_ca_key_pem Application.compile_env!(:my_app, [__MODULE__, :root_ca_key])
  # 证书有效期,例如 4 小时
  @cert_validity_seconds 4 * 60 * 60

  def issue_short_lived_cert(identity) do
    # 步骤 1: 动态为客户端生成一个新的密钥对
    client_key = :public_key.generate_key({:ecdh, :secp256r1})

    # 步骤 2: 构造证书的 Subject 和其他属性
    # 'identity' 应该是一个 map,例如 %{cn: "vercel-function-123", ou: "production"}
    subject = [
      {:commonName, to_charlist(identity.cn)},
      {:organizationalUnitName, to_charlist(identity.ou)},
      {:organizationName, 'My Company'}
    ]

    # 从根 CA 证书中获取签发者信息
    {:ok, root_ca_cert_der} = :public_key.pem_decode(@root_ca_cert_pem)
    [root_ca_entry] = root_ca_cert_der
    {'OTPCertificate', root_ca_tbs, _, _} = root_ca_entry
    {'TBSCertificate', _, _, issuer, _, _, _, _, _, _, _, _} = root_ca_tbs

    # 步骤 3: 创建待签名的证书(TBSCertificate)
    tbs_cert = build_tbs_certificate(subject, client_key, issuer)

    # 步骤 4: 使用根 CA 私钥进行签名
    {:ok, root_ca_key_decoded} = :public_key.pem_decode(@root_ca_key_pem)
    [root_ca_key_entry] = root_ca_key_decoded
    
    signature = :public_key.sign(tbs_cert, :sha256, root_ca_key_entry)

    # 步骤 5: 组装最终的 X.509 证书
    {'AlgorithmIdentifier', oid, _} = elem(root_ca_tbs, 2)
    signed_cert = {'Certificate', tbs_cert, {'AlgorithmIdentifier', oid, nil}, signature}
    
    # 步骤 6: 将证书和私钥编码为 PEM 格式返回
    cert_pem = :public_key.pem_encode([signed_cert])
    key_pem = :public_key.pem_encode([{:ECPrivateKey, client_key, {:explicit, :secp256r1}}])

    {:ok,
     %{
       certificate: cert_pem,
       private_key: key_pem,
       ca_chain: @root_ca_cert_pem
     }}
  rescue
    e -> {:error, {__MODULE__, :signing_error, e}}
  end

  defp build_tbs_certificate(subject, client_key, issuer) do
    serial_number = :crypto.strong_rand_bytes(20) |> :binary.decode_unsigned()
    now = :calendar.universal_time()
    not_before = now
    not_after = :calendar.seconds_to_gregorian_seconds(now)
    |> Kernel.+( @cert_validity_seconds)
    |> :calendar.gregorian_seconds_to_datetime()

    # X.509 v3 扩展
    extensions = [
      {'Extension', :id_ce_basicConstraints, true, {:extnValue, %{cA: false}}},
      {'Extension', :id_ce_keyUsage, true, {:extnValue, [:digitalSignature, :keyEncipherment]}},
      {'Extension', :id_ce_extKeyUsage, true, {:extnValue, [:id_kp_clientAuth]}}
    ]

    {'TBSCertificate',
     :v3,
     serial_number,
     {'AlgorithmIdentifier', :id_ecdsa_with_sha256, nil},
     issuer,
     {'Validity', not_before, not_after},
     subject,
     {:SubjectPublicKeyInfo, {'AlgorithmIdentifier', :id_ecPublicKey, :secp256r1}, client_key},
     nil,
     nil,
     extensions
    }
  end
end

这段代码非常底层,直接与 Erlang/OTP 的加密模块交互。在真实项目中,可以考虑使用 x509 这样的 Hex 包来简化操作,但这里的原生实现能更好地揭示其工作原理。

Serverless 函数端实现

Serverless 函数(无论是 Vercel 还是 Google Cloud Functions,这里以 Node.js 为例)的核心逻辑是在冷启动时获取并使用证书。

// common/mtls_client.ts
import https from 'https';
import axios from 'axios';

// 在函数环境中,这些变量需要被安全地设置
const PHOENIX_CA_URL = process.env.PHOENIX_CA_URL!;
const BOOTSTRAP_TOKEN = process.env.BOOTSTRAP_TOKEN!;

interface CertificatePayload {
  certificate: string;
  private_key: string;
  ca_chain: string;
}

// 缓存证书,避免在函数 warm start 时重复获取
let httpsAgent: https.Agent | null = null;

async function getHttpsAgent(): Promise<https.Agent> {
  if (httpsAgent) {
    // 日志:使用缓存的 mTLS Agent
    console.log("Using cached mTLS agent.");
    return httpsAgent;
  }

  try {
    console.log("Cold start: Fetching new mTLS certificate...");
    // 步骤 1: 使用引导令牌请求证书
    const response = await axios.post<CertificatePayload>(
      `${PHOENIX_CA_URL}/internal/pki/issue-certificate`,
      { bootstrap_token: BOOTSTRAP_TOKEN },
      { timeout: 5000 } // 设置超时,防止冷启动挂起
    );

    const { certificate, private_key, ca_chain } = response.data;
    
    // 步骤 2: 创建并缓存 HTTPS Agent
    httpsAgent = new https.Agent({
      cert: certificate,
      key: private_key,
      ca: ca_chain,
      keepAlive: true, // 保持连接以提高性能
    });
    
    console.log("Successfully created and cached new mTLS agent.");
    return httpsAgent;

  } catch (error) {
    console.error("Failed to fetch or create mTLS agent:", error);
    // 关键错误:如果证书获取失败,函数应该快速失败
    throw new Error("mTLS certificate acquisition failed.");
  }
}

// 导出一个配置好的 axios 实例
export async function createMtlsClient() {
  const agent = await getHttpsAgent();
  return axios.create({ httpsAgent: agent });
}

// Vercel Function 示例
// api/secure-proxy.ts
import { VercelRequest, VercelResponse } from '@vercel/node';
import { createMtlsClient } from '../common/mtls_client';

export default async (req: VercelRequest, res: VercelResponse) => {
  try {
    const apiClient = await createMtlsClient();
    // 使用 mTLS 客户端请求受保护的 Phoenix 服务
    const phoenixResponse = await apiClient.get('https://api.my-phoenix-app.com/v1/secure-data');
    
    res.status(200).json(phoenixResponse.data);
  } catch (error) {
    console.error("Error during mTLS request to Phoenix:", error);
    res.status(502).json({ error: 'bad_gateway' });
  }
};

这段 TypeScript 代码展示了函数的启动逻辑:获取证书、创建 https.Agent 并将其缓存以供后续的 warm invocations 使用。这是对性能至关重要的优化。

测试体系的构建

测试这个分布式安全系统是最大的挑战。我们不能在测试环境中依赖真实的 CA 服务或 Serverless 平台。我们需要在 Phoenix 应用内部构建一个完全自洽的、可控的测试环境。

目标: 在集成测试中,验证一个受 mTLS 保护的 Phoenix 端点能够正确地拒绝无效证书的请求,并接受由我们信任的(测试)CA 签发的有效证书的请求。

策略: 我们将创建一个仅在 :test 环境下编译的 TestCA 模块。这个模块使用一个独立的、仅用于测试的根 CA 证书来签发客户端证书。测试用例将直接调用这个模块来获取证书,然后用它来配置 HTTP 客户端并发起请求。

1. 配置 Cowboy (Phoenix Web Server) 以要求 mTLS

首先,我们需要配置 Phoenix 的底层 Web 服务器 Cowboy 来强制要求客户端证书。

# config/runtime.ex
if config_env() == :prod do
  config :my_app, MyAppWeb.Endpoint,
    https: [
      # ... 其他 https 配置
      port: 443,
      certfile: System.get_env("SERVER_CERT_PATH"),
      keyfile: System.get_env("SERVER_KEY_PATH"),
      # 要求客户端证书,并使用我们的根 CA 进行验证
      cacertfile: System.get_env("ROOT_CA_CERT_PATH"),
      verify: :verify_peer,
      fail_if_no_peer_cert: true
    ]
end

2. 构建测试专用的 CA 模块

# test/support/test_ca.ex
defmodule MyApp.Test.TestCA do
  @moduledoc """
  一个仅用于测试环境的证书颁发机构。
  它使用一个独立的、不安全的测试根CA。
  """
  
  # 这些证书文件仅存在于 test/fixtures/pki 目录中
  @test_root_ca_cert_pem File.read!("test/fixtures/pki/test_ca.crt")
  @test_root_ca_key_pem File.read!("test/fixtures/pki/test_ca.key")

  # 使用 `MyApp.PKI.CertificateAuthority` 的逻辑,但传入测试 CA 凭证
  def issue_client_cert_for_test(cn \\ "test-client") do
    # 简化版的 issue_short_lived_cert, 仅用于测试
    # 在真实项目中,可以重构 CertificateAuthority 模块使其更易于测试
    client_key = :public_key.generate_key({:ecdh, :secp256r1})

    subject = [{:commonName, to_charlist(cn)}]
    
    {:ok, root_ca_cert_der} = :public_key.pem_decode(@test_root_ca_cert_pem)
    [root_ca_entry] = root_ca_cert_der
    {'OTPCertificate', root_ca_tbs, _, _} = root_ca_entry
    {'TBSCertificate', _, _, issuer, _, _, _, _, _, _, _, _} = root_ca_tbs
    
    tbs_cert = MyApp.PKI.CertificateAuthority.build_tbs_certificate(subject, client_key, issuer)

    {:ok, root_ca_key_decoded} = :public_key.pem_decode(@test_root_ca_key_pem)
    [root_ca_key_entry] = root_ca_key_decoded
    
    signature = :public_key.sign(tbs_cert, :sha256, root_ca_key_entry)
    
    {'AlgorithmIdentifier', oid, _} = elem(root_ca_tbs, 2)
    signed_cert = {'Certificate', tbs_cert, {'AlgorithmIdentifier', oid, nil}, signature}
    
    cert_pem = :public_key.pem_encode([signed_cert])
    key_pem = :public_key.pem_encode([{:ECPrivateKey, client_key, {:explicit, :secp256r1}}])

    %{
      cert: cert_pem,
      key: key_pem,
      cacert: @test_root_ca_cert_pem
    }
  end
end

3. 编写集成测试

现在我们可以编写一个集成测试,来验证受保护的端点。我们将使用 Tesla 作为 HTTP 客户端,因为它能很方便地配置 mTLS 选项。

# test/my_app_web/controllers/secure_data_controller_test.exs
defmodule MyAppWeb.SecureDataControllerTest do
  use MyAppWeb.ConnCase, async: true
  alias MyApp.Test.TestCA

  # 这个测试需要在 endpoint_case.ex 中配置 https
  @endpoint_opts [
    https: [
      port: 4001,
      # 使用测试服务器证书
      certfile: "test/fixtures/pki/test_server.crt",
      keyfile: "test/fixtures/pki/test_server.key",
      # 使用测试 CA 验证客户端
      cacertfile: "test/fixtures/pki/test_ca.crt",
      verify: :verify_peer,
      fail_if_no_peer_cert: true
    ]
  ]
  use MyAppWeb.EndpointCase, opts: @endpoint_opts

  describe "GET /api/secure-data" do
    test "拒绝没有客户端证书的请求" do
      # 创建一个没有 mTLS 配置的客户端
      client = Tesla.client([], {Tesla.Adapter.Hackney, []})

      assert_raise Tesla.Error, fn ->
        Tesla.get(client, "https://localhost:4001/api/secure-data")
      end
    end

    test "使用有效的客户端证书成功访问" do
      # 步骤 1: 在测试中动态生成一个客户端证书
      creds = TestCA.issue_client_cert_for_test()

      # 步骤 2: 配置 Tesla 客户端使用这个证书
      adapter_opts = [
        ssl_options: [
          cert: to_charlist(creds.cert),
          key: {:ECPrivateKey, :public_key.pem_decode(creds.key)},
          cacert: to_charlist(creds.cacert)
        ]
      ]
      client = Tesla.client([], {Tesla.Adapter.Hackney, adapter_opts})
      
      # 步骤 3: 发起请求并断言成功
      {:ok, response} = Tesla.get(client, "https://localhost:4001/api/secure-data")
      assert response.status == 200
      assert response.body["data"] == "this is secret"
    end
  end
end

这个测试完美地模拟了整个流程:配置一个强制 mTLS 的服务器端点,在测试运行时动态生成一个客户端证书,然后使用该证书成功地通过了服务器的验证。它为我们这套复杂的安全体系提供了坚实的质量保障。

架构的局限性与未来展望

这套基于 Phoenix 的动态 mTLS 架构虽然解决了跨云 Serverless 安全通信的核心痛点,但它并非没有权衡。

首要的局限性在于,Phoenix CA 服务自身成为了一个高价值目标和潜在的单点故障。必须采用多实例、异地部署等高可用策略来保障其稳定性,并且其引导令牌数据库和根 CA 私钥的安全性是整个体系的基石,需要最高级别的保护。

其次,函数冷启动的延迟会因为增加了一次网络调用(证书申请)而略有上升。虽然可以通过内存缓存来优化 warm start,但在对延迟极度敏感的场景下,需要评估这个额外的开销。

展望未来,此架构可以进一步演进。例如,引入标准的 SPIFFE/SPIRE 框架来规范化工作负载身份的识别与证书签发流程,使之更具通用性。此外,还可以实现证书吊销列表(CRL)或在线证书状态协议(OCSP),来处理引导令牌或函数身份被撤销的场景,从而构建一个更加完备和健壮的零信任网络环境。


  目录