SLSA 和 in-toto 是两栈叠加,不是二选一

SLSA 和 in-toto 都在回答'这件软件是不是按声称的流程走出来的',但切在不同关节——in-toto 是可以在图上跑的验证算法,SLSA 是对签发方的等级承诺。手算 MATCH 规则看清算法本体。

都在讲”供应链安全”,但 SLSA 和 in-toto 不是同一层东西。in-toto 是一份算法——给出可以在有向图上真的跑起来的验证函数;SLSA 是一份等级承诺——规定一个构建平台自称到哪一层,它签发的溯源信息就该有多少含金量。两者的关系不是”竞争标准”,是验证算法 vs 可信任的输入。看清这一层分工,「供应链要不要上 SLSA 3」这种问题才有靠谱的回答。

上一次写供应链是从 Warp 那个 skills-lock.json 切进去的,讲了”提示词也开始需要锁文件”这条边界。这一次往下走一层——如果连提示词都要走供应链治理,那通用软件供应链这一层的两个奠基规范到底怎么工作?我把 SLSA v1.0 spec(slsa.dev/spec/v1.0)和 in-toto 的 USENIX Security 2019 论文(Torres-Arias 等,PDF)并排读了一遍,写下这篇。

先把三句话主张压在最前面:

  1. in-toto 的本体是 30 行的 VERIFY_FINAL_PRODUCT 算法:签名核对 → 过期检查 → 逐步 link 阈值 → 工件规则回放 → 检查项复核。把它读完,“供应链验证”就从口号变成一段可执行的伪代码。
  2. MATCH 规则的算法核就是”名字相同、哈希相同”:所有关于”两步之间的产物没被替换”的论证,最后都落在 sha256(前一步产物) == sha256(后一步材料) 这一次比较上。稍后手算给你看。
  3. SLSA 的三层等级不是”更好的加密”,而是”更少信任的构建环境”:L1 只要求”有溯源信息”,L2 要求它由托管平台签,L3 要求这个平台在不同任务之间隔离、且签名密钥用户脚本看不到

名词速查

面向”懂工程,但没系统读过供应链规范”的读者,把最容易卡的 7 个词一次说清。

术语一句话解释
Attestation(证明)一份带签名的”这件事发生过”的陈述。in-toto 里叫 link,SLSA 里叫 provenance——同一样东西的两个名字,粒度和字段不同
Provenance(溯源)特指”这个产物是谁在什么平台、拿什么输入、用什么脚本构建出来的”这份 attestation
Layout(布局文件)in-toto 里由项目所有者签名的 JSON:声明流程有哪几步、每步谁能签、每步的产物之间应满足什么规则
Link(步骤证明)in-toto 里由执行方(functionary)签名的 JSON:记录这一步实际用的输入 hash、输出 hash、命令和返回码
Functionary(执行方)具体执行某一步的角色(例如”负责代码签出的开发者”、“负责编译的 CI”),每步可以指定阈值 k-of-n
MATCH / ALLOW / DISALLOWin-toto 的工件规则:把两步的 link 里的名字+哈希做集合级比较,声明”这两步之间产物应当一致”或”这类文件不许出现”
SLSA Build L1 / L2 / L3构建平台的三档等级:L1 有溯源、L2 溯源由托管平台签、L3 平台任务隔离且签名密钥用户代码不可见

站内已写过的相关概念:SBOM 与 Agent Skill 的供应链治理见 Skills 锁住提示词供应链

一句话根本约束:算法只能挑签名后的陈述比对

写供应链框架的人拿到的是一手烂牌——每一步实际怎么执行、发生了什么,验证方永远没在现场。你能拿到的只有”某个功能方事后签名的 JSON”。所以任何一个供应链完整性框架的算法核,都必然是同一个骨架:

拿一份”应该怎样”的预声明(layout / SLSA 要求),和一叠”实际怎样”的事后陈述(links / provenance),做集合级别的对齐检查

反事实崩点:如果去掉这条约束、允许验证方到构建时”看一眼”,那所有分布式软件流水线的 attestation 机制都可以扔掉,直接跑一个可信执行环境即可。真实世界里我们做不到,所以框架只能长成”预声明 + 事后签名 + 对齐”这个三段式。

共同祖先:Kerberos 也是这个骨架(KDC 预声明 + 票据事后签发 + 服务端做对齐),Git 也是(每个 commit 签一份”这些父提交在此哈希汇合”的陈述)。in-toto 只是把这个骨架搬到了 DAG 上——每个节点是一步、每条边是一份 link。

in-toto 的算法本体:30 行、五个动作

先把论文里 Algorithm 1 VERIFY_FINAL_PRODUCT 完整贴出来(伪代码,对应论文 Section 4.3;行号来自 PDF 文本抽取,字段名保持一致):

