From c8ab4d6557966fb8967ec5c806b2295974dd1265 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Tue, 13 Oct 2026 06:59:05 +0100 Subject: [PATCH] fix: forget saved credentials when a synced/imported profile is retargeted; --answer needs --screenshot - Saved credentials are keyed by profile id. A sync peer (or a crafted import using 'overwrite') could keep an existing profile's id but point it at another host, user, GitHub API URL, RD Gateway or jump host, and the saved password/token went there on the next connect without a prompt. Such a change now forgets that profile's saved credentials (audited), so the user is asked again. - --answer / NEXUS_ANSWERS auto-fill every credential prompt and --answer is visible in the process list; they're now refused unless --screenshot is given (they exist for screenshot automation). - Docs: IRC passwords are only sent over TLS/tunnel/loopback; sync's retarget behaviour; the --answer restriction. --- docs/guide/chapters/appendix.tex | 2 +- docs/user-manual.md | 5 +++-- rust/app/src/main.rs | 5 ++++- rust/core/src/service.rs | 37 +++++++++++++++++++++++++++++++- rust/core/tests/sync.rs | 5 +++++ 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/guide/chapters/appendix.tex b/docs/guide/chapters/appendix.tex index 746b705..68c0859 100644 --- a/docs/guide/chapters/appendix.tex +++ b/docs/guide/chapters/appendix.tex @@ -76,7 +76,7 @@ \begin{sidequest}[How the screenshots in this guide were made] Every screenshot here is the real app, driven by \code{docs/guide/tools/screenshots.sh}: it starts throwaway SSH, IRC, VNC and GitHub-ish servers, fills a demo data folder, and runs Nexus Terminal headless with -\code{--screenshot}, \code{--do} (run a palette command) and \code{--answer} (fill a password prompt). Change the app, +\code{--screenshot}, \code{--do} (run a palette command) and \code{--answer} (fill a password prompt; only accepted together with \code{--screenshot}). Change the app, rerun the script, rebuild the guide. \end{sidequest} diff --git a/docs/user-manual.md b/docs/user-manual.md index 513c288..4f93a25 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -180,7 +180,7 @@ The viewer works like the VNC one: click the desktop to give it keyboard focus, ### IRC - **IRC**: nick, real name, **Use TLS** (port 6697 for most TLS networks) and an optional server password. -- **SASL**: log in during registration with SASL PLAIN (account defaults to your nick), which most networks now expect; use TLS or an SSH tunnel so the password isn't sent in the clear. If the network doesn't offer SASL or rejects the login, the Server tab says so and the connection carries on. +- **SASL**: log in during registration with SASL PLAIN (account defaults to your nick), which most networks now expect; Nexus Terminal only sends it (or a server or NickServ password) over TLS, an SSH tunnel or to a server on this machine; on a plain connection the Server tab says the password was held back. If the network doesn't offer SASL or rejects the login, the Server tab says so and the connection carries on. - **NickServ**: identify on connect, with an optional account name and password. - **Rooms**: rooms to join automatically, one per line or comma-separated. Networks that support IRCv3 server-time stamp messages with the time they were sent, so replayed history shows its real times. - **SSH Tunnel** and **Reconnection** settings. @@ -435,7 +435,8 @@ Connections, disconnections, profile changes, credential access, imports and exp - for a profile changed on both sides, the more recent change wins; - a profile deleted on one machine is deleted on the others (unless it was edited after the deletion); - saved passwords, keys and tokens are never synced; each machine keeps its own in its keychain or vault. Values that a login script `send`s (including a password typed after a `wait "Password:"`) are part of the profile and do travel, as they do in an export; -- timestamps in the shared file are never trusted beyond the local clock, so a file edited to claim a future date can't override later local changes. +- timestamps in the shared file are never trusted beyond the local clock, so a file edited to claim a future date can't override later local changes; +- if a synced (or imported) profile now points at a different host, user, GitHub API URL or RD Gateway, this machine forgets the credentials it had saved for it and asks again, rather than sending them to the new destination. - **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included. diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 0c78384..3eef0c6 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -99,8 +99,11 @@ fn main() -> eframe::Result { // SAFETY: still single-threaded here; no other thread can be reading the environment. unsafe { std::env::remove_var("NEXUS_ANSWERS") }; } + // They answer every credential prompt in order (and --answer is visible in the process list), + // so they're only honoured for screenshot runs. if !opts.answers.is_empty() && opts.screenshot.is_none() { - eprintln!("warning: --answer/NEXUS_ANSWERS auto-fill credential prompts; they are meant for screenshot automation only"); + eprintln!("--answer/NEXUS_ANSWERS only work together with --screenshot (they auto-fill credential prompts)"); + std::process::exit(2); } let config = match data_dir { Some(d) => { diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 9ca501a..5456124 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -112,6 +112,32 @@ impl Service { Ok(d) } + /// Saved credentials are keyed by profile id. When a profile arriving from a sync folder or + /// an import keeps the id but points somewhere else (another host, user, GitHub API or RD + /// Gateway), the saved password/token would be sent there without a prompt; forget them so + /// the user is asked again. + fn forget_credentials_if_retargeted(&self, old: &Profile, new: &Profile, via: &str) { + let target = |p: &Profile| { + ( + p.host.trim().to_ascii_lowercase(), + p.port, + p.username.trim().to_string(), + p.github.as_ref().map(|g| g.base_url.trim().to_ascii_lowercase()), + p.rdp.as_ref().map(|r| (r.gateway_host.trim().to_ascii_lowercase(), r.gateway_username.trim().to_string())), + p.ssh.as_ref().map(|s| s.jump_hosts.iter().map(|j| (j.host.trim().to_ascii_lowercase(), j.port, j.username.clone())).collect::>()), + ) + }; + if target(old) != target(new) { + self.secrets.delete_profile(&old.id); + self.store.add_audit( + "secret.forget", + &old.id, + "", + &format!("Forgot saved credentials for {:?}: its {via} copy points at a different host or account", old.display_name), + ); + } + } + fn sync_tombstones(&self) -> std::collections::BTreeMap { self.store.setting(SYNC_DELETED).and_then(|j| serde_json::from_str(&j).ok()).unwrap_or_default() } @@ -176,7 +202,13 @@ impl Service { report.skipped += 1; continue; } - let r = if self.store.get_profile(&p.id).is_ok() { self.store.update_profile(&p) } else { self.store.create_profile(&p) }; + let r = match self.store.get_profile(&p.id) { + Ok(old) => { + self.forget_credentials_if_retargeted(&old, &p, "sync"); + self.store.update_profile(&p) + } + Err(_) => self.store.create_profile(&p), + }; match r { Ok(()) => report.pulled += 1, Err(_) => report.skipped += 1, @@ -381,6 +413,9 @@ impl Service { "skip" if exists => continue, "overwrite" if exists => { p.updated_at = now(); + if let Ok(old) = self.store.get_profile(&p.id) { + self.forget_credentials_if_retargeted(&old, &p, "import"); + } self.store.update_profile(&p).is_ok() } "duplicate" => { diff --git a/rust/core/tests/sync.rs b/rust/core/tests/sync.rs index e2d03a5..a4e3dc1 100644 --- a/rust/core/tests/sync.rs +++ b/rust/core/tests/sync.rs @@ -29,6 +29,8 @@ fn profiles_follow_edits_and_deletes_across_machines() { p.host = "web.example".into(); p.username = "me".into(); let p = a.create_profile(p).unwrap(); + let pw_key = nexus_core::secrets::SecretKind::Password.key(&p.id); + a.secrets.set_checked(&pw_key, "hunter2").unwrap(); assert!(a.sync_profiles().unwrap().pushed); assert_eq!(b.sync_profiles().unwrap().pulled, 1); assert_eq!(b.profiles()[0].host, "web.example"); @@ -40,6 +42,9 @@ fn profiles_follow_edits_and_deletes_across_machines() { b.sync_profiles().unwrap(); assert_eq!(a.sync_profiles().unwrap().pulled, 1); assert_eq!(a.profiles()[0].host, "web2.example"); + // A's saved password was for web.example; the synced copy points elsewhere, so it's forgotten + // rather than sent to the new host without asking. + assert!(a.secrets.get(&pw_key).is_none()); a.delete_profile(&p.id).unwrap(); a.sync_profiles().unwrap();