wecanencrypt 0.6.0

Simple Rust OpenPGP library for encryption, signing, and key management.
Documentation
//! Network key fetching (WKD and keyserver support).
//!
//! This module provides functions for fetching OpenPGP keys from the network
//! using Web Key Directory (WKD) and HKP keyservers.

use crate::error::{Error, Result};
use crate::internal::{fingerprint_to_hex, keyid_to_hex, parse_cert};

/// Maximum response size for key fetches (10 MB).
const MAX_KEY_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;

/// Fetch a key from Web Key Directory (WKD) by email address.
///
/// WKD is a standard for distributing OpenPGP keys via HTTPS. It uses the
/// domain from the email address to construct URLs where keys can be found.
///
/// # Arguments
/// * `email` - Email address to look up
///
/// # Returns
/// The certificate data if found.
///
/// # Example
/// ```ignore
/// // Ignored: requires network access to WKD servers
/// let cert = fetch_key_by_email("user@example.com")?;
/// ```
#[cfg(feature = "network")]
pub fn fetch_key_by_email(email: &str) -> Result<Vec<u8>> {
    let (local, domain) = parse_email(email)?;

    // Try advanced method first, then direct method
    let urls = wkd_urls(&local, &domain);

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|e| Error::Network(e.to_string()))?;

    let mut last_error = None;

    for url in urls {
        match client.get(&url).send() {
            Ok(response) => {
                if response.status().is_success() {
                    let bytes = read_response_limited(response)?;

                    // Verify it's a valid certificate
                    let _ = parse_cert(&bytes)?;

                    return Ok(bytes);
                }
            }
            Err(e) => {
                last_error = Some(e.to_string());
            }
        }
    }

    Err(Error::KeyNotFound(format!(
        "No key found for email '{}': {}",
        email,
        last_error.unwrap_or_else(|| "Not found".to_string())
    )))
}

/// Fetch a key from an HKP keyserver by fingerprint.
///
/// # Arguments
/// * `fingerprint` - The key fingerprint (40 hex characters)
/// * `keyserver` - Optional keyserver URL (defaults to keys.openpgp.org)
///
/// # Returns
/// The certificate data if found.
///
/// # Example
/// ```ignore
/// // Ignored: requires network access to keyservers
/// let cert = fetch_key_by_fingerprint(
///     "A4F388BBB194925AE301F844C52B42177857DD79",
///     None,
/// )?;
/// ```
#[cfg(feature = "network")]
pub fn fetch_key_by_fingerprint(
    fingerprint: &str,
    keyserver: Option<&str>,
) -> Result<Vec<u8>> {
    let server = keyserver.unwrap_or("https://keys.openpgp.org");
    let url = format!("{}/vks/v1/by-fingerprint/{}", server, fingerprint.to_uppercase());

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|e| Error::Network(e.to_string()))?;

    let response = client.get(&url)
        .send()
        .map_err(|e| Error::Network(e.to_string()))?;

    if !response.status().is_success() {
        return Err(Error::KeyNotFound(format!(
            "Key not found on keyserver: {}",
            fingerprint
        )));
    }

    let bytes = read_response_limited(response)?;

    // Verify it's a valid certificate and matches the requested fingerprint
    let (public_key, _) = parse_cert(&bytes)?;
    let fetched_fp = fingerprint_to_hex(&public_key.primary_key);
    if fetched_fp != fingerprint.to_uppercase() {
        return Err(Error::KeyNotFound(format!(
            "Fetched key fingerprint {} does not match requested {}",
            fetched_fp, fingerprint
        )));
    }

    Ok(bytes)
}

/// Fetch a key from an HKP keyserver by key ID.
///
/// # Arguments
/// * `key_id` - The key ID (16 hex characters)
/// * `keyserver` - Optional keyserver URL (defaults to keys.openpgp.org)
///
/// # Returns
/// The certificate data if found.
#[cfg(feature = "network")]
pub fn fetch_key_by_keyid(
    key_id: &str,
    keyserver: Option<&str>,
) -> Result<Vec<u8>> {
    let server = keyserver.unwrap_or("https://keys.openpgp.org");
    let url = format!("{}/vks/v1/by-keyid/{}", server, key_id.to_uppercase());

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|e| Error::Network(e.to_string()))?;

    let response = client.get(&url)
        .send()
        .map_err(|e| Error::Network(e.to_string()))?;

    if !response.status().is_success() {
        return Err(Error::KeyNotFound(format!(
            "Key not found on keyserver: {}",
            key_id
        )));
    }

    let bytes = read_response_limited(response)?;

    // Verify it's a valid certificate and matches the requested key ID
    let (public_key, _) = parse_cert(&bytes)?;
    let fetched_keyid = keyid_to_hex(&public_key.primary_key);
    if fetched_keyid != key_id.to_uppercase() {
        // Also check subkey IDs — the key ID might refer to a subkey
        let subkey_match = public_key.public_subkeys.iter().any(|sk| {
            keyid_to_hex(&sk.key) == key_id.to_uppercase()
        });
        if !subkey_match {
            return Err(Error::KeyNotFound(format!(
                "Fetched key ID {} does not match requested {}",
                fetched_keyid, key_id
            )));
        }
    }

    Ok(bytes)
}

