diff --git a/README.md b/README.md index 8c9448ead8..2f13f8e2b9 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,14 @@ Install choice and network notes: | ARM64 host without a preinstalled .NET 8 runtime | `install.sh` | | Proxy or mirrored GitHub access | `install.sh --doctor` and `CDIDX_GITHUB_BASE_URL` / `CDIDX_GITHUB_API_BASE_URL` | +Installation security: release tarballs are still checked against +`sha256sums.txt`, and the installer also downloads `sha256sums.txt.asc` and +runs `gpg --verify` when GnuPG is available. Set `CDIDX_STRICT_VERIFY=1` or +pass `--strict-verify` to fail closed when signature verification cannot be +performed. Set `CDIDX_RELEASE_GPG_FINGERPRINT=` to pin the +expected release signing key; strict mode requires this fingerprint once GPG +verification succeeds. + See [DISTRIBUTION.md](DISTRIBUTION.md) for the full channel matrix and [isolated network install notes](USER_GUIDE.md#isolated-networks-and-proxies). For database compatibility across `cdidx` upgrades and downgrades, see @@ -385,6 +393,14 @@ help の探し方: | 共有 flag だけの一覧 | `cdidx --help-flags` | | 1 コマンドの usage 行 | `cdidx --help` | +install security: release tarball は引き続き `sha256sums.txt` と照合され、 +installer は `sha256sums.txt.asc` も取得して GnuPG がある場合は +`gpg --verify` を実行します。署名検証できない場合に fail closed したい +ときは `CDIDX_STRICT_VERIFY=1` または `--strict-verify` を使ってください。 +期待する release signing key を固定するには +`CDIDX_RELEASE_GPG_FINGERPRINT=` を設定します。strict mode では、 +GPG 検証が成功した後にこの fingerprint の設定も必須です。 + ### Validate `cdidx validate [--db ] [--json] [--verbose] [--kind ] [--path ]` diff --git a/changelog.d/unreleased/1795.security.md b/changelog.d/unreleased/1795.security.md new file mode 100644 index 0000000000..73fd02c75d --- /dev/null +++ b/changelog.d/unreleased/1795.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 1795 +affected: + - install.sh + - README.md + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **Installer checksum manifests can now be GPG-verified (#1795)** — `install.sh` downloads `sha256sums.txt.asc`, verifies it with GPG when available, supports `--strict-verify` / `CDIDX_STRICT_VERIFY=1`, and can pin the expected release signing fingerprint with `CDIDX_RELEASE_GPG_FINGERPRINT`. + +## 日本語 + +- **installer の checksum manifest を GPG 検証できるようになりました (#1795)** — `install.sh` は `sha256sums.txt.asc` を取得し、GPG が利用可能な場合は検証します。`--strict-verify` / `CDIDX_STRICT_VERIFY=1` と、期待する release signing fingerprint を固定する `CDIDX_RELEASE_GPG_FINGERPRINT` に対応しました。 diff --git a/install.sh b/install.sh index d20f22f45e..419c79ce72 100755 --- a/install.sh +++ b/install.sh @@ -19,6 +19,8 @@ # CDIDX_GITHUB_BASE_URL Release download base URL override # CDIDX_GITHUB_API_BASE_URL API base URL override for latest-release lookup # CDIDX_REQUIRE_ATTESTATION=1 Require GitHub provenance verification via gh +# CDIDX_STRICT_VERIFY=1 Require GPG checksum-manifest signature verification +# CDIDX_RELEASE_GPG_FINGERPRINT Expected checksum signer fingerprint # CDIDX_LOCAL_MIRROR_PORT Local self-test HTTP server port (default: 18765) # HTTPS_PROXY / HTTP_PROXY Proxy used by curl for release and API probes # NO_PROXY Hosts that should bypass the proxy @@ -78,6 +80,8 @@ MANIFEST_REQUIRED_VERSION="1.24.6" GITHUB_BASE_URL="${CDIDX_GITHUB_BASE_URL:-https://github.com}" GITHUB_API_BASE_URL="${CDIDX_GITHUB_API_BASE_URL:-https://api.github.com}" REQUIRE_ATTESTATION="${CDIDX_REQUIRE_ATTESTATION:-0}" +STRICT_VERIFY="${CDIDX_STRICT_VERIFY:-0}" +RELEASE_GPG_FINGERPRINT="${CDIDX_RELEASE_GPG_FINGERPRINT:-}" # Normalize optional base URL overrides by removing a trailing slash. # 末尾スラッシュ付きでも URL 連結が壊れないようにする。 GITHUB_BASE_URL="${GITHUB_BASE_URL%/}" @@ -227,6 +231,71 @@ verify_release_attestation() { warn "GitHub provenance attestation verification failed for ${artifact_name}; continuing with checksum verification. Set CDIDX_REQUIRE_ATTESTATION=1 to fail closed." } +checksum_signature_supported() { + if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" = "1" ] && [ "${CDIDX_TEST_ENABLE_SIGNATURE_VERIFY:-0}" != "1" ]; then + return 1 + fi + + return 0 +} + +normalize_gpg_fingerprint() { + printf '%s' "$1" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]' +} + +extract_validsig_fingerprint() { + awk '$1 == "[GNUPG:]" && $2 == "VALIDSIG" { print $3; exit }' "$1" +} + +verify_checksum_signature() { + local checksums_path="$1" + local signature_path="$2" + + if ! has_cmd gpg; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification is required, but the 'gpg' command was not found. Install GnuPG or unset CDIDX_STRICT_VERIFY." + fi + warn "Skipping GPG signature verification for sha256sums.txt: 'gpg' command not found. Set CDIDX_STRICT_VERIFY=1 to require this verification." + return 0 + fi + + local gpg_status="${signature_path}.status" + local gpg_stderr="${signature_path}.stderr" + info "Verifying checksum signature..." + if ! gpg --batch --status-fd 1 --verify "$signature_path" "$checksums_path" > "$gpg_status" 2> "$gpg_stderr"; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification failed for sha256sums.txt." + fi + warn "GPG signature verification failed for sha256sums.txt; continuing with checksum verification. Set CDIDX_STRICT_VERIFY=1 to fail closed." + return 0 + fi + + local actual_fingerprint + actual_fingerprint="$(extract_validsig_fingerprint "$gpg_status")" + if [ -z "$actual_fingerprint" ]; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification did not report a signer fingerprint." + fi + warn "GPG signature verification succeeded but no signer fingerprint was reported; continuing without fingerprint pinning." + return 0 + fi + + if [ -z "$RELEASE_GPG_FINGERPRINT" ]; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification is strict, but CDIDX_RELEASE_GPG_FINGERPRINT is not set." + fi + warn "GPG signature verification succeeded for sha256sums.txt, but no expected release signing fingerprint is configured. Set CDIDX_RELEASE_GPG_FINGERPRINT to pin the signer." + return 0 + fi + + local expected_fingerprint + expected_fingerprint="$(normalize_gpg_fingerprint "$RELEASE_GPG_FINGERPRINT")" + actual_fingerprint="$(normalize_gpg_fingerprint "$actual_fingerprint")" + if [ "$actual_fingerprint" != "$expected_fingerprint" ]; then + error "GPG signature fingerprint mismatch for sha256sums.txt. Expected ${expected_fingerprint}, got ${actual_fingerprint}." + fi +} + temp_root() { printf '%s' "${TMPDIR:-/tmp}" } @@ -910,6 +979,20 @@ download_release_file() { return 0 } +download_optional_release_file() { + local url="$1" + local output_path="$2" + local release_host_label + release_host_label="$(release_host_diagnostic_label)" + + local http_code + if ! http_code="$(curl_http_get "$url" "$output_path" "$release_host_label")"; then + return 1 + fi + + [ "$http_code" = "200" ] +} + # --- Detect OS and architecture / OS・アーキテクチャ検出 --- detect_platform() { @@ -1023,6 +1106,7 @@ download_and_install() { base_url="$(release_download_base_url)" local archive_url="${base_url}/${archive_name}" local checksums_url="${base_url}/sha256sums.txt" + local checksums_signature_url="${base_url}/sha256sums.txt.asc" local tmpdir probe_temp_root @@ -1040,6 +1124,17 @@ download_and_install() { download_release_file "$checksums_url" "${tmpdir}/sha256sums.txt" "sha256sums.txt" verify_release_attestation "${tmpdir}/sha256sums.txt" "sha256sums.txt" + if checksum_signature_supported; then + info "Downloading checksum signature..." + if download_optional_release_file "$checksums_signature_url" "${tmpdir}/sha256sums.txt.asc"; then + verify_checksum_signature "${tmpdir}/sha256sums.txt" "${tmpdir}/sha256sums.txt.asc" + elif [ "$STRICT_VERIFY" = "1" ]; then + error "Failed to download sha256sums.txt.asc while strict verification is enabled." + else + warn "Skipping GPG signature verification: sha256sums.txt.asc was not available. Set CDIDX_STRICT_VERIFY=1 to fail closed." + fi + fi + # Verify checksum / チェックサム検証 info "Verifying checksum..." local expected_checksum @@ -2070,6 +2165,11 @@ main() { } if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" != "1" ]; then + while [ "${1:-}" = "--strict-verify" ]; do + STRICT_VERIFY=1 + shift + done + case "${1:-}" in --self-test-local-mirror) shift diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 5f564224fc..22bcd4bbba 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -2316,6 +2316,104 @@ public void CheckExisting_DifferentVersion_UsesNeutralSwitchingWording() Assert.DoesNotContain("Upgrading cdidx from 1.10.0 to 0.99.0", stdout); } + [Fact] + public void VerifyChecksumSignature_StrictVerifyWithoutGpg_FailsClosed() + { + if (OperatingSystem.IsWindows()) + return; + + var checksumsPath = Path.Combine(_tempRoot, "strict.sha256sums.txt"); + var signaturePath = checksumsPath + ".asc"; + File.WriteAllText(checksumsPath, "checksum"); + File.WriteAllText(signaturePath, "signature"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + command() { + if [ "${1:-}" = "-v" ] && [ "${2:-}" = "gpg" ]; then + return 1 + fi + builtin command "$@" + } + + verify_checksum_signature "{{checksumsPath}}" "{{signaturePath}}" + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_STRICT_VERIFY"] = "1", + }, + enforceStrictMode: false); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("GPG signature verification is required", stderr); + } + + [Fact] + public void VerifyChecksumSignature_FingerprintMismatch_Fails() + { + if (OperatingSystem.IsWindows()) + return; + + var checksumsPath = Path.Combine(_tempRoot, "mismatch.sha256sums.txt"); + var signaturePath = checksumsPath + ".asc"; + File.WriteAllText(checksumsPath, "checksum"); + File.WriteAllText(signaturePath, "signature"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + gpg() { + printf '[GNUPG:] VALIDSIG AABBCCDDEEFF00112233445566778899AABBCCDD 2026-01-01 0 4 0 1 10 00 AABBCCDDEEFF00112233445566778899AABBCCDD\n' + return 0 + } + + verify_checksum_signature "{{checksumsPath}}" "{{signaturePath}}" + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_RELEASE_GPG_FINGERPRINT"] = "1111222233334444555566667777888899990000", + }, + enforceStrictMode: false); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("GPG signature fingerprint mismatch", stderr); + } + + [Fact] + public void VerifyChecksumSignature_MatchingFingerprint_Succeeds() + { + if (OperatingSystem.IsWindows()) + return; + + var checksumsPath = Path.Combine(_tempRoot, "match.sha256sums.txt"); + var signaturePath = checksumsPath + ".asc"; + File.WriteAllText(checksumsPath, "checksum"); + File.WriteAllText(signaturePath, "signature"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + gpg() { + printf '[GNUPG:] VALIDSIG AABBCCDDEEFF00112233445566778899AABBCCDD 2026-01-01 0 4 0 1 10 00 AABBCCDDEEFF00112233445566778899AABBCCDD\n' + return 0 + } + + verify_checksum_signature "{{checksumsPath}}" "{{signaturePath}}" + echo "VERIFIED" + """, + new Dictionary + { + ["CDIDX_RELEASE_GPG_FINGERPRINT"] = "aabb ccdd eeff 0011 2233 4455 6677 8899 aabb ccdd", + }); + + Assert.Equal(0, exitCode); + Assert.Contains("Verifying checksum signature", stdout); + Assert.Contains("VERIFIED", stdout); + Assert.Equal(string.Empty, stderr); + } + [Fact] public void AcquireInstallLock_WhenFlockIsAvailable_TakesNonBlockingFileLock() {