Sigstore 深度实战:从透明日志 Merkle 树、短期证书到软件供应链签名验证的工程全解

软件供应链攻击的典型形态不是"攻破加密算法",而是"攻破密钥管理"。2020 年的 SolarWinds 事件、2021 年的 Codecov 上传脚本泄露、以及此后反复出现的 npm/PyPI 投毒,攻击者拿到的往往都是构建流水线里一个长期存在、权限过大的签名密钥或发布 Token。传统 GPG 签名在数学上是安全的,在工程上却是失败的:密钥怎么生成、怎么存、丢了怎么吊销、验证者凭什么信任这把公钥——这四个问题没有一个有好答案。

Sigstore 的出现不是为了发明新密码学,而是把公钥基础设施(PKI)里最难的那部分——身份绑定与密钥生命周期——彻底删掉。本文从工程内核角度拆解它:Fulcio 的短期证书、Rekor 的透明日志 Merkle 树、以及落地时必须想清楚的信任模型与失效边界。

一、三次范式转移:把"长期密钥"换成"短暂身份"

Sigstore 的核心设计可以概括成三个替换:

传统做法Sigstore 做法解决的问题
长期 GPG 私钥 + 公钥分发Fulcio 颁发 10 分钟有效期的 X.509 证书,私钥当场生成即弃密钥泄露窗口、吊销列表
公钥信任靠 Web of Trust / 官网贴指纹证书里的 SAN 直接绑定 OIDC 身份(GitHub Actions workflow、Google 账号、K8s ServiceAccount)"这把公钥是谁的"
签名存在仓库里,可被静默替换签名同时写入 Rekor 透明日志,只可追加、不可篡改事后否认、日志分叉、定向隐藏

关键在于:短期证书本身是不自足的。证书 10 分钟后过期,验证者无法在一年后用证书有效性来确认签名——这正是 Rekor 必须存在的原因。签名在证书有效期内被"钉"进一个只可追加的梅克尔树,日志给出这个"曾经发生过"的加密学证明,长期可信性由日志的不可篡改性与可审计性提供,而不是由证书提供。这一点是理解 Sigstore 全部设计的钥匙。

二、Fulcio:把 OIDC 令牌换成 X.509 证书

Fulcio 是一个极简 CA。流程只有四步:

  1. 客户端本地生成临时密钥对(ECDSA P-256 或 Ed25519),私钥从不离开内存;
  2. 从 OIDC Provider 拿 ID Token(GitHub Actions 走 OIDC 联合身份,K8s 里走 projected ServiceAccount token);
  3. 把 ID Token + 临时公钥的 CSR 发给 Fulcio,Fulcio 校验令牌签名、issuer、audience;
  4. Fulcio 签发短证书,并把证书链写入 Rekor。

证书里最关键的是两个扩展:

  • SAN(Subject Alternative Name):放 OIDC 身份,比如 https://github.com/foo/bar/.github/workflows/release.yml@refs/tags/v1.2.3;
  • OIDC Issuer 扩展(OID 1.3.6.1.4.1.57264.1.1):记录签发方,验证时据此判断"哪个 GitHub 组织的哪个 workflow 签的"。

这意味着签名身份是工作流级别而非人级别的。一次 cosign sign 表达的是"这个制品由该仓库该 tag 的 CI 流水线产出",而不是"某个开发者签了名"。这个语义差别对合规审计极其重要:它天然支持 SLSA 的 provenance 概念。

一次真实的 keyless 签名:

# CI 中(GitHub Actions 已配置 id-token: write 权限)
cosign sign --yes ghcr.io/acme/api-server:v1.2.3

# 人工本地签名,走浏览器 OIDC 交互
cosign sign --yes ghcr.io/acme/api-server:v1.2.3

# 验证时同时约束身份与签发方,二者缺一不可
cosign verify ghcr.io/acme/api-server:v1.2.3 \
  --certificate-identity-regexp '^https://github\.com/acme/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

工程坑位:cosign verify 如果只校验签名合法性而不校验 --certificate-identity,等同于没做验证——任何人 fork 你的仓库跑一次 CI 都能拿到一个合法签名。身份约束必须写进策略,且用 -regexp 匹配组织前缀而不是精确串,否则分支保护绕过、fork workflow 都能造成误判。

三、Rekor:透明日志的梅克尔树到底保证了什么

Rekor 是 Certificate Transparency(RFC 6962)思路在通用制品签名上的泛化。它的两条不变式是:

  • 只可追加(append-only):任何已存在的条目永不被修改或删除;
  • 可验证追加(verifiable append):日志增长过程中,旧状态的树必须始终是新版树的前缀。

这两条分别由两类加密学证明支撑:

  • Inclusion Proof(包含证明):给定叶子哈希,给出从该叶子到根路径上的兄弟节点哈希序列,验证者重算根哈希并与已签名的 STH(Signed Tree Head)比对,证明"我的记录确实在这棵树里";
  • Consistency Proof(一致性证明):给定旧树大小 N 与新树大小 M,证明旧树是新树的前缀,从而阻止日志运营商偷偷分叉出"给你看的版本"和"给别人看的版本"。

RFC 6962 的实现细节里有两个很容易写错的地方:叶子哈希前缀是 0x00、内部节点哈希前缀是 0x01(域分离,防止第二原像攻击把内部节点伪装成叶子);以及一致性证明在 N 不是 2 的幂时,需要先找到旧树最右子树再向左递归。下面是可运行的验证代码:

import hashlib

LEAF, NODE = b'\x00', b'\x01'

def h(b: bytes) -> bytes:
    return hashlib.sha256(b).digest()

def leaf_hash(data: bytes) -> bytes:
    return h(LEAF + data)

def node_hash(l: bytes, r: bytes) -> bytes:
    return h(NODE + l + r)

def root_from(leaves: list[bytes]) -> bytes:
    """从叶子列表自底向上算 Merkle 根(完整树,用于演示)"""
    level = [leaf_hash(x) for x in leaves]
    while len(level) > 1:
        if len(level) % 2:            # 奇数个节点:最右节点提升一层
            level.append(level[-1])
        level = [node_hash(level[i], level[i + 1]) for i in range(0, len(level), 2)]
    return level[0]

def verify_inclusion(data: bytes, index: int, proof: list[bytes],
                     root: bytes, tree_size: int) -> bool:
    """验证 index 处的 data 在以 root 为根、大小为 tree_size 的树中"""
    cur = leaf_hash(data)
    i, size = index, tree_size
    for sib in proof:
        # 当前节点是左孩子还是右孩子,取决于 i 的奇偶与区间规模
        if i % 2 == 1 or i + 1 == size:
            cur = node_hash(sib, cur)      # cur 在右
            while i % 2 == 1:              # 回溯到区间边界
                i >>= 1; size = (size + 1) >> 1
        else:
            cur = node_hash(cur, sib)      # cur 在左
            i >>= 1; size = (size + 1) >> 1
    return cur == root

这段代码省略了 RFC 6962 的边界细节(真实实现请直接用 trillian 的 merkle 包或 rekor 的 verify CLI),但它揭示了本质:验证者不需要下载整棵树。一次证明只有 O(log N) 个哈希,这就是为什么透明日志可以支撑千万级条目而验证成本恒定。

工程上还有两个必须知道的点:

  1. Signed Tree Head 的监控(Witness / Checkpoint):STH 由 Rekor 私钥签名后以 note 格式(Log Checkpoint 语法)发布。真正防作弊的不是签名,而是多方见证——独立的 witness 服务定期拉取 STH、验证一致性证明并副署,形成公开可交叉验证的 checkpoint 链。一个没人监控的透明日志在安全性上等价于一个普通数据库。
  2. 对自己身份的监控:Sigstore 官方建议每个组织跑一个 monitor,持续扫描 Rekor 里所有 SAN 匹配自己域名的条目。因为 keyless 签名一旦被滥用(例如 CI 被攻破),日志是公开的,攻击者的签名会被永久记录——这是把"被攻击后无法察觉"变成了"被攻击后必然留痕"。

