From d40af3c9c4b4492d462f91e29dab221b54ea633f Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 16 Jun 2026 10:50:12 +0000 Subject: [PATCH 1/6] Verify metadata signatures in Rust, not via openssl Signature verification shelled out to the `openssl` CLI, which made it depend on a transient subprocess spawn succeeding. Under the parallel test run on CI, spawning openssl intermittently failed ("failed to launch openssl"), flaking the verification tests; it also forced every end user to have the openssl CLI on PATH at runtime. Replace the openssl invocations with a pure-Rust RSASSA-PKCS#1 v1.5 / SHA-256 verifier (rsa + sha2). A shared helper in rocm-core backs both the THeRock metadata and the model-recipe-index paths; test helpers now generate and sign keys in-process too. A committed openssl-produced test vector pins byte-compatibility with `openssl dgst -verify` without ever launching it. --- Cargo.lock | 197 +++++++++++++++++++++++++++++++++++ Cargo.toml | 3 + apps/rocm/Cargo.toml | 5 + apps/rocm/src/therock.rs | 120 ++++++++++------------ crates/rocm-core/Cargo.toml | 5 + crates/rocm-core/src/lib.rs | 200 ++++++++++++++++++++++++------------ 6 files changed, 399 insertions(+), 131 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f405a645..28aea480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" @@ -498,6 +504,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -648,6 +660,17 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -677,6 +700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1283,6 +1307,15 @@ dependencies = [ "log", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1295,6 +1328,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.15" @@ -1425,6 +1464,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1472,6 +1527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1543,6 +1599,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1572,6 +1637,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "polling" version = "3.11.0" @@ -1595,6 +1681,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1638,6 +1733,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -1733,6 +1858,7 @@ dependencies = [ "crossterm 0.29.0", "flate2", "keyring-core", + "rand", "ratatui", "rocm-core", "rocm-engine-atom", @@ -1743,8 +1869,10 @@ dependencies = [ "rocm-engine-sglang", "rocm-engine-vllm", "rpassword", + "rsa", "serde", "serde_json", + "sha2", "tar", "ureq", "windows-native-keyring-store", @@ -1758,8 +1886,11 @@ dependencies = [ "anyhow", "directories", "libc", + "rand", + "rsa", "serde", "serde_json", + "sha2", "ureq", "windows-sys 0.61.2", ] @@ -1876,6 +2007,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -2191,6 +2342,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2219,6 +2380,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3077,6 +3254,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index ab6425aa..2ddac804 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,10 +29,13 @@ crossterm = { version = "0.29", features = ["libc", "use-dev-tty"] } directories = "6.0" keyring-core = "1.0.0" libc = "0.2" +rand = "0.8" ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } rpassword = "7.5" +rsa = "0.9" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = { version = "0.10", features = ["oid"] } tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } [workspace.lints.rust] diff --git a/apps/rocm/Cargo.toml b/apps/rocm/Cargo.toml index 3e37a946..446b2a73 100644 --- a/apps/rocm/Cargo.toml +++ b/apps/rocm/Cargo.toml @@ -27,6 +27,11 @@ serde_json.workspace = true tar = "0.4" ureq = { version = "2.12", features = ["native-certs"] } +[dev-dependencies] +rand.workspace = true +rsa.workspace = true +sha2.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] windows-native-keyring-store = "1.1" diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index f98ac131..72f58de3 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -7,6 +7,7 @@ use rocm_core::{ normalize_therock_family, platform_binary_name, runtime_is_windows, runtime_os_name, runtime_path_for_windows_child, runtime_path_list_split, runtime_python_executable_in_env, unix_time_millis, uv_command_env, uv_pip_install_base, uv_venv_args, + verify_rsa_pkcs1_sha256_signature, }; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -1775,7 +1776,7 @@ fn fetch_and_verify_metadata_signature( download_signature_file(&signature_url, signature_path, max_time_secs)?; let public_key_source = with_metadata_public_key(policy, temp_key_path, |public_key, source| { - verify_signature_with_openssl(payload_path, signature_path, public_key)?; + verify_metadata_signature(payload_path, signature_path, public_key)?; Ok(source.to_owned()) })? .context("metadata signature policy was active but no public key was resolved")?; @@ -1802,7 +1803,7 @@ fn verify_cached_metadata_signature( ); } with_metadata_public_key(policy, temp_key_path, |public_key, _source| { - verify_signature_with_openssl(payload_path, signature_path, public_key) + verify_metadata_signature(payload_path, signature_path, public_key) })?; Ok(()) } @@ -1828,36 +1829,30 @@ fn download_signature_file( Ok(()) } -fn verify_signature_with_openssl( +fn verify_metadata_signature( payload_path: &Path, signature_path: &Path, public_key_path: &Path, ) -> Result<()> { - let output = capture_command_output( - Path::new("openssl"), - &[ - "dgst", - "-sha256", - "-verify", - public_key_path.to_string_lossy().as_ref(), - "-signature", - signature_path.to_string_lossy().as_ref(), - payload_path.to_string_lossy().as_ref(), - ], - ) - .context("failed to launch openssl for metadata signature verification")?; - if !output.status.success() { - let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - bail!( - "metadata signature verification failed{}", - if detail.is_empty() { - String::new() - } else { - format!(": {detail}") - } - ); - } - Ok(()) + let public_key_pem = fs::read_to_string(public_key_path).with_context(|| { + format!( + "failed to read metadata public key: {}", + public_key_path.display() + ) + })?; + let signature = fs::read(signature_path).with_context(|| { + format!( + "failed to read metadata signature: {}", + signature_path.display() + ) + })?; + let payload = fs::read(payload_path).with_context(|| { + format!( + "failed to read metadata payload: {}", + payload_path.display() + ) + })?; + verify_rsa_pkcs1_sha256_signature(&public_key_pem, &payload, &signature, "metadata") } fn metadata_cache_can_revalidate( @@ -4343,48 +4338,39 @@ echo Python 3.12.10 } fn generate_test_signing_key(private_key: &Path, public_key: &Path) -> Result<()> { - run_test_openssl(&[ - "genpkey", - "-algorithm", - "RSA", - "-pkeyopt", - "rsa_keygen_bits:2048", - "-out", - private_key.to_string_lossy().as_ref(), - ])?; - run_test_openssl(&[ - "rsa", - "-in", - private_key.to_string_lossy().as_ref(), - "-pubout", - "-out", - public_key.to_string_lossy().as_ref(), - ]) + use rsa::RsaPrivateKey; + use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; + + let mut rng = rand::thread_rng(); + let key = RsaPrivateKey::new(&mut rng, 2048) + .context("failed to generate test RSA signing key")?; + let private_pem = key + .to_pkcs8_pem(LineEnding::LF) + .context("failed to encode test private key")?; + fs::write(private_key, private_pem.as_bytes())?; + let public_pem = rsa::RsaPublicKey::from(&key) + .to_public_key_pem(LineEnding::LF) + .context("failed to encode test public key")?; + fs::write(public_key, public_pem.as_bytes())?; + Ok(()) } fn sign_test_payload(private_key: &Path, payload: &Path, signature: &Path) -> Result<()> { - run_test_openssl(&[ - "dgst", - "-sha256", - "-sign", - private_key.to_string_lossy().as_ref(), - "-out", - signature.to_string_lossy().as_ref(), - payload.to_string_lossy().as_ref(), - ]) - } - - fn run_test_openssl(args: &[&str]) -> Result<()> { - let output = Command::new("openssl") - .args(args) - .output() - .context("failed to launch openssl for signature test")?; - if !output.status.success() { - bail!( - "openssl signature test command failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } + use rsa::RsaPrivateKey; + use rsa::pkcs1v15::SigningKey; + use rsa::pkcs8::DecodePrivateKey; + use rsa::signature::{SignatureEncoding, Signer}; + use sha2::Sha256; + + let private_pem = fs::read_to_string(private_key)?; + let key = RsaPrivateKey::from_pkcs8_pem(&private_pem) + .context("failed to parse test private key")?; + let payload_bytes = fs::read(payload)?; + let signing_key = SigningKey::::new(key); + let produced = signing_key + .try_sign(&payload_bytes) + .context("failed to sign test payload")?; + fs::write(signature, produced.to_bytes())?; Ok(()) } diff --git a/crates/rocm-core/Cargo.toml b/crates/rocm-core/Cargo.toml index cae4794d..97a868ad 100644 --- a/crates/rocm-core/Cargo.toml +++ b/crates/rocm-core/Cargo.toml @@ -10,9 +10,14 @@ rust-version.workspace = true anyhow.workspace = true directories.workspace = true libc.workspace = true +rsa.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true ureq = { version = "2.12", features = ["native-certs"] } +[dev-dependencies] +rand.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] } diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index 304849ab..93b96ceb 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -4603,6 +4603,35 @@ pub fn model_recipe_index_signature_path(index_path: &Path) -> PathBuf { PathBuf::from(signature) } +/// Verify an RSASSA-PKCS#1 v1.5 signature over SHA-256 using a pure-Rust +/// implementation, with no external `openssl` process. +/// +/// `public_key_pem` is a SubjectPublicKeyInfo PEM (`-----BEGIN PUBLIC KEY-----`), +/// exactly what `openssl rsa -pubout` emits and what `openssl dgst -sha256 -verify` +/// consumes, so verification is byte-compatible with that command. `label` names the +/// artifact being checked (e.g. `"metadata"`); on a bad signature the error reads +/// `"