Skip to main content

lore_cli/sync/
keystore.rs

1//! Passphrase-based key management for git-ref sync.
2//!
3//! The no-account model derives the encryption key from a passphrase plus a
4//! salt. The canonical salt for a store lives in the ref tree at `meta/salt`
5//! (plaintext; a salt is not secret), so every machine that shares the
6//! passphrase derives the same key. This module consumes a salt provided by the
7//! caller (read from the ref by the [`gitref`](super::gitref) layer) and
8//! produces a fresh salt when a store is first initialized.
9//!
10//! Each repo or store has its own passphrase plus salt and therefore its own
11//! derived key, so the storage is namespaced by a store identifier derived from
12//! the salt. This lets a single machine hold more than one store key at a time.
13
14use 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
22/// Keychain service name for sync store keys.
23///
24/// Each store's key is stored under this service with the store-id as the
25/// account/user.
26const SYNC_KEYRING_SERVICE: &str = "lore-sync";
27
28/// Derives the store encryption key from a passphrase and a salt.
29///
30/// The salt is supplied by the caller (read from the store's `meta/salt` blob),
31/// keeping salt I/O in the git layer and key math here. The same passphrase and
32/// salt always yield the same 32-byte key.
33pub fn derive_store_key(passphrase: &str, salt: &[u8]) -> Result<Vec<u8>, SyncError> {
34    derive_key(passphrase, salt)
35}
36
37/// Generates a fresh random salt for a newly initialized store.
38///
39/// The caller writes the returned bytes to the store's `meta/salt` blob so
40/// other machines can derive the same key.
41pub fn generate_store_salt() -> Vec<u8> {
42    generate_salt()
43}
44
45/// Derives a stable store identifier from a store's salt.
46///
47/// The identifier is the hex-encoded SHA-256 of the salt bytes. Because every
48/// repo or store has its own salt, the resulting id is distinct per store, which
49/// is what lets each store occupy its own key slot (file name or keychain
50/// account) without clobbering another store's key.
51pub fn store_id_from_salt(salt: &[u8]) -> String {
52    let digest = Sha256::digest(salt);
53    encode_key_hex(&digest)
54}
55
56/// Persists the derived encryption key for a lore store, keyed by store-id.
57///
58/// Keys live in a dedicated per-store namespace so one store's key never touches
59/// another's. Depending on `use_keychain`, the key lands in the OS keychain
60/// (service `lore-sync`, account = store-id) or a permission-restricted file at
61/// `~/.lore/sync-keys/<store-id>.key`.
62pub struct KeyStore {
63    /// Whether to use the OS keychain (config-driven and available on system).
64    use_keyring: bool,
65    /// Base directory for file-backed storage (defaults to `~/.lore`).
66    base_dir: Option<PathBuf>,
67}
68
69impl KeyStore {
70    /// Creates a key store backed by file storage (the default).
71    pub fn new() -> Self {
72        Self {
73            use_keyring: false,
74            base_dir: None,
75        }
76    }
77
78    /// Creates a key store, using the OS keychain when requested and available.
79    ///
80    /// Falls back to file storage when the keychain is unavailable.
81    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    /// Tests whether the OS keychain is usable for sync keys.
97    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    /// Stores the derived key bytes for a store (hex-encoded under the hood).
105    ///
106    /// `store_id` namespaces the slot; obtain it from [`store_id_from_salt`].
107    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    /// Loads the stored key bytes for a store, or `None` if no key is stored.
121    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    /// Deletes any stored key for a store from both file and keychain storage.
142    ///
143    /// Part of the key-store foundation's public API. Retained for a future
144    /// store reset/forget flow; the per-repo `lore sync` command only stores and
145    /// loads keys, so the binary does not yet call this directly.
146    #[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    /// Builds a keychain entry for a store under the dedicated sync service.
166    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    /// Returns the `sync-keys` directory used for file-backed key storage.
171    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    /// Returns the file path for a store's key.
182    fn key_path(&self, store_id: &str) -> Result<PathBuf, SyncError> {
183        Ok(self.sync_keys_dir()?.join(format!("{store_id}.key")))
184    }
185
186    /// Writes a hex-encoded key to the per-store file with locked-down perms.
187    ///
188    /// The `sync-keys` directory is created 0700 and the key file 0600 so other
189    /// users on the machine cannot read the stored key material.
190    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        // Same salt always yields the same id.
272        assert_eq!(store_id_from_salt(&salt1), store_id_from_salt(&salt1));
273        // Different salts yield different ids.
274        assert_ne!(store_id_from_salt(&salt1), store_id_from_salt(&salt2));
275        // The id is a hex SHA-256 (64 hex chars).
276        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        // Each store-id loads its own key, not the other's.
311        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        // Deleting one store's key leaves the other intact.
319        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        // Smoke test: the Default impl constructs a file-backed store.
372        let _store = KeyStore::default();
373    }
374}