feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
//! Trusted Core process supervisor. The WebView never receives session material.
|
||||
use command_group::{CommandGroup, GroupChild};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ChildStdin, Command, Stdio};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
pub type Broker = Arc<dyn Fn(&serde_json::Value) -> Result<serde_json::Value> + Send + Sync>;
|
||||
|
||||
/// The manifest is embedded in the Host at build time, never loaded from the installation.
|
||||
pub fn verify_bundle(root: &Path, manifest: &str) -> Result<()> {
|
||||
use sha2::Digest;
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
protocol: u32,
|
||||
product: String,
|
||||
files: BTreeMap<String, String>,
|
||||
}
|
||||
let expected: Manifest = serde_json::from_str(manifest).map_err(|_| "CORE_MANIFEST_INVALID")?;
|
||||
if expected.protocol != 1 || expected.product != "OpenNexus" || expected.files.is_empty() {
|
||||
return Err("CORE_MANIFEST_INVALID".into());
|
||||
}
|
||||
fn inventory(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
files: &mut BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
let metadata = std::fs::symlink_metadata(directory).map_err(|_| "CORE_INTEGRITY_FAILED")?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if metadata.file_attributes() & 0x400 != 0 {
|
||||
return Err("CORE_INTEGRITY_FAILED".into());
|
||||
}
|
||||
}
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("CORE_INTEGRITY_FAILED".into());
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
for entry in std::fs::read_dir(directory).map_err(|_| "CORE_INTEGRITY_FAILED")? {
|
||||
inventory(
|
||||
root,
|
||||
&entry.map_err(|_| "CORE_INTEGRITY_FAILED")?.path(),
|
||||
files,
|
||||
)?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
let mut file = std::fs::File::open(directory).map_err(|_| "CORE_INTEGRITY_FAILED")?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0u8; 65536];
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|_| "CORE_INTEGRITY_FAILED")?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
let name = directory
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "CORE_INTEGRITY_FAILED")?
|
||||
.to_str()
|
||||
.ok_or("CORE_INTEGRITY_FAILED")?
|
||||
.replace('\\', "/");
|
||||
files.insert(name, format!("{:x}", hash.finalize()));
|
||||
} else {
|
||||
return Err("CORE_INTEGRITY_FAILED".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut actual = BTreeMap::new();
|
||||
inventory(root, root, &mut actual)?;
|
||||
if actual != expected.files {
|
||||
return Err("CORE_INTEGRITY_FAILED".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn random_hex() -> Result<String> {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng
|
||||
.try_fill_bytes(&mut bytes)
|
||||
.map_err(|_| "CORE_ENTROPY_UNAVAILABLE")?;
|
||||
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
fn decode_hex(value: &str) -> Result<Vec<u8>> {
|
||||
if value.len() != 64 || !value.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("CORE_HANDSHAKE_INVALID".into());
|
||||
}
|
||||
(0..64)
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&value[i..i + 2], 16).map_err(|_| "CORE_HANDSHAKE_INVALID".into())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Ready {
|
||||
protocol: u32,
|
||||
pid: u32,
|
||||
launcher_pid: u32,
|
||||
port: u16,
|
||||
generation: String,
|
||||
proof: String,
|
||||
}
|
||||
|
||||
fn verify_ready(
|
||||
line: &[u8],
|
||||
secret: &str,
|
||||
challenge: &str,
|
||||
generation: &str,
|
||||
pid: u32,
|
||||
) -> Result<u16> {
|
||||
let ready: Ready = serde_json::from_slice(line).map_err(|_| "CORE_HANDSHAKE_INVALID")?;
|
||||
if ready.protocol != 1 {
|
||||
return Err("PROTOCOL_INCOMPATIBLE".into());
|
||||
}
|
||||
if ready.launcher_pid != pid
|
||||
|| ready.pid == 0
|
||||
|| ready.port == 0
|
||||
|| ready.generation != generation
|
||||
{
|
||||
return Err("CORE_HANDSHAKE_INVALID".into());
|
||||
}
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&decode_hex(secret)?)
|
||||
.map_err(|_| "CORE_HANDSHAKE_INVALID")?;
|
||||
mac.update(
|
||||
format!(
|
||||
"1:{challenge}:{generation}:{pid}:{}:{}",
|
||||
ready.pid, ready.port
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
mac.verify_slice(&decode_hex(&ready.proof)?)
|
||||
.map_err(|_| "CORE_HANDSHAKE_INVALID")?;
|
||||
Ok(ready.port)
|
||||
}
|
||||
|
||||
pub fn checked_url(port: u16, path: &str) -> Result<String> {
|
||||
let resource = path.split('?').next().unwrap_or("");
|
||||
if !(resource.starts_with("/api/") || resource == "/api" || resource == "/health")
|
||||
|| path.contains(['\\', '\r', '\n', '#'])
|
||||
|| resource.contains('%')
|
||||
|| resource.split('/').any(|part| part == "." || part == "..")
|
||||
|| resource.contains("//")
|
||||
|| path.len() > 8192
|
||||
{
|
||||
return Err("CORE_PATH_DENIED".into());
|
||||
}
|
||||
Ok(format!("http://127.0.0.1:{port}{path}"))
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
child: GroupChild,
|
||||
lifetime: Arc<Mutex<Option<ChildStdin>>>,
|
||||
secret: Zeroizing<String>,
|
||||
generation: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut pipe) = self.lifetime.lock() {
|
||||
pipe.take();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if self.child.try_wait().ok().flatten().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
// Kill the entire group even if its leader has exited.
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoreSupervisor {
|
||||
executable: PathBuf,
|
||||
arguments: Vec<String>,
|
||||
working_dir: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
session: Option<Session>,
|
||||
attempts: VecDeque<Instant>,
|
||||
next_attempt: Option<Instant>,
|
||||
broker: Option<Broker>,
|
||||
bundle_manifest: Option<String>,
|
||||
}
|
||||
|
||||
/// Host-only request context; deliberately neither Serialize nor Debug.
|
||||
pub struct RequestSession {
|
||||
pub url: String,
|
||||
pub authorization: Zeroizing<String>,
|
||||
pub generation: String,
|
||||
}
|
||||
|
||||
impl CoreSupervisor {
|
||||
pub fn new(
|
||||
executable: PathBuf,
|
||||
arguments: Vec<String>,
|
||||
working_dir: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
executable,
|
||||
arguments,
|
||||
working_dir,
|
||||
data_dir,
|
||||
session: None,
|
||||
attempts: VecDeque::new(),
|
||||
next_attempt: None,
|
||||
broker: None,
|
||||
bundle_manifest: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_broker(mut self, broker: Broker) -> Self {
|
||||
self.broker = Some(broker);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bundle_manifest(mut self, manifest: String) -> Self {
|
||||
self.bundle_manifest = Some(manifest);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn available(&mut self) -> bool {
|
||||
self.session
|
||||
.as_mut()
|
||||
.is_some_and(|s| matches!(s.child.try_wait(), Ok(None)))
|
||||
}
|
||||
|
||||
pub fn request_session(&mut self, path: &str) -> Result<RequestSession> {
|
||||
checked_url(1, path)?;
|
||||
if !self.available() {
|
||||
self.start()?;
|
||||
}
|
||||
let session = self.session.as_ref().ok_or("CORE_UNAVAILABLE")?;
|
||||
Ok(RequestSession {
|
||||
url: checked_url(session.port, path)?,
|
||||
authorization: Zeroizing::new(format!("Bearer {}", session.secret.as_str())),
|
||||
generation: session.generation.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
if self.available() {
|
||||
return Ok(());
|
||||
}
|
||||
self.session.take();
|
||||
let now = Instant::now();
|
||||
self.attempts
|
||||
.retain(|t| now.duration_since(*t) < Duration::from_secs(300));
|
||||
if self.attempts.len() >= 5 {
|
||||
return Err("CORE_RESTART_LIMIT".into());
|
||||
}
|
||||
if self.next_attempt.is_some_and(|t| now < t) {
|
||||
return Err("CORE_RESTART_BACKOFF".into());
|
||||
}
|
||||
self.attempts.push_back(now);
|
||||
self.next_attempt = Some(now + Duration::from_secs(1 << (self.attempts.len() - 1)));
|
||||
if let Some(manifest) = &self.bundle_manifest {
|
||||
verify_bundle(&self.working_dir, manifest)?;
|
||||
}
|
||||
let session = Self::spawn(
|
||||
&self.executable,
|
||||
&self.arguments,
|
||||
&self.working_dir,
|
||||
&self.data_dir,
|
||||
self.broker.clone(),
|
||||
)?;
|
||||
self.session = Some(session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn(
|
||||
executable: &Path,
|
||||
args: &[String],
|
||||
working_dir: &Path,
|
||||
data_dir: &Path,
|
||||
broker: Option<Broker>,
|
||||
) -> Result<Session> {
|
||||
let secret = Zeroizing::new(random_hex()?);
|
||||
let generation = random_hex()?;
|
||||
let challenge = random_hex()?;
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.env_clear()
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
// Runtime requirements only; never copy Provider tokens or general PATH.
|
||||
for key in [
|
||||
"SystemRoot",
|
||||
"WINDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"LANG",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"LOCALAPPDATA",
|
||||
] {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
command.env(key, value);
|
||||
}
|
||||
}
|
||||
command.env("PYTHONUTF8", "1").env("PYTHONUNBUFFERED", "1");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let child = command.group_spawn().map_err(|_| "CORE_SPAWN_FAILED")?;
|
||||
let mut session = Session {
|
||||
child,
|
||||
lifetime: Arc::new(Mutex::new(None)),
|
||||
secret,
|
||||
generation,
|
||||
port: 0,
|
||||
};
|
||||
let stdout = session
|
||||
.child
|
||||
.inner()
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or("CORE_PIPE_FAILED")?;
|
||||
*session.lifetime.lock().map_err(|_| "CORE_PIPE_FAILED")? =
|
||||
session.child.inner().stdin.take();
|
||||
let mut payload = Zeroizing::new(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"protocol": 1, "launcher_pid": session.child.id(), "secret": session.secret.as_str(), "challenge": challenge,
|
||||
"generation": session.generation, "data_dir": data_dir,
|
||||
}))
|
||||
.map_err(|_| "CORE_BOOTSTRAP_INVALID")?,
|
||||
);
|
||||
payload.push(b'\n');
|
||||
session
|
||||
.lifetime
|
||||
.lock()
|
||||
.map_err(|_| "CORE_PIPE_FAILED")?
|
||||
.as_mut()
|
||||
.ok_or("CORE_PIPE_FAILED")?
|
||||
.write_all(&payload)
|
||||
.map_err(|_| "CORE_PIPE_FAILED")?;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let lifetime = session.lifetime.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
let mut line = Zeroizing::new(Vec::new());
|
||||
match reader.by_ref().take(131073).read_until(b'\n', &mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
if line.len() > 131072 {
|
||||
break;
|
||||
}
|
||||
let Ok(message) = serde_json::from_slice::<serde_json::Value>(&line) else {
|
||||
break;
|
||||
};
|
||||
if message.get("rpc").is_none() {
|
||||
let _ = tx.send(Ok::<Vec<u8>, std::io::Error>(line.to_vec()));
|
||||
continue;
|
||||
}
|
||||
let request_id = message
|
||||
.get("request_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let result = broker
|
||||
.as_ref()
|
||||
.ok_or_else(|| "HOST_BROKER_UNAVAILABLE".to_string())
|
||||
.and_then(|b| b(&message));
|
||||
let response = match result {
|
||||
Ok(result) => serde_json::json!({"request_id":request_id,"result":result}),
|
||||
Err(error) => serde_json::json!({"request_id":request_id,"error":error}),
|
||||
};
|
||||
let Ok(bytes) = serde_json::to_vec(&response) else {
|
||||
break;
|
||||
};
|
||||
let mut bytes = Zeroizing::new(bytes);
|
||||
if bytes.len() > 131072 {
|
||||
break;
|
||||
}
|
||||
bytes.push(b'\n');
|
||||
let Ok(mut pipe) = lifetime.lock() else {
|
||||
break;
|
||||
};
|
||||
let Some(pipe) = pipe.as_mut() else {
|
||||
break;
|
||||
};
|
||||
if pipe.write_all(&bytes).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let line = rx
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.map_err(|_| "CORE_READY_TIMEOUT")?
|
||||
.map_err(|_| "CORE_HANDSHAKE_INVALID")?;
|
||||
if line.len() > 16384 {
|
||||
return Err("CORE_HANDSHAKE_INVALID".into());
|
||||
}
|
||||
session.port = verify_ready(
|
||||
&line,
|
||||
&session.secret,
|
||||
&challenge,
|
||||
&session.generation,
|
||||
session.child.id(),
|
||||
)?;
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn paths_cannot_redirect_or_escape() {
|
||||
for path in [
|
||||
"https://evil/api",
|
||||
"/api/../x",
|
||||
"/api/%2e%2e/x",
|
||||
"/api//x",
|
||||
"/api/a\\b",
|
||||
"/api/a#x",
|
||||
"/api/a\r\nHost:x",
|
||||
] {
|
||||
assert!(checked_url(4321, path).is_err(), "{path}");
|
||||
}
|
||||
assert_eq!(
|
||||
checked_url(4321, "/api/search?q=%E4%B8%AD").unwrap(),
|
||||
"http://127.0.0.1:4321/api/search?q=%E4%B8%AD"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn proof_has_python_compatible_framing_and_binds_identity() {
|
||||
let secret = "01".repeat(32);
|
||||
let challenge = "02".repeat(32);
|
||||
let generation = "03".repeat(32);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&[1; 32]).unwrap();
|
||||
mac.update(format!("1:{challenge}:{generation}:123:123:4567").as_bytes());
|
||||
let signature: String = mac
|
||||
.finalize()
|
||||
.into_bytes()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let payload = serde_json::to_vec(&serde_json::json!({"protocol":1,"launcher_pid":123,"pid":123,"port":4567,"generation":generation,"proof":signature})).unwrap();
|
||||
assert_eq!(
|
||||
verify_ready(&payload, &secret, &challenge, &generation, 123).unwrap(),
|
||||
4567
|
||||
);
|
||||
assert!(verify_ready(&payload, &secret, &challenge, &generation, 124).is_err());
|
||||
assert!(verify_ready(&payload, &secret, &"04".repeat(32), &generation, 123).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
//! Device-local Stronghold broker. No public IPC returns secret bytes.
|
||||
//!
|
||||
//! Stronghold Store contains AEAD ciphertext, including while unlocked. Snapshot
|
||||
//! and salt are one atomic envelope, so password changes cannot tear two files.
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, Payload},
|
||||
ChaCha20Poly1305, KeyInit, Nonce,
|
||||
};
|
||||
use iota_stronghold::{KeyProvider, SnapshotPath, Stronghold};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
const CLIENT: &[u8] = b"opennexus.credentials.v1";
|
||||
const MAGIC: &[u8] = b"ONXCRED1";
|
||||
const MAX_FILE: u64 = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", content = "owner", rename_all = "snake_case")]
|
||||
pub enum Scope {
|
||||
Provider,
|
||||
Plugin(String),
|
||||
Mcp(String),
|
||||
Sync(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialId {
|
||||
pub scope: Scope,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl CredentialId {
|
||||
/// Preserve opaque legacy references. Hashed Plugin/MCP IDs remain isolated
|
||||
/// from Provider IDs; only the trusted Core adapter can use these aliases.
|
||||
pub fn legacy(id: &str) -> Self {
|
||||
let scope = if let Some(owner) = id.strip_prefix("plugin.") {
|
||||
Scope::Plugin(owner.into())
|
||||
} else if let Some(owner) = id.strip_prefix("mcp.") {
|
||||
Scope::Mcp(owner.into())
|
||||
} else {
|
||||
Scope::Provider
|
||||
};
|
||||
Self {
|
||||
scope,
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
fn key(&self) -> Result<Vec<u8>> {
|
||||
fn valid(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
|
||||
}
|
||||
if !valid(&self.id) {
|
||||
return Err("CREDENTIAL_ID_INVALID".into());
|
||||
}
|
||||
match &self.scope {
|
||||
Scope::Provider
|
||||
if self.id.to_lowercase().starts_with("plugin.")
|
||||
|| self.id.to_lowercase().starts_with("mcp.") =>
|
||||
{
|
||||
return Err("CREDENTIAL_SCOPE_DENIED".into())
|
||||
}
|
||||
Scope::Plugin(owner) | Scope::Mcp(owner) | Scope::Sync(owner) if !valid(owner) => {
|
||||
return Err("CREDENTIAL_SCOPE_DENIED".into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
serde_json::to_vec(self).map_err(|_| "CREDENTIAL_ID_INVALID".into())
|
||||
}
|
||||
}
|
||||
|
||||
struct Unlocked {
|
||||
stronghold: Stronghold,
|
||||
key: Zeroizing<Vec<u8>>,
|
||||
salt: [u8; 32],
|
||||
}
|
||||
|
||||
impl Drop for Unlocked {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stronghold.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocked {
|
||||
fn derive(password: &[u8], salt: [u8; 32]) -> Result<Self> {
|
||||
if password.len() < 12 || password.len() > 1024 {
|
||||
return Err("CREDENTIAL_PASSWORD_LENGTH".into());
|
||||
}
|
||||
let params = Params::new(65536, 3, 1, Some(32)).map_err(|_| "CREDENTIAL_KDF_FAILED")?;
|
||||
let mut key = Zeroizing::new(vec![0; 32]);
|
||||
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
|
||||
.hash_password_into(password, &salt, &mut key)
|
||||
.map_err(|_| "CREDENTIAL_KDF_FAILED")?;
|
||||
Ok(Self {
|
||||
stronghold: Stronghold::default(),
|
||||
key,
|
||||
salt,
|
||||
})
|
||||
}
|
||||
fn cipher(&self) -> Result<ChaCha20Poly1305> {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(self.key.as_slice());
|
||||
hash.update(b"opennexus.credential-record.v1");
|
||||
let key = Zeroizing::new(hash.finalize().to_vec());
|
||||
ChaCha20Poly1305::new_from_slice(&key).map_err(|_| "CREDENTIAL_CIPHER_FAILED".into())
|
||||
}
|
||||
fn provider(&self) -> Result<KeyProvider> {
|
||||
KeyProvider::try_from(Zeroizing::new(self.key.to_vec()))
|
||||
.map_err(|_| "CREDENTIAL_KDF_FAILED".into())
|
||||
}
|
||||
fn store(&self) -> Result<iota_stronghold::Store> {
|
||||
self.stronghold
|
||||
.get_client(CLIENT)
|
||||
.map(|c| c.store())
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED".into())
|
||||
}
|
||||
fn read(&self, key: &[u8]) -> Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(data) = self
|
||||
.store()?
|
||||
.get(key)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if data.len() < 28 {
|
||||
return Err("CREDENTIAL_STORE_CORRUPT".into());
|
||||
}
|
||||
self.cipher()?
|
||||
.decrypt(
|
||||
Nonce::from_slice(&data[..12]),
|
||||
Payload {
|
||||
msg: &data[12..],
|
||||
aad: key,
|
||||
},
|
||||
)
|
||||
.map(Zeroizing::new)
|
||||
.map(Some)
|
||||
.map_err(|_| "CREDENTIAL_STORE_CORRUPT".into())
|
||||
}
|
||||
fn write(&self, key: Vec<u8>, value: &[u8]) -> Result<()> {
|
||||
if value.is_empty() || value.len() > 65536 {
|
||||
return Err("CREDENTIAL_VALUE_INVALID".into());
|
||||
}
|
||||
let mut nonce = [0u8; 12];
|
||||
rand::rngs::OsRng
|
||||
.try_fill_bytes(&mut nonce)
|
||||
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
|
||||
let ciphertext = self
|
||||
.cipher()?
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
msg: value,
|
||||
aad: &key,
|
||||
},
|
||||
)
|
||||
.map_err(|_| "CREDENTIAL_CIPHER_FAILED")?;
|
||||
let mut record = nonce.to_vec();
|
||||
record.extend(ciphertext);
|
||||
self.store()?
|
||||
.insert(key, record, None)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
|
||||
Ok(())
|
||||
}
|
||||
fn persist(&self, path: &Path) -> Result<()> {
|
||||
let parent = path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
let staging = tempfile::tempdir_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
let snapshot = staging.path().join("snapshot");
|
||||
self.stronghold
|
||||
.write_client(CLIENT)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
|
||||
self.stronghold
|
||||
.commit_with_keyprovider(&SnapshotPath::from_path(&snapshot), &self.provider()?)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
|
||||
let bytes = fs::read(&snapshot).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
let mut target =
|
||||
tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
target
|
||||
.write_all(MAGIC)
|
||||
.and_then(|_| target.write_all(&self.salt))
|
||||
.and_then(|_| target.write_all(&bytes))
|
||||
.and_then(|_| target.as_file().sync_all())
|
||||
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
target.persist(path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CredentialBroker {
|
||||
path: PathBuf,
|
||||
unlocked: Option<Unlocked>,
|
||||
}
|
||||
|
||||
impl CredentialBroker {
|
||||
/// Source comes from the native file picker, never a raw WebView path.
|
||||
/// Import is idempotent; conflicting IDs stop the entire transaction.
|
||||
pub fn import_fernet(
|
||||
&mut self,
|
||||
directory: &Path,
|
||||
environment_key: Option<Zeroizing<String>>,
|
||||
) -> Result<usize> {
|
||||
use fs2::FileExt;
|
||||
let directory = directory
|
||||
.canonicalize()
|
||||
.map_err(|_| "MIGRATION_SOURCE_INVALID")?;
|
||||
let source = directory.join("credentials.json");
|
||||
let key_path = directory.join("master.key");
|
||||
if !fs::symlink_metadata(&source)
|
||||
.map_err(|_| "MIGRATION_SOURCE_INVALID")?
|
||||
.is_file()
|
||||
{
|
||||
return Err("MIGRATION_SOURCE_INVALID".into());
|
||||
}
|
||||
let lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(directory.join(".migration.lock"))
|
||||
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
|
||||
lock.try_lock_exclusive()
|
||||
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
|
||||
if fs::metadata(&source)
|
||||
.map_err(|_| "MIGRATION_SOURCE_INVALID")?
|
||||
.len()
|
||||
> MAX_FILE
|
||||
{
|
||||
return Err("MIGRATION_SOURCE_INVALID".into());
|
||||
}
|
||||
let source_bytes = fs::read(&source).map_err(|_| "MIGRATION_SOURCE_INVALID")?;
|
||||
let source_hash = format!("{:x}", Sha256::digest(&source_bytes));
|
||||
let tokens: BTreeMap<String, String> =
|
||||
serde_json::from_slice(&source_bytes).map_err(|_| "MIGRATION_SOURCE_INVALID")?;
|
||||
if tokens.len() > 10000 {
|
||||
return Err("MIGRATION_SOURCE_INVALID".into());
|
||||
}
|
||||
let local_key = if environment_key.is_none() {
|
||||
if !fs::symlink_metadata(&key_path)
|
||||
.map_err(|_| "MIGRATION_KEY_MISSING")?
|
||||
.is_file()
|
||||
{
|
||||
return Err("MIGRATION_KEY_MISSING".into());
|
||||
}
|
||||
Some(Zeroizing::new(
|
||||
fs::read_to_string(&key_path).map_err(|_| "MIGRATION_KEY_MISSING")?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let key = environment_key
|
||||
.as_ref()
|
||||
.or(local_key.as_ref())
|
||||
.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 mut decoded = Vec::new();
|
||||
for (id, token) in &tokens {
|
||||
let key = CredentialId::legacy(id).key()?;
|
||||
let value = Zeroizing::new(
|
||||
fernet
|
||||
.decrypt(token)
|
||||
.map_err(|_| "MIGRATION_DECRYPT_FAILED")?,
|
||||
);
|
||||
if value.is_empty() || value.len() > 65536 || std::str::from_utf8(&value).is_err() {
|
||||
return Err("MIGRATION_VALUE_INVALID".into());
|
||||
}
|
||||
if let Some(existing) = session.read(&key)? {
|
||||
if existing.as_slice() != value.as_slice() {
|
||||
return Err("MIGRATION_CONFLICT".into());
|
||||
}
|
||||
}
|
||||
decoded.push((key, value));
|
||||
}
|
||||
let migration_id = format!(
|
||||
"{:x}",
|
||||
Sha256::digest(directory.to_string_lossy().as_bytes())
|
||||
);
|
||||
let backup = self
|
||||
.path
|
||||
.parent()
|
||||
.ok_or("CREDENTIAL_PATH_INVALID")?
|
||||
.join("migration-backups")
|
||||
.join(&migration_id);
|
||||
fs::create_dir_all(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
|
||||
// Backups contain ciphertext; the legacy key is sealed under the already
|
||||
// unlocked device key, rather than adding another plaintext master.key.
|
||||
let mut nonce = [0u8; 12];
|
||||
rand::rngs::OsRng
|
||||
.try_fill_bytes(&mut nonce)
|
||||
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
|
||||
let sealed_key = session
|
||||
.cipher()?
|
||||
.encrypt(Nonce::from_slice(&nonce), key.as_bytes())
|
||||
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
|
||||
let mut key_backup = b"ONXFBK1".to_vec();
|
||||
key_backup.extend(session.salt);
|
||||
key_backup.extend(nonce);
|
||||
key_backup.extend(sealed_key);
|
||||
for (name, bytes) in [
|
||||
("credentials.json", source_bytes.as_slice()),
|
||||
("master-key.sealed", key_backup.as_slice()),
|
||||
] {
|
||||
let mut temporary =
|
||||
tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
|
||||
temporary
|
||||
.write_all(bytes)
|
||||
.and_then(|_| temporary.as_file().sync_all())
|
||||
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
|
||||
temporary
|
||||
.persist(backup.join(name))
|
||||
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
|
||||
}
|
||||
let result = (|| {
|
||||
for (key, value) in &decoded {
|
||||
session.write(key.clone(), value)?;
|
||||
}
|
||||
session.persist(&self.path)?;
|
||||
// Re-open the committed Stronghold snapshot, not the in-memory cache.
|
||||
let envelope = fs::read(&self.path).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
let mut temporary =
|
||||
tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
temporary
|
||||
.write_all(&envelope[40..])
|
||||
.map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
let verified = Unlocked {
|
||||
stronghold: Stronghold::default(),
|
||||
key: Zeroizing::new(session.key.to_vec()),
|
||||
salt: session.salt,
|
||||
};
|
||||
verified
|
||||
.stronghold
|
||||
.load_client_from_snapshot(
|
||||
CLIENT,
|
||||
&verified.provider()?,
|
||||
&SnapshotPath::from_path(temporary.path()),
|
||||
)
|
||||
.map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
for (key, value) in &decoded {
|
||||
if verified.read(key)?.as_deref().map(|v| v.as_slice()) != Some(value.as_slice()) {
|
||||
return Err("MIGRATION_VERIFY_FAILED".into());
|
||||
}
|
||||
}
|
||||
if fs::read(&source).map_err(|_| "MIGRATION_SOURCE_CHANGED")? != source_bytes {
|
||||
return Err("MIGRATION_SOURCE_CHANGED".into());
|
||||
}
|
||||
let marker = serde_json::json!({"schema":1,"owner":"OpenNexus","state":"switched","source_sha256":source_hash,"count":decoded.len(),"environment_key":environment_key.is_some()});
|
||||
let bytes = serde_json::to_vec(&marker).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
let mut marker_file = tempfile::NamedTempFile::new_in(&directory)
|
||||
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
|
||||
marker_file
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| marker_file.as_file().sync_all())
|
||||
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
|
||||
marker_file
|
||||
.persist(directory.join(".opennexus-owner.json"))
|
||||
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
|
||||
Ok(decoded.len())
|
||||
})();
|
||||
if result.is_err() {
|
||||
self.lock();
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn dispatch(&mut self, request: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
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 result = (|| {
|
||||
let mut removed = Vec::new();
|
||||
if method.ends_with("delete_many") {
|
||||
let ids = params["ids"].as_array().ok_or("HOST_REQUEST_INVALID")?;
|
||||
let keys: Vec<_> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let id = id.as_str().ok_or("HOST_REQUEST_INVALID")?;
|
||||
Ok((id.to_string(), CredentialId::legacy(id).key()?))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (id, key) in keys {
|
||||
if session
|
||||
.store()?
|
||||
.delete(&key)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
|
||||
.is_some()
|
||||
{
|
||||
removed.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let replacements = params["replacements"]
|
||||
.as_object()
|
||||
.ok_or("HOST_REQUEST_INVALID")?;
|
||||
let mut moves = Vec::new();
|
||||
for (old, new) in replacements {
|
||||
let source = CredentialId::legacy(old);
|
||||
let target =
|
||||
CredentialId::legacy(new.as_str().ok_or("HOST_REQUEST_INVALID")?);
|
||||
// ID migrations cannot change Provider/Plugin/MCP families.
|
||||
if std::mem::discriminant(&source.scope)
|
||||
!= std::mem::discriminant(&target.scope)
|
||||
{
|
||||
return Err("CREDENTIAL_SCOPE_DENIED".into());
|
||||
}
|
||||
let source_key = source.key()?;
|
||||
let target_key = target.key()?;
|
||||
if let Some(value) = session.read(&source_key)? {
|
||||
if let Some(existing) = session.read(&target_key)? {
|
||||
if existing.as_slice() != value.as_slice() {
|
||||
return Err("MIGRATION_CONFLICT".into());
|
||||
}
|
||||
}
|
||||
moves.push((source_key, target_key, value));
|
||||
}
|
||||
}
|
||||
// Reject cycles/overlapping source+destination rather than deleting
|
||||
// a newly written value midway through a multi-ID migration.
|
||||
if moves.iter().any(|(old, new, _)| {
|
||||
old != new && moves.iter().any(|(source, _, _)| source == new)
|
||||
}) {
|
||||
return Err("MIGRATION_CONFLICT".into());
|
||||
}
|
||||
for (old, new, value) in moves {
|
||||
session.write(new.clone(), &value)?;
|
||||
if old != new {
|
||||
session
|
||||
.store()?
|
||||
.delete(&old)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
session.persist(&self.path)?;
|
||||
Ok(serde_json::json!(removed))
|
||||
})();
|
||||
if result.is_err() {
|
||||
self.lock();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let id = CredentialId::legacy(params["id"].as_str().ok_or("HOST_REQUEST_INVALID")?);
|
||||
match method {
|
||||
"credentials.resolve" => self
|
||||
.resolve(&id.scope, &id)?
|
||||
.map(|v| {
|
||||
String::from_utf8(v.to_vec())
|
||||
.map(serde_json::Value::String)
|
||||
.map_err(|_| "CREDENTIAL_ENCODING_INVALID".into())
|
||||
})
|
||||
.unwrap_or(Ok(serde_json::Value::Null)),
|
||||
"credentials.has" => Ok(serde_json::json!(self.resolve(&id.scope, &id)?.is_some())),
|
||||
"credentials.put" => {
|
||||
let value = params["secret"].as_str().ok_or("HOST_REQUEST_INVALID")?;
|
||||
self.put(&id, Zeroizing::new(value.as_bytes().to_vec()))?;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
"credentials.delete" => {
|
||||
let existed = self.resolve(&id.scope, &id)?.is_some();
|
||||
self.delete(&id)?;
|
||||
Ok(serde_json::json!(existed))
|
||||
}
|
||||
_ => Err("HOST_METHOD_DENIED".into()),
|
||||
}
|
||||
}
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
unlocked: None,
|
||||
}
|
||||
}
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.unlocked.is_none()
|
||||
}
|
||||
pub fn lock(&mut self) {
|
||||
self.unlocked.take();
|
||||
}
|
||||
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
||||
self.lock();
|
||||
let session = 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..])
|
||||
.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);
|
||||
Ok(())
|
||||
}
|
||||
pub fn list(&self) -> Result<Vec<CredentialId>> {
|
||||
self.unlocked
|
||||
.as_ref()
|
||||
.ok_or("CREDENTIALS_LOCKED")?
|
||||
.store()?
|
||||
.keys()
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
|
||||
.iter()
|
||||
.map(|key| serde_json::from_slice(key).map_err(|_| "CREDENTIAL_STORE_CORRUPT".into()))
|
||||
.collect()
|
||||
}
|
||||
pub fn put(&mut self, id: &CredentialId, value: Zeroizing<Vec<u8>>) -> Result<()> {
|
||||
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
|
||||
let result = session
|
||||
.write(id.key()?, &value)
|
||||
.and_then(|_| session.persist(&self.path));
|
||||
if result.is_err() {
|
||||
self.lock();
|
||||
} // Never serve uncommitted memory after disk failure.
|
||||
result
|
||||
}
|
||||
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
|
||||
let session = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
|
||||
session
|
||||
.store()?
|
||||
.delete(&id.key()?)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
|
||||
let result = session.persist(&self.path);
|
||||
if result.is_err() {
|
||||
self.lock();
|
||||
}
|
||||
result
|
||||
}
|
||||
/// Internal consumers must supply the scope established by the Host dispatcher.
|
||||
/// This method must never be registered as a Tauri command.
|
||||
pub fn resolve(&self, caller: &Scope, id: &CredentialId) -> Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
if caller != &id.scope {
|
||||
return Err("CREDENTIAL_SCOPE_DENIED".into());
|
||||
}
|
||||
self.unlocked
|
||||
.as_ref()
|
||||
.ok_or("CREDENTIALS_LOCKED")?
|
||||
.read(&id.key()?)
|
||||
}
|
||||
pub fn change_password(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
||||
let previous = self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED")?;
|
||||
let mut salt = [0u8; 32];
|
||||
rand::rngs::OsRng
|
||||
.try_fill_bytes(&mut salt)
|
||||
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
|
||||
let next = Unlocked::derive(&password, salt)?;
|
||||
next.stronghold
|
||||
.create_client(CLIENT)
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?;
|
||||
for key in previous
|
||||
.store()?
|
||||
.keys()
|
||||
.map_err(|_| "CREDENTIAL_STORE_FAILED")?
|
||||
{
|
||||
let value = previous.read(&key)?.ok_or("CREDENTIAL_STORE_CORRUPT")?;
|
||||
next.write(key, &value)?;
|
||||
}
|
||||
next.persist(&self.path)?;
|
||||
self.unlocked = Some(next);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn password() -> Zeroizing<Vec<u8>> {
|
||||
Zeroizing::new(b"test-only-password-123".to_vec())
|
||||
}
|
||||
#[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();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let old = temp.path().join("legacy");
|
||||
fs::create_dir(&old).unwrap();
|
||||
let source = serde_json::to_vec(&fixture["tokens"]).unwrap();
|
||||
fs::write(old.join("credentials.json"), &source).unwrap();
|
||||
fs::write(old.join("master.key"), fixture["key"].as_str().unwrap()).unwrap();
|
||||
let mut broker = CredentialBroker::new(temp.path().join("new/stronghold.v1"));
|
||||
broker.unlock(password()).unwrap();
|
||||
assert_eq!(broker.import_fernet(&old, None).unwrap(), 100);
|
||||
assert_eq!(broker.import_fernet(&old, None).unwrap(), 100);
|
||||
assert_eq!(broker.list().unwrap().len(), 100);
|
||||
assert_eq!(fs::read(old.join("credentials.json")).unwrap(), source);
|
||||
assert!(old.join("master.key").is_file());
|
||||
broker.lock();
|
||||
broker.unlock(password()).unwrap();
|
||||
for (id, value) in fixture["values"].as_object().unwrap() {
|
||||
assert_eq!(
|
||||
broker
|
||||
.resolve(&Scope::Provider, &CredentialId::legacy(id))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
value.as_str().unwrap().as_bytes()
|
||||
);
|
||||
}
|
||||
broker
|
||||
.put(
|
||||
&CredentialId::legacy("provider-000"),
|
||||
Zeroizing::new(b"changed-new-value".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
broker.import_fernet(&old, None).unwrap_err(),
|
||||
"MIGRATION_CONFLICT"
|
||||
);
|
||||
assert_eq!(
|
||||
broker
|
||||
.resolve(&Scope::Provider, &CredentialId::legacy("provider-000"))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
b"changed-new-value"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn stronghold_roundtrip_scope_lock_and_password_rotation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("credentials.v1");
|
||||
let mut broker = CredentialBroker::new(file.clone());
|
||||
broker.unlock(password()).unwrap();
|
||||
let id = CredentialId {
|
||||
scope: Scope::Provider,
|
||||
id: "provider-one".into(),
|
||||
};
|
||||
broker
|
||||
.put(&id, Zeroizing::new(b"fixture-secret-do-not-log".to_vec()))
|
||||
.unwrap();
|
||||
assert!(broker.resolve(&Scope::Mcp("x".into()), &id).is_err());
|
||||
assert!(!fs::read(&file)
|
||||
.unwrap()
|
||||
.windows(b"fixture-secret-do-not-log".len())
|
||||
.any(|w| w == b"fixture-secret-do-not-log"));
|
||||
broker.lock();
|
||||
assert_eq!(
|
||||
broker.resolve(&Scope::Provider, &id).unwrap_err(),
|
||||
"CREDENTIALS_LOCKED"
|
||||
);
|
||||
broker.unlock(password()).unwrap();
|
||||
assert_eq!(
|
||||
broker
|
||||
.resolve(&Scope::Provider, &id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
b"fixture-secret-do-not-log"
|
||||
);
|
||||
broker
|
||||
.change_password(Zeroizing::new(b"second-test-password".to_vec()))
|
||||
.unwrap();
|
||||
broker.lock();
|
||||
assert!(broker.unlock(password()).is_err());
|
||||
broker
|
||||
.unlock(Zeroizing::new(b"second-test-password".to_vec()))
|
||||
.unwrap();
|
||||
assert_eq!(broker.list().unwrap().len(), 1);
|
||||
broker.delete(&id).unwrap();
|
||||
assert!(broker.resolve(&Scope::Provider, &id).unwrap().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_store_is_not_recreated() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("credentials.v1");
|
||||
fs::write(&path, b"corrupt").unwrap();
|
||||
let mut broker = CredentialBroker::new(path.clone());
|
||||
assert!(broker.unlock(password()).is_err());
|
||||
assert_eq!(fs::read(path).unwrap(), b"corrupt");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
|
||||
|
||||
pub mod core;
|
||||
pub mod credentials;
|
||||
pub mod recent;
|
||||
mod runtime_compat;
|
||||
pub mod workspace;
|
||||
|
||||
+329
-21
@@ -3,17 +3,24 @@
|
||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use notesagent_host::core::CoreSupervisor;
|
||||
use notesagent_host::credentials::CredentialBroker;
|
||||
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
||||
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Host {
|
||||
workspace: Mutex<Option<Workspace>>,
|
||||
recent: Mutex<Option<RecentVaultStore>>,
|
||||
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
||||
credentials: Arc<Mutex<Option<CredentialBroker>>>,
|
||||
streams: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
fn info(ws: &Workspace) -> RecentVault {
|
||||
@@ -39,8 +46,14 @@ fn with_workspace<T>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn host_capabilities() -> serde_json::Value {
|
||||
serde_json::json!({"protocol":1,"workspace":true,"core":true,"sync":false,"credentials":false,"extensions":false,"release":"preview"})
|
||||
fn host_capabilities(host: State<'_, Host>) -> serde_json::Value {
|
||||
let ready = host
|
||||
.core
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|mut core| core.as_mut().map(|c| c.available()))
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":false,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"})
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -65,14 +78,9 @@ fn is_json_content_type(content_type: &str) -> bool {
|
||||
media_type == "application/json" || media_type.ends_with("+json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn core_url(path: &str) -> Result<String, String> {
|
||||
if (!path.starts_with("/api/") && path != "/api" && path != "/health")
|
||||
|| path.contains("..")
|
||||
|| path.contains(['\r', '\n'])
|
||||
{
|
||||
return Err("CORE_PATH_DENIED".into());
|
||||
}
|
||||
Ok(format!("http://127.0.0.1:8000{path}"))
|
||||
notesagent_host::core::checked_url(8000, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -108,14 +116,28 @@ mod core_proxy_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。
|
||||
/// Authenticated process-local transport; session headers are owned by Rust.
|
||||
#[tauri::command]
|
||||
async fn core_request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: Option<serde_json::Value>,
|
||||
authorization: Option<String>,
|
||||
body_base64: Option<String>,
|
||||
content_type: Option<String>,
|
||||
idempotency_key: Option<String>,
|
||||
host: State<'_, Host>,
|
||||
) -> Result<CoreResponse, String> {
|
||||
let core = host.core.clone();
|
||||
let core_path = path.clone();
|
||||
let session = tauri::async_runtime::spawn_blocking(move || {
|
||||
core.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("CORE_UNAVAILABLE")?
|
||||
.request_session(&core_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "CORE_UNAVAILABLE")??;
|
||||
let method =
|
||||
reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?;
|
||||
if !matches!(
|
||||
@@ -130,16 +152,47 @@ async fn core_request(
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|_| "CORE_CLIENT_ERROR")?;
|
||||
let mut request = client.request(method, core_url(&path)?);
|
||||
let mut request = client
|
||||
.request(method, &session.url)
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
session.authorization.as_str(),
|
||||
)
|
||||
.header("X-Core-Generation", &session.generation);
|
||||
if let Some(value) = body {
|
||||
request = request.json(&value);
|
||||
}
|
||||
if let Some(value) = authorization {
|
||||
request = request.header(reqwest::header::AUTHORIZATION, value);
|
||||
if let Some(encoded) = body_base64 {
|
||||
if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 {
|
||||
return Err("CORE_REQUEST_TOO_LARGE".into());
|
||||
}
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| "CORE_BODY_INVALID")?;
|
||||
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
|
||||
return Err("CORE_REQUEST_TOO_LARGE".into());
|
||||
}
|
||||
let content_type = content_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
if !matches!(content_type, "application/octet-stream" | "application/zip") {
|
||||
return Err("CORE_CONTENT_TYPE_DENIED".into());
|
||||
}
|
||||
request = request
|
||||
.header(reqwest::header::CONTENT_TYPE, content_type)
|
||||
.body(bytes);
|
||||
}
|
||||
let response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||
if let Some(key) = idempotency_key {
|
||||
if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Err("CORE_HEADER_INVALID".into());
|
||||
}
|
||||
request = request.header("Idempotency-Key", key);
|
||||
}
|
||||
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||
let status = response.status().as_u16();
|
||||
let content_type = response
|
||||
.headers()
|
||||
@@ -153,9 +206,12 @@ async fn core_request(
|
||||
{
|
||||
return Err("CORE_RESPONSE_TOO_LARGE".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|_| "CORE_RESPONSE_ERROR")?;
|
||||
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
|
||||
return Err("CORE_RESPONSE_TOO_LARGE".into());
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES {
|
||||
return Err("CORE_RESPONSE_TOO_LARGE".into());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let (body, body_base64) = if is_json_content_type(&content_type) {
|
||||
(
|
||||
@@ -173,6 +229,182 @@ async fn core_request(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn core_stream_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> {
|
||||
if let Some(task) = host
|
||||
.streams
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.remove(&request_id)
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn core_stream(
|
||||
host: State<'_, Host>,
|
||||
request_id: String,
|
||||
path: String,
|
||||
method: String,
|
||||
body: Option<serde_json::Value>,
|
||||
last_event_id: Option<String>,
|
||||
channel: tauri::ipc::Channel<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
uuid::Uuid::parse_str(&request_id).map_err(|_| "CORE_REQUEST_ID_INVALID")?;
|
||||
if !matches!(method.as_str(), "GET" | "POST") {
|
||||
return Err("CORE_METHOD_DENIED".into());
|
||||
}
|
||||
if body
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.to_string().len() > 1024 * 1024)
|
||||
{
|
||||
return Err("CORE_REQUEST_TOO_LARGE".into());
|
||||
}
|
||||
if last_event_id
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.len() > 128 || s.contains(['\r', '\n']))
|
||||
{
|
||||
return Err("CORE_HEADER_INVALID".into());
|
||||
}
|
||||
let core = host.core.clone();
|
||||
let streams = host.streams.clone();
|
||||
let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?;
|
||||
if running.len() >= 16 || running.contains_key(&request_id) {
|
||||
return Err("CORE_STREAM_LIMIT".into());
|
||||
}
|
||||
let id = request_id.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let result: Result<(), String> = async {
|
||||
let session = tauri::async_runtime::spawn_blocking(move || {
|
||||
core.lock().map_err(|_| "HOST_BUSY")?.as_mut().ok_or("CORE_UNAVAILABLE")?.request_session(&path)
|
||||
}).await.map_err(|_| "CORE_UNAVAILABLE")??;
|
||||
let client = reqwest::Client::builder().no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none()).timeout(Duration::from_secs(600))
|
||||
.build().map_err(|_| "CORE_CLIENT_ERROR")?;
|
||||
let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?, session.url)
|
||||
.header("Authorization", session.authorization.as_str())
|
||||
.header("X-Core-Generation", session.generation).header("Accept", "text/event-stream");
|
||||
if let Some(body) = body { request = request.json(&body); }
|
||||
if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); }
|
||||
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||
channel.send(serde_json::json!({"kind":"headers","status":response.status().as_u16()})).map_err(|_| "CORE_STREAM_CLOSED")?;
|
||||
let mut size = 0usize;
|
||||
while let Some(bytes) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
|
||||
size = size.saturating_add(bytes.len());
|
||||
if size > MAX_CORE_RESPONSE_BYTES { return Err("CORE_RESPONSE_TOO_LARGE".into()); }
|
||||
for chunk in bytes.chunks(16384) {
|
||||
channel.send(serde_json::json!({"kind":"chunk","data":BASE64_STANDARD.encode(chunk)})).map_err(|_| "CORE_STREAM_CLOSED")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = channel.send(serde_json::json!({"kind":"done"}));
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = channel.send(serde_json::json!({"kind":"error","code":code}));
|
||||
}
|
||||
}
|
||||
if let Ok(mut running) = streams.lock() {
|
||||
running.remove(&id);
|
||||
}
|
||||
});
|
||||
running.insert(request_id, task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String> {
|
||||
let broker = host
|
||||
.credentials
|
||||
.try_lock()
|
||||
.map_err(|_| "CREDENTIALS_BUSY")?;
|
||||
let broker = broker.as_ref().ok_or("HOST_NOT_READY")?;
|
||||
Ok(serde_json::json!({"locked":broker.is_locked()}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(), String> {
|
||||
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)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn credentials_lock(host: State<'_, Host>) -> Result<(), String> {
|
||||
host.credentials
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.lock();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn credentials_import(host: State<'_, Host>) -> Result<Option<usize>, String> {
|
||||
let Some(path) = rfd::FileDialog::new()
|
||||
.set_title("选择旧版本的 credentials.json(不会删除原文件)")
|
||||
.add_filter("Fernet credentials", &["json"])
|
||||
.pick_file()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path.file_name().and_then(|n| n.to_str()) != Some("credentials.json") {
|
||||
return Err("MIGRATION_SOURCE_INVALID".into());
|
||||
}
|
||||
let directory = path
|
||||
.parent()
|
||||
.ok_or("MIGRATION_SOURCE_INVALID")?
|
||||
.to_path_buf();
|
||||
let broker = host.credentials.clone();
|
||||
let environment_key = std::env::var("APP_CREDENTIAL_MASTER_KEY")
|
||||
.ok()
|
||||
.map(Zeroizing::new);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
broker
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.import_fernet(&directory, environment_key)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn credentials_change_password(
|
||||
host: State<'_, Host>,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
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)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn editor_capabilities(app: tauri::AppHandle, metadata_enabled: bool) -> Result<(), String> {
|
||||
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
|
||||
@@ -314,6 +546,65 @@ fn main() {
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
|
||||
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
|
||||
let credential_state = app.state::<Host>().credentials.clone();
|
||||
*credential_state
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new(
|
||||
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
|
||||
));
|
||||
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) {
|
||||
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../backend")
|
||||
.canonicalize()?;
|
||||
let python = backend.join(if cfg!(windows) {
|
||||
".venv/Scripts/python.exe"
|
||||
} else {
|
||||
".venv/bin/python"
|
||||
});
|
||||
CoreSupervisor::new(
|
||||
python,
|
||||
vec!["-m".into(), "app.sidecar".into()],
|
||||
backend,
|
||||
data_dir,
|
||||
)
|
||||
} else {
|
||||
let root = app.path().resource_dir()?.join("core");
|
||||
CoreSupervisor::new(
|
||||
root.join(if cfg!(windows) {
|
||||
"opennexus-core.exe"
|
||||
} else {
|
||||
"opennexus-core"
|
||||
}),
|
||||
vec![],
|
||||
root,
|
||||
data_dir,
|
||||
)
|
||||
.with_bundle_manifest(
|
||||
include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(),
|
||||
)
|
||||
};
|
||||
let core = core.with_broker(Arc::new(move |request| {
|
||||
credential_state
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.as_mut()
|
||||
.ok_or("HOST_NOT_READY")?
|
||||
.dispatch(request)
|
||||
}));
|
||||
*app.state::<Host>()
|
||||
.core
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(core);
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(mut core) = handle.state::<Host>().core.lock() {
|
||||
if let Some(core) = core.as_mut() {
|
||||
let _ = core.start();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_menu_event(|app, event| {
|
||||
@@ -330,7 +621,14 @@ fn main() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
host_capabilities,
|
||||
credentials_status,
|
||||
credentials_unlock,
|
||||
credentials_lock,
|
||||
credentials_change_password,
|
||||
credentials_import,
|
||||
core_request,
|
||||
core_stream,
|
||||
core_stream_cancel,
|
||||
editor_capabilities,
|
||||
workspace_choose,
|
||||
workspace_open,
|
||||
@@ -343,6 +641,16 @@ fn main() {
|
||||
workspace_delete,
|
||||
workspace_mkdir
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("桌面 Host 启动失败");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("桌面 Host 启动失败")
|
||||
.run(|app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Ok(mut broker) = app.state::<Host>().credentials.lock() {
|
||||
broker.take();
|
||||
}
|
||||
if let Ok(mut core) = app.state::<Host>().core.lock() {
|
||||
core.take();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! C23 compatibility for the MSVCRT-based Windows GNU development target.
|
||||
//! Recent libsodium archives reference memset_explicit, absent in MSVCRT.
|
||||
//! Volatile stores preserve its non-elidable wipe semantics; MSVC/UCRT release
|
||||
//! builds use their native runtime and do not compile this compatibility symbol.
|
||||
|
||||
#[cfg(all(windows, target_env = "gnu"))]
|
||||
#[no_mangle]
|
||||
unsafe extern "C" fn memset_explicit(
|
||||
destination: *mut std::ffi::c_void,
|
||||
value: std::ffi::c_int,
|
||||
count: usize,
|
||||
) -> *mut std::ffi::c_void {
|
||||
for offset in 0..count {
|
||||
// SAFETY: the C ABI caller must supply a writable region of count bytes,
|
||||
// exactly as for memset. Volatile stores cannot be removed as dead writes.
|
||||
unsafe {
|
||||
destination
|
||||
.cast::<u8>()
|
||||
.add(offset)
|
||||
.write_volatile(value as u8);
|
||||
}
|
||||
}
|
||||
std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
|
||||
destination
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows, target_env = "gnu"))]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn explicit_memset_preserves_surrounding_bytes_and_return_pointer() {
|
||||
let mut data = [0x55u8; 34];
|
||||
let pointer = data[1..33].as_mut_ptr().cast();
|
||||
// SAFETY: the subslice contains exactly 32 writable bytes.
|
||||
assert_eq!(unsafe { super::memset_explicit(pointer, 0, 32) }, pointer);
|
||||
assert_eq!(data[0], 0x55);
|
||||
assert_eq!(data[33], 0x55);
|
||||
assert!(data[1..33].iter().all(|b| *b == 0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user