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//! ## 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}