pull: authenticate archive indexes against signed Release files
The Sources index was downloaded with no authentication: per-artifact checksums were verified, but against hashes taken from an index a MITM could substitute along with the artifacts. Fetch each suite's InRelease (or Release + Release.gpg), verify the signature with gpgv against the archive keyring (or the PPA signing key) the same way apt does, and checksum-check every Sources index against it before parsing. Distro archives and PPAs verify strictly: an invalid or unverifiable signature, or a missing gpgv binary, is a hard error. Flat repositories keep working without a Release file or without a verifiable one (warned as unauthenticated), but tampering evidence is a hard error there too. Also switches all archive, PPA and keyring base URLs to https, and reads suite components from the verified Release instead of fetching them separately over an unauthenticated channel.
This commit is contained in:
+120
-7
@@ -5,6 +5,7 @@ use std::io::Read;
|
||||
use xz2::read::XzDecoder;
|
||||
|
||||
use crate::ProgressCallback;
|
||||
use crate::apt::release::{self, VerifiedRelease};
|
||||
use crossterm::style::Stylize;
|
||||
use log::{debug, warn};
|
||||
|
||||
@@ -17,7 +18,7 @@ use log::{debug, warn};
|
||||
/// # Returns
|
||||
/// * The base URL for the PPA (e.g., "https://ppa.launchpadcontent.net/user/ppa_name/ubuntu/")
|
||||
pub fn ppa_to_base_url(user: &str, name: &str) -> String {
|
||||
format!("http://ppa.launchpadcontent.net/{}/{}/ubuntu", user, name)
|
||||
format!("https://ppa.launchpadcontent.net/{}/{}/ubuntu", user, name)
|
||||
}
|
||||
|
||||
fn check_launchpad_repo_sync(package: &str) -> Result<Option<String>, String> {
|
||||
@@ -315,7 +316,49 @@ async fn get(
|
||||
// If using a custom base URL (PPA), disable VCS lookup to force archive download
|
||||
let from_ppa = base_url != distro_base_url;
|
||||
|
||||
let components = crate::distro_info::get_components(&base_url, series, pocket).await?;
|
||||
// Authenticate the metadata of the suite before trusting any index
|
||||
// fetched from it: the signed Release file (InRelease, or Release plus
|
||||
// detached Release.gpg) is verified against the archive keyring (or the
|
||||
// PPA signing key), and each Sources index downloaded below is
|
||||
// checksum-checked against it before parsing.
|
||||
let suite = if pocket.is_empty() {
|
||||
series.to_string()
|
||||
} else {
|
||||
format!("{series}-{pocket}")
|
||||
};
|
||||
let suite_url = format!("{base_url}/dists/{suite}");
|
||||
|
||||
let keyring_source = if from_ppa {
|
||||
release::KeyringSource::Ppa {
|
||||
base_url: base_url.clone(),
|
||||
}
|
||||
} else {
|
||||
release::KeyringSource::Distro {
|
||||
series: series.to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Verifying the Release file of: {}", suite_url);
|
||||
let verified = match release::verify_suite(&suite_url, keyring_source, true).await {
|
||||
Ok(release::Verification::Available(verified)) => verified,
|
||||
Ok(release::Verification::Unavailable { .. }) => {
|
||||
// The suite does not exist: same outcome as before verification
|
||||
// existed (probing callers simply try the next pocket/series)
|
||||
return Err(format!("No Release file found for suite '{suite}' at {base_url}").into());
|
||||
}
|
||||
Ok(release::Verification::KeyringUnavailable { reason, .. }) => {
|
||||
// An existing suite whose keys are unavailable must not be
|
||||
// trusted, but this is not tampering: report and let callers
|
||||
// move on
|
||||
return Err(reason.into());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let components = verified.components();
|
||||
if components.is_empty() {
|
||||
return Err(format!("Components not found in the Release file of '{suite_url}'").into());
|
||||
}
|
||||
debug!("Found components: {:?}", components);
|
||||
|
||||
for component in components {
|
||||
@@ -338,6 +381,14 @@ async fn get(
|
||||
|
||||
let compressed_data = response.bytes().await?;
|
||||
|
||||
// The index must match the checksums listed in the signed Release
|
||||
// file: this is what closes the 'substituted index with matching
|
||||
// artifact checksums' man-in-the-middle attack
|
||||
let suite_rel_path = format!("{component}/source/Sources.gz");
|
||||
verified
|
||||
.verify_file(&suite_rel_path, &compressed_data)
|
||||
.map_err(release::VerifyError)?;
|
||||
|
||||
debug!(
|
||||
"Downloaded Sources.gz for {}/{}/{}",
|
||||
dist, series, component
|
||||
@@ -428,7 +479,13 @@ async fn find_package(
|
||||
}
|
||||
return Ok(info);
|
||||
}
|
||||
Err(_e) => {
|
||||
Err(e) => {
|
||||
// A Release verification failure is a security error,
|
||||
// not a missing package: abort the search instead of
|
||||
// silently probing other series/pockets
|
||||
if e.downcast_ref::<release::VerifyError>().is_some() {
|
||||
return Err(e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -471,15 +528,39 @@ async fn get_flat_repo_series(base_url: &str) -> Result<String, Box<dyn Error>>
|
||||
/// Fetch the sources index of a flat repository
|
||||
///
|
||||
/// Flat repositories are free to serve any compressed variant of the index
|
||||
/// (or an uncompressed one), so try the usual candidates in turn.
|
||||
async fn get_flat_repo_sources(base_url: &str) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
/// (or an uncompressed one), so try the usual candidates in turn. When the
|
||||
/// repository published a Release file, each index candidate is
|
||||
/// checksum-verified against it (failing on mismatch, since the artifact
|
||||
/// hashes would come from the index itself).
|
||||
async fn get_flat_repo_sources(
|
||||
base_url: &str,
|
||||
verified: Option<&VerifiedRelease>,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut errors = Vec::new();
|
||||
for name in ["Sources.xz", "Sources.gz", "Sources"] {
|
||||
let url = format!("{base}/{name}");
|
||||
match reqwest::get(&url).await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
return Ok(response.bytes().await?.to_vec());
|
||||
let data = response.bytes().await?.to_vec();
|
||||
|
||||
// Some flat Release files list their entries with a './' prefix
|
||||
if let Some(release) = verified {
|
||||
let mismatch = match release.verify_file(name, &data) {
|
||||
Ok(()) => None,
|
||||
Err(first) => release
|
||||
.verify_file(&format!("./{name}"), &data)
|
||||
.err()
|
||||
.map(|_| first),
|
||||
};
|
||||
if let Some(e) = mismatch {
|
||||
return Err(
|
||||
format!("Verification of the repository index failed: {e}").into()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(data);
|
||||
}
|
||||
Ok(response) => errors.push(format!("{}: HTTP {}", url, response.status())),
|
||||
Err(e) => errors.push(format!("{}: {}", url, e)),
|
||||
@@ -505,6 +586,11 @@ async fn get_flat_repo_sources(base_url: &str) -> Result<Vec<u8>, Box<dyn Error>
|
||||
/// Packages from external repositories are always downloaded from the
|
||||
/// repository itself; the 'Vcs-Git' of the stanza is never used, as it may
|
||||
/// point to an arbitrary source.
|
||||
///
|
||||
/// Third-party flat repositories have no known signing key: when they
|
||||
/// publish a Release file its checksums are enforced on the index (with a
|
||||
/// hard error on mismatch), but the absence of a verifiable signature only
|
||||
/// produces a warning, preserving the previous behavior for such repos.
|
||||
pub async fn lookup_repository(
|
||||
package: &str,
|
||||
version: Option<&str>,
|
||||
@@ -521,8 +607,35 @@ pub async fn lookup_repository(
|
||||
);
|
||||
}
|
||||
|
||||
// Attempt to authenticate the repository. In non-strict mode only the
|
||||
// absence of a Release file (or of a way to verify it) is tolerated:
|
||||
// tampering evidence — an invalid or malformed signature — is a hard
|
||||
// error even for third-party repositories.
|
||||
let verified = match release::verify_suite(
|
||||
repo_url.trim_end_matches('/'),
|
||||
release::KeyringSource::None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(release::Verification::Available(verified)) => Some(verified),
|
||||
Ok(release::Verification::Unavailable { .. }) => None,
|
||||
Ok(release::Verification::KeyringUnavailable { reason, .. }) => {
|
||||
warn!("Release verification of repository {repo_url} failed: {reason}");
|
||||
None
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Prefer the suite name from the (fetched, possibly verified) Release
|
||||
// file; fall back to a dedicated fetch when there is none
|
||||
let resolved_series = if let Some(s) = series {
|
||||
s.to_string()
|
||||
} else if let Some(codename) = verified
|
||||
.as_ref()
|
||||
.and_then(|v| v.field("Codename").or_else(|| v.field("Suite")))
|
||||
{
|
||||
codename.to_string()
|
||||
} else {
|
||||
get_flat_repo_series(repo_url).await?
|
||||
};
|
||||
@@ -536,7 +649,7 @@ pub async fn lookup_repository(
|
||||
);
|
||||
}
|
||||
|
||||
let sources = get_flat_repo_sources(repo_url).await?;
|
||||
let sources = get_flat_repo_sources(repo_url, verified.as_ref()).await?;
|
||||
let stanza = parse_sources(&sources, package, version)?
|
||||
.ok_or_else(|| format!("Package '{package}' not found in repository {repo_url}"))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user