1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
//! Secure API key storage.
//!
//! Keys are stored in `~/.config/koda/keys.toml` with
//! restrictive file permissions (0600). This file is user-level,
//! never inside a project directory, and never committed to git.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
const CONFIG_DIR: &str = "koda";
const KEYS_FILE: &str = "keys.toml";
/// Stored API keys, keyed by env var name (e.g. "ANTHROPIC_API_KEY").
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct KeyStore {
#[serde(default)]
/// Map of environment variable name → API key value.
pub keys: HashMap<String, String>,
}
impl KeyStore {
/// Load keys from disk. Returns empty store if file doesn't exist.
pub fn load() -> Result<Self> {
let path = Self::keys_path()?;
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let store: Self = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(store)
}
/// Save keys to disk with restrictive permissions.
///
/// On Unix, the file is created with mode 0600 atomically via `OpenOptions`
/// to avoid a TOCTOU window where the file is world-readable between
/// `write()` and `set_permissions()`.
pub fn save(&self) -> Result<()> {
let path = Self::keys_path()?;
// Ensure config directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("Failed to create {}", path.display()))?;
file.write_all(content.as_bytes())?;
}
#[cfg(not(unix))]
{
// TODO: Windows ACL — std::fs::write inherits parent directory ACLs,
// which typically grants read access to the Users group. API keys are
// readable by any local user. Low risk for single-user dev machines,
// but if koda ever runs on shared workstations or as a service,
// restrict the DACL to the current user SID via SetNamedSecurityInfoW.
std::fs::write(&path, &content)?;
}
tracing::info!("Saved keys to {}", path.display());
Ok(())
}
/// Set a key by env var name.
pub fn set(&mut self, env_name: &str, value: &str) {
self.keys.insert(env_name.to_string(), value.to_string());
}
/// Load all stored keys into the process environment.
/// Load all stored keys into the runtime environment.
/// Only sets vars that aren't already set (env vars and
/// previously-set runtime vars take precedence).
pub fn inject_into_env(&self) {
for (name, value) in &self.keys {
if crate::runtime_env::get(name).is_none() {
crate::runtime_env::set(name, value);
tracing::debug!("Injected stored key: {name}");
}
}
}
/// Path to the keys file: ~/.config/koda/keys.toml
pub fn keys_path() -> Result<PathBuf> {
let config_dir = dirs_config_dir().context("Could not determine config directory")?;
Ok(config_dir.join(CONFIG_DIR).join(KEYS_FILE))
}
}
/// Cross-platform config directory.
fn dirs_config_dir() -> Option<PathBuf> {
// $XDG_CONFIG_HOME or ~/.config on unix, AppData on windows
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
})
.or_else(|| std::env::var("APPDATA").ok().map(PathBuf::from))
}
/// Mask a key for display: shows first 4 and last 4 characters.
///
/// # Examples
///
/// ```
/// use koda_core::keystore::mask_key;
///
/// assert_eq!(mask_key("sk-ant-api03-longkey1234"), "sk-a...1234");
/// assert_eq!(mask_key("short"), "****");
/// ```
pub fn mask_key(key: &str) -> String {
if key.len() > 8 {
format!("{}...{}", &key[..4], &key[key.len() - 4..])
} else {
"****".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_keystore_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("keys.toml");
let mut store = KeyStore::default();
store.set("OPENAI_API_KEY", "sk-test-123");
store.set("ANTHROPIC_API_KEY", "sk-ant-456");
// Save
let content = toml::to_string_pretty(&store).unwrap();
std::fs::write(&path, &content).unwrap();
// Load
let loaded: KeyStore = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
loaded.keys.get("OPENAI_API_KEY").map(|s| s.as_str()),
Some("sk-test-123")
);
assert_eq!(
loaded.keys.get("ANTHROPIC_API_KEY").map(|s| s.as_str()),
Some("sk-ant-456")
);
}
#[test]
fn test_mask_key() {
assert_eq!(mask_key("sk-ant-api03-longkey1234"), "sk-a...1234");
assert_eq!(mask_key("short"), "****");
}
#[test]
fn test_remove_key() {
let mut store = KeyStore::default();
store.set("TEST_KEY", "value");
assert!(store.keys.remove("TEST_KEY").is_some());
assert!(store.keys.remove("TEST_KEY").is_none());
assert_eq!(store.keys.get("TEST_KEY"), None);
}
}