#!/usr/bin/env bash set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) RELEASE_DIR=${1:-$ROOT/dist/release} TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256:-} EXPECTED_SOURCE_REVISION=${KAIDI_EXPECTED_SOURCE_REVISION:-} EXPECTED_SOURCE_REF=${KAIDI_EXPECTED_SOURCE_REF:-} EXPECTED_SOURCE_DIRTY=${KAIDI_EXPECTED_SOURCE_DIRTY:-} WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } cd "$RELEASE_DIR" if command -v sha256sum >/dev/null 2>&1; then sha256sum -c SHA256SUMS else shasum -a 256 -c SHA256SUMS fi openssl dgst -sha256 -verify release-public.pem \ -signature release-manifest.sig release-manifest.json VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json) "$ROOT/scripts/check-semver.sh" "$VERSION" \ || { printf 'Release version is not valid SemVer\n' >&2; exit 1; } ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json) [ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; } [ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \ || { printf 'Release artifact name does not match the version\n' >&2; exit 1; } printf '%s\n' \ SHA256SUMS \ backend-sbom.cdx.json \ bootstrap-checksums.txt \ frontend-sbom.cdx.json \ install.sh \ purge.sh \ "$ARTIFACT" \ release-manifest.json \ release-manifest.sig \ release-public.pem | LC_ALL=C sort > "$WORK/expected-assets.txt" for path in ./* ./.[!.]* ./..?*; do [ -e "$path" ] || [ -L "$path" ] || continue basename "$path" done | LC_ALL=C sort > "$WORK/actual-assets.txt" diff -u "$WORK/expected-assets.txt" "$WORK/actual-assets.txt" \ || { printf 'Release directory must contain exactly the ten published assets\n' >&2; exit 1; } EXPECTED_ARTIFACT_SHA256=$(jq -er \ '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ release-manifest.json) [ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \ || { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; } [ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \ || { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; } jq -e --arg version "$VERSION" \ '.source.revision | strings | test("^[0-9a-f]{40}$")' release-manifest.json >/dev/null \ || { printf 'Release manifest source revision is invalid\n' >&2; exit 1; } jq -e \ '.source.ref | strings | (length > 0 and length <= 128)' release-manifest.json >/dev/null \ || { printf 'Release manifest source ref is invalid\n' >&2; exit 1; } jq -e '(.source.dirty | type) == "boolean"' release-manifest.json >/dev/null \ || { printf 'Release manifest source dirty flag is invalid\n' >&2; exit 1; } SOURCE_REVISION=$(jq -er '.source.revision | ascii_downcase' release-manifest.json) SOURCE_REF=$(jq -er '.source.ref' release-manifest.json) SOURCE_DIRTY=$(jq -er '.source.dirty | tostring' release-manifest.json) if [[ "$SOURCE_REF" == v* ]] && [ "$SOURCE_REF" != "v$VERSION" ]; then printf 'Release manifest source tag does not match the version\n' >&2 exit 1 fi if [ -n "$EXPECTED_SOURCE_REVISION" ]; then NORMALIZED_EXPECTED_SOURCE_REVISION=$(printf '%s' "$EXPECTED_SOURCE_REVISION" | tr '[:upper:]' '[:lower:]') [[ "$NORMALIZED_EXPECTED_SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]] \ || { printf 'KAIDI_EXPECTED_SOURCE_REVISION must be a 40-character Git commit SHA\n' >&2; exit 1; } [ "$SOURCE_REVISION" = "$NORMALIZED_EXPECTED_SOURCE_REVISION" ] \ || { printf 'Release manifest source revision does not match the expected commit\n' >&2; exit 1; } fi if [ -n "$EXPECTED_SOURCE_REF" ] && [ "$SOURCE_REF" != "$EXPECTED_SOURCE_REF" ]; then printf 'Release manifest source ref does not match the expected ref\n' >&2 exit 1 fi if [ -n "$EXPECTED_SOURCE_DIRTY" ]; then [[ "$EXPECTED_SOURCE_DIRTY" = true || "$EXPECTED_SOURCE_DIRTY" = false ]] \ || { printf 'KAIDI_EXPECTED_SOURCE_DIRTY must be true or false\n' >&2; exit 1; } [ "$SOURCE_DIRTY" = "$EXPECTED_SOURCE_DIRTY" ] \ || { printf 'Release manifest source dirty flag does not match the expected state\n' >&2; exit 1; } fi jq -e --arg version "$VERSION" \ '.buildVersions.backend == $version and .buildVersions.frontend == $version' \ release-manifest.json >/dev/null \ || { printf 'Release build versions do not match the manifest version\n' >&2; exit 1; } for ecosystem in maven npm; do file=$(jq -er --arg ecosystem "$ecosystem" \ '.sboms[] | select(.ecosystem == $ecosystem) | .artifact' release-manifest.json) expected=$(jq -er --arg ecosystem "$ecosystem" \ '.sboms[] | select(.ecosystem == $ecosystem) | .sha256' release-manifest.json) [ "$(sha256_file "$file")" = "$expected" ] \ || { printf '%s SBOM digest does not match the manifest\n' "$ecosystem" >&2; exit 1; } jq -e --arg version "$VERSION" \ '. as $document | .metadata.component.version == $version and any(.dependencies[]; .ref == $document.metadata.component["bom-ref"])' \ "$file" >/dev/null done PUBLIC_KEY_SHA256=$(sha256_file release-public.pem) INSTALLER_SHA256=$(sha256_file install.sh) PURGER_SHA256=$(sha256_file purge.sh) BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt) SIGNED_INSTALLER=$(jq -er '.bootstrap.installer' release-manifest.json) SIGNED_PURGER=$(jq -er '.bootstrap.purger' release-manifest.json) SIGNED_PUBLIC_KEY=$(jq -er '.bootstrap.publicKey' release-manifest.json) SIGNED_BOOTSTRAP=$(jq -er '.bootstrap.checksums' release-manifest.json) [ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PURGER" = purge.sh ] \ && [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \ && [ "$SIGNED_BOOTSTRAP" = bootstrap-checksums.txt ] \ || { printf 'Release manifest bootstrap asset names are invalid\n' >&2; exit 1; } jq -e --arg installer "$INSTALLER_SHA256" --arg purger "$PURGER_SHA256" \ --arg publicKey "$PUBLIC_KEY_SHA256" \ --arg bootstrap "$BOOTSTRAP_SHA256" \ '.bootstrap.installerSha256 == $installer and .bootstrap.purgerSha256 == $purger and .bootstrap.publicKeySha256 == $publicKey and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \ || { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; } NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]') [[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \ || { printf 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 must be 64 hexadecimal characters\n' >&2; exit 1; } [ "$PUBLIC_KEY_SHA256" = "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" ] \ || { printf 'Release public key does not match the trusted fingerprint\n' >&2; exit 1; } [ "$(sed -n 's/^KAIDI_RELEASE_PUBLIC_KEY_SHA256=//p' bootstrap-checksums.txt)" = "$PUBLIC_KEY_SHA256" ] \ || { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; } [ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \ || { printf 'Bootstrap installer digest does not match\n' >&2; exit 1; } [ "$(sed -n 's/^KAIDI_PURGER_SHA256=//p' bootstrap-checksums.txt)" = "$PURGER_SHA256" ] \ || { printf 'Bootstrap purger digest does not match\n' >&2; exit 1; } ARCHIVE_LIST=$(tar -tzf "$ARTIFACT") if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then printf 'Release archive contains an unsafe path\n' >&2 exit 1 fi if grep -Eq '(^|/)(signing-private|private-key|id_rsa)' <<< "$ARCHIVE_LIST"; then printf 'Release archive contains private key material\n' >&2 exit 1 fi [ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \ || { printf 'Release archive version does not match the manifest\n' >&2; exit 1; } tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar" (cd "$WORK" && jar -xf app.jar \ META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties) JAR_POM_VERSION=$(sed -n 's/^version=//p' \ "$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties") JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \ "$WORK/META-INF/MANIFEST.MF" | tr -d '\r') [ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \ || { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; } compare_archive_file() { archive_path=$1 source_path=$2 cmp <(tar -xOf "$ARTIFACT" "$archive_path") "$ROOT/$source_path" } compare_archive_file ./ops/update.sh deploy/update.sh compare_archive_file ./ops/baota-start.sh deploy/baota-start.sh compare_archive_file ./ops/baota-init.sh deploy/baota-init.sh compare_archive_file ./ops/release-public.pem deploy/release-public.pem compare_archive_file ./ops/baota.env.example deploy/baota.env.example compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \ || { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; } tar -tvzf "$ARTIFACT" ./ops/baota-init.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \ || { printf 'Baota initializer is not executable\n' >&2; exit 1; } bash -n "$ROOT/deploy/baota-start.sh" bash -n "$ROOT/deploy/baota-init.sh" cmp install.sh "$ROOT/deploy/install.sh" \ || { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; } bash -n "$ROOT/deploy/purge.sh" cmp purge.sh "$ROOT/deploy/purge.sh" \ || { printf 'Release purger does not match deploy/purge.sh\n' >&2; exit 1; } printf 'Release %s verified; installer SHA-256: %s\n' "$VERSION" "$INSTALLER_SHA256"