diff options
| author | Bitflash <admin@bitflash.network> | 2026-08-06 10:46:26 -0300 |
|---|---|---|
| committer | Bitflash <admin@bitflash.network> | 2026-08-06 10:46:26 -0300 |
| commit | c3f6b68f907b03865d2c417ee39fe9526787196f (patch) | |
| tree | 98a7c58fdf511d3cf98aae0811913afa51cd0c24 | |
| parent | b3a5a858e536acf65e6c6761293626628f2227bf (diff) | |
release: sign SHA256SUMS with a pinned key, verify against it
verify-release.sh embeds the Bitflash release signing key and now requires that
the checksum signature validate against that exact key, not merely against some
key in the caller's keyring (#138). It also fetches releases from
releases.bitflash.network instead of the retired GitHub host, resolving 'latest'
from latest.txt.
Release signing key fingerprint:
910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8
| -rw-r--r-- | docs/release-verification.md | 78 | ||||
| -rwxr-xr-x | scripts/verify-release.sh | 108 |
2 files changed, 108 insertions, 78 deletions
diff --git a/docs/release-verification.md b/docs/release-verification.md index ecacd5f..32de74f 100644 --- a/docs/release-verification.md +++ b/docs/release-verification.md | |||
| @@ -3,60 +3,82 @@ | |||
| 3 | Bitflash releases publish binaries and a `SHA256SUMS` file. The checksum file | 3 | Bitflash releases publish binaries and a `SHA256SUMS` file. The checksum file |
| 4 | proves that the file you downloaded matches the file the release page names. | 4 | proves that the file you downloaded matches the file the release page names. |
| 5 | A detached signature, `SHA256SUMS.asc`, proves that the checksum file itself was | 5 | A detached signature, `SHA256SUMS.asc`, proves that the checksum file itself was |
| 6 | signed by a trusted release key. | 6 | signed by the Bitflash release key. |
| 7 | 7 | ||
| 8 | That gives users two separate checks: | 8 | That gives users two separate checks: |
| 9 | 9 | ||
| 10 | 1. `gpg --verify SHA256SUMS.asc SHA256SUMS` checks who signed the checksums. | 10 | 1. `gpg --verify SHA256SUMS.asc SHA256SUMS` — who signed the checksums. |
| 11 | 2. `sha256sum -c SHA256SUMS` checks the binaries against those checksums. | 11 | 2. `sha256sum -c SHA256SUMS` — the binaries against those checksums. |
| 12 | 12 | ||
| 13 | ## User Check | 13 | But the first check is only worth something if you require a **specific** key. |
| 14 | A good signature by *some* key proves nothing; anyone can make one. | ||
| 14 | 15 | ||
| 15 | From a shell with `curl` or `wget` and `sha256sum`: | 16 | ## The release signing key |
| 16 | 17 | ||
| 17 | ```bash | 18 | ```text |
| 18 | scripts/verify-release.sh latest | 19 | fingerprint 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8 |
| 20 | uid Bitflash Releases (bitflash.network release signing) | ||
| 21 | <releases@bitflash.network> | ||
| 19 | ``` | 22 | ``` |
| 20 | 23 | ||
| 21 | For a specific release: | 24 | The public key is published at |
| 25 | `https://releases.bitflash.network/bitflash-release-key.asc` and embedded in | ||
| 26 | `scripts/verify-release.sh`, so the tool's trust anchor lives in this | ||
| 27 | repository rather than on the server it verifies. | ||
| 28 | |||
| 29 | ## User check | ||
| 30 | |||
| 31 | From a shell with `curl` or `wget`, `sha256sum` and `gpg`: | ||
| 22 | 32 | ||
| 23 | ```bash | 33 | ```bash |
| 24 | scripts/verify-release.sh v1.2.13 | 34 | scripts/verify-release.sh latest |
| 25 | ``` | 35 | ``` |
| 26 | 36 | ||
| 27 | For release audits, require the signature: | 37 | For a specific release: |
| 28 | 38 | ||
| 29 | ```bash | 39 | ```bash |
| 30 | scripts/verify-release.sh v1.2.13 --require-signature | 40 | scripts/verify-release.sh v1.2.15 |
| 31 | ``` | 41 | ``` |
| 32 | 42 | ||
| 33 | Older releases may not have `SHA256SUMS.asc`. In that case the script warns and | 43 | The script downloads the release, imports the pinned key into a throwaway |
| 34 | still checks file integrity. New release audits should use `--require-signature`. | 44 | keyring, and **refuses anything that is not a valid signature by the key above** |
| 35 | 45 | before checking the hashes. Releases from before signing was introduced ship | |
| 36 | ## Maintainer Flow | 46 | only `SHA256SUMS`; without `--require-signature` those are checked for integrity |
| 47 | and reported as unsigned. | ||
| 37 | 48 | ||
| 38 | After building release assets in the repository root: | 49 | By hand, if you would rather not run the script: |
| 39 | 50 | ||
| 40 | ```bash | 51 | ```bash |
| 41 | scripts/make-release-checksums.sh --sign --local-user RELEASE_KEY_ID | 52 | curl -O https://releases.bitflash.network/bitflash-release-key.asc |
| 53 | gpg --import bitflash-release-key.asc | ||
| 54 | gpg --verify SHA256SUMS.asc SHA256SUMS # must name the fingerprint above | ||
| 55 | sha256sum -c SHA256SUMS | ||
| 42 | ``` | 56 | ``` |
| 43 | 57 | ||
| 44 | Upload all built assets plus: | 58 | ## Maintainer flow |
| 45 | 59 | ||
| 46 | ```text | 60 | The signing key lives on a dedicated location, not in the build tree. After |
| 47 | SHA256SUMS | 61 | building the release assets in the repository root, write and sign the |
| 48 | SHA256SUMS.asc | 62 | checksums: |
| 63 | |||
| 64 | ```bash | ||
| 65 | sha256sum Bitflash-*.zip Bitflash-*.AppImage bitflash-node-* > SHA256SUMS | ||
| 66 | gpg --armor --detach-sign --output SHA256SUMS.asc SHA256SUMS | ||
| 49 | ``` | 67 | ``` |
| 50 | 68 | ||
| 51 | Keep the private signing key offline or on a dedicated release machine. Publish | 69 | Publish, alongside the assets, `SHA256SUMS` and `SHA256SUMS.asc`, and update |
| 52 | the public key fingerprint in the release notes and keep using the same key for | 70 | `latest.txt` on the releases host to the new tag. Keep using the same key; if it |
| 53 | future releases unless there is a clearly announced rotation. | 71 | is ever rotated, announce the new fingerprint clearly and update the embedded |
| 72 | key in `verify-release.sh`. | ||
| 54 | 73 | ||
| 55 | ## Why This Matters | 74 | ## Why this matters |
| 56 | 75 | ||
| 57 | `SHA256SUMS` alone protects against a broken download, but not against someone | 76 | `SHA256SUMS` alone protects against a broken download, but not against someone |
| 58 | replacing both a binary and the checksum file. Signing `SHA256SUMS` means an | 77 | replacing both a binary and the checksum file. Signing `SHA256SUMS`, and |
| 59 | attacker must also have the release signing key to make the replacement verify. | 78 | requiring a pinned key, means an attacker must also hold the release signing key |
| 79 | to make the replacement verify. Serving from bitflash.network makes this the | ||
| 80 | guarantee that matters: without a pinned key, "trust the site" is the only | ||
| 81 | thing standing behind a download. | ||
| 60 | 82 | ||
| 61 | This is not reproducible builds yet. It is the smaller, immediate step that | 83 | This is not reproducible builds yet. It is the smaller, immediate step that |
| 62 | makes every release asset auditable by users before they run it. | 84 | makes every release asset auditable before anyone runs it. |
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 23e71d1..4fd4558 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh | |||
| @@ -1,23 +1,50 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail | 2 | set -euo pipefail |
| 3 | 3 | ||
| 4 | repo="Bitflash-sh/bitflash" | 4 | # Verifies a Bitflash release: the checksum file's signature against a pinned |
| 5 | tag="${1:-latest}" | 5 | # key, then the assets against the checksums. |
| 6 | # | ||
| 7 | # The signing key is embedded below, not fetched. That is the whole point of a | ||
| 8 | # pinned key: the trust anchor ships with this script (in git), so it does not | ||
| 9 | # depend on the same server that serves the release. A server that is | ||
| 10 | # compromised can swap the binaries and the checksums, but it cannot forge a | ||
| 11 | # signature by this key. | ||
| 12 | |||
| 13 | host="https://releases.bitflash.network" | ||
| 6 | require_signature=0 | 14 | require_signature=0 |
| 7 | 15 | ||
| 16 | # The Bitflash release signing key. Verification requires this exact key -- | ||
| 17 | # gpg's own "good signature" is not enough, because a good signature by *some* | ||
| 18 | # key proves nothing. See issue #138. | ||
| 19 | RELEASE_KEY_FP="910A2B4CCA879E81FB4B2AEA1D4A53D3B78AA4B8" | ||
| 20 | read -r -d '' RELEASE_KEY <<'KEY' || true | ||
| 21 | -----BEGIN PGP PUBLIC KEY BLOCK----- | ||
| 22 | |||
| 23 | mDMEanSOmRYJKwYBBAHaRw8BAQdAXj24i2BYUcAmOpoXwjE4ZTynHsifCZ+niwQK | ||
| 24 | HqO0nMa0UEJpdGZsYXNoIFJlbGVhc2VzIChiaXRmbGFzaC5uZXR3b3JrIHJlbGVh | ||
| 25 | c2Ugc2lnbmluZykgPHJlbGVhc2VzQGJpdGZsYXNoLm5ldHdvcms+iJAEExYKADgW | ||
| 26 | IQSRCitMyoeegftLKuodSlPTt4qkuAUCanSOmQIbAwULCQgHAgYVCgkICwIEFgID | ||
| 27 | AQIeAQIXgAAKCRAdSlPTt4qkuIi9AP96X3OcstiCTTgKOr16Vhds3LyuZ19q3Apq | ||
| 28 | 4KAcNuOUMAD+NqgJ1CqkLGPXfZLCtYBCRHXNYWxMMEk+ViLcRtdLMg4= | ||
| 29 | =mxic | ||
| 30 | -----END PGP PUBLIC KEY BLOCK----- | ||
| 31 | KEY | ||
| 32 | |||
| 8 | usage() { | 33 | usage() { |
| 9 | cat <<'EOF' | 34 | cat <<'EOF' |
| 10 | Usage: scripts/verify-release.sh [tag|latest] [--require-signature] | 35 | Usage: scripts/verify-release.sh [tag|latest] [--require-signature] |
| 11 | 36 | ||
| 12 | Downloads the release checksum file and every asset named in it, then verifies: | 37 | Downloads the release checksum file and every asset named in it, then verifies: |
| 13 | 1. SHA256SUMS.asc, if present, against SHA256SUMS with gpg. | 38 | 1. SHA256SUMS.asc against SHA256SUMS, requiring the pinned Bitflash release |
| 39 | key (fingerprint 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8). | ||
| 14 | 2. SHA256SUMS against the downloaded assets. | 40 | 2. SHA256SUMS against the downloaded assets. |
| 15 | 41 | ||
| 16 | Use --require-signature for release audits. Without it, older releases that | 42 | Releases from before signing was introduced ship only SHA256SUMS; without |
| 17 | ship only SHA256SUMS are checked for integrity and reported as unsigned. | 43 | --require-signature those are checked for integrity and reported as unsigned. |
| 18 | EOF | 44 | EOF |
| 19 | } | 45 | } |
| 20 | 46 | ||
| 47 | tag="${1:-latest}" | ||
| 21 | for arg in "${@:2}"; do | 48 | for arg in "${@:2}"; do |
| 22 | case "$arg" in | 49 | case "$arg" in |
| 23 | --require-signature) require_signature=1 ;; | 50 | --require-signature) require_signature=1 ;; |
| @@ -25,19 +52,9 @@ for arg in "${@:2}"; do | |||
| 25 | *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; | 52 | *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; |
| 26 | esac | 53 | esac |
| 27 | done | 54 | done |
| 55 | if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then usage; exit 0; fi | ||
| 28 | 56 | ||
| 29 | if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then | 57 | need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; } |
| 30 | usage | ||
| 31 | exit 0 | ||
| 32 | fi | ||
| 33 | |||
| 34 | need() { | ||
| 35 | if ! command -v "$1" >/dev/null 2>&1; then | ||
| 36 | echo "missing required command: $1" >&2 | ||
| 37 | exit 1 | ||
| 38 | fi | ||
| 39 | } | ||
| 40 | |||
| 41 | need sed | 58 | need sed |
| 42 | need awk | 59 | need awk |
| 43 | 60 | ||
| @@ -46,39 +63,25 @@ if command -v sha256sum >/dev/null 2>&1; then | |||
| 46 | elif command -v shasum >/dev/null 2>&1; then | 63 | elif command -v shasum >/dev/null 2>&1; then |
| 47 | sha_check=(shasum -a 256 -c SHA256SUMS) | 64 | sha_check=(shasum -a 256 -c SHA256SUMS) |
| 48 | else | 65 | else |
| 49 | echo "missing required command: sha256sum or shasum" >&2 | 66 | echo "missing required command: sha256sum or shasum" >&2; exit 1 |
| 50 | exit 1 | ||
| 51 | fi | 67 | fi |
| 52 | 68 | ||
| 53 | download() { | 69 | download() { |
| 54 | local url="$1" | 70 | if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" |
| 55 | local out="$2" | 71 | elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2" |
| 56 | if command -v curl >/dev/null 2>&1; then | 72 | else echo "missing required command: curl or wget" >&2; exit 1; fi |
| 57 | curl -fsSL "$url" -o "$out" | ||
| 58 | elif command -v wget >/dev/null 2>&1; then | ||
| 59 | wget -q "$url" -O "$out" | ||
| 60 | else | ||
| 61 | echo "missing required command: curl or wget" >&2 | ||
| 62 | exit 1 | ||
| 63 | fi | ||
| 64 | } | 73 | } |
| 65 | 74 | ||
| 66 | if [ "$tag" = "latest" ]; then | 75 | if [ "$tag" = "latest" ]; then |
| 67 | tag="$(download "https://api.github.com/repos/$repo/releases/latest" - | | 76 | tag="$(download "$host/latest.txt" - 2>/dev/null | tr -d '[:space:]' || true)" |
| 68 | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | | 77 | [ -n "$tag" ] || { echo "could not resolve the latest release tag from $host/latest.txt" >&2; exit 1; } |
| 69 | head -1)" | ||
| 70 | if [ -z "$tag" ]; then | ||
| 71 | echo "could not resolve latest release tag" >&2 | ||
| 72 | exit 1 | ||
| 73 | fi | ||
| 74 | fi | 78 | fi |
| 75 | 79 | ||
| 76 | workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" | 80 | workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" |
| 77 | cleanup() { rm -rf "$workdir"; } | 81 | cleanup() { rm -rf "$workdir"; } |
| 78 | trap cleanup EXIT | 82 | trap cleanup EXIT |
| 79 | 83 | ||
| 80 | base_url="https://github.com/$repo/releases/download/$tag" | 84 | base_url="$host/$tag" |
| 81 | |||
| 82 | echo "Verifying Bitflash $tag" | 85 | echo "Verifying Bitflash $tag" |
| 83 | echo "Working directory: $workdir" | 86 | echo "Working directory: $workdir" |
| 84 | cd "$workdir" | 87 | cd "$workdir" |
| @@ -86,27 +89,32 @@ cd "$workdir" | |||
| 86 | download "$base_url/SHA256SUMS" SHA256SUMS | 89 | download "$base_url/SHA256SUMS" SHA256SUMS |
| 87 | 90 | ||
| 88 | if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then | 91 | if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then |
| 89 | if ! command -v gpg >/dev/null 2>&1; then | 92 | need gpg |
| 90 | echo "SHA256SUMS.asc exists, but gpg is not installed" >&2 | 93 | # Verify in a throwaway keyring holding ONLY the pinned key, and require that |
| 94 | # the signature validates against it with the pinned fingerprint. This is the | ||
| 95 | # difference between "signed" and "signed by Bitflash". | ||
| 96 | keyring="$workdir/keyring" | ||
| 97 | mkdir -p "$keyring"; chmod 700 "$keyring" | ||
| 98 | printf '%s\n' "$RELEASE_KEY" | GNUPGHOME="$keyring" gpg --batch --quiet --import 2>/dev/null | ||
| 99 | status="$(GNUPGHOME="$keyring" gpg --batch --status-fd=1 --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null || true)" | ||
| 100 | if printf '%s' "$status" | grep -q "VALIDSIG.*$RELEASE_KEY_FP"; then | ||
| 101 | echo "Signature OK -- signed by the pinned Bitflash release key" | ||
| 102 | else | ||
| 103 | echo "SIGNATURE REJECTED: not a valid signature by the pinned release key" >&2 | ||
| 104 | echo " expected key: $RELEASE_KEY_FP" >&2 | ||
| 91 | exit 1 | 105 | exit 1 |
| 92 | fi | 106 | fi |
| 93 | gpg --verify SHA256SUMS.asc SHA256SUMS | ||
| 94 | echo "Signature OK" | ||
| 95 | else | 107 | else |
| 96 | if [ "$require_signature" -eq 1 ]; then | 108 | if [ "$require_signature" -eq 1 ]; then |
| 97 | echo "release does not publish SHA256SUMS.asc" >&2 | 109 | echo "release does not publish SHA256SUMS.asc" >&2; exit 1 |
| 98 | exit 1 | ||
| 99 | fi | 110 | fi |
| 100 | echo "WARNING: no SHA256SUMS.asc found; checking hashes only" | 111 | echo "WARNING: no SHA256SUMS.asc found; checking hashes only (unsigned release)" |
| 101 | fi | 112 | fi |
| 102 | 113 | ||
| 103 | awk '{print $2}' SHA256SUMS | while IFS= read -r file; do | 114 | awk '{print $2}' SHA256SUMS | while IFS= read -r file; do |
| 104 | file="${file#\*}" | 115 | file="${file#\*}"; file="${file#./}" |
| 105 | file="${file#./}" | ||
| 106 | [ -z "$file" ] && continue | 116 | [ -z "$file" ] && continue |
| 107 | case "$file" in | 117 | case "$file" in */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;; esac |
| 108 | */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;; | ||
| 109 | esac | ||
| 110 | echo "Downloading $file" | 118 | echo "Downloading $file" |
| 111 | download "$base_url/$file" "$file" | 119 | download "$base_url/$file" "$file" |
| 112 | done | 120 | done |