在任何一个严肃的生产环境中,确保部署到集群的容器镜像是可信的、未经篡改的,这并非一个可选项,而是一条基线。问题随之而来:签名的私钥存放在哪里?直接以 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)
这个流程的关键优势在于:
- 无长期凭证: Tekton Pod 使用 K8s 自动注入的、有时效性的 ServiceAccount JWT 换取一个同样有时效性的 Vault Token。整个过程中没有静态密钥、密码或Token硬编码在任何地方。
- 密钥不离库:
cosign
的私钥存储在 Vault 的 Transit Engine 中,签名操作通过 API 调用完成。流水线 Pod 只接触到待签名的 payload 和签名结果,从未接触到私钥本身。 - 最小权限: 我们可以为 Tekton 流水线专用的 ServiceAccount 绑定一个 Vault Policy,该 Policy 仅授权其访问特定密钥的
sign
操作,除此之外别无他权。 - 统一审计: 所有签名请求都会在 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 和签名文件。 - 标准化工具:使用官方的
vault
和cosign
镜像,以及轻量级的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 中特定密钥签名的镜像。没有这个强制执行的环节,签名流程的价值将大打折扣。这将是我们平台工程团队下一步要攻克的关键挑战。