diff --git a/Cargo.lock b/Cargo.lock index d5d94e14..dfa28543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,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" @@ -1193,6 +1213,7 @@ dependencies = [ "ctrlc", "derive_more", "dialoguer", + "directories", "env_logger", "eyre", "futures", @@ -1621,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" diff --git a/Cargo.toml b/Cargo.toml index ab760686..898b6576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ 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 diff --git a/src/bin/main.rs b/src/bin/main.rs index 0a0a8b43..c1fcdcae 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,3 +1,8 @@ +// Contacts -> list by default +// Seed sending does not work +// Write at least one test + +mod seeds; mod util; use std::{ @@ -6,7 +11,7 @@ use std::{ }; use async_std::{fs::OpenOptions, sync::Arc}; -use clap::{crate_description, crate_name, crate_version, Arg, Args, Command, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use color_eyre::{eyre, eyre::Context}; use console::{style, Term}; use futures::{future::Either, Future, FutureExt}; @@ -17,7 +22,6 @@ use std::{ }; use magic_wormhole::{forwarding, transfer, transit, Wormhole}; -use std::str::FromStr; fn install_ctrlc_handler( ) -> eyre::Result futures::future::BoxFuture<'static, ()> + Clone> { @@ -73,8 +77,47 @@ struct CommonLeaderArgs { #[clap(long, value_name = "CODE")] code: Option, /// Length of code (in bytes/words) - #[clap(short = 'c', long, value_name = "NUMWORDS", default_value = "2")] + #[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 @@ -94,6 +137,35 @@ 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 @@ -156,6 +228,32 @@ enum ForwardCommand { }, } +#[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 @@ -217,6 +315,9 @@ enum WormholeCommand { /// 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, } @@ -268,6 +369,50 @@ async fn main() -> eyre::Result<()> { .try_init()?; } + 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 @@ -294,7 +439,7 @@ async fn main() -> eyre::Result<()> { match app.command { WormholeCommand::Send { common, - common_leader: CommonLeaderArgs { code, code_length }, + common_leader, common_send: CommonSenderArgs { file_name, @@ -310,11 +455,11 @@ async fn main() -> eyre::Result<()> { parse_and_connect( &mut term, common, - code, - Some(code_length), - true, + common_leader.into_connect_options(&seeds, "receive")?, transfer::APP_CONFIG, - Some(&sender_print_code), + Some(seed_ability), + &mut seeds, + &database_path, ), ctrl_c(), ) @@ -337,19 +482,18 @@ async fn main() -> eyre::Result<()> { tries, timeout, common, - common_leader: CommonLeaderArgs { code, code_length }, + common_leader, common_send: CommonSenderArgs { file_name, file }, - .. } => { let (wormhole, code, relay_server) = { let connect_fut = parse_and_connect( &mut term, common, - code, - Some(code_length), - true, + common_leader.into_connect_options(&seeds, "receive")?, transfer::APP_CONFIG, - Some(&sender_print_code), + None, + &mut seeds, + &database_path, ); futures::pin_mut!(connect_fut); match futures::future::select(connect_fut, ctrl_c()).await { @@ -363,7 +507,7 @@ async fn main() -> eyre::Result<()> { send_many( relay_server, - &code, + &code.unwrap(), file.as_ref(), &file_name, tries, @@ -377,7 +521,7 @@ async fn main() -> eyre::Result<()> { WormholeCommand::Receive { noconfirm, common, - common_follower: CommonFollowerArgs { code }, + common_follower, common_receiver: CommonReceiverArgs { file_name, @@ -389,11 +533,11 @@ async fn main() -> eyre::Result<()> { let connect_fut = parse_and_connect( &mut term, common, - code, - None, - false, + common_follower.into_connect_options(&seeds)?, transfer::APP_CONFIG, - None, + Some(seed_ability), + &mut seeds, + &database_path, ); futures::pin_mut!(connect_fut); match futures::future::select(connect_fut, ctrl_c()).await { @@ -415,7 +559,7 @@ async fn main() -> eyre::Result<()> { WormholeCommand::Forward(ForwardCommand::Serve { targets, common, - common_leader: CommonLeaderArgs { code, code_length }, + common_leader, .. }) => { // TODO make fancy @@ -457,15 +601,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, common.clone(), - code.clone(), - Some(code_length), - true, + 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) = @@ -487,19 +632,18 @@ async fn main() -> eyre::Result<()> { noconfirm, bind_address, common, - common_follower: CommonFollowerArgs { code }, - .. + 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, - code, - None, - false, + 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])]; @@ -517,6 +661,106 @@ async fn main() -> eyre::Result<()> { offer.reject().await?; } }, + 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); @@ -526,6 +770,24 @@ async fn main() -> eyre::Result<()> { 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. @@ -534,15 +796,15 @@ async fn main() -> eyre::Result<()> { * Otherwise, the user will be prompted interactively to enter it. */ #[allow(deprecated)] -async fn parse_and_connect( - term: &mut Term, +async fn parse_and_connect<'a>( + term: &'a mut Term, common_args: CommonArgs, - code: Option, - code_length: Option, - is_send: bool, + 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)> { + 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 @@ -553,43 +815,135 @@ async fn parse_and_connect( .parse() .unwrap() }); - let code = code - .map(Result::Ok) - .or_else(|| (!is_send).then(enter_code)) - .transpose()? - .map(magic_wormhole::Code); 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 = code_length.unwrap(); - + /* 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)) } @@ -623,18 +977,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(()) } @@ -724,7 +1077,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), 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 c9c918a6..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)] @@ -98,6 +99,10 @@ pub struct Wormhole { * (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 { @@ -113,6 +118,7 @@ impl Wormhole { pub async fn connect_without_code( config: AppConfig, code_length: usize, + seed_ability: Option>, ) -> Result< ( WormholeWelcome, @@ -140,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), )) } @@ -150,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, @@ -168,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 @@ -190,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) @@ -206,15 +248,21 @@ impl Wormhole { 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") + 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") + 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) @@ -224,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?; @@ -239,6 +304,7 @@ impl Wormhole { key: key::Key::new(key.into()), peer_version, session_id: session_id.into_boxed_slice(), + seed, }) } @@ -357,6 +423,28 @@ impl Wormhole { &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 diff --git a/src/core/key.rs b/src/core/key.rs index 9c73d6fd..c34602f5 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)] +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..c71086e6 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( 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()) +}