Skip to main content

ows_lib/
key_store.rs

1use ows_core::ApiKeyFile;
2use rand::RngCore;
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::error::OwsLibError;
8use crate::vault;
9
10/// Token prefix that signals agent mode in the credential parameter.
11pub const TOKEN_PREFIX: &str = "ows_key_";
12
13// ---------------------------------------------------------------------------
14// Path helpers
15// ---------------------------------------------------------------------------
16
17/// Returns the keys directory, creating it with strict permissions if needed.
18fn keys_dir(vault_path: Option<&Path>) -> Result<PathBuf, OwsLibError> {
19    let base = vault::resolve_vault_path(vault_path);
20    let dir = base.join("keys");
21    fs::create_dir_all(&dir)?;
22    set_dir_permissions(&dir);
23    Ok(dir)
24}
25
26#[cfg(unix)]
27fn set_dir_permissions(path: &Path) {
28    use std::os::unix::fs::PermissionsExt;
29    let perms = fs::Permissions::from_mode(0o700);
30    if let Err(e) = fs::set_permissions(path, perms) {
31        eprintln!(
32            "warning: failed to set permissions on {}: {e}",
33            path.display()
34        );
35    }
36}
37
38#[cfg(not(unix))]
39fn set_dir_permissions(_path: &Path) {}
40
41#[cfg(unix)]
42fn set_file_permissions(path: &Path) {
43    use std::os::unix::fs::PermissionsExt;
44    let perms = fs::Permissions::from_mode(0o600);
45    if let Err(e) = fs::set_permissions(path, perms) {
46        eprintln!(
47            "warning: failed to set permissions on {}: {e}",
48            path.display()
49        );
50    }
51}
52
53#[cfg(not(unix))]
54fn set_file_permissions(_path: &Path) {}
55
56// ---------------------------------------------------------------------------
57// Token generation and hashing
58// ---------------------------------------------------------------------------
59
60/// Generate a random API token: `ows_key_<64 hex chars>` (256 bits of entropy).
61pub fn generate_token() -> String {
62    let mut bytes = [0u8; 32];
63    rand::thread_rng().fill_bytes(&mut bytes);
64    format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
65}
66
67/// SHA-256 hash of the raw token string, hex-encoded.
68pub fn hash_token(token: &str) -> String {
69    let digest = Sha256::digest(token.as_bytes());
70    hex::encode(digest)
71}
72
73// ---------------------------------------------------------------------------
74// CRUD
75// ---------------------------------------------------------------------------
76
77/// Save an API key file to `~/.ows/keys/<id>.json` with strict permissions.
78pub fn save_api_key(key: &ApiKeyFile, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
79    let dir = keys_dir(vault_path)?;
80    let path = dir.join(format!("{}.json", key.id));
81    let json = serde_json::to_string_pretty(key)?;
82    fs::write(&path, json)?;
83    set_file_permissions(&path);
84    Ok(())
85}
86
87/// Load an API key by its ID.
88pub fn load_api_key(id: &str, vault_path: Option<&Path>) -> Result<ApiKeyFile, OwsLibError> {
89    let dir = keys_dir(vault_path)?;
90    let path = dir.join(format!("{id}.json"));
91    if !path.exists() {
92        return Err(OwsLibError::Core(ows_core::OwsError::ApiKeyNotFound));
93    }
94    let contents = fs::read_to_string(&path)?;
95    let key: ApiKeyFile = serde_json::from_str(&contents)?;
96    Ok(key)
97}
98
99/// Look up an API key by the SHA-256 hash of the token.
100/// Scans all key files — O(n) in the number of keys.
101pub fn load_api_key_by_token_hash(
102    token_hash: &str,
103    vault_path: Option<&Path>,
104) -> Result<ApiKeyFile, OwsLibError> {
105    let keys = list_api_keys(vault_path)?;
106    keys.into_iter()
107        .find(|k| k.token_hash == token_hash)
108        .ok_or(OwsLibError::Core(ows_core::OwsError::ApiKeyNotFound))
109}
110
111/// List all API keys, sorted by creation time (newest first).
112pub fn list_api_keys(vault_path: Option<&Path>) -> Result<Vec<ApiKeyFile>, OwsLibError> {
113    let dir = keys_dir(vault_path)?;
114    let mut keys = Vec::new();
115
116    let entries = match fs::read_dir(&dir) {
117        Ok(entries) => entries,
118        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(keys),
119        Err(e) => return Err(e.into()),
120    };
121
122    for entry in entries {
123        let entry = entry?;
124        let path = entry.path();
125        if path.extension().and_then(|e| e.to_str()) != Some("json") {
126            continue;
127        }
128        match fs::read_to_string(&path) {
129            Ok(contents) => match serde_json::from_str::<ApiKeyFile>(&contents) {
130                Ok(k) => keys.push(k),
131                Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
132            },
133            Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
134        }
135    }
136
137    keys.sort_by(|a, b| b.created_at.cmp(&a.created_at));
138    Ok(keys)
139}
140
141/// Delete an API key by ID.
142pub fn delete_api_key(id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
143    let dir = keys_dir(vault_path)?;
144    let path = dir.join(format!("{id}.json"));
145    if !path.exists() {
146        return Err(OwsLibError::Core(ows_core::OwsError::ApiKeyNotFound));
147    }
148    fs::remove_file(&path)?;
149    Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::collections::HashMap;
156
157    fn test_key(id: &str, name: &str, token: &str) -> ApiKeyFile {
158        ApiKeyFile {
159            id: id.to_string(),
160            name: name.to_string(),
161            token_hash: hash_token(token),
162            created_at: "2026-03-22T10:30:00Z".to_string(),
163            wallet_ids: vec!["wallet-1".to_string()],
164            policy_ids: vec!["policy-1".to_string()],
165            expires_at: None,
166            wallet_secrets: HashMap::new(),
167        }
168    }
169
170    #[test]
171    fn generate_token_has_correct_format() {
172        let token = generate_token();
173        assert!(token.starts_with(TOKEN_PREFIX));
174        // 8 chars prefix + 64 hex chars = 72 total
175        assert_eq!(token.len(), 72);
176        // The hex part should be valid hex
177        assert!(hex::decode(&token[TOKEN_PREFIX.len()..]).is_ok());
178    }
179
180    #[test]
181    fn generate_token_is_unique() {
182        let t1 = generate_token();
183        let t2 = generate_token();
184        assert_ne!(t1, t2);
185    }
186
187    #[test]
188    fn hash_token_is_deterministic() {
189        let token = "ows_key_abc123";
190        assert_eq!(hash_token(token), hash_token(token));
191    }
192
193    #[test]
194    fn hash_token_differs_for_different_tokens() {
195        assert_ne!(hash_token("ows_key_abc"), hash_token("ows_key_def"));
196    }
197
198    #[test]
199    fn save_and_load_roundtrip() {
200        let dir = tempfile::tempdir().unwrap();
201        let vault = dir.path().to_path_buf();
202        let key = test_key("key-1", "claude-agent", "ows_key_test");
203
204        save_api_key(&key, Some(&vault)).unwrap();
205        let loaded = load_api_key("key-1", Some(&vault)).unwrap();
206
207        assert_eq!(loaded.id, "key-1");
208        assert_eq!(loaded.name, "claude-agent");
209        assert_eq!(loaded.token_hash, key.token_hash);
210    }
211
212    #[test]
213    fn lookup_by_token_hash() {
214        let dir = tempfile::tempdir().unwrap();
215        let vault = dir.path().to_path_buf();
216        let token = "ows_key_findme";
217        let key = test_key("key-2", "finder", token);
218
219        save_api_key(&key, Some(&vault)).unwrap();
220
221        let found = load_api_key_by_token_hash(&hash_token(token), Some(&vault)).unwrap();
222        assert_eq!(found.id, "key-2");
223    }
224
225    #[test]
226    fn lookup_nonexistent_hash_returns_error() {
227        let dir = tempfile::tempdir().unwrap();
228        let vault = dir.path().to_path_buf();
229
230        let result = load_api_key_by_token_hash("nonexistent-hash", Some(&vault));
231        assert!(result.is_err());
232    }
233
234    #[test]
235    fn list_returns_newest_first() {
236        let dir = tempfile::tempdir().unwrap();
237        let vault = dir.path().to_path_buf();
238
239        let mut k1 = test_key("k1", "first", "t1");
240        k1.created_at = "2026-03-20T10:00:00Z".to_string();
241
242        let mut k2 = test_key("k2", "second", "t2");
243        k2.created_at = "2026-03-22T10:00:00Z".to_string();
244
245        save_api_key(&k1, Some(&vault)).unwrap();
246        save_api_key(&k2, Some(&vault)).unwrap();
247
248        let keys = list_api_keys(Some(&vault)).unwrap();
249        assert_eq!(keys.len(), 2);
250        assert_eq!(keys[0].id, "k2"); // newest first
251        assert_eq!(keys[1].id, "k1");
252    }
253
254    #[test]
255    fn delete_removes_key() {
256        let dir = tempfile::tempdir().unwrap();
257        let vault = dir.path().to_path_buf();
258        let key = test_key("del-me", "delete", "token");
259
260        save_api_key(&key, Some(&vault)).unwrap();
261        assert_eq!(list_api_keys(Some(&vault)).unwrap().len(), 1);
262
263        delete_api_key("del-me", Some(&vault)).unwrap();
264        assert_eq!(list_api_keys(Some(&vault)).unwrap().len(), 0);
265    }
266
267    #[test]
268    fn delete_nonexistent_returns_error() {
269        let dir = tempfile::tempdir().unwrap();
270        let vault = dir.path().to_path_buf();
271
272        let result = delete_api_key("nope", Some(&vault));
273        assert!(result.is_err());
274    }
275
276    #[test]
277    fn list_empty_returns_empty() {
278        let dir = tempfile::tempdir().unwrap();
279        let vault = dir.path().to_path_buf();
280
281        let keys = list_api_keys(Some(&vault)).unwrap();
282        assert!(keys.is_empty());
283    }
284
285    #[cfg(unix)]
286    #[test]
287    fn key_file_has_strict_permissions() {
288        use std::os::unix::fs::PermissionsExt;
289
290        let dir = tempfile::tempdir().unwrap();
291        let vault = dir.path().to_path_buf();
292        let key = test_key("perm-key", "perms", "token");
293
294        save_api_key(&key, Some(&vault)).unwrap();
295
296        let path = vault.join("keys/perm-key.json");
297        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
298        assert_eq!(
299            mode, 0o600,
300            "key file should have 0600 permissions, got {:04o}",
301            mode
302        );
303
304        let dir_mode = fs::metadata(vault.join("keys"))
305            .unwrap()
306            .permissions()
307            .mode()
308            & 0o777;
309        assert_eq!(
310            dir_mode, 0o700,
311            "keys dir should have 0700 permissions, got {:04o}",
312            dir_mode
313        );
314    }
315}