From d5089cab2ac1ec87f47498e9a914abf30d79f3c0 Mon Sep 17 00:00:00 2001 From: jtroo Date: Fri, 8 Apr 2022 01:13:31 -0700 Subject: [PATCH] Add optional secret for authenticating clients (#1) * Add optional secret for authenticating clients * Add server challenge to authentication * Refactor and simplify code, reduce dependencies * Update README to describe HMAC authentication Co-authored-by: Eric Zhang --- Cargo.lock | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 ++ README.md | 28 ++++++++++++---- src/auth.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++ src/client.rs | 32 +++++++++++++++--- src/lib.rs | 1 + src/main.rs | 15 +++++++-- src/server.rs | 21 ++++++++++-- src/shared.rs | 10 ++++-- 9 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index fa53710..024c451 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + [[package]] name = "bore-cli" version = "0.1.1" @@ -80,8 +89,11 @@ dependencies = [ "anyhow", "clap", "dashmap", + "hex", + "hmac", "serde", "serde_json", + "sha2", "tokio", "tracing", "tracing-subscriber", @@ -136,6 +148,25 @@ dependencies = [ "syn", ] +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dashmap" version = "5.2.0" @@ -147,6 +178,27 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.6" @@ -185,6 +237,21 @@ dependencies = [ "libc", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "indexmap" version = "1.8.1" @@ -443,6 +510,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.4" @@ -483,6 +561,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + [[package]] name = "syn" version = "1.0.90" @@ -607,6 +691,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + [[package]] name = "unicode-xid" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 4083a25..70a5b89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,11 @@ path = "src/main.rs" anyhow = { version = "1.0.56", features = ["backtrace"] } clap = { version = "3.1.8", features = ["derive"] } dashmap = "5.2.0" +hex = "0.4.3" +hmac = "0.12.1" serde = { version = "1.0.136", features = ["derive"] } serde_json = "1.0.79" +sha2 = "0.10.2" tokio = { version = "1.17.0", features = ["full"] } tracing = "0.1.32" tracing-subscriber = "0.3.10" diff --git a/README.md b/README.md index 7cc386b..5b6a0fa 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This will expose your local port at `localhost:8000` to the public internet at ` Similar to [localtunnel](https://github.com/localtunnel/localtunnel) and [ngrok](https://ngrok.io/), except `bore` is intended to be a highly efficient, unopinionated tool for forwarding TCP traffic that is simple to install and easy to self-host, with no frills attached. -(`bore` totals less than 300 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.) +(`bore` totals less than 400 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.) ## Detailed Usage @@ -38,7 +38,7 @@ You can optionally pass in a `--port` option to pick a specific port on the remo The full options are shown below. ```shell -bore-local 0.1.0 +bore-local 0.1.1 Starts a local proxy to the remote server USAGE: @@ -48,10 +48,11 @@ ARGS: The local port to listen on OPTIONS: - -h, --help Print help information - -p, --port Optional port on the remote server to select [default: 0] - -t, --to Address of the remote server to expose local ports to - -V, --version Print version information + -h, --help Print help information + -p, --port Optional port on the remote server to select [default: 0] + -s, --secret Optional secret for authentication + -t, --to Address of the remote server to expose local ports to + -V, --version Print version information ``` ### Self-Hosting @@ -67,7 +68,7 @@ That's all it takes! After the server starts running at a given address, you can The full options for the `bore server` command are shown below. ```shell -bore-server 0.1.0 +bore-server 0.1.1 Runs the remote proxy server USAGE: @@ -76,6 +77,7 @@ USAGE: OPTIONS: -h, --help Print help information --min-port Minimum TCP port number to accept [default: 1024] + -s, --secret Optional secret for authentication -V, --version Print version information ``` @@ -87,6 +89,18 @@ Whenever the server obtains a connection on the remote port, it generates a secu For correctness reasons and to avoid memory leaks, incoming connections are only stored by the server for up to 10 seconds before being discarded if the client does not accept them. +## Authentication + +On a custom deployment of `bore server`, you can optionally require a _secret_ to prevent the server from being used by others. The protocol requires clients to verify possession of the secret on each TCP connection by answering random challenges in the form of HMAC codes. (This secret is only used for the initial handshake, and no further traffic is encrypted by default.) + +```shell +# on the server +bore server --secret my_secret_string + +# on the client +bore local --to --secret my_secret_string +``` + ## Acknowledgements Created by Eric Zhang ([@ekzhang1](https://twitter.com/ekzhang1)). Licensed under the [MIT license](LICENSE). diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..e1616b0 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,79 @@ +//! Auth implementation for bore client and server. + +use anyhow::{bail, ensure, Result}; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncBufRead, AsyncWrite}; +use uuid::Uuid; + +use crate::shared::{recv_json, send_json, ClientMessage, ServerMessage}; + +/// Wrapper around a MAC used for authenticating clients that have a secret. +pub struct Authenticator(Hmac); + +impl Authenticator { + /// Generate an authenticator from a secret. + pub fn new(secret: &str) -> Self { + let hashed_secret = Sha256::new().chain_update(secret).finalize(); + Self(Hmac::new_from_slice(&hashed_secret).expect("HMAC can take key of any size")) + } + + /// Generate a reply message for a challenge. + pub fn answer(&self, challenge: &Uuid) -> String { + let mut hmac = self.0.clone(); + hmac.update(challenge.as_bytes()); + hex::encode(hmac.finalize().into_bytes()) + } + + /// Validate a reply to a challenge. + /// + /// ``` + /// use uuid:Uuid; + /// use crate::auth::Authenticator; + /// + /// let auth = Authenticator::new("secret"); + /// let challenge = Uuid::new_v4(); + /// + /// assert!(auth.validate(&challenge, auth.answer(&challenge))); + /// assert!(!auth.validate(&challenge, "wrong answer")); + /// ``` + pub fn validate(&self, challenge: &Uuid, tag: &str) -> bool { + if let Ok(tag) = hex::decode(tag) { + let mut hmac = self.0.clone(); + hmac.update(challenge.as_bytes()); + hmac.verify_slice(&tag).is_ok() + } else { + false + } + } + + /// As the server, send a challenge to the client and validate their response. + pub async fn server_handshake( + &self, + stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin), + ) -> Result<()> { + let challenge = Uuid::new_v4(); + send_json(stream, ServerMessage::Challenge(challenge)).await?; + match recv_json(stream, &mut Vec::new()).await? { + Some(ClientMessage::Authenticate(tag)) => { + ensure!(self.validate(&challenge, &tag), "invalid secret"); + Ok(()) + } + _ => bail!("server requires secret, but no secret was provided"), + } + } + + /// As the client, answer a challenge to attempt to authenticate with the server. + pub async fn client_handshake( + &self, + stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin), + ) -> Result<()> { + let challenge = match recv_json(stream, &mut Vec::new()).await? { + Some(ServerMessage::Challenge(challenge)) => challenge, + _ => bail!("expected authentication challenge, but no secret was required"), + }; + let tag = self.answer(&challenge); + send_json(stream, ClientMessage::Authenticate(tag)).await?; + Ok(()) + } +} diff --git a/src/client.rs b/src/client.rs index 79e4082..7ba0d0c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -7,6 +7,7 @@ use tokio::{io::BufReader, net::TcpStream}; use tracing::{error, info, info_span, warn, Instrument}; use uuid::Uuid; +use crate::auth::Authenticator; use crate::shared::{proxy, recv_json, send_json, ClientMessage, ServerMessage, CONTROL_PORT}; /// State structure for the client. @@ -22,18 +23,31 @@ pub struct Client { /// Port that is publicly available on the remote. remote_port: u16, + + /// Optional secret used to authenticate clients. + auth: Option, } impl Client { /// Create a new client. - pub async fn new(local_port: u16, to: &str, port: u16) -> Result { - let stream = TcpStream::connect((to, CONTROL_PORT)).await?; + pub async fn new(local_port: u16, to: &str, port: u16, secret: Option<&str>) -> Result { + let stream = TcpStream::connect((to, CONTROL_PORT)) + .await + .with_context(|| format!("could not connect to {to}:{CONTROL_PORT}"))?; let mut stream = BufReader::new(stream); + let auth = secret.map(Authenticator::new); + if let Some(auth) = &auth { + auth.client_handshake(&mut stream).await?; + } + send_json(&mut stream, ClientMessage::Hello(port)).await?; let remote_port = match recv_json(&mut stream, &mut Vec::new()).await? { Some(ServerMessage::Hello(remote_port)) => remote_port, Some(ServerMessage::Error(message)) => bail!("server error: {message}"), + Some(ServerMessage::Challenge(_)) => { + bail!("server requires authentication, but no client secret was provided"); + } Some(_) => bail!("unexpected initial non-hello message"), None => bail!("unexpected EOF"), }; @@ -45,6 +59,7 @@ impl Client { to: to.to_string(), local_port, remote_port, + auth, }) } @@ -62,6 +77,7 @@ impl Client { let msg = recv_json(&mut conn, &mut buf).await?; match msg { Some(ServerMessage::Hello(_)) => warn!("unexpected hello"), + Some(ServerMessage::Challenge(_)) => warn!("unexpected challenge"), Some(ServerMessage::Heartbeat) => (), Some(ServerMessage::Connection(id)) => { let this = Arc::clone(&this); @@ -86,9 +102,15 @@ impl Client { let local_conn = TcpStream::connect(("localhost", self.local_port)) .await .context("failed TCP connection to local port")?; - let mut remote_conn = TcpStream::connect((&self.to[..], CONTROL_PORT)) - .await - .context("failed TCP connection to remote port")?; + let mut remote_conn = BufReader::new( + TcpStream::connect((&self.to[..], CONTROL_PORT)) + .await + .context("failed TCP connection to remote port")?, + ); + + if let Some(auth) = &self.auth { + auth.client_handshake(&mut remote_conn).await?; + } send_json(&mut remote_conn, ClientMessage::Accept(id)).await?; proxy(local_conn, remote_conn).await?; diff --git a/src/lib.rs b/src/lib.rs index 4c595e5..18008dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod auth; pub mod client; pub mod server; pub mod shared; diff --git a/src/main.rs b/src/main.rs index cdcafad..8021446 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,10 @@ enum Command { /// Optional port on the remote server to select. #[clap(short, long, default_value_t = 0)] port: u16, + + /// Optional secret for authentication. + #[clap(short, long)] + secret: Option, }, /// Runs the remote proxy server. @@ -31,6 +35,10 @@ enum Command { /// Minimum TCP port number to accept. #[clap(long, default_value_t = 1024)] min_port: u16, + + /// Optional secret for authentication. + #[clap(short, long)] + secret: Option, }, } @@ -44,12 +52,13 @@ async fn main() -> Result<()> { local_port, to, port, + secret, } => { - let client = Client::new(local_port, &to, port).await?; + let client = Client::new(local_port, &to, port, secret.as_deref()).await?; client.listen().await?; } - Command::Server { min_port } => { - Server::new(min_port).listen().await?; + Command::Server { min_port, secret } => { + Server::new(min_port, secret.as_deref()).listen().await?; } } diff --git a/src/server.rs b/src/server.rs index 842d6f7..b75667a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -12,6 +12,7 @@ use tokio::time::{sleep, timeout}; use tracing::{info, info_span, warn, Instrument}; use uuid::Uuid; +use crate::auth::Authenticator; use crate::shared::{proxy, recv_json, send_json, ClientMessage, ServerMessage, CONTROL_PORT}; /// State structure for the server. @@ -19,16 +20,20 @@ pub struct Server { /// The minimum TCP port that can be forwarded. min_port: u16, + /// Optional secret used to authenticate clients. + auth: Option, + /// Concurrent map of IDs to incoming connections. conns: Arc>, } impl Server { /// Create a new server with a specified minimum port number. - pub fn new(min_port: u16) -> Self { + pub fn new(min_port: u16, secret: Option<&str>) -> Self { Server { min_port, conns: Arc::new(DashMap::new()), + auth: secret.map(Authenticator::new), } } @@ -58,11 +63,22 @@ impl Server { async fn handle_connection(&self, stream: TcpStream) -> Result<()> { let mut stream = BufReader::new(stream); + if let Some(auth) = &self.auth { + if let Err(err) = auth.server_handshake(&mut stream).await { + warn!(%err, "server handshake failed"); + send_json(&mut stream, ServerMessage::Error(err.to_string())).await?; + return Ok(()); + } + } let mut buf = Vec::new(); let msg = recv_json(&mut stream, &mut buf).await?; match msg { + Some(ClientMessage::Authenticate(_)) => { + warn!("unexpected authenticate"); + Ok(()) + } Some(ClientMessage::Hello(port)) => { if port != 0 && port < self.min_port { warn!(?port, "client port number too low"); @@ -99,6 +115,7 @@ impl Server { let id = Uuid::new_v4(); let conns = Arc::clone(&self.conns); + conns.insert(id, stream2); tokio::spawn(async move { // Remove stale entries to avoid memory leaks. @@ -129,6 +146,6 @@ impl Server { impl Default for Server { fn default() -> Self { - Server::new(1024) + Server::new(1024, None) } } diff --git a/src/shared.rs b/src/shared.rs index bf18806..a01fc25 100644 --- a/src/shared.rs +++ b/src/shared.rs @@ -10,8 +10,11 @@ use uuid::Uuid; pub const CONTROL_PORT: u16 = 7835; /// A message from the client on the control connection. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub enum ClientMessage { + /// Response to an authentication challenge from the server. + Authenticate(String), + /// Initial client message specifying a port to forward. Hello(u16), @@ -20,8 +23,11 @@ pub enum ClientMessage { } /// A message from the server on the control connection. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub enum ServerMessage { + /// Authentication challenge, sent as the first message, if enabled. + Challenge(Uuid), + /// Response to a client's initial message, with actual public port. Hello(u16),