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.
This commit is contained in:
Andy Dixon
2026-10-13 06:59:05 +01:00
parent 400d11ea4f
commit c8ab4d6557
5 changed files with 49 additions and 5 deletions
+1 -1
View File
@@ -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}
+3 -2
View File
@@ -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.
+4 -1
View File
@@ -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) => {
+36 -1
View File
@@ -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::<Vec<_>>()),
)
};
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<String, Time> {
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" => {
+5
View File
@@ -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();