sett 0.3.0

Rust port of sett (data compression, encryption and transfer tool).
Documentation
//! Interface to the GnuPG command line interface.

use anyhow::{ensure, Context, Result};
use std::{
    io::Write,
    path::Path,
    process::{Command, Output, Stdio},
};

use crate::openpgp::cert::CertType;

#[derive(Clone, Debug)]
/// GnuPG's representation of an OpenPGP certificate.
pub struct CertInfo {
    /// The certificate's fingerprint.
    pub fingerprint: String,
    /// The certificate's user ID.
    pub userid: Option<String>,
    /// Whether the certificate is public or secret.
    pub cert_type: CertType,
}

/// Run the `gpg` command with the specified arguments `args`. Optionally, the
/// GnuPG "home" directory can be passed with the `gpg_home` argument.
fn run_gnupg_command(
    args: Vec<&str>,
    password: Option<&[u8]>,
    gpg_home: Option<impl AsRef<Path>>,
) -> Result<Output> {
    let mut cmd = command("gpg");
    let gpg = cmd.stdout(Stdio::piped()).stderr(Stdio::null());
    if password.is_some() {
        gpg.arg("--passphrase-fd").arg("0");
    }
    if let Some(gpg_home) = gpg_home {
        gpg.arg("--homedir").arg(gpg_home.as_ref());
    }
    for arg in args {
        gpg.arg(arg);
    }
    let error_context = "'gpg' failed to start. Ensure that 'gpg' is installed AND in your PATH";
    if let Some(p) = password {
        let mut child = gpg.stdin(Stdio::piped()).spawn().context(error_context)?;
        let mut stdin = child.stdin.take().context("Failed to open stdin")?;
        std::thread::scope(|s| {
            s.spawn(|| -> Result<()> {
                stdin.write_all(p)?;
                stdin.write_all(b"\n")?;
                Ok(())
            });
        });
        Ok(child.wait_with_output()?)
    } else {
        gpg.stdin(Stdio::null()).output().context(error_context)
    }
}

/// Extract an OpenPGP certificate specified by a given `identifier` (e.g.
/// fingerprint or email) from a GnuPG keyring.
///
/// # Arguments
///
/// * `identifier`: Fingerprint or email of the OpenPGP certificate to export.
/// * `cert_type`: type of certificate (`Public` or `Secret`) to list.
/// * `password`: optional password to decrypt the secret key.
/// * `gpg_home`: home directory of GnuPG, where keyrings are stored on the
///    user's machine. On Linux systems, this is typically `~/.gnupg`. If not
///    specified, the default GnuPG directory is used.
pub fn export_key(
    identifier: &str,
    cert_type: CertType,
    password: Option<&[u8]>,
    gpg_home: Option<impl AsRef<Path>>,
) -> Result<Vec<u8>> {
    let gpg = run_gnupg_command(
        vec![
            "--armor",
            match cert_type {
                CertType::Public => "--export",
                CertType::Secret => "--export-secret-key",
            },
            identifier,
        ],
        password,
        gpg_home,
    )?;
    ensure!(
        !gpg.stdout.is_empty(),
        "No {} key matching the identifier '{}' was found in the GnuPG keyring.{}",
        cert_type,
        identifier,
        match cert_type {
            CertType::Public => "",
            CertType::Secret => " Please ensure that you use the correct password.",
        },
    );
    Ok(gpg.stdout.to_vec())
}

/// Gets the list of OpenPGP certificate identifiers from a GnuPG keyring.
///
/// # Arguments
///
/// * `cert_type`: type of certificate to list.
/// * `gpg_home`: home directory of GnuPG, where keyrings are stored on the
///    user's machine. On Linux systems, this is typically `~/.gnupg`. If not
///    specified, the default GnuPG directory is used.
pub fn list_keys(cert_type: CertType, gpg_home: Option<impl AsRef<Path>>) -> Result<Vec<CertInfo>> {
    let gpg = run_gnupg_command(
        vec![
            "--with-colons",
            match cert_type {
                CertType::Public => "--list-keys",
                CertType::Secret => "--list-secret-keys",
            },
        ],
        None,
        gpg_home,
    )?;
    Ok(parse_key_info(String::from_utf8_lossy(&gpg.stdout).trim()))
}

