Skip to content

Commit 2ccb45d

Browse files
committed
SMB: Backend for borrowing Finder's saved password (#2, backend half)
Lets Cmdr read the SMB password macOS/Finder already saved in the login keychain, so a yellow os-mount volume can go green (direct smb2) without the user retyping it. Backend only; the FE affordance lands next. - `secrets/system_keychain_smb.rs` (macOS): silent attribute probe (`account_for_any`, no consent) and consent-gated data read (`read_password`) of Finder's `kSecClassInternetPassword` smb item, via `SecItemCopyMatching`. Tries `server_query_candidates` (the name forms the item might be keyed under) and skips the "No user account" guest sentinel. Spike confirmed the read works on a real NAS. - `smb_upgrade::system_keychain_aliases`: gathers the mDNS-service-name / hostname forms for a server from the discovery state (Finder keys by the service name; we mount by IP). - Commands: `system_has_saved_smb_password` (prompt-free probe, drives whether to offer the affordance) and `upgrade_to_smb_volume_using_saved_password` (consent read → direct smb2 → copy the password into Cmdr's own store so future reconnects are silent → fall back to `CredentialsNeeded` if absent/denied). User-initiated only, never at startup. - Adds `security-framework-sys` (already in the lockfile) for the FFI constants + `SecItemCopyMatching`. macOS-only; non-macOS arms + stubs return false / unsupported. - Pure helpers unit-tested (candidate ordering, account sentinel, alias gathering).
1 parent 8e156f4 commit 2ccb45d

10 files changed

