release: OpenNexus 0.5.0

This commit is contained in:
2026-09-17 20:59:32 +08:00
parent e86809b238
commit f7d441bd92
34 changed files with 509 additions and 60 deletions
+1 -1
View File
@@ -3242,7 +3242,7 @@ dependencies = [
[[package]]
name = "notesagent-desktop"
version = "0.4.0-alpha.1"
version = "0.5.0"
dependencies = [
"argon2",
"base64 0.22.1",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "notesagent-desktop"
version = "0.4.0-alpha.1"
version = "0.5.0"
edition = "2021"
rust-version = "1.89"
@@ -48,7 +48,7 @@ cap-fs-ext = "4.0.2"
jsonschema = { version = "0.55", default-features = false }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_IO", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Cryptography", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_IO", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
[build-dependencies]
tauri-build = { version = "2", optional = true , features = [] }
@@ -0,0 +1,44 @@
use notesagent_host::credentials::CredentialBroker;
use std::path::PathBuf;
fn main() -> Result<(), String> {
let mut arguments = std::env::args_os().skip(1);
let vault = PathBuf::from(arguments.next().ok_or("TARGET_REQUIRED")?);
let legacy = PathBuf::from(arguments.next().ok_or("LEGACY_REQUIRED")?);
if arguments.next().is_some() {
return Err("ARGUMENTS_INVALID".into());
}
let parent = vault.parent().ok_or("TARGET_INVALID")?;
let backup = parent.join("stronghold.pre-0.5.0.onxcred");
let auto_key = parent.join("auto-unlock.dpapi");
if auto_key.exists() {
return Err("AUTO_UNLOCK_ALREADY_CONFIGURED".into());
}
if backup.exists() {
return Err("BACKUP_ALREADY_EXISTS".into());
}
if vault.exists() {
std::fs::rename(&vault, &backup).map_err(|_| "BACKUP_FAILED")?;
}
let migrated = (|| {
let mut broker = CredentialBroker::new(vault.clone());
if !broker.ensure_system_unlock()? {
return Err("AUTO_UNLOCK_INITIALIZATION_FAILED".into());
}
broker.import_fernet(&legacy, None)
})();
match migrated {
Ok(count) => {
println!("Migrated {count} credential(s) to Windows automatic unlock.");
Ok(())
}
Err(error) => {
let _ = std::fs::remove_file(&vault);
let _ = std::fs::remove_file(&auto_key);
if backup.exists() {
let _ = std::fs::rename(&backup, &vault);
}
Err(error)
}
}
}
@@ -0,0 +1,109 @@
//! Windows DPAPI-backed storage for the random Stronghold unlock secret.
use std::fs;
use std::io::Write;
use std::path::Path;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
use zeroize::Zeroizing;
type Result<T> = std::result::Result<T, String>;
const MAGIC: &[u8] = b"ONXDPAPI1";
const ENTROPY: &[u8] = b"OpenNexus credential auto-unlock v1";
fn transform(data: &[u8], protect: bool) -> Result<Zeroizing<Vec<u8>>> {
let input = CRYPT_INTEGER_BLOB {
cbData: u32::try_from(data.len()).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?,
pbData: data.as_ptr() as *mut u8,
};
let entropy = CRYPT_INTEGER_BLOB {
cbData: ENTROPY.len() as u32,
pbData: ENTROPY.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
let ok = unsafe {
if protect {
CryptProtectData(
&input,
std::ptr::null(),
&entropy,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
} else {
CryptUnprotectData(
&input,
std::ptr::null_mut(),
&entropy,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
}
};
if ok == 0 || output.pbData.is_null() || output.cbData == 0 {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
let result = unsafe {
Zeroizing::new(std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec())
};
if !protect {
unsafe { std::ptr::write_bytes(output.pbData, 0, output.cbData as usize) };
}
unsafe { LocalFree(output.pbData as *mut core::ffi::c_void) };
Ok(result)
}
pub fn load(path: &Path) -> Result<Option<Zeroizing<Vec<u8>>>> {
if !path.exists() {
return Ok(None);
}
let metadata = fs::symlink_metadata(path).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 64 * 1024 {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
let bytes = fs::read(path).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
if !bytes.starts_with(MAGIC) || bytes.len() == MAGIC.len() {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
transform(&bytes[MAGIC.len()..], false).map(Some)
}
pub fn save(path: &Path, secret: &[u8]) -> Result<()> {
let protected = transform(secret, true)?;
let parent = path.parent().ok_or("CREDENTIAL_AUTO_UNLOCK_FAILED")?;
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
let mut target =
tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
target
.write_all(MAGIC)
.and_then(|_| target.write_all(&protected))
.and_then(|_| target.as_file().sync_all())
.map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
target
.persist(path)
.map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dpapi_round_trip_never_persists_plaintext() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("auto-unlock.dpapi");
let secret = b"test-system-secret-123456789";
save(&path, secret).unwrap();
assert!(!fs::read(&path)
.unwrap()
.windows(secret.len())
.any(|part| part == secret));
assert_eq!(load(&path).unwrap().unwrap().as_slice(), secret);
}
}
+74
View File
@@ -354,6 +354,52 @@ pub struct CredentialBroker {
}
impl CredentialBroker {
#[cfg(windows)]
fn auto_unlock_path(&self) -> Result<PathBuf> {
Ok(self
.path
.parent()
.ok_or("CREDENTIAL_PATH_INVALID")?
.join("auto-unlock.dpapi"))
}
/// Unlocks with a random secret protected by Windows DPAPI. A new vault is initialized
/// automatically; an existing password vault is never overwritten implicitly.
#[cfg(windows)]
pub fn ensure_system_unlock(&mut self) -> Result<bool> {
let key_path = self.auto_unlock_path()?;
if let Some(secret) = crate::credential_autounlock::load(&key_path)? {
self.unlock(secret)?;
return Ok(true);
}
if self.path.exists() {
return Ok(false);
}
let mut secret = Zeroizing::new(vec![0u8; 32]);
rand::rngs::OsRng
.try_fill_bytes(&mut secret)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
crate::credential_autounlock::save(&key_path, &secret)?;
self.unlock(secret)?;
Ok(true)
}
#[cfg(windows)]
pub fn enable_system_unlock(&self, password: &[u8]) -> Result<()> {
self.session()?;
crate::credential_autounlock::save(&self.auto_unlock_path()?, password)
}
#[cfg(windows)]
pub fn has_system_unlock(&self) -> bool {
self.auto_unlock_path().is_ok_and(|path| path.is_file())
}
#[cfg(not(windows))]
pub fn has_system_unlock(&self) -> bool {
false
}
/// 源来自本机文件选择器,而不是原始 WebView 路径。导入是幂等的;冲突的 ID 会停止整个事务。
pub fn import_fernet(
&mut self,
@@ -1041,6 +1087,34 @@ mod tests {
fn password() -> Zeroizing<Vec<u8>> {
Zeroizing::new(b"test-only-password-123".to_vec())
}
#[cfg(windows)]
#[test]
fn system_unlock_survives_broker_restart() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("credentials/stronghold.v1");
let id = CredentialId {
scope: Scope::Provider,
id: "provider-restart".into(),
};
{
let mut broker = CredentialBroker::new(path.clone());
assert!(broker.ensure_system_unlock().unwrap());
broker
.put(&id, Zeroizing::new(b"restart-secret".to_vec()))
.unwrap();
}
let mut restarted = CredentialBroker::new(path);
assert!(restarted.ensure_system_unlock().unwrap());
assert_eq!(
restarted
.resolve(&Scope::Provider, &id)
.unwrap()
.unwrap()
.as_slice(),
b"restart-secret"
);
}
fn b04_fixture() -> (Vec<u8>, String, BTreeMap<String, String>) {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
+2
View File
@@ -2,6 +2,8 @@
pub mod core;
pub mod core_update;
#[cfg(windows)]
mod credential_autounlock;
pub mod credentials;
mod payloads;
mod preference_records;
+25 -16
View File
@@ -561,7 +561,10 @@ fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String
.try_lock()
.map_err(|_| "CREDENTIALS_BUSY")?;
let broker = broker.as_ref().ok_or("HOST_NOT_READY")?;
Ok(serde_json::json!({"locked":broker.is_locked()}))
Ok(serde_json::json!({
"locked": broker.is_locked(),
"automatic": if cfg!(windows) { broker.has_system_unlock() } else { false }
}))
}
#[tauri::command]
@@ -578,12 +581,13 @@ async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.unlock(password)
let mut guard = broker.lock().map_err(|_| "HOST_BUSY")?;
let broker = guard.as_mut().ok_or("HOST_NOT_READY")?;
let retained = Zeroizing::new(password.to_vec());
broker.unlock(password)?;
#[cfg(windows)]
broker.enable_system_unlock(&retained)?;
Ok(())
})
.await
.map_err(|_| "HOST_BUSY")?
@@ -705,12 +709,13 @@ async fn credentials_change_password(
let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.change_password(password)
let mut guard = broker.lock().map_err(|_| "HOST_BUSY")?;
let broker = guard.as_mut().ok_or("HOST_NOT_READY")?;
let retained = Zeroizing::new(password.to_vec());
broker.change_password(password)?;
#[cfg(windows)]
broker.enable_system_unlock(&retained)?;
Ok(())
})
.await
.map_err(|_| "HOST_BUSY")?
@@ -948,11 +953,15 @@ fn main() {
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store);
let credential_state = app.state::<Host>().credentials.clone();
let mut broker =
CredentialBroker::new(app.path().app_data_dir()?.join("credentials/stronghold.v1"));
#[cfg(windows)]
broker
.ensure_system_unlock()
.map_err(std::io::Error::other)?;
*credential_state
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
));
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(broker);
let signal = credential_state
.lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))?
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenNexus",
"version": "0.4.0-alpha.1",
"version": "0.5.0",
"identifier": "cc.kronecker.notesagent",
"build": {
"beforeDevCommand": "pnpm dev",