Skip to content

fix(release): attest and verify desktop updates end to end - #4008

Merged
M4n5ter merged 7 commits into
mainfrom
fix/desktop-autoupdate-release-hardening
Aug 28, 2026
Merged

fix(release): attest and verify desktop updates end to end#4008
M4n5ter merged 7 commits into
mainfrom
fix/desktop-autoupdate-release-hardening

Conversation

@M4n5ter

@M4n5ter M4n5ter commented Aug 27, 2026

Copy link
Copy Markdown
Member
English

Summary

Make Desktop auto-updates and update-artifact publication one source-bound, independently verifiable contract:

  • keep ASF releases source-only: the IPMC-approved source archive on ASF distribution infrastructure is the release;
  • validate the exact macOS and Windows updater metadata, payloads, hashes, sizes, and blockmaps consumed by packaged clients;
  • exercise real macOS and Windows update paths, including download, replacement, relaunch, rollback, uninstall, and workspace identity checks where applicable;
  • make each successful Release run emit a self-contained publication record binding the repository, workflow run and attempt, approved ASF source reference, source commit, product tag, version, channel, and exact asset names, sizes, and SHA-256 digests;
  • run Finalize only from reviewed main, verify the exact Release run artifacts and live Draft, and retain the protected product-release Environment as the sole human publication gate;
  • use GitHub Actions OIDC and actions/attest to generate SLSA provenance for the verified update artifacts, publishing Maka-<version>-attestation.sigstore.json beside them without a stored signing key or repository-administration credential;
  • make Desktop verify the Sigstore trust root, exact Finalize workflow identity, GitHub Actions OIDC issuer, SLSA statement, expected platform artifact name, and SHA-256 before an update becomes installable;
  • publish GitHub Releases as a distribution transport rather than the update trust root; replacing a Release asset cannot produce an accepted update without a matching protected-workflow attestation;
  • serialize Release and Finalize behind one product-release concurrency group and allow only stable, alpha, and beta product channels.

The local update harness remains loopback-only and intentionally isolates updater mechanics from provenance because it serves synthetic bytes. Ordinary packaged launches have no feed override and always perform provenance verification. Finalize verifies the exact publication input before attestation and verifies the freshly issued provenance for every subject before publication. Production signing, notarization, hardened runtime, and Developer ID behavior remain unchanged.

Release authority

approved ASF source release → exact Release run/attempt → publication record + exact update artifacts → current verifier on reviewed main → protected environment approval → OIDC/Sigstore attestation → update transport → client-side identity + digest verification

ASF release compliance and automatic-update trust are separate boundaries, and ASF release scope remains source-only. GitHub Release mutability can cause unavailability, but it cannot authorize different update bytes. Repository immutable releases and an Administration-read GitHub App are therefore neither required nor part of the publication contract.

Verification

  • npm run format:check
  • npm run lint
  • npm run rebuild
  • npm --workspace @maka/desktop run typecheck
  • Desktop updater/attestation focused suite — 27 tests passed
  • npm run check:release — 110 tests passed
  • npm run check:third-party-notices
  • git diff --check origin/main...HEAD
中文

摘要

将 Desktop 自动更新与更新产物发布收敛为一个绑定源码、可独立验证的契约:

  • ASF release 只包含源码:ASF 分发基础设施上经 IPMC 批准的源码归档即为 release;
  • 校验 macOS 和 Windows 打包客户端实际消费的更新元数据、安装包、哈希、大小与 blockmap;
  • 执行真实的 macOS 与 Windows 更新路径,按平台覆盖下载、替换、重启、回滚、卸载与 workspace 身份验证;
  • 每个成功的 Release run 生成自包含 publication record,绑定仓库、workflow run 与 attempt、已批准 ASF 源码引用、源码 commit、产品 tag、版本、channel,以及精确的资产名称、大小和 SHA-256;
  • Finalize 只能从经过 review 的 main 运行,验证精确 Release run 构建产物与 live Draft,并保留受保护的 product-release Environment 作为唯一人工发布门;
  • 使用 GitHub Actions OIDC 与 actions/attest 为已验证的更新产物生成 SLSA provenance,并发布 Maka-<version>-attestation.sigstore.json,无需保存签名私钥或仓库 administration credential;
  • Desktop 只有在验证 Sigstore trust root、精确 Finalize workflow 身份、GitHub Actions OIDC issuer、SLSA statement、目标平台文件名和 SHA-256 后,才允许安装下载完成的更新;
  • GitHub Release 只是分发传输层,不是更新信任根;替换 Release asset 无法在缺少受保护 workflow 匹配 attestation 的情况下形成可接受更新;
  • Release 与 Finalize 共用一个 product-release concurrency group,产品 channel 只允许 stable、alpha 与 beta。

