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