在 Azure AKS 上构建基于 Tekton 与 Vault 的 OCI 工件签名核心库


在任何一个严肃的生产环境中,确保部署到集群的容器镜像是可信的、未经篡改的,这并非一个可选项,而是一条基线。问题随之而来:签名的私钥存放在哪里?直接以 Kubernetes Secret 的形式挂载到 CI/CD Pod 中是一种常见做法,但这带来了密钥轮换困难、权限控制粒度粗、审计追踪模糊等一系列安全隐患。一旦该 Secret 泄露,整个软件供应链的可信根基便轰然倒塌。

我们的 CI/CD 流程完全构建在 Azure AKS 上的 Tekton Pipelines 之上。我们需要一个方案,既能与 Tekton 无缝集成,又能以企业级的安全标准来管理签名密钥。这意味着密钥本身绝不能直接暴露给任何流水线作业,签名操作必须在一个受控的、可审计的环境中完成。这正是 HashiCorp Vault 的 Transit Secrets Engine 发挥作用的地方。它允许我们将 Vault 作为一个“加密即服务”的平台,数据可以被发送到 Vault 进行签名或加密,而密钥本身永远不会离开 Vault 的安全边界。

我们的目标是打造一套标准化的、可复用的 Tekton ClusterTask 核心库,让任何团队的 Tekton 流水线都能通过调用这个核心库中的任务,以一种安全、标准化的方式为其 OCI 工件(容器镜像、SBOM等)签名,而无需关心底层密钥管理的复杂性。

架构设计与交互流程

在深入代码之前,必须理清各个组件之间的交互逻辑。整个流程的核心是利用 Vault 的 Kubernetes Auth Method,实现 Tekton Pipeline Pod 与 Vault 之间基于 ServiceAccount 的自动认证。

sequenceDiagram
    participant T as Tekton PipelineRun
    participant P as Pipeline Pod (on AKS)
    participant K8S as Kubernetes API Server
    participant V as HashiCorp Vault

    T ->> P: 创建并启动流水线Pod
    P ->> K8S: 读取Pod内挂载的ServiceAccount JWT
    note right of P: /var/run/secrets/kubernetes.io/serviceaccount/token
    P ->> V: 发送登录请求 (携带SA JWT和role)
    V ->> K8S: 验证JWT的有效性
    K8S-->>V: 验证通过
    V-->>P: 返回一个有时效性的Vault Token
    P ->> P: (cosign) 生成OCI工件的签名摘要(payload)
    P ->> V: 请求签名 (携带Vault Token和payload)
    note right of V: POST /v1/transit/sign/cosign-key
    V ->> V: 使用内部存储的私钥对payload进行签名
    V-->>P: 返回签名结果 (Signature)
    P ->> P: (cosign) 将签名附加到OCI工件
    participant OCI as OCI Registry
    P->>OCI: 上传签名附件 (Attestation)

这个流程的关键优势在于:

  1. 无长期凭证: Tekton Pod 使用 K8s 自动注入的、有时效性的 ServiceAccount JWT 换取一个同样有时效性的 Vault Token。整个过程中没有静态密钥、密码或Token硬编码在任何地方。
  2. 密钥不离库: cosign 的私钥存储在 Vault 的 Transit Engine 中,签名操作通过 API 调用完成。流水线 Pod 只接触到待签名的 payload 和签名结果,从未接触到私钥本身。
  3. 最小权限: 我们可以为 Tekton 流水线专用的 ServiceAccount 绑定一个 Vault Policy,该 Policy 仅授权其访问特定密钥的 sign 操作,除此之外别无他权。
  4. 统一审计: 所有签名请求都会在 Vault 中留下详细的审计日志,包括请求来源(哪个 K8s ServiceAccount)、操作时间、签名对象等,极大地方便了安全审计。

Vault 环境配置

假定我们已经在 AKS 集群或其可访问的网络中部署了 Vault。第一步是配置 Kubernetes Auth Method 和 Transit Engine。