本地更新 harness 仍严格限于 loopback;由于使用合成字节,它有意将 updater 机制与 provenance 分开验证。普通打包应用启动时不存在 feed override,始终执行 provenance 验证。Finalize 在签发前验证精确 publication input,并在发布前验证每个 subject 的新签发 provenance。生产签名、公证、hardened runtime 与 Developer ID 行为保持不变。

发布权威

已批准 ASF 源码 release → 精确 Release run/attempt → publication record + 精确更新产物 → reviewed main 上的当前 verifier → 受保护环境审批 → OIDC/Sigstore attestation → 更新传输 → 客户端验证身份与摘要

ASF release 合规与自动更新信任是两个独立边界,ASF release 的范围始终仅限源码。GitHub Release 的可变性可能造成不可用,但无法授权不同的更新字节。因此仓库 immutable releases 和 Administration-read GitHub App 都不是必需条件,也不属于发布契约。

验证

  • npm run format:check
  • npm run lint
  • npm run rebuild
  • npm --workspace @maka/desktop run typecheck
  • Desktop updater/attestation 聚焦套件 — 27 项测试通过
  • npm run check:release — 110 项测试通过
  • npm run check:third-party-notices
  • git diff --check origin/main...HEAD

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex implemented the release/update contract, attestation verification, tests, workflows, documentation, and release update harnesses.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@M4n5ter
M4n5ter marked this pull request as ready for review August 27, 2026 10:21
@github-actions github-actions Bot added the effort/XL Over 1000 readable lines label Aug 27, 2026
@M4n5ter
M4n5ter force-pushed the fix/desktop-autoupdate-release-hardening branch 2 times, most recently from bfd0cc7 to 24503ff Compare August 27, 2026 13:52
@M4n5ter M4n5ter changed the title fix(release): make desktop auto-updates verifiable end to end fix(release): attest and verify desktop updates end to end Aug 27, 2026
@M4n5ter
M4n5ter force-pushed the fix/desktop-autoupdate-release-hardening branch 2 times, most recently from d0e7bb2 to fc56ed0 Compare August 27, 2026 15:07
@Astro-Han

Copy link
Copy Markdown
Contributor

Right design, and the cryptographic half is solid: the SAN regex is anchored and escaped so no other workflow can satisfy it, the issuer is strictly compared, the trust root comes from the bundled TUF seed rather than the attestation, and installUpdate refuses anything but downloaded. I couldn't construct a malformed manifest that verifyDesktopUpdateArtifacts accepts.

P1 — workflow_dispatch inputs go into a shell in the job holding id-token: write. release-cli-finalize.yml:266-276 interpolates inputs.release_run_id, release_run_attempt and version into run:; the step right above does the same value correctly via env: (:252-258). "Only a committer can dispatch" doesn't contain it — main is branch-protected, so editing the workflow needs review while dispatching doesn't. That's arbitrary execution in the job that mints the OIDC identity, reached without review. inputs.version is never format-checked here.

