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    // `gateway init` scaffolds (and a full `revoke` serializes) the canonical
104    // empty set as a top-level `keys = []`. Appending a `[[keys]]` table to
105    // that body would make BOTH representations coexist — invalid TOML
106    // ("duplicate key", #716). The array form only ever encodes emptiness
107    // (entries are always written as `[[keys]]` tables), so drop it before
108    // appending the first entry.
109    body = body
110        .lines()
111        .filter(|line| line.trim() != "keys = []")
112        .collect::<Vec<_>>()
113        .join("\n");
114    if !body.is_empty() && !body.ends_with('\n') {
115        body.push('\n');
116    }
117    body.push_str("\n[[keys]]\n");
118    body.push_str(&format!("sha256_hex = \"{sha}\"\n"));
119    body.push_str(&format!("person = \"{}\"\n", toml_escape(person)));
120    if let Some(team) = team.map(str::trim).filter(|t| !t.is_empty()) {
121        body.push_str(&format!("team = \"{}\"\n", toml_escape(team)));
122    }
123    if let Some(project) = default_project.map(str::trim).filter(|p| !p.is_empty()) {
124        body.push_str(&format!("default_project = \"{}\"\n", toml_escape(project)));
125    }
126
127    // Validate the assembled body BEFORE the swap — a bad assembly must never
128    // replace a good file on disk (#716: write-then-validate left the file
129    // corrupted for every subsequent command).
130    let assembled = GatewayKeys::parse(&body, path)
131        .map_err(|e| anyhow::anyhow!("refusing to write an invalid key file: {e}"))?;
132    anyhow::ensure!(
133        assembled.lookup(&key).is_some(),
134        "pre-write validation failed — key not resolvable in assembled file"
135    );
136    write_atomic(path, &body)?;
137    Ok(key)
138}
139
140/// Lists identities (person/team/project + hash prefix), file order.
141///
142/// # Errors
143/// Fails on unreadable or unparsable files.
144pub fn list_keys(path: &Path) -> anyhow::Result<Vec<KeyListEntry>> {
145    if !path.exists() {
146        return Ok(Vec::new());
147    }
148    let raw = std::fs::read_to_string(path)?;
149    let value: toml::Value = toml::from_str(&raw)?;
150    let mut out = Vec::new();
151    for entry in value
152        .get("keys")
153        .and_then(|k| k.as_array())
154        .unwrap_or(&Vec::new())
155    {
156        let str_of = |k: &str| {
157            entry
158                .get(k)
159                .and_then(|v| v.as_str())
160                .map(str::trim)
161                .filter(|s| !s.is_empty())
162                .map(str::to_string)
163        };
164        out.push(KeyListEntry {
165            person: str_of("person").unwrap_or_else(|| "?".into()),
166            team: str_of("team"),
167            default_project: str_of("default_project"),
168            sha_prefix: str_of("sha256_hex")
169                .map(|s| s.chars().take(8).collect())
170                .unwrap_or_default(),
171        });
172    }
173    Ok(out)
174}
175
176/// The result of a key rotation: the fresh plaintext key plus the identity it
177/// kept and how many old entries it replaced.
178#[derive(Debug)]
179pub struct RotatedKey {
180    pub key: String,
181    pub team: Option<String>,
182    pub default_project: Option<String>,
183    pub replaced: usize,
184}
185
186/// Rotates `person`'s key (enterprise#67): mints a fresh key, drops every old
187/// entry of that person and writes the replacement **in one atomic swap** —
188/// there is no intermediate state where the person has zero valid keys on
189/// disk. Team and default project carry over from the person's first entry.
190///
191/// # Errors
192/// Fails when the person has no key (use `add`), on unreadable/unparsable
193/// files, or on write errors.
194pub fn rotate_key(path: &Path, person: &str) -> anyhow::Result<RotatedKey> {
195    let person = person.trim();
196    anyhow::ensure!(!person.is_empty(), "person must not be empty");
197
198    let existing = list_keys(path)?;
199    let current: Vec<&KeyListEntry> = existing
200        .iter()
201        .filter(|k| k.person.eq_ignore_ascii_case(person))
202        .collect();
203    anyhow::ensure!(
204        !current.is_empty(),
205        "no key for '{person}' in {} — use: lean-ctx gateway keys add --person={person}",
206        path.display()
207    );
208    // Keep the ledger identity exactly as stored — the caller may have typed
209    // a different case, but usage_events attribution must not fork.
210    let person = current[0].person.clone();
211    let person = person.as_str();
212    let team = current[0].team.clone();
213    let default_project = current[0].default_project.clone();
214    let replaced = current.len();
215
216    let key = generate_key(person)?;
217    let sha = sha256_hex(&key);
218
219    // Rebuild the file: keep everyone else's entries, replace this person's.
220    let raw = std::fs::read_to_string(path)?;
221    let mut value: toml::Value = toml::from_str(&raw)?;
222    let keys = value
223        .get_mut("keys")
224        .and_then(|k| k.as_array_mut())
225        .ok_or_else(|| anyhow::anyhow!("no [[keys]] entries in {}", path.display()))?;
226    keys.retain(|entry| {
227        entry
228            .get("person")
229            .and_then(|p| p.as_str())
230            .is_none_or(|p| !p.trim().eq_ignore_ascii_case(person))
231    });
232    let mut fresh = toml::value::Table::new();
233    fresh.insert("sha256_hex".into(), toml::Value::String(sha));
234    fresh.insert("person".into(), toml::Value::String(person.to_string()));
235    if let Some(team) = team.as_deref() {
236        fresh.insert("team".into(), toml::Value::String(team.to_string()));
237    }
238    if let Some(project) = default_project.as_deref() {
239        fresh.insert(
240            "default_project".into(),
241            toml::Value::String(project.to_string()),
242        );
243    }
244    keys.push(toml::Value::Table(fresh));
245
246    let mut body = String::from(
247        "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
248         # Managed by `lean-ctx gateway keys`; manual edits are fine (same format).\n",
249    );
250    body.push_str(&toml::to_string_pretty(&value)?);
251
252    // Pre-write validation (#716): the new key must resolve with the old
253    // identity in the assembled body — only then may it replace the file.
254    let assembled = GatewayKeys::parse(&body, path)
255        .map_err(|e| anyhow::anyhow!("refusing to write an invalid key file: {e}"))?;
256    let tags = assembled
257        .lookup(&key)
258        .ok_or_else(|| anyhow::anyhow!("pre-write validation failed — key not resolvable"))?;
259    anyhow::ensure!(
260        tags.person.as_deref() == Some(person),
261        "pre-write validation failed — identity mismatch"
262    );
263    write_atomic(path, &body)?;
264
265    Ok(RotatedKey {
266        key,
267        team,
268        default_project,
269        replaced,
270    })
271}
272
273/// Removes all keys of `person` (rewrites the file). Returns how many entries
274/// were removed.
275///
276/// # Errors
277/// Fails on unreadable/unparsable files or write errors.
278pub fn revoke_keys(path: &Path, person: &str) -> anyhow::Result<usize> {
279    anyhow::ensure!(path.exists(), "no key file at {}", path.display());
280    let raw = std::fs::read_to_string(path)?;
281    let mut value: toml::Value = toml::from_str(&raw)?;
282    let keys = value
283        .get_mut("keys")
284        .and_then(|k| k.as_array_mut())
285        .ok_or_else(|| anyhow::anyhow!("no [[keys]] entries in {}", path.display()))?;
286    let before = keys.len();
287    keys.retain(|entry| {
288        entry
289            .get("person")
290            .and_then(|p| p.as_str())
291            .is_none_or(|p| !p.trim().eq_ignore_ascii_case(person.trim()))
292    });
293    let removed = before - keys.len();
294    if removed > 0 {
295        let mut body = String::from(
296            "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
297             # Managed by `lean-ctx gateway keys`; manual edits are fine (same format).\n",
298        );
299        body.push_str(&toml::to_string_pretty(&value)?);
300        // Pre-write validation (#716) — never replace a good file with a bad one.
301        GatewayKeys::parse(&body, path)
302            .map_err(|e| anyhow::anyhow!("refusing to write an invalid key file: {e}"))?;
303        write_atomic(path, &body)?;
304    }
305    Ok(removed)
306}
307
308/// Creates a valid, empty key file (deploy mounts require the file to exist).
309///
310/// # Errors
311/// Fails on I/O errors; refuses to touch an existing file.
312pub fn write_empty(path: &Path) -> anyhow::Result<()> {
313    anyhow::ensure!(!path.exists(), "{} already exists", path.display());
314    write_atomic(
315        path,
316        "# lean-ctx gateway keys — SHA-256 hashes only, plaintext keys are never stored.\n\
317         # Add people: lean-ctx gateway keys add --person alice@example.com --file <this file>\n\
318         keys = []\n",
319    )
320}
321
322fn toml_escape(s: &str) -> String {
323    s.replace('\\', "\\\\").replace('"', "\\\"")
324}
325
326/// Temp-file + rename in the target directory (same-filesystem atomic swap).
327fn write_atomic(path: &Path, contents: &str) -> anyhow::Result<()> {
328    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
329    if let Some(dir) = dir {
330        std::fs::create_dir_all(dir)?;
331    }
332    let tmp: PathBuf = path.with_extension("toml.tmp");
333    std::fs::write(&tmp, contents)?;
334    #[cfg(unix)]
335    {
336        use std::os::unix::fs::PermissionsExt;
337        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
338    }
339    std::fs::rename(&tmp, path)?;
340    Ok(())
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn generated_keys_are_unique_and_well_formed() {
349        let a = generate_key("Alice Meier").unwrap();
350        let b = generate_key("Alice Meier").unwrap();
351        assert_ne!(a, b);
352        assert!(a.starts_with("gk-alice-meier-"), "got {a}");
353        let hex = a.rsplit('-').next().unwrap();
354        assert_eq!(hex.len(), KEY_RANDOM_BYTES * 2);
355        assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
356        // Degenerate person names still produce a usable slug.
357        assert!(generate_key("!!!").unwrap().starts_with("gk-key-"));
358    }
359
360    #[test]
361    fn add_list_revoke_round_trip() {
362        let tmp = tempfile::tempdir().unwrap();
363        let path = tmp.path().join("gateway-keys.toml");
364
365        let key1 = add_key(
366            &path,
367            "alice@zuehlke.com",
368            Some("platform"),
369            Some("checkout"),
370            false,
371        )
372        .unwrap();
373        let key2 = add_key(&path, "bob@zuehlke.com", None, None, false).unwrap();
374
375        // Plaintext resolves through the real auth loader.
376        let keys = GatewayKeys::load(&path).unwrap();
377        let alice = keys.lookup(&key1).expect("alice key resolves");
378        assert_eq!(alice.person.as_deref(), Some("alice@zuehlke.com"));
379        assert_eq!(alice.team.as_deref(), Some("platform"));
380        assert_eq!(alice.project.as_deref(), Some("checkout"));
381        assert!(keys.lookup(&key2).is_some());
382
383        // list shows identities, not hashes.
384        let listed = list_keys(&path).unwrap();
385        assert_eq!(listed.len(), 2);
386        assert_eq!(listed[0].person, "alice@zuehlke.com");
387        assert_eq!(listed[0].sha_prefix.len(), 8);
388
389        // Duplicate person is refused unless explicitly allowed.
390        assert!(add_key(&path, "alice@zuehlke.com", None, None, false).is_err());
391        assert!(add_key(&path, "alice@zuehlke.com", None, None, true).is_ok());
392
393        // Revoke removes all of alice's keys, bob survives.
394        let removed = revoke_keys(&path, "ALICE@zuehlke.com").unwrap();
395        assert_eq!(removed, 2);
396        let keys = GatewayKeys::load(&path).unwrap();
397        assert!(keys.lookup(&key1).is_none());
398        assert!(keys.lookup(&key2).is_some());
399    }
400
401    // #716: the documented onboarding flow is `gateway init` (scaffolds
402    // `keys = []`) followed by `gateway keys add`. Appending `[[keys]]` to a
403    // body that still contains the empty-array form is invalid TOML — the
404    // add must strip it, and a failing assembly must never reach the disk.
405    #[test]
406    fn add_key_after_init_scaffold_and_after_full_revoke() {
407        let tmp = tempfile::tempdir().unwrap();
408        let path = tmp.path().join("gateway-keys.toml");
409
410        // 1. Exactly what `gateway init` writes.
411        write_empty(&path).unwrap();
412        let key = add_key(&path, "alice@zuehlke.com", Some("core"), None, false)
413            .expect("add after init scaffold must work (#716)");
414        let keys = GatewayKeys::load(&path).unwrap();
415        assert_eq!(
416            keys.lookup(&key).unwrap().person.as_deref(),
417            Some("alice@zuehlke.com")
418        );
419        // The init comment header survives the strip, the array form does not.
420        let body = std::fs::read_to_string(&path).unwrap();
421        assert!(body.contains("# lean-ctx gateway keys"));
422        assert!(!body.contains("keys = []"));
423
424        // 2. A full revoke serializes back to the canonical empty array —
425        //    the next add must handle that state too.
426        assert_eq!(revoke_keys(&path, "alice@zuehlke.com").unwrap(), 1);
427        assert!(GatewayKeys::load(&path).unwrap().is_empty());
428        let key2 = add_key(&path, "bob@zuehlke.com", None, None, false)
429            .expect("add after revoke-to-empty must work (#716)");
430        assert!(GatewayKeys::load(&path).unwrap().lookup(&key2).is_some());
431
432        // 3. Pre-write validation: a poisoned existing file fails the add
433        //    loudly and is left byte-for-byte untouched (no half-written swap).
434        let poisoned = "keys = []\n\n[[keys]]\nsha256_hex = \"zz\"\nperson = \"x\"\n";
435        std::fs::write(&path, poisoned).unwrap();
436        assert!(add_key(&path, "carol@zuehlke.com", None, None, false).is_err());
437        assert_eq!(std::fs::read_to_string(&path).unwrap(), poisoned);
438    }
439
440    #[test]
441    fn rotate_replaces_key_atomically_and_keeps_identity() {
442        let tmp = tempfile::tempdir().unwrap();
443        let path = tmp.path().join("gateway-keys.toml");
444
445        let old_key = add_key(
446            &path,
447            "alice@zuehlke.com",
448            Some("platform"),
449            Some("checkout"),
450            false,
451        )
452        .unwrap();
453        let bob_key = add_key(&path, "bob@zuehlke.com", None, None, false).unwrap();
454
455        let rotated = rotate_key(&path, "ALICE@zuehlke.com").unwrap();
456        assert_eq!(rotated.replaced, 1);
457        assert_eq!(rotated.team.as_deref(), Some("platform"));
458        assert_eq!(rotated.default_project.as_deref(), Some("checkout"));
459        assert_ne!(rotated.key, old_key);
460
461        let keys = GatewayKeys::load(&path).unwrap();
462        // Old key is dead, new key carries the identical identity, bob intact.
463        assert!(keys.lookup(&old_key).is_none());
464        let alice = keys.lookup(&rotated.key).expect("new key resolves");
465        assert_eq!(alice.person.as_deref(), Some("alice@zuehlke.com"));
466        assert_eq!(alice.team.as_deref(), Some("platform"));
467        assert_eq!(alice.project.as_deref(), Some("checkout"));
468        assert!(keys.lookup(&bob_key).is_some());
469
470        // Rotating an unknown person is a hard error, not a silent add.
471        assert!(rotate_key(&path, "carol@zuehlke.com").is_err());
472    }
473
474    #[test]
475    fn rotate_collapses_multiple_keys_into_one() {
476        let tmp = tempfile::tempdir().unwrap();
477        let path = tmp.path().join("gateway-keys.toml");
478        let k1 = add_key(&path, "alice", Some("platform"), None, false).unwrap();
479        let k2 = add_key(&path, "alice", None, None, true).unwrap();
480
481        let rotated = rotate_key(&path, "alice").unwrap();
482        assert_eq!(rotated.replaced, 2);
483        // Both old keys die; exactly one entry remains for alice.
484        let keys = GatewayKeys::load(&path).unwrap();
485        assert!(keys.lookup(&k1).is_none());
486        assert!(keys.lookup(&k2).is_none());
487        assert!(keys.lookup(&rotated.key).is_some());
488        let listed = list_keys(&path).unwrap();
489        assert_eq!(
490            listed.iter().filter(|e| e.person == "alice").count(),
491            1,
492            "rotation must collapse duplicates"
493        );
494    }
495
496    #[test]
497    fn file_permissions_are_owner_only_on_unix() {
498        #[cfg(unix)]
499        {
500            use std::os::unix::fs::PermissionsExt;
501            let tmp = tempfile::tempdir().unwrap();
502            let path = tmp.path().join("gateway-keys.toml");
503            add_key(&path, "alice", None, None, false).unwrap();
504            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
505            assert_eq!(mode & 0o777, 0o600, "keys file must be owner-only");
506        }
507    }
508}