1. 启用并配置 Kubernetes Auth Method

这是实现自动认证的基石。

# 启用Kubernetes认证方法
vault auth enable kubernetes

# 配置认证方法,使其能够与AKS的API Server通信
# VAULT_SA_NAME是Vault自身的ServiceAccount名
# TOKEN_REVIEW_JWT是该SA的JWT
# KUBE_CA_CERT是集群的CA证书
vault write auth/kubernetes/config \
    token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
    kubernetes_host="https://<KUBE_API_SERVER_ADDR>:443" \
    kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# <KUBE_API_SERVER_ADDR> 可以通过 kubectl cluster-info 获取

2. 创建 Vault Policy 和 Role

我们需要一个 Policy 来精确定义 Tekton Pod 的权限,然后创建一个 Role 将这个 Policy 绑定到特定的 Kubernetes ServiceAccount。

tekton-signer-policy.hcl:

# 该策略仅允许对名为 cosign-key 的 transit key 执行签名操作
path "transit/sign/cosign-key" {
  capabilities = ["update"]
}

# 允许读取 transit key 的信息,cosign在某些场景下需要
path "transit/keys/cosign-key" {
    capabilities = ["read"]
}

应用此策略并创建 Role:

# 写入策略
vault policy write tekton-signer-policy tekton-signer-policy.hcl

# 创建一个Role,将tekton-signer-policy绑定到
# 命名空间tekton-pipelines下的名为pipeline-runner的ServiceAccount
vault write auth/kubernetes/role/tekton-signer \
    bound_service_account_names=pipeline-runner \
    bound_service_account_namespaces=tekton-pipelines \
    policies=tekton-signer-policy \
    ttl=20m

这里的 pipeline-runner 是 Tekton PipelineRun 默认使用的 ServiceAccount。在真实项目中,我们可能会为需要签名的流水线创建专用的 ServiceAccount,以实现更细粒度的权限控制。

3. 配置 Transit Secrets Engine

最后,创建用于 cosign 签名的非对称密钥。

# 启用Transit引擎
vault secrets enable transit

# 创建一个用于cosign签名的ECDSA P-384密钥
# Cosign 推荐使用 ecdsa-p384
vault write -f transit/keys/cosign-key type=ecdsa-p384

# (可选)允许密钥导出,虽然我们不用,但某些场景可能需要
# vault write transit/keys/cosign-key/config exportable=true

# 获取公钥,这个公钥需要分发给验证签名的系统
# 公钥是公开的,没有安全风险
vault read -format=json transit/keys/cosign-key | jq -r ".data.keys.\"1\".public_key" > cosign.pub

cosign.pub 文件内容应妥善保管,并配置到需要验证镜像签名的系统(如准入控制器)中。

构建核心库:vault-cosign-sign ClusterTask

现在,我们可以开始构建核心的 Tekton ClusterTask。这个任务将封装与 Vault 交互和使用 cosign 签名的所有逻辑。

vault-cosign-sign-task.yaml:

apiVersion: tekton.dev/v1beta1
kind: ClusterTask
metadata:
  name: vault-cosign-sign
