Skip to main content

encryptman_keyring/
lib.rs

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