Automatically import certificates from GnuPG's certificate store.

- This improves first impressions, as we'll present the same set of
    certificates that GnuPG knows, and also import the owner's
    certificates that are required to transparently use the keys using
    gpg-agent.

  - We keep it simple: we only do this for the default Sequoia and
    GnuPG state directories.  Further, We don't parse the GnuPG
    configuration file, we just scan GnuPG's default cert stores.

  - Fixes #489.
This commit is contained in:
Justus Winter 2024-12-15 13:32:32 +01:00
parent 367e71722f
commit 32e2d7743f
No known key found for this signature in database
GPG Key ID: 686F55B4AB2B3386
6 changed files with 389 additions and 0 deletions

3
Cargo.lock generated
View File

@ -3747,6 +3747,7 @@ dependencies = [
"clap_complete",
"culpa",
"dirs",
"filetime",
"fs_extra",
"futures-util",
"gethostname",
@ -3759,9 +3760,11 @@ dependencies = [
"reqwest",
"roff",
"rpassword",
"rusqlite",
"sequoia-autocrypt",
"sequoia-cert-store",
"sequoia-directories",
"sequoia-ipc",
"sequoia-keystore",
"sequoia-net",
"sequoia-openpgp",

View File

@ -32,6 +32,7 @@ maintenance = { status = "actively-developed" }
aho-corasick = "1"
buffered-reader = { version = "1.3.1", default-features = false, features = ["compression"] }
dirs = "5"
filetime = "0.2"
fs_extra = "1"
sequoia-directories = "0.1"
sequoia-openpgp = { version = "1.22", default-features = false, features = ["compression"] }
@ -48,6 +49,7 @@ indicatif = "0.17"
once_cell = "1.17"
reqwest = { version = ">=0.12, <0.13", features = ["hickory-dns", "stream"] }
sequoia-cert-store = { version = "0.6.0", default-features = false }
sequoia-ipc = { version = "0.35", default-features = false }
sequoia-keystore = { version = ">=0.5, <0.7" }
sequoia-wot = { version = "0.13.2", default-features = false }
tempfile = "3.1"
@ -56,6 +58,7 @@ tokio = { version = "1.13.1" }
toml_edit = { version = "0.22", default-features = false, features = ["display", "parse"] }
regex = "1"
rpassword = "7.0"
rusqlite = "0.31"
serde = { version = "1.0.137", features = ["derive"] }
terminal_size = ">=0.2.6, <0.5"
termcolor = "1.2.0"

6
NEWS
View File

@ -3,6 +3,12 @@
#+STARTUP: content hidestars
* Changes in 1.0.0
** New functionality
- `sq` now automatically imports certificates from GnuPG's
certificate store. Note: we only do this for the default Sequoia
and GnuPG state directories. Further, We don't parse the GnuPG
configuration file, we just scan GnuPG's default cert stores.
** Notable changes
- `sq cert list` now takes cert designators, like `--cert-email`
instead of `--email`.

364
src/compat.rs Normal file
View File

@ -0,0 +1,364 @@
//! Improves compatibility with legacy installations.
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags};
use sequoia_openpgp::{
self as openpgp,
cert::raw::{RawCert, RawCertParser},
crypto::hash::Digest,
parse::Parse,
types::HashAlgorithm,
};
use sequoia_cert_store::{
CertStore,
LazyCert,
StoreUpdate,
};
use crate::{
Sq,
print_error_chain,
};
/// Controls tracing in this module.
const TRACE: bool = false;
/// Computes the default home directory.
pub fn gnupghome() -> Result<PathBuf> {
platform! {
unix => {
Ok(dirs::home_dir()
.ok_or(anyhow::anyhow!("unsupported platform"))?
.join(".gnupg"))
},
windows => {
Err(anyhow::anyhow!("locating GnuPG's state on Windows \
is not supported"))
},
}
}
/// Syncs certificates from GnuPG.
pub fn sync_from_gnupg<'store>(sq: &Sq, cert_store: &CertStore<'store>)
-> Result<()> {
tracer!(TRACE, "sync_from_gnupg");
let overlay = if let Some(certd) = cert_store.certd() {
Overlay {
path: certd.certd().base_dir().into(),
}
} else {
t!("no certd in cert store");
return Ok(());
};
let home = match gnupghome() {
Ok(h) => h,
Err(e) => {
t!("locating GNUPGHOME failed: {}", e);
return Ok(());
},
};
let resources = [
Resource {
kind: Kind::Keybox,
path: home.join("pubring.kbx"),
},
Resource {
kind: Kind::Keyring,
path: home.join("pubring.gpg"),
},
Resource {
kind: Kind::KeyboxDB,
path: home.join("public-keys.d").join("pubring.db"),
},
];
for resource in resources {
if ! resource.path.exists() {
t!("{}: skipping non-existing resource", resource.path.display());
continue;
}
let f = fs::File::open(&resource.path);
let modified = match &f {
Ok(f) => Some(f.metadata()?.modified()?),
Err(_) => None,
};
t!("{}: last modified {:?}", resource.path.display(), modified);
// Get rid of sub-second precision, filetime doesn't seem
// to set them reliably on Linux.
let unix_time = |t: SystemTime| {
t.duration_since(UNIX_EPOCH).unwrap().as_secs()
};
if overlay.get_cached_mtime(&resource).ok()
.map(|cached| modified.map(unix_time) == Some(unix_time(cached)))
.unwrap_or(false)
{
// The overlay already contains all data from
// this resource.
t!("{}: skipping up-to-date resource", resource.path.display());
continue;
}
let certs = match resource.kind {
Kind::Keyring => {
initialize_keyring(sq, f?, &resource.path)
.with_context(|| format!(
"Reading the keyring {:?}", resource.path))
},
Kind::Keybox => {
initialize_keybox(sq, f?, &resource.path)
.with_context(|| format!(
"Reading the keybox {:?}", resource.path))
},
Kind::KeyboxX509 => {
t!("ignoring keybox {:?} only used fox X509",
resource.path);
Ok(Vec::new())
},
Kind::KeyboxDB => {
initialize_keybox_db(&resource.path)
.with_context(|| format!(
"{}: reading the keybox database",
resource.path.display()))
},
};
match certs {
Ok(certs) => {
for cert in certs.into_iter() {
let keyid = cert.keyid();
if let Err(err) = cert_store.update(Arc::new(cert)) {
if sq.verbose() {
let err = anyhow::Error::from(err)
.context(format!(
"Reading {} from {:?}",
keyid, resource.path));
print_error_chain(&err);
}
continue;
}
}
}
Err(err) => if sq.verbose() {
print_error_chain(&err);
},
}
if let Some(modified) = modified {
overlay.set_cached_mtime(&resource, modified)?;
}
}
Ok(())
}
#[derive(Clone)]
struct Resource {
kind: Kind,
path: PathBuf,
}
#[derive(PartialEq, Eq, Clone, Copy)]
#[allow(dead_code)]
pub enum Kind {
Keybox,
KeyboxX509,
KeyboxDB,
Keyring,
}
/// Initialize a keyring.
fn initialize_keyring<'store, P>(sq: &Sq, file: fs::File, path: P)
-> Result<Vec<LazyCert<'store>>>
where
P: AsRef<Path>,
{
tracer!(TRACE, "KeyDB::initialize_keyring");
let path = path.as_ref();
t!("loading keyring {:?}", path);
let results = {
let iter = match RawCertParser::from_reader(file) {
Ok(iter) => iter,
Err(err) => {
if sq.verbose() {
let err = anyhow::Error::from(err).context(
format!("Loading keyring {:?}", path));
print_error_chain(&err);
return Err(err);
} else {
return Err(err);
}
}
};
iter.filter_map(|cert| {
match cert {
Ok(cert) => Some(LazyCert::from(cert)),
Err(err) => {
if sq.verbose() {
let err = anyhow::Error::from(err).context(format!(
"While parsing cert from keyring {:?}", path));
print_error_chain(&err);
}
None
}
}
}).collect()
};
Ok(results)
}
/// Initialize a keybox.
fn initialize_keybox<'store, P>(sq: &Sq, file: fs::File, path: P)
-> Result<Vec<LazyCert<'store>>>
where
P: AsRef<Path>,
{
use sequoia_ipc::keybox::*;
tracer!(TRACE, "KeyDB::initialize_keybox");
let path = path.as_ref();
t!("loading keybox {:?}", path);
let iter = match Keybox::from_reader(file) {
Ok(iter) => iter,
Err(err) => {
if sq.verbose() {
let err = anyhow::Error::from(err).context(format!(
"While opening keybox at {:?}", path));
print_error_chain(&err);
return Err(err);
} else {
return Err(err);
}
}
};
let results = iter.filter_map(|record| {
let record = match record {
Ok(record) => record,
Err(err) => {
if sq.verbose() {
let err = anyhow::Error::from(err).context(format!(
"While parsing a record from keybox {:?}", path));
print_error_chain(&err);
}
return None;
}
};
if let KeyboxRecord::OpenPGP(record) = record {
match record.cert() {
Ok(cert) => Some(LazyCert::from(cert)),
Err(err) => {
if sq.verbose() {
let err = anyhow::Error::from(err).context(format!(
"While parsing a cert from keybox {:?}", path));
print_error_chain(&err);
}
None
}
}
} else {
None
}
}).collect();
Ok(results)
}
/// Initialize a keybox database.
fn initialize_keybox_db<'store, P>(path: P)
-> Result<Vec<LazyCert<'store>>>
where
P: AsRef<Path>,
{
tracer!(TRACE, "KeyDB::initialize_keybox_db");
let path = path.as_ref();
t!("loading keybox database at {}", path.display());
let conn = Connection::open_with_flags(
&path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let mut stmt = conn.prepare("SELECT keyblob \
FROM pubkey \
WHERE type = 1")?;
let certs = stmt.query_map([], |row| Ok(row.get::<_, Vec<u8>>(0)?))?
.filter_map(|bytes| {
let bytes = std::io::Cursor::new(bytes.ok()?);
let cert = RawCert::from_reader(bytes).ok()?;
t!("loaded {}", cert.fingerprint());
Some(cert.into())
})
.collect();
drop(stmt);
Ok(certs)
}
/// Stores metadata in the certd.
pub struct Overlay {
path: PathBuf,
}
impl Overlay {
pub fn path(&self) -> &Path {
&self.path
}
fn mtime_cache_path(&self, of: &Resource) -> PathBuf {
let mut hash = HashAlgorithm::SHA256.context()
.expect("MTI hash algorithm");
hash.update(of.path.to_string_lossy().as_bytes());
let name = format!(
"_sequoia_gpg_chameleon_mtime_{}",
openpgp::fmt::hex::encode(
hash.into_digest().expect("SHA2 is complete")));
self.path().join(name)
}
fn get_cached_mtime(&self, of: &Resource) -> Result<SystemTime> {
Ok(std::fs::metadata(self.mtime_cache_path(&of))?.modified()?)
}
fn set_cached_mtime(&self, of: &Resource, new: SystemTime)
-> Result<()> {
// Make sure the overlay exists. If we fail to create the
// directory, caching the mtime would fail anyway, and callers
// of this function expect a side-effect, so this seems like
// an okay place to do that.
std::fs::create_dir_all(self.path())?;
let p = self.mtime_cache_path(&of);
let f = tempfile::NamedTempFile::new_in(self.path())?;
filetime::set_file_mtime(f.path(), new.into())?;
f.persist(p)?;
Ok(())
}
}

View File

@ -46,6 +46,7 @@ use cli::types::Version;
use cli::types::paths::StateDirectory;
mod commands;
pub mod compat;
pub mod config;
pub mod output;
pub use output::Model;

View File

@ -295,6 +295,18 @@ impl<'store: 'rstore, 'rstore> Sq<'store, 'rstore> {
Box::new(keyring),
cert_store::AccessMode::Always);
// Sync certs from GnuPG's state if we are using the user's
// default home directory.
if self.home.as_ref().map(|h| h.is_default_location())
.unwrap_or(false)
&& std::env::var("GNUPGHOME").is_err()
{
if let Err(e) = crate::compat::sync_from_gnupg(self, &cert_store) {
self.info(format_args!(
"Syncing state from GnuPG failed: {}", e));
}
}
let cert_store = WotStore::from_store(
cert_store, self.policy, self.time);