450 lines
17 KiB
Rust
450 lines
17 KiB
Rust
//! GPG / OpenPGP helpers: secret key discovery, inline (clear) signing of
|
|
//! Debian artifacts such as `.dsc`, `.buildinfo` and `.changes` files, and
|
|
//! local verification of signed documents.
|
|
|
|
use std::error::Error;
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
use gpgme::error::Error as GpgError;
|
|
use gpgme::{Context, Data, Protocol, Signature, SignatureSummary, VerificationResult};
|
|
|
|
/// The armor marker opening an OpenPGP signature block.
|
|
const SIGNATURE_BEGIN: &str = "-----BEGIN PGP SIGNATURE-----";
|
|
/// The armor marker opening a clearsigned message.
|
|
const CLEARSIGNED_BEGIN: &str = "-----BEGIN PGP SIGNED MESSAGE-----";
|
|
|
|
/// Check if a GPG key matching 'email' exists
|
|
/// Returns the key ID if found, None otherwise
|
|
pub fn find_signing_key_for_email(
|
|
email: &str,
|
|
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
// Create a new GPG context
|
|
let mut ctx = Context::from_protocol(Protocol::OpenPgp)?;
|
|
|
|
// List all secret keys
|
|
let keys = ctx.secret_keys()?;
|
|
|
|
// Find a key that matches the email and can sign
|
|
for key_result in keys {
|
|
let key = key_result?;
|
|
// Check if the key has signing capability
|
|
if key.can_sign() {
|
|
// Check user IDs for email match
|
|
for user_id in key.user_ids() {
|
|
if let Ok(userid_email) = user_id.email()
|
|
&& userid_email.eq_ignore_ascii_case(email)
|
|
&& let Ok(fingerprint) = key.fingerprint()
|
|
{
|
|
return Ok(Some(fingerprint.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Validate an OpenPGP key id / fingerprint like dpkg does.
|
|
///
|
|
/// Short (<= 8 hex chars) key IDs are rejected outright, long (16 hex chars)
|
|
/// key IDs produce a warning; anything else must be a v4 (40) or v6 (64)
|
|
/// fingerprint length.
|
|
pub fn validate_key_id(keyid: &str) -> Result<(), Box<dyn Error>> {
|
|
let len = keyid.len();
|
|
if len <= 8 {
|
|
return Err("short OpenPGP key IDs are broken; use a key fingerprint instead".into());
|
|
} else if len <= 16 {
|
|
log::warn!(
|
|
"long OpenPGP key IDs are strongly discouraged; \
|
|
use a key fingerprint instead"
|
|
);
|
|
} else if len != 40 && len != 64 {
|
|
log::warn!("OpenPGP key ID has unknown v4 or v6 fingerprint length");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Find a secret key whose fingerprint matches `keyid` (suffix matching
|
|
/// allows passing a long key id instead of the full fingerprint).
|
|
fn find_secret_key(ctx: &mut Context, keyid: &str) -> Result<Option<gpgme::Key>, Box<dyn Error>> {
|
|
for key_result in ctx.secret_keys()? {
|
|
let key = key_result?;
|
|
if let Ok(fingerprint) = key.fingerprint()
|
|
&& fingerprint.ends_with(keyid)
|
|
{
|
|
return Ok(Some(key));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Clearsign a file in place: the original content becomes the payload of an
|
|
/// armored inline-signed document which atomically replaces the file.
|
|
///
|
|
/// This is the same operation as dpkg's `inline_sign` + rename sequence used
|
|
/// when signing `.dsc`, `.buildinfo` or `.changes` files.
|
|
pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box<dyn Error>> {
|
|
let content = std::fs::read(path)
|
|
.map_err(|e| format!("cannot read '{}' for signing: {}", path.display(), e))?;
|
|
|
|
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
|
|
.map_err(|e| format!("cannot initialize GPGME: {}", e))?;
|
|
ctx.set_armor(true);
|
|
|
|
let key = find_secret_key(&mut ctx, keyid)?
|
|
.ok_or_else(|| format!("no secret key matching '{}' found", keyid))?;
|
|
|
|
ctx.add_signer(&key)
|
|
.map_err(|e| format!("cannot add signer '{}': {}", keyid, e))?;
|
|
|
|
let input = Data::from_bytes(&content)?;
|
|
let mut output = Data::new()?;
|
|
ctx.sign_clear(input, &mut output)
|
|
.map_err(|e| format!("clear-signing '{}' failed: {}", path.display(), e))?;
|
|
|
|
// gpgme leaves the output buffer cursor at the end after writing.
|
|
use std::io::Seek;
|
|
use std::io::SeekFrom;
|
|
output.seek(SeekFrom::Start(0))?;
|
|
let mut signed = Vec::new();
|
|
output.read_to_end(&mut signed)?;
|
|
|
|
// Atomic replace, like dpkg's signfile (write .asc then move).
|
|
let tmp = path.with_extension("asc.tmp");
|
|
std::fs::write(&tmp, &signed)
|
|
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
|
std::fs::rename(&tmp, path)
|
|
.map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into())
|
|
}
|
|
|
|
/// How a document carries its OpenPGP signature: Debian tools
|
|
/// (`dpkg-buildpackage`, `debsign`) either wrap the whole document in a
|
|
/// clearsigned armor or append an armored signature block to the plain
|
|
/// deb822 body.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum SignatureLayout<'a> {
|
|
/// Whole document clearsigned: the signed text and the signature live
|
|
/// in the same armor buffer.
|
|
Clearsigned(&'a str),
|
|
/// Signature block appended after the plain body: the signed text and
|
|
/// the armored detached signature are distinct parts.
|
|
Detached {
|
|
/// The signed text, everything before the signature block.
|
|
body: &'a str,
|
|
/// The armored signature block, from its BEGIN marker to the end.
|
|
signature: &'a str,
|
|
},
|
|
}
|
|
|
|
/// Detect how `content` is signed and split it into signed text and
|
|
/// signature. Returns `None` when no signature block is present.
|
|
///
|
|
/// The split preserves the exact bytes of both parts: the body is
|
|
/// byte-for-byte what the signer signed, which is what a detached
|
|
/// verification must hash.
|
|
fn split_signature(content: &str) -> Option<SignatureLayout<'_>> {
|
|
if content.starts_with(CLEARSIGNED_BEGIN) {
|
|
return Some(SignatureLayout::Clearsigned(content));
|
|
}
|
|
let (body, _) = content.split_once(SIGNATURE_BEGIN)?;
|
|
Some(SignatureLayout::Detached {
|
|
body,
|
|
signature: &content[body.len()..],
|
|
})
|
|
}
|
|
|
|
/// Verify the OpenPGP signature of a signed document (`.changes`, `.dsc`,
|
|
/// `.buildinfo`): both dpkg signing layouts are accepted, the whole
|
|
/// document clearsigned or the signature block merely appended to the body.
|
|
///
|
|
/// Like dput-ng with `allow_unsigned_uploads = false`, the signature must
|
|
/// verify against the local keyring and the signing key must be present
|
|
/// locally, but any key will do: Launchpad authenticates the upload
|
|
/// through this signature, so a tampered document, a corrupt signature or
|
|
/// an unattributable signer must be refused before anything is uploaded.
|
|
pub fn verify_changes_signature(content: &str) -> Result<(), Box<dyn Error>> {
|
|
let layout = split_signature(content)
|
|
.ok_or("no OpenPGP signature block found: the document is not signed")?;
|
|
|
|
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
|
|
.map_err(|e| format!("cannot initialize GPGME: {e}"))?;
|
|
ctx.set_armor(true);
|
|
|
|
let result = match layout {
|
|
SignatureLayout::Clearsigned(signed) => {
|
|
// The clearsigned buffer is both the signed text and the
|
|
// signature; gpgme only needs a sink for the verified plaintext
|
|
let mut plaintext = Data::new()?;
|
|
ctx.verify_opaque(signed, &mut plaintext)
|
|
}
|
|
SignatureLayout::Detached { body, signature } => ctx.verify_detached(signature, body),
|
|
}
|
|
.map_err(|e| {
|
|
format!(
|
|
"signature verification failed ({e}): the signature block is \
|
|
unreadable, or the document was modified after signing"
|
|
)
|
|
})?;
|
|
|
|
require_valid_signature(&result)
|
|
}
|
|
|
|
/// Turn gpgme's per-signature verdicts into an accept/reject decision: at
|
|
/// least one signature must be valid; otherwise the most specific problem
|
|
/// found is reported.
|
|
fn require_valid_signature(result: &VerificationResult) -> Result<(), Box<dyn Error>> {
|
|
let signatures: Vec<Signature<'_>> = result.signatures().collect();
|
|
if signatures.is_empty() {
|
|
return Err("the PGP block contains no signature".into());
|
|
}
|
|
|
|
let mut first_failure = None;
|
|
for signature in &signatures {
|
|
match signature_verdict(signature) {
|
|
Ok(()) => return Ok(()),
|
|
Err(reason) => first_failure = first_failure.or(Some(reason)),
|
|
}
|
|
}
|
|
|
|
Err(first_failure
|
|
.unwrap_or_else(|| "signature verification failed".to_string())
|
|
.into())
|
|
}
|
|
|
|
/// A human-readable verdict for one signature: `Ok` when it is valid, or an
|
|
/// actionable reason to reject the document otherwise.
|
|
fn signature_verdict(signature: &Signature<'_>) -> Result<(), String> {
|
|
let fingerprint = signature
|
|
.fingerprint()
|
|
.unwrap_or("<unknown key>")
|
|
.to_string();
|
|
let summary = signature.summary();
|
|
|
|
// The signing key must be resolvable in the local keyring: without it
|
|
// the signature cannot be attributed at all. That is a keyring
|
|
// configuration problem, distinct from a corrupt document.
|
|
if summary.contains(SignatureSummary::KEY_MISSING)
|
|
|| signature
|
|
.status()
|
|
.err()
|
|
.is_some_and(|e| e.code() == GpgError::NO_PUBKEY.code())
|
|
{
|
|
return Err(format!(
|
|
"the signing key {fingerprint} is not in your local keyring, \
|
|
its signature cannot be verified; check it with \
|
|
`gpg --list-keys {fingerprint}` (and receive the key if missing)"
|
|
));
|
|
}
|
|
|
|
for (flag, reason) in [
|
|
(SignatureSummary::KEY_REVOKED, "the signing key is revoked"),
|
|
(SignatureSummary::KEY_EXPIRED, "the signing key is expired"),
|
|
(SignatureSummary::SIG_EXPIRED, "the signature is expired"),
|
|
] {
|
|
if summary.contains(flag) {
|
|
return Err(format!("invalid signature of key {fingerprint}: {reason}"));
|
|
}
|
|
}
|
|
|
|
// The canonical GPGME validity rule: accept a green signature, or one
|
|
// neither red nor errored (such a signature is valid although the key
|
|
// lacks full owner trust, which does not matter here: any locally known
|
|
// key is accepted). Everything else means the content does not match.
|
|
if summary.contains(SignatureSummary::GREEN)
|
|
|| (!summary.contains(SignatureSummary::RED) && signature.status().is_ok())
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
Err(format!(
|
|
"invalid signature of key {fingerprint}: the document was modified \
|
|
after signing, or the signature block is corrupt"
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn key_id_validation() {
|
|
assert!(validate_key_id("12345678").is_err()); // short: rejected
|
|
assert!(validate_key_id("1234567890ABCDEF").is_ok()); // long: warns
|
|
assert!(validate_key_id(&"a".repeat(40)).is_ok());
|
|
assert!(validate_key_id(&"b".repeat(64)).is_ok());
|
|
}
|
|
|
|
/// The deb822 body shared by the signing-layout fixtures below
|
|
const BODY: &str = "Format: 1.8\nSource: hello\nVersion: 1.0-1\n\n";
|
|
|
|
/// An unparseable armor body: enough shape for the layout split, but
|
|
/// never a signature gpg could verify
|
|
const FAKE_ARMOR: &str = "\
|
|
-----BEGIN PGP SIGNATURE-----
|
|
|
|
iQIzBAABCgAdFiEEfakesignatureblock
|
|
-----END PGP SIGNATURE-----
|
|
";
|
|
|
|
fn clearsigned_fixture() -> String {
|
|
format!("-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n{BODY}{FAKE_ARMOR}")
|
|
}
|
|
|
|
fn appended_fixture() -> String {
|
|
format!("{BODY}{FAKE_ARMOR}")
|
|
}
|
|
|
|
#[test]
|
|
fn split_detects_clearsigned_layout() {
|
|
let content = clearsigned_fixture();
|
|
assert_eq!(
|
|
split_signature(&content),
|
|
Some(SignatureLayout::Clearsigned(&content))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn split_detects_appended_signature_layout() {
|
|
let content = appended_fixture();
|
|
let Some(SignatureLayout::Detached { body, signature }) = split_signature(&content) else {
|
|
panic!("expected the appended-signature layout for: {content}");
|
|
};
|
|
// The body is the exact signed text: byte-for-byte everything
|
|
// before the signature marker
|
|
assert_eq!(body, BODY);
|
|
assert!(signature.starts_with("-----BEGIN PGP SIGNATURE-----"));
|
|
assert!(signature.ends_with("-----END PGP SIGNATURE-----\n"));
|
|
assert_eq!(format!("{body}{signature}"), content);
|
|
}
|
|
|
|
#[test]
|
|
fn split_rejects_unsigned_content() {
|
|
assert_eq!(split_signature("Format: 1.8\nSource: hello\n"), None);
|
|
}
|
|
|
|
/// Unsigned content is refused before any GPG context is created, so
|
|
/// this stays offline and keyring-independent
|
|
#[test]
|
|
fn verify_rejects_unsigned_content() {
|
|
let err = verify_changes_signature("Format: 1.8\nSource: hello\n")
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("not signed"), "unexpected: {err}");
|
|
}
|
|
|
|
/// Full verification round-trip in a throwaway GNUPGHOME: a generated
|
|
/// key signs a document in both layouts, then a tampered copy and an
|
|
/// empty keyring must be rejected with distinct errors.
|
|
///
|
|
/// Ignored by default: it shells out to `gpg` and generates keys (a few
|
|
/// seconds). Run it explicitly with `cargo test -- --ignored gpg`. It
|
|
/// never touches the real keyring: GNUPGHOME points at a temporary
|
|
/// directory for the whole run and is restored afterwards.
|
|
#[test]
|
|
#[ignore = "shells out to gpg and generates keys in a throwaway GNUPGHOME"]
|
|
fn verify_signed_documents_in_throwaway_gnupghome() {
|
|
let gpg_home = tempfile::tempdir().unwrap();
|
|
let empty_home = tempfile::tempdir().unwrap();
|
|
|
|
// SAFETY: the test runs alone (`-- --ignored` selects only it), and
|
|
// the previous value is saved and restored below: neither gpg nor
|
|
// gpgme ever see the user's real keyring.
|
|
let saved_home = std::env::var("GNUPGHOME").ok();
|
|
unsafe { std::env::set_var("GNUPGHOME", gpg_home.path()) };
|
|
|
|
let run_gpg = |args: &[&str]| {
|
|
let output = std::process::Command::new("gpg")
|
|
.args([
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode",
|
|
"loopback",
|
|
"--passphrase",
|
|
"",
|
|
])
|
|
.args(args)
|
|
.output()
|
|
.expect("the gpg binary must be installed");
|
|
assert!(
|
|
output.status.success(),
|
|
"gpg {args:?} failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
};
|
|
|
|
run_gpg(&[
|
|
"--quick-gen-key",
|
|
"pkh test <pkh-test@invalid>",
|
|
"ed25519",
|
|
"sign",
|
|
"2d",
|
|
]);
|
|
|
|
let scratch = gpg_home.path().join("document");
|
|
std::fs::write(&scratch, BODY).unwrap();
|
|
let plain = scratch.to_str().unwrap();
|
|
|
|
// Sign in both dpkg layouts: a clearsigned wrapper and a detached
|
|
// armor block appended after the plain body
|
|
run_gpg(&["--clearsign", "--output", &format!("{plain}.clear"), plain]);
|
|
run_gpg(&[
|
|
"--detach-sign",
|
|
"--armor",
|
|
"--output",
|
|
&format!("{plain}.sig"),
|
|
plain,
|
|
]);
|
|
let clearsigned = std::fs::read_to_string(format!("{plain}.clear")).unwrap();
|
|
let appended = format!(
|
|
"{}{}",
|
|
BODY,
|
|
std::fs::read_to_string(format!("{plain}.sig")).unwrap()
|
|
);
|
|
|
|
// A valid signature from any local key is accepted, in both layouts
|
|
verify_changes_signature(&clearsigned).unwrap();
|
|
verify_changes_signature(&appended).unwrap();
|
|
|
|
// A tampered body no longer matches its signature
|
|
let tampered = appended.replace("Source: hello", "Source: evil");
|
|
let err = verify_changes_signature(&tampered).unwrap_err().to_string();
|
|
assert!(err.contains("modified after signing"), "unexpected: {err}");
|
|
|
|
// So does a corrupt signature payload: the armor still parses but
|
|
// the signature packet can no longer be verified
|
|
let mut corrupt_lines: Vec<String> = clearsigned.lines().map(str::to_string).collect();
|
|
let block_begin = corrupt_lines
|
|
.iter()
|
|
.position(|line| line == SIGNATURE_BEGIN)
|
|
.expect("fixture must contain a signature block");
|
|
// The first base64 line of the signature packet, after the blank
|
|
// armor-header line
|
|
corrupt_lines[block_begin + 2].replace_range(..4, "AAAA");
|
|
let corrupt = corrupt_lines.join("\n");
|
|
let err = verify_changes_signature(&corrupt).unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("modified after signing")
|
|
|| err.contains("signature verification failed")
|
|
|| err.contains("no signature"),
|
|
"unexpected: {err}"
|
|
);
|
|
|
|
// The same valid document is refused when the signing key is not in
|
|
// the keyring, with the dedicated missing-key error
|
|
unsafe { std::env::set_var("GNUPGHOME", empty_home.path()) };
|
|
let err = verify_changes_signature(&clearsigned)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
err.contains("not in your local keyring"),
|
|
"unexpected: {err}"
|
|
);
|
|
|
|
match saved_home {
|
|
Some(home) => unsafe { std::env::set_var("GNUPGHOME", home) },
|
|
None => unsafe { std::env::remove_var("GNUPGHOME") },
|
|
}
|
|
}
|
|
}
|