/// Parse the output from the GnuPG command "--list-keys/--list-secret-keys".
///
/// Arguments
///
/// * `gpg_output`: stdout of the GnuPG command "gpg --list-keys --with-colons"
///   or "gpg --list-secret-keys --with-colons".
fn parse_key_info(gpg_output: &str) -> Vec<CertInfo> {
    let lines = gpg_output.lines();
    let mut keys = Vec::new();
    let mut fingerprint: Option<String> = None;
    let mut userid: Option<String> = None;
    let mut cert_type = CertType::Public;

    for line in lines.into_iter() {
        let tokens = line.split(':').collect::<Vec<_>>();
        match tokens[0] {
            "fpr" => {
                if fingerprint.is_none() {
                    fingerprint = Some(tokens[9].into())
                }
            }
            "uid" => {
                if userid.is_none() {
                    userid = Some(tokens[9].into())
                }
            }
            "sec" | "pub" => {
                cert_type = match tokens[0] {
                    "sec" => CertType::Secret,
                    "pub" => CertType::Public,
                    _ => unreachable!(),
                };
                if let Some(fp) = fingerprint {
                    // Add the key info to the output vector.
                    keys.push(CertInfo {
                        fingerprint: fp,
                        userid,
                        cert_type: CertType::Public,
                    });

                    // Reset values as we move to the next key in the output.
                    fingerprint = None;
                    userid = None;
                }
            }
            _ => (),
        }
    }
    // Add info of the last parsed key from the output.
    if let Some(fp) = fingerprint {
        keys.push(CertInfo {
            fingerprint: fp,
            userid,
            cert_type,
        });
    }
    keys
}

#[allow(clippy::let_and_return)]
fn command<S>(program: S) -> Command
where
    S: AsRef<std::ffi::OsStr>,
{
    let command = Command::new(program);

    #[cfg(windows)]
    let command = {
        use std::os::windows::process::CommandExt;

        // see https://docs.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
        const CREATE_NO_WINDOW: u32 = 0x08000000;
        let mut command = command;
        command.creation_flags(CREATE_NO_WINDOW);
        command
    };
    command
}

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

    // Note: these test outputs were generated using the command:
    // gpg --list-secret-keys --with-colons F7EE982159CFE60063D373FDD59C9655C1ACE95E
    const SEC_CERT: &str = "sec:u:4096:1:D59C9655C1ACE95E:1692170831:::u:::scESC:::+:::23::0:
fpr:::::::::F7EE982159CFE60063D373FDD59C9655C1ACE95E:
grp:::::::::62A6FDDB0DFB1A63141DAF4F49A26BF8E9561D7D:
uid:u::::1692170831::9464D2BD8DF8ECFE2012547819A68FB41DF70C26::Another test <test@test.ch>::::::::::0:
ssb:u:4096:1:2E12C85A59B7B933:1692170831::::::e:::+:::23:
fpr:::::::::FC5964D80D9E05CBF429B6A32E12C85A59B7B933:
grp:::::::::F87137FCF6AD55C2D5707028152B3CC252B1D77D:
";
    // gpg --list-keys --with-colons F7EE982159CFE60063D373FDD59C9655C1ACE95E
    const PUB_CERT: &str = "tru::1:1692170835:0:3:1:5
pub:u:4096:1:D59C9655C1ACE95E:1692170831:::u:::scESC::::::23::0:
fpr:::::::::F7EE982159CFE60063D373FDD59C9655C1ACE95E:
uid:u::::1692170831::9464D2BD8DF8ECFE2012547819A68FB41DF70C26::Another test <test@test.ch>::::::::::0:
sub:u:4096:1:2E12C85A59B7B933:1692170831::::::e::::::23:
fpr:::::::::FC5964D80D9E05CBF429B6A32E12C85A59B7B933:
";

    #[test]
    fn test_from_secret_keys() {
        let certs = parse_key_info(SEC_CERT);
        assert_eq!(certs.len(), 1);
        assert_eq!(
            certs[0].fingerprint,
            "F7EE982159CFE60063D373FDD59C9655C1ACE95E"
        );
        assert_eq!(
            certs[0].userid.as_deref(),
            Some("Another test <test@test.ch>")
        );
        assert_eq!(certs[0].cert_type, CertType::Secret);
    }

    #[test]
    fn test_from_public_keys() {
        let certs = parse_key_info(PUB_CERT);
        assert_eq!(certs.len(), 1);
        assert_eq!(
            certs[0].fingerprint,
            "F7EE982159CFE60063D373FDD59C9655C1ACE95E"
        );
        assert_eq!(
            certs[0].userid.as_deref(),
            Some("Another test <test@test.ch>")
        );
        assert_eq!(certs[0].cert_type, CertType::Public);
    }
}