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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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
Expand Down Expand Up @@ -385,6 +393,14 @@ help の探し方:
| 共有 flag だけの一覧 | `cdidx --help-flags` |
| 1 コマンドの usage 行 | `cdidx <command> --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=<fingerprint>` を設定します。strict mode では、
GPG 検証が成功した後にこの fingerprint の設定も必須です。

### Validate

`cdidx validate [--db <path>] [--json] [--verbose] [--kind <kind>] [--path <glob>]`
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1795.security.md
Original file line number Diff line number Diff line change
@@ -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` に対応しました。
100 changes: 100 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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%/}"
Expand Down Expand Up @@ -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}"
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions tests/CodeIndex.Tests/InstallScriptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?>
{
["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<string, string?>
{
["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<string, string?>
{
["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()
{
Expand Down
Loading