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:
@@ -76,7 +76,7 @@
|
|||||||
\begin{sidequest}[How the screenshots in this guide were made]
|
\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
|
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
|
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.
|
rerun the script, rebuild the guide.
|
||||||
\end{sidequest}
|
\end{sidequest}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -180,7 +180,7 @@ The viewer works like the VNC one: click the desktop to give it keyboard focus,
|
|||||||
### IRC
|
### IRC
|
||||||
|
|
||||||
- **IRC**: nick, real name, **Use TLS** (port 6697 for most TLS networks) and an optional server password.
|
- **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.
|
- **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.
|
- **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.
|
- **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;
|
- 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);
|
- 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;
|
- 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.
|
- **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.
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ fn main() -> eframe::Result {
|
|||||||
// SAFETY: still single-threaded here; no other thread can be reading the environment.
|
// SAFETY: still single-threaded here; no other thread can be reading the environment.
|
||||||
unsafe { std::env::remove_var("NEXUS_ANSWERS") };
|
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() {
|
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 {
|
let config = match data_dir {
|
||||||
Some(d) => {
|
Some(d) => {
|
||||||
|
|||||||
@@ -112,6 +112,32 @@ impl Service {
|
|||||||
Ok(d)
|
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> {
|
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()
|
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;
|
report.skipped += 1;
|
||||||
continue;
|
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 {
|
match r {
|
||||||
Ok(()) => report.pulled += 1,
|
Ok(()) => report.pulled += 1,
|
||||||
Err(_) => report.skipped += 1,
|
Err(_) => report.skipped += 1,
|
||||||
@@ -381,6 +413,9 @@ impl Service {
|
|||||||
"skip" if exists => continue,
|
"skip" if exists => continue,
|
||||||
"overwrite" if exists => {
|
"overwrite" if exists => {
|
||||||
p.updated_at = now();
|
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()
|
self.store.update_profile(&p).is_ok()
|
||||||
}
|
}
|
||||||
"duplicate" => {
|
"duplicate" => {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ fn profiles_follow_edits_and_deletes_across_machines() {
|
|||||||
p.host = "web.example".into();
|
p.host = "web.example".into();
|
||||||
p.username = "me".into();
|
p.username = "me".into();
|
||||||
let p = a.create_profile(p).unwrap();
|
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!(a.sync_profiles().unwrap().pushed);
|
||||||
assert_eq!(b.sync_profiles().unwrap().pulled, 1);
|
assert_eq!(b.sync_profiles().unwrap().pulled, 1);
|
||||||
assert_eq!(b.profiles()[0].host, "web.example");
|
assert_eq!(b.profiles()[0].host, "web.example");
|
||||||
@@ -40,6 +42,9 @@ fn profiles_follow_edits_and_deletes_across_machines() {
|
|||||||
b.sync_profiles().unwrap();
|
b.sync_profiles().unwrap();
|
||||||
assert_eq!(a.sync_profiles().unwrap().pulled, 1);
|
assert_eq!(a.sync_profiles().unwrap().pulled, 1);
|
||||||
assert_eq!(a.profiles()[0].host, "web2.example");
|
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.delete_profile(&p.id).unwrap();
|
||||||
a.sync_profiles().unwrap();
|
a.sync_profiles().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user