# 伪代码——对应 in-toto USENIX 2019 论文 Algorithm 1
def VERIFY_FINAL_PRODUCT(layout, links, project_owner_key):
    # 1. 布局必须由项目所有者签名
    if not verify_signature(layout, project_owner_key):
        return FAIL
    # 2. 布局未过期
    if layout.expiration < TODAY:
        return FAIL
    # 3. 载入每步允许签名的功能方公钥
    functionary_pubkeys = layout.keys
    # 4. 逐步核对 link 数量是否满足阈值
    for step in layout.steps:
        step_links = get_links_for_step(step, links)
        step_keys  = get_keys_for_step(step, functionary_pubkeys)
        for link in step_links:
            if not verify_signature(link, step_keys):
                step_links.remove(link)
        if len(step_links) < step.threshold:
            return FAIL  # "Link metadata is missing"
    # 5. 把每步的 materials/products 送进工件规则回放
    if apply_artifact_rules(steps, links) == FAIL:
        return FAIL
    # 6. 执行客户端 inspections(例如"打开这个 wheel 包,比对里面的文件")
    inspections = [Run(insp) for insp in layout.inspections]
    if apply_artifact_rules(steps + inspections, links) == FAIL:
        return FAIL
    return SUCCESS

五个动作按顺序读:布局签名 → 布局有效期 → 逐步 link 签名与阈值 → 工件规则回放 → 客户端复核。每个动作都对应一个明确的失败模式——签名对不上、过期、link 不够数、规则不匹配、复核失败。

这里最容易被忽视的是第 6 步的”再复核一遍”:inspections 可能会新产生工件(例如解压 .whl 得到源码文件),所以规则要在这些新工件加入图之后再回放一次,否则会漏掉”包里塞了不该有的东西”这类情况——这正是论文里 Datadog 部署方案能拦下 100% 历史攻击的关键机制。

手算 MATCH 规则:hash 相等就是全部

工件规则里最核心、也是所有攻击场景里被验的那条,是 MATCH。规范里的语法是:

MATCH <pattern> [IN <src-prefix>] WITH (MATERIALS|PRODUCTS)
       [IN <dst-prefix>] FROM <step>

去掉两个可选的路径前缀不谈,语义就一句话:这条 link 的工件队列里符合 pattern 的那部分,必须和被引用步骤的 materials/products 里同名的那部分在名字和 hash 上逐项相等。语言学上一层套一层,翻译成代码是三行:

def apply_match_rule(pattern, consuming_link, referenced_link):
    src = {k: v for k, v in consuming_link["materials"].items()
           if k == pattern or pattern == "*"}
    dst = {k: v for k, v in referenced_link["products"].items()
           if k == pattern or pattern == "*"}
    if set(src) != set(dst):        return "FAIL (name mismatch)"
    for name in src:
        if src[name] != dst[name]:  return f"FAIL (hash mismatch on {name})"
    return "PASS"

拿三个玩具 link 手算一遍就能证清楚整条规则的能力边界。我实际跑了下面这段脚本(脚本文件在自己机器上,正文只贴逻辑):

  • 场景 Acheckout 产出 foo.c(bytes = int main() { return 0; }\n),build 消费 foo.c(同样 bytes)。
  • 场景 Bcheckout 产出的还是原来的 foo.c,但 build 消费的是 int main() { return 1; }\n——差一个字符。

原始输出如下:

--- MATCH foo.c WITH PRODUCTS FROM checkout, case: matching hashes ---
checkout.products['foo.c']  = deac66ccb79f...
build.materials['foo.c']    = deac66ccb79f...
verdict                     = PASS

--- MATCH foo.c WITH PRODUCTS FROM checkout, case: differing hashes ---
checkout.products['foo.c']  = deac66ccb79f...
build.materials['foo.c']    = 03381f2bcc31...
verdict                     = FAIL (hash mismatch on foo.c)

--- content sensitivity ---
sha256('payload\n')  = d4e4877bac978b79...
sha256('payload!\n') = 0ff12f6ba40d931d...
equal? False

对着输出把 MATCH 规则的能与不能也就落定了:

  • 能识别的:任何让两个 hash 不相等的差异——包括单字符改动、编码方式改动、换行符差异。
  • 不能识别的:所有让 hash 依然相等的可能,例如”两步之间语义等价但内容不同”、“两步之间使用了受信任的转换但 layout 里没声明这条转换”。规范里给这种情况准备的不是更强的 MATCH,而是inspections——由消费方执行一段代码去展开这个转换,比对展开后的结果。

「只有 hash 相等」这一点是本质。 它是限制、也是优势:一旦你允许”两步之间语义等价即可”,验证方就要理解每一种”语义等价”,这会立刻把算法从常数时间的哈希比对退化成不可判定问题。规范承认了这一点,把”能常数时间验的部分(hash 相等)“和”要跑代码才能验的部分(inspections)“分开,才是它能被 Debian、Datadog、云原生三家都落地的原因。

