use std::{ process::{Child, Command, Output, Stdio}, path::Path, path::PathBuf, time::{Duration, SystemTime}, }; use anyhow::{anyhow, Result}; use sequoia_openpgp::{ Fingerprint, cert::{ Cert, CertBuilder, }, packet::Signature, serialize::Serialize, }; use sequoia_cert_store::{ StoreUpdate, store::certd::CertD, }; pub struct Identity { pub email: &'static str, pub petname: &'static str, pub fingerprint: Fingerprint, pub cert: Cert, pub rev: Signature, } impl Identity { fn new(email: &'static str, petname: &'static str) -> Result { let (cert, rev) = CertBuilder::general_purpose(None, Some(format!("<{}>", email))) .set_creation_time(SystemTime::now() - Duration::new(24 * 3600, 0)) .generate()?; Ok(Identity { email, petname, fingerprint: cert.fingerprint(), cert, rev, }) } /// Returns the certificate with the pregenerated hard revocation. #[allow(dead_code)] pub fn hard_revoke(&self) -> Cert { self.cert.clone().insert_packets(self.rev.clone()) .expect("ok") } } pub enum TempDir { TempDir(tempfile::TempDir), PathBuf(PathBuf), } impl TempDir { fn new() -> Result { Ok(TempDir::TempDir(tempfile::TempDir::new()?)) } fn path(&self) -> &Path { match self { TempDir::TempDir(d) => d.path(), TempDir::PathBuf(p) => p.as_path(), } } fn persist(&mut self) { let d = std::mem::replace(self, TempDir::PathBuf(PathBuf::new())); match d { TempDir::TempDir(d) => *self = TempDir::PathBuf(d.into_path()), TempDir::PathBuf(p) => *self = TempDir::PathBuf(p), } } } pub struct Environment { pub wd: TempDir, pub willow: Identity, pub willow_release: Identity, pub buffy: Identity, pub xander: Identity, pub riley: Identity, } impl Environment { pub fn new() -> Result { let e = Environment { wd: TempDir::new()?, willow: Identity::new("willow@scoobies.example", "Willow Rosenberg Code Signing")?, willow_release: Identity::new("willow@scoobies.example", "Willow Rosenberg Release Signing")?, buffy: Identity::new("buffy@scoobies.example", "Buffy Summers")?, xander: Identity::new("xander@scoobies.example", "Xander Harris")?, riley: Identity::new("riley@scoobies.example", "Riley Finn")?, }; std::fs::create_dir(e.gnupg_state())?; std::fs::create_dir(e.git_state())?; std::fs::create_dir(e.certd_state())?; std::fs::create_dir(e.xdg_cache_home())?; std::fs::create_dir(e.scratch_state())?; e.import(&e.willow.cert)?; e.import(&e.willow_release.cert)?; e.import(&e.buffy.cert)?; e.import(&e.xander.cert)?; e.import(&e.riley.cert)?; e.git(&["init", &e.git_state().display().to_string()])?; e.git(&["config", "--local", "user.email", "you@example.org"])?; e.git(&["config", "--local", "user.name", "Your Name"])?; // git's default is to not sign. But, the user might have // overridden this in their ~/.gitconfig, and be using an old // version of git (<2.32). In that case, GIT_CONFIG_GLOBAL // won't suppress this setting. Setting it unconditionally in // the local configuration file is a sufficient workaround. e.git(&["config", "--local", "user.signingkey", "0xDEADBEEF"])?; e.git(&["config", "--local", "commit.gpgsign", "false"])?; Ok(e) } /// Persists the directory so that it can be examined after this run. #[allow(dead_code)] pub fn persist(&mut self) { self.wd.persist(); eprintln!("Persisting temporary directory: {}", self.wd.path().display()); } /// Returns the environment and the root commit. #[allow(dead_code)] pub fn scooby_gang_bootstrap() -> Result<(Environment, String)> { let e = Environment::new()?; // Willow has a code-signing key. e.sq_git(&[ "policy", "authorize", e.willow.petname, &e.willow.fingerprint.to_string(), "--sign-commit" ])?; // Additionally, Willow also has a release key on her security // token. e.sq_git(&[ "policy", "authorize", e.willow_release.petname, &e.willow_release.fingerprint.to_string(), "--sign-commit", "--sign-tag", "--sign-archive", "--add-user", "--retire-user", "--audit", ])?; e.git(&["add", "openpgp-policy.toml"])?; e.git(&[ "commit", "-m", "Initial commit.", &format!("-S{}", e.willow_release.fingerprint), ])?; let root = e.git_current_commit()?; Ok((e, root)) } pub fn gnupg_state(&self) -> PathBuf { self.wd.path().join("gnupg") } pub fn git_state(&self) -> PathBuf { self.wd.path().join("git") } pub fn certd_state(&self) -> PathBuf { self.wd.path().join("certd") } pub fn xdg_cache_home(&self) -> PathBuf { self.wd.path().join("xdg_cache_home") } #[allow(dead_code)] pub fn scratch_state(&self) -> PathBuf { self.wd.path().join("scratch") } pub fn import(&self, cert: &Cert) -> Result<()> { let mut certd = CertD::open(self.certd_state())?; certd.update(std::borrow::Cow::Owned(cert.clone().into()))?; let mut c = Command::new("gpg"); c.arg("--status-fd=2"); c.arg("--import").stdin(Stdio::piped()); let mut child = self.spawn(c)?; // Write in a separate thread to avoid deadlocks. let mut stdin = child.stdin.take().expect("failed to get stdin"); let cert = cert.clone(); let thread_handle = std::thread::spawn(move || { cert.as_tsk().serialize(&mut stdin) }); let output = child.wait_with_output()?; thread_handle.join().unwrap()?; if output.status.success() { Ok(()) } else { Err(anyhow!("gpg --import failed\n\nstdout:\n{}\n\n stderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr))) } } pub fn git>(&self, args: &[A]) -> Result<(Vec, Vec)> { eprint!("$ git"); let mut c = Command::new("git"); for a in args { eprint!(" {}", a.as_ref()); c.arg(a.as_ref()); } eprintln!(); self.run(c) } // A convenience function to optionally modify and commit a few // files. // // Returns the new commit id. #[allow(dead_code)] pub fn git_commit(&self, files: &[(&str, Option<&[u8]>)], commit_msg: &str, signer: Option<&Identity>) -> Result { let p = self.git_state(); for (filename, content) in files.iter() { if let Some(content) = content { std::fs::write(p.join(filename), content).unwrap(); } self.git(&["add", filename])?; } let mut git_args = vec!["commit", "-m", commit_msg]; let signer_; if let Some(signer) = signer { signer_ = format!("-S{}", signer.fingerprint); git_args.push(&signer_); } self.git(&git_args)?; Ok(self.git_current_commit()?) } pub fn git_current_commit(&self) -> Result { Ok(String::from_utf8(self.git(&["rev-parse", "HEAD"])?.0)? .trim().to_string()) } pub fn sq_git_path() -> Result { use std::sync::Once; static BUILD: Once = Once::new(); BUILD.call_once(|| { let o = Command::new("cargo") .arg("build").arg("--quiet") .arg("--bin").arg("sq-git") .output() .expect("running cargo failed"); if ! o.status.success() { panic!("build failed:\n\nstdout:\n{}\n\n stderr:\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); } }); Ok(if let Ok(target) = std::env::var("CARGO_TARGET_DIR") { PathBuf::from(target).canonicalize()? } else { std::env::current_dir()?.join("target") }.join("debug/sq-git")) } pub fn sq_git>(&self, args: &[A]) -> Result { eprint!("$ sq-git"); let mut c = Command::new(Self::sq_git_path()?); // We are a machine, request machine-readable output. c.arg("--output-format=json"); for a in args { eprint!(" {}", sh_quote(a)); c.arg(a.as_ref()); } eprintln!(); let output = self.spawn(c)?.wait_with_output()?; if output.status.success() { Ok(output) } else { Err(CliError { output }.into()) } } pub fn spawn(&self, mut c: Command) -> Result { Ok(c.current_dir(self.git_state()) .env_clear() // Filter out all git-related environment variables. .envs(std::env::vars() .filter(|(k, _)| ! k.starts_with("GIT_")) .collect::>()) .env("SQ_CERT_STORE", self.certd_state()) .env("GNUPGHOME", self.gnupg_state()) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_NOSYSTEM", "1") .env("XDG_CACHE_HOME", self.xdg_cache_home()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?) } pub fn run(&self, c: Command) -> Result<(Vec, Vec)> { let output = self.spawn(c)?.wait_with_output()?; if output.status.success() { Ok((output.stdout, output.stderr)) } else { Err(anyhow!("command failed\n\nstdout:\n{}\n\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr))) } } } /// Errors for this crate. #[derive(thiserror::Error, Debug)] #[error("command failed\n\nstdout:\n{}\n\nstderr:\n{}", String::from_utf8_lossy(&self.output.stdout), String::from_utf8_lossy(&self.output.stderr))] pub struct CliError { output: std::process::Output, } fn sh_quote<'s, S: AsRef + 's>(s: S) -> String { let s = s.as_ref(); if s.contains(char::is_whitespace) { format!("{:?}", s) } else { s.to_string() } }