spec:
  description: >-
    Signs an OCI artifact using cosign with a key stored in HashiCorp Vault's
    Transit Engine. It authenticates to Vault using the pod's Kubernetes
    ServiceAccount.
  params:
    - name: OCI_IMAGE_URL
      description: The full URL of the OCI image to sign (e.g., myregistry.azurecr.io/myapp:latest).
    - name: VAULT_ADDR
      description: The address of the Vault server.
    - name: VAULT_ROLE
      description: The Vault Kubernetes auth role to use for authentication.
      default: "tekton-signer"
    - name: VAULT_TRANSIT_PATH
      description: The path to the Transit Secrets Engine.
      default: "transit"
    - name: VAULT_TRANSIT_KEY
      description: The name of the key in the Transit Engine to use for signing.
      default: "cosign-key"
  workspaces:
    - name: shared-data
      description: A workspace to share data between steps.
  steps:
    - name: vault-login
      image: hashicorp/vault:1.15.0
      script: |
        #!/bin/sh
        set -euo pipefail

        echo "--- Authenticating to Vault ---"
        # Tekton automatically provides the SA JWT at this path.
        KUBE_SA_TOKEN_PATH="/var/run/secrets/kubernetes.io/serviceaccount/token"
        
        # Log in to Vault using the Kubernetes auth method and write the token to the workspace.
        vault login \
          -no-store \
          -token-only \
          -method=kubernetes \
          role="${VAULT_ROLE}" \
          jwt=@"${KUBE_SA_TOKEN_PATH}" > $(workspaces.shared-data.path)/vault-token

        echo "Successfully authenticated to Vault."
      env:
        - name: VAULT_ADDR
          value: $(params.VAULT_ADDR)
        - name: VAULT_ROLE
          value: $(params.VAULT_ROLE)

    - name: generate-payload
      image: gcr.io/projectsigstore/cosign/cosign:v2.2.3
      workingDir: $(workspaces.shared-data.path)
      script: |
        #!/bin/sh
        set -euo pipefail

        echo "--- Generating signing payload for $(params.OCI_IMAGE_URL) ---"
        # Use cosign to generate the payload that needs to be signed.
        # This payload is essentially a structured JSON containing the image digest.
        cosign generate $(params.OCI_IMAGE_URL) > payload.json

        # The raw payload for Vault needs to be the base64 encoded version of the file content.
        base64 payload.json > payload.base64
        
        echo "Payload generated and base64 encoded."
        cat payload.json

    - name: request-signature-from-vault
      image: docker.io/library/alpine:3.18
      workingDir: $(workspaces.shared-data.path)
      script: |
        #!/bin/sh
        set -euo pipefail
        
        apk add --no-cache curl jq

        echo "--- Requesting signature from Vault Transit Engine ---"
        
        PAYLOAD_B64=$(cat payload.base64)
        VAULT_TOKEN=$(cat vault-token)

        # Make a direct API call to Vault's sign endpoint.
        # This is the core of the "crypto-as-a-service" pattern.
        SIGNATURE_RESPONSE=$(curl --silent --show-error \
          --header "X-Vault-Token: ${VAULT_TOKEN}" \
          --header "Content-Type: application/json" \
          --request POST \
          --data "{\"input\": \"${PAYLOAD_B64}\"}" \
          "$(params.VAULT_ADDR)/v1/$(params.VAULT_TRANSIT_PATH)/sign/$(params.VAULT_TRANSIT_KEY)")

        # The signature is returned in the response, base64 encoded.
        # We need to extract it, decode it, and then re-encode it for cosign.
        # Vault's signature format is "vault:v1:BASE64_SIGNATURE". We extract the signature part.
        SIGNATURE=$(echo "${SIGNATURE_RESPONSE}" | jq -r .data.signature | cut -d ":" -f 3)

        if [ -z "${SIGNATURE}" ]; then
            echo "Error: Failed to get signature from Vault."
            echo "Response: ${SIGNATURE_RESPONSE}"
            exit 1
        fi

        # The signature from Vault is base64. Cosign expects a file, so we decode and save it.
        echo "${SIGNATURE}" | base64 -d > signature.raw
        
        echo "Successfully received signature from Vault."

    - name: attach-signature
      image: gcr.io/projectsigstore/cosign/cosign:v2.2.3
      workingDir: $(workspaces.shared-data.path)
      script: |
        #!/bin/sh
        set -euo pipefail

        echo "--- Attaching signature to $(params.OCI_IMAGE_URL) ---"

        # Use cosign to attach the signature (from the file) to the OCI image.
        # This creates a new manifest in the registry pointing to the signature.
        cosign attach signature \
          --payload payload.json \
          --signature signature.raw \
          "$(params.OCI_IMAGE_URL)"

        echo "Signature attached successfully."

