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 use getrandom::{SysRng, rand_core::UnwrapErr};
57 let mut rng = UnwrapErr(SysRng);
58 let signing = SigningKey::generate(&mut rng);
59 save(domain, &signing)?;
60 Ok(public_key_string(&signing))
61}
62
63fn save(domain: &str, signing: &SigningKey) -> Result<()> {
65 let path = key_path(domain)?;
66 if let Some(parent) = path.parent() {
67 std::fs::create_dir_all(parent)
68 .with_context(|| format!("creating domain-keys dir at {}", parent.display()))?;
69 }
70 let body = BASE64.encode(signing.to_bytes());
71 std::fs::write(&path, body)
72 .with_context(|| format!("writing domain key at {}", path.display()))?;
73 tighten_permissions(&path)?;
74 Ok(())
75}
76
77pub fn load(domain: &str) -> Result<SigningKey> {
79 let path = key_path(domain)?;
80 let body = match std::fs::read_to_string(&path) {
81 Ok(s) => s,
82 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
83 anyhow::bail!(
84 "no signing key for {domain} — run `memstead domain keygen --domain {domain} \
85 --contact <email>` first, then host the printed manifest"
86 );
87 }
88 Err(e) => {
89 return Err(e).with_context(|| format!("reading domain key at {}", path.display()));
90 }
91 };
92 let bytes = BASE64
93 .decode(body.trim())
94 .with_context(|| format!("decoding domain key at {}", path.display()))?;
95 let seed: [u8; 32] = bytes
96 .as_slice()
97 .try_into()
98 .map_err(|_| anyhow::anyhow!("domain key at {} is not a 32-byte seed", path.display()))?;
99 Ok(SigningKey::from_bytes(&seed))
100}
101
102pub fn public_key_string(signing: &SigningKey) -> String {
105 format!(
106 "{ALG}:{}",
107 BASE64.encode(signing.verifying_key().to_bytes())
108 )
109}
110
111pub fn sign(signing: &SigningKey, payload: &[u8]) -> String {
113 format!("{ALG}:{}", BASE64.encode(signing.sign(payload).to_bytes()))
114}
115
116pub fn manifest_json(public_keys: &[String], contacts: &[String]) -> serde_json::Value {
118 json!({
119 "memstead_publishing": true,
120 "publish_keys": public_keys,
121 "contacts": contacts,
122 })
123}
124
125#[cfg(unix)]
126fn tighten_permissions(path: &std::path::Path) -> Result<()> {
127 use std::os::unix::fs::PermissionsExt;
128 let perms = std::fs::Permissions::from_mode(0o600);
129 std::fs::set_permissions(path, perms)
130 .with_context(|| format!("setting mode 0600 on {}", path.display()))?;
131 Ok(())
132}
133
134#[cfg(not(unix))]
135fn tighten_permissions(_: &std::path::Path) -> Result<()> {
136 Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use memstead_base::domain_authority_wire::signing_payload;
143 use std::sync::Mutex;
144 use tempfile::TempDir;
145
146 static ENV_LOCK: Mutex<()> = Mutex::new(());
149
150 fn with_keys_dir<T>(f: impl FnOnce() -> T) -> T {
151 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
152 let tmp = TempDir::new().unwrap();
153 unsafe { std::env::set_var("MEMSTEAD_DOMAIN_KEYS_DIR", tmp.path()) };
155 let out = f();
156 unsafe { std::env::remove_var("MEMSTEAD_DOMAIN_KEYS_DIR") };
157 out
158 }
159
160 #[test]
161 fn generate_then_load_roundtrips_and_signs_verifiably() {
162 with_keys_dir(|| {
163 let pk = generate("acme.com", false).unwrap();
164 assert!(pk.starts_with("ed25519:"));
165 let sk = load("acme.com").unwrap();
167 assert_eq!(public_key_string(&sk), pk);
168 let payload = signing_payload("hash", "acme.com:demo", "v", "1.0.0", 1000);
170 let sig = sign(&sk, &payload);
171 assert!(sig.starts_with("ed25519:"));
172 });
173 }
174
175 #[test]
176 fn generate_refuses_to_clobber_without_force() {
177 with_keys_dir(|| {
178 let pk1 = generate("acme.com", false).unwrap();
179 assert!(
180 generate("acme.com", false).is_err(),
181 "must not clobber silently"
182 );
183 let pk2 = generate("acme.com", true).unwrap();
185 assert_ne!(pk1, pk2, "force must produce a new key");
186 });
187 }
188
189 #[test]
190 fn load_missing_key_is_actionable() {
191 with_keys_dir(|| {
192 let err = load("nope.com").unwrap_err().to_string();
193 assert!(err.contains("keygen"), "error must point to keygen: {err}");
194 });
195 }
196
197 #[test]
198 fn manifest_has_marker_keys_and_contacts() {
199 let m = manifest_json(
200 &["ed25519:AAAA".to_string()],
201 &["mailto:abuse@acme.com".to_string()],
202 );
203 assert_eq!(m["memstead_publishing"], true);
204 assert_eq!(m["publish_keys"][0], "ed25519:AAAA");
205 assert_eq!(m["contacts"][0], "mailto:abuse@acme.com");
206 }
207}