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