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    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
62/// Persist `signing`'s seed (base64) to the key file, mode 0600.
63fn 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
76/// Load the signing key for `domain`. Errors actionably if none is stored.
77pub 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
101/// `ed25519:<base64>` public-key string for a signing key — the form listed in
102/// the manifest and presented on a publish.
103pub fn public_key_string(signing: &SigningKey) -> String {
104    format!(
105        "{ALG}:{}",
106        BASE64.encode(signing.verifying_key().to_bytes())
107    )
108}
109
110/// Sign `payload`, returning the `ed25519:<base64>` signature string.
111pub fn sign(signing: &SigningKey, payload: &[u8]) -> String {
112    format!("{ALG}:{}", BASE64.encode(signing.sign(payload).to_bytes()))
113}
114
115/// The proof manifest to host at `https://<domain>/.well-known/memstead-publishing.json`.
116pub 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    // `MEMSTEAD_DOMAIN_KEYS_DIR` is process-global; serialize the env-dependent
146    // tests so parallel runs don't clobber each other's override.
147    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        // SAFETY: the lock serializes env access across this module's tests.
153        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            // The stored key reproduces the same public key.
165            let sk = load("acme.com").unwrap();
166            assert_eq!(public_key_string(&sk), pk);
167            // A signature it makes verifies under the published public key.
168            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            // Force rotates to a new key.
183            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}