//! APT keyring management for mmdebstrap and PPA packages //! //! Provides functions to ensure that archive keyrings are available //! for mmdebstrap operations and for PPA packages by downloading them. use crate::context; use crate::data::embed_data; use crate::distro_info; use serde::Deserialize; use std::error::Error; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::sync::Arc; /// Keyserver endpoint, loaded from the bundled `keyserver.yml` data file /// (same pattern as `distro_info.yml`): the lookup URL is a static /// endpoint that was previously hardcoded in two modules. #[derive(Debug, Deserialize)] struct KeyserverData { /// OpenPGP key lookup URL template (`{fingerprint}`) lookup_template: String, } embed_data! { static ref KEYSERVER_DATA: KeyserverData = "../../data/keyserver.yml" } /// URL fetching the OpenPGP key of `fingerprint` from the keyserver pub(crate) fn keyserver_lookup_url(fingerprint: &str) -> String { KEYSERVER_DATA .lookup_template .replace("{fingerprint}", fingerprint) } /// Launchpad API response structure for PPA information #[derive(Deserialize)] struct LaunchpadPpaResponse { signing_key_fingerprint: String, } /// Download keyrings to a shared keyring directory and return the directory path /// /// This function downloads keyrings to a user-writable cache directory /// instead of the system apt keyring directory, allowing non-root usage. /// The returned directory path can be passed to mmdebstrap via --keyring=. /// /// For Debian keyrings (which are ASCII-armored .asc files), the keys are /// converted to binary GPG format using gpg --dearmor. /// /// For 'sid' and 'experimental', this downloads keyrings from the 3 latest /// releases since sid needs keys from all recent releases. /// /// # Arguments /// * `ctx` - Optional context to use /// * `series` - The distribution series (e.g., "noble", "sid") /// /// # Returns /// The path to the keyring directory containing all downloaded keyring files pub async fn download_cache_keyrings( ctx: Option>, series: &str, ) -> Result> { let ctx = ctx.unwrap_or_else(context::current); // Obtain keyring URLs from distro_info let keyring_urls = distro_info::get_keyring_urls(series).await?; log::debug!("Downloading keyrings from: {:?}", keyring_urls); // Use system temp directory for keyrings since it's accessible from unshare mode // The home directory may not be accessible from mmdebstrap's unshare namespace let temp_dir = std::env::temp_dir(); // Name the cache directory per-uid: a single shared /tmp directory would // be writable by any local user, and the skip-if-exists logic below // trusts pre-existing keyrings, so it must never be shared. let euid = current_euid(); let keyring_dir = temp_dir.join(format!("pkh-keyrings-{euid}")); if ctx.exists(&keyring_dir)? { if let context::ContextConfig::Local = ctx.config { // Cached keyrings are trusted as-is whenever they already exist, // so refuse to reuse a directory that is not owned by the current // user or is writable by group/others (it could have been planted // by another local user). let metadata = std::fs::symlink_metadata(&keyring_dir)?; validate_keyring_dir(metadata.uid(), metadata.mode(), euid).map_err(|reason| { format!( "Refusing to use keyring cache directory {}: {reason}; \ remove the directory and re-run pkh", keyring_dir.display() ) })?; // Upgrade cache directories created by versions that made them // private: mmdebstrap's unshare-mode hooks cannot read them. } else { // Remote contexts (e.g. ssh) have no stat/metadata access through // the context API, so the ownership guard cannot be performed; // keep the previous best-effort behavior of tightening the // directory permissions instead (no group/others write). } ctx.command("chmod").arg("755").arg(&keyring_dir).status()?; } else { // Create the directory readable but not writable by group/others. // mmdebstrap's unshare-mode hooks run under an identity that cannot // read the invoking user's private directories, so 0700 breaks the // keyring copy into the chroot; the planting guard stays on the // ownership and no-write checks of validate_keyring_dir (the // skip-if-exists logic below trusts pre-existing keyrings, so the // directory must never be writable by anyone else). ctx.command("mkdir") .arg("-p") .arg("-m") .arg("755") .arg(&keyring_dir) .status()?; } for keyring_url in keyring_urls { // Extract the original filename from the keyring URL let filename = keyring_url .split('/') .next_back() .unwrap_or("pkh-{}.gpg") .replace("{}", series); let download_path = keyring_dir.join(&filename); // Determine the binary keyring path let binary_path = if filename.ends_with(".asc") { // ASCII-armored key: convert to .gpg let binary_filename = filename.strip_suffix(".asc").unwrap_or(&filename); keyring_dir.join(format!("{}.gpg", binary_filename)) } else { download_path.clone() }; // Skip download if the binary keyring already exists if !ctx.exists(&binary_path)? { // Download the keyring using curl let mut curl_cmd = ctx.command("curl"); curl_cmd .arg("-s") .arg("-f") .arg("-L") .arg(&keyring_url) .arg("--output") .arg(&download_path); let status = curl_cmd.status()?; if !status.success() { return Err(format!("Failed to download keyring from {}", keyring_url).into()); } // If the downloaded file is an ASCII-armored key (.asc), convert it to binary GPG format if filename.ends_with(".asc") { log::debug!("Converting ASCII-armored key to binary GPG format"); let mut gpg_cmd = ctx.command("gpg"); gpg_cmd .arg("--dearmor") .arg("--output") .arg(&binary_path) .arg(&download_path); let status = gpg_cmd.status()?; if !status.success() { return Err("Failed to convert keyring to binary format" .to_string() .into()); } // Remove the original .asc file let _ = ctx.command("rm").arg("-f").arg(&download_path).status(); } log::info!( "Successfully downloaded keyring for {} to {}", series, binary_path.display() ); } else { log::debug!( "Keyring already exists at {}, skipping download", binary_path.display() ); } // Readable like the directory: mmdebstrap's hooks copy these into // the chroot. Applies to legacy files too, which a restrictive // umask may have left private, and a permissive one group-writable. let _ = ctx.command("chmod").arg("644").arg(&binary_path).status(); } log::info!( "Keyrings for {} available in {}", series, keyring_dir.display() ); Ok(keyring_dir) } /// Effective uid of the current process fn current_euid() -> u32 { unsafe { libc::geteuid() } } /// Check that an existing keyring cache directory is safe to reuse /// /// Cached keyrings are trusted whenever the files already exist (see the /// skip-if-exists logic in [`download_cache_keyrings`]), so the directory /// must be owned by the current user and must not be writable by group or /// others, otherwise another local user could plant a malicious keyring. /// /// Takes the directory's owner uid and permission mode (e.g. from /// `std::fs::symlink_metadata`) so it can be unit tested without touching /// the filesystem. fn validate_keyring_dir(dir_uid: u32, mode: u32, euid: u32) -> Result<(), String> { if dir_uid != euid { return Err(format!( "owned by uid {dir_uid}, not by the current user (uid {euid})" )); } if mode & 0o022 != 0 { return Err(format!( "writable by group or others (permissions {:04o})", mode & 0o7777 )); } Ok(()) } /// Download and import a PPA key using the Launchpad API /// /// The signing key fingerprint is looked up through the shared HTTP client; /// the key itself is fetched from the keyserver with curl through the /// context, because the key file must land in the context's filesystem /// (which may be remote). /// /// # Arguments /// * `ctx` - Optional context to use /// * `ppa_owner` - PPA owner (username) /// * `ppa_name` - PPA name /// /// # Returns /// Result indicating success or failure pub async fn download_trust_ppa_key( ctx: Option>, ppa_owner: &str, ppa_name: &str, ) -> Result<(), Box> { let ctx = ctx.unwrap_or_else(context::current); // Create trusted.gpg.d directory if it doesn't exist let trusted_gpg_d = "/etc/apt/trusted.gpg.d"; if !ctx.exists(Path::new(trusted_gpg_d))? { ctx.command("mkdir").arg("-p").arg(trusted_gpg_d).status()?; } let key_filename = format!("{}-{}.asc", ppa_owner, ppa_name); let key_path = format!("{}/{}", trusted_gpg_d, key_filename); log::debug!( "Retrieving PPA key for {}/{} using Launchpad API", ppa_owner, ppa_name ); // Get PPA information from the Launchpad API to get the signing key // fingerprint. The query is context-independent metadata, so it goes // through the shared HTTP client (timeouts, retries) rather than // shelling out to curl. let api_url = crate::launchpad::archive_url(ppa_owner, ppa_name); log::debug!("Querying Launchpad API: {}", api_url); let response = distro_info::http_get_retried(&api_url).await.map_err(|e| { format!( "Failed to query Launchpad API for PPA {}/{}: {}", ppa_owner, ppa_name, e ) })?; if !response.status().is_success() { return Err(format!( "Failed to query Launchpad API for PPA {}/{}: HTTP {}", ppa_owner, ppa_name, response.status() ) .into()); } // Parse the JSON response to extract the signing key fingerprint let api_response_str = response.text().await.map_err(|e| { format!( "Failed to read the Launchpad API response for PPA {}/{}: {}", ppa_owner, ppa_name, e ) })?; let ppa_response: LaunchpadPpaResponse = serde_json::from_str(&api_response_str).map_err(|e| { format!( "Failed to parse JSON response from Launchpad API for {}/{}: {}", ppa_owner, ppa_name, e ) })?; let fingerprint = ppa_response.signing_key_fingerprint; log::debug!("Found PPA signing key fingerprint: {}", fingerprint); // Download the actual key from the keyserver using the fingerprint let keyserver_url = keyserver_lookup_url(&fingerprint); log::debug!("Downloading key from keyserver: {}", keyserver_url); let mut curl_cmd = ctx.command("curl"); curl_cmd .arg("-s") .arg("-f") .arg("-L") .arg(&keyserver_url) .arg("--output") .arg(&key_path); let status = curl_cmd.status()?; if !status.success() { return Err(format!( "Failed to download PPA key from keyserver for fingerprint {}", fingerprint ) .into()); } log::info!( "Successfully downloaded and installed PPA key for {}/{} (fingerprint: {}) to {}", ppa_owner, ppa_name, fingerprint, key_path ); Ok(()) } #[cfg(test)] mod tests { use super::*; /// The data-driven template renders the lookup URL the hardcoded /// format! used to build (verified against the live keyserver) #[test] fn keyserver_lookup_url_substitutes_the_fingerprint() { assert_eq!( keyserver_lookup_url("0123456789ABCDEF"), "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x0123456789ABCDEF" ); } #[test] fn test_validate_keyring_dir_accepts_private_dir_owned_by_current_user() { assert!(validate_keyring_dir(1000, 0o700, 1000).is_ok()); assert!(validate_keyring_dir(1000, 0o750, 1000).is_ok()); assert!(validate_keyring_dir(1000, 0o1744, 1000).is_ok()); assert!(validate_keyring_dir(0, 0o700, 0).is_ok()); // The world-readable modes the cache now uses: readable so that // mmdebstrap's unshare-mode hooks can copy the keyrings, while the // ownership and no-write checks keep the planting guard. assert!(validate_keyring_dir(1000, 0o755, 1000).is_ok()); } #[test] fn test_validate_keyring_dir_rejects_foreign_owner() { let err = validate_keyring_dir(1000, 0o700, 1001).unwrap_err(); assert!(err.contains("owned by uid 1000")); let err = validate_keyring_dir(1001, 0o700, 1000).unwrap_err(); assert!(err.contains("owned by uid 1001")); } #[test] fn test_validate_keyring_dir_rejects_group_or_other_writable() { assert!(validate_keyring_dir(1000, 0o770, 1000).is_err()); assert!(validate_keyring_dir(1000, 0o706, 1000).is_err()); assert!(validate_keyring_dir(1000, 0o707, 1000).is_err()); assert!(validate_keyring_dir(1000, 0o777, 1000).is_err()); // Sticky bit does not neutralize the group/other write bits. assert!(validate_keyring_dir(1000, 0o1777, 1000).is_err()); } }