kasl/libs/secret.rs
1//! Credential storage backed by the operating system keyring.
2//!
3//! Passwords are kept in the platform credential store - Credential Manager on
4//! Windows, Keychain on macOS, the Secret Service on Linux - so they are
5//! protected by the user's login session rather than by this program.
6//!
7//! ## Why not the previous scheme
8//!
9//! Until 1.0 credentials lived in AES-256-CBC files under the data directory,
10//! encrypted with a key compiled into the binary. Because release builds were
11//! produced without build-time key material, every published binary shared one
12//! key that is derivable from the public source - so the stored ciphertext was
13//! only obfuscation. The key also had to be identical across versions, which
14//! made rotating it impossible. Both problems disappear once the OS holds the
15//! secret.
16//!
17//! ## Migration
18//!
19//! Existing AES files are read once, transparently: on the first lookup that
20//! misses the keyring, [`Secret::get_or_prompt`] decrypts the legacy file,
21//! stores the value in the keyring, and deletes the file. Users keep working
22//! without re-entering anything. Decryption uses the same compiled-in key as
23//! before, so a binary can always read what it previously wrote; when that
24//! fails - a file written by a differently-keyed build - the user is prompted
25//! instead, which is the same recovery path a corrupted file always had.
26//!
27//! ## Usage
28//!
29//! ```rust,no_run
30//! use kasl::libs::secret::Secret;
31//!
32//! # fn main() -> anyhow::Result<()> {
33//! let secret = Secret::new(".jira_secret", "Enter your Jira password");
34//! let password = secret.get_or_prompt()?;
35//! # Ok(())
36//! # }
37//! ```
38
39use super::data_storage::DataStorage;
40use aes::Aes256;
41use aes::cipher::block_padding::Pkcs7;
42use aes::cipher::{BlockModeDecrypt, KeyIvInit};
43use anyhow::{Context, Result};
44use base64::prelude::*;
45use dialoguer::{Password, theme::ColorfulTheme};
46use keyring::Entry;
47use std::fs;
48use std::io::Read;
49use std::path::PathBuf;
50
51// Include generated metadata containing the legacy encryption keys.
52// Still needed to read credentials written before the keyring migration.
53include!(concat!(env!("OUT_DIR"), "/app_metadata.rs"));
54
55/// Type alias for the legacy AES-256-CBC decryptor.
56type Aes256CbcDec = cbc::Decryptor<Aes256>;
57
58/// Service name under which kasl registers its credentials with the OS.
59///
60/// Appears verbatim in Credential Manager, Keychain and Secret Service, so it
61/// must stay stable: changing it orphans every stored credential.
62const KEYRING_SERVICE: &str = "lacodda.kasl";
63
64/// Credential manager backed by the OS keyring.
65///
66/// Each instance addresses one credential, identified by the account name
67/// derived from the legacy file name (`.jira_secret` becomes `jira`), and knows
68/// how to ask the user for it when the store has nothing.
69#[derive(Clone, Debug)]
70pub struct Secret {
71 /// Account name for this credential inside the keyring service.
72 account: String,
73
74 /// User-facing prompt text for password input.
75 ///
76 /// Displayed when prompting for credentials through the terminal.
77 /// Should be descriptive and indicate which service needs authentication.
78 prompt: String,
79
80 /// Location of the pre-1.0 encrypted file, if one was ever written.
81 ///
82 /// Consulted only during migration and removed once its contents reach the
83 /// keyring.
84 legacy_file_path: PathBuf,
85}
86
87impl Secret {
88 /// Creates a credential handle for the given secret.
89 ///
90 /// # Arguments
91 ///
92 /// * `secret_name` - Legacy file name for the credential (e.g. `.jira_secret`),
93 /// which also determines the keyring account name
94 /// * `prompt` - User-facing text shown when asking for the password
95 ///
96 /// # Examples
97 ///
98 /// ```rust
99 /// use kasl::libs::secret::Secret;
100 ///
101 /// let jira_secret = Secret::new(".jira_secret", "Enter your Jira password");
102 /// ```
103 pub fn new(secret_name: &str, prompt: &str) -> Self {
104 // `.jira_secret` -> `jira`: a readable account name in the OS UI.
105 let account = secret_name.trim_start_matches('.').trim_end_matches("_secret").to_string();
106
107 let legacy_file_path = DataStorage::new().get_path(secret_name).unwrap_or_else(|_| PathBuf::from(secret_name));
108
109 Self {
110 account,
111 prompt: prompt.to_owned(),
112 legacy_file_path,
113 }
114 }
115
116 /// Opens the keyring entry for this credential.
117 fn entry(&self) -> Result<Entry> {
118 Entry::new(KEYRING_SERVICE, &self.account).with_context(|| format!("cannot open keyring entry for '{}'", self.account))
119 }
120
121 /// Retrieves the password from the keyring, prompting when absent.
122 ///
123 /// Order of resolution: keyring, then a legacy AES file (migrated on the
124 /// spot), then the user. A value obtained from either of the last two is
125 /// written to the keyring, so this is the only time it is asked for.
126 ///
127 /// # Returns
128 ///
129 /// The stored or freshly entered password.
130 ///
131 /// # Errors
132 ///
133 /// Returns an error when the keyring is unavailable, or when there is no
134 /// terminal to prompt at and nothing stored to fall back on.
135 pub fn get_or_prompt(&self) -> Result<String> {
136 if let Some(password) = self.try_get_cached() {
137 return Ok(password);
138 }
139
140 self.prompt()
141 }
142
143 /// Returns a stored password without prompting the user.
144 ///
145 /// Used by the background daemon, which has no terminal and must never
146 /// block. Migrates a legacy file if one is found, so an unattended run
147 /// benefits from the migration too.
148 ///
149 /// # Returns
150 ///
151 /// `Some(password)` when the credential is known, `None` otherwise.
152 pub fn try_get_cached(&self) -> Option<String> {
153 if let Ok(entry) = self.entry()
154 && let Ok(password) = entry.get_password()
155 {
156 return Some(password);
157 }
158
159 // Nothing in the keyring - a pre-1.0 install may still have the file.
160 self.migrate_legacy_file()
161 }
162
163 /// Prompts for the password and stores it in the keyring.
164 ///
165 /// # Errors
166 ///
167 /// Fails when stdin is not a terminal, rather than blocking on input that
168 /// cannot arrive - this path is reachable from a scheduled `report --send`.
169 pub fn prompt(&self) -> Result<String> {
170 // Reached whenever a stored credential is missing or stale, including
171 // from a scheduled run. Fail loudly rather than hang there.
172 crate::libs::prompt::ensure_interactive(&format!(
173 "{} - but there is no terminal to ask; run `kasl init` interactively first",
174 self.prompt
175 ))?;
176
177 let password = Password::with_theme(&ColorfulTheme::default()).with_prompt(&self.prompt).interact()?;
178
179 self.store(&password)?;
180
181 Ok(password)
182 }
183
184 /// Writes the password into the OS keyring.
185 pub fn store(&self, password: &str) -> Result<()> {
186 self.entry()?
187 .set_password(password)
188 .with_context(|| format!("cannot store credential '{}' in the OS keyring", self.account))
189 }
190
191 /// Removes the credential from the keyring, if present.
192 ///
193 /// Absence is not an error: the goal is that nothing remains afterwards.
194 pub fn delete(&self) -> Result<()> {
195 match self.entry()?.delete_credential() {
196 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
197 Err(e) => Err(e).with_context(|| format!("cannot remove credential '{}' from the OS keyring", self.account)),
198 }
199 }
200
201 /// Moves a pre-1.0 AES file into the keyring and deletes it.
202 ///
203 /// Returns the recovered password, or `None` when there is no readable
204 /// legacy file. A file that cannot be decrypted is left untouched: it may
205 /// have been written by a build with different key material, and destroying
206 /// it would remove the user's only chance of recovering it by other means.
207 fn migrate_legacy_file(&self) -> Option<String> {
208 let password = self.decrypt_legacy_file().ok()?;
209
210 // Keep the file if the keyring rejects the value, so nothing is lost.
211 if self.store(&password).is_err() {
212 return Some(password);
213 }
214
215 let _ = fs::remove_file(&self.legacy_file_path);
216
217 Some(password)
218 }
219
220 /// Decrypts the legacy AES-256-CBC credential file.
221 fn decrypt_legacy_file(&self) -> Result<String> {
222 let mut file = fs::File::open(&self.legacy_file_path)?;
223 let mut encoded = String::new();
224 file.read_to_string(&mut encoded)?;
225
226 let ciphertext = BASE64_STANDARD.decode(encoded.trim())?;
227 let cipher = Aes256CbcDec::new_from_slices(APP_METADATA_ENCRYPTION_KEY, APP_METADATA_ENCRYPTION_IV)?;
228 let plaintext = cipher
229 .decrypt_padded_vec::<Pkcs7>(&ciphertext)
230 .map_err(|e| anyhow::anyhow!("failed to decrypt stored secret: {e}"))?;
231
232 Ok(String::from_utf8(plaintext)?)
233 }
234}