put: verify the changes signature locally with gpgme
This commit is contained in:
+43
-17
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user