From 757ef2842b06286ad5f7358551632f2fa2a1f612 Mon Sep 17 00:00:00 2001 From: Eugene Volen Date: Thu, 9 Jul 2026 13:14:51 +0000 Subject: [PATCH 1/2] Pin release signing key and verify by default Pin the production release signing public key in install.sh and install.ps1 and require signature verification by default on the release channel, so downloaded archives are checked against the pinned trust root before install. Guard the key end to end: release_readiness.py verifies built archives against the configured signing public key (a key mismatch fails at build time, not on users' machines), and verify_pinned_keys.py asserts the pinned constants match the canonical docs/keys file and the CI signing key. Both run in CI. Metadata pinning and the production-trust CI gate are wired but dormant (empty sentinel / unset variable) until their inputs exist. Signed-off-by: Eugene Volen --- .github/workflows/ci.yml | 13 + .github/workflows/nightly.yml | 20 ++ .github/workflows/release.yml | 20 ++ apps/rocm/src/therock.rs | 92 +++++- docs/keys/rocm-cli-release-current-public.pem | 9 + install.ps1 | 71 ++++- install.sh | 78 ++++- ...ceptance-install-upgrade-tui-uninstall.ps1 | 12 +- ...cceptance-install-upgrade-tui-uninstall.sh | 12 +- scripts/release_readiness.py | 65 +++- scripts/verify_pinned_keys.py | 296 ++++++++++++++++++ 11 files changed, 656 insertions(+), 32 deletions(-) create mode 100644 docs/keys/rocm-cli-release-current-public.pem create mode 100755 scripts/verify_pinned_keys.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13eb71cc..7c307a19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,12 @@ jobs: if: needs.changes.outputs.heavy == 'true' run: python scripts/release_readiness.py --self-test + - name: Pinned key consistency (self-test + repo check) + if: needs.changes.outputs.heavy == 'true' + run: | + python scripts/verify_pinned_keys.py --self-test + python scripts/verify_pinned_keys.py + - name: Acceptance install lifecycle if: needs.changes.outputs.heavy == 'true' run: ./scripts/acceptance-install-upgrade-tui-uninstall.sh @@ -287,6 +293,13 @@ jobs: shell: pwsh run: python .\scripts\release_readiness.py --self-test + - name: Pinned key consistency (self-test + repo check) + if: needs.changes.outputs.heavy == 'true' + shell: pwsh + run: | + python .\scripts\verify_pinned_keys.py --self-test + python .\scripts\verify_pinned_keys.py + - name: Check PowerShell installer syntax if: needs.changes.outputs.heavy == 'true' shell: pwsh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index fee9758a..f31ad701 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,6 +10,19 @@ permissions: env: CARGO_TERM_COLOR: always + # Production trust gate — dormant until the owner sets the repository variable + # ROCM_CLI_REQUIRE_PRODUCTION_TRUST (e.g. "1") and provides the trust inputs + # below. While the variable is unset/falsy, release_readiness.py skips + # validate_production_trust() and behavior is unchanged. The values are public + # keys and paths (not the private signing key, which stays step-scoped). When + # enabling the gate, the recipe-index inputs also require a step that + # materializes the hosted index + its .sig into the workspace. See + # plans/release-production-trust-rollout.md. + ROCM_CLI_REQUIRE_PRODUCTION_TRUST: ${{ vars.ROCM_CLI_REQUIRE_PRODUCTION_TRUST }} + ROCM_CLI_SIGNING_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_SIGNING_PUBLIC_KEY_PEM }} + ROCM_CLI_METADATA_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_METADATA_PUBLIC_KEY_PEM }} + ROCM_CLI_MODEL_RECIPE_INDEX_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PATH }} + ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH }} jobs: nightly: @@ -23,6 +36,9 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Verify pinned keys match the signing key + run: python scripts/verify_pinned_keys.py + - name: Check for changes since last nightly id: check env: @@ -180,6 +196,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Verify pinned keys match the signing key + shell: pwsh + run: python .\scripts\verify_pinned_keys.py + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Build release binaries diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4194c4bf..3882e65e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,19 @@ permissions: env: CARGO_TERM_COLOR: always + # Production trust gate — dormant until the owner sets the repository variable + # ROCM_CLI_REQUIRE_PRODUCTION_TRUST (e.g. "1") and provides the trust inputs + # below. While the variable is unset/falsy, release_readiness.py skips + # validate_production_trust() and behavior is unchanged. The values are public + # keys and paths (not the private signing key, which stays step-scoped). When + # enabling the gate, the recipe-index inputs also require a step that + # materializes the hosted index + its .sig into the workspace. See + # plans/release-production-trust-rollout.md. + ROCM_CLI_REQUIRE_PRODUCTION_TRUST: ${{ vars.ROCM_CLI_REQUIRE_PRODUCTION_TRUST }} + ROCM_CLI_SIGNING_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_SIGNING_PUBLIC_KEY_PEM }} + ROCM_CLI_METADATA_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_METADATA_PUBLIC_KEY_PEM }} + ROCM_CLI_MODEL_RECIPE_INDEX_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PATH }} + ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH }} jobs: release: @@ -24,6 +37,9 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Verify pinned keys match the signing key + run: python scripts/verify_pinned_keys.py + - name: Determine version id: version run: | @@ -164,6 +180,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Verify pinned keys match the signing key + shell: pwsh + run: python .\scripts\verify_pinned_keys.py + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Build release binaries diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index 53cd30af..9cf2bdb3 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -1702,12 +1702,48 @@ fn select_latest_version(versions: &[String], channel: TheRockChannel) -> Option } } +/// Pinned production metadata signing public key (trust root). Empty until the +/// repository owner publishes production keys (see docs/release-trust.md, +/// "Remaining Owner Step"). While empty, metadata verification stays opt-in +/// (enabled only via the `ROCM_CLI_METADATA_PUBLIC_KEY_*` env vars). Once +/// populated, metadata signatures are verified by default with this key as the +/// trust root. +const PINNED_METADATA_PUBLIC_KEY_PEM: &str = ""; + +/// The pinned metadata trust root, or `None` while the sentinel is still empty. +fn pinned_metadata_public_key() -> Option { + let pem = PINNED_METADATA_PUBLIC_KEY_PEM.trim(); + (!pem.is_empty()).then(|| pem.to_owned()) +} + impl MetadataSignaturePolicy { fn from_env() -> Self { + Self::resolve( + truthy_env("ROCM_CLI_REQUIRE_METADATA_SIGNATURE"), + env_path("ROCM_CLI_METADATA_PUBLIC_KEY_PATH"), + env_nonempty("ROCM_CLI_METADATA_PUBLIC_KEY_PEM"), + pinned_metadata_public_key(), + ) + } + + /// Combine the env-provided inputs with the pinned trust root. An explicit + /// env key (path or PEM) wins as an escape hatch; otherwise the pinned key is + /// used, and its presence makes verification required by default. + fn resolve( + env_required: bool, + env_path: Option, + env_pem: Option, + pinned_pem: Option, + ) -> Self { + let pinned = if env_path.is_none() && env_pem.is_none() { + pinned_pem + } else { + None + }; Self { - required: truthy_env("ROCM_CLI_REQUIRE_METADATA_SIGNATURE"), - public_key_path: env_path("ROCM_CLI_METADATA_PUBLIC_KEY_PATH"), - public_key_pem: env_nonempty("ROCM_CLI_METADATA_PUBLIC_KEY_PEM"), + required: env_required || pinned.is_some(), + public_key_path: env_path, + public_key_pem: env_pem.or(pinned), } } @@ -3837,6 +3873,56 @@ echo Python 3.12.10 Ok(()) } + #[test] + fn metadata_policy_uses_pinned_key_and_requires_by_default() { + // A pinned trust root with no env inputs: verification becomes required + // by default and the pinned PEM is used. + let pinned = "-----BEGIN PUBLIC KEY-----\npinned\n-----END PUBLIC KEY-----\n"; + let policy = MetadataSignaturePolicy::resolve(false, None, None, Some(pinned.to_owned())); + assert!(policy.required); + assert!(policy.active()); + assert_eq!(policy.public_key_pem.as_deref(), Some(pinned)); + assert!(policy.public_key_path.is_none()); + } + + #[test] + fn metadata_policy_env_key_overrides_pinned_key() { + // An explicit env PEM wins over the pinned root (escape hatch), and does + // not force `required` on its own. + let pinned = "-----BEGIN PUBLIC KEY-----\npinned\n-----END PUBLIC KEY-----\n"; + let env_pem = "-----BEGIN PUBLIC KEY-----\nenv\n-----END PUBLIC KEY-----\n"; + let policy = MetadataSignaturePolicy::resolve( + false, + None, + Some(env_pem.to_owned()), + Some(pinned.to_owned()), + ); + assert_eq!(policy.public_key_pem.as_deref(), Some(env_pem)); + + let env_path = PathBuf::from("/keys/metadata.pem"); + let policy = MetadataSignaturePolicy::resolve( + false, + Some(env_path.clone()), + None, + Some(pinned.to_owned()), + ); + assert_eq!(policy.public_key_path, Some(env_path)); + assert!(policy.public_key_pem.is_none()); + } + + #[test] + fn metadata_policy_without_pinned_key_preserves_optin_behavior() { + // Empty pinned sentinel + no env inputs: verification stays inactive, + // exactly as before pinning was introduced. + let policy = MetadataSignaturePolicy::resolve(false, None, None, None); + assert!(!policy.required); + assert!(!policy.active()); + + // `ROCM_CLI_REQUIRE_METADATA_SIGNATURE=1` alone still activates it. + let policy = MetadataSignaturePolicy::resolve(true, None, None, None); + assert!(policy.required); + } + #[test] fn metadata_cache_revalidation_requires_cached_signature_when_policy_is_active() -> Result<()> { let (root, paths) = test_paths("metadata-signature-revalidate"); diff --git a/docs/keys/rocm-cli-release-current-public.pem b/docs/keys/rocm-cli-release-current-public.pem new file mode 100644 index 00000000..5f52f144 --- /dev/null +++ b/docs/keys/rocm-cli-release-current-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxuyScR/BzV+kuXqWAHtE ++9xiPCWURUYnsio9MOrf2Xe01mBngP7qPcF13+5nrfT3EnuxOn5rSCYwjOndlS+c +KzOw6GZXJD/ZqeojnbXxxsxlftAQHHEke1WCtga5ZEFxOauTeB5nTV/IbjMAl2Xc +M4PaudpFFH/6j/E3gongDmt0hWdpMLbaCcd3i1vMTEsaHZooNoAbJ/dIAHR/dDNM +pScZAZoy0LL3Afhn5Hiv71trfbfnnboVSdhnCoMmisl6/sK55zR7VM8hWDTTowl3 +ultUtiz4emTfXDCb2RptOgoydBA+mu9z6O4eVF8S5dVr/S834SK6dD2fWHNnT0dc +JwIDAQAB +-----END PUBLIC KEY----- diff --git a/install.ps1 b/install.ps1 index f70d5150..42dd4db5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -75,6 +75,35 @@ function Convert-FileUriToPath { return $null } +# Pinned production release signing public keys (trust roots). These stay empty +# until the repository owner publishes production keys (see docs/release-trust.md, +# "Remaining Owner Step"). While empty, release installs keep the opt-in behavior: +# a signature is verified only when a key is supplied via the parameters/env vars +# or ROCM_CLI_REQUIRE_SIGNATURE=1. Once populated, release-channel installs verify +# signatures by default with these keys as trust roots. Two slots support +# zero-downtime key rotation: a release signed with either the current or the +# pre-staged next key verifies, so the next key is trusted before its first use. +$PinnedReleasePublicKeyCurrent = @" +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxuyScR/BzV+kuXqWAHtE ++9xiPCWURUYnsio9MOrf2Xe01mBngP7qPcF13+5nrfT3EnuxOn5rSCYwjOndlS+c +KzOw6GZXJD/ZqeojnbXxxsxlftAQHHEke1WCtga5ZEFxOauTeB5nTV/IbjMAl2Xc +M4PaudpFFH/6j/E3gongDmt0hWdpMLbaCcd3i1vMTEsaHZooNoAbJ/dIAHR/dDNM +pScZAZoy0LL3Afhn5Hiv71trfbfnnboVSdhnCoMmisl6/sK55zR7VM8hWDTTowl3 +ultUtiz4emTfXDCb2RptOgoydBA+mu9z6O4eVF8S5dVr/S834SK6dD2fWHNnT0dc +JwIDAQAB +-----END PUBLIC KEY----- +"@ +$PinnedReleasePublicKeyNext = "" + +function Test-HasPinnedReleaseKey { + return (-not [string]::IsNullOrWhiteSpace($PinnedReleasePublicKeyCurrent)) ` + -or (-not [string]::IsNullOrWhiteSpace($PinnedReleasePublicKeyNext)) +} + +# Return the candidate signing public keys as an array of file paths. An explicit +# parameter/env-provided key wins (an escape hatch for private mirrors); otherwise +# the pinned production trust roots are used. function Resolve-SigningPublicKey { param( [string] $KeyPath, @@ -83,16 +112,26 @@ function Resolve-SigningPublicKey { ) if (-not [string]::IsNullOrWhiteSpace($KeyPath)) { - return Resolve-InstallerPath $KeyPath + return @((Resolve-InstallerPath $KeyPath)) } if (-not [string]::IsNullOrWhiteSpace($KeyPem)) { $path = Join-Path $TempRoot "rocm-cli-signing-public-key.pem" Set-Content -LiteralPath $path -Value $KeyPem -Encoding ascii - return $path + return @($path) } - return "" + $paths = @() + $index = 0 + foreach ($pinned in @($PinnedReleasePublicKeyCurrent, $PinnedReleasePublicKeyNext)) { + if (-not [string]::IsNullOrWhiteSpace($pinned)) { + $index++ + $path = Join-Path $TempRoot "rocm-cli-pinned-key-$index.pem" + Set-Content -LiteralPath $path -Value $pinned -Encoding ascii + $paths += $path + } + } + return $paths } function Save-File { @@ -136,14 +175,17 @@ function Confirm-ArchiveSignature { param( [string] $ArchivePath, [string] $SignaturePath, - [string] $PublicKeyPath + [string[]] $PublicKeyPaths ) Confirm-Command openssl - & openssl dgst -sha256 -verify $PublicKeyPath -signature $SignaturePath $ArchivePath | Out-Null - if ($LASTEXITCODE -ne 0) { - Fail "signature verification failed" + foreach ($key in $PublicKeyPaths) { + & openssl dgst -sha256 -verify $key -signature $SignaturePath $ArchivePath | Out-Null + if ($LASTEXITCODE -eq 0) { + return + } } + Fail "signature verification failed" } function Get-ExpectedSha256 { @@ -380,10 +422,17 @@ $sigPath = "$archivePath.sig" $extractDir = Join-Path $tempRoot "extract" $manifestPath = Join-Path $InstallDir ".rocm-cli-manifest" $signatureRequired = $RequireSignature -or (Test-Truthy $env:ROCM_CLI_REQUIRE_SIGNATURE) +# Production default: once release trust roots are pinned, release-channel installs +# always verify. An unset or 0 ROCM_CLI_REQUIRE_SIGNATURE does not lower this +# floor; pass -SigningPublicKeyPath/-Pem (or the env vars) to point at an +# alternate key (e.g. a private mirror) instead. +if ($Channel -eq "release" -and (Test-HasPinnedReleaseKey)) { + $signatureRequired = $true +} try { New-Item -ItemType Directory -Force -Path $tempRoot, $extractDir | Out-Null - $signingPublicKey = Resolve-SigningPublicKey $SigningPublicKeyPath $SigningPublicKeyPem $tempRoot + $signingPublicKeys = @(Resolve-SigningPublicKey $SigningPublicKeyPath $SigningPublicKeyPem $tempRoot) Write-Host "rocm-cli installer" Write-Host " repo: $Repo" @@ -400,12 +449,12 @@ try { Fail "checksum verification failed" } - if ($signatureRequired -or -not [string]::IsNullOrWhiteSpace($signingPublicKey)) { - if ([string]::IsNullOrWhiteSpace($signingPublicKey)) { + if ($signatureRequired -or ($signingPublicKeys.Count -gt 0)) { + if ($signingPublicKeys.Count -eq 0) { Fail "signature verification requires ROCM_CLI_SIGNING_PUBLIC_KEY_PATH, ROCM_CLI_SIGNING_PUBLIC_KEY_PEM, -SigningPublicKeyPath, or -SigningPublicKeyPem" } Save-File $sigUrl $sigPath "required signature sidecar is missing or unavailable" - Confirm-ArchiveSignature $archivePath $sigPath $signingPublicKey + Confirm-ArchiveSignature $archivePath $sigPath $signingPublicKeys Write-Host "signature verified" } diff --git a/install.sh b/install.sh index 2358ab49..111bdcfe 100755 --- a/install.sh +++ b/install.sh @@ -70,7 +70,33 @@ fetch() { fi } -signing_public_key_path() { +# Pinned production release signing public keys (trust roots). These stay empty +# until the repository owner publishes production keys (see docs/release-trust.md, +# "Remaining Owner Step"). While empty, release installs keep the opt-in behavior +# below: a signature is verified only when a key is supplied via the env vars or +# ROCM_CLI_REQUIRE_SIGNATURE=1. Once populated, release-channel installs verify +# signatures by default with these keys as trust roots. Two slots support +# zero-downtime key rotation: a release signed with either the current or the +# pre-staged next key verifies, so the next key is trusted before its first use. +PINNED_RELEASE_PUBLIC_KEY_CURRENT="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxuyScR/BzV+kuXqWAHtE ++9xiPCWURUYnsio9MOrf2Xe01mBngP7qPcF13+5nrfT3EnuxOn5rSCYwjOndlS+c +KzOw6GZXJD/ZqeojnbXxxsxlftAQHHEke1WCtga5ZEFxOauTeB5nTV/IbjMAl2Xc +M4PaudpFFH/6j/E3gongDmt0hWdpMLbaCcd3i1vMTEsaHZooNoAbJ/dIAHR/dDNM +pScZAZoy0LL3Afhn5Hiv71trfbfnnboVSdhnCoMmisl6/sK55zR7VM8hWDTTowl3 +ultUtiz4emTfXDCb2RptOgoydBA+mu9z6O4eVF8S5dVr/S834SK6dD2fWHNnT0dc +JwIDAQAB +-----END PUBLIC KEY-----" +PINNED_RELEASE_PUBLIC_KEY_NEXT="" + +has_pinned_release_keys() { + [ -n "${PINNED_RELEASE_PUBLIC_KEY_CURRENT}" ] || [ -n "${PINNED_RELEASE_PUBLIC_KEY_NEXT}" ] +} + +# Emit the candidate signing public keys as newline-separated file paths. An +# explicit env-provided key wins (an escape hatch for private mirrors); otherwise +# the pinned production trust roots are used. Requires ${tmp_dir} to exist. +resolve_public_keys() { if [ -n "${ROCM_CLI_SIGNING_PUBLIC_KEY_PATH:-}" ]; then printf '%s\n' "${ROCM_CLI_SIGNING_PUBLIC_KEY_PATH}" return 0 @@ -83,16 +109,37 @@ signing_public_key_path() { return 0 fi - printf '%s\n' "" + pinned_index=0 + for pinned_pem in "${PINNED_RELEASE_PUBLIC_KEY_CURRENT}" "${PINNED_RELEASE_PUBLIC_KEY_NEXT}"; do + [ -n "${pinned_pem}" ] || continue + pinned_index=$((pinned_index + 1)) + key_path="${tmp_dir}/rocm-cli-pinned-key-${pinned_index}.pem" + printf '%s\n' "${pinned_pem}" > "${key_path}" + printf '%s\n' "${key_path}" + done } +# Verify ${signature} over ${archive} against any of the newline-separated public +# key file paths in ${keys}; succeed on the first match, fail if none verify. verify_signature() { archive="$1" signature="$2" - public_key="$3" + keys="$3" need_cmd openssl - openssl dgst -sha256 -verify "${public_key}" -signature "${signature}" "${archive}" >/dev/null 2>&1 \ - || fail "signature verification failed" + saved_ifs="${IFS}" + IFS=' +' + for key in ${keys}; do + IFS="${saved_ifs}" + [ -n "${key}" ] || continue + if openssl dgst -sha256 -verify "${key}" -signature "${signature}" "${archive}" >/dev/null 2>&1; then + return 0 + fi + IFS=' +' + done + IFS="${saved_ifs}" + fail "signature verification failed" } installer_config_dir() { @@ -319,11 +366,24 @@ expected="$(awk '{print $1}' "${sha_path}" | head -n1)" actual="$(sha256_file "${archive_path}")" [ "${expected}" = "${actual}" ] || fail "checksum verification failed" -public_key_path="$(signing_public_key_path)" -if truthy "${ROCM_CLI_REQUIRE_SIGNATURE:-0}" || [ -n "${public_key_path}" ]; then - [ -n "${public_key_path}" ] || fail "signature verification requires ROCM_CLI_SIGNING_PUBLIC_KEY_PATH or ROCM_CLI_SIGNING_PUBLIC_KEY_PEM" +public_keys="$(resolve_public_keys)" + +require_sig=0 +if truthy "${ROCM_CLI_REQUIRE_SIGNATURE:-0}"; then + require_sig=1 +fi +# Production default: once release trust roots are pinned, release-channel +# installs always verify. An unset or 0 ROCM_CLI_REQUIRE_SIGNATURE does not lower +# this floor; use ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/PEM to point at an alternate +# key (e.g. a private mirror) instead. +if [ "${CHANNEL}" = "release" ] && has_pinned_release_keys; then + require_sig=1 +fi + +if [ "${require_sig}" -eq 1 ] || [ -n "${public_keys}" ]; then + [ -n "${public_keys}" ] || fail "signature verification requires ROCM_CLI_SIGNING_PUBLIC_KEY_PATH or ROCM_CLI_SIGNING_PUBLIC_KEY_PEM" fetch "${sig_url}" "${sig_path}" "required signature sidecar is missing or unavailable: ${sig_url}" - verify_signature "${archive_path}" "${sig_path}" "${public_key_path}" + verify_signature "${archive_path}" "${sig_path}" "${public_keys}" echo "signature verified" fi diff --git a/scripts/acceptance-install-upgrade-tui-uninstall.ps1 b/scripts/acceptance-install-upgrade-tui-uninstall.ps1 index dac9dd26..0e778a87 100644 --- a/scripts/acceptance-install-upgrade-tui-uninstall.ps1 +++ b/scripts/acceptance-install-upgrade-tui-uninstall.ps1 @@ -299,7 +299,11 @@ try { Fail "installer did not seed minimal config with the expected default engine" } - Write-Host "acceptance: reject required signature without public key" + # With a production release key pinned in install.ps1, a release install with + # no public key supplied falls back to that pinned trust root. The bundle here + # is signed with the acceptance test key, not the pinned key, so verification + # must fail - proving the default-on pinned path rejects an untrusted signer. + Write-Host "acceptance: reject signature from an untrusted key" $noPublicKeyInstallDir = Join-Path $AcceptanceRoot "no-public-key-install\bin" $noPublicKeyInstallArgs = @( "-NoProfile", @@ -315,9 +319,9 @@ try { "-RequireSignature", "-NoPathUpdate" ) - Invoke-ExpectFailure "acceptance: required signature no public key install" $psExe $noPublicKeyInstallArgs $NoPublicKeyLog - if (-not (Select-String -LiteralPath $NoPublicKeyLog -Pattern "signature verification requires ROCM_CLI_SIGNING_PUBLIC_KEY_PATH" -Quiet)) { - Fail "installer did not report missing public key for required signature" + Invoke-ExpectFailure "acceptance: untrusted-key signature install" $psExe $noPublicKeyInstallArgs $NoPublicKeyLog + if (-not (Select-String -LiteralPath $NoPublicKeyLog -Pattern "signature verification failed" -Quiet)) { + Fail "installer did not reject a signature from an untrusted key" } Assert-Missing (Join-Path $noPublicKeyInstallDir "rocm.exe") Assert-Missing (Join-Path $noPublicKeyInstallDir ".rocm-cli-manifest") diff --git a/scripts/acceptance-install-upgrade-tui-uninstall.sh b/scripts/acceptance-install-upgrade-tui-uninstall.sh index e83631d6..27ec7d4f 100755 --- a/scripts/acceptance-install-upgrade-tui-uninstall.sh +++ b/scripts/acceptance-install-upgrade-tui-uninstall.sh @@ -188,14 +188,18 @@ grep -q "signature verified" "${PEM_INSTALL_LOG}" \ assert_file "${PEM_INSTALL_DIR}/rocm" assert_file "${PEM_INSTALL_DIR}/.rocm-cli-manifest" -echo "acceptance: reject required signature without public key" +# With a production release key pinned in install.sh, a release install with no +# public key supplied falls back to that pinned trust root. The bundle here is +# signed with the acceptance test key, not the pinned key, so verification must +# fail — proving the default-on pinned path rejects an untrusted signer. +echo "acceptance: reject signature from an untrusted key" NO_PUBLIC_KEY_INSTALL_DIR="${TMP_ROOT}/no-public-key-install/bin" expect_failure \ - "acceptance: required signature no public key install" \ + "acceptance: untrusted-key signature install" \ "${NO_PUBLIC_KEY_LOG}" \ run_installer_without_public_key "${DOWNLOAD_BASE}" "${NO_PUBLIC_KEY_INSTALL_DIR}" -grep -q "signature verification requires ROCM_CLI_SIGNING_PUBLIC_KEY_PATH or ROCM_CLI_SIGNING_PUBLIC_KEY_PEM" "${NO_PUBLIC_KEY_LOG}" \ - || fail "installer did not report missing public key for required signature" +grep -q "signature verification failed" "${NO_PUBLIC_KEY_LOG}" \ + || fail "installer did not reject a signature from an untrusted key" assert_missing "${NO_PUBLIC_KEY_INSTALL_DIR}/rocm" assert_missing "${NO_PUBLIC_KEY_INSTALL_DIR}/.rocm-cli-manifest" diff --git a/scripts/release_readiness.py b/scripts/release_readiness.py index 94a03c2f..66e7b294 100644 --- a/scripts/release_readiness.py +++ b/scripts/release_readiness.py @@ -17,6 +17,7 @@ import subprocess import sys import tarfile +import tempfile import zipfile from pathlib import Path @@ -463,6 +464,33 @@ def require_existing_env_path(name: str) -> Path: return path +def resolve_release_public_key() -> Path: + """Materialize the release signing public key from ROCM_CLI_SIGNING_PUBLIC_KEY_PEM + into a temp file so archives can be verified against it. Raises if it is not set. + + Verifying against this key proves the artifact CI is about to publish was signed + by the private half of the key that installers will pin as a trust root, instead + of merely confirming a key input exists — a mismatched pair would otherwise pass + the gate and ship releases that installers reject once default-on verification + lands. The env value MUST be the same public key pinned in install.sh/.ps1. + + Only the inline PEM form is accepted (that is what release/nightly CI wire from + the signing-key secret); to verify against a key *file* on disk, pass its path + with the --public-key flag instead. + """ + pem = env_text("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") + if pem is None: + raise ReadinessError( + "production trust requires the release signing public key: set " + "ROCM_CLI_SIGNING_PUBLIC_KEY_PEM (or pass --public-key)" + ) + with tempfile.NamedTemporaryFile( + "w", suffix=".pem", delete=False, encoding="ascii" + ) as handle: + handle.write(pem if pem.endswith("\n") else f"{pem}\n") + return Path(handle.name) + + def validate_production_trust() -> list[str]: """Validate only explicit owner-provided production trust inputs.""" @@ -841,6 +869,29 @@ def run_self_test(root: Path) -> None: ) print("release readiness self-test: valid production trust inputs accepted") + # resolve_release_public_key(): the PEM env input is materialized to a temp + # file, and a missing input is an error. This is the key CI verifies release + # archives against under production trust. + pem_text = "-----BEGIN PUBLIC KEY-----\nself-test\n-----END PUBLIC KEY-----" + resolved_pem = run_with_env( + {"ROCM_CLI_SIGNING_PUBLIC_KEY_PEM": pem_text}, + resolve_release_public_key, + ) + if resolved_pem.read_text(encoding="ascii") != f"{pem_text}\n": + raise ReadinessError( + "resolve_release_public_key did not materialize the PEM input" + ) + resolved_pem.unlink() + print("release readiness self-test: release public key resolution ok") + + expect_failure( + "production trust without a release public key", + lambda: run_with_env( + {"ROCM_CLI_SIGNING_PUBLIC_KEY_PEM": None}, + resolve_release_public_key, + ), + ) + expect_failure( "missing explicit asset", lambda: validate_release( @@ -954,12 +1005,24 @@ def main() -> None: require_production_trust = args.require_production_trust or truthy( os.environ.get("ROCM_CLI_REQUIRE_PRODUCTION_TRUST") ) + # Verify archives against the release public key whenever one is configured, + # not just check that a key input exists — so a private/public key mismatch + # fails here instead of at users' installers. Mandatory under production + # trust; otherwise opportunistic: if a signing public key is present in the + # environment (release/nightly wire it from the secret), verify against it. + # An explicit --public-key still wins; with neither, behavior is unchanged. + public_key = args.public_key try: + if public_key is None and ( + require_production_trust + or env_text("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") is not None + ): + public_key = resolve_release_public_key() messages = validate_release( Path(args.dist), assets=args.asset, require_signatures=require_signatures, - public_key=args.public_key, + public_key=public_key, require_production_trust=require_production_trust, require_rocm_asset_names=args.require_rocm_asset_names, require_exact_assets=args.require_exact_assets, diff --git a/scripts/verify_pinned_keys.py b/scripts/verify_pinned_keys.py new file mode 100755 index 00000000..600220d6 --- /dev/null +++ b/scripts/verify_pinned_keys.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +# Copyright © Advanced Micro Devices, Inc., or its affiliates. +# +# SPDX-License-Identifier: MIT + +"""Assert the release/metadata public keys pinned in the installers and rocm-core +match the canonical published keys under ``docs/keys/``. + +This closes the gap where CI verifies release artifacts against the public key +configured in its environment (see ``release_readiness.py``) while installers and +the binary trust a *separately embedded* copy. If those diverge, CI can bless a +release that installers then reject once default-on verification lands. This check +enforces a single source of truth: the committed ``docs/keys/*.pem`` files. + +Design notes: + +- **Dormant by default.** Until a canonical ``docs/keys/`` file exists and is + non-empty, its key is skipped. So this passes as a no-op today (empty pinned + sentinels, no canonical files) and only starts enforcing once the owner ceremony + publishes real keys. +- **Embedding-agnostic comparison.** The pinned key lives in three different + syntaxes (shell double-quoted string, PowerShell string/here-string, Rust string + literal). Rather than parse each, we reduce both the canonical key and the source + file to their base64 alphabet only (``[A-Za-z0-9+/=]``) and check containment. + A 2048-bit SPKI body is ~360 base64 chars — distinctive enough that this is a + reliable equality proxy immune to quoting, ``\\n`` escapes, line continuations, + and here-strings. +- **CI cross-check.** When ``ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/PEM`` is set (as in + release CI), it must equal the canonical *current* release key, so the key CI + verifies against is exactly the one users pin. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import shutil +import sys +import tempfile +from pathlib import Path + +PEM_BEGIN = "-----BEGIN PUBLIC KEY-----" +PEM_END = "-----END PUBLIC KEY-----" + +# canonical published key -> source files that must embed the identical bytes. +PINNED_KEYS = ( + { + "name": "release-current", + "canonical": "docs/keys/rocm-cli-release-current-public.pem", + "sources": ("install.sh", "install.ps1"), + }, + { + "name": "release-next", + "canonical": "docs/keys/rocm-cli-release-next-public.pem", + "sources": ("install.sh", "install.ps1"), + }, + { + "name": "metadata", + "canonical": "docs/keys/rocm-cli-metadata-public.pem", + "sources": ("apps/rocm/src/therock.rs",), + }, +) + +# The canonical key that CI signs and verifies against under production trust. +CURRENT_RELEASE_KEY = "release-current" + + +class ConsistencyError(Exception): + """A pinned key does not match its canonical source of truth.""" + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def base64_only(text: str) -> str: + """Reduce arbitrary text to its base64 alphabet, dropping everything else. + + Newline escape sequences are removed first: a literal ``\\n`` (shell/Rust) or + ``\\`n`` (PowerShell) would otherwise leave a stray ``n``/``r``/``t`` — all + base64-alphabet letters — and corrupt the payload. Real newlines are plain + whitespace and drop out with everything else non-base64. + """ + without_escapes = re.sub(r"[\\`][nrt]", "", text) + return re.sub(r"[^A-Za-z0-9+/=]", "", without_escapes) + + +def pem_body(text: str) -> str | None: + """Return the whitespace-stripped base64 body of a PUBLIC KEY PEM, or None. + + Uses plain string search (not a regex) so it is linear-time regardless of + input — no catastrophic/polynomial backtracking on adversarial content. + """ + begin = text.find(PEM_BEGIN) + if begin == -1: + return None + start = begin + len(PEM_BEGIN) + end = text.find(PEM_END, start) + if end == -1: + return None + body = base64_only(text[start:end]) + return body or None + + +def fingerprint(pem_text: str) -> str: + """SHA-256 over the normalized (LF, no trailing blank lines) PEM bytes.""" + normalized = "\n".join( + line.rstrip() for line in pem_text.replace("\r\n", "\n").split("\n") + ).strip() + return hashlib.sha256((normalized + "\n").encode("utf-8")).hexdigest() + + +def check_pinned_keys(root: Path) -> list[str]: + """Verify every populated canonical key is embedded verbatim in its sources. + + Returns human-readable status messages. Raises ConsistencyError on a mismatch. + """ + messages: list[str] = [] + current_release_body: str | None = None + + for entry in PINNED_KEYS: + name = entry["name"] + canonical_path = root / entry["canonical"] + if ( + not canonical_path.is_file() + or not canonical_path.read_text(encoding="utf-8").strip() + ): + messages.append(f"{name}: no canonical key yet — skipped (dormant)") + continue + + canonical_text = canonical_path.read_text(encoding="utf-8") + canonical_body = pem_body(canonical_text) + if canonical_body is None: + raise ConsistencyError( + f"{name}: {entry['canonical']} is not a valid PUBLIC KEY PEM" + ) + if name == CURRENT_RELEASE_KEY: + current_release_body = canonical_body + + for source in entry["sources"]: + source_path = root / source + if not source_path.is_file(): + raise ConsistencyError(f"{name}: source file is missing: {source}") + if canonical_body not in base64_only( + source_path.read_text(encoding="utf-8") + ): + raise ConsistencyError( + f"{name}: {source} does not embed the canonical key " + f"{entry['canonical']} — the pinned constant and the published " + f"key have diverged" + ) + messages.append( + f"{name}: pinned in {', '.join(entry['sources'])} " + f"(sha256 {fingerprint(canonical_text)})" + ) + + messages.extend(check_ci_public_key(current_release_body)) + return messages + + +def check_ci_public_key(current_release_body: str | None) -> list[str]: + """When a CI signing public key is configured, it must equal the canonical + current release key — the key CI verifies release artifacts against. + + Only the inline PEM form (ROCM_CLI_SIGNING_PUBLIC_KEY_PEM) is checked, which is + what release/nightly CI wire from the signing-key secret.""" + env_pem = os.environ.get("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM", "").strip() + if not env_pem: + return [] + + env_body = pem_body(env_pem) + if env_body is None: + raise ConsistencyError( + "the configured CI signing public key is not a valid PUBLIC KEY PEM" + ) + + if current_release_body is None: + return [ + "ci public key: configured, but no canonical current release key to " + "compare against yet — skipped" + ] + if env_body != current_release_body: + raise ConsistencyError( + "the CI signing public key does not match the canonical current " + f"release key ({PINNED_KEYS[0]['canonical']}); CI would verify against " + "a different key than installers pin" + ) + return ["ci public key: matches the canonical current release key"] + + +def fail(message: str) -> None: + print(f"pinned key check: {message}", file=sys.stderr) + sys.exit(1) + + +def run_self_test() -> None: + sample = ( + "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAselftestselftest+/\n" + "abcDEF0123456789selftestselftestselftestselftestselftestABCD==\n" + "-----END PUBLIC KEY-----\n" + ) + other = sample.replace("selftest", "different") + work = Path(tempfile.mkdtemp(prefix="pinned-key-self-test-")) + try: + keys_dir = work / "docs" / "keys" + keys_dir.mkdir(parents=True) + (work / "apps" / "rocm" / "src").mkdir(parents=True) + + # Dormant: no canonical files -> passes as a no-op. + (work / "install.sh").write_text('PINNED=""\n', encoding="utf-8") + (work / "install.ps1").write_text('$Pinned = ""\n', encoding="utf-8") + (work / "apps" / "rocm" / "src" / "therock.rs").write_text( + 'const PINNED: &str = "";\n', encoding="utf-8" + ) + check_pinned_keys(work) + print("pinned key self-test: dormant (no canonical keys) accepted") + + # Populated and matching (embedded with LF, CRLF, and \n escapes). + (keys_dir / "rocm-cli-metadata-public.pem").write_text(sample, encoding="utf-8") + escaped = sample.replace("\n", "\\n") + (work / "apps" / "rocm" / "src" / "therock.rs").write_text( + f'const PINNED: &str = "{escaped}";\n', encoding="utf-8" + ) + check_pinned_keys(work) + print("pinned key self-test: matching embedded key (\\n-escaped) accepted") + + # Divergent: source embeds a different key -> rejected. + (work / "apps" / "rocm" / "src" / "therock.rs").write_text( + f'const PINNED: &str = "{other.replace(chr(10), chr(92) + "n")}";\n', + encoding="utf-8", + ) + try: + check_pinned_keys(work) + except ConsistencyError: + print("pinned key self-test: divergent embedded key rejected as expected") + else: + raise ConsistencyError("divergent embedded key unexpectedly passed") + + # CI public-key cross-check against a mismatched key -> rejected. + (keys_dir / "rocm-cli-release-current-public.pem").write_text( + sample, encoding="utf-8" + ) + (work / "install.sh").write_text(f'PINNED="{sample}"\n', encoding="utf-8") + (work / "install.ps1").write_text( + f'$Pinned = @"\n{sample}\n"@\n', encoding="utf-8" + ) + (work / "apps" / "rocm" / "src" / "therock.rs").write_text( + f'const PINNED: &str = "{escaped}";\n', encoding="utf-8" + ) + saved = os.environ.get("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") + os.environ["ROCM_CLI_SIGNING_PUBLIC_KEY_PEM"] = other + try: + check_pinned_keys(work) + except ConsistencyError: + print("pinned key self-test: mismatched CI public key rejected as expected") + else: + raise ConsistencyError("mismatched CI public key unexpectedly passed") + finally: + if saved is None: + os.environ.pop("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM", None) + else: + os.environ["ROCM_CLI_SIGNING_PUBLIC_KEY_PEM"] = saved + + print("pinned key self-test: ok") + finally: + shutil.rmtree(work, ignore_errors=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--self-test", + action="store_true", + help="run built-in fixtures instead of checking the repository", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + try: + if args.self_test: + run_self_test() + return + messages = check_pinned_keys(repo_root()) + except ConsistencyError as error: + fail(str(error)) + for message in messages: + print(f"pinned key check: {message}") + + +if __name__ == "__main__": + main() From 7e70e1718dc91f4af272d9b69bbf22c240c0918f Mon Sep 17 00:00:00 2001 From: Eugene Volen Date: Wed, 15 Jul 2026 11:16:08 +0000 Subject: [PATCH 2/2] Port pinned-key check to cargo xtask and fix consistency bugs Replace the Python pinned-key consistency check with a Rust `cargo xtask verify-pinned-keys` command, keeping the repo all-Rust. The port fixes an unsound equality proxy: the old check used whole-file substring containment, so a wrong pinned constant passed whenever the canonical key body appeared anywhere else in the file (e.g. the NEXT rotation slot). The command now extracts each named pinned constant's own PEM body (shell, PowerShell, and Rust embeddings) and compares it for equality against the canonical docs/keys file, plus the CI signing key cross-check. Close the CI gate hole by adding docs/keys/** to the heavy paths filter so a canonical-key-only change still runs the check, and repoint every CI call site to the xtask command. Teach `cargo xtask verify` to read the public key from ROCM_CLI_SIGNING_PUBLIC_KEY_PEM when no --public-key is given, so release_readiness.py no longer materializes a temp key file. Also quote the openssl path arguments in install.ps1 and drop a stale doc reference from the workflow comments. Signed-off-by: Eugene Volen --- .github/workflows/ci.yml | 16 +- .github/workflows/nightly.yml | 13 +- .github/workflows/release.yml | 13 +- install.ps1 | 4 +- scripts/release_readiness.py | 91 +++---- scripts/verify_pinned_keys.py | 296 ----------------------- xtask/src/main.rs | 14 +- xtask/src/signing.rs | 25 +- xtask/src/verify_pinned_keys.rs | 410 ++++++++++++++++++++++++++++++++ 9 files changed, 500 insertions(+), 382 deletions(-) delete mode 100755 scripts/verify_pinned_keys.py create mode 100644 xtask/src/verify_pinned_keys.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c307a19..224de14f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,10 @@ jobs: - '**/*.sh' - '**/*.ps1' - 'install*' + # Pinned-key consistency compares docs/keys/* against the installers, + # so a canonical-key-only change must trigger the heavy job that runs + # `cargo xtask verify-pinned-keys`. + - 'docs/keys/**' - '.github/workflows/**' # THIRD_PARTY_NOTICES.txt staleness gate: anything that changes the # dependency tree, the cargo-about config/template, the generator, or @@ -168,11 +172,9 @@ jobs: if: needs.changes.outputs.heavy == 'true' run: python scripts/release_readiness.py --self-test - - name: Pinned key consistency (self-test + repo check) + - name: Pinned key consistency check if: needs.changes.outputs.heavy == 'true' - run: | - python scripts/verify_pinned_keys.py --self-test - python scripts/verify_pinned_keys.py + run: cargo xtask verify-pinned-keys - name: Acceptance install lifecycle if: needs.changes.outputs.heavy == 'true' @@ -293,12 +295,10 @@ jobs: shell: pwsh run: python .\scripts\release_readiness.py --self-test - - name: Pinned key consistency (self-test + repo check) + - name: Pinned key consistency check if: needs.changes.outputs.heavy == 'true' shell: pwsh - run: | - python .\scripts\verify_pinned_keys.py --self-test - python .\scripts\verify_pinned_keys.py + run: cargo xtask verify-pinned-keys - name: Check PowerShell installer syntax if: needs.changes.outputs.heavy == 'true' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f31ad701..340eca71 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,8 +16,7 @@ env: # validate_production_trust() and behavior is unchanged. The values are public # keys and paths (not the private signing key, which stays step-scoped). When # enabling the gate, the recipe-index inputs also require a step that - # materializes the hosted index + its .sig into the workspace. See - # plans/release-production-trust-rollout.md. + # materializes the hosted index + its .sig into the workspace. ROCM_CLI_REQUIRE_PRODUCTION_TRUST: ${{ vars.ROCM_CLI_REQUIRE_PRODUCTION_TRUST }} ROCM_CLI_SIGNING_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_SIGNING_PUBLIC_KEY_PEM }} ROCM_CLI_METADATA_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_METADATA_PUBLIC_KEY_PEM }} @@ -36,8 +35,10 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Verify pinned keys match the signing key - run: python scripts/verify_pinned_keys.py + run: cargo xtask verify-pinned-keys - name: Check for changes since last nightly id: check @@ -196,11 +197,11 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Verify pinned keys match the signing key shell: pwsh - run: python .\scripts\verify_pinned_keys.py - - - uses: actions-rust-lang/setup-rust-toolchain@v1 + run: cargo xtask verify-pinned-keys - name: Build release binaries shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3882e65e..fea72f98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,7 @@ env: # validate_production_trust() and behavior is unchanged. The values are public # keys and paths (not the private signing key, which stays step-scoped). When # enabling the gate, the recipe-index inputs also require a step that - # materializes the hosted index + its .sig into the workspace. See - # plans/release-production-trust-rollout.md. + # materializes the hosted index + its .sig into the workspace. ROCM_CLI_REQUIRE_PRODUCTION_TRUST: ${{ vars.ROCM_CLI_REQUIRE_PRODUCTION_TRUST }} ROCM_CLI_SIGNING_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_SIGNING_PUBLIC_KEY_PEM }} ROCM_CLI_METADATA_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_METADATA_PUBLIC_KEY_PEM }} @@ -37,8 +36,10 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Verify pinned keys match the signing key - run: python scripts/verify_pinned_keys.py + run: cargo xtask verify-pinned-keys - name: Determine version id: version @@ -180,11 +181,11 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Verify pinned keys match the signing key shell: pwsh - run: python .\scripts\verify_pinned_keys.py - - - uses: actions-rust-lang/setup-rust-toolchain@v1 + run: cargo xtask verify-pinned-keys - name: Build release binaries shell: pwsh diff --git a/install.ps1 b/install.ps1 index 42dd4db5..239c6391 100644 --- a/install.ps1 +++ b/install.ps1 @@ -180,7 +180,9 @@ function Confirm-ArchiveSignature { Confirm-Command openssl foreach ($key in $PublicKeyPaths) { - & openssl dgst -sha256 -verify $key -signature $SignaturePath $ArchivePath | Out-Null + # Quote every path argument so keys or archives under a directory with + # spaces are passed to native openssl as single arguments. + & openssl dgst -sha256 -verify "$key" -signature "$SignaturePath" "$ArchivePath" | Out-Null if ($LASTEXITCODE -eq 0) { return } diff --git a/scripts/release_readiness.py b/scripts/release_readiness.py index 66e7b294..1692639a 100644 --- a/scripts/release_readiness.py +++ b/scripts/release_readiness.py @@ -17,7 +17,6 @@ import subprocess import sys import tarfile -import tempfile import zipfile from pathlib import Path @@ -334,12 +333,22 @@ def validate_archive_contents(archive: Path) -> list[str]: raise ReadinessError(f"unsupported archive extension: {archive.name}") -def verify_signature(archive: Path, signature: Path, public_key: Path) -> None: - if not public_key.is_file(): +def verify_signature(archive: Path, signature: Path, public_key: Path | None) -> None: + """Verify ``archive`` against ``public_key`` via ``cargo xtask verify``. + + When ``public_key`` is ``None`` the ``--public-key`` flag is omitted and + ``cargo xtask verify`` reads the key from ``ROCM_CLI_SIGNING_PUBLIC_KEY_PEM`` + (the inline PEM release/nightly CI wire from the signing-key secret), so no + temporary key file has to be materialized here. + """ + if public_key is not None and not public_key.is_file(): raise ReadinessError(f"public key does not exist: {public_key}") cargo = shutil.which("cargo") if cargo is None: raise ReadinessError("cargo is required for signature verification") + key_args = ( + ["--public-key", str(public_key.resolve())] if public_key is not None else [] + ) # Resolve to absolute paths because the subprocess runs from the repo root # (so the `cargo xtask` alias resolves); relative paths would otherwise be # interpreted against the repo root rather than the caller's working directory. @@ -348,8 +357,7 @@ def verify_signature(archive: Path, signature: Path, public_key: Path) -> None: cargo, "xtask", "verify", - "--public-key", - str(public_key.resolve()), + *key_args, "--in", str(archive.resolve()), "--signature", @@ -374,6 +382,7 @@ def validate_archive( *, require_signatures: bool, public_key: Path | None, + verify_with_env_key: bool = False, require_rocm_asset_names: bool, ) -> list[str]: messages: list[str] = [] @@ -400,15 +409,16 @@ def validate_archive( ) messages.append(f"checksum ok: {archive.name}") + verify = public_key is not None or verify_with_env_key signature = Path(f"{archive}.sig") - if require_signatures or public_key is not None: + if require_signatures or verify: if not signature.is_file(): raise ReadinessError(f"missing signature sidecar: {signature}") if signature.stat().st_size <= 0: raise ReadinessError(f"signature sidecar is empty: {signature}") messages.append(f"signature present: {archive.name}.sig") - if public_key is not None: + if verify: verify_signature(archive, signature, public_key) messages.append(f"signature verified: {archive.name}") @@ -464,33 +474,6 @@ def require_existing_env_path(name: str) -> Path: return path -def resolve_release_public_key() -> Path: - """Materialize the release signing public key from ROCM_CLI_SIGNING_PUBLIC_KEY_PEM - into a temp file so archives can be verified against it. Raises if it is not set. - - Verifying against this key proves the artifact CI is about to publish was signed - by the private half of the key that installers will pin as a trust root, instead - of merely confirming a key input exists — a mismatched pair would otherwise pass - the gate and ship releases that installers reject once default-on verification - lands. The env value MUST be the same public key pinned in install.sh/.ps1. - - Only the inline PEM form is accepted (that is what release/nightly CI wire from - the signing-key secret); to verify against a key *file* on disk, pass its path - with the --public-key flag instead. - """ - pem = env_text("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") - if pem is None: - raise ReadinessError( - "production trust requires the release signing public key: set " - "ROCM_CLI_SIGNING_PUBLIC_KEY_PEM (or pass --public-key)" - ) - with tempfile.NamedTemporaryFile( - "w", suffix=".pem", delete=False, encoding="ascii" - ) as handle: - handle.write(pem if pem.endswith("\n") else f"{pem}\n") - return Path(handle.name) - - def validate_production_trust() -> list[str]: """Validate only explicit owner-provided production trust inputs.""" @@ -539,11 +522,13 @@ def validate_release( assets: list[str], require_signatures: bool, public_key: Path | None, + verify_with_env_key: bool = False, require_production_trust: bool, require_rocm_asset_names: bool, require_exact_assets: bool, ) -> list[str]: archives = discover_archives(dist, assets) + verify = public_key is not None or verify_with_env_key messages: list[str] = [] if require_exact_assets: if not assets: @@ -552,7 +537,7 @@ def validate_release( validate_exact_dist_assets( dist, archives, - expect_signatures=require_signatures or public_key is not None, + expect_signatures=require_signatures or verify, ) ) for archive in archives: @@ -561,6 +546,7 @@ def validate_release( archive, require_signatures=require_signatures, public_key=public_key, + verify_with_env_key=verify_with_env_key, require_rocm_asset_names=require_rocm_asset_names, ) ) @@ -869,29 +855,6 @@ def run_self_test(root: Path) -> None: ) print("release readiness self-test: valid production trust inputs accepted") - # resolve_release_public_key(): the PEM env input is materialized to a temp - # file, and a missing input is an error. This is the key CI verifies release - # archives against under production trust. - pem_text = "-----BEGIN PUBLIC KEY-----\nself-test\n-----END PUBLIC KEY-----" - resolved_pem = run_with_env( - {"ROCM_CLI_SIGNING_PUBLIC_KEY_PEM": pem_text}, - resolve_release_public_key, - ) - if resolved_pem.read_text(encoding="ascii") != f"{pem_text}\n": - raise ReadinessError( - "resolve_release_public_key did not materialize the PEM input" - ) - resolved_pem.unlink() - print("release readiness self-test: release public key resolution ok") - - expect_failure( - "production trust without a release public key", - lambda: run_with_env( - {"ROCM_CLI_SIGNING_PUBLIC_KEY_PEM": None}, - resolve_release_public_key, - ), - ) - expect_failure( "missing explicit asset", lambda: validate_release( @@ -1011,18 +974,28 @@ def main() -> None: # trust; otherwise opportunistic: if a signing public key is present in the # environment (release/nightly wire it from the secret), verify against it. # An explicit --public-key still wins; with neither, behavior is unchanged. + # + # When relying on the environment key, `cargo xtask verify` reads it directly + # from ROCM_CLI_SIGNING_PUBLIC_KEY_PEM, so no temp key file is materialized here. public_key = args.public_key + verify_with_env_key = False try: if public_key is None and ( require_production_trust or env_text("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") is not None ): - public_key = resolve_release_public_key() + if env_text("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") is None: + raise ReadinessError( + "production trust requires the release signing public key: set " + "ROCM_CLI_SIGNING_PUBLIC_KEY_PEM (or pass --public-key)" + ) + verify_with_env_key = True messages = validate_release( Path(args.dist), assets=args.asset, require_signatures=require_signatures, public_key=public_key, + verify_with_env_key=verify_with_env_key, require_production_trust=require_production_trust, require_rocm_asset_names=args.require_rocm_asset_names, require_exact_assets=args.require_exact_assets, diff --git a/scripts/verify_pinned_keys.py b/scripts/verify_pinned_keys.py deleted file mode 100755 index 600220d6..00000000 --- a/scripts/verify_pinned_keys.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -# Copyright © Advanced Micro Devices, Inc., or its affiliates. -# -# SPDX-License-Identifier: MIT - -"""Assert the release/metadata public keys pinned in the installers and rocm-core -match the canonical published keys under ``docs/keys/``. - -This closes the gap where CI verifies release artifacts against the public key -configured in its environment (see ``release_readiness.py``) while installers and -the binary trust a *separately embedded* copy. If those diverge, CI can bless a -release that installers then reject once default-on verification lands. This check -enforces a single source of truth: the committed ``docs/keys/*.pem`` files. - -Design notes: - -- **Dormant by default.** Until a canonical ``docs/keys/`` file exists and is - non-empty, its key is skipped. So this passes as a no-op today (empty pinned - sentinels, no canonical files) and only starts enforcing once the owner ceremony - publishes real keys. -- **Embedding-agnostic comparison.** The pinned key lives in three different - syntaxes (shell double-quoted string, PowerShell string/here-string, Rust string - literal). Rather than parse each, we reduce both the canonical key and the source - file to their base64 alphabet only (``[A-Za-z0-9+/=]``) and check containment. - A 2048-bit SPKI body is ~360 base64 chars — distinctive enough that this is a - reliable equality proxy immune to quoting, ``\\n`` escapes, line continuations, - and here-strings. -- **CI cross-check.** When ``ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/PEM`` is set (as in - release CI), it must equal the canonical *current* release key, so the key CI - verifies against is exactly the one users pin. -""" - -from __future__ import annotations - -import argparse -import hashlib -import os -import re -import shutil -import sys -import tempfile -from pathlib import Path - -PEM_BEGIN = "-----BEGIN PUBLIC KEY-----" -PEM_END = "-----END PUBLIC KEY-----" - -# canonical published key -> source files that must embed the identical bytes. -PINNED_KEYS = ( - { - "name": "release-current", - "canonical": "docs/keys/rocm-cli-release-current-public.pem", - "sources": ("install.sh", "install.ps1"), - }, - { - "name": "release-next", - "canonical": "docs/keys/rocm-cli-release-next-public.pem", - "sources": ("install.sh", "install.ps1"), - }, - { - "name": "metadata", - "canonical": "docs/keys/rocm-cli-metadata-public.pem", - "sources": ("apps/rocm/src/therock.rs",), - }, -) - -# The canonical key that CI signs and verifies against under production trust. -CURRENT_RELEASE_KEY = "release-current" - - -class ConsistencyError(Exception): - """A pinned key does not match its canonical source of truth.""" - - -def repo_root() -> Path: - return Path(__file__).resolve().parent.parent - - -def base64_only(text: str) -> str: - """Reduce arbitrary text to its base64 alphabet, dropping everything else. - - Newline escape sequences are removed first: a literal ``\\n`` (shell/Rust) or - ``\\`n`` (PowerShell) would otherwise leave a stray ``n``/``r``/``t`` — all - base64-alphabet letters — and corrupt the payload. Real newlines are plain - whitespace and drop out with everything else non-base64. - """ - without_escapes = re.sub(r"[\\`][nrt]", "", text) - return re.sub(r"[^A-Za-z0-9+/=]", "", without_escapes) - - -def pem_body(text: str) -> str | None: - """Return the whitespace-stripped base64 body of a PUBLIC KEY PEM, or None. - - Uses plain string search (not a regex) so it is linear-time regardless of - input — no catastrophic/polynomial backtracking on adversarial content. - """ - begin = text.find(PEM_BEGIN) - if begin == -1: - return None - start = begin + len(PEM_BEGIN) - end = text.find(PEM_END, start) - if end == -1: - return None - body = base64_only(text[start:end]) - return body or None - - -def fingerprint(pem_text: str) -> str: - """SHA-256 over the normalized (LF, no trailing blank lines) PEM bytes.""" - normalized = "\n".join( - line.rstrip() for line in pem_text.replace("\r\n", "\n").split("\n") - ).strip() - return hashlib.sha256((normalized + "\n").encode("utf-8")).hexdigest() - - -def check_pinned_keys(root: Path) -> list[str]: - """Verify every populated canonical key is embedded verbatim in its sources. - - Returns human-readable status messages. Raises ConsistencyError on a mismatch. - """ - messages: list[str] = [] - current_release_body: str | None = None - - for entry in PINNED_KEYS: - name = entry["name"] - canonical_path = root / entry["canonical"] - if ( - not canonical_path.is_file() - or not canonical_path.read_text(encoding="utf-8").strip() - ): - messages.append(f"{name}: no canonical key yet — skipped (dormant)") - continue - - canonical_text = canonical_path.read_text(encoding="utf-8") - canonical_body = pem_body(canonical_text) - if canonical_body is None: - raise ConsistencyError( - f"{name}: {entry['canonical']} is not a valid PUBLIC KEY PEM" - ) - if name == CURRENT_RELEASE_KEY: - current_release_body = canonical_body - - for source in entry["sources"]: - source_path = root / source - if not source_path.is_file(): - raise ConsistencyError(f"{name}: source file is missing: {source}") - if canonical_body not in base64_only( - source_path.read_text(encoding="utf-8") - ): - raise ConsistencyError( - f"{name}: {source} does not embed the canonical key " - f"{entry['canonical']} — the pinned constant and the published " - f"key have diverged" - ) - messages.append( - f"{name}: pinned in {', '.join(entry['sources'])} " - f"(sha256 {fingerprint(canonical_text)})" - ) - - messages.extend(check_ci_public_key(current_release_body)) - return messages - - -def check_ci_public_key(current_release_body: str | None) -> list[str]: - """When a CI signing public key is configured, it must equal the canonical - current release key — the key CI verifies release artifacts against. - - Only the inline PEM form (ROCM_CLI_SIGNING_PUBLIC_KEY_PEM) is checked, which is - what release/nightly CI wire from the signing-key secret.""" - env_pem = os.environ.get("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM", "").strip() - if not env_pem: - return [] - - env_body = pem_body(env_pem) - if env_body is None: - raise ConsistencyError( - "the configured CI signing public key is not a valid PUBLIC KEY PEM" - ) - - if current_release_body is None: - return [ - "ci public key: configured, but no canonical current release key to " - "compare against yet — skipped" - ] - if env_body != current_release_body: - raise ConsistencyError( - "the CI signing public key does not match the canonical current " - f"release key ({PINNED_KEYS[0]['canonical']}); CI would verify against " - "a different key than installers pin" - ) - return ["ci public key: matches the canonical current release key"] - - -def fail(message: str) -> None: - print(f"pinned key check: {message}", file=sys.stderr) - sys.exit(1) - - -def run_self_test() -> None: - sample = ( - "-----BEGIN PUBLIC KEY-----\n" - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAselftestselftest+/\n" - "abcDEF0123456789selftestselftestselftestselftestselftestABCD==\n" - "-----END PUBLIC KEY-----\n" - ) - other = sample.replace("selftest", "different") - work = Path(tempfile.mkdtemp(prefix="pinned-key-self-test-")) - try: - keys_dir = work / "docs" / "keys" - keys_dir.mkdir(parents=True) - (work / "apps" / "rocm" / "src").mkdir(parents=True) - - # Dormant: no canonical files -> passes as a no-op. - (work / "install.sh").write_text('PINNED=""\n', encoding="utf-8") - (work / "install.ps1").write_text('$Pinned = ""\n', encoding="utf-8") - (work / "apps" / "rocm" / "src" / "therock.rs").write_text( - 'const PINNED: &str = "";\n', encoding="utf-8" - ) - check_pinned_keys(work) - print("pinned key self-test: dormant (no canonical keys) accepted") - - # Populated and matching (embedded with LF, CRLF, and \n escapes). - (keys_dir / "rocm-cli-metadata-public.pem").write_text(sample, encoding="utf-8") - escaped = sample.replace("\n", "\\n") - (work / "apps" / "rocm" / "src" / "therock.rs").write_text( - f'const PINNED: &str = "{escaped}";\n', encoding="utf-8" - ) - check_pinned_keys(work) - print("pinned key self-test: matching embedded key (\\n-escaped) accepted") - - # Divergent: source embeds a different key -> rejected. - (work / "apps" / "rocm" / "src" / "therock.rs").write_text( - f'const PINNED: &str = "{other.replace(chr(10), chr(92) + "n")}";\n', - encoding="utf-8", - ) - try: - check_pinned_keys(work) - except ConsistencyError: - print("pinned key self-test: divergent embedded key rejected as expected") - else: - raise ConsistencyError("divergent embedded key unexpectedly passed") - - # CI public-key cross-check against a mismatched key -> rejected. - (keys_dir / "rocm-cli-release-current-public.pem").write_text( - sample, encoding="utf-8" - ) - (work / "install.sh").write_text(f'PINNED="{sample}"\n', encoding="utf-8") - (work / "install.ps1").write_text( - f'$Pinned = @"\n{sample}\n"@\n', encoding="utf-8" - ) - (work / "apps" / "rocm" / "src" / "therock.rs").write_text( - f'const PINNED: &str = "{escaped}";\n', encoding="utf-8" - ) - saved = os.environ.get("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") - os.environ["ROCM_CLI_SIGNING_PUBLIC_KEY_PEM"] = other - try: - check_pinned_keys(work) - except ConsistencyError: - print("pinned key self-test: mismatched CI public key rejected as expected") - else: - raise ConsistencyError("mismatched CI public key unexpectedly passed") - finally: - if saved is None: - os.environ.pop("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM", None) - else: - os.environ["ROCM_CLI_SIGNING_PUBLIC_KEY_PEM"] = saved - - print("pinned key self-test: ok") - finally: - shutil.rmtree(work, ignore_errors=True) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--self-test", - action="store_true", - help="run built-in fixtures instead of checking the repository", - ) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - try: - if args.self_test: - run_self_test() - return - messages = check_pinned_keys(repo_root()) - except ConsistencyError as error: - fail(str(error)) - for message in messages: - print(f"pinned key check: {message}") - - -if __name__ == "__main__": - main() diff --git a/xtask/src/main.rs b/xtask/src/main.rs index de6500fc..c49c97f5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,6 +16,7 @@ mod powershell; mod signing; mod tpn; mod verify_commits; +mod verify_pinned_keys; use std::path::PathBuf; use std::process::ExitCode; @@ -55,9 +56,10 @@ enum Command { }, /// Verify a file's signature against an RSA public key. Verify { - /// Path to the SubjectPublicKeyInfo public-key PEM. + /// Path to the SubjectPublicKeyInfo public-key PEM. When omitted, the key is + /// read from the `ROCM_CLI_SIGNING_PUBLIC_KEY_PEM` environment variable. #[arg(long)] - public_key: PathBuf, + public_key: Option, /// File whose signature is checked. #[arg(long = "in")] input: PathBuf, @@ -65,6 +67,11 @@ enum Command { #[arg(long)] signature: PathBuf, }, + /// Assert the release/metadata public keys pinned in the installers and + /// `apps/rocm/src/therock.rs` match the canonical published keys under + /// `docs/keys/`, and that any configured CI signing key matches the pinned + /// current release key. A no-op while no canonical keys are published. + VerifyPinnedKeys, /// Print the workspace crates affected by a git range (changed crates plus /// their transitive dependents) as `cargo` package-selection flags, so CI /// can build/test only what a change can reach instead of `--workspace`. @@ -142,7 +149,8 @@ fn run() -> Result<()> { public_key, input, signature, - } => signing::verify(&public_key, &input, &signature)?, + } => signing::verify(public_key.as_deref(), &input, &signature)?, + Command::VerifyPinnedKeys => verify_pinned_keys::run()?, Command::Affected { base } => affected::run(base)?, Command::Manifest { check } => manifest::run(check)?, Command::Tpn { check } => tpn::run(check)?, diff --git a/xtask/src/signing.rs b/xtask/src/signing.rs index ab5554aa..2ca4de65 100644 --- a/xtask/src/signing.rs +++ b/xtask/src/signing.rs @@ -67,9 +67,28 @@ pub fn sign(private_key: &Path, input: &Path, output: &Path) -> Result<()> { } /// Verify `input`'s `signature` against the RSA public key. -pub fn verify(public_key: &Path, input: &Path, signature: &Path) -> Result<()> { - let public_pem = fs::read_to_string(public_key) - .with_context(|| format!("failed to read {}", public_key.display()))?; +/// +/// When `public_key` is `None`, the key is taken from the +/// `ROCM_CLI_SIGNING_PUBLIC_KEY_PEM` environment variable (the inline PEM that +/// release/nightly CI wire from the signing-key secret) — so callers that already +/// have the key in the environment do not have to materialize a temp file. It is an +/// error if neither a path nor the env var is provided. +pub fn verify(public_key: Option<&Path>, input: &Path, signature: &Path) -> Result<()> { + let public_pem = if let Some(path) = public_key { + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))? + } else { + let pem = std::env::var("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM") + .ok() + .filter(|value| !value.trim().is_empty()) + .context( + "no signing public key: pass --public-key or set ROCM_CLI_SIGNING_PUBLIC_KEY_PEM", + )?; + if pem.ends_with('\n') { + pem + } else { + format!("{pem}\n") + } + }; let payload = fs::read(input).with_context(|| format!("failed to read {}", input.display()))?; let signature_bytes = fs::read(signature).with_context(|| format!("failed to read {}", signature.display()))?; diff --git a/xtask/src/verify_pinned_keys.rs b/xtask/src/verify_pinned_keys.rs new file mode 100644 index 00000000..208ce041 --- /dev/null +++ b/xtask/src/verify_pinned_keys.rs @@ -0,0 +1,410 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Assert the release/metadata public keys pinned in the installers and +//! `apps/rocm/src/therock.rs` match the canonical published keys under +//! `docs/keys/`. +//! +//! This closes the gap where CI verifies release artifacts against the public key +//! configured in its environment (see `scripts/release_readiness.py`) while +//! installers and the binary trust a *separately embedded* copy. If those diverge, +//! CI can bless a release that installers then reject once default-on verification +//! lands. This check enforces a single source of truth: the committed +//! `docs/keys/*.pem` files. +//! +//! Design notes: +//! +//! - **Dormant by default.** Until a canonical `docs/keys/` file exists and is +//! non-empty, its key is skipped. So this passes as a no-op today (empty pinned +//! sentinels, no canonical files) and only starts enforcing once real keys are +//! published. +//! - **Per-constant equality.** Each pinned key is embedded in a specific named +//! constant (shell `NAME="…"`, PowerShell string/here-string, Rust `const`). We +//! isolate *that constant's own value span*, reduce it to its base64 body, and +//! compare it for equality to the canonical key. Comparing the whole file (rather +//! than the specific constant) would let a wrong/tampered constant pass whenever +//! the canonical body appears anywhere else in the file — e.g. in the `NEXT` slot +//! mid-rotation or a stale comment. +//! - **CI cross-check.** When `ROCM_CLI_SIGNING_PUBLIC_KEY_PEM` is set (as in +//! release/nightly CI), it must equal the canonical *current* release key, so the +//! key CI verifies against is exactly the one users pin. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +const PEM_BEGIN: &str = "-----BEGIN PUBLIC KEY-----"; +const PEM_END: &str = "-----END PUBLIC KEY-----"; + +const CURRENT_RELEASE_KEY: &str = "release-current"; + +/// How a pinned constant embeds its PEM string, so its value span can be isolated. +#[derive(Clone, Copy)] +enum Embedding { + /// Shell double-quoted assignment: `NAME="…"` (value runs to the next `"`). + Shell, + /// Rust string const: `const NAME: &str = "…";` (value runs to the next `"`). + Rust, + /// PowerShell string or here-string: `$Name = "…"` or `$Name = @"…"@`. + PowerShell, +} + +/// A source file plus the specific constant within it that must embed the key. +struct Source { + path: &'static str, + /// Identifier that holds the pinned PEM (includes the leading `$` for PowerShell). + token: &'static str, + embedding: Embedding, +} + +/// A canonical published key and every source that must embed the identical bytes. +struct PinnedKey { + name: &'static str, + canonical: &'static str, + sources: &'static [Source], +} + +const PINNED_KEYS: &[PinnedKey] = &[ + PinnedKey { + name: CURRENT_RELEASE_KEY, + canonical: "docs/keys/rocm-cli-release-current-public.pem", + sources: &[ + Source { + path: "install.sh", + token: "PINNED_RELEASE_PUBLIC_KEY_CURRENT", + embedding: Embedding::Shell, + }, + Source { + path: "install.ps1", + token: "$PinnedReleasePublicKeyCurrent", + embedding: Embedding::PowerShell, + }, + ], + }, + PinnedKey { + name: "release-next", + canonical: "docs/keys/rocm-cli-release-next-public.pem", + sources: &[ + Source { + path: "install.sh", + token: "PINNED_RELEASE_PUBLIC_KEY_NEXT", + embedding: Embedding::Shell, + }, + Source { + path: "install.ps1", + token: "$PinnedReleasePublicKeyNext", + embedding: Embedding::PowerShell, + }, + ], + }, + PinnedKey { + name: "metadata", + canonical: "docs/keys/rocm-cli-metadata-public.pem", + sources: &[Source { + path: "apps/rocm/src/therock.rs", + token: "PINNED_METADATA_PUBLIC_KEY_PEM", + embedding: Embedding::Rust, + }], + }, +]; + +/// Reduce arbitrary text to its base64 alphabet, dropping everything else. +/// +/// Newline escape sequences are removed first: a literal `\n` (shell/Rust) or +/// `` `n `` (PowerShell) would otherwise leave a stray `n`/`r`/`t` — all +/// base64-alphabet letters — and corrupt the payload. Real newlines are plain +/// whitespace and drop out with everything else non-base64. +fn base64_only(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + if (c == '\\' || c == '`') && i + 1 < bytes.len() { + let next = bytes[i + 1] as char; + if next == 'n' || next == 'r' || next == 't' { + i += 2; + continue; + } + } + if c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' { + out.push(c); + } + i += 1; + } + out +} + +/// Return the base64 body of the first PUBLIC KEY PEM in `text`, or `None`. +fn pem_body(text: &str) -> Option { + let begin = text.find(PEM_BEGIN)?; + let start = begin + PEM_BEGIN.len(); + let end = text[start..].find(PEM_END)? + start; + let body = base64_only(&text[start..end]); + if body.is_empty() { None } else { Some(body) } +} + +/// Isolate the raw text span assigned to `token` under `embedding`, or `None` if the +/// assignment is not found. PEM payloads contain no `"`, so the first quote after the +/// assignment reliably closes the value. +fn extract_constant<'a>(source: &'a str, token: &str, embedding: Embedding) -> Option<&'a str> { + let key = source.find(token)?; + let after = &source[key + token.len()..]; + match embedding { + Embedding::Shell => { + // `NAME="…"` — the token is immediately followed by `="`. + let open = after.find('"')?; + let rest = &after[open + 1..]; + let close = rest.find('"')?; + Some(&rest[..close]) + } + Embedding::Rust => { + // `const NAME: &str = "…";` — skip to the `=`, then the opening quote. + let eq = after.find('=')?; + let open = after[eq..].find('"')? + eq; + let rest = &after[open + 1..]; + let close = rest.find('"')?; + Some(&rest[..close]) + } + Embedding::PowerShell => { + let here = after.find("@\""); + let quote = after.find('"'); + match (here, quote) { + // Here-string `= @"…"@`: the `@"` quote is the first quote seen. + (Some(h), Some(q)) if q == h + 1 => { + let rest = &after[h + 2..]; + let close = rest.find("\"@")?; + Some(&rest[..close]) + } + // Plain `= "…"`. + (_, Some(q)) => { + let rest = &after[q + 1..]; + let close = rest.find('"')?; + Some(&rest[..close]) + } + _ => None, + } + } + } +} + +/// Check every populated canonical key against its pinned sources. `ci_public_key` +/// is the inline PEM CI would verify against (from `ROCM_CLI_SIGNING_PUBLIC_KEY_PEM`) +/// or `None`; it is passed in rather than read here so tests need not mutate the +/// process environment. +fn check_pinned_keys(root: &Path, ci_public_key: Option<&str>) -> Result> { + let mut messages = Vec::new(); + let mut current_release_body: Option = None; + + for entry in PINNED_KEYS { + let canonical_path = root.join(entry.canonical); + let canonical_text = match std::fs::read_to_string(&canonical_path) { + Ok(text) if !text.trim().is_empty() => text, + _ => { + messages.push(format!( + "{}: no canonical key yet — skipped (dormant)", + entry.name + )); + continue; + } + }; + let canonical_body = pem_body(&canonical_text).with_context(|| { + format!( + "{}: {} is not a valid PUBLIC KEY PEM", + entry.name, entry.canonical + ) + })?; + if entry.name == CURRENT_RELEASE_KEY { + current_release_body = Some(canonical_body.clone()); + } + + for source in entry.sources { + let source_path = root.join(source.path); + let source_text = std::fs::read_to_string(&source_path).with_context(|| { + format!("{}: source file is missing: {}", entry.name, source.path) + })?; + let embedded = + extract_constant(&source_text, source.token, source.embedding).and_then(pem_body); + if embedded.as_deref() != Some(canonical_body.as_str()) { + bail!( + "{}: {} does not embed the canonical key {} in {} — the pinned \ + constant and the published key have diverged", + entry.name, + source.path, + entry.canonical, + source.token + ); + } + } + messages.push(format!( + "{}: pinned in {}", + entry.name, + entry + .sources + .iter() + .map(|s| s.path) + .collect::>() + .join(", ") + )); + } + + messages.extend(check_ci_public_key( + ci_public_key, + current_release_body.as_deref(), + )?); + Ok(messages) +} + +/// When a CI signing public key is configured, it must equal the canonical current +/// release key — the key CI verifies release artifacts against. +fn check_ci_public_key( + ci_public_key: Option<&str>, + current_release_body: Option<&str>, +) -> Result> { + let env_pem = ci_public_key.unwrap_or_default(); + if env_pem.trim().is_empty() { + return Ok(Vec::new()); + } + let env_body = pem_body(env_pem) + .context("the configured CI signing public key is not a valid PUBLIC KEY PEM")?; + let Some(current) = current_release_body else { + return Ok(vec![ + "ci public key: configured, but no canonical current release key to compare \ + against yet — skipped" + .to_owned(), + ]); + }; + if env_body != current { + bail!( + "the CI signing public key does not match the canonical current release key \ + ({}); CI would verify against a different key than installers pin", + PINNED_KEYS[0].canonical + ); + } + Ok(vec![ + "ci public key: matches the canonical current release key".to_owned(), + ]) +} + +fn repo_root() -> PathBuf { + // `CARGO_MANIFEST_DIR` is the `xtask/` crate dir; its parent is the repo root, + // so this is correct regardless of the working directory CI invokes us from. + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask crate has a parent directory") + .to_path_buf() +} + +pub fn run() -> Result<()> { + let ci_public_key = std::env::var("ROCM_CLI_SIGNING_PUBLIC_KEY_PEM").ok(); + for message in check_pinned_keys(&repo_root(), ci_public_key.as_deref())? { + println!("pinned key check: {message}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "-----BEGIN PUBLIC KEY-----\n\ + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtesttesttesttest+/\n\ + abcDEF0123456789testtesttesttesttesttesttesttesttesttestABCD==\n\ + -----END PUBLIC KEY-----\n"; + + #[test] + fn base64_only_strips_escapes_and_non_alphabet() { + assert_eq!(base64_only("AB\\nCD `n EF-=+/"), "ABCDEF=+/"); + } + + #[test] + fn pem_body_extracts_inner_base64() { + let body = pem_body(SAMPLE).expect("valid pem"); + assert!(body.starts_with("MIIBIjAN")); + assert!(!body.contains("BEGIN")); + assert_eq!(pem_body("not a pem"), None); + } + + #[test] + fn extract_constant_isolates_each_syntax() { + let shell = "PINNED_RELEASE_PUBLIC_KEY_CURRENT=\"the-current\"\n\ + PINNED_RELEASE_PUBLIC_KEY_NEXT=\"the-next\"\n"; + assert_eq!( + extract_constant(shell, "PINNED_RELEASE_PUBLIC_KEY_CURRENT", Embedding::Shell), + Some("the-current") + ); + assert_eq!( + extract_constant(shell, "PINNED_RELEASE_PUBLIC_KEY_NEXT", Embedding::Shell), + Some("the-next") + ); + + let rust = "const PINNED_METADATA_PUBLIC_KEY_PEM: &str = \"the-meta\";\n"; + assert_eq!( + extract_constant(rust, "PINNED_METADATA_PUBLIC_KEY_PEM", Embedding::Rust), + Some("the-meta") + ); + + let ps = "$PinnedReleasePublicKeyCurrent = @\"\nthe-here\n\"@\n\ + $PinnedReleasePublicKeyNext = \"the-plain\"\n"; + assert_eq!( + extract_constant(ps, "$PinnedReleasePublicKeyCurrent", Embedding::PowerShell), + Some("\nthe-here\n") + ); + assert_eq!( + extract_constant(ps, "$PinnedReleasePublicKeyNext", Embedding::PowerShell), + Some("the-plain") + ); + } + + #[test] + fn extract_targets_the_named_constant_not_the_whole_file() { + // The bug: a wrong CURRENT passes if the real key is anywhere in the file. + // Here CURRENT holds a wrong key while NEXT holds the real one; extracting + // CURRENT must return the wrong value, not the file's real key. + let shell = format!( + "PINNED_RELEASE_PUBLIC_KEY_CURRENT=\"{}\"\nPINNED_RELEASE_PUBLIC_KEY_NEXT=\"{}\"\n", + SAMPLE.replace("test", "wrong"), + SAMPLE + ); + let current = extract_constant( + &shell, + "PINNED_RELEASE_PUBLIC_KEY_CURRENT", + Embedding::Shell, + ) + .and_then(pem_body) + .unwrap(); + assert_ne!(current, pem_body(SAMPLE).unwrap()); + } + + #[test] + fn ci_public_key_cross_check() { + let current = pem_body(SAMPLE).unwrap(); + + // No CI key configured -> nothing to assert. + assert!( + check_ci_public_key(None, Some(¤t)) + .unwrap() + .is_empty() + ); + assert!( + check_ci_public_key(Some(" "), Some(¤t)) + .unwrap() + .is_empty() + ); + + // Configured but no canonical current key yet -> skipped, not an error. + let skipped = check_ci_public_key(Some(SAMPLE), None).unwrap(); + assert!(skipped.iter().any(|m| m.contains("skipped"))); + + // Matching CI key -> accepted. + assert!(check_ci_public_key(Some(SAMPLE), Some(¤t)).is_ok()); + + // Mismatched CI key -> rejected (CI would verify against a different key). + let other = SAMPLE.replace("test", "diff"); + assert!(check_ci_public_key(Some(&other), Some(¤t)).is_err()); + + // Malformed CI key -> rejected. + assert!(check_ci_public_key(Some("not a pem"), Some(¤t)).is_err()); + } +}