Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 334 additions & 18 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "drop"
version = "1.1.0"
version = "1.1.2"
edition = "2024"

[dependencies]
Expand All @@ -12,6 +12,7 @@ base64 = "0.22"
clap = { version = "4.6.0", features = ["derive"] }
clap_complete = "4.6.0"
dialoguer = "0.12.0"
hyper-util = { version = "0.1.20", features = ["server", "http1", "http2", "tokio"] }
http-body-util = "0.1.3"
indicatif = { version = "0.18.4", features = ["tokio"]}
local-ip-address = "0.6.10"
Expand All @@ -23,6 +24,14 @@ tokio = {version = "1.50.0", features = ["full"]}
tokio-util = {version = "0.7.18", features = ["io"] }
tokio-stream = "0.1"
whoami = "2.1.1"
tower-service = "0.3.3"
tokio-rustls = "0.26.4"
sha2 = "0.11.0"
rustls-pemfile = "2.2.0"
rustls = "0.23.37"
rcgen = "0.14.7"
hyper = "1.9.0"
hex = "0.4.3"

[dev-dependencies]
tempfile = "3.10"
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,29 @@ drop join path/to/file.ext # join a host in 'receive' mode (upload)
| `-p`, `--port <PORT>` | Custom port (1024–65535, default: `1844`) |
| `--max-size <MB>` | Maximum upload file size in megabytes (default: no limit) |
| `--encrypt` | Enable end-to-end encryption for CLI-to-CLI transfers |
| `--https` | Serve browser links and discovery sessions over HTTPS |
| `--tls-cert <PATH>` | PEM certificate to use with `--https` |
| `--tls-key <PATH>` | PEM private key to use with `--https` |
| `--no-link-token` | Disable token protection on generated QR code and browser links |

```bash
drop send --encrypt ./secret.pdf
drop receive --port 8080 --encrypt
drop send --https ./secret.pdf
drop receive --https
drop send --https --tls-cert cert.pem --tls-key key.pem ./movie.mp4
drop send ./movie.mp4 --no-link-token
```

### Encryption

When `--encrypt` is passed, `drop` uses AES-256-GCM streaming encryption. Both the sender and receiver must use the `--encrypt` flag. The encryption key is exchanged automatically over mDNS when using `join`.

Browser-based uploads/downloads (via QR code or link) always use plaintext HTTP since the browser cannot participate in the key exchange.
`--https` is separate from `--encrypt`:
- `--https` protects browser links and CLI transport with TLS.
- `--encrypt` keeps the existing end-to-end encryption flow for CLI-to-CLI transfers.

If you enable `--https` without `--tls-cert` and `--tls-key`, `drop` generates a self-signed certificate at runtime. Browser sessions may show a trust warning unless that certificate is explicitly trusted. `drop join` still works with these self-signed hosts by pinning the advertised certificate fingerprint from mDNS.

## Network Requirements

