fix(vnc): support RA2_256 Raspberry Pi connections
This commit is contained in:
1 parent
642c0de88b
commit
64d592cf41
35 files changed
+1823
-120
No files matched your search
@@ -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`
|
||||
|
||||
Generated
+39
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+5
@@ -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"
|
||||
|
||||
Vendored
+4
-1
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
+238
-82
@@ -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,43 +66,52 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
if connector.security_policy == VncSecurityPolicy::NoneOnly
|
||||
&& !security_types.contains(&SecurityType::None)
|
||||
{
|
||||
let selected = match connector.security_policy {
|
||||
VncSecurityPolicy::NoneOnly => {
|
||||
if !security_types.contains(&SecurityType::None) {
|
||||
return Err(VncError::RequiredSecurityTypeUnavailable("none"));
|
||||
}
|
||||
if connector.security_policy == VncSecurityPolicy::VncAuthOnly
|
||||
&& !security_types.contains(&SecurityType::VncAuth)
|
||||
{
|
||||
SecurityType::None
|
||||
}
|
||||
VncSecurityPolicy::VncAuthOnly => {
|
||||
if !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,
|
||||
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) {
|
||||
let client = match selected {
|
||||
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?;
|
||||
SecurityType::write(&selected, &mut connector.stream).await?;
|
||||
}
|
||||
VncVersion::RFB38 => {
|
||||
info!("No auth needed in vnc3.8");
|
||||
SecurityType::write(&SecurityType::None, &mut connector.stream)
|
||||
.await?;
|
||||
SecurityType::write(&selected, &mut connector.stream).await?;
|
||||
let result: AuthResult =
|
||||
connector.stream.read_u32().await?.try_into()?;
|
||||
if result == AuthResult::Failed {
|
||||
@@ -113,62 +124,6 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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?;
|
||||
}
|
||||
} 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 {
|
||||
let reason =
|
||||
read_security_failure(&mut connector.stream, &connector.limits)
|
||||
.await?;
|
||||
return Err(VncError::SecurityFailure(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("auth done, client connected");
|
||||
|
||||
Ok(VncState::Connected(
|
||||
VncClient::new(
|
||||
connector.stream,
|
||||
connector.allow_shared,
|
||||
@@ -176,8 +131,80 @@ where
|
||||
connector.encodings,
|
||||
connector.limits,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
.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,
|
||||
});
|
||||
}
|
||||
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(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
@@ -6,3 +6,4 @@ mod security;
|
||||
|
||||
pub use connection::VncClient;
|
||||
pub use connector::{VncConnector, VncSecurityPolicy};
|
||||
pub use security::ra2::VncServerKey;
|
||||
@@ -1 +1,3 @@
|
||||
pub(crate) mod des;
|
||||
|
||||
pub(crate) mod ra2;
|
||||
+854
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+26
@@ -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}")]
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
{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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "창",
|
||||
|
||||
@@ -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": "窗口行为",
|
||||
|
||||
@@ -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": "視窗行為",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user