use std::path::PathBuf;
use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use ed25519_dalek::{Signer, SigningKey};
use memstead_base::domain_authority_wire::ALG;
use serde_json::json;
pub fn keys_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("MEMSTEAD_DOMAIN_KEYS_DIR")
&& !dir.is_empty()
{
return Ok(PathBuf::from(dir));
}
let base = dirs::config_dir()
.context("no config directory resolvable on this platform (set $XDG_CONFIG_HOME)")?;
Ok(base.join("memstead").join("domain-keys"))
}
fn key_path(domain: &str) -> Result<PathBuf> {
Ok(keys_dir()?.join(format!("{domain}.key")))
}
pub fn exists(domain: &str) -> Result<bool> {
Ok(key_path(domain)?.exists())
}
pub fn generate(domain: &str, force: bool) -> Result<String> {
if exists(domain)? && !force {
anyhow::bail!(
"a signing key already exists for {domain}; pass --force to replace it \
(this rotates the key — update the hosted manifest to the new public key)"
);
}
let mut rng = rand_core::OsRng;
let signing = SigningKey::generate(&mut rng);
save(domain, &signing)?;
Ok(public_key_string(&signing))
}
fn save(domain: &str, signing: &SigningKey) -> Result<()> {
let path = key_path(domain)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating domain-keys dir at {}", parent.display()))?;
}
let body = BASE64.encode(signing.to_bytes());
std::fs::write(&path, body)
.with_context(|| format!("writing domain key at {}", path.display()))?;
tighten_permissions(&path)?;
Ok(())
}
pub fn load(domain: &str) -> Result<SigningKey> {
let path = key_path(domain)?;
let body = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!(
"no signing key for {domain} — run `memstead domain keygen --domain {domain} \
--contact <email>` first, then host the printed manifest"
);
}
Err(e) => {
return Err(e).with_context(|| format!("reading domain key at {}", path.display()));
}
};
let bytes = BASE64
.decode(body.trim())
.with_context(|| format!("decoding domain key at {}", path.display()))?;
let seed: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("domain key at {} is not a 32-byte seed", path.display()))?;
Ok(SigningKey::from_bytes(&seed))
}
pub fn public_key_string(signing: &SigningKey) -> String {
format!(
"{ALG}:{}",
BASE64.encode(signing.verifying_key().to_bytes())
)
}
pub fn sign(signing: &SigningKey, payload: &[u8]) -> String {
format!("{ALG}:{}", BASE64.encode(signing.sign(payload).to_bytes()))
}
pub fn manifest_json(public_keys: &[String], contacts: &[String]) -> serde_json::Value {
json!({
"memstead_publishing": true,
"publish_keys": public_keys,
"contacts": contacts,
})
}
#[cfg(unix)]
fn tighten_permissions(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)
.with_context(|| format!("setting mode 0600 on {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn tighten_permissions(_: &std::path::Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use memstead_base::domain_authority_wire::signing_payload;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_keys_dir<T>(f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
unsafe { std::env::set_var("MEMSTEAD_DOMAIN_KEYS_DIR", tmp.path()) };
let out = f();
unsafe { std::env::remove_var("MEMSTEAD_DOMAIN_KEYS_DIR") };
out
}
#[test]
fn generate_then_load_roundtrips_and_signs_verifiably() {
with_keys_dir(|| {
let pk = generate("acme.com", false).unwrap();
assert!(pk.starts_with("ed25519:"));
let sk = load("acme.com").unwrap();
assert_eq!(public_key_string(&sk), pk);
let payload = signing_payload("hash", "acme.com:demo", "v", "1.0.0", 1000);
let sig = sign(&sk, &payload);
assert!(sig.starts_with("ed25519:"));
});
}
#[test]
fn generate_refuses_to_clobber_without_force() {
with_keys_dir(|| {
let pk1 = generate("acme.com", false).unwrap();
assert!(
generate("acme.com", false).is_err(),
"must not clobber silently"
);
let pk2 = generate("acme.com", true).unwrap();
assert_ne!(pk1, pk2, "force must produce a new key");
});
}
#[test]
fn load_missing_key_is_actionable() {
with_keys_dir(|| {
let err = load("nope.com").unwrap_err().to_string();
assert!(err.contains("keygen"), "error must point to keygen: {err}");
});
}
#[test]
fn manifest_has_marker_keys_and_contacts() {
let m = manifest_json(
&["ed25519:AAAA".to_string()],
&["mailto:abuse@acme.com".to_string()],
);
assert_eq!(m["memstead_publishing"], true);
assert_eq!(m["publish_keys"][0], "ed25519:AAAA");
assert_eq!(m["contacts"][0], "mailto:abuse@acme.com");
}
}