Skip to main content

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}