Offer to save a password when a login is submitted
Submitting a form containing a password now offers to remember it. It goes into the macOS Keychain, through the Security framework rather than the `security` binary - a password passed as a command-line argument is visible in `ps` to anyone on the machine, however briefly. Never apps.json, never a log. The value travels as little as it can: the injected script hands it straight to Rust, which holds it in memory and tells the shell only which host and which username, since that is all the shell needs to ask the question. It is written on Save and dropped on anything else. Autofill is deliberately not built. Reading a password back out and injecting it into a page is a materially larger surface than offering to store one, and deserves its own decision.
This commit is contained in:
@@ -20,6 +20,9 @@ pub struct AppState {
|
||||
/// Title bar height: how far a child webview's origin sits above the
|
||||
/// content the shell measures from.
|
||||
pub chrome: Mutex<f64>,
|
||||
/// A login waiting on an answer: host, account, password. Held only until
|
||||
/// it is saved or declined, and never written anywhere but the Keychain.
|
||||
pub pending_password: Mutex<Option<(String, String, String)>>,
|
||||
pub config: Mutex<Config>,
|
||||
pub active: Mutex<Option<String>>,
|
||||
pub stage: Mutex<Stage>,
|
||||
@@ -77,6 +80,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
dir,
|
||||
radius: Mutex::new(0.0),
|
||||
chrome: Mutex::new(0.0),
|
||||
pending_password: Mutex::new(None),
|
||||
config: Mutex::new(config),
|
||||
active: Mutex::new(None),
|
||||
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
||||
@@ -763,6 +767,43 @@ pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ passwords
|
||||
|
||||
/// Writes the offered login to the macOS Keychain.
|
||||
///
|
||||
/// The Keychain, not `apps.json`: it is encrypted at rest, unlocked with the
|
||||
/// login session, and the one place on this machine that is actually built to
|
||||
/// hold a password. The value never touches the config file, the logs, or the
|
||||
/// shell.
|
||||
#[tauri::command]
|
||||
pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let offer = state.pending_password.lock().unwrap().take();
|
||||
let Some((host, account, password)) = offer else {
|
||||
return Err("nothing waiting to be saved".into());
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let service = format!("Work — {host}");
|
||||
security_framework::passwords::set_generic_password(
|
||||
&service,
|
||||
&account,
|
||||
password.as_bytes(),
|
||||
)
|
||||
.map_err(|e| format!("the Keychain refused it: {e}"))?;
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = password;
|
||||
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Drops the offered login without saving it.
|
||||
#[tauri::command]
|
||||
pub fn discard_password(state: State<'_, AppState>) {
|
||||
*state.pending_password.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
// -------------------------------------------------- notification clicks
|
||||
|
||||
/// Runs the page's own click handler for a notification it raised.
|
||||
|
||||
Reference in New Issue
Block a user