sett 0.4.0

Rust port of sett (data compression, encryption and transfer tool).
Documentation
//! OpenPGP private key store.

use sequoia_keystore::{sequoia_directories, sequoia_ipc::IPCPolicy};
use tracing::{debug, trace, warn};

use super::{
    cert::{Cert, Fingerprint},
    error,
};

/// Store for OpenPGP private keys.
pub struct KeyStore {
    pub(crate) inner: sequoia_keystore::Keystore,
}

impl KeyStore {
    /// Environment variable to override the default key store location.
    const ENV: &'static str = "SETT_KEYSTORE";

    /// Returns the default path for the key store.
    pub fn default_location() -> Result<std::path::PathBuf, error::PgpError> {
        Ok(sequoia_directories::Home::new(None)
            .map_err(error::PgpError::from)?
            .data_dir(sequoia_directories::Component::Keystore))
    }

    /// Opens a key store from disk.
    ///
    /// If `path` is not `None`, the specified location is used. Otherwise,
    /// if the `SETT_KEYSTORE` environment variable is set, its value is used.
    /// It defaults to the data directory defined by the `sequoia_directories`
    /// crate.
    pub async fn open(path: Option<&std::path::Path>) -> Result<Self, error::PgpError> {
        let c = sequoia_keystore::Context::configure()
            .home(if let Some(p) = path {
                p.to_path_buf()
            } else if let Some(p) = std::env::var_os(Self::ENV) {
                p.into()
            } else {
                Self::default_location()?
            })
            .ipc_policy(IPCPolicy::Robust)
            .build()
            .map_err(error::PgpError::from)?;
        let mut keystore = Self {
            inner: sequoia_keystore::Keystore::connect(&c).map_err(error::PgpError::from)?,
        };

        if let Err(error) = keystore.migrate().await {
            tracing::warn!(?error, "failed to migrate key store");
        }

        Ok(keystore)
    }

    /// Migrates keys from old key store format to the new one.
    ///
    /// The old key store contains TSK files and/or subdirectories with TSK
    /// files. This method does not directly create the new store structure,
    /// instead it relies on the `import` method to do so.
    ///
    /// This method is idempotent.
    async fn migrate(&mut self) -> Result<(), error::Error> {
        use std::collections::BTreeSet;

        const SEC_STORE_DEFAULT_DIR: &str = "pgp.cert.d.sec";
        const SEC_STORE_ENV_VARIABLE: &str = "PGP_CERT_D_SEC";

        let key_store_path = if let Some(p) = std::env::var_os(SEC_STORE_ENV_VARIABLE) {
            p.into()
        } else {
            dirs::data_dir()
                .ok_or_else(|| {
                    std::io::Error::new(std::io::ErrorKind::NotFound, "missing data dir")
                })?
                .join(SEC_STORE_DEFAULT_DIR)
        };

        async fn process_dir(
            keystore: &mut KeyStore,
            dir_path: &std::path::Path,
        ) -> Result<(), error::Error> {
            for maybe_tsk in std::fs::read_dir(dir_path)? {
                let mut remove = Vec::with_capacity(1);
                let maybe_tsk = maybe_tsk.map_err(error::PgpError::from)?.path();
                let path_str = maybe_tsk.display();
                tracing::debug!(path = %path_str, "migrating TSK file");
                let Ok(cert_parser) = super::cert::parse_certs(std::fs::File::open(&maybe_tsk)?)
                else {
                    tracing::warn!(path = %path_str, "failed to parse TSK file");
                    continue;
                };
                for cert in cert_parser {
                    let keys: BTreeSet<_> = cert
                        .0
                        .keys()
                        .secret()
                        .map(|ka| Fingerprint(ka.key().fingerprint()))
                        .collect();
                    let imported_keys = BTreeSet::from_iter(
                        keystore
                            .import(cert)
                            .await
                            .map_err(error::PgpError::from)?
                            .into_iter()
                            .map(|k| k.fingerprint()),
                    );
                    tracing::info!(
                            path = %path_str,
                            fingerprints = Vec::from_iter(imported_keys.iter().map(std::string::ToString::to_string)).join(", "),
                            "migrated private keys");
                    if keys != imported_keys {
                        tracing::warn!(
                            path = %path_str,
                            expected = ?keys,
                            imported = ?imported_keys,
                            "failed to migrate all private keys"
                        );
                        remove.push(false);
                    } else {
                        remove.push(true);
                    }
                }
                if remove.iter().all(|r| *r) {
                    tracing::debug!(path = %path_str, "removing TSK file");
                    std::fs::remove_file(&maybe_tsk)?;
                }
            }
            if std::fs::read_dir(dir_path)?.count() == 0 {
                tracing::debug!(
                    path = %dir_path.to_string_lossy(),
                    "removing empty directory"
                );
                std::fs::remove_dir(dir_path)?;
            }
            Ok(())
        }

        if key_store_path.is_dir() {
            // Migrate subdirectories.
            for entry in std::fs::read_dir(&key_store_path)? {
                let entry = entry?.path();
                if entry.is_dir() {
                    process_dir(self, &entry).await?;
                } else if entry.is_file() {
                    // If present, remove a leftover lock file at the top level.
                    if entry.ends_with("writelock") {
                        std::fs::remove_file(&entry)?;
                    }
                }
            }
            // Migrate the top level folder.
            process_dir(self, &key_store_path).await?;
        }
        Ok(())
    }

