Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
92 changes: 89 additions & 3 deletions apps/rocm/src/therock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<PathBuf>,
env_pem: Option<String>,
pinned_pem: Option<String>,
) -> 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),
}
}

Expand Down Expand Up @@ -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");
Expand Down
9 changes: 9 additions & 0 deletions docs/keys/rocm-cli-release-current-public.pem
Original file line number Diff line number Diff line change
@@ -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-----
71 changes: 60 additions & 11 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand All @@ -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"
}

Expand Down
Loading
Loading