fix(vnc): support RA2_256 Raspberry Pi connections

This commit is contained in:
victorwon2001 authored and Kang committed 2026-10-01 15:28:38 +08:00
1 parent 642c0de88b
commit 64d592cf41
35 files changed
+1847 -144

No files matched your search

+1 -1
View File
@@ -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`
+39
View File
@@ -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",
+1
View File
@@ -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(),
+9
View File
@@ -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<VncSessionManager>>,
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,
+4
View File
@@ -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);
+186 -25
View File
@@ -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<VncErrorKind>,
}
#[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<bool>,
}
#[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<String>,
pub security_mode: String,
pub scale_mode: String,
@@ -200,16 +222,19 @@ pub struct VncSession {
cancel_sender: Mutex<Option<watch::Sender<bool>>>,
worker: Mutex<Option<JoinHandle<()>>>,
close_requested: AtomicBool,
pending_server_keys: Arc<Mutex<HashMap<String, VncServerKeyPending>>>,
}
pub struct VncSessionManager {
sessions: Mutex<HashMap<String, Arc<VncSession>>>,
pending_server_keys: Arc<Mutex<HashMap<String, VncServerKeyPending>>>,
}
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<Self>, 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::<Vec<_>>();
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<VncSession>,
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<VncSession>,
@@ -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;
+1
View File
@@ -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,
+4
View File
@@ -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)?;
+18 -1
View File
@@ -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<KnownHostCheck> {
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<Option<String>> {
storage()?.load_master_key_token()
}
+12
View File
@@ -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,
+27
View File
@@ -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);
}
+56
View File
@@ -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<KnownHostCheck> {
let Some(record) = self.read_json::<VncKnownHostRecord>(
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<VncKnownHostRecord> = 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))
}
+1
View File
@@ -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
+5
View File
@@ -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"
+4 -1
View File
@@ -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
+27 -6
View File
@@ -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<u8> 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)));
}
}
+259 -103
View File
@@ -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<Box<dyn Future<Output = Result<Self, VncError>> + Send + Sync + 'static>> {
) -> Pin<Box<dyn Future<Output = Result<Self, VncError>> + 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<F>,
credentials_available: bool,
username: Option<String>,
server_key_verifier: Option<ServerKeyVerifier>,
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<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn set_server_key_verifier<V, Fut>(mut self, verifier: V) -> Self
where
V: Fn(VncServerKey) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), VncError>> + 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<S>(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(
+1
View File
@@ -6,3 +6,4 @@ mod security;
pub use connection::VncClient;
pub use connector::{VncConnector, VncSecurityPolicy};
pub use security::ra2::VncServerKey;
+2
View File
@@ -1 +1,3 @@
pub(crate) mod des;
pub(crate) mod ra2;
+854
View File
@@ -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<Aes256>;
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<u8>,
}
impl VncServerKey {
pub fn bits(&self) -> u32 {
self.bits
}
pub fn encoded(&self) -> &[u8] {
&self.encoded
}
}
pub(crate) type ServerKeyVerifyFuture =
Pin<Box<dyn Future<Output = Result<(), VncError>> + Send + 'static>>;
pub(crate) type ServerKeyVerifier =
Arc<dyn Fn(VncServerKey) -> 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<S>(
mut stream: S,
username: String,
password: String,
verifier: ServerKeyVerifier,
limits: VncLimits,
version: VncVersion,
) -> Result<Ra2Stream<S>, 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<S>(
stream: &mut S,
limits: &VncLimits,
) -> Result<(RsaPublicKey, Vec<u8>), 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<Vec<u8>, 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<S> {
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<u8>,
read_body_pos: usize,
read_plain: Vec<u8>,
read_plain_pos: usize,
write_pending: Vec<u8>,
write_pending_pos: usize,
}
impl<S> Ra2Stream<S> {
fn new(
stream: S,
write_key: [u8; 32],
read_key: [u8; 32],
max_record_bytes: usize,
) -> Result<Self, VncError> {
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<usize> {
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<io::Result<()>>
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<S> AsyncRead for Ra2Stream<S>
where
S: AsyncRead + AsyncWrite + Unpin,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
output: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
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<S> AsyncWrite for Ra2Stream<S>
where
S: AsyncWrite + Unpin,
{
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
input: &[u8],
) -> Poll<io::Result<usize>> {
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<io::Result<()>> {
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<io::Result<()>> {
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<S>(
mut stream: S,
limits: VncLimits,
corrupt_server_hash: bool,
) -> Ra2Stream<S>
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<S>(
stream: S,
subtype: u8,
expected_username: &str,
expected_password: &str,
limits: VncLimits,
) -> Ra2Stream<S>
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<u8>,
write_polls: usize,
}
impl AsyncWrite for PartialThenPendingWriter {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
input: &[u8],
) -> Poll<io::Result<usize>> {
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<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
fn paired_streams<S1, S2>(
left: S1,
right: S2,
max_record_bytes: usize,
) -> (Ra2Stream<S1>, Ra2Stream<S2>) {
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);
}
}
+26
View File
@@ -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}")]
+1 -1
View File
@@ -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::*;
+6
View File
@@ -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,
+11 -1
View File
@@ -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<VncServerKeyVerifyRequest[]>([]);
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,
+8
View File
@@ -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}
/>
<VncServerKeyVerifyDialog
request={dialogs.vncServerKeyVerifyRequest}
onDone={dialogs.onVncServerKeyVerifyDone}
/>
<TransferDuplicateDialog />
{dialogs.modalChildWindowCount > 0 && (
@@ -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 (
<Dialog
disablePointerDismissal
open={!!request}
onOpenChange={(open) => {
if (!open) void respond(false);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{changed ? (
<ShieldAlert className="h-4 w-4 text-destructive" />
) : (
<ShieldQuestion className="h-4 w-4 text-yellow-500" />
)}
{t("settings.vncServerKeyVerifyTitle")}
</DialogTitle>
<DialogDescription className="text-xs">
{changed
? t("settings.vncServerKeyVerifyChanged")
: t("settings.vncServerKeyVerifyNew")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 overflow-hidden py-2 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<span className="text-muted-foreground">
{t("settings.hostKeyVerifyHost")}
</span>
<span
className="truncate font-mono"
title={`${request?.host}:${request?.port}`}
>
{request?.host}:{request?.port}
</span>
<span className="text-muted-foreground">
{t("settings.hostKeyVerifyFingerprint")}
</span>
<span className="break-all font-mono select-all">
{request?.fingerprint}
</span>
<span className="text-muted-foreground">
{t("settings.vncServerKeyBits")}
</span>
<span className="font-mono">{request?.keyBits}</span>
</div>
{changed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-[0.6875rem] text-destructive">
{t("settings.vncServerKeyVerifyWarning")}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => void respond(false)}
disabled={submitting}
>
{t("settings.hostKeyVerifyReject")}
</Button>
<Button
size="sm"
className="text-xs"
variant={changed ? "destructive" : "default"}
onClick={() => void respond(true)}
disabled={submitting}
>
{t("settings.vncServerKeyAccept")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+11 -1
View File
@@ -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(<VncForm {...props()} />);
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(<VncForm {...props()} securityMode="vnc-auth" />);
expect(screen.getByText("dialog.vncPasswordLimit")).not.toBeNull();
});
+24 -3
View File
@@ -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({
<Label className="text-xs font-medium text-foreground/80">
{t("dialog.authentication")}
</Label>
<div className="mt-1">
<Label className="text-xs font-medium text-foreground/80">{t("dialog.username")}</Label>
<Input
className="mt-1 h-8 text-xs"
value={username}
disabled={securityMode === "none"}
onChange={(event) => setUsername(event.target.value)}
placeholder={t("dialog.vncUsernamePlaceholder")}
/>
{securityMode === "auto" ? (
<p className="mt-1 text-[0.6875rem] text-muted-foreground">
{t("dialog.vncUsernameHint")}
</p>
) : null}
</div>
<Tabs
value={passwordSource}
onValueChange={(value) => {
@@ -253,9 +272,11 @@ export function VncForm({
</>
) : null}
</div>
<p className="mt-1 text-[0.6875rem] text-muted-foreground">
{t("dialog.vncPasswordLimit")}
</p>
{securityMode === "vnc-auth" ? (
<p className="mt-1 text-[0.6875rem] text-muted-foreground">
{t("dialog.vncPasswordLimit")}
</p>
) : null}
</TabsContent>
<TabsContent value="saved" className="mt-3 border-0 outline-none">
<Select
+14
View File
@@ -3,6 +3,7 @@ import { type Dispatch, type SetStateAction, useEffect, useRef } from "react";
import { toast } from "sonner";
import type { HostKeyVerifyRequest } from "@/components/dialog/connections/HostKeyVerifyDialog";
import type { OtpRequest } from "@/components/dialog/connections/OtpDialog";
import type { VncServerKeyVerifyRequest } from "@/components/dialog/connections/VncServerKeyVerifyDialog";
import type { RdpCertificateVerifyRequest } from "@/components/dialog/connections/RdpCertificateVerifyDialog";
import type { SshAgentAuthRequest } from "@/components/dialog/connections/SshAgentAuthDialog";
import type { SshAuthRequest } from "@/components/dialog/connections/SshAuthDialog";
@@ -25,6 +26,7 @@ interface AppWindowEventsOptions
> {
setTerminalWindows: Dispatch<SetStateAction<TerminalWindowNode | null>>;
setDockerSudoPasswordRequest: Dispatch<SetStateAction<DockerSudoPasswordRequest | null>>;
setVncServerKeyRequests: Dispatch<SetStateAction<VncServerKeyVerifyRequest[]>>;
setRdpCertificateRequests: Dispatch<SetStateAction<RdpCertificateVerifyRequest[]>>;
handleConnectAfterEdit: (payload: SessionConnectAfterEditPayload) => Promise<void>;
handleOpenPanel: (panelId: "syncBackupHistory") => void;
@@ -38,6 +40,7 @@ export function useAppWindowEvents({
removeSecurityPrompt,
setDockerSudoPasswordRequest,
setRdpCertificateRequests,
setVncServerKeyRequests,
handleConnectAfterEdit,
handleOpenPanel,
}: AppWindowEventsOptions) {
@@ -176,6 +179,16 @@ export function useAppWindowEvents({
}),
);
unsubs.push(
listen<VncServerKeyVerifyRequest>("vnc-server-key-verify", (event) => {
if (!eventTargetsCurrentWindow(event.payload.targetWindowLabel)) return;
setVncServerKeyRequests((current) => {
if (current.some((item) => item.requestId === event.payload.requestId)) return current;
return [...current, event.payload];
});
}),
);
unsubs.push(
listen<{
requestId: string;
@@ -218,6 +231,7 @@ export function useAppWindowEvents({
removeSecurityPrompt,
setDockerSudoPasswordRequest,
setRdpCertificateRequests,
setVncServerKeyRequests,
handleConnectAfterEdit,
]);
+8
View File
@@ -871,6 +871,8 @@
"vncSecurityNone": "None",
"vncShared": "Shared session",
"vncUnencryptedWarning": "Classic VNC does not encrypt the desktop, keyboard, or clipboard traffic. Use a trusted network, VPN, or SSH tunnel.",
"vncUsernameHint": "Auto uses this username with RA2_256 servers. Classic VNC Authentication ignores it.",
"vncUsernamePlaceholder": "Username (for RA2_256)",
"vncViewOnly": "View only",
"workingDir": "Work Dir",
"workingDirPlaceholder": "e.g. C:\\Projects or ~/workspace",
@@ -2713,6 +2715,12 @@
"useOsKeyring": "Use OS Keyring",
"useOsKeyringDesc": "Securely store credentials in the system's native keychain (macOS Keychain, Windows Credential Manager, Secret Service).",
"viewPrivateKey": "View private key",
"vncServerKeyAccept": "Accept and remember",
"vncServerKeyBits": "RSA key",
"vncServerKeyVerifyChanged": "The saved VNC server key has changed. Verify the new key before reconnecting.",
"vncServerKeyVerifyNew": "The VNC server uses RA2_256 encryption. Verify this key before sending credentials.",
"vncServerKeyVerifyTitle": "VNC Server Key Verification",
"vncServerKeyVerifyWarning": "The VNC server key changed since the last trusted connection. Accept only if you expect this change.",
"webdavEndpoint": "WebDAV Endpoint",
"webdavEndpointRequired": "WebDAV endpoint is required.",
"windowBehavior": "Window",
+8
View File
@@ -870,6 +870,8 @@
"vncSecurityNone": "없음",
"vncShared": "공유 세션",
"vncUnencryptedWarning": "클래식 VNC는 데스크톱, 키보드 또는 클립보드 트래픽을 암호화하지 않습니다. 신뢰할 수 있는 네트워크, VPN 또는 SSH 터널을 사용하세요.",
"vncUsernameHint": "자동 모드는 RA2_256 서버에 이 사용자 이름을 사용합니다. 클래식 VNC 인증에서는 사용하지 않습니다.",
"vncUsernamePlaceholder": "사용자 이름(RA2_256용)",
"vncViewOnly": "보기 전용",
"workingDir": "작업 디렉터리",
"workingDirPlaceholder": "예: C:\\Projects 또는 ~/workspace",
@@ -2711,6 +2713,12 @@
"useOsKeyring": "OS 키체인 사용",
"useOsKeyringDesc": "시스템 기본 키체인(macOS 키체인, Windows 자격 증명 관리자, Secret Service)에 자격 증명을 안전하게 저장합니다.",
"viewPrivateKey": "개인 키 보기",
"vncServerKeyAccept": "수락하고 기억",
"vncServerKeyBits": "RSA 키",
"vncServerKeyVerifyChanged": "저장된 VNC 서버 키가 변경되었습니다. 다시 연결하기 전에 새 키를 확인하세요.",
"vncServerKeyVerifyNew": "VNC 서버가 RA2_256 암호화를 사용합니다. 자격 증명을 보내기 전에 이 키를 확인하세요.",
"vncServerKeyVerifyTitle": "VNC 서버 키 검증",
"vncServerKeyVerifyWarning": "VNC 서버 키가 마지막으로 신뢰한 연결과 다릅니다. 이 변경이 예상된 경우에만 수락하세요.",
"webdavEndpoint": "WebDAV 엔드포인트",
"webdavEndpointRequired": "WebDAV 엔드포인트가 필요합니다.",
"windowBehavior": "창",
+8
View File
@@ -870,6 +870,8 @@
"vncSecurityNone": "无",
"vncShared": "共享会话",
"vncUnencryptedWarning": "经典 VNC 不会加密桌面、键盘或剪贴板流量。请使用可信网络、VPN 或 SSH 隧道。",
"vncUsernameHint": "自动模式会在 RA2_256 服务器上使用此用户名;经典 VNC 身份验证会忽略它。",
"vncUsernamePlaceholder": "用户名(用于 RA2_256)",
"vncViewOnly": "仅查看",
"workingDir": "工作目录",
"workingDirPlaceholder": "例如:C:\\Projects 或 ~/workspace",
@@ -2711,6 +2713,12 @@
"useOsKeyring": "使用系统凭据管理器(OS Keyring)",
"useOsKeyringDesc": "将凭据安全地存储在系统原生钥匙串中 (macOS Keychain, Windows Credential Manager, Secret Service)。",
"viewPrivateKey": "查看私钥",
"vncServerKeyAccept": "接受并记住",
"vncServerKeyBits": "RSA 密钥",
"vncServerKeyVerifyChanged": "已保存的 VNC 服务器密钥已更改。重新连接前请核对新密钥。",
"vncServerKeyVerifyNew": "VNC 服务器正在使用 RA2_256 加密。发送凭据前请核对此密钥。",
"vncServerKeyVerifyTitle": "VNC 服务器密钥验证",
"vncServerKeyVerifyWarning": "VNC 服务器密钥与上次信任的连接不同。仅在确认此次更改符合预期时接受。",
"webdavEndpoint": "WebDAV 地址",
"webdavEndpointRequired": "必须填写 WebDAV 地址。",
"windowBehavior": "窗口行为",
+8
View File
@@ -870,6 +870,8 @@
"vncSecurityNone": "無",
"vncShared": "共享工作階段",
"vncUnencryptedWarning": "傳統 VNC 不會加密桌面、鍵盤或剪貼簿流量。請使用可信任網路、VPN 或 SSH 通道。",
"vncUsernameHint": "自動模式會在 RA2_256 伺服器上使用此使用者名稱;傳統 VNC 驗證會忽略它。",
"vncUsernamePlaceholder": "使用者名稱(用於 RA2_256)",
"vncViewOnly": "僅檢視",
"workingDir": "工作目錄",
"workingDirPlaceholder": "例如:C:\\Projects 或 ~/workspace",
@@ -2706,6 +2708,12 @@
"useOsKeyring": "使用系統認證管理器(OS Keyring)",
"useOsKeyringDesc": "將認證資訊安全地儲存在系統原生鑰匙串中 (macOS Keychain, Windows Credential Manager, Secret Service)。",
"viewPrivateKey": "檢視私鑰",
"vncServerKeyAccept": "接受並記住",
"vncServerKeyBits": "RSA 金鑰",
"vncServerKeyVerifyChanged": "已儲存的 VNC 伺服器金鑰已變更。重新連線前請核對新金鑰。",
"vncServerKeyVerifyNew": "VNC 伺服器正在使用 RA2_256 加密。傳送憑證前請核對此金鑰。",
"vncServerKeyVerifyTitle": "VNC 伺服器金鑰驗證",
"vncServerKeyVerifyWarning": "VNC 伺服器金鑰與上次信任的連線不同。僅在確認此次變更符合預期時接受。",
"webdavEndpoint": "WebDAV 位址",
"webdavEndpointRequired": "必須填寫 WebDAV 位址。",
"windowBehavior": "視窗行為",
+48 -1
View File
@@ -9,6 +9,7 @@ const {
invokeMock,
localTerminalMock,
rdpFormMock,
vncFormMock,
serialFormMock,
sshFormMock,
telnetFormMock,
@@ -19,6 +20,7 @@ const {
invokeMock: vi.fn(),
localTerminalMock: vi.fn(),
rdpFormMock: vi.fn(),
vncFormMock: vi.fn(),
serialFormMock: vi.fn(),
sshFormMock: vi.fn(),
telnetFormMock: vi.fn(),
@@ -127,7 +129,12 @@ vi.mock("@/components/sessions/TelnetForm", () => ({
);
},
}));
vi.mock("@/components/sessions/VncForm", () => ({ VncForm: () => null }));
vi.mock("@/components/sessions/VncForm", () => ({
VncForm: (props: Record<string, unknown>) => {
vncFormMock(props);
return null;
},
}));
vi.mock("@/components/sessions/RdpForm", () => ({
RdpForm: (props: Record<string, unknown>) => {
rdpFormMock(props);
@@ -230,6 +237,22 @@ const serialConnection: SavedConnection = {
modem_upload_protocol: "ymodem",
};
const vncConnection: SavedConnection = {
id: "vnc-1",
name: "Raspberry Pi",
type: "vnc",
host: "pi.local",
port: 5900,
username: "pi",
auth: { mode: "password", has_password: true },
security: { mode: "auto" },
display: { scale_mode: "fit" },
clipboard: { enabled: true },
reconnect: { enabled: true, max_attempts: 5 },
shared: true,
view_only: false,
};
describe("NewSessionPage", () => {
beforeEach(() => {
window.history.replaceState({}, "", `/?edit=${rdpConnection.id}`);
@@ -269,6 +292,7 @@ describe("NewSessionPage", () => {
});
localTerminalMock.mockReset();
rdpFormMock.mockReset();
vncFormMock.mockReset();
serialFormMock.mockReset();
sshFormMock.mockReset();
telnetFormMock.mockReset();
@@ -568,4 +592,27 @@ describe("NewSessionPage", () => {
expect(invokeMock).not.toHaveBeenCalledWith("save_connection", expect.anything());
expect(closeMock).not.toHaveBeenCalled();
});
it("restores and saves the optional VNC username", async () => {
window.history.replaceState({}, "", `/?edit=${vncConnection.id}`);
render(<NewSessionPage />);
await waitFor(() => {
expect(vncFormMock).toHaveBeenLastCalledWith(
expect.objectContaining({ username: "pi", securityMode: "auto" }),
);
});
fireEvent.click(screen.getByRole("button", { name: "dialog.save" }));
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith(
"save_connection",
expect.objectContaining({
connection: expect.objectContaining({
type: "vnc",
username: "pi",
}),
}),
);
});
});
});
+6
View File
@@ -196,6 +196,7 @@ export default function NewSessionPage() {
const [rdpPort, setRdpPort] = useState(3389);
const [vncPort, setVncPort] = useState(5900);
const [username, setUsername] = useState("root");
const [vncUsername, setVncUsername] = useState("");
const [rdpDomain, setRdpDomain] = useState("");
const [authType, setAuthType] = useState<SshAuthMode>("password");
const [accountId, setAccountId] = useState("");
@@ -436,6 +437,7 @@ export default function NewSessionPage() {
} else if (found.type === "vnc") {
setHost(found.host || "");
setVncPort(found.port || 5900);
setVncUsername(found.username || "");
setPasswordId(found.auth?.password_id || "");
setHasPassword(found.auth?.has_password || false);
setVncSecurityMode(found.security?.mode ?? "auto");
@@ -493,6 +495,7 @@ export default function NewSessionPage() {
setRdpPort(3389);
setVncPort(5900);
setUsername(currentTab === "rdp" ? DEFAULT_RDP_USERNAME : "root");
setVncUsername("");
setRdpDomain("");
setAuthType("password");
setAccountId("");
@@ -1188,6 +1191,7 @@ export default function NewSessionPage() {
? {
host: normalizedHost,
port: vncPort,
username: vncUsername,
auth,
network,
security: { mode: vncSecurityMode },
@@ -1818,6 +1822,8 @@ export default function NewSessionPage() {
setHost={setHost}
port={vncPort}
setPort={setVncPort}
username={vncUsername}
setUsername={setVncUsername}
passwordId={passwordId}
setPasswordId={setPasswordId}
password={password}