commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,315 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use url::Url;
|
||||
|
||||
pub async fn discover_wkd(
|
||||
email: &str,
|
||||
timeout_secs: u64,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let (local, domain) = split_email(email)?;
|
||||
let hash = zbase32(&Sha1::digest(local.to_ascii_lowercase().as_bytes()));
|
||||
let encoded_local: String = url::form_urlencoded::byte_serialize(local.as_bytes()).collect();
|
||||
let candidates = [
|
||||
format!(
|
||||
"https://openpgpkey.{domain}/.well-known/openpgpkey/{domain}/hu/{hash}?l={encoded_local}"
|
||||
),
|
||||
format!("https://{domain}/.well-known/openpgpkey/hu/{hash}?l={encoded_local}"),
|
||||
];
|
||||
let mut last_error = None;
|
||||
for candidate in candidates {
|
||||
match https_get(&candidate, timeout_secs, max_bytes).await {
|
||||
Ok(Some(body)) => return Ok(Some(body)),
|
||||
Ok(None) => {}
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
if let Some(error) = last_error {
|
||||
tracing::debug!(email, error = %error, "WKD lookup did not yield a key");
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn discover_hkp(
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
timeout_secs: u64,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut url = Url::parse(base_url).map_err(|e| format!("Invalid HKP URL: {e}"))?;
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("HKP endpoint must be a credential-free HTTPS URL".to_string());
|
||||
}
|
||||
url.set_path("/pks/lookup");
|
||||
url.set_query(None);
|
||||
url.query_pairs_mut()
|
||||
.append_pair("op", "get")
|
||||
.append_pair("options", "mr")
|
||||
.append_pair("search", email.trim());
|
||||
https_get(url.as_str(), timeout_secs, max_bytes).await
|
||||
}
|
||||
|
||||
async fn https_get(
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let url = Url::parse(url).map_err(|e| format!("Invalid HTTPS URL: {e}"))?;
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("OpenPGP discovery requires credential-free HTTPS".to_string());
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| "OpenPGP discovery URL has no host".to_string())?;
|
||||
let port = url.port_or_known_default().unwrap_or(443);
|
||||
let duration = Duration::from_secs(timeout_secs.clamp(1, 60));
|
||||
let addresses: Vec<SocketAddr> = timeout(duration, tokio::net::lookup_host((host, port)))
|
||||
.await
|
||||
.map_err(|_| "OpenPGP discovery DNS lookup timed out".to_string())?
|
||||
.map_err(|e| format!("OpenPGP discovery DNS lookup failed: {e}"))?
|
||||
.filter(|address| is_public_ip(address.ip()))
|
||||
.collect();
|
||||
if addresses.is_empty() {
|
||||
return Err("OpenPGP discovery host resolves only to non-public addresses".to_string());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for address in addresses {
|
||||
match https_get_address(&url, host, port, address, duration, max_bytes).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| "OpenPGP discovery request failed".to_string()))
|
||||
}
|
||||
|
||||
async fn https_get_address(
|
||||
url: &Url,
|
||||
host: &str,
|
||||
port: u16,
|
||||
address: SocketAddr,
|
||||
duration: Duration,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let tcp = timeout(duration, TcpStream::connect(address))
|
||||
.await
|
||||
.map_err(|_| "OpenPGP discovery connection timed out".to_string())?
|
||||
.map_err(|e| format!("OpenPGP discovery connection failed: {e}"))?;
|
||||
let tls = async_native_tls::TlsConnector::new();
|
||||
let mut stream = timeout(duration, tls.connect(host, tcp))
|
||||
.await
|
||||
.map_err(|_| "OpenPGP discovery TLS handshake timed out".to_string())?
|
||||
.map_err(|e| format!("OpenPGP discovery TLS handshake failed: {e}"))?;
|
||||
|
||||
let mut target = url.path().to_string();
|
||||
if target.is_empty() {
|
||||
target.push('/');
|
||||
}
|
||||
if let Some(query) = url.query() {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
let host_header = if host.contains(':') {
|
||||
if port == 443 {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
format!("[{host}]:{port}")
|
||||
}
|
||||
} else if port == 443 {
|
||||
host.to_string()
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
let request = format!(
|
||||
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
timeout(duration, stream.write_all(request.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| "OpenPGP discovery request timed out".to_string())?
|
||||
.map_err(|e| format!("OpenPGP discovery write failed: {e}"))?;
|
||||
|
||||
let hard_limit = max_bytes.clamp(16 * 1024, 4 * 1024 * 1024) + 64 * 1024;
|
||||
let mut response = Vec::with_capacity(8192);
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
let read = timeout(duration, stream.read(&mut chunk))
|
||||
.await
|
||||
.map_err(|_| "OpenPGP discovery response timed out".to_string())?
|
||||
.map_err(|e| format!("OpenPGP discovery read failed: {e}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if response.len().saturating_add(read) > hard_limit {
|
||||
return Err("OpenPGP discovery response exceeds configured size limit".to_string());
|
||||
}
|
||||
response.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
let header_end = response
|
||||
.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.ok_or_else(|| "OpenPGP discovery returned an invalid HTTP response".to_string())?;
|
||||
let header_bytes = &response[..header_end];
|
||||
let body = &response[header_end + 4..];
|
||||
let headers = String::from_utf8_lossy(header_bytes);
|
||||
let status = headers
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.ok_or_else(|| "OpenPGP discovery returned an invalid HTTP status".to_string())?;
|
||||
if status == 404 || status == 410 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("OpenPGP discovery returned HTTP {status}"));
|
||||
}
|
||||
if headers
|
||||
.lines()
|
||||
.any(|line| line.eq_ignore_ascii_case("Transfer-Encoding: chunked"))
|
||||
{
|
||||
let decoded = decode_chunked(body, max_bytes)?;
|
||||
return Ok(Some(decoded));
|
||||
}
|
||||
if body.len() > max_bytes {
|
||||
return Err("OpenPGP key exceeds configured size limit".to_string());
|
||||
}
|
||||
Ok(Some(body.to_vec()))
|
||||
}
|
||||
|
||||
fn decode_chunked(mut input: &[u8], max_bytes: usize) -> Result<Vec<u8>, String> {
|
||||
let mut output = Vec::new();
|
||||
loop {
|
||||
let line_end = input
|
||||
.windows(2)
|
||||
.position(|w| w == b"\r\n")
|
||||
.ok_or_else(|| "Invalid chunked HTTP response".to_string())?;
|
||||
let line = std::str::from_utf8(&input[..line_end])
|
||||
.map_err(|_| "Invalid HTTP chunk size".to_string())?;
|
||||
let size_text = line.split(';').next().unwrap_or(line).trim();
|
||||
let size = usize::from_str_radix(size_text, 16)
|
||||
.map_err(|_| "Invalid HTTP chunk size".to_string())?;
|
||||
input = &input[line_end + 2..];
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
if input.len() < size + 2 || &input[size..size + 2] != b"\r\n" {
|
||||
return Err("Truncated chunked HTTP response".to_string());
|
||||
}
|
||||
if output.len().saturating_add(size) > max_bytes {
|
||||
return Err("OpenPGP key exceeds configured size limit".to_string());
|
||||
}
|
||||
output.extend_from_slice(&input[..size]);
|
||||
input = &input[size + 2..];
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn split_email(email: &str) -> Result<(&str, &str), String> {
|
||||
let email = email.trim();
|
||||
let (local, domain) = email
|
||||
.rsplit_once('@')
|
||||
.ok_or_else(|| "Invalid email address".to_string())?;
|
||||
if local.is_empty() || domain.is_empty() || domain.contains('/') || domain.contains('\\') {
|
||||
return Err("Invalid email address".to_string());
|
||||
}
|
||||
Ok((local, domain))
|
||||
}
|
||||
|
||||
fn zbase32(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 32] = b"ybndrfg8ejkmcpqxot1uwisza345h769";
|
||||
let mut output = String::new();
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits = 0u8;
|
||||
for &byte in bytes {
|
||||
buffer = (buffer << 8) | u32::from(byte);
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
bits -= 5;
|
||||
output.push(ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
output.push(ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn is_public_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => is_public_ipv4(ip),
|
||||
IpAddr::V6(ip) => is_public_ipv6(ip),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||
let [a, b, c, d] = ip.octets();
|
||||
if ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_unspecified()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!matches!(
|
||||
(a, b, c, d),
|
||||
(0, _, _, _)
|
||||
| (100, 64..=127, _, _)
|
||||
| (192, 0, 0, _)
|
||||
| (192, 0, 2, _)
|
||||
| (198, 18..=19, _, _)
|
||||
| (198, 51, 100, _)
|
||||
| (203, 0, 113, _)
|
||||
| (240..=255, _, _, _)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
|
||||
if ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let segments = ip.segments();
|
||||
if segments[0] == 0x2001 && segments[1] == 0x0db8 {
|
||||
return false;
|
||||
}
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return is_public_ipv4(v4);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wkd_hash_matches_zbase32_shape() {
|
||||
let value = zbase32(&Sha1::digest(b"joe"));
|
||||
assert_eq!(value.len(), 32);
|
||||
assert!(
|
||||
value
|
||||
.chars()
|
||||
.all(|c| "ybndrfg8ejkmcpqxot1uwisza345h769".contains(c))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_private_addresses() {
|
||||
assert!(!is_public_ip("127.0.0.1".parse().unwrap()));
|
||||
assert!(!is_public_ip("10.0.0.1".parse().unwrap()));
|
||||
assert!(!is_public_ip("::1".parse().unwrap()));
|
||||
assert!(is_public_ip("1.1.1.1".parse().unwrap()));
|
||||
}
|
||||
}
|
||||
+1152
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user