Skip to main content

memstead_cli/auth/
domain_key.rs

1//! Local store for the ed25519 keys that authorise domain-scoped publishing.
2//!
3//! A domain publisher proves control by hosting a `.well-known` manifest that
4//! lists public keys, and signing each publish with the matching private key
5//! (see `memstead_base::domain_authority_wire`). This module owns the **private**
6//! half: one key per domain, stored under `~/.config/memstead/domain-keys/`, and
7//! the helpers to generate, load, sign with, and render the manifest for it.
8//!
9//! The key file holds the base64 of the 32-byte ed25519 seed, mode 0600. The
10//! directory can be overridden with `MEMSTEAD_DOMAIN_KEYS_DIR` (used by tests).
11
12use 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
21/// Directory holding per-domain key files. Honours `MEMSTEAD_DOMAIN_KEYS_DIR`
22/// (test hook), else `~/.config/memstead/domain-keys/`.
23pub 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
34/// Path to the key file for `domain`. The domain is a validated scope label
35/// (lowercase, dot-separated, no slashes), so it is a safe single path segment.
36fn key_path(domain: &str) -> Result<PathBuf> {
37    Ok(keys_dir()?.join(format!("{domain}.key")))
38}
39
40/// Is there already a key stored for `domain`?
41pub fn exists(domain: &str) -> Result<bool> {
42    Ok(key_path(domain)?.exists())
43}
44
45/// Generate a fresh keypair for `domain` and persist the private key. Refuses
46/// to overwrite an existing key unless `force` (rotation is deliberate — a lost
47/// old key cannot sign, so clobbering silently would strand published mems).
48/// Returns the new key's `ed25519:<base64>` public-key string.
49pub 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
63/// Persist `signing`'s seed (base64) to the key file, mode 0600.
64fn 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
77/// Load the signing key for `domain`. Errors actionably if none is stored.
78pub 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
102/// `ed25519:<base64>` public-key string for a signing key — the form listed in
103/// the manifest and presented on a publish.
104pub fn public_key_string(signing: &SigningKey) -> String {
105    format!(
106        "{ALG}:{}",
107        BASE64.encode(signing.verifying_key().to_bytes())
108    )
109}
110
111/// Sign `payload`, returning the `ed25519:<base64>` signature string.
112pub fn sign(signing: &SigningKey, payload: &[u8]) -> String {
113    format!("{ALG}:{}", BASE64.encode(signing.sign(payload).to_bytes()))
114}
115
116/// The proof manifest to host at `https://<domain>/.well-known/memstead-publishing.json`.
117pub 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    // `MEMSTEAD_DOMAIN_KEYS_DIR` is process-global; serialize the env-dependent
147    // tests so parallel runs don't clobber each other's override.
148    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        // SAFETY: the lock serializes env access across this module's tests.
154        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            // The stored key reproduces the same public key.
166            let sk = load("acme.com").unwrap();
167            assert_eq!(public_key_string(&sk), pk);
168            // A signature it makes verifies under the published public key.
169            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            // Force rotates to a new key.
184            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}