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}