/// Fetch a key from a VKS keyserver by email address.
///
/// This queries keyservers that implement the VKS (Verifying Key Server)
/// protocol, such as keys.openpgp.org, using the `/vks/v1/by-email/` endpoint.
///
/// Unlike [`fetch_key_by_email`] which uses WKD (querying the email domain),
/// this function queries a centralized keyserver directly.
///
/// # Arguments
/// * `email` - Email address to look up
/// * `keyserver` - Optional keyserver URL (defaults to keys.openpgp.org)
///
/// # Returns
/// The certificate data if found.
///
/// # Example
/// ```ignore
/// // Ignored: requires network access to keyservers
/// let cert = fetch_key_by_email_from_keyserver("user@example.com", None)?;
/// ```
#[cfg(feature = "network")]
pub fn fetch_key_by_email_from_keyserver(
    email: &str,
    keyserver: Option<&str>,
) -> Result<Vec<u8>> {
    let server = keyserver.unwrap_or("https://keys.openpgp.org");
    let url = format!("{}/vks/v1/by-email/{}", server, email);

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|e| Error::Network(e.to_string()))?;

    let response = client.get(&url)
        .send()
        .map_err(|e| Error::Network(e.to_string()))?;

    if !response.status().is_success() {
        return Err(Error::KeyNotFound(format!(
            "Key not found on keyserver for email: {}",
            email
        )));
    }

    let bytes = read_response_limited(response)?;

    // Verify it's a valid certificate
    let _ = parse_cert(&bytes)?;

    Ok(bytes)
}

/// Read response bytes with a size limit to prevent DoS from oversized responses.
#[cfg(feature = "network")]
fn read_response_limited(response: reqwest::blocking::Response) -> Result<Vec<u8>> {
    // Check Content-Length header if available
    if let Some(len) = response.content_length() {
        if len > MAX_KEY_RESPONSE_SIZE {
            return Err(Error::Network(format!(
                "Response too large: {} bytes (max {})",
                len, MAX_KEY_RESPONSE_SIZE
            )));
        }
    }

    let bytes = response.bytes()
        .map_err(|e| Error::Network(e.to_string()))?;

    if bytes.len() as u64 > MAX_KEY_RESPONSE_SIZE {
        return Err(Error::Network(format!(
            "Response too large: {} bytes (max {})",
            bytes.len(), MAX_KEY_RESPONSE_SIZE
        )));
    }

    Ok(bytes.to_vec())
}

/// Parse email into local part and domain.
fn parse_email(email: &str) -> Result<(String, String)> {
    let parts: Vec<&str> = email.split('@').collect();
    if parts.len() != 2 {
        return Err(Error::InvalidInput(format!("Invalid email address: {}", email)));
    }
    Ok((parts[0].to_lowercase(), parts[1].to_lowercase()))
}

/// Generate WKD URLs for a given email address.
/// Returns both advanced and direct method URLs.
#[cfg(feature = "network")]
fn wkd_urls(local: &str, domain: &str) -> Vec<String> {
    use sha1::{Sha1, Digest};

    // Z-base32 encoding for WKD
    let hash = {
        let mut hasher = Sha1::new();
        hasher.update(local.as_bytes());
        let result = hasher.finalize();
        zbase32_encode(&result)
    };

    vec![
        // Advanced method
        format!(
            "https://openpgpkey.{domain}/.well-known/openpgpkey/{domain}/hu/{hash}?l={local}",
            domain = domain,
            hash = hash,
            local = local
        ),
        // Direct method
        format!(
            "https://{domain}/.well-known/openpgpkey/hu/{hash}?l={local}",
            domain = domain,
            hash = hash,
            local = local
        ),
    ]
}

/// Z-base32 encoding (used by WKD).
#[cfg(feature = "network")]
fn zbase32_encode(data: &[u8]) -> String {
    const ALPHABET: &[u8] = b"ybndrfg8ejkmcpqxot1uwisza345h769";

    let mut result = String::new();
    let mut buffer: u64 = 0;
    let mut bits_in_buffer = 0;

    for &byte in data {
        buffer = (buffer << 8) | byte as u64;
        bits_in_buffer += 8;

        while bits_in_buffer >= 5 {
            bits_in_buffer -= 5;
            let index = ((buffer >> bits_in_buffer) & 0x1f) as usize;
            result.push(ALPHABET[index] as char);
        }
    }

    if bits_in_buffer > 0 {
        let index = ((buffer << (5 - bits_in_buffer)) & 0x1f) as usize;
        result.push(ALPHABET[index] as char);
    }

    result
}

#[cfg(test)]
mod tests {
    #[cfg(feature = "network")]
    use super::*;

    #[test]
    #[cfg(feature = "network")]
    fn test_zbase32_encode() {
        // Test vector from the WKD specification
        let input = b"test";
        let encoded = zbase32_encode(input);
        assert!(!encoded.is_empty());
    }

    #[test]
    fn test_parse_email() {
        let (local, domain) = parse_email("user@example.com").unwrap();
        assert_eq!(local, "user");
        assert_eq!(domain, "example.com");

        assert!(parse_email("invalid").is_err());
    }

    #[test]
    #[cfg(feature = "network")]
    fn test_wkd_urls() {
        let urls = wkd_urls("test", "example.com");
        assert_eq!(urls.len(), 2);
        assert!(urls[0].contains("openpgpkey.example.com"));
        assert!(urls[1].contains("example.com/.well-known"));
    }
}