diff options
author | Justus Winter <justus@sequoia-pgp.org> | 2018-11-26 17:24:47 +0100 |
---|---|---|
committer | Justus Winter <justus@sequoia-pgp.org> | 2018-11-26 19:50:41 +0100 |
commit | 02e61f0758e93b44a054a01b4137ea25ff7dd5ec (patch) | |
tree | b1ef6b097e5fc93faf20433219565bbabfa04fb5 /sqv | |
parent | 557aca35bad457622642308c1d780757b174bf50 (diff) |
sqv: Move sqv into a new crate.
- This allows us to use sequoia-openpgp without compression support
reducing binary size and trusted computing base.
Diffstat (limited to 'sqv')
-rw-r--r-- | sqv/Cargo.toml | 36 | ||||
-rw-r--r-- | sqv/Makefile | 29 | ||||
-rw-r--r-- | sqv/README.md | 8 | ||||
-rw-r--r-- | sqv/build.rs | 20 | ||||
-rw-r--r-- | sqv/make-usage.sh | 53 | ||||
-rw-r--r-- | sqv/src/sqv-usage.rs | 27 | ||||
-rw-r--r-- | sqv/src/sqv.rs | 320 | ||||
-rw-r--r-- | sqv/src/sqv_cli.rs | 47 | ||||
-rw-r--r-- | sqv/tests/bad-subkey.rs | 18 | ||||
-rw-r--r-- | sqv/tests/data/bad-subkey-keyring.pgp | bin | 0 -> 18064 bytes | |||
-rw-r--r-- | sqv/tests/data/bad-subkey-keyring.txt | 12 | ||||
-rw-r--r-- | sqv/tests/data/bad-subkey.txt | 1 | ||||
-rw-r--r-- | sqv/tests/data/bad-subkey.txt.sig | bin | 0 -> 310 bytes | |||
-rw-r--r-- | sqv/tests/duplicate-signatures.rs | 31 | ||||
-rw-r--r-- | sqv/tests/multiple-signatures.rs | 24 | ||||
-rw-r--r-- | sqv/tests/not-before-after.rs | 62 |
16 files changed, 688 insertions, 0 deletions
diff --git a/sqv/Cargo.toml b/sqv/Cargo.toml new file mode 100644 index 00000000..f9edb942 --- /dev/null +++ b/sqv/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "sequoia-sqv" +description = "A simple signature verification program" +version = "0.1.0" +authors = [ + "Justus Winter <justus@sequoia-pgp.org>", + "Kai Michaelis <kai@sequoia-pgp.org>", + "Neal H. Walfield <neal@sequoia-pgp.org>", +] +documentation = "https://docs.sequoia-pgp.org/sqv" +homepage = "https://sequoia-pgp.org/" +repository = "https://gitlab.com/sequoia-pgp/sequoia" +readme = "README.md" +keywords = ["cryptography", "openpgp", "pgp", "verification", "detached signatures"] +categories = ["cryptography", "command-line-utilities"] +license = "GPL-3.0" + +[badges] +gitlab = { repository = "https://gitlab.com/sequoia-pgp/sequoia" } +maintenance = { status = "actively-developed" } + +[dependencies] +sequoia-openpgp = { path = "../openpgp", version = "0.1", default-features = false } +clap = "2.32.0" +failure = "0.1.2" +time = "0.1.38" + +[build-dependencies] +clap = "2.27.1" + +[dev-dependencies] +assert_cli = "0.6" + +[[bin]] +name = "sqv" +path = "src/sqv-usage.rs" diff --git a/sqv/Makefile b/sqv/Makefile new file mode 100644 index 00000000..10df31b9 --- /dev/null +++ b/sqv/Makefile @@ -0,0 +1,29 @@ +# Configuration. +CARGO_TARGET_DIR ?= $(shell pwd)/../target +# We currently only support absolute paths. +CARGO_TARGET_DIR := $(abspath $(CARGO_TARGET_DIR)) +SQV ?= $(CARGO_TARGET_DIR)/debug/sqv + +# Tools. +CARGO ?= cargo +INSTALL ?= install + +all: src/sqv-usage.rs + +# Installation. +.PHONY: build-release +build-release: + CARGO_TARGET_DIR=$(CARGO_TARGET_DIR) \ + $(CARGO) build $(CARGO_FLAGS) --release --package sequoia-sqv + +.PHONY: install +install: build-release + $(INSTALL) -d $(DESTDIR)$(PREFIX)/bin + $(INSTALL) -t $(DESTDIR)$(PREFIX)/bin $(CARGO_TARGET_DIR)/release/sqv + +# Maintenance. +.PHONY: update-usage +update-usage: src/sqv-usage.rs + +src/sqv-usage.rs: make-usage.sh $(SQV) + sh make-usage.sh $(SQV) >$@ diff --git a/sqv/README.md b/sqv/README.md new file mode 100644 index 00000000..7cfb9c04 --- /dev/null +++ b/sqv/README.md @@ -0,0 +1,8 @@ +A simple signature verification program. + +`sqv` verifies detached OpenPGP signatures. It is a replacement for +`gpgv`. Unlike `gpgv`, it can take additional constraints on the +signature into account. + +See https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=872271 for +the motivation. diff --git a/sqv/build.rs b/sqv/build.rs new file mode 100644 index 00000000..290a1a6b --- /dev/null +++ b/sqv/build.rs @@ -0,0 +1,20 @@ +extern crate clap; + +use std::env; +use clap::Shell; + +mod sqv_cli { + include!("src/sqv_cli.rs"); +} + +fn main() { + let outdir = match env::var_os("OUT_DIR") { + None => return, + Some(outdir) => outdir, + }; + let mut sqv = sqv_cli::build(); + for shell in &[Shell::Bash, Shell::Fish, Shell::Zsh, Shell::PowerShell, + Shell::Elvish] { + sqv.gen_completions("sqv", *shell, &outdir); + } +} diff --git a/sqv/make-usage.sh b/sqv/make-usage.sh new file mode 100644 index 00000000..bec96386 --- /dev/null +++ b/sqv/make-usage.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +tool=$1 + +quote() { + sed 's@^@//! @' | sed 's/ $//' +} + +begin_code() { + printf '```text\n' +} + +end_code() { + printf '```\n' +} + +dump_help() { # subcommand, indention + if [ -z "$1" ] + then + printf "\n# Usage\n\n" + set "" "#" + else + printf "\n$2 Subcommand$1\n\n" + fi + + help="`$tool $1 --help`" + + begin_code + printf "$help\n" | tail -n +2 + end_code + + if echo $help | fgrep -q SUBCOMMANDS + then + printf "$help\n" | + sed -n '/^SUBCOMMANDS:/,$p' | + tail -n+2 | + while read subcommand desc + do + if [ "$subcommand" = help ]; then + continue + fi + + dump_help "$1 $subcommand" "#$2" + done + fi +} + +( + printf "A command-line frontend for Sequoia.\n" + dump_help +) | quote + +printf '\ninclude!("'"$(basename $tool)"'.rs");\n' diff --git a/sqv/src/sqv-usage.rs b/sqv/src/sqv-usage.rs new file mode 100644 index 00000000..d7e55716 --- /dev/null +++ b/sqv/src/sqv-usage.rs @@ -0,0 +1,27 @@ +//! A command-line frontend for Sequoia. +//! +//! # Usage +//! +//! ```text +//! sqv is a command-line OpenPGP signature verification tool. +//! +//! USAGE: +//! sqv [FLAGS] [OPTIONS] <SIG-FILE> <FILE> --keyring <FILE>... +//! +//! FLAGS: +//! -h, --help Prints help information +//! --trace Trace execution. +//! -V, --version Prints version information +//! +//! OPTIONS: +//! -r, --keyring <FILE>... A keyring. Can be given multiple times. +//! --not-after <YYYY-MM-DD> Consider signatures created after YYYY-MM-DD as invalid. Default: now +//! --not-before <YYYY-MM-DD> Consider signatures created before YYYY-MM-DD as invalid. Default: no constraint +//! -n, --signatures <N> The number of valid signatures to return success. Default: 1 +//! +//! ARGS: +//! <SIG-FILE> File containing the detached signature. +//! <FILE> File to verify. +//! ``` + +include!("sqv.rs"); diff --git a/sqv/src/sqv.rs b/sqv/src/sqv.rs new file mode 100644 index 00000000..567f6427 --- /dev/null +++ b/sqv/src/sqv.rs @@ -0,0 +1,320 @@ +/// A simple signature verification program. +/// +/// See https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=872271 for +/// the motivation. + +extern crate clap; +extern crate failure; +use failure::ResultExt; +extern crate time; + +extern crate sequoia_openpgp as openpgp; + +use std::process::exit; +use std::fs::File; +use std::collections::{HashMap, HashSet}; + +use openpgp::{TPK, Packet, packet::Signature, KeyID}; +use openpgp::constants::HashAlgorithm; +use openpgp::parse::{PacketParserResult, PacketParser}; +use openpgp::tpk::TPKParser; + +mod sqv_cli; + +fn real_main() -> Result<(), failure::Error> { + let matches = sqv_cli::build().get_matches(); + + let trace = matches.is_present("trace"); + + let good_threshold + = if let Some(good_threshold) = matches.value_of("signatures") { + match good_threshold.parse::<usize>() { + Ok(good_threshold) => good_threshold, + Err(err) => { + eprintln!("Value passed to --signatures must be numeric: \ + {} (got: {:?}).", + err, good_threshold); + exit(2); + }, + } + } else { + 1 + }; + if good_threshold < 1 { + eprintln!("Value passed to --signatures must be >= 1 (got: {:?}).", + good_threshold); + exit(2); + } + + let not_before = if let Some(t) = matches.value_of("not-before") { + Some(time::strptime(t, "%Y-%m-%d") + .context(format!("Bad value passed to --not-before: {:?}", t))?) + } else { + None + }; + let not_after = if let Some(t) = matches.value_of("not-after") { + Some(time::strptime(t, "%Y-%m-%d") + .context(format!("Bad value passed to --not-after: {:?}", t))?) + } else { + None + }.unwrap_or_else(|| time::now_utc()); + + // First, we collect the signatures and the alleged issuers. + // Then, we scan the keyrings exactly once to find the associated + // TPKs. + + // .unwrap() is safe, because "sig-file" is required. + let sig_file = matches.value_of_os("sig-file").unwrap(); + + let mut ppr = PacketParser::from_reader( + openpgp::Reader::from_file(sig_file)?)?; + + let mut sigs_seen = HashSet::new(); + let mut sigs : Vec<(Signature, KeyID, Option<TPK>)> = Vec::new(); + + // sig_i is count of all Signature packets that we've seen. This + // may be more than sigs.len() if we can't handle some of the + // sigs. + let mut sig_i = 0; + + while let PacketParserResult::Some(pp) = ppr { + let (packet, ppr_tmp) = pp.recurse().unwrap(); + ppr = ppr_tmp; + + match packet { + Packet::Signature(sig) => { + // To check for duplicates, we normalize the + // signature, and put it into the hashset of seen + // signatures. + let mut sig_normalized = sig.clone(); + sig_normalized.unhashed_area_mut().clear(); + if sigs_seen.replace(sig_normalized).is_some() { + eprintln!("Ignoring duplicate signature."); + continue; + } + + sig_i += 1; + if let Some(fp) = sig.issuer_fingerprint() { + if trace { + eprintln!("Will check signature allegedly issued by {}.", + fp); + } + + // XXX: We use a KeyID even though we have a + // fingerprint! + sigs.push((sig, fp.to_keyid(), None)); + } else if let Some(keyid) = sig.issuer() { + if trace { + eprintln!("Will check signature allegedly issued by {}.", + keyid); + } + + sigs.push((sig, keyid, None)); + } else { + eprintln!("Signature #{} does not contain information \ + about the issuer. Unable to validate.", + sig_i); + } + }, + Packet::CompressedData(_) => { + // Skip it. + }, + packet => { + eprintln!("OpenPGP message is not a detached signature. \ + Encountered unexpected packet: {:?} packet.", + packet.tag()); + exit(2); + } + } + } + + if sigs.len() == 0 { + eprintln!("{:?} does not contain an OpenPGP signature.", sig_file); + exit(2); + } + + + // Hash the content. + + // .unwrap() is safe, because "file" is required. + let file = matches.value_of_os("file").unwrap(); + let hash_algos : Vec<HashAlgorithm> + = sigs.iter().map(|&(ref sig, _, _)| sig.hash_algo()).collect(); + let hashes: HashMap<_, _> = + openpgp::crypto::hash_file(File::open(file)?, &hash_algos[..])? + .into_iter().collect(); + + fn tpk_has_key(tpk: &TPK, keyid: &KeyID) -> bool { + if *keyid == tpk.primary().keyid() { + return true; + } + for binding in tpk.subkeys() { + if *keyid == binding.subkey().keyid() { + return true; + } + } + false + } + + // Find the keys. + for filename in matches.values_of_os("keyring") + .expect("No keyring specified.") + { + // Load the keyring. + let tpks : Vec<TPK> = TPKParser::from_reader( + openpgp::Reader::from_file(filename)?)? + .unvalidated_tpk_filter(|tpk, _| { + for &(_, ref issuer, _) in &sigs { + if tpk_has_key(tpk, issuer) { + return true; + } + } + false + }) + .map(|tpkr| { + match tpkr { + Ok(tpk) => tpk, + Err(err) => { + eprintln!("Error reading keyring {:?}: {}", + filename, err); + exit(2); + } + } + }) + .collect(); + + for tpk in tpks { + for &mut (_, ref issuer, ref mut issuer_tpko) in sigs.iter_mut() { + if tpk_has_key(&tpk, issuer) { + if let Some(issuer_tpk) = issuer_tpko.take() { + if trace { + eprintln!("Found key {} again. Merging.", + issuer); + } + + *issuer_tpko + = issuer_tpk.merge(tpk.clone()).ok(); + } else { + if trace { + eprintln!("Found key {}.", issuer); + } + + *issuer_tpko = Some(tpk.clone()); + } + } + } + } + } + + // Verify the signatures. + let mut sigs_seen_from_tpk = HashSet::new(); + let mut good = 0; + 'sig_loop: for (mut sig, issuer, tpko) in sigs.into_iter() { + if trace { + eprintln!("Checking signature allegedly issued by {}.", issuer); + } + + if let Some(ref tpk) = tpko { + // Find the right key. + for (_, key) in tpk.keys() { + if issuer == key.keyid() { + let mut hash = match hashes.get(&sig.hash_algo()) { + Some(h) => h.clone(), + None => { + eprintln!("Cannot check signature, hash algorithm \ + {} not supported.", sig.hash_algo()); + continue 'sig_loop; + }, + }; + sig.hash(&mut hash); + + let mut digest = vec![0u8; hash.digest_size()]; + hash.digest(&mut digest); + let hash_algo = sig.hash_algo(); + sig.set_computed_hash(Some((hash_algo, digest))); + + match sig.verify(key) { + Ok(true) => { + if let Some(t) = sig.signature_creation_time() { + if let Some(not_before) = not_before { + if t < not_before { + eprintln!( + "Signature by {} was created before \ + the --not-before date.", + issuer); + break; + } + } + + if t > not_after { + eprintln!( + "Signature by {} was created after \ + the --not-after date.", + issuer); + break; + } + } else { + eprintln!( + "Signature by {} does not contain \ + information about the creation time.", + issuer); + break; + } + + if trace { + eprintln!("Signature by {} is good.", issuer); + } + + if sigs_seen_from_tpk.replace(tpk.fingerprint()) + .is_some() + { + eprintln!( + "Ignoring additional good signature by {}.", + issuer); + continue; + } + + println!("{}", tpk.primary().fingerprint()); + good += 1; + }, + Ok(false) => { + if trace { + eprintln!("Signature by {} is bad.", issuer); + } + }, + Err(err) => { + if trace { + eprintln!("Verifying signature: {}.", err); + } + }, + } + + break; + } + } + } else { + eprintln!("Can't verify signature by {}, missing key.", + issuer); + } + } + + if trace { + eprintln!("{} of {} signatures are valid (threshold is: {}).", + good, sig_i, good_threshold); + } + + exit(if good >= good_threshold { 0 } else { 1 }); +} + +fn main() { + if let Err(e) = real_main() { + let mut cause = e.as_fail(); + eprint!("{}", cause); + while let Some(c) = cause.cause() { + eprint!(":\n {}", c); + cause = c; + } + eprintln!(); + exit(2); + } +} diff --git a/sqv/src/sqv_cli.rs b/sqv/src/sqv_cli.rs new file mode 100644 index 00000000..b06382ab --- /dev/null +++ b/sqv/src/sqv_cli.rs @@ -0,0 +1,47 @@ +/// Command-line parser for sqv. +/// +/// If you change this file, please rebuild `sqv`, run `make -C tool +/// update-usage`, and commit the resulting changes to +/// `tool/src/sqv-usage.rs`. + +use clap::{App, Arg, AppSettings}; + +// The argument parser. +pub fn build() -> App<'static, 'static> { + App::new("sqv") + .version("0.1.0") + .about("sqv is a command-line OpenPGP signature verification tool.") + .setting(AppSettings::ArgRequiredElseHelp) + .arg(Arg::with_name("keyring").value_name("FILE") + .help("A keyring. Can be given multiple times.") + .long("keyring") + .short("r") + .required(true) + .takes_value(true) + .number_of_values(1) + .multiple(true)) + .arg(Arg::with_name("signatures").value_name("N") + .help("The number of valid signatures to return success. Default: 1") + .long("signatures") + .short("n") + .takes_value(true)) + .arg(Arg::with_name("not-before").value_name("YYYY-MM-DD") + .help("Consider signatures created before YYYY-MM-DD as invalid. \ + Default: no constraint") + .long("not-before") + .takes_value(true)) + .arg(Arg::with_name("not-after").value_name("YYYY-MM-DD") + .help("Consider signatures created after YYYY-MM-DD as invalid. \ + Default: now") + .long("not-after") + .takes_value(true)) + .arg(Arg::with_name("sig-file").value_name("SIG-FILE") + .help("File containing the detached signature.") + .required(true)) + .arg(Arg::with_name("file").value_name("FILE") + .help("File to verify.") + .required(true)) + .arg(Arg::with_name("trace") + .help("Trace execution.") + .long("trace")) +} diff --git a/sqv/tests/bad-subkey.rs b/sqv/tests/bad-subkey.rs new file mode 100644 index 00000000..6a6b7fc7 --- /dev/null +++ b/sqv/tests/bad-subkey.rs @@ -0,0 +1,18 @@ +extern crate assert_cli; + +#[cfg(test)] +mod integration { + use std::path; + + use assert_cli::Assert; + + #[test] + fn bad_subkey() { + Assert::cargo_binary("sqv") + .current_dir(path::Path::new("tests").join("data")) + .with_args(&["-r", "bad-subkey-keyring.pgp", + "bad-subkey.txt.sig", "bad-subkey.txt"]) + .stdout().is("8F17 7771 18A3 3DDA 9BA4 8E62 AACB 3243 6300 52D9") + .unwrap(); + } +} diff --git a/sqv/tests/data/bad-subkey-keyring.pgp b/sqv/tests/data/bad-subkey-keyring.pgp Binary files differnew file mode 100644 index 00000000..72acf4de --- /dev/null +++ b/sqv/tests/data/bad-subkey-keyring.pgp diff --git a/sqv/tests/data/bad-subkey-keyring.txt b/sqv/tests/data/bad-subkey-keyring.txt new file mode 100644 index 00000000..d98d157b --- /dev/null +++ b/sqv/tests/data/bad-subkey-keyring.txt @@ -0,0 +1,12 @@ +This key keyring contains two keys in the following order: "Justus", +"Neal". + +Justus's key includes all of Neal's subkeys. When Justus's key is +canonicalized, Neal's subkeys should be dropped. + +If an application looks for Neal's signing subkey and either doesn't +validate the keys or only filters on the unvalidated keys, then it +will not find the right key. + +This was fixed in sqv in commit +1d63e71a839bf68f50cb7f4c1942f0d0b1eccfca. diff --git a/sqv/tests/data/bad-subkey.txt b/sqv/tests/data/bad-subkey.txt new file mode 100644 index 00000000..257cc564 --- /dev/null +++ b/sqv/tests/data/bad-subkey.txt @@ -0,0 +1 @@ +foo diff --git a/sqv/tests/data/bad-subkey.txt.sig b/sqv/tests/data/bad-subkey.txt.sig Binary files differnew file mode 100644 index 00000000..65fc578c --- /dev/null +++ b/sqv/tests/data/bad-subkey.txt.sig diff --git a/sqv/tests/duplicate-signatures.rs b/sqv/tests/duplicate-signatures.rs new file mode 100644 index 00000000..691afb50 --- /dev/null +++ b/sqv/tests/duplicate-signatures.rs @@ -0,0 +1,31 @@ +extern crate assert_cli; + +use assert_cli::Assert; + +fn p(filename: &str) -> String { + format!("../openpgp/tests/data/{}", filename) +} + +/// Asserts that duplicate signatures are properly ignored. +#[test] +fn ignore_duplicates() { + // Duplicate is ignored, but remaining one is ok. + Assert::cargo_binary("sqv") + .with_args( + &["-r", + &p("keys/emmelie-dorothea-dina-samantha-awina-ed25519.pgp"), + &p("messages/a-cypherpunks-manifesto.txt.ed25519.sig.duplicated"), + &p("messages/a-cypherpunks-manifesto.txt")]) + .unwrap(); + + // Duplicate is ignored, and fails to meet the threshold. + Assert::cargo_binary("sqv") + .with_args( + &["-r", + &p("keys/emmelie-dorothea-dina-samantha-awina-ed25519.pgp"), + "--signatures=2", + &p("messages/a-cypherpunks-manifesto.txt.ed25519.sig.duplicated"), + &p("messages/a-cypherpunks-manifesto.txt")]) + .fails() + .unwrap(); +} diff --git a/sqv/tests/multiple-signatures.rs b/sqv/tests/multiple-signatures.rs new file mode 100644 index 00000000..a2bff844 --- /dev/null +++ b/sqv/tests/multiple-signatures.rs @@ -0,0 +1,24 @@ +extern crate assert_cli; + +use assert_cli::Assert; + +fn p(filename: &str) -> String { + format!("../openpgp/tests/data/{}", filename) +} + +/// Asserts that multiple signatures from the same TPK are properly +/// ignored. +#[test] +fn ignore_multiple_signatures() { + // Multiple signatures from the same TPK are ignored, and fails to + // meet the threshold. + Assert::cargo_binary("sqv") + .with_args( + &["-r", + &p("keys/emmelie-dorothea-dina-samantha-awina-ed25519.pgp"), + "--signatures=2", + &p("messages/a-cypherpunks-manifesto.txt.ed25519.sig.duplicated"), + &p("messages/a-cypherpunks-manifesto.txt")]) + .fails() + .unwrap(); +} diff --git a/sqv/tests/not-before-after.rs b/sqv/tests/not-before-after.rs new file mode 100644 index 00000000..5be41bf0 --- /dev/null +++ b/sqv/tests/not-before-after.rs @@ -0,0 +1,62 @@ +extern crate assert_cli; + +#[cfg(test)] +mod integration { + use assert_cli::Assert; + + fn p(filename: &str) -> String { + format!("../openpgp/tests/data/{}", filename) + } + + #[test] + fn unconstrained() { + Assert::cargo_binary("sqv") + .with_args( + &["-r", + &p("keys/emmelie-dorothea-dina-samantha-awina-ed25519.pgp"), + &p("messages/a-cypherpunks-manifesto.txt.ed25519.sig"), + & |