diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2824bfd..2ef7c0c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,6 +2,10 @@ # smoke-test all four bundles, then upload them (plus .sha256 companions) to # the matching stackql/stackql GitHub release. # +# Can also be re-run from the Actions tab (workflow_dispatch) to re-publish +# the pinned release from current main - e.g. after enabling signing secrets - +# without moving the tag. The confirm_release input must match release.yaml. +# # Requires one repo secret: # STACKQL_RELEASE_TOKEN - fine-grained PAT (or GitHub App token) with # contents:write on stackql/stackql. The default GITHUB_TOKEN is scoped to @@ -12,6 +16,12 @@ name: publish on: push: tags: ['v*'] + workflow_dispatch: + inputs: + confirm_release: + description: Re-publish guard - type the release exactly as pinned in release.yaml (e.g. v0.10.500) + required: true + type: string permissions: contents: read @@ -27,11 +37,17 @@ jobs: - name: Compare tag against pinned release id: cfg + env: + CONFIRM_RELEASE: ${{ inputs.confirm_release }} run: | cfg="$(sed -n 's/^stackql_release:[[:space:]]*//p' release.yaml | tr -d ' \r')" - tag="$GITHUB_REF_NAME" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + tag="$CONFIRM_RELEASE"; ctx="confirm_release input" + else + tag="$GITHUB_REF_NAME"; ctx="tag" + fi if [ "$tag" != "$cfg" ]; then - echo "::error::tag '$tag' does not match stackql_release '$cfg' in release.yaml" + echo "::error::$ctx '$tag' does not match stackql_release '$cfg' in release.yaml" exit 1 fi echo "publishing for $tag" diff --git a/README.md b/README.md index 797036c..abad1a5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ stackql-mcpb-packaging/ scripts/package.sh # build bundles from bin/ -> dist/ scripts/clean.sh # wipe dist/ scripts/render-server-json.sh # pin SHAs into registry/server.json + scripts/sign.sh # envelope-sign dist/*.mcpb + regen .sha256 + scripts/append-signature.py # frame an externally-produced CMS signature scripts/smoke-test.py # deterministic MCP smoke test (stdlib only) scripts/gemini-smoke.py # optional Gemini Flash agent smoke test docs/install.md # end-user install guide @@ -87,6 +89,8 @@ The sequence: 3. **Merge to main** - nothing is published yet. 4. **Push the matching tag** - `git tag vX.Y.Z && git push origin vX.Y.Z`. [publish.yml](.github/workflows/publish.yml) fails fast if the tag does not exactly match `release.yaml`, rebuilds and re-tests everything, then uploads all `.mcpb` + `.sha256` files to the `stackql/stackql` `vX.Y.Z` release via `make publish` (idempotent `--clobber`). +To re-publish the pinned release without moving the tag (e.g. after enabling signing secrets), run the publish workflow from the Actions tab (`workflow_dispatch`); the `confirm_release` input must be typed exactly as pinned in `release.yaml` (e.g. `v0.10.500`). It runs from current main and clobbers the existing release assets. + One-time setup: add a repo secret `STACKQL_RELEASE_TOKEN` - a fine-grained PAT (or GitHub App token) with `contents:write` on `stackql/stackql`. The default `GITHUB_TOKEN` cannot upload assets to another repo. Optionally add `GEMINI_API_KEY` to enable the agent smoke test on the linux-x64 job; without it that step soft-skips. Optional envelope signing: if the repo secrets `MCPB_SIGNING_CERT` and `MCPB_SIGNING_KEY` (PEM contents, plus optional `MCPB_SIGNING_INTERMEDIATES`) are set, the publish job runs `make sign` to `mcpb sign` every bundle and regenerate its `.sha256` before upload. Without the secrets the step prints a notice and skips, and unsigned bundles ship as before. Note `mcpb verify` in the current CLI is broken upstream (node-forge cannot verify PKCS#7, so every signed bundle reports as unsigned); `make sign` treats it as advisory and asserts the appended signature block instead. @@ -265,6 +269,28 @@ Dormant, intentionally: For end users, this means: the binary that actually runs is fully signed by the platform's trust authority; the bundle wrapping it is hash-pinned and registry-verified; the editorial vetting is layered on top via the Anthropic directory. Self-signed envelopes (`make signed`) are for local testing only and not suitable for release. +### Envelope signing with the hardware token + +`mcpb sign` requires a PEM key on disk, which the token cannot export. The workaround is to produce the detached CMS signature externally and frame it with [scripts/append-signature.py](scripts/append-signature.py), which emits the same byte layout as `mcpb sign` (`MCPB_SIG_V1` + 4-byte LE length + DER PKCS#7 + `MCPB_SIG_END`) and regenerates the `.sha256`: + +```bash +# 1. Sign the unsigned bundle bytes with the token via the PKCS#11 engine +# (prompts for the token PIN; cert.pem/chain.pem are the public materials +# exported from the token). +openssl cms -sign -binary -in dist/stackql-mcp-linux-x64.mcpb \ + -signer cert.pem -certfile chain.pem \ + -keyform engine -engine pkcs11 -inkey "pkcs11:type=private" \ + -outform DER -out sig.der + +# 2. Frame and append it, regenerating the .sha256. +python scripts/append-signature.py dist/stackql-mcp-linux-x64.mcpb sig.der + +# 3. Re-upload (idempotent). +make publish VERSION=X.Y.Z +``` + +This is interactive (PIN prompt), so it is a local flow, not a CI step. Requires an OpenSSL build with the PKCS#11 engine (libp11) pointed at the token vendor's PKCS#11 module. `--strip-only` removes an existing signature block if you need the unsigned bytes back. + ## Makefile reference ``` diff --git a/scripts/append-signature.py b/scripts/append-signature.py new file mode 100644 index 0000000..2311175 --- /dev/null +++ b/scripts/append-signature.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Append an externally-produced PKCS#7/CMS signature to a .mcpb bundle in the +MCPB signature framing, and regenerate the bundle's .sha256. + +This exists for signing keys that cannot be exported to PEM (hardware tokens, +HSMs): produce a detached DER SignedData over the unsigned bundle bytes with +any external tool (e.g. OpenSSL with a PKCS#11 engine), then frame and append +it here. The result is byte-compatible with 'mcpb sign' output: + + [zip bytes][MCPB_SIG_V1][4-byte LE sig length][DER PKCS#7][MCPB_SIG_END] + +Typical flow with a hardware token: + + openssl cms -sign -binary -in dist/stackql-mcp-linux-x64.mcpb \ + -signer cert.pem -certfile chain.pem \ + -keyform engine -engine pkcs11 -inkey "pkcs11:type=private" \ + -outform DER -out sig.der + python scripts/append-signature.py dist/stackql-mcp-linux-x64.mcpb sig.der + +If the bundle already carries a signature block it is replaced (the external +signature must have been produced over the *unsigned* bytes; pass the bundle +through this script only after signing the stripped content - or use +--strip-only first to get the unsigned bytes to sign). + +Usage: + python scripts/append-signature.py + python scripts/append-signature.py --strip-only +""" +from __future__ import annotations + +import hashlib +import struct +import sys +from pathlib import Path + +HEADER = b"MCPB_SIG_V1" +FOOTER = b"MCPB_SIG_END" + + +def strip_signature(content: bytes) -> bytes: + """Return the bundle bytes without any existing signature block.""" + footer_idx = content.rfind(FOOTER) + if footer_idx == -1: + return content + header_idx = content.rfind(HEADER, 0, footer_idx) + if header_idx == -1: + return content + return content[:header_idx] + + +def write_sha256(bundle: Path) -> None: + digest = hashlib.sha256(bundle.read_bytes()).hexdigest() + sha_path = bundle.with_name(bundle.name + ".sha256") + sha_path.write_text(f"{digest} {bundle.name}\n") + print(f"{digest} {bundle.name}") + + +def main(argv: list[str]) -> int: + if len(argv) == 3 and argv[1] == "--strip-only": + bundle = Path(argv[2]) + stripped = strip_signature(bundle.read_bytes()) + bundle.write_bytes(stripped) + write_sha256(bundle) + print(f"stripped signature block (if any) from {bundle.name}") + return 0 + + if len(argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + + bundle, sig = Path(argv[1]), Path(argv[2]) + if not bundle.exists(): + print(f"error: bundle not found: {bundle}", file=sys.stderr) + return 1 + if not sig.exists(): + print(f"error: signature not found: {sig}", file=sys.stderr) + return 1 + + sig_der = sig.read_bytes() + if not sig_der.startswith(b"\x30"): + print("error: signature does not look like DER (no ASN.1 SEQUENCE)", file=sys.stderr) + return 1 + + content = strip_signature(bundle.read_bytes()) + framed = content + HEADER + struct.pack("