kc-cli 0.3.3

CLI for reading and writing macOS keychain files without the Security framework
//! Shared helpers for the keychain tests.
//!
//! The fixtures are generated by macOS's own `security` tool rather than
//! committed as binaries, so every test compares this implementation against
//! what the running system actually writes. Tests skip themselves when
//! `security` is unavailable.

#![allow(dead_code)]

use std::path::{Path, PathBuf};
use std::process::Command;

/// A scratch directory that removes itself.
pub struct TempDir(PathBuf);

impl TempDir {
    pub fn new(tag: &str) -> Self {
        let path = std::env::temp_dir().join(format!(
            "kc-test-{tag}-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = std::fs::remove_dir_all(&path);
        std::fs::create_dir_all(&path).expect("create scratch directory");
        Self(path)
    }

    pub fn path(&self) -> &Path {
        &self.0
    }

    /// An absolute path inside the directory. `security` resolves relative
    /// keychain names against `~/Library/Keychains`, so paths must be absolute.
    pub fn join(&self, name: &str) -> PathBuf {
        self.0.join(name)
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

pub fn security_available() -> bool {
    Command::new("/usr/bin/security")
        .arg("help")
        .output()
        .is_ok_and(|output| output.status.success() || output.status.code() == Some(1))
}

/// Run `security` with the given arguments.
pub fn security(args: &[&str]) -> std::process::Output {
    Command::new("/usr/bin/security")
        .args(args)
        .output()
        .expect("run security")
}

/// Run `security`, requiring success.
pub fn security_ok(args: &[&str]) -> String {
    let output = security(args);
    assert!(
        output.status.success(),
        "security {args:?} failed: {}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

/// Create a keychain with `security`, and unlock it so later calls work.
pub fn create_with_security(path: &Path, password: &str) {
    let path = path.to_str().expect("utf-8 path");
    security_ok(&["create-keychain", "-p", password, path]);
    security_ok(&["unlock-keychain", "-p", password, path]);
}

/// A generic password stored by `security`, readable by any application.
pub fn add_generic_with_security(path: &Path, account: &str, service: &str, secret: &str) {
    security_ok(&[
        "add-generic-password",
        "-a",
        account,
        "-s",
        service,
        "-w",
        secret,
        "-A",
        path.to_str().expect("utf-8 path"),
    ]);
}

/// An internet password stored by `security`.
pub fn add_internet_with_security(path: &Path, account: &str, server: &str, secret: &str) {
    security_ok(&[
        "add-internet-password",
        "-a",
        account,
        "-s",
        server,
        "-w",
        secret,
        "-r",
        "http",
        "-P",
        "8080",
        "-p",
        "/login",
        "-A",
        path.to_str().expect("utf-8 path"),
    ]);
}

/// A keychain with two generic items and one internet item, all from `security`.
pub fn populated_keychain(dir: &TempDir, name: &str, password: &str) -> PathBuf {
    let path = dir.join(name);
    create_with_security(&path, password);
    add_generic_with_security(&path, "alice", "myservice", "s3cr3t-generic");
    add_generic_with_security(&path, "carol", "other", "third-one");
    add_internet_with_security(&path, "bob", "example.com", "s3cr3t-internet");
    path
}

/// The `kc` binary under test.
pub fn kc_binary() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_kc"))
}

/// Run `kc`, feeding the keychain password on stdin.
pub fn kc(args: &[&str], password: Option<&str>) -> std::process::Output {
    use std::io::Write;
    use std::process::Stdio;

    let mut child = Command::new(kc_binary())
        .args(args)
        .stdin(if password.is_some() {
            Stdio::piped()
        } else {
            Stdio::null()
        })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn kc");
    if let Some(password) = password {
        child
            .stdin
            .take()
            .expect("stdin")
            .write_all(format!("{password}\n").as_bytes())
            .expect("write password");
    }
    child.wait_with_output().expect("run kc")
}

/// Run `kc` with extra environment variables and no stdin, for `-e`.
pub fn kc_with_env(args: &[&str], env: &[(&str, &str)]) -> std::process::Output {
    use std::process::Stdio;

    let mut command = Command::new(kc_binary());
    command.args(args).stdin(Stdio::null());
    for (name, value) in env {
        command.env(name, value);
    }
    command.output().expect("run kc")
}

pub fn kc_ok(args: &[&str], password: Option<&str>) -> String {
    let output = kc(args, password);
    assert!(
        output.status.success(),
        "kc {args:?} failed: {}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

/// A generic password stored by `security`, readable only by the named
/// applications (`-T`).
pub fn add_generic_trusting_with_security(
    path: &Path,
    account: &str,
    service: &str,
    secret: &str,
    applications: &[&str],
) {
    let mut args = vec![
        "add-generic-password",
        "-a",
        account,
        "-s",
        service,
        "-w",
        secret,
    ];
    for application in applications {
        args.push("-T");
        args.push(application);
    }
    let path = path.to_str().expect("utf-8 path");
    args.push(path);
    security_ok(&args);
}

/// Compile a requirement expression to the blob a keychain ACL embeds.
///
/// `csreq` produces exactly the bytes that appear both in a binary's code
/// signature and in an ACL that trusts it, so tests need neither the
/// `trust-apps` feature nor a Mach-O parser.
pub fn compile_requirement(expression: &str) -> Vec<u8> {
    use std::sync::atomic::{AtomicU32, Ordering};
    static COUNTER: AtomicU32 = AtomicU32::new(0);

    // Unique per call: these tests run in parallel within one process.
    let dir = std::env::temp_dir().join(format!(
        "kc-req-{}-{}",
        std::process::id(),
        COUNTER.fetch_add(1, Ordering::Relaxed)
    ));
    std::fs::create_dir_all(&dir).expect("scratch dir");
    let out = dir.join("req.bin");
    let status = Command::new("/usr/bin/csreq")
        .arg(format!("-r={expression}"))
        .arg("-b")
        .arg(&out)
        .status()
        .expect("run csreq");
    assert!(status.success(), "csreq failed for {expression:?}");
    let blob = std::fs::read(&out).expect("read requirement");
    let _ = std::fs::remove_dir_all(&dir);
    blob
}

/// The designated requirement of a signed binary, via `codesign` and `csreq`.
pub fn designated_requirement(binary: &str) -> Vec<u8> {
    let output = Command::new("/usr/bin/codesign")
        .args(["-d", "-r-", binary])
        .output()
        .expect("run codesign");
    let text = String::from_utf8_lossy(&output.stdout);
    let expression = text
        .lines()
        .find_map(|line| line.strip_prefix("designated => "))
        .unwrap_or_else(|| panic!("no designated requirement for {binary}"));
    compile_requirement(expression)
}

/// A self-signed certificate and its private key, generated with `openssl`.
///
/// Returns the paths of a PEM certificate and an unencrypted PKCS#8 PEM key.
/// Generating them at test time keeps no key material in the repository, and
/// gives `security` something it will accept as an identity.
pub fn generate_identity(dir: &TempDir, name: &str, common_name: &str) -> (PathBuf, PathBuf) {
    let certificate = dir.join(&format!("{name}-cert.pem"));
    let key = dir.join(&format!("{name}-key.pem"));
    let openssl_key = dir.join(&format!("{name}-key-1.pem"));

    let status = Command::new("/usr/bin/openssl")
        .args([
            "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "30",
        ])
        .arg("-subj")
        .arg(format!("/CN={common_name}"))
        .arg("-keyout")
        .arg(&openssl_key)
        .arg("-out")
        .arg(&certificate)
        .output()
        .expect("run openssl req");
    assert!(
        status.status.success(),
        "openssl req failed: {}",
        String::from_utf8_lossy(&status.stderr)
    );

    // `openssl req` may emit a traditional RSA key; PKCS#8 is what a keychain
    // stores, so it is converted unconditionally.
    let status = Command::new("/usr/bin/openssl")
        .args(["pkcs8", "-topk8", "-nocrypt", "-in"])
        .arg(&openssl_key)
        .arg("-out")
        .arg(&key)
        .output()
        .expect("run openssl pkcs8");
    assert!(
        status.status.success(),
        "openssl pkcs8 failed: {}",
        String::from_utf8_lossy(&status.stderr)
    );

    (certificate, key)
}

/// Bundle a certificate and key into a PKCS#12 file `security import` accepts,
/// and import it. Returns the passphrase used.
pub fn import_identity_with_security(
    dir: &TempDir,
    keychain: &Path,
    name: &str,
    certificate: &Path,
    key: &Path,
    friendly_name: &str,
) {
    let bundle = generate_pkcs12(dir, name, certificate, key, friendly_name, "x");
    security_ok(&[
        "import",
        bundle.to_str().expect("utf-8 path"),
        "-k",
        keychain.to_str().expect("utf-8 path"),
        "-P",
        "x",
        "-A",
    ]);
}

/// Bundle a certificate and key into a DER PKCS#12 file with OpenSSL.
pub fn generate_pkcs12(
    dir: &TempDir,
    name: &str,
    certificate: &Path,
    key: &Path,
    friendly_name: &str,
    password: &str,
) -> PathBuf {
    let bundle = dir.join(&format!("{name}.p12"));
    let status = Command::new("/usr/bin/openssl")
        .args([
            "pkcs12",
            "-export",
            "-macalg",
            "sha1",
            "-certpbe",
            "PBE-SHA1-3DES",
            "-keypbe",
            "PBE-SHA1-3DES",
            "-passout",
        ])
        .arg(format!("pass:{password}"))
        .arg("-name")
        .arg(friendly_name)
        .arg("-inkey")
        .arg(key)
        .arg("-in")
        .arg(certificate)
        .arg("-out")
        .arg(&bundle)
        .output()
        .expect("run openssl pkcs12");
    assert!(
        status.status.success(),
        "openssl pkcs12 failed: {}",
        String::from_utf8_lossy(&status.stderr)
    );

    bundle
}