feat(agent): v2 secure-session-core Task 6 - full key fidelity
All checks were successful
All checks were successful
SPEC-002 Phase 1 Task 6, code-reviewed APPROVED (2 rounds), locally verified (cargo fmt + clippy -D warnings exit 0 + cargo test --workspace 70 pass + build). - Viewer WH_KEYBOARD_LL hook diverts system combos (Win/Win+R, Alt+Tab, Alt+Esc, Ctrl+Esc) to the remote as a full KeyEvent (vk + scan + is_extended + modifiers) and suppresses local handling - GATED on the viewer window having focus AND a "send system keys" toggle (default on; Pause/Break host-key), so it never bricks the technician's local keyboard when unfocused. - Agent injection via SendInput KEYEVENTF_SCANCODE + correct KEYEVENTF_EXTENDEDKEY (right Ctrl/Alt, arrows, nav, Win, NumLock, numpad Divide) - layout-independent, extended-key-correct. - Ctrl+Alt+Del completes through the SAS helper (SYSTEM SendSAS); installer sets the SoftwareSASGeneration policy; 3-tier fail-loud (no false success). SAS named pipe DACL tightened from NULL/Everyone to Authenticated Users. - Modifier hygiene: viewer emits key-ups for held Ctrl/Alt/Shift/Win on focus loss / close so modifiers never stick on the remote. - proto: KeyEvent.is_extended = 7 (additive; older agents derive the flag). Closes Win+R / Ctrl+C-V / Ctrl+Alt+Del / arrows-vs-numpad fidelity. Live on-device testing is plan Task 8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,24 @@
|
||||
//! Low-level keyboard hook for capturing all keys including Win key
|
||||
//! Low-level keyboard hook for capturing system key combinations.
|
||||
//!
|
||||
//! `WH_KEYBOARD_LL` is a GLOBAL hook: the OS invokes it for ALL desktop input regardless
|
||||
//! of which window is focused. We therefore gate diversion on the viewer's focus state.
|
||||
//! ONLY when the viewer window actually has focus AND "send system keys to remote" is
|
||||
//! enabled does the hook DIVERT the system combinations the local shell would otherwise
|
||||
//! consume — the Windows key, Win+R, Win+E, Alt+Tab, Ctrl+Esc, Alt+Esc — and forward them
|
||||
//! to the remote as full-fidelity `KeyEvent`s (virtual key + hardware scan code +
|
||||
//! extended-key flag + modifier snapshot), returning 1 from the hook proc to suppress the
|
||||
//! local handling. All other keys flow through the normal viewer input path.
|
||||
//!
|
||||
//! When the toggle is OFF, the viewer is not focused, or the key is not a system combo,
|
||||
//! the hook diverts NOTHING — it falls through to `CallNextHookEx` and every key reaches
|
||||
//! the local OS unchanged. This keeps the technician's own Start menu / Alt+Tab working
|
||||
//! while the viewer sits unfocused in the background.
|
||||
|
||||
use super::InputEvent;
|
||||
#[cfg(windows)]
|
||||
use crate::proto;
|
||||
use anyhow::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
#[cfg(windows)]
|
||||
use tracing::trace;
|
||||
@@ -13,37 +28,83 @@ use windows::{
|
||||
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage,
|
||||
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE, WH_KEYBOARD_LL, WM_KEYDOWN,
|
||||
WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, PM_REMOVE,
|
||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Global toggle: when `true`, system key combinations are diverted to the remote;
|
||||
/// when `false`, the hook is transparent and the local OS handles them. Default ON.
|
||||
///
|
||||
/// Lives at module scope because the `WH_KEYBOARD_LL` callback is a bare `extern "system"`
|
||||
/// function with no user context pointer, so its state must be reachable statically.
|
||||
static SEND_SYSTEM_KEYS: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Set whether system key combinations are forwarded to the remote (vs. handled locally).
|
||||
///
|
||||
/// Part of the programmatic toggle API (alongside `toggle_send_system_keys`, which the
|
||||
/// Pause/Break host key drives). Retained for a future viewer menu / tray item and used
|
||||
/// by the unit tests; not yet called from non-test code, hence the allow.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_send_system_keys(enabled: bool) {
|
||||
SEND_SYSTEM_KEYS.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Flip the "send system keys to remote" toggle and return the new value.
|
||||
pub fn toggle_send_system_keys() -> bool {
|
||||
// fetch_xor(true) flips the bit and returns the PREVIOUS value; invert for the new one.
|
||||
!SEND_SYSTEM_KEYS.fetch_xor(true, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Current state of the "send system keys to remote" toggle.
|
||||
///
|
||||
/// Part of the programmatic toggle API; used by the unit tests and available for a
|
||||
/// viewer menu / status indicator. Not yet read from non-test code, hence the allow.
|
||||
#[allow(dead_code)]
|
||||
pub fn send_system_keys_enabled() -> bool {
|
||||
SEND_SYSTEM_KEYS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the viewer window currently has input focus. Default `false`.
|
||||
///
|
||||
/// `WH_KEYBOARD_LL` is a GLOBAL hook fired for all desktop input, so it must NOT divert
|
||||
/// system combos while the viewer is unfocused — otherwise the technician's own local
|
||||
/// Win key / Alt+Tab / Ctrl+Esc would be suppressed and pushed to the remote. The render
|
||||
/// loop updates this on `WindowEvent::Focused`. Lives at module scope for the same reason
|
||||
/// as `SEND_SYSTEM_KEYS`: the bare `extern "system"` callback has no user-context pointer.
|
||||
static VIEWER_FOCUSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Record whether the viewer window has input focus (drives the hook's focus gate).
|
||||
pub fn set_viewer_focused(focused: bool) {
|
||||
VIEWER_FOCUSED.store(focused, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Current focus state as seen by the keyboard hook.
|
||||
///
|
||||
/// Used by the unit tests and available for diagnostics; not yet read from non-test code
|
||||
/// beyond the hook callback itself, hence the allow.
|
||||
#[allow(dead_code)]
|
||||
pub fn viewer_focused() -> bool {
|
||||
VIEWER_FOCUSED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
|
||||
|
||||
/// Virtual key codes for special keys
|
||||
// Several entries are reserved for upcoming special-key fidelity work.
|
||||
/// Virtual key codes for keys the hook reasons about.
|
||||
#[cfg(windows)]
|
||||
#[allow(dead_code)]
|
||||
mod vk {
|
||||
pub const VK_LWIN: u32 = 0x5B;
|
||||
pub const VK_RWIN: u32 = 0x5C;
|
||||
pub const VK_APPS: u32 = 0x5D;
|
||||
pub const VK_LSHIFT: u32 = 0xA0;
|
||||
pub const VK_RSHIFT: u32 = 0xA1;
|
||||
pub const VK_LCONTROL: u32 = 0xA2;
|
||||
pub const VK_RCONTROL: u32 = 0xA3;
|
||||
pub const VK_LMENU: u32 = 0xA4; // Left Alt
|
||||
pub const VK_RMENU: u32 = 0xA5; // Right Alt
|
||||
pub const VK_TAB: u32 = 0x09;
|
||||
pub const VK_ESCAPE: u32 = 0x1B;
|
||||
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -54,10 +115,10 @@ pub struct KeyboardHook {
|
||||
#[cfg(windows)]
|
||||
impl KeyboardHook {
|
||||
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||
// Store the sender globally for the hook callback
|
||||
INPUT_TX
|
||||
.set(input_tx)
|
||||
.map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
||||
// Store the sender globally for the hook callback. If it was already set (e.g.
|
||||
// a previous viewer instance in the same process), reuse the existing one rather
|
||||
// than failing — the hook handle itself is what we re-install.
|
||||
let _ = INPUT_TX.set(input_tx);
|
||||
|
||||
unsafe {
|
||||
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
|
||||
@@ -80,42 +141,78 @@ impl Drop for KeyboardHook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether a key event is a SYSTEM combination we must divert to the remote.
|
||||
///
|
||||
/// `vk_code` is the key; `alt`/`ctrl` are the modifier state at the moment of the event
|
||||
/// (from `GetAsyncKeyState`). The Windows-key combos (Win, Win+R, Win+E) are recognized
|
||||
/// by matching the Win keys themselves, so the held-Win state is not needed here. Pure
|
||||
/// functions like this keep the (untestable) hook callback thin and unit-testable.
|
||||
#[cfg(windows)]
|
||||
fn is_system_combo(vk_code: u32, alt: bool, ctrl: bool) -> bool {
|
||||
match vk_code {
|
||||
// The Windows keys and the Applications (context-menu) key: always divert so the
|
||||
// local Start menu / Win+R / Win+E / Win+E etc. do not fire. With Win forwarded
|
||||
// down to the remote, subsequent letters (R, E, ...) compose there naturally.
|
||||
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS => true,
|
||||
// Alt+Tab and Alt+Esc — the local window-switcher would otherwise eat these.
|
||||
vk::VK_TAB if alt => true,
|
||||
vk::VK_ESCAPE if alt => true,
|
||||
// Ctrl+Esc opens the local Start menu; divert it.
|
||||
vk::VK_ESCAPE if ctrl => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code >= 0 {
|
||||
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
let vk_code = kb_struct.vkCode;
|
||||
let scan_code = kb_struct.scanCode;
|
||||
// LLKHF_EXTENDED (bit 0) marks extended keys (right Ctrl/Alt, arrows, etc.).
|
||||
let is_extended = (kb_struct.flags.0 & LLKHF_EXTENDED.0) != 0;
|
||||
|
||||
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
|
||||
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
|
||||
|
||||
if is_down || is_up {
|
||||
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
||||
let should_intercept = matches!(vk_code, vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS);
|
||||
let forwarding = SEND_SYSTEM_KEYS.load(Ordering::Relaxed);
|
||||
let focused = VIEWER_FOCUSED.load(Ordering::Relaxed);
|
||||
let modifiers = current_modifiers();
|
||||
|
||||
// Send the key event to the remote
|
||||
if let Some(tx) = INPUT_TX.get() {
|
||||
let event = proto::KeyEvent {
|
||||
down: is_down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code,
|
||||
unicode: String::new(),
|
||||
modifiers: Some(get_current_modifiers()),
|
||||
};
|
||||
// Divert ONLY a SYSTEM combo, ONLY while forwarding is enabled, and ONLY while
|
||||
// the viewer window has focus. This is a global hook, so without the focus gate
|
||||
// we would swallow the technician's own Win/Alt+Tab/Ctrl+Esc while the viewer
|
||||
// sits unfocused in the background. When any condition is false we fall through
|
||||
// to CallNextHookEx and suppress nothing — the local OS handles the key. Ordinary
|
||||
// keys are left to the normal winit viewer input path (they are NOT forwarded
|
||||
// here to avoid double-injection).
|
||||
let divert =
|
||||
forwarding && focused && is_system_combo(vk_code, modifiers.alt, modifiers.ctrl);
|
||||
|
||||
let _ = tx.try_send(InputEvent::Key(event));
|
||||
trace!(
|
||||
"Key hook: vk={:#x} scan={} down={}",
|
||||
vk_code,
|
||||
scan_code,
|
||||
is_down
|
||||
);
|
||||
}
|
||||
if divert {
|
||||
if let Some(tx) = INPUT_TX.get() {
|
||||
let event = proto::KeyEvent {
|
||||
down: is_down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code,
|
||||
unicode: String::new(),
|
||||
is_extended,
|
||||
modifiers: Some(modifiers),
|
||||
};
|
||||
|
||||
// For Win key, consume the event so it doesn't open Start menu locally
|
||||
if should_intercept {
|
||||
let _ = tx.try_send(InputEvent::Key(event));
|
||||
trace!(
|
||||
"System-key hook diverted: vk={:#x} scan={} ext={} down={}",
|
||||
vk_code,
|
||||
scan_code,
|
||||
is_extended,
|
||||
is_down
|
||||
);
|
||||
}
|
||||
|
||||
// Suppress local handling of the diverted system combo.
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
@@ -125,7 +222,7 @@ unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam:
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_current_modifiers() -> proto::Modifiers {
|
||||
fn current_modifiers() -> proto::Modifiers {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||
|
||||
unsafe {
|
||||
@@ -140,7 +237,7 @@ fn get_current_modifiers() -> proto::Modifiers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump Windows message queue (required for hooks to work)
|
||||
/// Pump Windows message queue (required for hooks to work).
|
||||
#[cfg(windows)]
|
||||
pub fn pump_messages() {
|
||||
unsafe {
|
||||
@@ -168,3 +265,74 @@ impl KeyboardHook {
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn pump_messages() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toggle_defaults_on_and_flips() {
|
||||
// Default is ON.
|
||||
set_send_system_keys(true);
|
||||
assert!(send_system_keys_enabled());
|
||||
|
||||
// Toggling flips and returns the NEW value.
|
||||
assert!(!toggle_send_system_keys());
|
||||
assert!(!send_system_keys_enabled());
|
||||
assert!(toggle_send_system_keys());
|
||||
assert!(send_system_keys_enabled());
|
||||
|
||||
// Explicit set wins.
|
||||
set_send_system_keys(false);
|
||||
assert!(!send_system_keys_enabled());
|
||||
set_send_system_keys(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_focus_flag_defaults_off_and_tracks() {
|
||||
// The hook starts gated CLOSED (unfocused) so a background viewer never swallows
|
||||
// the technician's local system keys until it actually gains focus.
|
||||
set_viewer_focused(false);
|
||||
assert!(!viewer_focused());
|
||||
|
||||
set_viewer_focused(true);
|
||||
assert!(viewer_focused());
|
||||
|
||||
set_viewer_focused(false);
|
||||
assert!(!viewer_focused());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn win_keys_always_divert() {
|
||||
// Win / Apps keys divert regardless of modifier state.
|
||||
assert!(is_system_combo(vk::VK_LWIN, false, false));
|
||||
assert!(is_system_combo(vk::VK_RWIN, false, false));
|
||||
assert!(is_system_combo(vk::VK_APPS, false, false));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn alt_tab_and_alt_esc_divert_only_with_alt() {
|
||||
assert!(is_system_combo(vk::VK_TAB, true, false)); // Alt+Tab
|
||||
assert!(!is_system_combo(vk::VK_TAB, false, false)); // plain Tab -> local path
|
||||
assert!(is_system_combo(vk::VK_ESCAPE, true, false)); // Alt+Esc
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn ctrl_esc_diverts_only_with_ctrl() {
|
||||
assert!(is_system_combo(vk::VK_ESCAPE, false, true)); // Ctrl+Esc
|
||||
assert!(!is_system_combo(vk::VK_ESCAPE, false, false)); // plain Esc -> local path
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn ordinary_keys_never_divert() {
|
||||
// 'R' is NOT itself a "system combo" — Win was already diverted (and forwarded
|
||||
// down), so R flows through the normal viewer path and composes Win+R on the remote.
|
||||
assert!(!is_system_combo(0x52, false, false)); // 'R'
|
||||
assert!(!is_system_combo(0x41, false, false)); // 'A'
|
||||
assert!(!is_system_combo(vk::VK_TAB, false, true)); // Ctrl+Tab is app-level, not a shell combo
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user