Support addressing recipients by email address and User ID

- Extend `sq encrypt` with the `--recipient-email` and
    `--recipient-userid` arguments to allow the caller to designate a
    certificate by email address or User ID, respectively.  An email
    address or User ID is considered to designate a certificate, if
    the binding between the email address or User ID and the
    certificate can be authenticated using the web of trust.

  - Add support for the web of trust using the `sequoia-wot` crate.

  - Add a top-level option, `--trust-root`, to allow the user to
    specify trust roots.
This commit is contained in:
Neal H. Walfield 2023-03-18 21:03:16 +01:00
parent 62e6b4cb8b
commit 6c7b0de5c0
No known key found for this signature in database
GPG Key ID: 6863C9AD5B4D22D3
7 changed files with 561 additions and 4 deletions

74
Cargo.lock generated
View File

@ -477,6 +477,16 @@ dependencies = [
"os_str_bytes", "os_str_bytes",
] ]
[[package]]
name = "clap_mangen"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc872a4bca8ddf10be882b81d36f1c2817e43c5c59862ac25f401af581dc4181"
dependencies = [
"clap 4.0.32",
"roff",
]
[[package]] [[package]]
name = "cmac" name = "cmac"
version = "0.5.1" version = "0.5.1"
@ -818,6 +828,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "dot-writer"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d1b11bd5e7e98406c6ff39fbc94d6e910a489b978ce7f17c19fce91a1195b7a"
[[package]] [[package]]
name = "dyn-clone" name = "dyn-clone"
version = "1.0.5" version = "1.0.5"
@ -920,6 +936,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "enumber"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa35b49b30d8f4219e279f22c4b7c899aa7f98f475da4eff84b75f17ba11ed19"
dependencies = [
"quote",
"syn",
]
[[package]] [[package]]
name = "env_logger" name = "env_logger"
version = "0.9.0" version = "0.9.0"
@ -2821,6 +2847,20 @@ dependencies = [
"xxhash-rust", "xxhash-rust",
] ]
[[package]]
name = "sequoia-policy-config"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f41f8b29fdc21666e6a49d7d7a9c4396f83b11052de0e5434b35aebf302075"
dependencies = [
"anyhow",
"chrono",
"sequoia-openpgp",
"serde",
"thiserror",
"toml",
]
[[package]] [[package]]
name = "sequoia-sq" name = "sequoia-sq"
version = "0.28.0" version = "0.28.0"
@ -2843,6 +2883,7 @@ dependencies = [
"sequoia-cert-store", "sequoia-cert-store",
"sequoia-net", "sequoia-net",
"sequoia-openpgp", "sequoia-openpgp",
"sequoia-wot",
"serde", "serde",
"serde_json", "serde_json",
"subplot-build", "subplot-build",
@ -2852,6 +2893,30 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "sequoia-wot"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb08c5484e6265aa8cd19d523c09e88f575fc040bf9e6dfdbbeeff3ed0ba07d0"
dependencies = [
"anyhow",
"chrono",
"clap 4.0.32",
"clap_complete",
"clap_mangen",
"crossbeam",
"dot-writer",
"enumber",
"lazy_static",
"num_cpus",
"openpgp-cert-d",
"sequoia-cert-store",
"sequoia-openpgp",
"sequoia-policy-config",
"thiserror",
"tokio",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.137" version = "1.0.137"
@ -3430,6 +3495,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.1" version = "0.3.1"

View File

@ -34,13 +34,13 @@ dirs = "4"
sequoia-openpgp = { version = "1.13", default-features = false, features = ["compression-deflate"] } sequoia-openpgp = { version = "1.13", default-features = false, features = ["compression-deflate"] }
sequoia-autocrypt = { version = "0.25", default-features = false, optional = true } sequoia-autocrypt = { version = "0.25", default-features = false, optional = true }
sequoia-net = { version = "0.26", default-features = false } sequoia-net = { version = "0.26", default-features = false }
#sequoia-wot = { version = "0.6" }
anyhow = "1.0.18" anyhow = "1.0.18"
chrono = "0.4.10" chrono = "0.4.10"
clap = { version = "4", features = ["derive", "env", "wrap_help"] } clap = { version = "4", features = ["derive", "env", "wrap_help"] }
itertools = "0.10" itertools = "0.10"
once_cell = "1.17" once_cell = "1.17"
sequoia-cert-store = "0.2" sequoia-cert-store = "0.2"
sequoia-wot = "0.7"
tempfile = "3.1" tempfile = "3.1"
term_size = "0.3" term_size = "0.3"
tokio = { version = "1.13.1" } tokio = { version = "1.13.1" }

5
NEWS
View File

@ -34,6 +34,11 @@
option replaces the various subcommand's `--time` argument as option replaces the various subcommand's `--time` argument as
well as `sq key generate` and `sq key userid add`'s well as `sq key generate` and `sq key userid add`'s
`--creation-time` arguments. `--creation-time` arguments.
- Add top-level option, `--trust-root`, to allow the user to
specify trust roots.
- Extend `sq encrypt` to allow addressing recipients by User ID
(`--recipient-userid`) or email address (`--recipient-email`).
Only User IDs that can be fully authenticated are considered.
* Deprecated functionality * Deprecated functionality
- `sq key generate --creation-time TIME` is deprecated in favor of - `sq key generate --creation-time TIME` is deprecated in favor of
`sq key generate --time TIME`. `sq key generate --time TIME`.

249
src/sq.rs
View File

@ -27,6 +27,7 @@ use openpgp::{
}; };
use openpgp::{armor, Cert}; use openpgp::{armor, Cert};
use openpgp::crypto::Password; use openpgp::crypto::Password;
use openpgp::Fingerprint;
use openpgp::packet::prelude::*; use openpgp::packet::prelude::*;
use openpgp::parse::{Parse, PacketParser, PacketParserResult}; use openpgp::parse::{Parse, PacketParser, PacketParserResult};
use openpgp::packet::signature::subpacket::NotationData; use openpgp::packet::signature::subpacket::NotationData;
@ -35,10 +36,15 @@ use openpgp::serialize::{Serialize, stream::{Message, Armorer}};
use openpgp::cert::prelude::*; use openpgp::cert::prelude::*;
use openpgp::policy::StandardPolicy as P; use openpgp::policy::StandardPolicy as P;
use openpgp::types::KeyFlags; use openpgp::types::KeyFlags;
use openpgp::types::RevocationStatus;
use sequoia_cert_store as cert_store; use sequoia_cert_store as cert_store;
use cert_store::Store; use cert_store::Store;
use cert_store::store::StoreError; use cert_store::store::StoreError;
use cert_store::store::UserIDQueryParams;
use sequoia_wot as wot;
use wot::store::Store as _;
use clap::FromArgMatches; use clap::FromArgMatches;
use crate::sq_cli::packet; use crate::sq_cli::packet;
@ -572,9 +578,9 @@ impl<'store> Config<'store> {
if let Some(keyflags) = keyflags.as_ref() { if let Some(keyflags) = keyflags.as_ref() {
certs.retain(|cert| { certs.retain(|cert| {
// XXX: Respect any subcommand-specific let vc = match cert.with_policy(
// reference time. &self.policy, self.time)
let vc = match cert.with_policy(&self.policy, None) { {
Ok(vc) => vc, Ok(vc) => vc,
Err(err) => { Err(err) => {
let err = err.context( let err = err.context(
@ -636,6 +642,235 @@ impl<'store> Config<'store> {
Ok(results) Ok(results)
} }
/// Looks up certificates by User ID or email address.
///
/// This only returns certificates that can be authenticate for
/// the specified User ID (or email address, if `email` is true).
/// If no certificate can be authenticated for some User ID,
/// returns an error. If multiple certificates can be
/// authenticated for a given User ID or email address, then
/// returns them all.
fn lookup_by_userid(&self, trust_roots: &[Fingerprint],
userid: &[String], email: bool)
-> Result<Vec<Cert>>
{
if userid.is_empty() {
return Ok(Vec::new())
}
let cert_store = self.cert_store_or_else()?;
// Build a WoT network.
let cert_store = wot::store::CertStore::from_store(
cert_store, &self.policy, self.time);
let n = wot::Network::new(&cert_store)?;
let mut q = wot::QueryBuilder::new(&n);
q.roots(wot::Roots::new(trust_roots.iter().cloned()));
let q = q.build();
let mut results: Vec<Cert> = Vec::new();
// We try hard to not just stop at the first error, but lint
// the input so that the user gets as much feedback as
// possible. The first error that we encounter is saved here,
// and returned. The rest are printed directly.
let mut error: Option<anyhow::Error> = None;
// Iterate over each User ID address, find any certificates
// associated with the User ID, validate the certificates, and
// finally authenticate them for the User ID.
for userid in userid.iter() {
let matches: Vec<(Fingerprint, UserID)> = if email {
if let Err(err) = UserIDQueryParams::is_email(userid) {
eprintln!("{:?} is not a valid email address", userid);
if error.is_none() {
error = Some(err);
}
continue;
}
// Get all certificates that are associated with the email
// address.
cert_store.lookup_synopses_by_email(userid)
} else {
let userid = UserID::from(&userid[..]);
cert_store.lookup_synopses_by_userid(userid.clone())
.into_iter()
.map(|fpr| (fpr, userid.clone()))
.collect()
};
if matches.is_empty() {
if error.is_none() {
error = Some(anyhow::anyhow!(
"No certificates are associated with {:?}",
userid));
}
continue;
}
struct Entry {
fpr: Fingerprint,
userid: UserID,
cert: Result<Cert>,
}
let entries = matches.into_iter().map(|(fpr, userid)| {
// We've got a match, or two, or three... Lookup the certs.
let cert = match cert_store.lookup_by_cert_fpr(&fpr) {
Ok(cert) => cert,
Err(err) => {
let err = err.context(format!(
"Error fetching {} ({:?})",
fpr, String::from_utf8_lossy(userid.value())));
return Entry { fpr, userid, cert: Err(err), };
}
};
// Parse the LazyCerts.
let cert = match cert.into_owned().into_cert() {
Ok(cert) => cert,
Err(err) => {
let err = err.context(format!(
"Error parsing {} ({:?})",
fpr, String::from_utf8_lossy(userid.value())));
return Entry { fpr, userid, cert: Err(err), };
}
};
// Check the certs for validity.
let vc = match cert.with_policy(&self.policy, self.time) {
Ok(vc) => vc,
Err(err) => {
let err = err.context(format!(
"Certificate {} ({:?}) is invalid",
fpr, String::from_utf8_lossy(userid.value())));
return Entry { fpr, userid, cert: Err(err) };
}
};
if let Err(err) = vc.alive() {
let err = err.context(format!(
"Certificate {} ({:?}) is invalid",
fpr, String::from_utf8_lossy(userid.value())));
return Entry { fpr, userid, cert: Err(err), };
}
if let RevocationStatus::Revoked(_) = vc.revocation_status() {
let err = anyhow::anyhow!(
"Certificate {} ({:?}) is revoked",
fpr, String::from_utf8_lossy(userid.value()));
return Entry { fpr, userid, cert: Err(err), };
}
if let Some(ua) = vc.userids().find(|ua| {
ua.userid() == &userid
})
{
if let RevocationStatus::Revoked(_) = ua.revocation_status() {
let err = anyhow::anyhow!(
"User ID {:?} on certificate {} is revoked",
String::from_utf8_lossy(userid.value()), fpr);
return Entry { fpr, userid, cert: Err(err), };
}
}
// Authenticate the bindings.
let paths = q.authenticate(
&userid, cert.fingerprint(),
// XXX: Make this user configurable.
wot::FULLY_TRUSTED);
let r = if paths.amount() < wot::FULLY_TRUSTED {
Err(anyhow::anyhow!(
"{}, {:?} cannot be authenticated at the \
required level ({} of {}). After checking \
that {} really controls {}, you could certify \
their certificate by running \
`sq certify MY_KEY.pgp {} {}`.",
cert.fingerprint(),
String::from_utf8_lossy(userid.value()),
paths.amount(), wot::FULLY_TRUSTED,
String::from_utf8_lossy(userid.value()),
cert.fingerprint(),
cert.fingerprint(),
String::from_utf8_lossy(userid.value())))
} else {
Ok(cert)
};
Entry { fpr, userid, cert: r, }
});
// Partition into good (successfully authenticated) and
// bad (an error occurred).
let (good, bad): (Vec<Entry>, _)
= entries.partition(|entry| entry.cert.is_ok());
if good.is_empty() {
// We've only got errors.
let err = if bad.is_empty() {
// We got nothing :/.
if email {
anyhow::anyhow!(
"No known certificates have the email address {:?}",
userid)
} else {
anyhow::anyhow!(
"No known certificates have the User ID {:?}",
userid)
}
} else {
if email {
anyhow::anyhow!(
"None of the certificates with the email \
address {:?} can be authenticated using \
the configured trust model",
userid)
} else {
anyhow::anyhow!(
"None of the certificates with the User ID \
{:?} can be authenticated using \
the configured trust model",
userid)
}
};
eprintln!("{:?}:\n", err);
if error.is_none() {
error = Some(err);
}
// Print the errors.
for (i, Entry { fpr, userid, cert }) in bad.into_iter().enumerate() {
eprintln!("{}. When considering {} ({}):",
i + 1, fpr,
String::from_utf8_lossy(userid.value()));
let err = match cert {
Ok(_) => unreachable!(),
Err(err) => err,
};
print_error_chain(&err);
}
} else {
// We have at least one authenticated certificate.
// Silently ignore any errors.
results.extend(
good.into_iter().filter_map(|Entry { cert, .. }| {
cert.ok()
}));
}
}
if let Some(error) = error {
Err(error)
} else {
Ok(results)
}
}
/// Looks up a certificate. /// Looks up a certificate.
/// ///
/// Like `lookup`, but looks up a certificate, which must be /// Like `lookup`, but looks up a certificate, which must be
@ -765,6 +1000,14 @@ fn main() -> Result<()> {
true, true,
false) false)
.context("--recipient-cert")?); .context("--recipient-cert")?);
recipients.extend(
config.lookup_by_userid(&c.trust_roots,
&command.recipients_email, true)
.context("--recipient-email")?);
recipients.extend(
config.lookup_by_userid(&c.trust_roots,
&command.recipients_userid, false)
.context("--recipient-userid")?);
let mut input = open_or_stdin(command.io.input.as_deref())?; let mut input = open_or_stdin(command.io.input.as_deref())?;
let output = config.create_or_stdout_pgp( let output = config.create_or_stdout_pgp(

View File

@ -45,6 +45,21 @@ pub struct Command {
help = "Emits binary data", help = "Emits binary data",
)] )]
pub binary: bool, pub binary: bool,
#[clap(
long = "recipient-email",
value_name = "EMAIL",
help = "Encrypts to all certificates that can be authenticated \
for the specified email address",
)]
pub recipients_email: Vec<String>,
#[clap(
long = "recipient-userid",
value_name = "USERID",
help = "Encrypts to all certificates that can be authenticated \
for the specified User ID",
)]
pub recipients_userid: Vec<String>,
#[clap( #[clap(
long = "recipient-cert", long = "recipient-cert",
value_name = "FINGERPRINT|KEYID", value_name = "FINGERPRINT|KEYID",
@ -57,6 +72,7 @@ pub struct Command {
help = "Encrypts to all certificates in CERT_RING_FILE", help = "Encrypts to all certificates in CERT_RING_FILE",
)] )]
pub recipients_file: Vec<String>, pub recipients_file: Vec<String>,
#[clap( #[clap(
long = "signer-file", long = "signer-file",
value_name = "KEY_FILE", value_name = "KEY_FILE",

View File

@ -6,6 +6,9 @@ use clap::{Command, CommandFactory, Parser, Subcommand};
#[cfg(feature = "autocrypt")] #[cfg(feature = "autocrypt")]
pub mod autocrypt; pub mod autocrypt;
use sequoia_openpgp as openpgp;
use openpgp::Fingerprint;
pub mod armor; pub mod armor;
pub mod certify; pub mod certify;
pub mod dane; pub mod dane;
@ -149,6 +152,15 @@ $ sq --time 20130721T0550+0200 verify msg.pgp
", ",
)] )]
pub time: Option<String>, pub time: Option<String>,
#[clap(
long = "trust-root",
value_name = "FINGERPRINT|KEYID",
help = "Considers the specified certificate to be a trust root",
long_help = "Considers the specified certificate to be a trust root. \
Trust roots are used by trust models, e.g., the web of \
trust, to authenticate certificates and User IDs."
)]
pub trust_roots: Vec<Fingerprint>,
#[clap(subcommand)] #[clap(subcommand)]
pub subcommand: SqSubcommands, pub subcommand: SqSubcommands,
} }

