Repository navigation
feat(release-verify): add @cloudhal/release-verify (RFC-0046 §11a, #227) - #232
Merged
Merged
Conversation
The one verifier of Cloudhal release bundles, Worker-safe: - verifyRelease runs RFC-0046 §2's six steps in order (sizes, Ed25519 with a trusted key, the request binding, both precompiled validators and the invariants, the manifest's digest and listings, the tarball's digest). entries() then streams the tarball a second time and yields each entry only once its path is safe and its size and SHA-256 match its listing. - entry-path refuses empty, . and .. segments, backslashes, control characters, overlong paths and case-fold duplicates. It is a linear scanner with no regex (rule 13). - 19 stable error codes; PINNED_KEYS (frozen, equal to the key doc, trust can only be reduced); missingScopes with §3's admin-covers-edit rule; releaseAssetUrls for the five assets. - ./attestation (Node only): probes for gh 2.68.0 or newer with every flag and a signed-in session, else "unavailable"; runs the exact §11a command with --bundle; any non-zero exit, signal or timeout is "failed". - Runtime dependencies: @noble/hashes (when there is no DigestStream), plus the ajv runtime helpers and ajv-formats that the generated validators import. The validators come from the package's own gen:ts (after core-contracts', via turbo ^gen:ts) and are drift-checked by a test, by the CI contract-drift job and by pnpm drift. - Tests: the committed real v0.9.0 statement, signature and manifest, and synthetic bundles signed with a throwaway key. The opt-in real-tarball test runs in a new CI job that downloads v0.9.0. - The package joins the fixed group (PACKAGES, tsconfig.build.json, .changeset/config.json), with a changeset. - scripts/release-bundle/verify.ts runs it for signed bundles. Refs #227 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
- One byte rule for tar names. The ustar name field is the longest
suffix of at most 100 UTF-8 bytes, cut on a code-point boundary
(ustarName). The PAX record is written exactly when needsPax says so.
The producer imports both from the package, and the verifier compares
raw bytes. Long non-ASCII names cut on and between character
boundaries now verify.
- Release planner: a group package that npm has never released (nothing,
or only the placeholder) and whose changelog has no "## <version>"
entry is left out of the release and logged. main stays green after
this merge and nothing publishes release-verify 0.9.0, dry runs
included. A missing CHANGELOG.md reads as no entry.
- The producer runs the consumer's rules. The unsigned self-check runs
checkManifestDoc and the second-pass entry reader, and publish-ci runs
the full verifyRelease before it creates or uploads anything (loaded
lazily: the plan job has no install).
- The second pass closes its stream chain on every exit: the end, a
refusal, or a consumer that stops early. Errors from the source pass
through unchanged.
- Low items:
- missingScopes uses Object.hasOwn;
- attestation prints "exit N" and passes gh absolute paths;
- foldPath is NFC, lower, upper, lower, NFC;
- the starter part limit and mainModule (a safe path and a listed
module) are checked;
- new tests for invariants 1 and 4 and one-sided fact changes;
- the key doc states what each call covers;
- test file references are fixed.
Refs #227
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
This adds the
@cloudhal/release-verifypackage, as the accepted design in RFC-0046 §11a describes. It is the first of the two #227 PRs.cloudhal deploy --releasecomes in a second PR after this one merges, and it stays unshippable until #226's signedkeys.jsonexists (§11b).The package (
packages/release-verify)DecompressionStream("gzip"), and an incremental SHA-256: Workers' nativecrypto.DigestStream, otherwise@noble/hashes2.4.0, pinned in the catalog. A test walks the root entry's import graph and fails on anynode:import. Only./attestationuses Node.@noble/hashes,ajv(onlydist/runtime/equalanducs2length) andajv-formats(itsformatsmodule). Nothing constructsAjv. A test fails when the generated validators import anything else, or containnew Functionoreval.release-manifestandrelease-statement, plus their contract types, insrc/generated/.gen:ts(scripts/gen-validators.ts).gen:tsnow hasdependsOn: ["^gen:ts"], so the package's generator runs after core-contracts'.test/package.test.ts, by the CI contract-drift job, and bypnpm drift.@cloudhal/core-contractsonly as a dev dependency, for generation.verifyReleaseruns §2's six steps in order, then a two-pass tarball read:entries()streams the second pass. It yields an entry only after the entry's path is safe and its size and SHA-256 equal its listing. It refuses unlisted entries and §7 format violations, and, at the end, missing entries.entry-path. Every manifest listing is checked before the tarball is read, and every tar name before its entry is used. Refused: empty,.and..segments, a backslash, control characters, a leading/(assets need exactly one), more than 512 bytes, and case-fold duplicates within one Worker's modules or assets (NFC, then upper-casing and lower-casing). The check is a single pass plussplit("/"), with no regex (rule 13). A test runs it on 100k characters.ERROR_CODES, 19 of them), as §11a lists them.key-expiredis deliberately absent: it waits for Release signing phase 2: signed keys.json and yanked.json, key expiry, KMS custody (RFC-0046 §9) #226 (§11c point 5).PINNED_KEYSholdscloudhal-release-2026a. It is frozen and kept equal to the key doc's table by a test. Trust can only be reduced:revoked: [...]withdraws a key, and passing a wholekeysmap is the only way to trust a key outside the table.missingScopescompares exactly, with §3's single rule:workers-scripts.admincoversworkers-scripts.edit.releaseAssetUrlsgives all five assets, including the Sigstore bundle. The version must match a bounded, anchored pattern, so it cannot change the URL path../attestation(Node only) works in three stages:gh --versionmust be 2.68.0 or newer, every flag must appear ingh attestation verify --help, andgh auth statusmust pass. Otherwise the result isunavailable, with the required version named.--bundlewhen the Sigstore asset is present.failed.Elsewhere
scripts/release-bundle/verify.ts: a signed bundle now also passesverifyReleaseentry by entry, so consumer verification has one implementation (§11a). The producer's own checks are unchanged.scripts/build-packages.tsPACKAGES, totsconfig.build.json, and to the.changeset/config.jsonfixedgroup. ItspublishConfigcomes from--write-publish-config.release-plan.test.tsnow expects 14 packages.scripts/build-packages.ts: the generator is inDEV_ONLY, so it never ships.scripts/code-size/measure.ts: ignorespackages/release-verify/src/generated/, as it already does core-contracts' generated code.release-verify-package.md.release-verify-v0-9-0job runsgh release download v0.9.0and then the opt-intest/real-tarball.test.ts(CLOUDHAL_RELEASE_ASSETS). Ordinary test runs skip it and never use the network.biome.json: excludestest/fixtures/v0.9.0, and a.gitattributesthere sets-text. The real statement, signature and manifest (about 250 KB) are byte-exact signed data, so nothing may reformat or re-encode them.AGENTS.mdlayout entry, a README, and a pointer fromdocs/security/release-signing-keys.mdto the package.Tests (run locally, one at a time)
packages/release-verify:pnpm exec vitest runpassed 52, with 1 skipped (the opt-in test). The suites:verify-real: the committed v0.9.0 files pass steps 1 to 5 with the pinned key. These are refused:revoked;planVersionabove what the caller supports;verify-synthetic: synthetic bundles, written by the producer's own tar and gzip code and signed with a throwaway key, cover:manifest.sigform;entry-pathrule, in listings and in tar names, refused before the tarball is read;crypto: both SHA-256 paths (noble, and theDigestStreamadapter with a stand-in shaped like workerd's) against the NIST vectors, including a millionas, and againstnode:cryptoon random splits. Ed25519 is checked against node:crypto signatures.tar: more than 30,000 entries; the decompression cap; corrupt and truncated gzip; every source form;admitrunning before an entry is buffered; and a 100k adversarial run.attestation: a scriptedghcovering the exact arguments,--bundle, everyunavailableandfailedpath, and malformed input.package: the key doc, the producer's limits and names, generated-file drift, the validators' imports, Worker-safety,missingScopesandreleaseAssetUrls.Also run locally:
real-tarball.test.tspassed, and every listed part verified.gh2.102.0:verifyAttestationon the real v0.9.0 assets returnedverified. With a wrong commit it returnedfailed, andgh's error message became the reason.packages/cli:release-bundle-verify9/9,release-bundle18/18,release-bundle-publish8/8,release-bundle-sign3/3,release-plan6/6,packaging10/10.node scripts/build-packages.ts --checkis ok.pnpm build:packagesbuilt every package, and every publishedexports/bintarget resolves. The new package'sdistholds the generated validators.pnpm size:checkis ok.pnpm lintreports no errors.Review round 1 (aaf9a12)
ustarNameis the longest suffix of at most 100 UTF-8 bytes, cut on a code-point boundary. A PAX record is written exactly whenneedsPaxsays so.scripts/release-bundle/tar.tsimports them. The verifier compares raw name bytes, refuses a PAX record that no name needs, and refuses a PAX header that is not././@PaxHeader.releaseGroup).0.0.0-placeholder.*) and whose changelog has no## <version>entry is left out and logged. A missing CHANGELOG.md reads as no entry. Packages npm has already released are never left out.release-plan.test.tsbuilds this exact state: 14 packages at 0.9.0, and release-verify with no changelog and nothing (or the placeholder) on npm. It showspublish=false, and that aworkflow_dispatchdry run never includes the package. It also covers the state after the version PR, where the package is released.node scripts/publish-ci.ts --planon this branch against live npm logs the left-out package and printspublish=false. WithRELEASE_DRY_RUN=trueit printspublish=truewithout the package.verifyBundle, run byrelease-bundle.ts build) now runscheckManifestDocand the second-pass entry reader: paths, case folding,mainModule, limits and tar rules.publish-ciruns the fullverifyReleaseon the signed bundle before it creates or uploads anything. The package is loaded lazily, because theplanjob runs without an install.bundle-signscripts can't import the package (that job has no checkout), so they stay as they are./favicon.svgplus/FAVICON.svg, a path over 512 bytes, and a..segment each fail the unsigned self-check.publish-ci's consumer check accepts the signed bundle and refuses a wrong key.DecompressionStream. Every exit cancels it and releases the caller's stream directly: the end, a refusal, or an early stop.readTarcloses its source infinally.breakand after anentry-mismatch. Both tests fail with the cleanup removed. An I/O error in pass 2 comes out as the same error object.missingScopesusesObject.hasOwn(tested withconstructor,__proto__andtoString).exit Nwhen stderr is empty, and passes absolute paths togh(tested).foldPathis now NFC, lower, upper, lower, NFC (tested onẞ/ß/SS,ΐ/Ϊ́, and composed/decomposed é).starter.bytesmust be within the part limit, andmainModulemust be a safe path and a listed module.tarball.name), and one-sidedchannel,securityReleaseandcreatedAtchanges.verifyReleaseandverifyAttestationeach cover, and thatgh release verify-assetstays manual.keys.tsandlimits.tsnow name the right test file.Tests, run locally one at a time:
packages/release-verify: 62 passed, 1 skipped (opt-in). The opt-in real v0.9.0 tarball test passed again.packages/cli:release-bundle-verify12,release-bundle18,release-bundle-publish9,release-bundle-sign3,release-plan9,release-publish-plan17,packaging10,release-workflows14.pnpm lintshows no errors, andsize:checkandbuild-packages --checkare ok.Owner steps (required before the package's first version PR is merged)
@cloudhal/[email protected]by hand, with only a README and--tag placeholder. Then runnpm deprecate @cloudhal/[email protected] "placeholder; install a released version".planPublishwould skip it from then on.@cloudhal/release-verify: repositorycloudhal-dev/cloudhal, workflowrelease.yml, environmentnpm-publish, the same as every other package in the group.release.ymlwith--provenance, together with the rest of the fixed group. Nothing in this PR publishes anything.Refs #227 (the issue stays open for
cloudhal deploy --release)🤖 Generated with Claude Code