1use std::path::{Path, PathBuf};
10
11use crate::proxy::gateway_identity::{GatewayKeys, sha256_hex};
12
13const KEY_PREFIX: &str = "gk";
15
16const KEY_RANDOM_BYTES: usize = 24;
18
19#[derive(Debug, PartialEq, Eq)]
21pub struct KeyListEntry {
22 pub person: String,
23 pub team: Option<String>,
24 pub default_project: Option<String>,
25 pub sha_prefix: String,
28}
29
30pub 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
60pub 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 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 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 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
140pub 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#[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
186pub 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 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 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 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
273pub 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 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
308pub 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
326fn 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 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 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 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 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 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 #[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 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 let body = std::fs::read_to_string(&path).unwrap();
421 assert!(body.contains("# lean-ctx gateway keys"));
422 assert!(!body.contains("keys = []"));
423
424 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 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 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 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 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}