(论文 Section 7 的部署评估里,Datadog 集成的运行时验证开销 < 0.6 秒,主要成本来自签名验证——这也印证了”算法核是哈希比对”这条:真正花时间的是密码学,不是规则回放。)

SLSA 的三层:不是加密变强,是”信任面”缩小

现在切到另一份规范。SLSA v1.0 说的完全是另一件事——假设我们已经决定用类似 in-toto 这样的框架产 attestation,那么”产 attestation 的那个构建环境”本身要满足什么?

SLSA v1.0 Build Track 定义了三档等级(levels):

级别生产方要求(增量)拦下的威胁
Build L1Provenance 存在,写明构建平台、流程、顶层输入;由生态惯例分发给下游发布时的”意外”——例如从错误的 commit 分支构建,或者根本没走 CI
Build L2在 L1 之上:Build 运行在托管平台(不是个人工作站),且 provenance 通过数字签名与那个平台绑定构建之后的替换——签名让第三方替换攻击的门槛拉到”必须拿到平台密钥”
Build L3在 L2 之上:平台必须做任务隔离(不同 build 之间不能相互影响、后一个 build 不能修改前一个的环境、缓存不能被跨 build 污染),且签名密钥对用户脚本不可见构建过程中的干扰——例如同租户上另一个作业往缓存里塞东西,或者用户构建脚本本身试图读到 provenance 签名密钥

v1.0/requirements 里对 L3 隔离的字面要求是四个 MUST NOT(原文):

  1. 一个 build 不能读到平台的任何 secret(包括 provenance 的签名密钥)。
  2. 两个时间上重叠的 build 不能相互影响。
  3. 前一个 build 不能持久化或影响后一个 build 的环境。
  4. 一个 build 不能往另一个 build 用的缓存里塞假条目。

对着看,你会发现 L3 的所有工程要求都在解决同一个约束:既然 provenance 是”事后由平台签名的陈述”,那这个平台自己就必须做到——它签下去的每一个字段,都不是它服务的用户脚本能够左右的。规范里管这个叫 provenance is unforgeableEvery field in the provenance MUST be generated or verified by the build platform.

v0.1 版本里管这条叫 “non-falsifiable”,v1.0 合并进了 “unforgeable”——同一件事,字面变严了:不只是”用户改不了”,而是”每个字段都得由平台亲自填或亲自验”。

反事实崩点在这里非常清楚:如果你的 L3 平台把 provenance 字段的填写交给用户脚本,只是最后由平台签一下名——那你签的是一份用户可以自由改写的 JSON。哈希再长的签名也不会把假变成真。这就是为什么 SLSA L2 → L3 的差价不是密码学,是执行环境的隔离与最小信任面——这个约束跟隔离虚拟化、机密计算、supply chain–aware CI 是同一个共同祖先,都是”把签名者能触碰的数据面收窄到自己完全掌控的部分”。

两个规范怎么合成一套系统

把 in-toto 和 SLSA 摆到一张图上就好懂了:

         layout(谁能签什么、允许哪些工件流动)


          ┌────────────────────┐
     ┌──▶│ step: source       │──┐
     │    └────────────────────┘  │  link{materials, products, 签名}
     │                            ▼
     │    ┌────────────────────┐          ▲
     │    │ step: build        │◀─────────┤ SLSA Build L1/L2/L3 讨论的
     │    └────────────────────┘          │ 是"这个盒子有多可信"
     │              │  link                │
     │              ▼
     │    ┌────────────────────┐
     └───▶│ step: package      │
          └────────────────────┘


             delivered product


       VERIFY_FINAL_PRODUCT(layout, links, PK)
  • in-toto 定义整张图的形态和验证算法:图上每个节点要签什么、边上要满足什么规则、消费方怎么在收到产物时把整张图跑一遍验证。
  • SLSA 单独讨论其中一个节点(通常是 build)的可信度:这个节点产出的 link/provenance,凭什么值得当作真话来对齐?

所以现实中你会看到:SLSA 的 provenance schema(v1.0/provenance)本身就是 in-toto attestation 的一种 predicate——_type: "in-toto"predicateType: "https://slsa.dev/provenance/v1"。SLSA 借用了 in-toto 的数据模型作为外壳,把自己的字段(buildDefinitionrunDetailsexternalParametersresolvedDependencies)塞在 predicate 里。这是我在读 provenance 例子时最直接的收获——两份规范其实是 encoding 层套 semantic 层的关系,不是替代关系。

SLSA 的 GitHub Actions 官方构建类型(slsa-github-generator generic builder)产出的 attestation 就是这个形态:外层 in-toto envelope,内层 SLSA provenance predicate。这在实践中意味着:你在流水线里选好 SLSA 等级要求,选好 in-toto layout,两条链就自动接上——不需要你再手写一份格式定义。

