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 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 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
126pub 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#[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
172pub 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 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 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 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
257pub 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)?; }
287 Ok(removed)
288}
289
290pub 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
308fn 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 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 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 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 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 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 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 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 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}