View File

@ -87,4 +87,211 @@ mod integration {
Ok(()) Ok(())
} }
#[test]
fn sq_encrypt_recipient_userid() -> Result<()>
{
let dir = TempDir::new()?;
let certd = dir.path().join("cert.d").display().to_string();
std::fs::create_dir(&certd).expect("mkdir works");
let alice_pgp = dir.path().join("alice.pgp").display().to_string();
let bob_pgp = dir.path().join("bob.pgp").display().to_string();
// Generate the keys.
let mut cmd = Command::cargo_bin("sq")?;
cmd.args(["--cert-store", &certd,
"key", "generate",
"--expires", "never",
"--userid", "<alice@example.org>",
"--export", &alice_pgp]);
cmd.assert().success();
let alice = Cert::from_file(&alice_pgp)?;
let bob_userids = &[
"<bob@some.org>",
"Bob <bob@other.org>",
"<bob@other.org>",
];
let bob_emails = &[
"bob@some.org",
"bob@other.org",
];
let bob_certified_userids = &[
"Bob <bob@other.org>",
];
let bob_certified_emails = &[
"bob@other.org",
];
let mut cmd = Command::cargo_bin("sq")?;
cmd.args(["--cert-store", &certd,
"key", "generate",
"--expires", "never",
"--export", &bob_pgp]);
for userid in bob_userids.iter() {
cmd.args(["--userid", userid]);
}
cmd.assert().success();
let bob = Cert::from_file(&bob_pgp)?;
// Import the certificates.
let mut cmd = Command::cargo_bin("sq")?;
cmd.args(["--cert-store", &certd,
"import", &alice_pgp]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("sq")?;
cmd.args(["--cert-store", &certd,
"import", &bob_pgp]);
cmd.assert().success();
const MESSAGE: &[u8] = &[0x42; 24 * 1024 + 23];
let encrypt = |trust_roots: &[&str],
recipients: &[(&str, &str)],
decryption_keys: &[&str]|
{
let mut cmd = Command::cargo_bin("sq").unwrap();
cmd.args(["--cert-store", &certd]);
for trust_root in trust_roots {
cmd.args(["--trust-root", trust_root]);
}
cmd.arg("encrypt");
// Make a string for debugging.
let mut cmd_display = "sq encrypt".to_string();
for (option, recipient) in recipients.iter() {
cmd.args([option, recipient]);
cmd_display.push_str(" ");
cmd_display.push_str(option);
cmd_display.push_str(" ");
cmd_display.push_str(recipient);
}
cmd.write_stdin(MESSAGE);
let output = cmd.output().expect("success");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if decryption_keys.is_empty() {
assert!(! output.status.success(),
"'{}' should have failed\nstdout:\n{}\nstderr:\n{}",
cmd_display, stdout, stderr);
} else {
assert!(output.status.success(),
"'{}' should have succeeded\nstdout:\n{}\nstderr:\n{}",
cmd_display, stdout, stderr);
for key in decryption_keys.iter() {
let mut cmd = Command::cargo_bin("sq").unwrap();
cmd.args(["--no-cert-store",
"decrypt",
"--recipient-file",
&key])
.write_stdin(stdout.as_bytes());
let output = cmd.output().expect("success");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(),
"'{}' decryption should succeed\nstdout:\n{}\nstderr:\n{}",
cmd_display, stdout, stderr);
}
}
};
// Encryption by fingerprint should work.
encrypt(&[],
&[("--recipient-cert", &bob.fingerprint().to_string())],
&[&bob_pgp]);
// Encryption by email address and user id should fail if the
// binding can't be authenticated.
for email in bob_emails.iter() {
encrypt(&[],
&[("--recipient-email", email)],
&[]);
}
for userid in bob_userids.iter() {
encrypt(&[],
&[("--recipient-userid", userid)],
&[]);
}
// Alice certifies Bob's certificate.
for userid in bob_certified_userids {
let mut cmd = Command::cargo_bin("sq")?;
cmd.args(["--cert-store", &certd,
"certify", &alice_pgp, &bob_pgp, userid]);
let output = cmd.output().expect("success");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(),
"'sq certify {} ...' should have succeeded\
\nstdout:\n{}\nstderr:\n{}",
userid, stdout, stderr);
let mut cmd = Command::cargo_bin("sq")?;
cmd.args(["--cert-store", &certd,
"import"])
.write_stdin(stdout.as_bytes());
cmd.assert().success();
}
// Still don't use a trust root. This should still fail.
for email in bob_emails.iter() {
encrypt(&[],
&[("--recipient-email", email)],
&[]);
}
for userid in bob_userids.iter() {
encrypt(&[],
&[("--recipient-userid", userid)],
&[]);
}
// Make Alice the trust root. This should succeed.
for email in bob_emails.iter() {
if bob_certified_emails.contains(email) {
encrypt(&[&alice.fingerprint().to_string()],
&[("--recipient-email", email)],
&[ &bob_pgp ]);
} else {
encrypt(&[&alice.fingerprint().to_string()],
&[("--recipient-email", email)],
&[]);
}
}
for userid in bob_userids.iter() {
if bob_certified_userids.contains(userid) {
encrypt(&[&alice.fingerprint().to_string()],
&[("--recipient-userid", userid)],
&[ &bob_pgp ]);
} else {
encrypt(&[&alice.fingerprint().to_string()],
&[("--recipient-userid", userid)],
&[]);
}
}
// Make Bob a trust root. This should succeed for all
// self-signed user ids.
for email in bob_emails.iter() {
encrypt(&[&bob.fingerprint().to_string()],
&[("--recipient-email", email)],
&[&bob_pgp]);
}
for userid in bob_userids.iter() {
encrypt(&[&bob.fingerprint().to_string()],
&[("--recipient-userid", userid)],
&[&bob_pgp]);
}
Ok(())
}
} }