Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
}
```

Expand All @@ -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/<tag> \
--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
Expand Down
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
94 changes: 84 additions & 10 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
13 changes: 9 additions & 4 deletions test/inputs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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; }
Loading