diff --git a/README.md b/README.md index 1ec8f8e..eadb379 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ login, and attempt to prompt you for your password. - [`matrix-commander-rs`](https://github.com/8go/matrix-commander-rs) - [Matrix](https://matrix.org/) - Your Matrix user - - A Matrix bot user (just a normal user with a noticeable username like `notification-bot`) + - A Matrix bot user (just a normal user with a noticeable username like `gpg-bot`) - A private Matrix channel between you and the bot (preferably encrypted). - A Matrix client app for mobile, e.g. [Element](https://element.io/) - A proxy server with HTTPS support (E.g. [`caddy`](https://caddyserver.com/)) @@ -92,6 +92,26 @@ For development contributors only: ## Installation instructions +Configure the domain used for login links. This should be a subdomain you +control, e.g. `gpg.yourdomain.com`: + +```{bash} +mkdir -p "$HOME/.config/web-pinentry" +echo "gpg.yourdomain.com" >> "$HOME/.config/web-pinentry/domain" +``` + +Install and setup a reverse proxy with HTTPS support, e.g. `caddy`: + +- Install `caddy` / `nginx` / `apache` or any other reverse proxy with HTTPS support. +- Configure the reverse proxy to forward requests from your chosen domain to + 127.0.0.1:7563, where `web-pinentry` will be running its HTTP server. For example, with `caddy`: + +```{caddyfile} +gpg.yourdomain.com { + reverse_proxy 127.0.0.1:7563 +} +``` + Building and installing the `web-pinentry` program: ```{bash} @@ -140,7 +160,7 @@ Testing that you can receive messages from the bot: matrix-commander-rs --message 'Hello world!' ``` -The above should trigger a notification on your phone via the Matrix client app +The above should trigger a message notification on your phone via the Matrix client app you have installed. You might notice the message come with a warning about the bot's client. diff --git a/src/main.rs b/src/main.rs index b8beafb..6d98cb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::env; -use std::fs::File; +use std::fs::{self, File}; use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::process; @@ -8,384 +8,399 @@ const PATH_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv const MAX_ROUTE_HITS: u8 = 2; fn write_ok(stdout: &mut impl Write) -> io::Result<()> { - writeln!(stdout, "OK")?; - stdout.flush() + writeln!(stdout, "OK")?; + stdout.flush() } fn write_data(stdout: &mut impl Write, value: &str) -> io::Result<()> { - let mut encoded = String::with_capacity(value.len()); + let mut encoded = String::with_capacity(value.len()); - for byte in value.bytes() { - match byte { - b'\r' => encoded.push_str("%0D"), - b'\n' => encoded.push_str("%0A"), - b'%' => encoded.push_str("%25"), - _ => encoded.push(char::from(byte)), - } - } + for byte in value.bytes() { + match byte { + b'\r' => encoded.push_str("%0D"), + b'\n' => encoded.push_str("%0A"), + b'%' => encoded.push_str("%25"), + _ => encoded.push(char::from(byte)), + } + } - writeln!(stdout, "D {encoded}")?; - stdout.flush() + writeln!(stdout, "D {encoded}")?; + stdout.flush() } fn write_err(stdout: &mut impl Write, code: u32, message: &str) -> io::Result<()> { - writeln!(stdout, "ERR {code} {message}")?; - stdout.flush() + writeln!(stdout, "ERR {code} {message}")?; + stdout.flush() } fn assuan_unescape(value: &str) -> String { - let bytes = value.as_bytes(); - let mut output = Vec::with_capacity(bytes.len()); - let mut index = 0; + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' && index + 2 < bytes.len() { - let hex = &value[index + 1..index + 3]; - if let Ok(decoded) = u8::from_str_radix(hex, 16) { - output.push(decoded); - index += 3; - continue; - } - } + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = &value[index + 1..index + 3]; + if let Ok(decoded) = u8::from_str_radix(hex, 16) { + output.push(decoded); + index += 3; + continue; + } + } - output.push(bytes[index]); - index += 1; - } + output.push(bytes[index]); + index += 1; + } - String::from_utf8_lossy(&output).into_owned() + String::from_utf8_lossy(&output).into_owned() } fn percent_decode(value: &str) -> String { - let bytes = value.as_bytes(); - let mut output = Vec::with_capacity(bytes.len()); - let mut index = 0; + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; - while index < bytes.len() { - match bytes[index] { - b'+' => { - output.push(b' '); - index += 1; - } - b'%' if index + 2 < bytes.len() => { - let hex = &value[index + 1..index + 3]; - if let Ok(decoded) = u8::from_str_radix(hex, 16) { - output.push(decoded); - index += 3; - } else { - output.push(bytes[index]); - index += 1; - } - } - byte => { - output.push(byte); - index += 1; - } - } - } + while index < bytes.len() { + match bytes[index] { + b'+' => { + output.push(b' '); + index += 1; + } + b'%' if index + 2 < bytes.len() => { + let hex = &value[index + 1..index + 3]; + if let Ok(decoded) = u8::from_str_radix(hex, 16) { + output.push(decoded); + index += 3; + } else { + output.push(bytes[index]); + index += 1; + } + } + byte => { + output.push(byte); + index += 1; + } + } + } - String::from_utf8_lossy(&output).into_owned() + String::from_utf8_lossy(&output).into_owned() } fn html_escape(value: &str) -> String { - let mut escaped = String::with_capacity(value.len()); + let mut escaped = String::with_capacity(value.len()); - for ch in value.chars() { - match ch { - '&' => escaped.push_str("&"), - '<' => escaped.push_str("<"), - '>' => escaped.push_str(">"), - '"' => escaped.push_str("""), - '\'' => escaped.push_str("'"), - _ => escaped.push(ch), - } - } + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } - escaped + escaped } fn parse_form_value(body: &str, key: &str) -> Option { - for pair in body.split('&') { - let mut parts = pair.splitn(2, '='); - let form_key = parts.next()?; - let form_value = parts.next().unwrap_or_default(); + for pair in body.split('&') { + let mut parts = pair.splitn(2, '='); + let form_key = parts.next()?; + let form_value = parts.next().unwrap_or_default(); - if percent_decode(form_key) == key { - return Some(percent_decode(form_value)); - } - } + if percent_decode(form_key) == key { + return Some(percent_decode(form_value)); + } + } - None + None } fn random_path_segment() -> io::Result { - let mut bytes = [0u8; 6]; - File::open("/dev/urandom")?.read_exact(&mut bytes)?; + let mut bytes = [0u8; 6]; + File::open("/dev/urandom")?.read_exact(&mut bytes)?; - Ok(bytes - .iter() - .map(|byte| PATH_CHARS[(byte & 63) as usize] as char) - .collect()) + Ok(bytes + .iter() + .map(|byte| PATH_CHARS[(byte & 63) as usize] as char) + .collect()) +} + +fn advertised_domain() -> io::Result { + let home = env::var("HOME").map_err(|error| io::Error::new(io::ErrorKind::NotFound, error))?; + let domain = fs::read_to_string(format!("{home}/.config/web-pinentry/domain"))?; + let domain = domain.trim(); + + if domain.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "domain file is empty", + )); + } + + Ok(domain.to_string()) } fn advertised_url(route_path: &str) -> io::Result { - Ok(format!("https://gpg.seanhealy.ie{route_path}")) + Ok(format!("https://{}{route_path}", advertised_domain()?)) } fn send_url_message(url: &str) -> io::Result<()> { - let status = process::Command::new("matrix-commander-rs") - .arg("--message") - .arg(url) - .status()?; + let status = process::Command::new("matrix-commander-rs") + .arg("--message") + .arg(url) + .status()?; - if status.success() { - return Ok(()); - } + if status.success() { + return Ok(()); + } - Err(io::Error::other(format!( - "matrix-commander-rs exited with status {status}" - ))) + Err(io::Error::other(format!( + "matrix-commander-rs exited with status {status}" + ))) } /** * Allow any connections from docker or 127.0.0.1. - * Docker IPs looks like: 172.17.x.x or 172.18.x.x. + * Docker IPs looks like: 172.17.x.x, 172.18.x.x. etc. */ fn is_allowed_client(address: SocketAddr) -> bool { - matches!(address.ip(), IpAddr::V4(ip) if ip == Ipv4Addr::LOCALHOST || - (ip.octets()[0] == 172 && ip.octets()[1] >= 16 && ip.octets()[1] <= 31)) + matches!(address.ip(), IpAddr::V4(ip) if ip == Ipv4Addr::LOCALHOST || + (ip.octets()[0] == 172 && ip.octets()[1] >= 16 && ip.octets()[1] <= 31)) } fn send_http_response( - stream: &mut TcpStream, - status: &str, - content_type: &str, - body: &str, + stream: &mut TcpStream, + status: &str, + content_type: &str, + body: &str, ) -> io::Result<()> { - write!( - stream, - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", - body.len() - )?; - stream.flush() + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + stream.flush() } fn render_form(title: &str, description: &str, prompt: &str, url: &str, route_path: &str) -> String { - let title = html_escape(title); - let description = html_escape(description); - let prompt = html_escape(prompt); - let url = html_escape(url); - let route_path = html_escape(route_path); + let title = html_escape(title); + let description = html_escape(description); + let prompt = html_escape(prompt); + let url = html_escape(url); + let route_path = html_escape(route_path); - format!( - "{title}

{title}

{description}

Listening at {url}
" - ) + format!( + "{title}

{title}

{description}

Listening at {url}
" + ) } fn wait_for_password(title: &str, description: &str, prompt: &str) -> io::Result { - let bind_address = env::var("WEB_PINENTRY_BIND").unwrap_or_else(|_| "0.0.0.0:7563".to_string()); - let listener = TcpListener::bind(&bind_address)?; - let route_path = format!("/{}", random_path_segment()?); - let url = advertised_url(&route_path)?; - let form_page = render_form(title, description, prompt, &url, &route_path); - let mut route_hits = 0u8; + let bind_address = env::var("WEB_PINENTRY_BIND").unwrap_or_else(|_| "0.0.0.0:7563".to_string()); + let listener = TcpListener::bind(&bind_address)?; + let route_path = format!("/{}", random_path_segment()?); + let url = advertised_url(&route_path)?; + let form_page = render_form(title, description, prompt, &url, &route_path); + let mut route_hits = 0u8; - send_url_message(&url)?; + send_url_message(&url)?; - for connection in listener.incoming() { - let mut stream = connection?; + for connection in listener.incoming() { + let mut stream = connection?; - if !is_allowed_client(stream.peer_addr()?) { - send_http_response( - &mut stream, - "403 Forbidden", - "text/plain; charset=utf-8", - "Forbidden", - )?; - continue; - } + if !is_allowed_client(stream.peer_addr()?) { + send_http_response( + &mut stream, + "403 Forbidden", + "text/plain; charset=utf-8", + "Forbidden", + )?; + continue; + } - let mut reader = BufReader::new(stream.try_clone()?); - let mut request_line = String::new(); + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); - if reader.read_line(&mut request_line)? == 0 { - continue; - } + if reader.read_line(&mut request_line)? == 0 { + continue; + } - let request_line = request_line.trim_end_matches(['\r', '\n']); - let mut request_parts = request_line.split_whitespace(); - let method = request_parts.next().unwrap_or_default(); - let path = request_parts.next().unwrap_or("/"); - let mut content_length = 0usize; + let request_line = request_line.trim_end_matches(['\r', '\n']); + let mut request_parts = request_line.split_whitespace(); + let method = request_parts.next().unwrap_or_default(); + let path = request_parts.next().unwrap_or("/"); + let mut content_length = 0usize; - loop { - let mut header = String::new(); - if reader.read_line(&mut header)? == 0 { - break; - } + loop { + let mut header = String::new(); + if reader.read_line(&mut header)? == 0 { + break; + } - let header = header.trim_end_matches(['\r', '\n']); - if header.is_empty() { - break; - } + let header = header.trim_end_matches(['\r', '\n']); + if header.is_empty() { + break; + } - if let Some((name, value)) = header.split_once(':') { - if name.eq_ignore_ascii_case("Content-Length") { - content_length = value.trim().parse().unwrap_or(0); - } - } - } + if let Some((name, value)) = header.split_once(':') { + if name.eq_ignore_ascii_case("Content-Length") { + content_length = value.trim().parse().unwrap_or(0); + } + } + } - if path == route_path { - route_hits = route_hits.saturating_add(1); + if path == route_path { + route_hits = route_hits.saturating_add(1); - if route_hits > MAX_ROUTE_HITS { - send_http_response( - &mut stream, - "410 Gone", - "text/plain; charset=utf-8", - "Link expired", - )?; - return Ok(String::new()); - } - } + if route_hits > MAX_ROUTE_HITS { + send_http_response( + &mut stream, + "410 Gone", + "text/plain; charset=utf-8", + "Link expired", + )?; + return Ok(String::new()); + } + } - if method.eq_ignore_ascii_case("GET") && path == route_path { - send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", &form_page)?; - continue; - } + if method.eq_ignore_ascii_case("GET") && path == route_path { + send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", &form_page)?; + continue; + } - if method.eq_ignore_ascii_case("POST") && path == route_path { - let mut body = vec![0; content_length]; - reader.read_exact(&mut body)?; - let body = String::from_utf8_lossy(&body); - let password = parse_form_value(&body, "pin").unwrap_or_default(); - let response = "Submitted

Password submitted

You can close this tab.

"; - send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", response)?; - return Ok(password); - } + if method.eq_ignore_ascii_case("POST") && path == route_path { + let mut body = vec![0; content_length]; + reader.read_exact(&mut body)?; + let body = String::from_utf8_lossy(&body); + let password = parse_form_value(&body, "pin").unwrap_or_default(); + let response = "Submitted

Password submitted

You can close this tab.

"; + send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", response)?; + return Ok(password); + } - send_http_response( - &mut stream, - "404 Not Found", - "text/plain; charset=utf-8", - "Not found", - )?; - } + send_http_response( + &mut stream, + "404 Not Found", + "text/plain; charset=utf-8", + "Not found", + )?; + } - Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "password server closed before a password was submitted", - )) + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "password server closed before a password was submitted", + )) } #[derive(Default)] struct PromptState { - title: Option, - description: Option, - prompt: Option, + title: Option, + description: Option, + prompt: Option, } fn main() -> io::Result<()> { - let version = env!("CARGO_PKG_VERSION"); - let stdin = io::stdin(); - let mut stdout = io::stdout(); - let mut prompt_state = PromptState::default(); + let version = env!("CARGO_PKG_VERSION"); + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut prompt_state = PromptState::default(); - writeln!(stdout, "OK simple rust pinentry ready")?; - stdout.flush()?; + writeln!(stdout, "OK simple rust pinentry ready")?; + stdout.flush()?; - for line in stdin.lock().lines() { - let line = line?; - let command = line.trim(); + for line in stdin.lock().lines() { + let line = line?; + let command = line.trim(); - if command.is_empty() { - continue; - } + if command.is_empty() { + continue; + } - let mut parts = command.splitn(2, char::is_whitespace); - let verb = parts.next().unwrap_or_default(); - let argument = parts.next().unwrap_or("").trim(); + let mut parts = command.splitn(2, char::is_whitespace); + let verb = parts.next().unwrap_or_default(); + let argument = parts.next().unwrap_or("").trim(); - if verb.eq_ignore_ascii_case("GETPIN") { - let title = prompt_state - .title - .as_deref() - .filter(|value| !value.is_empty()) - .unwrap_or("Pinentry"); - let description = prompt_state - .description - .as_deref() - .filter(|value| !value.is_empty()) - .unwrap_or("Enter the password in the browser window to continue."); - let prompt = prompt_state - .prompt - .as_deref() - .filter(|value| !value.is_empty()) - .unwrap_or("Password"); + if verb.eq_ignore_ascii_case("GETPIN") { + let title = prompt_state + .title + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or("Pinentry"); + let description = prompt_state + .description + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or("Enter the password in the browser window to continue."); + let prompt = prompt_state + .prompt + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or("Password"); - match wait_for_password(title, description, prompt) { - Ok(password) => { - write_data(&mut stdout, &password)?; - write_ok(&mut stdout)?; - } - Err(error) => { - eprintln!("web-pinentry error: {error}"); - write_err(&mut stdout, 83886179, "canceled")?; - } - } + match wait_for_password(title, description, prompt) { + Ok(password) => { + write_data(&mut stdout, &password)?; + write_ok(&mut stdout)?; + } + Err(error) => { + eprintln!("web-pinentry error: {error}"); + write_err(&mut stdout, 83886179, "canceled")?; + } + } - continue; - } + continue; + } - if verb.eq_ignore_ascii_case("BYE") { - write_ok(&mut stdout)?; - break; - } + if verb.eq_ignore_ascii_case("BYE") { + write_ok(&mut stdout)?; + break; + } - if verb.eq_ignore_ascii_case("GETINFO") { - match argument { - value if value.eq_ignore_ascii_case("pid") => { - write_data(&mut stdout, &process::id().to_string())?; - } - value if value.eq_ignore_ascii_case("version") => { - write_data(&mut stdout, version)?; - } - value if value.eq_ignore_ascii_case("flavor") => { - write_data(&mut stdout, "browser")?; - } - _ => {} - } + if verb.eq_ignore_ascii_case("GETINFO") { + match argument { + value if value.eq_ignore_ascii_case("pid") => { + write_data(&mut stdout, &process::id().to_string())?; + } + value if value.eq_ignore_ascii_case("version") => { + write_data(&mut stdout, version)?; + } + value if value.eq_ignore_ascii_case("flavor") => { + write_data(&mut stdout, "browser")?; + } + _ => {} + } - write_ok(&mut stdout)?; - continue; - } + write_ok(&mut stdout)?; + continue; + } - if verb.eq_ignore_ascii_case("SETDESC") { - prompt_state.description = Some(assuan_unescape(argument)); - write_ok(&mut stdout)?; - continue; - } + if verb.eq_ignore_ascii_case("SETDESC") { + prompt_state.description = Some(assuan_unescape(argument)); + write_ok(&mut stdout)?; + continue; + } - if verb.eq_ignore_ascii_case("SETPROMPT") { - prompt_state.prompt = Some(assuan_unescape(argument)); - write_ok(&mut stdout)?; - continue; - } + if verb.eq_ignore_ascii_case("SETPROMPT") { + prompt_state.prompt = Some(assuan_unescape(argument)); + write_ok(&mut stdout)?; + continue; + } - if verb.eq_ignore_ascii_case("SETTITLE") { - prompt_state.title = Some(assuan_unescape(argument)); - write_ok(&mut stdout)?; - continue; - } + if verb.eq_ignore_ascii_case("SETTITLE") { + prompt_state.title = Some(assuan_unescape(argument)); + write_ok(&mut stdout)?; + continue; + } - if verb.eq_ignore_ascii_case("RESET") { - prompt_state = PromptState::default(); - write_ok(&mut stdout)?; - continue; - } + if verb.eq_ignore_ascii_case("RESET") { + prompt_state = PromptState::default(); + write_ok(&mut stdout)?; + continue; + } - write_ok(&mut stdout)?; - } + write_ok(&mut stdout)?; + } - Ok(()) + Ok(()) }