    /// Opens an ephemeral key store.
    ///
    /// The key store is created in a temporary directory, which is deleted
    /// when the key store is dropped.
    pub async fn open_ephemeral() -> Result<Self, error::PgpError> {
        Ok(Self {
            inner: sequoia_keystore::Keystore::connect(
                &sequoia_keystore::Context::configure()
                    .ephemeral()
                    .ipc_policy(IPCPolicy::Robust)
                    .build()
                    .map_err(error::PgpError::from)?,
            )
            .map_err(error::PgpError::from)?,
        })
    }

    /// Imports secret keys present in a TSK (Transferable Secret Key).
    ///
    /// Ignores any keys without secret material.
    pub async fn import(&mut self, cert: Cert) -> Result<Vec<Key>, error::PgpError> {
        let mut keys = Vec::new();
        for mut backend in self
            .inner
            .backends_async()
            .await
            .map_err(error::PgpError::from)?
        {
            if backend.id_async().await.map_err(error::PgpError::from)? == "softkeys" {
                for (import_status, key_handle) in backend
                    .import_async(&cert.0)
                    .await
                    .map_err(error::PgpError::from)?
                {
                    let key = Key { inner: key_handle };
                    tracing::debug!(?import_status, fingerprint=%key.fingerprint(), "imported private key");
                    keys.push(key);
                }
            }
        }

        Ok(keys)
    }

    /// List all keys from all backends of the store.
    pub async fn list(&mut self) -> Result<Vec<Key>, error::PgpError> {
        let mut keys = std::collections::BTreeMap::new();
        for mut backend in self
            .inner
            .backends_async()
            .await
            .map_err(error::PgpError::from)?
        {
            for mut device in backend
                .devices_async()
                .await
                .map_err(error::PgpError::from)?
            {
                for key_handle in device.keys_async().await.map_err(error::PgpError::from)? {
                    keys.insert(key_handle.fingerprint(), Key { inner: key_handle });
                }
            }
        }
        Ok(keys.into_values().collect())
    }

    /// Find a key by its fingerprint.
    ///
    /// If the key is found on multiple devices, it returns multiple keys.
    pub async fn find_key(
        &mut self,
        fingerprint: Fingerprint,
    ) -> Result<Vec<Key>, error::PgpError> {
        Ok(self
            .inner
            .find_key_async(fingerprint.0.into())
            .await
            .map_err(error::PgpError::from)?
            .into_iter()
            .map(|key_handle| Key { inner: key_handle })
            .collect())
    }

