lore_cli/sync/
keystore.rs1use keyring::Entry;
15use sha2::{Digest, Sha256};
16use std::fs;
17use std::path::PathBuf;
18
19use super::encryption::{decode_key_hex, derive_key, encode_key_hex, generate_salt};
20use super::SyncError;
21
22const SYNC_KEYRING_SERVICE: &str = "lore-sync";
27
28pub fn derive_store_key(passphrase: &str, salt: &[u8]) -> Result<Vec<u8>, SyncError> {
34 derive_key(passphrase, salt)
35}
36
37pub fn generate_store_salt() -> Vec<u8> {
42 generate_salt()
43}
44
45pub fn store_id_from_salt(salt: &[u8]) -> String {
52 let digest = Sha256::digest(salt);
53 encode_key_hex(&digest)
54}
55
56pub struct KeyStore {
63 use_keyring: bool,
65 base_dir: Option<PathBuf>,
67}
68
69impl KeyStore {
70 pub fn new() -> Self {
72 Self {
73 use_keyring: false,
74 base_dir: None,
75 }
76 }
77
78 pub fn with_keychain(use_keychain: bool) -> Self {
82 Self {
83 use_keyring: use_keychain && Self::is_keyring_available(),
84 base_dir: None,
85 }
86 }
87
88 #[cfg(test)]
89 pub(crate) fn with_base_dir(base_dir: std::path::PathBuf, use_keychain: bool) -> Self {
90 Self {
91 use_keyring: use_keychain && Self::is_keyring_available(),
92 base_dir: Some(base_dir),
93 }
94 }
95
96 fn is_keyring_available() -> bool {
98 match Entry::new(SYNC_KEYRING_SERVICE, "test-availability") {
99 Ok(entry) => matches!(entry.get_password(), Ok(_) | Err(keyring::Error::NoEntry)),
100 Err(_) => false,
101 }
102 }
103
104 pub fn store_key(&self, store_id: &str, key: &[u8]) -> Result<(), SyncError> {
108 let key_hex = encode_key_hex(key);
109 if self.use_keyring {
110 let entry = self.keyring_entry(store_id)?;
111 entry
112 .set_password(&key_hex)
113 .map_err(|e| SyncError::KeyStorage(e.to_string()))?;
114 } else {
115 self.store_to_file(store_id, &key_hex)?;
116 }
117 Ok(())
118 }
119
120 pub fn load_key(&self, store_id: &str) -> Result<Option<Vec<u8>>, SyncError> {
122 if self.use_keyring {
123 let entry = self.keyring_entry(store_id)?;
124 match entry.get_password() {
125 Ok(key_hex) => return Ok(Some(decode_key_hex(&key_hex)?)),
126 Err(keyring::Error::NoEntry) => {}
127 Err(e) => return Err(SyncError::KeyStorage(e.to_string())),
128 }
129 }
130
131 let path = self.key_path(store_id)?;
132 if path.exists() {
133 let key_hex = fs::read_to_string(&path)
134 .map_err(|e| SyncError::KeyStorage(format!("Failed to read key: {e}")))?;
135 return Ok(Some(decode_key_hex(key_hex.trim())?));
136 }
137
138 Ok(None)
139 }
140
141 #[allow(dead_code)]
147 pub fn delete_key(&self, store_id: &str) -> Result<(), SyncError> {
148 let path = self.key_path(store_id)?;
149 if path.exists() {
150 fs::remove_file(&path)
151 .map_err(|e| SyncError::KeyStorage(format!("Failed to delete key file: {e}")))?;
152 }
153
154 if Self::is_keyring_available() {
155 let entry = self.keyring_entry(store_id)?;
156 match entry.delete_credential() {
157 Ok(()) | Err(keyring::Error::NoEntry) => {}
158 Err(e) => return Err(SyncError::KeyStorage(e.to_string())),
159 }
160 }
161
162 Ok(())
163 }
164
165 fn keyring_entry(&self, store_id: &str) -> Result<Entry, SyncError> {
167 Entry::new(SYNC_KEYRING_SERVICE, store_id).map_err(|e| SyncError::KeyStorage(e.to_string()))
168 }
169
170 fn sync_keys_dir(&self) -> Result<PathBuf, SyncError> {
172 let base = match &self.base_dir {
173 Some(base_dir) => base_dir.clone(),
174 None => dirs::home_dir()
175 .ok_or_else(|| SyncError::KeyStorage("Could not find home directory".to_string()))?
176 .join(".lore"),
177 };
178 Ok(base.join("sync-keys"))
179 }
180
181 fn key_path(&self, store_id: &str) -> Result<PathBuf, SyncError> {
183 Ok(self.sync_keys_dir()?.join(format!("{store_id}.key")))
184 }
185
186 fn store_to_file(&self, store_id: &str, key_hex: &str) -> Result<(), SyncError> {
191 let dir = self.sync_keys_dir()?;
192 fs::create_dir_all(&dir)
193 .map_err(|e| SyncError::KeyStorage(format!("Failed to create key dir: {e}")))?;
194
195 #[cfg(unix)]
196 {
197 use std::os::unix::fs::PermissionsExt;
198 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).map_err(|e| {
199 SyncError::KeyStorage(format!("Failed to set key dir permissions: {e}"))
200 })?;
201 }
202
203 let path = dir.join(format!("{store_id}.key"));
204 fs::write(&path, key_hex)
205 .map_err(|e| SyncError::KeyStorage(format!("Failed to write key: {e}")))?;
206
207 #[cfg(unix)]
208 {
209 use std::os::unix::fs::PermissionsExt;
210 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|e| {
211 SyncError::KeyStorage(format!("Failed to set key permissions: {e}"))
212 })?;
213 }
214
215 Ok(())
216 }
217}
218
219impl Default for KeyStore {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::sync::encryption::KEY_SIZE;
229
230 #[test]
231 fn test_derive_store_key_deterministic() {
232 let salt = generate_store_salt();
233 let key1 = derive_store_key("my passphrase", &salt).unwrap();
234 let key2 = derive_store_key("my passphrase", &salt).unwrap();
235
236 assert_eq!(key1, key2);
237 assert_eq!(key1.len(), KEY_SIZE);
238 }
239
240 #[test]
241 fn test_derive_store_key_differs_by_passphrase() {
242 let salt = generate_store_salt();
243 let key1 = derive_store_key("passphrase one", &salt).unwrap();
244 let key2 = derive_store_key("passphrase two", &salt).unwrap();
245
246 assert_ne!(key1, key2);
247 }
248
249 #[test]
250 fn test_derive_store_key_differs_by_salt() {
251 let salt1 = generate_store_salt();
252 let salt2 = generate_store_salt();
253 let key1 = derive_store_key("same passphrase", &salt1).unwrap();
254 let key2 = derive_store_key("same passphrase", &salt2).unwrap();
255
256 assert_ne!(key1, key2);
257 }
258
259 #[test]
260 fn test_generate_store_salt_is_random() {
261 let salt1 = generate_store_salt();
262 let salt2 = generate_store_salt();
263 assert_ne!(salt1, salt2);
264 }
265
266 #[test]
267 fn test_store_id_from_salt_deterministic_and_distinct() {
268 let salt1 = generate_store_salt();
269 let salt2 = generate_store_salt();
270
271 assert_eq!(store_id_from_salt(&salt1), store_id_from_salt(&salt1));
273 assert_ne!(store_id_from_salt(&salt1), store_id_from_salt(&salt2));
275 assert_eq!(store_id_from_salt(&salt1).len(), 64);
277 }
278
279 #[test]
280 fn test_key_store_round_trip() {
281 let temp_dir = tempfile::TempDir::new().unwrap();
282 let store = KeyStore::with_base_dir(temp_dir.path().to_path_buf(), false);
283
284 let salt = generate_store_salt();
285 let store_id = store_id_from_salt(&salt);
286 let key = derive_store_key("secret passphrase", &salt).unwrap();
287
288 store.store_key(&store_id, &key).unwrap();
289 let loaded = store.load_key(&store_id).unwrap();
290
291 assert_eq!(loaded, Some(key));
292 }
293
294 #[test]
295 fn test_key_store_isolated_per_store_id() {
296 let temp_dir = tempfile::TempDir::new().unwrap();
297 let store = KeyStore::with_base_dir(temp_dir.path().to_path_buf(), false);
298
299 let salt_a = generate_store_salt();
300 let salt_b = generate_store_salt();
301 let id_a = store_id_from_salt(&salt_a);
302 let id_b = store_id_from_salt(&salt_b);
303
304 let key_a = derive_store_key("passphrase a", &salt_a).unwrap();
305 let key_b = derive_store_key("passphrase b", &salt_b).unwrap();
306
307 store.store_key(&id_a, &key_a).unwrap();
308 store.store_key(&id_b, &key_b).unwrap();
309
310 assert_eq!(store.load_key(&id_a).unwrap(), Some(key_a.clone()));
312 assert_eq!(store.load_key(&id_b).unwrap(), Some(key_b));
313 assert_ne!(
314 store.load_key(&id_a).unwrap(),
315 store.load_key(&id_b).unwrap()
316 );
317
318 store.delete_key(&id_a).unwrap();
320 assert!(store.load_key(&id_a).unwrap().is_none());
321 assert!(store.load_key(&id_b).unwrap().is_some());
322 }
323
324 #[test]
325 fn test_key_store_load_missing_returns_none() {
326 let temp_dir = tempfile::TempDir::new().unwrap();
327 let store = KeyStore::with_base_dir(temp_dir.path().to_path_buf(), false);
328
329 let store_id = store_id_from_salt(&generate_store_salt());
330 let loaded = store.load_key(&store_id).unwrap();
331 assert!(loaded.is_none());
332 }
333
334 #[test]
335 fn test_key_store_delete() {
336 let temp_dir = tempfile::TempDir::new().unwrap();
337 let store = KeyStore::with_base_dir(temp_dir.path().to_path_buf(), false);
338
339 let store_id = store_id_from_salt(&generate_store_salt());
340 let key = vec![7u8; KEY_SIZE];
341 store.store_key(&store_id, &key).unwrap();
342 assert!(store.load_key(&store_id).unwrap().is_some());
343
344 store.delete_key(&store_id).unwrap();
345 assert!(store.load_key(&store_id).unwrap().is_none());
346 }
347
348 #[cfg(unix)]
349 #[test]
350 fn test_key_store_file_permissions_locked_down() {
351 use std::os::unix::fs::PermissionsExt;
352
353 let temp_dir = tempfile::TempDir::new().unwrap();
354 let store = KeyStore::with_base_dir(temp_dir.path().to_path_buf(), false);
355
356 let salt = generate_store_salt();
357 let store_id = store_id_from_salt(&salt);
358 let key = derive_store_key("pp", &salt).unwrap();
359 store.store_key(&store_id, &key).unwrap();
360
361 let dir = temp_dir.path().join("sync-keys");
362 let file = dir.join(format!("{store_id}.key"));
363 let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
364 let file_mode = fs::metadata(&file).unwrap().permissions().mode() & 0o777;
365 assert_eq!(dir_mode, 0o700);
366 assert_eq!(file_mode, 0o600);
367 }
368
369 #[test]
370 fn test_key_store_default_constructs() {
371 let _store = KeyStore::default();
373 }
374}