diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 8a0258c..df6f666 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -429,3 +429,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 真实 AppContainer 双进程探针验证:本轮许可作废后约 56.90 ms、保险库锁定后约 47.80 ms、签发器释放后约 55.45 ms 观察到进程树清空;之后新工具调用均被拒绝。两秒测试租约自然到期也主动清空进程树。创建前撤销和创建后恢复前撤销均拒绝继续,无普通子进程降级。 - 原生参数/环境探针已使用 prepare → create_suspended → 带租约恢复路径。52 项扩展回归通过,4 项 ignored 为三个父测试实际驱动的 Job 辅助入口及单独执行的 60 秒验收;全目标 Clippy -D warnings 通过。日志 `.build/extension-revocation-tests.log`、`.build/extension-revocation-clippy.log`。 - 本轮锁定由真实 CredentialBroker.lock 触发,尚不是操作系统锁屏事件的桌面端到端测试。监视器提供有界撤销,并非对核验与 ResumeThread 之间竞态作“零指令执行”证明。Host 仍需把实际 Vault 切换、权限撤回、在线签名撤销等事件接入签发器/实例注册表,并完成 broker 与全部资源策略,整体生产化尚未完成。 + + +## 增量:Host Vault 与会话锁定事件接入撤销代次 + +- Host 增加持久于应用生命周期的 execution Authority;选择或打开不同 Vault、关闭/撤销 Vault 时推进其撤销代次,再替换工作区。重复打开当前 Vault 的既有早返回路径保持不变,打开新 Vault 失败也不会替换现有工作区;撤销请求在持久记录失败时仍先让旧执行许可失效。 +- 手动 credentials_lock 先原子撤销 Host 执行许可和凭据会话,再等待 CredentialBroker 互斥锁进行数据清理。凭据原子信号在初始化时缓存于 OnceLock,撤销不再被正在执行的解锁/KDF 互斥锁阻塞。 +- Windows SessionMonitor 支持有界多域信号组,锁屏、注销、控制台/远程断开同时撤销凭据与 Host execution Authority;解锁消息不会恢复原代次。线程创建失败返回稳定不可用错误,拒绝重复信号或超过 8 个域的配置。 +- 8 项 Host 二进制测试、2 项原生会话消息测试、3 项许可测试通过,全目标 Clippy -D warnings 通过。验证在持有凭据互斥锁时手动锁定信号已发出、工作区替换/关闭推进代次,以及不轮换 HMAC 密钥只推进事件代次也能拒绝旧许可/租约。日志 `.build/extension-host-lifecycle-tests.log`、`.build/extension-host-session-tests.log`、`.build/extension-host-permit-tests.log`、`.build/extension-host-events-clippy.log`。 +- 会话测试仅向本测试的隐藏窗口注入原生消息,未锁定用户桌面;尚未完成实际 OS 锁屏与已打包 Host/扩展实例的组合验收。第三方执行仍未开放,后续实例签发必须使用 Host 持有的 Authority,实际在线信任撤销、实例注册/派发、broker 与完整资源策略继续实施。 diff --git a/frontend/src-tauri/src/extension_permit.rs b/frontend/src-tauri/src/extension_permit.rs index a1fe2a0..bb1f32e 100644 --- a/frontend/src-tauri/src/extension_permit.rs +++ b/frontend/src-tauri/src/extension_permit.rs @@ -198,6 +198,13 @@ impl Claims { } } impl Authority { + /// Host event wiring only; never expose this signal through IPC. + pub fn revocation_signal(&self) -> Arc { + Arc::clone(&self.generation) + } + pub fn revoke(&self) { + self.generation.fetch_add(1, Ordering::SeqCst); + } /// Call only after consent and live trust validation. This authenticates the /// decision; it does not establish sandbox availability or grant broker access. pub fn issue(&self, claims: &Claims, now_ms: u64) -> Result { @@ -246,7 +253,7 @@ impl Authority { /// Lock/logout/policy invalidation may discard all permits. Restart creates a /// fresh key, so an old process token cannot silently revive authorization. pub fn invalidate_all(&mut self) { - self.generation.fetch_add(1, Ordering::SeqCst); + self.revoke(); self.key.zeroize(); rand::rngs::OsRng.fill_bytes(&mut self.key); } @@ -333,4 +340,24 @@ mod tests { c.source = "http://catalog.example".into(); assert!(authority.issue(&c, 100).is_err()); } + #[test] + fn external_host_signal_invalidates_mac_and_lease_without_key_rotation() { + let authority = Authority::default(); + let claims = claims(); + let old = authority.issue(&claims, 1).unwrap(); + let lease = authority.lease(&old, &claims, 1).unwrap(); + authority.revocation_signal().fetch_add(1, Ordering::SeqCst); + assert_eq!( + authority.verify(&old, &claims, 2).unwrap_err().code, + "EXTENSION_PERMIT_REVOKED" + ); + assert_eq!(lease.check().unwrap_err().code, "EXTENSION_PERMIT_REVOKED"); + let fresh = authority.issue(&claims, 2).unwrap(); + authority.verify(&fresh, &claims, 3).unwrap(); + authority.revoke(); + assert_eq!( + authority.verify(&fresh, &claims, 3).unwrap_err().code, + "EXTENSION_PERMIT_REVOKED" + ); + } } diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index cde2cb3..6e65564 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -29,6 +29,8 @@ struct Host { extensions: Arc>>, extension_reviews: extension_commands::Reviews, extension_requests: Requests, + extension_authority: notesagent_host::extension_permit::Authority, + credential_signal: std::sync::OnceLock>, sync: Arc, workspace: Arc>>, recent: Mutex>, @@ -39,6 +41,28 @@ struct Host { streams: Arc>>>, } +impl Host { + fn replace_workspace(&self, active: &mut Option, next: Option) { + self.extension_authority.revoke(); + self.sync.cancel(); + *active = next; + } + fn lock_credentials(&self) -> Result<(), String> { + // These do not wait for an in-flight unlock/KDF or credential operation. + self.extension_authority.revoke(); + if let Some(signal) = self.credential_signal.get() { + signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + self.credentials + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .lock(); + Ok(()) + } +} + fn info(ws: &Workspace) -> RecentVault { RecentVault { vault_id: ws.vault_id.clone(), @@ -433,13 +457,7 @@ async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<( #[tauri::command] fn credentials_lock(host: State<'_, Host>) -> Result<(), String> { - host.credentials - .lock() - .map_err(|_| "HOST_BUSY")? - .as_mut() - .ok_or("HOST_NOT_READY")? - .lock(); - Ok(()) + host.lock_credentials() } #[tauri::command] @@ -580,8 +598,7 @@ fn workspace_choose(host: State<'_, Host>) -> Result, String .as_mut() .ok_or("HOST_NOT_READY")? .remember(&result)?; - host.sync.cancel(); - *guard = Some(workspace); + host.replace_workspace(&mut guard, Some(workspace)); Ok(Some(result)) } @@ -605,8 +622,7 @@ fn workspace_open(host: State<'_, Host>, path: String) -> Result) -> Result, String> { #[tauri::command] fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> { let mut workspace = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + host.extension_authority.revoke(); if let Some(active) = workspace.as_ref() { host.recent .lock() @@ -631,8 +648,7 @@ fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> { .ok_or("HOST_NOT_READY")? .revoke(&active.root)?; } - host.sync.cancel(); - *workspace = None; + host.replace_workspace(&mut workspace, None); Ok(()) } @@ -729,19 +745,27 @@ fn main() { .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new( app.path().app_data_dir()?.join("credentials/stronghold.v1"), )); + let signal = credential_state + .lock() + .map_err(|_| std::io::Error::other("HOST_BUSY"))? + .as_ref() + .ok_or_else(|| std::io::Error::other("HOST_NOT_READY"))? + .lock_signal(); + app.state::() + .credential_signal + .set(signal.clone()) + .map_err(|_| std::io::Error::other("HOST_ALREADY_INITIALIZED"))?; #[cfg(windows)] { - let signal = credential_state - .lock() - .map_err(|_| std::io::Error::other("HOST_BUSY"))? - .as_ref() - .ok_or_else(|| std::io::Error::other("HOST_NOT_READY"))? - .lock_signal(); *app.state::() .session_monitor .lock() .map_err(|_| std::io::Error::other("HOST_BUSY"))? = - notesagent_host::session_lock::SessionMonitor::start(signal).ok(); + notesagent_host::session_lock::SessionMonitor::start_many(vec![ + signal, + app.state::().extension_authority.revocation_signal(), + ]) + .ok(); } let weak_credentials = Arc::downgrade(&credential_state); std::thread::spawn(move || { @@ -908,3 +932,52 @@ fn main() { } }); } + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use std::sync::atomic::Ordering; + #[test] + fn workspace_replacement_and_close_revoke_host_execution_generation() { + let host = Host::default(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let signal = host.extension_authority.revocation_signal(); + let mut active = host.workspace.lock().unwrap(); + host.replace_workspace(&mut active, Some(Workspace::open(first.path()).unwrap())); + let initial = signal.load(Ordering::SeqCst); + host.replace_workspace(&mut active, Some(Workspace::open(second.path()).unwrap())); + assert!(signal.load(Ordering::SeqCst) > initial); + let changed = signal.load(Ordering::SeqCst); + host.replace_workspace(&mut active, None); + assert!(active.is_none()); + assert!(signal.load(Ordering::SeqCst) > changed); + } + #[test] + fn manual_lock_revokes_before_waiting_for_credential_mutex() { + let host = Host::default(); + let temp = tempfile::tempdir().unwrap(); + let broker = CredentialBroker::new(temp.path().join("credentials.v1")); + let credentials = broker.lock_signal(); + host.credential_signal.set(credentials.clone()).unwrap(); + *host.credentials.lock().unwrap() = Some(broker); + let extension = host.extension_authority.revocation_signal(); + let held = host.credentials.lock().unwrap(); + std::thread::scope(|scope| { + let worker = scope.spawn(|| host.lock_credentials()); + let start = std::time::Instant::now(); + while credentials.load(Ordering::SeqCst) == 0 + && start.elapsed() < Duration::from_secs(2) + { + std::thread::sleep(Duration::from_millis(1)); + } + let observed = credentials.load(Ordering::SeqCst); + let revoked = extension.load(Ordering::SeqCst); + // Release before asserting, so an assertion cannot deadlock scope join. + drop(held); + worker.join().unwrap().unwrap(); + assert!(observed > 0); + assert!(revoked > 0); + }); + } +} diff --git a/frontend/src-tauri/src/session_lock.rs b/frontend/src-tauri/src/session_lock.rs index 2468e9b..1d9a960 100644 --- a/frontend/src-tauri/src/session_lock.rs +++ b/frontend/src-tauri/src/session_lock.rs @@ -11,7 +11,7 @@ use windows_sys::Win32::{ UI::WindowsAndMessaging::*, }; -thread_local! { static SIGNAL: RefCell>> = const { RefCell::new(None) }; } +thread_local! { static SIGNALS: RefCell>> = const { RefCell::new(Vec::new()) }; } unsafe extern "system" fn window_proc( hwnd: HWND, @@ -25,8 +25,8 @@ unsafe extern "system" fn window_proc( WTS_SESSION_LOCK | WTS_SESSION_LOGOFF | WTS_CONSOLE_DISCONNECT | WTS_REMOTE_DISCONNECT ) { - SIGNAL.with(|s| { - if let Some(signal) = s.borrow().as_ref() { + SIGNALS.with(|s| { + for signal in s.borrow().iter() { signal.fetch_add(1, Ordering::SeqCst); } }); @@ -45,58 +45,73 @@ pub struct SessionMonitor { } impl SessionMonitor { pub fn start(signal: Arc) -> Result { + Self::start_many(vec![signal]) + } + pub fn start_many(signals: Vec>) -> Result { + if signals.is_empty() + || signals.len() > 8 + || signals + .iter() + .enumerate() + .any(|(i, signal)| signals[..i].iter().any(|other| Arc::ptr_eq(signal, other))) + { + return Err("SESSION_MONITOR_UNAVAILABLE".into()); + } let (tx, rx) = std::sync::mpsc::sync_channel(1); - let thread = std::thread::spawn(move || unsafe { - SIGNAL.with(|s| *s.borrow_mut() = Some(signal)); - let class: Vec = format!("OpenNexusSession-{}\0", uuid::Uuid::new_v4()) - .encode_utf16() - .collect(); - let module = GetModuleHandleW(std::ptr::null()); - let descriptor = WNDCLASSW { - lpfnWndProc: Some(window_proc), - hInstance: module, - lpszClassName: class.as_ptr(), - ..std::mem::zeroed() - }; - if RegisterClassW(&descriptor) == 0 { - let _ = tx.send(Err("SESSION_MONITOR_UNAVAILABLE".to_string())); - return; - } - let window = CreateWindowExW( - 0, - class.as_ptr(), - class.as_ptr(), - 0, - 0, - 0, - 0, - 0, - std::ptr::null_mut(), - std::ptr::null_mut(), - module, - std::ptr::null(), - ); - if window.is_null() - || WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION) == 0 - { - if !window.is_null() { + let thread = std::thread::Builder::new() + .name("host-session-monitor".into()) + .spawn(move || unsafe { + SIGNALS.with(|s| *s.borrow_mut() = signals); + let class: Vec = format!("OpenNexusSession-{}\0", uuid::Uuid::new_v4()) + .encode_utf16() + .collect(); + let module = GetModuleHandleW(std::ptr::null()); + let descriptor = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: module, + lpszClassName: class.as_ptr(), + ..std::mem::zeroed() + }; + if RegisterClassW(&descriptor) == 0 { + let _ = tx.send(Err("SESSION_MONITOR_UNAVAILABLE".to_string())); + return; + } + let window = CreateWindowExW( + 0, + class.as_ptr(), + class.as_ptr(), + 0, + 0, + 0, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + module, + std::ptr::null(), + ); + if window.is_null() + || WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION) == 0 + { + if !window.is_null() { + DestroyWindow(window); + } + UnregisterClassW(class.as_ptr(), module); + let _ = tx.send(Err("SESSION_MONITOR_UNAVAILABLE".to_string())); + return; + } + if tx.send(Ok(window as usize)).is_err() { DestroyWindow(window); + } else { + let mut message: MSG = std::mem::zeroed(); + while GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&message); + DispatchMessageW(&message); + } } UnregisterClassW(class.as_ptr(), module); - let _ = tx.send(Err("SESSION_MONITOR_UNAVAILABLE".to_string())); - return; - } - if tx.send(Ok(window as usize)).is_err() { - DestroyWindow(window); - } else { - let mut message: MSG = std::mem::zeroed(); - while GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) > 0 { - TranslateMessage(&message); - DispatchMessageW(&message); - } - } - UnregisterClassW(class.as_ptr(), module); - }); + }) + .map_err(|_| "SESSION_MONITOR_UNAVAILABLE".to_string())?; match rx .recv() .map_err(|_| "SESSION_MONITOR_UNAVAILABLE".to_string())? @@ -150,4 +165,42 @@ mod tests { } assert_eq!(signal.load(Ordering::SeqCst), 1); } + #[test] + fn native_lock_logoff_and_disconnect_revoke_every_bound_domain() { + let credential = Arc::new(AtomicU64::new(0)); + let extension = Arc::new(AtomicU64::new(0)); + assert!(SessionMonitor::start_many(vec![credential.clone(), credential.clone()]).is_err()); + let monitor = + SessionMonitor::start_many(vec![credential.clone(), extension.clone()]).unwrap(); + for (index, event) in [ + WTS_SESSION_LOCK, + WTS_SESSION_LOGOFF, + WTS_CONSOLE_DISCONNECT, + WTS_REMOTE_DISCONNECT, + ] + .into_iter() + .enumerate() + { + unsafe { + SendMessageW( + monitor.window as HWND, + WM_WTSSESSION_CHANGE, + event as usize, + 0, + ); + } + assert_eq!(credential.load(Ordering::SeqCst), index as u64 + 1); + assert_eq!(extension.load(Ordering::SeqCst), index as u64 + 1); + } + unsafe { + SendMessageW( + monitor.window as HWND, + WM_WTSSESSION_CHANGE, + WTS_SESSION_UNLOCK as usize, + 0, + ); + } + assert_eq!(credential.load(Ordering::SeqCst), 4); + assert_eq!(extension.load(Ordering::SeqCst), 4); + } }