A fresh chroot download failed with mmdebstrap unable to copy the keyrings: its unshare-mode hooks run under an identity that cannot read the invoking user's private directories, so the 0700 cache directory the module created broke the trusted.gpg.d setup hook with 'Permission denied' (builds reusing an already-cached tarball never hit the path). Create the cache 0755 and chmod the keyrings 0644 instead. The planting guard does not weaken: cached keyrings are trusted as-is, and validate_keyring_dir keeps refusing directories that are not owned by the current user or are writable by group or others — read access for the bootstrap tool is not a planting vector. Legacy 0700 cache directories are validated as before, then widened.
383 lines
14 KiB
Rust
383 lines
14 KiB
Rust
//! 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<Arc<context::Context>>,
|
|
series: &str,
|
|
) -> Result<PathBuf, Box<dyn Error>> {
|
|
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<Arc<context::Context>>,
|
|
ppa_owner: &str,
|
|
ppa_name: &str,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
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());
|
|
}
|
|
}
|