Skip to content

Commit 1029b8c

Browse files
committed
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 <Eugene.Volen@amd.com>
1 parent ede350f commit 1029b8c

11 files changed

Lines changed: 656 additions & 32 deletions

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ jobs:
147147
if: needs.changes.outputs.heavy == 'true'
148148
run: python scripts/release_readiness.py --self-test
149149

150+
- name: Pinned key consistency (self-test + repo check)
151+
if: needs.changes.outputs.heavy == 'true'
152+
run: |
153+
python scripts/verify_pinned_keys.py --self-test
154+
python scripts/verify_pinned_keys.py
155+
150156
- name: Acceptance install lifecycle
151157
if: needs.changes.outputs.heavy == 'true'
152158
run: ./scripts/acceptance-install-upgrade-tui-uninstall.sh
@@ -266,6 +272,13 @@ jobs:
266272
shell: pwsh
267273
run: python .\scripts\release_readiness.py --self-test
268274

275+
- name: Pinned key consistency (self-test + repo check)
276+
if: needs.changes.outputs.heavy == 'true'
277+
shell: pwsh
278+
run: |
279+
python .\scripts\verify_pinned_keys.py --self-test
280+
python .\scripts\verify_pinned_keys.py
281+
269282
- name: Check PowerShell installer syntax
270283
if: needs.changes.outputs.heavy == 'true'
271284
shell: pwsh

.github/workflows/nightly.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ permissions:
1010

1111
env:
1212
CARGO_TERM_COLOR: always
13+
# Production trust gate — dormant until the owner sets the repository variable
14+
# ROCM_CLI_REQUIRE_PRODUCTION_TRUST (e.g. "1") and provides the trust inputs
15+
# below. While the variable is unset/falsy, release_readiness.py skips
16+
# validate_production_trust() and behavior is unchanged. The values are public
17+
# keys and paths (not the private signing key, which stays step-scoped). When
18+
# enabling the gate, the recipe-index inputs also require a step that
19+
# materializes the hosted index + its .sig into the workspace. See
20+
# plans/release-production-trust-rollout.md.
21+
ROCM_CLI_REQUIRE_PRODUCTION_TRUST: ${{ vars.ROCM_CLI_REQUIRE_PRODUCTION_TRUST }}
22+
ROCM_CLI_SIGNING_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_SIGNING_PUBLIC_KEY_PEM }}
23+
ROCM_CLI_METADATA_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_METADATA_PUBLIC_KEY_PEM }}
24+
ROCM_CLI_MODEL_RECIPE_INDEX_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PATH }}
25+
ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH }}
1326

1427
jobs:
1528
nightly:
@@ -23,6 +36,9 @@ jobs:
2336
steps:
2437
- uses: actions/checkout@v6
2538

39+
- name: Verify pinned keys match the signing key
40+
run: python scripts/verify_pinned_keys.py
41+
2642
- name: Check for changes since last nightly
2743
id: check
2844
env:
@@ -180,6 +196,10 @@ jobs:
180196
steps:
181197
- uses: actions/checkout@v6
182198

199+
- name: Verify pinned keys match the signing key
200+
shell: pwsh
201+
run: python .\scripts\verify_pinned_keys.py
202+
183203
- uses: actions-rust-lang/setup-rust-toolchain@v1
184204

185205
- name: Build release binaries

.github/workflows/release.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ permissions:
1414

1515
env:
1616
CARGO_TERM_COLOR: always
17+
# Production trust gate — dormant until the owner sets the repository variable
18+
# ROCM_CLI_REQUIRE_PRODUCTION_TRUST (e.g. "1") and provides the trust inputs
19+
# below. While the variable is unset/falsy, release_readiness.py skips
20+
# validate_production_trust() and behavior is unchanged. The values are public
21+
# keys and paths (not the private signing key, which stays step-scoped). When
22+
# enabling the gate, the recipe-index inputs also require a step that
23+
# materializes the hosted index + its .sig into the workspace. See
24+
# plans/release-production-trust-rollout.md.
25+
ROCM_CLI_REQUIRE_PRODUCTION_TRUST: ${{ vars.ROCM_CLI_REQUIRE_PRODUCTION_TRUST }}
26+
ROCM_CLI_SIGNING_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_SIGNING_PUBLIC_KEY_PEM }}
27+
ROCM_CLI_METADATA_PUBLIC_KEY_PEM: ${{ secrets.ROCM_CLI_METADATA_PUBLIC_KEY_PEM }}
28+
ROCM_CLI_MODEL_RECIPE_INDEX_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PATH }}
29+
ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH: ${{ vars.ROCM_CLI_MODEL_RECIPE_INDEX_PUBLIC_KEY_PATH }}
1730

