diff --git a/.github/actions/setup-sfetch/action.yml b/.github/actions/setup-sfetch/action.yml new file mode 100644 index 0000000..c9976a4 --- /dev/null +++ b/.github/actions/setup-sfetch/action.yml @@ -0,0 +1,190 @@ +--- +# setup-sfetch — verified sfetch bootstrap for GitHub Actions +# +# Public contract (additive-only inputs after first release): +# sfetch-version (required) exact tag in this action SHA's supported range +# goneat-version (optional) exact tag; no floating default +# install-dir (optional) binary install directory +# +# Trust model: +# Consumers pin this action by commit SHA (not a moving tag). That SHA is the +# TCB for the verification engine — same model as actions/checkout. +# GitHub checks out the whole action repository for a nested composite action. +# The engine is the single canonical script at +# /scripts/bootstrap-sfetch-verified.sh (resolved from +# GITHUB_ACTION_PATH/../../../scripts/...). Never from GITHUB_WORKSPACE +# (consumer repo). Trust anchor is embedded in the engine; never fetched from +# the release being authenticated. +# +# Dual-route (selected by sfetch-version; machine field route= on stdout): +# >= v0.4.11 → install-sfetch.sh.minisig +# < v0.4.11 → signed SHA256SUMS + installer hash +# +# Fail-closed: no continue-on-error, no || true soft skips, no @latest tools, +# no Go toolchain requirement, no ambient minisign preference, no workspace +# engine fallback. One shared engine — this action is a thin wrapper. +# +# Supported sfetch-version range is declared by the engine revision shipped with +# this action SHA (see scripts/bootstrap-sfetch-verified.sh constants). +name: "Setup sfetch" +description: "Install a pinned, minisign-verified sfetch (optional goneat) without pipe-to-bash" +author: "3leaps" + +inputs: + sfetch-version: + description: >- + Exact sfetch release tag (vMAJOR.MINOR.PATCH). Required. Never "latest". Must fall within the supported range of this action commit SHA. + required: true + goneat-version: + description: >- + Optional exact goneat release tag to install via verified sfetch after sfetch is installed. Empty means skip goneat. Never "latest". + required: false + default: "" + install-dir: + description: >- + Directory for installed binaries. Default: $HOME/.local/bin (created). + required: false + default: "" + +outputs: + sfetch-bin: + description: "Absolute path to the installed sfetch binary" + value: ${{ steps.install.outputs.sfetch-bin }} + goneat-bin: + description: "Absolute path to goneat when goneat-version was set" + value: ${{ steps.install.outputs.goneat-bin }} + route: + description: "Verification route taken (minisig or sha256sums)" + value: ${{ steps.install.outputs.route }} + +runs: + using: composite + steps: + - name: Verified sfetch install + id: install + shell: bash + env: + INPUT_SFETCH_VERSION: ${{ inputs.sfetch-version }} + INPUT_GONEAT_VERSION: ${{ inputs.goneat-version }} + INPUT_INSTALL_DIR: ${{ inputs.install-dir }} + run: | + set -euo pipefail + + # Resolve single canonical engine inside the action repository checkout. + # Nested composite actions live at .github/actions//; the package + # root is three levels up. Never consult GITHUB_WORKSPACE (consumer). + PACKAGE_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + ENGINE="${PACKAGE_ROOT}/scripts/bootstrap-sfetch-verified.sh" + if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then + echo "error: action-repo engine missing or unreadable: ${ENGINE}" >&2 + exit 1 + fi + if [ -d "${ENGINE}" ]; then + echo "error: engine path is a directory: ${ENGINE}" >&2 + exit 1 + fi + ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" + case "${ENGINE}" in + "${PACKAGE_ROOT}"/*) ;; + *) + echo "error: engine resolved outside action repository checkout: ${ENGINE}" >&2 + exit 1 + ;; + esac + # If workspace is a different tree, refuse to have resolved into it. + if [ -n "${GITHUB_WORKSPACE:-}" ]; then + WS_REAL="$(cd "${GITHUB_WORKSPACE}" && pwd)" + if [ "${PACKAGE_ROOT}" != "${WS_REAL}" ]; then + case "${ENGINE}" in + "${WS_REAL}"/*) + echo "error: engine resolved into consumer GITHUB_WORKSPACE" >&2 + exit 1 + ;; + esac + fi + fi + chmod +x "${ENGINE}" + + VERSION="${INPUT_SFETCH_VERSION:-}" + if [ -z "${VERSION}" ]; then + echo "error: sfetch-version is required" >&2 + exit 1 + fi + case "${VERSION}" in + latest|LATEST|main|master|HEAD|"") + echo "error: sfetch-version must be an exact tag (refusing ${VERSION:-empty})" >&2 + exit 1 + ;; + esac + + DIR="${INPUT_INSTALL_DIR:-}" + if [ -z "${DIR}" ]; then + DIR="${HOME}/.local/bin" + fi + mkdir -p "${DIR}" + + ARGS=(--version "${VERSION}" --dir "${DIR}" --yes) + if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then + case "${INPUT_GONEAT_VERSION}" in + latest|LATEST) + echo "error: goneat-version must be an exact tag (refusing ${INPUT_GONEAT_VERSION})" >&2 + exit 1 + ;; + esac + ARGS+=(--goneat-version "${INPUT_GONEAT_VERSION}") + fi + + # Capture machine stdout separately from human stderr logs. + # Engine is authoritative for version/route assertions before success. + LOG="$(mktemp)" + set +e + OUT="$("${ENGINE}" "${ARGS[@]}" 2>"${LOG}")" + RC=$? + set -e + cat "${LOG}" >&2 + if [ "${RC}" -ne 0 ]; then + echo "error: verified bootstrap failed (exit ${RC})" >&2 + rm -f "${LOG}" + exit "${RC}" + fi + rm -f "${LOG}" + + # Machine-readable fields only: exactly one ^route= line on stdout. + # awk count — no || true soft suppression. + ROUTE_COUNT="$(printf '%s\n' "${OUT}" | awk 'BEGIN{c=0} /^route=/{c++} END{print c}')" + if [ "${ROUTE_COUNT}" -ne 1 ]; then + echo "error: expected exactly one stdout route= field, got ${ROUTE_COUNT}" >&2 + exit 1 + fi + ROUTE="$(printf '%s\n' "${OUT}" | awk -F= '/^route=/{print $2; exit}')" + case "${ROUTE}" in + minisig|sha256sums) ;; + *) + echo "error: invalid route value: ${ROUTE}" >&2 + exit 1 + ;; + esac + + # Prefix strip (not field-split) so install paths containing '=' survive. + SFETCH_BIN="$(printf '%s\n' "${OUT}" | sed -n 's/^sfetch-bin=//p' | head -n1)" + GONEAT_BIN="$(printf '%s\n' "${OUT}" | sed -n 's/^goneat-bin=//p' | head -n1)" + + if [ -z "${SFETCH_BIN}" ] || [ ! -f "${SFETCH_BIN}" ]; then + echo "error: sfetch binary path missing after bootstrap" >&2 + exit 1 + fi + if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then + if [ -z "${GONEAT_BIN}" ] || [ ! -f "${GONEAT_BIN}" ]; then + echo "error: goneat was requested but binary path missing after bootstrap" >&2 + exit 1 + fi + fi + + echo "${DIR}" >> "${GITHUB_PATH}" + { + echo "sfetch-bin=${SFETCH_BIN}" + echo "goneat-bin=${GONEAT_BIN}" + echo "route=${ROUTE}" + } >> "${GITHUB_OUTPUT}" + + echo "setup-sfetch complete: version=${VERSION} route=${ROUTE} sfetch-bin=${SFETCH_BIN}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ecb32..5873d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,83 @@ jobs: echo "Install smoke pin: $TAG (make print-sfetch-version)" bash scripts/install-sfetch.sh --dry-run --tag "$TAG" --require-minisign + - name: Live verified bootstrap (N-1 SHA256SUMS route) + run: | + set -euo pipefail + TAG=$(make -s print-sfetch-version) + DEST="$RUNNER_TEMP/sfetch-bootstrap-live" + mkdir -p "$DEST" + ./scripts/bootstrap-sfetch-verified.sh --version "$TAG" --dir "$DEST" --yes + "$DEST/sfetch" --version + + # Dual-route consumer matrix: action thin-wraps the shared engine. + # v0.4.10 exercises sha256sums (backward pin). minisig route is unit-tested + # until v0.4.11 is published; after publish, add sfetch-version: v0.4.11 here. + setup-sfetch-matrix: + name: setup-sfetch (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + - os: macos-latest + - os: windows-latest + # Named org runner when available + - os: windows-latest-arm64-s + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - name: Setup sfetch via composite action (v0.4.10 sha256sums route) + uses: ./.github/actions/setup-sfetch + with: + sfetch-version: v0.4.10 + + - name: Assert sfetch on PATH + shell: bash + run: | + set -euo pipefail + command -v sfetch + # sfetch --version writes to stderr (not stdout) + sfetch --version 2>&1 | tee /tmp/sfetch-ver.txt + grep -E '0\.4\.10' /tmp/sfetch-ver.txt + + - name: Fail-closed optional tool (goneat not requested) + shell: bash + run: | + set -euo pipefail + # goneat must not be soft-installed when not requested + if command -v goneat >/dev/null 2>&1; then + echo "note: ambient goneat present on runner (not installed by action)" + else + echo "goneat absent as expected when goneat-version omitted" + fi + + - name: Fail-closed when requested goneat is unavailable + shell: bash + run: | + set -euo pipefail + # Request a non-existent goneat tag; engine must exit non-zero (no soft skip). + if ./scripts/bootstrap-sfetch-verified.sh \ + --version v0.4.10 \ + --dir "$RUNNER_TEMP/goneat-fail" \ + --goneat-version v0.0.0 \ + --yes; then + echo "error: requested unavailable goneat must fail closed" >&2 + exit 1 + fi + echo "requested-goneat failure path OK" + + - name: Reject floating sfetch-version + shell: bash + run: | + set -euo pipefail + # Exercise engine reject path directly (action would also fail) + if ./scripts/bootstrap-sfetch-verified.sh --version latest --dir "$RUNNER_TEMP/bad" --yes; then + echo "error: latest must be rejected" >&2 + exit 1 + fi + container-probe: name: Install probe (container) runs-on: ubuntu-latest @@ -120,10 +197,13 @@ jobs: with: go-version: '1.26.5' - - name: Install minisign - shell: pwsh + # Pinned official minisign 0.12 archive (no Chocolatey/winget). + - name: Install pinned minisign 0.12 + shell: bash run: | - choco install minisign -y --no-progress + set -euo pipefail + ./scripts/acquire-minisign-pinned.sh --dir "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build sfetch shell: pwsh @@ -151,15 +231,13 @@ jobs: with: go-version: '1.26.5' - - name: Install minisign - shell: pwsh + # Pinned official minisign 0.12 archive (no Chocolatey/winget). + - name: Install pinned minisign 0.12 + shell: bash run: | - if (Get-Command choco -ErrorAction SilentlyContinue) { - choco install minisign -y --no-progress - } else { - winget install -e --id FrankDenis.Minisign --silent ` - --accept-source-agreements --accept-package-agreements - } + set -euo pipefail + ./scripts/acquire-minisign-pinned.sh --dir "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build sfetch shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5915b1..3f63f48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,14 +25,21 @@ jobs: # The build matrix below appends per-platform archives to the same release. # Maintainer signs + adds SHA256SUMS/notes afterward via scripts/ # (see README "Manual signing workflow"). - - name: Create release and upload install script + # Draft until maintainer signs install-sfetch.sh + manifests and uploads + # via make release-upload. A published release without installer .minisig is + # non-consumable for verified bootstrap; draft keeps it off "latest". + - name: Create draft release and upload install script uses: softprops/action-gh-release@v3 with: name: sfetch ${{ github.ref_name }} body: | ## sfetch ${{ github.ref_name }} - Auto-generated release. - draft: false + Draft release — incomplete until maintainer signs and publishes. + + Do not bootstrap from this release until `install-sfetch.sh.minisig` + and signed checksum manifests are uploaded, then the release is + published (`gh release edit TAG --draft=false`). + draft: true prerelease: false files: scripts/install-sfetch.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c5873..4f1dcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.11] - 2026-07-31 + +### Added + +- **Detached `install-sfetch.sh.minisig`** on releases. Minisign signs the installer plus checksum manifests; PGP remains manifests-only. The installer signature is **required** by `make release-verify-signatures` and `upload-release-assets` (missing/tampered/wrong-key ⇒ non-zero). +- **`scripts/bootstrap-sfetch-verified.sh`** dual-route verified bootstrap: `.minisig` for ≥ v0.4.11, signed `SHA256SUMS` for earlier pins. Refuses `latest`/floating refs; embeds the trust anchor; pins minisign 0.12 provenance (including official Windows archive hashes). +- **Composite action** `.github/actions/setup-sfetch` thin-wrapping the shared engine (pin by commit SHA). +- Release signature and bootstrap regression harnesses wired into `make precommit`. + +### Changed + +- Tag **release workflow creates a draft** until maintainer sign/upload/publish so pre-signature assets never become `latest`. +- **`make bootstrap`** uses the verified engine against the N-1 pin (no pipe-to-bash). +- Security and CI docs: expanded signing set, dual-route consumer guidance, trust-anchor rotation notes, `--require-minisign` CLI vs installer default asymmetry. + ## [0.4.10] - 2026-07-30 ### Fixed diff --git a/Makefile b/Makefile index 798ea52..ecd651e 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ CORPUS_DEST ?= test-corpus .PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures release-verify .PHONY: release-clean bootstrap-script build-all gosec gosec-high update-scoop-manifest .PHONY: version-check version-set version-patch version-minor version-major -.PHONY: print-sfetch-version test-release-verify-checksums +.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified test-bootstrap-range-release all: build @@ -82,15 +82,18 @@ help: ## Show this help # Bootstrap - Trust Anchor Chain # ----------------------------------------------------------------------------- # -# Trust chain: curl -> sfetch (self-bootstrap) -> goneat +# Trust chain: verified bootstrap script -> sfetch (N-1 pin) -> goneat # -# sfetch bootstraps itself via curl, then uses itself to install goneat. -# This demonstrates sfetch eating its own dogfood. +# N-1 pin (SFETCH_VERSION) is always a published release — never this cut. +# At v0.4.11 the N-1 pin is v0.4.10, which has no install-sfetch.sh.minisig, +# so the shared engine takes the signed SHA256SUMS route. From v0.4.12 onward +# (when N-1 >= v0.4.11) the engine switches to the detached .minisig route. +# +# Do not pipe curl | bash here — that is the anti-pattern this release fixes. -bootstrap: ## Install development tools via trust chain +bootstrap: ## Install development tools via verified trust chain @echo "Bootstrapping sfetch development environment..." @echo "" - @# Step 0: Verify curl is available (required trust anchor) @if ! command -v curl >/dev/null 2>&1; then \ echo "[!!] curl not found (required for bootstrap)"; \ echo ""; \ @@ -102,16 +105,16 @@ bootstrap: ## Install development tools via trust chain fi @echo "[ok] curl found" @echo "" - @# Step 1: Install sfetch via curl (self-bootstrap trust anchor) @mkdir -p "$(BIN_DIR)" @if [ ! -x "$(BIN_DIR)/sfetch" ] && ! command -v sfetch >/dev/null 2>&1; then \ - echo "[..] Installing sfetch $(SFETCH_VERSION) (self-bootstrap)..."; \ - curl -fsSL https://github.com/3leaps/sfetch/releases/download/$(SFETCH_VERSION)/install-sfetch.sh | bash -s -- \ - --dir "$(BIN_DIR)" --tag "$(SFETCH_VERSION)" --require-minisign; \ + echo "[..] Installing sfetch $(SFETCH_VERSION) (verified bootstrap, N-1 pin)..."; \ + ./scripts/bootstrap-sfetch-verified.sh \ + --version "$(SFETCH_VERSION)" \ + --dir "$(BIN_DIR)" \ + --yes; \ else \ echo "[ok] sfetch already installed"; \ fi - @# Verify sfetch @SFETCH_BIN=""; \ if [ -x "$(BIN_DIR)/sfetch" ]; then SFETCH_BIN="$(BIN_DIR)/sfetch"; \ elif command -v sfetch >/dev/null 2>&1; then SFETCH_BIN="$$(command -v sfetch)"; fi; \ @@ -122,6 +125,7 @@ bootstrap: ## Install development tools via trust chain @if ! command -v goneat >/dev/null 2>&1; then \ echo "[!!] goneat not found on PATH"; \ echo " Install it from https://github.com/fulmenhq/goneat/releases (pinned: $(GONEAT_VERSION))"; \ + echo " Or: ./scripts/bootstrap-sfetch-verified.sh --version $(SFETCH_VERSION) --dir $(BIN_DIR) --goneat-version $(GONEAT_VERSION)"; \ exit 1; \ fi @echo "[ok] goneat: $$(goneat version 2>&1 | head -n1)" @@ -207,6 +211,9 @@ precommit: ## Run pre-commit checks (goneat assess + Go tests + build) $(MAKE) build-all # CI runs make precommit (not prepush); keep fail-closed release-verify regression on this path. $(MAKE) test-release-verify-checksums + $(MAKE) test-release-verify-signatures + $(MAKE) test-bootstrap-sfetch-verified + $(MAKE) test-bootstrap-range-release @echo "[ok] Pre-commit checks passed" prepush: precommit ## Run pre-push checks (same as precommit + security) @@ -276,6 +283,15 @@ release-verify-checksums: ## Verify checksums in dist/release (fail-closed; port test-release-verify-checksums: ## Regression: fail-closed checksum verify (corrupt/absent/empty) @./scripts/test-release-verify-checksums.sh +test-release-verify-signatures: ## Regression: required installer minisig + sign targets + @./scripts/test-release-verify-signatures.sh + +test-bootstrap-sfetch-verified: ## Regression: dual-route bootstrap rejects + fail-closed + @./scripts/test-bootstrap-sfetch-verified.sh + +test-bootstrap-range-release: ## Assert MAX==v(VERSION) and MINISIG_SINCE in range (committed constants) + @./scripts/assert-bootstrap-range-release.sh + release-notes: ## Copy release notes into dist/release @if [ -z "$(RELEASE_TAG)" ]; then echo "error: RELEASE_TAG not set" >&2; exit 1; fi @mkdir -p $(DIST_RELEASE) @@ -287,7 +303,7 @@ release-notes: ## Copy release notes into dist/release cp "$$src" "$(DIST_RELEASE)/release-notes-$(RELEASE_TAG).md" @echo "[ok] Release notes copied to $(DIST_RELEASE)" -release-sign: release-checksums ## Sign checksum manifests (minisign + optional PGP) +release-sign: release-checksums ## Sign manifests + installer (minisign); PGP manifests-only SFETCH_MINISIGN_KEY=$(SFETCH_MINISIGN_KEY) SFETCH_PGP_KEY_ID=$(SFETCH_PGP_KEY_ID) SFETCH_GPG_HOMEDIR=$(SFETCH_GPG_HOMEDIR) ./scripts/sign-release-manifests.sh $(RELEASE_TAG) $(DIST_RELEASE) release-export-key: ## Export PGP public key to dist/release @@ -328,7 +344,7 @@ release-verify-keys: release-verify-key ## Verify all exported public keys echo "ℹ️ No minisign public key to verify ($(MINISIGN_PUB_NAME) not found)"; \ fi -release-verify-signatures: ## Verify minisign and PGP signatures on checksum manifests +release-verify-signatures: ## Verify signatures (installer minisig required; PGP optional) ./scripts/verify-signatures.sh $(DIST_RELEASE) release-verify: release-verify-checksums release-verify-signatures release-verify-keys ## Full post-signing release verification diff --git a/README.md b/README.md index 5079a26..7edd654 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,35 @@ INSTALL_BINDIR=~/bin make install # override install location ### Bootstrap install +**Recommended (v0.4.11+):** verify the installer before execution. + ```bash -# Using curl +# Three-step path when install-sfetch.sh.minisig is published (v0.4.11+) +TAG=v0.4.11 +curl -fsSL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh" -o install-sfetch.sh +curl -fsSL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh.minisig" -o install-sfetch.sh.minisig +minisign -Vm install-sfetch.sh -P RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +bash install-sfetch.sh --tag "$TAG" --yes + +# Or use the shared engine (CI/Makefile; refuses "latest"): +# ./scripts/bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin +``` + +**GitHub Actions:** pin the composite action by commit SHA — see [CI/CD Usage Guide](docs/cicd-usage-guide.md). + +```yaml +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 +``` + +#### Quick install (interactive humans) + +Prefer a pinned tag. `latest` is fine for interactive use only — CI and Makefile +bootstrap **refuse** floating refs. + +```bash +# Using curl (human interactive) curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash # Using wget @@ -225,7 +252,7 @@ Pass arguments using `bash -s --`: curl -sSfL .../install-sfetch.sh | bash -s -- --dir ~/bin # Install specific version -curl -sSfL .../install-sfetch.sh | bash -s -- --tag v0.2.0 +curl -sSfL .../install-sfetch.sh | bash -s -- --tag v0.4.11 # Dry run (download and verify, don't install) curl -sSfL .../install-sfetch.sh | bash -s -- --dry-run @@ -242,9 +269,7 @@ The installer: - Requires minisign verification by default using the embedded trust anchor - Optional GPG fallback with pinned fingerprint; checksum-only requires explicit `--allow-checksum-only` -#### Verify before piping to bash - -For users who prefer to verify the installer before execution: +#### Verify via signed SHA256SUMS (pins ≤ v0.4.10) ```bash # Detect OS-appropriate SHA256 command (macOS uses shasum, Linux uses sha256sum) @@ -254,47 +279,32 @@ else SHA_CMD="shasum -a 256" fi -# Download assets (curl) -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh -o install-sfetch.sh -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS -o SHA256SUMS - -# Download assets (wget alternative - use -O to overwrite existing files) -# wget -qO install-sfetch.sh https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh -# wget -qO SHA256SUMS https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS - -# Option A: Verify with minisign (recommended) -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS.minisig -o SHA256SUMS.minisig +TAG=v0.4.10 +curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh" -o install-sfetch.sh +curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${TAG}/SHA256SUMS" -o SHA256SUMS +curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${TAG}/SHA256SUMS.minisig" -o SHA256SUMS.minisig minisign -Vm SHA256SUMS -P RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC $SHA_CMD -c SHA256SUMS --ignore-missing - -# Option B: Verify with GPG (uses temp keyring) -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS.asc -o SHA256SUMS.asc -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/sfetch-release-signing-key.asc -o sfetch-release-signing-key.asc -GPG_TMPDIR=$(mktemp -d) -gpg --homedir "$GPG_TMPDIR" --import sfetch-release-signing-key.asc -gpg --homedir "$GPG_TMPDIR" --verify SHA256SUMS.asc SHA256SUMS -rm -rf "$GPG_TMPDIR" -$SHA_CMD -c SHA256SUMS --ignore-missing - -# Run after verification -bash install-sfetch.sh +bash install-sfetch.sh --tag "$TAG" ``` ### Manual signing workflow -CI uploads unsigned archives. Maintainers generate `SHA256SUMS` and `SHA512SUMS` locally, then sign them with minisign (primary) and optionally PGP: +Tag CI creates a **draft** release with unsigned archives. Maintainers generate +`SHA256SUMS` / `SHA512SUMS`, sign them **and** `install-sfetch.sh` with minisign +(optional PGP on manifests only), verify, upload, then publish the draft: ```bash -export MINISIGN_KEY=/path/to/sfetch.key -export PGP_KEY_ID=security@fulmenhq.dev # optional - -RELEASE_TAG=v0.2.0 make release-download -RELEASE_TAG=v0.2.0 make release-checksums -RELEASE_TAG=v0.2.0 make release-sign -make release-export-minisign-key -make release-export-key # if using PGP -RELEASE_TAG=v0.2.0 make release-notes -RELEASE_TAG=v0.2.0 make release-upload +export SFETCH_MINISIGN_KEY=/path/to/sfetch.key +export SFETCH_PGP_KEY_ID=security@fulmenhq.dev # optional + +RELEASE_TAG=v0.4.11 make release-download +RELEASE_TAG=v0.4.11 make release-checksums +RELEASE_TAG=v0.4.11 make release-sign # signs manifests + install-sfetch.sh +make release-verify +RELEASE_TAG=v0.4.11 make release-notes +RELEASE_TAG=v0.4.11 make release-upload +gh release edit v0.4.11 --draft=false # publish only after signatures upload ``` Set `RELEASE_TAG` to the tag you're publishing. The scripts in `scripts/` can be used individually if you prefer manual control. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index ed56011..2088c06 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -25,7 +25,13 @@ Why this matters: `make release-upload` now uploads the signed GitHub release as ## 1. Prepare & Tag - [ ] Ensure `main` is clean and `make precommit` passes -- [ ] Update `VERSION` file with new semver (e.g., `0.2.0`) +- [ ] Update `VERSION` file with new semver (e.g., `0.4.12`) +- [ ] **Advance verified-bootstrap range constants** (required every release — missing bump fails consumers, not sfetch CI, unless you run the assert): + - [ ] Set `SFETCH_BOOTSTRAP_MAX` in `scripts/bootstrap-sfetch-verified.sh` to `v$(cat VERSION)` (committed constant — **do not** derive at engine runtime from `VERSION` or the consumer tree) + - [ ] Review `SFETCH_MINISIG_SINCE` (first tag that publishes `install-sfetch.sh.minisig`). Wrong value mis-routes (3-step vs 5-step); both routes still verify, but route selection is part of the contract + - [ ] Update boundary tests in `scripts/test-bootstrap-sfetch-verified.sh` (e.g. assert `v(MAX+1)` is refused) + - [ ] Run `make test-bootstrap-range-release` (or full `make precommit`) — asserts `SFETCH_BOOTSTRAP_MAX == v$(cat VERSION)` and `MINISIG_SINCE` ∈ `[MIN, MAX]` + - [ ] After merge/tag, publish the new **action SHA** (and engine script digest if documenting Makefile consumers) — action SHA and `sfetch-version` pins are coupled in time; consumers advancing the pin must also advance the action ref when the range expands - [ ] Update `CHANGELOG.md` (move Unreleased to new version section) - [ ] Update `RELEASE_NOTES.md` - [ ] Create `docs/releases/vX.Y.Z.md` @@ -39,7 +45,8 @@ Why this matters: `make release-upload` now uploads the signed GitHub release as - [ ] Wait for GitHub Actions release workflow to complete - CI validates VERSION file matches tag - Builds unsigned archives - - Uploads install-sfetch.sh + - Creates a **draft** release and uploads install-sfetch.sh + - Release remains non-consumable until you sign, upload signatures, and publish ## 2. Manual Signing (local machine) @@ -74,13 +81,17 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to make release-verify-checksums ``` -5. **Sign checksum manifests** with minisign + PGP +5. **Sign checksum manifests + installer** with minisign (+ optional PGP on manifests) ```bash make release-sign ``` - Produces: `SHA256SUMS`, `SHA512SUMS` plus `.minisig`/`.asc` + Produces: + - `SHA256SUMS.minisig`, `SHA512SUMS.minisig` (minisign) + - **`install-sfetch.sh.minisig` (minisign, required)** — second password prompt is expected + - optional `SHA256SUMS.asc` / `SHA512SUMS.asc` if `SFETCH_PGP_KEY_ID` is set + - PGP does **not** sign the installer -6. **Verify signatures** +6. **Verify signatures** (installer minisig required — missing fails the gate) ```bash make release-verify-signatures ``` @@ -113,6 +124,13 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to ``` > **Note:** This target depends on `release-verify` (checksums + signatures + keys). > It uploads ALL assets with `--clobber`, including binaries CI already uploaded. + > `install-sfetch.sh.minisig` is required; upload refuses a missing installer signature. + > Tag CI creates a **draft** release — after upload, publish: + > ```bash + > gh release edit v$(cat VERSION) --draft=false + > ``` + > Until publish, the release is incomplete / non-consumable for bootstrap consumers. + > > This is intentional for idempotency - rerun safely to fix any mistakes. > > If `../scoop-bucket` is present, this target also runs `make update-scoop-manifest` at the end so the bucket is ready for commit/push immediately after release upload. @@ -134,7 +152,14 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to ## 3. Post-Release - [ ] Verify release: `gh release view v$(cat VERSION)` -- [ ] Test install script: `curl -sSfL .../install-sfetch.sh | bash -s -- --dry-run` +- [ ] Smoke-test install via the **verified engine** (not pipe-to-bash), against the just-published tag: + ```bash + TAG=v$(cat VERSION) + DEST=$(mktemp -d) + ./scripts/bootstrap-sfetch-verified.sh --version "$TAG" --dir "$DEST" --yes + "$DEST/sfetch" --version # must report $TAG + ``` + (After first publish of this tag's signatures; draft/unsigned releases are non-consumable.) - [ ] Verify binary version: `sfetch --version` shows correct version - [ ] Commit and push `../scoop-bucket` after confirming `bucket/sfetch.json` has the right version and hashes - [ ] Test on local Windows VM: `scoop bucket add 3leaps ` then `scoop install sfetch` diff --git a/VERSION b/VERSION index e8423da..5f749c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.10 +0.4.11 diff --git a/docs/cicd-usage-guide.md b/docs/cicd-usage-guide.md index e6b329c..63f2f03 100644 --- a/docs/cicd-usage-guide.md +++ b/docs/cicd-usage-guide.md @@ -33,53 +33,111 @@ This is fragile and version-dependent—upgrading is recommended. ## GitHub Actions Examples -### Basic usage (recommended) +### Recommended: composite action (v0.4.11+) + +Pin the action by **commit SHA** (not a moving tag). The action embeds the +supported `sfetch-version` range for that SHA and fails closed outside it. ```yaml -- name: Install sfetch + tool +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 # exact tag; never latest + goneat-version: v0.5.15 # optional; exact tag if set env: GITHUB_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }} SFETCH_GITHUB_TOKEN: ${{ github.token }} + +- name: Use sfetch run: | - set -euo pipefail - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" + sfetch --version + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$HOME/.local/bin" --require-minisign +``` - # Install sfetch - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" +**Dual-route behavior (logged as `route=minisig` or `route=sha256sums`):** - # Install your tool (sfetch handles cross-device automatically) - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" --require-minisign +| `sfetch-version` | Verification | +|------------------|--------------| +| ≥ v0.4.11 | Detached `install-sfetch.sh.minisig` | +| v0.4.9 – v0.4.10 | Signed `SHA256SUMS` + installer hash | +| outside action range / `latest` | **Fail closed** | - # Verify - tool --version +Never fall back from a failed `.minisig` attempt to checksums. + +**Adoption mode:** link the action by SHA from this repository. Do not copy +`action.yml` into consumer repos (that re-creates the drift the action exists +to eliminate). + +**Pin coupling (action SHA ↔ `sfetch-version`):** each action commit freezes a +supported version range (`SFETCH_BOOTSTRAP_MIN`..`SFETCH_BOOTSTRAP_MAX` in the +engine at that SHA). A new sfetch release that raises the ceiling requires a +new action SHA that includes the advanced constants — bumping only +`sfetch-version` without moving the action ref fails closed with +`outside supported range`. That is intentional (fail-closed on unknown +versions). Plan consumer upgrades as **two coordinated pins**: action SHA and +exact tag. Do not read `VERSION` from a consumer workspace to “auto-advance” +the range at runtime; the range is a committed property of the pinned action. + +### Makefile / shell: shared engine (D2b) + +```bash +# Prefer in-repo script when developing sfetch itself: +./scripts/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin" + +# Other repos: fetch at an immutable SHA, verify digest, then execute. +# Replace and with values published for the release you trust. +SCRIPT_URL="https://raw.githubusercontent.com/3leaps/sfetch//scripts/bootstrap-sfetch-verified.sh" +SCRIPT_SHA256="" +curl -fsSL "$SCRIPT_URL" -o /tmp/bootstrap-sfetch-verified.sh +echo "${SCRIPT_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c +bash /tmp/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin" ``` -Exporting all three token variables at job or workflow scope keeps `sfetch`, `gh`, and child processes on authenticated GitHub API requests by default. +`latest` is refused by the engine (and by the action). Interactive humans may +still use `install-sfetch.sh` from a pinned tag or, carefully, from `latest`; +CI and Makefile recipes must use exact tags. -### With explicit version pinning +### Makefile / shell (immutable tag + verified engine) -```yaml -- name: Install tools (pinned versions) - run: | - set -euo pipefail - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" +For non-Actions consumers, retrieve the engine at an immutable SHA, verify its +digest, then run it with an exact tag (never `latest`): + +```bash +set -euo pipefail +BIN_DIR="$HOME/.local/bin" +mkdir -p "$BIN_DIR" +export PATH="$BIN_DIR:$PATH" + +# Replace and with values published for the release you trust. +SCRIPT_URL="https://raw.githubusercontent.com/3leaps/sfetch//scripts/bootstrap-sfetch-verified.sh" +SCRIPT_SHA256="" +curl -fsSL "$SCRIPT_URL" -o /tmp/bootstrap-sfetch-verified.sh +echo "${SCRIPT_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c +bash /tmp/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$BIN_DIR" + +export GITHUB_TOKEN="${GITHUB_TOKEN:-}" GH_TOKEN="${GH_TOKEN:-}" SFETCH_GITHUB_TOKEN="${SFETCH_GITHUB_TOKEN:-}" +sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign +``` - # Install sfetch (pinned; choose the minimum version you require) - SFETCH_VERSION="v0.2.6" - curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${SFETCH_VERSION}/install-sfetch.sh" | bash -s -- --yes --dir "$BIN_DIR" +Exporting all three token variables at job or workflow scope keeps `sfetch`, `gh`, and child processes on authenticated GitHub API requests by default. + +### Backward pin (v0.4.10 still on SHA256SUMS route) - # Install tool (pinned) - sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign +```yaml +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.10 # pre-minisig release; engine selects sha256sums + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} ``` ### Container jobs -When running in a container (e.g., with `container:` in GitHub Actions), the same approach works: +When running in a container (e.g., with `container:` in GitHub Actions), prefer +the composite action when the runner can reach it; otherwise use the verified +engine at an immutable tag (never `releases/latest` in CI): ```yaml jobs: @@ -89,13 +147,27 @@ jobs: image: golang:1.23 steps: - uses: actions/checkout@v4 - - name: Install tools + - uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} + - name: Use sfetch run: | - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$HOME/.local/bin" --require-minisign +``` + +Makefile / shell alternative inside the container (pinned tag + verified engine): + +```bash +SFETCH_VERSION="v0.4.11" +SCRIPT_URL="https://raw.githubusercontent.com/3leaps/sfetch//scripts/bootstrap-sfetch-verified.sh" +SCRIPT_SHA256="" +curl -fsSL "$SCRIPT_URL" -o /tmp/bootstrap-sfetch-verified.sh +echo "${SCRIPT_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c +bash /tmp/bootstrap-sfetch-verified.sh --version "${SFETCH_VERSION}" --dir "$HOME/.local/bin" ``` ### Non-root container users @@ -112,13 +184,16 @@ jobs: options: --user 1001 steps: - uses: actions/checkout@v4 - - name: Install tools + - uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} + - name: Use sfetch run: | - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$HOME/.local/bin" --require-minisign ``` If you need to install system packages (e.g., `apt-get install`), you may temporarily need root access: @@ -155,15 +230,26 @@ Or use POSIX-compatible options only: ## GitLab CI Example +CI must use an immutable tag and the verified engine (never `releases/latest`): + ```yaml install-tools: image: golang:1.23 + variables: + SFETCH_VERSION: "v0.4.11" + # Pin the engine script to an immutable git SHA and its digest. + SFETCH_ENGINE_SHA: "" + SFETCH_ENGINE_SHA256: "" script: - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" + - | + curl -fsSL "https://raw.githubusercontent.com/3leaps/sfetch/${SFETCH_ENGINE_SHA}/scripts/bootstrap-sfetch-verified.sh" \ + -o /tmp/bootstrap-sfetch-verified.sh + echo "${SFETCH_ENGINE_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c + bash /tmp/bootstrap-sfetch-verified.sh --version "${SFETCH_VERSION}" --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" --require-minisign + - sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign ``` ## Cache Directory @@ -266,3 +352,34 @@ precedence model. - [Examples & Pattern Matching](examples.md) - Real-world verification examples - [Security Documentation](security.md) - Verification workflows explained - [Key Handling](key-handling.md) - PGP and minisign key configuration + +## Minisign provenance (verifier pin) + +`minisign` is part of the trust-critical path. The verified bootstrap engine +installs or asserts **minisign 0.12** from official jedisct1 release archives +(hash-pinned before extract): + +| Platform | Artifact | SHA-256 | +|----------|----------|---------| +| Windows x64 / arm64 | `minisign-0.12-win64.zip` | `37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479` | +| macOS arm64 | `minisign-0.12-macos.zip` | `89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63` | +| Linux x86_64 / aarch64 | `minisign-0.12-linux.tar.gz` | `9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73` | + +Windows maps `RUNNER_ARCH` X64 → `x86_64`, ARM64 → `aarch64`. **macOS Intel is +not supported** by the upstream 0.12 macOS archive (arm64-only) and fails +closed. + +Production, Makefile D2b, and the composite action **always** download and +hash-verify the pinned official archive. They never prefer ambient PATH +minisign (a PATH shim must not become the verifier) and do not honor runtime +env overrides for download base or verifier acquisition. Local regression +harnesses patch a temporary engine copy when they need fixture URLs or ambient +minisign. Do **not** use Chocolatey/winget community packages for the verified +bootstrap path. + +## Incomplete release window + +Between tag CI (draft + unsigned installer upload) and maintainer +`make release-upload` + publish, a release is **non-consumable** for verified +bootstrap. Consumers must not treat draft assets or an unsigned installer as a +trust anchor. Prefer exact tags over `latest` so you never race that window. diff --git a/docs/releases/v0.4.11.md b/docs/releases/v0.4.11.md new file mode 100644 index 0000000..842a326 --- /dev/null +++ b/docs/releases/v0.4.11.md @@ -0,0 +1,50 @@ +# sfetch v0.4.11 + +Trust-anchor delivery and verified bootstrap (D1 / D2 / D2b). + +## Highlights + +- **Detached installer signature:** releases publish `install-sfetch.sh.minisig` in addition to signed checksum manifests. Minisign covers the installer plus `SHA256SUMS`/`SHA512SUMS`; PGP remains manifests-only. +- **Required release gate:** `make release-verify-signatures` fails closed if the installer signature is missing, tampered, or signed with the wrong key. Uploads refuse a missing installer minisig. +- **Verified bootstrap engine:** `scripts/bootstrap-sfetch-verified.sh` dual-route installer (`.minisig` for ≥ v0.4.11, signed `SHA256SUMS` for earlier pins). Fails closed; never falls back between routes; refuses `latest`. +- **Composite action:** `3leaps/sfetch/.github/actions/setup-sfetch` is a thin wrapper over the engine. Pin by commit SHA. +- **Draft releases until signed:** tag CI creates a **draft** release so incomplete (pre-signature) assets never become `latest`. +- **Own `make bootstrap`:** uses the verified engine against the N-1 pin (v0.4.10 → SHA256SUMS route for this cut). + +## Consumer impact + +| Your pin | Effect of v0.4.11 | +|----------|-------------------| +| Pinned ≤ v0.4.10, unchanged | **No change.** Existing install paths keep working. | +| Pinned ≤ v0.4.10, want 3-step `.minisig` | Advance pin to **v0.4.11+**. Signature is not retroactive. | +| On `latest` | Human interactive still OK; **CI/Makefile/action refuse `latest`**. Prefer exact tags. | +| Adopting the action | Route selected by pin; both routes fail closed. | + +## Verified bootstrap (recommended) + +```yaml +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 + goneat-version: v0.5.15 # optional, exact tag +``` + +Makefile / local: + +```bash +./scripts/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin" +``` + +Outside this repo, retrieve the script at an immutable SHA and verify a pinned digest before executing (see `docs/cicd-usage-guide.md`). + +## Trust anchor + +Unchanged: + +``` +RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +``` + +## Incomplete release window + +Tag CI uploads the installer into a **draft** release. The release is **non-consumable** for verified bootstrap until the maintainer runs `make release-sign` / `make release-verify` / `make release-upload` and publishes (`gh release edit TAG --draft=false`). diff --git a/docs/security.md b/docs/security.md index 594beba..a115c3b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -128,28 +128,95 @@ discoverable from the error message. ## Manual release signing -CI uploads unsigned archives only. Maintainers generate and sign checksum manifests (`SHA256SUMS`, `SHA512SUMS`) locally with minisign (primary) and optionally PGP: +Tag CI creates a **draft** release and uploads unsigned platform archives plus +`install-sfetch.sh`. The draft is **incomplete / non-consumable** for verified +bootstrap until the maintainer signs and publishes. + +Maintainers download artifacts, generate checksums, and sign **locally** with +minisign (primary) and optionally PGP: ```bash -export MINISIGN_KEY=/path/to/key.key -export PGP_KEY_ID=your-key-id # optional +export SFETCH_MINISIGN_KEY=/path/to/key.key +export SFETCH_MINISIGN_PUB=/path/to/key.pub +export SFETCH_PGP_KEY_ID=your-key-id # optional -RELEASE_TAG=v0.2.0 make release-download -RELEASE_TAG=v0.2.0 make release-checksums -RELEASE_TAG=v0.2.0 make release-sign +RELEASE_TAG=v0.4.11 make release-download +RELEASE_TAG=v0.4.11 make release-checksums +RELEASE_TAG=v0.4.11 make release-sign make release-verify-signatures make release-export-keys make release-verify-keys -RELEASE_TAG=v0.2.0 make release-notes -RELEASE_TAG=v0.2.0 make release-upload +RELEASE_TAG=v0.4.11 make release-notes +RELEASE_TAG=v0.4.11 make release-upload +gh release edit v0.4.11 --draft=false ``` -Only the checksum manifests are signed (not individual files). Users verify the signature on `SHA256SUMS`/`SHA512SUMS`, then verify archive checksums against them. This is standard practice - signing individual files would be redundant. +### What is signed (v0.4.11+) + +| Artifact | minisign | PGP | +|----------|----------|-----| +| `SHA256SUMS` / `SHA512SUMS` | yes | optional | +| `install-sfetch.sh` | **yes (required)** | no | +| Platform archives | covered by signed manifests | covered by signed manifests | + +**Why the installer is the one file signed outside the manifests:** consumers +must execute it *before* any sfetch binary exists. A detached +`install-sfetch.sh.minisig` makes the verified path three steps instead of five. +All other assets remain covered by the signed checksum manifests (signing every +archive would be redundant). + +`make release-verify-signatures` **requires** `install-sfetch.sh.minisig` +(missing ⇒ non-zero). Manifest minisign signatures remain skip-if-absent for +legacy staging; if present they must verify. Do not re-harmonise these branches +without a deliberate lock — the installer requirement exists specifically to +block incomplete releases. -Installer hardening: `scripts/install-sfetch.sh` now requires minisign verification by default (embedded trust anchor). GPG fallback is pinned by fingerprint. Checksum-only installs require explicit opt-in (`--allow-checksum-only`) and emit low-trust warnings. +`SHA256SUMS` never lists `*.minisig` files (suffix skip in the checksum +generator); a regression harness asserts this so the manifest cannot become +self-referential. + +Installer runtime hardening: `scripts/install-sfetch.sh` requires minisign +verification by default (embedded trust anchor). GPG fallback is pinned by +fingerprint. Checksum-only installs require explicit opt-in +(`--allow-checksum-only`) and emit low-trust warnings. See [docs/security/signing-runbook.md](security/signing-runbook.md) for detailed workflow. +### Trust-anchor rotation + +The minisign public key is embedded in: + +- `main.go` (`EmbeddedMinisignPubkey`) +- `scripts/install-sfetch.sh` +- `scripts/bootstrap-sfetch-verified.sh` + +All three must stay identical. The published `sfetch-minisign.pub` on each +release is for **human out-of-band comparison only** — verification tools must +not fetch the key from the same release they are authenticating (circular). + +If the key ever rotates: + +1. Announce the rotation in release notes and a security advisory *before* the + first release signed with the new key. +2. Publish the new public key through a channel independent of a single GitHub + release (project site / signed mailing list / prior release notes). +3. Bump major tooling that hard-codes the old key; consumers who pin the old + key must update deliberately — a rotation will look like a signature failure + by design. +4. Do not silently dual-sign with both keys without documenting the transition + window. + +### `--require-minisign` default asymmetry + +| Surface | Default | Rationale | +|---------|---------|-----------| +| `install-sfetch.sh` | **require minisign = true** | Bootstrap path; no prior binary trust | +| CLI (`sfetch`) | **require minisign = false** | Works against releases that only ship checksums / PGP; opt-in strictness via `--require-minisign` | + +Same flag name, opposite defaults — intentional. Do not "tidy" them into one +behavior without a migration plan; CI examples should pass `--require-minisign` +on the CLI explicitly. + ## Verifying Your Installation After installing sfetch, you can verify the binary matches the signed release: diff --git a/main.go b/main.go index 7405173..1ce391d 100644 --- a/main.go +++ b/main.go @@ -255,16 +255,29 @@ const ( // minisignPubkeyRegex matches a valid minisign public key line (with or without comment header) var minisignPubkeyRegex = regexp.MustCompile(`^RW[A-Za-z0-9+/]{54}$`) -// Embedded trust anchors for self-verification and transparency. -// Users can compare these against keys published at: -// - https://github.com/3leaps/sfetch/releases (sfetch-minisign.pub) -// - scripts/install-sfetch.sh (SFETCH_MINISIGN_PUBKEY) +// Canonical minisign trust anchor SSOT: scripts/sfetch-minisign-anchor.pub +// EmbeddedMinisignPubkey is derived from that file (go:embed). Standalone +// consumers (install-sfetch.sh, bootstrap-sfetch-verified.sh) embed the same +// RW line and are checked against the SSOT in tests / release verify. // -// Changing these keys requires updating both this file and install-sfetch.sh. -const ( - EmbeddedMinisignPubkey = "RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" - EmbeddedMinisignKeyID = "3leaps/sfetch release signing key" -) +//go:embed scripts/sfetch-minisign-anchor.pub +var embeddedMinisignPubFile string + +// EmbeddedMinisignPubkey is the RW… line from scripts/sfetch-minisign-anchor.pub. +var EmbeddedMinisignPubkey = mustMinisignPubkeyLine(embeddedMinisignPubFile) + +// EmbeddedMinisignKeyID is a human label for the release signing key. +const EmbeddedMinisignKeyID = "3leaps/sfetch release signing key" + +func mustMinisignPubkeyLine(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "RW") && len(line) == 56 { + return line + } + } + panic("scripts/sfetch-minisign-anchor.pub: missing RW… public key line") +} // minisignSecretkeyPrefixes are known prefixes for secret key files var minisignSecretkeyPrefixes = []string{ diff --git a/main_test.go b/main_test.go index 41401b4..2c7174e 100644 --- a/main_test.go +++ b/main_test.go @@ -2486,6 +2486,23 @@ func TestEmbeddedTrustAnchors(t *testing.T) { t.Error("EmbeddedMinisignPubkey does not match key in scripts/install-sfetch.sh") } }) + + t.Run("matches SSOT anchor file and bootstrap engine", func(t *testing.T) { + anchor, err := os.ReadFile("scripts/sfetch-minisign-anchor.pub") + if err != nil { + t.Fatalf("read scripts/sfetch-minisign-anchor.pub: %v", err) + } + if !strings.Contains(string(anchor), EmbeddedMinisignPubkey) { + t.Error("EmbeddedMinisignPubkey does not match scripts/sfetch-minisign-anchor.pub") + } + engine, err := os.ReadFile("scripts/bootstrap-sfetch-verified.sh") + if err != nil { + t.Fatalf("read bootstrap engine: %v", err) + } + if !strings.Contains(string(engine), EmbeddedMinisignPubkey) { + t.Error("EmbeddedMinisignPubkey does not match bootstrap-sfetch-verified.sh") + } + }) } func TestEmbeddedUpdateTargetConfigMatchesFile(t *testing.T) { diff --git a/scripts/acquire-minisign-pinned.sh b/scripts/acquire-minisign-pinned.sh new file mode 100755 index 0000000..d852dec --- /dev/null +++ b/scripts/acquire-minisign-pinned.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# acquire-minisign-pinned.sh — thin wrapper over the shared bootstrap engine. +# +# Usage: acquire-minisign-pinned.sh --dir PATH +# Installs official minisign 0.12 (hash-verified) into PATH. +# Single authoritative acquisition path: bootstrap-sfetch-verified.sh +# --acquire-minisign-only (constants + download live only there). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +ENGINE="${ROOT}/bootstrap-sfetch-verified.sh" +if [ ! -f "$ENGINE" ] || [ ! -x "$ENGINE" ]; then + if [ -f "$ENGINE" ]; then + chmod +x "$ENGINE" + else + echo "error: shared engine missing: $ENGINE" >&2 + exit 1 + fi +fi +exec "$ENGINE" --acquire-minisign-only "$@" diff --git a/scripts/assert-bootstrap-range-release.sh b/scripts/assert-bootstrap-range-release.sh new file mode 100755 index 0000000..eb0dfb5 --- /dev/null +++ b/scripts/assert-bootstrap-range-release.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# assert-bootstrap-range-release.sh — release-gate coherence for bootstrap constants. +# +# Compares two *committed* values visible at the same SHA: +# VERSION file vs SFETCH_BOOTSTRAP_MAX in scripts/bootstrap-sfetch-verified.sh +# Also sanity-checks SFETCH_MINISIG_SINCE against the committed range. +# +# WHY THIS IS NOT IN THE ENGINE AT RUNTIME +# ---------------------------------------- +# Do not "simplify" by reading VERSION (or any consumer-tree file) inside +# bootstrap-sfetch-verified.sh during install. The action SHA must declare a +# *frozen* supported range; a runtime-derived ceiling floats the contract and +# reintroduces a workspace trust seam (engine must not depend on GITHUB_WORKSPACE +# or consumer checkout contents for verification policy). +# +# This assert belongs in sfetch's release/CI gate so a *missing* MAX bump fails +# here, not first in a consumer repo that tried sfetch-version: vN+1. +# +# Usage: assert-bootstrap-range-release.sh +# Exit 0 if coherent; non-zero otherwise. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +ENGINE="${ROOT}/scripts/bootstrap-sfetch-verified.sh" +VERSION_FILE="${ROOT}/VERSION" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +[ -f "$VERSION_FILE" ] || fail "VERSION file missing" +[ -f "$ENGINE" ] || fail "engine missing: $ENGINE" + +VER="$(tr -d '[:space:]' <"$VERSION_FILE")" +case "$VER" in + [0-9]*.[0-9]*.[0-9]*) + if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "VERSION must be exact MAJOR.MINOR.PATCH (got: $VER)" + fi + ;; + *) + fail "VERSION must be exact MAJOR.MINOR.PATCH (got: $VER)" + ;; +esac +TAG="v${VER}" + +# Extract committed constants from the engine (readonly assignments). +extract_const() { + local name="$1" + local line + line="$(grep -E "^readonly ${name}=" "$ENGINE" | head -n1 || true)" + [ -n "$line" ] || fail "engine constant ${name} not found" + # readonly NAME="value" + local val="${line#*=}" + val="${val#\"}" + val="${val%\"}" + val="${val#\'}" + val="${val%\'}" + printf '%s\n' "$val" +} + +MAX="$(extract_const SFETCH_BOOTSTRAP_MAX)" +MIN="$(extract_const SFETCH_BOOTSTRAP_MIN)" +SINCE="$(extract_const SFETCH_MINISIG_SINCE)" + +echo "bootstrap-range assert: VERSION=${VER} MAX=${MAX} MIN=${MIN} MINISIG_SINCE=${SINCE}" + +[ "$MAX" = "$TAG" ] || + fail "SFETCH_BOOTSTRAP_MAX (${MAX}) must equal v\$(cat VERSION) (${TAG}). Advance the constant (and boundary tests) before cutting this release — see RELEASE_CHECKLIST.md §1." + +# MINISIG_SINCE: route-selection constant. Wrong value is not a crypto hole but +# mis-routes consumers (5-step vs 3-step). Must be an exact tag within [MIN, MAX]. +case "$SINCE" in + v[0-9]*.[0-9]*.[0-9]*) + if ! [[ "${SINCE#v}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "SFETCH_MINISIG_SINCE must be exact vMAJOR.MINOR.PATCH (got: $SINCE)" + fi + ;; + *) + fail "SFETCH_MINISIG_SINCE must be exact vMAJOR.MINOR.PATCH (got: $SINCE)" + ;; +esac + +# Lightweight ordering using the engine's own helpers without installing. +# shellcheck source=scripts/bootstrap-sfetch-verified.sh +# We only need is_exact / semver helpers — source is heavy; inline minimal cmp. +semver_cmp_tag() { + # echo -1/0/1 for a vs b (vX.Y.Z) + local a="${1#v}" b="${2#v}" + local a1 a2 a3 b1 b2 b3 + IFS=. read -r a1 a2 a3 <<<"$a" + IFS=. read -r b1 b2 b3 <<<"$b" + # Use pure string length+lex for decimal components (same class as engine; + # components here are small committed constants, not untrusted input). + _cmp_one() { + local x="$1" y="$2" + local lx=${#x} ly=${#y} + if [ "$lx" -lt "$ly" ]; then + echo -1 + return + fi + if [ "$lx" -gt "$ly" ]; then + echo 1 + return + fi + if [[ "$x" < "$y" ]]; then + echo -1 + return + fi + if [[ "$x" > "$y" ]]; then + echo 1 + return + fi + echo 0 + } + local c + c="$(_cmp_one "$a1" "$b1")" + [ "$c" != 0 ] && { + echo "$c" + return + } + c="$(_cmp_one "$a2" "$b2")" + [ "$c" != 0 ] && { + echo "$c" + return + } + _cmp_one "$a3" "$b3" +} + +[ "$(semver_cmp_tag "$SINCE" "$MIN")" != "-1" ] || + fail "SFETCH_MINISIG_SINCE (${SINCE}) is below SFETCH_BOOTSTRAP_MIN (${MIN})" +[ "$(semver_cmp_tag "$SINCE" "$MAX")" != "1" ] || + fail "SFETCH_MINISIG_SINCE (${SINCE}) is above SFETCH_BOOTSTRAP_MAX (${MAX})" +[ "$(semver_cmp_tag "$MIN" "$MAX")" != "1" ] || + fail "SFETCH_BOOTSTRAP_MIN (${MIN}) is above SFETCH_BOOTSTRAP_MAX (${MAX})" + +echo "[ok] bootstrap range coherent: MAX=${MAX} == VERSION tag; MINISIG_SINCE=${SINCE} in [${MIN}..${MAX}]" diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh new file mode 100755 index 0000000..5def5d2 --- /dev/null +++ b/scripts/bootstrap-sfetch-verified.sh @@ -0,0 +1,643 @@ +#!/usr/bin/env bash +# bootstrap-sfetch-verified.sh — verified sfetch install for Makefile / CI +# +# Fail-closed dual-route bootstrap: +# - v0.4.11+ → detached install-sfetch.sh.minisig (3-step) +# - earlier → SHA256SUMS + SHA256SUMS.minisig + installer hash (5-step) +# +# Never falls back from a failed .minisig attempt to checksums. +# Never accepts "latest", branches, or floating refs. +# Trust anchor is embedded here — never fetched from the release being authenticated. +# +# Usage: +# bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin +# bootstrap-sfetch-verified.sh --version v0.4.10 --dir ./bin --goneat-version v0.5.15 +# bootstrap-sfetch-verified.sh --acquire-minisign-only --dir PATH +# +# No runtime env overrides for trust-critical paths (download base, minisign +# acquisition). Production always uses GitHub release URLs and always downloads +# the hash-pinned official minisign 0.12 archive. Local harnesses patch a +# temporary copy of this script for fixture URLs / ambient minisign. +# +# Makefile consumers outside this repo should retrieve this script at an +# immutable git SHA and verify a pinned SHA-256 of the script before executing +# it (see docs/cicd-usage-guide.md). The composite action is a thin wrapper: +# it resolves this single canonical engine from the action repository checkout +# at scripts/bootstrap-sfetch-verified.sh (never from the consumer workspace). +# +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Contract constants (bump deliberately when expanding support) +# ----------------------------------------------------------------------------- +# Supported sfetch-version range for THIS script revision. Outside range ⇒ fail. +# Action SHA identifies this contract; consumers pin the action/script, then a +# version within the range it declares. +# +# Do NOT derive MAX/MIN/MINISIG_SINCE from VERSION or any path at runtime — +# that would float the pinned contract and reintroduce a workspace trust seam. +# Advance these constants at release-prep time (RELEASE_CHECKLIST.md §1); +# make test-bootstrap-range-release asserts MAX == v$(cat VERSION) using two +# committed values at the same SHA (never self-advancing at install time). +readonly SFETCH_BOOTSTRAP_MIN="v0.4.9" +readonly SFETCH_BOOTSTRAP_MAX="v0.4.11" +# First release that publishes install-sfetch.sh.minisig (route selection). +# Keep this accurate: too low → minisig route without asset; too high → 5-step +# when 3-step exists. Both routes verify; neither silently falls back. +readonly SFETCH_MINISIG_SINCE="v0.4.11" + +# Embedded trust anchor — must match scripts/sfetch-minisign-anchor.pub (SSOT), +# main.go (go:embed of that file), and scripts/install-sfetch.sh. Do NOT fetch +# sfetch-minisign.pub from the release for authentication (circular). +# The published .pub is for human out-of-band comparison only. +readonly SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" + +readonly MINISIGN_VERSION_EXPECTED="0.12" +readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" +readonly MINISIGN_WIN_SHA256="37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479" +readonly MINISIGN_MAC_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip" +readonly MINISIGN_MAC_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" +readonly MINISIGN_LINUX_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz" +readonly MINISIGN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" + +readonly SFETCH_REPO_DEFAULT="3leaps/sfetch" + +# ----------------------------------------------------------------------------- +# Logging / errors +# ----------------------------------------------------------------------------- +log() { printf '%s\n' "$*" >&2; } +die() { + log "error: $*" + exit 1 +} + +# ----------------------------------------------------------------------------- +# Semver helpers (tags must be vMAJOR.MINOR.PATCH, optional -prerelease rejected) +# ----------------------------------------------------------------------------- +# Canonical numeric components only: no leading zeros (v0.4.09 / v0.04.11 rejected). +is_exact_semver_tag() { + [[ "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] +} + +# Compare two non-negative integer decimal strings without machine arithmetic. +# Inputs must already be digits-only with no leading zeros (except "0"). +# Longer digit string is larger; equal length uses C-locale lexical order. +# +# Why no $((10#…)) / arithmetic: Bash integers wrap silently (e.g. 2^64 → 0, +# 2^64+11 → 11). That previously made huge components fail-open on the +# supported-range gate and even alias into a valid route (minisig). For +# canonical decimals, length-then-lexical is total and order-preserving. +_semver_cmp_component() { + local x="$1" y="$2" + local lx=${#x} ly=${#y} + if [ "$lx" -lt "$ly" ]; then + echo -1 + return 0 + fi + if [ "$lx" -gt "$ly" ]; then + echo 1 + return 0 + fi + if [ "$x" = "$y" ]; then + echo 0 + return 0 + fi + # Equal width digit strings: C-locale lexical order == numeric order. + if [ "$(printf '%s\n%s\n' "$x" "$y" | LC_ALL=C sort | head -n1)" = "$x" ]; then + echo -1 + else + echo 1 + fi +} + +# Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab. +# Fail loudly (return non-zero, no stdout) on non-canonical components — +# never use Bash integer arithmetic (see _semver_cmp_component). +semver_cmp() { + local a="${1#v}" b="${2#v}" + local a1 a2 a3 b1 b2 b3 + IFS=. read -r a1 a2 a3 <<<"$a" + IFS=. read -r b1 b2 b3 <<<"$b" + local c + for c in "$a1" "$a2" "$a3" "$b1" "$b2" "$b3"; do + case "$c" in + '' | *[!0-9]*) + log "error: semver_cmp: non-numeric component in $1 vs $2" + return 2 + ;; + 0) ;; + 0*) + log "error: semver_cmp: leading zero in component ($c) for $1 vs $2" + return 2 + ;; + esac + done + local r + r="$(_semver_cmp_component "$a1" "$b1")" + if [ "$r" != "0" ]; then + echo "$r" + return 0 + fi + r="$(_semver_cmp_component "$a2" "$b2")" + if [ "$r" != "0" ]; then + echo "$r" + return 0 + fi + _semver_cmp_component "$a3" "$b3" +} + +semver_ge() { + local r + r="$(semver_cmp "$1" "$2")" || die "semver comparison failed for $1 vs $2" + [[ "$r" != "-1" ]] +} +semver_le() { + local r + r="$(semver_cmp "$1" "$2")" || die "semver comparison failed for $1 vs $2" + [[ "$r" != "1" ]] +} + +# ----------------------------------------------------------------------------- +# Args +# ----------------------------------------------------------------------------- +VERSION="" +INSTALL_DIR="" +GONEAT_VERSION="" +REPO="${SFETCH_REPO_DEFAULT}" +ACQUIRE_MINISIGN_ONLY=0 +usage() { + cat <<'EOF' >&2 +Usage: bootstrap-sfetch-verified.sh --version vX.Y.Z --dir PATH [options] + or: bootstrap-sfetch-verified.sh --acquire-minisign-only --dir PATH + +Required (install mode): + --version TAG Exact immutable tag (e.g. v0.4.11). Rejects latest/branches. + --dir PATH Install directory for sfetch (and optional goneat) + +Required (acquire-minisign-only mode): + --dir PATH Directory to install pinned minisign 0.12 into + +Optional: + --goneat-version TAG Exact goneat tag to install via verified sfetch + --repo owner/name GitHub repo (default: 3leaps/sfetch) + --yes Non-interactive (always on for this script; accepted for CLI parity) + --acquire-minisign-only Only install pinned minisign (shared acquisition path for CI) + -h, --help Show help +EOF + exit 2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || die "--version requires an argument" + VERSION="$2" + shift 2 + ;; + --dir) + [ $# -ge 2 ] || die "--dir requires an argument" + INSTALL_DIR="$2" + shift 2 + ;; + --goneat-version) + [ $# -ge 2 ] || die "--goneat-version requires an argument" + GONEAT_VERSION="$2" + shift 2 + ;; + --repo) + [ $# -ge 2 ] || die "--repo requires an argument" + REPO="$2" + shift 2 + ;; + --acquire-minisign-only) + ACQUIRE_MINISIGN_ONLY=1 + shift + ;; + --yes) + # Accepted for CLI parity with install-sfetch.sh (always non-interactive). + shift + ;; + -h | --help) + usage + ;; + *) + die "unknown option: $1 (see --help)" + ;; + esac +done + +[ -n "$INSTALL_DIR" ] || die "--dir is required" + +# ----------------------------------------------------------------------------- +# Platform +# ----------------------------------------------------------------------------- +detect_os() { + case "$(uname -s 2>/dev/null || echo unknown)" in + Linux*) echo linux ;; + Darwin*) echo darwin ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) echo windows ;; + *) + # GitHub Actions Windows often reports MINGW via bash + if [ -n "${RUNNER_OS:-}" ]; then + case "${RUNNER_OS}" in + Windows) echo windows ;; + Linux) echo linux ;; + macOS) echo darwin ;; + *) die "unsupported OS: ${RUNNER_OS}" ;; + esac + else + die "unsupported OS: $(uname -s 2>/dev/null || echo unknown)" + fi + ;; + esac +} + +detect_arch() { + # Prefer GitHub runner arch when present (handles Windows ARM64 host quirks) + if [ -n "${RUNNER_ARCH:-}" ]; then + case "${RUNNER_ARCH}" in + X64 | x64 | AMD64 | amd64) + echo x86_64 + return + ;; + ARM64 | arm64) + echo aarch64 + return + ;; + *) die "unsupported RUNNER_ARCH: ${RUNNER_ARCH}" ;; + esac + fi + local m + m="$(uname -m 2>/dev/null || echo unknown)" + case "$m" in + x86_64 | amd64) echo x86_64 ;; + aarch64 | arm64) echo aarch64 ;; + *) die "unsupported architecture: $m" ;; + esac +} + +OS="$(detect_os)" +ARCH="$(detect_arch)" +log "platform: os=${OS} arch=${ARCH}" + +# macOS Intel: upstream 0.12 macOS archive is arm64-only — fail closed. +if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then + die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner" +fi + +# Reject unsafe TMPDIR characters before any path-derived shelling out (F3). +case "${TMPDIR:-}" in + *\'* | *\"* | *$'\n'* | *$'\r'*) + die "TMPDIR contains quote or newline characters (unsafe for path handling)" + ;; +esac + +# ----------------------------------------------------------------------------- +# Temp workspace (private; cleaned on exit) +# ----------------------------------------------------------------------------- +WORK="$(mktemp -d "${TMPDIR:-/tmp}/sfetch-bootstrap.XXXXXX")" +cleanup() { + rm -rf "${WORK}" +} +trap cleanup EXIT + +# Resolve install dir path without creating it yet (validate before side effects). +# acquire-minisign-only / install mode create the directory after validation. +_parent="$(dirname "$INSTALL_DIR")" +if [ -d "$_parent" ]; then + INSTALL_DIR="$(cd "$_parent" && pwd)/$(basename "$INSTALL_DIR")" +fi +unset _parent + +# ----------------------------------------------------------------------------- +# HTTPS fetch with bounded retries +# ----------------------------------------------------------------------------- +http_get() { + local url="$1" out="$2" + local attempt=1 max=4 + while [ "$attempt" -le "$max" ]; do + if command -v curl >/dev/null 2>&1; then + if curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 120 \ + -o "$out" "$url"; then + return 0 + fi + elif command -v wget >/dev/null 2>&1; then + if wget -q -O "$out" "$url"; then + return 0 + fi + else + die "curl or wget is required" + fi + log "fetch attempt ${attempt}/${max} failed: $url" + attempt=$((attempt + 1)) + sleep 1 + done + die "failed to fetch after ${max} attempts: $url" +} + +sha256_file() { + local f="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$f" | awk '{print $1}' + else + die "shasum or sha256sum required" + fi +} + +assert_sha256() { + local f="$1" expect="$2" + local got + got="$(sha256_file "$f")" + if [ "$got" != "$expect" ]; then + die "SHA-256 mismatch for $(basename "$f"): expected ${expect}, got ${got}" + fi +} + +# ----------------------------------------------------------------------------- +# Minisign acquisition (pinned official archive only; never prefer ambient PATH) +# ----------------------------------------------------------------------------- +MINISIGN_BIN="" + +# Exact whitespace-delimited version token match (authoritative for this engine). +# Rejects substring soft matches and suffix/prefix extensions (e.g. 0.4.11-rc1). +version_output_matches_pin() { + local out="$1" pin="$2" + local want="${pin#v}" + local tok + [ -n "$want" ] || return 1 + while IFS= read -r tok; do + [ -n "$tok" ] || continue + case "$tok" in + v*) + if [ "${tok#v}" = "$want" ]; then + return 0 + fi + ;; + esac + if [ "$tok" = "$want" ]; then + return 0 + fi + done </dev/null 2>&1; then + unzip -q -o "$zip" -d "${WORK}/minisign-extract" + else + # PowerShell Expand-Archive via env vars (no path interpolation into -Command). + # Single-quoted -Command intentionally keeps $env: for PowerShell, not Bash. + # shellcheck disable=SC2016 + SFETCH_MINISIGN_ZIP_PATH="$zip" \ + SFETCH_MINISIGN_EXTRACT_PATH="${WORK}/minisign-extract" \ + powershell.exe -NoProfile -Command \ + 'Expand-Archive -LiteralPath $env:SFETCH_MINISIGN_ZIP_PATH -DestinationPath $env:SFETCH_MINISIGN_EXTRACT_PATH -Force' || + die "failed to extract minisign zip" + fi + local sub + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Windows arch: $ARCH" ;; + esac + local src="${WORK}/minisign-extract/minisign-win64/${sub}/minisign.exe" + [ -f "$src" ] || die "minisign.exe not found at $src" + cp "$src" "${tools}/minisign.exe" + MINISIGN_BIN="${tools}/minisign.exe" + ;; + darwin) + local zip="${WORK}/minisign-mac.zip" + http_get "$MINISIGN_MAC_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_MAC_SHA256" + unzip -q -o "$zip" -d "${WORK}/minisign-extract" + [ -f "${WORK}/minisign-extract/minisign" ] || die "minisign binary missing from macOS archive" + cp "${WORK}/minisign-extract/minisign" "${tools}/minisign" + chmod 0755 "${tools}/minisign" + MINISIGN_BIN="${tools}/minisign" + ;; + linux) + local tgz="${WORK}/minisign-linux.tar.gz" + http_get "$MINISIGN_LINUX_URL" "$tgz" + assert_sha256 "$tgz" "$MINISIGN_LINUX_SHA256" + mkdir -p "${WORK}/minisign-extract" + tar -xzf "$tgz" -C "${WORK}/minisign-extract" + local sub + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Linux arch: $ARCH" ;; + esac + local src="${WORK}/minisign-extract/minisign-linux/${sub}/minisign" + [ -f "$src" ] || die "minisign not found at $src" + cp "$src" "${tools}/minisign" + chmod 0755 "${tools}/minisign" + MINISIGN_BIN="${tools}/minisign" + ;; + *) + die "unsupported OS for minisign install: $OS" + ;; + esac + assert_minisign_version + log "minisign ready (pinned archive): ${MINISIGN_BIN}" +} + +assert_minisign_version() { + local out first + # Fail closed if the verifier cannot report a version (no || true). + out="$("$MINISIGN_BIN" -v 2>&1)" || + die "minisign -v failed (cannot assert pinned version ${MINISIGN_VERSION_EXPECTED})" + first="$(printf '%s\n' "$out" | head -n1)" + version_output_matches_pin "$first" "$MINISIGN_VERSION_EXPECTED" || + die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${first}" +} + +# --acquire-minisign-only: shared acquisition path for CI Windows dogfood etc. +if [ "$ACQUIRE_MINISIGN_ONLY" = "1" ]; then + mkdir -p "$INSTALL_DIR" + INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + MINISIGN_INSTALL_DIR="$INSTALL_DIR" + ensure_minisign + printf 'minisign-bin=%s\n' "$MINISIGN_BIN" + exit 0 +fi + +# ----------------------------------------------------------------------------- +# Install mode: version / route validation (before mkdir side effects) +# ----------------------------------------------------------------------------- +[ -n "$VERSION" ] || die "--version is required" + +# Reject floating / non-immutable refs (CI and Makefile must never use latest). +case "$VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating version ${VERSION:-}; pin an exact tag (e.g. v0.4.11)" + ;; +esac +if ! is_exact_semver_tag "$VERSION"; then + die "version must be exact vMAJOR.MINOR.PATCH (got: $VERSION)" +fi +if [ -n "$GONEAT_VERSION" ]; then + case "$GONEAT_VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating goneat-version: ${GONEAT_VERSION:-}" + ;; + esac + if ! is_exact_semver_tag "$GONEAT_VERSION"; then + die "goneat-version must be exact vMAJOR.MINOR.PATCH (got: $GONEAT_VERSION)" + fi +fi + +if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SFETCH_BOOTSTRAP_MAX"; then + die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" +fi + +# Side effects only after version/range validation succeeds. +mkdir -p "$INSTALL_DIR" +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + +# Fixed download base (no env override — fixtures patch a temporary copy). +BASE_URL="https://github.com/${REPO}/releases/download" +ASSET_BASE="${BASE_URL}/${VERSION}" + +# Route selection (human log on stderr; machine field on stdout at end only). +ROUTE="" +if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then + ROUTE="minisig" +else + ROUTE="sha256sums" +fi +log "bootstrap-sfetch-verified: version=${VERSION} verify-route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" + +write_pubkey() { + local path="$1" + # minisign -p expects a file with optional comment + RW... key line + { + echo "untrusted comment: sfetch release signing key (embedded trust anchor)" + echo "${SFETCH_MINISIGN_PUBKEY}" + } >"$path" +} + +# ----------------------------------------------------------------------------- +# Verification routes +# ----------------------------------------------------------------------------- +verify_installer_minisig() { + local script="$1" + local _sig="$2" + local pub="$3" + log "verify verify-route=minisig: minisign -Vm install-sfetch.sh" + if ! "$MINISIGN_BIN" -Vm "$script" -p "$pub" -x "$_sig" >/dev/null; then + die "install-sfetch.sh.minisig verification failed (verify-route=minisig; no fallback)" + fi + log "install-sfetch.sh.minisig: OK" +} + +verify_installer_sha256sums() { + local script="$1" sums="$2" sums_sig="$3" pub="$4" + log "verify verify-route=sha256sums: minisign -Vm SHA256SUMS then hash install-sfetch.sh" + if ! "$MINISIGN_BIN" -Vm "$sums" -p "$pub" -x "$sums_sig" >/dev/null; then + die "SHA256SUMS.minisig verification failed (verify-route=sha256sums; no fallback)" + fi + local expect got + expect="$(awk '$2 == "install-sfetch.sh" { print $1; exit }' "$sums")" + [ -n "$expect" ] || die "install-sfetch.sh not listed in SHA256SUMS" + got="$(sha256_file "$script")" + if [ "$got" != "$expect" ]; then + die "install-sfetch.sh SHA-256 mismatch: expected ${expect}, got ${got}" + fi + log "install-sfetch.sh SHA-256 via signed SHA256SUMS: OK" +} + +# ----------------------------------------------------------------------------- +# Install sfetch +# ----------------------------------------------------------------------------- +ensure_minisign + +PUB="${WORK}/sfetch-minisign.pub" +write_pubkey "$PUB" + +SCRIPT="${WORK}/install-sfetch.sh" +http_get "${ASSET_BASE}/install-sfetch.sh" "$SCRIPT" +chmod 0755 "$SCRIPT" + +case "$ROUTE" in + minisig) + SIG="${WORK}/install-sfetch.sh.minisig" + http_get "${ASSET_BASE}/install-sfetch.sh.minisig" "$SIG" + verify_installer_minisig "$SCRIPT" "$SIG" "$PUB" + ;; + sha256sums) + SUMS="${WORK}/SHA256SUMS" + SUMS_SIG="${WORK}/SHA256SUMS.minisig" + http_get "${ASSET_BASE}/SHA256SUMS" "$SUMS" + http_get "${ASSET_BASE}/SHA256SUMS.minisig" "$SUMS_SIG" + verify_installer_sha256sums "$SCRIPT" "$SUMS" "$SUMS_SIG" "$PUB" + ;; + *) + die "internal error: unknown route $ROUTE" + ;; +esac + +# Execute only after verification (never pipe curl | bash). +# install-sfetch.sh requires minisign on PATH for its own SHA256SUMS verify; +# export the same pinned binary we already acquired (temp dir is not on PATH). +log "executing verified installer for ${VERSION} → ${INSTALL_DIR}" +_minisign_dir="$(dirname "${MINISIGN_BIN}")" +export PATH="${_minisign_dir}:${PATH}" +# shellcheck disable=SC2086 +bash "$SCRIPT" --tag "$VERSION" --dir "$INSTALL_DIR" --yes --require-minisign + +SFETCH_BIN="${INSTALL_DIR}/sfetch" +if [ "$OS" = "windows" ]; then + if [ -f "${INSTALL_DIR}/sfetch.exe" ]; then + SFETCH_BIN="${INSTALL_DIR}/sfetch.exe" + fi +fi +[ -x "$SFETCH_BIN" ] || [ -f "$SFETCH_BIN" ] || die "sfetch binary missing after install: $SFETCH_BIN" + +# Exact version assertion (token boundary; not soft substring/regex) +REPORT="$("$SFETCH_BIN" --version 2>&1)" || die "sfetch --version failed after install" +log "sfetch reports: ${REPORT}" +version_output_matches_pin "$REPORT" "$VERSION" || + die "sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" + +# Optional goneat via verified sfetch (no Go toolchain). Fail closed: any +# install or version failure is non-zero (no soft skip when requested). +if [ -n "$GONEAT_VERSION" ]; then + log "installing goneat ${GONEAT_VERSION} via verified sfetch" + "$SFETCH_BIN" --repo fulmenhq/goneat --tag "$GONEAT_VERSION" \ + --dest-dir "$INSTALL_DIR" --require-minisign || + die "goneat install failed for ${GONEAT_VERSION} (requested; fail closed)" + GONEAT_BIN="${INSTALL_DIR}/goneat" + if [ "$OS" = "windows" ] && [ -f "${INSTALL_DIR}/goneat.exe" ]; then + GONEAT_BIN="${INSTALL_DIR}/goneat.exe" + fi + [ -f "$GONEAT_BIN" ] || die "goneat binary missing after install" + GREP="$("$GONEAT_BIN" version 2>&1 | head -n1)" || die "goneat version command failed" + version_output_matches_pin "$GREP" "$GONEAT_VERSION" || + die "goneat version assertion failed: expected ${GONEAT_VERSION}, got: ${GREP}" + log "goneat OK: ${GREP}" +fi + +log "bootstrap-sfetch-verified complete: sfetch=${VERSION} verify-route=${ROUTE} dir=${INSTALL_DIR}" +# Machine-readable fields on stdout only (exactly one route= line for the action). +printf 'route=%s\n' "$ROUTE" +printf 'sfetch-bin=%s\n' "$SFETCH_BIN" +if [ -n "$GONEAT_VERSION" ]; then + printf 'goneat-bin=%s\n' "$GONEAT_BIN" +fi diff --git a/scripts/cmd/generate-checksums/generate-checksums_test.go b/scripts/cmd/generate-checksums/generate-checksums_test.go index dd79859..a249567 100644 --- a/scripts/cmd/generate-checksums/generate-checksums_test.go +++ b/scripts/cmd/generate-checksums/generate-checksums_test.go @@ -19,8 +19,10 @@ func TestRunGeneratesChecksumsAndSkipsNonArtifacts(t *testing.T) { } writeFile("sfetch_one", "one") - writeFile("sfetch_one.asc", "sig") // skipped - writeFile("install-sfetch.sh", "installer") // included + writeFile("sfetch_one.asc", "sig") // skipped + writeFile("install-sfetch.sh", "installer") // included + writeFile("install-sfetch.sh.minisig", "detached-sig") // skipped by .minisig suffix + writeFile("SHA256SUMS.minisig", "manifest-sig") // skipped writeFile("release-notes-v0.0.1.md", "notes") writeFile("SHA256SUMS", "old") // skipped/overwritten @@ -33,7 +35,12 @@ func TestRunGeneratesChecksumsAndSkipsNonArtifacts(t *testing.T) { t.Fatalf("read SHA256SUMS: %v", err) } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") + content := string(data) + if strings.Contains(content, ".minisig") { + t.Fatalf("SHA256SUMS must not list .minisig files (self-reference hazard):\n%s", content) + } + + lines := strings.Split(strings.TrimSpace(content), "\n") if len(lines) != 2 { t.Fatalf("expected 2 entries, got %d: %v", len(lines), lines) } diff --git a/scripts/install-sfetch.sh b/scripts/install-sfetch.sh index 4698f14..469ffc6 100755 --- a/scripts/install-sfetch.sh +++ b/scripts/install-sfetch.sh @@ -37,7 +37,8 @@ SFETCH_API="https://api.github.com/repos/${SFETCH_REPO}/releases" # This key is pinned here to prevent TOCTOU attacks where an attacker # could replace both the release artifacts and the verification key. # -# IMPORTANT: This key must match EmbeddedMinisignPubkey in main.go +# IMPORTANT: This key must match scripts/sfetch-minisign-anchor.pub (SSOT) +# and EmbeddedMinisignPubkey in main.go (go:embed of that file). # Update both when rotating keys (see docs/security/signing-runbook.md) SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" # Pinned PGP fingerprint (for optional fallback) diff --git a/scripts/sfetch-minisign-anchor.pub b/scripts/sfetch-minisign-anchor.pub new file mode 100644 index 0000000..0f5dbd5 --- /dev/null +++ b/scripts/sfetch-minisign-anchor.pub @@ -0,0 +1,2 @@ +untrusted comment: sfetch release signing key (canonical trust anchor SSOT) +RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC diff --git a/scripts/sign-release-manifests.sh b/scripts/sign-release-manifests.sh index 7b0f6a7..71a11c9 100755 --- a/scripts/sign-release-manifests.sh +++ b/scripts/sign-release-manifests.sh @@ -1,33 +1,39 @@ #!/usr/bin/env bash set -euo pipefail -# Dual-format release signing: minisign (.minisig) + PGP (.asc) +# Dual-format release signing: minisign (.minisig) + optional PGP (.asc) # -# Usage: sign-release-assets.sh [dir] +# Usage: sign-release-manifests.sh [dir] # # Environment variables: # SFETCH_MINISIGN_KEY - Path to minisign secret key file. Primary format. # SFETCH_PGP_KEY_ID - GPG key ID for PGP signing. Optional secondary format. # SFETCH_GPG_HOMEDIR - Custom GPG homedir (optional, defaults to ~/.gnupg) # +# Signing sets (deliberate split — do not "harmonise" without a lock): +# minisign: SHA256SUMS, SHA512SUMS, and install-sfetch.sh +# PGP: SHA256SUMS and SHA512SUMS only (manifests) +# +# Why the installer is signed with minisign (second password prompt): +# The installer is the one artifact consumers execute *before* any verifier +# chain exists. Signing only manifests forces a five-step consumer path; +# a detached install-sfetch.sh.minisig collapses that to three steps. +# All other release assets remain covered by the signed checksum manifests. +# Scaling is 2 prompts (manifests + installer), not N per-file signatures. +# # Minisign was chosen over raw ed25519 because: # - Created by Frank Denis (libsodium author), well-audited # - Trusted comments provide signed metadata (version, timestamp) # - Password-protected keys by default # - Compatible with OpenBSD signify -# -# Only SHA256SUMS is signed (not individual files). This is the standard pattern: -# verify signature on checksum file, then verify file checksums against that. -# This means one password prompt instead of N. -TAG=${1:?"usage: sign-release-assets.sh [dir]"} +TAG=${1:?"usage: sign-release-manifests.sh [dir]"} DIR=${2:-dist/release} SFETCH_MINISIGN_KEY=${SFETCH_MINISIGN_KEY:-} SFETCH_PGP_KEY_ID=${SFETCH_PGP_KEY_ID:-} SFETCH_GPG_HOMEDIR=${SFETCH_GPG_HOMEDIR:-} -# Validation if [ ! -d "$DIR" ]; then echo "error: directory $DIR not found" >&2 exit 1 @@ -45,6 +51,14 @@ if [ ${#checksum_files[@]} -eq 0 ]; then exit 1 fi +installer_file="" +if [ -f "$DIR/install-sfetch.sh" ]; then + installer_file="install-sfetch.sh" +else + echo "error: install-sfetch.sh not found in $DIR (run make bootstrap-script / release-checksums first)" >&2 + exit 1 +fi + has_minisign=false has_pgp=false @@ -62,7 +76,6 @@ if [ -n "$SFETCH_MINISIGN_KEY" ]; then echo "minisign signing enabled (key: $SFETCH_MINISIGN_KEY)" fi -# Only enable PGP if explicitly requested via SFETCH_PGP_KEY_ID if [ -n "$SFETCH_PGP_KEY_ID" ]; then if ! command -v gpg >/dev/null 2>&1; then echo "error: SFETCH_PGP_KEY_ID set but gpg not found in PATH" >&2 @@ -82,25 +95,31 @@ if [ "$has_minisign" = false ] && [ "$has_pgp" = false ]; then exit 1 fi -# Sign checksum manifests (preferred workflow) -# Users verify: 1) signature on checksum file, 2) file checksums against it -# +# Minisign is required for the installer signature (release contract). +if [ "$has_minisign" = false ]; then + echo "error: SFETCH_MINISIGN_KEY is required to sign install-sfetch.sh" >&2 + exit 1 +fi + # Signing is grouped by tool (all minisign first, then all PGP) to minimize # password prompt switching during manual signing workflows. if [ "$has_minisign" = true ]; then echo "" - echo "=== Minisign signatures ===" + echo "=== Minisign signatures (manifests + installer) ===" for file in "${checksum_files[@]}"; do echo "🔏 [minisign] Signing $file" rm -f "$DIR/$file.minisig" minisign -S -s "$SFETCH_MINISIGN_KEY" -t "sfetch $TAG $(date -u +%Y-%m-%dT%H:%M:%SZ)" -m "$DIR/$file" done + echo "🔏 [minisign] Signing $installer_file" + rm -f "$DIR/${installer_file}.minisig" + minisign -S -s "$SFETCH_MINISIGN_KEY" -t "sfetch $TAG install-sfetch.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)" -m "$DIR/$installer_file" fi if [ "$has_pgp" = true ]; then echo "" - echo "=== PGP signatures ===" + echo "=== PGP signatures (manifests only) ===" for file in "${checksum_files[@]}"; do echo "🔏 [PGP] Signing $file" if [ -n "$SFETCH_GPG_HOMEDIR" ]; then @@ -121,3 +140,6 @@ for file in "${checksum_files[@]}"; do echo " $file.asc: verify with --pgp-key-file" fi done +if [ "$has_minisign" = true ]; then + echo " ${installer_file}.minisig: required release-gate artifact (minisign only)" +fi diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh new file mode 100755 index 0000000..b1c650d --- /dev/null +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# Unit/fixture tests for bootstrap-sfetch-verified.sh (route selection, rejects, dual-route). +# Production engine has no runtime env trust overrides; fixtures patch a temporary copy. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +SCRIPT="${ROOT}/scripts/bootstrap-sfetch-verified.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} +pass() { echo "PASS: $*"; } + +# No colocated engine under the action directory — single SSOT under scripts/. +[ ! -e "${ROOT}/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh" ] || + fail "colocated action engine must not exist (single SSOT under scripts/)" +# Helper removed: exact matcher lives only in the engine. +[ ! -e "${ROOT}/scripts/version-matches-pin.sh" ] || + fail "version-matches-pin.sh must not exist (engine is sole matcher SSOT)" +[ -x "$SCRIPT" ] || chmod +x "$SCRIPT" +# Production engine must not honor trust-weakening env seams. +grep -q 'SFETCH_BOOTSTRAP_BASE_URL' "$SCRIPT" && + fail "production engine must not reference SFETCH_BOOTSTRAP_BASE_URL" +grep -q 'SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL' "$SCRIPT" && + fail "production engine must not reference SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL" +pass "single engine SSOT; no production env trust seams" + +# Load the engine's authoritative matcher for offline token unit tests. +eval "$(sed -n '/^version_output_matches_pin()/,/^}/p' "$SCRIPT")" +version_output_matches_pin "sfetch 0.4.11" "v0.4.11" || fail "should match sfetch 0.4.11" +version_output_matches_pin "sfetch version v0.4.11" "v0.4.11" || fail "should match v-prefixed" +if version_output_matches_pin "sfetch 10x4y110" "v0.4.11"; then fail "must not soft-match 10x4y110"; fi +if version_output_matches_pin "sfetch 10.4.11" "v0.4.11"; then fail "must not match inside 10.4.11"; fi +if version_output_matches_pin "sfetch 0.4.110" "v0.4.11"; then fail "must not match 0.4.110"; fi +if version_output_matches_pin "sfetch 0.4.11-rc1" "v0.4.11"; then fail "must not match suffixed 0.4.11-rc1"; fi +if version_output_matches_pin "sfetch v0.4.11-beta" "v0.4.11"; then fail "must not match v0.4.11-beta"; fi +if version_output_matches_pin "sfetch x0.4.11" "v0.4.11"; then fail "must not match prefixed x0.4.11"; fi +version_output_matches_pin "minisign 0.12" "0.12" || fail "should match minisign 0.12" +pass "engine version_output_matches_pin exact token rules" + +# --- Version rejection (no network) --- +if "$SCRIPT" 2>/dev/null; then fail "should require --version"; else pass "requires --version"; fi +if "$SCRIPT" --version latest --dir /tmp 2>/dev/null; then fail "latest should fail"; else pass "rejects latest"; fi +if "$SCRIPT" --version main --dir /tmp 2>/dev/null; then fail "main should fail"; else pass "rejects main"; fi +if "$SCRIPT" --version v0.4.11-rc1 --dir /tmp 2>/dev/null; then fail "prerelease should fail"; else pass "rejects prerelease"; fi +if "$SCRIPT" --version v0.4.8 --dir /tmp 2>/dev/null; then fail "below min should fail"; else pass "rejects below min"; fi +if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi +# F2: leading-zero components must not pass range/route selection +if "$SCRIPT" --version v0.4.09 --dir /tmp 2>/dev/null; then fail "v0.4.09 leading zero should fail"; else pass "rejects v0.4.09 leading zero"; fi +if "$SCRIPT" --version v0.04.11 --dir /tmp 2>/dev/null; then fail "v0.04.11 leading zero should fail"; else pass "rejects v0.04.11 leading zero"; fi + +# --- Helpers: patch temporary engines for fixtures (never production seams) --- +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" +SRV_PID="" +SRV_PID2="" +cleanup_harness() { + [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true + [ -n "${SRV_PID2:-}" ] && kill "$SRV_PID2" 2>/dev/null || true + wait "$SRV_PID" 2>/dev/null || true + wait "$SRV_PID2" 2>/dev/null || true + rm -rf "${WORKDIR}" +} +trap cleanup_harness EXIT + +# Rejected inputs must not create install dirs (validate-before-side-effect) +NO_SIDE="$WORKDIR/no-side" +if "$SCRIPT" --version v0.4.09 --dir "$NO_SIDE" 2>/dev/null; then fail "v0.4.09 should fail"; fi +[ ! -e "$NO_SIDE" ] || fail "rejected version must not create install dir" +pass "rejected version has no mkdir side effect" + +# Huge components: both overflow faces must fail closed before route selection / network. +# Face 1: wrap-to-zero major (2^64). Face 2: wrap-into-range patch (2^64+11 → was 11 under Bash $((10#…))). +# A late 404 would also be nonzero — pin that rejection is early (no verify-route=, no fetch log, no install dir). +assert_early_version_reject() { + local ver="$1" label="$2" + local idir="$WORKDIR/early-reject-${label}" + local logf="$WORKDIR/early-reject-${label}.log" + local rc=0 + set +e + "$SCRIPT" --version "$ver" --dir "$idir" >"$logf" 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "${label}: expected nonzero exit for ${ver}" + if grep -q 'verify-route=' "$logf"; then + fail "${label}: must not emit verify-route= before range reject (${ver})" + fi + if grep -Eiq 'fetch attempt|failed to fetch|http(s)?://|curl |wget ' "$logf"; then + fail "${label}: must not perform network/fetch before range reject (${ver})" + fi + [ ! -e "$idir" ] || fail "${label}: install dir must remain absent for ${ver}" + pass "rejects ${label} early (${ver})" +} +assert_early_version_reject "v18446744073709551616.4.10" "huge-major-wrap-zero" +assert_early_version_reject "v0.4.18446744073709551627" "huge-patch-wrap-into-range" + +PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" + +# Build a temporary engine: optional test pubkey + fixed BASE_URL for sfetch assets. +# Minisign always comes from the engine's pinned 0.12 download (not ambient PATH). +# CI runners often ship distro minisign 0.11; using ambient would fail the 0.12 assert +# and is not how production runs. +make_fixture_engine() { + local dest="$1" base_url="$2" pubkey="${3-}" + local src="$SCRIPT" + cp "$src" "$dest" + # Fixed download base → fixture URL (literal, no env). + # shellcheck disable=SC2016 + sed -i.bak \ + 's|BASE_URL="https://github.com/${REPO}/releases/download"|BASE_URL="'"${base_url}"'"|' \ + "$dest" + if [ -n "$pubkey" ]; then + sed -i.bak "s|${PROD_PUBKEY}|${pubkey}|g" "$dest" + grep -q "$pubkey" "$dest" || fail "patched engine missing test pubkey" + grep -q "$PROD_PUBKEY" "$dest" && fail "patched engine still has production pubkey" + fi + rm -f "${dest}.bak" + chmod +x "$dest" +} + +# --- Route selection via fixture engines with dead base URL --- +ROUTE_ENG="$WORKDIR/route-engine.sh" +make_fixture_engine "$ROUTE_ENG" "file://${WORKDIR}/empty" + +set +e +OUT1040="$WORKDIR/out410.txt" +"$ROUTE_ENG" --version v0.4.10 --dir "$WORKDIR/d410" >"$OUT1040" 2>&1 +set -e +grep -Eq 'verify-route=sha256sums' "$OUT1040" || fail "v0.4.10 should select sha256sums (log: $(cat "$OUT1040"))" +pass "v0.4.10 → verify-route=sha256sums" + +set +e +OUT411="$WORKDIR/out411.txt" +"$ROUTE_ENG" --version v0.4.11 --dir "$WORKDIR/d411" >"$OUT411" 2>&1 +set -e +grep -Eq 'verify-route=minisig' "$OUT411" || fail "v0.4.11 should select minisig (log: $(cat "$OUT411"))" +pass "v0.4.11 → verify-route=minisig" + +# Production engine ignores inherited BASE_URL / SKIP env (no seams). +export SFETCH_BOOTSTRAP_BASE_URL="http://evil.example/should-not-be-used" +export SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 +set +e +OUT_IGN="$WORKDIR/out-ignore-env.txt" +"$SCRIPT" --version v0.4.10 --dir "$WORKDIR/d-ign" >"$OUT_IGN" 2>&1 +set -e +# Should attempt real GitHub URL (or fail fetching), not evil.example +if grep -q 'evil.example' "$OUT_IGN"; then + fail "production engine honored SFETCH_BOOTSTRAP_BASE_URL" +fi +# With SKIP set, production still must not prefer ambient-only short-circuit via that var +# (it will try to download pinned minisign or fetch assets from real GitHub). +pass "production engine ignores inherited BASE_URL/SKIP env vars" +unset SFETCH_BOOTSTRAP_BASE_URL SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL + +# --- Local dual-route fixtures with ephemeral minisign --- +command -v minisign >/dev/null 2>&1 || fail "minisign required" +command -v python3 >/dev/null 2>&1 || fail "python3 required for local HTTP fixture" + +KEY="$WORKDIR/k.key" +PUB="$WORKDIR/k.pub" +minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + fail "keygen" + +TEST_PUBKEY="$(grep -E '^RW' "$PUB" | head -n1 | tr -d '\r\n')" +[ -n "$TEST_PUBKEY" ] || fail "could not read test pubkey" + +SRV_ROOT="$WORKDIR/www" +mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" + +make_stub_installer() { + local dest="$1" + cat >"$dest" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +DIR="" +TAG="" +while [ $# -gt 0 ]; do + case "$1" in + --dir) DIR="$2"; shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --yes|--require-minisign) shift ;; + *) shift ;; + esac +done +: "${DIR:?}" +mkdir -p "${DIR}" +VER="${TAG#v}" +cat >"${DIR}/sfetch" <"${DIR}/.stub-ran" +STUB + chmod +x "$dest" +} + +make_stub_installer "$SRV_ROOT/v0.4.11/install-sfetch.sh" +make_stub_installer "$SRV_ROOT/v0.4.10/install-sfetch.sh" + +minisign -S -s "$KEY" -t "test-v0.4.11" -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" +( + cd "$SRV_ROOT/v0.4.10" + shasum -a 256 install-sfetch.sh >SHA256SUMS +) +minisign -S -s "$KEY" -t "test-v0.4.10" -m "$SRV_ROOT/v0.4.10/SHA256SUMS" + +start_http_fixture() { + local root="$1" portfile="$2" + rm -f "$portfile" + python3 - "$root" "$portfile" <<'PY' & +import http.server, socketserver, sys, pathlib +root = pathlib.Path(sys.argv[1]) +portfile = pathlib.Path(sys.argv[2]) +class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **k): + super().__init__(*a, directory=str(root), **k) + def log_message(self, *args): + pass +with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd: + portfile.write_text(str(httpd.server_address[1])) + httpd.serve_forever() +PY +} + +PORTFILE="$WORKDIR/port" +start_http_fixture "$SRV_ROOT" "$PORTFILE" +SRV_PID=$! +for _ in $(seq 1 50); do + [ -f "$PORTFILE" ] && break + sleep 0.05 +done +[ -f "$PORTFILE" ] || fail "HTTP fixture server failed to start" +PORT="$(cat "$PORTFILE")" +BASE="http://127.0.0.1:${PORT}" + +PATCHED="$WORKDIR/bootstrap-patched.sh" +make_fixture_engine "$PATCHED" "$BASE" "$TEST_PUBKEY" + +# Positive: v0.4.11 minisig route +GOOD411="$WORKDIR/good411" +mkdir -p "$GOOD411" +set +e +OUT_GOOD="$WORKDIR/out-good411.txt" +"$PATCHED" --version v0.4.11 --dir "$GOOD411" >"$OUT_GOOD" 2>&1 +RC=$? +set -e +[ "$RC" -eq 0 ] || fail "positive v0.4.11 minisig should succeed (log: $(cat "$OUT_GOOD"))" +ROUTE_LINES="$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_GOOD")" +[ "$ROUTE_LINES" -eq 1 ] || fail "expected exactly one stdout route= field, got ${ROUTE_LINES}" +grep -q '^route=minisig$' "$OUT_GOOD" || fail "positive minisig machine route missing" +[ -f "$GOOD411/.stub-ran" ] || fail "installer should execute after successful verify" +[ -x "$GOOD411/sfetch" ] || fail "sfetch binary should be installed" +version_output_matches_pin "$("$GOOD411/sfetch" --version 2>&1)" "v0.4.11" || fail "stub sfetch version" +pass "positive v0.4.11 minisig route (patched ephemeral key)" + +# Positive: v0.4.10 sha256sums route +GOOD410="$WORKDIR/good410" +mkdir -p "$GOOD410" +set +e +OUT_GOOD410="$WORKDIR/out-good410.txt" +"$PATCHED" --version v0.4.10 --dir "$GOOD410" >"$OUT_GOOD410" 2>&1 +RC=$? +set -e +[ "$RC" -eq 0 ] || fail "positive v0.4.10 sha256sums should succeed (log: $(cat "$OUT_GOOD410"))" +[ "$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_GOOD410")" -eq 1 ] || fail "expected one route= field" +grep -q '^route=sha256sums$' "$OUT_GOOD410" || fail "positive sha256sums machine route missing" +[ -f "$GOOD410/.stub-ran" ] || fail "installer should execute after sha256sums verify" +pass "positive v0.4.10 sha256sums route (patched ephemeral key)" + +# Negative: wrong-key minisig (production pubkey engine against test-key sig) +WRONG_ENG="$WORKDIR/wrong-key-engine.sh" +make_fixture_engine "$WRONG_ENG" "$BASE" # keep production pubkey +BAD_DIR="$WORKDIR/bad" +mkdir -p "$BAD_DIR" +set +e +OUTBAD="$WORKDIR/outbad.txt" +"$WRONG_ENG" --version v0.4.11 --dir "$BAD_DIR" >"$OUTBAD" 2>&1 +RC=$? +set -e +[ "$RC" -ne 0 ] || fail "wrong-key minisig should fail" +[ ! -f "$BAD_DIR/.stub-ran" ] || fail "installer must not run before verify" +[ ! -f "$BAD_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" +grep -Eq 'verify-route=minisig' "$OUTBAD" || fail "expected minisig verify-route in log" +if grep -q '^route=' "$OUTBAD"; then fail "failed run must not emit machine route="; fi +pass "wrong-key minisig fails closed without executing installer" + +# Negative: missing signature +NOSIG_ROOT="$WORKDIR/www-nosig" +mkdir -p "$NOSIG_ROOT/v0.4.11" +cp "$SRV_ROOT/v0.4.11/install-sfetch.sh" "$NOSIG_ROOT/v0.4.11/" +PORTFILE2="$WORKDIR/port2" +start_http_fixture "$NOSIG_ROOT" "$PORTFILE2" +SRV_PID2=$! +for _ in $(seq 1 50); do + [ -f "$PORTFILE2" ] && break + sleep 0.05 +done +[ -f "$PORTFILE2" ] || fail "second HTTP fixture server failed to start" +PORT2="$(cat "$PORTFILE2")" +NOSIG_ENG="$WORKDIR/nosig-engine.sh" +make_fixture_engine "$NOSIG_ENG" "http://127.0.0.1:${PORT2}" "$TEST_PUBKEY" +NOSIG_DIR="$WORKDIR/nosig" +mkdir -p "$NOSIG_DIR" +set +e +OUTNOSIG="$WORKDIR/out-nosig.txt" +"$NOSIG_ENG" --version v0.4.11 --dir "$NOSIG_DIR" >"$OUTNOSIG" 2>&1 +RC=$? +set -e +[ "$RC" -ne 0 ] || fail "missing minisig should fail" +[ ! -f "$NOSIG_DIR/.stub-ran" ] || fail "installer must not run when sig missing" +pass "missing install-sfetch.sh.minisig fails closed" + +# --- Action engine resolution: package root scripts/, never GITHUB_WORKSPACE --- +FAKE_PKG="$WORKDIR/fake-action-repo" +mkdir -p "$FAKE_PKG/.github/actions/setup-sfetch" "$FAKE_PKG/scripts" +cp "$SCRIPT" "$FAKE_PKG/scripts/bootstrap-sfetch-verified.sh" +chmod +x "$FAKE_PKG/scripts/bootstrap-sfetch-verified.sh" +HOSTILE_WS="$WORKDIR/hostile-ws" +mkdir -p "$HOSTILE_WS/scripts" +cat >"$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" <<'HOSTILE' +#!/usr/bin/env bash +echo "HOSTILE_ENGINE_EXECUTED" >&2 +printf 'route=minisig\n' +printf 'sfetch-bin=/tmp/evil\n' +exit 0 +HOSTILE +chmod +x "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" + +resolve_engine() { + local GITHUB_ACTION_PATH="$1" + local GITHUB_WORKSPACE="${2-}" + local PACKAGE_ROOT ENGINE + PACKAGE_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + ENGINE="${PACKAGE_ROOT}/scripts/bootstrap-sfetch-verified.sh" + if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then + echo "error: action-repo engine missing" >&2 + return 1 + fi + ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" + case "${ENGINE}" in + "${PACKAGE_ROOT}"/*) ;; + *) + echo "error: engine outside package root" >&2 + return 1 + ;; + esac + if [ -n "${GITHUB_WORKSPACE}" ]; then + local WS_REAL + WS_REAL="$(cd "${GITHUB_WORKSPACE}" && pwd)" + if [ "${PACKAGE_ROOT}" != "${WS_REAL}" ]; then + case "${ENGINE}" in + "${WS_REAL}"/*) + echo "error: engine in consumer workspace" >&2 + return 1 + ;; + esac + fi + fi + printf '%s\n' "$ENGINE" +} + +RESOLVED="$(resolve_engine "$FAKE_PKG/.github/actions/setup-sfetch" "$HOSTILE_WS")" || + fail "package-root resolve failed" +case "$RESOLVED" in + *hostile*) fail "resolved engine under hostile workspace: $RESOLVED" ;; +esac +[ "$RESOLVED" = "$(cd "$FAKE_PKG/scripts" && pwd)/bootstrap-sfetch-verified.sh" ] || + fail "unexpected resolve path: $RESOLVED" +if resolve_engine "$WORKDIR/missing-action/nested/deep" "$HOSTILE_WS" 2>/dev/null; then + fail "missing package engine must fail" +fi +pass "action resolves package-root scripts/ engine (hostile workspace ignored)" + +# --- Action wrapper simulation: parse real successful engine stdout --- +SIM_OUT="$WORKDIR/sim-out.txt" +SIM_ERR="$WORKDIR/sim-err.txt" +"$PATCHED" --version v0.4.10 --dir "$WORKDIR/sim-install" >"$SIM_OUT" 2>"$SIM_ERR" +SIM_ROUTE_COUNT="$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$SIM_OUT")" +[ "$SIM_ROUTE_COUNT" -eq 1 ] || fail "action sim: expected one route= on stdout, got ${SIM_ROUTE_COUNT}" +SIM_ROUTE="$(awk -F= '/^route=/{print $2; exit}' "$SIM_OUT")" +[ "$SIM_ROUTE" = "sha256sums" ] || fail "action sim: route=$SIM_ROUTE" +SIM_BIN="$(awk -F= '/^sfetch-bin=/{print $2; exit}' "$SIM_OUT")" +[ -n "$SIM_BIN" ] && [ -f "$SIM_BIN" ] || fail "action sim: sfetch-bin missing" +grep -Eq 'verify-route=sha256sums' "$SIM_ERR" || fail "action sim: expected human verify-route log" +# Confirm action has no executable || true soft suppressions (comments OK). +if grep -n '|| true' "${ROOT}/.github/actions/setup-sfetch/action.yml" | grep -v '^\s*[0-9]*:\s*#'; then + fail "action.yml must not contain || true outside comments" +fi +pass "action wrapper parses single stdout route= from successful engine run" + +# Cleanup primary servers before goneat offline fixture +kill "$SRV_PID" 2>/dev/null || true +kill "$SRV_PID2" 2>/dev/null || true +wait "$SRV_PID" 2>/dev/null || true +wait "$SRV_PID2" 2>/dev/null || true +SRV_PID="" +SRV_PID2="" + +# Negative: requested goneat fails closed +if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then + GONEAT_FAIL_DIR="$WORKDIR/goneat-fail" + mkdir -p "$GONEAT_FAIL_DIR" + set +e + OUT_GF="$WORKDIR/out-goneat-fail.txt" + "$SCRIPT" --version v0.4.10 --dir "$GONEAT_FAIL_DIR" --goneat-version v0.0.0 >"$OUT_GF" 2>&1 + RC=$? + set -e + [ "$RC" -ne 0 ] || fail "requested unavailable goneat must fail closed" + pass "requested goneat v0.0.0 fails closed" +else + make_stub_installer_fail_goneat() { + local dest="$1" + cat >"$dest" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +DIR="" +TAG="" +while [ $# -gt 0 ]; do + case "$1" in + --dir) DIR="$2"; shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --yes|--require-minisign) shift ;; + *) shift ;; + esac +done +mkdir -p "${DIR}" +VER="${TAG#v}" +cat >"${DIR}/sfetch" <&2 +exit 1 +EOF +chmod +x "${DIR}/sfetch" +STUB + chmod +x "$dest" + } + GONEROOT="$WORKDIR/www-goneat" + mkdir -p "$GONEROOT/v0.4.11" + make_stub_installer_fail_goneat "$GONEROOT/v0.4.11/install-sfetch.sh" + minisign -S -s "$KEY" -t "g" -m "$GONEROOT/v0.4.11/install-sfetch.sh" + PORTFILE3="$WORKDIR/port3" + start_http_fixture "$GONEROOT" "$PORTFILE3" + SRV_PID=$! + for _ in $(seq 1 50); do + [ -f "$PORTFILE3" ] && break + sleep 0.05 + done + [ -f "$PORTFILE3" ] || fail "goneat HTTP fixture server failed to start" + PORT3="$(cat "$PORTFILE3")" + GONEAT_ENG="$WORKDIR/goneat-engine.sh" + make_fixture_engine "$GONEAT_ENG" "http://127.0.0.1:${PORT3}" "$TEST_PUBKEY" + GONEAT_DIR="$WORKDIR/goneat-offline" + mkdir -p "$GONEAT_DIR" + set +e + OUT_GO="$WORKDIR/out-goneat-offline.txt" + "$GONEAT_ENG" --version v0.4.11 --dir "$GONEAT_DIR" --goneat-version v0.5.15 >"$OUT_GO" 2>&1 + RC=$? + set -e + [ "$RC" -ne 0 ] || fail "goneat install failure must fail closed (log: $(cat "$OUT_GO"))" + pass "requested goneat fails closed when sfetch install of goneat fails" +fi + +# Live install against published v0.4.10 when explicitly enabled or in GHA. +if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then + LIVE_DIR="$WORKDIR/live" + mkdir -p "$LIVE_DIR" + if "$SCRIPT" --version v0.4.10 --dir "$LIVE_DIR"; then + version_output_matches_pin "$("$LIVE_DIR/sfetch" --version 2>&1)" "v0.4.10" || fail "live v0.4.10 version" + pass "live v0.4.10 sha256sums route install" + else + fail "live v0.4.10 install failed" + fi +else + pass "skip live install (set SFETCH_BOOTSTRAP_LIVE=1 or GITHUB_ACTIONS=true to enable)" +fi + +echo "[ok] bootstrap-sfetch-verified regression harness complete" diff --git a/scripts/test-release-verify-signatures.sh b/scripts/test-release-verify-signatures.sh new file mode 100755 index 0000000..0f2fcb1 --- /dev/null +++ b/scripts/test-release-verify-signatures.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Regression harness for release signature verification (installer required). +# Uses ephemeral minisign keys — no production secrets. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} +pass() { echo "PASS: $*"; } + +command -v minisign >/dev/null 2>&1 || fail "minisign required for this harness" + +WORKDIR="" +trap 'rm -rf "${WORKDIR:-}" 2>/dev/null || true' EXIT +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-sig-test.XXXXXX")" + +KEY="${WORKDIR}/test.key" +PUB="${WORKDIR}/test.pub" +minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + fail "minisign keygen failed" + +# Fixture scripts tree: ephemeral key is the temporary "canonical" anchor so +# verify mechanics can be tested without production secrets. Production +# scripts/sfetch-minisign-anchor.pub remains the real SSOT. +FIX_SCRIPTS="${WORKDIR}/scripts" +mkdir -p "$FIX_SCRIPTS" +cp "$ROOT/scripts/verify-signatures.sh" "$FIX_SCRIPTS/" +cp "$PUB" "$FIX_SCRIPTS/sfetch-minisign-anchor.pub" +VERIFY="$FIX_SCRIPTS/verify-signatures.sh" +chmod +x "$VERIFY" + +# --- Fixture: signed manifests + installer --- +STAGE="${WORKDIR}/stage" +mkdir -p "$STAGE" +printf 'payload\n' >"$STAGE/a.bin" +printf '#!/bin/sh\necho installer\n' >"$STAGE/install-sfetch.sh" +( + cd "$STAGE" + shasum -a 256 a.bin install-sfetch.sh >SHA256SUMS + shasum -a 512 a.bin install-sfetch.sh >SHA512SUMS +) + +export SFETCH_MINISIGN_KEY="$KEY" +export SFETCH_MINISIGN_PUB="$PUB" +minisign -S -s "$KEY" -t "test" -m "$STAGE/SHA256SUMS" +minisign -S -s "$KEY" -t "test" -m "$STAGE/SHA512SUMS" +minisign -S -s "$KEY" -t "test" -m "$STAGE/install-sfetch.sh" + +# Case 1: full set verifies (fixture anchor = test key) +if SFETCH_MINISIGN_PUB="$PUB" "$VERIFY" "$STAGE"; then + pass "full fixture verifies" +else + fail "full fixture should verify" +fi + +# Case 1b (F1): production gate rejects operator pub that is not the consumer anchor +# even when signatures would verify against that operator key. +if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$STAGE" 2>"${WORKDIR}/f1.err"; then + fail "production verify must reject non-canonical SFETCH_MINISIGN_PUB" +else + grep -qi 'canonical consumer trust anchor\|does not match' "${WORKDIR}/f1.err" || + fail "expected F1 anchor mismatch message (got: $(cat "${WORKDIR}/f1.err"))" + pass "production gate rejects non-canonical operator pubkey (F1)" +fi + +# Case 2: missing installer minisig ⇒ non-zero (required) +NO_INST="${WORKDIR}/no-inst" +cp -R "$STAGE/." "$NO_INST/" +rm -f "$NO_INST/install-sfetch.sh.minisig" +if SFETCH_MINISIGN_PUB="$PUB" "$VERIFY" "$NO_INST"; then + fail "missing install-sfetch.sh.minisig should exit non-zero" +else + pass "missing install-sfetch.sh.minisig exits non-zero" +fi + +# Case 3: tampered installer fails +TAMPER="${WORKDIR}/tamper" +cp -R "$STAGE/." "$TAMPER/" +echo "evil" >>"$TAMPER/install-sfetch.sh" +if SFETCH_MINISIGN_PUB="$PUB" "$VERIFY" "$TAMPER"; then + fail "tampered installer should exit non-zero" +else + pass "tampered installer exits non-zero" +fi + +# Case 4: wrong key fails (fixture anchor still test key; wrong pub fails both +# anchor check against fixture SSOT and/or signature verify) +WRONG="${WORKDIR}/wrong" +cp -R "$STAGE/." "$WRONG/" +WRONG_KEY="${WORKDIR}/wrong.key" +WRONG_PUB="${WORKDIR}/wrong.pub" +minisign -G -W -p "$WRONG_PUB" -s "$WRONG_KEY" >/dev/null 2>&1 || + minisign -G -n -p "$WRONG_PUB" -s "$WRONG_KEY" >/dev/null 2>&1 || + fail "wrong keygen failed" +if SFETCH_MINISIGN_PUB="$WRONG_PUB" "$VERIFY" "$WRONG"; then + fail "wrong key should exit non-zero" +else + pass "wrong key exits non-zero" +fi + +# Case 5: sign-release-manifests produces installer minisig (and PGP not on installer) +SIGN_DIR="${WORKDIR}/sign" +mkdir -p "$SIGN_DIR" +cp "$STAGE/install-sfetch.sh" "$SIGN_DIR/" +cp "$STAGE/SHA256SUMS" "$SIGN_DIR/" +cp "$STAGE/SHA512SUMS" "$SIGN_DIR/" +SFETCH_MINISIGN_KEY="$KEY" ./scripts/sign-release-manifests.sh v0.0.0-test "$SIGN_DIR" +[ -f "$SIGN_DIR/install-sfetch.sh.minisig" ] || fail "sign-release-manifests did not produce install-sfetch.sh.minisig" +[ -f "$SIGN_DIR/SHA256SUMS.minisig" ] || fail "sign-release-manifests did not produce SHA256SUMS.minisig" +[ -f "$SIGN_DIR/SHA512SUMS.minisig" ] || fail "sign-release-manifests did not produce SHA512SUMS.minisig" +[ ! -f "$SIGN_DIR/install-sfetch.sh.asc" ] || fail "PGP must not sign installer by default" +pass "sign-release-manifests minisign targets (manifests + installer)" + +# Case 6: upload script refuses missing installer minisig +UPLOAD_DIR="${WORKDIR}/upload" +mkdir -p "$UPLOAD_DIR" +cp "$STAGE/install-sfetch.sh" "$UPLOAD_DIR/" +printf '# notes\n' >"$UPLOAD_DIR/release-notes-v0.0.0-test.md" +printf 'bin\n' >"$UPLOAD_DIR/sfetch_linux_amd64.tar.gz" +cp "$STAGE/SHA256SUMS" "$UPLOAD_DIR/" +cp "$STAGE/SHA512SUMS" "$UPLOAD_DIR/" +if ./scripts/upload-release-assets.sh v0.0.0-test "$UPLOAD_DIR" 2>/dev/null; then + fail "upload should refuse missing install-sfetch.sh.minisig" +else + pass "upload refuses missing install-sfetch.sh.minisig" +fi + +# Case 7: generate-checksums does not list .minisig +GEN="${WORKDIR}/gen" +mkdir -p "$GEN" +printf 'x\n' >"$GEN/sfetch_linux_amd64" +printf '#!/bin/sh\n' >"$GEN/install-sfetch.sh" +printf 'sig\n' >"$GEN/install-sfetch.sh.minisig" +printf 'sig\n' >"$GEN/SHA256SUMS.minisig" +go run ./scripts/cmd/generate-checksums --dir "$GEN" +if grep -q '\.minisig' "$GEN/SHA256SUMS"; then + fail "SHA256SUMS must not contain .minisig entries" +else + pass "SHA256SUMS has no .minisig self-reference" +fi +grep -q 'install-sfetch.sh' "$GEN/SHA256SUMS" || fail "install-sfetch.sh should still be checksummed" +pass "installer remains in checksums; minisig skipped" + +# Case 8: PGP-on-manifests-only when PGP is enabled (ephemeral key; no installer .asc) +if command -v gpg >/dev/null 2>&1; then + GPG_HOME="${WORKDIR}/gnupg" + mkdir -p "$GPG_HOME" + chmod 700 "$GPG_HOME" + cat >"${WORKDIR}/gpg-batch" </dev/null 2>&1 || + fail "ephemeral gpg keygen failed" + PGP_ID="$(gpg --homedir "$GPG_HOME" --list-keys --with-colons 2>/dev/null | awk -F: '/^pub/{print $5; exit}')" + [ -n "$PGP_ID" ] || fail "could not read ephemeral PGP key id" + + PGP_DIR="${WORKDIR}/pgp" + mkdir -p "$PGP_DIR" + cp "$STAGE/install-sfetch.sh" "$PGP_DIR/" + cp "$STAGE/SHA256SUMS" "$PGP_DIR/" + cp "$STAGE/SHA512SUMS" "$PGP_DIR/" + SFETCH_MINISIGN_KEY="$KEY" \ + SFETCH_PGP_KEY_ID="$PGP_ID" \ + SFETCH_GPG_HOMEDIR="$GPG_HOME" \ + ./scripts/sign-release-manifests.sh v0.0.0-pgp-test "$PGP_DIR" + + [ -f "$PGP_DIR/SHA256SUMS.minisig" ] || fail "PGP path still needs SHA256SUMS.minisig" + [ -f "$PGP_DIR/SHA512SUMS.minisig" ] || fail "PGP path still needs SHA512SUMS.minisig" + [ -f "$PGP_DIR/install-sfetch.sh.minisig" ] || fail "PGP path still needs install-sfetch.sh.minisig" + [ -f "$PGP_DIR/SHA256SUMS.asc" ] || fail "PGP should sign SHA256SUMS" + [ -f "$PGP_DIR/SHA512SUMS.asc" ] || fail "PGP should sign SHA512SUMS" + [ ! -f "$PGP_DIR/install-sfetch.sh.asc" ] || fail "PGP must not sign install-sfetch.sh" + pass "PGP signs manifests only; installer minisign-only (no .asc)" +else + pass "skip PGP target-set proof (gpg not available)" +fi + +# Case 9: SSOT file present and matches embedded consumers +ANCHOR="$ROOT/scripts/sfetch-minisign-anchor.pub" +[ -f "$ANCHOR" ] || fail "canonical anchor SSOT missing" +ANCHOR_RW="$(grep -E '^RW' "$ANCHOR" | head -n1 | tr -d '\r\n')" +[ -n "$ANCHOR_RW" ] || fail "anchor has no RW line" +grep -q "$ANCHOR_RW" "$ROOT/scripts/install-sfetch.sh" || fail "install-sfetch.sh missing anchor key" +grep -q "$ANCHOR_RW" "$ROOT/scripts/bootstrap-sfetch-verified.sh" || fail "engine missing anchor key" +pass "canonical anchor SSOT present and matches install + engine embeds" + +echo "[ok] release signature regression harness complete" diff --git a/scripts/upload-release-assets.sh b/scripts/upload-release-assets.sh index ad15a3a..fb513c0 100755 --- a/scripts/upload-release-assets.sh +++ b/scripts/upload-release-assets.sh @@ -15,12 +15,28 @@ if [ ! -f "$NOTES_FILE" ]; then echo "release notes file $NOTES_FILE not found" >&2 exit 1 fi + +# Installer detached signature is required before upload (release contract). +# Without it the release must not be treated as consumable for bootstrap. +if [ ! -f "$DIR/install-sfetch.sh.minisig" ]; then + echo "❌ required signature missing: $DIR/install-sfetch.sh.minisig" >&2 + echo " Run 'make release-sign' then 'make release-verify' before upload." >&2 + exit 1 +fi + # Assumes release artifacts were built in CI and downloaded locally. # This script only re-uploads/clobbers assets on GitHub. shopt -s nullglob ARTIFACTS=("$DIR"/sfetch_* "$DIR"/SHA256SUMS "$DIR"/SHA512SUMS "$DIR"/install-sfetch.sh) # Build signature list from globs only; filter to existing files. -SIG_CANDIDATES=("$DIR"/SHA256SUMS.* "$DIR"/SHA512SUMS.* "$DIR"/*-minisign.pub "$DIR"/*-signing-key.asc) +# Include installer minisig explicitly (not covered by SHA*SUMS.* globs). +SIG_CANDIDATES=( + "$DIR"/SHA256SUMS.* + "$DIR"/SHA512SUMS.* + "$DIR"/install-sfetch.sh.minisig + "$DIR"/*-minisign.pub + "$DIR"/*-signing-key.asc +) SIGNATURES=() for f in "${SIG_CANDIDATES[@]}"; do if [ -f "$f" ]; then @@ -34,13 +50,27 @@ fi echo "📤 Uploading binaries/checksums for ${TAG}" gh release upload "$TAG" "${ARTIFACTS[@]}" --clobber echo "📤 Uploading signatures and keys" -if [ ${#SIGNATURES[@]} -gt 0 ]; then - gh release upload "$TAG" "${SIGNATURES[@]}" --clobber -else +if [ ${#SIGNATURES[@]} -eq 0 ]; then echo "❌ No signature files found in $DIR" >&2 echo " Did you run 'make release-sign' first?" >&2 exit 1 fi +# Hard-require installer minisig in the upload set (belt and suspenders with file check above). +has_installer_sig=false +for f in "${SIGNATURES[@]}"; do + case "$f" in + */install-sfetch.sh.minisig) has_installer_sig=true ;; + esac +done +if [ "$has_installer_sig" != true ]; then + echo "❌ install-sfetch.sh.minisig not in upload set" >&2 + exit 1 +fi +gh release upload "$TAG" "${SIGNATURES[@]}" --clobber echo "📝 Updating release notes" gh release edit "$TAG" --notes-file "$NOTES_FILE" echo "✅ Release updated" +echo "" +echo "If the release is still a draft, publish only after this upload succeeds:" +echo " gh release edit ${TAG} --draft=false" +echo "Until then the release is incomplete / non-consumable for bootstrap consumers." diff --git a/scripts/verify-signatures.sh b/scripts/verify-signatures.sh index 16c66f4..42100be 100755 --- a/scripts/verify-signatures.sh +++ b/scripts/verify-signatures.sh @@ -1,13 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -# Verify release signatures (minisign and optional PGP) on checksum manifests. +# Verify release signatures (minisign and optional PGP). # # Usage: verify-signatures.sh [dir] # # Env: -# SFETCH_MINISIGN_PUB - path to minisign public key (required for minisign) +# SFETCH_MINISIGN_PUB - path to operator minisign public key (required advisory). +# Must match the canonical consumer trust anchor. Actual minisign -V always +# uses scripts/sfetch-minisign-anchor.pub (SSOT) so parser divergence cannot +# reintroduce "signed with some key ≠ consumer key". # SFETCH_GPG_HOMEDIR - isolated gpg homedir for PGP verification (optional) +# +# Policy (deliberate divergence — do not re-harmonise without a lock): +# - install-sfetch.sh.minisig is REQUIRED. Missing signature ⇒ non-zero exit. +# This is the gate that prevents declaring a release consumable before the +# installer is signed (bootstrap consumers must not race the pre-signature window). +# - SHA256SUMS / SHA512SUMS minisign: optional-skip when absent (legacy path); +# if present, verification must pass. +# - PGP (.asc) remains optional: skip when absent; verify when present. DIR=${1:-dist/release} @@ -21,43 +32,147 @@ SFETCH_GPG_HOMEDIR=${SFETCH_GPG_HOMEDIR:-} verified=0 failed=0 +_anchor_checked=0 + +# Canonical consumer-facing trust anchor SSOT (must match main.go embed, +# install-sfetch.sh, and bootstrap-sfetch-verified.sh). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CANONICAL_ANCHOR_PUB="${SCRIPT_DIR}/sfetch-minisign-anchor.pub" + +# Extract the operative minisign public key the same way minisign does: +# line 1 = untrusted comment, line 2 = RW… key (when standard .pub format). +minisign_operative_pubkey() { + local f="$1" + local l2 + l2="$(sed -n '2p' "$f" | tr -d '\r\n')" + case "$l2" in + RW*) + printf '%s\n' "$l2" + return 0 + ;; + esac + # Bare single-line key file (no comment header). + local line + line="$(grep -E '^RW[A-Za-z0-9+/]{54}$' "$f" 2>/dev/null | head -n1 | tr -d '\r\n' || true)" + if [ -n "$line" ]; then + printf '%s\n' "$line" + return 0 + fi + return 1 +} -verify_minisign() { - local manifest="$1" - local base="${DIR}/${manifest}" - local sig="${base}.minisig" +require_minisign_tool() { + if ! command -v minisign >/dev/null 2>&1; then + echo "error: minisign not found in PATH" >&2 + failed=$((failed + 1)) + return 1 + fi + return 0 +} - if [ ! -f "${sig}" ]; then - echo "ℹ️ No minisign signature for ${manifest} (skipping)" +# F1: operator pub must match the consumer SSOT; minisign always verifies with SSOT. +assert_operator_pub_matches_canonical() { + if [ "${_anchor_checked}" -eq 1 ]; then return 0 fi + if [ ! -f "${CANONICAL_ANCHOR_PUB}" ]; then + echo "error: canonical trust anchor missing: ${CANONICAL_ANCHOR_PUB}" >&2 + failed=$((failed + 1)) + return 1 + fi + local expect got + expect="$(minisign_operative_pubkey "${CANONICAL_ANCHOR_PUB}")" || { + echo "error: canonical anchor has no operative RW… key: ${CANONICAL_ANCHOR_PUB}" >&2 + failed=$((failed + 1)) + return 1 + } + got="$(minisign_operative_pubkey "${SFETCH_MINISIGN_PUB}")" || { + echo "error: SFETCH_MINISIGN_PUB has no operative RW… key: ${SFETCH_MINISIGN_PUB}" >&2 + failed=$((failed + 1)) + return 1 + } + if [ "${got}" != "${expect}" ]; then + echo "error: SFETCH_MINISIGN_PUB does not match the canonical consumer trust anchor" >&2 + echo " expected (scripts/sfetch-minisign-anchor.pub): ${expect}" >&2 + echo " got (SFETCH_MINISIGN_PUB): ${got}" >&2 + echo " Signing with a non-consumer key would make the release gate green while" >&2 + echo " every bootstrap consumer fails against the embedded anchor." >&2 + failed=$((failed + 1)) + return 1 + fi + _anchor_checked=1 + return 0 +} +require_minisign_pub() { if [ -z "${SFETCH_MINISIGN_PUB}" ]; then - echo "⚠️ SFETCH_MINISIGN_PUB not set, cannot verify ${manifest}.minisig" + echo "error: SFETCH_MINISIGN_PUB not set (required advisory; must match consumer anchor)" >&2 failed=$((failed + 1)) return 1 fi - if [ ! -f "${SFETCH_MINISIGN_PUB}" ]; then echo "error: SFETCH_MINISIGN_PUB=${SFETCH_MINISIGN_PUB} not found" >&2 failed=$((failed + 1)) return 1 fi + assert_operator_pub_matches_canonical || return 1 + return 0 +} - if ! command -v minisign >/dev/null 2>&1; then - echo "error: minisign not found in PATH" >&2 +# Always verify against the in-repo canonical anchor (not the operator path). +# SFETCH_MINISIGN_PUB is advisory-only after the match assert above. +verify_minisign_with_anchor() { + local label="$1" base="$2" + echo "🔍 [minisign] Verifying ${label} (canonical anchor)" + if minisign -V -p "${CANONICAL_ANCHOR_PUB}" -m "${base}"; then + echo "✅ ${label}.minisig verified against consumer trust anchor" + verified=$((verified + 1)) + else + echo "❌ ${label}.minisig verification FAILED against consumer trust anchor" + failed=$((failed + 1)) + fi +} + +# Optional: skip when signature file is absent (manifests only). +verify_minisign_optional() { + local manifest="$1" + local base="${DIR}/${manifest}" + local sig="${base}.minisig" + + if [ ! -f "${sig}" ]; then + echo "ℹ️ No minisign signature for ${manifest} (skipping)" + return 0 + fi + + require_minisign_pub || return 1 + require_minisign_tool || return 1 + verify_minisign_with_anchor "${manifest}" "${base}" +} + +# Required: missing signature is a hard failure (installer only). +verify_minisign_required() { + local target="$1" + local base="${DIR}/${target}" + local sig="${base}.minisig" + + if [ ! -f "${base}" ]; then + echo "❌ required file missing: ${target}" failed=$((failed + 1)) return 1 fi - echo "🔍 [minisign] Verifying ${manifest}" - if minisign -V -p "${SFETCH_MINISIGN_PUB}" -m "${base}"; then - echo "✅ ${manifest}.minisig verified" - verified=$((verified + 1)) - else - echo "❌ ${manifest}.minisig verification FAILED" + if [ ! -f "${sig}" ]; then + # POLICY: installer signature is required — no skip-on-missing. + # (Manifests may still skip via verify_minisign_optional; do not merge.) + echo "❌ required minisign signature missing: ${target}.minisig" + echo " Release is incomplete until the installer is signed (make release-sign)." failed=$((failed + 1)) + return 1 fi + + require_minisign_pub || return 1 + require_minisign_tool || return 1 + verify_minisign_with_anchor "${target}" "${base}" } verify_pgp() { @@ -94,8 +209,11 @@ verify_pgp() { echo "Verifying release signatures in ${DIR}..." echo "" -verify_minisign "SHA256SUMS" -verify_minisign "SHA512SUMS" +# Required first so a missing installer signature fails before optional skips. +verify_minisign_required "install-sfetch.sh" + +verify_minisign_optional "SHA256SUMS" +verify_minisign_optional "SHA512SUMS" echo ""