Skip to main content

encryptman_keyring/
lib.rs

1#![forbid(unsafe_code)]
2
3//! # encryptman-keyring
4//!
5//! OS keychain-backed master key storage for
6//! [encryptman](https://crates.io/crates/encryptman).
7//!
8//! This crate eliminates the need to manage raw key files by storing the
9//! master key in the operating system's native credential store:
10//!
11//! - **Windows** — Credential Manager
12//! - **macOS** — Keychain Services
13//! - **Linux** — Secret Service (DBus)
14//!
15//! ## Quick Start
16//!
17//! ```no_run
18//! use encryptman_keyring::Vault;
19//!
20//! // First call: generates a new master key and stores it in the OS keychain.
21//! // Subsequent calls: loads the existing key from the keychain.
22//! let vault = Vault::new("my-app").unwrap();
23//!
24//! let encrypted = vault.encrypt("my_database_password").unwrap();
25//! let decrypted = vault.decrypt(&encrypted).unwrap();
26//!
27//! assert_eq!(decrypted, "my_database_password");
28//!
29//! // Delete the key from the keychain when no longer needed
30//! Vault::delete("my-app").unwrap();
31//! ```
32//!
33//! ## Design
34//!
35//! ```text
36//! Vault::new("my-app")
37//!     │
38//!     ├── keyring::Entry::new("my-app", "master-key")
39//!     │       │
40//!     │       ├── get_secret() → OK → MasterKey::from_bytes()
41//!     │       └── get_secret() → NoEntry → generate + set_secret()
42//!     │
43//!     └── encryptman::encrypt / decrypt using the master key
44//! ```
45//!
46//! The `service` name passed to [`Vault::new`] is used as both the keyring
47//! service identifier **and** the HKDF context for encryptman, providing
48//! domain isolation between different applications.
49//!
50//! ## Migration from file-based keys
51//!
52//! Use [`Vault::migrate_from_file`] to import an existing `.key` file
53//! into the OS keychain and delete the file:
54//!
55//! ```no_run
56//! use encryptman_keyring::Vault;
57//!
58//! let vault = Vault::migrate_from_file("my-app", std::path::Path::new("/path/to/.key")).unwrap();
59//! ```
60//!
61//! ## When NOT to use this crate
62//!
63//! - **Headless / CI environments** — the OS keychain may not be available.
64//!   Use file-based key storage instead.
65//! - **Multi-user servers** — keyring entries are per-user; consider a
66//!   shared secret store like Vault or AWS Secrets Manager.
67
68use encryptman::MasterKey;
69use thiserror::Error;
70use zeroize::Zeroize;
71
72/// The keyring username used to store the master key.
73const KEY_USERNAME: &str = "master-key";
74
75/// Errors that can occur during vault operations.
76#[derive(Debug, Error)]
77pub enum Error {
78    /// The OS keychain returned an error.
79    #[error("keychain error: {0}")]
80    Keychain(#[from] keyring::Error),
81
82    /// The master key stored in the keychain is corrupted or has the wrong length.
83    #[error("invalid master key in keychain: expected 32 bytes, got {0}")]
84    InvalidKeyLength(usize),
85
86    /// A file-based migration source could not be read.
87    #[error("failed to read key file: {0}")]
88    FileRead(#[from] std::io::Error),
89
90    /// The file-based key has the wrong length.
91    #[error("invalid key file: expected 32 bytes, got {0}")]
92    InvalidFileKeyLength(usize),
93
94    /// Encryption or decryption failed.
95    #[error("crypto error: {0}")]
96    Crypto(#[from] encryptman::CryptoError),
97}
98
99/// A vault that stores its master key in the OS keychain and delegates
100/// encryption/decryption to `encryptman`.
101///
102/// Each `Vault` instance is bound to a **service name** (and optionally a
103/// **target username**) that identifies the keychain entry. The same service
104/// name is also used as the HKDF context in `encryptman`, so different
105/// service names produce different encryption keys from the same underlying
106/// keychain entry.
107///
108/// # Examples
109///
110/// ```no_run
111/// use encryptman_keyring::Vault;
112///
113/// let vault = Vault::new("my-app").unwrap();
114/// let ct = vault.encrypt("secret").unwrap();
115/// let pt = vault.decrypt(&ct).unwrap();
116/// assert_eq!(pt, "secret");
117/// Vault::delete("my-app").unwrap();
118/// ```
119pub struct Vault {
120    service: String,
121    master_key: MasterKey,
122}
123
124impl Vault {
125    /// Create or open a vault with the given service name.
126    ///
127    /// On first call, a new random master key is generated and stored in the
128    /// OS keychain. On subsequent calls, the existing key is loaded.
129    ///
130    /// The `service` is used as the keyring service name and as the encryptman
131    /// HKDF context (`"encryptman:{service}"`).
132    ///
133    /// # Errors
134    ///
135    /// Returns [`Error::Keychain`] if the OS keychain is unavailable.
136    pub fn new(service: &str) -> Result<Self, Error> {
137        Self::new_with_target(service, KEY_USERNAME)
138    }
139
140    /// Create or open a vault with a custom target (username) in the keyring.
141    ///
142    /// This is useful when multiple independent vaults are needed within the
143    /// same service namespace.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`Error::Keychain`] if the OS keychain is unavailable.
148    pub fn new_with_target(service: &str, target: &str) -> Result<Self, Error> {
149        let entry = keyring::Entry::new(service, target)?;
150        let master_key = match entry.get_secret() {
151            Ok(bytes) => MasterKey::try_from(bytes.as_slice())
152                .map_err(|_| Error::InvalidKeyLength(bytes.len()))?,
153            Err(keyring::Error::NoEntry) => {
154                let key = MasterKey::generate()?;
155                entry.set_secret(key.as_bytes())?;
156                key
157            }
158            Err(e) => return Err(e.into()),
159        };
160        Ok(Self {
161            service: service.to_string(),
162            master_key,
163        })
164    }
165
166    /// Migrate a file-based key into the OS keychain.
167    ///
168    /// Reads a 32-byte key from `key_path`, stores it in the keychain under
169    /// the given `service` name, and deletes the file on success.
170    ///
171    /// Returns the vault ready for use.
172    ///
173    /// # Errors
174    ///
175    /// - [`Error::FileRead`] if the file cannot be read.
176    /// - [`Error::InvalidFileKeyLength`] if the file is not exactly 32 bytes.
177    /// - [`Error::Keychain`] if the OS keychain is unavailable.
178    pub fn migrate_from_file(service: &str, key_path: &std::path::Path) -> Result<Self, Error> {
179        Self::migrate_from_file_with_target(service, KEY_USERNAME, key_path)
180    }
181
182    /// Migrate a file-based key with a custom target (username).
183    ///
184    /// See [`Vault::migrate_from_file`] for details.
185    pub fn migrate_from_file_with_target(
186        service: &str,
187        target: &str,
188        key_path: &std::path::Path,
189    ) -> Result<Self, Error> {
190        let mut raw = std::fs::read(key_path)?;
191        if raw.len() != 32 {
192            return Err(Error::InvalidFileKeyLength(raw.len()));
193        }
194        let mut bytes = [0u8; 32];
195        bytes.copy_from_slice(&raw);
196        raw.zeroize();
197
198        let entry = keyring::Entry::new(service, target)?;
199        let set_result = entry.set_secret(&bytes);
200        let master_key = MasterKey::from_bytes(bytes);
201        bytes.zeroize();
202        set_result?;
203
204        // Delete the file after successful migration
205        std::fs::remove_file(key_path)?;
206
207        Ok(Self {
208            service: service.to_string(),
209            master_key,
210        })
211    }
212
213    /// Encrypt a plaintext string using the vault's master key.
214    ///
215    /// Delegates to `encryptman::encrypt`. Each call produces a unique
216    /// ciphertext (random nonce).
217    ///
218    /// # Errors
219    ///
220    /// Returns [`Error::Crypto`] if encryption fails.
221    pub fn encrypt(&self, plaintext: &str) -> Result<String, Error> {
222        Ok(encryptman::encrypt(&self.master_key, plaintext)?)
223    }
224
225    /// Decrypt a ciphertext string using the vault's master key.
226    ///
227    /// Delegates to `encryptman::decrypt`.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`Error::Crypto`] if decryption fails (wrong key, corrupted
232    /// data, or invalid base64).
233    pub fn decrypt(&self, ciphertext: &str) -> Result<String, Error> {
234        Ok(encryptman::decrypt(&self.master_key, ciphertext)?)
235    }
236
237    /// Encrypt with a custom HKDF context.
238    ///
239    /// The `context` is appended to `"encryptman:"` to derive a
240    /// domain-specific AES key from the master key.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`Error::Crypto`] if encryption fails.
245    pub fn encrypt_with_context(&self, context: &str, plaintext: &str) -> Result<String, Error> {
246        Ok(encryptman::encrypt_with_context(
247            &self.master_key,
248            context,
249            plaintext,
250        )?)
251    }
252
253    /// Decrypt with a custom HKDF context.
254    ///
255    /// The `context` must match the one used during encryption.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`Error::Crypto`] if decryption fails.
260    pub fn decrypt_with_context(&self, context: &str, ciphertext: &str) -> Result<String, Error> {
261        Ok(encryptman::decrypt_with_context(
262            &self.master_key,
263            context,
264            ciphertext,
265        )?)
266    }
267
268    /// Return a reference to the underlying master key.
269    ///
270    /// This is useful when you need direct access to the key for advanced
271    /// use cases (e.g., custom encryption contexts or binary data).
272    pub fn master_key(&self) -> &MasterKey {
273        &self.master_key
274    }
275
276    /// Encrypt arbitrary bytes using the vault's master key.
277    ///
278    /// Delegates to `encryptman::encrypt_bytes_with_context` using the
279    /// vault's service name as context.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`Error::Crypto`] if encryption fails.
284    pub fn encrypt_bytes(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
285        Ok(encryptman::encrypt_bytes_with_context(
286            &self.master_key,
287            &self.service,
288            plaintext,
289        )?)
290    }
291
292    /// Decrypt arbitrary bytes using the vault's master key.
293    ///
294    /// Delegates to `encryptman::decrypt_bytes_with_context` using the
295    /// vault's service name as context.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`Error::Crypto`] if decryption fails.
300    pub fn decrypt_bytes(&self, packed: &[u8]) -> Result<Vec<u8>, Error> {
301        Ok(encryptman::decrypt_bytes_with_context(
302            &self.master_key,
303            &self.service,
304            packed,
305        )?)
306    }
307
308    /// Encrypt arbitrary bytes with a custom HKDF context.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`Error::Crypto`] if encryption fails.
313    pub fn encrypt_bytes_with_context(
314        &self,
315        context: &str,
316        plaintext: &[u8],
317    ) -> Result<Vec<u8>, Error> {
318        Ok(encryptman::encrypt_bytes_with_context(
319            &self.master_key,
320            context,
321            plaintext,
322        )?)
323    }
324
325    /// Decrypt arbitrary bytes with a custom HKDF context.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`Error::Crypto`] if decryption fails.
330    pub fn decrypt_bytes_with_context(
331        &self,
332        context: &str,
333        packed: &[u8],
334    ) -> Result<Vec<u8>, Error> {
335        Ok(encryptman::decrypt_bytes_with_context(
336            &self.master_key,
337            context,
338            packed,
339        )?)
340    }
341
342    /// Delete the master key from the OS keychain.
343    ///
344    /// This is an associated function because deletion only requires the
345    /// service name — no vault instance (or master key) is needed.
346    ///
347    /// **Warning**: This is destructive. All encrypted data will become
348    /// unrecoverable unless you have a backup of the key.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`Error::Keychain`] if the keychain entry cannot be deleted.
353    pub fn delete(service: &str) -> Result<(), Error> {
354        Self::delete_with_target(service, KEY_USERNAME)
355    }
356
357    /// Delete the master key with a custom target from the OS keychain.
358    ///
359    /// This is an associated function — no vault instance needed.
360    ///
361    /// See [`Vault::delete`] for details.
362    pub fn delete_with_target(service: &str, target: &str) -> Result<(), Error> {
363        let entry = keyring::Entry::new(service, target)?;
364        entry.delete_credential()?;
365        Ok(())
366    }
367}
368
369impl std::fmt::Debug for Vault {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.debug_struct("Vault")
372            .field("service", &self.service)
373            .field("master_key", &"***")
374            .finish()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use serial_test::serial;
382
383    #[test]
384    #[serial]
385    fn encrypt_decrypt_roundtrip() {
386        let vault = Vault::new("test-encryptman-keyring").unwrap();
387        let original = "my_secret_password_123!";
388        let encrypted = vault.encrypt(original).unwrap();
389        let decrypted = vault.decrypt(&encrypted).unwrap();
390        assert_eq!(original, decrypted);
391        let _ = Vault::delete("test-encryptman-keyring");
392    }
393
394    #[test]
395    #[serial]
396    fn encrypt_produces_different_output_each_time() {
397        let vault = Vault::new("test-encryptman-keyring-ne").unwrap();
398        let a = vault.encrypt("same_password").unwrap();
399        let b = vault.encrypt("same_password").unwrap();
400        assert_ne!(a, b);
401        let _ = Vault::delete("test-encryptman-keyring-ne");
402    }
403
404    #[test]
405    #[serial]
406    fn wrong_key_fails() {
407        let vault1 = Vault::new("test-encryptman-keyring-wk1").unwrap();
408        let vault2 = Vault::new("test-encryptman-keyring-wk2").unwrap();
409        let encrypted = vault1.encrypt("secret").unwrap();
410        assert!(vault2.decrypt(&encrypted).is_err());
411        let _ = Vault::delete("test-encryptman-keyring-wk1");
412        let _ = Vault::delete("test-encryptman-keyring-wk2");
413    }
414
415    #[test]
416    #[serial]
417    fn unicode_roundtrip() {
418        let vault = Vault::new("test-encryptman-keyring-unicode").unwrap();
419        let original = "รหัสผ่านภาษาไทย 🔐";
420        let encrypted = vault.encrypt(original).unwrap();
421        let decrypted = vault.decrypt(&encrypted).unwrap();
422        assert_eq!(original, decrypted);
423        let _ = Vault::delete("test-encryptman-keyring-unicode");
424    }
425
426    #[test]
427    #[serial]
428    fn context_isolation() {
429        let vault = Vault::new("test-encryptman-keyring-ctx").unwrap();
430        let enc_a = vault.encrypt_with_context("ctx-a", "same").unwrap();
431        let enc_b = vault.encrypt_with_context("ctx-b", "same").unwrap();
432        assert_ne!(enc_a, enc_b);
433        assert!(vault.decrypt_with_context("ctx-b", &enc_a).is_err());
434        let _ = Vault::delete("test-encryptman-keyring-ctx");
435    }
436
437    #[test]
438    #[serial]
439    fn debug_does_not_leak_key() {
440        let vault = Vault::new("test-encryptman-keyring-debug").unwrap();
441        let debug = format!("{:?}", vault);
442        assert_eq!(
443            debug,
444            "Vault { service: \"test-encryptman-keyring-debug\", master_key: \"***\" }"
445        );
446        let _ = Vault::delete("test-encryptman-keyring-debug");
447    }
448
449    #[test]
450    #[serial]
451    fn new_with_target() {
452        let vault =
453            Vault::new_with_target("test-encryptman-keyring-target", "custom-user").unwrap();
454        let ct = vault.encrypt("hello").unwrap();
455        let pt = vault.decrypt(&ct).unwrap();
456        assert_eq!(pt, "hello");
457        let _ = Vault::delete_with_target("test-encryptman-keyring-target", "custom-user");
458    }
459
460    #[test]
461    #[serial]
462    fn migrate_from_file() {
463        use std::fs;
464        use tempfile::TempDir;
465
466        let dir = TempDir::new().unwrap();
467        let key_path = dir.path().join(".key");
468        let key_bytes = [42u8; 32];
469        fs::write(&key_path, key_bytes).unwrap();
470
471        let vault = Vault::migrate_from_file("test-encryptman-keyring-migrate", &key_path).unwrap();
472        assert_eq!(vault.master_key().as_bytes(), &key_bytes);
473        assert!(
474            !key_path.exists(),
475            "key file should be deleted after migration"
476        );
477
478        let ct = vault.encrypt("migrated").unwrap();
479        let pt = vault.decrypt(&ct).unwrap();
480        assert_eq!(pt, "migrated");
481        let _ = Vault::delete("test-encryptman-keyring-migrate");
482    }
483
484    #[test]
485    #[serial]
486    fn migrate_from_file_wrong_length() {
487        use std::fs;
488        use tempfile::TempDir;
489
490        let dir = TempDir::new().unwrap();
491        let key_path = dir.path().join(".key");
492        fs::write(&key_path, [1u8; 16]).unwrap();
493
494        let result = Vault::migrate_from_file("test-encryptman-keyring-migrate-err", &key_path);
495        assert!(result.is_err());
496        assert!(key_path.exists());
497    }
498
499    #[test]
500    #[serial]
501    fn encrypt_bytes_roundtrip() {
502        let vault = Vault::new("test-encryptman-keyring-bytes").unwrap();
503        let data = b"binary secret data";
504        let encrypted = vault.encrypt_bytes(data).unwrap();
505        assert_ne!(encrypted, data.to_vec());
506        let decrypted = vault.decrypt_bytes(&encrypted).unwrap();
507        assert_eq!(decrypted, data);
508        let _ = Vault::delete("test-encryptman-keyring-bytes");
509    }
510
511    #[test]
512    #[serial]
513    fn encrypt_bytes_produces_different_output_each_time() {
514        let vault = Vault::new("test-encryptman-keyring-bytes-ne").unwrap();
515        let data = b"same data";
516        let a = vault.encrypt_bytes(data).unwrap();
517        let b = vault.encrypt_bytes(data).unwrap();
518        assert_ne!(a, b);
519        let _ = Vault::delete("test-encryptman-keyring-bytes-ne");
520    }
521
522    #[test]
523    #[serial]
524    fn encrypt_bytes_context_isolation() {
525        let vault = Vault::new("test-encryptman-keyring-bytes-ctx").unwrap();
526        let data = b"same data";
527        let a = vault.encrypt_bytes_with_context("ctx-a", data).unwrap();
528        let b = vault.encrypt_bytes_with_context("ctx-b", data).unwrap();
529        assert_ne!(a, b);
530        assert!(vault.decrypt_bytes_with_context("ctx-b", &a).is_err());
531        let _ = Vault::delete("test-encryptman-keyring-bytes-ctx");
532    }
533
534    #[test]
535    #[serial]
536    fn delete_as_associated_function() {
537        let vault = Vault::new("test-encryptman-keyring-del").unwrap();
538        let ct = vault.encrypt("test").unwrap();
539        Vault::delete("test-encryptman-keyring-del").unwrap();
540        assert!(
541            Vault::new("test-encryptman-keyring-del")
542                .unwrap()
543                .decrypt(&ct)
544                .is_err()
545        );
546    }
547}