first commit
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+315
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff