1use std::{fs, path::PathBuf};
2
3use rand::RngCore as _;
4
5use crate::error::KeyError;
6
7pub 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
17pub trait KeyProvider {
19 fn key(&self, app_name: &str) -> Result<SecretKey, KeyError>;
24}
25
26pub 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#[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 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 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 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#[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#[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 let ok = unsafe { CredReadW(PCWSTR(target.as_ptr()), CRED_TYPE_GENERIC, 0, &mut pcred) };
266
267 if !ok.as_bool() {
268 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 let cred = unsafe { &*pcred };
281 let blob_size = cred.CredentialBlobSize as usize;
282
283 if blob_size != 32 {
284 unsafe { CredFree(pcred.cast()) };
286 return Err(KeyError::InvalidLength(blob_size));
287 }
288
289 let raw: [u8; 32] = unsafe {
291 std::slice::from_raw_parts(cred.CredentialBlob, 32)
292 .try_into()
293 .unwrap()
294 };
295
296 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 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 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#[cfg(feature = "sealed-key")]
344pub trait Unsealer {
345 fn seal(&self, key: &[u8; 32]) -> Result<Vec<u8>, KeyError>;
350
351 fn unseal(&self, blob: &[u8]) -> Result<[u8; 32], KeyError>;
356}
357
358#[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 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 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#[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#[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}