use std::env::{self, VarError};
use std::fs::{self, DirBuilder, OpenOptions};
use std::io::{ErrorKind, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use waos_package::SigningKey;
const VARIABLE: &str = "WAOS_SIGNING_KEY";
const KEY_FILE: &str = "signing.key";
const CI: &str = "CI";
pub fn signing_key() -> Result<SigningKey> {
if let Some(key) = key_in_variable(env::var(VARIABLE))? {
return Ok(key);
}
key_in_file(&key_path()?, env::var_os(CI).is_some())
}
fn key_in_variable(value: Result<String, VarError>) -> Result<Option<SigningKey>> {
let text = match value {
Ok(text) => text,
Err(VarError::NotPresent) => return Ok(None),
Err(VarError::NotUnicode(_)) => {
bail!("{VARIABLE} is not UTF-8 text; it holds the PEM text of your signing key")
}
};
if text.trim().is_empty() {
bail!(
"{VARIABLE} is set but empty: set it to the text of your signing key, the content of {KEY_FILE}, or unset it to use the key file"
);
}
let key =
SigningKey::from_pem(&text).with_context(|| format!("the signing key in {VARIABLE}"))?;
Ok(Some(key))
}
fn key_in_file(path: &Path, on_ci: bool) -> Result<SigningKey> {
if let Some(key) = read_key(path)? {
return Ok(key);
}
if on_ci {
bail!(
"{CI} is set and {VARIABLE} is not: set {VARIABLE} to the text of your signing key, the content of {KEY_FILE}, rather than sign with a key made here and thrown away with this machine"
);
}
let key = make_key(path)?;
say_made(&key, path);
Ok(key)
}
fn key_path() -> Result<PathBuf> {
let configuration = match env::var_os("XDG_CONFIG_HOME") {
Some(folder) if Path::new(&folder).is_absolute() => PathBuf::from(folder),
_ => env::home_dir()
.context("this system names no home folder to keep the signing key in")?
.join(".config"),
};
Ok(configuration.join("waos").join(KEY_FILE))
}
fn read_key(path: &Path) -> Result<Option<SigningKey>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error)
.with_context(|| format!("reading the signing key at {}", path.display()));
}
};
let key = SigningKey::from_pem(&text)
.with_context(|| format!("the signing key at {}", path.display()))?;
Ok(Some(key))
}
fn make_key(path: &Path) -> Result<SigningKey> {
let folder = path.parent().expect("the key file is in a folder");
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(folder)
.with_context(|| format!("making {}", folder.display()))?;
let key = SigningKey::generate();
let write = || {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(key.to_pem().as_bytes())?;
file.sync_all()
};
write().with_context(|| format!("writing a signing key at {}", path.display()))?;
Ok(key)
}
fn say_made(key: &SigningKey, path: &Path) {
eprintln!(
"cargo waos: made your signing key at {}; the packages it signs carry the publisher key {}",
path.display(),
key.publisher().fingerprint()
);
eprintln!(
"cargo waos: back it up: every update of a program you publish must be signed with it, and a program whose key is lost can be published again only under a new id"
);
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn key_file_in(folder: &Path) -> PathBuf {
folder.join("waos").join(KEY_FILE)
}
#[test]
fn a_key_is_made_once_and_then_read() {
let scratch = tempfile::tempdir().unwrap();
let path = key_file_in(scratch.path());
assert!(read_key(&path).unwrap().is_none());
let made = key_in_file(&path, false).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert_eq!(
key_in_file(&path, false).unwrap().publisher(),
made.publisher()
);
assert!(make_key(&path).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), text);
}
#[test]
fn only_the_user_may_read_the_key_file() {
let scratch = tempfile::tempdir().unwrap();
let path = key_file_in(scratch.path());
make_key(&path).unwrap();
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
}
#[test]
fn on_ci_a_key_is_read_but_never_made() {
let scratch = tempfile::tempdir().unwrap();
let path = key_file_in(scratch.path());
let error = key_in_file(&path, true).unwrap_err();
assert!(error.to_string().contains(VARIABLE), "{error}");
assert!(!path.exists());
let made = make_key(&path).unwrap();
assert_eq!(
key_in_file(&path, true).unwrap().publisher(),
made.publisher()
);
}
#[test]
fn a_key_file_that_holds_no_key_is_an_error_not_a_reason_to_make_one() {
let scratch = tempfile::tempdir().unwrap();
let path = key_file_in(scratch.path());
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "not a key").unwrap();
let error = key_in_file(&path, false).unwrap_err();
assert!(format!("{error:#}").contains("PKCS#8"), "{error:#}");
assert_eq!(fs::read_to_string(&path).unwrap(), "not a key");
}
#[test]
fn the_variable_holds_what_the_key_file_holds() {
let scratch = tempfile::tempdir().unwrap();
let path = key_file_in(scratch.path());
let made = make_key(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
let read = key_in_variable(Ok(text)).unwrap().unwrap();
assert_eq!(read.publisher(), made.publisher());
}
#[test]
fn an_unset_variable_leaves_the_key_to_the_file() {
assert!(
key_in_variable(Err(VarError::NotPresent))
.unwrap()
.is_none()
);
}
#[test]
fn an_empty_variable_is_refused_rather_than_making_a_key() {
for empty in ["", "\n"] {
let error = key_in_variable(Ok(empty.to_owned())).unwrap_err();
assert!(error.to_string().contains("empty"), "{error}");
}
}
#[test]
fn a_variable_that_holds_no_key_is_refused() {
let error = key_in_variable(Ok("a key".to_owned())).unwrap_err();
assert!(format!("{error:#}").contains(VARIABLE), "{error:#}");
}
}