From 3439d1fe3f77239198fcf210d1679042174f8f08 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 14:46:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E6=81=A2=E5=A4=8D=E4=B8=8E=20Windows=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=92=A4=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/contracts/Host-v1契约.md | 4 + .../OpenNexus生产化实施进度-2026-09-08.md | 6 + frontend/src-tauri/Cargo.lock | 1 + frontend/src-tauri/Cargo.toml | 3 + frontend/src-tauri/build.rs | 2 + frontend/src-tauri/capabilities/main.json | 2 + .../autogenerated/credentials_backup.toml | 11 + .../autogenerated/credentials_restore.toml | 11 + frontend/src-tauri/src/credentials.rs | 261 ++++++++++++++---- frontend/src-tauri/src/lib.rs | 2 + frontend/src-tauri/src/main.rs | 97 +++++++ frontend/src-tauri/src/session_lock.rs | 153 ++++++++++ .../settings/CredentialVaultSettings.spec.ts | 37 +++ .../settings/CredentialVaultSettings.vue | 30 +- 14 files changed, 564 insertions(+), 56 deletions(-) create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_backup.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/credentials_restore.toml create mode 100644 frontend/src-tauri/src/session_lock.rs create mode 100644 frontend/src/features/settings/CredentialVaultSettings.spec.ts diff --git a/docs/contracts/Host-v1契约.md b/docs/contracts/Host-v1契约.md index f6bafb5..1861ecb 100644 --- a/docs/contracts/Host-v1契约.md +++ b/docs/contracts/Host-v1契约.md @@ -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 不会被测试读取或迁移。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 012ae10..04ad1a2 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -4,6 +4,12 @@ **状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 A–E 的完整验收。 +## 持续实施增量 + +- 已修复多实例凭据覆盖与桌面请求取消/超时,证据见[全量回归](OpenNexus验收修复与回归-2026-09-08.md)。 +- 新增加密凭据备份、原生确认恢复、恢复前留存和逐条完整性验证。Windows 新增 WTS 锁屏/注销/断开通知,原子撤销解析资格;解锁不会随系统返回而自动恢复。设置页同步锁定状态,提供备份和恢复入口。 +- 备份恢复、错误口令保护、锁屏后解析/写入拒绝、原生窗口通知测试通过;后者向专用测试窗口注入消息,不等于真实锁屏时延验收。前端两项交互测试、类型检查、Rust desktop 全目标 Clippy 通过。凭据旧库确认清除和迁移故障矩阵仍待补齐。 + ## 已实现的链路 - Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index c8eebaf..88e7f7d 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2943,6 +2943,7 @@ dependencies = [ "tempfile", "tokio", "uuid", + "windows-sys 0.61.2", "zeroize", ] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index d33b1b1..1efeb45 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index d7b8090..7e347f2 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -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", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 42189e5..e319656 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -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", diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_backup.toml b/frontend/src-tauri/permissions/autogenerated/credentials_backup.toml new file mode 100644 index 0000000..d1c7753 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_backup.toml @@ -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"] diff --git a/frontend/src-tauri/permissions/autogenerated/credentials_restore.toml b/frontend/src-tauri/permissions/autogenerated/credentials_restore.toml new file mode 100644 index 0000000..adde0bd --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/credentials_restore.toml @@ -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"] diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index d8910d0..d598e0e 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -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 = std::result::Result; @@ -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, + lock_epoch: Arc, + 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 { + 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>) -> 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 { 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 { + 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>) -> Result { + 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> { - 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>) -> 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>) -> 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(); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 135209a..182bc1a 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -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; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 4414369..007e178 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -22,6 +22,8 @@ struct Host { recent: Mutex>, core: Arc>>, credentials: Arc>>, + #[cfg(windows)] + session_monitor: Mutex>, streams: Arc>>>, } @@ -378,6 +380,15 @@ fn credentials_status(host: State<'_, Host>) -> Result, 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 { + 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, 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::>() @@ -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::() + .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, diff --git a/frontend/src-tauri/src/session_lock.rs b/frontend/src-tauri/src/session_lock.rs new file mode 100644 index 0000000..2468e9b --- /dev/null +++ b/frontend/src-tauri/src/session_lock.rs @@ -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>> = 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>, +} +impl SessionMonitor { + pub fn start(signal: Arc) -> Result { + 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() { + 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); + } +} diff --git a/frontend/src/features/settings/CredentialVaultSettings.spec.ts b/frontend/src/features/settings/CredentialVaultSettings.spec.ts new file mode 100644 index 0000000..80a8c70 --- /dev/null +++ b/frontend/src/features/settings/CredentialVaultSettings.spec.ts @@ -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() +}) diff --git a/frontend/src/features/settings/CredentialVaultSettings.vue b/frontend/src/features/settings/CredentialVaultSettings.vue index a30098a..4842b64 100644 --- a/frontend/src/features/settings/CredentialVaultSettings.vue +++ b/frontend/src/features/settings/CredentialVaultSettings.vue @@ -1,5 +1,5 @@