From 3ddbeb9e8f998bcbd375791afabc8581c577f523 Mon Sep 17 00:00:00 2001 From: xeisenberg Date: Wed, 8 Apr 2026 23:31:43 +0530 Subject: [PATCH 1/2] Implement HTTPS hosting with self-signed TLS and mDNS fingerprint pinning --- Cargo.lock | 352 ++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 11 +- README.md | 12 +- src/cli.rs | 24 ++++ src/client.rs | 25 +++- src/discovery.rs | 39 ++++-- src/main.rs | 105 +++++++++++--- src/tls.rs | 293 +++++++++++++++++++++++++++++++++++++++ src/utils.rs | 7 + tests/test.rs | 243 +++++++++++++++++++++++++++++++- 10 files changed, 1061 insertions(+), 50 deletions(-) create mode 100644 src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 081ae9f..6d4cae4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -93,12 +93,57 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "aws-lc-rs" version = "1.16.2" @@ -186,6 +231,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -206,9 +260,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -251,7 +305,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -341,6 +395,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -418,6 +478,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -462,6 +531,35 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -505,6 +603,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -518,7 +627,7 @@ dependencies = [ [[package]] name = "drop" -version = "1.1.0" +version = "1.1.2" dependencies = [ "aead", "aes-gcm", @@ -528,17 +637,26 @@ dependencies = [ "clap", "clap_complete", "dialoguer", + "hex", "http-body-util", + "hyper", + "hyper-util", "indicatif", "local-ip-address", "mdns-sd", "qr2term", "rand 0.10.0", + "rcgen", "reqwest", + "rustls", + "rustls-pemfile", + "sha2", "tempfile", "tokio", + "tokio-rustls", "tokio-stream", "tokio-util", + "tower-service", "whoami", ] @@ -587,9 +705,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -808,6 +926,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -853,6 +977,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1043,9 +1176,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1170,6 +1303,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1284,6 +1423,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1342,6 +1487,50 @@ dependencies = [ "syn", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -1360,6 +1549,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1407,6 +1605,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1446,6 +1654,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1644,6 +1858,20 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1715,6 +1943,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1748,6 +1985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -1767,6 +2005,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1877,9 +2124,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1947,6 +2194,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -2134,6 +2392,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2161,9 +2450,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -2178,9 +2467,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2335,7 +2624,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -2967,9 +3256,36 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 1b5d87f..1cea921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "drop" -version = "1.1.0" +version = "1.1.2" edition = "2024" [dependencies] @@ -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" @@ -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" diff --git a/README.md b/README.md index 9040d91..573e917 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,17 @@ drop join path/to/file.ext # join a host in 'receive' mode (upload) | `-p`, `--port ` | Custom port (1024–65535, default: `1844`) | | `--max-size ` | 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 ` | PEM certificate to use with `--https` | +| `--tls-key ` | 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 ``` @@ -85,7 +91,11 @@ drop send ./movie.mp4 --no-link-token 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 diff --git a/src/cli.rs b/src/cli.rs index 311ac05..251fca1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + + /// Path to a PEM-encoded TLS private key file + #[arg(long, requires = "https")] + tls_key: Option, + /// Disable token protection on generated QR code and browser links #[arg(long, default_value_t = false)] no_link_token: bool, @@ -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, + + /// Path to a PEM-encoded TLS private key file + #[arg(long, requires = "https")] + tls_key: Option, + /// Disable token protection on generated QR code and browser links #[arg(long, default_value_t = false)] no_link_token: bool, diff --git a/src/client.rs b/src/client.rs index ae90aae..387e05b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -76,6 +76,8 @@ pub async fn join_network(file_path: Option) -> 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") @@ -91,11 +93,11 @@ pub async fn join_network(file_path: Option) -> 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..."); @@ -226,7 +228,7 @@ pub async fn join_network(file_path: Option) -> 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, ); @@ -363,3 +365,20 @@ pub async fn join_network(file_path: Option) -> Result<()> { Ok(()) } + +fn build_client_for_host( + scheme: &str, + expected_fingerprint: Option<&str>, +) -> Result { + 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}" + )), + } +} diff --git a/src/discovery.rs b/src/discovery.rs index 5ecd6e2..addd2c6 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -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, enc_key: Option, + tls_fingerprint: Option, token: CancellationToken, ) { tokio::spawn(async move { @@ -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")? @@ -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 { + 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 +} diff --git a/src/main.rs b/src/main.rs index f2c1b5e..66de46e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod client; mod crypto; mod discovery; mod server; +mod tls; mod utils; use anyhow::{Context, Result}; @@ -30,6 +31,9 @@ async fn main() -> Result<()> { port, max_size, encrypt, + https, + tls_cert, + tls_key, no_link_token, } => { let auth_token: Option> = (!no_link_token).then(|| { @@ -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")?; @@ -72,13 +88,20 @@ 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"); } @@ -86,25 +109,41 @@ async fn main() -> Result<()> { 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) { @@ -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)) @@ -147,12 +198,19 @@ 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"); } @@ -160,18 +218,31 @@ async fn main() -> Result<()> { 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 } => { diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..c478012 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,293 @@ +use anyhow::{Context, Result, bail}; +use axum::Router; +use hyper::service::service_fn; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder as HyperBuilder; +use rcgen::{CertificateParams, DnType, KeyPair, SanType}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use rustls::{ + ClientConfig, DigitallySignedStruct, Error as RustlsError, ServerConfig, SignatureScheme, +}; +use sha2::{Digest, Sha256}; +use std::net::IpAddr; +use std::path::Path; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +#[derive(Debug)] +pub struct HttpsConfig { + cert_chain: Vec>, + private_key: PrivateKeyDer<'static>, + fingerprint: String, + generated: bool, +} + +impl HttpsConfig { + pub fn load_or_generate( + local_ip: IpAddr, + mdns_host_name: Option<&str>, + cert_path: Option<&Path>, + key_path: Option<&Path>, + ) -> Result { + match (cert_path, key_path) { + (Some(cert_path), Some(key_path)) => Self::from_pem_files(cert_path, key_path), + _ => Self::generate(local_ip, mdns_host_name), + } + } + + fn from_pem_files(cert_path: &Path, key_path: &Path) -> Result { + let cert_pem = std::fs::read(cert_path).with_context(|| { + format!( + "Failed to read TLS certificate from {}", + cert_path.display() + ) + })?; + let key_pem = std::fs::read(key_path).with_context(|| { + format!("Failed to read TLS private key from {}", key_path.display()) + })?; + + let cert_chain: Vec> = rustls_pemfile::certs(&mut &cert_pem[..]) + .collect::>() + .context("Failed to parse TLS certificate PEM")?; + + if cert_chain.is_empty() { + bail!("TLS certificate file did not contain any certificates"); + } + + let private_key = load_private_key(&key_pem)?; + Ok(Self { + fingerprint: fingerprint_hex(cert_chain[0].as_ref()), + cert_chain, + private_key, + generated: false, + }) + } + + fn generate(local_ip: IpAddr, mdns_host_name: Option<&str>) -> Result { + let mut params = CertificateParams::new(vec!["localhost".to_string()]) + .context("Failed to initialize self-signed certificate parameters")?; + params.subject_alt_names.push(SanType::IpAddress(local_ip)); + if let Some(host_name) = mdns_host_name.and_then(normalize_mdns_san_name) { + params.subject_alt_names.push(SanType::DnsName( + host_name + .try_into() + .context("Failed to encode mDNS hostname into certificate SAN")?, + )); + } + params + .distinguished_name + .push(DnType::CommonName, "drop self-signed"); + + let key_pair = KeyPair::generate().context("Failed to generate self-signed private key")?; + let cert = params + .self_signed(&key_pair) + .context("Failed to generate self-signed certificate")?; + let cert_der: CertificateDer<'static> = cert.der().clone(); + let private_key = PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()); + + Ok(Self { + fingerprint: fingerprint_hex(cert_der.as_ref()), + cert_chain: vec![cert_der], + private_key, + generated: true, + }) + } + + pub fn rustls_server_config(&self) -> Result> { + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(self.cert_chain.clone(), self.private_key.clone_key()) + .context("Failed to build Rustls server config")?; + Ok(Arc::new(config)) + } + + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + pub fn is_generated(&self) -> bool { + self.generated + } +} + +fn load_private_key(key_pem: &[u8]) -> Result> { + let mut reader = &key_pem[..]; + let mut pkcs8_keys = rustls_pemfile::pkcs8_private_keys(&mut reader); + if let Some(key) = pkcs8_keys.next() { + return Ok(PrivateKeyDer::Pkcs8( + key.context("Failed to parse PKCS#8 private key")?, + )); + } + + let mut reader = &key_pem[..]; + let mut rsa_keys = rustls_pemfile::rsa_private_keys(&mut reader); + if let Some(key) = rsa_keys.next() { + return Ok(PrivateKeyDer::Pkcs1( + key.context("Failed to parse RSA private key")?, + )); + } + + let mut reader = &key_pem[..]; + let mut sec1_keys = rustls_pemfile::ec_private_keys(&mut reader); + if let Some(key) = sec1_keys.next() { + return Ok(PrivateKeyDer::Sec1( + key.context("Failed to parse SEC1 private key")?, + )); + } + + bail!("TLS private key file did not contain a supported key") +} + +pub fn build_pinned_https_client(expected_fingerprint: &str) -> Result { + let verifier = Arc::new(FingerprintVerifier::new(expected_fingerprint)?); + let config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + + reqwest::Client::builder() + .use_preconfigured_tls(config) + .build() + .context("Failed to build HTTPS client") +} + +pub async fn serve_https( + addr: std::net::SocketAddr, + app: Router, + config: Arc, + shutdown_token: CancellationToken, +) -> Result<()> { + let listener = TcpListener::bind(addr) + .await + .with_context(|| format!("Failed to bind TLS listener on {}", addr))?; + let acceptor = tokio_rustls::TlsAcceptor::from(config); + + loop { + tokio::select! { + biased; + _ = shutdown_token.cancelled() => break, + accept_result = listener.accept() => { + let (stream, _) = accept_result.context("Failed to accept TLS connection")?; + let acceptor = acceptor.clone(); + let app = app.clone(); + + tokio::spawn(async move { + let tls_stream = match acceptor.accept(stream).await { + Ok(stream) => stream, + Err(_) => return, + }; + + let service = service_fn(move |request| { + let mut app = app.clone(); + async move { + use tower_service::Service; + app.call(request).await + } + }); + + let io = TokioIo::new(tls_stream); + let builder = HyperBuilder::new(TokioExecutor::new()); + let connection = builder.serve_connection_with_upgrades(io, service); + let _ = connection.await; + }); + } + } + } + + Ok(()) +} + +pub fn fingerprint_hex(cert_der: &[u8]) -> String { + hex::encode(Sha256::digest(cert_der)) +} + +pub fn normalize_mdns_san_name(host_name: &str) -> Option { + let trimmed = host_name.trim_end_matches('.'); + if trimmed.is_empty() { + return None; + } + + let valid = trimmed + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.'); + valid.then(|| trimmed.to_string()) +} + +#[derive(Debug)] +struct FingerprintVerifier { + expected_fingerprint: String, +} + +impl FingerprintVerifier { + fn new(expected_fingerprint: &str) -> Result { + if expected_fingerprint.is_empty() { + bail!("Expected TLS fingerprint cannot be empty"); + } + + Ok(Self { + expected_fingerprint: expected_fingerprint.to_ascii_lowercase(), + }) + } + + fn verify_fingerprint( + &self, + end_entity: &CertificateDer<'_>, + ) -> Result { + let actual_fingerprint = fingerprint_hex(end_entity.as_ref()); + if actual_fingerprint == self.expected_fingerprint { + Ok(ServerCertVerified::assertion()) + } else { + Err(RustlsError::General(format!( + "Server certificate fingerprint mismatch (expected {}, got {})", + self.expected_fingerprint, actual_fingerprint + ))) + } + } +} + +impl ServerCertVerifier for FingerprintVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + self.verify_fingerprint(end_entity) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + ] + } +} diff --git a/src/utils.rs b/src/utils.rs index 7ea42f0..e8e8e46 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -46,6 +46,13 @@ pub fn with_optional_token(url: &str, token: Option<&str>) -> String { } } +pub fn build_base_url(scheme: &str, host: &str, path: Option<&str>) -> String { + match path { + Some(path) => format!("{scheme}://{host}{path}"), + None => format!("{scheme}://{host}"), + } +} + pub async fn shutdown_signal(token: CancellationToken) { let ctrl_c = async { tokio::signal::ctrl_c() diff --git a/tests/test.rs b/tests/test.rs index 35889ad..2c68890 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -4,7 +4,7 @@ use axum::{ routing::{get, post}, }; use reqwest::{Client, StatusCode}; -use std::{io::Write, path::PathBuf, sync::Arc}; +use std::{io::Write, net::IpAddr, path::PathBuf, sync::Arc}; use tempfile::NamedTempFile; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; @@ -29,6 +29,10 @@ mod cli; #[path = "../src/discovery.rs"] mod discovery; +#[allow(dead_code)] +#[path = "../src/tls.rs"] +mod tls; + async fn spawn_test_server(app: Router) -> String { // Binding to port 0 tells the OS to assign a random available port. let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -42,6 +46,41 @@ async fn spawn_test_server(app: Router) -> String { address } +async fn spawn_https_test_server(app: Router) -> (String, String, CancellationToken) { + let shutdown = CancellationToken::new(); + let config = tls::HttpsConfig::load_or_generate( + IpAddr::from([127, 0, 0, 1]), + Some("drop-test.local."), + None, + None, + ) + .unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let addr = format!("127.0.0.1:{port}").parse().unwrap(); + let url = format!("https://127.0.0.1:{port}"); + let fingerprint = config.fingerprint().to_string(); + let server_shutdown = shutdown.clone(); + + tokio::spawn(async move { + tls::serve_https( + addr, + app, + config.rustls_server_config().unwrap(), + server_shutdown, + ) + .await + .unwrap(); + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + (url, fingerprint, shutdown) +} + // ---------------------------------------------------------------------- // 1. TEST: The Upload Web UI (GET /) // ---------------------------------------------------------------------- @@ -231,6 +270,18 @@ fn test_utils_format_size() { assert_eq!(utils::format_size(4_120_000_000), "4.12 Gigabytes"); } +#[test] +fn test_utils_build_base_url() { + assert_eq!( + utils::build_base_url("http", "127.0.0.1:1844", Some("/download")), + "http://127.0.0.1:1844/download" + ); + assert_eq!( + utils::build_base_url("https", "127.0.0.1:1844", None), + "https://127.0.0.1:1844" + ); +} + #[test] fn test_utils_with_optional_token() { assert_eq!( @@ -440,9 +491,19 @@ fn test_cli_parsing() { // Test default port and mode let cli = cli::Cli::try_parse_from(&["drop", "receive"]).unwrap(); match cli.command { - cli::Commands::Receive { port, encrypt, .. } => { + cli::Commands::Receive { + port, + encrypt, + https, + tls_cert, + tls_key, + .. + } => { assert_eq!(port, 1844); assert!(!encrypt); + assert!(!https); + assert!(tls_cert.is_none()); + assert!(tls_key.is_none()); } _ => panic!("Expected Receive subcommand"), } @@ -461,6 +522,36 @@ fn test_cli_parsing() { _ => panic!("Expected Receive subcommand"), } + let cli = cli::Cli::try_parse_from(&["drop", "receive", "--https"]).unwrap(); + match cli.command { + cli::Commands::Receive { https, .. } => assert!(https), + _ => panic!("Expected Receive subcommand"), + } + + let cli = cli::Cli::try_parse_from(&[ + "drop", + "receive", + "--https", + "--tls-cert", + "cert.pem", + "--tls-key", + "key.pem", + ]) + .unwrap(); + match cli.command { + cli::Commands::Receive { + https, + tls_cert, + tls_key, + .. + } => { + assert!(https); + assert_eq!(tls_cert.unwrap(), PathBuf::from("cert.pem")); + assert_eq!(tls_key.unwrap(), PathBuf::from("key.pem")); + } + _ => panic!("Expected Receive subcommand"), + } + let cli = cli::Cli::try_parse_from(&["drop", "receive", "--no-link-token"]).unwrap(); match cli.command { cli::Commands::Receive { no_link_token, .. } => assert!(no_link_token), @@ -474,11 +565,17 @@ fn test_cli_parsing() { file_path, port, encrypt, + https, + tls_cert, + tls_key, no_link_token, } => { assert_eq!(file_path.to_str().unwrap(), "my_file.txt"); assert_eq!(port, 1844); assert!(!encrypt); + assert!(!https); + assert!(tls_cert.is_none()); + assert!(tls_key.is_none()); assert!(!no_link_token); } _ => panic!("Expected Send subcommand"), @@ -490,6 +587,17 @@ fn test_cli_parsing() { cli::Commands::Send { no_link_token, .. } => assert!(no_link_token), _ => panic!("Expected Send subcommand"), } + + let cli = cli::Cli::try_parse_from(&["drop", "send", "my_file.txt", "--https"]).unwrap(); + match cli.command { + cli::Commands::Send { https, .. } => assert!(https), + _ => panic!("Expected Send subcommand"), + } + + assert!(cli::Cli::try_parse_from(&["drop", "receive", "--tls-cert", "cert.pem"]).is_err()); + assert!( + cli::Cli::try_parse_from(&["drop", "send", "my_file.txt", "--tls-key", "key.pem"]).is_err() + ); } // ---------------------------------------------------------------------- @@ -510,6 +618,51 @@ fn test_discovery_name_logic() { // The code handles truncation internally. } +#[test] +fn test_discovery_properties_include_https_metadata() { + let properties = discovery::build_mdns_properties( + "send", + "https", + Some("token"), + Some("enc-key"), + Some("fingerprint"), + ); + + assert_eq!(properties.get("mode").unwrap(), "send"); + assert_eq!(properties.get("scheme").unwrap(), "https"); + assert_eq!(properties.get("token").unwrap(), "token"); + assert_eq!(properties.get("enc_key").unwrap(), "enc-key"); + assert_eq!(properties.get("tls_fp").unwrap(), "fingerprint"); +} + +#[test] +fn test_tls_fingerprint_is_stable() { + let config = tls::HttpsConfig::load_or_generate( + IpAddr::from([127, 0, 0, 1]), + Some("drop-test.local."), + None, + None, + ) + .unwrap(); + + let fingerprint = config.fingerprint().to_string(); + assert_eq!(fingerprint.len(), 64); + assert_eq!(fingerprint, config.fingerprint()); +} + +#[test] +fn test_tls_self_signed_cert_is_generated() { + let config = tls::HttpsConfig::load_or_generate( + IpAddr::from([127, 0, 0, 1]), + Some("drop-test.local."), + None, + None, + ) + .unwrap(); + + assert!(config.is_generated()); +} + // ---------------------------------------------------------------------- // 11. TEST: Server Filename Sanitization // ---------------------------------------------------------------------- @@ -669,3 +822,89 @@ async fn test_server_cancellation_on_download() { // The handler calls token.cancel() after streaming assert!(token.is_cancelled()); } + +#[tokio::test] +async fn test_https_server_download_flow_with_pinned_client() { + let mut temp_file = NamedTempFile::new().unwrap(); + let file_content = b"Hello over HTTPS"; + temp_file.write_all(file_content).unwrap(); + + let file_path = Arc::new(PathBuf::from(temp_file.path())); + let token = CancellationToken::new(); + + let app = Router::new() + .route("/download", get(server::download)) + .layer(Extension(file_path)) + .layer(Extension(token)) + .layer(Extension(None as Option>)); + + let (base_url, fingerprint, shutdown) = spawn_https_test_server(app).await; + let client = tls::build_pinned_https_client(&fingerprint).unwrap(); + + let res = client + .get(format!("{}/download", base_url)) + .send() + .await + .unwrap(); + + assert!(res.status().is_success()); + assert_eq!(res.bytes().await.unwrap().as_ref(), file_content); + shutdown.cancel(); +} + +#[tokio::test] +async fn test_https_server_upload_flow_with_pinned_client() { + let temp_dir = tempfile::tempdir().unwrap(); + let token = CancellationToken::new(); + let upload_dir = Arc::new(temp_dir.path().to_path_buf()); + + let app = Router::new() + .route("/upload", post(server::post_upload)) + .layer(DefaultBodyLimit::disable()) + .layer(Extension(token)) + .layer(Extension(upload_dir)) + .layer(Extension(None as Option>)); + + let (base_url, fingerprint, shutdown) = spawn_https_test_server(app).await; + let client = tls::build_pinned_https_client(&fingerprint).unwrap(); + + let file_content = "secure upload"; + let part = reqwest::multipart::Part::bytes(file_content.as_bytes().to_vec()) + .file_name("https_upload.txt"); + let form = reqwest::multipart::Form::new().part("uploadedFile", part); + + let res = client + .post(format!("{}/upload", base_url)) + .multipart(form) + .send() + .await + .unwrap(); + + assert!(res.status().is_success()); + let saved_file_path = temp_dir.path().join("https_upload.txt"); + assert_eq!( + std::fs::read_to_string(saved_file_path).unwrap(), + file_content + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn test_https_client_rejects_wrong_fingerprint() { + let token = CancellationToken::new(); + let app = Router::new() + .route("/", get(|| async { "ok" })) + .layer(Extension(token)); + + let (base_url, _fingerprint, shutdown) = spawn_https_test_server(app).await; + let client = tls::build_pinned_https_client(&"0".repeat(64)).unwrap(); + + let err = client + .get(format!("{}/", base_url)) + .send() + .await + .unwrap_err(); + let err_text = format!("{err:?}").to_ascii_lowercase(); + assert!(err_text.contains("fingerprint") || err_text.contains("certificate")); + shutdown.cancel(); +} From a0d663ce26cc7d0c693c95064b97ea9f9f559965 Mon Sep 17 00:00:00 2001 From: xeisenberg Date: Wed, 8 Apr 2026 23:35:25 +0530 Subject: [PATCH 2/2] fix clippy --- src/tls.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tls.rs b/src/tls.rs index c478012..427c18c 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -113,23 +113,25 @@ impl HttpsConfig { } fn load_private_key(key_pem: &[u8]) -> Result> { - let mut reader = &key_pem[..]; + let mut reader = key_pem; let mut pkcs8_keys = rustls_pemfile::pkcs8_private_keys(&mut reader); if let Some(key) = pkcs8_keys.next() { return Ok(PrivateKeyDer::Pkcs8( key.context("Failed to parse PKCS#8 private key")?, )); } + drop(pkcs8_keys); - let mut reader = &key_pem[..]; + let mut reader = key_pem; let mut rsa_keys = rustls_pemfile::rsa_private_keys(&mut reader); if let Some(key) = rsa_keys.next() { return Ok(PrivateKeyDer::Pkcs1( key.context("Failed to parse RSA private key")?, )); } + drop(rsa_keys); - let mut reader = &key_pem[..]; + let mut reader = key_pem; let mut sec1_keys = rustls_pemfile::ec_private_keys(&mut reader); if let Some(key) = sec1_keys.next() { return Ok(PrivateKeyDer::Sec1(