diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf585597..c3fc5630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,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 @@ -266,6 +272,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()