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
+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();