fix(vnc): guard server trust and route verification to owner windows
Persist server keys only after authenticated RA2 and RFB initialization, guarded against cancellation and stale generations. Preserve owner routing through creation, restoration, and reconnect, and clear resolved verification prompts. Keep the compatible RSA range, honor the configured 8192-bit maximum, support both documented and TigerVNC random lengths, and add protocol, storage, and frontend regressions.
This commit is contained in:
1 parent
64d592cf41
commit
28c7115504
16 files changed
+871
-56
No files matched your search
@@ -1,17 +1,26 @@
|
||||
use crate::core::vnc::{self, VncInputEvent, VncSessionManager};
|
||||
use crate::error::AppResult;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_vnc_session(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, Arc<VncSessionManager>>,
|
||||
connection_id: String,
|
||||
_create_request_id: Option<String>,
|
||||
create_request_id: Option<String>,
|
||||
owner_window_label: String,
|
||||
) -> AppResult<String> {
|
||||
let config = vnc::load_saved_vnc_config(&app, &connection_id)?;
|
||||
let _ = create_request_id;
|
||||
if !crate::window_state::is_main_window_label(&owner_window_label)
|
||||
|| app.get_webview_window(&owner_window_label).is_none()
|
||||
{
|
||||
return Err(crate::error::AppError::Config(
|
||||
"Invalid VNC owner main window".into(),
|
||||
));
|
||||
}
|
||||
let config = vnc::load_saved_vnc_config(&app, &connection_id, owner_window_label)?;
|
||||
let session_id = state.create_session(app.clone(), config).await?;
|
||||
if let Err(error) = crate::storage::mark_connection_used(&connection_id) {
|
||||
tracing::warn!(connection_id, %error, "Failed to mark VNC connection as recently used");
|
||||
|
||||
+346
-34
@@ -30,7 +30,7 @@ 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 SERVER_KEY_PROMPT_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
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);
|
||||
@@ -77,6 +77,7 @@ pub struct VncServerKeyVerifyEvent {
|
||||
pub fingerprint: String,
|
||||
pub key_bits: u32,
|
||||
pub known_host_status: String,
|
||||
pub target_window_label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -117,6 +118,7 @@ struct VncServerKeyPending {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VncConnectConfig {
|
||||
pub owner_window_label: String,
|
||||
pub session_id: String,
|
||||
pub connection_id: String,
|
||||
pub name: String,
|
||||
@@ -222,6 +224,7 @@ pub struct VncSession {
|
||||
cancel_sender: Mutex<Option<watch::Sender<bool>>>,
|
||||
worker: Mutex<Option<JoinHandle<()>>>,
|
||||
close_requested: AtomicBool,
|
||||
trust_commit_guard: std::sync::Mutex<()>,
|
||||
pending_server_keys: Arc<Mutex<HashMap<String, VncServerKeyPending>>>,
|
||||
}
|
||||
|
||||
@@ -243,6 +246,11 @@ impl VncSessionManager {
|
||||
app: AppHandle,
|
||||
config: VncConnectConfig,
|
||||
) -> AppResult<String> {
|
||||
if !crate::window_state::is_main_window_label(&config.owner_window_label) {
|
||||
return Err(AppError::Config(
|
||||
"VNC session requires an owner main window".into(),
|
||||
));
|
||||
}
|
||||
let session_id = config.session_id.clone();
|
||||
let session = Arc::new(VncSession {
|
||||
config,
|
||||
@@ -259,6 +267,7 @@ impl VncSessionManager {
|
||||
cancel_sender: Mutex::new(None),
|
||||
worker: Mutex::new(None),
|
||||
close_requested: AtomicBool::new(false),
|
||||
trust_commit_guard: std::sync::Mutex::new(()),
|
||||
pending_server_keys: self.pending_server_keys.clone(),
|
||||
});
|
||||
self.sessions
|
||||
@@ -338,9 +347,10 @@ 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;
|
||||
invalidate_vnc_generation(&session, false);
|
||||
stop_worker(&session).await;
|
||||
self.cancel_pending_server_keys_for_session(app.clone(), session_id)
|
||||
.await;
|
||||
session.close_requested.store(false, Ordering::Release);
|
||||
*session.framebuffer.lock().await = None;
|
||||
session.pending_frames.lock().await.clear();
|
||||
@@ -354,10 +364,10 @@ impl VncSessionManager {
|
||||
let Some(session) = self.sessions.lock().await.remove(session_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
session.close_requested.store(true, Ordering::Release);
|
||||
self.cancel_pending_server_keys_for_session(session_id)
|
||||
.await;
|
||||
invalidate_vnc_generation(&session, true);
|
||||
stop_worker(&session).await;
|
||||
self.cancel_pending_server_keys_for_session(app.clone(), session_id)
|
||||
.await;
|
||||
session.frame_attach_id.fetch_add(1, Ordering::AcqRel);
|
||||
*session.frame_channel.lock().await = None;
|
||||
session.pending_frames.lock().await.clear();
|
||||
@@ -374,8 +384,9 @@ impl VncSessionManager {
|
||||
"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
|
||||
let session = self.get(&pending.session_id).await?;
|
||||
if session.generation.load(Ordering::Acquire) != pending.generation
|
||||
|| session.close_requested.load(Ordering::Acquire)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -383,17 +394,20 @@ impl VncSessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel_pending_server_keys_for_session(&self, session_id: &str) {
|
||||
async fn cancel_pending_server_keys_for_session(&self, app: AppHandle, 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())
|
||||
})
|
||||
.filter(|(_, request)| request.session_id == session_id)
|
||||
.map(|(request_id, _)| request_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for request_id in request_ids {
|
||||
if let Some(request) = pending.remove(&request_id) {
|
||||
let _ = request.responder.send(false);
|
||||
let _ = app.emit(
|
||||
"vnc-server-key-verify-resolved",
|
||||
serde_json::json!({"requestId": request_id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +529,7 @@ async fn run_protocol_generation(
|
||||
host,
|
||||
port,
|
||||
session.config.network.as_ref(),
|
||||
None,
|
||||
Some(session.config.owner_window_label.clone()),
|
||||
)) => {
|
||||
result.map_err(|_| (VncErrorKind::Transport, "VNC connection timed out".to_string(), true))?
|
||||
.map_err(|error| (VncErrorKind::Transport, format!("Unable to connect to the VNC server: {error}"), true))?
|
||||
@@ -559,6 +573,8 @@ async fn run_protocol_generation(
|
||||
} else {
|
||||
HANDSHAKE_TIMEOUT
|
||||
};
|
||||
let authenticated_key = Arc::new(std::sync::Mutex::new(None::<String>));
|
||||
let notified_key = authenticated_key.clone();
|
||||
let verifier_app = app.clone();
|
||||
let verifier_session = session.clone();
|
||||
let state = VncConnector::new(stream)
|
||||
@@ -570,6 +586,10 @@ async fn run_protocol_generation(
|
||||
let session = verifier_session.clone();
|
||||
async move { verify_vnc_server_key(&app, &session, generation, &server_key).await }
|
||||
})
|
||||
.set_server_key_authenticated(move |key| {
|
||||
*notified_key.lock().expect("VNC authenticated key lock") =
|
||||
Some(vnc_key_fingerprint(&key));
|
||||
})
|
||||
.set_security_policy(security_policy(&session.config.security_mode))
|
||||
.set_pixel_format(PixelFormat::rgba())
|
||||
.set_limits(limits)
|
||||
@@ -580,8 +600,17 @@ async fn run_protocol_generation(
|
||||
.allow_shared(session.config.shared)
|
||||
.build()
|
||||
.map_err(classify_vnc_error)?;
|
||||
let client = timeout(handshake_timeout, state.try_start())
|
||||
.await
|
||||
let handshake_result = tokio::select! {
|
||||
biased;
|
||||
changed = cancel_rx.changed() => { let _ = changed; None },
|
||||
result = timeout(handshake_timeout, state.try_start()) => { Some(result) },
|
||||
};
|
||||
// Also clean up a prompt if the outer handshake timeout dropped its future.
|
||||
resolve_vnc_prompts(app, session, generation).await;
|
||||
let Some(handshake_result) = handshake_result else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = handshake_result
|
||||
.map_err(|_| {
|
||||
(
|
||||
VncErrorKind::Transport,
|
||||
@@ -591,6 +620,39 @@ async fn run_protocol_generation(
|
||||
})?
|
||||
.and_then(|state| state.finish())
|
||||
.map_err(classify_vnc_error)?;
|
||||
{
|
||||
// Serialize the trust write with close/reconnect invalidation. No await
|
||||
// separates the final generation/cancellation check from the storage write.
|
||||
let _guard = session
|
||||
.trust_commit_guard
|
||||
.lock()
|
||||
.expect("VNC trust commit lock");
|
||||
let fingerprint = authenticated_key
|
||||
.lock()
|
||||
.expect("VNC authenticated key lock");
|
||||
commit_vnc_trust(
|
||||
fingerprint.as_deref(),
|
||||
generation,
|
||||
session.generation.load(Ordering::Acquire),
|
||||
session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow(),
|
||||
|fingerprint| {
|
||||
if crate::storage::check_vnc_known_host(
|
||||
&session.config.host,
|
||||
session.config.port,
|
||||
fingerprint,
|
||||
)? != crate::storage::KnownHostCheck::Match
|
||||
{
|
||||
crate::storage::upsert_vnc_known_host(
|
||||
&session.config.host,
|
||||
session.config.port,
|
||||
fingerprint,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?;
|
||||
}
|
||||
|
||||
set_state(session, VncSessionState::Negotiating, None, None).await;
|
||||
emit_state(app, session, VncSessionState::Negotiating, None, None);
|
||||
@@ -655,32 +717,108 @@ fn security_policy(mode: &str) -> VncSecurityPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn vnc_key_fingerprint(key: &VncServerKey) -> String {
|
||||
format!("SHA256:{}", hex::encode(Sha256::digest(key.encoded())))
|
||||
}
|
||||
|
||||
fn invalidate_vnc_generation(session: &VncSession, closing: bool) {
|
||||
let _guard = session
|
||||
.trust_commit_guard
|
||||
.lock()
|
||||
.expect("VNC trust commit lock");
|
||||
session.generation.fetch_add(1, Ordering::AcqRel);
|
||||
if closing {
|
||||
session.close_requested.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_vnc_trust(
|
||||
fingerprint: Option<&str>,
|
||||
generation: u64,
|
||||
current_generation: u64,
|
||||
cancelled: bool,
|
||||
save: impl FnOnce(&str) -> AppResult<()>,
|
||||
) -> AppResult<()> {
|
||||
if generation == current_generation
|
||||
&& !cancelled
|
||||
&& let Some(fingerprint) = fingerprint
|
||||
{
|
||||
save(fingerprint)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_vnc_prompts(app: &AppHandle, session: &VncSession, generation: u64) {
|
||||
let mut pending = session.pending_server_keys.lock().await;
|
||||
let ids: Vec<_> = pending
|
||||
.iter()
|
||||
.filter(|(_, request)| {
|
||||
request.session_id == session.config.session_id && request.generation == generation
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids {
|
||||
pending.remove(&id);
|
||||
let _ = app.emit(
|
||||
"vnc-server-key-verify-resolved",
|
||||
serde_json::json!({"requestId": id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if session.generation.load(Ordering::Acquire) != generation
|
||||
|| session.close_requested.load(Ordering::Acquire)
|
||||
{
|
||||
return Err(VncError::Ra2ServerKeyRejected(
|
||||
"stale VNC connection generation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let fingerprint = format!(
|
||||
"SHA256:{}",
|
||||
hex::encode(Sha256::digest(server_key.encoded()))
|
||||
);
|
||||
let fingerprint = vnc_key_fingerprint(server_key);
|
||||
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()))?;
|
||||
confirm_vnc_trust(
|
||||
known_host_status,
|
||||
prompt_vnc_server_key(
|
||||
app,
|
||||
session,
|
||||
generation,
|
||||
server_key,
|
||||
fingerprint,
|
||||
known_host_status,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn confirm_vnc_trust(
|
||||
known_host_status: crate::storage::KnownHostCheck,
|
||||
prompt: impl std::future::Future<Output = Result<(), VncError>>,
|
||||
) -> Result<(), VncError> {
|
||||
if known_host_status == crate::storage::KnownHostCheck::Match {
|
||||
return Ok(());
|
||||
}
|
||||
prompt.await
|
||||
}
|
||||
|
||||
async fn prompt_vnc_server_key(
|
||||
app: &AppHandle,
|
||||
session: &Arc<VncSession>,
|
||||
generation: u64,
|
||||
server_key: &VncServerKey,
|
||||
fingerprint: String,
|
||||
known_host_status: crate::storage::KnownHostCheck,
|
||||
) -> Result<(), VncError> {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
session.pending_server_keys.lock().await.insert(
|
||||
@@ -696,8 +834,9 @@ async fn verify_vnc_server_key(
|
||||
session_id: session.config.session_id.clone(),
|
||||
host: session.config.host.clone(),
|
||||
port: session.config.port,
|
||||
fingerprint: fingerprint.clone(),
|
||||
fingerprint,
|
||||
key_bits: server_key.bits(),
|
||||
target_window_label: session.config.owner_window_label.clone(),
|
||||
known_host_status: match known_host_status {
|
||||
crate::storage::KnownHostCheck::Match => "match",
|
||||
crate::storage::KnownHostCheck::HostSeen => "changed",
|
||||
@@ -705,18 +844,27 @@ async fn verify_vnc_server_key(
|
||||
}
|
||||
.to_string(),
|
||||
};
|
||||
let _ = app.emit("vnc-server-key-verify", payload);
|
||||
app.emit_to(
|
||||
&session.config.owner_window_label,
|
||||
"vnc-server-key-verify",
|
||||
payload,
|
||||
)
|
||||
.map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?;
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
let response = timeout(SERVER_KEY_PROMPT_TIMEOUT, rx).await;
|
||||
session.pending_server_keys.lock().await.remove(&request_id);
|
||||
let _ = app.emit(
|
||||
"vnc-server-key-verify-resolved",
|
||||
serde_json::json!({"requestId": request_id}),
|
||||
);
|
||||
let Ok(Ok(accepted)) = response else {
|
||||
return Err(VncError::Ra2ServerKeyRejected(
|
||||
"verification timed out".to_string(),
|
||||
));
|
||||
};
|
||||
if session.generation.load(Ordering::Acquire) != generation {
|
||||
if session.generation.load(Ordering::Acquire) != generation
|
||||
|| session.close_requested.load(Ordering::Acquire)
|
||||
{
|
||||
return Err(VncError::Ra2ServerKeyRejected(
|
||||
"stale VNC connection generation".to_string(),
|
||||
));
|
||||
@@ -726,8 +874,6 @@ async fn verify_vnc_server_key(
|
||||
"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(())
|
||||
}
|
||||
|
||||
@@ -1134,7 +1280,11 @@ fn validate_rectangle(rect: Rect, desktop_width: u16, desktop_height: u16) -> Ap
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_saved_vnc_config(app: &AppHandle, connection_id: &str) -> AppResult<VncConnectConfig> {
|
||||
pub fn load_saved_vnc_config(
|
||||
app: &AppHandle,
|
||||
connection_id: &str,
|
||||
owner_window_label: String,
|
||||
) -> AppResult<VncConnectConfig> {
|
||||
let connection = config::load_connection_by_id(app, connection_id)?;
|
||||
let network = connection.network.clone();
|
||||
let password = resolve_vnc_password(app, connection.auth.as_ref())?;
|
||||
@@ -1169,6 +1319,7 @@ pub fn load_saved_vnc_config(app: &AppHandle, connection_id: &str) -> AppResult<
|
||||
}
|
||||
|
||||
Ok(VncConnectConfig {
|
||||
owner_window_label,
|
||||
session_id: uuid::Uuid::new_v4().to_string(),
|
||||
connection_id: connection_id.to_string(),
|
||||
name: connection.name,
|
||||
@@ -1253,6 +1404,164 @@ mod tests {
|
||||
assert_eq!(reconnect_delay(100), Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vnc_tofu_commits_only_confirmed_successful_current_handshakes() {
|
||||
use crate::storage::{KnownHostCheck, Storage};
|
||||
for (name, old_key, accepted, authenticated, current_generation, cancelled, expected) in [
|
||||
(
|
||||
"unknown-success",
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::Match,
|
||||
),
|
||||
(
|
||||
"unknown-reject",
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::UnknownHost,
|
||||
),
|
||||
(
|
||||
"hash-mismatch",
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::UnknownHost,
|
||||
),
|
||||
(
|
||||
"transport-error",
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::UnknownHost,
|
||||
),
|
||||
(
|
||||
"timeout",
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::UnknownHost,
|
||||
),
|
||||
(
|
||||
"changed-success",
|
||||
Some("SHA256:old"),
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::Match,
|
||||
),
|
||||
(
|
||||
"changed-reject",
|
||||
Some("SHA256:old"),
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
false,
|
||||
KnownHostCheck::HostSeen,
|
||||
),
|
||||
(
|
||||
"stale-generation",
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
2,
|
||||
false,
|
||||
KnownHostCheck::UnknownHost,
|
||||
),
|
||||
(
|
||||
"cancelled",
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
true,
|
||||
KnownHostCheck::UnknownHost,
|
||||
),
|
||||
] {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("vnc-tofu-{name}-{}", uuid::Uuid::new_v4()));
|
||||
let storage = Storage::open(&dir).unwrap();
|
||||
if let Some(old_key) = old_key {
|
||||
storage
|
||||
.upsert_vnc_known_host("pi.local", 5900, old_key)
|
||||
.unwrap();
|
||||
}
|
||||
let status = storage
|
||||
.check_vnc_known_host("pi.local", 5900, "SHA256:new")
|
||||
.unwrap();
|
||||
let confirmed = confirm_vnc_trust(status, async {
|
||||
if accepted {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VncError::Ra2ServerKeyRejected("rejected".into()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
let candidate = (confirmed && authenticated).then_some("SHA256:new");
|
||||
commit_vnc_trust(candidate, 1, current_generation, cancelled, |fingerprint| {
|
||||
storage.upsert_vnc_known_host("pi.local", 5900, fingerprint)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
storage
|
||||
.check_vnc_known_host("pi.local", 5900, "SHA256:new")
|
||||
.unwrap(),
|
||||
expected,
|
||||
"{name}"
|
||||
);
|
||||
if old_key.is_some() && expected != KnownHostCheck::Match {
|
||||
assert_eq!(
|
||||
storage
|
||||
.check_vnc_known_host("pi.local", 5900, "SHA256:old")
|
||||
.unwrap(),
|
||||
KnownHostCheck::Match
|
||||
);
|
||||
}
|
||||
drop(storage);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_vnc_key_does_not_poll_user_confirmation() {
|
||||
confirm_vnc_trust(crate::storage::KnownHostCheck::Match, async {
|
||||
panic!("matching key must never prompt");
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vnc_verification_payload_routes_to_its_session_owner() {
|
||||
let payload = VncServerKeyVerifyEvent {
|
||||
request_id: "request".into(),
|
||||
session_id: "session".into(),
|
||||
host: "pi.local".into(),
|
||||
port: 5900,
|
||||
fingerprint: "key".into(),
|
||||
key_bits: 1024,
|
||||
known_host_status: "unknown".into(),
|
||||
target_window_label: "main-second".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(payload).unwrap()["targetWindowLabel"],
|
||||
"main-second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_mode_maps_to_fail_closed_policy() {
|
||||
assert_eq!(security_policy("none"), VncSecurityPolicy::NoneOnly);
|
||||
@@ -1263,6 +1572,7 @@ mod tests {
|
||||
#[test]
|
||||
fn connect_target_preserves_ipv6_literals_without_host_port_concatenation() {
|
||||
let config = VncConnectConfig {
|
||||
owner_window_label: "main-test".into(),
|
||||
session_id: "vnc-test".to_string(),
|
||||
connection_id: "connection-test".to_string(),
|
||||
name: "IPv6 VNC".to_string(),
|
||||
@@ -1287,6 +1597,7 @@ mod tests {
|
||||
async fn pending_frame_queue_keeps_latest_frames_under_pressure() {
|
||||
let session = Arc::new(VncSession {
|
||||
config: VncConnectConfig {
|
||||
owner_window_label: "main-test".into(),
|
||||
session_id: "vnc-test".to_string(),
|
||||
connection_id: "connection-test".to_string(),
|
||||
name: "Test VNC".to_string(),
|
||||
@@ -1316,6 +1627,7 @@ mod tests {
|
||||
cancel_sender: Mutex::new(None),
|
||||
worker: Mutex::new(None),
|
||||
close_requested: AtomicBool::new(false),
|
||||
trust_commit_guard: std::sync::Mutex::new(()),
|
||||
pending_server_keys: Arc::new(Mutex::new(HashMap::new())),
|
||||
});
|
||||
|
||||
|
||||
@@ -519,3 +519,46 @@ fn vnc_known_hosts_distinguish_unknown_match_and_changed_keys() {
|
||||
);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_v3_storage_creates_vnc_table_and_preserves_local_trust_on_reopen() {
|
||||
let (dir, storage) = test_storage("vnc-v3-reopen");
|
||||
storage
|
||||
.upsert_rdp_known_host("rdp.local", 3389, "old", RdpCertificateMetadata::default())
|
||||
.unwrap();
|
||||
let txn = storage.db.begin_write().unwrap();
|
||||
txn.delete_table(VNC_KNOWN_HOSTS_TABLE).unwrap();
|
||||
txn.commit().unwrap();
|
||||
drop(storage);
|
||||
let storage = Storage::open(&dir).unwrap();
|
||||
assert_eq!(storage.get_schema_version().unwrap(), 3);
|
||||
storage
|
||||
.upsert_vnc_known_host("pi.local", 5900, "SHA256:key")
|
||||
.unwrap();
|
||||
storage
|
||||
.replace_known_hosts_export("other.local ssh-rsa AAAA\n")
|
||||
.unwrap();
|
||||
storage.clear_known_hosts().unwrap();
|
||||
drop(storage);
|
||||
let storage = Storage::open(&dir).unwrap();
|
||||
assert_eq!(
|
||||
storage
|
||||
.check_vnc_known_host("pi.local", 5900, "SHA256:key")
|
||||
.unwrap(),
|
||||
KnownHostCheck::Match
|
||||
);
|
||||
assert_eq!(
|
||||
storage
|
||||
.check_vnc_known_host("pi.local", 5901, "SHA256:key")
|
||||
.unwrap(),
|
||||
KnownHostCheck::UnknownHost
|
||||
);
|
||||
assert_eq!(
|
||||
storage
|
||||
.check_rdp_known_host("rdp.local", 3389, "old")
|
||||
.unwrap(),
|
||||
KnownHostCheck::Match
|
||||
);
|
||||
drop(storage);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
Vendored
+8
-1
@@ -15,7 +15,14 @@ reduce queue sizes, add explicit security-selection policy, and add
|
||||
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.
|
||||
credentials are sent. A separate authenticated-key notification runs only after
|
||||
ServerHash/MAC verification, successful SecurityResult, and RFB initialization;
|
||||
applications retain responsibility for persistence and cancellation guards.
|
||||
The RSA server-key range remains 1024..=8192, matching TigerVNC, with a
|
||||
2048-bit client key. RA2_256 accepts both the published RFB extension's
|
||||
16-byte randoms and TigerVNC's 32-byte randoms, mirroring the server length.
|
||||
References: <https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst>
|
||||
and <https://github.com/TigerVNC/tigervnc/blob/master/common/rfb/CSecurityRSAAES.cxx>.
|
||||
|
||||
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
|
||||
|
||||
+169
-3
@@ -183,7 +183,7 @@ where
|
||||
let username = connector.username.take().unwrap_or_default();
|
||||
ra2::validate_credentials(&username, &credential)?;
|
||||
SecurityType::write(&selected, &mut connector.stream).await?;
|
||||
let stream = ra2::authenticate(
|
||||
let (stream, server_key) = ra2::authenticate(
|
||||
connector.stream,
|
||||
username,
|
||||
credential,
|
||||
@@ -192,14 +192,18 @@ where
|
||||
connector.rfb_version,
|
||||
)
|
||||
.await?;
|
||||
VncClient::new(
|
||||
let client = VncClient::new(
|
||||
stream,
|
||||
connector.allow_shared,
|
||||
connector.pixel_format,
|
||||
connector.encodings,
|
||||
connector.limits,
|
||||
)
|
||||
.await?
|
||||
.await?;
|
||||
if let Some(notify) = connector.server_key_authenticated {
|
||||
notify(server_key);
|
||||
}
|
||||
client
|
||||
}
|
||||
_ => return Err(VncError::UnsupportedSecurityType),
|
||||
};
|
||||
@@ -231,6 +235,7 @@ where
|
||||
credentials_available: bool,
|
||||
username: Option<String>,
|
||||
server_key_verifier: Option<ServerKeyVerifier>,
|
||||
server_key_authenticated: Option<Arc<dyn Fn(VncServerKey) + Send + Sync>>,
|
||||
security_policy: VncSecurityPolicy,
|
||||
rfb_version: VncVersion,
|
||||
allow_shared: bool,
|
||||
@@ -278,6 +283,7 @@ where
|
||||
credentials_available: false,
|
||||
username: None,
|
||||
server_key_verifier: None,
|
||||
server_key_authenticated: None,
|
||||
security_policy: VncSecurityPolicy::Auto,
|
||||
allow_shared: true,
|
||||
rfb_version: VncVersion::RFB38,
|
||||
@@ -351,6 +357,18 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Notify the application after server-key confirmation, authenticated
|
||||
/// ServerHash, SecurityResult, and RFB initialization have all succeeded.
|
||||
/// This callback has no storage policy; applications must still guard
|
||||
/// cancellation and connection ownership before committing trust.
|
||||
pub fn set_server_key_authenticated<V>(mut self, notify: V) -> Self
|
||||
where
|
||||
V: Fn(VncServerKey) + Send + Sync + 'static,
|
||||
{
|
||||
self.server_key_authenticated = Some(Arc::new(notify));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_security_policy(mut self, policy: VncSecurityPolicy) -> Self {
|
||||
self.security_policy = policy;
|
||||
self
|
||||
@@ -694,6 +712,8 @@ mod tests {
|
||||
write_server_init(&mut encrypted).await;
|
||||
});
|
||||
|
||||
let notification_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let notified = notification_count.clone();
|
||||
let state = VncConnector::new(client)
|
||||
.set_auth_method(async { Ok("raspberry".to_owned()) })
|
||||
.set_credentials_available(true)
|
||||
@@ -703,6 +723,10 @@ mod tests {
|
||||
assert!(!key.encoded().is_empty());
|
||||
Ok(())
|
||||
})
|
||||
.set_server_key_authenticated(move |key| {
|
||||
assert!(!key.encoded().is_empty());
|
||||
notified.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
})
|
||||
.set_security_policy(VncSecurityPolicy::Auto)
|
||||
.add_encoding(VncEncoding::Raw)
|
||||
.build()
|
||||
@@ -710,6 +734,10 @@ mod tests {
|
||||
.try_start()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
notification_count.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
state.finish().unwrap().close().await.unwrap();
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
@@ -727,6 +755,144 @@ mod tests {
|
||||
run_ra2_handshake(2, "").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_or_timed_out_accepted_handshake_never_notifies_trust() {
|
||||
use rsa::traits::PublicKeyParts;
|
||||
for timeout_case in [false, true] {
|
||||
let (client, mut server) = tokio::io::duplex(16 * 1024);
|
||||
let (exchanged, exchange_ready) = tokio::sync::oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 1024).unwrap();
|
||||
server.write_all(b"RFB 003.008\n").await.unwrap();
|
||||
let mut version = [0; 12];
|
||||
server.read_exact(&mut version).await.unwrap();
|
||||
server.write_all(&[1, 129]).await.unwrap();
|
||||
assert_eq!(server.read_u8().await.unwrap(), 129);
|
||||
server.write_u32(1024).await.unwrap();
|
||||
server.write_all(&key.n().to_bytes_be()).await.unwrap();
|
||||
let mut exponent = vec![0; 128 - key.e().to_bytes_be().len()];
|
||||
exponent.extend(key.e().to_bytes_be());
|
||||
server.write_all(&exponent).await.unwrap();
|
||||
let client_bits = server.read_u32().await.unwrap();
|
||||
let mut client_key = vec![0; (client_bits as usize).div_ceil(8) * 2];
|
||||
server.read_exact(&mut client_key).await.unwrap();
|
||||
// Confirmation succeeded and key exchange started, but the
|
||||
// cryptographic server identity has not yet been authenticated.
|
||||
exchanged.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let notified = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let notification = notified.clone();
|
||||
let mut handshake = tokio::spawn(async move {
|
||||
VncConnector::new(client)
|
||||
.set_auth_method(async { Ok("raspberry".into()) })
|
||||
.set_server_key_verifier(|_| async { Ok(()) })
|
||||
.set_server_key_authenticated(move |_| {
|
||||
notification.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
})
|
||||
.add_encoding(VncEncoding::Raw)
|
||||
.build()
|
||||
.unwrap()
|
||||
.try_start()
|
||||
.await
|
||||
});
|
||||
exchange_ready.await.unwrap();
|
||||
if timeout_case {
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(10), &mut handshake)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
handshake.abort();
|
||||
assert!(matches!(handshake.await, Err(error) if error.is_cancelled()));
|
||||
assert!(!notified.load(std::sync::atomic::Ordering::SeqCst));
|
||||
server_task.abort();
|
||||
let _ = server_task.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_ra2_handshakes_never_notify_authenticated_key() {
|
||||
for outcome in [
|
||||
"reject",
|
||||
"hash-mismatch",
|
||||
"transport-error",
|
||||
"security-failure",
|
||||
"truncated-init",
|
||||
] {
|
||||
let (client, mut server) = tokio::io::duplex(16 * 1024);
|
||||
let server_task = tokio::spawn(async move {
|
||||
server.write_all(b"RFB 003.008\n").await.unwrap();
|
||||
let mut version = [0; 12];
|
||||
server.read_exact(&mut version).await.unwrap();
|
||||
server.write_all(&[1, 129]).await.unwrap();
|
||||
assert_eq!(server.read_u8().await.unwrap(), 129);
|
||||
if outcome == "reject" {
|
||||
let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 1024).unwrap();
|
||||
use rsa::traits::PublicKeyParts;
|
||||
server.write_u32(1024).await.unwrap();
|
||||
server.write_all(&key.n().to_bytes_be()).await.unwrap();
|
||||
let mut exponent = vec![0; 128 - key.e().to_bytes_be().len()];
|
||||
exponent.extend(key.e().to_bytes_be());
|
||||
server.write_all(&exponent).await.unwrap();
|
||||
return;
|
||||
}
|
||||
let mut encrypted = ra2::establish_test_encryption(
|
||||
server,
|
||||
VncLimits::default(),
|
||||
outcome == "hash-mismatch",
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, "hash-mismatch" | "transport-error") {
|
||||
encrypted.flush().await.unwrap();
|
||||
return;
|
||||
}
|
||||
encrypted.write_u8(2).await.unwrap();
|
||||
assert_eq!(encrypted.read_u8().await.unwrap(), 0);
|
||||
let len = encrypted.read_u8().await.unwrap();
|
||||
let mut password = vec![0; usize::from(len)];
|
||||
encrypted.read_exact(&mut password).await.unwrap();
|
||||
if outcome == "security-failure" {
|
||||
encrypted.write_u32(1).await.unwrap();
|
||||
encrypted.write_u32(6).await.unwrap();
|
||||
encrypted.write_all(b"denied").await.unwrap();
|
||||
} else {
|
||||
encrypted.write_u32(0).await.unwrap();
|
||||
encrypted.flush().await.unwrap();
|
||||
let _shared = encrypted.read_u8().await.unwrap();
|
||||
encrypted.write_u16(2).await.unwrap();
|
||||
}
|
||||
encrypted.flush().await.unwrap();
|
||||
});
|
||||
let notified = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let notification = notified.clone();
|
||||
let result = VncConnector::new(client)
|
||||
.set_auth_method(async { Ok("raspberry".into()) })
|
||||
.set_server_key_verifier(move |_| async move {
|
||||
if outcome == "reject" {
|
||||
Err(VncError::Ra2ServerKeyRejected("rejected".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.set_server_key_authenticated(move |_| {
|
||||
notification.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
})
|
||||
.add_encoding(VncEncoding::Raw)
|
||||
.build()
|
||||
.unwrap()
|
||||
.try_start()
|
||||
.await;
|
||||
assert!(result.is_err(), "{outcome}");
|
||||
assert!(
|
||||
!notified.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"{outcome}"
|
||||
);
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_with_credentials_never_downgrades_to_none() {
|
||||
let (client, mut server) = tokio::io::duplex(128);
|
||||
|
||||
+139
-13
@@ -71,18 +71,18 @@ pub(crate) async fn authenticate<S>(
|
||||
verifier: ServerKeyVerifier,
|
||||
limits: VncLimits,
|
||||
version: VncVersion,
|
||||
) -> Result<Ra2Stream<S>, VncError>
|
||||
) -> Result<(Ra2Stream<S>, VncServerKey), 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 {
|
||||
let server_key = VncServerKey {
|
||||
bits: u32::try_from(server_public_key.n().bits()).unwrap_or(u32::MAX),
|
||||
encoded: server_wire.clone(),
|
||||
})
|
||||
.await?;
|
||||
};
|
||||
verifier(server_key.clone()).await?;
|
||||
|
||||
let mut rng = OsRng;
|
||||
let client_private_key = RsaPrivateKey::new(&mut rng, CLIENT_RSA_BITS)
|
||||
@@ -104,11 +104,14 @@ where
|
||||
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 {
|
||||
// The published RFB extension describes 16-byte randoms. TigerVNC
|
||||
// RA2_256 uses 32 bytes (AES key size / 8); mirror the server length
|
||||
// to interoperate with both without relaxing RSA or record verification.
|
||||
if !matches!(server_random.len(), RA2_RANDOM_BYTES | 32) {
|
||||
return Err(VncError::InvalidRa2RandomLength(server_random.len()));
|
||||
}
|
||||
|
||||
let mut client_random = [0_u8; RA2_RANDOM_BYTES];
|
||||
let mut client_random = vec![0_u8; server_random.len()];
|
||||
rng.fill_bytes(&mut client_random);
|
||||
let encrypted_client_random = server_public_key
|
||||
.encrypt(&mut rng, Pkcs1v15Encrypt, &client_random)
|
||||
@@ -157,7 +160,7 @@ where
|
||||
|
||||
let result = encrypted.read_u32().await?;
|
||||
match result {
|
||||
0 => Ok(encrypted),
|
||||
0 => Ok((encrypted, server_key)),
|
||||
1 => {
|
||||
if version == VncVersion::RFB38 {
|
||||
let reason = read_security_failure(&mut encrypted, &limits).await?;
|
||||
@@ -196,12 +199,23 @@ where
|
||||
stream.read_exact(&mut modulus).await?;
|
||||
stream.read_exact(&mut exponent).await?;
|
||||
|
||||
let public_key = RsaPublicKey::new(
|
||||
let public_key = RsaPublicKey::new_with_max_size(
|
||||
BigUint::from_bytes_be(&modulus),
|
||||
BigUint::from_bytes_be(&exponent),
|
||||
usize::try_from(limits.max_ra2_key_bits)
|
||||
.map_err(|_| VncError::IntegerOverflow("RA2 maximum key length"))?,
|
||||
)
|
||||
.map_err(|_| VncError::InvalidRa2PublicKey)?;
|
||||
|
||||
let actual_bits = u32::try_from(public_key.n().bits()).unwrap_or(u32::MAX);
|
||||
if actual_bits < limits.min_ra2_key_bits || actual_bits > limits.max_ra2_key_bits {
|
||||
return Err(VncError::InvalidRa2KeyLength {
|
||||
actual: actual_bits,
|
||||
min: limits.min_ra2_key_bits,
|
||||
max: limits.max_ra2_key_bits,
|
||||
});
|
||||
}
|
||||
|
||||
let mut wire = Vec::with_capacity(4 + key_bytes * 2);
|
||||
wire.extend_from_slice(&bits.to_be_bytes());
|
||||
wire.extend_from_slice(&modulus);
|
||||
@@ -544,22 +558,43 @@ fn increment_counter(counter: &mut [u8; 16]) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn establish_test_encryption<S>(
|
||||
mut stream: S,
|
||||
pub(crate) async fn establish_test_encryption<S>(
|
||||
stream: S,
|
||||
limits: VncLimits,
|
||||
corrupt_server_hash: bool,
|
||||
) -> Ra2Stream<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
establish_test_encryption_with_params(
|
||||
stream,
|
||||
limits,
|
||||
corrupt_server_hash,
|
||||
CLIENT_RSA_BITS,
|
||||
RA2_RANDOM_BYTES,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn establish_test_encryption_with_params<S>(
|
||||
mut stream: S,
|
||||
limits: VncLimits,
|
||||
corrupt_server_hash: bool,
|
||||
key_bits: usize,
|
||||
random_bytes: usize,
|
||||
) -> 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_private_key = RsaPrivateKey::new(&mut rng, key_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 server_random = vec![0x31_u8; random_bytes];
|
||||
let encrypted_server_random = client_public_key
|
||||
.encrypt(&mut rng, Pkcs1v15Encrypt, &server_random)
|
||||
.unwrap();
|
||||
@@ -578,7 +613,7 @@ where
|
||||
let client_random = server_private_key
|
||||
.decrypt(Pkcs1v15Encrypt, &encrypted_client_random)
|
||||
.unwrap();
|
||||
assert_eq!(client_random.len(), RA2_RANDOM_BYTES);
|
||||
assert_eq!(client_random.len(), random_bytes);
|
||||
|
||||
let client_session_key = sha256_concat(&server_random, &client_random);
|
||||
let server_session_key = sha256_concat(&client_random, &server_random);
|
||||
@@ -751,6 +786,97 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_8192_bit_public_key_limit_is_honored() {
|
||||
// Public-key parsing needs a modulus and exponent, not private primes.
|
||||
// RsaPublicKey::new defaults to 4096 bits; RA2 allows up to 8192.
|
||||
let mut wire = 8192_u32.to_be_bytes().to_vec();
|
||||
wire.extend_from_slice(&[0xff; 1024]);
|
||||
wire.resize(4 + 2048 - 3, 0);
|
||||
wire.extend_from_slice(&[1, 0, 1]);
|
||||
let (key, encoded) = read_public_key(&mut wire.as_slice(), &VncLimits::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key.n().bits(), 8192);
|
||||
assert_eq!(encoded, wire);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_rsa_modulus_smaller_than_advertised_policy() {
|
||||
let key = RsaPrivateKey::new(&mut OsRng, 512).unwrap();
|
||||
let mut wire = 1024_u32.to_be_bytes().to_vec();
|
||||
wire.resize(4 + 128 - key.n().to_bytes_be().len(), 0);
|
||||
wire.extend(key.n().to_bytes_be());
|
||||
wire.resize(4 + 256 - key.e().to_bytes_be().len(), 0);
|
||||
wire.extend(key.e().to_bytes_be());
|
||||
let error = read_public_key(&mut wire.as_slice(), &VncLimits::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
VncError::InvalidRa2KeyLength { actual: 512, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supports_1024_bit_keys_and_tigervnc_256_bit_randoms() {
|
||||
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_with_params(server, limits, false, 1024, 32).await;
|
||||
encrypted.write_u8(2).await.unwrap();
|
||||
assert_eq!(encrypted.read_u8().await.unwrap(), 0);
|
||||
assert_eq!(encrypted.read_u8().await.unwrap(), 9);
|
||||
let mut password = [0; 9];
|
||||
encrypted.read_exact(&mut password).await.unwrap();
|
||||
assert_eq!(&password, b"raspberry");
|
||||
encrypted.write_u32(0).await.unwrap();
|
||||
encrypted.flush().await.unwrap();
|
||||
});
|
||||
let verifier: ServerKeyVerifier = Arc::new(|key| {
|
||||
Box::pin(async move {
|
||||
assert_eq!(key.bits(), 1024);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
assert!(authenticate(
|
||||
client,
|
||||
String::new(),
|
||||
"raspberry".into(),
|
||||
verifier,
|
||||
limits,
|
||||
VncVersion::RFB38
|
||||
)
|
||||
.await
|
||||
.is_ok());
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_truncated_records_and_oversized_record_headers() {
|
||||
for bytes in [vec![0], vec![0, 3, 1, 2], vec![0, 65]] {
|
||||
let (mut writer, input) = tokio::io::duplex(128);
|
||||
writer.write_all(&bytes).await.unwrap();
|
||||
drop(writer);
|
||||
let mut reader = Ra2Stream::new(input, [1; 32], [2; 32], 64).unwrap();
|
||||
let mut output = [0; 3];
|
||||
let error = reader.read_exact(&mut output).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::UnexpectedEof | io::ErrorKind::InvalidData
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_counter_is_little_endian_and_carries() {
|
||||
let mut counter = [0; 16];
|
||||
counter[0] = 255;
|
||||
increment_counter(&mut counter);
|
||||
assert_eq!(&counter[..3], &[0, 1, 0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticate_rejects_server_hash_mismatch() {
|
||||
let (client, server) = tokio::io::duplex(16 * 1024);
|
||||
|
||||
+5
@@ -21,6 +21,11 @@ impl Default for VncLimits {
|
||||
Self {
|
||||
max_server_name_bytes: 64 * 1024,
|
||||
max_failure_reason_bytes: 64 * 1024,
|
||||
// RFB RSA-AES does not require a 2048-bit server key. Keep the
|
||||
// TigerVNC/RealVNC-compatible 1024..=8192 acceptance range for
|
||||
// existing Raspberry Pi servers (NyaTerm issue #625). Our client
|
||||
// still generates 2048-bit keys; do not infer RSA size from AES-256.
|
||||
// https://github.com/TigerVNC/tigervnc/blob/master/common/rfb/CSecurityRSAAES.cxx
|
||||
min_ra2_key_bits: 1_024,
|
||||
max_ra2_key_bits: 8_192,
|
||||
max_ra2_record_bytes: usize::from(u16::MAX),
|
||||
|
||||
@@ -685,6 +685,7 @@ export default function AppLayout({
|
||||
onDone={dialogs.onRdpCertificateVerifyDone}
|
||||
/>
|
||||
<VncServerKeyVerifyDialog
|
||||
key={dialogs.vncServerKeyVerifyRequest?.requestId ?? "no-vnc-prompt"}
|
||||
request={dialogs.vncServerKeyVerifyRequest}
|
||||
onDone={dialogs.onVncServerKeyVerifyDone}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
VncServerKeyVerifyDialog,
|
||||
type VncServerKeyVerifyRequest,
|
||||
} from "./VncServerKeyVerifyDialog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const request: VncServerKeyVerifyRequest = {
|
||||
requestId: "key-request",
|
||||
sessionId: "vnc-session",
|
||||
host: "pi.local",
|
||||
port: 5900,
|
||||
fingerprint: "SHA256:key",
|
||||
keyBits: 1024,
|
||||
knownHostStatus: "changed",
|
||||
targetWindowLabel: "main-second",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.invoke.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("warns about changed keys and treats closing the dialog as Reject", async () => {
|
||||
const onDone = vi.fn();
|
||||
render(<VncServerKeyVerifyDialog request={request} onDone={onDone} />);
|
||||
expect(screen.getByText("settings.vncServerKeyVerifyWarning")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledWith(request.requestId));
|
||||
expect(mocks.invoke).toHaveBeenCalledExactlyOnceWith(
|
||||
"respond_vnc_server_key",
|
||||
{
|
||||
requestId: request.requestId,
|
||||
accepted: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a dialog when its request has expired before the response arrives", async () => {
|
||||
mocks.invoke.mockRejectedValue(
|
||||
new Error("No pending VNC server key request"),
|
||||
);
|
||||
const onDone = vi.fn();
|
||||
render(<VncServerKeyVerifyDialog request={request} onDone={onDone} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.vncServerKeyAccept" }),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledWith(request.requestId));
|
||||
});
|
||||
@@ -67,6 +67,15 @@ describe("VncForm", () => {
|
||||
expect(screen.getByText("dialog.vncPasswordLimit")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("disables the username in None mode and hides authentication hints", () => {
|
||||
render(<VncForm {...props()} securityMode="none" />);
|
||||
expect((screen.getByDisplayValue("pi") as HTMLInputElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
expect(screen.queryByText("dialog.vncUsernameHint")).toBeNull();
|
||||
expect(screen.queryByText("dialog.vncPasswordLimit")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows switching to saved password selection", () => {
|
||||
render(<VncForm {...props()} />);
|
||||
fireEvent.click(screen.getByText("dialog.savedPassword"));
|
||||
|
||||
@@ -1175,6 +1175,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
tasks.push(invoke<string>("create_vnc_session", {
|
||||
ownerWindowLabel: getOwnerMainWindowLabel(),
|
||||
connectionId: cid,
|
||||
createRequestId: pane.createRequestId,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { StrictMode, useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { VncServerKeyVerifyRequest } from "@/components/dialog/connections/VncServerKeyVerifyDialog";
|
||||
import type { RdpCertificateVerifyRequest } from "@/components/dialog/connections/RdpCertificateVerifyDialog";
|
||||
import { createTerminalWindowLeaf, type TerminalWindowNode } from "@/lib/tabWindows";
|
||||
import { setOwnerMainWindowLabel } from "@/lib/windowManager";
|
||||
@@ -32,6 +33,7 @@ function eventOptions(): Parameters<typeof useAppWindowEvents>[0] {
|
||||
removeSecurityPrompt: vi.fn(),
|
||||
setDockerSudoPasswordRequest: vi.fn(),
|
||||
setRdpCertificateRequests: vi.fn(),
|
||||
setVncServerKeyRequests: vi.fn(),
|
||||
handleConnectAfterEdit: vi.fn().mockResolvedValue(undefined),
|
||||
handleOpenPanel: vi.fn(),
|
||||
};
|
||||
@@ -114,6 +116,7 @@ describe("useAppWindowEvents", () => {
|
||||
"docker-sudo-password-request",
|
||||
"host-key-verify",
|
||||
"rdp-certificate-verify",
|
||||
"vnc-server-key-verify",
|
||||
"transfer-duplicate-request",
|
||||
"session-connect-after-edit",
|
||||
])("ignores %s addressed to another window", async (name) => {
|
||||
@@ -122,6 +125,7 @@ describe("useAppWindowEvents", () => {
|
||||
expect(options.addTab).not.toHaveBeenCalled();
|
||||
expect(options.queueSecurityPrompt).not.toHaveBeenCalled();
|
||||
expect(options.setDockerSudoPasswordRequest).not.toHaveBeenCalled();
|
||||
expect(options.setVncServerKeyRequests).not.toHaveBeenCalled();
|
||||
expect(options.handleConnectAfterEdit).not.toHaveBeenCalled();
|
||||
expect(mocks.focusTerminalSession).not.toHaveBeenCalled();
|
||||
expect(mocks.setBackendTransferDuplicatePrompt).not.toHaveBeenCalled();
|
||||
@@ -151,6 +155,39 @@ describe("useAppWindowEvents", () => {
|
||||
expect(options.removeSecurityPrompt).toHaveBeenCalledExactlyOnceWith("auth-1");
|
||||
});
|
||||
|
||||
it("routes VNC prompts to the owner, deduplicates, and removes resolved requests", async () => {
|
||||
const options = eventOptions();
|
||||
let requests: VncServerKeyVerifyRequest[] = [];
|
||||
options.setVncServerKeyRequests = vi.fn((update) => {
|
||||
requests = typeof update === "function" ? update(requests) : update;
|
||||
});
|
||||
renderEvents(options);
|
||||
const first: VncServerKeyVerifyRequest = {
|
||||
requestId: "vnc-1",
|
||||
targetWindowLabel: "main-test",
|
||||
sessionId: "session-1",
|
||||
host: "pi.local",
|
||||
port: 5900,
|
||||
fingerprint: "key",
|
||||
keyBits: 1024,
|
||||
knownHostStatus: "unknown",
|
||||
};
|
||||
const second = { requestId: "vnc-2", targetWindowLabel: "main-test" };
|
||||
await receive("vnc-server-key-verify", {
|
||||
...first,
|
||||
targetWindowLabel: "main-other",
|
||||
});
|
||||
expect(requests).toEqual([]);
|
||||
await receive("vnc-server-key-verify", first);
|
||||
await receive("vnc-server-key-verify", first);
|
||||
await receive("vnc-server-key-verify", second);
|
||||
expect(requests).toEqual([first, second]);
|
||||
await receive("vnc-server-key-verify-resolved", { requestId: "vnc-1" });
|
||||
expect(requests).toEqual([second]);
|
||||
await receive("vnc-server-key-verify-resolved", { requestId: "vnc-2" });
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("reloads settings through the existing command", async () => {
|
||||
const settings = { ui: { language: "zh-CN" } };
|
||||
mocks.invoke.mockResolvedValue(settings);
|
||||
@@ -254,7 +291,7 @@ describe("useAppWindowEvents", () => {
|
||||
);
|
||||
const options = eventOptions();
|
||||
const { unmount } = renderHook(() => useAppWindowEvents(options), { wrapper: StrictMode });
|
||||
expect(registrations).toHaveLength(30);
|
||||
expect(registrations).toHaveLength(34);
|
||||
unmount();
|
||||
await act(async () => {
|
||||
for (const registration of registrations) registration.resolve(registration.dispose);
|
||||
|
||||
@@ -179,6 +179,14 @@ export function useAppWindowEvents({
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
listen<{ requestId: string }>("vnc-server-key-verify-resolved", (event) => {
|
||||
setVncServerKeyRequests((current) =>
|
||||
current.filter((item) => item.requestId !== event.payload.requestId),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
listen<VncServerKeyVerifyRequest>("vnc-server-key-verify", (event) => {
|
||||
if (!eventTargetsCurrentWindow(event.payload.targetWindowLabel)) return;
|
||||
@@ -231,7 +239,7 @@ export function useAppWindowEvents({
|
||||
removeSecurityPrompt,
|
||||
setDockerSudoPasswordRequest,
|
||||
setRdpCertificateRequests,
|
||||
setVncServerKeyRequests,
|
||||
setVncServerKeyRequests,
|
||||
handleConnectAfterEdit,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setOwnerMainWindowLabel } from "./windowManager";
|
||||
import type { SavedConnection, TerminalSessionPane } from "@/types/global";
|
||||
import { createSessionForConnection, createSessionForPane } from "./appSessionFactory";
|
||||
|
||||
@@ -62,3 +63,34 @@ describe("SSH runtime mode creation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VNC owner window routing", () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset().mockResolvedValue("vnc-session");
|
||||
});
|
||||
|
||||
it.each(["main", "main-second"])(
|
||||
"passes owner %s through creation and pane recreation",
|
||||
async (owner) => {
|
||||
setOwnerMainWindowLabel(owner);
|
||||
await createSessionForConnection(
|
||||
{ id: "pi", type: "vnc" },
|
||||
"create-request",
|
||||
);
|
||||
await createSessionForPane(
|
||||
{ id: "pane", connectionId: "pi", type: "VNC" },
|
||||
"recreate-request",
|
||||
);
|
||||
expect(invokeMock).toHaveBeenNthCalledWith(1, "create_vnc_session", {
|
||||
connectionId: "pi",
|
||||
createRequestId: "create-request",
|
||||
ownerWindowLabel: owner,
|
||||
});
|
||||
expect(invokeMock).toHaveBeenNthCalledWith(2, "create_vnc_session", {
|
||||
connectionId: "pi",
|
||||
createRequestId: "recreate-request",
|
||||
ownerWindowLabel: owner,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { emit } from "@tauri-apps/api/event";
|
||||
import { assertMatchingTemporaryConfig } from "@/lib/appWorkspace";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import { getOwnerMainWindowLabel } from "@/lib/windowManager";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
buildTerminalCommandInput,
|
||||
@@ -126,6 +127,7 @@ export async function createSessionForConnection(
|
||||
});
|
||||
case "vnc":
|
||||
return invoke<string>("create_vnc_session", {
|
||||
ownerWindowLabel: getOwnerMainWindowLabel(),
|
||||
connectionId: connection.id,
|
||||
createRequestId,
|
||||
});
|
||||
@@ -259,6 +261,7 @@ export function createSessionForPane(
|
||||
case "VNC":
|
||||
if (!pane.connectionId) throw new Error("Missing VNC connection id");
|
||||
return invoke<string>("create_vnc_session", {
|
||||
ownerWindowLabel: getOwnerMainWindowLabel(),
|
||||
connectionId: pane.connectionId,
|
||||
createRequestId,
|
||||
});
|
||||
|
||||
@@ -272,6 +272,7 @@ describe("NewSessionPage", () => {
|
||||
return Promise.resolve([account]);
|
||||
case "get_saved_connections":
|
||||
return Promise.resolve([
|
||||
vncConnection,
|
||||
rdpConnection,
|
||||
serialConnection,
|
||||
jumpHost,
|
||||
|
||||
Reference in new issue
Block a user