Skip to main content

kasl/libs/
secret.rs

1//! Secure credential storage and management with AES encryption.
2//!
3//! Provides functionality for securely storing and retrieving sensitive information
4//! such as passwords and API tokens using AES-256-CBC encryption.
5//!
6//! ## Features
7//!
8//! - **AES-256-CBC Encryption**: Industry-standard encryption for credential storage
9//! - **Compile-time Keys**: Encryption keys embedded during build process
10//! - **Secure Input**: Password prompting without echo to terminal
11//! - **File Protection**: Encrypted credentials stored in user data directory
12//! - **Memory Safety**: Credentials cleared from memory after use
13//!
14//! ## Usage
15//!
16//! ```rust
17//! use kasl::libs::secret::Secret;
18//!
19//! let secret = Secret::new(".jira_secret", "Enter your Jira password");
20//! let password = secret.get_or_prompt()?;
21//! let new_password = secret.prompt()?;
22//! ```
23
24use super::data_storage::DataStorage;
25use aes::Aes256;
26use aes::cipher::block_padding::Pkcs7;
27use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
28use anyhow::Result;
29use base64::prelude::*;
30use dialoguer::{Password, theme::ColorfulTheme};
31use std::fs::{self, File};
32use std::io::{Read, Write};
33use std::path::PathBuf;
34
35// Include generated metadata containing encryption keys
36// This file is created during the build process and contains
37// compile-time embedded encryption keys and initialization vectors
38include!(concat!(env!("OUT_DIR"), "/app_metadata.rs"));
39
40/// Type alias for AES-256-CBC cipher with PKCS7 padding.
41///
42/// This cipher configuration provides:
43/// - **AES-256**: Advanced Encryption Standard with 256-bit keys
44/// - **CBC Mode**: Cipher Block Chaining for secure block encryption
45/// - **PKCS7 Padding**: Standard padding scheme for block alignment
46type Aes256CbcEnc = cbc::Encryptor<Aes256>;
47type Aes256CbcDec = cbc::Decryptor<Aes256>;
48
49/// Secure credential storage and management system.
50///
51/// This structure manages the complete lifecycle of sensitive credentials
52/// including user prompting, encryption, file storage, and decryption.
53/// It provides a high-level interface for secure credential handling.
54///
55/// ## Design Principles
56///
57/// - **Lazy Loading**: Passwords only decrypted when needed
58/// - **Immutable State**: Credential changes create new instances
59/// - **Error Recovery**: Graceful handling of encryption/decryption failures
60/// - **User Friendly**: Clear prompts and error messages
61///
62/// ## Internal State
63///
64/// The struct maintains both encrypted and decrypted state:
65/// - File-based encrypted storage for persistence
66/// - Memory-based decrypted storage for immediate use
67/// - Cryptographic keys for encryption/decryption operations
68///
69/// ## Lifecycle
70///
71/// 1. **Creation**: Initialize with file path and user prompt
72/// 2. **Retrieval**: Check for existing encrypted file
73/// 3. **Prompting**: Secure password input when needed
74/// 4. **Encryption**: AES encryption before file storage
75/// 5. **Decryption**: AES decryption when retrieving
76#[derive(Clone, Debug)]
77pub struct Secret {
78    /// Optional in-memory password storage.
79    ///
80    /// When present, contains the decrypted password for immediate use.
81    /// This avoids repeated decryption operations but increases memory
82    /// exposure time. Cleared when instance is dropped.
83    password: Option<String>,
84
85    /// User-facing prompt text for password input.
86    ///
87    /// Displayed when prompting for credentials through the terminal.
88    /// Should be descriptive and indicate which service needs authentication.
89    /// Examples: "Enter your Jira password", "GitLab API token"
90    prompt: String,
91
92    /// File system path for encrypted credential storage.
93    ///
94    /// Points to the location where encrypted credentials are stored.
95    /// Typically in the user's application data directory with a
96    /// service-specific filename (e.g., ".jira_secret").
97    secret_file_path: PathBuf,
98
99    /// AES encryption key for credential protection.
100    ///
101    /// 256-bit key used for AES encryption/decryption operations.
102    /// Embedded at compile time from build environment or defaults.
103    /// Should be kept consistent across application versions.
104    key: Vec<u8>,
105
106    /// Initialization vector for AES-CBC encryption.
107    ///
108    /// Fixed IV used with AES-CBC mode for deterministic encryption.
109    /// While using a fixed IV reduces security, it allows for consistent
110    /// file-based storage without additional key derivation complexity.
111    iv: Vec<u8>,
112}
113
114impl Secret {
115    /// Creates a new Secret instance for credential management.
116    ///
117    /// This constructor initializes a new secret manager with the specified
118    /// file storage location and user prompt text. It loads encryption keys
119    /// from compile-time embedded metadata and prepares the file path within
120    /// the application's data directory.
121    ///
122    /// ## Key Management
123    ///
124    /// Encryption keys are loaded from compile-time metadata:
125    /// - Keys embedded during build process for security
126    /// - Consistent keys across application installations
127    /// - No runtime key generation or derivation needed
128    ///
129    /// ## File Path Resolution
130    ///
131    /// The secret file path is resolved using the application's data storage:
132    /// - Platform-appropriate user data directory
133    /// - Service-specific filename for credential isolation
134    /// - Automatic directory creation when needed
135    ///
136    /// # Arguments
137    ///
138    /// * `secret_name` - Filename for storing encrypted credentials (e.g., ".jira_secret")
139    /// * `prompt` - User-facing text for password prompts (e.g., "Enter your Jira password")
140    ///
141    /// # Returns
142    ///
143    /// A new Secret instance ready for credential operations.
144    ///
145    /// # Examples
146    ///
147    /// ```rust
148    /// use kasl::libs::secret::Secret;
149    ///
150    /// // Jira password management
151    /// let jira_secret = Secret::new(".jira_secret", "Enter your Jira password");
152    ///
153    /// // GitLab token management
154    /// let gitlab_secret = Secret::new(".gitlab_token", "Enter your GitLab API token");
155    ///
156    /// // SI Server credentials
157    /// let si_secret = Secret::new(".si_credentials", "Enter your SI Server password");
158    /// ```
159    ///
160    /// # Error Handling
161    ///
162    /// Path resolution errors are handled gracefully by falling back to
163    /// the current directory if the data storage path cannot be created.
164    pub fn new(secret_name: &str, prompt: &str) -> Self {
165        // Load compile-time embedded encryption keys
166        let key = APP_METADATA_ENCRYPTION_KEY.to_vec();
167        let iv = APP_METADATA_ENCRYPTION_IV.to_vec();
168
169        // Resolve secret file path in application data directory
170        let secret_file_path = DataStorage::new().get_path(secret_name).unwrap_or_else(|_| PathBuf::from(secret_name));
171
172        Self {
173            password: None,
174            secret_file_path,
175            prompt: prompt.to_owned(),
176            key,
177            iv,
178        }
179    }
180
181    /// Creates a new Secret instance with the specified password.
182    ///
183    /// This internal method creates a copy of the current Secret with
184    /// a different password value. Used for maintaining immutable state
185    /// while updating the in-memory password storage.
186    ///
187    /// # Arguments
188    ///
189    /// * `password` - The password to store in the new instance
190    ///
191    /// # Returns
192    ///
193    /// A new Secret instance with the updated password.
194    fn set_password(&self, password: &str) -> Self {
195        Self {
196            password: Some(password.to_owned()),
197            ..self.clone()
198        }
199    }
200
201    /// Retrieves password from cache or prompts user if not available.
202    ///
203    /// This method implements the primary credential retrieval logic:
204    /// 1. Check if encrypted file exists and is readable
205    /// 2. Attempt to decrypt existing credentials
206    /// 3. Prompt user for new credentials if decryption fails
207    /// 4. Encrypt and store new credentials for future use
208    ///
209    /// ## Caching Behavior
210    ///
211    /// - **Cache Hit**: Return decrypted password from file
212    /// - **Cache Miss**: Prompt user and store new password
213    /// - **Decryption Error**: Re-prompt user (file may be corrupted)
214    ///
215    /// ## Error Recovery
216    ///
217    /// If decryption fails (corrupted file, wrong keys, etc.), the method
218    /// gracefully falls back to prompting the user for new credentials.
219    /// This ensures the application can recover from storage corruption.
220    ///
221    /// # Returns
222    ///
223    /// Returns the user's password, either from cache or fresh input.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if:
228    /// - User cancels password prompt
229    /// - File system operations fail
230    /// - Encryption operations fail
231    ///
232    /// # Examples
233    ///
234    /// ```rust
235    /// use kasl::libs::secret::Secret;
236    ///
237    /// let secret = Secret::new(".api_token", "Enter API token");
238    ///
239    /// // First call prompts user and caches result
240    /// let token = secret.get_or_prompt()?;
241    ///
242    /// // Subsequent calls use cached value
243    /// let same_token = secret.get_or_prompt()?;
244    /// ```
245    pub fn get_or_prompt(&self) -> Result<String> {
246        // Check if encrypted credentials file exists
247        if fs::metadata(&self.secret_file_path).is_ok() {
248            // Attempt to decrypt existing credentials
249            if let Ok(password) = self.decrypt() {
250                return Ok(password);
251            }
252            // Decryption failed - file may be corrupted, continue to prompt
253        }
254
255        // No cached credentials or decryption failed - prompt user
256        self.prompt()
257    }
258
259    /// Returns a cached password without prompting the user.
260    ///
261    /// Used by background daemons that must not block on stdin. Returns
262    /// `None` when the secret file is missing or cannot be decrypted.
263    pub fn try_get_cached(&self) -> Option<String> {
264        if fs::metadata(&self.secret_file_path).is_ok() {
265            self.decrypt().ok()
266        } else {
267            None
268        }
269    }
270
271    /// Prompts user for password and stores it securely.
272    ///
273    /// This method handles the complete password input and storage workflow:
274    /// 1. Display secure password prompt (no echo)
275    /// 2. Encrypt the entered password
276    /// 3. Store encrypted data to file
277    /// 4. Return the entered password
278    ///
279    /// ## Security Features
280    ///
281    /// - **No Echo**: Password characters not displayed on screen
282    /// - **Immediate Encryption**: Password encrypted before file storage
283    /// - **Memory Clearing**: Original password cleared after encryption
284    ///
285    /// ## User Experience
286    ///
287    /// The prompt uses a colorful theme for better visibility and
288    /// provides clear instructions to the user. Password input is
289    /// handled securely without displaying characters.
290    ///
291    /// # Returns
292    ///
293    /// Returns the password entered by the user.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if:
298    /// - User cancels password input (Ctrl+C)
299    /// - Password encryption fails
300    /// - File system write operations fail
301    ///
302    /// # Examples
303    ///
304    /// ```rust
305    /// use kasl::libs::secret::Secret;
306    ///
307    /// let secret = Secret::new(".password", "Enter your password");
308    ///
309    /// // Force password prompt (ignores cache)
310    /// let password = secret.prompt()?;
311    /// ```
312    pub fn prompt(&self) -> Result<String> {
313        // Display secure password prompt
314        let password = Password::with_theme(&ColorfulTheme::default()).with_prompt(&self.prompt).interact()?;
315
316        // Encrypt and store the password
317        self.set_password(&password).encrypt()?;
318
319        Ok(password)
320    }
321
322    /// Encrypts the stored password and saves it to file.
323    ///
324    /// This method performs the complete encryption and storage workflow:
325    /// 1. Initialize AES-256-CBC cipher with embedded keys
326    /// 2. Encrypt the password using PKCS7 padding
327    /// 3. Encode encrypted data as Base64 for safe storage
328    /// 4. Write encoded data to the secret file
329    ///
330    /// ## Encryption Process
331    ///
332    /// - **Input**: Plain text password from memory
333    /// - **Cipher**: AES-256-CBC with compile-time keys
334    /// - **Padding**: PKCS7 for block alignment
335    /// - **Encoding**: Base64 for text-safe storage
336    /// - **Output**: Encrypted file in application data directory
337    ///
338    /// ## File System Operations
339    ///
340    /// - Creates parent directories if they don't exist
341    /// - Overwrites existing credential files
342    /// - Uses platform-appropriate file permissions
343    ///
344    /// # Returns
345    ///
346    /// Returns a new Secret instance for method chaining.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if:
351    /// - No password is set in memory
352    /// - AES encryption fails
353    /// - Base64 encoding fails
354    /// - File system write operations fail
355    ///
356    /// # Examples
357    ///
358    /// ```rust
359    /// let secret = Secret::new(".test", "Test password")
360    ///     .set_password("my_secret")
361    ///     .encrypt()?;
362    /// ```
363    fn encrypt(&self) -> Result<Self> {
364        // Initialize AES cipher with embedded keys
365        let cipher = Aes256CbcEnc::new_from_slices(&self.key, &self.iv)?;
366
367        // Get password from memory
368        let password = &self.password.clone().unwrap();
369
370        // Encrypt password with PKCS7 padding
371        let ciphertext = cipher.encrypt_padded_vec::<Pkcs7>(password.as_bytes());
372
373        // Encode as Base64 for safe file storage
374        let encoded = BASE64_STANDARD.encode(&ciphertext);
375
376        // Ensure parent directory exists
377        if let Some(parent) = self.secret_file_path.parent() {
378            let _ = fs::create_dir_all(parent);
379        }
380
381        // Write encrypted data to file
382        let mut file = File::create(&self.secret_file_path)?;
383        file.write_all(encoded.as_bytes())?;
384
385        Ok(self.clone())
386    }
387
388    /// Decrypts stored credentials from file.
389    ///
390    /// This method performs the complete decryption workflow:
391    /// 1. Read Base64-encoded data from credential file
392    /// 2. Decode Base64 to get raw encrypted bytes
393    /// 3. Initialize AES cipher with embedded keys
394    /// 4. Decrypt data and remove PKCS7 padding
395    /// 5. Convert decrypted bytes to UTF-8 string
396    ///
397    /// ## Decryption Process
398    ///
399    /// - **Input**: Base64-encoded encrypted file
400    /// - **Decoding**: Base64 to raw encrypted bytes
401    /// - **Cipher**: AES-256-CBC with compile-time keys
402    /// - **Padding**: PKCS7 removal for original data
403    /// - **Output**: Plain text password string
404    ///
405    /// ## Error Recovery
406    ///
407    /// The method handles various failure modes:
408    /// - **File Not Found**: Returns error for missing credentials
409    /// - **Invalid Base64**: Returns error for corrupted encoding
410    /// - **Decryption Failure**: Returns error for wrong keys or corrupted data
411    /// - **Invalid UTF-8**: Returns error for corrupted password data
412    ///
413    /// # Returns
414    ///
415    /// Returns the decrypted password as a string.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error if:
420    /// - Credential file doesn't exist or can't be read
421    /// - Base64 decoding fails (corrupted file)
422    /// - AES decryption fails (wrong keys, corrupted data)
423    /// - Decrypted data is not valid UTF-8
424    ///
425    /// # Examples
426    ///
427    /// ```rust
428    /// let secret = Secret::new(".existing_secret", "Password");
429    ///
430    /// match secret.decrypt() {
431    ///     Ok(password) => println!("Retrieved cached password"),
432    ///     Err(_) => println!("No cached password or decryption failed"),
433    /// }
434    /// ```
435    fn decrypt(&self) -> Result<String> {
436        // Read Base64-encoded data from file
437        let mut file = File::open(&self.secret_file_path)?;
438        let mut encoded = String::new();
439        file.read_to_string(&mut encoded)?;
440
441        // Decode Base64 to get encrypted bytes
442        let ciphertext = BASE64_STANDARD.decode(encoded)?;
443
444        // Initialize AES cipher with embedded keys
445        let cipher = Aes256CbcDec::new_from_slices(&self.key, &self.iv)?;
446
447        // Decrypt data and remove padding
448        let decrypted_ciphertext = cipher
449            .decrypt_padded_vec::<Pkcs7>(&ciphertext)
450            .map_err(|e| anyhow::anyhow!("Failed to decrypt stored secret: {e}"))?;
451
452        // Convert decrypted bytes to UTF-8 string
453        let decrypted_password = String::from_utf8(decrypted_ciphertext)?;
454
455        Ok(decrypted_password)
456    }
457}