Lines changed: 487 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ objc2-app-kit = { version = "0.3", features = [
222222
objc2-quick-look-ui = { version = "0.3.2", features = ["QLPreviewPanel", "QLPreviewItem", "objc2-app-kit"] }
223223
block2 = "0.6"
224224
security-framework = "3.2"
225+
# Raw `SecItemCopyMatching` + keychain query constants, for reading SMB passwords that
226+
# Finder/macOS saved (internet-password items the high-level crate can't filter by
227+
# server/protocol). Already in the lockfile as a transitive dep of `security-framework`.
228+
security-framework-sys = "2.17"
225229
# Drive indexing: macOS FSEvents watcher with event IDs and sinceWhen replay
226230
cmdr-fsevent-stream = { path = "../../../crates/fsevent-stream" }
227231
# Custom updater: signature verification, tarball extraction, version comparison

apps/desktop/src-tauri/src/commands/network.rs

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::network::{
88

99
use crate::network::smb_upgrade::{
1010
UpgradeError, UpgradeResult, friendly_server_name, get_keychain_password, register_smb_volume,
11-
resolve_ip_to_hostname_with_wait, try_smb_upgrade,
11+
resolve_ip_to_hostname_with_wait, system_keychain_aliases, try_smb_upgrade,
1212
};
1313

1414
/// Gets all currently discovered network hosts.
@@ -536,6 +536,150 @@ pub async fn upgrade_to_smb_volume_with_credentials(
536536
}
537537
}
538538

539+
/// Does the system (login) keychain hold an SMB password another app (Finder) saved for
540+
/// this volume's server? Attributes-only probe — **never triggers the consent dialog** —
541+
/// so the frontend can decide whether to offer the "Use the password macOS saved"
542+
/// affordance. macOS-only; returns `false` everywhere else.
543+
#[cfg(target_os = "macos")]
544+
#[tauri::command]
545+
#[specta::specta]
546+
pub async fn system_has_saved_smb_password(volume_id: String) -> Result<bool, String> {
547+
use crate::file_system::get_volume_manager;
548+
use crate::secrets::system_keychain_smb;
549+
use crate::volumes::get_smb_mount_info;
550+
551+
let manager = get_volume_manager();
552+
let Some(volume) = manager.get(&volume_id) else {
553+
return Ok(false);
554+
};
555+
let mount_path = volume.root().to_string_lossy().to_string();
556+
let Some(info) = get_smb_mount_info(&mount_path) else {
557+
return Ok(false);
558+
};
559+
560+
// Use whatever the discovery state already knows (don't warm mDNS just to probe).
561+
let aliases = system_keychain_aliases(&info.server);
562+
let candidates = system_keychain_smb::server_query_candidates(&info.server, None, &aliases);
563+
564+
// Attribute read is fast and prompt-free, but still FFI — keep it off the async worker.
565+
Ok(
566+
tokio::task::spawn_blocking(move || system_keychain_smb::account_for_any(&candidates).is_some())
567+
.await
568+
.unwrap_or(false),
569+
)
570+
}
571+
572+
#[cfg(not(target_os = "macos"))]
573+
#[tauri::command]
574+
#[specta::specta]
575+
pub async fn system_has_saved_smb_password(_volume_id: String) -> Result<bool, String> {
576+
Ok(false)
577+
}
578+
579+
/// Upgrades an OS-mounted SMB volume to a direct smb2 connection using the password that
580+
/// another app (Finder/macOS) already saved in the login keychain — so the user doesn't
581+
/// retype it. Reading the password triggers the macOS consent dialog (the frontend primes
582+
/// the user first; we can't customize the system dialog's text). On success, the password
583+
/// is also copied into Cmdr's own store so future reconnects are silent. If nothing is
584+
/// saved or the user denies access, returns `CredentialsNeeded` so the frontend falls back
585+
/// to its login form. **User-initiated only** — never call this at startup.
586+
#[cfg(target_os = "macos")]
587+
#[tauri::command]
588+
#[specta::specta]
589+
pub async fn upgrade_to_smb_volume_using_saved_password(
590+
volume_id: String,
591+
app_handle: tauri::AppHandle,
592+
) -> Result<UpgradeResult, String> {
593+
use crate::file_system::get_volume_manager;
594+
use crate::secrets::system_keychain_smb;
595+
use crate::volumes::get_smb_mount_info;
596+
597+
let manager = get_volume_manager();
598+
let volume = manager.get(&volume_id).ok_or("Volume not found")?;
599+
let mount_path = volume.root().to_string_lossy().to_string();
600+
601+
if volume.smb_connection_state().is_some() {
602+
return Ok(UpgradeResult::Success);
603+
}
604+
605+
let info = get_smb_mount_info(&mount_path).ok_or_else(|| {
606+
format!(
607+
"Can't determine SMB server info for {}. Is this an SMB mount?",
608+
mount_path
609+
)
610+
})?;
611+
612+
// Warm mDNS so the alias set (Finder keys by the mDNS service name) is populated.
613+
crate::network::ensure_mdns_started(app_handle);
614+
let hostname = resolve_ip_to_hostname_with_wait(&info.server, std::time::Duration::from_millis(1500)).await;
615+
let display_name = friendly_server_name(&info.server);
616+
617+
let aliases = system_keychain_aliases(&info.server);
618+
let candidates = system_keychain_smb::server_query_candidates(&info.server, hostname.as_deref(), &aliases);
619+
620+
// The data read triggers the consent dialog and blocks on the user — keep it off the
621+
// async worker pool.
622+
let creds = tokio::task::spawn_blocking(move || system_keychain_smb::read_password(&candidates))
623+
.await
624+
.ok()
625+
.flatten();
626+
627+
let Some(creds) = creds else {
628+
// Nothing readable, or the user denied access → fall back to the login form.
629+
return Ok(UpgradeResult::CredentialsNeeded {
630+
server: info.server,
631+
share: info.share,
632+
port: info.port,
633+
display_name,
634+
username_hint: info.username,
635+
message: None,
636+
});
637+
};
638+
639+
let result = try_smb_upgrade(
640+
&info.server,
641+
&info.share,
642+
&mount_path,
643+
Some(&creds.username),
644+
Some(&creds.password),
645+
info.port,
646+
&volume_id,
647+
)
648+
.await;
649+
650+
match result {
651+
Ok(()) => {
652+
// Copy the borrowed password into Cmdr's own store so the next reconnect is
653+
// silent (no consent dialog). Keyed by hostname when known, else the server.
654+
let server_key = hostname.as_deref().unwrap_or(&info.server);
655+
if let Err(e) = keychain::save_credentials(server_key, Some(&info.share), &creds.username, &creds.password)
656+
{
657+
log::warn!("Couldn't copy borrowed credentials into Cmdr's store: {}", e);
658+
}
659+
Ok(UpgradeResult::Success)
660+
}
661+
Err(UpgradeError::Auth) => Ok(UpgradeResult::CredentialsNeeded {
662+
server: info.server,
663+
share: info.share,
664+
port: info.port,
665+
display_name,
666+
username_hint: Some(creds.username),
667+
message: Some("The saved password didn't work".to_string()),
668+
}),
669+
Err(UpgradeError::Network(msg)) => Ok(UpgradeResult::NetworkError { message: msg }),
670+
}
671+
}
672+
673+
#[cfg(not(target_os = "macos"))]
674+
#[tauri::command]
675+
#[specta::specta]
676+
pub async fn upgrade_to_smb_volume_using_saved_password(
677+
_volume_id: String,
678+
_app_handle: tauri::AppHandle,
679+
) -> Result<UpgradeResult, String> {
680+
Err("Reading saved SMB passwords is only supported on macOS".to_string())
681+
}
682+
539683
// --- Disconnect Command ---
540684

541685
/// Unmounts all SMB shares mounted from a given server.

apps/desktop/src-tauri/src/ipc.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ pub fn builder() -> Builder<tauri::Wry> {
311311
#[cfg(any(target_os = "macos", target_os = "linux"))]
312312
crate::commands::network::upgrade_to_smb_volume_with_credentials,
313313
#[cfg(any(target_os = "macos", target_os = "linux"))]
314+
crate::commands::network::system_has_saved_smb_password,
315+
#[cfg(any(target_os = "macos", target_os = "linux"))]
316+
crate::commands::network::upgrade_to_smb_volume_using_saved_password,
317+
#[cfg(any(target_os = "macos", target_os = "linux"))]
314318
crate::commands::network::reconnect_smb_volume,
315319
#[cfg(any(target_os = "macos", target_os = "linux"))]
316320
crate::commands::network::reconnect_smb_volume_with_credentials,
@@ -372,6 +376,10 @@ pub fn builder() -> Builder<tauri::Wry> {
372376
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
373377
crate::stubs::network::upgrade_to_smb_volume_with_credentials,
374378
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
379+
crate::stubs::network::system_has_saved_smb_password,
380+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
381+
crate::stubs::network::upgrade_to_smb_volume_using_saved_password,
382+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
375383
crate::stubs::network::reconnect_smb_volume,
376384
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
377385
crate::stubs::network::reconnect_smb_volume_with_credentials,

apps/desktop/src-tauri/src/ipc_collectors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,8 @@ pub(super) fn collect_network_types(types: &mut Types) -> Vec<Function> {
352352
crate::commands::network::mount_network_share,
353353
crate::commands::network::upgrade_to_smb_volume,
354354
crate::commands::network::upgrade_to_smb_volume_with_credentials,
355+
crate::commands::network::system_has_saved_smb_password,
356+
crate::commands::network::upgrade_to_smb_volume_using_saved_password,
355357
crate::commands::network::reconnect_smb_volume,
356358
crate::commands::network::reconnect_smb_volume_with_credentials,
357359
crate::commands::network::disconnect_smb_volume,
@@ -389,6 +391,8 @@ pub(super) fn collect_network_types(types: &mut Types) -> Vec<Function> {
389391
crate::stubs::network::mount_network_share,
390392
crate::stubs::network::upgrade_to_smb_volume,
391393
crate::stubs::network::upgrade_to_smb_volume_with_credentials,
394+
crate::stubs::network::system_has_saved_smb_password,
395+
crate::stubs::network::upgrade_to_smb_volume_using_saved_password,
392396
crate::stubs::network::reconnect_smb_volume,
393397
crate::stubs::network::reconnect_smb_volume_with_credentials,
394398
crate::stubs::network::disconnect_smb_volume,

apps/desktop/src-tauri/src/network/smb_upgrade.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,34 @@ pub(crate) fn friendly_server_name(server: &str) -> String {
351351
resolve_ip_to_hostname(server).unwrap_or_else(|| server.to_string())
352352
}
353353

354+
/// The server-name forms another app (Finder) might have keyed an SMB password under for
355+
/// this server, gathered from the discovery state. Finder typically uses the full mDNS
356+
/// service name (`Naspolya._smb._tcp.local`), while we mount by IP — so for each
357+
/// discovered host that's the same identity as `server`, we contribute its advertised
358+
/// name, its `.local` hostname, and the synthesized `{name}._smb._tcp.local` service form.
359+
/// Pure over `hosts` for testability; the live wrapper feeds `get_discovered_hosts()`.
360+
pub(crate) fn system_keychain_aliases(server: &str) -> Vec<String> {
361+
system_keychain_aliases_from(server, &get_discovered_hosts())
362+
}
363+
364+
fn system_keychain_aliases_from(server: &str, hosts: &[crate::network::NetworkHost]) -> Vec<String> {
365+
use crate::network::server_identity::same_server;
366+
let mut out = Vec::new();
367+
for h in hosts {
368+
let matches = same_server(&h.name, server, hosts)
369+
|| h.hostname.as_deref().is_some_and(|hn| same_server(hn, server, hosts))
370+
|| h.ip_address.as_deref() == Some(server);
371+
if matches {
372+
out.push(h.name.clone());
373+
out.push(format!("{}._smb._tcp.local", h.name));
374+
if let Some(hn) = &h.hostname {
375+
out.push(hn.clone());
376+
}
377+
}
378+
}
379+
out
380+
}
381+
354382
/// Tries to retrieve SMB credentials from the Keychain.
355383
///
356384
/// Tries multiple keys: by IP (from statfs), by hostname (from mDNS discovery),
@@ -400,6 +428,33 @@ mod tests {
400428
use super::*;
401429
use std::time::Duration;
402430

431+
#[test]
432+
fn system_keychain_aliases_include_the_mdns_service_form_for_an_ip() {
433+
use crate::network::{HostSource, NetworkHost};
434+
let hosts = [NetworkHost {
435+
id: "naspolya".into(),
436+
name: "Naspolya".into(),
437+
hostname: Some("Naspolya.local".into()),
438+
ip_address: Some("192.168.1.111".into()),
439+
port: 445,
440+
source: HostSource::Discovered,
441+
}];
442+
// We mount by IP; Finder keyed its password by the mDNS service name. The alias
443+
// set must include that form so the keychain lookup can find it.
444+
let aliases = system_keychain_aliases_from("192.168.1.111", &hosts);
445+
assert!(
446+
aliases.contains(&"Naspolya._smb._tcp.local".to_string()),
447+
"got {aliases:?}"
448+
);
449+
assert!(aliases.contains(&"Naspolya.local".to_string()));
450+
assert!(aliases.contains(&"Naspolya".to_string()));
451+
}
452+
453+
#[test]
454+
fn system_keychain_aliases_empty_for_an_unknown_server() {
455+
assert!(system_keychain_aliases_from("10.9.9.9", &[]).is_empty());
456+
}
457+
403458
#[test]
404459
fn is_private_ipv4_recognizes_rfc1918_and_link_local() {
405460
assert!(is_private_ipv4("10.0.0.1"));

apps/desktop/src-tauri/src/secrets/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ use std::sync::LazyLock;
1414
#[cfg(target_os = "macos")]
1515
mod keychain_macos;
1616

17+
/// Reading SMB passwords other apps (Finder/macOS) saved in the login keychain. macOS-only.
18+
#[cfg(target_os = "macos")]
19+
pub mod system_keychain_smb;
20+
1721
#[cfg(target_os = "linux")]
1822
mod keyring_linux;
1923

0 commit comments

Comments
 (0)