diff --git a/README.md b/README.md index 03838da3..d2b3d389 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ NyaTerm is built for people who move between servers, local commands, devices, a - SSH, Local Terminal, Telnet, Serial, RDP, and VNC session support - Multi-tab workspace with horizontal and vertical pane splits, tab drag docking, and layout restoration -- RDP and VNC remote desktop panes; VNC currently supports direct TCP, None / classic VNC Auth, Raw / ZRLE / Tight / Tight JPEG framebuffer updates, window scaling, bounded reconnects, and text clipboard exchange for Latin-1 text +- RDP and VNC remote desktop panes; VNC supports direct TCP, None / classic VNC Auth, RA2_256 in Auto mode with server-key TOFU verification, Raw / ZRLE / Tight / Tight JPEG framebuffer updates, window scaling, bounded reconnects, and text clipboard exchange for Latin-1 text - Saved connections with folders, icons, metadata, duplication, keyboard copy, reconnect, and import flows - Command Palette and session quick switcher for finding actions, open sessions, saved connections, and new-session entry points - Main-window `Background Image` customization with `cover` / `contain` / `stretch` / `tile` sizing and adjustable `Background Content Opacity` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 10057cb4..5b9534e3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -950,6 +950,17 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1500,6 +1511,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "dbus" version = "0.9.12" @@ -1586,6 +1606,7 @@ dependencies = [ "const-oid 0.9.6", "der_derive 0.7.3", "flagset", + "pem-rfc7468 0.7.0", "zeroize", ] @@ -1900,6 +1921,19 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -9507,10 +9541,15 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "vnc-rs" version = "0.5.3" dependencies = [ + "aes 0.8.4", "async_io_stream", + "eax", "flate2", "futures", "image", + "rand 0.8.8", + "rsa 0.9.10", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index 4504b837..a0052f8b 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -899,6 +899,7 @@ e+JpiSq66Z6GIt0801skPh20jxOO3F52SoX1IeO5D5PXfZrfSZlw6S8c7bwyp2FHxDewRx config: ConnectionType::Vnc { host: "example.com".to_string(), port: 5900, + username: String::new(), security: VncSecuritySettings::default(), display: VncDisplaySettings::default(), clipboard: VncClipboardSettings::default(), diff --git a/src-tauri/src/cmd/vnc.rs b/src-tauri/src/cmd/vnc.rs index e53fa477..ad84a942 100644 --- a/src-tauri/src/cmd/vnc.rs +++ b/src-tauri/src/cmd/vnc.rs @@ -60,6 +60,15 @@ pub async fn vnc_reconnect( state.inner().clone().reconnect(app, &session_id).await } +#[tauri::command] +pub async fn respond_vnc_server_key( + state: tauri::State<'_, Arc>, + request_id: String, + accepted: bool, +) -> AppResult<()> { + state.respond_server_key(&request_id, accepted).await +} + #[tauri::command] pub async fn close_vnc_session( app: tauri::AppHandle, diff --git a/src-tauri/src/config/connection.rs b/src-tauri/src/config/connection.rs index 85826580..a78ae738 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/src/config/connection.rs @@ -724,6 +724,8 @@ pub enum ConnectionType { #[serde(default = "default_vnc_port")] port: u16, #[serde(default)] + username: String, + #[serde(default)] security: VncSecuritySettings, #[serde(default)] display: VncDisplaySettings, @@ -2721,6 +2723,7 @@ mod tests { let ConnectionType::Vnc { port, + username, security, display, clipboard, @@ -2734,6 +2737,7 @@ mod tests { }; assert_eq!(port, 5900); + assert!(username.is_empty()); assert_eq!(security.mode, "auto"); assert_eq!(display.scale_mode, "fit"); assert!(clipboard.enabled); diff --git a/src-tauri/src/core/vnc.rs b/src-tauri/src/core/vnc.rs index 3acccebd..750f7f59 100644 --- a/src-tauri/src/core/vnc.rs +++ b/src-tauri/src/core/vnc.rs @@ -2,17 +2,18 @@ use crate::config::{self, ConnectionAuth, ConnectionNetwork, ConnectionType}; use crate::core::network::open_tcp_transport; use crate::error::{AppError, AppResult}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use tauri::async_runtime::JoinHandle; use tauri::ipc::{Channel, InvokeResponseBody}; use tauri::{AppHandle, Emitter}; -use tokio::sync::{Mutex, mpsc, watch}; +use tokio::sync::{Mutex, mpsc, oneshot, watch}; use tokio::time::{Duration, timeout}; use vnc::{ ClientKeyEvent, ClientMouseEvent, PixelFormat, Rect, Screen, VncClient, VncConnector, - VncEncoding, VncError, VncEvent, VncLimits, VncSecurityPolicy, X11Event, + VncEncoding, VncError, VncEvent, VncLimits, VncSecurityPolicy, VncServerKey, X11Event, }; use zeroize::Zeroizing; @@ -28,6 +29,8 @@ const WORKER_COMMAND_CHANNEL_CAPACITY: usize = 256; const MAX_PENDING_FRAMES: usize = 2; const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); +const RA2_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(75); +const SERVER_KEY_PROMPT_TIMEOUT: Duration = Duration::from_secs(60); const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(8); const UPDATE_REQUEST_INTERVAL: Duration = Duration::from_millis(16); @@ -64,6 +67,18 @@ pub struct VncStateEvent { pub error_kind: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncServerKeyVerifyEvent { + pub request_id: String, + pub session_id: String, + pub host: String, + pub port: u16, + pub fingerprint: String, + pub key_bits: u32, + pub known_host_status: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct VncClipboardEvent { @@ -94,6 +109,12 @@ enum VncWorkerCommand { FullRefresh, } +struct VncServerKeyPending { + session_id: String, + generation: u64, + responder: oneshot::Sender, +} + #[derive(Debug, Clone)] pub struct VncConnectConfig { pub session_id: String, @@ -101,6 +122,7 @@ pub struct VncConnectConfig { pub name: String, pub host: String, pub port: u16, + pub username: String, pub password: Option, pub security_mode: String, pub scale_mode: String, @@ -200,16 +222,19 @@ pub struct VncSession { cancel_sender: Mutex>>, worker: Mutex>>, close_requested: AtomicBool, + pending_server_keys: Arc>>, } pub struct VncSessionManager { sessions: Mutex>>, + pending_server_keys: Arc>>, } impl VncSessionManager { pub fn new() -> Self { Self { sessions: Mutex::new(HashMap::new()), + pending_server_keys: Arc::new(Mutex::new(HashMap::new())), } } @@ -234,6 +259,7 @@ impl VncSessionManager { cancel_sender: Mutex::new(None), worker: Mutex::new(None), close_requested: AtomicBool::new(false), + pending_server_keys: self.pending_server_keys.clone(), }); self.sessions .lock() @@ -312,6 +338,8 @@ impl VncSessionManager { pub async fn reconnect(self: &Arc, app: AppHandle, session_id: &str) -> AppResult<()> { let session = self.get(session_id).await?; + self.cancel_pending_server_keys_for_session(session_id) + .await; stop_worker(&session).await; session.close_requested.store(false, Ordering::Release); *session.framebuffer.lock().await = None; @@ -327,6 +355,8 @@ impl VncSessionManager { return Ok(()); }; session.close_requested.store(true, Ordering::Release); + self.cancel_pending_server_keys_for_session(session_id) + .await; stop_worker(&session).await; session.frame_attach_id.fetch_add(1, Ordering::AcqRel); *session.frame_channel.lock().await = None; @@ -338,6 +368,36 @@ impl VncSessionManager { Ok(()) } + pub async fn respond_server_key(&self, request_id: &str, accepted: bool) -> AppResult<()> { + let Some(pending) = self.pending_server_keys.lock().await.remove(request_id) else { + return Err(AppError::Cancelled(format!( + "No pending VNC server key request with id '{request_id}'" + ))); + }; + if let Ok(session) = self.get(&pending.session_id).await + && session.generation.load(Ordering::Acquire) != pending.generation + { + return Ok(()); + } + let _ = pending.responder.send(accepted); + Ok(()) + } + + async fn cancel_pending_server_keys_for_session(&self, session_id: &str) { + let mut pending = self.pending_server_keys.lock().await; + let request_ids = pending + .iter() + .filter_map(|(request_id, request)| { + (request.session_id == session_id).then(|| request_id.clone()) + }) + .collect::>(); + for request_id in request_ids { + if let Some(request) = pending.remove(&request_id) { + let _ = request.responder.send(false); + } + } + } + pub async fn close_all(&self, app: &AppHandle) { let ids = self .sessions @@ -470,7 +530,7 @@ async fn run_protocol_generation( set_state(session, VncSessionState::Authenticating, None, None).await; emit_state(app, session, VncSessionState::Authenticating, None, None); let password = Zeroizing::new(session.config.password.clone().unwrap_or_default()); - if password.as_bytes().len() > 8 { + if session.config.security_mode == "vnc-auth" && password.len() > 8 { return Err(( VncErrorKind::Authentication, "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), @@ -493,12 +553,24 @@ async fn run_protocol_generation( ..VncLimits::default() }; let auth_password = password.to_string(); + let has_credentials = session.config.password.is_some(); + let handshake_timeout = if session.config.security_mode == "auto" && has_credentials { + RA2_HANDSHAKE_TIMEOUT + } else { + HANDSHAKE_TIMEOUT + }; + let verifier_app = app.clone(); + let verifier_session = session.clone(); let state = VncConnector::new(stream) .set_auth_method(async move { Ok(auth_password) }) - .set_security_policy(security_policy( - &session.config.security_mode, - session.config.password.is_some(), - )) + .set_credentials_available(has_credentials) + .set_username(session.config.username.clone()) + .set_server_key_verifier(move |server_key| { + let app = verifier_app.clone(); + let session = verifier_session.clone(); + async move { verify_vnc_server_key(&app, &session, generation, &server_key).await } + }) + .set_security_policy(security_policy(&session.config.security_mode)) .set_pixel_format(PixelFormat::rgba()) .set_limits(limits) .add_encoding(VncEncoding::DesktopSizePseudo) @@ -508,7 +580,7 @@ async fn run_protocol_generation( .allow_shared(session.config.shared) .build() .map_err(classify_vnc_error)?; - let client = timeout(HANDSHAKE_TIMEOUT, state.try_start()) + let client = timeout(handshake_timeout, state.try_start()) .await .map_err(|_| { ( @@ -575,15 +647,90 @@ fn vnc_connect_target(config: &VncConnectConfig) -> (&str, u16) { (config.host.as_str(), config.port) } -fn security_policy(mode: &str, has_password: bool) -> VncSecurityPolicy { +fn security_policy(mode: &str) -> VncSecurityPolicy { match mode { "none" => VncSecurityPolicy::NoneOnly, "vnc-auth" => VncSecurityPolicy::VncAuthOnly, - _ if has_password => VncSecurityPolicy::VncAuthOnly, - _ => VncSecurityPolicy::NoneOnly, + _ => VncSecurityPolicy::Auto, } } +async fn verify_vnc_server_key( + app: &AppHandle, + session: &Arc, + generation: u64, + server_key: &VncServerKey, +) -> Result<(), VncError> { + if session.generation.load(Ordering::Acquire) != generation { + return Err(VncError::Ra2ServerKeyRejected( + "stale VNC connection generation".to_string(), + )); + } + + let fingerprint = format!( + "SHA256:{}", + hex::encode(Sha256::digest(server_key.encoded())) + ); + let known_host_status = crate::storage::check_vnc_known_host( + &session.config.host, + session.config.port, + &fingerprint, + ) + .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; + if known_host_status == crate::storage::KnownHostCheck::Match { + return Ok(()); + } + + let request_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + session.pending_server_keys.lock().await.insert( + request_id.clone(), + VncServerKeyPending { + session_id: session.config.session_id.clone(), + generation, + responder: tx, + }, + ); + let payload = VncServerKeyVerifyEvent { + request_id: request_id.clone(), + session_id: session.config.session_id.clone(), + host: session.config.host.clone(), + port: session.config.port, + fingerprint: fingerprint.clone(), + key_bits: server_key.bits(), + known_host_status: match known_host_status { + crate::storage::KnownHostCheck::Match => "match", + crate::storage::KnownHostCheck::HostSeen => "changed", + crate::storage::KnownHostCheck::UnknownHost => "unknown", + } + .to_string(), + }; + let _ = app.emit("vnc-server-key-verify", payload); + + let accepted = match timeout(SERVER_KEY_PROMPT_TIMEOUT, rx).await { + Ok(Ok(accepted)) => accepted, + _ => { + session.pending_server_keys.lock().await.remove(&request_id); + return Err(VncError::Ra2ServerKeyRejected( + "verification timed out".to_string(), + )); + } + }; + if session.generation.load(Ordering::Acquire) != generation { + return Err(VncError::Ra2ServerKeyRejected( + "stale VNC connection generation".to_string(), + )); + } + if !accepted { + return Err(VncError::Ra2ServerKeyRejected( + "user rejected the server key".to_string(), + )); + } + crate::storage::upsert_vnc_known_host(&session.config.host, session.config.port, &fingerprint) + .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; + Ok(()) +} + async fn handle_vnc_event( app: &AppHandle, session: &Arc, @@ -910,18 +1057,24 @@ fn classify_vnc_error(error: VncError) -> (VncErrorKind, String, bool) { "VNC authentication failed".to_string(), false, ), + VncError::CredentialTooLong { .. } | VncError::Ra2ServerKeyRejected(_) => { + (VncErrorKind::Authentication, error.to_string(), false) + } VncError::UnsupportedSecurityType | VncError::RequiredSecurityTypeUnavailable(_) | VncError::InvalidSecurityType(_) => ( VncErrorKind::Authentication, format!( - "The VNC server requires an unsupported security type. Currently supported: None and VNC Authentication. Details: {error}" + "The VNC server requires an unsupported security type. Currently supported: None, VNC Authentication, and RA2_256 in Auto mode. Details: {error}" ), false, ), VncError::InvalidEncoding(_) | VncError::InvalidImageData => { (VncErrorKind::Encoding, error.to_string(), false) } + VncError::IoError(ref source) if source.kind() == std::io::ErrorKind::InvalidData => { + (VncErrorKind::Protocol, error.to_string(), false) + } VncError::IoError(_) => (VncErrorKind::Transport, error.to_string(), true), VncError::LimitExceeded { .. } | VncError::InvalidDimensions @@ -929,7 +1082,16 @@ fn classify_vnc_error(error: VncError) -> (VncErrorKind, String, bool) { | VncError::WrongPixelFormat | VncError::WrongServerMessage | VncError::InvalidSecurityResult(_) - | VncError::SecurityFailure(_) => (VncErrorKind::Protocol, error.to_string(), false), + | VncError::SecurityFailure(_) + | VncError::InvalidRa2KeyLength { .. } + | VncError::InvalidRa2PublicKey + | VncError::InvalidRa2EncryptedRandomLength { .. } + | VncError::InvalidRa2RandomLength(_) + | VncError::Ra2ServerHashMismatch + | VncError::InvalidRa2Subtype(_) + | VncError::Ra2ServerKeyVerifierRequired + | VncError::InvalidRa2RecordLimit(_) + | VncError::Ra2Crypto(_) => (VncErrorKind::Protocol, error.to_string(), false), _ => (VncErrorKind::Internal, error.to_string(), true), } } @@ -979,6 +1141,7 @@ pub fn load_saved_vnc_config(app: &AppHandle, connection_id: &str) -> AppResult< let ConnectionType::Vnc { host, port, + username, security, display, clipboard, @@ -996,8 +1159,9 @@ pub fn load_saved_vnc_config(app: &AppHandle, connection_id: &str) -> AppResult< "VNC Authentication requires a password".to_string(), )); } - if let Some(password) = password.as_ref() - && password.as_bytes().len() > 8 + if security.mode == "vnc-auth" + && let Some(password) = password.as_ref() + && password.len() > 8 { return Err(AppError::Config( "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), @@ -1010,6 +1174,7 @@ pub fn load_saved_vnc_config(app: &AppHandle, connection_id: &str) -> AppResult< name: connection.name, host, port, + username, password, security_mode: security.mode, scale_mode: display.scale_mode, @@ -1090,16 +1255,9 @@ mod tests { #[test] fn security_mode_maps_to_fail_closed_policy() { - assert_eq!(security_policy("none", true), VncSecurityPolicy::NoneOnly); - assert_eq!( - security_policy("vnc-auth", false), - VncSecurityPolicy::VncAuthOnly - ); - assert_eq!( - security_policy("auto", true), - VncSecurityPolicy::VncAuthOnly - ); - assert_eq!(security_policy("auto", false), VncSecurityPolicy::NoneOnly); + assert_eq!(security_policy("none"), VncSecurityPolicy::NoneOnly); + assert_eq!(security_policy("vnc-auth"), VncSecurityPolicy::VncAuthOnly); + assert_eq!(security_policy("auto"), VncSecurityPolicy::Auto); } #[test] @@ -1110,6 +1268,7 @@ mod tests { name: "IPv6 VNC".to_string(), host: "::1".to_string(), port: 5900, + username: String::new(), password: None, security_mode: "none".to_string(), scale_mode: "fit".to_string(), @@ -1133,6 +1292,7 @@ mod tests { name: "Test VNC".to_string(), host: "127.0.0.1".to_string(), port: 5900, + username: String::new(), password: None, security_mode: "none".to_string(), scale_mode: "fit".to_string(), @@ -1156,6 +1316,7 @@ mod tests { cancel_sender: Mutex::new(None), worker: Mutex::new(None), close_requested: AtomicBool::new(false), + pending_server_keys: Arc::new(Mutex::new(HashMap::new())), }); queue_or_send_frame(&session, vec![1]).await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 684fde47..ae5a34d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -236,6 +236,7 @@ pub fn run() { cmd::vnc::vnc_input_batch, cmd::vnc::vnc_set_clipboard_text, cmd::vnc::vnc_reconnect, + cmd::vnc::respond_vnc_server_key, cmd::vnc::close_vnc_session, cmd::session::cancel_session_creation, cmd::session::list_serial_ports, diff --git a/src-tauri/src/storage/migration.rs b/src-tauri/src/storage/migration.rs index b1303f12..2a02f90c 100644 --- a/src-tauri/src/storage/migration.rs +++ b/src-tauri/src/storage/migration.rs @@ -166,6 +166,8 @@ impl Storage { txn.open_table(KNOWN_HOSTS_TABLE).map_err(storage_error)?; txn.open_table(RDP_KNOWN_HOSTS_TABLE) .map_err(storage_error)?; + txn.open_table(VNC_KNOWN_HOSTS_TABLE) + .map_err(storage_error)?; txn.open_table(NOTE_FOLDERS_TABLE).map_err(storage_error)?; txn.open_table(NOTES_TABLE).map_err(storage_error)?; txn.open_table(NOTE_SUMMARIES_TABLE) @@ -193,6 +195,8 @@ pub(super) fn open_all_v3_tables(txn: &redb::WriteTransaction) -> AppResult<()> txn.open_table(KNOWN_HOSTS_TABLE).map_err(storage_error)?; txn.open_table(RDP_KNOWN_HOSTS_TABLE) .map_err(storage_error)?; + txn.open_table(VNC_KNOWN_HOSTS_TABLE) + .map_err(storage_error)?; txn.open_table(COMMAND_HISTORY_TABLE) .map_err(storage_error)?; txn.open_table(NOTE_FOLDERS_TABLE).map_err(storage_error)?; diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 7f157806..46560121 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -13,6 +13,7 @@ mod sessions; mod settings_impl; mod tables; mod util; +mod vnc_known_hosts; #[cfg(test)] mod tests; @@ -26,7 +27,7 @@ pub use tables::{ IDX_CONNECTIONS_BY_GROUP_TABLE, IDX_CONNECTIONS_BY_LAST_USED_TABLE, IDX_CONNECTIONS_BY_PROTOCOL_TABLE, KNOWN_HOSTS_TABLE, META_TABLE, NOTE_FOLDERS_TABLE, NOTES_TABLE, OTP_ACCOUNTS_TABLE, PROXIES_TABLE, RDP_KNOWN_HOSTS_TABLE, SETTINGS_TABLE, - TUNNELS_TABLE, + TUNNELS_TABLE, VNC_KNOWN_HOSTS_TABLE, }; use crate::error::{AppError, AppResult}; @@ -341,6 +342,22 @@ pub(crate) fn upsert_rdp_known_host( storage()?.upsert_rdp_known_host(host, port, sha256_fingerprint, certificate) } +pub(crate) fn check_vnc_known_host( + host: &str, + port: u16, + sha256_fingerprint: &str, +) -> AppResult { + storage()?.check_vnc_known_host(host, port, sha256_fingerprint) +} + +pub(crate) fn upsert_vnc_known_host( + host: &str, + port: u16, + sha256_fingerprint: &str, +) -> AppResult<()> { + storage()?.upsert_vnc_known_host(host, port, sha256_fingerprint) +} + pub(crate) fn load_master_key_token() -> AppResult> { storage()?.load_master_key_token() } diff --git a/src-tauri/src/storage/tables.rs b/src-tauri/src/storage/tables.rs index c08c45fb..c256f19b 100644 --- a/src-tauri/src/storage/tables.rs +++ b/src-tauri/src/storage/tables.rs @@ -26,6 +26,7 @@ pub(super) const TUNNEL_PREFIX: &str = "tunnels/"; pub(super) const KNOWN_HOST_PREFIX: &str = "known_hosts/"; pub(super) const KNOWN_HOST_RAW_PREFIX: &str = "known_hosts/raw/"; pub(super) const RDP_KNOWN_HOST_PREFIX: &str = "rdp_known_hosts/"; +pub(super) const VNC_KNOWN_HOST_PREFIX: &str = "vnc_known_hosts/"; pub(super) const COMMAND_HISTORY_PREFIX: &str = "command_history/"; pub(super) const NOTE_FOLDER_PREFIX: &str = "note_folders/"; pub(super) const NOTE_DOCUMENT_PREFIX: &str = "notes/"; @@ -45,6 +46,8 @@ pub const TUNNELS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("tu pub const KNOWN_HOSTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("known_hosts"); pub const RDP_KNOWN_HOSTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("rdp_known_hosts"); +pub const VNC_KNOWN_HOSTS_TABLE: TableDefinition<&str, &[u8]> = + TableDefinition::new("vnc_known_hosts"); pub const COMMAND_HISTORY_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("command_history"); pub const NOTE_FOLDERS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("note_folders"); @@ -127,6 +130,15 @@ pub(super) struct RdpKnownHostRecord { pub(super) updated_at_ms: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct VncKnownHostRecord { + pub(super) host: String, + pub(super) port: u16, + pub(super) sha256_fingerprint: String, + pub(super) created_at_ms: u64, + pub(super) updated_at_ms: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct HistoryStoreFileV2 { pub(super) version: u32, diff --git a/src-tauri/src/storage/tests.rs b/src-tauri/src/storage/tests.rs index ebc11431..a0ce5c85 100644 --- a/src-tauri/src/storage/tests.rs +++ b/src-tauri/src/storage/tests.rs @@ -492,3 +492,30 @@ fn known_hosts_management_clear_removes_ssh_records_without_touching_rdp() { ); let _ = fs::remove_dir_all(dir); } + +#[test] +fn vnc_known_hosts_distinguish_unknown_match_and_changed_keys() { + let (dir, storage) = test_storage("vnc-known-hosts"); + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:first") + .expect("check unknown"), + KnownHostCheck::UnknownHost + ); + storage + .upsert_vnc_known_host("pi.local", 5900, "SHA256:first") + .expect("save fingerprint"); + assert_eq!( + storage + .check_vnc_known_host("PI.LOCAL", 5900, "sha256:FIRST") + .expect("check match"), + KnownHostCheck::Match + ); + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:changed") + .expect("check changed"), + KnownHostCheck::HostSeen + ); + let _ = fs::remove_dir_all(dir); +} diff --git a/src-tauri/src/storage/vnc_known_hosts.rs b/src-tauri/src/storage/vnc_known_hosts.rs new file mode 100644 index 00000000..c0712acf --- /dev/null +++ b/src-tauri/src/storage/vnc_known_hosts.rs @@ -0,0 +1,56 @@ +use crate::error::AppResult; + +use super::history::history_id; +use super::tables::*; +use super::util::*; +use super::{KnownHostCheck, Storage}; + +impl Storage { + pub fn check_vnc_known_host( + &self, + host: &str, + port: u16, + sha256_fingerprint: &str, + ) -> AppResult { + let Some(record) = self.read_json::( + VNC_KNOWN_HOSTS_TABLE, + &vnc_known_host_key(host, port), + )? + else { + return Ok(KnownHostCheck::UnknownHost); + }; + + if record + .sha256_fingerprint + .eq_ignore_ascii_case(sha256_fingerprint) + { + Ok(KnownHostCheck::Match) + } else { + Ok(KnownHostCheck::HostSeen) + } + } + + pub fn upsert_vnc_known_host( + &self, + host: &str, + port: u16, + sha256_fingerprint: &str, + ) -> AppResult<()> { + let now = current_time_ms(); + let key = vnc_known_host_key(host, port); + let existing: Option = self.read_json(VNC_KNOWN_HOSTS_TABLE, &key)?; + let record = VncKnownHostRecord { + host: host.to_string(), + port, + sha256_fingerprint: sha256_fingerprint.to_string(), + created_at_ms: existing.map_or(now, |record| record.created_at_ms), + updated_at_ms: now, + }; + self.write_json(VNC_KNOWN_HOSTS_TABLE, &key, &record) + } +} + +pub(super) fn vnc_known_host_key(host: &str, port: u16) -> String { + let host_identifier = format!("{}:{}", host.trim().to_ascii_lowercase(), port); + format!("{VNC_KNOWN_HOST_PREFIX}{}", history_id(&host_identifier)) +} diff --git a/src-tauri/vendor/README.md b/src-tauri/vendor/README.md index d6f4f4d1..3cc9c877 100644 --- a/src-tauri/vendor/README.md +++ b/src-tauri/vendor/README.md @@ -47,6 +47,7 @@ This directory contains local copies of upstream crates used by NyaTerm's RDP st - Adds configurable limits for strings, dimensions, rectangles, encoded/decoded payloads, clipboard data, and channels. - Reduces internal channel capacity and adds deterministic protocol/parser regression tests. - Adds an explicit security policy so NyaTerm can fail closed for `none`, `vnc-auth`, or password-aware `auto`. + - Adds RA2_256 (security type 129) with bounded RSA key exchange, AES-EAX encrypted records, username/password subtypes, and a pre-credential server-key verification callback used by NyaTerm's TOFU flow. - Integration status: used by NyaTerm's VNC direct-TCP manager and React pane. Raw is the required fallback; compressed encodings must remain gated by fork tests and interoperability checks before being advertised. ## Update Method diff --git a/src-tauri/vendor/vnc-rs/Cargo.toml b/src-tauri/vendor/vnc-rs/Cargo.toml index 680a0130..8da3995a 100644 --- a/src-tauri/vendor/vnc-rs/Cargo.toml +++ b/src-tauri/vendor/vnc-rs/Cargo.toml @@ -18,6 +18,11 @@ name = "vnc" path = "src/lib.rs" [dependencies] +aes = "0.8" +eax = "0.5" +rand = "0.8" +rsa = "0.9" +sha2 = "0.10" #error thiserror = "^1" flate2 = "^1" diff --git a/src-tauri/vendor/vnc-rs/VENDOR.md b/src-tauri/vendor/vnc-rs/VENDOR.md index 9e83d289..aa5c0876 100644 --- a/src-tauri/vendor/vnc-rs/VENDOR.md +++ b/src-tauri/vendor/vnc-rs/VENDOR.md @@ -12,7 +12,10 @@ NyaTerm vendors this crate to harden its network parser before application integration. Local changes forbid unsafe Rust, replace network-controlled panic and undefined-behavior paths with typed errors, add bounded protocol limits, reduce queue sizes, add explicit security-selection policy, and add -deterministic handshake/parser regression tests. +deterministic handshake/parser regression tests. The fork also implements +RA2_256 (security type 129) with RSA key exchange, AES-EAX records, bounded +credentials, and an application-provided server-key verifier that runs before +credentials are sent. This fork is used by NyaTerm's direct-TCP VNC manager and React pane. Raw must remain the required fallback. ZRLE/Tight support should only be advertised after diff --git a/src-tauri/vendor/vnc-rs/src/client/auth.rs b/src-tauri/vendor/vnc-rs/src/client/auth.rs index 87cfeefe..e0f5eba9 100644 --- a/src-tauri/vendor/vnc-rs/src/client/auth.rs +++ b/src-tauri/vendor/vnc-rs/src/client/auth.rs @@ -11,6 +11,7 @@ pub(super) enum SecurityType { VncAuth = 2, RA2 = 5, RA2ne = 6, + RA2_256 = 129, Tight = 16, Ultra = 17, Tls = 18, @@ -30,6 +31,7 @@ impl TryFrom for SecurityType { 2 => Ok(Self::VncAuth), 5 => Ok(Self::RA2), 6 => Ok(Self::RA2ne), + 129 => Ok(Self::RA2_256), 16 => Ok(Self::Tight), 17 => Ok(Self::Ultra), 18 => Ok(Self::Tls), @@ -108,7 +110,17 @@ impl SecurityType { } let mut sec_types = Vec::with_capacity(num as usize); for _ in 0..num { - sec_types.push(reader.read_u8().await?.try_into()?); + let raw = reader.read_u8().await?; + match raw.try_into() { + Ok(security_type) => sec_types.push(security_type), + Err(VncError::InvalidSecurityType(_)) => { + tracing::trace!( + raw, + "Ignoring unsupported advertised VNC security type" + ); + } + Err(error) => return Err(error), + } } tracing::trace!("Server supported security type: {:?}", sec_types); Ok(sec_types) @@ -235,15 +247,24 @@ mod tests { } #[tokio::test] - async fn rejects_unknown_security_type_and_result() { - let mut security = &[1_u8, 99][..]; - let error = SecurityType::read(&mut security, &VncVersion::RFB38, &VncLimits::default()) + async fn ignores_unknown_security_types_in_rfb38_lists_but_rejects_unknown_results() { + let mut security = &[3_u8, 99, 129, 2][..]; + let types = SecurityType::read(&mut security, &VncVersion::RFB38, &VncLimits::default()) .await - .unwrap_err(); - assert!(matches!(error, VncError::InvalidSecurityType(99))); + .unwrap(); + assert_eq!(types, vec![SecurityType::RA2_256, SecurityType::VncAuth]); assert!(matches!( AuthResult::try_from(7), Err(VncError::InvalidSecurityResult(7)) )); } + + #[tokio::test] + async fn rfb33_keeps_unknown_single_security_type_fail_closed() { + let mut security = &99_u32.to_be_bytes()[..]; + let error = SecurityType::read(&mut security, &VncVersion::RFB33, &VncLimits::default()) + .await + .unwrap_err(); + assert!(matches!(error, VncError::InvalidSecurityType(99))); + } } diff --git a/src-tauri/vendor/vnc-rs/src/client/connector.rs b/src-tauri/vendor/vnc-rs/src/client/connector.rs index 1ce14faa..e7acf2db 100644 --- a/src-tauri/vendor/vnc-rs/src/client/connector.rs +++ b/src-tauri/vendor/vnc-rs/src/client/connector.rs @@ -1,9 +1,11 @@ use super::{ auth::{read_security_failure, AuthHelper, AuthResult, SecurityType}, connection::VncClient, + security::ra2::{self, ServerKeyVerifier, VncServerKey}, }; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; use tracing::{info, trace}; @@ -33,7 +35,7 @@ where { pub fn try_start( self, - ) -> Pin> + Send + Sync + 'static>> { + ) -> Pin> + Send + 'static>> { Box::pin(async move { match self { VncState::Handshake(mut connector) => { @@ -64,120 +66,145 @@ where ) .await?; - if connector.security_policy == VncSecurityPolicy::NoneOnly - && !security_types.contains(&SecurityType::None) - { - return Err(VncError::RequiredSecurityTypeUnavailable("none")); - } - if connector.security_policy == VncSecurityPolicy::VncAuthOnly - && !security_types.contains(&SecurityType::VncAuth) - { - return Err(VncError::RequiredSecurityTypeUnavailable("vnc-auth")); - } - - let prefer_none = match connector.security_policy { - VncSecurityPolicy::Auto => connector.auth_methond.is_none(), - VncSecurityPolicy::NoneOnly => true, - VncSecurityPolicy::VncAuthOnly => false, + let selected = match connector.security_policy { + VncSecurityPolicy::NoneOnly => { + if !security_types.contains(&SecurityType::None) { + return Err(VncError::RequiredSecurityTypeUnavailable("none")); + } + SecurityType::None + } + VncSecurityPolicy::VncAuthOnly => { + if !security_types.contains(&SecurityType::VncAuth) { + return Err(VncError::RequiredSecurityTypeUnavailable("vnc-auth")); + } + SecurityType::VncAuth + } + VncSecurityPolicy::Auto if connector.credentials_available => { + if connector.rfb_version != VncVersion::RFB33 + && security_types.contains(&SecurityType::RA2_256) + { + SecurityType::RA2_256 + } else if security_types.contains(&SecurityType::VncAuth) { + SecurityType::VncAuth + } else { + return Err(VncError::UnsupportedSecurityType); + } + } + VncSecurityPolicy::Auto => { + if security_types.contains(&SecurityType::None) { + SecurityType::None + } else { + return Err(VncError::UnsupportedSecurityType); + } + } }; - if prefer_none && security_types.contains(&SecurityType::None) { - match connector.rfb_version { - VncVersion::RFB33 => { - // If the security-type is 1, for no authentication, the server does not - // send the SecurityResult message but proceeds directly to the - // initialization messages (Section 7.3). - info!("No auth needed in vnc3.3"); - } - VncVersion::RFB37 => { - // After the security handshake, if the security-type is 1, for no - // authentication, the server does not send the SecurityResult message - // but proceeds directly to the initialization messages (Section 7.3). - info!("No auth needed in vnc3.7"); - SecurityType::write(&SecurityType::None, &mut connector.stream) - .await?; - } - VncVersion::RFB38 => { - info!("No auth needed in vnc3.8"); - SecurityType::write(&SecurityType::None, &mut connector.stream) - .await?; - let result: AuthResult = - connector.stream.read_u32().await?.try_into()?; - if result == AuthResult::Failed { - let reason = read_security_failure( - &mut connector.stream, - &connector.limits, - ) - .await?; - return Err(VncError::SecurityFailure(reason)); + let client = match selected { + SecurityType::None => { + match connector.rfb_version { + VncVersion::RFB33 => { + info!("No auth needed in vnc3.3"); + } + VncVersion::RFB37 => { + info!("No auth needed in vnc3.7"); + SecurityType::write(&selected, &mut connector.stream).await?; + } + VncVersion::RFB38 => { + info!("No auth needed in vnc3.8"); + SecurityType::write(&selected, &mut connector.stream).await?; + let result: AuthResult = + connector.stream.read_u32().await?.try_into()?; + if result == AuthResult::Failed { + let reason = read_security_failure( + &mut connector.stream, + &connector.limits, + ) + .await?; + return Err(VncError::SecurityFailure(reason)); + } } } + VncClient::new( + connector.stream, + connector.allow_shared, + connector.pixel_format, + connector.encodings, + connector.limits, + ) + .await? } - } else { - // choose a auth method - if security_types.contains(&SecurityType::VncAuth) { - if connector.rfb_version != VncVersion::RFB33 { - // In the security handshake (Section 7.1.2), rather than a two-way - // negotiation, the server decides the security type and sends a single - // word: - - // +--------------+--------------+---------------+ - // | No. of bytes | Type [Value] | Description | - // +--------------+--------------+---------------+ - // | 4 | U32 | security-type | - // +--------------+--------------+---------------+ - - // The security-type may only take the value 0, 1, or 2. A value of 0 - // means that the connection has failed and is followed by a string - // giving the reason, as described in Section 7.1.2. - SecurityType::write(&SecurityType::VncAuth, &mut connector.stream) - .await?; + SecurityType::VncAuth => { + let credential = connector + .auth_methond + .take() + .ok_or(VncError::NoPassword)? + .await?; + if credential.len() > 8 { + return Err(VncError::CredentialTooLong { + field: "classic VNC password", + actual: credential.len(), + limit: 8, + }); } - } else { - return Err(VncError::UnsupportedSecurityType); - } - - // get password - if connector.auth_methond.is_none() { - return Err(VncError::NoPassword); - } - - let credential = connector - .auth_methond - .take() - .ok_or(VncError::NoPassword)? - .await?; - - // auth - let auth = AuthHelper::read(&mut connector.stream, &credential).await?; - auth.write(&mut connector.stream).await?; - let result = auth.finish(&mut connector.stream).await?; - if let AuthResult::Failed = result { - if let VncVersion::RFB37 = connector.rfb_version { - // In VNC Authentication (Section 7.2.2), if the authentication fails, - // the server sends the SecurityResult message, but does not send an - // error message before closing the connection. - return Err(VncError::WrongPassword); - } else { + if connector.rfb_version != VncVersion::RFB33 { + SecurityType::write(&selected, &mut connector.stream).await?; + } + let auth = AuthHelper::read(&mut connector.stream, &credential).await?; + auth.write(&mut connector.stream).await?; + let result = auth.finish(&mut connector.stream).await?; + if let AuthResult::Failed = result { + if let VncVersion::RFB37 = connector.rfb_version { + return Err(VncError::WrongPassword); + } let reason = read_security_failure(&mut connector.stream, &connector.limits) .await?; return Err(VncError::SecurityFailure(reason)); } + VncClient::new( + connector.stream, + connector.allow_shared, + connector.pixel_format, + connector.encodings, + connector.limits, + ) + .await? } - } + SecurityType::RA2_256 => { + let verifier = connector + .server_key_verifier + .clone() + .ok_or(VncError::Ra2ServerKeyVerifierRequired)?; + let credential = connector + .auth_methond + .take() + .ok_or(VncError::NoPassword)? + .await?; + let username = connector.username.take().unwrap_or_default(); + ra2::validate_credentials(&username, &credential)?; + SecurityType::write(&selected, &mut connector.stream).await?; + let stream = ra2::authenticate( + connector.stream, + username, + credential, + verifier, + connector.limits, + connector.rfb_version, + ) + .await?; + VncClient::new( + stream, + connector.allow_shared, + connector.pixel_format, + connector.encodings, + connector.limits, + ) + .await? + } + _ => return Err(VncError::UnsupportedSecurityType), + }; info!("auth done, client connected"); - - Ok(VncState::Connected( - VncClient::new( - connector.stream, - connector.allow_shared, - connector.pixel_format, - connector.encodings, - connector.limits, - ) - .await?, - )) + Ok(VncState::Connected(client)) } VncState::Connected(_) => Err(VncError::ConnectError), } @@ -201,6 +228,9 @@ where { stream: S, auth_methond: Option, + credentials_available: bool, + username: Option, + server_key_verifier: Option, security_policy: VncSecurityPolicy, rfb_version: VncVersion, allow_shared: bool, @@ -245,6 +275,9 @@ where Self { stream, auth_methond: None, + credentials_available: false, + username: None, + server_key_verifier: None, security_policy: VncSecurityPolicy::Auto, allow_shared: true, rfb_version: VncVersion::RFB38, @@ -295,6 +328,26 @@ where /// pub fn set_auth_method(mut self, auth_callback: F) -> Self { self.auth_methond = Some(auth_callback); + self.credentials_available = true; + self + } + + pub fn set_credentials_available(mut self, available: bool) -> Self { + self.credentials_available = available; + self + } + + pub fn set_username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + + pub fn set_server_key_verifier(mut self, verifier: V) -> Self + where + V: Fn(VncServerKey) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.server_key_verifier = Some(Arc::new(move |key| Box::pin(verifier(key)))); self } @@ -406,7 +459,10 @@ mod tests { .unwrap() } - async fn write_server_init(server: &mut DuplexStream) { + async fn write_server_init(server: &mut S) + where + S: AsyncRead + AsyncWrite + Unpin, + { server.write_all(&2_u16.to_be_bytes()).await.unwrap(); server.write_all(&2_u16.to_be_bytes()).await.unwrap(); server @@ -615,6 +671,49 @@ mod tests { server_task.await.unwrap(); } + async fn run_ra2_handshake(subtype: u8, expected_username: &'static str) { + let (client, mut server) = tokio::io::duplex(16 * 1024); + let limits = VncLimits::default(); + let server_task = tokio::spawn(async move { + server.write_all(b"RFB 003.008\n").await.unwrap(); + let mut version = [0_u8; 12]; + server.read_exact(&mut version).await.unwrap(); + assert_eq!(&version, b"RFB 003.008\n"); + server.write_all(&[4, 99, 1, 2, 129]).await.unwrap(); + assert_eq!(server.read_u8().await.unwrap(), 129); + + let mut encrypted = crate::client::security::ra2::accept_test_handshake( + server, + subtype, + expected_username, + "raspberry", + limits, + ) + .await; + assert_eq!(encrypted.read_u8().await.unwrap(), 1); + write_server_init(&mut encrypted).await; + }); + + let state = VncConnector::new(client) + .set_auth_method(async { Ok("raspberry".to_owned()) }) + .set_credentials_available(true) + .set_username("pi") + .set_server_key_verifier(|key| async move { + assert!(key.bits() >= 2048); + assert!(!key.encoded().is_empty()); + Ok(()) + }) + .set_security_policy(VncSecurityPolicy::Auto) + .add_encoding(VncEncoding::Raw) + .build() + .unwrap() + .try_start() + .await + .unwrap(); + state.finish().unwrap().close().await.unwrap(); + server_task.await.unwrap(); + } + #[tokio::test] async fn none_security_33_37_38() { run_none_handshake(VncVersion::RFB33).await; @@ -622,6 +721,63 @@ mod tests { run_none_handshake(VncVersion::RFB38).await; } + #[tokio::test] + async fn ra2_256_auto_prefers_encrypted_auth_and_supports_both_subtypes() { + run_ra2_handshake(1, "pi").await; + run_ra2_handshake(2, "").await; + } + + #[tokio::test] + async fn auto_with_credentials_never_downgrades_to_none() { + let (client, mut server) = tokio::io::duplex(128); + tokio::spawn(async move { + server.write_all(b"RFB 003.008\n").await.unwrap(); + let mut version = [0_u8; 12]; + server.read_exact(&mut version).await.unwrap(); + server.write_all(&[1, 1]).await.unwrap(); + }); + + let result = VncConnector::new(client) + .set_auth_method(async { Ok("password".to_owned()) }) + .set_credentials_available(true) + .set_security_policy(VncSecurityPolicy::Auto) + .add_encoding(VncEncoding::Raw) + .build() + .unwrap() + .try_start() + .await; + assert!(matches!(result, Err(VncError::UnsupportedSecurityType))); + } + + #[tokio::test] + async fn classic_vnc_auth_keeps_the_eight_byte_limit() { + let (client, mut server) = tokio::io::duplex(128); + tokio::spawn(async move { + server.write_all(b"RFB 003.008\n").await.unwrap(); + let mut version = [0_u8; 12]; + server.read_exact(&mut version).await.unwrap(); + server.write_all(&[1, 2]).await.unwrap(); + }); + + let result = VncConnector::new(client) + .set_auth_method(async { Ok("123456789".to_owned()) }) + .set_credentials_available(true) + .set_security_policy(VncSecurityPolicy::Auto) + .add_encoding(VncEncoding::Raw) + .build() + .unwrap() + .try_start() + .await; + assert!(matches!( + result, + Err(VncError::CredentialTooLong { + field: "classic VNC password", + actual: 9, + limit: 8 + }) + )); + } + #[tokio::test] async fn scripted_rfb_none_decodes_zrle_tight_and_jpeg_as_raw() { let zrle = run_scripted_framebuffer( diff --git a/src-tauri/vendor/vnc-rs/src/client/mod.rs b/src-tauri/vendor/vnc-rs/src/client/mod.rs index c02e0b7a..d8b751d2 100644 --- a/src-tauri/vendor/vnc-rs/src/client/mod.rs +++ b/src-tauri/vendor/vnc-rs/src/client/mod.rs @@ -6,3 +6,4 @@ mod security; pub use connection::VncClient; pub use connector::{VncConnector, VncSecurityPolicy}; +pub use security::ra2::VncServerKey; diff --git a/src-tauri/vendor/vnc-rs/src/client/security/mod.rs b/src-tauri/vendor/vnc-rs/src/client/security/mod.rs index e9d49ee3..e3c30c71 100644 --- a/src-tauri/vendor/vnc-rs/src/client/security/mod.rs +++ b/src-tauri/vendor/vnc-rs/src/client/security/mod.rs @@ -1 +1,3 @@ pub(crate) mod des; + +pub(crate) mod ra2; diff --git a/src-tauri/vendor/vnc-rs/src/client/security/ra2.rs b/src-tauri/vendor/vnc-rs/src/client/security/ra2.rs new file mode 100644 index 00000000..2c777850 --- /dev/null +++ b/src-tauri/vendor/vnc-rs/src/client/security/ra2.rs @@ -0,0 +1,854 @@ +use std::{ + future::Future, + io, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use aes::Aes256; +use eax::{ + aead::{generic_array::GenericArray, AeadInPlace, KeyInit}, + Eax, +}; +use rand::{rngs::OsRng, RngCore}; +use rsa::{traits::PublicKeyParts, BigUint, Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; + +use crate::{VncError, VncLimits, VncVersion}; + +use super::super::auth::read_security_failure; + +type Aes256Eax = Eax; +const RA2_RANDOM_BYTES: usize = 16; +const RA2_HASH_BYTES: usize = 32; +const RA2_MAC_BYTES: usize = 16; +const CLIENT_RSA_BITS: usize = 2048; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VncServerKey { + bits: u32, + encoded: Vec, +} + +impl VncServerKey { + pub fn bits(&self) -> u32 { + self.bits + } + + pub fn encoded(&self) -> &[u8] { + &self.encoded + } +} + +pub(crate) type ServerKeyVerifyFuture = + Pin> + Send + 'static>>; +pub(crate) type ServerKeyVerifier = + Arc ServerKeyVerifyFuture + Send + Sync + 'static>; + +pub(crate) fn validate_credentials(username: &str, password: &str) -> Result<(), VncError> { + validate_credential("RA2 username", username)?; + validate_credential("RA2 password", password) +} + +fn validate_credential(field: &'static str, value: &str) -> Result<(), VncError> { + let len = value.len(); + if len > usize::from(u8::MAX) { + return Err(VncError::CredentialTooLong { + field, + actual: len, + limit: usize::from(u8::MAX), + }); + } + Ok(()) +} + +pub(crate) async fn authenticate( + mut stream: S, + username: String, + password: String, + verifier: ServerKeyVerifier, + limits: VncLimits, + version: VncVersion, +) -> Result, VncError> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + validate_credentials(&username, &password)?; + + let (server_public_key, server_wire) = read_public_key(&mut stream, &limits).await?; + verifier(VncServerKey { + bits: u32::try_from(server_public_key.n().bits()).unwrap_or(u32::MAX), + encoded: server_wire.clone(), + }) + .await?; + + let mut rng = OsRng; + let client_private_key = RsaPrivateKey::new(&mut rng, CLIENT_RSA_BITS) + .map_err(|_| VncError::Ra2Crypto("failed to generate client RSA key"))?; + let client_public_key = RsaPublicKey::from(&client_private_key); + let client_wire = encode_public_key(&client_public_key)?; + stream.write_all(&client_wire).await?; + + let encrypted_server_random_len = usize::from(stream.read_u16().await?); + let expected_server_random_len = client_private_key.size(); + if encrypted_server_random_len != expected_server_random_len { + return Err(VncError::InvalidRa2EncryptedRandomLength { + actual: encrypted_server_random_len, + expected: expected_server_random_len, + }); + } + let mut encrypted_server_random = vec![0; encrypted_server_random_len]; + stream.read_exact(&mut encrypted_server_random).await?; + let server_random = client_private_key + .decrypt(Pkcs1v15Encrypt, &encrypted_server_random) + .map_err(|_| VncError::Ra2Crypto("failed to decrypt server random"))?; + if server_random.len() != RA2_RANDOM_BYTES { + return Err(VncError::InvalidRa2RandomLength(server_random.len())); + } + + let mut client_random = [0_u8; RA2_RANDOM_BYTES]; + rng.fill_bytes(&mut client_random); + let encrypted_client_random = server_public_key + .encrypt(&mut rng, Pkcs1v15Encrypt, &client_random) + .map_err(|_| VncError::Ra2Crypto("failed to encrypt client random"))?; + let encrypted_client_random_len = + u16::try_from(encrypted_client_random.len()).map_err(|_| VncError::LimitExceeded { + field: "RA2 encrypted client random", + actual: encrypted_client_random.len() as u64, + limit: u64::from(u16::MAX), + })?; + stream.write_u16(encrypted_client_random_len).await?; + stream.write_all(&encrypted_client_random).await?; + + let client_session_key = sha256_concat(&server_random, &client_random); + let server_session_key = sha256_concat(&client_random, &server_random); + let expected_server_hash = sha256_concat(&server_wire, &client_wire); + let client_hash = sha256_concat(&client_wire, &server_wire); + + let mut encrypted = Ra2Stream::new( + stream, + client_session_key, + server_session_key, + limits.max_ra2_record_bytes, + )?; + + let mut server_hash = [0_u8; RA2_HASH_BYTES]; + encrypted.read_exact(&mut server_hash).await?; + if server_hash != expected_server_hash { + return Err(VncError::Ra2ServerHashMismatch); + } + encrypted.write_all(&client_hash).await?; + + let subtype = encrypted.read_u8().await?; + let mut credentials = Vec::with_capacity(username.len() + password.len() + 2); + match subtype { + 1 => { + credentials.push(username.len() as u8); + credentials.extend_from_slice(username.as_bytes()); + } + 2 => credentials.push(0), + invalid => return Err(VncError::InvalidRa2Subtype(invalid)), + } + credentials.push(password.len() as u8); + credentials.extend_from_slice(password.as_bytes()); + encrypted.write_all(&credentials).await?; + + let result = encrypted.read_u32().await?; + match result { + 0 => Ok(encrypted), + 1 => { + if version == VncVersion::RFB38 { + let reason = read_security_failure(&mut encrypted, &limits).await?; + Err(VncError::SecurityFailure(reason)) + } else { + Err(VncError::WrongPassword) + } + } + invalid => Err(VncError::InvalidSecurityResult(invalid)), + } +} + +async fn read_public_key( + stream: &mut S, + limits: &VncLimits, +) -> Result<(RsaPublicKey, Vec), VncError> +where + S: AsyncRead + Unpin, +{ + let bits = stream.read_u32().await?; + if bits < limits.min_ra2_key_bits || bits > limits.max_ra2_key_bits { + return Err(VncError::InvalidRa2KeyLength { + actual: bits, + min: limits.min_ra2_key_bits, + max: limits.max_ra2_key_bits, + }); + } + let key_bytes_u32 = bits + .checked_add(7) + .ok_or(VncError::IntegerOverflow("RA2 key byte length"))? + / 8; + let key_bytes = usize::try_from(key_bytes_u32) + .map_err(|_| VncError::IntegerOverflow("RA2 key byte length"))?; + let mut modulus = vec![0_u8; key_bytes]; + let mut exponent = vec![0_u8; key_bytes]; + stream.read_exact(&mut modulus).await?; + stream.read_exact(&mut exponent).await?; + + let public_key = RsaPublicKey::new( + BigUint::from_bytes_be(&modulus), + BigUint::from_bytes_be(&exponent), + ) + .map_err(|_| VncError::InvalidRa2PublicKey)?; + + let mut wire = Vec::with_capacity(4 + key_bytes * 2); + wire.extend_from_slice(&bits.to_be_bytes()); + wire.extend_from_slice(&modulus); + wire.extend_from_slice(&exponent); + Ok((public_key, wire)) +} + +fn encode_public_key(key: &RsaPublicKey) -> Result, VncError> { + let bits = u32::try_from(key.n().bits()).map_err(|_| VncError::InvalidRa2PublicKey)?; + let key_bytes = usize::try_from( + bits.checked_add(7) + .ok_or(VncError::IntegerOverflow("RA2 key byte length"))? + / 8, + ) + .map_err(|_| VncError::IntegerOverflow("RA2 key byte length"))?; + let modulus = key.n().to_bytes_be(); + let exponent = key.e().to_bytes_be(); + if modulus.len() > key_bytes || exponent.len() > key_bytes { + return Err(VncError::InvalidRa2PublicKey); + } + + let mut wire = Vec::with_capacity(4 + key_bytes * 2); + wire.extend_from_slice(&bits.to_be_bytes()); + wire.resize(4 + key_bytes - modulus.len(), 0); + wire.extend_from_slice(&modulus); + wire.resize(4 + key_bytes * 2 - exponent.len(), 0); + wire.extend_from_slice(&exponent); + Ok(wire) +} + +fn sha256_concat(left: &[u8], right: &[u8]) -> [u8; RA2_HASH_BYTES] { + let mut hasher = Sha256::new(); + hasher.update(left); + hasher.update(right); + hasher.finalize().into() +} + +pub(crate) struct Ra2Stream { + stream: S, + write_cipher: Aes256Eax, + read_cipher: Aes256Eax, + write_counter: [u8; 16], + read_counter: [u8; 16], + max_record_bytes: usize, + read_header: [u8; 2], + read_header_pos: usize, + read_body: Vec, + read_body_pos: usize, + read_plain: Vec, + read_plain_pos: usize, + write_pending: Vec, + write_pending_pos: usize, +} + +impl Ra2Stream { + fn new( + stream: S, + write_key: [u8; 32], + read_key: [u8; 32], + max_record_bytes: usize, + ) -> Result { + if max_record_bytes == 0 || max_record_bytes > usize::from(u16::MAX) { + return Err(VncError::InvalidRa2RecordLimit(max_record_bytes)); + } + let write_cipher = Aes256Eax::new_from_slice(&write_key) + .map_err(|_| VncError::Ra2Crypto("invalid client session key"))?; + let read_cipher = Aes256Eax::new_from_slice(&read_key) + .map_err(|_| VncError::Ra2Crypto("invalid server session key"))?; + Ok(Self { + stream, + write_cipher, + read_cipher, + write_counter: [0; 16], + read_counter: [0; 16], + max_record_bytes, + read_header: [0; 2], + read_header_pos: 0, + read_body: Vec::new(), + read_body_pos: 0, + read_plain: Vec::new(), + read_plain_pos: 0, + write_pending: Vec::new(), + write_pending_pos: 0, + }) + } + + fn decrypt_ready_record(&mut self) -> io::Result<()> { + let ciphertext_len = usize::from(u16::from_be_bytes(self.read_header)); + let (ciphertext, tag_bytes) = self.read_body.split_at_mut(ciphertext_len); + let tag = GenericArray::clone_from_slice(tag_bytes); + self.read_cipher + .decrypt_in_place_detached( + GenericArray::from_slice(&self.read_counter), + &self.read_header, + ciphertext, + &tag, + ) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "RA2 AES-EAX authentication failed", + ) + })?; + increment_counter(&mut self.read_counter); + self.read_plain.clear(); + self.read_plain.extend_from_slice(ciphertext); + self.read_plain_pos = 0; + self.read_header_pos = 0; + self.read_body.clear(); + self.read_body_pos = 0; + Ok(()) + } + + fn prepare_write_record(&mut self, plaintext: &[u8]) -> io::Result { + let len = plaintext.len().min(self.max_record_bytes); + let header = (len as u16).to_be_bytes(); + let mut ciphertext = plaintext[..len].to_vec(); + let tag = self + .write_cipher + .encrypt_in_place_detached( + GenericArray::from_slice(&self.write_counter), + &header, + &mut ciphertext, + ) + .map_err(|_| io::Error::other("RA2 AES-EAX encryption failed"))?; + increment_counter(&mut self.write_counter); + + self.write_pending.clear(); + self.write_pending.reserve(2 + len + RA2_MAC_BYTES); + self.write_pending.extend_from_slice(&header); + self.write_pending.extend_from_slice(&ciphertext); + self.write_pending.extend_from_slice(&tag); + self.write_pending_pos = 0; + Ok(len) + } + + fn poll_drain_write_pending(&mut self, cx: &mut Context<'_>) -> Poll> + where + S: AsyncWrite + Unpin, + { + while self.write_pending_pos < self.write_pending.len() { + let written = match Pin::new(&mut self.stream) + .poll_write(cx, &self.write_pending[self.write_pending_pos..]) + { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write RA2 record", + ))); + } + Poll::Ready(Ok(written)) => written, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + }; + self.write_pending_pos += written; + } + self.write_pending.clear(); + self.write_pending_pos = 0; + Poll::Ready(Ok(())) + } +} + +impl AsyncRead for Ra2Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + loop { + if this.read_plain_pos < this.read_plain.len() { + let remaining = &this.read_plain[this.read_plain_pos..]; + let count = remaining.len().min(output.remaining()); + output.put_slice(&remaining[..count]); + this.read_plain_pos += count; + if this.read_plain_pos == this.read_plain.len() { + this.read_plain.clear(); + this.read_plain_pos = 0; + } + return Poll::Ready(Ok(())); + } + if output.remaining() == 0 { + return Poll::Ready(Ok(())); + } + if !this.write_pending.is_empty() { + match this.poll_drain_write_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + } + + if this.read_header_pos < this.read_header.len() { + let start = this.read_header_pos; + let mut buffer = ReadBuf::new(&mut this.read_header[start..]); + match Pin::new(&mut this.stream).poll_read(cx, &mut buffer) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => { + let read = buffer.filled().len(); + if read == 0 { + if this.read_header_pos == 0 { + return Poll::Ready(Ok(())); + } + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated RA2 record header", + ))); + } + this.read_header_pos += read; + if this.read_header_pos < this.read_header.len() { + continue; + } + let ciphertext_len = usize::from(u16::from_be_bytes(this.read_header)); + if ciphertext_len > this.max_record_bytes { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "RA2 record exceeds configured limit: {ciphertext_len} > {}", + this.max_record_bytes + ), + ))); + } + this.read_body.resize(ciphertext_len + RA2_MAC_BYTES, 0); + this.read_body_pos = 0; + } + } + } + + if this.read_body_pos < this.read_body.len() { + let start = this.read_body_pos; + let mut buffer = ReadBuf::new(&mut this.read_body[start..]); + match Pin::new(&mut this.stream).poll_read(cx, &mut buffer) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => { + let read = buffer.filled().len(); + if read == 0 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated RA2 encrypted record", + ))); + } + this.read_body_pos += read; + if this.read_body_pos < this.read_body.len() { + continue; + } + } + } + } + + if let Err(error) = this.decrypt_ready_record() { + return Poll::Ready(Err(error)); + } + } + } +} + +impl AsyncWrite for Ra2Stream +where + S: AsyncWrite + Unpin, +{ + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + let this = self.get_mut(); + if !this.write_pending.is_empty() { + match this.poll_drain_write_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + } + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let consumed = match this.prepare_write_record(input) { + Ok(consumed) => consumed, + Err(error) => return Poll::Ready(Err(error)), + }; + let written = match Pin::new(&mut this.stream) + .poll_write(cx, &this.write_pending[this.write_pending_pos..]) + { + Poll::Pending => return Poll::Ready(Ok(consumed)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write RA2 record", + ))); + } + Poll::Ready(Ok(written)) => written, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + }; + this.write_pending_pos += written; + if this.write_pending_pos == this.write_pending.len() { + this.write_pending.clear(); + this.write_pending_pos = 0; + } + Poll::Ready(Ok(consumed)) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if !this.write_pending.is_empty() { + match this.poll_drain_write_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + } + Pin::new(&mut this.stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if !this.write_pending.is_empty() { + match this.poll_drain_write_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + } + Pin::new(&mut this.stream).poll_shutdown(cx) + } +} + +fn increment_counter(counter: &mut [u8; 16]) { + for byte in counter { + let (next, overflow) = byte.overflowing_add(1); + *byte = next; + if !overflow { + break; + } + } +} + +#[cfg(test)] +async fn establish_test_encryption( + mut stream: S, + limits: VncLimits, + corrupt_server_hash: bool, +) -> Ra2Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut rng = OsRng; + let server_private_key = RsaPrivateKey::new(&mut rng, CLIENT_RSA_BITS).unwrap(); + let server_public_key = RsaPublicKey::from(&server_private_key); + let server_wire = encode_public_key(&server_public_key).unwrap(); + stream.write_all(&server_wire).await.unwrap(); + + let (client_public_key, client_wire) = read_public_key(&mut stream, &limits).await.unwrap(); + let server_random = [0x31_u8; RA2_RANDOM_BYTES]; + let encrypted_server_random = client_public_key + .encrypt(&mut rng, Pkcs1v15Encrypt, &server_random) + .unwrap(); + stream + .write_u16(encrypted_server_random.len() as u16) + .await + .unwrap(); + stream.write_all(&encrypted_server_random).await.unwrap(); + + let encrypted_client_random_len = usize::from(stream.read_u16().await.unwrap()); + let mut encrypted_client_random = vec![0_u8; encrypted_client_random_len]; + stream + .read_exact(&mut encrypted_client_random) + .await + .unwrap(); + let client_random = server_private_key + .decrypt(Pkcs1v15Encrypt, &encrypted_client_random) + .unwrap(); + assert_eq!(client_random.len(), RA2_RANDOM_BYTES); + + let client_session_key = sha256_concat(&server_random, &client_random); + let server_session_key = sha256_concat(&client_random, &server_random); + let mut server_hash = sha256_concat(&server_wire, &client_wire); + let expected_client_hash = sha256_concat(&client_wire, &server_wire); + let mut encrypted = Ra2Stream::new( + stream, + server_session_key, + client_session_key, + limits.max_ra2_record_bytes, + ) + .unwrap(); + + if corrupt_server_hash { + server_hash[0] ^= 0x80; + } + encrypted.write_all(&server_hash).await.unwrap(); + if corrupt_server_hash { + return encrypted; + } + let mut client_hash = [0_u8; RA2_HASH_BYTES]; + encrypted.read_exact(&mut client_hash).await.unwrap(); + assert_eq!(client_hash, expected_client_hash); + encrypted +} + +#[cfg(test)] +pub(crate) async fn accept_test_handshake( + stream: S, + subtype: u8, + expected_username: &str, + expected_password: &str, + limits: VncLimits, +) -> Ra2Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut encrypted = establish_test_encryption(stream, limits, false).await; + + encrypted.write_u8(subtype).await.unwrap(); + let username_len = usize::from(encrypted.read_u8().await.unwrap()); + let mut username = vec![0_u8; username_len]; + encrypted.read_exact(&mut username).await.unwrap(); + let password_len = usize::from(encrypted.read_u8().await.unwrap()); + let mut password = vec![0_u8; password_len]; + encrypted.read_exact(&mut password).await.unwrap(); + assert_eq!(username, expected_username.as_bytes()); + assert_eq!(password, expected_password.as_bytes()); + encrypted.write_u32(0).await.unwrap(); + encrypted +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct PartialThenPendingWriter { + bytes: Vec, + write_polls: usize, + } + + impl AsyncWrite for PartialThenPendingWriter { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + self.write_polls += 1; + match self.write_polls { + 1 => { + let written = input.len().min(3); + self.bytes.extend_from_slice(&input[..written]); + Poll::Ready(Ok(written)) + } + 2 => { + cx.waker().wake_by_ref(); + Poll::Pending + } + _ => { + self.bytes.extend_from_slice(input); + Poll::Ready(Ok(input.len())) + } + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + fn paired_streams( + left: S1, + right: S2, + max_record_bytes: usize, + ) -> (Ra2Stream, Ra2Stream) { + let left_to_right = [0x11; 32]; + let right_to_left = [0x22; 32]; + ( + Ra2Stream::new(left, left_to_right, right_to_left, max_record_bytes).unwrap(), + Ra2Stream::new(right, right_to_left, left_to_right, max_record_bytes).unwrap(), + ) + } + + #[test] + fn credentials_use_utf8_byte_limits() { + assert!(validate_credentials("用户", "密码").is_ok()); + let error = validate_credentials(&"é".repeat(128), "password").unwrap_err(); + assert!(matches!( + error, + VncError::CredentialTooLong { + field: "RA2 username", + actual: 256, + limit: 255 + } + )); + } + + #[test] + fn encrypted_stream_backpressure_does_not_consume_replacement_input() { + let writer = PartialThenPendingWriter::default(); + let mut stream = Ra2Stream::new(writer, [0x55; 32], [0x66; 32], 64).unwrap(); + let waker = futures::task::noop_waker_ref(); + let mut cx = Context::from_waker(waker); + + assert!(matches!( + Pin::new(&mut stream).poll_write(&mut cx, b"first"), + Poll::Ready(Ok(5)) + )); + assert!(matches!( + Pin::new(&mut stream).poll_write(&mut cx, b"cancelled"), + Poll::Pending + )); + assert!(matches!( + Pin::new(&mut stream).poll_write(&mut cx, b"x"), + Poll::Ready(Ok(1)) + )); + assert!(matches!( + Pin::new(&mut stream).poll_flush(&mut cx), + Poll::Ready(Ok(())) + )); + + let first_record_len = 2 + 5 + RA2_MAC_BYTES; + let bytes = &stream.stream.bytes; + assert_eq!(bytes.len(), first_record_len + 2 + 1 + RA2_MAC_BYTES); + assert_eq!(&bytes[..2], &5_u16.to_be_bytes()); + assert_eq!( + &bytes[first_record_len..first_record_len + 2], + &1_u16.to_be_bytes() + ); + } + + #[tokio::test] + async fn rejects_out_of_range_server_rsa_key_before_allocation() { + let mut input = &9_000_u32.to_be_bytes()[..]; + let error = read_public_key(&mut input, &VncLimits::default()) + .await + .unwrap_err(); + assert!(matches!( + error, + VncError::InvalidRa2KeyLength { + actual: 9_000, + min: 1_024, + max: 8_192 + } + )); + } + + #[tokio::test] + async fn authenticate_rejects_server_hash_mismatch() { + let (client, server) = tokio::io::duplex(16 * 1024); + let limits = VncLimits::default(); + let server_task = tokio::spawn(async move { + let _encrypted = establish_test_encryption(server, limits, true).await; + }); + let verifier: ServerKeyVerifier = Arc::new(|_| Box::pin(async { Ok(()) })); + + let error = match authenticate( + client, + "pi".to_string(), + "raspberry".to_string(), + verifier, + limits, + VncVersion::RFB38, + ) + .await + { + Ok(_) => panic!("corrupted server key hash must fail closed"), + Err(error) => error, + }; + assert!(matches!(error, VncError::Ra2ServerHashMismatch)); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn authenticate_rejects_unknown_subtype() { + let (client, server) = tokio::io::duplex(16 * 1024); + let limits = VncLimits::default(); + let server_task = tokio::spawn(async move { + let mut encrypted = establish_test_encryption(server, limits, false).await; + encrypted.write_u8(3).await.unwrap(); + }); + let verifier: ServerKeyVerifier = Arc::new(|_| Box::pin(async { Ok(()) })); + + let error = match authenticate( + client, + "pi".to_string(), + "raspberry".to_string(), + verifier, + limits, + VncVersion::RFB38, + ) + .await + { + Ok(_) => panic!("unknown RA2 subtype must fail closed"), + Err(error) => error, + }; + assert!(matches!(error, VncError::InvalidRa2Subtype(3))); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn encrypted_stream_splits_and_coalesces_independently_of_rfb_reads() { + let (left_io, right_io) = tokio::io::duplex(4096); + let (mut left, mut right) = paired_streams(left_io, right_io, 5); + + let sender = tokio::spawn(async move { + left.write_all(b"abcdefgh").await.unwrap(); + left.write_all(b"ijklmnop").await.unwrap(); + left + }); + + let mut first = [0_u8; 3]; + right.read_exact(&mut first).await.unwrap(); + assert_eq!(&first, b"abc"); + let mut rest = [0_u8; 13]; + right.read_exact(&mut rest).await.unwrap(); + assert_eq!(&rest, b"defghijklmnop"); + + let mut left = sender.await.unwrap(); + right.write_all(b"reply-one").await.unwrap(); + right.write_all(b"reply-two").await.unwrap(); + let mut reply = [0_u8; 18]; + left.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"reply-onereply-two"); + } + + #[tokio::test] + async fn encrypted_stream_rejects_bad_mac() { + let (mut raw_writer, raw_reader) = tokio::io::duplex(128); + let key = [0x44; 32]; + let mut reader = Ra2Stream::new(raw_reader, [0x33; 32], key, 64).unwrap(); + let cipher = Aes256Eax::new_from_slice(&key).unwrap(); + let header = 3_u16.to_be_bytes(); + let mut ciphertext = b"bad".to_vec(); + let tag = cipher + .encrypt_in_place_detached(GenericArray::from_slice(&[0; 16]), &header, &mut ciphertext) + .unwrap(); + raw_writer.write_all(&header).await.unwrap(); + raw_writer.write_all(&ciphertext).await.unwrap(); + let mut bad_tag = tag.to_vec(); + bad_tag[0] ^= 0x80; + raw_writer.write_all(&bad_tag).await.unwrap(); + + let mut output = [0_u8; 3]; + let error = reader.read_exact(&mut output).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/src-tauri/vendor/vnc-rs/src/error.rs b/src-tauri/vendor/vnc-rs/src/error.rs index 579a7130..c55ebc86 100644 --- a/src-tauri/vendor/vnc-rs/src/error.rs +++ b/src-tauri/vendor/vnc-rs/src/error.rs @@ -19,6 +19,32 @@ pub enum VncError { RequiredSecurityTypeUnavailable(&'static str), #[error("Wrong password")] WrongPassword, + #[error("VNC credential is too long for {field}: {actual} > {limit} bytes")] + CredentialTooLong { + field: &'static str, + actual: usize, + limit: usize, + }, + #[error("RA2 server key length is outside the supported range: {actual} bits (expected {min}..={max})")] + InvalidRa2KeyLength { actual: u32, min: u32, max: u32 }, + #[error("Invalid RA2 RSA public key")] + InvalidRa2PublicKey, + #[error("Invalid RA2 encrypted random length: {actual} bytes, expected {expected}")] + InvalidRa2EncryptedRandomLength { actual: usize, expected: usize }, + #[error("Invalid RA2 decrypted random length: {0} bytes")] + InvalidRa2RandomLength(usize), + #[error("RA2 server public-key hash verification failed")] + Ra2ServerHashMismatch, + #[error("Unknown RA2 authentication subtype: {0}")] + InvalidRa2Subtype(u8), + #[error("RA2 server key verification callback is required")] + Ra2ServerKeyVerifierRequired, + #[error("RA2 server key was rejected: {0}")] + Ra2ServerKeyRejected(String), + #[error("Invalid RA2 record size limit: {0}")] + InvalidRa2RecordLimit(usize), + #[error("RA2 cryptographic operation failed: {0}")] + Ra2Crypto(&'static str), #[error("Server rejected the connection: {0}")] SecurityFailure(String), #[error("Protocol limit exceeded for {field}: {actual} > {limit}")] diff --git a/src-tauri/vendor/vnc-rs/src/lib.rs b/src-tauri/vendor/vnc-rs/src/lib.rs index f2841d02..2924d69b 100644 --- a/src-tauri/vendor/vnc-rs/src/lib.rs +++ b/src-tauri/vendor/vnc-rs/src/lib.rs @@ -217,7 +217,7 @@ pub mod event; pub mod limits; pub use client::VncClient; -pub use client::{VncConnector, VncSecurityPolicy}; +pub use client::{VncConnector, VncSecurityPolicy, VncServerKey}; pub use config::*; pub use error::*; pub use event::*; diff --git a/src-tauri/vendor/vnc-rs/src/limits.rs b/src-tauri/vendor/vnc-rs/src/limits.rs index 58eaed75..0f93f9d3 100644 --- a/src-tauri/vendor/vnc-rs/src/limits.rs +++ b/src-tauri/vendor/vnc-rs/src/limits.rs @@ -3,6 +3,9 @@ pub struct VncLimits { pub max_server_name_bytes: usize, pub max_failure_reason_bytes: usize, + pub min_ra2_key_bits: u32, + pub max_ra2_key_bits: u32, + pub max_ra2_record_bytes: usize, pub max_clipboard_bytes: usize, pub max_framebuffer_width: u16, pub max_framebuffer_height: u16, @@ -18,6 +21,9 @@ impl Default for VncLimits { Self { max_server_name_bytes: 64 * 1024, max_failure_reason_bytes: 64 * 1024, + min_ra2_key_bits: 1_024, + max_ra2_key_bits: 8_192, + max_ra2_record_bytes: usize::from(u16::MAX), max_clipboard_bytes: 16 * 1024 * 1024, max_framebuffer_width: 16_384, max_framebuffer_height: 16_384, diff --git a/src/App.tsx b/src/App.tsx index de4e9b1c..b5359534 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import AppPanelContent from "./components/app/AppPanelContent"; import ActivityBarResetDialog from "./components/dialog/app/ActivityBarResetDialog"; import AppOverlayDialogs from "./components/dialog/app/AppOverlayDialogs"; import { McpApprovalHost } from "./components/dialog/app/McpApprovalHost"; +import type { VncServerKeyVerifyRequest } from "./components/dialog/connections/VncServerKeyVerifyDialog"; import type { RdpCertificateVerifyRequest } from "./components/dialog/connections/RdpCertificateVerifyDialog"; import type { DockerSudoPasswordRequest } from "./components/dialog/docker/DockerSudoPasswordDialog"; import type { QuickSwitcherSession } from "./components/dialog/terminal/SessionQuickSwitcherDialog"; @@ -312,6 +313,7 @@ function App() { const [rdpCertificateRequests, setRdpCertificateRequests] = useState< RdpCertificateVerifyRequest[] >([]); + const [vncServerKeyRequests, setVncServerKeyRequests] = useState([]); const modalChildWindowCount = useModalChildWindows(); // Idle auto-lock @@ -486,6 +488,7 @@ function App() { removeSecurityPrompt, setDockerSudoPasswordRequest, setRdpCertificateRequests, + setVncServerKeyRequests, handleConnectAfterEdit, handleOpenPanel, }); @@ -2953,7 +2956,8 @@ function App() { Boolean(activeOtpRequest) || Boolean(activeSshAuthRequest) || Boolean(dockerSudoPasswordRequest) || - rdpCertificateRequests.length > 0 + rdpCertificateRequests.length > 0 || + vncServerKeyRequests.length > 0 ) { return; } @@ -2989,6 +2993,7 @@ function App() { pendingFileDocumentClose, postLoginConfirm, rdpCertificateRequests.length, + vncServerKeyRequests.length, showAbout, showActivityBarResetConfirm, showQuitConfirm, @@ -3369,6 +3374,11 @@ function App() { setRdpCertificateRequests((current) => current.filter((item) => item.requestId !== requestId), ), + vncServerKeyVerifyRequest: vncServerKeyRequests[0] ?? null, + onVncServerKeyVerifyDone: (requestId) => + setVncServerKeyRequests((current) => + current.filter((item) => item.requestId !== requestId), + ), modalChildWindowCount, locked: isLocked, hasMasterPassword: !!appSettings.security.master_password, diff --git a/src/components/app/AppLayout.tsx b/src/components/app/AppLayout.tsx index cf857ad6..d0040ea7 100644 --- a/src/components/app/AppLayout.tsx +++ b/src/components/app/AppLayout.tsx @@ -21,6 +21,8 @@ import type { OtpRequest } from "@/components/dialog/connections/OtpDialog"; import { OtpDialog } from "@/components/dialog/connections/OtpDialog"; import type { RdpCertificateVerifyRequest } from "@/components/dialog/connections/RdpCertificateVerifyDialog"; import { RdpCertificateVerifyDialog } from "@/components/dialog/connections/RdpCertificateVerifyDialog"; +import type { VncServerKeyVerifyRequest } from "@/components/dialog/connections/VncServerKeyVerifyDialog"; +import { VncServerKeyVerifyDialog } from "@/components/dialog/connections/VncServerKeyVerifyDialog"; import type { SshAuthRequest } from "@/components/dialog/connections/SshAuthDialog"; import { SshAuthDialog } from "@/components/dialog/connections/SshAuthDialog"; import type { SshAgentAuthRequest } from "@/components/dialog/connections/SshAgentAuthDialog"; @@ -166,6 +168,8 @@ interface AppLayoutProps { onHostKeyVerifyDone: (requestId: string) => void; rdpCertificateVerifyRequest: RdpCertificateVerifyRequest | null; onRdpCertificateVerifyDone: (requestId: string) => void; + vncServerKeyVerifyRequest: VncServerKeyVerifyRequest | null; + onVncServerKeyVerifyDone: (requestId: string) => void; modalChildWindowCount: number; locked: boolean; hasMasterPassword: boolean; @@ -680,6 +684,10 @@ export default function AppLayout({ request={dialogs.rdpCertificateVerifyRequest} onDone={dialogs.onRdpCertificateVerifyDone} /> + {dialogs.modalChildWindowCount > 0 && ( diff --git a/src/components/dialog/connections/VncServerKeyVerifyDialog.tsx b/src/components/dialog/connections/VncServerKeyVerifyDialog.tsx new file mode 100644 index 00000000..8a193e93 --- /dev/null +++ b/src/components/dialog/connections/VncServerKeyVerifyDialog.tsx @@ -0,0 +1,149 @@ +import { ShieldAlert, ShieldQuestion } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { invoke } from "@/lib/invoke"; +import { logger } from "@/lib/logger"; + +export interface VncServerKeyVerifyRequest { + requestId: string; + sessionId: string; + host: string; + port: number; + fingerprint: string; + keyBits: number; + knownHostStatus: "match" | "changed" | "unknown" | string; + targetWindowLabel?: string | null; +} + +interface VncServerKeyVerifyDialogProps { + request: VncServerKeyVerifyRequest | null; + onDone: (requestId: string) => void; +} + +export function VncServerKeyVerifyDialog({ + request, + onDone, +}: VncServerKeyVerifyDialogProps) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const changed = request?.knownHostStatus === "changed"; + + const respond = async (accepted: boolean) => { + if (!request || submitting) return; + setSubmitting(true); + try { + await invoke("respond_vnc_server_key", { + requestId: request.requestId, + accepted, + }); + logger.info({ + domain: "security.flow", + event: accepted + ? "vnc_server_key.user_accepted" + : "vnc_server_key.user_rejected", + message: accepted + ? "User accepted VNC server key" + : "User rejected VNC server key", + ids: { request_id: request.requestId, session_id: request.sessionId }, + }); + } catch (error) { + logger.error({ + domain: "security.flow", + event: "vnc_server_key.response_failed", + message: "Failed to send VNC server key response", + ids: { request_id: request.requestId, session_id: request.sessionId }, + error, + }); + } + setSubmitting(false); + onDone(request.requestId); + }; + + return ( + { + if (!open) void respond(false); + }} + > + + + + {changed ? ( + + ) : ( + + )} + {t("settings.vncServerKeyVerifyTitle")} + + + {changed + ? t("settings.vncServerKeyVerifyChanged") + : t("settings.vncServerKeyVerifyNew")} + + + +
+
+ + {t("settings.hostKeyVerifyHost")} + + + {request?.host}:{request?.port} + + + {t("settings.hostKeyVerifyFingerprint")} + + + {request?.fingerprint} + + + {t("settings.vncServerKeyBits")} + + {request?.keyBits} +
+ + {changed && ( +
+ {t("settings.vncServerKeyVerifyWarning")} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/sessions/VncForm.test.tsx b/src/components/sessions/VncForm.test.tsx index 9c993eec..c053f757 100644 --- a/src/components/sessions/VncForm.test.tsx +++ b/src/components/sessions/VncForm.test.tsx @@ -15,6 +15,8 @@ function props() { setHost: vi.fn(), port: 5900, setPort: vi.fn(), + username: "pi", + setUsername: vi.fn(), passwordId: "", setPasswordId: vi.fn(), password: "", @@ -50,10 +52,18 @@ describe("VncForm", () => { invokeMock.mockResolvedValue([]); }); - it("uses the standard VNC port and explains the classic password limit", () => { + it("shows the RA2 username in auto mode without applying the classic password hint", () => { render(); expect(screen.getByDisplayValue("5900")).not.toBeNull(); + expect(screen.getByDisplayValue("pi")).not.toBeNull(); + expect(screen.getByText("dialog.vncUsernameHint")).not.toBeNull(); + expect(screen.queryByText("dialog.vncPasswordLimit")).toBeNull(); + }); + + it("shows the 8-byte password hint only for classic VNC authentication", () => { + render(); + expect(screen.getByText("dialog.vncPasswordLimit")).not.toBeNull(); }); diff --git a/src/components/sessions/VncForm.tsx b/src/components/sessions/VncForm.tsx index 672a98a5..0f1e88c2 100644 --- a/src/components/sessions/VncForm.tsx +++ b/src/components/sessions/VncForm.tsx @@ -28,6 +28,8 @@ interface VncFormProps { setHost: (value: string) => void; port: number; setPort: (value: number) => void; + username: string; + setUsername: (value: string) => void; passwordId: string; setPasswordId: (value: string) => void; password: string; @@ -69,6 +71,8 @@ export function VncForm({ setHost, port, setPort, + username, + setUsername, passwordId, setPasswordId, password, @@ -181,6 +185,21 @@ export function VncForm({ +
+ + setUsername(event.target.value)} + placeholder={t("dialog.vncUsernamePlaceholder")} + /> + {securityMode === "auto" ? ( +

+ {t("dialog.vncUsernameHint")} +

+ ) : null} +
{ @@ -253,9 +272,11 @@ export function VncForm({ ) : null} -

- {t("dialog.vncPasswordLimit")} -

+ {securityMode === "vnc-auth" ? ( +

+ {t("dialog.vncPasswordLimit")} +

+ ) : null}