diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 28ca888..281ceec 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,6 +9,11 @@ env: jobs: check-all: runs-on: ubuntu-latest + container: + image: ghcr.io/raspberry-devs/mina-multi-sig/frost-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - name: Build diff --git a/Cargo.lock b/Cargo.lock index 675aa47..b43e7a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,11 +1136,13 @@ dependencies = [ "frost-rerandomized", "hex", "itertools 0.14.0", + "lazy_static", "message-io", "postcard", "rand 0.8.5", "rand_core 0.6.4", "reddsa", + "regex", "reqwest", "rpassword", "serde", diff --git a/Cargo.toml b/Cargo.toml index 71fc5d0..05744cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ postcard = "1.1.1" rand = "0.8.5" rand_core = "0.6.4" reddsa = { git = "https://github.com/ZcashFoundation/reddsa.git", rev = "ed49e9ca0699a6450f6d4a9fe62ff168f5ea1ead" } +regex = "1.11.1" reqwest = { version = "0.12.12", default-features = false } rpassword = "7.3.1" serde = "1.0.204" diff --git a/frost-client/Cargo.toml b/frost-client/Cargo.toml index e8a7cd8..2975b75 100644 --- a/frost-client/Cargo.toml +++ b/frost-client/Cargo.toml @@ -43,4 +43,8 @@ thiserror = { workspace = true } zeroize = { workspace = true, features = ["serde", "zeroize_derive"] } message-io = { workspace = true } uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] } -rand_core = { workspace = true } +rand_core = { workspace = true } +lazy_static.workspace = true + +[dev-dependencies] +regex.workspace = true diff --git a/frost-client/tests/integration-tests.rs b/frost-client/tests/integration-tests.rs new file mode 100644 index 0000000..7ef83e1 --- /dev/null +++ b/frost-client/tests/integration-tests.rs @@ -0,0 +1,228 @@ +use lazy_static::lazy_static; +use regex::Regex; +use std::fs; +use std::io::Result; +use std::path::PathBuf; +use std::process; +use std::process::Command; +use std::process::Stdio; + +lazy_static! { + static ref binary_path: PathBuf = PathBuf::from(format!( + "{}/../target/release/{}", + env!("CARGO_MANIFEST_DIR"), + if cfg!(windows) { + "frost-client.exe" + } else { + "frost-client" + } + )); + static ref working_dir: PathBuf = + PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/assets")); +} + +#[derive(Debug)] +struct Pid { + toml: String, + contact: String, + pk: String, +} + +macro_rules! run_cli { + ( $args:expr ) => {{ + let status = Command::new(binary_path.clone()) + .args($args) + .stderr(Stdio::null()) // control verbosity by commenting line + .current_dir(working_dir.clone()) + .status() + .expect("subprocess failed"); + + assert!(status.success(), "CLI command failed: {:?}", $args); + }}; +} + +macro_rules! run_cli_wait { + ( $args:expr ) => {{ + Command::new(binary_path.clone()) + .args($args) + .stderr(Stdio::null()) // control verbosity by commenting line + .current_dir(working_dir.clone()) + .spawn()? + }}; +} + +macro_rules! run_cli_extract { + ( + args = [ $( $arg:expr ),* $(,)? ], + regexes = [ $( $regex:expr ),* $(,)? ] + ) => {{ + + let output = Command::new(binary_path.clone()) + .args([ $( $arg ),* ]) + .current_dir(working_dir.clone()) + .output() + .expect("subprocess failed"); + + assert!( + output.status.success(), + "CLI command failed: {:?}", + [ $( $arg ),* ] + ); + + let stderr_str = String::from_utf8_lossy(&output.stderr); + + ( + $({ + let re = Regex::new($regex).unwrap(); + let caps = re.captures(&stderr_str).unwrap(); + // if the regex has a capture group, return group 1; otherwise full match + if caps.len() > 1 { + caps[1].to_string() + } else { + caps[0].to_string() + } + }),* + ) + }}; +} + +/// Create a participant by making them a .toml file +fn introduce(name: &str) -> Result { + let toml = format!("{}.toml", name); + run_cli!(["init", "-c", &toml]); + + let (contact, pk) = run_cli_extract!( + args = ["export", "--name", name, "-c", &toml], + regexes = [r"(?m)^minafrost[^\r\n]*$", r"(?m)^Public Key: ([0-9a-f]+)$"] + ); + + Ok(Pid { toml, contact, pk }) +} + +// All participants exchange contact info +fn greet(pids: &[Pid]) -> Result<()> { + for Pid { toml, .. } in pids { + for Pid { + contact, + toml: b_toml, + .. + } in pids + { + if toml != b_toml { + run_cli!(["import", "-c", toml, contact]); + } + } + } + Ok(()) +} + +/// Errors if pids is empty +/// The first participant acts as coordinator and the rest join the session +/// t is the threshold +fn form_group(pids: &[Pid], threshold: usize) -> Result<()> { + let len = threshold.to_string(); + let pks: Vec<&str> = pids.iter().map(|Pid { pk, .. }| pk.as_str()).collect(); + + let mut children = Vec::new(); + + for (i, Pid { toml, .. }) in pids.iter().enumerate() { + let mut args = vec![ + "dkg", + "-d", + "Raspberry Devs", + "-s", + "localhost:2744", + "-t", + &len, + "-c", + toml, + ]; + if i == 0 { + // first participant plays coordinator, and also needs to know all public keys + for pk in &pks { + args.push("-S"); + args.push(pk); + } + } + children.push(run_cli_wait!(args)); + + // Have to make sure that the coordinator properly starts before the participant + // Otherwise the test will fail. So we hope 1 second is more than enough... + if i == 0 { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + for child in &mut children { + assert!(child + .wait() + .expect("participant subprocess didn't stop during group formation") + .success()); + } + Ok(()) +} + +fn cross_package_test(threshold: usize, max_signers: usize) -> Result<()> { + let mut server_process = setup()?; + + let pids = (0..max_signers) + .map(|x| x.to_string()) + .map(|x| introduce(&x)) + .collect::>>()?; + + greet(&pids)?; + + form_group(&pids, threshold)?; + + server_process.kill()?; + + Ok(()) +} + +#[test] +fn permute_cross_package_test() -> Result<()> { + // TODO for now there's just one cause it's quite slow + cross_package_test(5, 10) +} + +fn setup() -> Result { + // Clean up generated directory if it exists + if working_dir.exists() { + println!("Cleaning up existing generated directory..."); + fs::remove_dir_all(working_dir.clone())?; + } + // Create directory for generated files + fs::create_dir_all(working_dir.clone())?; + + // compile release binaries + let repo_root_path: PathBuf = PathBuf::from(format!("{}/..", env!("CARGO_MANIFEST_DIR"))); + let status = Command::new("cargo") + .args(["build", "--release"]) + .current_dir(repo_root_path) + .status() + .expect("Failed to build release binary"); + assert!(status.success(), "Release build failed"); + + // ensure mkcert certificates are installed everytime (required for docker) + Command::new("mkcert") + .arg("-install") + .output() + .expect("failed to run mkcert -install"); + + Command::new("mkcert") + .args(["localhost", "127.0.0.1", "::1"]) + .stderr(Stdio::null()) // discard stderr + .current_dir(working_dir.clone()) + .status()?; + + let tls_cert_path = working_dir.join("localhost+2.pem"); + let tls_key_path = working_dir.join("localhost+2-key.pem"); + + // Start frostd server in the background + Command::new("frostd") + .arg("--tls-cert") + .arg(&tls_cert_path) + .arg("--tls-key") + .arg(&tls_key_path) + .stderr(Stdio::null()) // discard stderr + .spawn() +}