P2 — the attestation is signed before anything verifies it. Order in publish is download (:227-235) → actions/attest over product-release/* (:246-250) → publish-draft (:266-276), and publish-draft is where the exact-set check first runs. The step is named "Attest the verified convenience artifacts". verifyProductReleaseArtifactIntegrity gets this right internally (product-release-artifacts.mjs:123-124); the workflow undoes it. Contents are controlled today, but any future release-*-<attempt> upload silently widens the attested set, and a bad attestation is in the store before anything fails.

P2 — an env var disables verification in a signed production build, and the code claims otherwise. runtime-host-boot.ts:672 swaps the verifier for async () => {} on MAKA_UPDATE_TEST_FEED alone; isPackaged reaches only start() (app-update-service.ts:408). Reachability is low — setting env vars on someone's app already implies local control — so the real problem is that the comment above that line and the PR body both promise "production always verifies". app-update-test-context.ts:85-96 already reads a build-time makaUpdateTestProfile marker; gating on that closes it without breaking the harness. Compare e2e-fixture.ts:96.

P2 — coverage went down while the trust chain went up. product-release-authority.mjs +273 with 2 new tests; none of the eight throws in verifyDesktopUpdateArtifacts is unit-tested; every app-update-attestation.test.ts case passes verifyBundle: async () => {}, which is an if/else that skips real verification entirely, and both E2E verifiers set MAKA_UPDATE_TEST_FEED — so the real Verifier, SAN regex and issuer check execute in no test at all. Meanwhile product-release-artifacts.test.mjs was deleted and dropped from check:release and ci-test-plan.test.mjs:145, with its four negative-path assertions unreplaced while the guards they covered are still live. The checklist has "Tests cover the change and fail without it" ticked. A committed Sigstore bundle fixture would cover the part that matters.

P2 — docs contradict the code. cli-npm-release.md:43-45 (and .zh-CN.md:36-38) still say the Release is owned only by Release and that publishing the Draft follows Finalize — Finalize now publishes it. RELEASE_CHECKLIST.md also dropped the immutable-releases bullet; attestation detects mutation rather than preventing it, which is a fine trade but a different control worth stating.

Suggestion: experiment-macos-autoupdate.mjs doesn't belong here — AGENTS.md keeps experiments separate. It runs npm run clean and rm -rf apps/desktop/release against the developer's checkout (:143-147), installs a trusted code-signing root whose removal is .catch(() => {}) (:187), duplicates the packaging that package-macos-autoupdate-next.mjs owns, and is in RELEASE_CONTRACT_FILES despite no gate invoking it.

Minor: the record has no channel field, only prerelease, so the body overstates that slightly.

AI-assisted review: five subagents across the client path, workflow authority, removals, record binding and verifier scripts; I re-verified every finding above against head d0e7bb26b myself. No tests run. AI review is not independent human review.

简体中文

设计方向正确,密码学部分扎实:SAN 正则锚定且转义,别的 workflow 满足不了;issuer 严格比较;信任根来自打包的 TUF seed 而非 attestation 自身;installUpdate 只接受 downloaded。我没能构造出让 verifyDesktopUpdateArtifacts 通过的畸形 manifest。

P1 —— workflow_dispatch 输入被拼进持有 id-token: write 的 job 的 shell。 release-cli-finalize.yml:266-276 直接插值三个输入,而紧邻上一步对同一个值用的是正确的 env: 写法(:252-258)。「只有 committer 能触发」挡不住:main 有分支保护,改 workflow 要过 review,触发不用。这是在铸造 OIDC 身份的 job 里执行任意命令,且绕过 review。inputs.version 在本文件从未做过格式校验。

P2 —— attestation 在任何验证之前就签发了。 publish 的步骤顺序是 download(:227-235)→ actions/attest 覆盖 product-release/*:246-250)→ publish-draft:266-276),而 exact-set 检查首次运行是在 publish-draft 里。那个步骤名叫「Attest the verified convenience artifacts」。verifyProductReleaseArtifactIntegrity 内部顺序是对的(product-release-artifacts.mjs:123-124),是 workflow 把它反了。目前目录内容受控,但今后任何新增的 release-*-<attempt> 上传都会静默扩大被签集合,而错误的 attestation 在失败前就已进入 attestation store。

P2 —— 一个环境变量能在签名的生产构建里关掉验证,而代码声称不会。 runtime-host-boot.ts:672 仅凭 MAKA_UPDATE_TEST_FEED 就把验证器换成 async () => {}isPackaged 只到达 start()app-update-service.ts:408)。可达性低——能给别人的 app 设环境变量本身已意味着本地控制——所以真正的问题是那行上方的注释和 PR 正文都承诺「生产始终验证」。app-update-test-context.ts:85-96 已有构建期标记 makaUpdateTestProfile,改用它即可关闭且不影响 harness。可对照 e2e-fixture.ts:96

P2 —— 信任链变长,覆盖率却变低。 product-release-authority.mjs +273 行只配 2 个测试;verifyDesktopUpdateArtifacts 的八处 throw 无一有单测;app-update-attestation.test.ts 每个用例都传 verifyBundle: async () => {},而该参数是 if/else,会整个跳过真实验证,两个 E2E 验证脚本又都设了 MAKA_UPDATE_TEST_FEED——所以真实 Verifier、SAN 正则与 issuer 检查在任何测试里都没执行过。同时 product-release-artifacts.test.mjs 被整体删除并从 check:releaseci-test-plan.test.mjs:145 摘除,它那四条负路径断言无任何替代,而对应的守卫仍然活着。checklist 却勾了「Tests cover the change and fail without it」。提交一份真实 Sigstore bundle fixture 即可覆盖真正要紧的部分。

P2 —— 文档与代码矛盾。 cli-npm-release.md:43-45(及 .zh-CN.md:36-38)仍称 Release 由 Release workflow 独占、发布 Draft 是 Finalize 之后的动作,而现在 Finalize 就是发布者。RELEASE_CHECKLIST.md 还删掉了 immutable releases 那条;attestation 是「更新时检测篡改」而非「阻止篡改」,取舍合理但属于不同控制,应当写明而非静默替换。

建议: experiment-macos-autoupdate.mjs 不该放在这个 PR 里——AGENTS.md 要求实验与产品改动分开。它对开发者的真实 checkout 执行 npm run cleanrm -rf apps/desktop/release:143-147),安装受信任的代码签名根而清理是 .catch(() => {}):187),重复了 package-macos-autoupdate-next.mjs 已拥有的打包逻辑,并且在无任何 gate 调用的情况下进入了 RELEASE_CONTRACT_FILES

次要:publication record 没有 channel 字段,只有 prerelease,正文的表述略有夸大。

@M4n5ter
M4n5ter force-pushed the fix/desktop-autoupdate-release-hardening branch from fc56ed0 to 31ff980 Compare August 27, 2026 15:31
@M4n5ter

M4n5ter commented Aug 27, 2026

Copy link
Copy Markdown
Member Author
English

Thanks for the detailed review. The current head (31ff9805c) keeps the design direction and closes the findings that affect the release trust boundary:

  • Publication is now a two-phase boundary. Finalize runs verify-publication before actions/attest. That preflight verifies the exact local asset set and digests against the immutable publication record and revalidates the live Draft. publish-draft reuses the same authority immediately before mutation, so there is one verification implementation rather than two drifting copies.
  • The issued provenance is verified before publication. Finalize runs gh attestation verify for every subject in the freshly generated offline bundle, requiring SLSA provenance, the exact apache/maka/.github/workflows/release-cli-finalize.yml@refs/heads/main certificate identity, the GitHub Actions OIDC issuer, and each artifact's digest. A failed attestation cannot reach Draft publication.
  • The OIDC-capable job consumes only validated outputs. Run ID, attempt, and version are validated by the inspection job and emitted as canonical outputs; the publish job contains no raw workflow_dispatch input interpolation. The earlier P1 execution path was not reachable because the dependent inspection job rejected nonnumeric run identity and any version not equal to the strictly parsed Stage record before Environment approval, but the interpolation and that implicit dependency are gone.
  • The runbook now assigns ownership exactly: Release creates the product tag and Draft; Finalize is the sole publisher. Both languages are aligned.
  • The harness exception is stated accurately. The loopback harness serves synthetic bytes and intentionally tests updater mechanics separately from provenance. Ordinary packaged launches always use the Sigstore verifier. Gating the exception on makaUpdateTestProfile was not adopted because the candidate side is deliberately the exact, unmarked release artifact; requiring the successor-only marker would stop the real candidate update path without adding a privilege boundary. Setting the loopback environment on another user's process already requires same-user code execution.
  • The one-off macOS experiment and its CI routing were removed from the current head.

The deleted artifact micro-tests remain deleted. The integrated publication test covers the durable integrity boundary, including tampered bytes, while the workflow contract test now fixes the security-relevant order and validated-output flow. Restoring per-throw and staging implementation tests would add maintenance without strengthening the merge decision.

The publication record also does not add a second channel field: its exact validated version uniquely derives stable/alpha/beta, so storing both would create duplicate state. Immutable GitHub Releases remain unnecessary to authorization: transport mutation can make an update unavailable, but cannot make different bytes pass the protected-workflow identity and digest checks.

Validation: rebuild, format, lint, Desktop typecheck, the focused release tests, and check:release (110 tests) pass locally.

简体中文

感谢详细审查。当前 head(31ff9805c)保留整体设计方向,并关闭会影响发布信任边界的 findings:

  • 发布现在是两阶段边界。 Finalize 在 actions/attest 前执行 verify-publication,根据 immutable publication record 验证本地精确资产集合与 digest,并重新验证 live Draft。publish-draft 在 mutation 前复用同一个 authority,不存在两套可能漂移的校验实现。
  • 发布前验证新签发的 provenance。 Finalize 对离线 bundle 中的每个 subject 执行 gh attestation verify,要求 SLSA provenance、精确的 apache/maka/.github/workflows/release-cli-finalize.yml@refs/heads/main certificate identity、GitHub Actions OIDC issuer,以及每个产物的 digest。验证失败的 attestation 无法进入 Draft publication。
  • 持有 OIDC 权限的 job 只消费已验证输出。 Inspection job 验证 run ID、attempt 和 version,并输出 canonical values;publish job 不再插值任何原始 workflow_dispatch input。原 P1 所描述的执行路径并不可达:依赖的 inspection job 会在 Environment approval 前拒绝非数字 run identity,以及任何不等于严格解析 Stage record 的 version;现在连这种隐式前置关系也已删除。
  • Runbook 的 ownership 已精确对齐: Release 创建产品 tag 与 Draft,Finalize 是唯一发布者;中英文一致。
  • Harness 例外已准确表述。 Loopback harness 使用合成字节,因此有意将 updater 机制与 provenance 分开验证;普通打包应用始终使用 Sigstore verifier。没有采用 makaUpdateTestProfile gate,因为 candidate 端刻意使用精确、无 marker 的 release artifact;要求 successor-only marker 会直接破坏真实 candidate 更新路径,却不会增加新的权限边界。给其他用户的进程设置该 loopback 环境本身已要求同用户代码执行权限。
  • 当前 head 已删除一次性 macOS experiment 及其 CI routing。

已删除的 artifact 微型测试不会恢复。集成 publication 测试覆盖持久完整性边界,包括字节篡改;workflow contract 测试现在固定安全相关的执行顺序和已验证输出传递。为每个 throw 或 staging 实现细节恢复测试只会增加维护成本,不会增强合并判断。

Publication record 也不会增加第二个 channel 字段:精确且已验证的 version 可以唯一推导 stable/alpha/beta;同时保存两者会形成重复状态。Immutable GitHub Releases 仍不是授权所必需:传输层 mutation 可能使更新不可用,但无法让不同字节通过受保护 workflow identity 与 digest 校验。

本地已通过 rebuild、format、lint、Desktop typecheck、聚焦 release 测试和 check:release(110 项测试)。

@M4n5ter
M4n5ter requested a review from Astro-Han August 27, 2026 15:33

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at head 31ff9805c.

First, a correction to my own grading. I filed the raw workflow-input interpolation in the OIDC-capable publish job as P1, and you are right that the path was not reachable: validateStageRun (release-cli-publication.mjs:130) rejects any expectedVersion that differs from the checked-in release record, and the run identity is regex-gated to a positive integer before the inspection job emits anything. A version carrying shell metacharacters cannot equal a record value the attacker does not control, so the dependent job fails first. That is a multi-premise path, which puts it at P2 by my own rubric, not P1. The finding was still worth fixing - its safety rested on a non-local argument about a different job, so a future edit to inspect could have opened it silently - and the fix you shipped is better than what I asked for: consuming needs.inspect.outputs.* rather than merely quoting the raw inputs.

Verified at this head:

  • Ordering is now correct. verify-publication (:263) runs before actions/attest (:275), not after, so the exact-set and digest check against the immutable record happens before anything is signed.
  • The issued provenance is verified before publication, which I did not ask for and which materially strengthens the boundary. gh attestation verify (:287) pins an exact --cert-identity string rather than a pattern, pins the Actions OIDC issuer, iterates every file under product-release, and fails explicitly when nothing was verified - closing the empty-find hole that would otherwise pass silently.
  • experiment-macos-autoupdate.mjs is gone. The remaining package-macos-autoupdate-next.mjs and verify-macos-autoupdate.mjs are the macOS counterparts of the Windows path and belong here.

The three you declined, I accept:

  • The MAKA_UPDATE_TEST_FEED exception. Your point that gating on makaUpdateTestProfile would break the real candidate path - because the candidate is deliberately the exact unmarked release artifact - is the argument I did not have. Combined with the fact that setting that variable already requires same-user code execution, there is no privilege boundary to gain.
  • Not restoring the deleted micro-tests. The integrated publication test covers the durable integrity boundary including tampered bytes, and the workflow contract test now pins the security-relevant ordering. Per-throw tests would be maintenance without changing the merge decision.
  • No second channel field. The validated version already derives the channel uniquely; storing both would be duplicate state.

AI-assisted review disclosure: Claude Code re-read the finalize workflow and the publication authority at this head; I verified the step ordering, the certificate-identity pinning, the version-validation chain that makes the original P1 unreachable, and the experiment removal against current main myself.

@M4n5ter
M4n5ter merged commit 3e98eb2 into main Aug 28, 2026
17 checks passed
@M4n5ter
M4n5ter deleted the fix/desktop-autoupdate-release-hardening branch August 28, 2026 00:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants