NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+149
View File
@@ -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
+739
View File
@@ -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");
}