#![allow(unused)]
use std::{
collections::{BTreeMap, BTreeSet},
ffi::OsString,
fs::File,
io::Write,
process::{Child, Command, Output, Stdio},
path::Path,
path::PathBuf,
sync::{Arc, OnceLock},
time::{Duration, SystemTime},
};
use anyhow::anyhow;
use sequoia_openpgp::{
Fingerprint,
Packet,
cert::{
Cert,
CertBuilder,
CertParser,
CertRevocationBuilder,
KeyBuilder,
SubkeyRevocationBuilder,
amalgamation::ValidAmalgamation,
},
packet::Signature,
parse::Parse,
policy::StandardPolicy,
serialize::Serialize,
types::{
KeyFlags,
ReasonForRevocation,
RevocationStatus,
}
};
use sequoia_cert_store::{
StoreUpdate,
store::certd::CertD,
};
pub type Result<T, E=Error> = std::result::Result<T, E>;
const P: &StandardPolicy = &StandardPolicy::new();
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<Identity> {
let (cert, rev) =
CertBuilder::general_purpose(Some(format!("<{}>", email)))
.set_creation_time(SystemTime::now() - Duration::new(24 * 3600, 0))
.generate()?;
Ok(Identity {
email,
petname,
fingerprint: cert.fingerprint(),
cert,
rev,
})
}
#[allow(dead_code)]
pub fn hard_revoke(&self) -> Cert {
self.cert.clone().insert_packets(self.rev.clone())
.expect("ok").0
}
}
pub fn rotate_subkeys(cert: &Cert) -> Cert {
let vc = cert.with_policy(P, None).expect("valid cert");
let mut signer = cert.primary_key().key().clone()
.parts_into_secret().unwrap()
.into_keypair().unwrap();
let mut packets = Vec::new();
for sk in vc.keys().subkeys().for_signing() {
if let RevocationStatus::Revoked(_) = sk.revocation_status() {
continue;
}
let sig = SubkeyRevocationBuilder::new()
.set_reason_for_revocation(ReasonForRevocation::KeyRetired,
b"Retired").unwrap()
.build(&mut signer, &cert, sk.key(), None).unwrap();
packets.push(Packet::from(sk.key().clone()));
packets.push(Packet::from(sig));
}
let cert2 = KeyBuilder::new(KeyFlags::signing())
.subkey(vc.clone()).unwrap()
.attach_cert().unwrap();
if packets.is_empty() {
cert2
} else {
cert2.insert_packets(packets).expect("can insert packets").0
}
}
pub fn revoke_cert(cert: &Cert, reason: ReasonForRevocation) -> Cert {
let vc = cert.with_policy(P, None).expect("valid cert");
let mut signer = cert.primary_key().key().clone()
.parts_into_secret().unwrap()
.into_keypair().unwrap();
let sig = CertRevocationBuilder::new()
.set_reason_for_revocation(reason, b"revoked").unwrap()
.build(&mut signer, &cert, None).unwrap();
let cert = cert.clone().insert_packets(sig.clone())
.expect("can insert revocation certificate").0;
assert_eq!(RevocationStatus::Revoked(vec![&sig]),
cert.revocation_status(P, None));
cert
}
pub enum TempDir {
TempDir(tempfile::TempDir),
PathBuf(PathBuf),
}
impl TempDir {
fn new() -> Result<Self> {
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 time: Option<SystemTime>,
pub wd: TempDir,
pub willow: Identity,
pub willow_release: Identity,
pub buffy: Identity,
pub xander: Identity,
pub riley: Identity,
}
static HAVE_FAKETIME: OnceLock<std::result::Result<bool, Error>>
= OnceLock::new();
impl Environment {
pub fn check_for_faketime() -> std::result::Result<bool, &'static Error> {
let r = HAVE_FAKETIME.get_or_init(|| {
if let Ok(val) = std::env::var("NO_FAKETIME") {
return Ok(false);
}
let mut cmd = Command::new("faketime");
cmd.env("TZ", "UTC");
let t = chrono::DateTime::<chrono::Utc>::from(SystemTime::now())
.format("%Y-%m-%d %H:%M:%S")
.to_string();
cmd.arg("-f").arg(t);
cmd.arg("git").arg("--version");
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn().map_err(anyhow::Error::from)?
.wait_with_output().map_err(anyhow::Error::from)?;
eprintln!("stdout:{}\nstderr:{}\n",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr));
if output.status.success() {
Ok(true)
} else {
Err(Error::NoFaketime(output))
}
});
match r {
Ok(b) => Ok(*b),
Err(err) => Err(err)
}
}
pub fn at<T>(t: T) -> Result<Environment>
where T: Into<Option<SystemTime>>
{
let t = t.into();
if t.is_some() {
assert!(HAVE_FAKETIME.get().is_some(),
"You forgot to call Environment::check_for_faketime \
before calling Environment::at");
eprintln!("Using faketime.");
} else {
eprintln!("Using wall clock.");
}
let e = Environment {
time: t,
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")?,
};
let create_dir = |path: &Path| {
eprintln!("Creating {}", path.display());
std::fs::create_dir(path)
};
create_dir(&e.gnupg_state())?;
create_dir(&e.git_state())?;
create_dir(&e.certd_state())?;
create_dir(&e.xdg_cache_home())?;
create_dir(&e.scratch_state())?;
let mut c = e.command("gpg");
c.arg("--version");
e.run(c);
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.init_repo(e.git_state().as_path())?;
Ok(e)
}
pub fn new() -> Result<Environment> {
Self::at(None)
}
#[allow(dead_code)]
pub fn persist(&mut self) {
self.wd.persist();
eprintln!("Persisting temporary directory: {}",
self.wd.path().display());
}
pub fn init_repo<'a, P: Into<Option<&'a Path>>>(&self, wd: P) -> Result<()> {
let git_state = self.git_state();
let wd = wd.into().unwrap_or(git_state.as_path());
self.git(&["version"])?;
eprintln!("Creating {}", wd.display());
std::fs::create_dir(wd)
.or_else(|err| {
if err.kind() == std::io::ErrorKind::AlreadyExists {
Ok(())
} else {
Err(err)
}
})
.expect(&format!("Can create {}", wd.display()));
self.git(&["init", &wd.display().to_string()])?;
self.git_in(wd, &["config", "--local", "user.email", "you@example.org"])?;
self.git_in(wd, &["config", "--local", "user.name", "Your Name"])?;
self.git_in(wd, &["config", "--local", "user.signingkey", "0xDEADBEEF"])?;
self.git_in(wd, &["config", "--local", "commit.gpgsign", "false"])?;
self.sq_git_in(wd, &["version"])?;
Ok(())
}
pub fn scooby_gang_bootstrap(signer: Option<&str>) -> Result<(Environment, String)> {
let e = Environment::new()?;
e.scooby_gang_bootstrap_in(None, signer)
.map(|root| (e, root))
}
#[allow(dead_code)]
pub fn scooby_gang_bootstrap_in<'a, P: Into<Option<&'a Path>>>(
&self, wd: P, signer: Option<&str>)
-> Result<String>
{
let git_state = self.git_state();
let wd = wd.into().unwrap_or(git_state.as_path());
self.scooby_gang_bootstrap_policy(wd.join("openpgp-policy.toml").as_path());
let signer = if let Some(signer) = signer {
if self.willow.petname == signer {
&self.willow.fingerprint
} else if self.willow_release.petname == signer {
&self.willow_release.fingerprint
} else if self.buffy.petname == signer {
&self.buffy.fingerprint
} else if self.xander.petname == signer {
&self.xander.fingerprint
} else if self.riley.petname == signer {
&self.riley.fingerprint
} else {
panic!("Unknown signer: {}", signer);
}
} else {
&self.willow_release.fingerprint
};
self.git_in(wd, &["add", "openpgp-policy.toml"])?;
self.git_in(
wd,
&[
"commit",
"-m", "Initial commit.",
&format!("-S{}", signer),
])?;
let root = self.git_current_commit_in(wd)?;
Ok(root)
}
pub fn scooby_gang_bootstrap_policy(
&self, external_policy_file: &Path)
-> Result<()>
{
let external_policy_file = external_policy_file.display().to_string();
self.sq_git(
&[
"policy",
"authorize",
"--policy-file", &external_policy_file,
self.willow.petname,
&self.willow.fingerprint.to_string(),
"--sign-commit"
])?;
self.sq_git(
&[
"policy",
"authorize",
"--policy-file", &external_policy_file,
self.willow_release.petname,
&self.willow_release.fingerprint.to_string(),
"--sign-commit",
"--sign-tag",
"--sign-archive",
"--add-user",
"--retire-user",
"--audit",
])?;
Ok(())
}
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 certd = CertD::open(self.certd_state())?;
certd.update(Arc::new(cert.clone().into()))?;
let mut c = self.command("gpg");
c.arg("--status-fd=2");
c.arg("--import").stdin(Stdio::piped());
eprintln!("$ {:?} {}", c, cert.fingerprint());
let mut child = self.spawn(c)?;
let mut stdin = child.stdin.take().expect("failed to get stdin");
let cert = cert.clone();
let thread_handle = std::thread::spawn(move || -> Result<()> {
cert.as_tsk().serialize(&mut stdin)?;
Ok(stdin.flush()?)
});
let output = child.wait_with_output()?;
thread_handle.join().unwrap()?;
if output.status.success() {
eprintln!(" -> success");
Ok(())
} else {
eprintln!(" -> failure");
eprintln!("stdout:\n{}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr:\n{}", String::from_utf8_lossy(&output.stderr));
Err(Error::CliError("gpg --import".into(), output))
}
}
pub fn time(&mut self, t: SystemTime) {
assert!(HAVE_FAKETIME.get().is_some(),
"You forgot to call Environment::check_for_faketime \
before calling Environment::time");
self.time = Some(t);
}
pub fn tick(&mut self, secs: u64) {
assert!(HAVE_FAKETIME.get().is_some(),
"You forgot to call Environment::check_for_faketime \
before calling Environment::tick");
if let Some(t) = self.time {
self.time = Some(t + Duration::new(secs, 0));
} else {
self.time = Some(SystemTime::now() + Duration::new(secs, 0));
}
}
fn command<P>(&self, cmd: P) -> Command
where P: Into<PathBuf>
{
let cmd = cmd.into();
if let Some(t) = self.time.as_ref() {
let mut c = Command::new("faketime");
c.env("TZ", "UTC");
let t = chrono::DateTime::<chrono::Utc>::from(t.clone())
.format("%Y-%m-%d %H:%M:%S")
.to_string();
c.arg("-f").arg(&t);
c.arg(&cmd);
c
} else {
Command::new(&cmd)
}
}
pub fn git<A: AsRef<str>>(&self, args: &[A]) -> Result<Output> {
self.git_in(None, args)
}
pub fn git_in<'a, P: Into<Option<&'a Path>>, A: AsRef<str>>(
&self, wd: P, args: &[A])
-> Result<Output>
{
let mut c = self.command("git");
for a in args {
c.arg(a.as_ref());
}
self.run_in(wd, c)
}
#[allow(dead_code)]
pub fn git_commit(&self,
files: &[(&str, Option<&[u8]>)],
commit_msg: &str,
signer: Option<&Identity>)
-> Result<String>
{
self.git_commit_in(None, files, commit_msg, signer)
}
pub fn git_commit_in<'a, P: Into<Option<&'a Path>>>(
&self,
wd: P,
files: &[(&str, Option<&[u8]>)],
commit_msg: &str,
signer: Option<&Identity>)
-> Result<String>
{
let git_state = self.git_state();
let wd = wd.into().unwrap_or(git_state.as_path());
for (filename, content) in files.iter() {
if let Some(content) = content {
std::fs::write(wd.join(filename), content).unwrap();
}
self.git_in(wd, &["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_in(wd, &git_args)?;
Ok(self.git_current_commit_in(wd)?)
}
#[allow(dead_code)]
pub fn git_merge_no_ff(&self,
commits: &[&str],
commit_msg: &str,
signer: Option<&Identity>,
args: &[&str])
-> Result<String>
{
let p = self.git_state();
let mut git_args = vec!["merge", "--no-ff", "-m", commit_msg];
git_args.extend_from_slice(args);
let signer_;
if let Some(signer) = signer {
signer_ = format!("-S{}", signer.fingerprint);
git_args.push(&signer_);
}
git_args.extend_from_slice(commits);
let output = self.git(&git_args)?;
assert!(!output.stdout.starts_with(&b"Already up to date."[..]),
"Failed to create merge commit. Perhaps you are merging \
commits that are already merged.");
Ok(self.git_current_commit()?)
}
pub fn git_current_commit(&self) -> Result<String> {
self.git_current_commit_in(None)
}
pub fn git_current_commit_in<'a, P: Into<Option<&'a Path>>>(&self, wd: P)
-> Result<String>
{
Ok(String::from_utf8(self.git_in(wd, &["rev-parse", "HEAD"])?.stdout)?
.trim().to_string())
}
pub fn git_log_graph(&self) -> Result<String> {
Ok(String::from_utf8(self.git(&["log", "--graph", "--show-signature"])?.stdout)?
.trim().to_string())
}
pub fn sq_git_path() -> Result<PathBuf> {
Ok(env!("CARGO_BIN_EXE_sq-git").into())
}
pub fn sq_git<A: AsRef<str>>(&self, args: &[A]) -> std::result::Result<Output, Error> {
self.sq_git_in(None, args)
}
pub fn sq_git_in<'a, P: Into<Option<&'a Path>>, A: AsRef<str>>(
&self, wd: P, args: &[A])
-> std::result::Result<Output, Error>
{
let mut c = self.command(Self::sq_git_path()?);
c.arg("--output-format=json");
for a in args {
c.arg(a.as_ref());
}
self.run_in(wd, c)
}
pub fn spawn(&self, mut c: Command) -> Result<Child> {
self.spawn_in(None, c)
}
pub fn spawn_in<'a, P: Into<Option<&'a Path>>>(&self, wd: P, mut c: Command)
-> Result<Child>
{
let git_state = self.git_state();
let wd = wd.into().unwrap_or(git_state.as_path());
let env: Vec<_> = c.get_envs()
.filter_map(|(k, v)| {
if let Some(v) = v {
Some((k.to_os_string(), v.to_os_string()))
} else {
None
}
})
.collect::<Vec<(OsString, OsString)>>();
let c = c.current_dir(wd)
.env_clear() .envs(std::env::vars()
.filter(|(k, _)| ! k.starts_with("GIT_"))
.collect::<Vec<_>>())
.env("SEQUOIA_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())
.envs(env);
Ok(c
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?)
}
pub fn run(&self, c: Command) -> Result<Output> {
self.run_in(None, c)
}
pub fn run_in<'a, P: Into<Option<&'a Path>>>(&self, wd: P, c: Command)
-> Result<Output>
{
let cmd = format!("{:?}", c);
eprintln!("$ {}", cmd);
let output = self.spawn_in(wd, c)
.map_err(|err| {
eprintln!(" -> failed to spawn command: {}", err);
err
})?
.wait_with_output()
.map_err(|err| {
eprintln!(" -> waiting for command to complete: {}", err);
err
})?;
if output.status.success() {
eprintln!(" -> success");
} else {
eprintln!(" -> failure");
}
eprintln!("stdout:\n{}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr:\n{}", String::from_utf8_lossy(&output.stderr));
if output.status.success() {
Ok(output)
} else {
Err(Error::CliError(cmd, output))
}
}
pub fn serialize_cert(&self, name: &str, cert: &Cert) -> String {
let mut bytes = Vec::new();
cert.as_tsk().serialize(&mut bytes)
.expect("serializing to a vec is infallible");
let cert_pgp = self.scratch_state().join(format!("{}.pgp", name));
let mut f = File::create_new(&cert_pgp).expect("can create file");
f.write_all(&bytes).expect("can write");
drop(f);
format!("{}", cert_pgp.display())
}
pub fn gen<C, V>(&self, localpart: &str, creation_time: C, validity: V)
-> (Cert, String)
where C: Into<Option<SystemTime>>,
V: Into<Option<Duration>>,
{
let mut builder = CertBuilder::new()
.add_userid(&format!("<{}@example.org>", localpart)[..])
.add_signing_subkey();
if let Some(t) = creation_time.into() {
builder = builder.set_creation_time(t);
}
if let Some(v) = validity.into() {
builder = builder.set_validity_period(v);
}
let (cert, _rev) = builder
.generate()
.expect("can generate a key");
let filename = self.serialize_cert(localpart, &cert);
self.import(&cert).expect("can import");
(cert, filename)
}
pub fn check_export<'a, E, C>(&self,
entity: E,
commit: C,
expected: &[ &Cert ])
where
E: Into<Option<&'a str>>,
C: Into<Option<&'a str>>,
{
let entity = entity.into();
let commit = commit.into();
let mut args = vec![ "policy", "export" ];
if let Some(entity) = entity {
args.push("--name");
args.push(entity);
} else {
args.push("--all");
}
if let Some(commit) = commit {
args.extend(&[ "--commit", commit]);
}
let got = if let Ok(output) = self.sq_git(&args[..]) {
CertParser::from_bytes(&output.stdout)
.expect("can parse keyring")
.map(|r| r.map_err(Into::into))
.collect::<Result<Vec<Cert>>>()
.expect("can parse all certificates")
} else {
eprintln!("Warning: sq-git policy export failed: entity \
appears to not exist");
Vec::new()
};
let got: BTreeMap<Fingerprint, Cert>
= BTreeMap::from_iter(got.into_iter().map(|c| (c.fingerprint(), c)));
let got_fprs = BTreeSet::from_iter(got.keys());
let expected: BTreeMap<Fingerprint, Cert>
= BTreeMap::from_iter(expected.iter().map(|&c| {
(c.fingerprint(), c.clone().strip_secret_key_material())
}));
let expected_fprs = BTreeSet::from_iter(expected.keys());
let mut die = false;
for unexpected in got_fprs.difference(&expected_fprs) {
let c = got.get(unexpected).unwrap();
eprintln!("Unexpectedly got {} ({})",
unexpected,
c.userids().next().map(|ua| {
String::from_utf8_lossy(ua.userid().value())
})
.unwrap_or("<unknown>".into()));
die = true;
}
for missing in expected_fprs.difference(&got_fprs) {
let c = expected.get(missing).unwrap();
eprintln!("Missing {} ({})",
missing,
c.userids().next().map(|ua| {
String::from_utf8_lossy(ua.userid().value())
})
.unwrap_or("<unknown>".into()));
die = true;
}
for fpr in expected_fprs.intersection(&got_fprs) {
let expected = expected.get(fpr).unwrap();
let got = got.get(fpr).unwrap();
if expected != got {
eprintln!("{} ({}) differs",
fpr,
expected.userids().next().map(|ua| {
String::from_utf8_lossy(ua.userid().value())
})
.unwrap_or("<unknown>".into()));
eprintln!("Got ({} packets):",
got.clone().into_packets().count());
let mut bytes = Vec::new();
got.as_tsk().armored().serialize(&mut bytes)
.expect("serializing to a vec is infallible");
eprintln!("{}", String::from_utf8_lossy(&bytes));
eprintln!("Expected ({} packets):",
expected.clone().into_packets().count());
let mut bytes = Vec::new();
expected.as_tsk().armored().serialize(&mut bytes)
.expect("serializing to a vec is infallible");
eprintln!("{}", String::from_utf8_lossy(&bytes));
die = true;
}
}
if die {
panic!("{}'s certificates are unexpected",
entity.unwrap_or("--all"));
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("command failed\n$ {}\nstdout:\n{}\n\nstderr:\n{}",
.0,
String::from_utf8_lossy(&.1.stdout),
String::from_utf8_lossy(&.1.stderr))]
CliError(String, std::process::Output),
#[error("`faketime` not available (to skip tests that need `faketime`, \
set the NO_FAKETIME environment variable)\n\
\nstdout:\n{}\n\
\nstderr:\n{}",
String::from_utf8_lossy(&.0.stdout),
String::from_utf8_lossy(&.0.stderr))]
NoFaketime(std::process::Output),
#[error(transparent)]
Other(#[from] anyhow::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Utf8(#[from] std::string::FromUtf8Error),
}
fn sh_quote<'s, S: AsRef<str> + 's>(s: S) -> String {
let s = s.as_ref();
if s.contains(char::is_whitespace) {
format!("{:?}", s)
} else {
s.to_string()
}
}