feat: 添加加密凭据恢复与 Windows 会话撤销
This commit is contained in:
@@ -27,6 +27,10 @@
|
|||||||
|
|
||||||
凭据保险库解锁前取得独立 `.lock` 文件的 OS 排他锁,持有至手动锁定、错误后锁定或实例销毁。其他实例返回 CREDENTIALS_BUSY,不读取旧快照后继续覆盖写入;错误口令释放临时锁,进程退出由 OS 释放。Windows 禁止在持锁期间替换该锁文件。
|
凭据保险库解锁前取得独立 `.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 不会被测试读取或迁移。
|
OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vault_mutation` 使用同一 OS 文件锁;发现 `.ainote/host.sqlite3` 后拒绝 Web 写入,不自动降级。已有个人 Vault 不会被测试读取或迁移。
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
**状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 A–E 的完整验收。
|
**状态:实施中,未 done,未通过生产发布门禁。** 本文记录工程进度,不将单元测试或开发机运行替代[生产化计划](../architecture/第三阶段生产化工程规划与验收目标.md)中 A–E 的完整验收。
|
||||||
|
|
||||||
|
## 持续实施增量
|
||||||
|
|
||||||
|
- 已修复多实例凭据覆盖与桌面请求取消/超时,证据见[全量回归](OpenNexus验收修复与回归-2026-09-08.md)。
|
||||||
|
- 新增加密凭据备份、原生确认恢复、恢复前留存和逐条完整性验证。Windows 新增 WTS 锁屏/注销/断开通知,原子撤销解析资格;解锁不会随系统返回而自动恢复。设置页同步锁定状态,提供备份和恢复入口。
|
||||||
|
- 备份恢复、错误口令保护、锁屏后解析/写入拒绝、原生窗口通知测试通过;后者向专用测试窗口注入消息,不等于真实锁屏时延验收。前端两项交互测试、类型检查、Rust desktop 全目标 Clippy 通过。凭据旧库确认清除和迁移故障矩阵仍待补齐。
|
||||||
|
|
||||||
## 已实现的链路
|
## 已实现的链路
|
||||||
|
|
||||||
- Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。
|
- Rust 管理 Core 进程组;使用 stdin 引导、随机 loopback 端口、HMAC 握手、独立代际与会话令牌。每个请求先认证,拒绝浏览器 Origin、重复鉴权头及旧代际。Host 关闭时关闭管道并清理进程组,重启具有退避和次数上限。
|
||||||
|
|||||||
Generated
+1
@@ -2943,6 +2943,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ argon2 = "0.5"
|
|||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] }
|
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]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", optional = true , features = [] }
|
tauri-build = { version = "2", optional = true , features = [] }
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ fn main() {
|
|||||||
"credentials_lock",
|
"credentials_lock",
|
||||||
"credentials_change_password",
|
"credentials_change_password",
|
||||||
"credentials_import",
|
"credentials_import",
|
||||||
|
"credentials_backup",
|
||||||
|
"credentials_restore",
|
||||||
"core_request",
|
"core_request",
|
||||||
"core_request_prepare",
|
"core_request_prepare",
|
||||||
"core_request_cancel",
|
"core_request_cancel",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"allow-credentials-lock",
|
"allow-credentials-lock",
|
||||||
"allow-credentials-change-password",
|
"allow-credentials-change-password",
|
||||||
"allow-credentials-import",
|
"allow-credentials-import",
|
||||||
|
"allow-credentials-backup",
|
||||||
|
"allow-credentials-restore",
|
||||||
"allow-core-request",
|
"allow-core-request",
|
||||||
"allow-core-request-prepare",
|
"allow-core-request-prepare",
|
||||||
"allow-core-request-cancel",
|
"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"]
|
||||||
@@ -15,6 +15,10 @@ use std::collections::BTreeMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
type Result<T> = std::result::Result<T, String>;
|
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
|
// Separate stable inode: snapshots are atomically replaced, so locking the
|
||||||
// snapshot itself would not protect the next writer after replacement.
|
// snapshot itself would not protect the next writer after replacement.
|
||||||
ownership: Option<fs::File>,
|
ownership: Option<fs::File>,
|
||||||
|
lock_epoch: Arc<AtomicU64>,
|
||||||
|
unlocked_epoch: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CredentialBroker {
|
impl CredentialBroker {
|
||||||
@@ -268,7 +274,7 @@ impl CredentialBroker {
|
|||||||
.ok_or("MIGRATION_KEY_MISSING")?;
|
.ok_or("MIGRATION_KEY_MISSING")?;
|
||||||
let fernet =
|
let fernet =
|
||||||
Zeroizing::new(fernet::Fernet::new(key.trim()).ok_or("MIGRATION_KEY_INVALID")?);
|
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();
|
let mut decoded = Vec::new();
|
||||||
for (id, token) in &tokens {
|
for (id, token) in &tokens {
|
||||||
let key = CredentialId::legacy(id).key()?;
|
let key = CredentialId::legacy(id).key()?;
|
||||||
@@ -381,7 +387,7 @@ impl CredentialBroker {
|
|||||||
let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?;
|
let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?;
|
||||||
let params = &request["params"];
|
let params = &request["params"];
|
||||||
if method == "credentials.delete_many" || method == "credentials.move_many" {
|
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 result = (|| {
|
||||||
let mut removed = Vec::new();
|
let mut removed = Vec::new();
|
||||||
if method.ends_with("delete_many") {
|
if method.ends_with("delete_many") {
|
||||||
@@ -483,10 +489,21 @@ impl CredentialBroker {
|
|||||||
path,
|
path,
|
||||||
unlocked: None,
|
unlocked: None,
|
||||||
ownership: None,
|
ownership: None,
|
||||||
|
lock_epoch: Arc::new(AtomicU64::new(0)),
|
||||||
|
unlocked_epoch: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_locked(&self) -> bool {
|
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) {
|
pub fn lock(&mut self) {
|
||||||
self.unlocked.take();
|
self.unlocked.take();
|
||||||
@@ -494,11 +511,36 @@ impl CredentialBroker {
|
|||||||
}
|
}
|
||||||
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
||||||
self.lock();
|
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;
|
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")?;
|
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||||
let mut lock_name = self
|
let mut lock_name = path
|
||||||
.path
|
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or("CREDENTIAL_PATH_INVALID")?
|
.ok_or("CREDENTIAL_PATH_INVALID")?
|
||||||
.to_os_string();
|
.to_os_string();
|
||||||
@@ -534,54 +576,96 @@ impl CredentialBroker {
|
|||||||
ownership
|
ownership
|
||||||
.try_lock_exclusive()
|
.try_lock_exclusive()
|
||||||
.map_err(|_| "CREDENTIALS_BUSY")?;
|
.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")?;
|
let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||||
if !metadata.is_file() || metadata.len() > MAX_FILE {
|
if !metadata.is_file() || metadata.len() > MAX_FILE {
|
||||||
return Err("CREDENTIAL_STORE_CORRUPT".into());
|
return Err("CREDENTIAL_STORE_CORRUPT".into());
|
||||||
}
|
}
|
||||||
let data = fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
let mut previous = tempfile::Builder::new()
|
||||||
if data.len() < 40 || &data[..8] != MAGIC {
|
.prefix("pre-restore-")
|
||||||
return Err("SCHEMA_INCOMPATIBLE".into());
|
.suffix(".onxcred")
|
||||||
}
|
.tempfile_in(parent)
|
||||||
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..])
|
|
||||||
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||||
session
|
previous
|
||||||
.stronghold
|
.write_all(&fs::read(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?)
|
||||||
.load_client_from_snapshot(
|
.and_then(|_| previous.as_file().sync_all())
|
||||||
CLIENT,
|
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||||
&session.provider()?,
|
previous.keep().map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||||
&SnapshotPath::from_path(temp.path()),
|
}
|
||||||
)
|
session.persist(&self.path)?;
|
||||||
.map_err(|_| "CREDENTIAL_UNLOCK_FAILED")?;
|
// Restoration deliberately leaves the vault locked; no implicit permission grant.
|
||||||
session
|
Ok(keys.len())
|
||||||
} 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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list(&self) -> Result<Vec<CredentialId>> {
|
pub fn list(&self) -> Result<Vec<CredentialId>> {
|
||||||
self.unlocked
|
self.session()?
|
||||||
.as_ref()
|
|
||||||
.ok_or("CREDENTIALS_LOCKED")?
|
|
||||||
.store()?
|
.store()?
|
||||||
.keys()
|
.keys()
|
||||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
|
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
|
||||||
@@ -590,7 +674,7 @@ impl CredentialBroker {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
pub fn put(&mut self, id: &CredentialId, value: Zeroizing<Vec<u8>>) -> Result<()> {
|
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
|
let result = session
|
||||||
.write(id.key()?, &value)
|
.write(id.key()?, &value)
|
||||||
.and_then(|_| session.persist(&self.path));
|
.and_then(|_| session.persist(&self.path));
|
||||||
@@ -600,7 +684,7 @@ impl CredentialBroker {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
|
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
|
||||||
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
|
let session = self.session()?;
|
||||||
session
|
session
|
||||||
.store()?
|
.store()?
|
||||||
.delete(&id.key()?)
|
.delete(&id.key()?)
|
||||||
@@ -617,13 +701,10 @@ impl CredentialBroker {
|
|||||||
if caller != &id.scope {
|
if caller != &id.scope {
|
||||||
return Err("CREDENTIAL_SCOPE_DENIED".into());
|
return Err("CREDENTIAL_SCOPE_DENIED".into());
|
||||||
}
|
}
|
||||||
self.unlocked
|
self.session()?.read(&id.key()?)
|
||||||
.as_ref()
|
|
||||||
.ok_or("CREDENTIALS_LOCKED")?
|
|
||||||
.read(&id.key()?)
|
|
||||||
}
|
}
|
||||||
pub fn change_password(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
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];
|
let mut salt = [0u8; 32];
|
||||||
rand::rngs::OsRng
|
rand::rngs::OsRng
|
||||||
.try_fill_bytes(&mut salt)
|
.try_fill_bytes(&mut salt)
|
||||||
@@ -653,6 +734,78 @@ mod tests {
|
|||||||
Zeroizing::new(b"test-only-password-123".to_vec())
|
Zeroizing::new(b"test-only-password-123".to_vec())
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn python_fernet_migration_is_verified_idempotent_and_preserves_sources() {
|
||||||
let fixture: serde_json::Value =
|
let fixture: serde_json::Value =
|
||||||
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
|
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ pub mod recent;
|
|||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod request_lifecycle;
|
pub mod request_lifecycle;
|
||||||
mod runtime_compat;
|
mod runtime_compat;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod session_lock;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct Host {
|
|||||||
recent: Mutex<Option<RecentVaultStore>>,
|
recent: Mutex<Option<RecentVaultStore>>,
|
||||||
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
||||||
credentials: Arc<Mutex<Option<CredentialBroker>>>,
|
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<()>>>>,
|
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]
|
#[tauri::command]
|
||||||
async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(), String> {
|
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 broker = host.credentials.clone();
|
||||||
let password = Zeroizing::new(password.into_bytes());
|
let password = Zeroizing::new(password.into_bytes());
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
@@ -455,6 +466,62 @@ async fn credentials_change_password(
|
|||||||
.map_err(|_| "HOST_BUSY")?
|
.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]
|
#[tauri::command]
|
||||||
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
|
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
|
||||||
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
|
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
|
||||||
@@ -602,6 +669,34 @@ fn main() {
|
|||||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
|
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
|
||||||
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
|
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");
|
let data_dir = app.path().app_data_dir()?.join("core-data");
|
||||||
// Debug builds use this worktree's interpreter; release builds only use bundled Core.
|
// Debug builds use this worktree's interpreter; release builds only use bundled Core.
|
||||||
let core = if cfg!(debug_assertions) {
|
let core = if cfg!(debug_assertions) {
|
||||||
@@ -676,6 +771,8 @@ fn main() {
|
|||||||
credentials_lock,
|
credentials_lock,
|
||||||
credentials_change_password,
|
credentials_change_password,
|
||||||
credentials_import,
|
credentials_import,
|
||||||
|
credentials_backup,
|
||||||
|
credentials_restore,
|
||||||
core_request,
|
core_request,
|
||||||
core_request_prepare,
|
core_request_prepare,
|
||||||
core_request_cancel,
|
core_request_cancel,
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { hostInvoke } from '@/services/platform/desktop'
|
import { hostInvoke } from '@/services/platform/desktop'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@@ -26,6 +26,25 @@ async function importLegacy() {
|
|||||||
} catch (error) { message.value = failureMessage(error, 'MIGRATION_FAILED') }
|
} catch (error) { message.value = failureMessage(error, 'MIGRATION_FAILED') }
|
||||||
finally { busy.value = false }
|
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') {
|
async function act(action: 'unlock' | 'lock' | 'change_password') {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
message.value = ''
|
message.value = ''
|
||||||
@@ -42,7 +61,12 @@ async function act(action: 'unlock' | 'lock' | 'change_password') {
|
|||||||
} catch (error) { message.value = failureMessage(error, 'CREDENTIAL_STORE_FAILED') }
|
} catch (error) { message.value = failureMessage(error, 'CREDENTIAL_STORE_FAILED') }
|
||||||
finally { busy.value = false }
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 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="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="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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="message" role="status">{{ message }}</p>
|
<p v-if="message" role="status">{{ message }}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user