Skip to main content

confex_core/
key.rs

1use std::{fs, path::PathBuf};
2
3use rand::RngCore as _;
4
5use crate::error::KeyError;
6
7/// 256-bit symmetric key used for AES-256-GCM encryption.
8pub struct SecretKey(pub(crate) [u8; 32]);
9
10impl SecretKey {
11    #[must_use]
12    pub fn as_bytes(&self) -> &[u8; 32] {
13        &self.0
14    }
15}
16
17/// Retrieves or creates the encryption key for an application.
18pub trait KeyProvider {
19    /// Returns the key for `app_name`.
20    ///
21    /// # Errors
22    /// Returns [`KeyError`] on I/O failure or invalid stored key data.
23    fn key(&self, app_name: &str) -> Result<SecretKey, KeyError>;
24}
25
26/// Stores the key as a 32-byte file with permissions `0600` (Unix).
27///
28/// Generates a new random key on first use and persists it.
29///
30/// # Security note
31/// The key lives in the same blast radius as `$HOME`. This protects against
32/// other local users, not against a full session compromise. See plan §5.
33pub struct FileKeyProvider {
34    path: PathBuf,
35}
36
37impl From<PathBuf> for FileKeyProvider {
38    fn from(path: PathBuf) -> Self {
39        Self { path }
40    }
41}
42
43impl KeyProvider for FileKeyProvider {
44    fn key(&self, _app_name: &str) -> Result<SecretKey, KeyError> {
45        if self.path.exists() {
46            let bytes = fs::read(&self.path)?;
47            let len = bytes.len();
48            let raw: [u8; 32] = bytes.try_into().map_err(|_| KeyError::InvalidLength(len))?;
49            return Ok(SecretKey(raw));
50        }
51
52        let mut raw = [0u8; 32];
53        rand::thread_rng().fill_bytes(&mut raw);
54
55        if let Some(parent) = self.path.parent() {
56            fs::create_dir_all(parent)?;
57        }
58        fs::write(&self.path, raw)?;
59
60        #[cfg(unix)]
61        {
62            use std::os::unix::fs::PermissionsExt as _;
63            fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
64        }
65
66        Ok(SecretKey(raw))
67    }
68}
69
70/// Stores the key in the system Secret Service (`org.freedesktop.secrets` via D-Bus).
71///
72/// Persistent across reboots. Requires a running Secret Service daemon (GNOME Keyring,
73/// `KWallet`). On headless or server systems, use [`FileKeyProvider`] instead.
74///
75/// Requires the `native-keyring` feature.
76#[cfg(all(target_os = "linux", feature = "native-keyring"))]
77#[derive(Debug, Default, Clone, Copy)]
78#[must_use]
79pub struct SecretServiceKeyring;
80
81#[cfg(all(target_os = "linux", feature = "native-keyring"))]
82impl KeyProvider for SecretServiceKeyring {
83    fn key(&self, app_name: &str) -> Result<SecretKey, KeyError> {
84        ss_get_or_create(app_name).map(SecretKey)
85    }
86}
87
88#[cfg(all(target_os = "linux", feature = "native-keyring"))]
89fn ss_get_or_create(app_name: &str) -> Result<[u8; 32], KeyError> {
90    use std::collections::HashMap;
91
92    use zbus::{
93        blocking::{Connection, Proxy},
94        zvariant::{OwnedObjectPath, OwnedValue, Value},
95    };
96
97    const SERVICE: &str = "org.freedesktop.secrets";
98    const SERVICE_PATH: &str = "/org/freedesktop/secrets";
99    const SERVICE_IFACE: &str = "org.freedesktop.Secret.Service";
100    const ITEM_IFACE: &str = "org.freedesktop.Secret.Item";
101    const COLLECTION_IFACE: &str = "org.freedesktop.Secret.Collection";
102
103    let conn = Connection::session().map_err(|_| KeyError::NoDaemon)?;
104
105    let svc = Proxy::new(&conn, SERVICE, SERVICE_PATH, SERVICE_IFACE)
106        .map_err(|e| KeyError::SecretService(e.to_string()))?;
107
108    // Plain session — no transport encryption needed on local socket.
109    let (_out, session): (OwnedValue, OwnedObjectPath) = svc
110        .call("OpenSession", &("plain", Value::from("")))
111        .map_err(|e| {
112            let msg = e.to_string();
113            if msg.contains("ServiceUnknown") {
114                KeyError::NoDaemon
115            } else {
116                KeyError::SecretService(msg)
117            }
118        })?;
119
120    let attrs: HashMap<&str, &str> = [("application", "confex"), ("name", app_name)]
121        .into_iter()
122        .collect();
123
124    let (mut unlocked, locked): (Vec<OwnedObjectPath>, Vec<OwnedObjectPath>) = svc
125        .call("SearchItems", &attrs)
126        .map_err(|e| KeyError::SecretService(e.to_string()))?;
127
128    if !locked.is_empty() {
129        let (newly, _prompt): (Vec<OwnedObjectPath>, OwnedObjectPath) = svc
130            .call("Unlock", &locked)
131            .map_err(|e| KeyError::SecretService(e.to_string()))?;
132        unlocked.extend(newly);
133    }
134
135    if let Some(item_path) = unlocked.into_iter().next() {
136        let item = Proxy::new(&conn, SERVICE, item_path, ITEM_IFACE)
137            .map_err(|e| KeyError::SecretService(e.to_string()))?;
138        let (_sess, _params, value, _ct): (OwnedObjectPath, Vec<u8>, Vec<u8>, String) = item
139            .call("GetSecret", &session.as_ref())
140            .map_err(|e| KeyError::SecretService(e.to_string()))?;
141        let len = value.len();
142        return value.try_into().map_err(|_| KeyError::InvalidLength(len));
143    }
144
145    // No existing item — generate and store.
146    let mut raw = [0u8; 32];
147    rand::thread_rng().fill_bytes(&mut raw);
148
149    let collection_path: OwnedObjectPath = svc
150        .call("ReadAlias", &"default")
151        .map_err(|e| KeyError::SecretService(e.to_string()))?;
152
153    if collection_path.as_str() == "/" {
154        return Err(KeyError::SecretService(
155            "no default Secret Service collection".into(),
156        ));
157    }
158
159    let label = format!("confex:{app_name}");
160    let item_attrs: HashMap<String, String> = [
161        ("application".to_owned(), "confex".to_owned()),
162        ("name".to_owned(), app_name.to_owned()),
163    ]
164    .into_iter()
165    .collect();
166    let mut props: HashMap<String, Value<'_>> = HashMap::new();
167    props.insert(
168        "org.freedesktop.Secret.Item.Label".to_owned(),
169        Value::from(label.as_str()),
170    );
171    props.insert(
172        "org.freedesktop.Secret.Item.Attributes".to_owned(),
173        Value::from(item_attrs),
174    );
175
176    // Secret struct: (session_path, parameters, value, content_type)
177    let secret = (
178        session.as_ref(),
179        &b""[..],
180        &raw[..],
181        "application/octet-stream",
182    );
183
184    let collection = Proxy::new(&conn, SERVICE, collection_path, COLLECTION_IFACE)
185        .map_err(|e| KeyError::SecretService(e.to_string()))?;
186
187    let _: (OwnedObjectPath, OwnedObjectPath) = collection
188        .call("CreateItem", &(props, secret, true))
189        .map_err(|e| KeyError::SecretService(e.to_string()))?;
190
191    Ok(raw)
192}
193
194/// Stores the key in the macOS Keychain under service `"confex"`.
195///
196/// Requires the `native-keyring` feature.
197#[cfg(all(target_os = "macos", feature = "native-keyring"))]
198#[derive(Debug, Default, Clone, Copy)]
199#[must_use]
200pub struct MacOsKeychain;
201
202#[cfg(all(target_os = "macos", feature = "native-keyring"))]
203impl KeyProvider for MacOsKeychain {
204    fn key(&self, app_name: &str) -> Result<SecretKey, KeyError> {
205        use security_framework::passwords::{get_generic_password, set_generic_password};
206
207        match get_generic_password("confex", app_name) {
208            Ok(data) => {
209                let len = data.len();
210                let raw: [u8; 32] = data.try_into().map_err(|_| KeyError::InvalidLength(len))?;
211                Ok(SecretKey(raw))
212            }
213            Err(_) => {
214                let mut raw = [0u8; 32];
215                rand::thread_rng().fill_bytes(&mut raw);
216                set_generic_password("confex", app_name, &raw).map_err(KeyError::Keychain)?;
217                Ok(SecretKey(raw))
218            }
219        }
220    }
221}
222
223/// Stores the key in the Windows Credential Manager.
224///
225/// Persistent across reboots, bound to the current user. Requires the
226/// `native-keyring` feature.
227#[cfg(all(windows, feature = "native-keyring"))]
228#[derive(Debug, Default, Clone, Copy)]
229#[must_use]
230pub struct WindowsCredentialManager;
231
232#[cfg(all(windows, feature = "native-keyring"))]
233impl KeyProvider for WindowsCredentialManager {
234    fn key(&self, app_name: &str) -> Result<SecretKey, KeyError> {
235        if let Some(raw) = cred_read(app_name)? {
236            return Ok(SecretKey(raw));
237        }
238        let mut raw = [0u8; 32];
239        rand::thread_rng().fill_bytes(&mut raw);
240        cred_write(app_name, &raw)?;
241        Ok(SecretKey(raw))
242    }
243}
244
245#[cfg(all(windows, feature = "native-keyring"))]
246fn cred_target(app_name: &str) -> Vec<u16> {
247    format!("confex:{app_name}")
248        .encode_utf16()
249        .chain(std::iter::once(0))
250        .collect()
251}
252
253#[cfg(all(windows, feature = "native-keyring"))]
254fn cred_read(app_name: &str) -> Result<Option<[u8; 32]>, KeyError> {
255    use windows::Win32::{
256        Foundation::{ERROR_NOT_FOUND, GetLastError},
257        Security::Credentials::{CRED_TYPE_GENERIC, CredFree, CredReadW},
258    };
259    use windows::core::PCWSTR;
260
261    let target = cred_target(app_name);
262    let mut pcred = std::ptr::null_mut();
263
264    // SAFETY: target is a valid null-terminated UTF-16 string; pcred is a valid out-pointer.
265    let ok = unsafe { CredReadW(PCWSTR(target.as_ptr()), CRED_TYPE_GENERIC, 0, &mut pcred) };
266
267    if !ok.as_bool() {
268        // SAFETY: called immediately after a failed Win32 API on the same thread.
269        let err = unsafe { GetLastError() };
270        return if err == ERROR_NOT_FOUND {
271            Ok(None)
272        } else {
273            Err(KeyError::CredentialManager(format!(
274                "CredReadW failed: {err:?}"
275            )))
276        };
277    }
278
279    // SAFETY: CredReadW succeeded; pcred points to a valid CREDENTIALW.
280    let cred = unsafe { &*pcred };
281    let blob_size = cred.CredentialBlobSize as usize;
282
283    if blob_size != 32 {
284        // SAFETY: pcred was allocated by CredReadW.
285        unsafe { CredFree(pcred.cast()) };
286        return Err(KeyError::InvalidLength(blob_size));
287    }
288
289    // SAFETY: CredentialBlob is valid for CredentialBlobSize bytes.
290    let raw: [u8; 32] = unsafe {
291        std::slice::from_raw_parts(cred.CredentialBlob, 32)
292            .try_into()
293            .unwrap()
294    };
295
296    // SAFETY: pcred was allocated by CredReadW.
297    unsafe { CredFree(pcred.cast()) };
298
299    Ok(Some(raw))
300}
301
302#[cfg(all(windows, feature = "native-keyring"))]
303fn cred_write(app_name: &str, raw: &[u8; 32]) -> Result<(), KeyError> {
304    use windows::Win32::Security::Credentials::{
305        CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, CREDENTIALW, CredWriteW,
306    };
307    use windows::core::PWSTR;
308
309    let mut target = cred_target(app_name);
310    let mut comment: Vec<u16> = "confex encryption key"
311        .encode_utf16()
312        .chain(std::iter::once(0))
313        .collect();
314
315    let cred = CREDENTIALW {
316        Flags: 0,
317        Type: CRED_TYPE_GENERIC,
318        TargetName: PWSTR(target.as_mut_ptr()),
319        Comment: PWSTR(comment.as_mut_ptr()),
320        LastWritten: Default::default(),
321        CredentialBlobSize: 32,
322        // SAFETY: raw lives for the duration of this call; CredWriteW only reads it.
323        CredentialBlob: raw.as_ptr().cast_mut(),
324        Persist: CRED_PERSIST_LOCAL_MACHINE,
325        AttributeCount: 0,
326        Attributes: std::ptr::null_mut(),
327        TargetAlias: PWSTR::null(),
328        UserName: PWSTR::null(),
329    };
330
331    // SAFETY: cred is a fully initialised CREDENTIALW; all pointer fields are valid for
332    // the duration of the call (target, comment, raw are on the stack above).
333    let ok = unsafe { CredWriteW(&cred, 0) };
334
335    if !ok.as_bool() {
336        return Err(KeyError::CredentialManager("CredWriteW failed".into()));
337    }
338
339    Ok(())
340}
341
342/// Pluggable strategy for wrapping and unwrapping a 32-byte key into/from an opaque blob.
343#[cfg(feature = "sealed-key")]
344pub trait Unsealer {
345    /// Encrypts `key` into an opaque blob.
346    ///
347    /// # Errors
348    /// Returns [`KeyError`] if sealing fails.
349    fn seal(&self, key: &[u8; 32]) -> Result<Vec<u8>, KeyError>;
350
351    /// Decrypts `blob` back to a 32-byte key.
352    ///
353    /// # Errors
354    /// Returns [`KeyError::InvalidBlob`] if the blob is malformed or decryption fails.
355    fn unseal(&self, blob: &[u8]) -> Result<[u8; 32], KeyError>;
356}
357
358/// Seals a 32-byte key with a passphrase using Argon2id KDF + AES-256-GCM.
359///
360/// Blob layout: `[32-byte Argon2id salt][12-byte AES nonce][32-byte ciphertext][16-byte GCM tag]`
361/// = 92 bytes total.
362#[cfg(feature = "sealed-key")]
363pub struct PassphraseUnsealer {
364    passphrase: String,
365}
366
367#[cfg(feature = "sealed-key")]
368impl From<String> for PassphraseUnsealer {
369    fn from(passphrase: String) -> Self {
370        Self { passphrase }
371    }
372}
373
374#[cfg(feature = "sealed-key")]
375impl From<&str> for PassphraseUnsealer {
376    fn from(passphrase: &str) -> Self {
377        Self {
378            passphrase: passphrase.to_owned(),
379        }
380    }
381}
382
383#[cfg(feature = "sealed-key")]
384impl PassphraseUnsealer {
385    /// Creates a new unsealer by reading the passphrase from an environment variable.
386    ///
387    /// # Errors
388    /// Returns [`KeyError::Passphrase`] if the variable is absent or not valid UTF-8.
389    pub fn from_env(var: &str) -> Result<Self, KeyError> {
390        let passphrase = std::env::var(var).map_err(|_| KeyError::Passphrase)?;
391        Ok(Self { passphrase })
392    }
393
394    fn derive_kek(&self, salt: &[u8]) -> Result<[u8; 32], KeyError> {
395        use argon2::{Algorithm, Argon2, Params, Version};
396
397        let params = Params::new(65_536, 3, 4, Some(32)).map_err(KeyError::Kdf)?;
398        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
399        let mut kek = [0u8; 32];
400        argon2
401            .hash_password_into(self.passphrase.as_bytes(), salt, &mut kek)
402            .map_err(KeyError::Kdf)?;
403        Ok(kek)
404    }
405}
406
407#[cfg(feature = "sealed-key")]
408impl Unsealer for PassphraseUnsealer {
409    fn seal(&self, key: &[u8; 32]) -> Result<Vec<u8>, KeyError> {
410        use crate::crypto::Aes256GcmEncryptor;
411        use crate::crypto::Encryptor as _;
412
413        let mut salt = [0u8; 32];
414        rand::thread_rng().fill_bytes(&mut salt);
415
416        let kek = self.derive_kek(&salt)?;
417        let encryptor = Aes256GcmEncryptor::from(&SecretKey(kek));
418        let encrypted = encryptor.encrypt(key).map_err(|_| KeyError::InvalidBlob)?;
419
420        // salt (32) + nonce+ciphertext+tag (12+32+16 = 60) = 92 bytes
421        let mut blob = Vec::with_capacity(32 + encrypted.len());
422        blob.extend_from_slice(&salt);
423        blob.extend_from_slice(&encrypted);
424        Ok(blob)
425    }
426
427    fn unseal(&self, blob: &[u8]) -> Result<[u8; 32], KeyError> {
428        use crate::crypto::Aes256GcmEncryptor;
429        use crate::crypto::Encryptor as _;
430
431        if blob.len() < 33 {
432            return Err(KeyError::InvalidBlob);
433        }
434        let (salt, ciphertext) = blob.split_at(32);
435        let kek = self.derive_kek(salt)?;
436        let encryptor = Aes256GcmEncryptor::from(&SecretKey(kek));
437        let plain = encryptor
438            .decrypt(ciphertext)
439            .map_err(|_| KeyError::InvalidBlob)?;
440
441        let len = plain.len();
442        plain.try_into().map_err(|_| KeyError::InvalidLength(len))
443    }
444}
445
446/// Stores a sealed (encrypted) key blob on disk, using a pluggable [`Unsealer`].
447#[cfg(feature = "sealed-key")]
448pub struct SealedFileKeyProvider<U: Unsealer> {
449    path: PathBuf,
450    unsealer: U,
451}
452
453#[cfg(feature = "sealed-key")]
454impl<U: Unsealer> SealedFileKeyProvider<U> {
455    #[must_use]
456    pub fn new(path: PathBuf, unsealer: U) -> Self {
457        Self { path, unsealer }
458    }
459}
460
461#[cfg(feature = "sealed-key")]
462impl<U: Unsealer> KeyProvider for SealedFileKeyProvider<U> {
463    fn key(&self, _app_name: &str) -> Result<SecretKey, KeyError> {
464        if self.path.exists() {
465            let blob = fs::read(&self.path)?;
466            let raw = self.unsealer.unseal(&blob)?;
467            return Ok(SecretKey(raw));
468        }
469
470        let mut raw = [0u8; 32];
471        rand::thread_rng().fill_bytes(&mut raw);
472
473        let blob = self.unsealer.seal(&raw)?;
474
475        if let Some(parent) = self.path.parent() {
476            fs::create_dir_all(parent)?;
477        }
478        fs::write(&self.path, &blob)?;
479
480        #[cfg(unix)]
481        {
482            use std::os::unix::fs::PermissionsExt as _;
483            fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
484        }
485
486        Ok(SecretKey(raw))
487    }
488}
489
490/// Platform-native keyring provider, selected at compile time.
491///
492/// - Linux: [`SecretServiceKeyring`] (Secret Service via D-Bus, reboot-persistent)
493/// - macOS: [`MacOsKeychain`] (system Keychain, login-persistent)
494/// - Windows: [`WindowsCredentialManager`] (Credential Manager, reboot-persistent)
495///
496/// Requires the `native-keyring` feature.
497#[cfg(all(target_os = "linux", feature = "native-keyring"))]
498pub type NativeKeyring = SecretServiceKeyring;
499
500#[cfg(all(target_os = "macos", feature = "native-keyring"))]
501pub type NativeKeyring = MacOsKeychain;
502
503#[cfg(all(windows, feature = "native-keyring"))]
504pub type NativeKeyring = WindowsCredentialManager;
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use tempfile::tempdir;
510
511    #[test]
512    fn file_key_provider_generates_and_persists() {
513        let dir = tempdir().unwrap();
514        let path = dir.path().join("key");
515        let provider = FileKeyProvider::from(path.clone());
516        let k1 = provider.key("app").unwrap();
517        let k2 = provider.key("app").unwrap();
518        assert_eq!(k1.as_bytes(), k2.as_bytes());
519    }
520
521    #[cfg(all(target_os = "linux", feature = "native-keyring"))]
522    #[test]
523    fn secret_service_stores_and_retrieves() {
524        let provider = SecretServiceKeyring;
525        let app = "confex-test-ss-42";
526        let k1 = match provider.key(app) {
527            Ok(k) => k,
528            Err(KeyError::NoDaemon) => return,
529            Err(e) => panic!("{e}"),
530        };
531        let k2 = provider.key(app).unwrap();
532        assert_eq!(k1.as_bytes(), k2.as_bytes());
533    }
534
535    #[cfg(feature = "sealed-key")]
536    #[test]
537    fn passphrase_unsealer_round_trip() {
538        let unsealer = PassphraseUnsealer::from("hunter2");
539        let key = [0x42u8; 32];
540        let blob = unsealer.seal(&key).unwrap();
541        let recovered = unsealer.unseal(&blob).unwrap();
542        assert_eq!(key, recovered);
543    }
544
545    #[cfg(feature = "sealed-key")]
546    #[test]
547    fn sealed_provider_creates_and_loads() {
548        let dir = tempdir().unwrap();
549        let path = dir.path().join("key.sealed");
550        let provider = SealedFileKeyProvider::new(path, PassphraseUnsealer::from("secret"));
551        let k1 = provider.key("app").unwrap();
552        let k2 = provider.key("app").unwrap();
553        assert_eq!(k1.as_bytes(), k2.as_bytes());
554    }
555
556    #[cfg(feature = "sealed-key")]
557    #[test]
558    fn sealed_provider_wrong_passphrase_errors() {
559        let dir = tempdir().unwrap();
560        let path = dir.path().join("key.sealed");
561        let p1 = SealedFileKeyProvider::new(path.clone(), PassphraseUnsealer::from("correct"));
562        p1.key("app").unwrap();
563        let p2 = SealedFileKeyProvider::new(path, PassphraseUnsealer::from("wrong"));
564        assert!(matches!(p2.key("app"), Err(KeyError::InvalidBlob)));
565    }
566}