feat: enhance public key handling with URL fetching and verification support

This commit is contained in:
2026-02-25 21:31:01 -06:00
parent fbb8e83a00
commit 59ea295b77
2 changed files with 452 additions and 21 deletions
+382 -14
View File
@@ -380,6 +380,323 @@ fn copy_file_url_to_path(url: &str, dest: &Path) -> Result<FileUrlCopyOutcome> {
Ok(FileUrlCopyOutcome::Copied)
}
fn fetch_url_to_path(client: &reqwest::blocking::Client, url: &str, dest: &Path) -> Result<bool> {
match copy_file_url_to_path(url, dest)? {
FileUrlCopyOutcome::Copied => return Ok(true),
FileUrlCopyOutcome::Missing => return Ok(false),
FileUrlCopyOutcome::NotFileUrl => {}
}
let mut resp = client
.get(url)
.send()
.with_context(|| format!("Failed to fetch {}", url))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
if !resp.status().is_success() {
anyhow::bail!("Failed to fetch {}: HTTP {}", url, resp.status());
}
let mut out =
fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
std::io::copy(&mut resp, &mut out)
.with_context(|| format!("Failed to save {}", dest.display()))?;
out.flush()
.with_context(|| format!("Failed to flush {}", dest.display()))?;
Ok(true)
}
fn extract_html_href_targets(html: &str) -> Vec<String> {
let lower = html.to_ascii_lowercase();
let lower_bytes = lower.as_bytes();
let html_bytes = html.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i < lower_bytes.len() {
let Some(rel) = lower[i..].find("href") else {
break;
};
let mut j = i + rel + 4;
while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= lower_bytes.len() || lower_bytes[j] != b'=' {
i = j;
continue;
}
j += 1;
while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= lower_bytes.len() {
break;
}
let (start, end) = if lower_bytes[j] == b'"' || lower_bytes[j] == b'\'' {
let quote = lower_bytes[j];
let start = j + 1;
let mut k = start;
while k < lower_bytes.len() && lower_bytes[k] != quote {
k += 1;
}
(start, k)
} else {
let start = j;
let mut k = start;
while k < lower_bytes.len()
&& !lower_bytes[k].is_ascii_whitespace()
&& lower_bytes[k] != b'>'
{
k += 1;
}
(start, k)
};
if start < end && end <= html_bytes.len() {
out.push(String::from_utf8_lossy(&html_bytes[start..end]).to_string());
}
i = end.saturating_add(1);
}
out
}
fn list_repo_public_key_urls(
base_url: &str,
client: &reqwest::blocking::Client,
) -> Result<Vec<(String, String)>> {
let parsed =
url::Url::parse(base_url).with_context(|| format!("Invalid repo URL: {base_url}"))?;
if parsed.scheme() == "file" {
let repo_dir = parsed
.to_file_path()
.map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", base_url))?;
let keys_dir = repo_dir.join("keys");
if !keys_dir.exists() {
return Ok(Vec::new());
}
if !keys_dir.is_dir() {
anyhow::bail!(
"Binary repo keys path is not a directory: {}",
keys_dir.display()
);
}
let mut out = Vec::new();
for entry in fs::read_dir(&keys_dir)
.with_context(|| format!("Failed to read {}", keys_dir.display()))?
{
let path = entry?.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.to_ascii_lowercase().ends_with(".pub") {
continue;
}
let key_url = url::Url::from_file_path(&path)
.map_err(|_| anyhow::anyhow!("Failed to build file:// URL for {}", path.display()))?
.to_string();
out.push((name.to_string(), key_url));
}
out.sort();
out.dedup();
return Ok(out);
}
let keys_url = join_repo_url(base_url, "keys/")?;
let resp = client
.get(&keys_url)
.send()
.with_context(|| format!("Failed to fetch {}", keys_url))?;
if !resp.status().is_success() {
return Ok(Vec::new());
}
let body = resp
.text()
.with_context(|| format!("Failed to read {}", keys_url))?;
let keys_base = url::Url::parse(&keys_url)
.with_context(|| format!("Invalid repo keys URL: {}", keys_url))?;
let mut out = Vec::new();
for href in extract_html_href_targets(&body) {
if href.is_empty() || href.starts_with('#') || href.starts_with('?') {
continue;
}
let Ok(url) = keys_base.join(&href) else {
continue;
};
let Some(name) = url.path_segments().and_then(|mut s| s.next_back()) else {
continue;
};
if name.is_empty() || !name.to_ascii_lowercase().ends_with(".pub") {
continue;
}
out.push((name.to_string(), url.to_string()));
}
out.sort();
out.dedup();
Ok(out)
}
fn verify_with_any_trusted_public_key(
rootfs: &Path,
input: &Path,
sig_path: &Path,
) -> Result<PathBuf> {
let keys = crate::signing::list_trusted_public_keys(rootfs)?;
if keys.is_empty() {
anyhow::bail!("No trusted minisign public keys found in rootfs or host");
}
let mut last_failure: Option<(PathBuf, anyhow::Error)> = None;
for key_path in keys {
match crate::signing::verify_zst_file_detached_with_public_key(input, sig_path, &key_path) {
Ok(()) => return Ok(key_path),
Err(err) => last_failure = Some((key_path, err)),
}
}
let (key_path, err) = last_failure.expect("non-empty key list must produce a failure");
Err(err).with_context(|| {
format!(
"Detached signature verification failed with all trusted public keys (last tried {})",
key_path.display()
)
})
}
fn sanitize_filename_component(input: &str) -> String {
input
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => ch,
_ => '_',
})
.collect()
}
fn install_trusted_repo_public_key(
rootfs: &Path,
repo_name: &str,
source_key_path: &Path,
source_name: &str,
) -> Result<PathBuf> {
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs);
fs::create_dir_all(&trusted_dir)
.with_context(|| format!("Failed to create {}", trusted_dir.display()))?;
let base_name = source_name
.split('/')
.next_back()
.filter(|name| !name.is_empty())
.unwrap_or("repo.pub");
let base_name = sanitize_filename_component(base_name);
let repo_prefix = sanitize_filename_component(repo_name);
let source_bytes = fs::read(source_key_path)
.with_context(|| format!("Failed to read {}", source_key_path.display()))?;
let mut candidates = Vec::new();
candidates.push(trusted_dir.join(&base_name));
if !repo_prefix.is_empty() {
candidates.push(trusted_dir.join(format!("{}-{}", repo_prefix, base_name)));
}
for candidate in &candidates {
if candidate.exists() {
let existing = fs::read(candidate)
.with_context(|| format!("Failed to read {}", candidate.display()))?;
if existing == source_bytes {
return Ok(candidate.clone());
}
} else {
fs::write(candidate, &source_bytes)
.with_context(|| format!("Failed to write {}", candidate.display()))?;
return Ok(candidate.clone());
}
}
for idx in 1usize.. {
let candidate = trusted_dir.join(format!("{}-{}.{}", repo_prefix, base_name, idx));
if candidate.exists() {
let existing = fs::read(&candidate)
.with_context(|| format!("Failed to read {}", candidate.display()))?;
if existing == source_bytes {
return Ok(candidate);
}
continue;
}
fs::write(&candidate, &source_bytes)
.with_context(|| format!("Failed to write {}", candidate.display()))?;
return Ok(candidate);
}
unreachable!("infinite loop returns on first available candidate")
}
fn try_trust_repo_public_key_for_repo_db(
repo_name: &str,
base_url: &str,
rootfs: &Path,
cache_dir: &Path,
client: &reqwest::blocking::Client,
repo_db_zst_path: &Path,
repo_db_sig_path: &Path,
) -> Result<Option<PathBuf>> {
let repo_keys = list_repo_public_key_urls(base_url, client)?;
if repo_keys.is_empty() {
return Ok(None);
}
let repo_keys_cache_dir = cache_dir.join("repo_keys");
fs::create_dir_all(&repo_keys_cache_dir)
.with_context(|| format!("Failed to create {}", repo_keys_cache_dir.display()))?;
for (key_name, key_url) in repo_keys {
let cache_name = sanitize_filename_component(&key_name);
let key_tmp_path = repo_keys_cache_dir.join(&cache_name);
if !fetch_url_to_path(client, &key_url, &key_tmp_path)? {
continue;
}
if crate::signing::verify_zst_file_detached_with_public_key(
repo_db_zst_path,
repo_db_sig_path,
&key_tmp_path,
)
.is_err()
{
continue;
}
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs);
let prompt = format!(
"Trust repo key '{}' from binary repo '{}' and copy it to {}?",
key_name,
repo_name,
trusted_dir.display()
);
if !crate::ui::prompt_yes_no(&prompt, false)? {
crate::log_warn!(
"Skipped trusting repo key '{}' for binary repo '{}'",
key_name,
repo_name
);
continue;
}
let installed =
install_trusted_repo_public_key(rootfs, repo_name, &key_tmp_path, &key_name)?;
return Ok(Some(installed));
}
Ok(None)
}
fn normalize_git_mirror_url(url: &str) -> Result<String> {
let parsed = match url::Url::parse(url) {
Ok(parsed) => parsed,
@@ -552,30 +869,44 @@ pub fn fetch_binary_repo_db(
};
if sig_downloaded {
let keys = crate::signing::locate_keys(rootfs)?;
if keys.public_key.is_none() {
if !repo.allow_unsigned {
let trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
if trusted_keys.is_empty() {
if let Some(installed_key) = try_trust_repo_public_key_for_repo_db(
repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig,
)? {
crate::log_info!(
"Trusted repo key for '{}' installed at {}",
repo_name,
installed_key.display()
);
} else if !repo.allow_unsigned {
anyhow::bail!(
"No minisign public key found for verifying binary repo '{}' (checked rootfs and host)",
"No trusted minisign public key found for binary repo '{}' and no trusted key was accepted from {}/keys/",
repo_name,
base_url.trim_end_matches('/')
);
} else {
crate::log_warn!(
"No trusted minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true",
repo_name
);
}
crate::log_warn!(
"No minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true",
repo_name
);
}
if crate::signing::list_trusted_public_keys(rootfs)?.is_empty() {
// No key was trusted/installed, and allow_unsigned=true already handled above.
} else {
crate::signing::verify_zst_file_detached(rootfs, &tmp_zst, &tmp_sig).with_context(
|| {
let verified_key = verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig)
.with_context(|| {
format!(
"Failed to verify detached signature for binary repo '{}'",
repo_name
)
},
)?;
})?;
crate::log_info!(
"Verified detached signature for binary repo '{}'",
repo_name
"Verified detached signature for binary repo '{}' using {}",
repo_name,
verified_key.display()
);
}
}
@@ -1492,6 +1823,43 @@ license = ["MIT", "Apache-2.0"]
assert!(!dst.exists());
}
#[test]
fn test_extract_html_href_targets_parses_common_forms() {
let html = r#"
<html><body>
<a href="alpha.pub">alpha</a>
<a HREF='nested/beta.pub'>beta</a>
<a href=gamma.pub>gamma</a>
<a href="../">parent</a>
</body></html>
"#;
let hrefs = extract_html_href_targets(html);
assert!(hrefs.iter().any(|h| h == "alpha.pub"));
assert!(hrefs.iter().any(|h| h == "nested/beta.pub"));
assert!(hrefs.iter().any(|h| h == "gamma.pub"));
assert!(hrefs.iter().any(|h| h == "../"));
}
#[test]
fn test_list_repo_public_key_urls_reads_file_repo_keys_dir() {
let tmp = tempfile::tempdir().unwrap();
let repo_dir = tmp.path().join("repo");
let keys_dir = repo_dir.join("keys");
fs::create_dir_all(&keys_dir).unwrap();
fs::write(keys_dir.join("repo.pub"), b"pubkey").unwrap();
fs::write(keys_dir.join("ignore.txt"), b"nope").unwrap();
fs::create_dir_all(keys_dir.join("subdir")).unwrap();
let base_url = url::Url::from_directory_path(&repo_dir)
.expect("file URL")
.to_string();
let client = reqwest::blocking::Client::builder().build().unwrap();
let keys = list_repo_public_key_urls(&base_url, &client).unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].0, "repo.pub");
assert!(keys[0].1.ends_with("/repo.pub"));
}
#[test]
fn test_normalize_git_mirror_url_converts_file_scheme() {
let tmp = tempfile::tempdir().unwrap();
+70 -7
View File
@@ -36,6 +36,13 @@ fn key_dir_candidates(rootfs: &Path, host_root: &Path, rel: &str) -> Vec<PathBuf
.collect()
}
fn is_public_key_file(path: &Path) -> bool {
path.is_file()
&& path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("pub"))
}
fn pick_key_file(dir: &Path, is_public: bool) -> Result<Option<PathBuf>> {
if !dir.exists() {
return Ok(None);
@@ -90,6 +97,27 @@ fn pick_key_file(dir: &Path, is_public: bool) -> Result<Option<PathBuf>> {
);
}
fn list_public_key_files_in_roots(rootfs: &Path, host_root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for dir in key_dir_candidates(rootfs, host_root, PUBLIC_KEYS_DIR_REL) {
if !dir.exists() {
continue;
}
if !dir.is_dir() {
anyhow::bail!("Key path is not a directory: {}", dir.display());
}
let mut files: Vec<PathBuf> = fs::read_dir(&dir)
.with_context(|| format!("Failed to read {}", dir.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| is_public_key_file(p))
.collect();
files.sort();
out.extend(files);
}
Ok(out)
}
fn locate_keys_in_roots(rootfs: &Path, host_root: &Path) -> Result<KeyLocations> {
let mut keys = KeyLocations::default();
@@ -113,6 +141,16 @@ pub fn locate_keys(rootfs: &Path) -> Result<KeyLocations> {
locate_keys_in_roots(rootfs, Path::new("/"))
}
/// Return the directory where trusted minisign public keys are stored under `rootfs`.
pub fn trusted_public_keys_dir(rootfs: &Path) -> PathBuf {
rootfs.join(PUBLIC_KEYS_DIR_REL)
}
/// List all trusted minisign public keys found in `rootfs` and then the host `/`.
pub fn list_trusted_public_keys(rootfs: &Path) -> Result<Vec<PathBuf>> {
list_public_key_files_in_roots(rootfs, Path::new("/"))
}
fn detached_sig_path(input: &Path) -> PathBuf {
PathBuf::from(format!("{}.sig", input.display()))
}
@@ -222,6 +260,19 @@ fn verify_detached_with_key_paths(
Ok(())
}
/// Verify a `.zst` file using a detached minisign signature and an explicit public key path.
pub fn verify_zst_file_detached_with_public_key(
input: &Path,
sig_path: &Path,
public_key_path: &Path,
) -> Result<()> {
let keys = KeyLocations {
public_key: Some(public_key_path.to_path_buf()),
signing_key: None,
};
verify_detached_with_key_paths(input, sig_path, &keys)
}
/// Sign a `.zst` file with a detached minisign signature written to `<file>.sig`.
pub fn sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result<PathBuf> {
let keys = locate_keys(rootfs)?;
@@ -230,12 +281,6 @@ pub fn sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result<PathBuf> {
Ok(sig_path)
}
/// Verify a `.zst` file using a detached minisign signature.
pub fn verify_zst_file_detached(rootfs: &Path, input: &Path, sig_path: &Path) -> Result<()> {
let keys = locate_keys(rootfs)?;
verify_detached_with_key_paths(input, sig_path, &keys)
}
/// Attempt to sign a `.zst` file using discovered keys; skip if no signing key exists.
pub fn auto_sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result<Option<PathBuf>> {
if !is_zst_file(input) {
@@ -306,15 +351,33 @@ mod tests {
sign_detached_with_key_paths(&file, &sig_path, &keys)?;
assert!(sig_path.exists());
let pk = PublicKey::from_file(pub_path)?;
let pk = PublicKey::from_file(&pub_path)?;
let sig_box = SignatureBox::from_file(&sig_path)?;
let mut reader = fs::File::open(&file)?;
minisign::verify(&pk, &sig_box, &mut reader, true, false, false)
.context("signature verification should succeed")?;
verify_detached_with_key_paths(&file, &sig_path, &keys)?;
verify_zst_file_detached_with_public_key(&file, &sig_path, &pub_path)?;
// Also make sure host/rootfs lookup path version works without touching /.
let _ = locate_keys_in_roots(rootfs.path(), host.path())?;
Ok(())
}
#[test]
fn list_trusted_public_keys_finds_multiple_pub_files() -> Result<()> {
let rootfs = tempfile::tempdir()?;
let host = tempfile::tempdir()?;
let public_dir = rootfs.path().join(PUBLIC_KEYS_DIR_REL);
fs::create_dir_all(&public_dir)?;
fs::write(public_dir.join("a.pub"), b"dummy")?;
fs::write(public_dir.join("note.txt"), b"ignore")?;
fs::write(public_dir.join("b.pub"), b"dummy")?;
let found = list_public_key_files_in_roots(rootfs.path(), host.path())?;
assert_eq!(found.len(), 2);
assert!(found[0].ends_with("a.pub"));
assert!(found[1].ends_with("b.pub"));
Ok(())
}
}