1831
jobs:
1932
release:
@@ -24,6 +37,9 @@ jobs:
2437
steps:
2538
- uses: actions/checkout@v6
2639

40+
- name: Verify pinned keys match the signing key
41+
run: python scripts/verify_pinned_keys.py
42+
2743
- name: Determine version
2844
id: version
2945
run: |
@@ -164,6 +180,10 @@ jobs:
164180
steps:
165181
- uses: actions/checkout@v6
166182

183+
- name: Verify pinned keys match the signing key
184+
shell: pwsh
185+
run: python .\scripts\verify_pinned_keys.py
186+
167187
- uses: actions-rust-lang/setup-rust-toolchain@v1
168188

169189
- name: Build release binaries

apps/rocm/src/therock.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,12 +1702,48 @@ fn select_latest_version(versions: &[String], channel: TheRockChannel) -> Option
17021702
}
17031703
}
17041704

1705+
/// Pinned production metadata signing public key (trust root). Empty until the
1706+
/// repository owner publishes production keys (see docs/release-trust.md,
1707+
/// "Remaining Owner Step"). While empty, metadata verification stays opt-in
1708+
/// (enabled only via the `ROCM_CLI_METADATA_PUBLIC_KEY_*` env vars). Once
1709+
/// populated, metadata signatures are verified by default with this key as the
1710+
/// trust root.
1711+
const PINNED_METADATA_PUBLIC_KEY_PEM: &str = "";
1712+
1713+
/// The pinned metadata trust root, or `None` while the sentinel is still empty.
1714+
fn pinned_metadata_public_key() -> Option<String> {
1715+
let pem = PINNED_METADATA_PUBLIC_KEY_PEM.trim();
1716+
(!pem.is_empty()).then(|| pem.to_owned())
1717+
}
1718+
17051719
impl MetadataSignaturePolicy {
17061720
fn from_env() -> Self {
1721+
Self::resolve(
1722+
truthy_env("ROCM_CLI_REQUIRE_METADATA_SIGNATURE"),
1723+
env_path("ROCM_CLI_METADATA_PUBLIC_KEY_PATH"),
1724+
env_nonempty("ROCM_CLI_METADATA_PUBLIC_KEY_PEM"),
1725+
pinned_metadata_public_key(),
1726+
)
1727+
}
1728+
1729+
/// Combine the env-provided inputs with the pinned trust root. An explicit
1730+
/// env key (path or PEM) wins as an escape hatch; otherwise the pinned key is
1731+
/// used, and its presence makes verification required by default.
1732+
fn resolve(
1733+
env_required: bool,
1734+
env_path: Option<PathBuf>,
1735+
env_pem: Option<String>,
1736+
pinned_pem: Option<String>,
1737+
) -> Self {
1738+
let pinned = if env_path.is_none() && env_pem.is_none() {
1739+
pinned_pem
1740+
} else {
1741+
None
1742+
};
17071743
Self {
1708-
required: truthy_env("ROCM_CLI_REQUIRE_METADATA_SIGNATURE"),
1709-
public_key_path: env_path("ROCM_CLI_METADATA_PUBLIC_KEY_PATH"),
1710-
public_key_pem: env_nonempty("ROCM_CLI_METADATA_PUBLIC_KEY_PEM"),
1744+
required: env_required || pinned.is_some(),
1745+
public_key_path: env_path,
1746+
public_key_pem: env_pem.or(pinned),
17111747
}
17121748
}
17131749

@@ -3837,6 +3873,56 @@ echo Python 3.12.10
38373873
Ok(())
38383874
}
38393875