    /// Delete key from the key store.
    pub async fn delete_key(&mut self, fingerprint: Fingerprint) -> Result<(), error::PgpError> {
        let mut keys = self
            .inner
            .find_key_async(fingerprint.0.clone().into())
            .await
            .map_err(error::PgpError::from)?;
        if keys.is_empty() {
            return Err(error::PgpError::from(
                "key deletion failed, no key matching the fingerprint found",
            ));
        }
        if keys.len() != 1 {
            return Err(error::PgpError::from(
                "key deletion failed, found multiple keys matching the fingerprint",
            ));
        }
        keys.first_mut()
            .unwrap()
            .delete_secret_key_material_async()
            .await
            .map_err(error::PgpError::from)?;
        tracing::info!(%fingerprint, "deleted private key");
        Ok(())
    }

    /// Exports the first key identified by the given fingerprint.
    pub(crate) async fn export(
        &mut self,
        fingerprint: &sequoia_openpgp::Fingerprint,
    ) -> Result<
        sequoia_openpgp::packet::Key<
            sequoia_openpgp::packet::key::SecretParts,
            sequoia_openpgp::packet::key::UnspecifiedRole,
        >,
        error::PgpError,
    > {
        let mut errors = Vec::new();
        for mut key in self
            .inner
            .find_key_async(fingerprint.into())
            .await
            .map_err(error::PgpError::from)?
        {
            let exported_key = key.export_async().await;
            match exported_key {
                Ok(exported_key) => return Ok(exported_key),
                Err(e) => errors.push(e),
            }
        }
        Err(error::PgpError::Error(format!(
            "Unable to export key: {errors:?}"
        )))
    }
}

/// OpenPGP private key handle.
#[derive(Clone)]
pub struct Key {
    pub(crate) inner: sequoia_keystore::Key,
}

impl Key {
    /// Returns the fingerprint of the key.
    pub fn fingerprint(&self) -> Fingerprint {
        Fingerprint(self.inner.fingerprint())
    }

    /// Unlock the key.
    pub async fn unlock<F, Fut>(&mut self, password: F) -> Result<(), error::PgpError>
    where
        F: Fn(super::cert::Fingerprint) -> Fut,
        Fut: std::future::Future<Output = crate::secret::Secret>,
    {
        match self.inner.locked_async().await {
            Ok(sequoia_keystore::Protection::Unlocked) => {
                trace!("Key is unlocked");
                Ok(())
            }
            Ok(sequoia_keystore::Protection::Password(_)) => {
                if let Ok(()) = self
                    .inner
                    .unlock_async(password(self.fingerprint()).await.as_inner().clone())
                    .await
                {
                    trace!("Unlocked key with the provided password");
                    Ok(())
                } else {
                    let err_msg = format!(
                        "Failed to unlock key ({}) with the provided password",
                        self.inner.fingerprint()
                    );
                    debug!(err_msg);
                    Err(error::PgpError::Error(err_msg))
                }
            }
            Ok(_) => {
                trace!("Externally protected key");
                Ok(())
            }
            Err(e) => {
                let err_msg = format!("Failed to check key lock status, {e}");
                warn!(err_msg);
                Err(error::PgpError::Error(err_msg))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::KeyStore;

    /// Ensure that the keystore is empty.
    ///
    /// It shouldn't contain any folders, but can contain files created by
    /// sequoia_keystore.
    fn assert_keystore_empty(location: &std::path::Path) {
        assert!(location.is_dir());
        assert!(
            !std::fs::read_dir(location)
                .unwrap()
                .any(|p| p.as_ref().unwrap().path().is_dir())
        );
    }

    #[tokio::test]
    async fn custom_keystore_location() {
        let location = tempfile::tempdir().unwrap().keep().join("keystore");
        KeyStore::open(Some(&location)).await.unwrap();
        assert_keystore_empty(&location);
    }
}