diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1e5eab..c28cace 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,3 +44,32 @@ 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 + with: + persist-credentials: false + - 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; }