put: verify the changes signature locally with gpgme

This commit is contained in:
2026-09-16 22:24:53 +02:00
parent 0c2cf0ac5e
commit e8d4b98f52
2 changed files with 370 additions and 20 deletions
+43 -17
View File
@@ -1,9 +1,10 @@
//! `.changes` file parsing and pre-upload validation: signature presence,
//! field extraction, and verification that every file listed in the
//! checksums sections exists and matches on disk.
//! `.changes` file parsing and pre-upload validation: OpenPGP signature
//! verification, field extraction, and verification that every file listed
//! in the checksums sections exists and matches on disk.
//!
//! Launchpad authenticates the upload through the GPG signature of the
//! `.changes` file, so an unsigned file is rejected before connecting, and
//! `.changes` file, so an unsigned file — or one whose signature does not
//! verify against the local keyring — is rejected before connecting, and
//! a file whose checksums do not match the artifacts next to it would only
//! fail server-side after a partial upload.
@@ -61,8 +62,9 @@ pub struct ChangesFile {
pub files: Vec<FileEntry>,
}
/// Parse a `.changes` file: extract the upload fields and the file list from
/// the checksums sections. Rejects unsigned files (Launchpad requires a
/// Parse a `.changes` file: verify its OpenPGP signature against the local
/// keyring, then extract the upload fields and the file list from the
/// checksums sections. Rejects unsigned files (Launchpad requires a
/// signature) and `UNRELEASED` entries (which are not uploadable).
pub fn parse(path: &Path) -> Result<ChangesFile, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)
@@ -77,6 +79,20 @@ pub fn parse(path: &Path) -> Result<ChangesFile, Box<dyn std::error::Error>> {
.into());
}
// Launchpad authenticates the upload through the OpenPGP signature, so
// verify it locally, like dput-ng with `allow_unsigned_uploads = false`:
// a tampered document, or one signed by a key absent from the keyring,
// must fail before anything is uploaded.
crate::utils::gpg::verify_changes_signature(&content)
.map_err(|e| format!("'{}': {e}", path.display()))?;
parse_body(&content, path)
}
/// Parse the deb822 content of an already signature-checked `.changes`
/// document: strip the signing armor, extract the upload fields and the
/// file list from the checksums sections.
fn parse_body(content: &str, path: &Path) -> Result<ChangesFile, Box<dyn std::error::Error>> {
// dpkg signs `.changes` either clearsigned (the whole document wrapped
// in a `-----BEGIN PGP SIGNED MESSAGE-----` armor, with the fields only
// starting after the armor headers) or with the signature block merely
@@ -87,7 +103,7 @@ pub fn parse(path: &Path) -> Result<ChangesFile, Box<dyn std::error::Error>> {
content
.split("-----BEGIN PGP SIGNATURE-----")
.next()
.unwrap_or(&content),
.unwrap_or(content),
);
let paragraphs = parse_paragraphs(body);
@@ -222,6 +238,11 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
/// Write a minimal valid changes file plus its two artifacts; returns
/// (changes path, dsc content, tarball content)
///
/// The signature block is a fake: parsing tests exercise the two
/// signing layouts only, the cryptographic verification happens before
/// [`parse_body`] and is covered in `utils::gpg` (see the ignored
/// throwaway-keyring test there).
fn fixture(dir: &Path, distribution: &str) -> (PathBuf, Vec<u8>, Vec<u8>) {
let dsc = b"dsc content";
let tarball = b"tarball content";
@@ -255,8 +276,9 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
fn parse_and_validate_valid_changes() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
let content = std::fs::read_to_string(&path).unwrap();
let changes = parse(&path).unwrap();
let changes = parse_body(&content, &path).unwrap();
assert_eq!(changes.source, "hello");
assert_eq!(changes.version, "1.0-1");
assert_eq!(changes.distribution, "resolute");
@@ -285,9 +307,9 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
SIGNATURE
);
let path = dir.path().join("hello_1.0-1_source.changes");
std::fs::write(&path, content).unwrap();
std::fs::write(&path, &content).unwrap();
let changes = parse(&path).unwrap();
let changes = parse_body(&content, &path).unwrap();
assert_eq!(changes.source, "hello");
validate(&changes).unwrap();
}
@@ -303,17 +325,19 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
.unwrap();
std::fs::write(&path, unsigned).unwrap();
// Detected before the signature verification, so this stays offline
let err = parse(&path).unwrap_err().to_string();
assert!(err.contains("not signed"), "unexpected error: {err}");
assert!(err.contains("not signed"), "unexpected: {err}");
}
#[test]
fn parse_rejects_unreleased_distribution() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "UNRELEASED");
let content = std::fs::read_to_string(&path).unwrap();
let err = parse(&path).unwrap_err().to_string();
assert!(err.contains("UNRELEASED"), "unexpected error: {err}");
let err = parse_body(&content, &path).unwrap_err().to_string();
assert!(err.contains("UNRELEASED"), "unexpected: {err}");
}
#[test]
@@ -321,8 +345,9 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
std::fs::write(dir.path().join("hello_1.0-1.dsc"), b"tampered!").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let changes = parse(&path).unwrap();
let changes = parse_body(&content, &path).unwrap();
let err = validate(&changes).unwrap_err().to_string();
assert!(
err.contains("does not match its checksums"),
@@ -335,8 +360,9 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
std::fs::remove_file(dir.path().join("hello_1.0-1.dsc")).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let changes = parse(&path).unwrap();
let changes = parse_body(&content, &path).unwrap();
let err = validate(&changes).unwrap_err().to_string();
assert!(err.contains("cannot upload"), "unexpected: {err}");
}
@@ -357,9 +383,9 @@ iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
SIGNATURE
);
let path = dir.path().join("hello_1.0-1_source.changes");
std::fs::write(&path, content).unwrap();
std::fs::write(&path, &content).unwrap();
let changes = parse(&path).unwrap();
let changes = parse_body(&content, &path).unwrap();
assert_eq!(changes.files.len(), 1);
assert!(changes.files[0].sha256.is_none());
assert!(changes.files[0].md5.is_some());