diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..1cd5d08e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --quiet --release -p xtask --" diff --git a/Cargo.lock b/Cargo.lock index f405a645..c42b45d1 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" @@ -1758,8 +1883,11 @@ dependencies = [ "anyhow", "directories", "libc", + "rand", + "rsa", "serde", "serde_json", + "sha2", "ureq", "windows-sys 0.61.2", ] @@ -1876,6 +2004,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 +2339,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 +2377,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" @@ -2981,6 +3155,15 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xtask" +version = "0.3.0" +dependencies = [ + "anyhow", + "clap", + "rocm-core", +] + [[package]] name = "yoke" version = "0.8.2" @@ -3077,6 +3260,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..7e316218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "engines/lemonade", "engines/sglang", "engines/vllm", + "xtask", ] resolver = "2" @@ -29,10 +30,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/src/therock.rs b/apps/rocm/src/therock.rs index f98ac131..6f067a09 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -7,7 +7,10 @@ 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, }; +#[cfg(test)] +use rocm_core::{generate_rsa_signing_keypair, sign_rsa_pkcs1_sha256_signature}; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::fs; @@ -1775,7 +1778,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 +1805,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 +1831,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 +4340,17 @@ 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(), - ]) + let (private_pem, public_pem) = generate_rsa_signing_keypair()?; + fs::write(private_key, private_pem.as_bytes())?; + 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() - ); - } + let private_pem = fs::read_to_string(private_key)?; + let payload_bytes = fs::read(payload)?; + let produced = sign_rsa_pkcs1_sha256_signature(&private_pem, &payload_bytes)?; + fs::write(signature, produced)?; Ok(()) } diff --git a/crates/rocm-core/Cargo.toml b/crates/rocm-core/Cargo.toml index cae4794d..63cd6696 100644 --- a/crates/rocm-core/Cargo.toml +++ b/crates/rocm-core/Cargo.toml @@ -10,8 +10,11 @@ rust-version.workspace = true anyhow.workspace = true directories.workspace = true libc.workspace = true +rand.workspace = true +rsa.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true ureq = { version = "2.12", features = ["native-certs"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index 304849ab..a4037095 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -4603,6 +4603,96 @@ pub fn model_recipe_index_signature_path(index_path: &Path) -> PathBuf { PathBuf::from(signature) } +/// Normalize a PEM document the way the OpenSSL CLI tolerated input, so keys +/// produced or copied through other tooling still parse with the strict RFC 7468 +/// reader. Strips a leading UTF-8 BOM, accepts any line-ending style (CRLF, lone +/// CR, or LF), and drops trailing whitespace from each line — Windows tooling +/// (e.g. PowerShell `Set-Content`) can introduce CRLF or a stray trailing space +/// on the `-----BEGIN ...-----` boundary that the parser would otherwise reject. +fn normalize_pem(pem: &str) -> String { + let without_bom = pem.strip_prefix('\u{feff}').unwrap_or(pem); + let unified = without_bom.replace("\r\n", "\n").replace('\r', "\n"); + let mut normalized: String = unified + .split('\n') + .map(|line| line.trim_end_matches([' ', '\t'])) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n"); + normalized.push('\n'); + normalized +} + +/// 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 +/// `"