在 Go 里直接查日志:

client, err := client.GetRekorClient("https://rekor.sigstore.dev")
if err != nil { log.Fatal(err) }

// 按制品摘要检索条目
resp, err := client.Tlog.SearchLogQuery(&tlog.SearchLogQueryParams{
    Query: &models.SearchLogQuery{Entries: []*models.LogQueryEntrySchema{
        {Kind: "hashedrekord", Hash: "sha256:" + digestHex},
    }},
})

四、落地:从签名到准入控制

签名只是第一步,真正产生价值的是在部署侧强制校验。以 Kubernetes 为例,Sigstore 官方的 policy-controller(或 Kyverno)提供 admission webhook,把"镜像必须有可被信任身份的签名"变成集群级硬约束:

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: acme-images
spec:
  images: [{ glob: "ghcr.io/acme/**" }]
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: "^https://github\\.com/acme/"
      attestations:
        - name: must-have-sbom
          predicateType: https://spdx.dev/Document
          policy:
            type: cue
            data: |
              package sigstore
              // 要求 SBOM 存在且含有 SPDX 版本字段
              spdxVersion: =~"^SPDX-2\\."

注意这里同时校验了 attestation(声明) 而非仅签名。cosign attest 可以把 SBOM、SLSA provenance、测试结果作为 in-toto 谓词(predicate)附加到制品上,策略用 CUE 或 Rego 表达。从"这个镜像被签过"升级到"这个镜像带着可验证的构建来源与依赖清单",正是 SLSA L2→L3 的实质差距。

五、必须诚实面对的边界

Sigstore 不是银弹,落地时至少要想清楚四件事:

  • Keyless ≠ 零信任:你把信任从"我的私钥"转移到了"Fulcio CA + OIDC Provider + Rekor 日志"。GitHub 的 OIDC 服务被攻破或配置错误(比如 workflow 里误给 id-token: write 而分支保护缺失),攻击者依然能拿到合法身份。Sigstore 降低的是长期密钥泄露风险与事后否认风险,不是全部供应链风险。
  • 时间窗口与时钟偏移:证书 10 分钟有效期内完成签名,若验证方时钟漂移大,会误判"证书尚未生效"。CI runner 必须启用 NTP 同步,cosign verify 的 --certificate-chain 与 insecureIgnoreTlog 绝不能为了跑通而长期打开。
  • 公开日志的隐私代价:公共 Rekor 实例会永久记录你的制品摘要、身份、时间戳。对内部制品,应自建 Sigstore 栈(Fulcio + Rekor + 私有 Trillian/数据库后端 + TUF 信任根),但要意识到自建就把"公开可审计"这条性质换成了"自证清白",此时 witness 与 checkpoint 归档更加必要。
  • 规模与可用性:每次签名都写 Rekor,大规模 CI 会产生签名风暴。公共实例有速率限制,生产环境应自建或做批量合并;同时 admission 校验路径上 Rekor 是强依赖,需为日志服务不可用时设计 fail-closed 还是 fail-open——默认必须是 fail-closed,否则一次日志抖动就让你的准入控制形同虚设。

结语

Sigstore 真正的贡献,是把"要不要给制品签名"这个长期依赖工程师自觉的问题,变成了"签名只需一条命令、密钥根本不存在"的默认动作。它的三个组件分工清晰:Fulcio 负责身份即时化,Rekor 负责历史不可篡改,Cosign 负责把复杂度藏起来。落地时最容易被忽略的不是技术细节,而是信任模型的重新表述:你现在信任的是一个公开可审计的日志与一套身份提供者,而不是一把锁在抽屉里的私钥——因此监控自己的身份、固定 identity + issuer 双重约束、用 attestation 而非仅签名做策略判定,这三件事必须同时做到,缺一项都会让整个链条退化为一次昂贵的仪式。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部