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:
@@ -314,6 +314,20 @@ on a blocking worker rather than the calling thread — its completion handler r
|
|||||||
main thread, and waiting for it *there* deadlocks until the timeout and returns nothing
|
main thread, and waiting for it *there* deadlocks until the timeout and returns nothing
|
||||||
every time.
|
every time.
|
||||||
|
|
||||||
|
## Passwords
|
||||||
|
|
||||||
|
Submitting a form that contains a password offers to remember it, the way a browser does.
|
||||||
|
|
||||||
|
It goes into the **macOS Keychain** — encrypted at rest, unlocked with the login session,
|
||||||
|
and the one place on this machine actually built to hold a password. Never `apps.json`,
|
||||||
|
never a log.
|
||||||
|
|
||||||
|
The value also travels as little as possible: the injected script hands it straight to
|
||||||
|
Rust, which holds it in memory and tells the shell only *which host* and *which username*,
|
||||||
|
because that is all the shell needs to ask the question. It is written on Save and dropped
|
||||||
|
on anything else. Autofill is not built — reading a password back out and injecting it into
|
||||||
|
a page is a larger surface than offering to store one, and worth deciding on separately.
|
||||||
|
|
||||||
## Zoom
|
## Zoom
|
||||||
|
|
||||||
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
|
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
|
||||||
|
|||||||
Generated
+24
@@ -3025,6 +3025,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
@@ -4896,6 +4919,7 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -32,3 +32,4 @@ block2 = "0.6"
|
|||||||
# Notifications are raised here rather than through the plugin, which offers no
|
# Notifications are raised here rather than through the plugin, which offers no
|
||||||
# way to learn that one was clicked.
|
# way to learn that one was clicked.
|
||||||
mac-notification-sys = "0.6"
|
mac-notification-sys = "0.6"
|
||||||
|
security-framework = "3"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub struct AppState {
|
|||||||
/// Title bar height: how far a child webview's origin sits above the
|
/// Title bar height: how far a child webview's origin sits above the
|
||||||
/// content the shell measures from.
|
/// content the shell measures from.
|
||||||
pub chrome: Mutex<f64>,
|
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 config: Mutex<Config>,
|
||||||
pub active: Mutex<Option<String>>,
|
pub active: Mutex<Option<String>>,
|
||||||
pub stage: Mutex<Stage>,
|
pub stage: Mutex<Stage>,
|
||||||
@@ -77,6 +80,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
|||||||
dir,
|
dir,
|
||||||
radius: Mutex::new(0.0),
|
radius: Mutex::new(0.0),
|
||||||
chrome: Mutex::new(0.0),
|
chrome: Mutex::new(0.0),
|
||||||
|
pending_password: Mutex::new(None),
|
||||||
config: Mutex::new(config),
|
config: Mutex::new(config),
|
||||||
active: Mutex::new(None),
|
active: Mutex::new(None),
|
||||||
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
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()
|
.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
|
// -------------------------------------------------- notification clicks
|
||||||
|
|
||||||
/// Runs the page's own click handler for a notification it raised.
|
/// Runs the page's own click handler for a notification it raised.
|
||||||
|
|||||||
@@ -481,6 +481,42 @@
|
|||||||
};
|
};
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- passwords */
|
||||||
|
|
||||||
|
/* Offers to remember a login you just typed, the way a browser does.
|
||||||
|
|
||||||
|
The value goes straight to Rust and into the macOS Keychain; it is never
|
||||||
|
written to this app's config, never logged, and never handed to the shell
|
||||||
|
— the shell is only told which host and which username, which is all it
|
||||||
|
needs to ask you the question. */
|
||||||
|
function credentialsIn(form) {
|
||||||
|
var pw = form.querySelector('input[type="password"]');
|
||||||
|
if (!pw || !pw.value) return null;
|
||||||
|
|
||||||
|
/* The username is whatever text-like field comes before the password —
|
||||||
|
which is how every login form on the web is built, whatever it calls
|
||||||
|
its fields. */
|
||||||
|
var fields = form.querySelectorAll('input');
|
||||||
|
var user = '';
|
||||||
|
for (var i = 0; i < fields.length; i++) {
|
||||||
|
if (fields[i] === pw) break;
|
||||||
|
var t = (fields[i].type || '').toLowerCase();
|
||||||
|
if (t === 'text' || t === 'email' || t === 'tel') user = fields[i].value || user;
|
||||||
|
}
|
||||||
|
return { user: user, pass: pw.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('submit', function (e) {
|
||||||
|
var form = e.target;
|
||||||
|
if (!form || form.tagName !== 'FORM') return;
|
||||||
|
try {
|
||||||
|
var found = credentialsIn(form);
|
||||||
|
if (found) {
|
||||||
|
send('savepw', { u: found.user, p: found.pass, h: location.hostname });
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}, true);
|
||||||
|
|
||||||
/* ---------------------------------------------------------- the icon */
|
/* ---------------------------------------------------------- the icon */
|
||||||
|
|
||||||
/* The site's own icon, read off the page it is on.
|
/* The site's own icon, read off the page it is on.
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ pub fn run() {
|
|||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
commands::unread_counts,
|
commands::unread_counts,
|
||||||
commands::focus_window,
|
commands::focus_window,
|
||||||
|
commands::save_password,
|
||||||
|
commands::discard_password,
|
||||||
commands::notification_click,
|
commands::notification_click,
|
||||||
commands::set_zoom,
|
commands::set_zoom,
|
||||||
commands::set_hidden,
|
commands::set_hidden,
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ pub struct NotificationClick {
|
|||||||
pub notification_id: String,
|
pub notification_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A login worth offering to remember. Carries no password: the value stays in
|
||||||
|
/// Rust until it is either written to the Keychain or dropped.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PasswordOffer {
|
||||||
|
pub app_id: String,
|
||||||
|
pub host: String,
|
||||||
|
pub account: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct IconEvent {
|
pub struct IconEvent {
|
||||||
@@ -247,6 +257,27 @@ fn handle_sentinel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A login was just submitted. The password is held here and offered;
|
||||||
|
// the shell is told only the host and the username, because that is
|
||||||
|
// all it needs to ask the question and the less that value travels
|
||||||
|
// the better.
|
||||||
|
"savepw" => {
|
||||||
|
let (Some(host), Some(pass)) = (params.get("h"), params.get("p")) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if pass.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let account = params.get("u").cloned().unwrap_or_default();
|
||||||
|
let state = handle.state::<crate::commands::AppState>();
|
||||||
|
*state.pending_password.lock().unwrap() =
|
||||||
|
Some((host.clone(), account.clone(), pass.clone()));
|
||||||
|
let _ = handle.emit(
|
||||||
|
"password-offer",
|
||||||
|
PasswordOffer { app_id: from.clone(), host: host.clone(), account },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
"emptycache" => {
|
"emptycache" => {
|
||||||
empty_cache(&handle, &from);
|
empty_cache(&handle, &from);
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-2
@@ -4,9 +4,16 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
import Nav from "./components/Nav";
|
import Nav from "./components/Nav";
|
||||||
import Settings from "./components/Settings";
|
import Settings from "./components/Settings";
|
||||||
import { BTN_PRIMARY } from "./components/ui";
|
import { BTN_PRIMARY, Dialog } from "./components/ui";
|
||||||
import { useAppearance, type Theme } from "./hooks/useAppearance";
|
import { useAppearance, type Theme } from "./hooks/useAppearance";
|
||||||
import type { Config, Group, HiddenEvent, NotificationClick, SwitchEvent } from "./types";
|
import type {
|
||||||
|
Config,
|
||||||
|
Group,
|
||||||
|
HiddenEvent,
|
||||||
|
NotificationClick,
|
||||||
|
PasswordOffer,
|
||||||
|
SwitchEvent,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [config, setConfig] = useState<Config | null>(null);
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
@@ -16,6 +23,8 @@ export default function App() {
|
|||||||
const [theme, setTheme] = useAppearance("system");
|
const [theme, setTheme] = useAppearance("system");
|
||||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||||
const [backdrop, setBackdrop] = useState<string | null>(null);
|
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||||
|
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
||||||
|
const [saved, setSaved] = useState<string | null>(null);
|
||||||
|
|
||||||
const stageRef = useRef<HTMLDivElement>(null);
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
const booted = useRef(false);
|
const booted = useRef(false);
|
||||||
@@ -110,6 +119,8 @@ export default function App() {
|
|||||||
listen<[string, number][]>("unread-changed", (e) => {
|
listen<[string, number][]>("unread-changed", (e) => {
|
||||||
setUnread(Object.fromEntries(e.payload));
|
setUnread(Object.fromEntries(e.payload));
|
||||||
}),
|
}),
|
||||||
|
// A login was submitted. The password is held in Rust; this only asks.
|
||||||
|
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
|
||||||
];
|
];
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.forEach((p) => p.then((f) => f()));
|
unlisten.forEach((p) => p.then((f) => f()));
|
||||||
@@ -204,6 +215,47 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offer && (
|
||||||
|
<Dialog
|
||||||
|
title="Save this password?"
|
||||||
|
onCancel={() => {
|
||||||
|
void api.discardPassword();
|
||||||
|
setOffer(null);
|
||||||
|
}}
|
||||||
|
confirmLabel="Save"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
setSaved(await api.savePassword());
|
||||||
|
} catch {
|
||||||
|
setSaved(null);
|
||||||
|
}
|
||||||
|
setOffer(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{offer.account ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{offer.account}</span> at{" "}
|
||||||
|
<span className="font-mono text-[12px]">{offer.host}</span>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
The login you just entered at{" "}
|
||||||
|
<span className="font-mono text-[12px]">{offer.host}</span>.
|
||||||
|
</>
|
||||||
|
)}{" "}
|
||||||
|
It goes into your macOS Keychain — not into this app's settings file, and
|
||||||
|
nowhere else on disk.
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saved && (
|
||||||
|
<Dialog title="Saved" onCancel={() => setSaved(null)}>
|
||||||
|
The password for <span className="font-mono text-[12px]">{saved}</span> is in
|
||||||
|
your Keychain. You can see and remove it in Keychain Access, under{" "}
|
||||||
|
<span className="font-mono text-[12px]">Work — {saved}</span>.
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Settings
|
<Settings
|
||||||
config={config}
|
config={config}
|
||||||
|
|||||||
@@ -58,3 +58,5 @@ export const appReports = () => invoke<[string, string][]>("app_reports");
|
|||||||
|
|
||||||
export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
|
export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
|
||||||
export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
|
export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
|
||||||
|
export const savePassword = () => invoke<string>("save_password");
|
||||||
|
export const discardPassword = () => invoke<void>("discard_password");
|
||||||
|
|||||||
@@ -57,3 +57,10 @@ export interface NotificationClick {
|
|||||||
appId: string;
|
appId: string;
|
||||||
notificationId: string;
|
notificationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A login just submitted, offered for saving. Carries no password. */
|
||||||
|
export interface PasswordOffer {
|
||||||
|
appId: string;
|
||||||
|
host: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user