myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 2b601b6c32d2c49b6f711e27b4f62303216345c4
parent 22fa4753f3a5ea0578870720945ea4b378ffbf3c
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 20:13:32 +0000

custody: harden external command execution

- drain helper pipes concurrently under one bounded deadline
- release Tokio workers and kill/reap timed-out helpers
- enforce output ceilings and zeroize copied request payloads
- redact helper diagnostics and cover deadlock/limit/runtime behavior

Diffstat:
MCargo.toml | 2+-
MREADME | 5+++++
Msrc/custody.rs | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/error.rs | 13+++++++++++--
4 files changed, 342 insertions(+), 47 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -31,7 +31,7 @@ radroots_sql_core = { version = "=1.0.0-alpha.1", path = "../lib/crates/sql_core serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" -tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1.48", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2.5" diff --git a/README b/README @@ -30,6 +30,11 @@ only when the author and event id match the exact request and the complete NIP-01 event verifies. Caller-supplied NIP-46 `sign_event` payloads remain a separate low-level interoperability boundary and receive the same exact-result integrity checks without acquiring a typed product-authoring claim. +The production external-command executor drains stdout and stderr concurrently, +enforces bounded output, applies one end-to-end timeout, kills and reaps timed +out helpers, and releases Myc's multi-thread Tokio worker while waiting. +Helper-provided stderr and protocol error text are never copied into operator +errors. Use the repository-owned Nix lanes for validation: diff --git a/src/custody.rs b/src/custody.rs @@ -1,9 +1,9 @@ use std::fs; use std::path::Path; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use nostr::nips::nip44::Version; use nostr::nips::{nip04, nip44}; @@ -17,6 +17,9 @@ use radroots_nostr_accounts::prelude::{ }; use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring}; use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::runtime::RuntimeFlavor; +use tokio::time::Instant as TokioInstant; use zeroize::Zeroizing; use crate::config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec}; @@ -266,6 +269,10 @@ struct MycExternalCommandOutput { enum MycExternalCommandExecuteError { Io(std::io::Error), TimedOut, + OutputLimitExceeded { + stream: &'static str, + limit_bytes: usize, + }, } trait MycExternalCommandExecutor: Send + Sync { @@ -280,52 +287,199 @@ trait MycExternalCommandExecutor: Send + Sync { #[derive(Debug, Default)] struct MycProcessCommandExecutor; -impl MycExternalCommandExecutor for MycProcessCommandExecutor { - fn execute( - &self, +const MYC_EXTERNAL_COMMAND_STDOUT_LIMIT_BYTES: usize = 1024 * 1024; +const MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES: usize = 64 * 1024; + +impl MycProcessCommandExecutor { + fn execute_on_runtime( command_path: &Path, request_json: &[u8], timeout: Duration, ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { - let mut child = Command::new(command_path) + match tokio::runtime::Handle::try_current() { + Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => { + tokio::task::block_in_place(|| { + handle.block_on(Self::execute_async(command_path, request_json, timeout)) + }) + } + Ok(_) => std::thread::scope(|scope| { + scope + .spawn(|| Self::execute_on_new_runtime(command_path, request_json, timeout)) + .join() + .map_err(|_| { + MycExternalCommandExecuteError::Io(std::io::Error::other( + "external custody command worker panicked", + )) + })? + }), + Err(_) => Self::execute_on_new_runtime(command_path, request_json, timeout), + } + } + + fn execute_on_new_runtime( + command_path: &Path, + request_json: &[u8], + timeout: Duration, + ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(MycExternalCommandExecuteError::Io)? + .block_on(Self::execute_async(command_path, request_json, timeout)) + } + + async fn execute_async( + command_path: &Path, + request_json: &[u8], + timeout: Duration, + ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { + let mut child = tokio::process::Command::new(command_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) + .kill_on_drop(true) .spawn() .map_err(MycExternalCommandExecuteError::Io)?; - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; + let mut stdin = child.stdin.take().ok_or_else(|| { + MycExternalCommandExecuteError::Io(std::io::Error::other( + "external custody command stdin was unavailable", + )) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + MycExternalCommandExecuteError::Io(std::io::Error::other( + "external custody command stdout was unavailable", + )) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + MycExternalCommandExecuteError::Io(std::io::Error::other( + "external custody command stderr was unavailable", + )) + })?; + let request_json = Zeroizing::new(request_json.to_vec()); + let mut stdin_task = tokio::spawn(async move { stdin - .write_all(request_json) + .write_all(&request_json) + .await .map_err(MycExternalCommandExecuteError::Io)?; - } - let deadline = Instant::now() + timeout; - loop { - match child - .try_wait() - .map_err(MycExternalCommandExecuteError::Io)? - { - Some(_) => break, - None if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(MycExternalCommandExecuteError::TimedOut); - } - None => std::thread::sleep(Duration::from_millis(10)), + stdin + .shutdown() + .await + .map_err(MycExternalCommandExecuteError::Io) + }); + let mut stdout_task = tokio::spawn(read_limited_external_command_output( + stdout, + "stdout", + MYC_EXTERNAL_COMMAND_STDOUT_LIMIT_BYTES, + )); + let mut stderr_task = tokio::spawn(read_limited_external_command_output( + stderr, + "stderr", + MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES, + )); + let deadline = TokioInstant::now() + timeout; + + let status = match tokio::time::timeout_at(deadline, child.wait()).await { + Ok(Ok(status)) => status, + Ok(Err(source)) => { + abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task); + return Err(MycExternalCommandExecuteError::Io(source)); } - } - let output = child - .wait_with_output() - .map_err(MycExternalCommandExecuteError::Io)?; + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task); + return Err(MycExternalCommandExecuteError::TimedOut); + } + }; + + let collect_output = async { + let (stdin_result, stdout_result, stderr_result) = + tokio::join!(&mut stdin_task, &mut stdout_task, &mut stderr_task); + flatten_external_command_task(stdin_result)?; + let stdout = flatten_external_command_task(stdout_result)?; + let stderr = flatten_external_command_task(stderr_result)?; + Ok((stdout, stderr)) + }; + let (stdout, stderr) = match tokio::time::timeout_at(deadline, collect_output).await { + Ok(result) => result?, + Err(_) => { + abort_external_command_io_tasks(&stdin_task, &stdout_task, &stderr_task); + return Err(MycExternalCommandExecuteError::TimedOut); + } + }; + Ok(MycExternalCommandOutput { - success: output.status.success(), - status: output.status.code(), - stdout: output.stdout, - stderr: output.stderr, + success: status.success(), + status: status.code(), + stdout, + stderr, }) } } +impl MycExternalCommandExecutor for MycProcessCommandExecutor { + fn execute( + &self, + command_path: &Path, + request_json: &[u8], + timeout: Duration, + ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> { + Self::execute_on_runtime(command_path, request_json, timeout) + } +} + +async fn read_limited_external_command_output<R>( + mut reader: R, + stream: &'static str, + limit_bytes: usize, +) -> Result<Vec<u8>, MycExternalCommandExecuteError> +where + R: AsyncRead + Unpin, +{ + let mut output = Vec::with_capacity(limit_bytes.min(8 * 1024)); + let mut buffer = [0_u8; 8 * 1024]; + let mut exceeded_limit = false; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(MycExternalCommandExecuteError::Io)?; + if count == 0 { + break; + } + let remaining = limit_bytes.saturating_sub(output.len()); + output.extend_from_slice(&buffer[..count.min(remaining)]); + exceeded_limit |= count > remaining; + } + if exceeded_limit { + return Err(MycExternalCommandExecuteError::OutputLimitExceeded { + stream, + limit_bytes, + }); + } + Ok(output) +} + +fn flatten_external_command_task<T>( + result: Result<Result<T, MycExternalCommandExecuteError>, tokio::task::JoinError>, +) -> Result<T, MycExternalCommandExecuteError> { + result.map_err(|_| { + MycExternalCommandExecuteError::Io(std::io::Error::other( + "external custody command I/O task failed", + )) + })? +} + +fn abort_external_command_io_tasks<T, U, V>( + stdin_task: &tokio::task::JoinHandle<T>, + stdout_task: &tokio::task::JoinHandle<U>, + stderr_task: &tokio::task::JoinHandle<V>, +) { + stdin_task.abort(); + stdout_task.abort(); + stderr_task.abort(); +} + trait MycIdentityOperations: Send + Sync { fn nostr_client(&self) -> RadrootsNostrClient; fn nostr_client_owned(&self) -> RadrootsNostrClient; @@ -494,9 +648,17 @@ impl MycExternalCommandIdentityOperations { timeout_secs: self.timeout.as_secs(), } } + MycExternalCommandExecuteError::OutputLimitExceeded { + stream, + limit_bytes, + } => MycError::CustodyExternalCommandOutputLimit { + role: self.role.clone(), + path: self.command_path.clone(), + stream, + limit_bytes, + }, })?; if !output.success { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); return Err(MycError::CustodyExternalCommandFailed { role: self.role.clone(), path: self.command_path.clone(), @@ -504,10 +666,10 @@ impl MycExternalCommandIdentityOperations { .status .map(|status| status.to_string()) .unwrap_or_else(|| "terminated by signal".to_owned()), - stderr: if stderr.is_empty() { - "external signer command failed without stderr".to_owned() + diagnostic: if output.stderr.is_empty() { + "helper returned no diagnostic output" } else { - stderr + "helper diagnostic output was redacted" }, }); } @@ -519,12 +681,12 @@ impl MycExternalCommandIdentityOperations { source, } })?; - if let Some(error) = response.error.as_deref() { + if response.error.is_some() { return Err(MycError::CustodyExternalCommandFailed { role: self.role.clone(), path: self.command_path.clone(), status: "0".to_owned(), - stderr: error.to_owned(), + diagnostic: "helper reported a protocol error", }); } Ok(response) @@ -1209,9 +1371,17 @@ impl MycIdentityProvider { timeout_secs: timeout.as_secs(), } } + MycExternalCommandExecuteError::OutputLimitExceeded { + stream, + limit_bytes, + } => MycError::CustodyExternalCommandOutputLimit { + role: self.role.clone(), + path: command_path.to_path_buf(), + stream, + limit_bytes, + }, })?; if !output.success { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); return Err(MycError::CustodyExternalCommandFailed { role: self.role.clone(), path: command_path.to_path_buf(), @@ -1219,10 +1389,10 @@ impl MycIdentityProvider { .status .map(|status| status.to_string()) .unwrap_or_else(|| "terminated by signal".to_owned()), - stderr: if stderr.is_empty() { - "external signer command failed without stderr".to_owned() + diagnostic: if output.stderr.is_empty() { + "helper returned no diagnostic output" } else { - stderr + "helper diagnostic output was redacted" }, }); } @@ -1234,12 +1404,12 @@ impl MycIdentityProvider { source, } })?; - if let Some(error) = response.error { + if response.error.is_some() { return Err(MycError::CustodyExternalCommandFailed { role: self.role.clone(), path: command_path.to_path_buf(), status: "0".to_owned(), - stderr: error, + diagnostic: "helper reported a protocol error", }); } let identity = @@ -1757,6 +1927,33 @@ mod tests { } #[cfg(unix)] + fn write_output_helper(path: &Path) { + let script = "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do\n printf '0123456789abcdef\\n'\n printf 'err\\n' >&2\n i=$((i + 1))\ndone\n"; + fs::write(path, script).expect("write helper"); + let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("helper permissions"); + } + + #[cfg(unix)] + fn write_delayed_helper(path: &Path) { + let script = "#!/bin/sh\nsleep 1\nprintf '{}'\n"; + fs::write(path, script).expect("write helper"); + let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("helper permissions"); + } + + #[cfg(unix)] + fn write_excess_output_helper(path: &Path) { + let script = "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 17000 ]; do\n printf 'err\\n' >&2\n i=$((i + 1))\ndone\n"; + fs::write(path, script).expect("write helper"); + let mut permissions = fs::metadata(path).expect("helper metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("helper permissions"); + } + + #[cfg(unix)] fn process_exists(pid: u32) -> bool { Command::new("kill") .arg("-0") @@ -2512,6 +2709,90 @@ mod tests { )); } + #[tokio::test] + async fn external_command_output_reader_enforces_its_byte_limit() { + let (mut writer, reader) = tokio::io::duplex(128); + let writer_task = tokio::spawn(async move { + let _ = writer.write_all(&[b'x'; 65]).await; + }); + + let error = read_limited_external_command_output(reader, "stdout", 64) + .await + .expect_err("output limit"); + writer_task.await.expect("writer task"); + + assert!(matches!( + error, + MycExternalCommandExecuteError::OutputLimitExceeded { + stream: "stdout", + limit_bytes: 64, + } + )); + } + + #[cfg(unix)] + #[test] + fn process_executor_drains_stdout_and_stderr_while_the_helper_runs() { + let temp = tempfile::tempdir().expect("tempdir"); + let helper_path = temp.path().join("output-helper.sh"); + write_output_helper(&helper_path); + + let output = MycProcessCommandExecutor + .execute(&helper_path, b"{}", Duration::from_secs(5)) + .expect("helper output"); + + assert!(output.success); + assert_eq!(output.stdout.len(), 85_000); + assert_eq!(output.stderr.len(), 20_000); + } + + #[cfg(unix)] + #[test] + fn process_executor_reports_output_limits_without_pipe_deadlock() { + let temp = tempfile::tempdir().expect("tempdir"); + let helper_path = temp.path().join("excess-output-helper.sh"); + write_excess_output_helper(&helper_path); + + let error = MycProcessCommandExecutor + .execute(&helper_path, b"{}", Duration::from_secs(5)) + .expect_err("stderr output limit"); + + assert!(matches!( + error, + MycExternalCommandExecuteError::OutputLimitExceeded { + stream: "stderr", + limit_bytes: MYC_EXTERNAL_COMMAND_STDERR_LIMIT_BYTES, + } + )); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn process_executor_releases_the_tokio_worker_while_waiting() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let temp = tempfile::tempdir().expect("tempdir"); + let helper_path = temp.path().join("delayed-helper.sh"); + write_delayed_helper(&helper_path); + let made_progress = Arc::new(AtomicBool::new(false)); + let task_progress = made_progress.clone(); + let progress_task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + task_progress.store(true, Ordering::SeqCst); + }); + + let output = MycProcessCommandExecutor + .execute(&helper_path, b"{}", Duration::from_secs(5)) + .expect("helper output"); + progress_task.await.expect("progress task"); + + assert!(output.success); + assert!( + made_progress.load(Ordering::SeqCst), + "the Tokio worker must continue scheduling while custody waits" + ); + } + #[cfg(unix)] #[test] fn process_executor_times_out_and_kills_real_helper() { @@ -2537,7 +2818,7 @@ mod tests { // Give the real helper a little slack to create its pid file under a busy full-test run // before we conclude the timeout path never launched it. - let pid_deadline = Instant::now() + timeout + Duration::from_secs(5); + let pid_deadline = Instant::now() + timeout + Duration::from_secs(20); let pid = loop { match fs::read_to_string(&pid_path) { Ok(value) => match value.trim().parse::<u32>() { diff --git a/src/error.rs b/src/error.rs @@ -217,13 +217,22 @@ pub enum MycError { timeout_secs: u64, }, #[error( - "external custody command for {role} identity at {path} failed with status {status}: {stderr}" + "external custody command for {role} identity at {path} exceeded the {stream} output limit of {limit_bytes} bytes" + )] + CustodyExternalCommandOutputLimit { + role: String, + path: PathBuf, + stream: &'static str, + limit_bytes: usize, + }, + #[error( + "external custody command for {role} identity at {path} failed with status {status}: {diagnostic}" )] CustodyExternalCommandFailed { role: String, path: PathBuf, status: String, - stderr: String, + diagnostic: &'static str, }, #[error( "external custody command response parse error for {role} identity at {path}: {source}"