这个 ClusterTask 的设计有几个关键点:

  • 多步骤解耦:每个步骤只做一件事,登录、生成payload、请求签名、附加签名。这使得任务更容易理解和调试。
  • 工作区共享:使用 workspaces.shared-data 在步骤之间安全地传递 Vault Token、payload 和签名文件。
  • 标准化工具:使用官方的 vaultcosign 镜像,以及轻量级的 alpine 进行 API 调用,确保环境的纯净和可预测。
  • 健壮性:脚本中加入了 set -euo pipefail,确保任何一步失败都会导致整个任务失败,防止出现部分成功但结果错误的情况。

在流水线中集成与使用

有了这个核心任务,在任何流水线中为镜像签名就变得非常简单了。下面是一个完整的示例 Pipeline,它会构建一个镜像,然后调用我们的 vault-cosign-sign 任务进行签名,最后再进行验证。

build-and-sign-pipeline.yaml:

apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: build-sign-verify-image
spec:
  description: This pipeline builds an image, signs it with Vault, and verifies the signature.
  params:
    - name: repo-url
      type: string
    - name: image-reference
      type: string
    - name: vault-addr
      type: string
  workspaces:
    - name: source-code
    - name: shared-data
  tasks:
    - name: fetch-source
      taskRef:
        name: git-clone
      workspaces:
        - name: output
          workspace: source-code
      params:
        - name: url
          value: $(params.repo-url)

    - name: build-image
      taskRef:
        name: kaniko
      runAfter: [fetch-source]
      workspaces:
        - name: source
          workspace: source-code
      params:
        - name: IMAGE
          value: $(params.image-reference)
        - name: DOCKERFILE
          value: Dockerfile
    
    - name: sign-image
      taskRef:
        name: vault-cosign-sign
      runAfter: [build-image]
      workspaces:
        - name: shared-data
          workspace: shared-data
      params:
        - name: OCI_IMAGE_URL
          value: $(params.image-reference)
        - name: VAULT_ADDR
          value: $(params.vault-addr)
        # Assuming we use the default role, key, etc. defined in the task.

    - name: verify-image-signature
      taskRef:
        name: cosign-verify
      runAfter: [sign-image]
      params:
        - name: images
          value:
            - $(params.image-reference)
        - name: key
          value: |
            -----BEGIN PUBLIC KEY-----
            MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... (paste content of cosign.pub here)
            -----END PUBLIC KEY-----

注意:cosign-verify 是一个社区提供的 Tekton Hub 任务,你需要提前在集群中安装它。公钥内容需要从之前生成的 cosign.pub 文件中复制过来。

方案的局限性与未来迭代路径

这套方案有效地解决了 Tekton 中 OCI 工件签名的密钥管理问题,但它并非银弹。在真实项目中,还有一些边界情况和可以优化的点需要考虑。

首先,当前的 vault-cosign-sign 任务只处理了镜像签名。一个完整的软件供应链安全方案还需要对 SBOM(软件物料清单)、SLSA provenance 等其他类型的工件进行签名和证明(attestation)。我们的核心库需要扩展,提供例如 vault-cosign-attest 这样的任务,逻辑类似,但使用的是 cosign attest 命令。

其次,密钥管理本身。虽然密钥不离开 Vault,但 Vault 中的密钥轮换策略、访问策略的审计和更新,都需要纳入到平台的整体治理流程中。例如,可以设置 Transit Key 自动轮换,并确保验证端(如准入控制器)能够动态获取到最新的公钥集。

最后,签名只是第一步,强制执行才是闭环。在 AKS 集群中部署一个准入控制器(如 Kyverno 或 OPA Gatekeeper),并配置策略,强制要求所有进入生产环境的 Pod 都必须使用由我们 Vault 中特定密钥签名的镜像。没有这个强制执行的环节,签名流程的价值将大打折扣。这将是我们平台工程团队下一步要攻克的关键挑战。


  目录