3876+
#[test]
3877+
fn metadata_policy_uses_pinned_key_and_requires_by_default() {
3878+
// A pinned trust root with no env inputs: verification becomes required
3879+
// by default and the pinned PEM is used.
3880+
let pinned = "-----BEGIN PUBLIC KEY-----\npinned\n-----END PUBLIC KEY-----\n";
3881+
let policy = MetadataSignaturePolicy::resolve(false, None, None, Some(pinned.to_owned()));
3882+
assert!(policy.required);
3883+
assert!(policy.active());
3884+
assert_eq!(policy.public_key_pem.as_deref(), Some(pinned));
3885+
assert!(policy.public_key_path.is_none());
3886+
}
3887+
3888+
#[test]
3889+
fn metadata_policy_env_key_overrides_pinned_key() {
3890+
// An explicit env PEM wins over the pinned root (escape hatch), and does
3891+
// not force `required` on its own.
3892+
let pinned = "-----BEGIN PUBLIC KEY-----\npinned\n-----END PUBLIC KEY-----\n";
3893+
let env_pem = "-----BEGIN PUBLIC KEY-----\nenv\n-----END PUBLIC KEY-----\n";
3894+
let policy = MetadataSignaturePolicy::resolve(
3895+
false,
3896+
None,
3897+
Some(env_pem.to_owned()),
3898+
Some(pinned.to_owned()),
3899+
);
3900+
assert_eq!(policy.public_key_pem.as_deref(), Some(env_pem));
3901+
3902+
let env_path = PathBuf::from("/keys/metadata.pem");
3903+
let policy = MetadataSignaturePolicy::resolve(
3904+
false,
3905+
Some(env_path.clone()),
3906+
None,
3907+
Some(pinned.to_owned()),
3908+
);
3909+
assert_eq!(policy.public_key_path, Some(env_path));
3910+
assert!(policy.public_key_pem.is_none());
3911+
}
3912+
3913+
#[test]
3914+
fn metadata_policy_without_pinned_key_preserves_optin_behavior() {
3915+
// Empty pinned sentinel + no env inputs: verification stays inactive,
3916+
// exactly as before pinning was introduced.
3917+
let policy = MetadataSignaturePolicy::resolve(false, None, None, None);
3918+
assert!(!policy.required);
3919+
assert!(!policy.active());
3920+
3921+
// `ROCM_CLI_REQUIRE_METADATA_SIGNATURE=1` alone still activates it.
3922+
let policy = MetadataSignaturePolicy::resolve(true, None, None, None);
3923+
assert!(policy.required);
3924+
}
3925+
38403926
#[test]
38413927
fn metadata_cache_revalidation_requires_cached_signature_when_policy_is_active() -> Result<()> {
38423928
let (root, paths) = test_paths("metadata-signature-revalidate");
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-----BEGIN PUBLIC KEY-----
2+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxuyScR/BzV+kuXqWAHtE
3+
+9xiPCWURUYnsio9MOrf2Xe01mBngP7qPcF13+5nrfT3EnuxOn5rSCYwjOndlS+c
4+
KzOw6GZXJD/ZqeojnbXxxsxlftAQHHEke1WCtga5ZEFxOauTeB5nTV/IbjMAl2Xc
5+
M4PaudpFFH/6j/E3gongDmt0hWdpMLbaCcd3i1vMTEsaHZooNoAbJ/dIAHR/dDNM
6+
pScZAZoy0LL3Afhn5Hiv71trfbfnnboVSdhnCoMmisl6/sK55zR7VM8hWDTTowl3
7+
ultUtiz4emTfXDCb2RptOgoydBA+mu9z6O4eVF8S5dVr/S834SK6dD2fWHNnT0dc
8+
JwIDAQAB
9+
-----END PUBLIC KEY-----

install.ps1

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,35 @@ function Convert-FileUriToPath {
7575
return $null
7676
}
7777

78+
# Pinned production release signing public keys (trust roots). These stay empty
79+
# until the repository owner publishes production keys (see docs/release-trust.md,
80+
# "Remaining Owner Step"). While empty, release installs keep the opt-in behavior:
81+
# a signature is verified only when a key is supplied via the parameters/env vars
82+
# or ROCM_CLI_REQUIRE_SIGNATURE=1. Once populated, release-channel installs verify
83+
# signatures by default with these keys as trust roots. Two slots support
84+
# zero-downtime key rotation: a release signed with either the current or the
85+
# pre-staged next key verifies, so the next key is trusted before its first use.
86+
$PinnedReleasePublicKeyCurrent = @"
87+
-----BEGIN PUBLIC KEY-----
88+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxuyScR/BzV+kuXqWAHtE
89+
+9xiPCWURUYnsio9MOrf2Xe01mBngP7qPcF13+5nrfT3EnuxOn5rSCYwjOndlS+c
90+
KzOw6GZXJD/ZqeojnbXxxsxlftAQHHEke1WCtga5ZEFxOauTeB5nTV/IbjMAl2Xc
91+
M4PaudpFFH/6j/E3gongDmt0hWdpMLbaCcd3i1vMTEsaHZooNoAbJ/dIAHR/dDNM
92+
pScZAZoy0LL3Afhn5Hiv71trfbfnnboVSdhnCoMmisl6/sK55zR7VM8hWDTTowl3
93+
ultUtiz4emTfXDCb2RptOgoydBA+mu9z6O4eVF8S5dVr/S834SK6dD2fWHNnT0dc
94+
JwIDAQAB
95+
-----END PUBLIC KEY-----
96+
"@
97+
$PinnedReleasePublicKeyNext = ""
98+
99+
function Test-HasPinnedReleaseKey {
100+
return (-not [string]::IsNullOrWhiteSpace($PinnedReleasePublicKeyCurrent)) `
101+
-or (-not [string]::IsNullOrWhiteSpace($PinnedReleasePublicKeyNext))
102+
}
103+
104+
# Return the candidate signing public keys as an array of file paths. An explicit
105+
# parameter/env-provided key wins (an escape hatch for private mirrors); otherwise
106+
# the pinned production trust roots are used.
78107
function Resolve-SigningPublicKey {
79108
param(
80109
[string] $KeyPath,
@@ -83,16 +112,26 @@ function Resolve-SigningPublicKey {
83112
)
84113

85114
if (-not [string]::IsNullOrWhiteSpace($KeyPath)) {
86-
return Resolve-InstallerPath $KeyPath
115+
return @((Resolve-InstallerPath $KeyPath))
87116
}
88117

89118
if (-not [string]::IsNullOrWhiteSpace($KeyPem)) {
90119
$path = Join-Path $TempRoot "rocm-cli-signing-public-key.pem"
91120
Set-Content -LiteralPath $path -Value $KeyPem -Encoding ascii
92-
return $path
121+
return @($path)
93122
}
94123

95-
return ""
124+
$paths = @()
125+
$index = 0
126+
foreach ($pinned in @($PinnedReleasePublicKeyCurrent, $PinnedReleasePublicKeyNext)) {
127+
if (-not [string]::IsNullOrWhiteSpace($pinned)) {
128+
$index++
129+
$path = Join-Path $TempRoot "rocm-cli-pinned-key-$index.pem"
130+
Set-Content -LiteralPath $path -Value $pinned -Encoding ascii
131+
$paths += $path
132+
}
133+
}
134+
return $paths
96135
}
97136

98137
function Save-File {
@@ -136,14 +175,17 @@ function Confirm-ArchiveSignature {
136175
param(
137176
[string] $ArchivePath,
138177
[string] $SignaturePath,
139-
[string] $PublicKeyPath
178+
[string[]] $PublicKeyPaths
140179
)
141180

142181
Confirm-Command openssl
143-
& openssl dgst -sha256 -verify $PublicKeyPath -signature $SignaturePath $ArchivePath | Out-Null
144-
if ($LASTEXITCODE -ne 0) {
145-
Fail "signature verification failed"
182+
foreach ($key in $PublicKeyPaths) {
183+
& openssl dgst -sha256 -verify $key -signature $SignaturePath $ArchivePath | Out-Null
184+
if ($LASTEXITCODE -eq 0) {
185+
return
186+
}
146187
}
188+
Fail "signature verification failed"
147189
}
148190

149191
function Get-ExpectedSha256 {
@@ -380,10 +422,17 @@ $sigPath = "$archivePath.sig"
380422
$extractDir = Join-Path $tempRoot "extract"
381423
$manifestPath = Join-Path $InstallDir ".rocm-cli-manifest"
382424
$signatureRequired = $RequireSignature -or (Test-Truthy $env:ROCM_CLI_REQUIRE_SIGNATURE)
425+
# Production default: once release trust roots are pinned, release-channel installs
426+
# always verify. An unset or 0 ROCM_CLI_REQUIRE_SIGNATURE does not lower this
427+
# floor; pass -SigningPublicKeyPath/-Pem (or the env vars) to point at an
428+
# alternate key (e.g. a private mirror) instead.
429+
if ($Channel -eq "release" -and (Test-HasPinnedReleaseKey)) {
430+
$signatureRequired = $true
431+
}
383432

384433
try {
385434
New-Item -ItemType Directory -Force -Path $tempRoot, $extractDir | Out-Null
386-
$signingPublicKey = Resolve-SigningPublicKey $SigningPublicKeyPath $SigningPublicKeyPem $tempRoot
435+
$signingPublicKeys = @(Resolve-SigningPublicKey $SigningPublicKeyPath $SigningPublicKeyPem $tempRoot)
387436

388437
Write-Host "rocm-cli installer"
389438
Write-Host " repo: $Repo"
@@ -400,12 +449,12 @@ try {
400449
Fail "checksum verification failed"
401450
}
402451

403-
if ($signatureRequired -or -not [string]::IsNullOrWhiteSpace($signingPublicKey)) {
404-
if ([string]::IsNullOrWhiteSpace($signingPublicKey)) {
452+
if ($signatureRequired -or ($signingPublicKeys.Count -gt 0)) {
453+
if ($signingPublicKeys.Count -eq 0) {
405454
Fail "signature verification requires ROCM_CLI_SIGNING_PUBLIC_KEY_PATH, ROCM_CLI_SIGNING_PUBLIC_KEY_PEM, -SigningPublicKeyPath, or -SigningPublicKeyPem"
406455
}
407456
Save-File $sigUrl $sigPath "required signature sidecar is missing or unavailable"
408-
Confirm-ArchiveSignature $archivePath $sigPath $signingPublicKey
457+
Confirm-ArchiveSignature $archivePath $sigPath $signingPublicKeys
409458
Write-Host "signature verified"
410459
}
411460

0 commit comments

Comments
 (0)