diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index aa46da6a..1e89aede 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -56,7 +56,7 @@ jobs: - ubuntu-latest - windows-latest rust: - - 1.56.0 # MSRV + - 1.58.0 # MSRV - stable - nightly steps: @@ -118,12 +118,13 @@ jobs: command: build args: --bins --locked --release - name: Upload artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: wormhole-${{ matrix.os }}-${{ github.sha }} - path: ./target/release/wormhole-rs{,.exe} + path: | + ./target/release/wormhole-rs + ./target/release/wormhole-rs.exe if-no-files-found: error - continue-on-error: true coverage: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 69079458..dfa28543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,15 +36,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi 0.3.9", -] - [[package]] name = "async-attributes" version = "1.1.2" @@ -426,17 +417,33 @@ dependencies = [ [[package]] name = "clap" -version = "2.34.0" +version = "3.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +checksum = "d8c93436c21e4698bacadf42917db28b23017027a4deccb35dbe47a7e7840123" dependencies = [ - "ansi_term", "atty", "bitflags", - "strsim 0.8.0", + "clap_derive", + "indexmap", + "lazy_static", + "os_str_bytes", + "strsim 0.10.0", + "termcolor", + "terminal_size", "textwrap", - "unicode-width", - "vec_map", +] + +[[package]] +name = "clap_derive" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95d038ede1a964ce99f49cbe27a7fb538d1da595e4b4f70b8c8f338d17bf16" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -675,6 +682,26 @@ dependencies = [ "subtle 2.4.1", ] +[[package]] +name = "directories" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780" +dependencies = [ + "libc", + "redox_users", + "winapi 0.3.9", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -952,6 +979,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -1069,6 +1108,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "indexmap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" +dependencies = [ + "autocfg 1.1.0", + "hashbrown", +] + [[package]] name = "indicatif" version = "0.16.2" @@ -1164,6 +1213,7 @@ dependencies = [ "ctrlc", "derive_more", "dialoguer", + "directories", "env_logger", "eyre", "futures", @@ -1327,6 +1377,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "os_str_bytes" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" +dependencies = [ + "memchr", +] + [[package]] name = "owo-colors" version = "3.2.0" @@ -1387,6 +1446,30 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.36" @@ -1559,6 +1642,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + [[package]] name = "regex" version = "1.5.5" @@ -1829,15 +1922,15 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "strsim" -version = "0.8.0" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" [[package]] name = "strsim" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "stun_codec" @@ -1922,11 +2015,11 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.11.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" dependencies = [ - "unicode-width", + "terminal_size", ] [[package]] @@ -2164,12 +2257,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "vec_map" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" - [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 20d40658..85a72295 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,13 +50,14 @@ stun_codec = "0.1.13" bytecodec = "0.4.15" # for "bin" feature -clap = { version = "2.34.0", optional = true } +clap = { version = "3.1.5", optional = true, features = ["cargo", "derive", "wrap_help"] } env_logger = { version = "0.9.0", optional = true } console = { version = "0.15.0", optional = true } indicatif = { version = "0.16.0", optional = true } dialoguer = { version = "0.10.0", optional = true } color-eyre = { version = "0.6.0", optional = true } number_prefix = { version = "0.4.0", optional = true } +directories = { version = "4.0.1", optional = true } url = { version = "2.2.2", features = ["serde"] } uri = "0.4.0" rmp-serde = "0.15.5" @@ -69,7 +70,7 @@ env_logger = "0.9.0" eyre = "0.6.5" [features] -bin = ["clap", "env_logger", "console", "indicatif", "dialoguer", "color-eyre", "number_prefix"] +bin = ["clap", "env_logger", "console", "indicatif", "dialoguer", "color-eyre", "number_prefix", "directories"] # TODO remove this one day # - Removing it now requires all cargo calls to have --features=bin which is annoying # - There is a cargo issue that would allow proper bin dependencies and thus would resolve it @@ -83,3 +84,5 @@ required-features = ["bin"] [profile.release] overflow-checks = true +strip = "debuginfo" +lto = "thin" diff --git a/changelog.md b/changelog.md index 79e1a218..9f642bc1 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Unreleased +- Added a contact book and session resumption + - If you send a file to somebody, a "seed" will be stored on both sides. + - Afterwards, you can you `wormhole send --to ` and `wormhole receive --from ` + - The secure connection will then be established without having to enter a code. + ## Version 0.3.0 - Added experimental port forwarding feature diff --git a/src/bin/main.rs b/src/bin/main.rs index 2ff8937a..eed62eb0 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,3 +1,4 @@ +mod seeds; mod util; use std::{ @@ -6,15 +7,17 @@ use std::{ }; use async_std::{fs::OpenOptions, sync::Arc}; -use clap::{crate_description, crate_name, crate_version, App, AppSettings, Arg, SubCommand}; +use clap::{Args, Parser, Subcommand}; use color_eyre::{eyre, eyre::Context}; use console::{style, Term}; use futures::{future::Either, Future, FutureExt}; use indicatif::{MultiProgress, ProgressBar}; -use std::io::Write; +use std::{ + io::Write, + path::{Path, PathBuf}, +}; use magic_wormhole::{forwarding, transfer, transit, Wormhole}; -use std::str::FromStr; fn install_ctrlc_handler( ) -> eyre::Result futures::future::BoxFuture<'static, ()> + Clone> { @@ -53,225 +56,299 @@ fn install_ctrlc_handler( }) } +// send, send-many +#[derive(Debug, Args)] +struct CommonSenderArgs { + /// Suggest a different name to the receiver to keep the file's actual name secret. + #[clap(long = "rename", visible_alias = "name", value_name = "FILE_NAME")] + file_name: Option, + #[clap(index = 1, required = true, value_name = "FILENAME|DIRNAME")] + file: PathBuf, +} + +// send, send-many, serve +#[derive(Debug, Args)] +struct CommonLeaderArgs { + /// Enter a code instead of generating one automatically + #[clap(long, value_name = "CODE")] + code: Option, + /// Length of code (in bytes/words) + #[clap( + short = 'c', + long, + value_name = "NUMWORDS", + default_value = "2", + conflicts_with = "code" + )] + code_length: usize, + /// Send to one of your contacts instead of using a code + #[clap(long, value_name = "NAME", conflicts_with_all = &["code", "code-length"])] + to: Option, +} + +impl CommonLeaderArgs { + fn into_connect_options<'a, 'b>( + self, + seeds: &'a seeds::Database, + follower_command: &'b str, + ) -> eyre::Result> { + Ok(match (self.code, self.to) { + (None, None) => ConnectOptions::GenerateCode { + size: self.code_length, + follower_command, + }, + (Some(code), None) => ConnectOptions::ProvideCode(code), + (None, Some(to)) if to == "myself" => ConnectOptions::ProvideSeed { + seed: seeds.myself.into(), + follower_command: Some(follower_command), + }, + (None, Some(to)) => { + let peer = seeds + .find(&to) + .ok_or_else(|| eyre::format_err!("Contact '{to}' not found"))?; + ConnectOptions::ProvideSeed { + seed: peer.seed.into(), + follower_command: Some(follower_command), + } + }, + (Some(_), Some(_)) => unreachable!(), + }) + } +} + +// receive +#[derive(Debug, Args)] +struct CommonReceiverArgs { + /// Rename the received file or folder, overriding the name suggested by the sender. + #[clap(long = "rename", visible_alias = "name", value_name = "FILE_NAME")] + file_name: Option, + /// Store transferred file or folder in the specified directory. Defaults to $PWD. + #[clap(long = "out-dir", value_name = "PATH", default_value = ".")] + file_path: PathBuf, +} + +// receive, connect +#[derive(Debug, Args)] +struct CommonFollowerArgs { + /// Provide the code now rather than typing it interactively + #[clap(value_name = "CODE")] + code: Option, + /// Receive from one of your contacts instead of using a code + #[clap(long, value_name = "NAME", conflicts_with = "code")] + from: Option, +} + +impl CommonFollowerArgs { + fn into_connect_options<'a, 'b>( + self, + seeds: &'a seeds::Database, + ) -> eyre::Result> { + Ok(match (self.code, self.from) { + (None, None) => ConnectOptions::EnterCode, + (Some(code), None) => ConnectOptions::ProvideCode(code), + (None, Some(from)) if from == "myself" => ConnectOptions::ProvideSeed { + seed: seeds.myself.into(), + follower_command: None, + }, + (None, Some(from)) => { + let peer = seeds + .find(&from) + .ok_or_else(|| eyre::format_err!("Contact '{from}' not found"))?; + ConnectOptions::ProvideSeed { + seed: peer.seed.into(), + follower_command: None, + } + }, + (Some(_), Some(_)) => unreachable!(), + }) + } +} + +// send, send-mane, receive, serve, connect +#[derive(Debug, Clone, Args)] +struct CommonArgs { + /// Use a custom relay server (specify multiple times for multiple relays) + #[clap( + long = "relay-server", + visible_alias = "relay", + multiple_occurrences = true, + value_name = "tcp://HOSTNAME:PORT" + )] + relay_server: Vec, + /// Use a custom rendezvous server. Both sides need to use the same value in order to find each other. + #[clap(long, value_name = "ws://example.org")] + rendezvous_server: Option, +} + +#[derive(Debug, Subcommand)] +#[clap(arg_required_else_help = true)] +enum ForwardCommand { + /// Make the following ports of your system available to your peer + #[clap( + visible_alias = "open", + alias = "server", /* Muscle memory <3 */ + mut_arg("help", |a| a.help("Print this help message")), + )] + Serve { + /// List of ports to open up. You can optionally specify a domain/address to forward remote ports + #[clap(value_name = "[DOMAIN:]PORT", multiple_occurrences = true)] + targets: Vec, + #[clap(flatten)] + common: CommonArgs, + #[clap(flatten)] + common_leader: CommonLeaderArgs, + }, + /// Connect to some ports forwarded to you + #[clap( + mut_arg("help", |a| a.help("Print this help message")), + )] + Connect { + /// Bind to specific ports instead of taking random free high ports. Can be provided multiple times. + #[clap( + short = 'p', + long = "port", + multiple_occurrences = true, + value_name = "PORT" + )] + ports: Vec, + /// Bind to a specific address to accept the forwarding. Depending on your system and firewall, this may make the forwarded ports accessible from the outside. + #[clap(long = "bind", value_name = "ADDRESS", default_value = "::")] + bind_address: std::net::IpAddr, + /// Accept the forwarding without asking for confirmation + #[clap(long, visible_alias = "yes")] + noconfirm: bool, + #[clap(flatten)] + common: CommonArgs, + #[clap(flatten)] + common_follower: CommonFollowerArgs, + }, +} + +#[derive(Debug, Subcommand)] +#[clap(arg_required_else_help = true)] +enum ContactCommand { + /// List your existing contacts + #[clap( + visible_alias = "show", + mut_arg("help", |a| a.help("Print this help message")), + )] + List, + /// Store a previously made connection in your contacts + #[clap( + mut_arg("help", |a| a.help("Print this help message")), + )] + Add { + /// The ID of the previous connection + #[clap(value_name = "ID")] + id: String, + /// The name under which to add the contact + #[clap(value_name = "NAME")] + name: String, + /// Overwrite contacts with the same name + #[clap(short, long)] + force: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum WormholeCommand { + /// Send a file or a folder + #[clap( + visible_alias = "tx", + mut_arg("help", |a| a.help("Print this help message")), + )] + Send { + /// The file or directory to send + #[clap(flatten)] + common: CommonArgs, + #[clap(flatten)] + common_leader: CommonLeaderArgs, + #[clap(flatten)] + common_send: CommonSenderArgs, + }, + /// Send a file to many recipients. READ HELP PAGE FIRST! + #[clap( + mut_arg("help", |a| a.help("Print this help message")), + after_help = "This works by sending the file in a loop with the same code over \ + and over again. Note that this also gives an attacker multiple tries \ + to guess the code, whereas normally they have only one. This can be \ + countered by using a longer than usual code (default 4 bytes entropy).\n\n\ + The application terminates on interruption, after a timeout or after a + number of sent files, whichever comes first. It will always try to send + at least one file, regardless of the limits." + )] + SendMany { + /// Only send the file up to n times, limiting the number of people that may receive it. + /// These are also the number of tries a potential attacker gets at guessing the password. + #[clap(short = 'n', long, value_name = "N", default_value = "30")] + tries: u64, + /// Automatically stop providing the file after a certain amount of time. + #[clap(long, value_name = "MINUTES", default_value = "60")] + timeout: u64, + #[clap(flatten)] + common: CommonArgs, + #[clap(flatten)] + common_leader: CommonLeaderArgs, + #[clap(flatten)] + common_send: CommonSenderArgs, + }, + /// Receive a file or a folder + #[clap( + visible_alias = "rx", + mut_arg("help", |a| a.help("Print this help message")), + )] + Receive { + /// Accept file transfer without asking for confirmation + #[clap(long, visible_alias = "yes")] + noconfirm: bool, + #[clap(flatten)] + common: CommonArgs, + #[clap(flatten)] + common_follower: CommonFollowerArgs, + #[clap(flatten)] + common_receiver: CommonReceiverArgs, + }, + /// Forward ports from one machine to another + #[clap(subcommand)] + Forward(ForwardCommand), + /// Manage your contacts to which you may send files without having to enter a code + #[clap(subcommand, visible_alias = "contact")] + Contacts(ContactCommand), + #[clap(hide = true)] + Help, +} + +#[derive(Debug, Parser)] +#[clap( + version, + author, + about, + arg_required_else_help = true, + disable_help_subcommand = true, + propagate_version = true, + after_help = "Run a subcommand with `--help` to know how it's used.\n\ + To send files, use `wormhole send `.\n\ + To receive files, use `wormhole receive `.", + mut_arg("help", |a| a.help("Print this help message")), +)] +struct WormholeCli { + /// Enable logging to stdout, for debugging purposes + #[clap(short = 'v', long = "verbose", alias = "log", global = true)] + log: bool, + #[clap(subcommand)] + command: WormholeCommand, +} + #[async_std::main] async fn main() -> eyre::Result<()> { color_eyre::install()?; let ctrl_c = install_ctrlc_handler()?; - /* Define some common arguments first */ - - let relay_server_arg = Arg::with_name("relay-server") - .long("relay-server") - .visible_alias("relay") - .takes_value(true) - .multiple(true) - .value_name("tcp://HOSTNAME:PORT") - .help("Use a custom relay server (specify multiple times for multiple relays)"); - let rendezvous_server_arg = Arg::with_name("rendezvous-server") - .long("rendezvous-server") - .takes_value(true) - .value_name("ws://example.org") - .help("Use a custom rendezvous server. Both sides need to use the same value in order to find each other."); - let log_arg = Arg::with_name("log") - .short("-v") - .long("verbose") - .alias("log") // Legacy, remove in the future - .global(true) - .help("Enable logging to stdout, for debugging purposes"); - let code_length_arg = Arg::with_name("code-length") - .short("c") - .long("code-length") - .takes_value(true) - .value_name("NUMWORDS") - .default_value("2") - .help("Length of code (in bytes/words)"); - /* Use in send commands */ - let file_name = Arg::with_name("file-name") - .long("rename") - .visible_alias("name") - .takes_value(true) - .value_name("FILE_NAME") - .help("Suggest a different name to the receiver to keep the file's actual name secret."); - let code_send = Arg::with_name("code") - .long("code") - .takes_value(true) - .value_name("CODE") - .help("Enter a code instead of generating one automatically"); - /* Use in receive commands */ - let code = Arg::with_name("code") - .index(1) - .value_name("CODE") - .help("Provide the code now rather than typing it interactively"); - let file_rename = Arg::with_name("file-name") - .long("rename") - .visible_alias("name") - .takes_value(true) - .value_name("FILE_NAME") - .help("Rename the received file or folder, overriding the name suggested by the sender."); - let file_path = Arg::with_name("file-path") - .long("out-dir") - .takes_value(true) - .value_name("PATH") - .required(true) - .default_value(".") - .help("Store transferred file or folder in the specified directory. Defaults to $PWD."); - - /* The subcommands here */ - - let send_command = SubCommand::with_name("send") - .visible_alias("tx") - .about("Send a file or a folder") - .arg(code_length_arg.clone()) - .arg(code_send.clone()) - .arg(relay_server_arg.clone()) - .arg(rendezvous_server_arg.clone()) - .arg(file_name.clone()) - .arg( - Arg::with_name("file") - .index(1) - .required(true) - .value_name("FILENAME|DIRNAME") - .help("The file or directory to send"), - ) - .help_message("Print this help message"); - let send_many_command = SubCommand::with_name("send-many") - .about("Send a file to many recipients. READ HELP PAGE FIRST!") - .after_help( - "This works by sending the file in a loop with the same code over \ - and over again. Note that this also gives an attacker multiple tries \ - to guess the code, whereas normally they have only one. This can be \ - countered by using a longer than usual code (default 4 bytes entropy).\n\n\ - The application terminates on interruption, after a timeout or after a - number of sent files, whichever comes first. It will always try to send - at least one file, regardless of the limits.", - ) - .arg(code_length_arg.clone().default_value("4")) - .arg( - Arg::with_name("code") - .long("code") - .takes_value(true) - .value_name("CODE") - .help("Enter a code instead of generating one automatically"), - ) - .arg(relay_server_arg.clone()) - .arg(rendezvous_server_arg.clone()) - .arg(file_name) - .arg( - Arg::with_name("file") - .index(1) - .required(true) - .value_name("FILENAME|DIRNAME") - .help("The file or directory to send"), - ) - .arg( - Arg::with_name("tries") - .long("tries") - .short("n") - .takes_value(true) - .value_name("N") - .default_value("30") - .help("Only send the file up to n times, limiting the number of people that may receive it. \ - These are also the number of tries a potential attacker gets at guessing the password."), - ) - .arg( - Arg::with_name("timeout") - .long("timeout") - .takes_value(true) - .value_name("MINUTES") - .default_value("60") - .help("Automatically stop providing the file after a certain amount of time."), - ) - .help_message("Print this help message"); - let receive_command = SubCommand::with_name("receive") - .visible_alias("rx") - .about("Receive a file or a folder") - .arg( - Arg::with_name("noconfirm") - .long("noconfirm") - .visible_alias("yes") - .help("Accept file transfer without asking for confirmation"), - ) - .arg(file_rename) - .arg(file_path) - .arg(code.clone()) - .arg(relay_server_arg) - .arg(rendezvous_server_arg) - .help_message("Print this help message"); - let forward_command = SubCommand::with_name("forward") - .about("Forward ports from one machine to another") - .setting(AppSettings::ArgRequiredElseHelp) - .subcommand(SubCommand::with_name("serve") - .visible_alias("open") - .alias("server") /* Muscle memory <3 */ - .about("Make the following ports of your system available to your peer") - .arg( - Arg::with_name("targets") - .index(1) - .multiple(true) - .required(true) - .value_name("[DOMAIN:]PORT") - .help("List of ports to open up. You can optionally specify a domain/address to forward remote ports") - ) - .arg(code_length_arg) - .arg(code_send) - ) - .subcommand(SubCommand::with_name("connect") - .about("Connect to some ports forwarded to you") - .arg(code) - .arg( - Arg::with_name("port") - .long("port") - .short("p") - .takes_value(true) - .multiple(true) - .value_name("PORT") - .help("Bind to specific ports instead of taking random free high ports. Can be provided multiple times.") - ) - .arg( - Arg::with_name("bind") - .long("bind") - .takes_value(true) - .value_name("ADDRESS") - .default_value("::") - .help("Bind to a specific address to accept the forwarding. Depending on your system and firewall, this may make the forwarded ports accessible from the outside.") - ) - .arg( - Arg::with_name("noconfirm") - .long("noconfirm") - .visible_alias("yes") - .help("Accept the forwarding without asking for confirmation"), - ) - ) - .subcommand(SubCommand::with_name("help").setting(AppSettings::Hidden)) - .help_message("Print this help message"); - - /* The Clap application */ - let clap = App::new(crate_name!()) - .version(crate_version!()) - .about(crate_description!()) - .setting(AppSettings::ArgRequiredElseHelp) - .global_setting(AppSettings::DisableHelpSubcommand) - .global_setting(AppSettings::VersionlessSubcommands) - .global_setting(AppSettings::ColoredHelp) - .global_setting(AppSettings::ColorAuto) - .global_setting(AppSettings::UnifiedHelpMessage) - .after_help( - "Run a subcommand with `--help` to know how it's used.\n\ - To send files, use `wormhole send `.\n\ - To receive files, use `wormhole receive `.", - ) - .subcommand(send_command) - .subcommand(send_many_command) - .subcommand(receive_command) - .subcommand(forward_command) - .subcommand(SubCommand::with_name("help").setting(AppSettings::Hidden)) - .arg(log_arg) - .help_message("Print this help message"); - let matches = clap.get_matches(); + let app = WormholeCli::parse(); let mut term = Term::stdout(); - if matches.is_present("log") { + if app.log { env_logger::builder() .filter_level(log::LevelFilter::Debug) .filter_module("magic_wormhole::core", log::LevelFilter::Trace) @@ -288,19 +365,61 @@ async fn main() -> eyre::Result<()> { .try_init()?; } - let file_name = |file_path| { + let directories = directories::ProjectDirs::from("io", "magic-wormhole", "wormhole-rs") + .ok_or_else(|| eyre::format_err!("Could not find the data storage location"))?; + std::fs::create_dir_all(directories.data_dir()).context(format!( + "Failed to create data dir at '{}'", + directories.data_dir().display() + ))?; + let database_path = directories.data_dir().join("seeds.json"); + let mut seeds = if database_path.exists() { + seeds::Database::load(&database_path).context(format!( + "Failed to load '{}'. Please delete or fix it and try again", + database_path.display() + ))? + } else { + let mut seeds = seeds::Database::default(); + seeds.myself = rand::random(); + seeds + }; + if seeds.our_names.is_empty() { + let username = + std::env::var("USER").context("Failed to fetch $USER environment variable")?; + log::warn!( + "No name configured yet. You will be identified to the other side as '{username}'." + ); + log::warn!("If you are not comfortable with this, abort and use `wormhole-rs TODO` to set a differnt name. This warning won't be shown again."); + seeds.our_names.push(username); + } + { + let now = std::time::SystemTime::now(); + let old_size = seeds.peers.len(); + seeds.peers.retain(|_, peer| peer.expires() >= now); + let new_size = seeds.peers.len(); + if old_size > new_size { + log::info!("Removed {} old contacts from database", old_size - new_size); + } + } + seeds.save(&database_path).context(format!( + "Failed to write seeds database to '{}'", + &database_path.display() + ))?; + let seed_ability = magic_wormhole::SeedAbility:: { + display_names: seeds.our_names.clone(), + known_seeds: seeds.iter_known_peers().collect(), + }; + + let concat_file_name = |file_path: &Path, file_name: Option<_>| { // TODO this has gotten out of hand (it ugly) // The correct solution would be to make `file_name` an Option everywhere and // move the ".tar" part further down the line. // The correct correct solution would be to have working file transfer instead // of sending stupid archives. - matches - .value_of_os("file-name") + file_name .map(std::ffi::OsString::from) .or_else(|| { - let path = std::path::Path::new(file_path); - let mut name = path.file_name().map(std::ffi::OsString::from); - if path.is_dir() { + let mut name = file_path.file_name().map(std::ffi::OsString::from); + if file_path.is_dir() { name = name.map(|mut name| { name.push(".tar"); name @@ -313,106 +432,137 @@ async fn main() -> eyre::Result<()> { }) }; - /* Handling of the argument matches (one branch per subcommand) */ + match app.command { + WormholeCommand::Send { + common, + common_leader, + common_send: + CommonSenderArgs { + file_name, + file: file_path, + }, + .. + } => { + let file_name = concat_file_name(&file_path, file_name.as_ref())?; - if let Some(matches) = matches.subcommand_matches("send") { - let file_path = matches.value_of_os("file").unwrap(); - let file_name = file_name(file_path)?; + eyre::ensure!(file_path.exists(), "{} does not exist", file_path.display()); - eyre::ensure!( - std::path::Path::new(file_path).exists(), - "{:?} does not exist", - file_path - ); + let (wormhole, _code, relay_server) = match util::cancellable( + parse_and_connect( + &mut term, + common, + common_leader.into_connect_options(&seeds, "receive")?, + transfer::APP_CONFIG, + Some(seed_ability), + &mut seeds, + &database_path, + ), + ctrl_c(), + ) + .await + { + Ok(result) => result?, + Err(_) => return Ok(()), + }; - let (wormhole, _code, relay_server) = match util::cancellable( - parse_and_connect( - &mut term, - matches, - true, - transfer::APP_CONFIG, - Some(&sender_print_code), - ), - ctrl_c(), - ) - .await - { - Ok(result) => result?, - Err(_) => return Ok(()), - }; + send( + wormhole, + relay_server, + file_path.as_ref(), + &file_name, + ctrl_c.clone(), + ) + .await?; + }, + WormholeCommand::SendMany { + tries, + timeout, + common, + common_leader, + common_send: CommonSenderArgs { file_name, file }, + } => { + let (wormhole, code, relay_server) = { + let connect_fut = parse_and_connect( + &mut term, + common, + common_leader.into_connect_options(&seeds, "receive")?, + transfer::APP_CONFIG, + None, + &mut seeds, + &database_path, + ); + futures::pin_mut!(connect_fut); + match futures::future::select(connect_fut, ctrl_c()).await { + Either::Left((result, _)) => result?, + Either::Right(((), _)) => return Ok(()), + } + }; + let timeout = Duration::from_secs(timeout * 60); - send( - wormhole, - relay_server, - file_path, - &file_name, - ctrl_c.clone(), - ) - .await?; - } else if let Some(matches) = matches.subcommand_matches("send-many") { - let (wormhole, code, relay_server) = { - let connect_fut = parse_and_connect( + let file_name = concat_file_name(&file, file_name.as_ref())?; + + send_many( + relay_server, + &code.unwrap(), + file.as_ref(), + &file_name, + tries, + timeout, + wormhole, &mut term, - matches, - true, - transfer::APP_CONFIG, - Some(&sender_print_code), - ); - futures::pin_mut!(connect_fut); - match futures::future::select(connect_fut, ctrl_c()).await { - Either::Left((result, _)) => result?, - Either::Right(((), _)) => return Ok(()), - } - }; - let timeout = - Duration::from_secs(u64::from_str(matches.value_of("timeout").unwrap())? * 60); - let max_tries = u64::from_str(matches.value_of("tries").unwrap())?; - - let file_path = matches.value_of_os("file").unwrap(); - let file_name = file_name(file_path)?; - - send_many( - relay_server, - &code, - file_path, - &file_name, - max_tries, - timeout, - wormhole, - &mut term, - ctrl_c, - ) - .await?; - } else if let Some(matches) = matches.subcommand_matches("receive") { - let file_path = matches.value_of_os("file-path").unwrap(); - - let (wormhole, _code, relay_server) = { - let connect_fut = - parse_and_connect(&mut term, matches, false, transfer::APP_CONFIG, None); - futures::pin_mut!(connect_fut); - match futures::future::select(connect_fut, ctrl_c()).await { - Either::Left((result, _)) => result?, - Either::Right(((), _)) => return Ok(()), - } - }; + ctrl_c, + ) + .await?; + }, + WormholeCommand::Receive { + noconfirm, + common, + common_follower, + common_receiver: + CommonReceiverArgs { + file_name, + file_path, + }, + .. + } => { + let (wormhole, _code, relay_server) = { + let connect_fut = parse_and_connect( + &mut term, + common, + common_follower.into_connect_options(&seeds)?, + transfer::APP_CONFIG, + Some(seed_ability), + &mut seeds, + &database_path, + ); + futures::pin_mut!(connect_fut); + match futures::future::select(connect_fut, ctrl_c()).await { + Either::Left((result, _)) => result?, + Either::Right(((), _)) => return Ok(()), + } + }; - receive( - wormhole, - relay_server, - file_path, - matches.value_of_os("file-name"), - matches.is_present("noconfirm"), - ctrl_c, - ) - .await?; - } else if let Some(matches) = matches.subcommand_matches("forward") { - // TODO make fancy - log::warn!("This is an unstable feature. Make sure that your peer is running the exact same version of the program as you."); - if let Some(matches) = matches.subcommand_matches("serve") { + receive( + wormhole, + relay_server, + file_path.as_os_str(), + file_name.map(std::ffi::OsString::from).as_deref(), + noconfirm, + ctrl_c, + ) + .await?; + }, + WormholeCommand::Forward(ForwardCommand::Serve { + targets, + common, + common_leader, + .. + }) => { + // TODO make fancy + log::warn!("This is an unstable feature. Make sure that your peer is running the exact same version of the program as you."); /* Map the CLI argument to Strings. Use the occasion to inspect them and fail early on malformed input. */ - let targets = matches - .values_of("targets") - .unwrap() + let targets = targets + .into_iter() .enumerate() .map(|(index, target)| { let result = (|| { @@ -447,13 +597,16 @@ async fn main() -> eyre::Result<()> { )) }) .collect::, _>>()?; + let connect_options = common_leader.into_connect_options(&seeds, "forward connect")?; loop { let connect_fut = parse_and_connect( &mut term, - matches, - true, + common.clone(), + connect_options.clone(), forwarding::APP_CONFIG, - Some(&server_print_code), + None, + &mut seeds, + &database_path, ); futures::pin_mut!(connect_fut); let (wormhole, _code, relay_server) = @@ -469,22 +622,30 @@ async fn main() -> eyre::Result<()> { ctrl_c(), )); } - } else if let Some(matches) = matches.subcommand_matches("connect") { - let custom_ports: Vec = matches - .values_of("port") - .into_iter() - .flatten() - .map(|port| port.parse::().map_err(eyre::Error::from)) - .collect::>()?; - let noconfirm = matches.is_present("noconfirm"); - let bind_address: std::net::IpAddr = matches.value_of("bind").unwrap().parse()?; - let (wormhole, _code, relay_server) = - parse_and_connect(&mut term, matches, false, forwarding::APP_CONFIG, None).await?; + }, + WormholeCommand::Forward(ForwardCommand::Connect { + ports, + noconfirm, + bind_address, + common, + common_follower, + }) => { + // TODO make fancy + log::warn!("This is an unstable feature. Make sure that your peer is running the exact same version of the program as you."); + let (wormhole, _code, relay_server) = parse_and_connect( + &mut term, + common, + common_follower.into_connect_options(&seeds)?, + forwarding::APP_CONFIG, + None, + &mut seeds, + &database_path, + ) + .await?; let relay_server = vec![transit::RelayHint::from_urls(None, [relay_server])]; let offer = - forwarding::connect(wormhole, relay_server, Some(bind_address), &custom_ports) - .await?; + forwarding::connect(wormhole, relay_server, Some(bind_address), &ports).await?; log::info!("Mapping the following open ports to targets:"); log::info!(" local port -> remote target (no address = localhost on remote)"); for (port, target) in &offer.mapping { @@ -495,19 +656,134 @@ async fn main() -> eyre::Result<()> { } else { offer.reject().await?; } - } else { - unreachable!() - } - } else if let Some(_matches) = matches.subcommand_matches("help") { - println!("Use --help to get help"); - std::process::exit(1); - } else { - unreachable!() + }, + WormholeCommand::Contacts(ContactCommand::List) => { + use time::format_description::{Component, FormatItem}; + /* YYYY-mm-dd */ + let format = [ + FormatItem::Component(Component::Year( + time::format_description::modifier::Year::default(), + )), + FormatItem::Literal(b"-"), + FormatItem::Component(Component::Month( + time::format_description::modifier::Month::default(), + )), + FormatItem::Literal(b"-"), + FormatItem::Component(Component::Day( + time::format_description::modifier::Day::default(), + )), + ]; + + #[allow(clippy::print_literal)] + { + let contacts = seeds + .peers + .iter() + .filter(|(_id, peer)| peer.contact_name.is_some()) + .collect::>(); + if contacts.is_empty() { + println!("You have no stored contacts yet!"); + } else { + println!("Known contacts:\n"); + println!("{:8} {:12} {}", "ID", "NAME", "EXPIRES"); + for (id, peer) in &contacts { + println!( + "{:8} {:12} {}", + id, + peer.contact_name.as_ref().unwrap(), + time::OffsetDateTime::from(peer.expires()) + .date() + .format(&format[..]) + .unwrap(), + ); + } + } + } + #[allow(clippy::print_literal)] + { + let contacts = seeds + .peers + .iter() + .filter(|(_id, peer)| peer.contact_name.is_none()) + .collect::>(); + if !contacts.is_empty() { + println!("\nContacts you haven't stored yet:"); + println!("{:8} {:12} {}", "ID", "ALSO KNOWN AS", "EXPIRES"); + for (id, peer) in &contacts { + println!( + "{:8} {:12} {}", + id, + peer.names.join(", "), + time::OffsetDateTime::from(peer.expires()) + .date() + .format(&format[..]) + .unwrap(), + ); + } + println!("\nRun `wormhole-rs contact add ` to add them. Remember that both sides need to do this."); + } + } + }, + WormholeCommand::Contacts(ContactCommand::Add { id, name, force }) => { + eyre::ensure!( + name != "myself", + "'myself' is a reserved contact name, to send files between two clients on the same machine (mostly useful for testing)", + ); + eyre::ensure!( + !name.chars().any(|c| ['\'', '"', ','].contains(&c)), + "For practical purposes, please choose a name without quotes or commas in it", + ); + if !force { + eyre::ensure!( + !seeds.peers.values().any(|peer| peer.contact_name.as_ref() == Some(&name)), + "You already stored a contact under that name. Either use --force to overwrite it, or chose another name", + ); + } + let peer = seeds + .find_mut(&id) + .ok_or_else(|| eyre::format_err!("No connection with that ID known"))?; + if !force { + eyre::ensure!( + peer.contact_name.is_none(), + "This contact is already known as {}. Use --force to rename it.", + peer.contact_name.as_ref().unwrap(), + ); + } + peer.contact_name = Some(name.clone()); + seeds.save(&database_path).context(format!( + "Failed to write seeds database to '{}'", + &database_path.display() + ))?; + log::info!("Successfully added '{name}' to your contacts."); + log::info!("You can now use '--to {name}' or '--from {name}'"); + }, + WormholeCommand::Help => { + println!("Use --help to get help"); + std::process::exit(2); + }, } Ok(()) } +#[derive(Clone)] +enum ConnectOptions<'a> { + /* Leader/follower */ + ProvideCode(String), + /* Leader only */ + GenerateCode { + size: usize, + follower_command: &'a str, + }, + /* Follower only */ + EnterCode, + /* Leader/follower */ + ProvideSeed { + seed: xsalsa20poly1305::Key, + follower_command: Option<&'a str>, + }, +} + /** * Parse the necessary command line arguments to establish an initial server connection. * This is used over and over again by the different subcommands. @@ -515,62 +791,155 @@ async fn main() -> eyre::Result<()> { * If this `is_send` and the code is not specified via the CLI, then a code will be allocated. * Otherwise, the user will be prompted interactively to enter it. */ -async fn parse_and_connect( - term: &mut Term, - matches: &clap::ArgMatches<'_>, - is_send: bool, +#[allow(deprecated)] +async fn parse_and_connect<'a>( + term: &'a mut Term, + common_args: CommonArgs, + connect_options: ConnectOptions<'_>, mut app_config: magic_wormhole::AppConfig, - print_code: Option<&dyn Fn(&mut Term, &magic_wormhole::Code) -> eyre::Result<()>>, -) -> eyre::Result<(Wormhole, magic_wormhole::Code, url::Url)> { - let relay_server: url::Url = matches - .value_of("relay-server") - .unwrap_or(magic_wormhole::transit::DEFAULT_RELAY_SERVER) - .parse() - .unwrap(); - let rendezvous_server = matches.value_of("rendezvous-server"); - let code = matches - .value_of("code") - .map(ToOwned::to_owned) - .map(Result::Ok) - .or_else(|| (!is_send).then(enter_code)) - .transpose()? - .map(magic_wormhole::Code); - - if let Some(rendezvous_server) = rendezvous_server { - app_config = app_config.rendezvous_url(rendezvous_server.to_owned().into()); + seed_ability: Option>, + seeds: &mut seeds::Database, + database_path: &Path, +) -> eyre::Result<(Wormhole, Option, url::Url)> { + // TODO handle multiple relay servers correctly + let relay_server: url::Url = common_args + .relay_server + .into_iter() + .next() + .unwrap_or_else(|| { + magic_wormhole::transit::DEFAULT_RELAY_SERVER + .parse() + .unwrap() + }); + + if let Some(rendezvous_server) = common_args.rendezvous_server { + app_config = app_config.rendezvous_url(rendezvous_server.into_string().into()); } - let (wormhole, code) = match code { - Some(code) => { - if is_send { - print_code.expect("`print_code` must be `Some` when `is_send` is `true`")( - term, &code, - )?; - } - let (server_welcome, wormhole) = - magic_wormhole::Wormhole::connect_with_code(app_config, code).await?; + + let (mut wormhole, code, relay_server) = match connect_options { + /* Leader/follower */ + ConnectOptions::ProvideCode(code) => { + let (server_welcome, wormhole) = magic_wormhole::Wormhole::connect_with_code( + app_config, + magic_wormhole::Code(code), + seed_ability, + ) + .await?; print_welcome(term, &server_welcome)?; - (wormhole, server_welcome.code) + (wormhole, Some(server_welcome.code), relay_server) }, - None => { - let numwords = matches - .value_of("code-length") - .unwrap() - .parse() - .expect("TODO error handling"); - + /* Leader only */ + ConnectOptions::GenerateCode { + size, + follower_command, + } => { let (server_welcome, connector) = - magic_wormhole::Wormhole::connect_without_code(app_config, numwords).await?; + magic_wormhole::Wormhole::connect_without_code(app_config, size, seed_ability) + .await?; print_welcome(term, &server_welcome)?; - if is_send { - print_code.expect("`print_code` must be `Some` when `is_send` is `true`")( - term, - &server_welcome.code, - )?; - } + print_code(term, &server_welcome.code, follower_command)?; let wormhole = connector.await?; - (wormhole, server_welcome.code) + (wormhole, Some(server_welcome.code), relay_server) + }, + /* Follower only */ + ConnectOptions::EnterCode => { + let code = magic_wormhole::Code(enter_code()?); + let (server_welcome, wormhole) = + magic_wormhole::Wormhole::connect_with_code(app_config, code, seed_ability).await?; + print_welcome(term, &server_welcome)?; + (wormhole, Some(server_welcome.code), relay_server) + }, + /* Leader/follower */ + ConnectOptions::ProvideSeed { + seed, + follower_command, + } => { + if let Some(follower_command) = follower_command { + print_seed(term, follower_command)?; + } + let mut wormhole = + magic_wormhole::Wormhole::connect_with_seed(app_config, seed).await?; + /* We don't want to execute the code block below if the connection came from a seed */ + wormhole.take_seed(); + (wormhole, None, relay_server) }, }; + + /* Handle the seeds result */ + if let Some(result) = wormhole.take_seed() { + /* Sending to ourselves, are we? */ + if result + .existing_seeds + .contains(&xsalsa20poly1305::Key::from(seeds.myself)) + { + log::info!( + "You appear to be sending a file to yourself. You may use `--to myself` and `--from myself` instead.", + ); + } else { + /* We are interested in common seeds that we've already given a name */ + match seeds + .peers + .values_mut() + .filter(|peer| { + result + .existing_seeds + .contains(&xsalsa20poly1305::Key::from(peer.seed)) + }) + .find(|peer| peer.contact_name.is_some()) + { + /* We only care about the first one and ignore the others. It should be rare enough to see duplicate contacts */ + Some(peer) => { + peer.seen(); + log::info!( + "You already know your peer as '{}'. You may use the appropriate `--to` and `--from` arguments for connecting to that person without having to enter a code.", + peer.contact_name.as_ref().unwrap(), + ); + }, + None => { + /* Check if we have at least one that wasn't saved */ + match seeds.peers.iter_mut().find(|(_, peer)| { + result + .existing_seeds + .contains(&xsalsa20poly1305::Key::from(peer.seed)) + }) { + Some((id, peer)) => { + peer.seen(); + let name = if !peer.names.is_empty() && !peer.names[0].contains(' ') { + peer.names[0].clone() + } else { + "".into() + }; + log::info!( + "If you want to connect to your peer without password the next time, run" + ); + log::info!("wormhole-rs contacts add {} {}", id, name); + }, + None => { + /* New seed, store it in database */ + let seed = result.session_seed; + let name = if !seed.display_names.is_empty() + && !seed.display_names[0].contains(' ') + { + seed.display_names[0].clone() + } else { + "".into() + }; + let id = seeds.insert_peer(seed); + log::info!( + "If you want to connect to your peer without password the next time, run" + ); + log::info!("wormhole-rs contacts add {} {}", id, name); + }, + } + }, + } + seeds.save(database_path).context(format!( + "Failed to write seeds database to '{}'", + &database_path.display() + ))?; + } + } + eyre::Result::<_>::Ok((wormhole, code, relay_server)) } @@ -604,18 +973,17 @@ fn print_welcome(term: &mut Term, welcome: &magic_wormhole::WormholeWelcome) -> } // For file transfer -fn sender_print_code(term: &mut Term, code: &magic_wormhole::Code) -> eyre::Result<()> { +fn print_code(term: &mut Term, code: &magic_wormhole::Code, command: &str) -> eyre::Result<()> { writeln!(term, "\nThis wormhole's code is: {}", &code)?; writeln!(term, "On the other computer, please run:\n")?; - writeln!(term, "wormhole receive {}\n", &code)?; + writeln!(term, "wormhole {} {}\n", command, &code)?; Ok(()) } // For port forwarding -fn server_print_code(term: &mut Term, code: &magic_wormhole::Code) -> eyre::Result<()> { - writeln!(term, "\nThis wormhole's code is: {}", &code)?; - writeln!(term, "On the other computer, please run:\n")?; - writeln!(term, "wormhole forward connect {}\n", &code)?; +fn print_seed(term: &mut Term, command: &str) -> eyre::Result<()> { + writeln!(term, "\nOn the other computer, please run (replace with the corresponding name of the contact):\n")?; + writeln!(term, "wormhole {} --from \n", command)?; Ok(()) } @@ -705,7 +1073,8 @@ async fn send_many( } let (_server_welcome, wormhole) = - magic_wormhole::Wormhole::connect_with_code(transfer::APP_CONFIG, code.clone()).await?; + magic_wormhole::Wormhole::connect_with_code(transfer::APP_CONFIG, code.clone(), None) + .await?; send_in_background( relay_server.clone(), Arc::clone(&file_path), @@ -814,7 +1183,7 @@ async fn receive( let file_name = file_name .or_else(|| req.filename.file_name()) .ok_or_else(|| eyre::format_err!("The sender did not specify a valid file name, and neither did you. Try using --rename."))?; - let file_path = std::path::Path::new(target_dir).join(file_name); + let file_path = Path::new(target_dir).join(file_name); let pb = create_progress_bar(req.filesize); diff --git a/src/bin/seeds.rs b/src/bin/seeds.rs new file mode 100644 index 00000000..6d5d3436 --- /dev/null +++ b/src/bin/seeds.rs @@ -0,0 +1,127 @@ +use color_eyre::eyre; +use serde_derive::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + time::{Duration, SystemTime}, +}; + +fn current_version_number() -> u32 { + 0 +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Database { + // A special seed to recognize when we are sending something to ourselves + #[serde(with = "hex::serde")] + pub myself: [u8; xsalsa20poly1305::KEY_SIZE], + // We assign each peer a unique opaque string that we use as identifier, + // next to the name. + #[serde(default)] + pub peers: HashMap, + // Tell our peers who we are + #[serde(default)] + pub our_names: Vec, + + // Backwards compatibility + #[serde(default = "current_version_number")] + version: u32, + #[serde(flatten)] + other: HashMap, +} + +impl Database { + pub fn load(path: &std::path::Path) -> eyre::Result { + Ok(serde_json::from_reader(std::fs::File::open(path)?)?) + } + + pub fn save(&self, path: &std::path::Path) -> eyre::Result<()> { + serde_json::to_writer_pretty(std::fs::File::create(path)?, self)?; + Ok(()) + } + + pub fn find(&self, name: &str) -> Option<&Peer> { + self.peers + .iter() + .find(|(key, value)| { + key.as_str() == name || value.contact_name.as_deref() == Some(name) + }) + .map(|(_key, value)| value) + } + + pub fn find_mut(&mut self, name: &str) -> Option<&mut Peer> { + self.peers + .iter_mut() + .find(|(key, value)| { + key.as_str() == name || value.contact_name.as_deref() == Some(name) + }) + .map(|(_key, value)| value) + } + + pub fn insert_peer(&mut self, peer: magic_wormhole::WormholeSeed) -> String { + // Three bytes = Six hex chars length, 2^24 possible values + // If this repeatedly fails to generate unique strings, slowly increase entropy + let id = (0..) + .map(|i| match i { + // https://github.com/rust-lang/rust/issues/37854 + 0..=99 => hex::encode(rand::random::<[u8; 3]>()), + 100..=999 => hex::encode(rand::random::<[u8; 4]>()), + _ => hex::encode(rand::random::<[u8; 32]>()), + }) + .find(|id| !self.peers.contains_key(id)) + .unwrap(); + self.peers.insert( + id.clone(), + Peer { + contact_name: None, + names: peer.display_names, + seed: peer.seed.into(), + last_seen: std::time::SystemTime::now(), + other: Default::default(), + }, + ); + id + } + + pub fn iter_known_peers(&self) -> impl Iterator + '_ { + self.peers + .values() + .map(|peer| peer.seed.into()) + .chain(std::iter::once(self.myself.into())) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Peer { + /// Then name under which we stored the seed. + pub contact_name: Option, + /// The peer's chosen display names, in decreasing order of preference + pub names: Vec, + #[serde(with = "hex::serde")] + pub seed: [u8; xsalsa20poly1305::KEY_SIZE], + pub last_seen: SystemTime, + + // Backwards compatibility + #[serde(flatten)] + other: HashMap, +} + +impl Peer { + pub fn seen(&mut self) { + self.last_seen = SystemTime::now(); + } + + pub fn expires(&self) -> SystemTime { + if self.contact_name.is_some() { + /* One year */ + self.last_seen + Duration::from_secs(3600 * 24 * 365) + } else { + /* One day */ + self.last_seen + Duration::from_secs(3600 * 24) + } + } +} + +#[allow(dead_code)] +fn main() { + panic!("This ought to be a helper module, no idea why Rust thinks it's a crate"); +} diff --git a/src/core.rs b/src/core.rs index c0e00f73..655cbd47 100644 --- a/src/core.rs +++ b/src/core.rs @@ -6,12 +6,13 @@ mod test; mod wordlist; use serde_derive::{Deserialize, Serialize}; -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashSet}; use self::rendezvous::*; pub(self) use self::server_messages::EncryptedMessage; use log::*; +use hex::{FromHex, ToHex}; use xsalsa20poly1305 as secretbox; #[derive(Debug, thiserror::Error)] @@ -91,25 +92,17 @@ pub struct Wormhole { phase: u64, key: key::Key, appid: AppID, - /** - * If you're paranoid, let both sides check that they calculated the same verifier. - * - * PAKE hardens a standard key exchange with a password ("password authenticated") in order - * to mitigate potential man in the middle attacks that would otherwise be possible. Since - * the passwords usually are not of hight entropy, there is a low-probability possible of - * an attacker guessing the password correctly, enabling them to MitM the connection. - * - * Not only is that probability low, but they also have only one try per connection and a failed - * attempts will be noticed by both sides. Nevertheless, comparing the verifier mitigates that - * attack vector. - */ - pub verifier: Box, + session_id: Box<[u8]>, /** * Protocol version information from the other side. * This is bound by the [`AppID`]'s protocol and thus shall be handled on a higher level * (e.g. by the file transfer API). */ pub peer_version: serde_json::Value, + /** We managed to create a seed to use in the future + * If this is `Some`, the application should store this in their database persistently + */ + seed: Option, } impl Wormhole { @@ -125,6 +118,7 @@ impl Wormhole { pub async fn connect_without_code( config: AppConfig, code_length: usize, + seed_ability: Option>, ) -> Result< ( WormholeWelcome, @@ -152,7 +146,7 @@ impl Wormhole { welcome, code: code.clone(), }, - Self::connect_custom(server, appid, code.0, versions), + Self::connect_custom(server, appid, code.0, versions, seed_ability), )) } @@ -162,6 +156,7 @@ impl Wormhole { pub async fn connect_with_code( config: AppConfig, code: Code, + seed_ability: Option>, ) -> Result<(WormholeWelcome, Self), WormholeError> { let AppConfig { id: appid, @@ -180,13 +175,34 @@ impl Wormhole { welcome, code: code.clone(), }, - Self::connect_custom(server, appid, code.0, versions).await?, + Self::connect_custom(server, appid, code.0, versions, seed_ability).await?, )) } - /** TODO */ - pub async fn connect_with_seed() { - todo!() + pub async fn connect_with_seed( + config: AppConfig, + seed: xsalsa20poly1305::Key, + ) -> Result { + // TODO map to better types + let seed = key::WormholeSeed { + seed, + display_names: vec![], + }; + let AppConfig { + id: appid, + rendezvous_url, + app_version: versions, + } = config; + let versions = serde_json::to_value(versions).unwrap(); + let (mut server, _welcome) = RendezvousServer::connect(&appid, &rendezvous_url).await?; + + let nameplate = Nameplate(seed.mailbox().encode_hex()); + // let mailbox = Mailbox(seed.mailbox().encode_hex()); + // server.open_directly(mailbox.clone()).await?; + let mailbox = server.claim_open(nameplate).await?; + log::debug!("Connected to mailbox {}", mailbox); + + Ok(Self::connect_custom(server, appid, seed.code().encode_hex(), versions, None).await?) } /// Do only the client-client part of the connection setup @@ -202,13 +218,27 @@ impl Wormhole { appid: AppID, password: String, app_versions: impl serde::Serialize, + seed_ability: Option>, ) -> Result { /* Send PAKE */ let (pake_state, pake_msg_ser) = key::make_pake(&password, &appid); server.send_peer_message(Phase::PAKE, pake_msg_ser).await?; /* Receive PAKE */ - let peer_pake = key::extract_pake_msg(&server.next_peer_message_some().await?.body)?; + let pake_message = server.next_peer_message_some().await?; + ensure!( + pake_message.phase == Phase::PAKE, + WormholeError::Protocol( + format!( + "Got phase '{}', but expected '{}'", + pake_message.phase, + Phase::PAKE + ) + .into() + ) + ); + let peer_pake = key::extract_pake_msg(&pake_message.body)?; + let peer_side = pake_message.side; let key = pake_state .finish(&peer_pake) .map_err(|_| WormholeError::PakeFailed) @@ -217,10 +247,22 @@ impl Wormhole { /* Send versions message */ let mut versions = key::VersionsMessage::new(); versions.set_app_versions(serde_json::to_value(app_versions).unwrap()); + let session_id = if ***server.side() > **peer_side { + Vec::::from_hex(format!("{}{}", &***server.side(), &**peer_side)) + .expect("TODO error handling") + } else { + Vec::::from_hex(format!("{}{}", &**peer_side, &***server.side())) + .expect("TODO error handling") + }; + + if let Some(seed_ability) = seed_ability.as_ref() { + versions.add_seeds_ability(seed_ability.hash(&session_id)); + } + let (version_phase, version_msg) = key::build_version_msg(server.side(), &key, &versions); + server.send_peer_message(version_phase, version_msg).await?; let peer_version = server.next_peer_message_some().await?; - /* Handle received message */ let versions: key::VersionsMessage = peer_version .decrypt(&key) @@ -230,6 +272,23 @@ impl Wormhole { })?; let peer_version = versions.app_versions; + let seed = seed_ability + .zip(versions.seed) + .map(|(seed_ability, peer_seed_ability)| { + let common_seeds: HashSet<_> = seed_ability + .intersect(&peer_seed_ability, &session_id) + .collect(); + + #[allow(clippy::wildcard_in_or_patterns)] + SeedResult { + /* Derive a new seed */ + session_seed: key::WormholeSeed { + display_names: peer_seed_ability.display_names, + seed: key::derive_key(&key, b"wormhole:seed"), + }, + existing_seeds: common_seeds, + } + }); if server.needs_nameplate_release() { server.release_nameplate().await?; @@ -243,8 +302,9 @@ impl Wormhole { appid, phase: 0, key: key::Key::new(key.into()), - verifier: Box::new(key::derive_verifier(&key)), peer_version, + session_id: session_id.into_boxed_slice(), + seed, }) } @@ -341,6 +401,50 @@ impl Wormhole { pub fn key(&self) -> &key::Key { &self.key } + + /** + * If you're paranoid, let both sides check that they calculated the same verifier. + * + * PAKE hardens a standard key exchange with a password ("password authenticated") in order + * to mitigate potential man in the middle attacks that would otherwise be possible. Since + * the passwords usually are not of hight entropy, there is a low-probability possible of + * an attacker guessing the password correctly, enabling them to MitM the connection. + * + * Not only is that probability low, but they also have only one try per connection and a failed + * attempts will be noticed by both sides. Nevertheless, comparing the verifier mitigates that + * attack vector. + */ + pub fn verifier(&self) -> secretbox::Key { + key::derive_verifier(&self.key) + } + + /** Generated from our and the peer's side */ + pub fn session_id(&self) -> &[u8] { + &self.session_id + } + + /** + * Derive a seed that will grow a new + */ + pub fn seed(&self) -> Option<&SeedResult> { + self.seed.as_ref() + } + + /** + * Derive a seed that will grow a new + */ + pub fn take_seed(&mut self) -> Option { + self.seed.take() + } +} + +// TODO use newtypes around the keys +#[derive(Clone, Debug)] +pub struct SeedResult { + /** Seed derived from the current session */ + pub session_seed: key::WormholeSeed, + /** We may already have a seed (or multiple) in common with peer */ + pub existing_seeds: HashSet, } // the serialized forms of these variants are part of the wire protocol, so @@ -540,6 +644,6 @@ impl Code { } pub fn nameplate(&self) -> Nameplate { - Nameplate::new(self.0.splitn(2, '-').next().unwrap()) + Nameplate::new(self.0.split_once('-').unwrap().0) } } diff --git a/src/core/key.rs b/src/core/key.rs index 9c73d6fd..d9744a78 100644 --- a/src/core/key.rs +++ b/src/core/key.rs @@ -3,6 +3,7 @@ use hkdf::Hkdf; use serde_derive::{Deserialize, Serialize}; use sha2::{digest::FixedOutput, Digest, Sha256}; use spake2::{Ed25519Group, Identity, Password, SPAKE2}; +use std::collections::HashSet; use xsalsa20poly1305 as secretbox; use xsalsa20poly1305::{ aead::{generic_array::GenericArray, Aead, AeadCore, NewAead}, @@ -107,7 +108,9 @@ pub struct VersionsMessage { pub abilities: Vec, #[serde(default)] pub app_versions: serde_json::Value, - // resume: Option, + /** Only ever send the [obfuscated](SeedAbility::Hash) variant over the wire! */ + #[serde(default)] + pub seed: Option>, } impl VersionsMessage { @@ -119,9 +122,113 @@ impl VersionsMessage { self.app_versions = versions; } - // pub fn add_resume_ability(&mut self, _resume: ()) { - // self.abilities.push("resume-v1".into()) - // } + pub fn add_seeds_ability(&mut self, seed: SeedAbility) { + self.abilities.push("seeds-v1".into()); + self.seed = Some(seed); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SeedAbility { + /// List of human readable names for a peer + pub display_names: Vec, + /// List of known seeds of a peer + /// + /// Note that the values are blinded by a hash function in order to keep the actual seeds secret + /// The type parameter `hashed` is used to distinguish both cases in the type system + #[serde(with = "SeedAbility::")] + pub known_seeds: HashSet, +} + +impl SeedAbility { + pub fn new_hashed<'a>( + display_names: Vec, + known_seeds: impl Iterator, + session_id: &[u8], + ) -> Self { + Self { + display_names, + known_seeds: known_seeds + .map(|seed| derive_key(seed, session_id)) + .collect(), + } + } +} + +impl SeedAbility { + /** We obfuscate the list of known mailboxed to not leak our contact graph */ + pub fn hash(&self, session_id: &[u8]) -> SeedAbility { + SeedAbility::::new_hashed( + self.display_names.clone(), + self.known_seeds.iter(), + session_id, + ) + } + + /** Intersect self with the peer's set of seeds, + * execpt that the peer's seeds are hashed so we don't actually know them. + */ + pub fn intersect<'a>( + &'a self, + other: &'a SeedAbility, + session_id: &'a [u8], + ) -> impl Iterator + 'a { + self.known_seeds + .iter() + .map(|seed| (*seed, derive_key(seed, session_id))) + .filter(|(_seed, hash)| other.known_seeds.contains(hash)) + .map(|(seed, _)| seed) + } + + // Serialize the `sessions` attribute + pub fn serialize( + data: &HashSet, + serializer: S, + ) -> Result { + serializer.collect_seq( + data.iter() + .map(::encode_hex::), + ) + } + + // Deserialize the `sessions` attribute + fn deserialize<'de, D>(de: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let values: Vec = serde::Deserialize::deserialize(de)?; + values + .into_iter() + .map(|value: String| { + <[u8; xsalsa20poly1305::KEY_SIZE]>::from_hex(value) + .map(Into::into) + .map_err(::custom) + }) + .collect() + } +} + +/** "Grow" a new Wormhole connection */ +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct WormholeSeed { + /// List of human readable names + pub display_names: Vec, + /// The seed. Should be kept secret + #[serde( + serialize_with = "hex::serde::serialize", + deserialize_with = "crate::util::deserialize_key_hex" + )] + pub seed: xsalsa20poly1305::Key, +} + +impl WormholeSeed { + pub fn code(&self) -> xsalsa20poly1305::Key { + derive_key(&self.seed, b"wormhole:seed:code") + } + + pub fn mailbox(&self) -> xsalsa20poly1305::Key { + derive_key(&self.seed, b"wormhole:seed:mailbox") + } } pub fn build_version_msg( diff --git a/src/core/test.rs b/src/core/test.rs index 1a0b8b70..b263d4ce 100644 --- a/src/core/test.rs +++ b/src/core/test.rs @@ -31,7 +31,8 @@ pub async fn test_file_rust2rust() -> eyre::Result<()> { .name("sender".to_owned()) .spawn(async { let (welcome, connector) = - Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2).await?; + Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2, None) + .await?; if let Some(welcome) = &welcome.welcome { log::info!("Got welcome: {}", welcome); } @@ -59,7 +60,8 @@ pub async fn test_file_rust2rust() -> eyre::Result<()> { let code = code_rx.await?; log::info!("Got code over local: {}", &code); let (welcome, wormhole) = - Wormhole::connect_with_code(transfer::APP_CONFIG.id(TEST_APPID), code).await?; + Wormhole::connect_with_code(transfer::APP_CONFIG.id(TEST_APPID), code, None) + .await?; if let Some(welcome) = &welcome.welcome { log::info!("Got welcome: {}", welcome); } @@ -98,7 +100,7 @@ pub async fn test_send_many() -> eyre::Result<()> { init_logger(); let (welcome, connector) = - Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2).await?; + Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2, None).await?; let code = welcome.code; log::info!("The code is {:?}", code); @@ -137,6 +139,7 @@ pub async fn test_send_many() -> eyre::Result<()> { let (_welcome, wormhole) = Wormhole::connect_with_code( transfer::APP_CONFIG.id(TEST_APPID), sender_code.clone(), + None, ) .await?; senders.push(async_std::task::spawn(async move { @@ -164,7 +167,8 @@ pub async fn test_send_many() -> eyre::Result<()> { for i in 0..5usize { log::info!("Receiving file #{}", i); let (_welcome, wormhole) = - Wormhole::connect_with_code(transfer::APP_CONFIG.id(TEST_APPID), code.clone()).await?; + Wormhole::connect_with_code(transfer::APP_CONFIG.id(TEST_APPID), code.clone(), None) + .await?; log::info!("Got key: {}", &wormhole.key); let req = crate::transfer::request_file( wormhole, @@ -198,7 +202,8 @@ pub async fn test_wrong_code() -> eyre::Result<()> { .name("sender".to_owned()) .spawn(async { let (welcome, connector) = - Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2).await?; + Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2, None) + .await?; if let Some(welcome) = &welcome.welcome { log::info!("Got welcome: {}", welcome); } @@ -219,6 +224,7 @@ pub async fn test_wrong_code() -> eyre::Result<()> { transfer::APP_CONFIG.id(TEST_APPID), /* Making a wrong code here by appending bullshit */ Code::new(&nameplate, "foo-bar"), + None, ) .await; @@ -239,14 +245,20 @@ pub async fn test_crowded() -> eyre::Result<()> { init_logger(); let (welcome, connector1) = - Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2).await?; + Wormhole::connect_without_code(transfer::APP_CONFIG.id(TEST_APPID), 2, None).await?; log::info!("This test's code is: {}", &welcome.code); - let connector2 = - Wormhole::connect_with_code(transfer::APP_CONFIG.id(TEST_APPID), welcome.code.clone()); + let connector2 = Wormhole::connect_with_code( + transfer::APP_CONFIG.id(TEST_APPID), + welcome.code.clone(), + None, + ); - let connector3 = - Wormhole::connect_with_code(transfer::APP_CONFIG.id(TEST_APPID), welcome.code.clone()); + let connector3 = Wormhole::connect_with_code( + transfer::APP_CONFIG.id(TEST_APPID), + welcome.code.clone(), + None, + ); match futures::try_join!(connector1, connector2, connector3).unwrap_err() { magic_wormhole::WormholeError::ServerError( @@ -260,6 +272,92 @@ pub async fn test_crowded() -> eyre::Result<()> { Ok(()) } +/** Generate a seed and then use it */ +#[async_std::test] +pub async fn test_seeds() -> eyre::Result<()> { + init_logger(); + + /* Generate the seed */ + + let seed_ability = magic_wormhole::SeedAbility:: { + display_names: vec!["foo".into(), "bar".into()], + known_seeds: [Default::default()].into_iter().collect(), + }; + let seed_ability2 = seed_ability.clone(); + + let (code_tx, code_rx) = futures::channel::oneshot::channel(); + + let seed_1 = async_std::task::Builder::new() + .name("leader".to_owned()) + .spawn(async { + let (welcome, connector) = Wormhole::connect_without_code( + transfer::APP_CONFIG.id(TEST_APPID), + 2, + Some(seed_ability), + ) + .await?; + code_tx.send(welcome.code).unwrap(); + let mut wormhole = connector.await?; + let seed = wormhole.take_seed(); + wormhole.close().await?; + eyre::Result::<_>::Ok(seed) + })?; + let seed_2 = async_std::task::Builder::new() + .name("follower".to_owned()) + .spawn(async { + let code = code_rx.await?; + let (_welcome, mut wormhole) = Wormhole::connect_with_code( + transfer::APP_CONFIG.id(TEST_APPID), + code, + Some(seed_ability2), + ) + .await?; + let seed = wormhole.take_seed(); + wormhole.close().await?; + eyre::Result::<_>::Ok(seed) + })?; + + let seed_1 = seed_1.await?.expect("Seed must be Some"); + let seed_2 = seed_2.await?.expect("Seed must be Some"); + + assert_eq!(seed_1.session_seed.seed, seed_2.session_seed.seed); + assert_eq!( + seed_1.session_seed.display_names, + seed_2.session_seed.display_names + ); + assert_eq!(seed_1.existing_seeds, seed_2.existing_seeds); + + /* Resume the seed */ + + let task_1 = async_std::task::Builder::new() + .name("leader".to_owned()) + .spawn(async move { + let wormhole = Wormhole::connect_with_seed( + transfer::APP_CONFIG.id(TEST_APPID), + seed_1.session_seed.seed, + ) + .await?; + wormhole.close().await?; + eyre::Result::<_>::Ok(()) + })?; + let task_2 = async_std::task::Builder::new() + .name("follower".to_owned()) + .spawn(async move { + let wormhole = Wormhole::connect_with_seed( + transfer::APP_CONFIG.id(TEST_APPID), + seed_2.session_seed.seed, + ) + .await?; + wormhole.close().await?; + eyre::Result::<_>::Ok(()) + })?; + + task_1.await?; + task_2.await?; + + Ok(()) +} + #[test] fn test_phase() { let p = Phase::PAKE; diff --git a/src/lib.rs b/src/lib.rs index 48c188fa..a50cddb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,6 @@ pub mod transfer; pub mod transit; pub use crate::core::{ - key::{GenericKey, Key, KeyPurpose, WormholeKey}, - rendezvous, AppConfig, AppID, Code, Wormhole, WormholeError, WormholeWelcome, + key::{GenericKey, Key, KeyPurpose, SeedAbility, WormholeKey, WormholeSeed}, + rendezvous, AppConfig, AppID, Code, SeedResult, Wormhole, WormholeError, WormholeWelcome, }; diff --git a/src/util.rs b/src/util.rs index 6ce0c89c..65ec79c8 100644 --- a/src/util.rs +++ b/src/util.rs @@ -198,3 +198,13 @@ impl std::fmt::Display for Cancelled { write!(f, "Task has been cancelled") } } + +/// Workaround for https://github.com/KokaKiwi/rust-hex/issues/69 +pub fn deserialize_key_hex<'de, D>(de: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value: [u8; xsalsa20poly1305::KEY_SIZE] = + hex::serde::deserialize(de).map_err(::custom)?; + Ok(value.into()) +}