From bf39a4f57cd456aff36a03806fe924cb989ad3fd Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 19 Oct 2026 20:09:54 -0400 Subject: [PATCH 1/2] feat(release): sign each platform tarball under its own domain (contract v1.1, P7-PLUG-83) sign.sh takes an optional PLATFORM and signs nself-plugin-bin-v1; the sign job signs the platform tarballs of every signed plugin, only those this run built (platform-built-check.sh); verify-signatures.sh and verify-published-checksums.sh carry platform_signatures; cli tools pin moved to the P7-PLUG-85 merge. --- .github/wiki/Releasing.md | 34 ++- .github/workflows/release-tarballs.yml | 78 +++++- schemas/SOURCE | 2 +- .../tests/test_verify_published_checksums.py | 129 ++++++++- scripts/cli-tools.version | 2 +- scripts/release/platform-built-check.sh | 56 ++++ scripts/release/platform-signatures-test.sh | 262 ++++++++++++++++++ scripts/release/rehearse.sh | 31 ++- scripts/release/sign.sh | 30 +- .../release/testdata/platform-v1/README.md | 10 + .../platform-v1/demo-1.0.0-linux-amd64.tar.gz | Bin 0 -> 135 bytes .../testdata/platform-v1/demo-1.0.0.tar.gz | Bin 0 -> 119 bytes .../release/testdata/platform-v1/fixture.pub | 3 + .../testdata/platform-v1/releases.json | 24 ++ .../release/testdata/platform-v1/vectors.json | 54 ++++ scripts/release/verify-signatures.sh | 75 +++-- scripts/verify-published-checksums.sh | 41 ++- 17 files changed, 765 insertions(+), 66 deletions(-) create mode 100755 scripts/release/platform-built-check.sh create mode 100755 scripts/release/platform-signatures-test.sh create mode 100644 scripts/release/testdata/platform-v1/README.md create mode 100644 scripts/release/testdata/platform-v1/demo-1.0.0-linux-amd64.tar.gz create mode 100644 scripts/release/testdata/platform-v1/demo-1.0.0.tar.gz create mode 100644 scripts/release/testdata/platform-v1/fixture.pub create mode 100644 scripts/release/testdata/platform-v1/releases.json create mode 100644 scripts/release/testdata/platform-v1/vectors.json diff --git a/.github/wiki/Releasing.md b/.github/wiki/Releasing.md index c5461151a..57620f498 100644 --- a/.github/wiki/Releasing.md +++ b/.github/wiki/Releasing.md @@ -31,25 +31,39 @@ the chain uses the workflow's own `GITHUB_TOKEN` plus scoped repository secrets: | Job | Permissions | Secrets | Does | |---|---|---|---| | `build` | contents read | none | deterministic source and per-platform tarballs (`scripts/deterministic-tar.sh`) | -| `publish` | contents write, id-token write, attestations write | Cloudflare R2 (R2 step only) | uploads missing tarballs to the Release (never `--clobber`), re-downloads the published bytes, writes the `.sha256` files from them, mirrors those bytes to R2 and reads each object back, attests build provenance for the tarballs this run built, records the published sha256 list (also in the job summary) | -| `sign` | contents read | free-tier signing key (signing step only) | bound to the `release-signing` Environment; downloads what the Release serves, refuses it unless it equals the list `publish` recorded, signs the source tarballs; runs the tag's own commit of `sign.sh` | +| `publish` | contents write, id-token write, attestations write | Cloudflare R2 (R2 step only) | uploads missing tarballs to the Release (never `--clobber`), re-downloads the published bytes, writes the `.sha256` files from them, mirrors those bytes to R2 and reads each object back, attests build provenance for the tarballs this run built, records the published sha256 list (also in the job summary) and the list of tarballs this run built (`built-sha256.txt`) | +| `sign` | contents read | free-tier signing key (signing step only) | bound to the `release-signing` Environment; downloads what the Release serves, refuses it unless it equals the list `publish` recorded, signs the source tarballs and, under their own domain, the platform tarballs of every plugin it signed (only tarballs this run built); runs the tag's own commit of `sign.sh` | | `release-data` | contents write, pull-requests write | none | writes `releases.json` from the published bytes and the signatures, opens the release-data PR | Checksums and signatures always describe the bytes on the Release, never a rebuild. Gzip output differs between machines, so an asset that is already on the Release stays the artifact of record and everything downstream follows it. -### Signature format (contract `plugins.release-signature` v1, hq PLUG D7) +### Signature format (contract `plugins.release-signature` v1.1, hq PLUG D7) - Message: `nself-plugin-v1\n\n\n\n`, signed with raw Ed25519 (`openssl pkeyutl -sign -rawin`). - `release_signature` in `releases.json` and the projections: `{"key_id": "plugins-<16 hex>", "alg": "ed25519", "sig": ""}`. `sig` is the 64-byte signature in standard padded base64 (the text `sdk/go/signing` `EncodeSig` produces); `verify-signatures.sh` accepts only that canonical text. `key_id` is derived, never chosen: `plugins-` plus the first 16 hex of sha256 over the raw 32-byte public key. `sign.sh` refuses a key id that does not match its key. -- Only the source tarball `-.tar.gz` is signed. The per-platform binary tarballs are covered by `platform_checksums` (in the unsigned - `releases.json`) and by the provenance attestation; verify them with `gh attestation verify --repo nself-org/plugins`. Only bytes this run built +- The source tarball `-.tar.gz` is signed under the message above. Since v1.1 each platform (binary) tarball + `--.tar.gz` is signed too, under its own domain: `nself-plugin-bin-v1\n\n\n\n\n`, the same raw Ed25519 and the same tier key. `` is one of `darwin-amd64`, `darwin-arm64`, `linux-amd64`, `linux-arm64`, + `windows-amd64`. The first lines differ, so a source signature never verifies as a platform signature and the reverse. `releases.json` rows carry + `platform_signatures` `{"": {"key_id", "alg", "sig"}}` next to `platform_checksums`. A platform signature covers slug, version, platform and the tarball + bytes; it does not cover the registry's `binaryName`, `pluginType` or `repository` fields, so a hostile registry can choose which signed tarball of a slug and + version installs, never unsigned bytes. The provenance attestation (`gh attestation verify --repo nself-org/plugins`) stays as a second check. Only bytes this run built and the Release still serves unchanged are attested: a tarball uploaded earlier (a draft upload, an older run) is published and checksummed but carries no attestation, and the `publish` job summary says which. - `verify-signatures.sh` counts source tarballs (checksum + signature) and binary tarballs (checksum only) separately. + `verify-signatures.sh` counts source tarballs and binary tarballs separately, each checked for checksum and signature; on a signed row a binary tarball with a + checksum and no platform signature fails (`unsigned platform`). +- The `sign` job signs a platform tarball only if its ` ` line is in `built-sha256.txt`, the list `publish` wrote of the tarballs this run built + (`scripts/release/platform-built-check.sh`). The publish job never uses `--clobber`, so a tarball already on the Release wins over this run's build; without + this check anyone with `contents: write` could upload a binary while the reviewer decides and receive the official platform signature. A refusal fails the job + with nothing more signed: delete the foreign or draft-uploaded platform asset from the Release and re-run, so this run's build is the one published. +- `release-data` writes a platform signature into `releases.json` only when the signed sha256 equals the sha256 of the published bytes in that run; a published + platform tarball with no such signature stops the write (`UNSIGNED`), so a release never lands half-signed. Rewriting an existing row keeps a platform signature only + while the row's version, source sha256 and that platform's sha256 are all unchanged. +- The licensed tier has no platform producer (bundles serves source through ping); a pinned licensed tier keeps refusing platform packages (debt D-1286). - A source tarball is signed only when its plugin's manifest `version` equals the tag's version; the signed set is exactly the set of rows `release-data` writes. Both scripts accept Ed25519 keys only and refuse anything else. - Verification order is checksum, then signature, both before extraction. @@ -58,9 +72,11 @@ asset that is already on the Release stays the artifact of record and everything | Script | Use | |---|---| -| `sign.sh SLUG VERSION TARBALL` | prints the `release_signature` JSON. Key from `NSELF_RELEASE_SIGNING_KEY_FREE` (PKCS8 PEM) and `NSELF_RELEASE_SIGNING_KEY_FREE_ID`, environment only; written to a 0600 temp file removed on exit | -| `verify-signatures.sh --pub FILE DIR` | checks every `*.tar.gz` in DIR against `DIR/releases.json` and exactly the public key FILE (PEM SPKI). Failures on stderr: `checksum mismatch`, `missing release data`, `missing tarball`, `unsigned`, `signature mismatch`. No key discovery | -| `rehearse.sh --tag T --no-upload --out DIR` | builds all source tarballs and signs them with a throwaway key; writes `DIR/releases.json` and `DIR/rehearsal-test.pub`. Two runs of one tag give byte-identical tarballs | +| `sign.sh SLUG VERSION TARBALL [PLATFORM]` | prints the `release_signature` JSON (with `PLATFORM`, the platform-domain signature for that platform tarball; a fifth argument or an unknown platform is exit 2). Key from `NSELF_RELEASE_SIGNING_KEY_FREE` (PKCS8 PEM) and `NSELF_RELEASE_SIGNING_KEY_FREE_ID`, environment only; written to a 0600 temp file removed on exit | +| `verify-signatures.sh --pub FILE DIR` | checks every `*.tar.gz` in DIR against `DIR/releases.json` and exactly the public key FILE (PEM SPKI). Failures on stderr: `checksum mismatch`, `missing release data`, `missing tarball`, `unsigned`, `signature mismatch`, `unsigned platform`, `platform signature mismatch`, `orphan platform signature`. No key discovery | +| `platform-built-check.sh BUILT_LIST DIR` | exit 0 only if every platform tarball in DIR has its exact ` ` line in BUILT_LIST (the sign job's gate); `not built by this run: ` otherwise | +| `platform-signatures-test.sh` | offline gate: platform signatures accepted only for their slug, version, platform and bytes; built-check cases; the committed fixture `testdata/platform-v1/` (throwaway key, made by `sign.sh`) for the cli verifier. `--regen-testdata` rebuilds the fixture | +| `rehearse.sh --tag T --no-upload --out DIR` | builds all source tarballs and signs them with a throwaway key, plus fixture binary tarballs (stub payload, all five platforms) for the first slug; writes `DIR/releases.json` and `DIR/rehearsal-test.pub`. Two runs of one tag give byte-identical tarballs | | `keys/` | committed release public keys (`plugins-release-free-.pub`). Empty until P7-PLUG-69 commits the production key, which must happen before the first real tag run | OpenSSL 3 is required (`OPENSSL=/path/to/openssl` to pick one); macOS system LibreSSL cannot sign or verify Ed25519 with `-rawin`. diff --git a/.github/workflows/release-tarballs.yml b/.github/workflows/release-tarballs.yml index dc7497167..fb8a7812a 100644 --- a/.github/workflows/release-tarballs.yml +++ b/.github/workflows/release-tarballs.yml @@ -4,13 +4,14 @@ name: Release Plugin Tarballs (Free) # build deterministic source + per-platform tarballs (scripts/deterministic-tar.sh); no secrets, read-only token. # publish uploads the missing tarballs to the GitHub Release (never --clobber), writes the .sha256 files from the PUBLISHED # bytes, mirrors those bytes to Cloudflare R2 and attests build provenance for the tarballs this run built and the -# Release serves unchanged; records the published sha256 list the signer is held to. Holds the R2 secrets, never the -# signing key. +# Release serves unchanged; records the published sha256 list the signer is held to and the sha256 list of the +# tarballs THIS run built (built-sha256.txt). Holds the R2 secrets, never the signing key. # sign bound to the protected Environment `release-signing` (required reviewer): the only job that can read the free-tier # signing key (NSELF_RELEASE_SIGNING_KEY_FREE + _ID). Downloads what is on the Release, refuses it unless it is -# byte-for-byte what publish recorded, and signs the source tarballs per hq PLUG D7 (scripts/release/sign.sh). The -# licensed key is never here. Runs the tag's own commit of sign.sh, the code the reviewer approved. -# release-data re-downloads the published bytes, writes sha256 + release_signature into releases.json +# byte-for-byte what publish recorded, and signs the source tarballs per hq PLUG D7 (scripts/release/sign.sh) and, under +# their own domain (contract plugins.release-signature v1.1), the platform tarballs of every signed plugin, but only +# tarballs this run built (scripts/release/platform-built-check.sh). The licensed key is never here. Runs the tag's own commit of sign.sh, the code the reviewer approved. +# release-data re-downloads the published bytes, writes sha256 + release_signature + platform_signatures into releases.json # (scripts/verify-published-checksums.sh --write --signatures) and opens the release-data PR with the workflow's own # GITHUB_TOKEN. No PAT, no cross-repo trigger. The projections (registry.json, catalog.json, counts.json) need the # private bundles repo, which this token cannot read: the PR body tells the maintainer to regenerate them. @@ -394,13 +395,21 @@ jobs: # What the reviewer of the `sign` job approves: the sha256 of every tarball the Release serves right now. `sign` refuses any # tarball that is not in this list with this hash, so an asset swapped while the approval waits is never signed. + # + # built-sha256.txt is the shorter list the platform signer is held to: the tarballs this run built that the Release serves + # unchanged (attest/). A platform tarball is signed only if its " " line is in it, so a binary uploaded to the + # Release by anyone else during the approval wait is published but never signed. - name: Record the published checksums run: | (cd published && sha256sum -- *.tar.gz) > published-sha256.txt + : > built-sha256.txt + if compgen -G 'attest/*.tar.gz' >/dev/null; then + (cd attest && sha256sum -- *.tar.gz) > built-sha256.txt + fi { echo "### Published tarballs for ${TAG}" echo - echo "$(wc -l < published-sha256.txt | tr -d ' ') tarball(s) on the Release; $(wc -l < not-attested.txt | tr -d ' ') differ from this run's rebuild (published, not attested)." + echo "$(wc -l < published-sha256.txt | tr -d ' ') tarball(s) on the Release; $(wc -l < not-attested.txt | tr -d ' ') differ from this run's rebuild (published, not attested); $(wc -l < built-sha256.txt | tr -d ' ') built by this run and eligible for platform signing." echo echo '```' cat published-sha256.txt @@ -418,7 +427,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: published-sha256 - path: published-sha256.txt + path: | + published-sha256.txt + built-sha256.txt retention-days: 30 if-no-files-found: error @@ -549,10 +560,11 @@ jobs: (cd published && sha256sum -- *.tar.gz) | sort > actual-sha256.txt sort expected/published-sha256.txt | diff - actual-sha256.txt || { echo "ERROR: the Release no longer serves the bytes the publish job recorded; nothing is signed"; exit 1; } - # Source tarballs only (-.tar.gz): the signed message names slug, version and the source tarball's sha256. - # Binary tarballs (--.tar.gz) are covered by platform_checksums and, when this run built them, the - # provenance attestation made in the publish job. - - name: Sign the published source tarballs (hq PLUG D7) + # Source tarballs (-.tar.gz): the signed message names slug, version and the source tarball's sha256. + # Platform tarballs (--.tar.gz) of every plugin signed here are signed under their own domain + # (contract v1.1): the message names slug, version, platform and that tarball's sha256. They are signed only if + # platform-built-check.sh confirms this run built the exact bytes; otherwise the job fails and signs nothing more. + - name: Sign the published source and platform tarballs (hq PLUG D7, contract v1.1) env: NSELF_RELEASE_SIGNING_KEY_FREE: ${{ secrets.NSELF_RELEASE_SIGNING_KEY_FREE }} NSELF_RELEASE_SIGNING_KEY_FREE_ID: ${{ secrets.NSELF_RELEASE_SIGNING_KEY_FREE_ID }} @@ -586,12 +598,54 @@ jobs: rm -f signatures.ndjson echo "Signed ${count} source tarball(s)" + # Platform tarballs of the signed plugins only (a plugin left unsigned above gets none). Every one must be a tarball this + # run built: the Release never takes a clobbering upload, so an asset put there by anyone else during the approval wait + # is already published, and signing it would hand it the official platform signature. + platforms="darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 windows-amd64" + bin_stage="$(mktemp -d)" + for slug in $(jq -r 'keys[]' signatures.json); do + for platform in $platforms; do + f="published/${slug}-${VERSION}-${platform}.tar.gz" + [ -e "$f" ] || continue + ln -s "${PWD}/${f}" "$bin_stage/" + done + done + if ! bash scripts/release/platform-built-check.sh expected/built-sha256.txt "$bin_stage"; then + echo "ERROR: a platform tarball on the Release was not built by this run; nothing is signed. Delete the foreign asset and re-run the release (Releasing.md)." + exit 1 + fi + : > platform-signatures.ndjson + pcount=0 + for slug in $(jq -r 'keys[]' signatures.json); do + for platform in $platforms; do + f="published/${slug}-${VERSION}-${platform}.tar.gz" + [ -e "$f" ] || continue + psig="$(bash scripts/release/sign.sh "$slug" "$VERSION" "$f" "$platform")" + psha="$(sha256sum "$f" | cut -d' ' -f1)" + jq -nc --arg s "$slug" --arg p "$platform" --arg sha "$psha" --argjson sig "$psig" \ + '{($s): {platform_signatures: {($p): ($sig + {sha256: $sha})}}}' >> platform-signatures.ndjson + pcount=$((pcount + 1)) + done + done + jq -sS 'reduce .[1:][] as $r (.[0]; . * $r)' signatures.json platform-signatures.ndjson > signatures.merged.json + mv signatures.merged.json signatures.json + rm -f platform-signatures.ndjson + echo "Signed ${pcount} platform tarball(s)" + # Once a release public key is committed (P7-PLUG-69 commits the first), only a committed key may have signed. + # The stage mirrors releases.json: platform_checksums from the covered sha256, platform_signatures without it. stage="$(mktemp -d)" - jq --arg v "$VERSION" 'map_values(. + {version: $v}) | {releases: .}' signatures.json > "$stage/releases.json" + jq --arg v "$VERSION" 'map_values(. + {version: $v} + + (if has("platform_signatures") then {platform_checksums: (.platform_signatures | map_values(.sha256)), + platform_signatures: (.platform_signatures | map_values(del(.sha256)))} else {} end)) + | {releases: .}' signatures.json > "$stage/releases.json" for slug in $(jq -r 'keys[]' signatures.json); do ln -s "${PWD}/published/${slug}-${VERSION}.tar.gz" "$stage/" done + for f in "$bin_stage"/*.tar.gz; do + [ -e "$f" ] || continue + ln -s "$(readlink "$f")" "$stage/" + done pinned=0 ok=0 for k in scripts/release/keys/plugins-release-free-*.pub; do diff --git a/schemas/SOURCE b/schemas/SOURCE index bdc358e0c..a18790e3b 100644 --- a/schemas/SOURCE +++ b/schemas/SOURCE @@ -1,5 +1,5 @@ # Written by scripts/conformance/sync-schema.sh. Do not edit. repo=https://github.com/nself-org/cli -sha=6fe7bc673b1dbe95db3e92464a9f395772e7ee3a +sha=5b214db6008d4991144b639bfc7a8beda6b83d8c path=schemas/plugin-manifest.v2.schema.json sha256=d530b7e60260f804fb6f6c823a236d43bbb519d432afc1885074832f40435e0c diff --git a/scripts/ci/tests/test_verify_published_checksums.py b/scripts/ci/tests/test_verify_published_checksums.py index 83677c013..c5e8802c5 100644 --- a/scripts/ci/tests/test_verify_published_checksums.py +++ b/scripts/ci/tests/test_verify_published_checksums.py @@ -58,9 +58,9 @@ def setUp(self): gh.chmod(0o755) self.exception() - def archive(self, name): + def archive(self, name, marker=""): with tarfile.open(self.assets / name, "w:gz") as archive: - data = b"fixture bytes\n" + data = b"fixture bytes\n" + marker.encode() entry = tarfile.TarInfo("nself-demo") entry.size = len(data) archive.addfile(entry, io.BytesIO(data)) @@ -72,15 +72,46 @@ def exception(self, rule="cli-targets", plugin="demo", expires=None): f" reason: fixture\n owner_ref: TEST\n expires: {expires}\n") (self.root / "scripts/conformance/exceptions.yaml").write_text(text) - def verify(self): + def sha(self, name): + return hashlib.sha256((self.assets / name).read_bytes()).hexdigest() + + def sign_platforms(self, platforms=PLATFORMS, sha=None): + """Add a platform signature per platform to the signatures file; sha overrides the covered hash.""" + doc = json.loads(self.signatures.read_text()) + doc["demo"]["platform_signatures"] = { + p: {"sha256": sha or self.sha(f"demo-1.0.0-{p}.tar.gz"), + "key_id": "fixture", "alg": "ed25519", "sig": f"fixture-{p}"} + for p in platforms + } + self.signatures.write_text(json.dumps(doc)) + + def verify(self, signed=True): env = dict(os.environ, PATH=f"{self.bin}{os.pathsep}{os.environ['PATH']}", FIXTURE_ASSETS=str(self.assets), CONF_TODAY="2026-10-14") + args = ["--write", "--signatures", str(self.signatures)] if signed else ["--write"] return subprocess.run( - ["bash", str(self.root / "scripts/verify-published-checksums.sh"), - "--write", "--signatures", str(self.signatures), "v1.0.0"], + ["bash", str(self.root / "scripts/verify-published-checksums.sh"), *args, "v1.0.0"], env=env, text=True, capture_output=True, check=False, ) + def archive_all(self): + for platform in PLATFORMS: + self.archive(f"demo-1.0.0-{platform}.tar.gz", marker=platform) + + def seed_row(self, signatures=True, **extra): + """A releases.json row already on v1.0.0, as a previous --write left it.""" + row = {"version": "1.0.0", "release_tag": "v1.0.0", "sha256": self.sha("demo-1.0.0.tar.gz"), + "release_signature": {"key_id": "fixture", "alg": "ed25519", "sig": "src"}, + "platform_checksums": {p: self.sha(f"demo-1.0.0-{p}.tar.gz") for p in PLATFORMS}} + if signatures: + row["platform_signatures"] = {p: {"key_id": "fixture", "alg": "ed25519", "sig": f"old-{p}"} for p in PLATFORMS} + row.update(extra) + self.releases.write_text(json.dumps({"releases": {"demo": row}})) + return row + + def row(self): + return json.loads(self.releases.read_text())["releases"]["demo"] + def assert_missing_platforms(self, count=5): result = self.verify() output = result.stdout + result.stderr @@ -115,13 +146,95 @@ def test_exception_does_not_allow_partial_binary_release(self): self.assert_missing_platforms(count=4) def test_complete_binary_release_records_all_platforms(self): - for platform in PLATFORMS: - self.archive(f"demo-1.0.0-{platform}.tar.gz") + self.archive_all() + self.sign_platforms() result = self.verify() self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - row = json.loads(self.releases.read_text())["releases"]["demo"] + row = self.row() self.assertEqual(set(row["platform_checksums"]), set(PLATFORMS)) self.assertEqual(row["binaries"], ["nself-demo"]) + # Each platform carries its own signature object, without the covered sha256 (that is platform_checksums). + self.assertEqual(row["platform_signatures"], { + p: {"key_id": "fixture", "alg": "ed25519", "sig": f"fixture-{p}"} for p in PLATFORMS}) + + def test_published_platform_without_signature_stops_the_write(self): + self.archive_all() + self.sign_platforms() + for missing in ("absent", "stale", "none"): + with self.subTest(missing=missing): + self.sign_platforms() + doc = json.loads(self.signatures.read_text()) + if missing == "absent": + del doc["demo"]["platform_signatures"]["linux-arm64"] + elif missing == "stale": + doc["demo"]["platform_signatures"]["linux-arm64"]["sha256"] = "0" * 64 + else: + del doc["demo"]["platform_signatures"] + self.signatures.write_text(json.dumps(doc)) + result = self.verify() + output = result.stdout + result.stderr + self.assertEqual(result.returncode, 1, output) + self.assertIn("UNSIGNED demo/", output) + self.assertEqual(self.releases.read_text(), self.original) + + def test_platform_signature_for_another_platforms_bytes_is_refused(self): + self.archive_all() + self.sign_platforms() + doc = json.loads(self.signatures.read_text()) + # darwin-arm64 carries the signed hash of darwin-amd64. + doc["demo"]["platform_signatures"]["darwin-arm64"]["sha256"] = self.sha("demo-1.0.0-darwin-amd64.tar.gz") + self.signatures.write_text(json.dumps(doc)) + result = self.verify() + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("UNSIGNED demo/darwin-arm64", result.stdout) + + def test_source_only_release_gets_no_platform_signatures(self): + self.exception(expires="2026-12-31") + self.sign_platforms(sha="0" * 64) + result = self.verify() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("platform_signatures", self.row()) + + def test_rewrite_keeps_platform_signatures_when_bytes_are_unchanged(self): + self.archive_all() + self.seed_row() + result = self.verify(signed=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.row()["platform_signatures"], { + p: {"key_id": "fixture", "alg": "ed25519", "sig": f"old-{p}"} for p in PLATFORMS}) + + def test_rewrite_drops_only_the_platform_signature_whose_bytes_changed(self): + self.archive_all() + self.seed_row() + self.archive("demo-1.0.0-linux-arm64.tar.gz", marker="rebuilt") + result = self.verify(signed=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + kept = self.row()["platform_signatures"] + self.assertEqual(set(kept), set(PLATFORMS) - {"linux-arm64"}) + + def test_rewrite_drops_all_platform_signatures_when_source_bytes_changed(self): + self.archive_all() + self.seed_row() + self.archive("demo-1.0.0.tar.gz", marker="rebuilt") + result = self.verify(signed=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + row = self.row() + self.assertIsNone(row["release_signature"]) + self.assertNotIn("platform_signatures", row) + + def test_rewrite_drops_platform_signatures_made_for_another_version(self): + self.archive_all() + self.seed_row(version="0.9.0") + result = self.verify(signed=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("platform_signatures", self.row()) + + def test_rewrite_without_platform_signatures_adds_none(self): + self.archive_all() + self.seed_row(signatures=False) + result = self.verify(signed=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("platform_signatures", self.row()) def test_source_plugin_needs_no_binary_exception(self): self.manifest.write_text("{}") diff --git a/scripts/cli-tools.version b/scripts/cli-tools.version index 1e32491ab..a1e7ce070 100644 --- a/scripts/cli-tools.version +++ b/scripts/cli-tools.version @@ -6,4 +6,4 @@ # tool from its committed vendor/ directory. Change the sha, then run # scripts/conformance/sync-schema.sh to pull the matching schema. repo=https://github.com/nself-org/cli -sha=6fe7bc673b1dbe95db3e92464a9f395772e7ee3a +sha=5b214db6008d4991144b639bfc7a8beda6b83d8c diff --git a/scripts/release/platform-built-check.sh b/scripts/release/platform-built-check.sh new file mode 100755 index 000000000..cfe37efd5 --- /dev/null +++ b/scripts/release/platform-built-check.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# platform-built-check.sh BUILT_LIST DIR +# +# Purpose: the gate in front of platform (binary) signing in .github/workflows/release-tarballs.yml (contract plugins.release-signature +# v1.1, ticket P7-PLUG-83). The publish job never uses --clobber, so a tarball already on the Release wins over this run's build. +# Signing a platform tarball vouches for the binary inside it, so the sign job may only sign bytes THIS run built: anyone with +# contents:write could otherwise upload a binary while the sign job waits for approval and receive the official signature. +# +# Inputs: +# BUILT_LIST sha256sum-format lines ("<64 hex> ") of the tarballs this run built and the Release serves unchanged +# (the publish job writes it as built-sha256.txt). It may be empty. Lines in any other shape grant nothing. +# DIR a directory (symlinks are followed) holding the tarballs about to be signed. Only platform tarballs count: +# names ending in -darwin-amd64 -darwin-arm64 -linux-amd64 -linux-arm64 -windows-amd64 .tar.gz. +# +# Output: exit 0 only if every platform tarball in DIR has its exact " " line in BUILT_LIST (the name is part of the +# match: the bytes of one platform renamed as another are refused too). Otherwise exit 1 and one stderr line per refused file: +# not built by this run: +# Exit 2 on usage errors (a missing BUILT_LIST or DIR is an error, never an empty pass). Nothing is written. +# +# Requires sha256sum or shasum. + +set -euo pipefail + +err() { printf "[platform-built-check] %s\n" "$*" >&2; } + +if [ "$#" -ne 2 ]; then + err "usage: $0 BUILT_LIST DIR" + exit 2 +fi +LIST="$1" +DIR="$2" +[ -f "$LIST" ] || { err "built list not found: $LIST"; exit 2; } +[ -d "$DIR" ] || { err "directory not found: $DIR"; exit 2; } + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +# "\t" per well-formed line; text mode ("sha name") and binary mode ("sha *name") both appear in the wild. +TAB=$'\t' +GRANTS="$(sed -nE "s/^([0-9a-f]{64}) [ *](.+)\$/\\1${TAB}\\2/p" "$LIST")" + +fail=0 +checked=0 +shopt -s nullglob +for f in "$DIR"/*-darwin-amd64.tar.gz "$DIR"/*-darwin-arm64.tar.gz "$DIR"/*-linux-amd64.tar.gz "$DIR"/*-linux-arm64.tar.gz "$DIR"/*-windows-amd64.tar.gz; do + name="$(basename "$f")" + checked=$((checked + 1)) + sha="$(sha256_file "$f")" + if ! printf '%s\n' "$GRANTS" | grep -qxF -- "$(printf '%s\t%s' "$sha" "$name")"; then + err "not built by this run: $name" + fail=1 + fi +done +[ "$fail" -eq 0 ] || exit 1 +printf "[platform-built-check] %s platform tarball(s) in %s were built by this run\n" "$checked" "$DIR" diff --git a/scripts/release/platform-signatures-test.sh b/scripts/release/platform-signatures-test.sh new file mode 100755 index 000000000..555ec36ec --- /dev/null +++ b/scripts/release/platform-signatures-test.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# platform-signatures-test.sh: offline gate for the platform (binary) tarball signatures of contract plugins.release-signature v1.1 +# (P7-PLUG-83). Every key here is a THROWAWAY Ed25519 key generated by this script; no real key is read, nothing is uploaded, no key +# text is printed. It covers, with scripts/release/sign.sh as the signer and scripts/release/verify-signatures.sh as the verifier: +# - a platform signature is accepted for its slug, version, platform and bytes and for nothing else (moved platform, version, slug, +# flipped byte, source signature used as platform signature and the reverse, missing signature, orphan signature); +# - the three-argument form of sign.sh is still the v1 source signature, and both messages equal what openssl signs directly; +# - the same release directory fails against every key committed in scripts/release/keys/; +# - platform-built-check.sh passes tarballs this run built and refuses a replaced, renamed or unlisted one; +# - the committed interop fixture scripts/release/testdata/platform-v1/ (made by sign.sh, consumed by the cli Ticket P7-PLUG-84) +# still verifies and its vectors still hold. +# Run from any directory: bash scripts/release/platform-signatures-test.sh (OPENSSL=/path/to/openssl3 if the default is LibreSSL) +# Regenerate the committed fixture (new throwaway key, the private half is discarded): bash scripts/release/platform-signatures-test.sh --regen-testdata +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +O="${OPENSSL:-openssl}" +"$O" version 2>/dev/null | grep -q '^OpenSSL ' || { echo "FAIL: $O is not OpenSSL (set OPENSSL)"; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "FAIL: jq not found"; exit 1; } +T="$(mktemp -d "${TMPDIR:-/tmp}/platsig.XXXXXX")" +trap 'rm -rf "$T"' EXIT + +sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } + +# --- throwaway key ------------------------------------------------------------------------------------------------------- +umask 077 +"$O" genpkey -algorithm ed25519 -out "$T/key.pem" 2>/dev/null +"$O" pkey -in "$T/key.pem" -pubout -out "$T/test.pub" 2>/dev/null +ID="plugins-$("$O" pkey -in "$T/key.pem" -pubout -outform DER 2>/dev/null | tail -c 32 | { sha256sum 2>/dev/null || shasum -a 256; } | cut -c1-16)" +umask 022 +sign() { NSELF_RELEASE_SIGNING_KEY_FREE="$(cat "$T/key.pem")" NSELF_RELEASE_SIGNING_KEY_FREE_ID="$ID" OPENSSL="$O" bash "$HERE/sign.sh" "$@"; } + +# --- a good release directory ---------------------------------------------------------------------------------------------- +# mktar OUT STUBTEXT: deterministic tarball of one stub file nself-demo (never a real build). +mktar() { + local out="$1" d + d="$(mktemp -d "$T/stub.XXXXXX")" + printf '%s\n' "$2" > "$d/nself-demo" + chmod 755 "$d/nself-demo" + bash "$REPO/scripts/deterministic-tar.sh" "$out" -C "$d" nself-demo +} +# build_release DIR SLUG VERSION: source tarball plus linux-amd64 and darwin-arm64 binary tarballs, all signed, releases.json. +build_release() { + local dir="$1" slug="$2" ver="$3" src plin pdar + mkdir -p "$dir" + src="$dir/$slug-$ver.tar.gz"; plin="$dir/$slug-$ver-linux-amd64.tar.gz"; pdar="$dir/$slug-$ver-darwin-arm64.tar.gz" + mktar "$src" "source of $slug $ver" + mktar "$plin" "stub binary $slug $ver linux-amd64" + mktar "$pdar" "stub binary $slug $ver darwin-arm64" + jq -nc --arg s "$slug" --arg v "$ver" \ + --arg ssha "$(sha256_file "$src")" --argjson ssig "$(sign "$slug" "$ver" "$src")" \ + --arg lsha "$(sha256_file "$plin")" --argjson lsig "$(sign "$slug" "$ver" "$plin" linux-amd64)" \ + --arg dsha "$(sha256_file "$pdar")" --argjson dsig "$(sign "$slug" "$ver" "$pdar" darwin-arm64)" \ + '{schema_version: 1, releases: {($s): {version: $v, sha256: $ssha, release_signature: $ssig, + platform_checksums: {"linux-amd64": $lsha, "darwin-arm64": $dsha}, + platform_signatures: {"linux-amd64": $lsig, "darwin-arm64": $dsig}}}}' > "$dir/releases.json" +} +GOOD="$T/good" +build_release "$GOOD" demo 1.2.3 + +# --- tiny harness ---------------------------------------------------------------------------------------------------------- +fails=0 +pass() { echo "ok $1"; } +fail() { echo "FAIL $1"; fails=$((fails + 1)); } +verify() { bash "$HERE/verify-signatures.sh" --pub "${2:-$T/test.pub}" "$1"; } +# expect_ok LABEL DIR: verify-signatures accepts DIR with the throwaway key. +expect_ok() { local out; if out="$(verify "$2" 2>&1)"; then pass "$1"; else fail "$1 (rejected: $out)"; fi; } +# expect_refused LABEL NEEDLE DIR: verify-signatures rejects DIR and says NEEDLE. +expect_refused() { + local out + if out="$(verify "$3" 2>&1)"; then fail "$1 (accepted)"; return; fi + case "$out" in *"$2"*) pass "$1" ;; *) fail "$1 (rejected, but not with '$2': $out)" ;; esac +} +# variant NAME JQ_FILTER: copy GOOD to $T/NAME and edit its releases.json with the jq filter. +variant() { rm -rf "${T:?}/$1"; cp -R "$GOOD" "$T/$1"; jq -c "$2" "$GOOD/releases.json" > "$T/$1/releases.json"; } + +expect_ok "good release (source + 2 platform signatures)" "$GOOD" +case "$(verify "$GOOD" 2>&1)" in *"verified 1 source tarball(s) and 2 binary tarball(s) (checksum + signature each)"*) pass "summary counts source and binary apart" ;; *) fail "summary line" ;; esac + +# --- tamper cases ------------------------------------------------------------------------------------------------------------ +rm -rf "$T/flip"; cp -R "$GOOD" "$T/flip" +printf 'x' >> "$T/flip/demo-1.2.3-linux-amd64.tar.gz" +expect_refused "flipped byte in a platform tarball" "checksum mismatch: demo-1.2.3-linux-amd64.tar.gz" "$T/flip" + +# The attacker also rewrites platform_checksums to match the new bytes: the signature, not the checksum, must stop it. +rm -rf "$T/flipfix"; cp -R "$GOOD" "$T/flipfix" +printf 'x' >> "$T/flipfix/demo-1.2.3-linux-amd64.tar.gz" +jq -c --arg sha "$(sha256_file "$T/flipfix/demo-1.2.3-linux-amd64.tar.gz")" '.releases.demo.platform_checksums["linux-amd64"] = $sha' "$GOOD/releases.json" > "$T/flipfix/releases.json" +expect_refused "tampered bytes with a rewritten checksum" "platform signature mismatch: demo 1.2.3 linux-amd64" "$T/flipfix" + +variant swapped '.releases.demo.platform_signatures |= {"linux-amd64": .["darwin-arm64"], "darwin-arm64": .["linux-amd64"]}' +expect_refused "signature moved to another platform" "platform signature mismatch: demo 1.2.3 linux-amd64" "$T/swapped" + +# Same bytes, claimed as another version: rename the files and the row; every signature was made for 1.2.3. +rm -rf "$T/ver"; mkdir "$T/ver" +for f in "$GOOD"/demo-1.2.3*.tar.gz; do cp "$f" "$T/ver/$(basename "$f" | sed 's/1\.2\.3/1.2.4/')"; done +jq -c '.releases.demo.version = "1.2.4"' "$GOOD/releases.json" > "$T/ver/releases.json" +expect_refused "signature moved to another version" "platform signature mismatch: demo 1.2.4 linux-amd64" "$T/ver" + +rm -rf "$T/slug"; mkdir "$T/slug" +for f in "$GOOD"/demo-1.2.3*.tar.gz; do cp "$f" "$T/slug/$(basename "$f" | sed 's/^demo-/other-/')"; done +jq -c '.releases = {other: .releases.demo}' "$GOOD/releases.json" > "$T/slug/releases.json" +expect_refused "signature moved to another slug" "platform signature mismatch: other 1.2.3 linux-amd64" "$T/slug" + +variant srcaspl '.releases.demo.platform_signatures["linux-amd64"] = .releases.demo.release_signature' +expect_refused "source signature used as a platform signature" "platform signature mismatch: demo 1.2.3 linux-amd64" "$T/srcaspl" + +variant plasrc '.releases.demo.release_signature = .releases.demo.platform_signatures["linux-amd64"]' +expect_refused "platform signature used as the source signature" "signature mismatch: demo 1.2.3" "$T/plasrc" + +variant nosig1 'del(.releases.demo.platform_signatures["linux-amd64"])' +expect_refused "one platform tarball with no signature" "unsigned platform: demo 1.2.3 linux-amd64" "$T/nosig1" +variant nosigall 'del(.releases.demo.platform_signatures)' +expect_refused "no platform signatures on a signed row" "unsigned platform: demo 1.2.3 darwin-arm64" "$T/nosigall" +variant badshape '.releases.demo.platform_signatures["linux-amd64"] = "not-an-object"' +expect_refused "platform signature of the wrong shape" "platform signature mismatch: demo 1.2.3 linux-amd64" "$T/badshape" +variant badalg '.releases.demo.platform_signatures["linux-amd64"].alg = "rsa"' +expect_refused "platform signature with another algorithm" "platform signature mismatch: demo 1.2.3 linux-amd64" "$T/badalg" +variant badkid '.releases.demo.platform_signatures["linux-amd64"].key_id = "plugins-0000000000000000"' +expect_refused "platform signature naming another key" "platform signature mismatch: demo 1.2.3 linux-amd64" "$T/badkid" +variant orphan '.releases.demo.platform_signatures["windows-amd64"] = .releases.demo.platform_signatures["linux-amd64"]' +expect_refused "platform signature without a platform checksum" "orphan platform signature: demo 1.2.3 windows-amd64" "$T/orphan" +variant orphanrow '.releases.demo.release_signature = null' +expect_refused "platform signatures on an unsigned row" "orphan platform signature: demo 1.2.3 linux-amd64" "$T/orphanrow" + +# A binary tarball dropped from the directory is a failure, not a smaller set. +rm -rf "$T/dropped"; cp -R "$GOOD" "$T/dropped"; rm "$T/dropped/demo-1.2.3-darwin-arm64.tar.gz" +expect_refused "platform tarball removed" "missing tarball: demo-1.2.3-darwin-arm64.tar.gz" "$T/dropped" + +# --- the committed keys must refuse the throwaway signatures ------------------------------------------------------------------- +n=0 +for k in "$HERE"/keys/*.pub; do + [ -e "$k" ] || continue + n=$((n + 1)) + if verify "$GOOD" "$k" >/dev/null 2>&1; then fail "throwaway signatures verify against committed key $(basename "$k")"; else pass "committed key $(basename "$k") refuses them"; fi +done +[ "$n" -gt 0 ] || fail "no committed key found under scripts/release/keys/ to test against" + +# --- sign.sh itself ---------------------------------------------------------------------------------------------------------- +# Independent re-implementation of both messages: openssl signs them directly; Ed25519 is deterministic, so the bytes must match. +SRC="$GOOD/demo-1.2.3.tar.gz"; PLIN="$GOOD/demo-1.2.3-linux-amd64.tar.gz" +direct_sig() { printf "$1" > "$T/direct.msg"; "$O" pkeyutl -sign -rawin -inkey "$T/key.pem" -in "$T/direct.msg" -out "$T/direct.sig" 2>/dev/null; "$O" base64 -A -in "$T/direct.sig"; } +want_src="$(direct_sig "nself-plugin-v1\ndemo\n1.2.3\n$(sha256_file "$SRC")\n")" +want_bin="$(direct_sig "nself-plugin-bin-v1\ndemo\n1.2.3\nlinux-amd64\n$(sha256_file "$PLIN")\n")" +[ "$(sign demo 1.2.3 "$SRC" | jq -r .sig)" = "$want_src" ] && pass "three-argument form signs the v1 source message" || fail "three-argument form changed" +[ "$(sign demo 1.2.3 "$PLIN" linux-amd64 | jq -r .sig)" = "$want_bin" ] && pass "PLATFORM form signs the nself-plugin-bin-v1 message" || fail "platform message differs from the contract" +[ "$want_src" != "$want_bin" ] && pass "the two domains give different signatures" || fail "domains collide" +sign demo 1.2.3 "$PLIN" linux-amd64 | jq -e '.alg == "ed25519" and .key_id == "'"$ID"'" and (.sig | length) == 88' >/dev/null && pass "platform signature object has the release_signature shape" || fail "platform signature shape" +for bad in "" linux darwin-x64 LINUX-AMD64 "linux-amd64 " windows; do + rc=0; sign demo 1.2.3 "$PLIN" "$bad" >/dev/null 2>&1 || rc=$? + [ "$rc" = 2 ] && pass "sign.sh refuses platform '$bad' with exit 2" || fail "sign.sh platform '$bad' exit $rc" +done +rc=0; sign demo 1.2.3 "$PLIN" linux-amd64 extra >/dev/null 2>&1 || rc=$? +[ "$rc" = 2 ] && pass "sign.sh refuses a fifth argument" || fail "sign.sh fifth argument exit $rc" + +# --- platform-built-check.sh ------------------------------------------------------------------------------------------------- +BIN="$T/binstage"; mkdir -p "$BIN" +cp "$PLIN" "$GOOD/demo-1.2.3-darwin-arm64.tar.gz" "$BIN/" +BUILT="$T/built.txt"; (cd "$BIN" && sha256sum -- *.tar.gz 2>/dev/null || shasum -a 256 -- *.tar.gz) > "$BUILT" +check() { bash "$HERE/platform-built-check.sh" "$@"; } +check "$BUILT" "$BIN" >/dev/null 2>&1 && pass "built check passes tarballs this run built" || fail "built check refused built tarballs" +: > "$T/empty.txt" +if check "$T/empty.txt" "$BIN" >/dev/null 2>&1; then fail "built check passed with an empty list"; else pass "built check refuses when the list is empty"; fi +# A foreign upload under a built name: other bytes, same file name. +REPL="$T/replaced"; cp -R "$BIN" "$REPL"; mktar "$REPL/demo-1.2.3-linux-amd64.tar.gz" "attacker binary" +out="$(check "$BUILT" "$REPL" 2>&1)" && fail "built check passed a replaced asset" || case "$out" in *"not built by this run: demo-1.2.3-linux-amd64.tar.gz"*) pass "built check refuses a replaced asset" ;; *) fail "replaced asset refused wrongly: $out" ;; esac +# Built bytes presented under another platform's name. +REN="$T/renamed"; mkdir "$REN"; cp "$PLIN" "$REN/demo-1.2.3-windows-amd64.tar.gz" +if check "$BUILT" "$REN" >/dev/null 2>&1; then fail "built check passed built bytes under another platform name"; else pass "built check refuses built bytes under another name"; fi +# A tarball that was built but is not listed (list from another run). +printf '%s %s\n' "$(sha256_file "$PLIN")" "demo-1.2.3-linux-amd64.tar.gz" > "$T/partial.txt" +if check "$T/partial.txt" "$BIN" >/dev/null 2>&1; then fail "built check passed an unlisted tarball"; else pass "built check refuses a tarball missing from the list"; fi +# Garbage and substring lines grant nothing. +printf 'zz demo-1.2.3-linux-amd64.tar.gz\n%s demo-1.2.3-linux-amd64.tar.gz\n' "$(sha256_file "$PLIN")" > "$T/garbage.txt" +if check "$T/garbage.txt" "$BIN" >/dev/null 2>&1; then fail "built check accepted malformed list lines"; else pass "built check ignores malformed list lines"; fi +# Source tarballs and unrelated files in DIR are not platform tarballs. +SRCONLY="$T/srconly"; mkdir "$SRCONLY"; cp "$SRC" "$SRCONLY/" +check "$T/empty.txt" "$SRCONLY" >/dev/null 2>&1 && pass "built check ignores source tarballs" || fail "built check refused a source tarball" +rc=0; check "$T/nope.txt" "$BIN" >/dev/null 2>&1 || rc=$? +[ "$rc" = 2 ] && pass "built check: a missing list is a usage error, not a pass" || fail "missing list exit $rc" +rc=0; check "$BUILT" "$T/nodir" >/dev/null 2>&1 || rc=$? +[ "$rc" = 2 ] && pass "built check: a missing directory is a usage error" || fail "missing directory exit $rc" +# Symlinked entries are read through (the workflow stages symlinks). +LNK="$T/linked"; mkdir "$LNK"; ln -s "$PLIN" "$LNK/demo-1.2.3-linux-amd64.tar.gz" +check "$BUILT" "$LNK" >/dev/null 2>&1 && pass "built check follows symlinks" || fail "built check refused a symlinked built tarball" + +# --- the committed interop fixture (P7-PLUG-84 copies it) ---------------------------------------------------------------------- +FIX="$HERE/testdata/platform-v1" + +# regen_testdata: rebuild the fixture with the throwaway key above (its private half dies with $T). +regen_testdata() { + local f="$FIX" src plin sig + rm -rf "$f"; mkdir -p "$f" + build_release "$T/fx" demo 1.0.0 + cp "$T/fx/demo-1.0.0.tar.gz" "$T/fx/demo-1.0.0-linux-amd64.tar.gz" "$T/fx/releases.json" "$f/" + cp "$T/test.pub" "$f/fixture.pub" + chmod 644 "$f/fixture.pub" + src="$f/demo-1.0.0.tar.gz"; plin="$f/demo-1.0.0-linux-amd64.tar.gz" + jq -S '.releases.demo |= (del(.platform_checksums["darwin-arm64"]) | del(.platform_signatures["darwin-arm64"]))' "$T/fx/releases.json" > "$f/releases.json" + sig="$(jq -c '.releases.demo.platform_signatures["linux-amd64"]' "$f/releases.json")" + jq -nS --arg id "$ID" --arg ssha "$(sha256_file "$src")" --arg psha "$(sha256_file "$plin")" --argjson sig "$sig" \ + --argjson ssig "$(jq -c '.releases.demo.release_signature' "$f/releases.json")" '{ + "contract": "plugins.release-signature v1.1", + "public_key": "fixture.pub", + "key_id": $id, + "slug": "demo", "version": "1.0.0", "platform": "linux-amd64", + "source_tarball": "demo-1.0.0.tar.gz", "source_sha256": $ssha, "source_signature": $ssig, + "platform_tarball": "demo-1.0.0-linux-amd64.tar.gz", "platform_sha256": $psha, "platform_signature": $sig, + "platform_message": "nself-plugin-bin-v1\\n\\n\\n\\n\\n", + "cases": [ + {"name": "valid", "expect": "ok"}, + {"name": "other platform", "platform": "linux-arm64", "expect": "fail"}, + {"name": "other version", "version": "1.0.1", "expect": "fail"}, + {"name": "other slug", "slug": "other", "expect": "fail"}, + {"name": "flipped byte", "tarball_append_byte": true, "expect": "fail"}, + {"name": "source signature as platform signature", "signature": "source", "expect": "fail"} + ]}' > "$f/vectors.json" + cat > "$f/README.md" <<'EOF' +Interop fixture for contract plugins.release-signature v1.1 (platform signatures). Made by +scripts/release/platform-signatures-test.sh --regen-testdata with scripts/release/sign.sh and a THROWAWAY key (fixture.pub is its +public half; the private half was discarded). Not a release key: it is not in scripts/release/keys/ and no CLI pins it. + +- demo-1.0.0.tar.gz / demo-1.0.0-linux-amd64.tar.gz: stub tarballs. releases.json: the release-data rows for them. +- vectors.json: slug, version, platform, tarball sha256, signature object, and cases (each overrides fields of the vector and says + whether the signature must verify). A verifier recomputes the message + `nself-plugin-bin-v1\n\n\n\n\n` from the case, and verifies the raw + Ed25519 signature (base64 `sig`) with fixture.pub. The `tarball_append_byte` case verifies the signature over the sha256 of the + tarball with one byte appended; `signature: source` uses `source_signature` as the platform signature. +EOF + echo "regenerated $f (key id $ID)" +} +if [ "${1:-}" = "--regen-testdata" ]; then regen_testdata; exit 0; fi + +if [ -f "$FIX/vectors.json" ]; then + FXPUB="$FIX/fixture.pub" + if verify "$FIX" "$FXPUB" >/dev/null 2>&1; then pass "fixture verifies with its own public key"; else fail "fixture does not verify: $(verify "$FIX" "$FXPUB" 2>&1)"; fi + for k in "$HERE"/keys/*.pub; do + [ -e "$k" ] || continue + if verify "$FIX" "$k" >/dev/null 2>&1; then fail "fixture verifies against committed key $(basename "$k")"; else pass "fixture refused by committed key $(basename "$k")"; fi + done + # Every vector case, recomputed from first principles with openssl. + fx() { jq -r "$1" "$FIX/vectors.json"; } + ncases="$(fx '.cases | length')" + i=0 + while [ "$i" -lt "$ncases" ]; do + cname="$(fx ".cases[$i].name")"; want="$(fx ".cases[$i].expect")" + slug="$(fx ".cases[$i].slug // .slug")"; ver="$(fx ".cases[$i].version // .version")"; plat="$(fx ".cases[$i].platform // .platform")" + cp "$FIX/$(fx .platform_tarball)" "$T/vec.tar.gz" + [ "$(fx ".cases[$i].tarball_append_byte // false")" != "true" ] || printf 'x' >> "$T/vec.tar.gz" + printf 'nself-plugin-bin-v1\n%s\n%s\n%s\n%s\n' "$slug" "$ver" "$plat" "$(sha256_file "$T/vec.tar.gz")" > "$T/vec.msg" + if [ "$(fx ".cases[$i].signature // \"platform\"")" = "source" ]; then s="$(fx .source_signature.sig)"; else s="$(fx .platform_signature.sig)"; fi + printf '%s' "$s" | "$O" base64 -d -A > "$T/vec.sig" + if "$O" pkeyutl -verify -rawin -pubin -inkey "$FXPUB" -in "$T/vec.msg" -sigfile "$T/vec.sig" >/dev/null 2>&1; then got=ok; else got=fail; fi + [ "$got" = "$want" ] && pass "fixture vector '$cname' is $want" || fail "fixture vector '$cname': wanted $want, got $got" + i=$((i + 1)) + done +else + fail "scripts/release/testdata/platform-v1/vectors.json is missing (regenerate with --regen-testdata)" +fi + +[ "$fails" = 0 ] && echo "PASS platform-signatures" || { echo "FAILED: $fails case(s)"; exit 1; } diff --git a/scripts/release/rehearse.sh b/scripts/release/rehearse.sh index e3c5db6ac..430186fee 100755 --- a/scripts/release/rehearse.sh +++ b/scripts/release/rehearse.sh @@ -9,7 +9,12 @@ # # Output in DIR (created; must be empty or absent): # -.tar.gz source tarballs, version = TAG without the leading v -# releases.json release-data shape: {"releases": {"": {version, sha256, release_tag, release_signature}}} +# --.tar.gz +# fixture binary tarballs for the FIRST slug only (contract v1.1): one per platform, each a deterministic +# tarball of a stub file nself- (never a real build, no toolchain, no network), signed under the +# platform domain with the same throwaway key +# releases.json release-data shape: {"releases": {"": {version, sha256, release_tag, release_signature}}}; the +# first slug's row also carries platform_checksums and platform_signatures # rehearsal-test.pub PEM SPKI public half of the throwaway key. It is the only key these signatures verify against: # scripts/release/verify-signatures.sh --pub DIR/rehearsal-test.pub DIR # @@ -83,11 +88,29 @@ while IFS= read -r dir; do # The throwaway key is handed to the signer through its environment only, for this one call. sig="$(NSELF_RELEASE_SIGNING_KEY_FREE="$(cat "$KEYDIR/key.pem")" NSELF_RELEASE_SIGNING_KEY_FREE_ID="$KEY_ID" \ OPENSSL="$O" bash scripts/release/sign.sh "$slug" "$VERSION" "$tarball")" - jq -nc --arg s "$slug" --arg v "$VERSION" --arg t "$TAG" --arg sha "$sha" --argjson sig "$sig" \ - '{($s): {version: $v, sha256: $sha, release_tag: $t, release_signature: $sig}}' >> "$ROWS" + plat='{}' + psig='{}' + if [ "$count" -eq 0 ]; then + stub="$KEYDIR/stub" + mkdir -p "$stub" + for platform in darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 windows-amd64; do + printf 'stub binary for %s %s %s\n' "$slug" "$VERSION" "$platform" > "$stub/nself-${slug}" + chmod 755 "$stub/nself-${slug}" + ptar="${OUT}/${slug}-${VERSION}-${platform}.tar.gz" + bash scripts/deterministic-tar.sh "$ptar" -C "$stub" "nself-${slug}" + psha="$(sha256_file "$ptar")" + psignature="$(NSELF_RELEASE_SIGNING_KEY_FREE="$(cat "$KEYDIR/key.pem")" NSELF_RELEASE_SIGNING_KEY_FREE_ID="$KEY_ID" \ + OPENSSL="$O" bash scripts/release/sign.sh "$slug" "$VERSION" "$ptar" "$platform")" + plat="$(jq -c --arg p "$platform" --arg sha "$psha" '.[$p] = $sha' <<<"$plat")" + psig="$(jq -c --arg p "$platform" --argjson sig "$psignature" '.[$p] = $sig' <<<"$psig")" + done + fi + jq -nc --arg s "$slug" --arg v "$VERSION" --arg t "$TAG" --arg sha "$sha" --argjson sig "$sig" --argjson plat "$plat" --argjson psig "$psig" \ + '{($s): ({version: $v, sha256: $sha, release_tag: $t, release_signature: $sig} + + (if ($plat | length) > 0 then {platform_checksums: $plat, platform_signatures: $psig} else {} end))}' >> "$ROWS" count=$((count + 1)) done < <(find free -maxdepth 1 -mindepth 1 -type d | sort) [ "$count" -gt 0 ] || { err "no free plugin with a plugin.json found under $REPO_ROOT/free"; exit 1; } jq -sS '{schema_version: 1, releases: (add)}' "$ROWS" > "$OUT/releases.json" -log "built and signed ${count} tarball(s) for ${TAG} into ${OUT} (throwaway key ${KEY_ID}; public half: rehearsal-test.pub)" +log "built and signed ${count} source tarball(s) (and the fixture binary tarballs of the first slug) for ${TAG} into ${OUT} (throwaway key ${KEY_ID}; public half: rehearsal-test.pub)" diff --git a/scripts/release/sign.sh b/scripts/release/sign.sh index eadcccf6e..7cd86dd10 100755 --- a/scripts/release/sign.sh +++ b/scripts/release/sign.sh @@ -1,9 +1,13 @@ #!/usr/bin/env bash -# sign.sh SLUG VERSION TARBALL +# sign.sh SLUG VERSION TARBALL [PLATFORM] # -# Purpose: sign one plugin tarball with the free-tier release key (hq PLUG D7, contract plugins.release-signature v1). -# The signed message is the domain-separated string "nself-plugin-v1\n\n\n\n" -# (every field followed by a newline, the last one too), signed with raw Ed25519 (`openssl pkeyutl -sign -rawin`). +# Purpose: sign one plugin tarball with the free-tier release key (hq PLUG D7, contract plugins.release-signature v1.1). +# Without PLATFORM, TARBALL is the source tarball and the signed message is the domain-separated string +# "nself-plugin-v1\n\n\n\n" (every field followed by a newline, the last one too). +# With PLATFORM (one of darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 windows-amd64), TARBALL is that platform's binary +# tarball and the message is "nself-plugin-bin-v1\n\n\n\n\n". The first lines +# differ, so a source signature never verifies as a platform signature and the reverse. Both use raw Ed25519 +# (`openssl pkeyutl -sign -rawin`) and the same tier key; the three-argument form is byte-identical to v1. # # Inputs (environment only, never argv): # NSELF_RELEASE_SIGNING_KEY_FREE PKCS8 PEM of the Ed25519 private key (raw PEM, PEM with collapsed newlines, or base64 of the PEM). @@ -22,13 +26,21 @@ set +x err() { printf "[sign] ERROR: %s\n" "$*" >&2; } -if [ "$#" -ne 3 ]; then - printf "usage: %s SLUG VERSION TARBALL\n" "$0" >&2 +if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then + printf "usage: %s SLUG VERSION TARBALL [PLATFORM]\n" "$0" >&2 exit 2 fi SLUG="$1" VERSION="$2" TARBALL="$3" +PLATFORM="${4-}" + +if [ "$#" -eq 4 ]; then + case "$PLATFORM" in + darwin-amd64|darwin-arm64|linux-amd64|linux-arm64|windows-amd64) ;; + *) printf "[sign] ERROR: platform '%s' is not one of darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 windows-amd64\n" "$PLATFORM" >&2; exit 2 ;; + esac +fi [[ "$SLUG" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { err "slug '$SLUG' is not lowercase-with-hyphens"; exit 2; } [[ "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z.+-]*$ ]] || { err "version '$VERSION' is not a plain version string"; exit 2; } @@ -88,7 +100,11 @@ if [ "plugins-$DERIVED" != "$KEY_ID" ]; then fi SHA="$(sha256_file "$TARBALL")" -printf 'nself-plugin-v1\n%s\n%s\n%s\n' "$SLUG" "$VERSION" "$SHA" > "$WORK/msg" +if [ "$#" -eq 4 ]; then + printf 'nself-plugin-bin-v1\n%s\n%s\n%s\n%s\n' "$SLUG" "$VERSION" "$PLATFORM" "$SHA" > "$WORK/msg" +else + printf 'nself-plugin-v1\n%s\n%s\n%s\n' "$SLUG" "$VERSION" "$SHA" > "$WORK/msg" +fi "$O" pkeyutl -sign -rawin -inkey "$WORK/key.pem" -in "$WORK/msg" -out "$WORK/sig" 2>/dev/null || { err "signing failed (is the key Ed25519?)"; exit 1; } [ "$(wc -c < "$WORK/sig" | tr -d ' ')" = "64" ] || { err "signature is not 64 bytes; the key is not Ed25519"; exit 1; } SIG="$("$O" base64 -A -in "$WORK/sig")" diff --git a/scripts/release/testdata/platform-v1/README.md b/scripts/release/testdata/platform-v1/README.md new file mode 100644 index 000000000..f1d1b3487 --- /dev/null +++ b/scripts/release/testdata/platform-v1/README.md @@ -0,0 +1,10 @@ +Interop fixture for contract plugins.release-signature v1.1 (platform signatures). Made by +scripts/release/platform-signatures-test.sh --regen-testdata with scripts/release/sign.sh and a THROWAWAY key (fixture.pub is its +public half; the private half was discarded). Not a release key: it is not in scripts/release/keys/ and no CLI pins it. + +- demo-1.0.0.tar.gz / demo-1.0.0-linux-amd64.tar.gz: stub tarballs. releases.json: the release-data rows for them. +- vectors.json: slug, version, platform, tarball sha256, signature object, and cases (each overrides fields of the vector and says + whether the signature must verify). A verifier recomputes the message + `nself-plugin-bin-v1\n\n\n\n\n` from the case, and verifies the raw + Ed25519 signature (base64 `sig`) with fixture.pub. The `tarball_append_byte` case verifies the signature over the sha256 of the + tarball with one byte appended; `signature: source` uses `source_signature` as the platform signature. diff --git a/scripts/release/testdata/platform-v1/demo-1.0.0-linux-amd64.tar.gz b/scripts/release/testdata/platform-v1/demo-1.0.0-linux-amd64.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..523b749ea250046f2223c0ca09906e6e5e70a53f GIT binary patch literal 135 zcmb2|=3oE;Cg!)747r*e1X>@iv^^T*AlaLCe)A$m$MAw!kG%Q4z6b0SC;ZR8_hHs` zpN_{h+U0ju-_gnCX+s^Uv3rbU` ezO-pA(f?}{bK41Q6`a`rob%WVJ01oN1_l6J%Qx`= literal 0 HcmV?d00001 diff --git a/scripts/release/testdata/platform-v1/demo-1.0.0.tar.gz b/scripts/release/testdata/platform-v1/demo-1.0.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..fea4abd921f8a67f51183f256930a70038196f22 GIT binary patch literal 119 zcmb2|=3oE;Cg!)747nN%1Y9nzwRP27kkc~ZbH_0+UWvW?4SN&TbNsu0#^|ic?j;7j zUj?3N^j|z<9d5LHdDiQ7yT9Dq9@cY;``eb%?0Hk_y$a)hCp^kHCvULrqMJ4&*lZY4 PbC>bzo7!y*8Vn2ozWp%X literal 0 HcmV?d00001 diff --git a/scripts/release/testdata/platform-v1/fixture.pub b/scripts/release/testdata/platform-v1/fixture.pub new file mode 100644 index 000000000..30d8f19bd --- /dev/null +++ b/scripts/release/testdata/platform-v1/fixture.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAp04Ycv+rg7AYgTJPBeElpEvt4dau7dZg5YeqTm6dkgM= +-----END PUBLIC KEY----- diff --git a/scripts/release/testdata/platform-v1/releases.json b/scripts/release/testdata/platform-v1/releases.json new file mode 100644 index 000000000..cfde86fdd --- /dev/null +++ b/scripts/release/testdata/platform-v1/releases.json @@ -0,0 +1,24 @@ +{ + "releases": { + "demo": { + "platform_checksums": { + "linux-amd64": "0bbf761b252a3f33a7e560bc6e2f6e14368b5fad183ba3b55b3d94cd1c423934" + }, + "platform_signatures": { + "linux-amd64": { + "alg": "ed25519", + "key_id": "plugins-b0a54b8c82cd374c", + "sig": "UXydM3ZxI/cFhxHbz1Czwy4OMXBzpUptrNoELV8ioVuuXIp2f+WwZAphmZWzKhGbNMO1jjHuxlbXBEzy26IIBg==" + } + }, + "release_signature": { + "alg": "ed25519", + "key_id": "plugins-b0a54b8c82cd374c", + "sig": "UjlQd8JE1gHR+Q6+w6j6bHVx/FalkoYpeiwICNI60PytRFbBYS0yWdDfqEBfeAbuGfNk4rbACfX7km+1YOouBg==" + }, + "sha256": "635edf24942ec563525eb92dc487921c1bb0ff7fb2f9941976752f13a54e17d2", + "version": "1.0.0" + } + }, + "schema_version": 1 +} diff --git a/scripts/release/testdata/platform-v1/vectors.json b/scripts/release/testdata/platform-v1/vectors.json new file mode 100644 index 000000000..e26ee8927 --- /dev/null +++ b/scripts/release/testdata/platform-v1/vectors.json @@ -0,0 +1,54 @@ +{ + "cases": [ + { + "expect": "ok", + "name": "valid" + }, + { + "expect": "fail", + "name": "other platform", + "platform": "linux-arm64" + }, + { + "expect": "fail", + "name": "other version", + "version": "1.0.1" + }, + { + "expect": "fail", + "name": "other slug", + "slug": "other" + }, + { + "expect": "fail", + "name": "flipped byte", + "tarball_append_byte": true + }, + { + "expect": "fail", + "name": "source signature as platform signature", + "signature": "source" + } + ], + "contract": "plugins.release-signature v1.1", + "key_id": "plugins-b0a54b8c82cd374c", + "platform": "linux-amd64", + "platform_message": "nself-plugin-bin-v1\\n\\n\\n\\n\\n", + "platform_sha256": "0bbf761b252a3f33a7e560bc6e2f6e14368b5fad183ba3b55b3d94cd1c423934", + "platform_signature": { + "alg": "ed25519", + "key_id": "plugins-b0a54b8c82cd374c", + "sig": "UXydM3ZxI/cFhxHbz1Czwy4OMXBzpUptrNoELV8ioVuuXIp2f+WwZAphmZWzKhGbNMO1jjHuxlbXBEzy26IIBg==" + }, + "platform_tarball": "demo-1.0.0-linux-amd64.tar.gz", + "public_key": "fixture.pub", + "slug": "demo", + "source_sha256": "635edf24942ec563525eb92dc487921c1bb0ff7fb2f9941976752f13a54e17d2", + "source_signature": { + "alg": "ed25519", + "key_id": "plugins-b0a54b8c82cd374c", + "sig": "UjlQd8JE1gHR+Q6+w6j6bHVx/FalkoYpeiwICNI60PytRFbBYS0yWdDfqEBfeAbuGfNk4rbACfX7km+1YOouBg==" + }, + "source_tarball": "demo-1.0.0.tar.gz", + "version": "1.0.0" +} diff --git a/scripts/release/verify-signatures.sh b/scripts/release/verify-signatures.sh index c3284cf3d..3d145bcab 100755 --- a/scripts/release/verify-signatures.sh +++ b/scripts/release/verify-signatures.sh @@ -2,17 +2,18 @@ # verify-signatures.sh --pub FILE DIR # # Purpose: check every *.tar.gz in DIR against DIR/releases.json and against exactly one public key, the way an installer -# must (hq PLUG D7, contract plugins.release-signature v1): checksum first, then signature, both before anything is extracted. +# must (hq PLUG D7, contract plugins.release-signature v1.1): checksum first, then signature, both before anything is extracted. # There is no key discovery: the key is the one --pub names (PEM SPKI Ed25519), so a rehearsal signature verifies against the # rehearsal test key and fails against every other key, committed or not. A check that only compared sha256 to releases.json # would pass for any key; this one does not. # # Input: DIR holds tarballs and a releases.json in the release-data shape ({"releases": {"": {"version", "sha256", -# "platform_checksums", "release_signature": {"key_id", "alg", "sig"}}}}). rehearse.sh writes it; P7-PLUG-69 copies the -# published one. A tarball is matched to its row by name: -.tar.gz is the source tarball (sha256 + signature), -# --.tar.gz is a binary tarball. LIMIT: a binary tarball is checked against platform_checksums in the -# same unsigned releases.json and nothing more (the signature covers the source tarball); its provenance is the GitHub attestation -# (`gh attestation verify`). The summary line counts the two kinds apart so this is never silent. +# "platform_checksums", "release_signature": {"key_id", "alg", "sig"}, "platform_signatures": {"": {"key_id", +# "alg", "sig"}}}}}). rehearse.sh writes it; P7-PLUG-69 copies the published one. A tarball is matched to its row by name: +# -.tar.gz is the source tarball (sha256 + signature over "nself-plugin-v1\n\n\n\n"), +# --.tar.gz is a binary tarball (sha256 in platform_checksums + signature in platform_signatures over +# "nself-plugin-bin-v1\n\n\n\n\n"). On a signed row (non-null release_signature) a binary +# tarball with a checksum but no platform signature is a failure. The summary line counts the two kinds apart. # # Output (stderr, exit 1 on any failure; nothing on stdout but the summary), per tarball, in this order: # checksum mismatch: sha256 of the file differs from releases.json @@ -20,6 +21,11 @@ # missing tarball: releases.json names a tarball that is not in DIR (a dropped file never passes) # unsigned: the row has no release_signature # signature mismatch: bad alg, key id other than the one derived from --pub, or Ed25519 verification failed +# unsigned platform: a signed row has a binary tarball but no platform signature for it +# platform signature mismatch: the platform signature is malformed, made by another key, or does not +# verify for this slug, version, platform and these bytes +# orphan platform signature: platform_signatures names a platform with no platform_checksums entry, +# or sits on a row with no release_signature # Exit 0 only when every tarball passes. Exit 2 on usage errors. # # Requires jq and OpenSSL 3 (LibreSSL cannot verify Ed25519 -rawin); set OPENSSL to pick the binary. @@ -59,6 +65,22 @@ PUB_TEXT="$("$O" pkey -pubin -in "$PUB" -noout -text 2>/dev/null)" || { err "not case "$PUB_TEXT" in "ED25519 Public-Key:"*) ;; *) err "not an Ed25519 public key: $PUB"; exit 2 ;; esac EXPECTED_ID="plugins-$("$O" pkey -pubin -in "$PUB" -outform DER 2>/dev/null | tail -c 32 | { sha256sum 2>/dev/null || shasum -a 256; } | cut -c1-16)" +# sig_check FILTER SLUG PLATFORM MSGFILE: succeeds when the object FILTER selects from releases.json is {alg ed25519, key_id of --pub, +# canonical 64-byte base64 sig} and the signature verifies over MSGFILE with --pub. jq compares the exact strings (a trailing newline in +# key_id or alg is a different string) and reads sig with -j so nothing is trimmed. sig must be canonical base64 of 64 bytes: 86 +# characters and "==", the same text cli sdk/go/signing EncodeSig writes and DecodeSig accepts. +sig_check() { + local sig + sig="$(jq -j --arg s "$2" --arg p "$3" --arg id "$EXPECTED_ID" "try ($1) catch null"' + | select(type == "object" and .alg == "ed25519" and .key_id == $id and (.sig | type) == "string" and (.sig | test("\\A[A-Za-z0-9+/]{86}==\\z"))) + | .sig' "$RELEASES")" || return 1 + [ -n "$sig" ] || return 1 + printf '%s' "$sig" | "$O" base64 -d -A > "$WORK/sig" 2>/dev/null || return 1 + [ "$(wc -c < "$WORK/sig" | tr -d ' ')" = "64" ] || return 1 + [ "$("$O" base64 -A -in "$WORK/sig")" = "$sig" ] || return 1 + "$O" pkeyutl -verify -rawin -pubin -inkey "$PUB" -in "$4" -sigfile "$WORK/sig" >/dev/null 2>&1 +} + # Row lookup by file name: one jq pass writes "filekindslugversionplatform" for every known asset name. # Only the five published platforms count as binary tarballs, and a file name may appear once: a name that two rows could claim # is ambiguous release data, never a way to be checked as the weaker kind. @@ -78,6 +100,16 @@ fail=0 checked=0 source_ok=0 platform_ok=0 + +# A platform signature must sit on a signed row and name a platform the row has a checksum for; anything else is not a release this +# producer writes (a signature "for" bytes nothing records is never a pass). Rows with a non-object platform_signatures are orphans too. +while IFS=$'\t' read -r o_slug o_version o_platform; do + [ -n "$o_slug" ] || continue + err "orphan platform signature: $o_slug $o_version $o_platform" + fail=1 +done < <(jq -r '.releases | to_entries[] | .key as $s | .value as $v | select($v.version != null and ($v | has("platform_signatures"))) + | ($v.platform_signatures | if type == "object" then keys else ["(not an object)"] end)[] as $p + | select(($v.release_signature | type) != "object" or (($v.platform_checksums // {}) | has($p) | not)) | [$s, $v.version, $p] | @tsv' "$RELEASES") shopt -s nullglob for f in "$DIR"/*.tar.gz; do name="$(basename "$f")" @@ -107,29 +139,32 @@ for f in "$DIR"/*.tar.gz; do continue fi if [ "$kind" = "platform" ]; then + # A row without a source signature fails on its source tarball ("unsigned"), so its binary tarballs are not reported again here. + if [ "$(jq -r --arg s "$slug" '.releases[$s].release_signature | type' "$RELEASES")" = "null" ]; then + continue + fi + if [ "$(jq -r --arg s "$slug" --arg p "$platform" 'try (.releases[$s].platform_signatures[$p] | type) catch "null"' "$RELEASES")" = "null" ]; then + err "unsigned platform: $slug $version $platform" + fail=1 + continue + fi + printf 'nself-plugin-bin-v1\n%s\n%s\n%s\n%s\n' "$slug" "$version" "$platform" "$actual" > "$WORK/msg" + if ! sig_check '.releases[$s].platform_signatures[$p]' "$slug" "$platform" "$WORK/msg"; then + err "platform signature mismatch: $slug $version $platform" + fail=1 + continue + fi platform_ok=$((platform_ok + 1)) continue fi - # jq compares the exact strings (a trailing newline in key_id or alg is a different string) and reads sig with -j so nothing is trimmed. - # sig must be canonical base64 of 64 bytes: 86 characters and "==", the same text cli sdk/go/signing EncodeSig writes and DecodeSig accepts. if [ "$(jq -r --arg s "$slug" '.releases[$s].release_signature | type' "$RELEASES")" = "null" ]; then err "unsigned: $slug $version" fail=1 continue fi - sig="$(jq -j --arg s "$slug" --arg id "$EXPECTED_ID" '.releases[$s].release_signature - | select(type == "object" and .alg == "ed25519" and .key_id == $id and (.sig | type) == "string" and (.sig | test("\\A[A-Za-z0-9+/]{86}==\\z"))) - | .sig' "$RELEASES")" - if [ -z "$sig" ]; then - err "signature mismatch: $slug $version" - fail=1 - continue - fi printf 'nself-plugin-v1\n%s\n%s\n%s\n' "$slug" "$version" "$actual" > "$WORK/msg" - if ! printf '%s' "$sig" | "$O" base64 -d -A > "$WORK/sig" 2>/dev/null || [ "$(wc -c < "$WORK/sig" | tr -d ' ')" != "64" ] || - [ "$("$O" base64 -A -in "$WORK/sig")" != "$sig" ] || - ! "$O" pkeyutl -verify -rawin -pubin -inkey "$PUB" -in "$WORK/msg" -sigfile "$WORK/sig" >/dev/null 2>&1; then + if ! sig_check '.releases[$s].release_signature' "$slug" "" "$WORK/msg"; then err "signature mismatch: $slug $version" fail=1 continue @@ -149,4 +184,4 @@ if [ "$checked" -eq 0 ]; then err "missing release data: no tarballs in $DIR" exit 1 fi -printf "verified %s source tarball(s) (checksum + signature) and %s binary tarball(s) (checksum only) against %s (%s)\n" "$source_ok" "$platform_ok" "$PUB" "$EXPECTED_ID" +printf "verified %s source tarball(s) and %s binary tarball(s) (checksum + signature each) against %s (%s)\n" "$source_ok" "$platform_ok" "$PUB" "$EXPECTED_ID" diff --git a/scripts/verify-published-checksums.sh b/scripts/verify-published-checksums.sh index 8de52c6fa..5cdba5135 100755 --- a/scripts/verify-published-checksums.sh +++ b/scripts/verify-published-checksums.sh @@ -22,14 +22,20 @@ # summary line; nothing is modified. Exit 0 if no mismatches, exit 1 if any (a value disagreed or an expected asset was # missing from the release). # --write: writes version, sha256, release_tag, tarball_url, platform_checksums and binaries (the nself-* files in the -# linux-amd64 tarball) into releases.json, keeping an existing release_signature, then tells you to run +# linux-amd64 tarball) into releases.json, keeping an existing release_signature and platform_signatures, then tells you to run # scripts/plugin-counts.sh to regenerate registry.json. Exit 0 on success, exit 1 if any expected asset could not be # downloaded (releases.json is not written then). # --signatures FILE (with --write): the plugins to write are exactly the signed ones (plus rows already on TAG), instead of the # manifest-version scan. FILE is the release workflow's signing output, {"": {"sha256", "release_signature"}} # (see scripts/release/sign.sh). A row's release_signature is taken from it only when its sha256 equals the freshly # downloaded published bytes; a row with no matching signature is UNSIGNED and, like a missing asset, stops the write (exit 1), -# so a release can never land half-signed. Without the flag an existing release_signature is kept (nulled if the bytes changed). +# so a release can never land half-signed. The same holds per platform (contract plugins.release-signature v1.1): FILE rows may +# carry "platform_signatures": {"": {"sha256", "key_id", "alg", "sig"}}, a published platform tarball whose sha256 has +# no signature over exactly that sha256 is UNSIGNED, and releases.json gets platform_signatures {"": {"key_id", +# "alg", "sig"}} (the sha256 is dropped: it is platform_checksums). Without the flag an existing release_signature is kept +# (nulled if the bytes changed) and an existing platform signature is kept only while the row's version, source sha256 and that +# platform's sha256 are all unchanged (a binary tarball carries no version, so identical bytes under a new version would +# otherwise keep a signature made for the old one); otherwise it is dropped. # # Constraints: requires `gh` (authenticated, repo access), `jq`, `tar`, and `sha256sum`/`shasum`. Never creates or publishes # a release, never tags, never touches anything but releases.json. @@ -246,6 +252,26 @@ while IFS= read -r name; do fi fi + psigs_json="{}" + if [ "$WRITE" = "true" ] && [ -n "$SIGNATURES_FILE" ]; then + # Every published platform tarball needs a signature over exactly its published sha256 (the same no-half-signed rule as the source). + unsigned_platform=0 + for platform in $(jq -r 'keys[]' <<<"$platforms_json"); do + p_sha="$(jq -r --arg p "$platform" '.[$p]' <<<"$platforms_json")" + psig="$(jq -c --arg n "$name" --arg p "$platform" --arg sha "$p_sha" 'try (.[$n].platform_signatures[$p]) catch null + | select(type == "object" and .sha256 == $sha and (.key_id | type) == "string" and (.alg | type) == "string" and (.sig | type) == "string") + | {key_id, alg, sig}' "$SIGNATURES_FILE")" + if [ -z "$psig" ]; then + printf "UNSIGNED %s/%s: %s has no platform signature for the published sha256 %s\n" "$name" "$platform" "$SIGNATURES_FILE" "$p_sha" + UNSIGNED=$((UNSIGNED + 1)) + unsigned_platform=1 + continue + fi + psigs_json="$(jq -c --arg p "$platform" --argjson s "$psig" '.[$p] = $s' <<<"$psigs_json")" + done + [ "$unsigned_platform" -eq 0 ] || continue + fi + if [ "$WRITE" = "true" ]; then # The binaries a released CLI requires of the archive: the nself-* files in the linux-amd64 tarball. binaries_json="[]" @@ -256,13 +282,20 @@ while IFS= read -r name; do fi RELEASES_JSON="$(jq --arg n "$name" --arg v "$VERSION" --arg tag "$TAG" --arg sha "$computed_sha" \ --arg url "https://github.com/${REPO}/releases/download/${TAG}/${tarball_name}" \ - --argjson plat "$platforms_json" --argjson bins "$binaries_json" --argjson sig "$sig_json" ' - .releases[$n] = ((.releases[$n] // {release_signature: null}) + --argjson plat "$platforms_json" --argjson bins "$binaries_json" --argjson sig "$sig_json" \ + --argjson psig "$psigs_json" --argjson signed "$([ -n "$SIGNATURES_FILE" ] && echo true || echo false)" ' + (.releases[$n] // {release_signature: null}) as $old + | .releases[$n] = ($old | (if .sha256 != $sha then .release_signature = null else . end) | (if $sig != null then .release_signature = $sig else . end) | . + {version: $v, sha256: $sha, release_tag: $tag, tarball_url: $url} | del(.tarball) | if ($plat | length) > 0 then .platform_checksums = $plat else del(.platform_checksums) end + | .platform_signatures = (if $signed then $psig + elif $old.version == $v and .release_signature != null then + (($old.platform_signatures // {}) | with_entries(select(.key as $p | $plat[$p] != null and ($old.platform_checksums // {})[$p] == $plat[$p]))) + else {} end) + | if (.platform_signatures | length) == 0 then del(.platform_signatures) else . end | if ($bins | length) > 0 then .binaries = $bins else del(.binaries) end)' <<<"$RELEASES_JSON")" WRITTEN=$((WRITTEN + 1)) fi From 9318383b3eccf06ff3212f64dbc8a606e8e77f43 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 19 Oct 2026 20:18:32 -0400 Subject: [PATCH 2/2] fix(release): review fixes for platform signing (built-check name guard, recovery wording, idempotency note, empty-list guard) --- .github/wiki/Releasing.md | 5 ++++- .github/workflows/release-tarballs.yml | 13 +++++++++---- scripts/release/platform-built-check.sh | 4 ++++ scripts/release/platform-signatures-test.sh | 8 ++++++++ scripts/release/verify-signatures.sh | 2 ++ 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/wiki/Releasing.md b/.github/wiki/Releasing.md index 57620f498..b91bbb7ca 100644 --- a/.github/wiki/Releasing.md +++ b/.github/wiki/Releasing.md @@ -59,7 +59,10 @@ asset that is already on the Release stays the artifact of record and everything - The `sign` job signs a platform tarball only if its ` ` line is in `built-sha256.txt`, the list `publish` wrote of the tarballs this run built (`scripts/release/platform-built-check.sh`). The publish job never uses `--clobber`, so a tarball already on the Release wins over this run's build; without this check anyone with `contents: write` could upload a binary while the reviewer decides and receive the official platform signature. A refusal fails the job - with nothing more signed: delete the foreign or draft-uploaded platform asset from the Release and re-run, so this run's build is the one published. + with nothing more signed: delete that platform tarball AND its `.sha256` from the Release (the first run uploaded a `.sha256` from the foreign bytes, and `publish` + never overwrites one) and re-run, so this run's build is the one published. Two consequences to plan for: a draft-first upload of platform tarballs (the old flow) now fails `sign`, + so for a plugin with binaries let the workflow publish the platform tarballs (or delete the draft's platform assets before tagging); and a full re-run whose rebuild is not + byte-identical to the published platform tarballs (changed runner gzip or Go toolchain) fails `sign` the same way. - `release-data` writes a platform signature into `releases.json` only when the signed sha256 equals the sha256 of the published bytes in that run; a published platform tarball with no such signature stops the write (`UNSIGNED`), so a release never lands half-signed. Rewriting an existing row keeps a platform signature only while the row's version, source sha256 and that platform's sha256 are all unchanged. diff --git a/.github/workflows/release-tarballs.yml b/.github/workflows/release-tarballs.yml index fb8a7812a..5c620a550 100644 --- a/.github/workflows/release-tarballs.yml +++ b/.github/workflows/release-tarballs.yml @@ -16,7 +16,10 @@ name: Release Plugin Tarballs (Free) # GITHUB_TOKEN. No PAT, no cross-repo trigger. The projections (registry.json, catalog.json, counts.json) need the # private bundles repo, which this token cannot read: the PR body tells the maintainer to regenerate them. # Checksums and signatures always describe the bytes on the Release, never a rebuild: the Release asset is the artifact of record. -# Idempotent: re-running skips assets already present on the release. See .github/wiki/Releasing.md. +# Idempotent: re-running skips assets already present on the release. Limit (contract v1.1): the `sign` job signs a platform tarball +# only if THIS run built the exact bytes, so a full re-run whose rebuild is not byte-identical to the published platform tarballs +# (a changed runner gzip/zlib or Go toolchain) fails `sign`; a draft-first upload of platform tarballs fails it too. See Releasing.md. +# See .github/wiki/Releasing.md. on: push: @@ -409,7 +412,7 @@ jobs: { echo "### Published tarballs for ${TAG}" echo - echo "$(wc -l < published-sha256.txt | tr -d ' ') tarball(s) on the Release; $(wc -l < not-attested.txt | tr -d ' ') differ from this run's rebuild (published, not attested); $(wc -l < built-sha256.txt | tr -d ' ') built by this run and eligible for platform signing." + echo "$(wc -l < published-sha256.txt | tr -d ' ') tarball(s) on the Release; $(wc -l < not-attested.txt | tr -d ' ') differ from this run's rebuild (published, not attested); $(wc -l < built-sha256.txt | tr -d ' ') built by this run (source and platform tarballs); only those platform tarballs are eligible for platform signing." echo echo '```' cat published-sha256.txt @@ -610,8 +613,10 @@ jobs: ln -s "${PWD}/${f}" "$bin_stage/" done done - if ! bash scripts/release/platform-built-check.sh expected/built-sha256.txt "$bin_stage"; then - echo "ERROR: a platform tarball on the Release was not built by this run; nothing is signed. Delete the foreign asset and re-run the release (Releasing.md)." + if [ -z "$(ls -A "$bin_stage")" ]; then + echo "No platform tarball for the signed plugins; nothing to sign beyond the source tarballs" + elif ! bash scripts/release/platform-built-check.sh expected/built-sha256.txt "$bin_stage"; then + echo "ERROR: a platform tarball on the Release was not built by this run; nothing is signed. Delete that platform tarball AND its .sha256 from the Release, then re-run the release (Releasing.md)." exit 1 fi : > platform-signatures.ndjson diff --git a/scripts/release/platform-built-check.sh b/scripts/release/platform-built-check.sh index cfe37efd5..05da183f5 100755 --- a/scripts/release/platform-built-check.sh +++ b/scripts/release/platform-built-check.sh @@ -46,6 +46,10 @@ shopt -s nullglob for f in "$DIR"/*-darwin-amd64.tar.gz "$DIR"/*-darwin-arm64.tar.gz "$DIR"/*-linux-amd64.tar.gz "$DIR"/*-linux-arm64.tar.gz "$DIR"/*-windows-amd64.tar.gz; do name="$(basename "$f")" checked=$((checked + 1)) + # A name with a line break or tab could smuggle a granted "" line into the match below. + case "$name" in + *$'\n'*|*$'\t'*) err "not built by this run: refusing a file name with a line break or tab"; fail=1; continue ;; + esac sha="$(sha256_file "$f")" if ! printf '%s\n' "$GRANTS" | grep -qxF -- "$(printf '%s\t%s' "$sha" "$name")"; then err "not built by this run: $name" diff --git a/scripts/release/platform-signatures-test.sh b/scripts/release/platform-signatures-test.sh index 555ec36ec..d17b7b822 100755 --- a/scripts/release/platform-signatures-test.sh +++ b/scripts/release/platform-signatures-test.sh @@ -181,6 +181,14 @@ rc=0; check "$T/nope.txt" "$BIN" >/dev/null 2>&1 || rc=$? [ "$rc" = 2 ] && pass "built check: a missing list is a usage error, not a pass" || fail "missing list exit $rc" rc=0; check "$BUILT" "$T/nodir" >/dev/null 2>&1 || rc=$? [ "$rc" = 2 ] && pass "built check: a missing directory is a usage error" || fail "missing directory exit $rc" +# A file name carrying a line break plus a granted line must not ride on that grant (Opus review). +NL="$T/newline"; mkdir "$NL" +printf 'evil' > "$T/evil.bin" +evsha="$(sha256_file "$T/evil.bin")" +nlname="x"$'\n'"${evsha}"$'\t'"bar-linux-amd64.tar.gz" +cp "$T/evil.bin" "$NL/$nlname" +printf '%s bar-linux-amd64.tar.gz\n' "$evsha" > "$T/nl.txt" +if check "$T/nl.txt" "$NL" >/dev/null 2>&1; then fail "built check passed a file name with a line break"; else pass "built check refuses a file name with a line break"; fi # Symlinked entries are read through (the workflow stages symlinks). LNK="$T/linked"; mkdir "$LNK"; ln -s "$PLIN" "$LNK/demo-1.2.3-linux-amd64.tar.gz" check "$BUILT" "$LNK" >/dev/null 2>&1 && pass "built check follows symlinks" || fail "built check refused a symlinked built tarball" diff --git a/scripts/release/verify-signatures.sh b/scripts/release/verify-signatures.sh index 3d145bcab..27d750e16 100755 --- a/scripts/release/verify-signatures.sh +++ b/scripts/release/verify-signatures.sh @@ -149,6 +149,7 @@ for f in "$DIR"/*.tar.gz; do continue fi printf 'nself-plugin-bin-v1\n%s\n%s\n%s\n%s\n' "$slug" "$version" "$platform" "$actual" > "$WORK/msg" + # shellcheck disable=SC2016 # the jq filter is passed as text; $s and $p are jq variables if ! sig_check '.releases[$s].platform_signatures[$p]' "$slug" "$platform" "$WORK/msg"; then err "platform signature mismatch: $slug $version $platform" fail=1 @@ -164,6 +165,7 @@ for f in "$DIR"/*.tar.gz; do continue fi printf 'nself-plugin-v1\n%s\n%s\n%s\n' "$slug" "$version" "$actual" > "$WORK/msg" + # shellcheck disable=SC2016 # the jq filter is passed as text; $s is a jq variable if ! sig_check '.releases[$s].release_signature' "$slug" "" "$WORK/msg"; then err "signature mismatch: $slug $version" fail=1