release: OpenNexus 0.5.0
This commit is contained in:
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
pub mod core;
|
||||
pub mod core_update;
|
||||
#[cfg(windows)]
|
||||
mod credential_autounlock;
|
||||
pub mod credentials;
|
||||
mod payloads;
|
||||
mod preference_records;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user