Skip to main content

lean_ctx/gateway_server/
keys_cli.rs

1//! `lean-ctx gateway keys` (enterprise#48) — per-person key management for
2//! `gateway-keys.toml`, replacing the manual `openssl rand | shasum` dance.
3//!
4//! Storage rule is unchanged (enterprise#11): the file holds **only** SHA-256
5//! hashes; the plaintext key is printed exactly once at creation and never
6//! touches disk. Writes are atomic (temp file + rename) so a concurrent
7//! gateway restart never sees a half-written key set.
8
9use std::path::{Path, PathBuf};
10
11use crate::proxy::gateway_identity::{GatewayKeys, sha256_hex};
12
13/// Prefix of generated keys — recognizable in client configs and log redaction.
14const KEY_PREFIX: &str = "gk";
15
16/// Random bytes per generated key (hex-encoded → 48 chars of entropy).
17const KEY_RANDOM_BYTES: usize = 24;
18
19/// A parsed identity row for `list` (no hash material beyond a short prefix).
20#[derive(Debug, PartialEq, Eq)]
21pub struct KeyListEntry {
22    pub person: String,
23    pub team: Option<String>,
24    pub default_project: Option<String>,
25    /// First 8 hex chars of the stored hash — enough to correlate with the
26    /// file when revoking, useless for authentication.
27    pub sha_prefix: String,
28}
29
30/// Generates a new bearer key: `gk-<person-slug>-<48 hex chars>`.
31///
32/// # Errors
33/// Fails only if the OS CSPRNG is unavailable.
34pub fn generate_key(person: &str) -> anyhow::Result<String> {
35    let slug: String = person
36        .chars()
37        .map(|c| {
38            if c.is_ascii_alphanumeric() {
39                c.to_ascii_lowercase()
40            } else {
41                '-'
42            }
43        })
44        .collect::<String>()
45        .split('-')
46        .filter(|s| !s.is_empty())
47        .collect::<Vec<_>>()
48        .join("-");
49    let slug = if slug.is_empty() { "key" } else { &slug };
50    let mut buf = [0u8; KEY_RANDOM_BYTES];
51    getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("CSPRNG unavailable: {e}"))?;
52    let hex: String = buf.iter().fold(String::new(), |mut acc, b| {
53        use std::fmt::Write as _;
54        let _ = write!(acc, "{b:02x}");
55        acc
56    });
57    Ok(format!("{KEY_PREFIX}-{slug}-{hex}"))
58}
59
60/// Appends a `[[keys]]` entry. Preserves existing content (comments included)
61/// by appending; refuses a duplicate person unless `allow_multiple`.
62///
63/// Returns the plaintext key (print once, never store).
64///
65/// # Errors
66/// Fails on unreadable/unparsable files, duplicate person, or write errors.
67pub fn add_key(
68    path: &Path,
69    person: &str,
70    team: Option<&str>,
71    default_project: Option<&str>,
72    allow_multiple: bool,
73) -> anyhow::Result<String> {
74    let person = person.trim();
75    anyhow::ensure!(!person.is_empty(), "person must not be empty");
76
77    // Validate current file first: never append to a broken key set.
78    let existing = GatewayKeys::load(path)
79        .map_err(|e| anyhow::anyhow!("existing key file is invalid — fix it first: {e}"))?;
80    if !allow_multiple
81        && list_keys(path)?
82            .iter()
83            .any(|k| k.person.eq_ignore_ascii_case(person))
84    {
85        anyhow::bail!(
86            "person '{person}' already has a key (revoke it first, or pass --allow-multiple \
87             for an intentional second key)"
88        );
89    }
90    drop(existing);
91
92    let key = generate_key(person)?;
93    let sha = sha256_hex(&key);
94
95    let mut body = if path.exists() {
96        std::fs::read_to_string(path)?
97    } else {
98        String::from(
99            "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
100             # Managed by `lean-ctx gateway keys`; manual edits are fine (same format).\n",
101        )
102    };
103    if !body.is_empty() && !body.ends_with('\n') {
104        body.push('\n');
105    }
106    body.push_str("\n[[keys]]\n");
107    body.push_str(&format!("sha256_hex = \"{sha}\"\n"));
108    body.push_str(&format!("person = \"{}\"\n", toml_escape(person)));
109    if let Some(team) = team.map(str::trim).filter(|t| !t.is_empty()) {
110        body.push_str(&format!("team = \"{}\"\n", toml_escape(team)));
111    }
112    if let Some(project) = default_project.map(str::trim).filter(|p| !p.is_empty()) {
113        body.push_str(&format!("default_project = \"{}\"\n", toml_escape(project)));
114    }
115
116    write_atomic(path, &body)?;
117    // Sanity: the file we just wrote must parse and contain the new key.
118    let reloaded = GatewayKeys::load(path)?;
119    anyhow::ensure!(
120        reloaded.lookup(&key).is_some(),
121        "post-write validation failed — key not resolvable"
122    );
123    Ok(key)
124}
125
126/// Lists identities (person/team/project + hash prefix), file order.
127///
128/// # Errors
129/// Fails on unreadable or unparsable files.
130pub fn list_keys(path: &Path) -> anyhow::Result<Vec<KeyListEntry>> {
131    if !path.exists() {
132        return Ok(Vec::new());
133    }
134    let raw = std::fs::read_to_string(path)?;
135    let value: toml::Value = toml::from_str(&raw)?;
136    let mut out = Vec::new();
137    for entry in value
138        .get("keys")
139        .and_then(|k| k.as_array())
140        .unwrap_or(&Vec::new())
141    {
142        let str_of = |k: &str| {
143            entry
144                .get(k)
145                .and_then(|v| v.as_str())
146                .map(str::trim)
147                .filter(|s| !s.is_empty())
148                .map(str::to_string)
149        };
150        out.push(KeyListEntry {
151            person: str_of("person").unwrap_or_else(|| "?".into()),
152            team: str_of("team"),
153            default_project: str_of("default_project"),
154            sha_prefix: str_of("sha256_hex")
155                .map(|s| s.chars().take(8).collect())
156                .unwrap_or_default(),
157        });
158    }
159    Ok(out)
160}
161
162/// The result of a key rotation: the fresh plaintext key plus the identity it
163/// kept and how many old entries it replaced.
164#[derive(Debug)]
165pub struct RotatedKey {
166    pub key: String,
167    pub team: Option<String>,
168    pub default_project: Option<String>,
169    pub replaced: usize,
170}
171
172/// Rotates `person`'s key (enterprise#67): mints a fresh key, drops every old
173/// entry of that person and writes the replacement **in one atomic swap** —
174/// there is no intermediate state where the person has zero valid keys on
175/// disk. Team and default project carry over from the person's first entry.
176///
177/// # Errors
178/// Fails when the person has no key (use `add`), on unreadable/unparsable
179/// files, or on write errors.
180pub fn rotate_key(path: &Path, person: &str) -> anyhow::Result<RotatedKey> {
181    let person = person.trim();
182    anyhow::ensure!(!person.is_empty(), "person must not be empty");
183
184    let existing = list_keys(path)?;
185    let current: Vec<&KeyListEntry> = existing
186        .iter()
187        .filter(|k| k.person.eq_ignore_ascii_case(person))
188        .collect();
189    anyhow::ensure!(
190        !current.is_empty(),
191        "no key for '{person}' in {} — use: lean-ctx gateway keys add --person={person}",
192        path.display()
193    );
194    // Keep the ledger identity exactly as stored — the caller may have typed
195    // a different case, but usage_events attribution must not fork.
196    let person = current[0].person.clone();
197    let person = person.as_str();
198    let team = current[0].team.clone();
199    let default_project = current[0].default_project.clone();
200    let replaced = current.len();
201
202    let key = generate_key(person)?;
203    let sha = sha256_hex(&key);
204
205    // Rebuild the file: keep everyone else's entries, replace this person's.
206    let raw = std::fs::read_to_string(path)?;
207    let mut value: toml::Value = toml::from_str(&raw)?;
208    let keys = value
209        .get_mut("keys")
210        .and_then(|k| k.as_array_mut())
211        .ok_or_else(|| anyhow::anyhow!("no [[keys]] entries in {}", path.display()))?;
212    keys.retain(|entry| {
213        entry
214            .get("person")
215            .and_then(|p| p.as_str())
216            .is_none_or(|p| !p.trim().eq_ignore_ascii_case(person))
217    });
218    let mut fresh = toml::value::Table::new();
219    fresh.insert("sha256_hex".into(), toml::Value::String(sha));
220    fresh.insert("person".into(), toml::Value::String(person.to_string()));
221    if let Some(team) = team.as_deref() {
222        fresh.insert("team".into(), toml::Value::String(team.to_string()));
223    }
224    if let Some(project) = default_project.as_deref() {
225        fresh.insert(
226            "default_project".into(),
227            toml::Value::String(project.to_string()),
228        );
229    }
230    keys.push(toml::Value::Table(fresh));
231
232    let mut body = String::from(
233        "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
234         # Managed by `lean-ctx gateway keys`; manual edits are fine (same format).\n",
235    );
236    body.push_str(&toml::to_string_pretty(&value)?);
237    write_atomic(path, &body)?;
238
239    // Post-write validation: the new key must resolve with the old identity.
240    let reloaded = GatewayKeys::load(path)?;
241    let tags = reloaded
242        .lookup(&key)
243        .ok_or_else(|| anyhow::anyhow!("post-write validation failed — key not resolvable"))?;
244    anyhow::ensure!(
245        tags.person.as_deref() == Some(person),
246        "post-write validation failed — identity mismatch"
247    );
248
249    Ok(RotatedKey {
250        key,
251        team,
252        default_project,
253        replaced,
254    })
255}
256
257/// Removes all keys of `person` (rewrites the file). Returns how many entries
258/// were removed.
259///
260/// # Errors
261/// Fails on unreadable/unparsable files or write errors.
262pub fn revoke_keys(path: &Path, person: &str) -> anyhow::Result<usize> {
263    anyhow::ensure!(path.exists(), "no key file at {}", path.display());
264    let raw = std::fs::read_to_string(path)?;
265    let mut value: toml::Value = toml::from_str(&raw)?;
266    let keys = value
267        .get_mut("keys")
268        .and_then(|k| k.as_array_mut())
269        .ok_or_else(|| anyhow::anyhow!("no [[keys]] entries in {}", path.display()))?;
270    let before = keys.len();
271    keys.retain(|entry| {
272        entry
273            .get("person")
274            .and_then(|p| p.as_str())
275            .is_none_or(|p| !p.trim().eq_ignore_ascii_case(person.trim()))
276    });
277    let removed = before - keys.len();
278    if removed > 0 {
279        let mut body = String::from(
280            "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
281             # Managed by `lean-ctx gateway keys`; manual edits are fine (same format).\n",
282        );
283        body.push_str(&toml::to_string_pretty(&value)?);
284        write_atomic(path, &body)?;
285        GatewayKeys::load(path)?; // post-write validation
286    }
287    Ok(removed)
288}
289
290/// Creates a valid, empty key file (deploy mounts require the file to exist).
291///
292/// # Errors
293/// Fails on I/O errors; refuses to touch an existing file.
294pub fn write_empty(path: &Path) -> anyhow::Result<()> {
295    anyhow::ensure!(!path.exists(), "{} already exists", path.display());
296    write_atomic(
297        path,
298        "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
299         # Add people: lean-ctx gateway keys add --person alice@example.com --file <this file>\n\
300         keys = []\n",
301    )
302}
303
304fn toml_escape(s: &str) -> String {
305    s.replace('\\', "\\\\").replace('"', "\\\"")
306}
307
308/// Temp-file + rename in the target directory (same-filesystem atomic swap).
309fn write_atomic(path: &Path, contents: &str) -> anyhow::Result<()> {
310    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
311    if let Some(dir) = dir {
312        std::fs::create_dir_all(dir)?;
313    }
314    let tmp: PathBuf = path.with_extension("toml.tmp");
315    std::fs::write(&tmp, contents)?;
316    #[cfg(unix)]
317    {
318        use std::os::unix::fs::PermissionsExt;
319        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
320    }
321    std::fs::rename(&tmp, path)?;
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn generated_keys_are_unique_and_well_formed() {
331        let a = generate_key("Alice Meier").unwrap();
332        let b = generate_key("Alice Meier").unwrap();
333        assert_ne!(a, b);
334        assert!(a.starts_with("gk-alice-meier-"), "got {a}");
335        let hex = a.rsplit('-').next().unwrap();
336        assert_eq!(hex.len(), KEY_RANDOM_BYTES * 2);
337        assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
338        // Degenerate person names still produce a usable slug.
339        assert!(generate_key("!!!").unwrap().starts_with("gk-key-"));
340    }
341
342    #[test]
343    fn add_list_revoke_round_trip() {
344        let tmp = tempfile::tempdir().unwrap();
345        let path = tmp.path().join("gateway-keys.toml");
346
347        let key1 = add_key(
348            &path,
349            "alice@zuehlke.com",
350            Some("platform"),
351            Some("checkout"),
352            false,
353        )
354        .unwrap();
355        let key2 = add_key(&path, "bob@zuehlke.com", None, None, false).unwrap();
356
357        // Plaintext resolves through the real auth loader.
358        let keys = GatewayKeys::load(&path).unwrap();
359        let alice = keys.lookup(&key1).expect("alice key resolves");
360        assert_eq!(alice.person.as_deref(), Some("alice@zuehlke.com"));
361        assert_eq!(alice.team.as_deref(), Some("platform"));
362        assert_eq!(alice.project.as_deref(), Some("checkout"));
363        assert!(keys.lookup(&key2).is_some());
364
365        // list shows identities, not hashes.
366        let listed = list_keys(&path).unwrap();
367        assert_eq!(listed.len(), 2);
368        assert_eq!(listed[0].person, "alice@zuehlke.com");
369        assert_eq!(listed[0].sha_prefix.len(), 8);
370
371        // Duplicate person is refused unless explicitly allowed.
372        assert!(add_key(&path, "alice@zuehlke.com", None, None, false).is_err());
373        assert!(add_key(&path, "alice@zuehlke.com", None, None, true).is_ok());
374
375        // Revoke removes all of alice's keys, bob survives.
376        let removed = revoke_keys(&path, "ALICE@zuehlke.com").unwrap();
377        assert_eq!(removed, 2);
378        let keys = GatewayKeys::load(&path).unwrap();
379        assert!(keys.lookup(&key1).is_none());
380        assert!(keys.lookup(&key2).is_some());
381    }
382
383    #[test]
384    fn rotate_replaces_key_atomically_and_keeps_identity() {
385        let tmp = tempfile::tempdir().unwrap();
386        let path = tmp.path().join("gateway-keys.toml");
387
388        let old_key = add_key(
389            &path,
390            "alice@zuehlke.com",
391            Some("platform"),
392            Some("checkout"),
393            false,
394        )
395        .unwrap();
396        let bob_key = add_key(&path, "bob@zuehlke.com", None, None, false).unwrap();
397
398        let rotated = rotate_key(&path, "ALICE@zuehlke.com").unwrap();
399        assert_eq!(rotated.replaced, 1);
400        assert_eq!(rotated.team.as_deref(), Some("platform"));
401        assert_eq!(rotated.default_project.as_deref(), Some("checkout"));
402        assert_ne!(rotated.key, old_key);
403
404        let keys = GatewayKeys::load(&path).unwrap();
405        // Old key is dead, new key carries the identical identity, bob intact.
406        assert!(keys.lookup(&old_key).is_none());
407        let alice = keys.lookup(&rotated.key).expect("new key resolves");
408        assert_eq!(alice.person.as_deref(), Some("alice@zuehlke.com"));
409        assert_eq!(alice.team.as_deref(), Some("platform"));
410        assert_eq!(alice.project.as_deref(), Some("checkout"));
411        assert!(keys.lookup(&bob_key).is_some());
412
413        // Rotating an unknown person is a hard error, not a silent add.
414        assert!(rotate_key(&path, "carol@zuehlke.com").is_err());
415    }
416
417    #[test]
418    fn rotate_collapses_multiple_keys_into_one() {
419        let tmp = tempfile::tempdir().unwrap();
420        let path = tmp.path().join("gateway-keys.toml");
421        let k1 = add_key(&path, "alice", Some("platform"), None, false).unwrap();
422        let k2 = add_key(&path, "alice", None, None, true).unwrap();
423
424        let rotated = rotate_key(&path, "alice").unwrap();
425        assert_eq!(rotated.replaced, 2);
426        // Both old keys die; exactly one entry remains for alice.
427        let keys = GatewayKeys::load(&path).unwrap();
428        assert!(keys.lookup(&k1).is_none());
429        assert!(keys.lookup(&k2).is_none());
430        assert!(keys.lookup(&rotated.key).is_some());
431        let listed = list_keys(&path).unwrap();
432        assert_eq!(
433            listed.iter().filter(|e| e.person == "alice").count(),
434            1,
435            "rotation must collapse duplicates"
436        );
437    }
438
439    #[test]
440    fn file_permissions_are_owner_only_on_unix() {
441        #[cfg(unix)]
442        {
443            use std::os::unix::fs::PermissionsExt;
444            let tmp = tempfile::tempdir().unwrap();
445            let path = tmp.path().join("gateway-keys.toml");
446            add_key(&path, "alice", None, None, false).unwrap();
447            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
448            assert_eq!(mode & 0o777, 0o600, "keys file must be owner-only");
449        }
450    }
451}