diff --git a/rustfs/src/config/cli.rs b/rustfs/src/config/cli.rs index 4bfc7a86e..867006bdf 100644 --- a/rustfs/src/config/cli.rs +++ b/rustfs/src/config/cli.rs @@ -613,6 +613,10 @@ pub struct ConnectClientPerformanceOpts { #[arg(long = "state-dir")] pub state_dir: PathBuf, + /// Select an existing separately enrolled offline key by its SPKI SHA-256 identifier + #[arg(long = "offline-key-id", value_parser = NonEmptyStringValueParser::new())] + pub offline_key_id: Option, + /// RustFS deployment endpoint #[arg(long, value_parser = NonEmptyStringValueParser::new())] pub endpoint: String, diff --git a/rustfs/src/config/config_test.rs b/rustfs/src/config/config_test.rs index 0499c79cc..91c763018 100644 --- a/rustfs/src/config/config_test.rs +++ b/rustfs/src/config/config_test.rs @@ -128,6 +128,55 @@ mod tests { assert!(Opt::parse_command(approved.into_iter().chain(["--peer-address", "example.com"])).is_err()); } + #[test] + fn connect_client_cli_selects_offline_key_only_when_requested() { + let base = [ + "rustfs", + "connect", + "performance", + "client", + "--state-dir", + "/state", + "--endpoint", + "https://storage.example", + "--access-key-file", + "/access", + "--secret-key-file", + "/secret", + "--output", + "/output.zip", + "--organization", + "org", + "--cluster", + "cluster", + "--device", + "device", + "--run-uid", + "run", + "--artifact-uid", + "artifact", + "--consent-uid", + "consent", + "--policy-revision", + "1", + "--consent-expires-at", + "10", + "--expires-at", + "10", + "--operation", + "get", + "--acknowledge-l1", + ]; + assert!(matches!( + Opt::parse_command(base), + Ok(CommandResult::ConnectClientPerformance(options)) if options.offline_key_id.is_none() + )); + assert!(matches!( + Opt::parse_command(base.into_iter().chain(["--offline-key-id", "selected"])), + Ok(CommandResult::ConnectClientPerformance(options)) if options.offline_key_id.as_deref() == Some("selected") + )); + } + #[test] #[serial] fn test_tls_inspect_subcommand_parses_tls_path_alias() { diff --git a/rustfs/src/connect/diagnostics/mod.rs b/rustfs/src/connect/diagnostics/mod.rs index 19631b639..f3b1d821a 100644 --- a/rustfs/src/connect/diagnostics/mod.rs +++ b/rustfs/src/connect/diagnostics/mod.rs @@ -172,9 +172,9 @@ pub use trace_record::{ }; pub use trace_replay::{LocallyReviewedTraceArtifact, ReplayedTrace, TraceReplayError, replay_trace, replay_trace_result}; pub(crate) use trace_runtime::{ - LocalHealthRequest, LocalNetworkRequest, LocalTraceCaptureError, LocalTraceCaptureRuntime, request_local_health, - request_local_native_threads_profile, request_local_network, request_local_runtime_profile, request_local_top_api, - request_local_top_disk, request_local_top_locks, request_local_top_rpc, request_local_trace_capture, + LocalHealthRequest, LocalNetworkRequest, LocalTraceCaptureError, LocalTraceCaptureRuntime, load_selected_offline_key, + request_local_health, request_local_native_threads_profile, request_local_network, request_local_runtime_profile, + request_local_top_api, request_local_top_disk, request_local_top_locks, request_local_top_rpc, request_local_trace_capture, spawn_local_trace_capture_runtime, }; diff --git a/rustfs/src/connect/diagnostics/trace_runtime.rs b/rustfs/src/connect/diagnostics/trace_runtime.rs index 52560e46c..f830f03c6 100644 --- a/rustfs/src/connect/diagnostics/trace_runtime.rs +++ b/rustfs/src/connect/diagnostics/trace_runtime.rs @@ -93,6 +93,8 @@ pub(crate) enum LocalTraceCaptureError { Network(String), #[error("local diagnostic cancellation was not acknowledged")] CancellationUnconfirmed, + #[error("selected offline identity is unavailable or unsafe")] + OfflineIdentity, } pub(crate) struct LocalTraceCaptureRuntime { @@ -1441,6 +1443,20 @@ fn load_offline_key(state_root: &Path, offline_key_id: &str) -> Result Result { + if offline_key_id.len() != 64 + || !offline_key_id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(LocalTraceCaptureError::OfflineIdentity); + } + load_offline_key(state_root, offline_key_id).map_err(|_| LocalTraceCaptureError::OfflineIdentity) +} + async fn capture_local_runtime_profile( state_root: &Path, protocol_version: u16, @@ -2100,6 +2116,48 @@ mod tests { } } + #[test] + fn selected_offline_key_never_falls_back_or_follows_unsafe_state() { + use std::os::unix::fs::symlink; + + let state = tempfile::tempdir().unwrap(); + std::fs::set_permissions(state.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let (request, key) = runtime_request(state.path()); + let store = crate::connect::OfflineKeyStore::new(state.path()); + assert_eq!( + super::load_selected_offline_key(state.path(), &request.offline_key_id) + .unwrap() + .public_key_der(), + key.public_key_der() + ); + assert!(super::load_selected_offline_key(state.path(), &"0".repeat(64)).is_err()); + + let path = store.key_path(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(super::load_selected_offline_key(state.path(), &request.offline_key_id).is_err()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions(state.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(super::load_selected_offline_key(state.path(), &request.offline_key_id).is_err()); + std::fs::set_permissions(state.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + let target = state.path().join("key-target"); + std::fs::rename(&path, &target).unwrap(); + crate::connect::IdentityStore::new(state.path().join("identity")) + .load_or_create() + .unwrap(); + assert!(super::load_selected_offline_key(state.path(), &request.offline_key_id).is_err()); + symlink(&target, &path).unwrap(); + assert!(super::load_selected_offline_key(state.path(), &request.offline_key_id).is_err()); + std::fs::remove_file(&path).unwrap(); + std::fs::rename(&target, &path).unwrap(); + std::fs::set_permissions(state.path().join("offline"), std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(super::load_selected_offline_key(state.path(), &request.offline_key_id).is_err()); + std::fs::set_permissions(state.path().join("offline"), std::fs::Permissions::from_mode(0o755)).unwrap(); + let linked_root = state.path().join("linked-root"); + symlink(state.path(), &linked_root).unwrap(); + assert!(super::load_selected_offline_key(&linked_root, &request.offline_key_id).is_err()); + } + #[tokio::test] #[serial] async fn local_network_rejects_unapproved_expired_and_unbounded_work_before_using_key() { diff --git a/rustfs/src/connect/diagnostics/trace_runtime_unsupported.rs b/rustfs/src/connect/diagnostics/trace_runtime_unsupported.rs index 87535ebf3..d51d92f93 100644 --- a/rustfs/src/connect/diagnostics/trace_runtime_unsupported.rs +++ b/rustfs/src/connect/diagnostics/trace_runtime_unsupported.rs @@ -50,6 +50,13 @@ pub(crate) async fn request_local_network( Err(LocalTraceCaptureError::RuntimeUnavailable) } +pub(crate) fn load_selected_offline_key( + _state_root: &Path, + _offline_key_id: &str, +) -> Result { + Err(LocalTraceCaptureError::RuntimeUnavailable) +} + #[derive(Debug, Error)] pub(crate) enum LocalTraceCaptureError { #[error("telemetry server runtime is unavailable")] diff --git a/rustfs/src/connect/mod.rs b/rustfs/src/connect/mod.rs index a6398a595..bfa422797 100644 --- a/rustfs/src/connect/mod.rs +++ b/rustfs/src/connect/mod.rs @@ -94,9 +94,9 @@ pub use diagnostics::{ save_signed_health_export, sign_health_export, }; pub(crate) use diagnostics::{ - LocalHealthRequest, LocalNetworkRequest, LocalTraceCaptureError, LocalTraceCaptureRuntime, request_local_health, - request_local_native_threads_profile, request_local_network, request_local_runtime_profile, request_local_top_api, - request_local_top_disk, request_local_top_locks, request_local_top_rpc, request_local_trace_capture, + LocalHealthRequest, LocalNetworkRequest, LocalTraceCaptureError, LocalTraceCaptureRuntime, load_selected_offline_key, + request_local_health, request_local_native_threads_profile, request_local_network, request_local_runtime_profile, + request_local_top_api, request_local_top_disk, request_local_top_locks, request_local_top_rpc, request_local_trace_capture, spawn_local_trace_capture_runtime, }; pub use diagnostics::{ diff --git a/rustfs/src/startup_entrypoint.rs b/rustfs/src/startup_entrypoint.rs index 7ac85853a..b378b5f9f 100644 --- a/rustfs/src/startup_entrypoint.rs +++ b/rustfs/src/startup_entrypoint.rs @@ -867,10 +867,14 @@ async fn execute_connect_client_performance(options: ConnectClientPerformanceOpt let duration = Duration::from_millis(options.duration_millis); validate_client_limits(duration, options.traffic_bytes).map_err(Error::other)?; - let key = IdentityStore::new(options.state_dir.join("identity")) - .load() - .map_err(Error::other)? - .ok_or_else(|| Error::other("connect client performance requires an enrolled device identity"))?; + let key = if let Some(offline_key_id) = options.offline_key_id.as_deref() { + crate::connect::load_selected_offline_key(&options.state_dir, offline_key_id).map_err(Error::other)? + } else { + IdentityStore::new(options.state_dir.join("identity")) + .load() + .map_err(Error::other)? + .ok_or_else(|| Error::other("connect client performance requires an enrolled device identity"))? + }; let access_key = read_protected_client_credential(&options.access_key_file).map_err(Error::other)?; let secret_key = read_protected_client_credential(&options.secret_key_file).map_err(Error::other)?; let session_token = options diff --git a/rustfs/tests/connect_perf_client.rs b/rustfs/tests/connect_perf_client.rs index 2c2152062..d02f8b517 100644 --- a/rustfs/tests/connect_perf_client.rs +++ b/rustfs/tests/connect_perf_client.rs @@ -547,6 +547,92 @@ async fn production_cli_writes_a_verifiable_export_with_exact_binary_provenance( .expect("public key") .verify(&signed, &signature) .expect("valid ES256 signature"); + + #[cfg(unix)] + { + // The offline key is separate from the online identity and is selected explicitly. + fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).expect("private offline state root"); + let offline = rustfs::connect::OfflineKeyStore::new(&state) + .load_or_create() + .expect("existing offline identity"); + let offline_key_id = hex_lower(&Sha256::digest(offline.public_key_der())); + let offline_output = temp.path().join("client-offline.zip"); + let mut command = Command::new(env!("CARGO_BIN_EXE_rustfs")); + command + .args(["connect", "performance", "client", "--state-dir"]) + .arg(&state) + .args([ + "--offline-key-id", + &offline_key_id, + "--endpoint", + &server.endpoint(), + "--access-key-file", + ]) + .arg(&access_key_file) + .arg("--secret-key-file") + .arg(&secret_key_file) + .arg("--output") + .arg(&offline_output) + .args(["--organization", organization, "--cluster", &cluster, "--device"]) + .arg(format!("{cluster}/clusterDevices/019e3ae0-0000-7000-8000-000000000012")) + .args([ + "--run-uid", + "019e3ae0-0000-7000-8000-000000000016", + "--artifact-uid", + "019e3ae0-0000-7000-8000-000000000017", + "--consent-uid", + "019e3ae0-0000-7000-8000-000000000018", + "--policy-revision", + "7", + "--consent-expires-at", + &(now() + 120).to_string(), + "--expires-at", + &(now() + 60).to_string(), + "--operation", + "get", + "--traffic-bytes", + "65536", + "--duration-millis", + "1000", + "--acknowledge-l1", + ]); + let result = tokio::task::spawn_blocking(move || command.output()) + .await + .expect("offline CLI task") + .expect("run production rustfs binary"); + assert!(result.status.success(), "stderr: {}", String::from_utf8_lossy(&result.stderr)); + assert!(String::from_utf8_lossy(&result.stdout).contains("upload=not-performed\n")); + let bytes = fs::read(&offline_output).expect("offline signed archive"); + let mut archive = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).expect("offline archive"); + let envelope_bytes = read_archive_member(&mut archive, "envelope.json"); + let signature_bytes = read_archive_member(&mut archive, "envelope.sig"); + let result_bytes = read_archive_member(&mut archive, "result.json"); + let envelope: serde_json::Value = serde_json::from_slice(&envelope_bytes).expect("offline envelope"); + let signed_result: serde_json::Value = serde_json::from_slice(&result_bytes).expect("offline result"); + assert_eq!(envelope["classification"], "L1"); + assert_eq!(envelope["deviceKeyId"], offline_key_id); + assert_eq!( + signed_result["provenance"]["executableSha256"], + sha256_file(Path::new(env!("CARGO_BIN_EXE_rustfs"))) + ); + let signature_document: serde_json::Value = serde_json::from_slice(&signature_bytes).expect("offline signature"); + let raw = URL_SAFE_NO_PAD + .decode_to_vec(signature_document["value"].as_str().expect("signature value")) + .expect("base64url signature"); + let signature = Signature::from_slice(&raw).expect("P-256 signature"); + let mut signed = b"rustfs-diagnostic-envelope-v1\0".to_vec(); + signed.extend_from_slice(&envelope_bytes); + VerifyingKey::from_public_key_der(&offline.public_key_der()) + .expect("offline public key") + .verify(&signed, &signature) + .expect("offline ES256 signature"); + assert!( + VerifyingKey::from_public_key_der(&identity.public_key_der()) + .expect("online public key") + .verify(&signed, &signature) + .is_err() + ); + } } fn write_credential(path: &Path, value: &str) {