From 53f095933a78a05d439bf52454f6bdf4db75c622 Mon Sep 17 00:00:00 2001 From: Suryansh Prajapati <58465650+drk1rd@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:18:59 +0530 Subject: [PATCH 1/2] Verify the release signature; fix the README's Terraform example Releases from v0.5.0 on publish checksums.txt.sigstore.json. When it exists, the action verifies it with cosign against the release workflow's identity at the release tag before trusting checksums.txt, and fails if a v0.5.0+ release has no bundle. Releases before v0.5.0 keep the checksum-only check. cosign is taken from PATH, or v3.1.3 is downloaded and checked against a pinned SHA-256 (a JavaScript action cannot call sigstore/cosign-installer). "latest" is resolved to its tag so the identity names the exact release. New input verify-signature (default true) turns the check off. The Terraform example no longer sets skip_credentials_validation and skip_requesting_account_id: HomeCloud implements STS. --- .github/workflows/test.yml | 27 +++++++++++ README.md | 34 ++++++++++++-- action.yml | 7 +++ main.js | 1 + setup.sh | 94 ++++++++++++++++++++++++++++++++++---- test/inputs.sh | 13 ++++-- 6 files changed, 157 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1e5eab..885d1c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,3 +44,30 @@ jobs: aws dynamodb get-item --table-name action-test --key '{"id":{"S":"1"}}' aws lambda list-functions homecloud version + + # Download and verification only (HC_INSTALL_ONLY stops before the server + # starts): a signed release, an unsigned one, and the check turned off. + install: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - { version: latest, verify: "true", expect: "signature OK" } + - { version: v0.5.0, verify: "true", expect: "signature OK" } + - { version: v0.4.1, verify: "true", expect: "is not signed" } + - { version: v0.5.0, verify: "false", expect: "verify-signature is false" } + steps: + - uses: actions/checkout@v4 + - name: Install ${{ matrix.version }} (verify-signature ${{ matrix.verify }}) + env: + HC_INSTALL_ONLY: "1" + HC_VERSION: ${{ matrix.version }} + HC_VERIFY_SIGNATURE: ${{ matrix.verify }} + EXPECT: ${{ matrix.expect }} + run: | + set -o pipefail + bash setup.sh | tee install.log + grep -q "$EXPECT" install.log + grep -q "checksum OK" install.log diff --git a/README.md b/README.md index 9fa84d5..63b589b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ the rest of the AWS API without an AWS account. The action: -1. downloads the HomeCloud release you ask for and verifies it against the release's `checksums.txt`, +1. downloads the HomeCloud release you ask for, verifies the Sigstore signature on its `checksums.txt` (releases from v0.5.0 on are signed) and checks the download against it, 2. starts `homecloud serve` in the background (data in `$RUNNER_TEMP/homecloud-data`, log in `$RUNNER_TEMP/homecloud.log`), 3. waits for `/api/v1/health` and for any services listed in `services-wait`, 4. exports `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (masked), `AWS_REGION`, `AWS_DEFAULT_REGION` and `HOMECLOUD_DATA_DIR` for the following steps, and puts `homecloud` on `PATH`, @@ -40,6 +40,7 @@ jobs: | `port` | `8080` | Port the API listens on, on `127.0.0.1`. | | `services-wait` | (none) | Comma or newline separated services to wait for. `s3`, `ecr` and `route53` start containers in the background; in-process services (`sqs`, `dynamodb`, `lambda`, ...) are ready with the API; an unknown name fails the step. | | `wait-timeout` | `180` | Seconds to wait for the API and the services. | +| `verify-signature` | `true` | Verify the release signature with cosign before trusting `checksums.txt` (see [Release verification](#release-verification)). `false` checks the SHA-256 checksum only. | ### Outputs @@ -97,7 +98,8 @@ def test_upload(): ### Terraform -The AWS provider (5.x and later) honours `AWS_ENDPOINT_URL`; tell it not to look for a real account: +The AWS provider (5.x and later) honours `AWS_ENDPOINT_URL`. HomeCloud implements STS, so the +provider's account checks pass unchanged; only path-style S3 addressing needs setting: ```yaml - uses: solinode/setup-homecloud@v1 @@ -109,9 +111,7 @@ The AWS provider (5.x and later) honours `AWS_ENDPOINT_URL`; tell it not to look ```hcl provider "aws" { - skip_credentials_validation = true - skip_requesting_account_id = true - s3_use_path_style = true + s3_use_path_style = true } ``` @@ -123,6 +123,30 @@ provider "aws" { - run: ./run-tests.sh --endpoint "${{ steps.homecloud.outputs.endpoint }}" ``` +## Release verification + +HomeCloud releases from v0.5.0 on publish `checksums.txt.sigstore.json`, a Sigstore bundle +over `checksums.txt` made by keyless signing in HomeCloud's release workflow. When the bundle +exists, the action runs + +```sh +cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \ + --certificate-identity https://github.com/solinode/homecloud/.github/workflows/release.yml@refs/tags/ \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +and fails the step if the signature does not verify, or if a release from v0.5.0 on has no +bundle. Only then does it trust the SHA-256 in `checksums.txt` for the archive. `latest` is +resolved to its tag first, so the signer identity names the exact release. + +- cosign comes from the runner's `PATH` if it is there (for example installed with + `sigstore/cosign-installer`; cosign 2.4 or newer), otherwise the action downloads cosign + v3.1.3 from its GitHub release and checks it against a SHA-256 pinned in `setup.sh`. +- Verification fetches Sigstore's trust root over the network. On a runner that cannot + reach Sigstore, set `verify-signature: false` to check the checksum only. +- Releases before v0.5.0 are unsigned: they are checked against `checksums.txt` only, as + before. + ## Notes - One HomeCloud per Docker host: it names its helper containers (`homecloud-s3`, ...) and diff --git a/action.yml b/action.yml index e227f48..999fd5f 100644 --- a/action.yml +++ b/action.yml @@ -25,6 +25,13 @@ inputs: description: Seconds to wait for the API and every service in services-wait. required: false default: "180" + verify-signature: + description: >- + Verify the release's Sigstore signature (checksums.txt.sigstore.json, published from + v0.5.0 on) with cosign before trusting checksums.txt: "true" or "false". Releases + before v0.5.0 are unsigned and get the checksum check only. + required: false + default: "true" outputs: endpoint: diff --git a/main.js b/main.js index 9fa7713..85f1246 100644 --- a/main.js +++ b/main.js @@ -10,6 +10,7 @@ const env = { HC_PORT: input("port", "8080"), HC_SERVICES_WAIT: input("services-wait", ""), HC_WAIT_TIMEOUT: input("wait-timeout", "180"), + HC_VERIFY_SIGNATURE: input("verify-signature", "true"), }; const r = spawnSync("bash", [path.join(__dirname, "setup.sh")], { stdio: "inherit", env }); if (r.error) { diff --git a/setup.sh b/setup.sh index 1bd79fe..85387c6 100755 --- a/setup.sh +++ b/setup.sh @@ -25,6 +25,11 @@ port=$((10#$port)) case "$timeout" in '' | *[!0-9]*) fail "wait-timeout must be a whole number of seconds, got '$timeout'" ;; esac timeout=$((10#$timeout)) [ "$timeout" -ge 1 ] || fail "wait-timeout must be at least 1 second" +case "$(echo "${HC_VERIFY_SIGNATURE:-true}" | tr '[:upper:]' '[:lower:]')" in + true) verify_sig=1 ;; + false) verify_sig= ;; + *) fail "verify-signature must be true or false, got '$HC_VERIFY_SIGNATURE'" ;; +esac # Services that start containers in the background log a line when they are ready; # the others run inside the HomeCloud process and are ready with the API. @@ -36,7 +41,7 @@ for svc in $(echo "${HC_SERVICES_WAIT:-}" | tr ',\n' ' ' | tr '[:upper:]' '[:lo *) case "$in_process" in *" $svc "*) ;; *) fail "services-wait: unknown service '$svc' (background: s3, ecr, route53; in-process:$in_process)" ;; esac ;; esac done -[ "${HC_VALIDATE_ONLY:-}" = 1 ] && { echo "inputs OK: port=$port wait-timeout=$timeout waits=${waits[*]:-}"; exit 0; } +[ "${HC_VALIDATE_ONLY:-}" = 1 ] && { echo "inputs OK: port=$port wait-timeout=$timeout waits=${waits[*]:-} verify-signature=${verify_sig:-0}"; exit 0; } command -v docker >/dev/null 2>&1 || fail "HomeCloud needs Docker on the runner (ubuntu-* runners have it)" docker info >/dev/null 2>&1 || fail "Docker is installed but not running" @@ -49,24 +54,91 @@ case "$(uname -m)" in esac name="homecloud-$os-$arch" if [ "$version" = latest ]; then - base="https://github.com/$REPO/releases/latest/download" -else - case "$version" in v*) ;; *) version="v$version" ;; esac - base="https://github.com/$REPO/releases/download/$version" + # Resolve "latest" to its tag: the signature names the tag it was made for, and + # every file then comes from the same release. + version=$(curl -fsSL --retry 3 -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" | sed -n 's#.*/releases/tag/##p') || true + [ -n "$version" ] || fail "could not determine the latest HomeCloud release" fi +case "$version" in v*) ;; *) version="v$version" ;; esac +base="https://github.com/$REPO/releases/download/$version" + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# signed_release VERSION: true for v0.5.0 and later, which are all signed. +signed_release() { + local v=${1#v} major rest minor + v=${v%%-*} + major=${v%%.*} + rest=${v#*.} + minor=${rest%%.*} + case "$major$minor" in *[!0-9]* | "") return 1 ;; esac + [ "$major" -gt 0 ] || [ "$minor" -ge 5 ] +} + +# cosign for the signature check: the runner's own (for example from +# sigstore/cosign-installer), else this pinned release, checked against its SHA-256. +COSIGN_VERSION=v3.1.3 +cosign_sha256() { + case "$1" in + linux-amd64) echo 4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 ;; + linux-arm64) echo c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a ;; + darwin-amd64) echo 2347488e5d5b25336644024dfeca5601b190e91197a71a917bda44744aff106c ;; + darwin-arm64) echo 5cf948c2f4dfe59687bdd0b8523709067383e03982cc543475c8a7dc70e92a76 ;; + esac +} +find_cosign() { + if cosign=$(command -v cosign); then + return + fi + local dir="$tmp/homecloud-cosign" want got + want=$(cosign_sha256 "$os-$arch") + [ -n "$want" ] || fail "no pinned cosign for $os/$arch: install cosign first, or set verify-signature: false" + mkdir -p "$dir" + curl -fsSL --retry 3 "https://github.com/sigstore/cosign/releases/download/$COSIGN_VERSION/cosign-$os-$arch" -o "$dir/cosign" || + fail "download of cosign $COSIGN_VERSION failed" + got=$(sha256 "$dir/cosign") + [ "$got" = "$want" ] || fail "checksum mismatch for cosign $COSIGN_VERSION (expected $want, got $got)" + chmod +x "$dir/cosign" + cosign="$dir/cosign" +} echo "::group::Install HomeCloud ($version, $os/$arch)" dl="$tmp/homecloud-dl" rm -rf "$dl" && mkdir -p "$dl" "$bin_dir" curl -fsSL --retry 3 "$base/$name.tar.gz" -o "$dl/$name.tar.gz" || fail "download $base/$name.tar.gz failed" curl -fsSL --retry 3 "$base/checksums.txt" -o "$dl/checksums.txt" || fail "download $base/checksums.txt failed" -expected=$(awk -v f="$name.tar.gz" '$2 == f || $2 == "*" f {print $1}' "$dl/checksums.txt") -[ -n "$expected" ] || fail "checksums.txt has no entry for $name.tar.gz" -if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$dl/$name.tar.gz" | cut -d' ' -f1) + +# Releases from v0.5.0 on sign checksums.txt with Sigstore keyless signing in the +# release workflow (checksums.txt.sigstore.json). Verify it when it exists, so the +# checksum below is trusted; older releases are unsigned and get the checksum only. +if [ -z "$verify_sig" ]; then + echo "verify-signature is false: checking the SHA-256 checksum only" +elif curl -fsSL --retry 3 "$base/checksums.txt.sigstore.json" -o "$dl/checksums.txt.sigstore.json" 2>/dev/null; then + find_cosign + echo "verifying the release signature with $cosign" + identity="https://github.com/$REPO/.github/workflows/release.yml@refs/tags/$version" + if ! "$cosign" verify-blob "$dl/checksums.txt" --bundle "$dl/checksums.txt.sigstore.json" \ + --certificate-identity "$identity" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com >"$dl/cosign.log" 2>&1; then + cat "$dl/cosign.log" + fail "signature verification of checksums.txt failed for $version (expected signer $identity)" + fi + echo "signature OK (checksums.txt signed by $identity)" +elif signed_release "$version"; then + fail "$version should be signed, but $base/checksums.txt.sigstore.json could not be downloaded (set verify-signature: false to skip the check)" else - actual=$(shasum -a 256 "$dl/$name.tar.gz" | cut -d' ' -f1) + echo "$version is not signed (releases before v0.5.0 were not): checking the SHA-256 checksum only" fi + +expected=$(awk -v f="$name.tar.gz" '$2 == f || $2 == "*" f {print $1}' "$dl/checksums.txt") +[ -n "$expected" ] || fail "checksums.txt has no entry for $name.tar.gz" +actual=$(sha256 "$dl/$name.tar.gz") [ "$expected" = "$actual" ] || fail "checksum mismatch for $name.tar.gz (expected $expected, got $actual)" echo "checksum OK ($actual)" tar -xzf "$dl/$name.tar.gz" -C "$dl" @@ -75,6 +147,8 @@ rm -rf "$dl" [ -n "${GITHUB_PATH:-}" ] && echo "$bin_dir" >> "$GITHUB_PATH" "$bin_dir/homecloud" version echo "::endgroup::" +# Tests of the download and verification stop here (test.yml). +[ "${HC_INSTALL_ONLY:-}" = 1 ] && exit 0 echo "Starting homecloud serve on 127.0.0.1:$port (data: $data_dir, log: $log)" mkdir -p "$data_dir" diff --git a/test/inputs.sh b/test/inputs.sh index 4ffc3bf..19b44b6 100755 --- a/test/inputs.sh +++ b/test/inputs.sh @@ -5,16 +5,16 @@ set -u setup="$(cd "$(dirname "$0")/.." && pwd)/setup.sh" fails=0 -# expect ok|fail PORT TIMEOUT SERVICES +# expect ok|fail PORT TIMEOUT SERVICES [VERIFY_SIGNATURE] expect() { local want=$1 out got=ok - out=$(HC_VALIDATE_ONLY=1 HC_PORT="$2" HC_WAIT_TIMEOUT="$3" HC_SERVICES_WAIT="$4" \ + out=$(HC_VALIDATE_ONLY=1 HC_PORT="$2" HC_WAIT_TIMEOUT="$3" HC_SERVICES_WAIT="$4" HC_VERIFY_SIGNATURE="${5-true}" \ GITHUB_OUTPUT=/dev/null GITHUB_ENV=/dev/null GITHUB_STATE= RUNNER_TEMP="${TMPDIR:-/tmp}" bash "$setup" 2>&1) || got=fail if [ "$got" != "$want" ]; then - echo "FAIL: port='$2' timeout='$3' services='$4': want $want, got $got: $out" + echo "FAIL: port='$2' timeout='$3' services='$4' verify='${5-true}': want $want, got $got: $out" fails=$((fails + 1)) else - echo "ok: port='$2' timeout='$3' services='$4' -> $want" + echo "ok: port='$2' timeout='$3' services='$4' verify='${5-true}' -> $want" fi } @@ -30,5 +30,10 @@ expect fail http 180 "" expect fail 70000 180 "" expect fail 8080 180 "s33" expect fail 8080 180 "s3,nosuchservice" +expect ok 8080 180 "" false +expect ok 8080 180 "" FALSE +expect ok 8080 180 "" "" # empty means the default (true) +expect fail 8080 180 "" no +expect fail 8080 180 "" 1 [ "$fails" -eq 0 ] && echo "all input checks passed" || { echo "$fails input checks failed"; exit 1; } From 21726c8fba49e0999aae4262a7a3ef134aa0d19f Mon Sep 17 00:00:00 2001 From: Suryansh Prajapati <58465650+drk1rd@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:43:07 +0530 Subject: [PATCH 2/2] Don't persist the checkout token in the install job --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 885d1c3..c28cace 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,6 +60,8 @@ jobs: - { version: v0.5.0, verify: "false", expect: "verify-signature is false" } steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install ${{ matrix.version }} (verify-signature ${{ matrix.verify }}) env: HC_INSTALL_ONLY: "1"