论文 Section 7 的部署账本:整套系统能拦下几成历史事件

论文最实证的一段是 Section 7 的历史事件回溯。作者查了 2010–2019 年 30 起有据可查的供应链完整性事件(Xcode、Android GTK、MeDoc、Adobe updater、PHP PEAR、南韩组织事件等等),把每一起按”执行方密钥有没有被拿到”分类:

  • 23 / 30 起事件不涉及执行方密钥被拿到——这类三种部署方式全部能拦下(客户端 inspection 会在开包检查阶段发现多出来的文件或替换过的产物)。
  • 7 / 30 起事件涉及执行方密钥被拿到——这一档就要看部署细节:
事件Datadog可复现构建云原生
Keydnap(Apple 开发者证书)
backdoored-pypi(开发者 ssh 密钥)
CCleaner未拦
RedHat 事件未拦
NotPetya(假设涉及密钥)未拦未拦
Operation Red未拦未拦
KingSlayer未拦未拦

汇总下来 Datadog 部署 100%、可复现构建部署 90%、云原生部署 83% 覆盖率。三者的差别不是加密算法不同,是阈值签名的部署深度——Datadog 在 build 和 packaging 两步都要求 k-of-n 签名,云原生部署完全没用阈值。规范给的能力上限一样,落到部署侧才见分晓——这是”选型层”最容易忽略、也最值钱的信号:能力上限是买的,实际水位是自己盖的。

(这里的 100% / 90% / 83% 三个数是论文原文报告值,我未在自己环境复现——但表格里逐行”是否可拦”的判断我核到了论文 Table 3 的原文,逻辑链是可信的。)

边界卡:什么时候这两份规范都盖不住

灰度地把两个规范”不能做”的部分列出来,读者才知道哪些坑要另找工具补:

想拦的东西in-totoSLSA L3得靠什么
依赖包本身就带隐患(上游源码里就有)不能,只保证”没被改过”不能SCA / SBOM / 漏洞库比对
合规的开发者本人主动埋雷不能(他有合法密钥)不能代码评审 + 阈值签名 + 运行时观测
构建环境正常,但产物里的行为问题不能不能动态分析 / 沙盒执行 / fuzz
语义等价但内容不同(合理转换)导致 MATCH 误报能,但要写 inspection 来展开不涉及明确声明中间转换步骤
密钥托管方本身出问题部分——阈值机制可缓解不能HSM / 硬件根信任 / 分布式签名

“两个规范都没覆盖代码本身在做什么”是最值得写进 checklist 的一条——供应链完整性 ≠ 供应链安全。前者只回答”这个包是不是你以为的那家用你以为的流程造出来的”,后者要额外回答”这个包做的事是不是你以为的”。我在读之前把两者视为同一件事,读完发现完全不是——它们是两栈叠加,需要各自的工具链。

小结

根本约束:验证方永远不在事故现场,因此供应链完整性框架的算法核只能是”预声明 + 事后签名 + 集合级对齐”三段式。

崩点:一旦允许验证方在构建时”看一眼”,规范设计会崩塌到”直接跑可信执行环境”,把整个分布式流水线的意义抹掉。

可带走的动作:给现有 CI 流水线做一次 5 分钟自检——(a) 你的构建平台自称到 SLSA 哪一级?(b) 如果 provenance 是关于”谁在什么平台造出了这个包”,签名密钥用户脚本能读到吗?(c) 如果换在 in-toto 的图上表达,你的流水线是几步、每步几人阈值、有没有客户端 inspection?三个问题答完就知道你现在处在论文表里的哪一列。

通俗总结:把两份规范当成”体检报告”和”X 光机”

把复杂的规范翻成生活化的类比:

  • in-toto 是 X 光机——它给你一整套照片和一段判读程序:预先告诉你每张片子该是什么样,事后拿真片子逐张比对。判读程序本身很简单:照片里的哈希对得上,就放行;对不上,就报错
  • SLSA 是体检等级证书——它不直接看片子,而是评估”给你拍片子的那家医院”够不够格:设备是不是用户能碰的?两个病人的片子会不会串?签名章能不能被别人拿去用?

你把两者叠一起:先挑一家 SLSA 3 等级的”医院”(构建平台)来拍片子,再把片子送进 in-toto 的判读程序(VERIFY_FINAL_PRODUCT)去比对。一家不合格的医院拍出来的片子,判读程序照读不误——但它读到的可能是别人塞进来的假片子。这是为什么两份规范必须搭配着看的最短版本。

给同事复述用的一句话:in-toto 定义”怎么验”,SLSA 定义”谁签的字能算数”,两个规范一栈叠加才叫供应链完整性。

参考来源

规范与工程实践

论文

站内相关