feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
14 changed files with 564 additions and 56 deletions
Showing only changes of commit 3439d1fe3f - Show all commits
+4
View File
@@ -27,6 +27,10 @@
凭据保险库解锁前取得独立 `.lock` 文件的 OS 排他锁,持有至手动锁定、错误后锁定或实例销毁。其他实例返回 CREDENTIALS_BUSY,不读取旧快照后继续覆盖写入;错误口令释放临时锁,进程退出由 OS 释放。Windows 禁止在持锁期间替换该锁文件。
Windows 使用独立隐藏窗口注册 WTS 会话通知,锁屏、注销及本地/远程会话断开立即增加凭据撤销代际。新解析和新修改检查该代际,不等待解锁 KDF 的 Mutex;解锁过程中发生撤销则解锁失败。后台清理加密会话,系统解锁不会自动解锁凭据库。通知注册失败时凭据解锁返回 SESSION_MONITOR_UNAVAILABLE,但本地笔记继续可用。设置页每 500 ms 刷新真实锁定状态。
`credentials_backup` 不接受路径参数,以原生保存窗口选择新的 `.onxcred` 文件,只导出加密快照且拒绝覆盖已有文件。`credentials_restore(password)` 通过原生文件选择和原生确认对话框恢复,要求当前保险库已锁定。先验证口令及每条凭据,再保留恢复前的加密文件并原子替换;失败不覆盖现有库,成功后仍保持锁定。口令对应备份创建时的口令,不能通过备份绕过口令遗失。两项命令均不返回秘密或文件内容。
## 写入及恢复
OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vault_mutation` 使用同一 OS 文件锁;发现 `.ainote/host.sqlite3` 后拒绝 Web 写入,不自动降级。已有个人 Vault 不会被测试读取或迁移。
@@ -4,6 +4,12 @@
**状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 AE 的完整验收。
## 持续实施增量
- 已修复多实例凭据覆盖与桌面请求取消/超时,证据见[全量回归](OpenNexus验收修复与回归-2026-09-08.md)。
- 新增加密凭据备份、原生确认恢复、恢复前留存和逐条完整性验证。Windows 新增 WTS 锁屏/注销/断开通知,原子撤销解析资格;解锁不会随系统返回而自动恢复。设置页同步锁定状态,提供备份和恢复入口。
- 备份恢复、错误口令保护、锁屏后解析/写入拒绝、原生窗口通知测试通过;后者向专用测试窗口注入消息,不等于真实锁屏时延验收。前端两项交互测试、类型检查、Rust desktop 全目标 Clippy 通过。凭据旧库确认清除和迁移故障矩阵仍待补齐。
## 已实现的链路
- Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。
+1
View File
@@ -2943,6 +2943,7 @@ dependencies = [
"tempfile",
"tokio",
"uuid",
"windows-sys 0.61.2",
"zeroize",
]
+3
View File
@@ -38,6 +38,9 @@ argon2 = "0.5"
chacha20poly1305 = "0.10"
fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
[build-dependencies]
tauri-build = { version = "2", optional = true , features = [] }
+2
View File
@@ -21,6 +21,8 @@ fn main() {
"credentials_lock",
"credentials_change_password",
"credentials_import",
"credentials_backup",
"credentials_restore",
"core_request",
"core_request_prepare",
"core_request_cancel",
@@ -13,6 +13,8 @@
"allow-credentials-lock",
"allow-credentials-change-password",
"allow-credentials-import",
"allow-credentials-backup",
"allow-credentials-restore",
"allow-core-request",
"allow-core-request-prepare",
"allow-core-request-cancel",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-backup"
description = "Enables the credentials_backup command without any pre-configured scope."
commands.allow = ["credentials_backup"]
[[permission]]
identifier = "deny-credentials-backup"
description = "Denies the credentials_backup command without any pre-configured scope."
commands.deny = ["credentials_backup"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-restore"
description = "Enables the credentials_restore command without any pre-configured scope."
commands.allow = ["credentials_restore"]
[[permission]]
identifier = "deny-credentials-restore"
description = "Denies the credentials_restore command without any pre-configured scope."
commands.deny = ["credentials_restore"]
+207 -54
View File
@@ -15,6 +15,10 @@ use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use zeroize::Zeroizing;
type Result<T> = std::result::Result<T, String>;
@@ -204,6 +208,8 @@ pub struct CredentialBroker {
// Separate stable inode: snapshots are atomically replaced, so locking the
// snapshot itself would not protect the next writer after replacement.
ownership: Option<fs::File>,
lock_epoch: Arc<AtomicU64>,
unlocked_epoch: u64,
}
impl CredentialBroker {
@@ -268,7 +274,7 @@ impl CredentialBroker {
.ok_or("MIGRATION_KEY_MISSING")?;
let fernet =
Zeroizing::new(fernet::Fernet::new(key.trim()).ok_or("MIGRATION_KEY_INVALID")?);
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let session = self.session()?;
let mut decoded = Vec::new();
for (id, token) in &tokens {
let key = CredentialId::legacy(id).key()?;
@@ -381,7 +387,7 @@ impl CredentialBroker {
let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?;
let params = &request["params"];
if method == "credentials.delete_many" || method == "credentials.move_many" {
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let session = self.session()?;
let result = (|| {
let mut removed = Vec::new();
if method.ends_with("delete_many") {
@@ -483,10 +489,21 @@ impl CredentialBroker {
path,
unlocked: None,
ownership: None,
lock_epoch: Arc::new(AtomicU64::new(0)),
unlocked_epoch: 0,
}
}
pub fn is_locked(&self) -> bool {
self.unlocked.is_none()
self.unlocked.is_none() || self.lock_epoch.load(Ordering::SeqCst) != self.unlocked_epoch
}
pub fn lock_signal(&self) -> Arc<AtomicU64> {
self.lock_epoch.clone()
}
fn session(&self) -> Result<&Unlocked> {
if self.is_locked() {
return Err("CREDENTIALS_LOCKED".into());
}
self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED".into())
}
pub fn lock(&mut self) {
self.unlocked.take();
@@ -494,11 +511,36 @@ impl CredentialBroker {
}
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
self.lock();
let epoch = self.lock_epoch.load(Ordering::SeqCst);
let ownership = Self::acquire_ownership(&self.path)?;
let session = if self.path.exists() {
Self::load_snapshot(&self.path, &password)?
} else {
let mut salt = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut salt)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
let session = Unlocked::derive(&password, salt)?;
session
.stronghold
.create_client(CLIENT)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
session.persist(&self.path)?;
session
};
if self.lock_epoch.load(Ordering::SeqCst) != epoch {
return Err("CREDENTIALS_LOCKED".into());
}
self.unlocked_epoch = epoch;
self.unlocked = Some(session);
self.ownership = Some(ownership);
Ok(())
}
fn acquire_ownership(path: &Path) -> Result<fs::File> {
use fs2::FileExt;
let parent = self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
let parent = path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
let mut lock_name = self
.path
let mut lock_name = path
.file_name()
.ok_or("CREDENTIAL_PATH_INVALID")?
.to_os_string();
@@ -534,54 +576,96 @@ impl CredentialBroker {
ownership
.try_lock_exclusive()
.map_err(|_| "CREDENTIALS_BUSY")?;
let session = if self.path.exists() {
Ok(ownership)
}
fn load_snapshot(path: &Path, password: &[u8]) -> Result<Unlocked> {
let metadata = fs::symlink_metadata(path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if !metadata.is_file() || metadata.len() > MAX_FILE {
return Err("CREDENTIAL_STORE_CORRUPT".into());
}
let data = fs::read(path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if data.len() < 40 || &data[..8] != MAGIC {
return Err("SCHEMA_INCOMPATIBLE".into());
}
let mut salt = [0u8; 32];
salt.copy_from_slice(&data[8..40]);
let session = Unlocked::derive(password, salt)?;
// Backups can be on read-only media. This temporary file contains ciphertext only.
let mut temp = tempfile::NamedTempFile::new().map_err(|_| "CREDENTIAL_IO_FAILED")?;
temp.write_all(&data[40..])
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
session
.stronghold
.load_client_from_snapshot(
CLIENT,
&session.provider()?,
&SnapshotPath::from_path(temp.path()),
)
.map_err(|_| "CREDENTIAL_UNLOCK_FAILED")?;
Ok(session)
}
/// Native picker selected destination; backup is encrypted and never overwrites.
pub fn backup(&self, destination: &Path) -> Result<()> {
self.session()?;
let parent = destination.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
let mut target =
tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
let bytes = fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
target
.write_all(&bytes)
.and_then(|_| target.as_file().sync_all())
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
target
.persist_noclobber(destination)
.map_err(|_| "CREDENTIAL_BACKUP_EXISTS")?;
Ok(())
}
/// Validate every record before atomic replacement; preserve the previous encrypted file.
/// Caller must obtain explicit confirmation through the native dialog.
pub fn restore(&mut self, source: &Path, password: Zeroizing<Vec<u8>>) -> Result<usize> {
if !self.is_locked() {
return Err("CREDENTIALS_MUST_LOCK".into());
}
self.lock();
let _ownership = Self::acquire_ownership(&self.path)?;
let session = Self::load_snapshot(source, &password)?;
let keys = session
.store()?
.keys()
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
for key in &keys {
let id: CredentialId =
serde_json::from_slice(key).map_err(|_| "CREDENTIAL_STORE_CORRUPT")?;
if id.key()? != *key || session.read(key)?.is_none() {
return Err("CREDENTIAL_STORE_CORRUPT".into());
}
}
let parent = self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
if self.path.exists() {
let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if !metadata.is_file() || metadata.len() > MAX_FILE {
return Err("CREDENTIAL_STORE_CORRUPT".into());
}
let data = fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if data.len() < 40 || &data[..8] != MAGIC {
return Err("SCHEMA_INCOMPATIBLE".into());
}
let mut salt = [0u8; 32];
salt.copy_from_slice(&data[8..40]);
let session = Unlocked::derive(&password, salt)?;
let mut temp = tempfile::NamedTempFile::new_in(
self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?,
)
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
temp.write_all(&data[40..])
let mut previous = tempfile::Builder::new()
.prefix("pre-restore-")
.suffix(".onxcred")
.tempfile_in(parent)
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
session
.stronghold
.load_client_from_snapshot(
CLIENT,
&session.provider()?,
&SnapshotPath::from_path(temp.path()),
)
.map_err(|_| "CREDENTIAL_UNLOCK_FAILED")?;
session
} else {
let mut salt = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut salt)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
let session = Unlocked::derive(&password, salt)?;
session
.stronghold
.create_client(CLIENT)
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
session.persist(&self.path)?;
session
};
self.unlocked = Some(session);
self.ownership = Some(ownership);
Ok(())
previous
.write_all(&fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?)
.and_then(|_| previous.as_file().sync_all())
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
previous.keep().map_err(|_| "CREDENTIAL_IO_FAILED")?;
}
session.persist(&self.path)?;
// Restoration deliberately leaves the vault locked; no implicit permission grant.
Ok(keys.len())
}
pub fn list(&self) -> Result<Vec<CredentialId>> {
self.unlocked
.as_ref()
.ok_or("CREDENTIALS_LOCKED")?
self.session()?
.store()?
.keys()
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
@@ -590,7 +674,7 @@ impl CredentialBroker {
.collect()
}
pub fn put(&mut self, id: &CredentialId, value: Zeroizing<Vec<u8>>) -> Result<()> {
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let session = self.session()?;
let result = session
.write(id.key()?, &value)
.and_then(|_| session.persist(&self.path));
@@ -600,7 +684,7 @@ impl CredentialBroker {
result
}
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let session = self.session()?;
session
.store()?
.delete(&id.key()?)
@@ -617,13 +701,10 @@ impl CredentialBroker {
if caller != &id.scope {
return Err("CREDENTIAL_SCOPE_DENIED".into());
}
self.unlocked
.as_ref()
.ok_or("CREDENTIALS_LOCKED")?
.read(&id.key()?)
self.session()?.read(&id.key()?)
}
pub fn change_password(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
let previous = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
let previous = self.session()?;
let mut salt = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut salt)
@@ -653,6 +734,78 @@ mod tests {
Zeroizing::new(b"test-only-password-123".to_vec())
}
#[test]
fn encrypted_backup_restores_corrupt_store_without_overwrite_on_failure() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("credentials.v1");
let backup = temp.path().join("backup.onxcred");
let mut broker = CredentialBroker::new(path.clone());
broker.unlock(password()).unwrap();
let id = CredentialId::legacy("test-provider");
broker
.put(&id, Zeroizing::new(b"backup-test-secret".to_vec()))
.unwrap();
broker.backup(&backup).unwrap();
assert_eq!(
broker.backup(&backup).unwrap_err(),
"CREDENTIAL_BACKUP_EXISTS"
);
assert_eq!(
broker.restore(&backup, password()).unwrap_err(),
"CREDENTIALS_MUST_LOCK"
);
broker.lock();
fs::write(&path, b"corrupt-original").unwrap();
assert!(broker
.restore(&backup, Zeroizing::new(b"wrong-test-password".to_vec()))
.is_err());
assert_eq!(fs::read(&path).unwrap(), b"corrupt-original");
assert_eq!(broker.restore(&backup, password()).unwrap(), 1);
assert!(broker.is_locked());
let saved = fs::read_dir(temp.path())
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name().to_string_lossy().starts_with("pre-restore-"))
.unwrap();
assert_eq!(fs::read(saved.path()).unwrap(), b"corrupt-original");
broker.unlock(password()).unwrap();
assert_eq!(
broker
.resolve(&Scope::Provider, &id)
.unwrap()
.unwrap()
.as_slice(),
b"backup-test-secret"
);
assert!(!fs::read(backup)
.unwrap()
.windows(18)
.any(|w| w == b"backup-test-secret"));
}
#[test]
fn session_revocation_denies_new_resolves_and_mutations() {
let temp = tempfile::tempdir().unwrap();
let mut broker = CredentialBroker::new(temp.path().join("credentials.v1"));
broker.unlock(password()).unwrap();
let id = CredentialId::legacy("test-provider");
broker
.put(&id, Zeroizing::new(b"test-value".to_vec()))
.unwrap();
broker.lock_signal().fetch_add(1, Ordering::SeqCst);
assert!(broker.is_locked());
assert_eq!(
broker.resolve(&Scope::Provider, &id).unwrap_err(),
"CREDENTIALS_LOCKED"
);
assert_eq!(
broker
.put(&id, Zeroizing::new(b"new-value".to_vec()))
.unwrap_err(),
"CREDENTIALS_LOCKED"
);
broker.unlock(password()).unwrap();
assert!(!broker.is_locked());
}
#[test]
fn python_fernet_migration_is_verified_idempotent_and_preserves_sources() {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
+2
View File
@@ -6,4 +6,6 @@ pub mod recent;
#[cfg(feature = "desktop")]
pub mod request_lifecycle;
mod runtime_compat;
#[cfg(windows)]
pub mod session_lock;
pub mod workspace;
+97
View File
@@ -22,6 +22,8 @@ struct Host {
recent: Mutex<Option<RecentVaultStore>>,
core: Arc<Mutex<Option<CoreSupervisor>>>,
credentials: Arc<Mutex<Option<CredentialBroker>>>,
#[cfg(windows)]
session_monitor: Mutex<Option<notesagent_host::session_lock::SessionMonitor>>,
streams: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
}
@@ -378,6 +380,15 @@ fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String
#[tauri::command]
async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(), String> {
#[cfg(windows)]
if host
.session_monitor
.lock()
.map_err(|_| "HOST_BUSY")?
.is_none()
{
return Err("SESSION_MONITOR_UNAVAILABLE".into());
}
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
@@ -455,6 +466,62 @@ async fn credentials_change_password(
.map_err(|_| "HOST_BUSY")?
}
#[tauri::command]
async fn credentials_backup(host: State<'_, Host>) -> Result<bool, String> {
let Some(path) = rfd::FileDialog::new()
.set_title("导出加密凭据备份(请选择新文件)")
.set_file_name("OpenNexus.onxcred")
.add_filter("OpenNexus credential backup", &["onxcred"])
.save_file()
else {
return Ok(false);
};
let broker = host.credentials.clone();
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_ref()
.ok_or("HOST_NOT_READY")?
.backup(&path)?;
Ok(true)
})
.await
.map_err(|_| "HOST_BUSY")?
}
#[tauri::command]
async fn credentials_restore(
host: State<'_, Host>,
password: String,
) -> Result<Option<usize>, String> {
let password = Zeroizing::new(password.into_bytes());
let Some(path) = rfd::FileDialog::new()
.set_title("选择加密凭据备份")
.add_filter("OpenNexus credential backup", &["onxcred"])
.pick_file()
else {
return Ok(None);
};
if rfd::MessageDialog::new().set_title("恢复凭据备份")
.set_description("恢复将替换本机凭据库。当前加密文件会另存为恢复前备份;恢复后仍需解锁。笔记不会被替换。是否继续?")
.set_buttons(rfd::MessageButtons::YesNo).show() != rfd::MessageDialogResult::Yes {
return Ok(None);
}
let broker = host.credentials.clone();
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.restore(&path, password)
.map(Some)
})
.await
.map_err(|_| "HOST_BUSY")?
}
#[tauri::command]
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
@@ -602,6 +669,34 @@ fn main() {
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
));
#[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::<Host>()
.session_monitor
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
notesagent_host::session_lock::SessionMonitor::start(signal).ok();
}
let weak_credentials = Arc::downgrade(&credential_state);
std::thread::spawn(move || {
while let Some(state) = weak_credentials.upgrade() {
if let Ok(mut broker) = state.try_lock() {
if let Some(broker) = broker.as_mut() {
if broker.is_locked() {
broker.lock();
}
}
}
drop(state);
std::thread::sleep(Duration::from_millis(200));
}
});
let data_dir = app.path().app_data_dir()?.join("core-data");
// Debug builds use this worktree's interpreter; release builds only use bundled Core.
let core = if cfg!(debug_assertions) {
@@ -676,6 +771,8 @@ fn main() {
credentials_lock,
credentials_change_password,
credentials_import,
credentials_backup,
credentials_restore,
core_request,
core_request_prepare,
core_request_cancel,
+153
View File
@@ -0,0 +1,153 @@
//! Windows session notifications. Revocation is atomic and never waits for a KDF.
//! https://learn.microsoft.com/en-us/windows/win32/termserv/wm-wtssession-change
use std::cell::RefCell;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use windows_sys::Win32::{
Foundation::*,
System::{LibraryLoader::GetModuleHandleW, RemoteDesktop::*},
UI::WindowsAndMessaging::*,
};
thread_local! { static SIGNAL: RefCell<Option<Arc<AtomicU64>>> = const { RefCell::new(None) }; }
unsafe extern "system" fn window_proc(
hwnd: HWND,
message: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
if message == WM_WTSSESSION_CHANGE
&& matches!(
wparam as u32,
WTS_SESSION_LOCK | WTS_SESSION_LOGOFF | WTS_CONSOLE_DISCONNECT | WTS_REMOTE_DISCONNECT
)
{
SIGNAL.with(|s| {
if let Some(signal) = s.borrow().as_ref() {
signal.fetch_add(1, Ordering::SeqCst);
}
});
}
if message == WM_DESTROY {
WTSUnRegisterSessionNotification(hwnd);
PostQuitMessage(0);
return 0;
}
DefWindowProcW(hwnd, message, wparam, lparam)
}
pub struct SessionMonitor {
window: usize,
thread: Option<std::thread::JoinHandle<()>>,
}
impl SessionMonitor {
pub fn start(signal: Arc<AtomicU64>) -> Result<Self, String> {
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<u16> = 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);
});
match rx
.recv()
.map_err(|_| "SESSION_MONITOR_UNAVAILABLE".to_string())?
{
Ok(window) => Ok(Self {
window,
thread: Some(thread),
}),
Err(error) => {
let _ = thread.join();
Err(error)
}
}
}
}
impl Drop for SessionMonitor {
fn drop(&mut self) {
unsafe {
PostMessageW(self.window as HWND, WM_CLOSE, 0, 0);
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_message_revokes_without_unlocking_on_session_return() {
let signal = Arc::new(AtomicU64::new(0));
let monitor = SessionMonitor::start(signal.clone()).unwrap();
// Inject only into our hidden test window; never lock the user's desktop.
unsafe {
SendMessageW(
monitor.window as HWND,
WM_WTSSESSION_CHANGE,
WTS_SESSION_LOCK as usize,
0,
);
}
assert_eq!(signal.load(Ordering::SeqCst), 1);
unsafe {
SendMessageW(
monitor.window as HWND,
WM_WTSSESSION_CHANGE,
WTS_SESSION_UNLOCK as usize,
0,
);
}
assert_eq!(signal.load(Ordering::SeqCst), 1);
}
}
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import { hostInvoke } from '@/services/platform/desktop'
import CredentialVaultSettings from './CredentialVaultSettings.vue'
vi.mock('@/services/platform/desktop', () => ({ hostInvoke: vi.fn() }))
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks() })
it('reflects host session lock and clears its polling timer on unmount', async () => {
vi.useFakeTimers()
vi.mocked(hostInvoke).mockResolvedValue({ locked: false })
const wrapper = mount(CredentialVaultSettings)
await flushPromises()
expect(wrapper.text()).toContain('立即锁定')
vi.mocked(hostInvoke).mockResolvedValue({ locked: true })
await vi.advanceTimersByTimeAsync(500)
expect(wrapper.text()).not.toContain('立即锁定')
expect(wrapper.text()).toContain('恢复备份')
wrapper.unmount()
const calls = vi.mocked(hostInvoke).mock.calls.length
await vi.advanceTimersByTimeAsync(2000)
expect(vi.mocked(hostInvoke).mock.calls.length).toBe(calls)
})
it('clears the restore password and treats native cancellation as no change', async () => {
vi.mocked(hostInvoke).mockImplementation(async command => command === 'credentials_status' ? { locked: true } : null)
const wrapper = mount(CredentialVaultSettings)
await flushPromises()
await wrapper.get('input[type=password]').setValue('test-backup-password')
await wrapper.findAll('button').find(button => button.text().includes('恢复备份'))!.trigger('click')
await flushPromises()
expect(hostInvoke).toHaveBeenCalledWith('credentials_restore', { password: 'test-backup-password' })
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).not.toContain('已恢复')
wrapper.unmount()
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { hostInvoke } from '@/services/platform/desktop'
import { t } from '@/i18n'
@@ -26,6 +26,25 @@ async function importLegacy() {
} catch (error) { message.value = failureMessage(error, 'MIGRATION_FAILED') }
finally { busy.value = false }
}
async function backup() {
busy.value = true; message.value = ''
try {
if (await hostInvoke<boolean>('credentials_backup')) message.value = t('已导出加密备份,请保留备份时使用的口令。', 'Encrypted backup exported. Keep the password used for this backup.')
} catch (error) { message.value = failureMessage(error, 'CREDENTIAL_BACKUP_FAILED') }
finally { busy.value = false }
}
async function restore() {
if (password.value.length < 12) { message.value = t('请输入备份的口令。', 'Enter the backup password.'); return }
busy.value = true; message.value = ''
const value = password.value
password.value = ''; confirmation.value = ''
try {
const count = await hostInvoke<number | null>('credentials_restore', { password: value })
if (count !== null) message.value = t(`已恢复 ${count} 条凭据,请使用备份口令解锁。`, `Restored ${count} credentials. Unlock with the backup password.`)
await refresh()
} catch (error) { message.value = failureMessage(error, 'CREDENTIAL_RESTORE_FAILED') }
finally { busy.value = false }
}
async function act(action: 'unlock' | 'lock' | 'change_password') {
if (busy.value) return
message.value = ''
@@ -42,7 +61,12 @@ async function act(action: 'unlock' | 'lock' | 'change_password') {
} catch (error) { message.value = failureMessage(error, 'CREDENTIAL_STORE_FAILED') }
finally { busy.value = false }
}
onMounted(() => refresh().catch(error => { message.value = String(error) }))
let statusTimer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
void refresh().catch(error => { message.value = String(error) })
statusTimer = setInterval(() => { if (!busy.value) void refresh().catch(() => {}) }, 500)
})
onUnmounted(() => clearInterval(statusTimer))
</script>
<template>
@@ -61,6 +85,8 @@ onMounted(() => refresh().catch(error => { message.value = String(error) }))
<button class="button-primary" type="submit" :disabled="busy">{{ busy ? t('处理中…', 'Working…') : locked ? t('解锁', 'Unlock') : t('更改口令', 'Change password') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="act('lock')">{{ t('立即锁定', 'Lock now') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="importLegacy">{{ t('迁移旧凭据', 'Import legacy credentials') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="backup">{{ t('导出加密备份', 'Export encrypted backup') }}</button>
<button v-if="locked" class="button-secondary" type="button" :disabled="busy" @click="restore">{{ t('使用此口令恢复备份', 'Restore backup with this password') }}</button>
</div>
</form>
<p v-if="message" role="status">{{ message }}</p>