memstead_cli/auth/
domain_key.rs1use std::path::PathBuf;
13
14use anyhow::{Context, Result};
15use base64::Engine as _;
16use base64::engine::general_purpose::STANDARD as BASE64;
17use ed25519_dalek::{Signer, SigningKey};
18use memstead_base::domain_authority_wire::ALG;
19use serde_json::json;
20
21pub fn keys_dir() -> Result<PathBuf> {
24 if let Ok(dir) = std::env::var("MEMSTEAD_DOMAIN_KEYS_DIR")
25 && !dir.is_empty()
26 {
27 return Ok(PathBuf::from(dir));
28 }
29 let base = dirs::config_dir()
30 .context("no config directory resolvable on this platform (set $XDG_CONFIG_HOME)")?;
31 Ok(base.join("memstead").join("domain-keys"))
32}
33
34fn key_path(domain: &str) -> Result<PathBuf> {
37 Ok(keys_dir()?.join(format!("{domain}.key")))
38}
39
40pub fn exists(domain: &str) -> Result<bool> {
42 Ok(key_path(domain)?.exists())
43}
44
45pub fn generate(domain: &str, force: bool) -> Result<String> {
50 if exists(domain)? && !force {
51 anyhow::bail!(
52 "a signing key already exists for {domain}; pass --force to replace it \
53 (this rotates the key — update the hosted manifest to the new public key)"
54 );
55 }
56 let mut rng = rand_core::OsRng;
57 let signing = SigningKey::generate(&mut rng);
58 save(domain, &signing)?;
59 Ok(public_key_string(&signing))
60}
61
62fn save(domain: &str, signing: &SigningKey) -> Result<()> {
64 let path = key_path(domain)?;
65 if let Some(parent) = path.parent() {
66 std::fs::create_dir_all(parent)
67 .with_context(|| format!("creating domain-keys dir at {}", parent.display()))?;
68 }
69 let body = BASE64.encode(signing.to_bytes());
70 std::fs::write(&path, body)
71 .with_context(|| format!("writing domain key at {}", path.display()))?;
72 tighten_permissions(&path)?;
73 Ok(())
74}
75
76pub fn load(domain: &str) -> Result<SigningKey> {
78 let path = key_path(domain)?;
79 let body = match std::fs::read_to_string(&path) {
80 Ok(s) => s,
81 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
82 anyhow::bail!(
83 "no signing key for {domain} — run `memstead domain keygen --domain {domain} \
84 --contact <email>` first, then host the printed manifest"
85 );
86 }
87 Err(e) => {
88 return Err(e).with_context(|| format!("reading domain key at {}", path.display()));
89 }
90 };
91 let bytes = BASE64
92 .decode(body.trim())
93 .with_context(|| format!("decoding domain key at {}", path.display()))?;
94 let seed: [u8; 32] = bytes
95 .as_slice()
96 .try_into()
97 .map_err(|_| anyhow::anyhow!("domain key at {} is not a 32-byte seed", path.display()))?;
98 Ok(SigningKey::from_bytes(&seed))
99}
100
101pub fn public_key_string(signing: &SigningKey) -> String {
104 format!(
105 "{ALG}:{}",
106 BASE64.encode(signing.verifying_key().to_bytes())
107 )
108}
109
110pub fn sign(signing: &SigningKey, payload: &[u8]) -> String {
112 format!("{ALG}:{}", BASE64.encode(signing.sign(payload).to_bytes()))
113}
114
115pub fn manifest_json(public_keys: &[String], contacts: &[String]) -> serde_json::Value {
117 json!({
118 "memstead_publishing": true,
119 "publish_keys": public_keys,
120 "contacts": contacts,
121 })
122}
123
124#[cfg(unix)]
125fn tighten_permissions(path: &std::path::Path) -> Result<()> {
126 use std::os::unix::fs::PermissionsExt;
127 let perms = std::fs::Permissions::from_mode(0o600);
128 std::fs::set_permissions(path, perms)
129 .with_context(|| format!("setting mode 0600 on {}", path.display()))?;
130 Ok(())
131}
132
133#[cfg(not(unix))]
134fn tighten_permissions(_: &std::path::Path) -> Result<()> {
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use memstead_base::domain_authority_wire::signing_payload;
142 use std::sync::Mutex;
143 use tempfile::TempDir;
144
145 static ENV_LOCK: Mutex<()> = Mutex::new(());
148
149 fn with_keys_dir<T>(f: impl FnOnce() -> T) -> T {
150 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
151 let tmp = TempDir::new().unwrap();
152 unsafe { std::env::set_var("MEMSTEAD_DOMAIN_KEYS_DIR", tmp.path()) };
154 let out = f();
155 unsafe { std::env::remove_var("MEMSTEAD_DOMAIN_KEYS_DIR") };
156 out
157 }
158
159 #[test]
160 fn generate_then_load_roundtrips_and_signs_verifiably() {
161 with_keys_dir(|| {
162 let pk = generate("acme.com", false).unwrap();
163 assert!(pk.starts_with("ed25519:"));
164 let sk = load("acme.com").unwrap();
166 assert_eq!(public_key_string(&sk), pk);
167 let payload = signing_payload("hash", "acme.com:demo", "v", "1.0.0", 1000);
169 let sig = sign(&sk, &payload);
170 assert!(sig.starts_with("ed25519:"));
171 });
172 }
173
174 #[test]
175 fn generate_refuses_to_clobber_without_force() {
176 with_keys_dir(|| {
177 let pk1 = generate("acme.com", false).unwrap();
178 assert!(
179 generate("acme.com", false).is_err(),
180 "must not clobber silently"
181 );
182 let pk2 = generate("acme.com", true).unwrap();
184 assert_ne!(pk1, pk2, "force must produce a new key");
185 });
186 }
187
188 #[test]
189 fn load_missing_key_is_actionable() {
190 with_keys_dir(|| {
191 let err = load("nope.com").unwrap_err().to_string();
192 assert!(err.contains("keygen"), "error must point to keygen: {err}");
193 });
194 }
195
196 #[test]
197 fn manifest_has_marker_keys_and_contacts() {
198 let m = manifest_json(
199 &["ed25519:AAAA".to_string()],
200 &["mailto:abuse@acme.com".to_string()],
201 );
202 assert_eq!(m["memstead_publishing"], true);
203 assert_eq!(m["publish_keys"][0], "ed25519:AAAA");
204 assert_eq!(m["contacts"][0], "mailto:abuse@acme.com");
205 }
206}