Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ Navop user-facing release notes. Generate and review each bilingual version entr
#### 修复与优化

- 个人同步:修复引用了钥匙串凭据的连接在多设备间持续产生假冲突的问题。连接载荷里的凭据引用导出时保留了源设备的本地整数 ID,而每台设备的行号各不相同,导致同一条连接跨设备的 checksum 永不相等,被判定为「双方都改过」而反复冲突、来回覆盖。现在导出载荷统一只保留跨设备稳定的 `credential_cloud_id`,整数 ID 置零(下载时仍按云端 ID 映射回本机行号,行为不变)。
- 安全:个人主密钥的派生从「SHA-256 + 固定盐」升级为 Argon2id + 随机盐(64 MiB / 3 次迭代),验证数据改为版本化信封 `NAVOPKDF2:`。旧格式在解锁时自动迁移并重加密本地连接、凭据、团队密钥缓存与冲突快照,云端记录由既有 pending 机制补做;迁移中断可续,旧密文始终可读(#460)。
- 安全:自动解锁用的主密钥不再默认用程序内置密钥加密写进 `data/state/key_storage`,改存系统凭据库(macOS 钥匙串 / Windows 凭据管理器)并自动迁移、删除旧文件;没有可用凭据库的平台改为每次启动手动输入,不再静默回退弱存储(#459)。
- 设置:「记住主密码」新增「主密钥保存位置」选项,可在系统凭据库(macOS 显示「macOS 钥匙串」、Windows 显示「Windows 凭据管理器」)与「本机文件(数据目录)」之间切换,便携模式同样可选。切换时先把已记住的密钥写入新位置、成功后再删除旧副本;选择本机文件会显示内置密钥加密的风险提示(#459)。
- 首页:从标签栏「+」打开的「打开连接」列表里点一次不再新建两个 SSH 会话。此前同一行既挂了行内的打开回调,又有列表内建的点击 → `confirm` 打开路径,一次点击先后走完两条路径(已有 2 个标签时点一次直接变成 4 个);现在点击与键盘回车统一只走 `confirm`,并补源码级回归测试守住「行渲染不得直接打开连接」(#461)。

---

#### Fixes and Improvements

- Personal sync: fixed constantly recurring false conflicts for connections that reference keychain credentials. The exported payload kept the source device's local integer credential ID, which differs on every device, so the same logical connection never produced an equal checksum across devices and was repeatedly flagged as "modified on both sides", overwriting back and forth. Exports now carry only the device-stable `credential_cloud_id` with the integer ID zeroed (downloads still map the cloud ID back to the local row, unchanged behavior).
- Security: the personal master key is now derived with Argon2id and a random salt (64 MiB / 3 iterations) instead of SHA-256 with a fixed salt, and its verification data uses a versioned `NAVOPKDF2:` envelope. Legacy installs are migrated on unlock: local connections, credentials, team key caches and conflict snapshots are re-encrypted, cloud records are re-encrypted through the existing pending mechanism, interrupted migrations resume, and old ciphertext stays readable throughout (#460).
- Security: the master key kept for automatic unlock is no longer encrypted into `data/state/key_storage` with an application-embedded key by default. It is stored in the system credential store (macOS Keychain / Windows Credential Manager), the legacy file is migrated and deleted, and platforms without an available credential store ask for the key on each start instead of silently falling back to the weak store (#459).
- Settings: "remember master key" now has a storage location choice between the system credential store (shown as "macOS Keychain" on macOS and "Windows Credential Manager" on Windows) and a local file in the data directory, also in portable mode. Switching writes the remembered key to the new location first and only then deletes the old copy; the local file option shows the risk that it is encrypted with an application-embedded key (#459).
- Home: clicking an entry in the “Open connection” list opened from the tab bar’s “+” no longer creates two SSH sessions. A row carried both its own open callback and the list’s built-in click → `confirm` open path, so a single click ran through both (going from 2 tabs to 4 in one click); clicks and the Enter key now share the single `confirm` path, guarded by a source-level regression test (#461).

## [v0.21.1] - 2026-10-22
Expand Down
19 changes: 17 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662

anyhow = "1.0.102"
argon2 = "0.5.3"
keyring = { version = "3.6.3", default-features = false }
log = "0.4"
markdown = { version = "1.0.0", features = ["serde"] }
lsp-types = { version = "0.97.0", features = ["proposed"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ smallvec.workspace = true
tempfile = "3"

[target.'cfg(target_os = "macos")'.dependencies]
keyring = { workspace = true, features = ["apple-native"] }
objc2 = "0.6"
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSResponder", "NSView", "NSWindow"] }
raw-window-handle = { workspace = true, features = ["std"] }

[target.'cfg(target_os = "windows")'.dependencies]
keyring = { workspace = true, features = ["windows-native"] }
windows = { workspace = true, features = [
"Win32_Storage_FileSystem",
] }
Expand Down
23 changes: 23 additions & 0 deletions crates/core/src/app_dirs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ pub(crate) fn installed_data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|root| preferred_dir_from_root(&root))
}

/// 单元测试专用的数据目录。
///
/// `cargo test` 绝不能落盘到真实用户数据目录:验证文件、密钥元信息这类
/// 「固定文件名」的状态一旦被测试覆盖,已安装的正式版本就再也解不开自己的
/// 数据(2026-10-26 的 key_verification 污染事故即此原因)。每个测试进程
/// 使用独立临时目录,既隔离真实目录,也避免多次运行互相干扰。
#[cfg(test)]
pub(crate) fn test_data_dir() -> PathBuf {
static TEST_DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
TEST_DATA_DIR
.get_or_init(|| {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default();
let dir = std::env::temp_dir()
.join(format!("navop-test-data-{}-{nanos}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
dir
})
.clone()
}

pub fn migrate_legacy_directories() -> Result<()> {
// 便携与 debug 开发目录都不该触碰生产安装目录。
if crate::app_paths::initialized_paths()
Expand Down
5 changes: 3 additions & 2 deletions crates/core/src/cloud_sync/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,10 @@ impl SyncEngine {
.crypto_service
.write()
.map_err(|_| SyncError::StorageError("同步服务锁获取失败".to_string()))?;
if !service_write.master_key_matches(&raw_key) {
if !service_write.master_key_pinned() && !service_write.master_key_matches(&raw_key)
{
tracing::info!("[同步引擎] 主密钥已变更,重新注入同步服务");
service_write.set_master_key_directly(raw_key);
service_write.set_master_key_from_local(raw_key);
}
}
}
Expand Down
23 changes: 21 additions & 2 deletions crates/core/src/cloud_sync/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ pub struct CloudSyncService {
operation_queues: HashMap<String, OperationQueue>,
/// 团队密钥缓存:team_id -> team_key
team_keys: HashMap<String, String>,
/// 主密钥是否由调用方显式指定且应保持权威
///
/// 应用内的共享服务不设置该标志,由同步引擎在每次同步前从本地 crypto
/// 模块对齐;持有独立测试/迁移场景的密钥时置位,避免被进程级主密钥覆盖。
master_key_pinned: bool,
}

impl CloudSyncService {
Expand All @@ -112,6 +117,7 @@ impl CloudSyncService {
sync_states: HashMap::new(),
operation_queues: HashMap::new(),
team_keys: HashMap::new(),
master_key_pinned: false,
}
}

Expand Down Expand Up @@ -141,6 +147,7 @@ impl CloudSyncService {
self.logged_in = false;
self.user_id = None;
self.master_key = None;
self.master_key_pinned = false;
self.key_version = 0;
self.sync_states.clear();
self.operation_queues.clear();
Expand Down Expand Up @@ -173,13 +180,25 @@ impl CloudSyncService {

/// 直接设置主密钥(不验证)
///
/// 用于从本地 crypto 模块同步密钥状态,跳过云端验证。
/// 调用者需确保密钥已通过本地验证。
/// 调用者显式声明该密钥为本服务实例的权威密钥:此后同步引擎不会再用
/// 本地 crypto 模块的全局主密钥覆盖它。用于改主密钥后的迁移、测试等
/// 场景;应用内的共享服务请交给引擎在同步前自动对齐。
pub fn set_master_key_directly(&mut self, master_key: String) {
self.master_key = Some(master_key);
self.master_key_pinned = true;
// key_version 保持默认或之前的值,实际同步时会从云端更新
}

/// 由同步引擎从本地 crypto 模块注入主密钥(不取得权威、不置位 pinned)
pub fn set_master_key_from_local(&mut self, master_key: String) {
self.master_key = Some(master_key);
}

/// 当前主密钥是否由调用方显式指定
pub fn master_key_pinned(&self) -> bool {
self.master_key_pinned
}

/// 服务内当前的主密钥是否与给定密钥一致
///
/// 主密钥被修改后,持有旧密钥的同步服务必须被重新注入,否则会用旧密钥去
Expand Down
Loading
Loading