NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -0,0 +1,149 @@
|
||||
//! End-to-end: the core HOST (host.rs) serves a session that the core VIEWER
|
||||
//! (signaling.rs connect) authenticates to, over the LIVE server. Proves the
|
||||
//! Android host's protocol (register -> challenge -> auth/E2E -> accept) and
|
||||
//! input routing interoperate before any Kotlin is wired.
|
||||
//!
|
||||
//! Network test -> #[ignore] by default. Run explicitly:
|
||||
//! cargo test -p netdesk-core --target x86_64-pc-windows-gnu --test host_viewer -- --ignored --nocapture
|
||||
|
||||
use netdesk_core::crypto::{gen_host_id, new_host_credentials};
|
||||
use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame};
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
async fn wait_host<F: Fn(&HostEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<HostEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<SessionEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn host_viewer_handshake_and_input() {
|
||||
let ws = "wss://netdesk.space/ws";
|
||||
let id = gen_host_id();
|
||||
let password = "e2e-host-test-pw";
|
||||
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let hcfg = HostConfig {
|
||||
ws_url: ws.into(),
|
||||
host_id: id.clone(),
|
||||
name: "TestAndroidHost".into(),
|
||||
salt_b64,
|
||||
iters,
|
||||
verifier_hex,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
audio: false,
|
||||
monitors: Vec::new(),
|
||||
monitor: 0,
|
||||
perms: Default::default(),
|
||||
approve: false,
|
||||
totp_secret: String::new(),
|
||||
};
|
||||
// Outbound control channel (unused by this test; drop the sender).
|
||||
let (_hctl_tx, hctl_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let host_task =
|
||||
tokio::spawn(async move { run_host(hcfg, frame_rx, hev_tx, hctl_rx).await });
|
||||
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Registered), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"host did not register"
|
||||
);
|
||||
// let the registration settle on the server before the viewer dials in
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
|
||||
// viewer connects to our host id
|
||||
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: ws.into(),
|
||||
host_id: id.clone(),
|
||||
password: password.into(),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect(vcfg, vtx).await.expect("viewer connect+handshake");
|
||||
|
||||
// host sees the viewer; viewer sees Connected with our advertised size
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"host never saw ViewerConnected"
|
||||
);
|
||||
let connected = wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10).await;
|
||||
match connected {
|
||||
Some(SessionEvent::Connected { w, h, e2e, .. }) => {
|
||||
assert_eq!((w, h), (1080, 1920), "advertised resolution");
|
||||
assert!(e2e, "E2E negotiated");
|
||||
}
|
||||
_ => panic!("viewer never got Connected"),
|
||||
}
|
||||
|
||||
// viewer input must surface on the host side with matching coordinates
|
||||
session.mouse(123, 456, 1, 0.0, 0.0);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
|
||||
Some(HostEvent::Mouse { x, y, buttons, .. }) => {
|
||||
assert_eq!((x, y, buttons), (123, 456, 1), "mouse routed to host");
|
||||
}
|
||||
_ => panic!("host never received the viewer's mouse"),
|
||||
}
|
||||
|
||||
session.key("KeyA", true);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { .. }), 10).await {
|
||||
Some(HostEvent::Key { code, down }) => {
|
||||
assert_eq!((code.as_str(), down), ("KeyA", true), "key routed to host");
|
||||
}
|
||||
_ => panic!("host never received the viewer's key"),
|
||||
}
|
||||
|
||||
// a frame fed to the host reaches the viewer as a binary message (we only
|
||||
// assert the host accepts + seals it; decode is covered elsewhere).
|
||||
let _ = frame_tx.send(HostFrame {
|
||||
data: vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28],
|
||||
keyframe: true,
|
||||
codec: 0,
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(3), host_task).await;
|
||||
println!("HOST_VIEWER_E2E_OK id={id}");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,739 @@
|
||||
//! Host-enforced permission ACL + view-only + approve-before-connect — protocol
|
||||
//! tests over the in-memory relay (same harness as p2p_loopback.rs): the FULL
|
||||
//! `connect_with` / `run_host_with` stacks run server-free, and the assertions
|
||||
//! are on what the ENGINE emits/sends — i.e. what a (possibly modified) viewer
|
||||
//! could actually make the host do, not what a well-behaved UI chooses to send.
|
||||
//!
|
||||
//! Proves the Tier-0 security fix: with a permission disabled, the engine never
|
||||
//! emits the corresponding platform event (input/clipboard/file/audio), replies
|
||||
//! `file.err` to file requests, advertises the REAL perms in `session.accept`,
|
||||
//! enforces viewer-requested view-only host-side, and holds a password-passed
|
||||
//! session for Accept/Reject when approve mode is on.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use netdesk_core::crypto::{
|
||||
gen_host_id, new_host_credentials, password_proof, password_proof_bound,
|
||||
};
|
||||
use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame, HostPerms};
|
||||
use netdesk_core::signaling::connect_with;
|
||||
use netdesk_core::{ConnectConfig, NdError, SessionEvent, Transport, TransportMsg};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory relay pair (the p2p_loopback.rs harness, minus the drop toggles).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MemTransport {
|
||||
tx: mpsc::UnboundedSender<TransportMsg>,
|
||||
rx: mpsc::UnboundedReceiver<TransportMsg>,
|
||||
}
|
||||
|
||||
fn mem_relay_pair() -> (MemTransport, MemTransport) {
|
||||
let (a_tx, a_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
||||
let (b_tx, b_rx) = mpsc::unbounded_channel::<TransportMsg>();
|
||||
(MemTransport { tx: b_tx, rx: a_rx }, MemTransport { tx: a_tx, rx: b_rx })
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for MemTransport {
|
||||
async fn send_binary(&mut self, data: Vec<u8>) -> netdesk_core::Result<()> {
|
||||
self.tx
|
||||
.send(TransportMsg::Binary(data))
|
||||
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
|
||||
}
|
||||
async fn send_text(&mut self, text: String) -> netdesk_core::Result<()> {
|
||||
self.tx
|
||||
.send(TransportMsg::Text(text))
|
||||
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
|
||||
}
|
||||
async fn recv(&mut self) -> Option<netdesk_core::Result<TransportMsg>> {
|
||||
self.rx.recv().await.map(Ok)
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_host<F: Fn(&HostEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<HostEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
pred: F,
|
||||
secs: u64,
|
||||
) -> Option<SessionEvent> {
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if pred(&ev) {
|
||||
return Some(ev);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn host_cfg(perms: HostPerms, approve: bool) -> (HostConfig, String) {
|
||||
host_cfg_totp(perms, approve, "")
|
||||
}
|
||||
|
||||
fn host_cfg_totp(perms: HostPerms, approve: bool, totp_secret: &str) -> (HostConfig, String) {
|
||||
let password = "perms-test-pw".to_string();
|
||||
let (salt_b64, iters, verifier_hex) = new_host_credentials(&password);
|
||||
let cfg = HostConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id: gen_host_id(),
|
||||
name: "PermsHost".into(),
|
||||
salt_b64,
|
||||
iters,
|
||||
verifier_hex,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
audio: true,
|
||||
monitors: Vec::new(),
|
||||
monitor: 0,
|
||||
perms,
|
||||
approve,
|
||||
totp_secret: totp_secret.to_string(),
|
||||
};
|
||||
(cfg, password)
|
||||
}
|
||||
|
||||
/// Collect host events until `marker` matches (inclusive), with a timeout.
|
||||
/// Returns everything seen before the marker.
|
||||
async fn collect_until_marker<F: Fn(&HostEvent) -> bool>(
|
||||
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
|
||||
marker: F,
|
||||
secs: u64,
|
||||
) -> Vec<HostEvent> {
|
||||
let mut seen = Vec::new();
|
||||
let deadline = Duration::from_secs(secs);
|
||||
loop {
|
||||
match timeout(deadline, rx.recv()).await {
|
||||
Ok(Some(ev)) => {
|
||||
if marker(&ev) {
|
||||
return seen;
|
||||
}
|
||||
seen.push(ev);
|
||||
}
|
||||
_ => panic!("marker event never arrived; saw {seen:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1) All perms DISABLED: the engine must not emit input/clipboard/file/audio
|
||||
// events no matter what the viewer sends, and file requests get file.err.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn perms_disabled_block_all_channels() {
|
||||
let (viewer_relay, host_relay) = mem_relay_pair();
|
||||
let perms = HostPerms { input: false, clipboard: false, files: false, audio: false };
|
||||
let (hcfg, password) = host_cfg(perms, false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let host_task = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect_with(vcfg, vtx, Box::new(viewer_relay))
|
||||
.await
|
||||
.expect("viewer connects (password auth still works with perms off)");
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"host saw the viewer"
|
||||
);
|
||||
|
||||
// Fire one of EVERYTHING a hostile viewer could send on disabled channels…
|
||||
session.mouse(100, 100, 1, 0.0, 0.0);
|
||||
session.key("F15", true);
|
||||
session.key("F15", false);
|
||||
session.clipboard("must-not-reach-the-platform");
|
||||
session.audio_set(1);
|
||||
session.list_roots();
|
||||
// …then flip view-only as an ORDERED MARKER (its handler is independent of
|
||||
// the four perms, so it must still emit — everything sent before it was
|
||||
// processed first on the same ordered channel).
|
||||
session.view_only(true);
|
||||
|
||||
let before = collect_until_marker(
|
||||
&mut hev_rx,
|
||||
|e| matches!(e, HostEvent::ViewOnly { on: true }),
|
||||
10,
|
||||
)
|
||||
.await;
|
||||
for ev in &before {
|
||||
assert!(
|
||||
!matches!(
|
||||
ev,
|
||||
HostEvent::Mouse { .. }
|
||||
| HostEvent::Key { .. }
|
||||
| HostEvent::Clipboard { .. }
|
||||
| HostEvent::File { .. }
|
||||
| HostEvent::FileChunk { .. }
|
||||
| HostEvent::AudioSource { .. }
|
||||
| HostEvent::Voice { .. }
|
||||
),
|
||||
"engine leaked a blocked event to the platform: {ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The denied file request must answer with a clear file.err.
|
||||
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::FileError { .. }), 10).await {
|
||||
Some(SessionEvent::FileError { msg }) => {
|
||||
assert!(
|
||||
msg.contains("disabled"),
|
||||
"file.err should say the channel is disabled, got: {msg}"
|
||||
);
|
||||
}
|
||||
_ => panic!("viewer never got file.err for the denied file.roots"),
|
||||
}
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(5), host_task).await;
|
||||
println!("PERMS_DENY_OK all four channels blocked at the engine + file.err reply");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) Default perms: every channel flows (no regression from the gates).
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn perms_default_channels_flow() {
|
||||
let (viewer_relay, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let host_task = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let (vtx, _vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect");
|
||||
|
||||
session.mouse(321, 654, 2, 0.0, 0.0);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
|
||||
Some(HostEvent::Mouse { x, y, buttons, .. }) => assert_eq!((x, y, buttons), (321, 654, 2)),
|
||||
_ => panic!("mouse blocked despite perms on"),
|
||||
}
|
||||
// Relative (game-mouse) motion flows through the same input gate.
|
||||
session.mouse_rel(17, -9, 1, 0.0);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::MouseRel { .. }), 10).await {
|
||||
Some(HostEvent::MouseRel { dx, dy, buttons, .. }) => {
|
||||
assert_eq!((dx, dy, buttons), (17, -9, 1))
|
||||
}
|
||||
_ => panic!("relative mouse blocked despite perms on"),
|
||||
}
|
||||
// Translate-keyboard Unicode text flows through the same input gate.
|
||||
session.text("h\u{00e9}llo");
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Text { .. }), 10).await {
|
||||
Some(HostEvent::Text { text }) => assert_eq!(text, "h\u{00e9}llo"),
|
||||
_ => panic!("text input blocked despite perms on"),
|
||||
}
|
||||
// Annotation strokes flow through the same input gate and reach the host.
|
||||
session.annotate_stroke(0xFF_0000, 3, &[(10, 20), (30, 40)]);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Annot { .. }), 10).await {
|
||||
Some(HostEvent::Annot { json }) => assert!(json.contains("stroke"), "annot json: {json}"),
|
||||
_ => panic!("annotation blocked despite perms on"),
|
||||
}
|
||||
session.key("KeyA", true);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { down: true, .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"key blocked despite perms on"
|
||||
);
|
||||
session.clipboard("hello");
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Clipboard { .. }), 10).await {
|
||||
Some(HostEvent::Clipboard { text }) => assert_eq!(text, "hello"),
|
||||
_ => panic!("clipboard blocked despite perms on"),
|
||||
}
|
||||
session.audio_set(1);
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::AudioSource { .. }), 10).await {
|
||||
Some(HostEvent::AudioSource { src }) => assert_eq!(src, 1),
|
||||
_ => panic!("audio.set blocked despite perms on"),
|
||||
}
|
||||
session.list_roots();
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::File { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"file.roots blocked despite perms on"
|
||||
);
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(5), host_task).await;
|
||||
println!("PERMS_ALLOW_OK all channels flow with default perms");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3) View-only: input gated OFF and back ON mid-session, enforced host-side.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn view_only_gates_input_live() {
|
||||
let (viewer_relay, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
|
||||
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let host_task = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let (vtx, _vrx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let vcfg = ConnectConfig {
|
||||
ws_url: "mem://relay".into(),
|
||||
host_id,
|
||||
password,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect");
|
||||
|
||||
// Input flows before view-only…
|
||||
session.mouse(10, 10, 0, 0.0, 0.0);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(),
|
||||
"input should flow before view-only"
|
||||
);
|
||||
|
||||
// …is dropped while it is on…
|
||||
session.view_only(true);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewOnly { on: true }), 10)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
session.mouse(20, 20, 1, 0.0, 0.0);
|
||||
session.key("KeyB", true);
|
||||
session.view_only(false); // ordered marker: everything above processed first
|
||||
let between = collect_until_marker(
|
||||
&mut hev_rx,
|
||||
|e| matches!(e, HostEvent::ViewOnly { on: false }),
|
||||
10,
|
||||
)
|
||||
.await;
|
||||
for ev in &between {
|
||||
assert!(
|
||||
!matches!(ev, HostEvent::Mouse { .. } | HostEvent::Key { .. }),
|
||||
"input leaked through host-enforced view-only: {ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// …and flows again after it is off.
|
||||
session.mouse(30, 30, 0, 0.0, 0.0);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(),
|
||||
"input should flow again after view-only off"
|
||||
);
|
||||
|
||||
session.close();
|
||||
drop(frame_tx);
|
||||
let _ = timeout(Duration::from_secs(5), host_task).await;
|
||||
println!("VIEW_ONLY_OK host-enforced input gate on/off mid-session");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4) Wire-level: session.accept advertises the REAL perms; approve mode holds
|
||||
// the session and Accept/Reject finalizes/denies it. Drives the raw
|
||||
// protocol by hand so the actual JSON on the wire is asserted.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A parsed session.challenge from the host.
|
||||
struct Challenge {
|
||||
sid: String,
|
||||
salt: String,
|
||||
iters: u32,
|
||||
nonce: String,
|
||||
pub_b64: String,
|
||||
totp: bool,
|
||||
}
|
||||
|
||||
/// Drive begin → challenge on a raw viewer-end transport; return the challenge.
|
||||
async fn raw_challenge(viewer: &mut MemTransport, host_id: &str) -> Challenge {
|
||||
viewer
|
||||
.send_text(format!(r#"{{"t":"session.begin","id":"{host_id}","peer":"203.0.113.7","user":"tester@example.com"}}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
match timeout(Duration::from_secs(10), viewer.recv()).await {
|
||||
Ok(Some(Ok(TransportMsg::Text(t)))) => {
|
||||
let v: Value = serde_json::from_str(&t).unwrap();
|
||||
if v["t"] == "session.challenge" {
|
||||
return Challenge {
|
||||
sid: v["sid"].as_str().unwrap().to_string(),
|
||||
salt: v["salt"].as_str().unwrap().to_string(),
|
||||
iters: v["iters"].as_u64().unwrap() as u32,
|
||||
nonce: v["nonce"].as_str().unwrap().to_string(),
|
||||
pub_b64: v["pub"].as_str().unwrap_or("").to_string(),
|
||||
totp: v["totp"].as_bool().unwrap_or(false),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => panic!("no session.challenge"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive begin → challenge → auth (plain proof only, legacy viewer). Returns sid.
|
||||
async fn raw_auth(viewer: &mut MemTransport, host_id: &str, password: &str) -> String {
|
||||
let ch = raw_challenge(viewer, host_id).await;
|
||||
let proof = password_proof(password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
viewer
|
||||
.send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid))
|
||||
.await
|
||||
.unwrap();
|
||||
ch.sid
|
||||
}
|
||||
|
||||
async fn next_text(viewer: &mut MemTransport, secs: u64) -> Option<Value> {
|
||||
loop {
|
||||
match timeout(Duration::from_secs(secs), viewer.recv()).await {
|
||||
Ok(Some(Ok(TransportMsg::Text(t)))) => {
|
||||
return serde_json::from_str(&t).ok();
|
||||
}
|
||||
Ok(Some(Ok(TransportMsg::Binary(_)))) => continue,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn accept_advertises_real_perms() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let perms = HostPerms { input: true, clipboard: false, files: true, audio: false };
|
||||
let (hcfg, password) = host_cfg(perms, false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let _host = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
raw_auth(&mut viewer, &host_id, &password).await;
|
||||
let accept = next_text(&mut viewer, 10).await.expect("session.accept");
|
||||
assert_eq!(accept["t"], "session.accept");
|
||||
let p = &accept["info"]["perms"];
|
||||
assert_eq!(p["input"], true, "perms.input must be advertised truthfully");
|
||||
assert_eq!(p["clipboard"], false, "perms.clipboard must be advertised truthfully");
|
||||
assert_eq!(p["files"], true, "perms.files must be advertised truthfully");
|
||||
assert_eq!(p["audio"], false, "perms.audio must be advertised truthfully");
|
||||
// Disabled audio also clears the audio capability flag.
|
||||
assert_eq!(accept["info"]["audio"], false);
|
||||
println!("PERMS_ADVERTISE_OK session.accept carries the enforced perms");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn approve_holds_then_rejects() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), true);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let _host = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let sid = raw_auth(&mut viewer, &host_id, &password).await;
|
||||
|
||||
// The platform gets the approve request with the relay-reported identity…
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10).await {
|
||||
Some(HostEvent::ApproveRequest { sid: s, peer, user }) => {
|
||||
assert_eq!(s, sid);
|
||||
assert_eq!(peer, "203.0.113.7");
|
||||
assert_eq!(user, "tester@example.com");
|
||||
}
|
||||
_ => panic!("no ApproveRequest emitted in approve mode"),
|
||||
}
|
||||
// …and the wire stays SILENT (no session.accept while held).
|
||||
assert!(
|
||||
timeout(Duration::from_millis(900), viewer.recv()).await.is_err(),
|
||||
"session must be held (no accept) until the platform answers"
|
||||
);
|
||||
|
||||
// Reject → session.deny with a clear reason.
|
||||
hctl_tx
|
||||
.send(HostControl::ApproveReply { sid: sid.clone(), accept: false })
|
||||
.unwrap();
|
||||
let deny = next_text(&mut viewer, 10).await.expect("session.deny");
|
||||
assert_eq!(deny["t"], "session.deny");
|
||||
assert_eq!(deny["sid"], sid.as_str());
|
||||
assert_eq!(deny["reason"], "not accepted by host");
|
||||
println!("APPROVE_REJECT_OK held session denied with 'not accepted by host'");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn approve_accept_finalizes_session() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), true);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
let _host = tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let sid = raw_auth(&mut viewer, &host_id, &password).await;
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
hctl_tx
|
||||
.send(HostControl::ApproveReply { sid: sid.clone(), accept: true })
|
||||
.unwrap();
|
||||
let accept = next_text(&mut viewer, 10).await.expect("session.accept");
|
||||
assert_eq!(accept["t"], "session.accept");
|
||||
assert_eq!(accept["sid"], sid.as_str());
|
||||
assert_eq!(accept["info"]["perms"]["input"], true);
|
||||
assert!(
|
||||
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
|
||||
.await
|
||||
.is_some(),
|
||||
"accepting must finalize the session (ViewerConnected)"
|
||||
);
|
||||
println!("APPROVE_ACCEPT_OK held session finalized on Accept");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5) Device-key binding (MITM hardening): a viewer that binds the host's REAL
|
||||
// ephemeral pub is accepted; one that binds a SWAPPED pub (what a malicious
|
||||
// relay would do) is denied "key verification failed". Legacy viewers that
|
||||
// send no proof_bind still connect (covered by the approve_* tests above).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn bound_proof_accepts_matching_key() {
|
||||
// Rebuild host_cfg inline so we keep both host_id and password.
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
assert!(!ch.pub_b64.is_empty(), "host must offer an ECDH pub (E2E)");
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
// Bind the host's REAL pub (what an honest, un-swapped path yields).
|
||||
let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &ch.pub_b64).unwrap();
|
||||
viewer
|
||||
.send_text(format!(
|
||||
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#,
|
||||
ch.sid, ch.pub_b64
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let accept = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(accept["t"], "session.accept", "matching bound proof must be accepted");
|
||||
println!("BIND_OK matching channel-bound proof accepted");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn bound_proof_rejects_swapped_key() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg(HostPerms::default(), false);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
// A malicious relay swapped the host's pub: the viewer binds the ATTACKER's
|
||||
// pub, but the host verifies against its own real pub → mismatch → deny.
|
||||
// (A valid-looking but different P-256 point, base64 of 65 bytes 0x04||…)
|
||||
let swapped = {
|
||||
use netdesk_core::crypto::HostE2e;
|
||||
HostE2e::new().our_pub_b64 // a different, real ECDH pub
|
||||
};
|
||||
assert_ne!(swapped, ch.pub_b64, "the swapped pub must differ from the host's");
|
||||
let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &swapped).unwrap();
|
||||
viewer
|
||||
.send_text(format!(
|
||||
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#,
|
||||
ch.sid, swapped
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let deny = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(deny["t"], "session.deny", "a swapped-key bound proof must be denied");
|
||||
assert_eq!(deny["reason"], "key verification failed");
|
||||
println!("BIND_SWAP_OK relay key-swap detected + denied ('key verification failed')");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6) TOTP 2FA on the native host: the challenge advertises totp:true, a wrong/
|
||||
// missing code is denied "bad 2FA code", and a valid code is accepted.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn totp_required_rejects_missing_and_accepts_valid() {
|
||||
use netdesk_core::crypto::{password_proof, totp_at};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // RFC 6238 test secret
|
||||
|
||||
// --- wrong/missing code is denied ---
|
||||
{
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hc, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
assert!(ch.totp, "challenge must advertise totp:true when a secret is set");
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
// Correct password, NO totp code → bad 2FA.
|
||||
viewer
|
||||
.send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid))
|
||||
.await
|
||||
.unwrap();
|
||||
let deny = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(deny["t"], "session.deny");
|
||||
assert_eq!(deny["reason"], "bad 2FA code");
|
||||
}
|
||||
|
||||
// --- a valid, current code is accepted ---
|
||||
{
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret);
|
||||
let host_id = hcfg.host_id.clone();
|
||||
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, _hev) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (_hc, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
let ch = raw_challenge(&mut viewer, &host_id).await;
|
||||
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
let code = totp_at(secret, now, 30, 6).unwrap();
|
||||
viewer
|
||||
.send_text(format!(
|
||||
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","totp":"{code}"}}"#,
|
||||
ch.sid
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let accept = next_text(&mut viewer, 10).await.expect("reply");
|
||||
assert_eq!(accept["t"], "session.accept", "a valid TOTP code must be accepted");
|
||||
}
|
||||
println!("TOTP_OK native host advertises + enforces TOTP (deny bad, accept valid)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7) TCP tunnel routing (core): tunnel.open -> HostEvent::TunnelOpen; a raw
|
||||
// [0xF3][tid][payload] frame (NOT E2E) -> HostEvent::TunnelData ONLY when
|
||||
// that tid is open; HostControl::TunnelData -> a [0xF3] frame on the wire.
|
||||
// Proves the server-relayed tunnel plumbing + the 0xF3 disambiguation.
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn tunnel_routing_core() {
|
||||
let (mut viewer, host_relay) = mem_relay_pair();
|
||||
let (hcfg, _password) = host_cfg(HostPerms::default(), false);
|
||||
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
|
||||
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
|
||||
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
|
||||
tokio::spawn(async move {
|
||||
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
|
||||
});
|
||||
|
||||
// The server asks the host to open tunnel tid=7 (no video session needed).
|
||||
viewer
|
||||
.send_text(r#"{"t":"tunnel.open","tid":7,"thost":"127.0.0.1","port":5555}"#.into())
|
||||
.await
|
||||
.unwrap();
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelOpen { .. }), 10).await {
|
||||
Some(HostEvent::TunnelOpen { tid, thost, port }) => {
|
||||
assert_eq!((tid, thost.as_str(), port), (7, "127.0.0.1", 5555));
|
||||
}
|
||||
_ => panic!("no TunnelOpen"),
|
||||
}
|
||||
|
||||
// Raw [0xF3][tid=7 LE][payload] -> TunnelData for an OPEN tid.
|
||||
let mut frame = vec![0xF3u8];
|
||||
frame.extend_from_slice(&7u32.to_le_bytes());
|
||||
frame.extend_from_slice(b"hello-tcp");
|
||||
viewer.send_binary(frame).await.unwrap();
|
||||
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelData { .. }), 10).await {
|
||||
Some(HostEvent::TunnelData { tid, data }) => {
|
||||
assert_eq!(tid, 7);
|
||||
assert_eq!(&data, b"hello-tcp");
|
||||
}
|
||||
_ => panic!("no TunnelData for an open tid"),
|
||||
}
|
||||
|
||||
// Outbound: HostControl::TunnelData -> a raw [0xF3][tid][data] on the wire.
|
||||
hctl_tx
|
||||
.send(HostControl::TunnelData { tid: 7, data: b"reply42".to_vec() })
|
||||
.unwrap();
|
||||
loop {
|
||||
match timeout(Duration::from_secs(10), viewer.recv()).await {
|
||||
Ok(Some(Ok(TransportMsg::Binary(b)))) => {
|
||||
assert_eq!(b[0], 0xF3);
|
||||
assert_eq!(u32::from_le_bytes([b[1], b[2], b[3], b[4]]), 7);
|
||||
assert_eq!(&b[5..], b"reply42");
|
||||
break;
|
||||
}
|
||||
Ok(Some(Ok(TransportMsg::Text(_)))) => continue,
|
||||
_ => panic!("no outbound tunnel frame"),
|
||||
}
|
||||
}
|
||||
println!("TUNNEL_CORE_OK tunnel.open + [0xF3] route in/out, tid-gated");
|
||||
}
|
||||
Reference in new issue
Block a user