Expand Down
24 changes: 24 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ pub enum Commands {
#[arg(long, default_value_t = false)]
encrypt: bool,

/// Enable HTTPS for browser links and discovery clients
#[arg(long, default_value_t = false)]
https: bool,

/// Path to a PEM-encoded TLS certificate file
#[arg(long, requires = "https")]
tls_cert: Option<PathBuf>,

/// Path to a PEM-encoded TLS private key file
#[arg(long, requires = "https")]
tls_key: Option<PathBuf>,

/// Disable token protection on generated QR code and browser links
#[arg(long, default_value_t = false)]
no_link_token: bool,
Expand All @@ -45,6 +57,18 @@ pub enum Commands {
#[arg(long, default_value_t = false)]
encrypt: bool,

/// Enable HTTPS for browser links and discovery clients
#[arg(long, default_value_t = false)]
https: bool,

/// Path to a PEM-encoded TLS certificate file
#[arg(long, requires = "https")]
tls_cert: Option<PathBuf>,

/// Path to a PEM-encoded TLS private key file
#[arg(long, requires = "https")]
tls_key: Option<PathBuf>,

/// Disable token protection on generated QR code and browser links
#[arg(long, default_value_t = false)]
no_link_token: bool,
Expand Down
25 changes: 22 additions & 3 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ pub async fn join_network(file_path: Option<PathBuf>) -> Result<()> {
let properties = selected_host.get_properties();
let mode = properties.get_property_val_str("mode").unwrap_or("unknown");
let auth_token = properties.get_property_val_str("token");
let scheme = properties.get_property_val_str("scheme").unwrap_or("http");
let tls_fingerprint = properties.get_property_val_str("tls_fp");

let enc_key: Option<[u8; 32]> = properties
.get_property_val_str("enc_key")
Expand All @@ -91,11 +93,11 @@ pub async fn join_network(file_path: Option<PathBuf>) -> Result<()> {
selected_host.get_fullname()
);

let client = reqwest::Client::new();
let client = build_client_for_host(scheme, tls_fingerprint)?;

if mode == "send" {
let url = crate::utils::with_optional_token(
&format!("http://{}:{}/download", ip, port),
&crate::utils::build_base_url(scheme, &format!("{}:{}", ip, port), Some("/download")),
auth_token,
);
println!("[ INFO ] : Downloading from host...");
Expand Down Expand Up @@ -226,7 +228,7 @@ pub async fn join_network(file_path: Option<PathBuf>) -> Result<()> {
}
} else if mode == "receive" {
let url = crate::utils::with_optional_token(
&format!("http://{}:{}/upload", ip, port),
&crate::utils::build_base_url(scheme, &format!("{}:{}", ip, port), Some("/upload")),
auth_token,
);

Expand Down Expand Up @@ -363,3 +365,20 @@ pub async fn join_network(file_path: Option<PathBuf>) -> Result<()> {

Ok(())
}

fn build_client_for_host(
scheme: &str,
expected_fingerprint: Option<&str>,
) -> Result<reqwest::Client> {
match scheme {
"http" => Ok(reqwest::Client::new()),
"https" => {
let fingerprint = expected_fingerprint
.context("Discovered HTTPS host is missing the advertised TLS fingerprint")?;
crate::tls::build_pinned_https_client(fingerprint)
}
other => Err(anyhow::anyhow!(
"Unsupported transport scheme advertised by host: {other}"
)),
}
}
39 changes: 31 additions & 8 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ pub fn get_mdns_names(mode: &str) -> (String, String) {
pub fn spawn_mdns_advertiser(
port: u16,
mode: &'static str,
scheme: &'static str,
auth_token: Option<String>,
enc_key: Option<String>,
tls_fingerprint: Option<String>,
token: CancellationToken,
) {
tokio::spawn(async move {
Expand All @@ -48,14 +50,13 @@ pub fn spawn_mdns_advertiser(
let service_type = "_dropshare._tcp.local.";
let (instance_name, host_name) = get_mdns_names(mode);

let mut properties = HashMap::new();
properties.insert("mode".to_string(), mode.to_string());
if let Some(ref auth_token) = auth_token {
properties.insert("token".to_string(), auth_token.clone());
}
if let Some(ref key) = enc_key {
properties.insert("enc_key".to_string(), key.clone());
}
let properties = build_mdns_properties(
mode,
scheme,
auth_token.as_deref(),
enc_key.as_deref(),
tls_fingerprint.as_deref(),
);

let my_ip = local_ip()
.context("Failed to determine local IP address for broadcasting")?
Expand Down Expand Up @@ -94,3 +95,25 @@ pub fn spawn_mdns_advertiser(
}
});
}

pub fn build_mdns_properties(
mode: &str,
scheme: &str,
auth_token: Option<&str>,
enc_key: Option<&str>,
tls_fingerprint: Option<&str>,
) -> HashMap<String, String> {
let mut properties = HashMap::new();
properties.insert("mode".to_string(), mode.to_string());
properties.insert("scheme".to_string(), scheme.to_string());
if let Some(auth_token) = auth_token {
properties.insert("token".to_string(), auth_token.to_string());
}
if let Some(enc_key) = enc_key {
properties.insert("enc_key".to_string(), enc_key.to_string());
}
if let Some(fingerprint) = tls_fingerprint {
properties.insert("tls_fp".to_string(), fingerprint.to_string());
}
properties
}
105 changes: 88 additions & 17 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod client;
mod crypto;
mod discovery;
mod server;
mod tls;
mod utils;

use anyhow::{Context, Result};
Expand Down Expand Up @@ -30,6 +31,9 @@ async fn main() -> Result<()> {
port,
max_size,
encrypt,
https,
tls_cert,
tls_key,
no_link_token,
} => {
let auth_token: Option<Arc<String>> = (!no_link_token).then(|| {
Expand All @@ -53,6 +57,18 @@ async fn main() -> Result<()> {

let local_ip = local_ip().context("Failed to obtain local IP")?;
let ip_with_port = format!("{local_ip}:{}", port);
let (_, mdns_host_name) = discovery::get_mdns_names("receive");
let https_config = if https {
Some(tls::HttpsConfig::load_or_generate(
local_ip,
Some(&mdns_host_name),
tls_cert.as_deref(),
tls_key.as_deref(),
)?)
} else {
None
};
let scheme = if https { "https" } else { "http" };

let current_dir = std::env::current_dir().context("Failed to get current directory")?;

Expand All @@ -72,39 +88,62 @@ async fn main() -> Result<()> {
.layer(Extension(enc_key.clone()));

let link = utils::with_optional_token(
&format!("http://{ip_with_port}"),
&utils::build_base_url(scheme, &ip_with_port, None),
auth_token.as_deref().map(String::as_str),
);
println!();

qr2term::print_qr(&link).context("Failed to print QR code")?;
println!("\nScan the QR or go to {}", &link);
if let Some(config) = https_config.as_ref()
&& config.is_generated()
{
println!(
"[ INFO ] : Using a self-signed HTTPS certificate; browsers may show a trust warning"
);
}
if no_link_token {
println!("[ INFO ] : Link token disabled for browser access");
}

discovery::spawn_mdns_advertiser(
port,
"receive",
scheme,
auth_token.as_deref().cloned(),
enc_key_encoded.clone(),
https_config
.as_ref()
.map(|config| config.fingerprint().to_string()),
token,
);

let listener = tokio::net::TcpListener::bind(&ip_with_port)
.await
.context(format!("Failed to bind to port {}", port))?;

axum::serve(listener, app)
.with_graceful_shutdown(utils::shutdown_signal(shutdown_token))
.await
.context(format!("Failed to serve web server at {}", ip_with_port))?;
if let Some(config) = https_config {
let addr = ip_with_port.parse()?;
let shutdown = shutdown_token.clone();
let serve = tls::serve_https(addr, app, config.rustls_server_config()?, shutdown);
let (serve_result, _) = tokio::join!(serve, utils::shutdown_signal(shutdown_token));
serve_result
.context(format!("Failed to serve HTTPS server at {}", ip_with_port))?;
} else {
let listener = tokio::net::TcpListener::bind(&ip_with_port)
.await
.context(format!("Failed to bind to port {}", port))?;

axum::serve(listener, app)
.with_graceful_shutdown(utils::shutdown_signal(shutdown_token))
.await
.context(format!("Failed to serve web server at {}", ip_with_port))?;
}
}

Commands::Send {
file_path,
port,
encrypt,
https,
tls_cert,
tls_key,
no_link_token,
} => {
if let Err(e) = std::fs::File::open(&file_path) {
Expand Down Expand Up @@ -137,6 +176,18 @@ async fn main() -> Result<()> {

let local_ip = local_ip().context("Failed to obtain local IP")?;
let ip_with_port = format!("{local_ip}:{}", port);
let (_, mdns_host_name) = discovery::get_mdns_names("send");
let https_config = if https {
Some(tls::HttpsConfig::load_or_generate(
local_ip,
Some(&mdns_host_name),
tls_cert.as_deref(),
tls_key.as_deref(),
)?)
} else {
None
};
let scheme = if https { "https" } else { "http" };

let app = Router::new()
.route("/download", get(server::download))
Expand All @@ -147,31 +198,51 @@ async fn main() -> Result<()> {
.layer(Extension(enc_key.clone()));

let link = utils::with_optional_token(
&format!("http://{ip_with_port}/download"),
&utils::build_base_url(scheme, &ip_with_port, Some("/download")),
auth_token.as_deref().map(String::as_str),
);

qr2term::print_qr(&link).context("Failed to print QR code")?;
println!("\nScan the QR or go to {}", &link);
if let Some(config) = https_config.as_ref()
&& config.is_generated()
{
println!(
"[ INFO ] : Using a self-signed HTTPS certificate; browsers may show a trust warning"
);
}
if no_link_token {
println!("[ INFO ] : Link token disabled for browser access");
}

discovery::spawn_mdns_advertiser(
port,
"send",
scheme,
auth_token.as_deref().cloned(),
enc_key_encoded.clone(),
https_config
.as_ref()
.map(|config| config.fingerprint().to_string()),
token,
);

let listener = tokio::net::TcpListener::bind(&ip_with_port)
.await
.context(format!("Failed to bind to port {}", port))?;
axum::serve(listener, app)
.with_graceful_shutdown(utils::shutdown_signal(shutdown_token))
.await
.context(format!("Failed to serve web server at {}", ip_with_port))?;
if let Some(config) = https_config {
let addr = ip_with_port.parse()?;
let shutdown = shutdown_token.clone();
let serve = tls::serve_https(addr, app, config.rustls_server_config()?, shutdown);
let (serve_result, _) = tokio::join!(serve, utils::shutdown_signal(shutdown_token));
serve_result
.context(format!("Failed to serve HTTPS server at {}", ip_with_port))?;
} else {
let listener = tokio::net::TcpListener::bind(&ip_with_port)
.await
.context(format!("Failed to bind to port {}", port))?;
axum::serve(listener, app)
.with_graceful_shutdown(utils::shutdown_signal(shutdown_token))
.await
.context(format!("Failed to serve web server at {}", ip_with_port))?;
}
}

Commands::Join { file_path } => {
Expand Down
Loading
Loading